[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:
parent
1f8ad13ed6
commit
b627a0f2ff
@ -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
79
scenegod/web/render.js
Normal 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();
|
||||
}
|
||||
@ -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()
|
||||
|
||||
Loading…
Reference in New Issue
Block a user