SCENEGOD/scenegod/server.py
type-two 22c8a9a324 M12: look, render button, save integrity — 3 workflow rounds, 22 agents
FEATURES (from a 5-lens audit: UX friction, feature gaps, bug hunt, visual
quality, discoverability):
- ACES filmic tone mapping + per-preset exposure. grammar.js runs physical sun
  intensities of 1.2-3.4; with NoToneMapping every lit face clipped to white and
  a warm key rendered neutral. Single biggest look win in the app.
- Real shadows: PCFSoft, a shadow box fitted to the scene's bounds, and plates
  that no longer double as the shadow catcher (dedicated ShadowMaterial).
- A Render button in the scene bar. finalRender had ZERO callers - the app's
  deliverable was console-only.
- Save actually saves the stage you built (gizmo moves, inspector edits), and
  Save/Load stop lying about what happened.
- 2x supersample + lanczos downscale; correct bt709 tagging and faststart.
- Frame guide, camera-from-view, dock counts, delete confirmations.

DEFECTS FOUND BY ADVERSARIAL REVIEW AND FIXED (all reproduced first):
- BLOCKER: save wrote the playhead pose over the authored rest transform of
  keyed entities, so every save produced a different file.
- renders were converted with the bt601 matrix while tagged bt709.
- deleting an unkeyed camera stranded its cut -> scene 422s forever.
- corner plates showed one photo at two exposures across the fold.
- exposure was advertised as keyable but nothing keyed it, so a second lighting
  preset permanently poisoned the first.
- the audio pre-flight could never fire (FastAPI does not route HEAD -> 405).
- the two render buttons were not mutually exclusive.
- frame guide letterboxed a narrow viewport that the render does not crop.

ORCHESTRATOR (round 3): dangling camera cuts are pruned in Timeline.toJSON and
skipped by cutCameraAt, rather than trying to keep every undo history clean -
deleting a camera spans Stage (no undo) and Timeline (undo), so any older entry
can resurrect a cut for a dead camera. Verified against the real validator.
Also replaced a VACUOUS test that drained a stack whose only entry was a seeded
no-op; timeline_test.mjs now replays both real histories and discriminates.

Verified: 4 JS suites + 23 server groups + render client green; blocker repro
now preserves the authored rest at every playhead position; save round-trips
200 with zero dangling cuts; zero console errors.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 22:41:17 +10:00

1451 lines
66 KiB
Python

"""SCENEGOD server — page + assets + scenes + render pipeline.
Single-file FastAPI app (MESHGOD convention). A plain folder tree is the asset
database (no index file to drift): assets/{characters,animations,props,
backdrops,audio}/<pack>/name.ext. Files sharing dir+stem = one entry, many
formats. See PLAN.md §4.3 for the HTTP contract.
uvicorn scenegod.server:app --port 8020 # binds 127.0.0.1
"""
import array
import base64
import json
import math
import operator
import os
import re
import shutil
import subprocess
import threading
import time
import urllib.error
import urllib.request
import uuid
from pathlib import Path
from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
ROOT = Path(__file__).resolve().parent.parent
WEB = Path(__file__).resolve().parent / "web" # scenegod/web (PLAN §2)
ASSETS = Path(os.environ.get("SCENEGOD_ASSETS", ROOT / "assets")).resolve()
SCENES = Path(os.environ.get("SCENEGOD_SCENES", ROOT / "scenes")).resolve()
RENDERS = Path(os.environ.get("SCENEGOD_RENDERS", ROOT / "renders")).resolve()
TEMPLATES = Path(os.environ.get("SCENEGOD_TEMPLATES", ROOT / "templates")).resolve()
SEQUENCES = Path(os.environ.get("SCENEGOD_SEQUENCES", ROOT / "sequences")).resolve()
FILMS = Path(os.environ.get("SCENEGOD_FILMS", ROOT / "films")).resolve()
MODEL_EXTS = {"glb", "gltf", "fbx", "obj", "bvh"}
IMG_EXTS = {"jpg", "jpeg", "png", "webp"}
AUDIO_EXTS = {"wav", "mp3"}
VIDEO_EXTS = {"mp4", "webm", "mov"} # M6: Flow video plates
ASSET_EXTS = MODEL_EXTS | IMG_EXTS | AUDIO_EXTS | VIDEO_EXTS
# primary ext per category (others sharing a stem are thumbnails)
CATEGORIES = {"characters": MODEL_EXTS, "animations": MODEL_EXTS, "props": MODEL_EXTS,
"backdrops": IMG_EXTS | VIDEO_EXTS, "audio": AUDIO_EXTS}
MEDIA_TYPES = {
"glb": "model/gltf-binary", "gltf": "model/gltf+json", "fbx": "application/octet-stream",
"obj": "text/plain", "bvh": "text/plain", "jpg": "image/jpeg", "jpeg": "image/jpeg",
"png": "image/png", "webp": "image/webp", "wav": "audio/wav", "mp3": "audio/mpeg",
"mp4": "video/mp4", "webm": "video/webm", "mov": "video/quicktime",
}
SCENES.mkdir(parents=True, exist_ok=True)
RENDERS.mkdir(parents=True, exist_ok=True)
SEQUENCES.mkdir(parents=True, exist_ok=True)
FILMS.mkdir(parents=True, exist_ok=True)
HAVE_FFMPEG = shutil.which("ffmpeg") is not None
HAVE_RHUBARB = shutil.which("rhubarb") is not None
# MODELBEAST proxy (M4) — off unless SCENEGOD_MB=1. Token read from disk at
# request time, never logged. Contract mirrors MESHGOD meshgod/ops.py.
MB_HOST = os.environ.get("MB_HOST", "http://100.89.131.57:8777")
MB_ENV = Path(os.environ.get("MB_ENV", "~/Documents/backnforth/.env")).expanduser()
def _mb_token() -> str:
if os.environ.get("MB_TOKEN"):
return os.environ["MB_TOKEN"]
try:
for line in MB_ENV.read_text().splitlines():
if line.startswith("MB_TOKEN"):
return line.split("=", 1)[1].strip().strip('"').strip("'")
except OSError:
pass
raise HTTPException(503, f"no MB token (set MB_TOKEN or provide {MB_ENV})")
def reap_dirs(root: Path, max_age_s=48 * 3600):
"""Delete job dirs under root older than max_age_s. Called at boot (C5/M8)."""
now = time.time()
for d in root.iterdir() if root.is_dir() else []:
try:
if d.is_dir() and now - d.stat().st_mtime > max_age_s:
shutil.rmtree(d, ignore_errors=True)
except OSError:
pass
reap_dirs(RENDERS)
reap_dirs(FILMS)
app = FastAPI(title="SCENEGOD")
# ---- path guard (ship-check rule: every path-taking endpoint uses this) ----
def safe_under(root: Path, relpath: str) -> Path:
"""relpath resolved under root; rejects absolute paths and every `..` escape.
LEXICAL on purpose — do NOT "fix" this back to `.resolve()`. resolve()
follows symlinks, so a bank the operator deliberately symlinked into the
asset root (`ln -s ~/MESHGOD/assets3d/gallery assets/props`, the documented
way to get characters/clips/props in — PLAN §4.3) resolved OUTSIDE the root
and every single fetch 400'd, while /assets/tree happily listed the files.
os.path.normpath collapses `a/../../x` to `../x`, so traversal is caught
before touching the disk; user-supplied input still cannot escape. The only
thing this permits is a symlink an operator placed inside a curated root.
(A symlink LOOP under assets would hang scan_assets' os.walk — accepted: the
asset root is operator-curated, not user-writable.)"""
rel = os.path.normpath(str(relpath).replace("\\", "/"))
if os.path.isabs(rel) or rel == ".." or rel.startswith("../"):
raise HTTPException(400, "bad path")
return root / rel # unresolved: a symlinked bank inside root must stay usable
def _num(v, default=None):
"""Strict number coercion: None -> default, wrong type -> None (a violation)."""
if v is None:
return default
if isinstance(v, bool) or not isinstance(v, (int, float)):
return None
return float(v)
def resolve_audio(a: dict) -> tuple[dict | None, str | None]:
"""One audio entry (scene `audio[]` / sequence bed) -> a muxable dict, or an
error string. Shared by /render/end and plan_sequence so a clip means the
same thing in a shot and in a film. `in`/`out` are SOURCE-relative seconds
(Lane B's audio trim); `start` is where the trimmed piece lands on the
timeline. Path-guarded like every other path-taking input."""
if not isinstance(a, dict) or not isinstance(a.get("path"), str):
return None, "needs a string path"
try:
f = safe_under(ASSETS, a["path"])
except HTTPException:
return None, f"bad path {a['path']!r}"
if not f.is_file():
return None, f"not found under assets: {a['path']!r}"
start, gain = _num(a.get("start"), 0.0), _num(a.get("gain"), 1.0)
t_in, t_out = _num(a.get("in"), 0.0), _num(a.get("out"))
if start is None or gain is None or t_in is None or \
(a.get("out") is not None and t_out is None):
return None, "start/gain/in/out must be numbers"
if start < 0 or t_in < 0:
return None, f"start/in must be >= 0 (start={start}, in={t_in})"
if t_out is not None and t_out <= t_in:
return None, f"in ({t_in}) must be less than out ({t_out})"
e = {"path": a["path"], "file": str(f), "start": start, "gain": gain}
if t_in or t_out is not None:
# a window entirely past the end of the file makes ffmpeg exit -22 mid-mux
# ("Error muxing a packet"), which surfaces as a mystery failed render.
src = _probe_dur(f) # only probed when a trim is asked for
if src:
if t_in >= src:
return None, f"in ({t_in}) is past the end of {a['path']!r} ({src:.3f}s)"
if t_out is not None and t_out > src:
t_out = None # tail trim past EOF = no tail trim
if t_in:
e["in"] = t_in
if t_out is not None:
e["out"] = t_out
return e, None
# ---- assets ----
_tree_cache = {"t": 0.0, "val": None}
def scan_assets() -> dict:
"""Walk each category dir, group files by (dir, stem) → one entry w/ formats.
Live scan (hundreds of files); cached 5s in /assets/tree."""
out = {c: [] for c in CATEGORIES}
for cat, primary in CATEGORIES.items():
base = ASSETS / cat
if not base.is_dir():
continue
groups: dict[str, dict] = {}
# followlinks: a bank symlinked one level deeper (assets/props/gallery ->
# ~/MESHGOD/assets3d/gallery) must be scanned, not silently empty.
for dirpath, dirs, files in os.walk(base, followlinks=True):
dirs[:] = [d for d in dirs if not d.startswith(".")]
for f in sorted(files):
stem, dot, ext = f.rpartition(".")
ext = ext.lower()
if f.startswith(".") or not dot or ext not in ASSET_EXTS:
continue
rel = os.path.relpath(os.path.join(dirpath, f), ASSETS).replace(os.sep, "/")
key = os.path.relpath(os.path.join(dirpath, stem), ASSETS) # dir+stem
g = groups.setdefault(key, {"name": stem, "path": None, "formats": [], "thumb": None})
if ext not in primary:
if ext in IMG_EXTS:
g["thumb"] = rel
continue
g["formats"].append(ext)
# prefer glb (models) / video (backdrops: mp4 beats its jpg poster), else first
if g["path"] is None or ext == "glb" or ext in VIDEO_EXTS:
g["path"] = rel
if ext in IMG_EXTS and g["thumb"] is None: # primary img doubles as poster/thumb
g["thumb"] = rel
entries = [g for g in groups.values() if g["formats"]] # thumb-only stems aren't assets
out[cat] = sorted(entries, key=lambda g: g["name"].lower())
return out
@app.get("/assets/tree")
def assets_tree():
if not ASSETS.is_dir():
raise HTTPException(500, f"asset root missing: {ASSETS} (set SCENEGOD_ASSETS)")
now = time.monotonic()
if _tree_cache["val"] is None or now - _tree_cache["t"] > 5.0:
_tree_cache["val"] = scan_assets()
_tree_cache["t"] = now
return _tree_cache["val"]
@app.get("/assets/file")
def assets_file(path: str):
full = safe_under(ASSETS, path)
if not full.is_file():
raise HTTPException(404, "not found")
ext = full.suffix.lstrip(".").lower()
return FileResponse(full, media_type=MEDIA_TYPES.get(ext, "application/octet-stream"))
@app.get("/assets/thumb")
def assets_thumb(path: str):
"""Sidecar thumbnail for an asset path (same dir+stem, any image ext), 404
if none. Serves the file as-is — no resize (would need a Pillow dep).
ponytail: pass-through; add downscaling if dock load is measurably slow."""
full = safe_under(ASSETS, path)
for ext in IMG_EXTS:
thumb = full.with_suffix("." + ext)
if thumb.is_file():
return FileResponse(thumb, media_type=MEDIA_TYPES.get(ext),
headers={"Cache-Control": "max-age=3600"})
raise HTTPException(404, "no thumbnail")
# ---- scenes ----
SLUG = re.compile(r"[^a-z0-9_-]")
def slugify(name: str) -> str:
s = SLUG.sub("-", name.lower()).strip("-")
if not s:
raise HTTPException(422, "empty scene name after slugify")
return s
def validate_scene(s: dict) -> list[str]:
"""Return list of violations (empty = valid). Stdlib only, no jsonschema."""
errs = []
if s.get("version") != 1:
errs.append(f"version must be 1, got {s.get('version')!r}")
ents = s.get("entities")
if not isinstance(ents, list):
return errs + ["entities must be a list"]
ids, cam_ids = set(), set()
for i, e in enumerate(ents):
eid = e.get("id")
if eid in ids:
errs.append(f"entity[{i}]: duplicate id {eid!r}")
ids.add(eid)
if e.get("kind") == "camera":
cam_ids.add(eid)
tracks = e.get("tracks") or {}
for tk in ("transform", "params"):
keys = tracks.get(tk) or []
ts = [k.get("t") for k in keys]
if ts != sorted(ts):
errs.append(f"entity {eid!r}: track {tk} not sorted by t")
clips = sorted(tracks.get("clips") or [], key=lambda c: c.get("start", 0))
for a, b in zip(clips, clips[1:]):
end = a.get("start", 0) + (a.get("out", 0) - a.get("in", 0)) * a.get("loop", 1)
if b.get("start", 0) < end - a.get("fade", 0) - 1e-6:
errs.append(f"entity {eid!r}: clips overlap beyond fade "
f"(block ends {end:.3f}, next starts {b.get('start')})")
for cut in s.get("cameraCuts") or []:
if cut.get("camera") not in cam_ids:
errs.append(f"cameraCut references non-camera entity {cut.get('camera')!r}")
return errs
@app.get("/scenes")
def scenes_list():
out = []
for p in sorted(SCENES.glob("*.json")):
try:
dur = json.loads(p.read_text()).get("duration")
except Exception:
dur = None
out.append({"name": p.stem, "mtime": int(p.stat().st_mtime), "duration": dur})
return out
@app.get("/scenes/{name}")
def scene_get(name: str):
p = safe_under(SCENES, slugify(name) + ".json")
if not p.is_file():
raise HTTPException(404, "not found")
return json.loads(p.read_text())
# ship-check: body caps — this server now fronts digalot.fyi behind nginx
# basic-auth; never trust content-length alone, check the read bytes too.
async def capped_body(request: Request, limit: int) -> bytes:
cl = request.headers.get("content-length")
if cl and cl.isdigit() and int(cl) > limit:
raise HTTPException(413, f"body over {limit} bytes")
body = await request.body()
if len(body) > limit:
raise HTTPException(413, f"body over {limit} bytes")
return body
@app.post("/scenes/{name}")
async def scene_save(name: str, request: Request):
body = await capped_body(request, 5 * 1024 * 1024)
try:
scene = json.loads(body)
except json.JSONDecodeError as e:
raise HTTPException(400, f"invalid JSON: {e}")
errs = validate_scene(scene)
if errs:
return JSONResponse({"errors": errs}, status_code=422)
p = safe_under(SCENES, slugify(name) + ".json")
tmp = p.with_suffix(".json.tmp")
tmp.write_text(json.dumps(scene, indent=2))
tmp.replace(p) # atomic
return {"ok": True, "name": p.stem}
# ---- starter templates (M7-C) ----
# `templates/` ships WITH the repo (not gitignored like scenes/), so the app has
# something to open on instead of an empty grid. READ-ONLY on purpose: there is no
# POST/DELETE here — "new from template" is GET /templates/{name} followed by an
# ordinary POST /scenes/{name}. Presentation metadata rides in a `template` key
# (title/description/thumb/order) that validate_scene ignores and that never
# survives a Timeline round-trip, so saved scenes stay plain scene JSON.
def _template_meta(p: Path) -> dict | None:
try:
t = (json.loads(p.read_text()).get("template") or {})
except (OSError, json.JSONDecodeError):
return None # a broken file is not a template
m = {"name": p.stem, "title": t.get("title") or p.stem,
"description": t.get("description") or "", "_order": t.get("order", 100)}
if t.get("thumb"):
m["thumb"] = t["thumb"] # asset-relative → GET /assets/file?path=
return m
@app.get("/templates")
def templates_list():
if not TEMPLATES.is_dir():
return []
metas = [m for m in (_template_meta(p) for p in TEMPLATES.glob("*.json")) if m]
metas.sort(key=lambda m: (m["_order"], m["name"])) # the wow one first, not alphabetical
return [{k: v for k, v in m.items() if k != "_order"} for m in metas]
@app.get("/templates/{name}")
def template_get(name: str):
p = safe_under(TEMPLATES, slugify(name) + ".json") # same guard discipline as /scenes
if not p.is_file():
raise HTTPException(404, "not found")
return json.loads(p.read_text())
# ---- render (C5) ----
# ponytail: in-memory state, single-process local tool. A server restart
# mid-encode loses live status; status() falls back to on-disk out.mp4.
_renders: dict[str, dict] = {} # rid -> {state: queued|encoding|done|error, log: str}
def _render_dir(rid: str) -> Path:
return safe_under(RENDERS, rid) # rid is a hex uuid; guard anyway
def audio_input(a: dict) -> list[str]:
"""ffmpeg input args for one audio entry, honouring the SOURCE-relative
`in`/`out` window (Lane B's audio trim; absent = whole file).
The trim rides on the INPUT (-ss/-t) rather than an `atrim` in the filter
graph, which is not a style choice: `atrim,...,apad` yields a zero-sample
audio stream on ffmpeg 8.x (apad emits frames at ~INT64_MAX PTS, the muxer
drops the lot — an mp4 with an aac stream and no audio in it). Input seeking
keeps the graph byte-identical to the chain that has been shipping."""
pre = []
if a.get("in"):
pre += ["-ss", f"{a['in']:.6f}"]
if a.get("out") is not None:
pre += ["-t", f"{a['out'] - (a.get('in') or 0.0):.6f}"]
return pre + ["-i", a["file"]]
def audio_filters(audio: list[dict], first_input: int) -> list[str]:
"""Filter-graph fragments for the audio inputs, ending in [aout]: delay each
track to its timeline `start`, apply gain, mix down to one stereo stream."""
if not audio:
return []
parts = [f"[{first_input + k}:a]adelay={int(a['start']*1000)}:all=1,volume={a['gain']}[a{k}]"
for k, a in enumerate(audio)]
# apad + -shortest: the VIDEO decides the length. Without the pad, audio
# shorter than the shot truncates the picture (silent footage loss).
parts.append("".join(f"[a{k}]" for k in range(len(audio)))
+ f"amix=inputs={len(audio)}:normalize=0,apad[aout]")
return parts
def video_args(crf: int = 18) -> list[str]:
"""OUTPUT-only x264 options, shared by the shot encode and the film concat.
The colour tags are the point: an untagged yuv420p mp4 makes QuickTime,
Safari, Finder preview and most NLEs GUESS the primaries/transfer, and the
guess shifts hue and contrast away from what the WebGL canvas drew — which
reads as "the render came out wrong". three.js outputs sRGB, i.e. bt709.
+faststart because these are played over http; -preset slow costs a few
seconds on a 300-frame render at the same crf."""
return ["-c:v", "libx264", "-preset", "slow", "-profile:v", "high", "-level", "4.2",
"-pix_fmt", "yuv420p", "-crf", str(crf),
"-colorspace", "bt709", "-color_primaries", "bt709", "-color_trc", "bt709",
"-color_range", "tv", "-movflags", "+faststart"]
# MEASURED on ffmpeg 8.1.2: the -color_primaries/-color_trc OUTPUT options above
# are silently ignored (only -colorspace lands) because the frames coming out of
# the png decoder + auto-scaler carry "unspecified" and frame properties win.
# Tagging the FRAMES is what actually writes the VUI, so every encode ends in
# this filter — verified with ffprobe in scripts/test_server.py, not assumed.
SETPARAMS = "setparams=color_primaries=bt709:color_trc=bt709:colorspace=bt709:range=tv"
# ...but a TAG IS NOT A CONVERSION, and setparams actively breaks the one ffmpeg
# would otherwise negotiate. MEASURED (ffmpeg 8.1.2, pure green PNG in, first
# luma sample out): `scale=w:h:flags=lanczos,setparams=...` stores Y=145 — that
# is bt601's green (bt709's is 173) — and the file then decodes through its own
# bt709 tag as (0,216,0). Realistic case: neon (230,40,120) came back (244,61,120).
# Cause: setparams pins ITS output link to bt709, which leaves the scale filter's
# output link unspecified, so swscale's RGB->YUV falls back to bt601; with no
# setparams at all the graph negotiates bt709 from `-colorspace` and gets it right.
# So the conversion must be stated on the converting filter itself. `out_color_matrix`
# on scale = Y 173, round-trip (0,255,1), (230,38,119). Never write one without
# the other. (Range stays swscale's default limited, which is what `range=tv` says.)
CONVERT = "out_color_matrix=bt709"
def vf_args(meta: dict) -> list[str]:
"""The output video filter chain: downscale to the render's DECLARED size
(render/begin), convert RGB->YUV with the bt709 matrix, then tag bt709.
render.js supersamples: it renders the viewport at 2x the deliverable and
lets ffmpeg's lanczos do the resolve, which is the only anti-aliasing that
touches shading and texture aliasing (MSAA only fixes geometry edges) — the
frame-to-frame crawl on fences/foliage/hair is the loudest amateur tell in
motion. Near-noop when the frames already match. When begin declared no size
the scale keeps iw:ih (it is there for the matrix, not the resize).
It does NOT police the frame size — a capture SMALLER than the declared size
would be silently upscaled here, so _encode ffprobes the first frame and
errors out before we get this far."""
try:
w, h = int(meta.get("width", 0)), int(meta.get("height", 0))
except (TypeError, ValueError):
w = h = 0
size = f"{w}:{h}:flags=lanczos:" if w >= 16 and h >= 16 else ""
return ["-vf", f"scale={size}{CONVERT},{SETPARAMS}"]
def _png_size(path: Path) -> tuple[int, int]:
"""(w, h) of an image/video file, or (0, 0) if ffprobe can't say."""
try:
out = subprocess.check_output(
["ffprobe", "-v", "error", "-select_streams", "v:0", "-show_entries",
"stream=width,height", "-of", "csv=p=0:s=x", str(path)],
text=True, stderr=subprocess.DEVNULL).strip()
w, h = out.split("x")[:2]
return int(w), int(h)
except (OSError, ValueError, subprocess.SubprocessError):
return 0, 0
def _encode(rid: str, fps: int, audio: list[dict], crf: int = 18):
"""audio = [{file: abs path, start: sec, gain: float, in?, out?}] (resolved).
crf 18 = deliverable; film SHOTS pass 14 — they get concatenated (re-encoded)
into the film, and two crf-18 generations chew gradients and plate grain."""
d = _render_dir(rid)
_renders[rid] = {"state": "encoding", "log": ""}
try:
meta = json.loads((d / "meta.json").read_text())
except (OSError, json.JSONDecodeError):
meta = {}
# A capture SMALLER than the declared deliverable is a client failure, not a
# render: three.js keeps drawing at whatever buffer it actually got (a lost
# WebGL context, a clamped canvas on a small-memory box) and vf_args' scale
# would happily UPSCALE it to a soft, mushy mp4 with state=done and no log
# line anywhere. One ffprobe of frame 0 turns that into a visible failure.
# Bigger is normal — that is the supersample.
try:
dw, dh = int(meta.get("width", 0)), int(meta.get("height", 0))
except (TypeError, ValueError):
dw = dh = 0
first = next(iter(sorted((d / "frames").glob("*.png"))), None)
if first is not None and dw >= 16 and dh >= 16:
fw, fh = _png_size(first)
if fw and fh and (fw < dw or fh < dh):
_renders[rid] = {"state": "error", "log":
f"frames are {fw}x{fh} but this render declared {dw}x{dh}: the "
f"browser never got the drawing buffer it asked for (lost WebGL "
f"context / clamped canvas). Upscaling would hide it — refusing."}
return
cmd = ["ffmpeg", "-y", "-framerate", str(fps), "-i", str(d / "frames" / "%06d.png")]
for a in audio:
cmd += audio_input(a) # in/out trim rides on the input (M9)
# -vf (picture) before the audio -filter_complex: different streams, they
# coexist because the video map is a plain input, not a complex-graph label.
cmd += vf_args(meta) + video_args(crf)
if audio: # delay to start, apply gain, mix down to one stereo stream
cmd += ["-filter_complex", ";".join(audio_filters(audio, 1)),
"-map", "0:v", "-map", "[aout]", "-c:a", "aac", "-shortest"]
cmd += [str(d / "out.mp4")]
try:
p = subprocess.run(cmd, capture_output=True, text=True)
ok = p.returncode == 0 and (d / "out.mp4").is_file()
_renders[rid] = {"state": "done" if ok else "error", "log": p.stderr[-2000:]}
except Exception as e: # noqa: BLE001 — surface any spawn failure as error state
_renders[rid] = {"state": "error", "log": str(e)}
@app.post("/render/begin")
async def render_begin(request: Request):
if not HAVE_FFMPEG:
raise HTTPException(503, "ffmpeg not found on server")
rid = uuid.uuid4().hex
(RENDERS / rid / "frames").mkdir(parents=True)
try:
meta = json.loads(await capped_body(request, 64 * 1024) or b"{}")
except json.JSONDecodeError:
meta = {}
if not (1 <= int(meta.get("fps", 30)) <= 60) or \
not (16 <= int(meta.get("width", 1280)) <= 4096) or \
not (16 <= int(meta.get("height", 720)) <= 4096):
raise HTTPException(400, "fps/width/height out of range")
(RENDERS / rid / "meta.json").write_text(json.dumps(meta))
_renders[rid] = {"state": "queued", "log": ""}
return {"renderId": rid}
@app.post("/render/{rid}/frame/{n}")
async def render_frame(rid: str, n: int, request: Request):
frames = _render_dir(rid) / "frames"
if not frames.is_dir():
raise HTTPException(404, "unknown renderId")
if not (0 <= n <= 20000):
raise HTTPException(400, "frame index out of range")
# 40MB, not 25: render.js supersamples 2x, so a 1080p deliverable posts
# 3840x2160 PNGs and a detailed frame gets close to the old cap. Every
# OTHER capped_body limit stays where it is.
# NOTE (prod): this is NOT the binding limit at digalot.fyi —
# deploy/nginx-scenegod.conf sets client_max_body_size 30m in front of it,
# so a frame between 30MB and 40MB 413s in nginx with no message from here.
# Typical 4K PNG frames measure ~3MB, so nothing hits either wall today;
# deploy/ is not Lane C's to edit — logged as a REQUEST in logs/laneC.md.
body = await capped_body(request, 40 * 1024 * 1024)
if not body:
raise HTTPException(400, "empty frame body")
(frames / f"{n:06d}.png").write_bytes(body)
return {"ok": True}
@app.post("/render/{rid}/end")
async def render_end(rid: str, request: Request):
d = _render_dir(rid)
if not (d / "frames").is_dir():
raise HTTPException(404, "unknown renderId")
if not any((d / "frames").glob("*.png")):
raise HTTPException(400, "no frames posted")
try:
fps = int(json.loads((d / "meta.json").read_text()).get("fps", 30))
except (OSError, json.JSONDecodeError):
fps = 30
try:
body = json.loads(await capped_body(request, 1024 * 1024) or b"{}")
except json.JSONDecodeError:
body = {}
audio = []
for a in body.get("audio") or []: # scene JSON audio[] (M4, +in/out M9)
e, err = resolve_audio(a if isinstance(a, dict) else {})
if err:
raise HTTPException(400, f"audio: {err}")
audio.append(e)
# film.js flags its per-shot renders: they are intermediates that get
# re-encoded by the concat, so they encode at crf 14 (one lossy generation
# that matters, not two). Not client-tunable beyond that boolean.
crf = 14 if body.get("intermediate") else 18
threading.Thread(target=_encode, args=(rid, fps, audio, crf), daemon=True).start()
return {"ok": True}
@app.get("/render/{rid}/status")
def render_status(rid: str):
st = _renders.get(rid)
if st:
return st
d = _render_dir(rid) # fell out of memory (restart) — infer from disk
if (d / "out.mp4").is_file():
return {"state": "done", "log": ""}
if d.is_dir():
return {"state": "queued", "log": ""}
raise HTTPException(404, "unknown renderId")
@app.get("/render/{rid}/out.mp4")
def render_out(rid: str):
out = _render_dir(rid) / "out.mp4"
if not out.is_file():
raise HTTPException(404, "not ready")
return FileResponse(out, media_type="video/mp4")
# ---- sequences: multi-shot films (M8-C) ----
# A scene is ONE take. A sequence is the film-level object: an ordered list of
# shots, each a trimmed window [in,out) of a saved scene, joined by cuts or
# dissolves, over an optional film-level audio bed.
#
# The server CANNOT render a shot — three.js lives in the browser. So a film is a
# client-driven job the server orchestrates: POST /sequences/{n}/render hands back
# the resolved shot plan, the client renders each shot through the ORDINARY
# /render/* session endpoints and reports which renderId belongs to which shot
# index, and /films/{id}/end concatenates those mp4s with ffmpeg. See web/film.js.
SEQ_NAME = re.compile(r"[a-z0-9_-]{1,64}\Z") # same alphabet slugify() emits
TRANSITIONS = {"cut", "dissolve"}
MAX_SHOTS = 200
MAX_TRANSITION_S = 10.0
def plan_sequence(seq: dict) -> tuple[list[str], dict]:
"""Validate a sequence AND resolve it to a concrete shot plan in one pass, so
save-time validation and render-time planning can never disagree about what a
sequence means. Returns (violations, plan); plan is meaningful only when the
violation list is empty."""
errs: list[str] = []
warnings: list[str] = []
plan = {"fps": 30, "shots": [], "audio": [], "duration": 0.0, "warnings": warnings}
if seq.get("version") != 1:
errs.append(f"version must be 1, got {seq.get('version')!r}")
shots = seq.get("shots")
if not isinstance(shots, list) or not shots:
errs.append("shots must be a non-empty list")
shots = []
if len(shots) > MAX_SHOTS:
errs.append(f"too many shots ({len(shots)} > {MAX_SHOTS})")
shots = shots[:MAX_SHOTS]
fps_seen: dict[int, str] = {}
has_bed = bool(seq.get("audio"))
for i, sh in enumerate(shots):
if not isinstance(sh, dict):
errs.append(f"shot[{i}]: must be an object")
continue
nm = sh.get("scene")
if not isinstance(nm, str) or not SEQ_NAME.match(nm):
errs.append(f"shot[{i}]: bad scene name {nm!r}")
continue
p = SCENES / (nm + ".json") # name is [a-z0-9_-] only: no traversal possible
if not p.is_file():
errs.append(f"shot[{i}]: unknown scene {nm!r}")
continue
try:
sc = json.loads(p.read_text())
except (OSError, json.JSONDecodeError) as e:
errs.append(f"shot[{i}]: scene {nm!r} unreadable ({e.__class__.__name__})")
continue
sdur = _num(sc.get("duration"), 0.0) or 0.0
fps_seen.setdefault(int(_num(sc.get("fps"), 30) or 30), nm)
if (sc.get("audio") or []) and not has_bed:
warnings.append(f"scene {nm!r} has audio[] — shot audio is NOT carried into the "
f"film; put the soundtrack in the sequence's own audio[]")
t_in, t_out = _num(sh.get("in"), 0.0), _num(sh.get("out"), sdur)
if t_in is None or t_out is None:
errs.append(f"shot[{i}]: in/out must be numbers")
continue
if t_in < 0:
errs.append(f"shot[{i}]: in must be >= 0, got {t_in}")
if t_out <= t_in:
errs.append(f"shot[{i}]: in ({t_in}) must be less than out ({t_out})")
if t_out > sdur + 1e-6:
errs.append(f"shot[{i}]: out ({t_out}) is past the end of scene {nm!r} ({sdur})")
tr, td = sh.get("transition", "cut"), _num(sh.get("transitionDur"), 0.5)
if tr not in TRANSITIONS:
errs.append(f"shot[{i}]: unknown transition {tr!r} (one of {sorted(TRANSITIONS)})")
elif td is None or td < 0 or td > MAX_TRANSITION_S:
errs.append(f"shot[{i}]: transitionDur must be 0..{MAX_TRANSITION_S}s, "
f"got {sh.get('transitionDur')!r}")
elif tr == "dissolve" and td <= 0:
errs.append(f"shot[{i}]: a dissolve needs transitionDur > 0")
plan["shots"].append({"index": i, "scene": nm, "in": t_in, "out": t_out,
"len": max(0.0, t_out - t_in),
"transition": tr if tr in TRANSITIONS else "cut",
"transitionDur": (td or 0.0) if tr == "dissolve" else 0.0})
# a dissolve eats the tail of this shot and the head of the next: ffmpeg xfade
# needs it shorter than BOTH, or the fold produces garbage instead of a film.
ps = plan["shots"]
for k, s in enumerate(ps):
if s["transition"] != "dissolve" or k + 1 >= len(ps):
continue
nxt = ps[k + 1]
if s["transitionDur"] >= min(s["len"], nxt["len"]) - 1e-6:
errs.append(f"shot[{s['index']}]: dissolve {s['transitionDur']}s must be shorter than "
f"both shots it joins ({s['len']:.3f}s, {nxt['len']:.3f}s)")
if len(fps_seen) > 1:
errs.append("shots mix scene fps ("
+ ", ".join(f"{f} in {n!r}" for f, n in sorted(fps_seen.items()))
+ ") — one film, one fps")
if fps_seen:
plan["fps"] = min(fps_seen)
aud = seq.get("audio") or []
if not isinstance(aud, list):
errs.append("audio must be a list")
aud = []
for k, a in enumerate(aud):
e, err = resolve_audio(a)
if err:
errs.append(f"audio[{k}]: {err}")
else:
plan["audio"].append(e)
plan["duration"] = round(sum(s["len"] for s in ps) - sum(s["transitionDur"] for s in ps[:-1]), 6)
plan["warnings"] = list(dict.fromkeys(warnings)) # dedupe, keep order
return errs, plan
def validate_sequence(seq: dict) -> list[str]:
return plan_sequence(seq)[0]
def _seq_path(name: str) -> Path:
return safe_under(SEQUENCES, slugify(name) + ".json")
@app.get("/sequences")
def sequences_list():
out = []
for p in sorted(SEQUENCES.glob("*.json")):
try:
seq = json.loads(p.read_text())
except (OSError, json.JSONDecodeError):
continue
errs, plan = plan_sequence(seq)
out.append({"name": p.stem, "title": seq.get("title") or p.stem,
"mtime": int(p.stat().st_mtime), "shots": len(seq.get("shots") or []),
"duration": None if errs else plan["duration"], "ok": not errs})
return out
@app.get("/sequences/{name}")
def sequence_get(name: str):
p = _seq_path(name)
if not p.is_file():
raise HTTPException(404, "not found")
return json.loads(p.read_text())
@app.post("/sequences/{name}")
async def sequence_save(name: str, request: Request):
body = await capped_body(request, 1024 * 1024)
try:
seq = json.loads(body)
except json.JSONDecodeError as e:
raise HTTPException(400, f"invalid JSON: {e}")
errs, plan = plan_sequence(seq)
if errs:
return JSONResponse({"errors": errs}, status_code=422)
p = _seq_path(name)
tmp = p.with_suffix(".json.tmp")
tmp.write_text(json.dumps(seq, indent=2))
tmp.replace(p) # atomic
return {"ok": True, "name": p.stem, "duration": plan["duration"],
"shots": len(plan["shots"]), "warnings": plan["warnings"]}
@app.delete("/sequences/{name}")
def sequence_delete(name: str):
p = _seq_path(name)
if not p.is_file():
raise HTTPException(404, "not found")
p.unlink()
return {"ok": True, "name": p.stem}
# ---- film render jobs (M8-C) ----
_films: dict[str, dict] = {} # fid -> {state: queued|encoding|done|error, log} (encode only)
def _film_dir(fid: str) -> Path:
return safe_under(FILMS, fid)
def _film_meta(fid: str) -> dict:
"""Plan + shot registrations live on disk: a film spans minutes of client
rendering, and /end must still work if the process was restarted underneath."""
p = _film_dir(fid) / "meta.json"
if not p.is_file():
raise HTTPException(404, "unknown filmId")
return json.loads(p.read_text())
def _film_write(fid: str, meta: dict):
p = _film_dir(fid) / "meta.json"
tmp = p.with_suffix(".json.tmp")
tmp.write_text(json.dumps(meta))
tmp.replace(p)
def _probe_dur(f: Path) -> float | None:
"""Actual mp4 duration — xfade offsets are absolute, so a half-frame of drift
between nominal and encoded length shows up as a stutter at the join."""
try:
out = subprocess.run(["ffprobe", "-v", "error", "-show_entries", "format=duration",
"-of", "default=nw=1:nokey=1", str(f)],
capture_output=True, text=True, timeout=30)
return float(out.stdout.strip())
except (OSError, ValueError, subprocess.SubprocessError):
return None
def _concat_film(fid: str):
"""Join the shot mp4s in order: cut = concat filter, dissolve = xfade. The
concat FILTER (not the demuxer) because a film may need xfade anyway, and it
re-encodes once instead of twice."""
meta = json.loads((_film_dir(fid) / "meta.json").read_text())
_films[fid] = {"state": "encoding", "log": ""}
d, shots = _film_dir(fid), meta["shots"]
w, h, fps = meta["width"], meta["height"], meta["fps"]
srcs = [RENDERS / s["renderId"] / "out.mp4" for s in shots]
missing = [str(f) for f in srcs if not f.is_file()]
if missing:
_films[fid] = {"state": "error", "log": "shot mp4 missing (reaped?): " + ", ".join(missing)}
return
durs = [_probe_dur(f) or s["len"] for f, s in zip(srcs, shots)]
cmd = ["ffmpeg", "-y"] + [x for f in srcs for x in ("-i", str(f))]
cmd += [x for a in meta["audio"] for x in audio_input(a)] # in/out trim (M9)
# normalise every shot to the film's grid — mismatches are rejected at
# registration, this is the belt to that braces (SAR/fps oddities included).
# CONVERT here is belt-and-braces: the shots are already bt709 YUV so it is a
# no-op, but a shot mp4 left in renders/ by an older build (or any hand-dropped
# file) is bt601, and mixing matrices inside one xfade is invisible until it
# isn't. It never touches an already-bt709 input.
parts = [f"[{i}:v]scale={w}:{h}:{CONVERT},setsar=1,fps={fps},format=yuv420p[v{i}]"
for i in range(len(srcs))]
cur, acc = "v0", durs[0]
for i in range(1, len(srcs)):
prev = shots[i - 1]
td = prev["transitionDur"] if prev["transition"] == "dissolve" else 0.0
if td > 0:
parts.append(f"[{cur}][v{i}]xfade=transition=fade:duration={td}"
f":offset={max(0.0, acc - td):.6f}[x{i}]")
acc += durs[i] - td
else:
parts.append(f"[{cur}][v{i}]concat=n=2:v=1:a=0[x{i}]")
acc += durs[i]
cur = f"x{i}"
parts.append(f"[{cur}]{SETPARAMS}[vout]") # bt709 on the FRAMES (see SETPARAMS)
parts += audio_filters(meta["audio"], len(srcs)) # delay/gain/mix the film-level bed
cmd += ["-filter_complex", ";".join(parts), "-map", "[vout]"]
if meta["audio"]:
cmd += ["-map", "[aout]", "-c:a", "aac", "-shortest"] # apad+shortest: video sets the length
# shots were encoded at crf 14 so this — the only pass anyone watches — is
# the only one that meaningfully quantises. Each shot is already scaled to
# the film grid in the filter_complex above, so no -vf here.
cmd += video_args(18) + [str(d / "out.mp4")]
try:
p = subprocess.run(cmd, capture_output=True, text=True)
ok = p.returncode == 0 and (d / "out.mp4").is_file()
_films[fid] = {"state": "done" if ok else "error", "log": p.stderr[-2000:]}
except Exception as e: # noqa: BLE001 — surface any spawn failure as error state
_films[fid] = {"state": "error", "log": str(e)}
meta["state"] = _films[fid]["state"]
_film_write(fid, meta)
@app.post("/sequences/{name}/render")
async def film_begin(name: str, request: Request):
"""Start a film job. Returns the ordered shot list the CLIENT must render
(web/film.js) — one /render/* session per shot, reported back per index."""
if not HAVE_FFMPEG:
raise HTTPException(503, "ffmpeg not found on server")
p = _seq_path(name)
if not p.is_file():
raise HTTPException(404, "not found")
errs, plan = plan_sequence(json.loads(p.read_text()))
if errs:
return JSONResponse({"errors": errs}, status_code=422) # a scene may have vanished since save
try:
body = json.loads(await capped_body(request, 64 * 1024) or b"{}")
except json.JSONDecodeError:
body = {}
try:
w, h = int(body.get("width", 1920)), int(body.get("height", 1080))
except (TypeError, ValueError):
raise HTTPException(400, "width/height must be integers")
if not (16 <= w <= 4096 and 16 <= h <= 4096):
raise HTTPException(400, "width/height out of range")
fid = uuid.uuid4().hex
(FILMS / fid).mkdir(parents=True)
meta = {"sequence": p.stem, "fps": plan["fps"], "width": w, "height": h,
"audio": plan["audio"], "state": "collecting", "created": time.time(),
"shots": [dict(s, renderId=None) for s in plan["shots"]]}
_film_write(fid, meta)
return {"filmId": fid, "fps": plan["fps"], "width": w, "height": h,
"duration": plan["duration"], "warnings": plan["warnings"],
"shots": [{k: s[k] for k in ("index", "scene", "in", "out", "len",
"transition", "transitionDur")} for s in plan["shots"]]}
@app.post("/films/{fid}/shot/{i}")
async def film_shot(fid: str, i: int, request: Request):
"""The client says 'renderId R is shot i'. The render must be finished and cut
to the film's grid — one resolution and one fps per film, checked here so the
failure is a clear 400 and not a cryptic ffmpeg filter error 10 minutes later."""
meta = _film_meta(fid)
if not (0 <= i < len(meta["shots"])):
raise HTTPException(400, f"shot index {i} out of range (0..{len(meta['shots'])-1})")
try:
rid = str(json.loads(await capped_body(request, 8 * 1024)).get("renderId") or "")
except json.JSONDecodeError:
raise HTTPException(400, "invalid JSON")
if not re.fullmatch(r"[0-9a-f]{8,64}", rid):
raise HTTPException(400, "bad renderId")
rd = safe_under(RENDERS, rid)
if not (rd / "out.mp4").is_file():
raise HTTPException(400, f"render {rid} has no out.mp4 yet (encode it before registering)")
try:
rm = json.loads((rd / "meta.json").read_text())
rw, rh, rf = int(rm.get("width", 0)), int(rm.get("height", 0)), int(rm.get("fps", 0))
except (OSError, json.JSONDecodeError, TypeError, ValueError):
rw = rh = rf = 0 # no meta (restart/manual) — trust the pixels
if rw and rh and (rw, rh) != (meta["width"], meta["height"]):
raise HTTPException(400, f"shot {i} rendered {rw}x{rh}, film is "
f"{meta['width']}x{meta['height']} — one resolution per film")
if rf and rf != meta["fps"]:
raise HTTPException(400, f"shot {i} rendered at {rf}fps, film is {meta['fps']}fps")
meta["shots"][i]["renderId"] = rid
_film_write(fid, meta)
done = sum(1 for s in meta["shots"] if s.get("renderId"))
return {"ok": True, "shot": i, "of": len(meta["shots"]), "registered": done}
@app.post("/films/{fid}/end")
async def film_end(fid: str, request: Request):
await capped_body(request, 8 * 1024) # no payload today; cap it anyway (ship-check)
meta = _film_meta(fid)
missing = [s["index"] for s in meta["shots"] if not s.get("renderId")]
if missing:
raise HTTPException(400, f"shots not registered yet: {missing}")
meta["state"] = "queued"
_film_write(fid, meta)
_films[fid] = {"state": "queued", "log": ""}
threading.Thread(target=_concat_film, args=(fid,), daemon=True).start()
return {"ok": True, "shots": len(meta["shots"])}
@app.get("/films/{fid}/status")
def film_status(fid: str):
meta = _film_meta(fid)
n = len(meta["shots"])
done = sum(1 for s in meta["shots"] if s.get("renderId"))
st = _films.get(fid)
if not st: # fell out of memory (restart) — infer from disk
st = {"state": "done" if (_film_dir(fid) / "out.mp4").is_file()
else meta.get("state", "collecting"), "log": ""}
return {"state": st["state"], "shot": done, "of": n, "log": st["log"]}
@app.get("/films/{fid}/out.mp4")
def film_out(fid: str):
out = _film_dir(fid) / "out.mp4"
if not out.is_file():
raise HTTPException(404, "not ready")
return FileResponse(out, media_type="video/mp4")
# ---- viseme lip-sync (M4) — feeds Lane A's viseme lane ----
@app.get("/rhubarb")
def rhubarb(path: str):
"""Rhubarb Lip Sync on an asset audio file -> {metadata, mouthCues:[...]}.
503 if rhubarb absent (same pattern as ffmpeg). ponytail: synchronous —
VO clips are short; make it a render-style session if that ever bites."""
if not HAVE_RHUBARB:
raise HTTPException(503, "rhubarb not installed (see github.com/DanielSWolf/rhubarb-lip-sync)")
f = safe_under(ASSETS, path)
if not f.is_file():
raise HTTPException(404, "not found")
p = subprocess.run(["rhubarb", "-f", "json", str(f)], capture_output=True, text=True)
if p.returncode != 0:
raise HTTPException(500, f"rhubarb failed: {p.stderr[-500:]}")
return json.loads(p.stdout)
# ---- BEAT DETECTION (M9-C) — the grid a music video cuts itself to ----
# stdlib + ffmpeg only (repo rule: no librosa/aubio/numpy). The chain:
# ffmpeg -> mono s16 PCM @ 44100 (anything ffmpeg reads: wav/mp3/mp4/...)
# framing -> per-frame log energy, hop 512 = 86.13 envelope fps
# onset -> half-wave-rectified first difference [ENERGY flux]
# whitening -> subtract a local mean so a dense mix has a floor near zero
# tempo -> normalised autocorrelation 60..180 BPM, harmonics added, then
# a log-gaussian prior centred on 125 BPM (John's material is
# 90s house), then an explicit half/double check
# phase -> comb-filter search over one period
# grid -> least-squares refit of period+phase to the nearest onset peaks
# ENERGY flux, not spectral flux: a pure-Python radix-2 FFT would be ~20k
# transforms for a 4-minute track (tens of seconds of CPU), and on
# four-to-the-floor material the kick dominates the broadband energy anyway.
# Measured on ultra: 5-minute mp3 = 0.85s wall. 4/4 is ASSUMED — `downbeats` is
# every 4th beat, phase chosen as the strongest of the four cosets. Beats are a
# CONSTANT-tempo grid (no rubato tracking).
#
# DECODE AT 44100, NOT 22050 (M9-C fix — do not "optimise" this back down):
# a percussive hit is a high-frequency transient over a slower low-frequency
# body. Resampling to 22050 throws away everything above 11kHz, and in
# assets/audio/test/beat120.wav the >11k share of the attack grows 20x across
# the file (1402 -> 27711 peak sample) — so for the middle beats the surviving
# <11k energy peaks 30-50ms AFTER the attack while the first and last beats
# still peak at +0ms. A lateness that VARIES per beat is a tempo bias, not an
# offset: the detector read 119.62 on a file whose kicks are on 0.5000s to the
# microsecond, i.e. 0.32% slow = ~0.8s of slip across a 4-minute track. At 44100
# the transient survives and the same code reads 120.03. (172 fps was tried and
# rejected: it breaks octave choice on real records — a 90s house window went to
# 63.7 BPM — because the prior/harmonic weights below are tuned for 86 fps.)
BEAT_SR, BEAT_HOP = 44100, 512
BEAT_FPS = BEAT_SR / BEAT_HOP # 86.13 onset frames/s
BEAT_MIN_BPM, BEAT_MAX_BPM = 60.0, 180.0
BEAT_PRIOR_BPM, BEAT_PRIOR_OCT = 125.0, 0.9 # tempo prior: centre, width in octaves
BEAT_PREF = (90.0, 180.0) # preferred band for the octave check
BEAT_WHITEN_W = 32 # local-mean window, +-0.37s
BEAT_MAX_S = 900 # analyse at most 15 min (memory); grid extends
_beats_cache: dict[tuple, dict] = {}
def _frame_energy(f: Path) -> tuple[list[float], int]:
"""Decode and reduce to per-frame log energy in ONE streaming pass. 15
minutes at 44.1k is 79MB of PCM; buffering that twice (subprocess buffer +
array) is not a thing to do inside the prod container, and none of it is
needed after the framing. stderr stays a pipe for the error message —
safe only because `-v error` keeps it far under the pipe buffer."""
p = subprocess.Popen(["ffmpeg", "-v", "error", "-nostdin", "-i", str(f), "-map", "0:a:0",
"-t", str(BEAT_MAX_S), "-f", "s16le", "-ac", "1",
"-ar", str(BEAT_SR), "-"],
stdout=subprocess.PIPE, stderr=subprocess.PIPE)
e, buf, stride, nbytes = [], b"", BEAT_HOP * 2, 0
ap = e.append
while True:
chunk = p.stdout.read(stride * 4096) # ~4MB reads
if not chunk:
break
nbytes += len(chunk)
buf = buf + chunk if buf else chunk
nfr = len(buf) // stride
if not nfr:
continue
a = array.array("h")
a.frombytes(buf[:nfr * stride])
buf = buf[nfr * stride:]
for i in range(0, len(a), BEAT_HOP):
fr = a[i:i + BEAT_HOP]
ap(math.log1p(sum(map(operator.mul, fr, fr)) / BEAT_HOP))
err = p.stderr.read()
if p.wait() != 0:
raise HTTPException(400, "cannot decode audio: "
+ err[-200:].decode("utf-8", "replace").strip())
return e, nbytes // 2 # sample count, so `duration` isn't a frame short
def _onset_envelope(e: list[float]) -> list[float]:
"""Per-frame log energy -> rectified first difference -> 3-tap smooth ->
local-mean subtraction. The whitening is what makes a busy record's peaks
stand out from its floor (and the confidence number mean anything)."""
o = [0.0] + [max(0.0, e[i] - e[i - 1]) for i in range(1, len(e))]
n = len(o)
o = [(o[max(0, i - 1)] + o[i] + o[min(n - 1, i + 1)]) / 3 for i in range(n)]
w = BEAT_WHITEN_W
if n < 2 * w + 2:
return o
run, out = sum(o[:w + 1]), []
for i in range(n):
out.append(max(0.0, o[i] - run / (min(n - 1, i + w) - max(0, i - w) + 1)))
if i + 1 + w < n:
run += o[i + 1 + w]
if i - w >= 0:
run -= o[i - w]
return out
def _beat_tempo(o: list[float]) -> tuple[float, dict]:
"""(period in frames, autocorrelation table). Peak is parabola-interpolated:
the integer lag is only good to ~0.5 frame, which is seconds of drift across
a 4-minute track before the least-squares refit cleans it up."""
n = len(o)
m = sum(o) / n
oc = [x - m for x in o]
e0 = sum(map(operator.mul, oc, oc)) / n or 1e-9
lo = int(60 * BEAT_FPS / BEAT_MAX_BPM)
hi = int(math.ceil(60 * BEAT_FPS / BEAT_MIN_BPM))
r = {}
for lag in range(lo, hi + 1):
k = n - lag
r[lag] = (sum(map(operator.mul, oc[:k], oc[lag:])) / k / e0) if k > 8 else 0.0
p0 = 60 * BEAT_FPS / BEAT_PRIOR_BPM
def score(g):
# harmonics that fall OUTSIDE the lag table must not count as zero — that
# silently penalises every lag whose 2x is past the table edge (it cost a
# 118 BPM track 3 BPM: lag 43 kept its 2x at 86, lag 44 lost its 88).
terms = [(1.0, r[g])] + [(w, r[h]) for w, h in
((.5, 2 * g), (.5, g // 2), (.3, 3 * g // 2)) if h in r]
return (sum(w * v for w, v in terms) / sum(w for w, _ in terms)
* math.exp(-.5 * (math.log2(g / p0) / BEAT_PRIOR_OCT) ** 2))
best = max(r, key=score)
# the harmonics and the prior choose WHICH peak; the raw autocorrelation
# locates it. Walk uphill first: a parabola fitted to a point on a SLOPE
# extrapolates instead of interpolating (that is how lag 43 became 44.95).
while best + 1 in r and r[best + 1] > r[best]:
best += 1
while best - 1 in r and r[best - 1] > r[best]:
best -= 1
a, b, c = r.get(best - 1, 0.), r[best], r.get(best + 1, 0.)
d = a - 2 * b + c
return best + max(-0.5, min(0.5, 0.5 * (a - c) / d if d else 0.0)), r
def _comb(o: list[float], period: float, phase: float) -> float:
"""Mean onset strength on a beat grid — the score a phase/period is judged by.
MEAN (not sum) is deliberate: it makes a double-tempo grid pay for the empty
positions it adds, which is half the octave problem solved for free."""
s, cnt, i, n = 0.0, 0, phase, len(o)
while i < n - 1:
s += o[int(i + 0.5)]
cnt += 1
i += period
return s / cnt if cnt else 0.0
def _best_phase(o: list[float], period: float) -> tuple[float, int]:
return max((_comb(o, period, ph), ph) for ph in range(max(1, int(period))))
def _fit_grid(o: list[float], period: float, phase: float, iters: int = 3):
"""Snap each predicted beat to the nearest onset peak (sub-frame, parabolic)
and refit t_k = phase + k*period by onset-weighted least squares."""
n = len(o)
lo_p, hi_p = 60 * BEAT_FPS / BEAT_MAX_BPM, 60 * BEAT_FPS / BEAT_MIN_BPM
for _ in range(iters):
win = max(1, int(period * 0.12))
xs, ys, ws = [], [], []
k, t = 0, phase
while t < n - 1:
c = int(round(t))
a, b = max(1, c - win), min(n - 2, c + win)
if b > a:
j = max(range(a, b + 1), key=o.__getitem__)
p1, p2, p3 = o[j - 1], o[j], o[j + 1]
d = p1 - 2 * p2 + p3
frac = (0.5 * (p1 - p3) / d) if d else 0.0
if abs(frac) > 1:
frac = 0.0
if p2 > 0:
xs.append(k)
ys.append(j + frac)
ws.append(p2)
k += 1
t = phase + k * period
if len(xs) < 4:
break
sw = sum(ws)
mx = sum(w * x for w, x in zip(ws, xs)) / sw
my = sum(w * y for w, y in zip(ws, ys)) / sw
den = sum(w * (x - mx) ** 2 for w, x in zip(ws, xs))
if den <= 0:
break
period = min(max(sum(w * (x - mx) * (y - my)
for w, x, y in zip(ws, xs, ys)) / den, lo_p), hi_p)
phase = my - period * mx
return period, phase
def analyse_beats(f: Path) -> dict:
frames, nsamples = _frame_energy(f)
analyzed = nsamples / BEAT_SR
truncated = analyzed >= BEAT_MAX_S - 0.5
full = (_probe_dur(f) or analyzed) if truncated else analyzed
out = {"bpm": 0.0, "confidence": 0.0, "beats": [], "downbeats": [], "meter": 4,
"duration": round(full, 3), "analyzed": round(analyzed, 3),
"period": 0.0, "acPeak": 0.0, "onBeatContrast": 0.0}
o = _onset_envelope(frames)
n = len(o)
lo_p, hi_p = 60 * BEAT_FPS / BEAT_MAX_BPM, 60 * BEAT_FPS / BEAT_MIN_BPM
if n < 2 * hi_p or max(o, default=0.0) <= 1e-9:
out["note"] = "no beat grid: silent, or shorter than two slow bars"
return out
lag, r = _beat_tempo(o)
cands = []
for p in (lag / 2, lag, lag * 2): # explicit octave check: half, self, double
if lo_p <= p <= hi_p:
s, ph = _best_phase(o, p)
cands.append((s, p, ph))
top = max(c[0] for c in cands)
keep = [c for c in cands if c[0] >= 0.85 * top] or cands
# when half/double score comparably, take the one in the sane band — a 128 BPM
# record whose onset envelope autocorrelates hardest at 64 is the normal case.
_, period, phase = max(keep, key=lambda c: (BEAT_PREF[0] <= 60 * BEAT_FPS / c[1]
<= BEAT_PREF[1], c[0]))
period, phase = _fit_grid(o, period, phase)
# frame i is the window [i, i+1) hops, so its centre is the honest onset time
ph = phase + 0.5
ph -= period * math.floor((ph + 0.05 * BEAT_FPS) / period) # into [-50ms, period-50ms):
beats_f = [] # a track starting ON the
k = 0 # beat gets a beat at ~0
while ph + k * period <= full * BEAT_FPS:
beats_f.append(ph + k * period)
k += 1
inwin = [(i, int(round(t))) for i, t in enumerate(beats_f) if 0 <= round(t) < n]
onb = [o[j] for _, j in inwin]
onm = sum(onb) / len(onb) if onb else 0.0
allm = sum(o) / n
# confidence = geometric mean of two independent, honest checks:
# acPeak how periodic the onset envelope is at this period (0..1)
# onBeatContrast how much onset energy lands ON the emitted grid vs the
# whole track, (on-all)/(on+all), 0 = no better than chance
# Measured: synthetic click tracks 0.83-0.93, real 90s house 0.32-0.51,
# pink noise 0.09, silence 0.0. Treat >0.5 as a solid grid, <0.2 as mush.
ac = max(0.0, min(1.0, r.get(int(round(period)), 0.0)))
contrast = max(0.0, (onm - allm) / (onm + allm)) if onm + allm else 0.0
dbc = max(range(4), key=lambda c: sum(o[j] for i, j in inwin if i % 4 == c)
/ max(1, sum(1 for i, _ in inwin if i % 4 == c)))
out.update(bpm=round(60 * BEAT_FPS / period, 2),
period=round(period / BEAT_FPS, 6),
confidence=round(math.sqrt(ac * contrast), 3),
acPeak=round(ac, 3), onBeatContrast=round(contrast, 3),
beats=[round(max(0.0, t / BEAT_FPS), 4) for t in beats_f],
downbeats=[round(max(0.0, t / BEAT_FPS), 4) for t in beats_f[dbc::4]])
if truncated:
out["truncated"] = True # grid extrapolated past the analysed window
return out
@app.get("/beats")
def beats(path: str):
"""Beat grid for an audio asset -> {bpm, confidence, beats[], downbeats[],
duration, ...}. Times are absolute seconds from the start of the file; the
grid is constant-tempo and 4/4 is assumed (see the section header for the
algorithm and what `confidence` means). Cached by (path, mtime, size)."""
if not HAVE_FFMPEG:
raise HTTPException(503, "ffmpeg not found on server")
f = safe_under(ASSETS, path)
if not f.is_file():
raise HTTPException(404, "not found")
st = f.stat()
key = (str(f), st.st_mtime_ns, st.st_size)
if key not in _beats_cache:
if len(_beats_cache) > 32:
_beats_cache.clear() # ponytail: tiny bounded cache; clearing beats an LRU here
_beats_cache[key] = analyse_beats(f)
return {"path": path, **_beats_cache[key]}
# ---- DIRECTOR MODE (M5) — NL text -> validated preset ops via tailnet LLM ----
# Vocab mirrors PLAN §5 M5. NOTE: Lane A's web/grammar.js hasn't landed yet —
# when it does, re-point these lists at that file (read at boot) so the server
# prompt and the client presets never drift. Single source of truth until then.
SHOTS = ["ECU", "CU", "MCU", "MS", "MWS", "WS", "EWS"]
ANGLES = ["eye", "low", "high", "dutch", "OTS-L", "OTS-R"]
FOCALS = [18, 24, 35, 50, 85]
LIGHTS = ["day", "golden-hour", "night", "noir", "horror", "neon", "overcast"]
MARKS = ["center", "two-shot-L", "two-shot-R", "walk-to-mark", "face-camera", "face-other"]
DIRECTOR_SYS = f"""You are a film director's assistant. Translate the direction into JSON: {{"ops": [...]}} and NOTHING else. Allowed ops (scenegod:direct contract):
- {{"op": "shot", "subject": "<entity id>", "shot": <shot>, "angle": <angle>, "focal": <mm>}} — frame the subject. shot sizes: {SHOTS}. angles: {ANGLES} (default "eye"). focal lengths mm: {FOCALS} (default 35).
- {{"op": "light", "preset": <preset>}} — lighting presets: {LIGHTS}.
- {{"op": "mark", "subject": "<entity id>", "mark": <mark>}} — blocking marks: {MARKS}. "face-other" and two-shots may add "target": "<entity id>".
Use ONLY entity ids from the scene roster. Skip anything you cannot express with these ops. At most 6 ops."""
def _validate_ops(ops, ids: set) -> tuple[list, list]:
"""Enforce the scenegod:direct contract server-side: unknown op names,
vocab values, or entity ids not in the scene -> `rejected` (UI toasts it).
Never forwards raw model output."""
good, rejected = [], []
def rej(o, why):
rejected.append({"op": o, "reason": why})
if not isinstance(ops, list):
return [], [{"op": ops, "reason": "model output is not an op list"}]
for o in ops:
if not isinstance(o, dict):
rej(o, "op is not an object")
continue
kind = o.get("op")
if kind == "shot":
o = {"op": "shot", "subject": o.get("subject"), "shot": o.get("shot"),
"angle": o.get("angle", "eye"), "focal": o.get("focal", 35)}
try:
o["focal"] = int(o["focal"])
except (TypeError, ValueError):
pass
for field, allowed in (("shot", SHOTS), ("angle", ANGLES), ("focal", FOCALS)):
if o[field] not in allowed:
rej(o, f"unknown {field} {o[field]!r}")
break
else:
if o["subject"] not in ids:
rej(o, f"unknown subject {o['subject']!r}")
else:
good.append(o)
elif kind == "light":
o = {"op": "light", "preset": o.get("preset")}
if o["preset"] not in LIGHTS:
rej(o, f"unknown preset {o['preset']!r}")
else:
good.append(o)
elif kind == "mark":
keep = {"op": "mark", "subject": o.get("subject"), "mark": o.get("mark")}
if "target" in o:
keep["target"] = o["target"]
if keep["mark"] not in MARKS:
rej(keep, f"unknown mark {keep['mark']!r}")
elif keep["subject"] not in ids:
rej(keep, f"unknown subject {keep['subject']!r}")
elif "target" in keep and keep["target"] not in ids:
rej(keep, f"unknown target {keep['target']!r}")
else:
good.append(keep)
else:
rej(o, f"unknown op {kind!r}")
return good, rejected
@app.post("/director")
async def director(request: Request):
"""{text, scene:{entities:[{id,kind,label}], time}} -> {ops, rejected}.
OpenAI-compat chat call (tailnet Ollama); ops validated before returning."""
url = os.environ.get("SCENEGOD_LLM_URL", "").rstrip("/")
if not url:
raise HTTPException(503, "no LLM configured (set SCENEGOD_LLM_URL, e.g. "
"http://100.69.21.128:11434, and SCENEGOD_LLM_MODEL)")
try:
body = json.loads(await capped_body(request, 256 * 1024))
except json.JSONDecodeError:
raise HTTPException(400, "invalid JSON")
text = (body.get("text") or "").strip()
if not text:
raise HTTPException(400, "text required")
scene = body.get("scene") or {}
ents = scene.get("entities") or []
ids = {e.get("id") for e in ents}
roster = "\n".join(f"- id={e.get('id')!r} kind={e.get('kind')} label={e.get('label')!r}"
for e in ents) or "(empty scene)"
payload = {
"model": os.environ.get("SCENEGOD_LLM_MODEL", "qwen2.5:7b"),
"messages": [
{"role": "system", "content": DIRECTOR_SYS},
{"role": "user", "content": f"Scene roster:\n{roster}\n"
f"playhead: {scene.get('time', 0)}s\n\nDirection: {text}"},
],
"temperature": 0.1,
"response_format": {"type": "json_object"}, # ignored by servers without it
}
req = urllib.request.Request(url + "/v1/chat/completions", method="POST",
data=json.dumps(payload).encode(), headers={"Content-Type": "application/json"})
try:
with urllib.request.urlopen(req, timeout=120) as resp: # 7b on m4pro can be slow
data = json.load(resp)
except urllib.error.HTTPError as e:
raise HTTPException(502, f"LLM: {e.code} {e.read()[:200].decode('utf-8', 'replace')}")
except OSError as e:
raise HTTPException(502, f"LLM unreachable: {e.__class__.__name__}")
try:
parsed = json.loads(data["choices"][0]["message"]["content"])
except (KeyError, IndexError, TypeError, json.JSONDecodeError):
raise HTTPException(502, "LLM returned unparseable output") # never forward raw text
ops = parsed.get("ops") if isinstance(parsed, dict) else parsed # accept bare array too
good, rejected = _validate_ops(ops, ids)
return {"ops": good, "rejected": rejected}
# ---- MODELBEAST proxy (M4, gated) ----
def _mb_post(path: str, data: bytes, content_type: str) -> dict:
req = urllib.request.Request(MB_HOST + path, data=data, method="POST",
headers={"Authorization": "Bearer " + _mb_token(), "Content-Type": content_type})
try:
with urllib.request.urlopen(req, timeout=60) as resp:
return json.load(resp)
except urllib.error.HTTPError as e:
raise HTTPException(502, f"MB {path}: {e.code} {e.read()[:200].decode('utf-8', 'replace')}")
except OSError as e: # never surface the token in the error
raise HTTPException(502, f"MB {path} unreachable: {e.__class__.__name__}")
@app.post("/mb/submit")
async def mb_submit(request: Request):
"""Submit a MODELBEAST job (tts/voice-clone/lipsync). Optional input file →
/api/assets first, then /api/jobs — the meshgod/ops.py contract."""
if os.environ.get("SCENEGOD_MB") != "1":
raise HTTPException(503, "MODELBEAST proxy disabled (set SCENEGOD_MB=1)")
try:
body = json.loads(await capped_body(request, 256 * 1024))
except json.JSONDecodeError:
raise HTTPException(400, "invalid JSON")
op = (body.get("operator") or "").strip()
if not op:
raise HTTPException(400, "operator required")
asset_id = None
if body.get("file_b64"):
raw = base64.b64decode(body["file_b64"].split(",")[-1])
name = os.path.basename(body.get("file_name") or "input.bin") or "input.bin"
bnd = uuid.uuid4().hex
mp = ((f'--{bnd}\r\nContent-Disposition: form-data; name="file"; filename="{name}"\r\n'
f"Content-Type: application/octet-stream\r\n\r\n").encode()
+ raw + f"\r\n--{bnd}--\r\n".encode())
a = _mb_post("/api/assets", mp, f"multipart/form-data; boundary={bnd}")
asset_id = a.get("id") or (a.get("items") or [a])[0].get("id")
payload = {"operator": op, "params": body.get("params") or {}}
if asset_id:
payload["asset_id"] = asset_id
j = _mb_post("/api/jobs", json.dumps(payload).encode(), "application/json")
return {"job_id": j.get("id"), "asset_id": asset_id}
# ---- statics (last: catch-all /web is defined after API routes) ----
@app.get("/", response_class=HTMLResponse)
def index():
f = WEB / "index.html"
html = f.read_text() if f.is_file() else "<h1>SCENEGOD</h1><p>Lane A page not landed yet.</p>"
return HTMLResponse(html, headers={"Cache-Control": "no-store"}) # MESHGOD stale-cache lesson
@app.get("/web/{path:path}")
def web_static(path: str):
full = safe_under(WEB, path)
if not full.is_file():
raise HTTPException(404, "not found")
return FileResponse(full, headers={"Cache-Control": "max-age=60"})
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="127.0.0.1", port=int(os.environ.get("SCENEGOD_PORT", 8020)))