7afc38232b
Rarity was inflating to 65% Legendary: the loop mass-produces ~70 near-clone VMs that look near-identical on every axis, so no absolute PowerScore threshold can create scarcity within that cluster. Fix (option C, hybrid): - grade_curve() is now the primary rarity mechanism — a population quota (Leg 8 / Epic 15 / Rare 25 / Uncommon 27 / Common 25%) ranked by PowerScore with a deterministic label-seed tiebreak. Yields a clean 8/15/26/27/24% pyramid. Rebuilds each card's rarity-dependent name + flavor via _apply_rarity. - Absolute RARITY_TIERS kept as a single-card (--label) fallback, recalibrated. - Dead axes repaired: _pedigree now derives from author lineage (human/hh-loop/ pulled/smoke) instead of a flat 2.0; _heft rescaled to a 512MB log reference so it spreads instead of pinning mid-size VMs at the cap. cardimg: apply grade_curve to the gallery so rendered cards match hh-cardex, plus the interactive 3D flip-card gallery (--flip: front dossier / back stats). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
661 lines
32 KiB
Python
661 lines
32 KiB
Python
"""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'<defs><radialGradient id="sg" cx="50%" cy="42%" r="62%">'
|
||
f'<stop offset="0%" stop-color="{col2}" stop-opacity="0.95"/>'
|
||
f'<stop offset="100%" stop-color="{col}" stop-opacity="0.15"/>'
|
||
f'</radialGradient></defs>']
|
||
# 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'<polyline points="{" ".join(pts)}" fill="none" '
|
||
f'stroke="{col}" stroke-width="{6 - seg*0.6:.1f}" '
|
||
f'stroke-linecap="round" opacity="0.85"/>')
|
||
# body
|
||
parts.append(f'<circle cx="{cx:.1f}" cy="{cy:.1f}" r="{R*0.62:.1f}" fill="url(#sg)" '
|
||
f'stroke="{col2}" stroke-width="2"/>')
|
||
# 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'<circle cx="{ex:.1f}" cy="{ey:.1f}" r="{R*0.16:.1f}" '
|
||
f'fill="#0c0f14"/>')
|
||
parts.append(f'<circle cx="{ex:.1f}" cy="{ey-R*0.05:.1f}" r="{R*0.06:.1f}" '
|
||
f'fill="#ffffff" opacity="0.9"/>')
|
||
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 = ['<defs>']
|
||
# backdrop: deep vertical wash, type-tinted toward the foot
|
||
d.append(f'<linearGradient id="bg" x1="0" y1="0" x2="0.2" y2="1">'
|
||
f'<stop offset="0%" stop-color="#0c0e13"/>'
|
||
f'<stop offset="55%" stop-color="#13161d"/>'
|
||
f'<stop offset="100%" stop-color="{glow}"/></linearGradient>')
|
||
# type spotlight behind the art
|
||
d.append(f'<radialGradient id="spot" cx="50%" cy="34%" r="62%">'
|
||
f'<stop offset="0%" stop-color="{tc}" stop-opacity="0.32"/>'
|
||
f'<stop offset="100%" stop-color="{tc}" stop-opacity="0"/>'
|
||
f'</radialGradient>')
|
||
# brushed-metal border: light glint → rarity color → shadow, for a bevel
|
||
d.append(f'<linearGradient id="metal" x1="0" y1="0" x2="1" y2="1">'
|
||
f'<stop offset="0%" stop-color="{glint}"/>'
|
||
f'<stop offset="28%" stop-color="{border}"/>'
|
||
f'<stop offset="60%" stop-color="{border}"/>'
|
||
f'<stop offset="78%" stop-color="#0c0e13"/>'
|
||
f'<stop offset="100%" stop-color="{border}"/></linearGradient>')
|
||
# title-banner sheen
|
||
d.append(f'<linearGradient id="banner" x1="0" y1="0" x2="0" y2="1">'
|
||
f'<stop offset="0%" stop-color="{border}" stop-opacity="0.95"/>'
|
||
f'<stop offset="100%" stop-color="#0c0e13" stop-opacity="0.85"/>'
|
||
f'</linearGradient>')
|
||
# diagonal holographic foil (rainbow shimmer)
|
||
d.append('<linearGradient id="foil" x1="0" y1="0" x2="1" y2="1">'
|
||
'<stop offset="0%" stop-color="#ff5d8f" stop-opacity="0.0"/>'
|
||
'<stop offset="20%" stop-color="#ffd166" stop-opacity="0.16"/>'
|
||
'<stop offset="40%" stop-color="#06d6a0" stop-opacity="0.10"/>'
|
||
'<stop offset="60%" stop-color="#4cc9f0" stop-opacity="0.18"/>'
|
||
'<stop offset="80%" stop-color="#b8a6ff" stop-opacity="0.12"/>'
|
||
'<stop offset="100%" stop-color="#ff5d8f" stop-opacity="0.0"/>'
|
||
'</linearGradient>')
|
||
d.append(f'<radialGradient id="vig" cx="50%" cy="50%" r="75%">'
|
||
f'<stop offset="60%" stop-color="#000000" stop-opacity="0"/>'
|
||
f'<stop offset="100%" stop-color="#000000" stop-opacity="0.45"/>'
|
||
f'</radialGradient>')
|
||
d.append(f'<filter id="glow" x="-30%" y="-30%" width="160%" height="160%">'
|
||
f'<feGaussianBlur stdDeviation="6"/></filter>')
|
||
d.append('</defs>')
|
||
return "".join(d)
|
||
|
||
|
||
def _corner_gem(p: list, cx, cy, accent, glint):
|
||
"""A faceted diamond gem ornament at a frame corner."""
|
||
p.append(f'<g transform="translate({cx},{cy}) rotate(45)">'
|
||
f'<rect x="-9" y="-9" width="18" height="18" rx="3" fill="{accent}" '
|
||
f'stroke="{glint}" stroke-width="1.5"/>'
|
||
f'<rect x="-9" y="-9" width="18" height="9" rx="3" fill="{glint}" '
|
||
f'opacity="0.55"/></g>')
|
||
|
||
|
||
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'<rect x="0" y="0" width="{W}" height="{H}" rx="24" fill="url(#bg)"/>')
|
||
p.append(f'<rect x="14" y="80" width="{W-28}" height="330" rx="16" fill="url(#spot)"/>')
|
||
# holo foil sheen for Rare+ / shiny
|
||
if card.shiny or card.rarity in FOIL_RARITIES:
|
||
p.append(f'<rect x="8" y="8" width="{W-16}" height="{H-16}" rx="20" '
|
||
f'fill="url(#foil)"/>')
|
||
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'<circle cx="{sx}" cy="{sy}" r="{r}" fill="#ffffff" '
|
||
f'opacity="0.7"/>')
|
||
# multi-layer beveled border
|
||
if card.rarity == "Legendary": # outer glow ring for legendaries
|
||
p.append(f'<rect x="9" y="9" width="{W-18}" height="{H-18}" rx="20" '
|
||
f'fill="none" stroke="{border}" stroke-width="10" '
|
||
f'filter="url(#glow)" opacity="0.7"/>')
|
||
p.append(f'<rect x="7" y="7" width="{W-14}" height="{H-14}" rx="19" '
|
||
f'fill="none" stroke="url(#metal)" stroke-width="8"/>')
|
||
p.append(f'<rect x="13" y="13" width="{W-26}" height="{H-26}" rx="14" '
|
||
f'fill="none" stroke="{accent}" stroke-width="1.5" opacity="0.7"/>')
|
||
p.append(f'<rect x="0" y="0" width="{W}" height="{H}" rx="24" fill="url(#vig)"/>')
|
||
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'<rect x="{PAD}" y="20" width="{W-2*PAD}" height="50" rx="12" '
|
||
f'fill="url(#banner)" stroke="{accent}" stroke-width="1" opacity="0.96"/>')
|
||
p.append(f'<circle cx="{PAD+26}" cy="45" r="15" fill="{tc}" '
|
||
f'stroke="{glint}" stroke-width="2"/>')
|
||
p.append(f'<circle cx="{PAD+21}" cy="40" r="5" fill="#ffffff" opacity="0.7"/>')
|
||
p.append(f'<text x="{PAD+50}" y="44" font-size="25" font-weight="bold" '
|
||
f'fill="#f7f9fb">{_esc(card.name)}</text>')
|
||
holo = " · ★HOLO" if card.shiny else ""
|
||
p.append(f'<text x="{PAD+50}" y="62" font-size="11" fill="{glint}">'
|
||
f'No.{card.dex:03d} · {_esc(card.label)}{holo}</text>')
|
||
p.append(f'<text x="{W-PAD-8}" y="40" text-anchor="end" font-size="15" '
|
||
f'fill="#ffffff" font-weight="bold">PWR {card.power}</text>')
|
||
bx = W - PAD - 8
|
||
for ty in reversed(card.types):
|
||
tcc = TYPE_COLOR.get(ty, "#a0a4ab")
|
||
w = 16 + len(ty) * 8
|
||
p.append(f'<rect x="{bx-w}" y="50" width="{w}" height="18" rx="9" fill="{tcc}" '
|
||
f'stroke="{glint}" stroke-width="0.75"/>')
|
||
p.append(f'<text x="{bx-w/2}" y="63" text-anchor="middle" font-size="11" '
|
||
f'fill="#0c0f14" font-weight="bold">{_esc(ty)}</text>')
|
||
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'<rect x="{ART_X}" y="{ART_Y}" width="{ART_W}" height="{ART_H}" rx="12" '
|
||
f'fill="#0c0f14" stroke="{border}" stroke-width="3"/>')
|
||
if art_data_uri:
|
||
p.append(f'<clipPath id="aw"><rect x="{ART_X}" y="{ART_Y}" width="{ART_W}" '
|
||
f'height="{ART_H}" rx="12"/></clipPath>')
|
||
p.append(f'<image x="{ART_X}" y="{ART_Y}" width="{ART_W}" height="{ART_H}" '
|
||
f'preserveAspectRatio="xMidYMid slice" clip-path="url(#aw)" '
|
||
f'xlink:href="{art_data_uri}"/>')
|
||
else:
|
||
p.append(_sigil_svg(card))
|
||
p.append(f'<rect x="{ART_X}" y="{ART_Y+ART_H-26}" width="{ART_W}" height="26" '
|
||
f'fill="{border}" opacity="0.85"/>')
|
||
p.append(f'<text x="{W/2}" y="{ART_Y+ART_H-8}" text-anchor="middle" font-size="14" '
|
||
f'fill="#0c0f14" font-weight="bold" letter-spacing="2">'
|
||
f'{card.rarity.upper()}</text>')
|
||
sw = 22 + len(st_txt) * 7
|
||
p.append(f'<rect x="{ART_X+8}" y="{ART_Y+8}" width="{sw}" height="20" rx="10" '
|
||
f'fill="{st_col}" opacity="0.92"/>')
|
||
p.append(f'<text x="{ART_X+8+sw/2}" y="{ART_Y+22}" text-anchor="middle" '
|
||
f'font-size="11" fill="#0c0f14" font-weight="bold">{_esc(st_txt)}</text>')
|
||
|
||
|
||
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'<text x="{PAD+8}" y="{y+13}" font-size="13" fill="#c8ccd2" '
|
||
f'font-weight="bold">{lbl}</text>')
|
||
p.append(f'<rect x="{PAD+50}" y="{y}" width="{bw-90}" height="16" rx="8" '
|
||
f'fill="#2a2e36"/>')
|
||
fillw = max(6, (bw - 90) * val / 255)
|
||
p.append(f'<rect x="{PAD+50}" y="{y}" width="{fillw:.0f}" height="16" rx="8" '
|
||
f'fill="{border}"/>')
|
||
p.append(f'<text x="{W-PAD-8}" y="{y+13}" text-anchor="end" font-size="13" '
|
||
f'fill="#f4f6f8">{val}</text>')
|
||
|
||
|
||
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'<svg xmlns="http://www.w3.org/2000/svg" '
|
||
f'xmlns:xlink="http://www.w3.org/1999/xlink" width="{W}" height="{H}" '
|
||
f'viewBox="0 0 {W} {H}" font-family="DejaVu Sans, Arial, sans-serif">']
|
||
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'<text x="{PAD+8}" y="{y0}" font-size="13" fill="{accent}" '
|
||
f'font-weight="bold" letter-spacing="2">VM DOSSIER</text>')
|
||
p.append(f'<line x1="{PAD+8}" y1="{y0+8}" x2="{W-PAD-8}" y2="{y0+8}" '
|
||
f'stroke="{border}" stroke-width="1.5" opacity="0.6"/>')
|
||
desc = card.purpose or card.flavor or "an unknown sandbox"
|
||
for j, ln in enumerate(_wrap(desc, 46, maxlines=8)):
|
||
p.append(f'<text x="{PAD+8}" y="{y0+30+j*22}" font-size="15" '
|
||
f'fill="#d6dae0">{_esc(ln)}</text>')
|
||
p.append(f'<text x="{PAD+8}" y="{H-46}" font-size="12" fill="{accent}" '
|
||
f'font-weight="bold">PWR {card.power} · BST {bst} · '
|
||
f'{card.rarity}</text>')
|
||
p.append(f'<text x="{PAD+26}" y="{H-30}" font-size="11" fill="#7f868f">'
|
||
f'tap to flip → stats</text>')
|
||
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'<text x="{PAD+8}" y="{fy}" font-size="13" fill="{accent}" '
|
||
f'font-weight="bold" letter-spacing="2">VALUE BREAKDOWN</text>')
|
||
order = ["completeness", "reusability", "richness", "pedigree", "heft"]
|
||
for k, sk in enumerate(order):
|
||
v = card.subscores.get(sk, 0)
|
||
p.append(f'<text x="{PAD+8}" y="{fy+24+k*20}" font-size="12" '
|
||
f'fill="#b8bdc4">{sk.capitalize():<14}</text>')
|
||
p.append(f'<text x="{W-PAD-8}" y="{fy+24+k*20}" text-anchor="end" '
|
||
f'font-size="12" fill="#f4f6f8">{v}</text>')
|
||
gy = fy + 24 + 5 * 20 + 18
|
||
p.append(f'<text x="{PAD+8}" y="{gy}" font-size="13" fill="{accent}" '
|
||
f'font-weight="bold">BST {bst}</text>')
|
||
for j, ln in enumerate(_wrap(card.flavor, 52, maxlines=3)):
|
||
p.append(f'<text x="{PAD+8}" y="{gy+22+j*16}" font-size="11" '
|
||
f'fill="#9aa0a8">{_esc(ln)}</text>')
|
||
p.append(f'<text x="{W-PAD-26}" y="{H-30}" text-anchor="end" font-size="11" '
|
||
f'fill="#7f868f">← tap to flip</text>')
|
||
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'<text x="{PAD+8}" y="{fy+8}" font-size="12" fill="{accent}" '
|
||
f'font-weight="bold">BST {bst}</text>')
|
||
for j, ln in enumerate(_wrap(card.flavor, 58)):
|
||
p.append(f'<text x="{PAD+8}" y="{fy+26+j*15}" font-size="11" '
|
||
f'fill="#9aa0a8">{_esc(ln)}</text>')
|
||
p.append("</svg>")
|
||
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'<div class="flip-card" tabindex="0"><div class="flip-inner">'
|
||
f'<div class="flip-face flip-front">{front}</div>'
|
||
f'<div class="flip-face flip-back">{back}</div>'
|
||
f'</div></div>')
|
||
else:
|
||
cells.append(f'<div class="card">{render_card_svg(c, art_uri)}</div>')
|
||
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 = ('<script>document.querySelectorAll(".flip-card").forEach(c=>'
|
||
'c.addEventListener("click",()=>c.classList.toggle("flipped")));'
|
||
'</script>') if flip else ''
|
||
html = (
|
||
'<!doctype html><html><head><meta charset="utf-8">'
|
||
'<style>'
|
||
'body{margin:0;background:radial-gradient(1200px 700px at 50% -10%,'
|
||
'#161b24,#0a0c10 60%);color:#e8eaed;'
|
||
'font-family:DejaVu Sans,Arial,sans-serif}'
|
||
'.hd{padding:28px 36px 8px}'
|
||
'.hd h1{margin:0;font-size:30px;letter-spacing:1px}'
|
||
'.hd p{margin:6px 0 0;color:#9aa0a8;font-size:15px}'
|
||
f'{flip_css if flip else static_css}'
|
||
'</style></head><body>'
|
||
f'<div class="hd"><h1>hack-house VM Cardex</h1>'
|
||
f'<p>{len(cards)} saved VMs · {spread}{" — " + sub if flip else ""}</p></div>'
|
||
f'<div class="grid">{"".join(cells)}</div>'
|
||
f'{script}</body></html>')
|
||
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 <vm> 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())
|