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