"""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''] # defs: backdrop + holo p.append(f'' f'' f'' f'' f'' f'' f'' f'' f'') # frame p.append(f'') p.append(f'') if card.shiny: p.append(f'') # header: name + dex (holo marker folded into the dex line to avoid PWR collision) p.append(f'{_esc(card.name)}') holo = " · ★HOLO" if card.shiny else "" p.append(f'' f'No.{card.dex:03d} · {_esc(card.label)}{holo}') p.append(f'PWR {card.power}') # status pill on the rarity ribbon's left (done = green check, else amber) st_done = card.status in ("done", "complete", "completed") st_col, st_txt = (("#3fae5a", f"✓ {card.status}") if st_done else ("#d8a23a", f"● {card.status or 'unknown'}")) # type badges (top-right under PWR) bx = W - PAD - 6 for ty in reversed(card.types): tc = TYPE_COLOR.get(ty, "#a0a4ab") w = 16 + len(ty) * 8 p.append(f'') p.append(f'{_esc(ty)}') bx -= w + 6 # art window p.append(f'') if art_data_uri: p.append(f'') p.append(f'') else: p.append(_sigil_svg(card)) # rarity ribbon p.append(f'') p.append(f'' f'{card.rarity.upper()}') # status pill (top-left corner of the art window) sw = 22 + len(st_txt) * 7 p.append(f'') p.append(f'{_esc(st_txt)}') # stat bars y0 = ART_Y + ART_H + 26 bw = W - 2 * (PAD + 8) maxstat = 255 for i, (key, lbl) in enumerate(STAT_ORDER): y = y0 + i * 30 val = s[key] p.append(f'{lbl}') p.append(f'') fillw = max(6, (bw - 90) * val / maxstat) p.append(f'') p.append(f'{val}') # footer: BST + flavor fy = y0 + 6 * 30 + 6 p.append(f'BST {bst}') for j, ln in enumerate(_wrap(card.flavor, 58)): p.append(f'{_esc(ln)}') p.append("") 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())