not-tonight/tools/gen/run_batch.py
m3ultra 91a27b56a4 A contact sheet, and the luminance ceiling the pipeline never had
127 sprites had been generated and about eight had ever been looked at. The
pipeline will happily render the wrong object, confidently and well-lit, and no
assertion can tell — barrier and flightCase are both "a grey box with edges" as
far as any test goes. tools/gen/contact_sheet.py puts every shipped sprite on one
page at integer upscale on the venue's own floor colour. It immediately showed
about twenty props reading as pale blobs.

First instinct was that the vertex baker had lost the albedo. That was wrong:
sat=0 on ashUrn, marbleSink and pillar is honest, because stainless and marble
and concrete really are grey. The fault was BRIGHTNESS. Props that read sit at
median luminance 23-123 (patioHeater 23, arcade 45, poolTable 53, cdj 68); the
blobs were 150-190. The venue is painted at ~35 under a 50% black sheet and props
are lit by the room's own LIGHTS, so a prop arriving at 180 doesn't read as a
bright object — it reads as self-illuminated. Every prop that worked before
worked by accident of subject matter; the first pale subjects exposed the gap.

props_import --dim scales RGB until median opaque luminance is <=110. Only ever
darkens, so anything already in band passes through untouched. Fixed 48 sprites
with no regeneration at all — the 512px Blender renders were still in
art_incoming, which is exactly why that directory is kept.

Also: poster7 shipped with 29% of its pixels and poster9 with 42%, because the
near-black-to-alpha pass was eating the dark half of a dark poster. Posters are
rectangular plates on a wall, so 4-9 now keep their background and fill the frame.

And cf_flux --probe was reporting EXHAUSTED on an account with a full 10,000
neurons: the probe prompt was "a grey square", Cloudflare's safety filter refused
it as NSFW, and every non-429 fell through to SystemExit. A health check that
can't tell "refused" from "empty" will eventually call a healthy system dead.

Gate: lint clean, build clean, 821 tests passing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 10:19:32 +10:00

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", "--dim"],
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()