[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 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-18 20:00:14 +10:00
parent 1f8ad13ed6
commit b627a0f2ff
3 changed files with 206 additions and 10 deletions

View File

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

79
scenegod/web/render.js Normal file
View File

@ -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<Blob> quick webm via MediaRecorder
// finalRender(stage, timeline, opts) -> Promise<url> 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();
}

View File

@ -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()