not-tonight/tools/gen/mb.py
m3ultra 4caa901df2 Four venues, four rooms: the ladder stops being one map with different numbers
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>
2026-07-28 19:51:54 +10:00

193 lines
7.4 KiB
Python

#!/usr/bin/env python3
"""mb.py — MODELBEAST client for the not-tonight asset pipeline.
python3 tools/gen/mb.py image <out.png> "<prompt>" [--seed 4300]
python3 tools/gen/mb.py mesh <in.png> <out.glb> [--operator trellis2_mlx]
python3 tools/gen/mb.py ops
The farm is free and unlimited on our own silicon, which makes it the fallback
when Cloudflare's daily neurons run out — and the ONLY route for meshes.
Contract notes that cost previous sessions time, encoded here so they cannot be
got wrong again:
- `asset_id` is a **TOP-LEVEL** job field, not a member of `params`. Nested, the
mesh operators fail with a flat "no input image" that reads like a bad upload.
- Job logs carry raw control characters, so every response is scrubbed before
`json.loads` — otherwise polling dies mid-batch on a malformed payload.
- Jobs do not back-link their output asset id. Retrieval is "newest asset whose
`parent_job` is my job id"; the newest-of-type shortcut RACES when two jobs
run at once, and on 2026-07-16 that handed one project's mesh to another.
- The asset list field is `name`, not `filename`.
- The guest token allows 4 active jobs, so the batch driver keeps its own gate.
"""
from __future__ import annotations
import json
import mimetypes
import sys
import time
import urllib.error
import urllib.request
import uuid
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import creds # noqa: E402
HOST = "http://100.89.131.57:8777"
MAX_ACTIVE = 4 # guest-token ceiling
POLL_SECONDS = 6
def _scrub(raw: bytes) -> object:
text = raw.decode("utf-8", "replace")
return json.loads("".join(c if c >= " " or c == "\t" else " " for c in text))
def _req(path: str, data: bytes | None = None, headers: dict[str, str] | None = None,
raw: bool = False, timeout: int = 180) -> object:
head = {"Authorization": f"Bearer {creds.get('MB_TOKEN')}"}
head.update(headers or {})
req = urllib.request.Request(HOST + path, data=data, headers=head)
try:
body = urllib.request.urlopen(req, timeout=timeout).read()
except urllib.error.HTTPError as exc:
detail = exc.read().decode(errors="ignore")[:400]
raise SystemExit(f"MODELBEAST HTTP {exc.code} on {path}: {detail}") from None
except urllib.error.URLError as exc:
raise SystemExit(f"MODELBEAST unreachable at {HOST}: {exc.reason}") from None
return body if raw else _scrub(body)
def ops() -> list[str]:
data = _req("/api/operators")
items = data if isinstance(data, list) else data.get("operators", data.get("items", []))
return sorted(o.get("name", str(o)) if isinstance(o, dict) else str(o) for o in items)
def upload(path: Path) -> str:
boundary = uuid.uuid4().hex
ctype = mimetypes.guess_type(str(path))[0] or "application/octet-stream"
body = (
f'--{boundary}\r\nContent-Disposition: form-data; name="file"; filename="{path.name}"\r\n'
f"Content-Type: {ctype}\r\n\r\n"
).encode() + path.read_bytes() + f"\r\n--{boundary}--\r\n".encode()
res = _req("/api/assets", data=body,
headers={"Content-Type": f"multipart/form-data; boundary={boundary}"})
if isinstance(res, dict):
return res.get("id") or (res.get("items") or [{}])[0].get("id", "")
return res[0]["id"] if res else ""
def submit(operator: str, params: dict | None = None, asset_id: str | None = None) -> str:
"""Submit a job. `asset_id` goes TOP-LEVEL — see the module docstring."""
payload: dict[str, object] = {"operator": operator, "params": params or {}}
if asset_id:
payload["asset_id"] = asset_id
res = _req("/api/jobs", data=json.dumps(payload).encode(),
headers={"Content-Type": "application/json"})
return res["id"]
def status(job_id: str) -> str:
return _req(f"/api/jobs/{job_id}").get("status", "unknown")
def wait(job_id: str, *, timeout: int = 2400, label: str = "") -> str:
t0 = time.time()
last = ""
while True:
state = status(job_id)
if state != last:
print(f"[mb] {label or job_id}: {state} ({int(time.time() - t0)}s)", flush=True)
last = state
if state in ("done", "error", "cancelled", "failed"):
return state
if time.time() - t0 > timeout:
return "timeout"
time.sleep(POLL_SECONDS)
def output_asset(job_id: str, suffix: str) -> str | None:
"""The asset THIS job produced. parent_job only — never newest-of-type."""
data = _req("/api/assets?limit=200")
items = data if isinstance(data, list) else data.get("items", [])
mine = [
a for a in items
if a.get("parent_job") == job_id
and str(a.get("name", a.get("filename", ""))).lower().endswith(suffix)
]
return mine[0]["id"] if mine else None
def fetch(asset_id: str, out: Path) -> Path:
out.parent.mkdir(parents=True, exist_ok=True)
out.write_bytes(_req(f"/api/assets/{asset_id}/file", raw=True, timeout=600))
return out
# ---- the two things the asset batch actually asks for ------------------------
def image(out: Path, prompt: str, *, seed: int = 0, steps: int = 4,
width: int = 1024, height: int = 1024, label: str = "") -> Path:
job = submit("flux_local", {
"prompt": prompt, "seed": seed, "steps": steps,
"width": width, "height": height, "model": "flux2-klein-4b",
})
state = wait(job, label=label or out.stem)
if state != "done":
raise RuntimeError(f"flux_local {state} for {out.stem}")
asset = output_asset(job, ".png")
if not asset:
raise RuntimeError(f"flux_local produced no png for {out.stem}")
return fetch(asset, out)
# Props ship at 16-48px. TRELLIS's defaults (1024_cascade + a 2048px metal PBR
# bake) are tuned for hero meshes you look at up close, and they cost 5-12
# minutes each on a queue that runs ONE gpu job at a time — call it four hours
# for a 37-prop tileset. The 512 tier with a vertex bake lands in ~90s, and at
# the size these are actually seen (docs/SPACES.md: "judge at ship size, not
# render size") the difference does not survive the downscale to 32x32, let
# alone the venue's own darkness sheet.
FAST_MESH = {
"pipeline_type": "512",
"baker": "vertex", # 1s bake, cleanest colours, no MR maps we'd use
"max_bake_faces": 200000,
"alpha_mode": "opaque", # props_import keys off Blender's alpha, not the mesh's
}
def mesh(src: Path, out: Path, *, operator: str = "trellis2_mlx", label: str = "",
params: dict | None = None) -> Path:
asset_id = upload(src)
job = submit(operator, {**FAST_MESH, **(params or {})}, asset_id=asset_id)
state = wait(job, timeout=3600, label=label or out.stem)
if state != "done":
raise RuntimeError(f"{operator} {state} for {out.stem}")
glb = output_asset(job, ".glb")
if not glb:
raise RuntimeError(f"{operator} produced no glb for {out.stem}")
return fetch(glb, out)
def main() -> None:
if len(sys.argv) < 2:
sys.exit(__doc__)
cmd = sys.argv[1]
if cmd == "ops":
print("\n".join(ops()))
elif cmd == "image":
seed = int(sys.argv[sys.argv.index("--seed") + 1]) if "--seed" in sys.argv else 0
print(image(Path(sys.argv[2]), sys.argv[3], seed=seed))
elif cmd == "mesh":
op = sys.argv[sys.argv.index("--operator") + 1] if "--operator" in sys.argv else "trellis2_mlx"
print(mesh(Path(sys.argv[2]), Path(sys.argv[3]), operator=op))
else:
sys.exit(__doc__)
if __name__ == "__main__":
main()