"""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}//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": "", "shot": , "angle": , "focal": }} — frame the subject. shot sizes: {SHOTS}. angles: {ANGLES} (default "eye"). focal lengths mm: {FOCALS} (default 35). - {{"op": "light", "preset": }} — lighting presets: {LIGHTS}. - {{"op": "mark", "subject": "", "mark": }} — blocking marks: {MARKS}. "face-other" and two-shots may add "target": "". 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 "

SCENEGOD

Lane A page not landed yet.

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