"""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"} ASSET_EXTS = MODEL_EXTS | IMG_EXTS | AUDIO_EXTS # primary ext per category (others sharing a stem are thumbnails) CATEGORIES = {"characters": MODEL_EXTS, "animations": MODEL_EXTS, "props": MODEL_EXTS, "backdrops": IMG_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", } 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) if g["path"] is None or ext == "glb": # prefer glb, else first g["path"] = 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) # ---- 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)))