The venue ladder has always listed four venues in data/venues.ts, but they shared ONE floor map and differed only by difficulty knobs — so a promotion you survived a whole week for looked exactly like the room you had just left. - venueMap.ts grows a FloorLayout contract (map + props + lights + posts + probes + palette) and the newGrid() primitives every room is painted with. Voltage stays IN venueMap rather than moving to layouts/, so the new rooms can import primitives from it without closing an import cycle. - Three new rooms in src/scenes/floor/layouts/: The Royal (horseshoe public bar, pool room, pokies corner, trough + two cubicles, a beer garden that is plainly the nicest room in the pub, and a DJ "corner" that is a folding table because this pub never built a booth), Elevate (open-air rooftop — most of the grid is sky, the DJ is on an unwalled plinth, you arrive by lift), ROOM (concrete warehouse, pillars you path around, central booth, loading-dock smoking area, twelve lights in the whole venue). - Nothing about a room is a module singleton any more. FloorView, sweep and FloorDemoScene take the layout; the door picks its street plate by venue id. - FloorDemoScene's six hardcoded station spots (TAPS_SPOT, DECKS_SPOT, ...) were Voltage's tile coordinates. At four venues a hardcoded (47,3) puts the bar shift inside The Royal's pool room, so each layout now names its own stations and the scene reads them from there. - tests/floor/layoutInvariants.ts is an executable rulebook: perimeter holds, every walkable tile reachable from the entry, anchors on walkable ground, posts not sealed (a post on a sealed tile freezes the player for the night — that has shipped twice), probes reachable, props on-grid. Proved against Voltage BEFORE the new rooms were authored against it. Dev route #floor:<venueId> boots the floor straight into one room, because otherwise seeing ROOM means surviving three weeks of the ladder. Gate: lint clean, build clean, 821 tests passing (was 784). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
196 lines
7.6 KiB
Python
196 lines
7.6 KiB
Python
#!/usr/bin/env python3
|
|
"""run_batch.py — generate every sprite in assets.py, unattended.
|
|
|
|
python3 tools/gen/run_batch.py # everything still missing
|
|
python3 tools/gen/run_batch.py pokie jukebox # just these kinds
|
|
python3 tools/gen/run_batch.py --force # redo even if the png exists
|
|
python3 tools/gen/run_batch.py --flat-only # cheap pass, no mesh jobs
|
|
|
|
Resumable by design: a kind whose `public/props/<kind>.png` already exists is
|
|
skipped, so a run that dies at asset 30 of 47 costs 17 assets to finish, not 47.
|
|
Every intermediate (product shot, cutout, GLB) is kept in `art_incoming/` for
|
|
the same reason — re-angling a prop should never mean re-meshing it, which is
|
|
the [GLB] route's whole advantage (LANEHANDOVER, FABLE-SOLO-25).
|
|
|
|
Image tier order is Cloudflare FIRST — 10,000 free neurons a day, ~4s an image —
|
|
then MODELBEAST `flux_local`, which is slower but unmetered. The fallback is
|
|
automatic and one-way per run: once Cloudflare reports the daily allocation
|
|
spent, we stop asking it and go to the farm for the rest of the batch.
|
|
|
|
Heartbeat lands in ~/.jobs/not-tonight-assets.json per the fleet jobs
|
|
convention, so "where are we at" is answerable without reading the log.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import concurrent.futures as cf
|
|
import json
|
|
import subprocess
|
|
import sys
|
|
import threading
|
|
import time
|
|
import traceback
|
|
from pathlib import Path
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
import assets as manifest # noqa: E402
|
|
import cf_flux # noqa: E402
|
|
import mb # noqa: E402
|
|
|
|
REPO = Path(__file__).resolve().parent.parent.parent
|
|
INCOMING = REPO / "art_incoming"
|
|
PROPS = REPO / "public" / "props"
|
|
HEARTBEAT = Path.home() / ".jobs" / "not-tonight-assets.json"
|
|
|
|
# Flipped the first time Cloudflare says the day's neurons are gone. One-way:
|
|
# re-probing per asset would waste a call each time to learn the same thing.
|
|
_cf_spent = False
|
|
|
|
|
|
def beat(**fields: object) -> None:
|
|
HEARTBEAT.parent.mkdir(parents=True, exist_ok=True)
|
|
payload = {"job": "not-tonight-assets", "updated": time.strftime("%Y-%m-%d %H:%M:%S"), **fields}
|
|
HEARTBEAT.write_text(json.dumps(payload, indent=2) + "\n")
|
|
|
|
|
|
def out_png(kind: str) -> Path:
|
|
return PROPS / f"{kind.replace(':', '_')}.png"
|
|
|
|
|
|
def draw(a: manifest.Asset, dest: Path) -> str:
|
|
"""One image from the cheapest tier that still has budget. Returns the tier."""
|
|
global _cf_spent
|
|
w, h = a.gen
|
|
if not _cf_spent:
|
|
try:
|
|
# Workers AI schnell has no width/height knob — it returns square.
|
|
# Fine for props; the wide plates below need the farm regardless.
|
|
if w == h:
|
|
cf_flux.generate(dest, a.prompt, steps=6, seed=a.seed)
|
|
return "cloudflare"
|
|
except cf_flux.Exhausted:
|
|
_cf_spent = True
|
|
print("[gen] cloudflare free tier spent — rest of the batch on the farm", flush=True)
|
|
except Exception as exc: # network wobble: fall through to the farm
|
|
print(f"[gen] cloudflare failed ({exc.__class__.__name__}), using farm", flush=True)
|
|
mb.image(dest, a.prompt, seed=a.seed, steps=4, width=w, height=h, label=a.kind)
|
|
return "farm"
|
|
|
|
|
|
def do_flat(a: manifest.Asset) -> None:
|
|
raw = INCOMING / f"{a.kind.replace(':', '_')}_raw.png"
|
|
if not raw.exists():
|
|
draw(a, raw)
|
|
cmd = [sys.executable, str(REPO / "tools" / "props_import.py"), str(raw), a.kind]
|
|
if not a.quantise:
|
|
cmd.append("--no-quantise")
|
|
if a.keep_bg:
|
|
cmd.append("--keep-bg")
|
|
subprocess.run(cmd, check=True)
|
|
|
|
|
|
def do_mesh(a: manifest.Asset) -> None:
|
|
stem = a.kind.replace(":", "_")
|
|
shot = INCOMING / f"{stem}_shot.png"
|
|
cut = INCOMING / f"{stem}_cut.png"
|
|
glb = INCOMING / f"{stem}.glb"
|
|
|
|
if not shot.exists():
|
|
draw(a, shot)
|
|
if not cut.exists():
|
|
# TRELLIS reconstructs markedly better from a clean cutout than from an
|
|
# object sitting on a grey sweep — the operator's own docs say so, and
|
|
# it costs one cheap local job.
|
|
try:
|
|
asset_id = mb.upload(shot)
|
|
job = mb.submit("bg_remove_local", {"resolution": 1024}, asset_id=asset_id)
|
|
if mb.wait(job, label=f"{a.kind}:cut") == "done":
|
|
got = mb.output_asset(job, ".png")
|
|
if got:
|
|
mb.fetch(got, cut)
|
|
except Exception as exc:
|
|
print(f"[gen] {a.kind}: bg_remove failed ({exc}) — meshing the raw shot", flush=True)
|
|
src = cut if cut.exists() else shot
|
|
|
|
if not glb.exists():
|
|
mb.mesh(src, glb, operator="trellis2_mlx", label=a.kind)
|
|
|
|
# Blender ortho render at the house rake, then the normal sprite import.
|
|
subprocess.run(
|
|
[sys.executable, str(REPO / "tools" / "glb_to_sprite.py"), str(glb), a.kind,
|
|
"--rake", str(a.rake), "--px", "512"],
|
|
check=True,
|
|
)
|
|
|
|
|
|
def main() -> None:
|
|
raw_args = sys.argv[1:]
|
|
force = "--force" in raw_args
|
|
flat_only = "--flat-only" in raw_args
|
|
# Drop flags AND the value that follows a value-taking flag — otherwise
|
|
# `--workers 3` leaves a bare "3" in the positional list and the batch
|
|
# filters down to the kind named "3", i.e. nothing.
|
|
argv: list[str] = []
|
|
skip_next = False
|
|
for i, tok in enumerate(raw_args):
|
|
if skip_next:
|
|
skip_next = False
|
|
continue
|
|
if tok in ("--workers",):
|
|
skip_next = True
|
|
continue
|
|
if tok.startswith("--"):
|
|
continue
|
|
argv.append(tok)
|
|
|
|
todo = [a for a in manifest.ALL if not argv or a.kind in argv]
|
|
if flat_only:
|
|
todo = [a for a in todo if a.route == "flat"]
|
|
if not force:
|
|
todo = [a for a in todo if not out_png(a.kind).exists()]
|
|
|
|
workers = int(sys.argv[sys.argv.index("--workers") + 1]) if "--workers" in sys.argv else 3
|
|
INCOMING.mkdir(exist_ok=True)
|
|
done: list[str] = []
|
|
failed: list[str] = []
|
|
print(f"[gen] {len(todo)} assets to make, {workers} at a time", flush=True)
|
|
beat(status="running", total=len(todo), done=0, failed=0)
|
|
|
|
# The farm load-balances gpu jobs across nodes, so a strictly sequential
|
|
# batch leaves most of the cluster idle — 37 meshes at ~7 min each is over
|
|
# four hours in a queue of one. Three in flight stays under the guest
|
|
# token's 4-active ceiling and keeps every node fed. Safe to overlap
|
|
# because output retrieval matches on `parent_job`, never newest-of-type
|
|
# (the 2026-07-16 race that handed one project's mesh to another).
|
|
lock = threading.Lock()
|
|
|
|
def run_one(a: manifest.Asset) -> None:
|
|
t0 = time.time()
|
|
try:
|
|
(do_flat if a.route == "flat" else do_mesh)(a)
|
|
with lock:
|
|
done.append(a.kind)
|
|
print(f"[gen] {a.kind} OK in {int(time.time() - t0)}s", flush=True)
|
|
except Exception as exc:
|
|
with lock:
|
|
failed.append(a.kind)
|
|
print(f"[gen] {a.kind} FAILED: {exc}", flush=True)
|
|
traceback.print_exc()
|
|
with lock:
|
|
beat(status="running", total=len(todo), done=len(done), failed=len(failed),
|
|
last=a.kind, still_to_go=len(todo) - len(done) - len(failed))
|
|
|
|
with cf.ThreadPoolExecutor(max_workers=workers) as pool:
|
|
list(pool.map(run_one, todo))
|
|
|
|
beat(status="finished", total=len(todo), done=len(done), failed=len(failed),
|
|
failed_kinds=failed)
|
|
print(f"\n[gen] DONE {len(done)}/{len(todo)}"
|
|
+ (f" — FAILED: {', '.join(failed)}" if failed else ""))
|
|
# A partial batch is a normal outcome; the venue layouts fall back to
|
|
# placeholders for anything missing, so this must not read as total failure.
|
|
sys.exit(0 if not failed else 2)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|