FEATURES (from a 5-lens audit: UX friction, feature gaps, bug hunt, visual quality, discoverability): - ACES filmic tone mapping + per-preset exposure. grammar.js runs physical sun intensities of 1.2-3.4; with NoToneMapping every lit face clipped to white and a warm key rendered neutral. Single biggest look win in the app. - Real shadows: PCFSoft, a shadow box fitted to the scene's bounds, and plates that no longer double as the shadow catcher (dedicated ShadowMaterial). - A Render button in the scene bar. finalRender had ZERO callers - the app's deliverable was console-only. - Save actually saves the stage you built (gizmo moves, inspector edits), and Save/Load stop lying about what happened. - 2x supersample + lanczos downscale; correct bt709 tagging and faststart. - Frame guide, camera-from-view, dock counts, delete confirmations. DEFECTS FOUND BY ADVERSARIAL REVIEW AND FIXED (all reproduced first): - BLOCKER: save wrote the playhead pose over the authored rest transform of keyed entities, so every save produced a different file. - renders were converted with the bt601 matrix while tagged bt709. - deleting an unkeyed camera stranded its cut -> scene 422s forever. - corner plates showed one photo at two exposures across the fold. - exposure was advertised as keyable but nothing keyed it, so a second lighting preset permanently poisoned the first. - the audio pre-flight could never fire (FastAPI does not route HEAD -> 405). - the two render buttons were not mutually exclusive. - frame guide letterboxed a narrow viewport that the render does not crop. ORCHESTRATOR (round 3): dangling camera cuts are pruned in Timeline.toJSON and skipped by cutCameraAt, rather than trying to keep every undo history clean - deleting a camera spans Stage (no undo) and Timeline (undo), so any older entry can resurrect a cut for a dead camera. Verified against the real validator. Also replaced a VACUOUS test that drained a stack whose only entry was a seeded no-op; timeline_test.mjs now replays both real histories and discriminates. Verified: 4 JS suites + 23 server groups + render client green; blocker repro now preserves the authored rest at every playhead position; save round-trips 200 with zero dangling cuts; zero console errors. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
72 lines
4.0 KiB
JavaScript
72 lines
4.0 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, ssFactor, 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. Rendered at ss x the
|
|
// deliverable (2 up to 1080p) and downscaled by ffmpeg; begin still declares the
|
|
// OUTPUT size, so meta.json and the film grid keep matching.
|
|
const ss = ssFactor(width, height, opts.ss);
|
|
await withRenderSize(stage, width * ss, height * ss, 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, [], { intermediate: true }); // silent (bed is film-level), crf 14
|
|
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();
|
|
}
|