PROCITY/pipeline/gen_skins.py
m3ultra 1568c9b62b Lane E R38 (v8 wave 1): the asset wave — trellis2_mlx swap verified 1.92x, six sounds, four grounds, and the thin-structure answer
A. PIPELINE FIX: gen_props.py trellis_mac -> trellis2_mlx, contract read from its
   manifest.json. The two operators are NOT drop-in on output — trellis2_mlx writes
   BOTH candidate.glb (raw) and candidate_pbr.glb (baked), so the old
   sorted(rglob('*.glb'))[0] would have silently shipped untextured meshes. Added
   _pick_glb() (result.json first) + the bg_remove_local cutout stage, fail-soft.
   MEASURED: median 128.0s vs trellis_mac's 245.4s p50 = 1.92x. 5/5 first attempt,
   0 retries against the 4-5% budget. One 489s tail outlier, no error — named.

B. AUDIO (1.1) — six PACK entries, +0.86MB, ZERO draws/tris, all originalby
   construction: dog-bark-fence, mower-distant, radio-through-window, sprinkler,
   roller-door, magpie-carol. Seeded via crc32(name); beds fold seamlessly
   (wrap-jump <=0.18x internal step). Verified by spectrogram + band-energy, and
   one defect found by LOOKING: the sprinkler's spray masked its own ticks and read
   as a hose — rebalanced and re-rendered. Distance is spent in the SPECTRUM, not
   the level, because encode()'s loudnorm undoes a quiet render.

C. GROUNDS (1.2) — gen_skins.py had NO ground template; added one with its own
   outdoor style prefix (the house STYLE says "warm fluorescent", an interior cue).
   4 new 512^2 square tiles. Review's 5.7s is 9.7s here — same per-pixel, mine are
   1.78x the pixels. Found: gravel/reddust are selected by nothing partly because
   their `use` values name no slot ground.js:177 has.

D. MESHES — 3 shipped (magpie 894 tris, paling_fence 1121, water_tank 500), all
   GLB-law clean. 2 FAILED and were DELETED: hills_hoist + tv_aerial. Thin wire
   returns as thousands of disconnected fragments, so collapse floors at 250k/89k
   AND a remesh coarse enough for 600 tris erases the pole. Lane B's boxMats
   primitives cost +0 draws and are strictly better. The magpie's geometry passes
   but TRELLIS crushed its white nape/wing to black — traced to the operator's bake
   (the cutout and our decimation are both exonerated); flagged, not hidden.

E. REUSE — all 9 idle assets re-measured; the review's "one line each" holds for
   only 5. furniture.js's extractGLB keeps only the FIRST material, so park_bench/
   coffee_table/box_crate render in material #1 and bench_wood (0 materials) falls
   to grey. Nothing was regenerated that already existed.

The 3 GLBs are staged, NOT published: a depot push is outward-facing and had no
in-session authorization. Held at _r38_results.PENDING_PUBLISH.json; publish +
rename + rebuild lands them. Gate green: validate_manifest 0 errors / 0 warnings.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 18:30:33 +10:00

291 lines
17 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",
}
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 "--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.")