feat(cardex): retune value model for spread + add card image renderer
Retune cardex against the live 24-VM library: the old rubric tied 11 VMs at 615 Rare (completeness/reusability maxed for any done+shareable VM, only heft varied). Rebalance the five sub-score caps (richness 20→30 becomes the discriminator via per-tag CAPABILITY_WEIGHT; heft log2-scaled) and recalibrate the rarity thresholds. Library now spreads 2 Legendary / 3 Epic / 16 Rare / 2 Uncommon / 2 Common instead of a single fat Rare band. Add cmd_chat/cardimg.py: render a Card as a collectible trading-card image. Free + local-first — a hand-built SVG frame (rarity holo, type badges, 6 stat bars, dex#, flavor) plus a deterministic procedural creature sigil that needs no network, rasterized to PNG via local cairosvg/ImageMagick. Pluggable art backends are opt-in: free hosted Pollinations flux, and key-gated paid Stability / OpenAI / RunwayML. Every backend only fills the portrait window, so a card always renders fully offline; failures fall back to the sigil. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
+61
-22
@@ -32,12 +32,41 @@ BASE_MB = 128.0 # ~size of the empty hh-dev base image; payload = size
|
|||||||
SUBSCORE_MAX = 100 # completeness+reusability+richness+pedigree+heft caps sum
|
SUBSCORE_MAX = 100 # completeness+reusability+richness+pedigree+heft caps sum
|
||||||
POWER_MAX = 1000 # PowerScore = subscore_sum * 10
|
POWER_MAX = 1000 # PowerScore = subscore_sum * 10
|
||||||
|
|
||||||
|
# Sub-score caps (sum == SUBSCORE_MAX). completeness/reusability are near-binary
|
||||||
|
# "is it a real, shipped VM?" gates, so they're kept small; the discriminating
|
||||||
|
# signal lives in richness (how self-describing + how rare its capabilities),
|
||||||
|
# heft (installed payload), and pedigree — those carry most of the spread.
|
||||||
|
COMP_MAX = 20.0
|
||||||
|
REUSE_MAX = 15.0
|
||||||
|
RICH_MAX = 30.0
|
||||||
|
PED_MAX = 10.0
|
||||||
|
HEFT_MAX = 25.0
|
||||||
|
|
||||||
|
# Per-tag capability weight — rare/high-skill security capabilities are worth
|
||||||
|
# more than generic ones, so a VM's "type rarity" pushes its richness (and thus
|
||||||
|
# its rarity tier). Unlisted tags default to 1.5.
|
||||||
|
CAPABILITY_WEIGHT = {
|
||||||
|
# marquee / rare offensive + IR capabilities
|
||||||
|
"exploit": 3.0, "pwn": 3.0, "c2": 3.0, "payload": 3.0, "malware": 3.0,
|
||||||
|
"crack": 3.0, "redteam": 3.0, "dfir": 3.0, "forensics": 3.0, "reverse": 3.0,
|
||||||
|
"ransomware": 3.0,
|
||||||
|
# solid mid-tier capabilities
|
||||||
|
"recon": 2.0, "osint": 2.0, "fuzz": 2.0, "brute": 2.0, "crypto": 2.0,
|
||||||
|
"tls": 2.0, "dns": 2.0, "yara": 2.0, "pcap": 2.0, "vuln": 2.0, "cve": 2.0,
|
||||||
|
"ids": 2.0, "threat": 2.0, "nmap": 2.0,
|
||||||
|
# generic / scaffolding tags
|
||||||
|
"dev": 1.0, "build": 1.0, "lib": 1.0, "http": 1.0, "web": 1.0, "log": 1.0,
|
||||||
|
"network": 1.0, "audit": 1.0, "blue": 1.0, "training": 1.0, "lab": 1.0,
|
||||||
|
"security": 1.0, "smoke": 0.0, "slice": 0.0,
|
||||||
|
}
|
||||||
|
|
||||||
# Rarity by absolute PowerScore cutoff (user choice: stable & reproducible).
|
# Rarity by absolute PowerScore cutoff (user choice: stable & reproducible).
|
||||||
|
# Calibrated against the live library distribution so tiers actually spread.
|
||||||
RARITY_TIERS = [
|
RARITY_TIERS = [
|
||||||
("Legendary", 825),
|
("Legendary", 720),
|
||||||
("Epic", 650),
|
("Epic", 660),
|
||||||
("Rare", 450),
|
("Rare", 540),
|
||||||
("Uncommon", 250),
|
("Uncommon", 380),
|
||||||
("Common", 0),
|
("Common", 0),
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -82,37 +111,47 @@ def _clamp(x: float, lo: float = 0.0, hi: float = 1.0) -> float:
|
|||||||
|
|
||||||
# ── the five value sub-scores ────────────────────────────────────────────────
|
# ── the five value sub-scores ────────────────────────────────────────────────
|
||||||
def _completeness(status: str, todo: list, blockers: list) -> float:
|
def _completeness(status: str, todo: list, blockers: list) -> float:
|
||||||
|
"""Is it a real, finished VM? Near-binary gate; small cap by design."""
|
||||||
s = (status or "").strip().lower()
|
s = (status or "").strip().lower()
|
||||||
base = 25.0 if s in ("done", "complete", "completed") else (12.0 if s == "in_progress" else 0.0)
|
base = COMP_MAX if s in ("done", "complete", "completed") else (10.0 if s == "in_progress" else 0.0)
|
||||||
base -= min(len(todo) * 3, 12)
|
base -= min(len(todo) * 3, 12)
|
||||||
base -= len(blockers) * 5
|
base -= len(blockers) * 5
|
||||||
return _clamp(base, 0, 25)
|
return _clamp(base, 0, COMP_MAX)
|
||||||
|
|
||||||
|
|
||||||
def _reusability(shareable: bool, share_path: str, artifact_kind: str) -> float:
|
def _reusability(shareable: bool, share_path: str, artifact_kind: str) -> float:
|
||||||
v = 14.0 if (shareable and share_path) else 0.0
|
"""Can another agent actually pull and reuse it?"""
|
||||||
v += {"file": 6.0, "image": 2.0, "snapshot": 2.0}.get(artifact_kind, 0.0)
|
v = 10.0 if (shareable and share_path) else 0.0
|
||||||
return _clamp(v, 0, 20)
|
v += {"file": 5.0, "image": 3.0, "snapshot": 2.0}.get(artifact_kind, 0.0)
|
||||||
|
return _clamp(v, 0, REUSE_MAX)
|
||||||
|
|
||||||
|
|
||||||
def _richness(purpose: str, objective: str, tags: list, has_usage: bool) -> float:
|
def _richness(purpose: str, objective: str, tags: list, has_usage: bool) -> float:
|
||||||
|
"""The discriminating axis: how self-describing + how rare its capabilities.
|
||||||
|
|
||||||
|
Rewards prose depth (purpose/objective length), tag breadth, *and* the
|
||||||
|
capability-rarity of those tags (a c2/exploit VM out-scores a dev-lib one)."""
|
||||||
v = 0.0
|
v = 0.0
|
||||||
v += 5 if purpose.strip() else 0
|
v += min(len(purpose.split()) * 0.8, 8) # purpose prose depth
|
||||||
v += 5 if objective.strip() else 0
|
v += 5 if objective.strip() else 0 # has a stated objective
|
||||||
v += min(len(tags) * 2, 6)
|
v += min(len(tags) * 1.5, 9) # tag breadth
|
||||||
v += 4 if has_usage else 0
|
cap = sum(CAPABILITY_WEIGHT.get(t.strip().lower(), 1.5) for t in tags)
|
||||||
return _clamp(v, 0, 20)
|
v += min(cap, 8) # capability rarity
|
||||||
|
v += 4 if has_usage else 0 # documents how to run
|
||||||
|
return _clamp(v, 0, RICH_MAX)
|
||||||
|
|
||||||
|
|
||||||
def _pedigree(provenance: list, created_by: str) -> float:
|
def _pedigree(provenance: list, created_by: str) -> float:
|
||||||
v = min(len(provenance) * 3, 12)
|
"""How battle-tested / hand-built its lineage is."""
|
||||||
v += 3 if created_by.strip().lower() not in ("", "smoke", "pulled") else 0
|
v = min(len(provenance) * 3, 8)
|
||||||
return _clamp(v, 0, 15)
|
v += 2 if created_by.strip().lower() not in ("", "smoke", "pulled") else 0
|
||||||
|
return _clamp(v, 0, PED_MAX)
|
||||||
|
|
||||||
|
|
||||||
def _heft(size_bytes: int) -> float:
|
def _heft(size_bytes: int) -> float:
|
||||||
|
"""Installed payload over the empty base — log-scaled so giants don't run away."""
|
||||||
payload_mb = max(0.0, (size_bytes or 0) / 1e6 - BASE_MB)
|
payload_mb = max(0.0, (size_bytes or 0) / 1e6 - BASE_MB)
|
||||||
return _clamp(4.0 * math.log2(1 + payload_mb / 8.0), 0, 20)
|
return _clamp(5.0 * math.log2(1 + payload_mb / 6.0), 0, HEFT_MAX)
|
||||||
|
|
||||||
|
|
||||||
def _rarity_of(power: int) -> str:
|
def _rarity_of(power: int) -> str:
|
||||||
@@ -226,12 +265,12 @@ def card_from_entry(entry: dict, manifest: dict | None = None) -> Card:
|
|||||||
payload_mb = max(0.0, (entry.get("size_bytes") or 0) / 1e6 - BASE_MB)
|
payload_mb = max(0.0, (entry.get("size_bytes") or 0) / 1e6 - BASE_MB)
|
||||||
speed_raw = _clamp(1 - payload_mb / 256)
|
speed_raw = _clamp(1 - payload_mb / 256)
|
||||||
stats = {
|
stats = {
|
||||||
"HP": _stat(floor + 0.70 * (0.6 * heft / 20 + 0.4 * comp / 25)),
|
"HP": _stat(floor + 0.70 * (0.6 * heft / HEFT_MAX + 0.4 * comp / COMP_MAX)),
|
||||||
"Attack": _stat(floor + 0.70 * off),
|
"Attack": _stat(floor + 0.70 * off),
|
||||||
"Defense": _stat(floor + 0.70 * (0.5 * dfn + 0.5 * comp / 25)),
|
"Defense": _stat(floor + 0.70 * (0.5 * dfn + 0.5 * comp / COMP_MAX)),
|
||||||
"SpAtk": _stat(floor + 0.70 * (rich / 20)),
|
"SpAtk": _stat(floor + 0.70 * (rich / RICH_MAX)),
|
||||||
"Speed": _stat(floor + 0.70 * speed_raw),
|
"Speed": _stat(floor + 0.70 * speed_raw),
|
||||||
"SpDef": _stat(floor + 0.70 * (0.6 * reuse / 20 + 0.4 * ped / 15)),
|
"SpDef": _stat(floor + 0.70 * (0.6 * reuse / REUSE_MAX + 0.4 * ped / PED_MAX)),
|
||||||
}
|
}
|
||||||
|
|
||||||
shiny = (_seed(label) >> 16) % 16 == 0 # ~1/16, the classic shiny odds
|
shiny = (_seed(label) >> 16) % 16 == 0 # ~1/16, the classic shiny odds
|
||||||
|
|||||||
@@ -0,0 +1,410 @@
|
|||||||
|
"""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'<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")
|
||||||
|
|
||||||
|
|
||||||
|
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'<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">']
|
||||||
|
# defs: backdrop + holo
|
||||||
|
p.append(f'<defs>'
|
||||||
|
f'<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">'
|
||||||
|
f'<stop offset="0%" stop-color="#1a1d24"/>'
|
||||||
|
f'<stop offset="100%" stop-color="{glow}"/></linearGradient>'
|
||||||
|
f'<linearGradient id="holo" x1="0" y1="0" x2="1" y2="1">'
|
||||||
|
f'<stop offset="0%" stop-color="{accent}" stop-opacity="0.0"/>'
|
||||||
|
f'<stop offset="50%" stop-color="{accent}" stop-opacity="0.18"/>'
|
||||||
|
f'<stop offset="100%" stop-color="{accent}" stop-opacity="0.0"/>'
|
||||||
|
f'</linearGradient></defs>')
|
||||||
|
# frame
|
||||||
|
p.append(f'<rect x="0" y="0" width="{W}" height="{H}" rx="22" fill="url(#bg)"/>')
|
||||||
|
p.append(f'<rect x="6" y="6" width="{W-12}" height="{H-12}" rx="18" '
|
||||||
|
f'fill="none" stroke="{border}" stroke-width="5"/>')
|
||||||
|
if card.shiny:
|
||||||
|
p.append(f'<rect x="6" y="6" width="{W-12}" height="{H-12}" rx="18" '
|
||||||
|
f'fill="url(#holo)"/>')
|
||||||
|
# header: name + dex (holo marker folded into the dex line to avoid PWR collision)
|
||||||
|
p.append(f'<text x="{PAD+6}" y="40" font-size="26" font-weight="bold" '
|
||||||
|
f'fill="#f4f6f8">{_esc(card.name)}</text>')
|
||||||
|
holo = " · ★HOLO" if card.shiny else ""
|
||||||
|
p.append(f'<text x="{PAD+6}" y="62" font-size="12" fill="{accent}">'
|
||||||
|
f'No.{card.dex:03d} · {_esc(card.label)}{holo}</text>')
|
||||||
|
p.append(f'<text x="{W-PAD-6}" y="40" text-anchor="end" font-size="15" '
|
||||||
|
f'fill="#f4f6f8" font-weight="bold">PWR {card.power}</text>')
|
||||||
|
# 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'<rect x="{bx-w}" y="50" width="{w}" height="20" rx="10" fill="{tc}"/>')
|
||||||
|
p.append(f'<text x="{bx-w/2}" y="64" text-anchor="middle" font-size="12" '
|
||||||
|
f'fill="#0c0f14" font-weight="bold">{_esc(ty)}</text>')
|
||||||
|
bx -= w + 6
|
||||||
|
# art window
|
||||||
|
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))
|
||||||
|
# rarity ribbon
|
||||||
|
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>')
|
||||||
|
# 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'<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 / maxstat)
|
||||||
|
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>')
|
||||||
|
# footer: BST + flavor
|
||||||
|
fy = y0 + 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"><tspan>{_esc(ln)}</tspan></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
|
||||||
|
|
||||||
|
|
||||||
|
# ── 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")
|
||||||
|
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]
|
||||||
|
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)
|
||||||
|
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())
|
||||||
Reference in New Issue
Block a user