diff --git a/tools/cardgen/build_picker.py b/tools/cardgen/build_picker.py new file mode 100644 index 0000000..e6ed11c --- /dev/null +++ b/tools/cardgen/build_picker.py @@ -0,0 +1,190 @@ +#!/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 — 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 = ['''Cover art — pick one per game + +
+
+

Cover art — pick one per game

+

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 — that grey card is the cover you have now.

+
+
+ 0 picked + + + +
'''] + +for r in rows: + cls = 'game wide' if r['wide'] else 'game' + html.append(f'''
+
{r['title']}no pick
+
''') + if r['cur']: + html.append(f'''
+
current
''') + for i, c in enumerate(r['cells'], 1): + html.append(f''' ''') + html.append('
\n
') + +html.append('''
+
pick a cover in each row - your choices collect here +
+''') + +open(OUT, 'w').write('\n'.join(html)) +print(f'→ {OUT} ({os.path.getsize(OUT)//1024}KB)') diff --git a/tools/cardgen/gen_cards.py b/tools/cardgen/gen_cards.py new file mode 100644 index 0000000..e5681e5 --- /dev/null +++ b/tools/cardgen/gen_cards.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python3 +"""Build the 4-variant card-art spec for monsterrobot.games from profiles.json. + +Recipe rules, all learned from the bake-off against the existing covers: + * Tiles render 172-240px wide -> 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 ("INFTE! COPFRIICGN") - that is the slop tell. + * Busy interiors smuggle text back in via album sleeves/posters and go muddy + at thumbnail size. Single hero subject, always. +""" +import json, os, sys + +OUT = sys.argv[2] if len(sys.argv) > 2 else '/private/tmp/claude-501/-Users-m3ultra-Documents/097e3817-807d-439d-b5f5-95a6ffd4a1f9/scratchpad/cards' + +READS = ("single bold hero subject filling the frame, strong clean silhouette, " + "dramatic rim light, high contrast, saturated limited palette, " + "deep dark background, poster composition, reads clearly at thumbnail size") +NOTEXT = ("completely wordless, no text, no letters, no words, no title, no logo, " + "no signage, no watermark, no signature, blank unlabelled sleeves and " + "plain unmarked surfaces") + +VARIANTS = [ + # key model steps style + ('neon', 'flux2-klein-4b', 4, + "1987 British home-computer game box art, airbrushed gouache, glossy " + "highlights, heroic low angle, screaming neon cyan and magenta on pure black"), + ('vector', 'flux2-klein-4b', 4, + "1980s arcade cabinet side art, bold flat vector shapes, hard edges, thick " + "black outlines, blazing neon on black, geometric and graphic, no gradients"), + ('comic', 'flux2-klein-4b', 4, + "early-1990s Amiga game cover painting, chunky saturated brushwork, bold cel " + "shading, comic-book energy, primary colours, dark vignette"), + ('paint', 'z-image-turbo', 8, + "classic 1980s airbrush poster painting, silhouetted figures against a glowing " + "backlit haze, lush deep colour, cinematic, painted on black"), +] + + +def main(): + profiles = json.load(open(sys.argv[1])) + spec = [] + for p in profiles: + subj = p['hero_subject'] + pal = p.get('palette') or '' + for key, model, steps, style in VARIANTS: + params = { + 'prompt': f"{style}. {subj}. {pal + '. ' if pal else ''}{READS}. {NOTEXT}", + 'model': model, 'steps': steps, + 'width': 896, 'height': 1152, 'seed': 7, + } + if model == 'z-image-turbo': + params['quantize'] = '4' + spec.append({'out': f"{OUT}/{p['id']}__{key}.png", + 'operator': 'flux_local', 'params': params}) + os.makedirs(OUT, exist_ok=True) + path = OUT + '/spec.json' + json.dump(spec, open(path, 'w'), indent=1) + print(f'{len(spec)} jobs ({len(profiles)} games x {len(VARIANTS)}) -> {path}') + + +if __name__ == '__main__': + main() diff --git a/tools/cardgen/hero_spec.py b/tools/cardgen/hero_spec.py new file mode 100644 index 0000000..2b41e49 --- /dev/null +++ b/tools/cardgen/hero_spec.py @@ -0,0 +1,33 @@ +#!/usr/bin/env python3 +"""Hero banner variants. The slot is 21:9 and the current art is a PORTRAIT +image cropped to fit, so most of it is thrown away. The page scrims the left +side for the headline, so the robot is composed hard right with clean dark +space on the left.""" +import json, os + +OUT = '/private/tmp/claude-501/-Users-m3ultra-Documents/097e3817-807d-439d-b5f5-95a6ffd4a1f9/scratchpad/hero' +SUBJ = ("a colossal chrome monster robot standing menacingly among glowing arcade " + "cabinets in a dark neon arcade, reflections on a wet tiled floor, " + "composed on the right hand side of a wide frame") +READS = ("ultra wide cinematic banner, the left third is empty dark negative space, " + "strong silhouette, high contrast, saturated neon on near black") +NOTEXT = ("completely wordless, no text, no letters, no words, no logo, no marquee " + "lettering, no watermark, no signature, blank unmarked cabinet panels") +STYLES = { + 'neon': "1987 British home-computer game box art, airbrushed gouache, glossy highlights, screaming neon cyan and magenta on black", + 'vector': "1980s arcade cabinet side art, bold flat vector shapes, thick black outlines, blazing neon on black, geometric", + 'comic': "early-1990s Amiga game cover painting, chunky saturated brushwork, bold cel shading, dark vignette", + 'paint': "classic 1980s airbrush poster painting, silhouettes against a glowing backlit haze, lush deep colour, cinematic", +} +spec = [] +for k, st in STYLES.items(): + p = {'prompt': f'{st}. {SUBJ}. {READS}. {NOTEXT}', + 'model': 'z-image-turbo' if k == 'paint' else 'flux2-klein-4b', + 'steps': 8 if k == 'paint' else 4, + 'width': 1344, 'height': 576, 'seed': 13} + if k == 'paint': + p['quantize'] = '4' + spec.append({'out': f'{OUT}/hero__{k}.png', 'operator': 'flux_local', 'params': p}) +os.makedirs(OUT, exist_ok=True) +json.dump(spec, open(OUT + '/spec.json', 'w'), indent=1) +print(len(spec), 'hero jobs') diff --git a/tools/cardgen/install_picks.py b/tools/cardgen/install_picks.py new file mode 100644 index 0000000..28f8ade --- /dev/null +++ b/tools/cardgen/install_picks.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +"""Install chosen card art onto monsterrobot.games. + +Usage: install_picks.py "cratewars=vector stylus9=neon ..." [--dry] + +For each pick it: + 1. trims the paper border z-image sometimes paints around 'paint' variants, + 2. writes covers/.jpg at the tile's native 896x1152, + 3. queues a matching risograph _riso.jpg for the page's hover flip, + 4. rsyncs both to the lander (host path — the nginx mount is read-only). +""" +import json, os, subprocess, sys, tempfile + +S = os.path.dirname(os.path.abspath(__file__)) +CARDS = f'{S}/cards' +RISO = f'{S}/riso' +STAGE = f'{S}/install' +VPS = 'humanjing@100.71.119.27' +LANDER = '/home/humanjing/monsterrobot.games/covers' +PROFILES = {p['id']: p for p in json.load(open(f'{S}/profiles.json'))} + +RISO_STYLE = ("risograph screenprint poster, three flat spot colours only, visible halftone " + "dot texture, slight misregistration, bold graphic shapes, flat matte inks") +READS = ("single bold hero subject filling the frame, strong clean silhouette, high contrast, " + "poster composition, reads clearly at thumbnail size") +NOTEXT = ("completely wordless, no text, no letters, no words, no logo, no watermark, " + "no signature, blank unlabelled sleeves") + + +def sh(*a): + r = subprocess.run(a, capture_output=True) + if r.returncode: + sys.exit(f'FAILED: {" ".join(a)}\n{r.stderr.decode()[:400]}') + + +def to_cover(src, dst, trim): + """896x1152 jpeg, optionally trimming a painted paper border first.""" + with tempfile.TemporaryDirectory() as td: + cur = src + if trim: + w = int(subprocess.run(['sips', '-g', 'pixelWidth', src], capture_output=True) + .stdout.decode().split(':')[-1]) + h = int(subprocess.run(['sips', '-g', 'pixelHeight', src], capture_output=True) + .stdout.decode().split(':')[-1]) + cur = os.path.join(td, 'c.png') + sh('sips', '-c', str(int(h * 0.955)), str(int(w * 0.955)), src, '--out', cur) + sh('sips', '-s', 'format', 'jpeg', '-s', 'formatOptions', '82', + '-z', '1152', '896', cur, '--out', dst) + + +def main(): + picks = dict(p.split('=') for p in sys.argv[1].split() if '=' in p) + dry = '--dry' in sys.argv + os.makedirs(STAGE, exist_ok=True) + os.makedirs(RISO, exist_ok=True) + + # 1. main covers from the picked variants + riso_spec = [] + for gid, variant in picks.items(): + src = f'{CARDS}/{gid}__{variant}.png' + if not os.path.exists(src): + print(f' ! missing {src}'); continue + name = PROFILES[gid].get('current_cover', gid) + if name == '__none__': + name = gid + to_cover(src, f'{STAGE}/{name}.jpg', trim=(variant == 'paint')) + print(f' āœ“ {name}.jpg ({variant})') + p = PROFILES[gid] + riso_spec.append({ + 'out': f'{RISO}/{name}_riso.png', 'operator': 'flux_local', + 'params': {'prompt': f"{RISO_STYLE}. {p['hero_subject']}. {READS}. {NOTEXT}", + 'model': 'flux2-klein-4b', 'steps': 4, + 'width': 896, 'height': 1152, 'seed': 7}}) + + # 2. the hover b-sides + json.dump(riso_spec, open(f'{RISO}/spec.json', 'w'), indent=1) + if not dry: + sh(sys.executable, f'{os.path.expanduser("~")}/Documents/paradramorama/tools/mbgen.py', + f'{RISO}/spec.json') + for item in riso_spec: + base = os.path.basename(item['out'])[:-4] + if os.path.exists(item['out']): + to_cover(item['out'], f'{STAGE}/{base}.jpg', trim=False) + print(f' āœ“ {base}.jpg') + + # 3. ship + if dry: + print('\n[dry] staged only, nothing uploaded') + return + sh('rsync', '-az', f'{STAGE}/', f'{VPS}:{LANDER}/') + print(f'\nāœ“ uploaded {len(os.listdir(STAGE))} files to {LANDER}') + + +if __name__ == '__main__': + main() diff --git a/tools/cardgen/profiles.json b/tools/cardgen/profiles.json new file mode 100644 index 0000000..9bd6a18 --- /dev/null +++ b/tools/cardgen/profiles.json @@ -0,0 +1,164 @@ +[ + { + "id": "hero", + "title": "Hero banner", + "current_cover": "hero", + "aspect": "wide", + "hero_subject": "colossal chrome monster robot among glowing arcade cabinets", + "palette": "" + }, + { + "id": "dj90s", + "title": "90s DJ Sim", + "current_cover": "dj90s", + "hero_subject": "a teenager in a cluttered 1990s Australian bedroom hunched over two belt-drive turntables on a plank-and-crate desk, headphones on one ear, milk crates of records under the bench, late afternoon light through venetian blinds", + "palette": "faded teal, warm orange, wood-panel brown" + }, + { + "id": "nottonight", + "title": "Not Tonight", + "current_cover": "nottonight", + "hero_subject": "a huge nightclub doorman filling a doorway at night, one arm out flat blocking the way, stamp in his fist, a long queue bunched behind a rope in the rain, red door light on his face", + "palette": "night blue, sodium orange, deep red" + }, + { + "id": "thriftgod", + "title": "Thriftgod", + "current_cover": "thriftgod", + "hero_subject": "a treasure hunter elbow deep in an overflowing op-shop bin of tangled clothes and dusty records, holding one find aloft in a shaft of fluorescent light", + "palette": "fluorescent beige, faded pastel jumble, brown" + }, + { + "id": "procity", + "title": "Procity", + "current_cover": "procity", + "hero_subject": "a giant hand lowering a glowing skyscraper into place on a tiny isometric city block, cranes and roads sprawling below, dusk light", + "palette": "twilight blue, warm window gold, concrete grey" + }, + { + "id": "recordstoreguy", + "title": "Record Store Guy", + "current_cover": "recordstoreguy", + "hero_subject": "a record store clerk diving sideways as an entire shelf of records avalanches down on him, blank sleeves fanning through the air mid-fall, cinematic film grain", + "palette": "amber tungsten, deep shadow black, VHS teal" + }, + { + "id": "stylus9", + "title": "Stylus-9", + "current_cover": "stylus9", + "hero_subject": "a glowing wireframe diamond stylus ship screaming down a canyon of black vinyl groove walls, tracer fire ahead, a colossal chrome tonearm looming overhead", + "palette": "vector black, phosphor cyan, hot magenta" + }, + { + "id": "morp", + "title": "Beyond Morp", + "current_cover": "morp", + "hero_subject": "a lone shopper dwarfed at the mouth of an endless maze of record racks curving overhead like canyon walls, one bare bulb swinging above, green terminal light spilling across the floor", + "palette": "phosphor green, black, dusty cardboard brown" + }, + { + "id": "turncraft", + "title": "Turncraft", + "current_cover": "turncraft", + "hero_subject": "a close low three-quarter view of a pair of chunky DJ turntables and a mixer, one hand pinning the spinning platter mid scratch, tonearm and cartridge in sharp focus", + "palette": "matte black, brushed silver, red LED glow" + }, + { + "id": "cratewars", + "title": "Crate Wars 2", + "current_cover": "cratewars", + "hero_subject": "two rival crate diggers wrestling over a milk crate stuffed with records in a bare warehouse, blank sleeves spilling and skidding across concrete, a beat-up van idling behind them", + "palette": "nicotine amber, concrete grey, blood red" + }, + { + "id": "roguelike", + "title": "Monster Roguelike", + "current_cover": "roguelike", + "hero_subject": "a hunched crate digger with a bulging record bag facing a matted wild-eyed neckbeard monster clutching warped melted records like claws, in a torchlit dungeon aisle of record racks", + "palette": "dungeon black, gold, sickly green" + }, + { + "id": "keyboardwarrior", + "title": "Keyboard Warrior", + "current_cover": "keyboardwarrior", + "hero_subject": "two furious music nerds hunched at facing desks in the dark, faces lit blue by monitors, hammering mechanical keyboards at each other like duelling swordsmen", + "palette": "forum blue, monitor glow white, dark grey" + }, + { + "id": "vinylgauntlet", + "title": "Vinyl Gauntlet", + "current_cover": "vinylgauntlet", + "hero_subject": "a lone digger swinging a record crate at a swarming mob of collector ghouls in a warehouse aisle, records flying like discs, a glowing pile of gold and vinyl in the corner", + "palette": "dungeon purple black, gold, acid green" + }, + { + "id": "morpquest", + "title": "Morpquest", + "current_cover": "morpquest", + "hero_subject": "a queue of desperate collectors surging at dawn across a wet car park toward a roller door, one lone digger clutching a blank white label record above his head", + "palette": "sky blue, brick orange, black" + }, + { + "id": "deadstock", + "title": "Deadstock", + "current_cover": "deadstock", + "hero_subject": "a battered portable suitcase record player open on a stone floor, its spinning platter throwing a blinding shaft of white light up onto a huge sealed vault door", + "palette": "cyan, magenta, black" + }, + { + "id": "blobbo", + "title": "Blobbo", + "current_cover": "blobbo", + "hero_subject": "a fat translucent jelly blob mid splat against a candy coloured platform, wobbling out of shape, trailing a smear of wet paint and a burst of confetti, cartoon cannons behind", + "palette": "sky blue, hot pink, lime green" + }, + { + "id": "glytch", + "title": "Glytch", + "current_cover": "glytch", + "hero_subject": "a half grown clone creature floating in a glowing bubbling vat on a factory production line, cables snaking into its back, a chunky CRT monitor bolted beside it flickering with magenta scanlines", + "palette": "deep violet black, hot magenta, cyan" + }, + { + "id": "gutsy", + "title": "Guts", + "current_cover": "gutsy", + "hero_subject": "a tiny chrome bio-ship banking hard down a slick pink peristaltic tunnel, wet ribbed tissue walls glistening, splashes of green acid and glowing molecule clusters ahead", + "palette": "wet salmon pink, bile green, chrome silver" + }, + { + "id": "hardyards", + "title": "Hard Yards", + "current_cover": "hardyards", + "hero_subject": "a person hauling a rope tight on a shade sail in a suburban Australian backyard as a black hail supercell rolls over the gum trees, hailstones bouncing off a corrugated carport", + "palette": "storm grey blue, dry gum leaf green, rusted corrugated iron" + }, + { + "id": "sandoniette", + "title": "Sandoniette", + "current_cover": "sandoniette", + "hero_subject": "a scowling samurai in armour completing a katana slash through a flying sandwich, halved bread cheese egg and cutlet spinning apart mid air, a splash of sauce trailing the blade", + "palette": "washi cream, ink black, vermilion red" + }, + { + "id": "toastsim", + "title": "Toastsim", + "current_cover": "toastsim", + "hero_subject": "a chrome two slot toaster on a wooden kitchen bench at the moment of ejection, a slice of sourdough launched into the air trailing crumbs and butter, tomatoes garlic and a knife scattered around", + "palette": "golden crust brown, tomato red, warm kitchen cream" + }, + { + "id": "mollycool", + "title": "Molly Cool", + "current_cover": "mollycool", + "hero_subject": "a glowing white particle creature with two energy hands hauling two brightly coloured atoms together against a straining bond of light, a swirling storm of jittering coloured spheres all around in the dark", + "palette": "black, electric white, emission green and blue" + }, + { + "id": "paradramorama", + "title": "Paradramorama", + "current_cover": "__none__", + "hero_subject": "a formless crackling ball of electric blue static pouring itself into the front panel of a rack mounted synthesizer, the machine's knobs and meters glowing awake, other dark hardware watching from the studio gloom", + "palette": "near black navy, electric cyan, violet" + } +] \ No newline at end of file