From b627a0f2ff1cb560d533196258ba9df9c031ea96 Mon Sep 17 00:00:00 2001 From: type-two Date: Sat, 18 Jul 2026 20:00:14 +1000 Subject: [PATCH] [laneC] M2/M3: render pipeline (ffmpeg encode + render.js), thumbnails endpoint, reaper - /render/begin|frame|end|status|out with background ffmpeg thread + on-disk status fallback; boot-time reap of dirs >48h - /assets/thumb sidecar lookup (pass-through, no resize dep) - web/render.js: draftRecord (MediaRecorder) + finalRender (deterministic timeline.step -> PNG -> POST, <=4 in flight) + download helper - test_server.py: +render pipeline (ffmpeg synthetic frames -> mp4, ffprobe ~1s) +thumbnail; 6 groups green Co-Authored-By: Claude Opus 4.8 --- scenegod/server.py | 96 ++++++++++++++++++++++++++++++++++++++---- scenegod/web/render.js | 79 ++++++++++++++++++++++++++++++++++ scripts/test_server.py | 41 +++++++++++++++++- 3 files changed, 206 insertions(+), 10 deletions(-) create mode 100644 scenegod/web/render.js diff --git a/scenegod/server.py b/scenegod/server.py index 6b2ecc2..55a0393 100644 --- a/scenegod/server.py +++ b/scenegod/server.py @@ -11,12 +11,14 @@ import json import os import re import shutil +import subprocess +import threading import time import uuid from pathlib import Path from fastapi import FastAPI, HTTPException, Request -from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, Response +from fastapi.responses import FileResponse, HTMLResponse, JSONResponse ROOT = Path(__file__).resolve().parent.parent WEB = Path(__file__).resolve().parent / "web" # scenegod/web (PLAN §2) @@ -41,6 +43,19 @@ SCENES.mkdir(parents=True, exist_ok=True) RENDERS.mkdir(parents=True, exist_ok=True) HAVE_FFMPEG = shutil.which("ffmpeg") is not None + +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") @@ -108,6 +123,20 @@ def assets_file(path: str): 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_-]") @@ -190,39 +219,90 @@ async def scene_save(name: str, request: Request): return {"ok": True, "name": p.stem} -# ---- render (M3 — design landed, encode stubbed) ---- +# ---- 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): + d = _render_dir(rid) + _renders[rid] = {"state": "encoding", "log": ""} + cmd = ["ffmpeg", "-y", "-framerate", str(fps), "-i", str(d / "frames" / "%06d.png"), + "-c:v", "libx264", "-pix_fmt", "yuv420p", "-crf", "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() + _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) - (RENDERS / rid / "meta.json").write_text((await request.body()).decode() or "{}") + 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 = safe_under(RENDERS, rid) / "frames" + frames = _render_dir(rid) / "frames" if not frames.is_dir(): raise HTTPException(404, "unknown renderId") - (frames / f"{n:06d}.png").write_bytes(await request.body()) + 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): - raise HTTPException(501, "encode not implemented until M3") + 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 + threading.Thread(target=_encode, args=(rid, fps), daemon=True).start() + return {"ok": True} @app.get("/render/{rid}/status") def render_status(rid: str): - raise HTTPException(501, "not implemented until M3") + 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): - raise HTTPException(501, "not implemented until M3") + out = _render_dir(rid) / "out.mp4" + if not out.is_file(): + raise HTTPException(404, "not ready") + return FileResponse(out, media_type="video/mp4") # ---- statics (last: catch-all /web is defined after API routes) ---- diff --git a/scenegod/web/render.js b/scenegod/web/render.js new file mode 100644 index 0000000..a0c0b3f --- /dev/null +++ b/scenegod/web/render.js @@ -0,0 +1,79 @@ +// render.js — draft + final video capture (Lane C, PLAN §4.3 / C-server §C5). +// Talks to Stage/Timeline through their public APIs only. No three.js import. +// +// draftRecord(stage, timeline) -> Promise quick webm via MediaRecorder +// finalRender(stage, timeline, opts) -> Promise deterministic PNG->ffmpeg mp4 + +const sleep = ms => new Promise(r => setTimeout(r, ms)); + +// --- draft: record the live canvas while the timeline plays, once through ---- +export function draftRecord(stage, timeline, { fps = 30, mimeType = 'video/webm' } = {}) { + const stream = stage.renderer.domElement.captureStream(fps); + const rec = new MediaRecorder(stream, MediaRecorder.isTypeSupported(mimeType) ? { mimeType } : {}); + const chunks = []; + rec.ondataavailable = e => e.data.size && chunks.push(e.data); + return new Promise((resolve, reject) => { + rec.onstop = () => resolve(new Blob(chunks, { type: rec.mimeType || 'video/webm' })); + rec.onerror = reject; + rec.start(); + timeline.seek(0); + timeline.play(); + const stop = timeline.onTick(t => { + if (t >= timeline.duration) { timeline.pause(); rec.stop(); if (typeof stop === 'function') stop(); } + }); + }); +} + +// --- final: step frame-by-frame (deterministic), upload PNGs, ffmpeg encode --- +export async function finalRender(stage, timeline, opts = {}) { + const { fps = 30, width = 1920, height = 1080, name = 'scene', onProgress } = opts; + const canvas = stage.renderer.domElement; + const prev = { w: canvas.width, h: canvas.height, dpr: stage.renderer.getPixelRatio() }; + stage.renderer.setPixelRatio(1); + stage.renderer.setSize(width, height, false); // false = don't touch CSS size + + try { + const total = Math.max(1, Math.round(timeline.duration * fps)); + const { renderId } = await postJSON('/render/begin', { name, fps, width, height }); + + const inflight = new Set(); + for (let f = 0; f < total; f++) { + timeline.step(f); // seek(f/fps)+evaluate — never wall-clock + stage.renderActiveCamera(null); // draw active cam (or director cam) to main canvas + const blob = await new Promise(res => canvas.toBlob(res, 'image/png')); + while (inflight.size >= 4) await Promise.race(inflight); // backpressure: <=4 in flight + const p = fetch(`/render/${renderId}/frame/${f}`, { method: 'POST', body: blob }) + .then(r => { if (!r.ok) throw new Error(`frame ${f}: ${r.status}`); }) + .finally(() => inflight.delete(p)); + inflight.add(p); + onProgress?.(f + 1, total); + } + await Promise.all(inflight); + + await postJSON(`/render/${renderId}/end`, {}); + let st; + do { await sleep(500); st = await (await fetch(`/render/${renderId}/status`)).json(); } + while (st.state === 'queued' || st.state === 'encoding'); + if (st.state !== 'done') throw new Error('encode failed: ' + (st.log || st.state)); + return `/render/${renderId}/out.mp4`; + } finally { + stage.renderer.setPixelRatio(prev.dpr); + stage.renderer.setSize(prev.w, prev.h, false); + } +} + +// trigger a browser download of a blob or url (draft webm / final mp4) +export function download(blobOrUrl, filename) { + const a = document.createElement('a'); + a.href = typeof blobOrUrl === 'string' ? blobOrUrl : URL.createObjectURL(blobOrUrl); + a.download = filename; + a.click(); + if (typeof blobOrUrl !== 'string') setTimeout(() => URL.revokeObjectURL(a.href), 1000); +} + +async function postJSON(url, body) { + const r = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) }); + if (!r.ok) throw new Error(`${url}: ${r.status}`); + return r.json(); +} diff --git a/scripts/test_server.py b/scripts/test_server.py index 3afa6c6..f541a11 100644 --- a/scripts/test_server.py +++ b/scripts/test_server.py @@ -9,6 +9,7 @@ validation failures. """ import json import os +import shutil import subprocess import sys import tempfile @@ -51,8 +52,11 @@ def main(): (assets / "backdrops" / "street.jpg").write_bytes(b"img") for d in ("animations", "props", "audio"): (assets / d).mkdir() + (assets / "props" / "box.glb").write_bytes(b"b") # no image sidecar - env = {**os.environ, "SCENEGOD_ASSETS": str(assets), "SCENEGOD_SCENES": str(scenes)} + renders = tmp / "renders" + env = {**os.environ, "SCENEGOD_ASSETS": str(assets), "SCENEGOD_SCENES": str(scenes), + "SCENEGOD_RENDERS": str(renders)} proc = subprocess.Popen( [str(PY), "-m", "uvicorn", "scenegod.server:app", "--port", str(PORT)], cwd=ROOT, env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) @@ -77,9 +81,12 @@ def main(): assert tree["backdrops"][0]["path"] == "backdrops/street.jpg", tree["backdrops"] n += 1 - # --- file streaming --- + # --- file streaming + thumbnail sidecar --- st, b = req("GET", "/assets/file?path=backdrops/street.jpg") assert st == 200 and b == b"img", (st, b) + st, b = req("GET", "/assets/thumb?path=" + urllib.parse.quote("characters/pack01/lady.glb")) + assert st == 200 and b == b"x", (st, b) # resolves lady.jpg sidecar + assert req("GET", "/assets/thumb?path=props/box.glb")[0] == 404 # no sidecar n += 1 # --- path traversal rejected --- @@ -123,6 +130,36 @@ def main(): assert st == 200, st n += 1 + # --- render pipeline: begin -> post 30 PNGs -> end -> poll -> mp4 --- + if shutil.which("ffmpeg") and shutil.which("ffprobe"): + fr = tmp / "genframes" + fr.mkdir() + subprocess.run(["ffmpeg", "-y", "-f", "lavfi", "-i", "color=c=red:s=64x64", + "-frames:v", "30", str(fr / "%06d.png")], + check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + rid = json.loads(req("POST", "/render/begin", + json.dumps({"name": "t", "fps": 30}))[1])["renderId"] + for i, png in enumerate(sorted(fr.glob("*.png"))): + st, _ = req("POST", f"/render/{rid}/frame/{i}", png.read_bytes()) + assert st == 200, st + assert req("POST", f"/render/{rid}/end", "{}")[0] == 200 + for _ in range(100): # poll up to ~20s + state = json.loads(req("GET", f"/render/{rid}/status")[1])["state"] + if state in ("done", "error"): + break + time.sleep(0.2) + assert state == "done", state + st, mp4 = req("GET", f"/render/{rid}/out.mp4") + assert st == 200 and mp4[:4] != b"", st + dur = subprocess.check_output( + ["ffprobe", "-v", "error", "-show_entries", "format=duration", + "-of", "default=nw=1:nokey=1", str(renders / rid / "out.mp4")], + text=True).strip() + assert abs(float(dur) - 1.0) < 0.2, f"mp4 duration {dur}s, expected ~1s" + n += 1 + else: + print("(render test skipped — ffmpeg/ffprobe not found)") + print(f"OK: {n} test groups passed") finally: proc.terminate()