PROCITY/pipeline/gen_skins.py
m3ultra 30b86a9ef7 Lane E R39 (2/n): THE ARCADE, MEASURED THEN DRESSED — 3 skins live ($0, on-device, green), and the instance count taken BEFORE generating
MEASURED FIRST (pipeline/arcade_measure.mjs, over 5 seeds, off Lane A's own generatePlan):
  the arcade is ONE 42.00 m x 5 m edge, 2 blocks, 16-17 shops (17 at the default seed), frontage
  3.00-4.94 m (mean 3.87-4.05), depth 5.1-8.0 m, 4-11 of them two-storey. Walkable 5.00 m between
  lot faces. AWNING_DEPTH 2.2 from each face means the two slabs already meet 0.60 m apart over the
  centreline — the arcade IS roofed, and the roof is an accident.
  ⚠ AND THE POSTS ARE IN THE WRONG PLACE: buildings.js:643 puts them 2.00 m out from each lot face,
  i.e. +/-0.50 m from the centreline — two rows of posts 1.00 m apart down a 5.00 m lane. No
  collider, so it walks; it just reads as a colonnade planted in its own doorway. Lane B ask filed.

SHIPPED (all riding existing shared materials or stating their draw):
  facade-shuttered   768x595 (the 1.29:1 arcade lot aspect, not the pool's 1.78:1) — the dead
                     tenant. +0 draws, +0 tris, 1 of the 14 FREE facade-atlas slots (22 of 36 used).
  ground-arcade-floor 512^2 terrazzo — +1 draw TOWN-WIDE, +0 tris (the 504 m2 lane quad moves out
                     of footGeos). use:'arcade-floor' names a slot that does not exist yet, on purpose.
  ground-arcade-roof  512^2 pressed-metal + wired glass — +1 draw in the 1-2 arcade chunks, +2 tris.

pipeline/seamless_tile.py: cut a regular-pattern skin to a whole number of its own periods instead
of feathering the joint. It has a guard, because on this pair the naive version made things WORSE:
the roof's x seam went 26.7 -> 8.3 (kept), the diffusion-drawn terrazzo floor is not periodic enough
to cut on (autocorr r=0.26) and 27.9 -> 31.9 was rejected and passed through. Recorded, not hidden.

manifest: facades 48->49, grounds 14->16. validate_manifest.py 0 errors, 0 warnings.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 20:46:43 +10:00

345 lines
21 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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-<slug>.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.")