"""hh-cardimg — render a cardex Card as a collectible trading-card image.
Free + local-first by design, with paid art backends wired in as opt-in options:
art backend cost needs what it draws in the art window
─────────── ────── ─────────────── ───────────────────────────────
sigil free nothing a deterministic procedural creature
(offline) glyph rendered in pure SVG (default)
pollinations free network Pollinations flux text-to-image (no key)
stability paid STABILITY_API_KEY Stability "stable-image core"
openai paid OPENAI_API_KEY OpenAI gpt-image-1
runway paid RUNWAYML_API_SECRET RunwayML gen4_image (async task)
The card *frame* (border, name, type badges, 6 stat bars, dex#, rarity holo,
flavor) is always hand-built SVG — no backend touches it, so a card always
renders fully offline; the chosen backend only fills the portrait window. The
SVG is self-contained (any fetched art is inlined as a base64 data URI) and is
rasterized to PNG locally via `cairosvg` or ImageMagick `convert` when present.
Everything is deterministic and seeded by the VM label: the same VM mints the
same card art (same Pollinations/paid seed, same sigil geometry).
python -m cmd_chat.cardimg --label net-mapper-kit --art sigil --format png
python -m cmd_chat.cardimg --all --art pollinations --out cards/
"""
from __future__ import annotations
import argparse
import base64
import json
import math
import os
import shutil
import subprocess
import sys
import urllib.parse
import urllib.request
from pathlib import Path
from .cardex import Card, card_from_entry, load_entries, _registry_path, _seed
# ── card geometry ─────────────────────────────────────────────────────────────
W, H = 500, 700
PAD = 18
ART_X, ART_Y, ART_W, ART_H = PAD + 8, 96, W - 2 * (PAD + 8), 300
# rarity → frame palette (border, inner glow, accent text)
RARITY_THEME = {
"Common": ("#8a8f98", "#3a3f47", "#c8ccd2"),
"Uncommon": ("#3fae5a", "#13351f", "#9be8ae"),
"Rare": ("#3f7fd6", "#102742", "#9cc3f4"),
"Epic": ("#9a4fd0", "#2a1140", "#d6a8f4"),
"Legendary": ("#e0a020", "#3a2705", "#ffd86b"),
}
# elemental type → badge color
TYPE_COLOR = {
"Psychic": "#d44d8a", "Dark": "#5a5366", "Fire": "#e0622a", "Electric": "#e0b62a",
"Ghost": "#7766b8", "Steel": "#8a96a6", "Flying": "#6fa8dc", "Dragon": "#5a4fd0",
"Normal": "#a0a4ab", "Ground": "#c8a05a", "Water": "#3f8fd6",
}
# type → a couple of visual descriptors for the AI-art prompt
TYPE_VISUAL = {
"Psychic": "luminous psionic aura, third eye, violet energy",
"Dark": "shadowy, obsidian carapace, smoke wreathed",
"Fire": "molten cracks, ember plumes, scorched scales",
"Electric": "crackling arcs, neon circuitry, charged spines",
"Ghost": "spectral, translucent, drifting wisps",
"Steel": "armored plating, riveted chrome, bladed edges",
"Flying": "feathered wings, wind currents, aerodynamic",
"Dragon": "ancient draconic, horned, scaled wings",
"Normal": "earthen, sturdy, understated",
"Ground": "rocky hide, sediment, tectonic",
"Water": "fluid, iridescent fins, deep currents",
}
STAT_ORDER = [("HP", "HP"), ("Attack", "ATK"), ("Defense", "DEF"),
("SpAtk", "SpA"), ("Speed", "SPE"), ("SpDef", "SpD")]
# ── procedural creature sigil (free, offline, deterministic) ──────────────────
def _sigil_svg(card: Card) -> str:
"""A deterministic radial creature glyph drawn from the label seed.
No model, no network: a symmetric burst of limbs + an eye, colored by type.
Distinct per VM (geometry, limb count, eye) yet stable across runs."""
h = _seed(card.label)
cx, cy = ART_X + ART_W / 2, ART_Y + ART_H / 2
R = min(ART_W, ART_H) * 0.34
col = TYPE_COLOR.get(card.types[0], "#a0a4ab")
col2 = TYPE_COLOR.get(card.types[-1], col)
limbs = 5 + (h % 6) # 5..10 radial limbs
twist = (h >> 4) % 360
parts = [f''
f''
f''
f'']
# radial limbs
for i in range(limbs):
ang = math.radians(twist + i * 360.0 / limbs)
seg = 3 + ((h >> (i + 2)) % 3)
pts = []
for s in range(seg + 1):
rr = R * (0.35 + 0.75 * s / seg)
wob = math.radians(((h >> (i + s)) % 40) - 20)
x = cx + rr * math.cos(ang + wob)
y = cy + rr * math.sin(ang + wob)
pts.append(f"{x:.1f},{y:.1f}")
parts.append(f'')
# body
parts.append(f'')
# eye(s)
eyes = 1 + (h % 3)
for e in range(eyes):
ex = cx + (e - (eyes - 1) / 2) * R * 0.36
ey = cy - R * 0.06
parts.append(f'')
parts.append(f'')
return "".join(parts)
# ── AI-art prompt (deterministic text from the card) ──────────────────────────
def build_prompt(card: Card) -> str:
ty = card.types[0]
vis = ", ".join(TYPE_VISUAL.get(t, "") for t in card.types if TYPE_VISUAL.get(t))
grandeur = {
"Common": "humble small creature",
"Uncommon": "spirited creature",
"Rare": "powerful beast",
"Epic": "fearsome legendary-class monster",
"Legendary": "god-tier mythical titan, epic scale, awe-inspiring",
}[card.rarity]
return (f"a {grandeur} named {card.name}, {ty}-type mythical creature, {vis}, "
f"centered character portrait, fantasy trading-card creature art, "
f"dramatic rim lighting, clean dark background, highly detailed, "
f"digital painting, no text, no border")
# ── art backends: return raw image bytes (PNG/JPEG) or raise ──────────────────
def _http(req: urllib.request.Request, timeout=120) -> bytes:
with urllib.request.urlopen(req, timeout=timeout) as r:
return r.read()
def _art_pollinations(card: Card) -> bytes:
"""Free hosted flux text-to-image — no API key."""
prompt = urllib.parse.quote(build_prompt(card))
seed = _seed(card.label) % 1_000_000
url = (f"https://image.pollinations.ai/prompt/{prompt}"
f"?width={ART_W*2}&height={ART_H*2}&seed={seed}&nologo=true&model=flux")
return _http(urllib.request.Request(url, headers={"User-Agent": "hh-cardimg"}))
def _art_stability(card: Card) -> bytes:
key = os.environ.get("STABILITY_API_KEY")
if not key:
raise RuntimeError("STABILITY_API_KEY not set")
boundary = "----hhcardimg"
parts, body = [], b""
fields = {"prompt": build_prompt(card), "output_format": "png",
"aspect_ratio": "1:1", "seed": str(_seed(card.label) % 4_000_000_000)}
for k, v in fields.items():
parts.append(f'--{boundary}\r\nContent-Disposition: form-data; name="{k}"\r\n\r\n{v}\r\n')
body = ("".join(parts) + f"--{boundary}--\r\n").encode()
req = urllib.request.Request(
"https://api.stability.ai/v2beta/stable-image/generate/core", data=body,
headers={"Authorization": f"Bearer {key}", "Accept": "image/*",
"Content-Type": f"multipart/form-data; boundary={boundary}"})
return _http(req)
def _art_openai(card: Card) -> bytes:
key = os.environ.get("OPENAI_API_KEY")
if not key:
raise RuntimeError("OPENAI_API_KEY not set")
payload = json.dumps({"model": "gpt-image-1", "prompt": build_prompt(card),
"size": "1024x1024", "n": 1}).encode()
req = urllib.request.Request(
"https://api.openai.com/v1/images/generations", data=payload,
headers={"Authorization": f"Bearer {key}", "Content-Type": "application/json"})
out = json.loads(_http(req))
return base64.b64decode(out["data"][0]["b64_json"])
def _art_runway(card: Card) -> bytes:
"""RunwayML gen4_image — async task: create, poll, download."""
key = os.environ.get("RUNWAYML_API_SECRET")
if not key:
raise RuntimeError("RUNWAYML_API_SECRET not set")
hdr = {"Authorization": f"Bearer {key}", "Content-Type": "application/json",
"X-Runway-Version": "2024-11-06"}
payload = json.dumps({"model": "gen4_image", "ratio": "1024:1024",
"promptText": build_prompt(card)}).encode()
task = json.loads(_http(urllib.request.Request(
"https://api.dev.runwayml.com/v1/text_to_image", data=payload, headers=hdr)))
tid = task["id"]
import time
for _ in range(60):
time.sleep(3)
st = json.loads(_http(urllib.request.Request(
f"https://api.dev.runwayml.com/v1/tasks/{tid}", headers=hdr)))
if st.get("status") == "SUCCEEDED":
return _http(urllib.request.Request(st["output"][0]))
if st.get("status") in ("FAILED", "CANCELLED"):
raise RuntimeError(f"runway task {st.get('status')}: {st.get('failure','')}")
raise RuntimeError("runway task timed out")
ART_BACKENDS = {
"sigil": None, # handled inline (no fetch)
"pollinations": _art_pollinations,
"stability": _art_stability,
"openai": _art_openai,
"runway": _art_runway,
}
def fetch_art_data_uri(card: Card, backend: str) -> str | None:
"""Return a base64 data-URI for the portrait, or None to use the sigil."""
fn = ART_BACKENDS.get(backend)
if fn is None:
return None
raw = fn(card)
mime = "image/png"
if raw[:3] == b"\xff\xd8\xff":
mime = "image/jpeg"
return f"data:{mime};base64," + base64.b64encode(raw).decode()
# ── SVG card frame ────────────────────────────────────────────────────────────
def _esc(s: str) -> str:
return (s.replace("&", "&").replace("<", "<").replace(">", ">"))
def _wrap(s: str, n: int) -> list[str]:
out, line = [], ""
for w in s.split():
if len(line) + len(w) + 1 > n:
out.append(line)
line = w
else:
line = (line + " " + w).strip()
if line:
out.append(line)
return out[:3]
def render_card_svg(card: Card, art_data_uri: str | None = None) -> str:
border, glow, accent = RARITY_THEME.get(card.rarity, RARITY_THEME["Common"])
s = card.stats
bst = sum(s.values())
p = [f'")
return "".join(p)
# ── rasterize SVG → PNG locally ───────────────────────────────────────────────
def svg_to_png(svg_path: Path, png_path: Path) -> bool:
if shutil.which("cairosvg"):
subprocess.run(["cairosvg", str(svg_path), "-o", str(png_path)], check=True)
return True
conv = shutil.which("convert") or shutil.which("magick")
if conv:
subprocess.run([conv, "-density", "192", "-background", "none",
str(svg_path), str(png_path)], check=True)
return True
return False
def render_card(card: Card, out_dir: Path, backend: str, fmt: str) -> Path:
out_dir.mkdir(parents=True, exist_ok=True)
art_uri = None
if backend != "sigil":
try:
art_uri = fetch_art_data_uri(card, backend)
except Exception as e: # graceful: fall back to sigil
print(f" ! {card.label}: {backend} art failed ({e}); using sigil",
file=sys.stderr)
svg = render_card_svg(card, art_uri)
svg_path = out_dir / f"{card.label}.svg"
svg_path.write_text(svg)
if fmt == "svg":
return svg_path
png_path = out_dir / f"{card.label}.png"
if svg_to_png(svg_path, png_path):
return png_path
print(" ! no local SVG rasterizer (cairosvg/convert); kept SVG", file=sys.stderr)
return svg_path
# ── browser gallery ───────────────────────────────────────────────────────────
RARITY_ORDER = {"Legendary": 0, "Epic": 1, "Rare": 2, "Uncommon": 3, "Common": 4}
def build_gallery(cards: list[Card], out_dir: Path, backend: str) -> Path:
"""Render every card as an inline SVG and lay them out in one index.html."""
out_dir.mkdir(parents=True, exist_ok=True)
cards = sorted(cards, key=lambda c: (RARITY_ORDER.get(c.rarity, 9), -c.power))
tally: dict[str, int] = {}
cells = []
for c in cards:
art_uri = None
if backend != "sigil":
try:
art_uri = fetch_art_data_uri(c, backend)
except Exception as e:
print(f" ! {c.label}: {backend} art failed ({e}); sigil", file=sys.stderr)
tally[c.rarity] = tally.get(c.rarity, 0) + 1
cells.append(f'
{render_card_svg(c, art_uri)}
')
print(f" rendered {c.rarity:<10} {c.label}")
spread = " · ".join(f"{n}× {r}" for r, n in
sorted(tally.items(), key=lambda kv: RARITY_ORDER.get(kv[0], 9)))
html = (
''
''
f'
hack-house VM Cardex
'
f'
{len(cards)} saved VMs · {spread}
'
f'
{"".join(cells)}
'
'')
idx = out_dir / "index.html"
idx.write_text(html)
return idx
# ── CLI ───────────────────────────────────────────────────────────────────────
def main(argv=None) -> int:
p = argparse.ArgumentParser(prog="hh-cardimg",
description="Render cardex VM cards as images.")
p.add_argument("--registry", default=str(_registry_path()))
p.add_argument("--label", help="one VM (default: all)")
p.add_argument("--all", action="store_true", help="render every VM in the registry")
p.add_argument("--art", default="sigil", choices=list(ART_BACKENDS),
help="portrait backend (default: sigil = free + offline)")
p.add_argument("--format", default="png", choices=["png", "svg"])
p.add_argument("--out", default="cards", help="output directory")
p.add_argument("--prompt", action="store_true",
help="just print the AI-art prompt(s) and exit")
p.add_argument("--gallery", action="store_true",
help="emit a single browser index.html grid of all cards")
args = p.parse_args(argv)
entries = load_entries(Path(args.registry))
if args.label:
entries = [e for e in entries if e.get("label") == args.label]
if not entries:
print(f"no VM labelled '{args.label}'")
return 1
elif not args.all:
print("specify --label or --all")
return 2
cards = [card_from_entry(e) for e in entries]
if args.prompt:
for c in cards:
print(f"# {c.label} [{c.rarity} {('/'.join(c.types))}]\n{build_prompt(c)}\n")
return 0
out = Path(args.out)
if args.gallery:
idx = build_gallery(cards, out, args.art)
print(f"gallery → {idx}")
return 0
for c in cards:
path = render_card(c, out, args.art, args.format)
print(f"{c.rarity:<10} {c.label:<22} → {path}")
return 0
if __name__ == "__main__":
raise SystemExit(main())