#!/usr/bin/env python3 """PROCITY facade-skin gap-fill — ported from thriftgod/gen_assets.py (same engine, same bible). Two backends, one prompt pack. The STYLE prefix and the blank-signboard FACADE template are lifted verbatim from thriftgod so new skins are indistinguishable from the 25 originals (Lane B overlays the shop name onto the blank sign). --local (DEFAULT CHOICE) MODELBEAST flux_local → FLUX.2-klein-4B on the M3 Ultra's MPS GPU. FREE, on-device, Apache/ungated (weights already cached). ~7s/img, one job at a time. (cloud) OpenRouter `google/gemini-3.1-flash-image`, ~$0.004/img. Needs OPENROUTER_KEY. python3 pipeline/gen_skins.py --local --dry-run # list the pack, no spend, no GPU python3 pipeline/gen_skins.py --local # generate all on-device → pipeline/.genraw/ python3 pipeline/gen_skins.py --local --only res # just the residential family python3 pipeline/gen_skins.py --harvest # copy approved .genraw/* → web/assets/gen/facade-*.jpg Everything here is a Step-4 GAP — the game already ships 25 facades and runs facade-free, so none of this blocks anyone. `--local` costs only electricity; cloud is the fallback if MODELBEAST is down. Review `.genraw/` by eye and delete rejects before `--harvest`. """ import base64, concurrent.futures, json, os, re, sys, time, urllib.request, shutil ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) RAW = os.path.join(ROOT, "pipeline", ".genraw") GEN = os.path.join(ROOT, "web", "assets", "gen") os.makedirs(RAW, exist_ok=True) MODEL = os.environ.get("TG_ART_MODEL", "google/gemini-3.1-flash-image") KEY = os.environ.get("OPENROUTER_KEY") if not KEY: # house convention — key never committed kf = os.path.expanduser("~/Documents/dealgod/lore/keys.env") if os.path.exists(kf): for line in open(kf): if line.startswith("OPENROUTER_KEY="): KEY = line.strip().split("=", 1)[1] # ── the style bible (verbatim from thriftgod gen_assets.py:20) ────────────────────────── STYLE = ("1990s Australian op-shop aesthetic, slightly faded and worn, warm fluorescent " "lighting, kitsch but loving, no text unless specified, no watermarks. ") # ── the blank-signboard shop-facade template (verbatim, gen_assets.py:69-73) ──────────── FACADE = ("Straight-on photograph of a small single-storey Australian suburban shop facade, " "{v}, blank empty signboard above the entrance, glass shop window and a door, " "no text or lettering anywhere, overcast daylight, flat front elevation filling " "the entire frame edge to edge, nothing else visible") # ── wide anchor + windowless side-wall templates (same discipline, different geometry) ─── WIDE = ("Straight-on photograph of a WIDE two-storey Australian department-store anchor " "building facade, {v}, a long blank signboard band across the top, rows of ground-floor " "display windows and a central entrance, no text or lettering anywhere, overcast daylight, " "flat front elevation filling the entire frame edge to edge, nothing else visible") SIDE = ("Straight-on photograph of the plain windowless SIDE wall of an Australian shop building, " "{v}, no shopfront, no door, a faded blank rectangular ghost-sign area, no text or lettering " "anywhere, overcast daylight, flat elevation filling the entire frame edge to edge") HOUSE = ("Straight-on photograph of a modest Australian suburban house front, {v}, front path and " "a bit of fence, no text anywhere, overcast daylight, flat front elevation filling the " "entire frame edge to edge, nothing else visible") # ── round-12 gig posters: blank name-band like the blank-signboard facades (B/C overprint the band # name). Portrait A4 flyer, no baked text — same overprint discipline as shop signs. ────────────── POSTER = ("A 1990s Australian pub-rock gig poster flyer, {v}, a large BLANK empty rectangular banner " "panel across the top reserved for the band name, no text no lettering no words anywhere, " "portrait A4 flyer filling the entire frame edge to edge, high contrast, photocopied paper texture") POSTERS = { "poster-xerox": "black-and-white photocopied punk flyer, high-contrast halftone silhouette of a band, torn paper edges and staple marks", "poster-screenprint":"bold two-colour screenprinted poster, crimson and black, a graphic electric-guitar motif and sound-wave bars", "poster-grunge": "grungy spray-paint stencil flyer, distressed mustard-yellow and black, splatter and photocopier streaks", "poster-retro": "colourful retro flyer, orange and teal, geometric 70s-revival shapes and a starburst", } # ── round-38 (v8 wave 1.2): GROUND palette. The 23 real towns differ only in street layout and # shop names; ground.js merges each surface class into ONE town-wide mesh, so a palette swap is # +0 draws / +0 tris. Grounds get their OWN style prefix: the house STYLE bible says "warm # fluorescent lighting", which is an INTERIOR cue and wrong for an outdoor overhead surface. # Square (1024²) by deliberate choice — grounds tile through RepeatWrapping, and the two djsim # grounds (the ones that ship 512×512) are the correct precedent; the eight flux-era grounds are # 512×279 and stretch under a square UV tile. See LANE_E_NOTES R38. GROUND_STYLE = ("Photographic ground texture, flat overhead view, neutral overcast daylight, " "slightly faded and worn, no watermarks, no text. ") GROUND = ("Straight-down overhead photograph of {v}, camera pointing vertically at the ground, " "completely flat-on with no perspective and no horizon, evenly lit with no cast shadows, " "no objects, no people, no plants sticking up, a uniform repeating surface texture " "filling the entire frame edge to edge") GROUNDS = { # slug (→ web/assets/gen/ground-.jpg) : subject "grass-dry": "dry Australian summer lawn, straw-blond and khaki, sun-bleached and " "patchy, bare dusty soil showing through in places", "grass-temperate": "a cool-temperate southern lawn, dense deep-green damp grass with a few " "clover leaves and moss, evenly cropped", # R38: the first roll of this one read as red PAINT SPATTER — discrete bright blobs, not dust. # Rewritten to say "even film" and "no bright spots" explicitly; the second roll is what ships. "bitumen-reddust": "worn grey bitumen road surface under an even film of fine red outback " "dust, the dust lying evenly across the whole surface and drifted into " "the cracks, dusty and matte, no bright spots, no paint, no road markings", "coastal-sand": "pale coastal sand mixed with crushed shell grit, fine dry wind-rippled " "beach sand", } # ── round-39 (v9 39.4): THE ARCADE'S SKINS ────────────────────────────────────────────────────── # The synthetic's arcade is a 42.00 m covered lane, 16-17 shops, 2 blocks, already awning-roofed by # accident (buildings.js AWNING_DEPTH 2.2 from each lot face, lane half-width 2.5 ⇒ the two slabs # meet 0.6 m apart over the centreline) and dressed as NOTHING. These three are the cheapest three # surfaces in it, in ascending draw cost: # facade-shuttered +0 draws, +0 tris — one of the 14 FREE facade-atlas slots (the synthetic uses # 22 of 36; skins.js:facadeAtlasUV warns and reuses a slot past 36) # ground-arcade-tile +1 draw TOWN-WIDE (a 6th merged ground class; ground.js builds once, not per # chunk), +0 tris — the lane quad moves out of footGeos into its own class # tex-arcade-roof +1 draw in the 1-2 chunks that hold arcade lots, +2 tris (one 42x5 m quad) # Aspect is not decoration: skins.js paints every facade into a SQUARE 341 px atlas slot, and arcade # lots are 3.87 x 3.0 m ⇒ ~1.29:1, not the 1.78:1 the main-street pool is shot at. `facade-shuttered` # is generated 4:3 so it lands on an arcade lot almost undistorted (1024x768 -> square -> 1.29:1 quad # ≈ 1.03 net). Recorded, not retro-fitted to the other 48. CEILING = ("Straight-up overhead photograph of {v}, camera pointing vertically at the ceiling, " "completely flat-on with no perspective, evenly lit with no glare, no objects hanging " "down, a uniform repeating surface texture filling the entire frame edge to edge") SHUT = ("Straight-on photograph of a CLOSED and vacant small Australian shop front, {v}, no glass, " "no people, no text or lettering anywhere, overcast daylight, flat front elevation filling " "the entire frame edge to edge, nothing else visible") ARCADE = { # slug : (template, subject, width, height, style) "ground-arcade-tile": (GROUND, "the tiled floor of a 1970s Australian shopping arcade, small terrazzo and mosaic floor " "tiles in beige, terracotta and cream laid in a simple repeating geometric pattern, " "grouted joints, polished and worn smooth by foot traffic", 1024, 1024, "GROUND_STYLE"), "tex-arcade-roof": (CEILING, "the underside of a covered shopping arcade roof, cream pressed-metal ceiling panels in a " "repeating square pattern between painted steel beams, with panels of ribbed wired glass " "letting grey daylight through", 1024, 1024, "GROUND_STYLE"), "facade-shuttered": (SHUT, "a corrugated steel roller shutter pulled all the way down over the whole shopfront, faded " "cream paint with rust streaks and scuff marks along the bottom, a blank empty signboard " "above it, a scuffed tiled stall-riser at the base", 1024, 768, None), } PROMPTS = { # ── shop-type facades that the registry is thin on ────────────────────────────────── **{f"facade-{k}": FACADE.format(v=v) for k, v in { "toy-brights": "cheerful toy shop, primary-colour painted render, big low display window", "toy-lolly": "old-fashioned lolly and toy shop, pastel candy-stripe painted front", "book-barn": "a crammed secondhand book barn, tall dusty windows stacked with books, dark timber", "book-scholarly": "a genteel antiquarian bookshop, dark green paint, gold-line window, brass fittings", "video-neon": "a 1990s video rental store, glossy red and blue plastic signage band, poster light-boxes (all blank)", "video-suburban": "a suburban strip video shop, cream brick, glass double doors, returns chute", "milkbar-corner": "a classic corner milk bar, cream and green wall tiles, wide wrap-around window, ice-cream awning", "milkbar-fibro": "a little fibro milk bar with a fly-screen door, a bench out front, faded soft-drink colours", "milkbar-deco": "a streamline-deco milk bar, curved rendered corner, chrome trim, pastel mint paint", "arcade-entry": "a covered pedestrian shopping arcade entrance, tiled archway, terrazzo floor, patterned ceiling", "warehouse-roller": "an industrial discount warehouse shop, red brick with a big corrugated steel roller door, blank banner", "warehouse-tin": "a corrugated-tin clearance shed shopfront, roller door half up, concrete apron", "market-fruit": "an open fruit-and-veg shopfront, empty produce crates and astro-turf steps, striped awning", "market-edge": "a row of humble market-square edge shopfronts under one shared posted verandah, mixed paint colours", }.items()}, # ── the WIDE dept-store anchor (needs a wide UV plane in Lane B) ───────────────────── **{f"facade-{k}": WIDE.format(v=v) for k, v in { "dept-anchor": "mid-century mustard-render and glass, cantilevered awning, grand central doors", "dept-emporium": "a federation-era brick emporium, arched upper windows, ornate parapet", }.items()}, # ── corner-lot second faces: the `-side` convention (see manifest conventions) ────── **{f"facade-{k}-side": SIDE.format(v=v) for k, v in { "brick": "painted red brick, a downpipe, one small high window", "weatherboard": "cream weatherboard boards, a rain downpipe", "render": "weathered pastel painted render with hairline cracks", }.items()}, # ── residential terrace/cottage fronts (lots with use:'house') ────────────────────── **{f"facade-{k}": HOUSE.format(v=v) for k, v in { "res-terrace": "a single-fronted Victorian terrace, iron-lace verandah, dark brick, tiny front garden", "res-weatherboard": "a modest weatherboard cottage, bull-nose verandah, picket fence", "res-fibro": "a 1950s cream fibro house, hip roof, small steel-framed windows", "res-brickveneer": "a 1970s brown brick-veneer house, aluminium sliding windows, carport edge", }.items()}, } def gen(slug, prompt): body = json.dumps({"model": MODEL, "modalities": ["image", "text"], "messages": [{"role": "user", "content": STYLE + prompt}]}).encode() req = urllib.request.Request("https://openrouter.ai/api/v1/chat/completions", data=body, headers={"Authorization": f"Bearer {KEY}", "Content-Type": "application/json"}) with urllib.request.urlopen(req, timeout=120) as r: d = json.load(r) msg = d["choices"][0]["message"] imgs = msg.get("images") or [] url = imgs[0]["image_url"]["url"] if imgs else "" m = re.match(r"data:image/(\w+);base64,(.+)", url, re.S) if not m: raise RuntimeError(f"no image in response: {str(msg)[:120]}") fn = os.path.join(RAW, f"{slug}.{m.group(1)}") with open(fn, "wb") as f: f.write(base64.b64decode(m.group(2))) return fn # ── LOCAL backend: MODELBEAST flux_local (mflux / FLUX.2-klein-4B, MPS) — FREE, ~7s/img, ungated ── import subprocess, shutil as _sh MB = os.environ.get("MB_HOME", os.path.expanduser("~/Documents/MODELBEAST")) FLUX_PY = os.path.join(MB, "venvs/mflux/bin/python") FLUX_RUN = os.path.join(MB, "server/operators/flux_local/run.py") def local_available(): return os.path.exists(FLUX_PY) and os.path.exists(FLUX_RUN) def gen_local(slug, prompt, w=1024, h=576, steps=4, seed=7, style=None): outdir = os.path.join(RAW, "_job_" + slug) os.makedirs(outdir, exist_ok=True) params = json.dumps({"prompt": (STYLE if style is None else style) + prompt, "model": "flux2-klein-4b", "steps": steps, "width": w, "height": h, "seed": seed, "quantize": "8", "guidance": 3.5}) r = subprocess.run([FLUX_PY, FLUX_RUN, "--outdir", outdir, "--params", params], capture_output=True, text=True, timeout=300) pngs = [p for p in os.listdir(outdir) if p.endswith(".png")] if not pngs: raise RuntimeError((r.stderr or r.stdout or "no png")[-160:]) fn = os.path.join(RAW, f"{slug}.png") _sh.move(os.path.join(outdir, pngs[0]), fn) _sh.rmtree(outdir, ignore_errors=True) return fn def have(slug): return any(os.path.exists(os.path.join(RAW, f"{slug}.{e}")) for e in ("png", "jpeg", "jpg", "webp")) LOCAL = False # set by --local: use MODELBEAST flux_local instead of paid OpenRouter def gen_one(item): slug, prompt = item for attempt in (1, 2): try: fn = gen_local(slug, prompt) if LOCAL else gen(slug, prompt) return slug, os.path.getsize(fn) // 1024, None except Exception as e: if attempt == 2: return slug, 0, str(e)[:90] time.sleep(3) def harvest(): """Copy reviewed .genraw/*.{png,jpeg} into web/assets/gen/ as *.jpg. Manual review first — only files you keep in .genraw/ get harvested. Uses sips if available for real JPEG.""" n = 0 for f in sorted(os.listdir(RAW)): slug, ext = os.path.splitext(f) if ext.lower() not in (".png", ".jpeg", ".jpg", ".webp"): continue dst = os.path.join(GEN, f"{slug}.jpg") src = os.path.join(RAW, f) if ext.lower() in (".jpg", ".jpeg"): shutil.copyfile(src, dst) else: if os.system(f"sips -s format jpeg {src!r} --out {dst!r} >/dev/null 2>&1") != 0: shutil.copyfile(src, dst) # fallback: copy as-is (rename) n += 1 print(f" harvested {slug}.jpg") print(f"harvested {n} → {GEN} (re-run build_manifest.py to catalogue them)") if __name__ == "__main__": if "--harvest" in sys.argv: harvest(); sys.exit(0) if "--arcade" in sys.argv: # round-39 arcade kit (local only) — see ARCADE above todo = {s: v for s, v in ARCADE.items() if not have(s)} print(f"{len(todo)} arcade skins (MODELBEAST flux2-klein-4b, local·free)") if "--dry-run" in sys.argv: for s in sorted(todo): print(f" {s} {todo[s][2]}x{todo[s][3]}") sys.exit(0) if not local_available(): print(f"ERROR: MODELBEAST flux_local missing ({FLUX_RUN})."); sys.exit(2) for i, (slug, (tpl, subj, w, h, sty)) in enumerate(sorted(todo.items()), 1): try: fn = gen_local(slug, tpl.format(v=subj), w=w, h=h, seed=39, style=GROUND_STYLE if sty == "GROUND_STYLE" else None) print(f"[{i}/{len(todo)}] {slug} {w}x{h} ({os.path.getsize(fn)//1024}KB)") except Exception as e: print(f"[{i}/{len(todo)}] {slug} FAILED: {str(e)[:90]}") print("done. Review .genraw/, then --harvest → web/assets/gen/.") sys.exit(0) if "--grounds" in sys.argv: # round-38 ground palette (square tile, local only) todo = {f"ground-{s}": GROUND.format(v=v) for s, v in GROUNDS.items() if not have(f"ground-{s}")} print(f"{len(todo)} ground skins (MODELBEAST flux2-klein-4b, local·free, 1024² tile)") if "--dry-run" in sys.argv: for s in sorted(todo): print(f" {s}") sys.exit(0) if not local_available(): print(f"ERROR: MODELBEAST flux_local missing ({FLUX_RUN})."); sys.exit(2) for i, (slug, prompt) in enumerate(sorted(todo.items()), 1): try: fn = gen_local(slug, prompt, w=1024, h=1024, seed=38, style=GROUND_STYLE) print(f"[{i}/{len(todo)}] {slug} ({os.path.getsize(fn)//1024}KB)") except Exception as e: print(f"[{i}/{len(todo)}] {slug} FAILED: {str(e)[:90]}") print("done. Review .genraw/ground-*.png, then --harvest → web/assets/gen/ground-*.jpg.") sys.exit(0) if "--posters" in sys.argv: # round-12 gig posters (portrait, local only) todo = {s: POSTER.format(v=v) for s, v in POSTERS.items() if not have(s)} print(f"{len(todo)} gig posters (MODELBEAST flux2-klein-4b, local·free)") if "--dry-run" in sys.argv: for s in sorted(todo): print(f" {s}") sys.exit(0) if not local_available(): print(f"ERROR: MODELBEAST flux_local missing ({FLUX_RUN})."); sys.exit(2) for i, (slug, prompt) in enumerate(sorted(todo.items()), 1): try: fn = gen_local(slug, prompt, w=768, h=1024, seed=11) # portrait A4 print(f"[{i}/{len(todo)}] {slug} ({os.path.getsize(fn)//1024}KB)") except Exception as e: print(f"[{i}/{len(todo)}] {slug} FAILED: {str(e)[:90]}") print("done. Review .genraw/poster-*.png, then --harvest → web/assets/gen/poster-*.jpg.") sys.exit(0) LOCAL = "--local" in sys.argv # MODELBEAST flux_local: on-device, free, ungated only = sys.argv[sys.argv.index("--only") + 1] if "--only" in sys.argv else "" # local = one GPU job at a time (MPS won't share cleanly); cloud fans out default_workers = 1 if LOCAL else 4 workers = int(sys.argv[sys.argv.index("--workers") + 1]) if "--workers" in sys.argv else default_workers todo = {s: p for s, p in PROMPTS.items() if (not only or only in s) and not have(s)} backend = "MODELBEAST flux2-klein-4b, local·free" if LOCAL else MODEL cost = 0.0 if LOCAL else len(todo) * 0.004 print(f"{len(todo)} skins to generate ({backend}) ≈ ${cost:.2f}" + (f" filter:*{only}*" if only else "")) if "--dry-run" in sys.argv: for s in sorted(todo): print(f" {s}") sys.exit(0) if LOCAL: if not local_available(): print(f"ERROR: MODELBEAST flux_local missing ({FLUX_RUN}).\n" "Set MB_HOME or check ~/Documents/MODELBEAST/server/operators/flux_local/.") sys.exit(2) elif not KEY: print("ERROR: OPENROUTER_KEY not set and no ~/Documents/dealgod/lore/keys.env.\n" "Use --local (MODELBEAST, free) or route 2: pipeline/SKIN_PROMPTS.md.") sys.exit(2) fails = 0 with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as ex: for i, (slug, kb, err) in enumerate(ex.map(gen_one, todo.items()), 1): if err: fails += 1; print(f"[{i}/{len(todo)}] {slug} FAILED: {err}") else: print(f"[{i}/{len(todo)}] {slug} ({kb}KB)") print(f"done, {fails} failures. Review .genraw/, delete rejects, then --harvest.")