SCENEGOD/scenegod/web/film.js
type-two b773d29776 [m8-C] multi-shot sequencing — a film is now a first-class object
Sequences (ordered shots = scene + in/out + transition) with full validation,
CRUD, and a client-driven film render the server orchestrates: browser renders
each shot through the existing /render/* endpoints, server ffmpeg-concatenates
in order with cuts or xfade dissolves, muxes a film-level audio bed, reaps
after 48h. render.js refactored so there is one frame-post path in the repo
(captureFrames/withRenderSize/begin/end/poll); new web/film.js drives the loop.
Also fixes a pre-existing mux bug: no apad meant short audio truncated PICTURE.

Orchestrator: deploy compose/script now carry SCENEGOD_SEQUENCES + SCENEGOD_FILMS
volumes so shot lists survive a rebuild.

Verified live: demo-two-shot renders one 11.500s mp4 with a genuinely
compositing dissolve; a film built from the M7 templates (street wide ->
dissolve -> closer -> cut -> record store) renders 11.800s at 960x540.
15 test groups green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 23:21:45 +10:00

69 lines
3.7 KiB
JavaScript

// film.js — multi-shot sequencing client (Lane C, M8-C).
//
// renderSequence(stage, timeline, seqName, opts) -> Promise<url of the film mp4>
//
// A scene is one continuous take; a SEQUENCE is the film — an ordered list of
// shots, each a trimmed window of a saved scene, joined by cuts or dissolves.
// Only the browser can render a shot (three.js lives here), so the server
// orchestrates and this file does the work:
//
// POST sequences/{name}/render -> {filmId, fps, width, height, shots[]}
// per shot: GET scenes/{scene} -> timeline.applyScene -> render frames
// [in*fps, out*fps) through the ORDINARY render session endpoints
// -> POST films/{id}/shot/{i} {renderId}
// POST films/{id}/end -> ffmpeg concat/xfade -> poll -> films/{id}/out.mp4
//
// Shots are rendered SILENT on purpose: a film's soundtrack is the sequence's
// own audio[] (mixed server-side over the finished cut), not per-scene audio —
// the server warns when a referenced scene has audio[] that will be skipped.
// All URLs relative (the app serves under /scenegod/ in prod).
import { withRenderSize, beginRender, captureFrames, endRender, pollRender,
postJSON, getJSON } from './render.js';
const sleep = ms => new Promise(r => setTimeout(r, ms));
export async function renderSequence(stage, timeline, seqName, opts = {}) {
const { width = 1920, height = 1080, onProgress, onShot, onWarning } = opts;
const film = await postJSON(`sequences/${encodeURIComponent(seqName)}/render`, { width, height });
const { filmId, fps, shots } = film;
for (const w of film.warnings || []) (onWarning || (m => console.warn('[film]', m)))(w);
if (!shots?.length) throw new Error('sequence has no shots');
// one canvas resize for the whole film — shots must all be the same size, the
// server rejects a mismatched shot at registration anyway.
await withRenderSize(stage, width, height, async () => {
for (const shot of shots) {
const i = shot.index;
onShot?.(i, shots.length, shot);
const scene = await getJSON(`scenes/${encodeURIComponent(shot.scene)}`);
await timeline.applyScene(scene); // stage.applyState + tracks + clip preload (Lane B)
const from = Math.round(shot.in * fps), to = Math.round(shot.out * fps);
const { renderId } = await beginRender({ name: `${seqName}-${i}`, fps, width, height });
await captureFrames(stage, timeline, { renderId, from, to,
onProgress: (done, total) => onProgress?.({ shot: i, of: shots.length, scene: shot.scene,
frame: done, frames: total }) });
await endRender(renderId); // silent: the bed is film-level
await pollRender(renderId); // shot mp4 must exist before we register it
await postJSON(`films/${filmId}/shot/${i}`, { renderId });
}
});
await postJSON(`films/${filmId}/end`, {});
let st;
do { await sleep(500); st = await getJSON(`films/${filmId}/status`); }
while (st.state === 'collecting' || st.state === 'queued' || st.state === 'encoding');
if (st.state !== 'done') throw new Error('film encode failed: ' + (st.log || st.state));
return `films/${filmId}/out.mp4`;
}
// convenience: the sequence list/read/save/delete surface, so a UI lane never has
// to hand-roll the URLs (and never gets them absolute).
export const listSequences = () => getJSON('sequences');
export const getSequence = name => getJSON(`sequences/${encodeURIComponent(name)}`);
export const saveSequence = (name, seq) => postJSON(`sequences/${encodeURIComponent(name)}`, seq);
export async function deleteSequence(name) {
const r = await fetch(`sequences/${encodeURIComponent(name)}`, { method: 'DELETE' });
if (!r.ok) throw new Error(`delete ${name}: ${r.status}`);
return r.json();
}