SCENEGOD/scenegod/server.py
type-two 20af1b20c2 [m5+m6] DIRECTOR MODE + FLOW VIDEO PLATES — all lanes + sync6 seam fixes
Lane A: grammar.js (shot/angle/focal/light/mark data), presets.js (bbox shot
solver, light/mark appliers, DIRECT bar UI, NL box), stage video plates
(VideoTexture plane/corner/dome, screen kind, syncVideos), dock video cards.
Lane B: scenegod:direct → grouped-undo keys/cuts, 🗣 lip-sync from audio via
/rhubarb, Cmd+wheel zoom / wheel scroll, audio trim, video clock-follow via
nullable entityVideo seam. Lane C: POST /director (LLM proxy, server-side op
validation vs vocab+scene, mock-tested), render.js awaits stage.syncVideos.
Sync6 (orchestrator): VIDEO_EXTS in server tree/media types (video primary,
img poster as thumb), stage.entityVideo accessor, syncVideos forces
videoTex.needsUpdate (rVFC never fires in hidden tabs → black plates).

Verified: 9 server groups + timeline + presets suites green; browser SYNC:
one-click MCU framing lands camera+fov keys+cut (single undo), golden_hour
relight keys, plate scrub-follow frame-exact, finalRender mp4 shows the Flow
golden-hour street plate behind an animated character.

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

549 lines
23 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 base64
import json
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()
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)
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_renders(max_age_s=48 * 3600):
"""Delete render dirs older than max_age_s. Called at boot (C5)."""
now = time.time()
for d in RENDERS.iterdir() if RENDERS.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_renders()
app = FastAPI(title="SCENEGOD")
# ---- path guard (ship-check rule: every path-taking endpoint uses this) ----
def safe_under(root: Path, relpath: str) -> Path:
"""Resolve relpath under root; reject traversal, absolute, symlink escape."""
full = (root / relpath).resolve()
if full != root and root not in full.parents:
raise HTTPException(400, "bad path")
return full
# ---- 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] = {}
for dirpath, dirs, files in os.walk(base):
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())
@app.post("/scenes/{name}")
async def scene_save(name: str, request: Request):
body = await request.body()
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}
# ---- 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 _encode(rid: str, fps: int, audio: list[dict]):
"""audio = [{file: abs path, start: sec, gain: float}] (paths pre-resolved)."""
d = _render_dir(rid)
_renders[rid] = {"state": "encoding", "log": ""}
cmd = ["ffmpeg", "-y", "-framerate", str(fps), "-i", str(d / "frames" / "%06d.png")]
for a in audio:
cmd += ["-i", a["file"]]
cmd += ["-c:v", "libx264", "-pix_fmt", "yuv420p", "-crf", "18"]
if audio: # delay each track to its start, apply gain, mix down to one stereo stream
parts = [f"[{i}:a]adelay={int(a['start']*1000)}:all=1,volume={a['gain']}[a{i}]"
for i, a in enumerate(audio, start=1)]
parts.append("".join(f"[a{i}]" for i in range(1, len(audio) + 1))
+ f"amix=inputs={len(audio)}:normalize=0[aout]")
cmd += ["-filter_complex", ";".join(parts),
"-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 request.body() or b"{}")
except json.JSONDecodeError:
meta = {}
(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")
body = await request.body()
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 request.body() or b"{}")
except json.JSONDecodeError:
body = {}
audio = []
for a in body.get("audio") or []: # scene JSON audio[] (M4)
f = safe_under(ASSETS, a["path"]) # path-guard every audio input
if not f.is_file():
raise HTTPException(400, f"audio not found: {a['path']}")
audio.append({"file": str(f), "start": float(a.get("start", 0)),
"gain": float(a.get("gain", 1.0))})
threading.Thread(target=_encode, args=(rid, fps, audio), 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")
# ---- 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)
# ---- 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)")
body = await request.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)")
body = await request.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)))