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:
@@ -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