"""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, grade_curve, _registry_path, _seed from . import chardex # ── 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") def _art_chardex(card: Card) -> bytes: """Use a harvested real character portrait matched to this VM (free, offline).""" lib = chardex.load_library() ch = chardex.match(card, lib) if ch is None: raise RuntimeError("empty/unmatched character library") path = ch.path(chardex.chardex_dir()) if not path.exists(): raise RuntimeError(f"portrait missing: {path}") return path.read_bytes() ART_BACKENDS = { "sigil": None, # handled inline (no fetch) "chardex": _art_chardex, "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, maxlines: int = 3) -> 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) if len(out) > maxlines: out = out[:maxlines] out[-1] = out[-1][:n - 1] + "…" return out # premium-card visual constants FOIL_RARITIES = {"Rare", "Epic", "Legendary"} RARITY_GLINT = { # lighter metallic highlight per rarity for the beveled border "Common": "#cfd4da", "Uncommon": "#c8f4d6", "Rare": "#cfe2ff", "Epic": "#ecc9ff", "Legendary": "#fff0b0", } def _card_defs(card: Card, border, glow, accent) -> str: """All gradients/filters/foil for one premium card (ids are card-local).""" tc = TYPE_COLOR.get(card.types[0], "#a0a4ab") glint = RARITY_GLINT.get(card.rarity, "#cfd4da") d = [''] # backdrop: deep vertical wash, type-tinted toward the foot d.append(f'' f'' f'' f'') # type spotlight behind the art d.append(f'' f'' f'' f'') # brushed-metal border: light glint → rarity color → shadow, for a bevel d.append(f'' f'' f'' f'' f'' f'') # title-banner sheen d.append(f'') # diagonal holographic foil (rainbow shimmer) d.append('' '' '' '' '' '' '' '') d.append(f'' f'' f'' f'') d.append(f'' f'') d.append('') return "".join(d) def _corner_gem(p: list, cx, cy, accent, glint): """A faceted diamond gem ornament at a frame corner.""" p.append(f'' f'' f'') def _frame_and_header(p: list, card: Card, border, glow, accent) -> tuple: """Shared premium frame + title banner + type gem + header. Returns status pill.""" tc = TYPE_COLOR.get(card.types[0], "#a0a4ab") glint = RARITY_GLINT.get(card.rarity, "#cfd4da") p.append(_card_defs(card, border, glow, accent)) # backdrop + spotlight + vignette p.append(f'') p.append(f'') # holo foil sheen for Rare+ / shiny if card.shiny or card.rarity in FOIL_RARITIES: p.append(f'') if card.shiny or card.rarity in ("Epic", "Legendary"): h = _seed(card.label) for k in range(7): sx = 30 + (h >> (k * 3)) % (W - 60) sy = 90 + (h >> (k * 3 + 1)) % (H - 180) r = 1.5 + (h >> (k * 2)) % 3 p.append(f'') # multi-layer beveled border if card.rarity == "Legendary": # outer glow ring for legendaries p.append(f'') p.append(f'') p.append(f'') p.append(f'') for (gx, gy) in ((22, 22), (W - 22, 22), (22, H - 22), (W - 22, H - 22)): _corner_gem(p, gx, gy, accent, glint) # title banner + type energy gem + name p.append(f'') p.append(f'') p.append(f'') 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}') bx = W - PAD - 8 for ty in reversed(card.types): tcc = TYPE_COLOR.get(ty, "#a0a4ab") w = 16 + len(ty) * 8 p.append(f'') p.append(f'{_esc(ty)}') bx -= w + 6 st_done = card.status in ("done", "complete", "completed") return (("#3fae5a", f"✓ {card.status}") if st_done else ("#d8a23a", f"● {card.status or 'unknown'}")) def _art_window(p: list, card: Card, art_data_uri, border, st_col, st_txt): p.append(f'') if art_data_uri: p.append(f'') p.append(f'') else: p.append(_sigil_svg(card)) p.append(f'') p.append(f'' f'{card.rarity.upper()}') sw = 22 + len(st_txt) * 7 p.append(f'') p.append(f'{_esc(st_txt)}') def _stat_bars(p: list, card: Card, y0: int, border, accent): s = card.stats bw = W - 2 * (PAD + 8) 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 / 255) p.append(f'') p.append(f'{val}') def render_card_svg(card: Card, art_data_uri: str | None = None, face: str = "full") -> str: """Render a card SVG. face: 'full' (art+stats), 'front' (art+description), 'back' (stats+subscores) — front/back drive the flippable hub cards.""" border, glow, accent = RARITY_THEME.get(card.rarity, RARITY_THEME["Common"]) bst = sum(card.stats.values()) p = [f''] st_col, st_txt = _frame_and_header(p, card, border, glow, accent) if face in ("full", "front"): _art_window(p, card, art_data_uri, border, st_col, st_txt) if face == "front": # DOSSIER panel where stats live on the back — the VM's description. y0 = ART_Y + ART_H + 24 p.append(f'VM DOSSIER') p.append(f'') desc = card.purpose or card.flavor or "an unknown sandbox" for j, ln in enumerate(_wrap(desc, 46, maxlines=8)): p.append(f'{_esc(ln)}') p.append(f'PWR {card.power} · BST {bst} · ' f'{card.rarity}') p.append(f'' f'tap to flip → stats') elif face == "back": _stat_bars(p, card, ART_Y + 6, border, accent) # subscore breakdown + flavor under the bars fy = ART_Y + 6 + 6 * 30 + 14 p.append(f'VALUE BREAKDOWN') order = ["completeness", "reusability", "richness", "pedigree", "heft"] for k, sk in enumerate(order): v = card.subscores.get(sk, 0) p.append(f'{sk.capitalize():<14}') p.append(f'{v}') gy = fy + 24 + 5 * 20 + 18 p.append(f'BST {bst}') for j, ln in enumerate(_wrap(card.flavor, 52, maxlines=3)): p.append(f'{_esc(ln)}') p.append(f'← tap to flip') else: # full _stat_bars(p, card, ART_Y + ART_H + 26, border, accent) fy = ART_Y + ART_H + 26 + 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, flip: bool = False) -> Path: """Render every card as an inline SVG and lay them out in one index.html. flip=False → a static grid of 'full' cards (art + stats), good for PNG export. flip=True → an interactive hub: each card shows its VM dossier on the front and flips on hover/click to reveal the stat block on the back.""" 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 if flip: front = render_card_svg(c, art_uri, face="front") back = render_card_svg(c, art_uri, face="back") cells.append( f'
' f'
{front}
' f'
{back}
' f'
') else: 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))) flip_css = ( '.grid{display:grid;grid-template-columns:repeat(4,500px);gap:30px;' 'padding:24px 36px 60px;justify-content:center}' '.flip-card{width:500px;height:700px;perspective:1600px;cursor:pointer;' 'outline:none}' '.flip-inner{position:relative;width:100%;height:100%;' 'transition:transform .7s cubic-bezier(.2,.7,.2,1);transform-style:preserve-3d}' '.flip-card:hover .flip-inner,.flip-card:focus .flip-inner,' '.flip-card.flipped .flip-inner{transform:rotateY(180deg)}' '.flip-face{position:absolute;inset:0;backface-visibility:hidden;' 'border-radius:22px;box-shadow:0 10px 36px rgba(0,0,0,.65);overflow:hidden}' '.flip-face svg{width:500px;height:700px;display:block}' '.flip-back{transform:rotateY(180deg)}' '@media(max-width:2100px){.grid{grid-template-columns:repeat(3,500px)}}') static_css = ( '.grid{display:grid;grid-template-columns:repeat(4,500px);gap:26px;' 'padding:24px 36px 40px;justify-content:center}' '.card svg{width:500px;height:700px;display:block;' 'border-radius:22px;box-shadow:0 8px 30px rgba(0,0,0,.6)}' '@media(max-width:2100px){.grid{grid-template-columns:repeat(3,500px)}}') sub = ('hover or tap a card to flip → stats' if flip else f'{len(cards)} saved VMs · {spread}') script = ('') if flip else '' html = ( '' '' f'

hack-house VM Cardex

' f'

{len(cards)} saved VMs · {spread}{" — " + sub if flip else ""}

' f'
{"".join(cells)}
' f'{script}') 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") p.add_argument("--flip", action="store_true", help="with --gallery: interactive flip cards (front=dossier, back=stats)") 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] # rarity is a population quota — grade the full library on the curve so the # gallery matches `hh-cardex`. A lone --label card keeps its absolute rarity. if not args.label: grade_curve(cards) 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, flip=args.flip) 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())