// film.js — multi-shot sequencing client (Lane C, M8-C). // // renderSequence(stage, timeline, seqName, opts) -> Promise // // 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(); }