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>
291 lines
17 KiB
Python
291 lines
17 KiB
Python
#!/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.")
|