SCENEGOD/scenegod/web/film.js
type-two 22c8a9a324 M12: look, render button, save integrity — 3 workflow rounds, 22 agents
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>
2026-07-28 22:41:17 +10:00

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();
}