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>
549 lines
23 KiB
Python
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)))
|