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>
69 lines
3.7 KiB
JavaScript
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();
|
|
}
|