paradramorama/tools/cardgen/build_picker.py
m3ultra f3773919e5 Card-art generation pipeline for monsterrobot.games
Four directions per tile (neon box art / vector side art / amiga comic /
80s airbrush) generated on the farm, plus the picker page and the installer
that ships chosen art and its risograph hover b-side.

Recipe rules, all learned the hard way against the existing covers:
tiles render 172-240px so silhouette and colour are all that read; the page
draws every title in HTML so art carries no lettering (the old covers baked
in garbled type); busy interiors smuggle text back via album sleeves and go
muddy small, so it is one hero subject per card.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 12:05:04 +10:00

191 lines
9.5 KiB
Python

#!/usr/bin/env python3
"""Build the cover-art picker page (self-contained, images inlined as webp data URIs).
Judged on the same dark ground the cards actually live on, and at the true
172px tile size, because that is the only size that matters on the real page.
"""
import base64, io, json, os, subprocess, sys, tempfile
CARDS = sys.argv[1]
CURRENT = sys.argv[2] # dir of existing covers for comparison
PROFILES = json.load(open(sys.argv[3]))
OUT = sys.argv[4]
VARIANTS = ['neon', 'vector', 'comic', 'paint']
LABELS = {'neon': 'neon box art', 'vector': 'vector side art',
'comic': 'amiga comic', 'paint': '80s airbrush'}
# tiles the arcade page shows for each game id
TITLES = {p['id']: p.get('title', p['id']) for p in PROFILES}
def webp(path, width=380, q=72):
"""Downscale to a data URI (macOS sips cannot WRITE webp &mdash; use cwebp)."""
if not os.path.exists(path):
return None
with tempfile.TemporaryDirectory() as td:
dst = os.path.join(td, 'o.webp')
r = subprocess.run(['cwebp', '-q', str(q), '-resize', str(width), '0', path, '-o', dst],
capture_output=True)
if r.returncode != 0 or not os.path.exists(dst):
return None
b = open(dst, 'rb').read()
return 'data:image/webp;base64,' + base64.b64encode(b).decode()
rows = []
for p in PROFILES:
gid = p['id']
cells = []
wide = p.get('aspect') == 'wide'
src_dir = os.path.join(os.path.dirname(CARDS), 'hero') if wide else CARDS
cur = webp(os.path.join(CURRENT, p.get('current_cover', gid) + '.jpg'),
620 if wide else 300, 70)
for v in VARIANTS:
d = webp(os.path.join(src_dir, f'{gid}__{v}.png'), 560 if wide else 380)
if d:
cells.append({'v': v, 'src': d})
if cells:
rows.append({'id': gid, 'title': TITLES.get(gid, gid), 'cur': cur,
'cells': cells, 'wide': wide})
total_kb = sum(len(c['src']) for r in rows for c in r['cells']) // 1024
print(f'{len(rows)} games, {sum(len(r["cells"]) for r in rows)} variants, ~{total_kb//1024}MB inlined')
html = ['''<title>Cover art &mdash; pick one per game</title>
<style>
:root{
--bg:#07080d; --panel:#0d1018; --line:#1b2233; --ink:#dbe6da; --dim:#7b8894;
--faint:#47535f; --phos:#3dff9a; --pink:#ff3ea5; --cyan:#38d6ff;
--mono:ui-monospace,"Cascadia Code","JetBrains Mono",Menlo,monospace;
--disp:"Arial Black","Helvetica Neue",Impact,sans-serif;
}
*{margin:0;padding:0;box-sizing:border-box}
body{background:var(--bg);color:var(--ink);font-family:var(--mono);font-size:14px;line-height:1.5;
background-image:radial-gradient(circle at 12% 0%,rgba(255,62,165,.10),transparent 42%),
radial-gradient(circle at 88% 100%,rgba(56,214,255,.10),transparent 42%),
repeating-linear-gradient(0deg,rgba(255,255,255,.014) 0 1px,transparent 1px 3px)}
.wrap{max-width:1180px;margin:0 auto;padding:0 18px 100px}
header{padding:26px 0 8px}
h1{font-family:var(--disp);text-transform:uppercase;font-size:clamp(1.4rem,3.4vw,2.1rem);
letter-spacing:-.02em;color:#fff;line-height:1}
h1 .g{color:var(--phos)}
.sub{color:var(--dim);margin-top:.5rem;max-width:64ch}
.bar{position:sticky;top:0;z-index:20;display:flex;flex-wrap:wrap;align-items:center;gap:10px;
background:rgba(7,8,13,.93);border-bottom:1px solid var(--line);padding:11px 0;margin:14px 0 4px;
backdrop-filter:blur(8px)}
.count{color:var(--phos);letter-spacing:.1em;text-transform:uppercase;font-size:11px}
button{font-family:var(--mono);font-size:11px;letter-spacing:.12em;text-transform:uppercase;
background:var(--panel);color:var(--ink);border:1px solid var(--line);border-radius:4px;
padding:7px 12px;cursor:pointer;transition:.15s}
button:hover{border-color:var(--pink);color:#fff}
button:focus-visible{outline:2px solid var(--cyan);outline-offset:2px}
button.on{border-color:var(--phos);color:var(--phos)}
/* 114 inlined covers is ~100MB of decoded bitmap if the browser paints them
all at once, which blanks the page on scroll. Only render rows near the
viewport; the intrinsic size keeps the scrollbar honest. */
.game{border-top:1px solid var(--line);padding:20px 0 24px;
content-visibility:auto;contain-intrinsic-size:auto 360px}
.gh{display:flex;align-items:baseline;gap:12px;margin-bottom:12px}
.gh .t{font-family:var(--disp);text-transform:uppercase;color:#fff;font-size:16px}
.gh .pick{color:var(--phos);font-size:11px;letter-spacing:.1em;text-transform:uppercase}
.gh .pick.none{color:var(--faint)}
.strip{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:12px;align-items:start}
body.tiny .strip{grid-template-columns:repeat(auto-fill,172px)}
.opt{border:1px solid var(--line);border-radius:9px;overflow:hidden;background:var(--panel);
cursor:pointer;transition:transform .16s,border-color .16s,box-shadow .16s;padding:0;display:block;
text-align:left;width:100%}
.opt:hover{transform:translateY(-4px);border-color:var(--pink);box-shadow:0 12px 30px -14px rgba(255,62,165,.6)}
.opt.sel{border-color:var(--phos);box-shadow:0 0 0 1px var(--phos),0 12px 30px -14px rgba(61,255,154,.6)}
.opt .art{position:relative;aspect-ratio:896/1152;background:#000}
.game.wide .strip{grid-template-columns:repeat(auto-fit,minmax(300px,1fr))}
body.tiny .game.wide .strip{grid-template-columns:1fr}
.game.wide .opt .art{aspect-ratio:21/9}
.opt .art img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;display:block}
.opt .lab{display:flex;align-items:center;gap:6px;padding:7px 9px;font-size:10px;
letter-spacing:.09em;text-transform:uppercase;color:var(--dim)}
.opt.sel .lab{color:var(--phos)}
.opt .lab .k{margin-left:auto;color:var(--faint)}
.opt.cur{opacity:.62}
.opt.cur .lab{color:var(--pink)}
.opt.cur:hover{transform:none;border-color:var(--line);box-shadow:none;cursor:default}
.out{position:fixed;left:0;right:0;bottom:0;z-index:30;background:rgba(7,8,13,.96);
border-top:1px solid var(--line);padding:10px 18px;display:flex;gap:12px;align-items:center;
backdrop-filter:blur(8px)}
.out code{flex:1;color:var(--cyan);font-size:12px;overflow-x:auto;white-space:nowrap;padding:4px 0}
@media(prefers-reduced-motion:reduce){*{transition:none!important}}
</style>
<div class="wrap">
<header>
<h1>Cover art &mdash; <span class="g">pick one</span> per game</h1>
<p class="sub">Four directions per game, generated clean on the farm. The old covers had
garbled AI lettering baked in; these carry no type at all, because the site already
draws every title in HTML. Judge them small &mdash; that grey card is the cover you have now.</p>
</header>
<div class="bar">
<span class="count" id="count">0 picked</span>
<button id="tiny">actual tile size</button>
<button id="jump">next undecided</button>
<button id="copy">copy my picks</button>
</div>''']
for r in rows:
cls = 'game wide' if r['wide'] else 'game'
html.append(f''' <section class="{cls}" id="g-{r['id']}" data-game="{r['id']}">
<div class="gh"><span class="t">{r['title']}</span><span class="pick none">no pick</span></div>
<div class="strip">''')
if r['cur']:
html.append(f''' <div class="opt cur"><div class="art"><img decoding="async" src="{r['cur']}" alt=""></div>
<div class="lab">current</div></div>''')
for i, c in enumerate(r['cells'], 1):
html.append(f''' <button class="opt" data-v="{c['v']}"><div class="art"><img decoding="async" src="{c['src']}" alt=""></div>
<div class="lab">{LABELS[c['v']]}<span class="k">{i}</span></div></button>''')
html.append(' </div>\n </section>')
html.append('''</div>
<div class="out"><code id="picks">pick a cover in each row - your choices collect here</code>
<button id="copy2">copy</button></div>
<script>
const picks = {};
const games = [...document.querySelectorAll('.game')];
const fmt = () => games.filter(g => picks[g.dataset.game])
.map(g => g.dataset.game + '=' + picks[g.dataset.game]).join(' ');
function sync(){
document.getElementById('count').textContent =
Object.keys(picks).length + ' / ' + games.length + ' picked';
document.getElementById('picks').textContent =
Object.keys(picks).length ? fmt() : 'pick a cover in each row - your choices collect here';
}
games.forEach(g => {
const badge = g.querySelector('.pick');
g.querySelectorAll('.opt:not(.cur)').forEach(o => o.addEventListener('click', () => {
g.querySelectorAll('.opt').forEach(x => x.classList.remove('sel'));
o.classList.add('sel');
picks[g.dataset.game] = o.dataset.v;
badge.textContent = o.dataset.v;
badge.classList.remove('none');
sync();
}));
});
document.getElementById('tiny').addEventListener('click', e => {
document.body.classList.toggle('tiny');
e.currentTarget.classList.toggle('on');
});
document.getElementById('jump').addEventListener('click', () => {
const next = games.find(g => !picks[g.dataset.game]);
if (next) next.scrollIntoView({behavior:'smooth', block:'start'});
});
const copy = () => {
const t = fmt();
if (!t) return;
navigator.clipboard.writeText(t);
[...document.querySelectorAll('#copy,#copy2')].forEach(b => {
const o = b.textContent; b.textContent = 'copied'; setTimeout(()=>b.textContent=o, 1200);
});
};
document.getElementById('copy').addEventListener('click', copy);
document.getElementById('copy2').addEventListener('click', copy);
sync();
</script>''')
open(OUT, 'w').write('\n'.join(html))
print(f'&rarr; {OUT} ({os.path.getsize(OUT)//1024}KB)')