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>
262 lines
13 KiB
JavaScript
262 lines
13 KiB
JavaScript
#!/usr/bin/env node
|
|
// test_render_client.mjs — headless checks on web/render.js (Lane C).
|
|
//
|
|
// node scripts/test_render_client.mjs
|
|
//
|
|
// No deps, no browser, no three: fake Stage + fake Timeline + stubbed fetch.
|
|
// Pins two bugs that shipped in real mp4s:
|
|
// 1. the capture loop never called stage.pause(), so Stage._chrome() (SYNC7)
|
|
// never fired and every frame carried the gizmo/grid/camera cones;
|
|
// 2. withRenderSize restored the viewport from DEVICE pixels through setSize,
|
|
// which multiplies by the pixel ratio again — the buffer grew by dpr^1 per
|
|
// render on a retina Mac.
|
|
// Plus the supersample contract (item C3): render at 2x, deliver at 1x.
|
|
import { finalRender, captureFrames, withRenderSize, ssFactor } from '../scenegod/web/render.js';
|
|
import { readFile } from 'node:fs/promises';
|
|
|
|
let failures = 0;
|
|
const ok = (cond, msg) => { if (!cond) { failures++; console.error('FAIL:', msg); } };
|
|
// An in-flight frame POST that rejects with nobody awaiting it is a DROPPED FRAME,
|
|
// not just console noise (see group 5b) — so the whole file runs under a sentinel.
|
|
const unhandled = [];
|
|
process.on('unhandledRejection', e => unhandled.push((e && e.message) || String(e)));
|
|
const eq = (a, b, msg) => ok(JSON.stringify(a) === JSON.stringify(b),
|
|
`${msg}\n got ${JSON.stringify(a)}\n expected ${JSON.stringify(b)}`);
|
|
|
|
// --- fakes -------------------------------------------------------------------
|
|
function fakeStage(calls, { width = 2560, height = 1440, clientWidth = 1280, clientHeight = 720,
|
|
dpr = 2 } = {}) {
|
|
const domElement = { width, height, clientWidth, clientHeight,
|
|
toBlob: cb => cb(new Blob(['png'])) };
|
|
return {
|
|
renderer: {
|
|
domElement,
|
|
setSize(...a) { calls.push(['setSize', ...a]); },
|
|
setPixelRatio(r) { calls.push(['dpr', r]); },
|
|
getPixelRatio: () => dpr,
|
|
},
|
|
pause() { calls.push(['pause']); },
|
|
resume() { calls.push(['resume']); },
|
|
renderActiveCamera() { calls.push(['render']); },
|
|
};
|
|
}
|
|
|
|
const fakeTimeline = (duration = 1, fps = 30) => ({
|
|
time: 0, duration, fps,
|
|
step(f) { this.time = f / fps; },
|
|
});
|
|
|
|
// every render/* endpoint the client touches, answered instantly
|
|
function stubFetch(log) {
|
|
globalThis.fetch = async (url, init = {}) => {
|
|
log.push(String(url));
|
|
const body = String(url).endsWith('/begin') ? { renderId: 'r1' }
|
|
: String(url).endsWith('/status') ? { state: 'done' }
|
|
: { ok: true };
|
|
return { ok: true, status: 200, json: async () => body, text: async () => '' };
|
|
};
|
|
}
|
|
|
|
// --- 1. pause -> render xN -> resume, and the CSS-size restore ---------------
|
|
{
|
|
const calls = [], urls = [];
|
|
stubFetch(urls);
|
|
const stage = fakeStage(calls);
|
|
await finalRender(stage, fakeTimeline(1, 30), { width: 1280, height: 720, fps: 30 });
|
|
|
|
const kinds = calls.map(c => c[0]);
|
|
const pauseAt = kinds.indexOf('pause'), resumeAt = kinds.indexOf('resume');
|
|
ok(pauseAt !== -1, 'stage.pause() was never called — editor chrome (gizmo/grid/cones) bakes into the mp4');
|
|
ok(resumeAt !== -1, 'stage.resume() was never called — the editor stays frozen after a render');
|
|
ok(kinds.filter(k => k === 'pause').length === 1, 'pause called more than once');
|
|
ok(kinds.filter(k => k === 'resume').length === 1, 'resume called more than once');
|
|
const renders = kinds.reduce((a, k, i) => (k === 'render' ? [...a, i] : a), []);
|
|
eq(renders.length, 30, '30 frames (duration 1s @30fps) should be rendered');
|
|
ok(renders.every(i => i > pauseAt && i < resumeAt),
|
|
'every renderActiveCamera must sit BETWEEN pause and resume');
|
|
// frames posted 0..29, then end, then status
|
|
eq(urls.filter(u => u.includes('/frame/')).length, 30, 'one POST per frame');
|
|
ok(urls.some(u => u.endsWith('render/r1/end')), 'endRender was not posted');
|
|
|
|
const sizes = calls.filter(c => c[0] === 'setSize');
|
|
eq(sizes[sizes.length - 1].slice(1, 3), [1280, 720],
|
|
'viewport must be restored in CSS px (1280x720), not device px (2560x1440) — ' +
|
|
'setSize multiplies by the pixel ratio, so device px grows the buffer every render');
|
|
eq(calls.filter(c => c[0] === 'dpr').map(c => c[1]), [1, 2], 'pixel ratio: forced to 1, then restored');
|
|
}
|
|
|
|
// --- 2. supersample: render 2x, deliver 1x -----------------------------------
|
|
{
|
|
const calls = [], urls = [];
|
|
stubFetch(urls);
|
|
await finalRender(fakeStage(calls), fakeTimeline(0.1, 30), { width: 1280, height: 720, fps: 30, ss: 2 });
|
|
const sizes = calls.filter(c => c[0] === 'setSize');
|
|
eq(sizes[0].slice(1, 3), [2560, 1440], 'ss:2 must render a 1280x720 deliverable at 2560x1440');
|
|
eq(sizes[sizes.length - 1].slice(1, 3), [1280, 720], 'restore is still the CSS size');
|
|
}
|
|
|
|
// --- 3. ss forced to 1 ABOVE 1080p (1080p itself still supersamples) ---------
|
|
{
|
|
const calls = [], urls = [];
|
|
stubFetch(urls);
|
|
await finalRender(fakeStage(calls), fakeTimeline(0.1, 30), { width: 2560, height: 1440, fps: 30 });
|
|
const sizes = calls.filter(c => c[0] === 'setSize');
|
|
eq(sizes[0].slice(1, 3), [2560, 1440],
|
|
'above 1080p ss is forced to 1 — a 2x buffer there blows past texture/body limits');
|
|
eq([ssFactor(1280, 720), ssFactor(1920, 1080), ssFactor(1920, 1081), ssFactor(3840, 2160),
|
|
ssFactor(1280, 720, 1)], [2, 2, 1, 1, 1], 'ssFactor thresholds');
|
|
// the default deliverable IS 1080p, so it must be the case that gets the 2x buffer
|
|
const c2 = [];
|
|
await finalRender(fakeStage(c2), fakeTimeline(0.1, 30), { width: 1920, height: 1080, fps: 30 });
|
|
eq(c2.filter(c => c[0] === 'setSize')[0].slice(1, 3), [3840, 2160],
|
|
'a 1920x1080 deliverable renders at 3840x2160 (this is why the frame cap is 40MB)');
|
|
}
|
|
|
|
// --- 4. the deliverable size is what render/begin declares -------------------
|
|
{
|
|
const bodies = [];
|
|
globalThis.fetch = async (url, init = {}) => {
|
|
if (String(url).endsWith('/begin')) bodies.push(JSON.parse(init.body));
|
|
const body = String(url).endsWith('/begin') ? { renderId: 'r1' }
|
|
: String(url).endsWith('/status') ? { state: 'done' } : { ok: true };
|
|
return { ok: true, status: 200, json: async () => body, text: async () => '' };
|
|
};
|
|
await finalRender(fakeStage([]), fakeTimeline(0.1, 30), { width: 1280, height: 720, fps: 30 });
|
|
eq([bodies[0].width, bodies[0].height], [1280, 720],
|
|
'render/begin must declare the OUTPUT size (meta.json describes the deliverable), not the 2x buffer');
|
|
}
|
|
|
|
// --- 5. pause/resume balanced when a frame POST throws ------------------------
|
|
{
|
|
const calls = [];
|
|
globalThis.fetch = async () => ({ ok: false, status: 500, json: async () => ({}), text: async () => '' });
|
|
const stage = fakeStage(calls);
|
|
let threw = false;
|
|
try {
|
|
await captureFrames(stage, fakeTimeline(1, 30), { renderId: 'r1', from: 0, to: 30 });
|
|
} catch { threw = true; }
|
|
ok(threw, 'a failing frame POST must reject');
|
|
ok(calls.filter(c => c[0] === 'resume').length === 1,
|
|
'resume() must run even when a frame POST throws (try/finally)');
|
|
}
|
|
|
|
// --- 5b. ONE failed frame POST must not be swallowed -------------------------
|
|
// The .finally() pulls each POST out of `inflight` as it settles, so a rejection
|
|
// nobody happened to be awaiting used to disappear: measured 29/30 frames stored
|
|
// and captureFrames RESOLVING. ffmpeg's %06d demuxer stops at the gap -> a short
|
|
// mp4 with state=done, and no error anywhere.
|
|
{
|
|
const stored = [];
|
|
globalThis.fetch = async url => {
|
|
const n = Number(String(url).split('/').pop());
|
|
if (String(url).includes('/frame/') && n === 3)
|
|
return { ok: false, status: 500, json: async () => ({}), text: async () => '' };
|
|
if (String(url).includes('/frame/')) stored.push(n);
|
|
return { ok: true, status: 200, json: async () => ({}), text: async () => '' };
|
|
};
|
|
let msg = null;
|
|
try {
|
|
await captureFrames(fakeStage([]), fakeTimeline(1, 30), { renderId: 'r1', from: 0, to: 30 });
|
|
} catch (e) { msg = e.message; }
|
|
ok(msg !== null, 'a single failed frame POST must reject the capture — it used to resolve, ' +
|
|
`storing ${stored.length}/30 frames and encoding a truncated mp4`);
|
|
ok(/frame 3/.test(msg || ''), `the rejection must name the frame that failed (got ${msg})`);
|
|
}
|
|
|
|
// --- 6. withRenderSize restores from device px when the panel is hidden -------
|
|
{
|
|
const calls = [];
|
|
const stage = fakeStage(calls, { clientWidth: 0, clientHeight: 0, width: 2560, height: 1440, dpr: 2 });
|
|
await withRenderSize(stage, 640, 360, async () => {});
|
|
const sizes = calls.filter(c => c[0] === 'setSize');
|
|
eq(sizes[sizes.length - 1].slice(1, 3), [1280, 720],
|
|
'hidden panel (clientWidth 0): fall back to device px / dpr, still CSS px');
|
|
}
|
|
|
|
// --- 7. the render must not leave a GHOST GIZMO in the viewport --------------
|
|
// Stage._chrome(true) re-shows chrome after every paused frame by FORCING
|
|
// visible = true on the TransformControls object — even when select() had
|
|
// detached it (nothing selected, or a frustum-fitted plate). This fake mirrors
|
|
// stage.js exactly: attach/detach drive _tc.visible (three r160 semantics), and
|
|
// _render brackets each paused draw with _chrome(false)/_chrome(true).
|
|
function chromeStage(calls, selected = null, live = ['e1']) {
|
|
const s = fakeStage(calls);
|
|
s._selected = selected;
|
|
s.getEntity = id => (live.includes(id) ? { id } : null);
|
|
s._tc = { visible: selected != null, object: selected ?? undefined };
|
|
s.select = id => { // stage.js select(): attach or detach
|
|
s._selected = id;
|
|
if (id == null) { s._tc.visible = false; s._tc.object = undefined; }
|
|
else { s._tc.visible = true; s._tc.object = id; }
|
|
calls.push(['select', id]);
|
|
};
|
|
s._chrome = v => { s._tc.visible = v; };
|
|
s.pause = () => { calls.push(['pause']); s._paused = true; };
|
|
s.resume = () => { calls.push(['resume']); s._paused = false; };
|
|
s.renderActiveCamera = () => { // stage.js _render()
|
|
calls.push(['render']);
|
|
if (s._paused) { s._chrome(false); /* renderer.render */ s._chrome(true); }
|
|
};
|
|
return s;
|
|
}
|
|
{
|
|
const calls = [], urls = [];
|
|
stubFetch(urls);
|
|
const nothingSelected = chromeStage(calls, null);
|
|
await finalRender(nothingSelected, fakeTimeline(0.1, 30), { width: 640, height: 360, fps: 30 });
|
|
ok(nothingSelected._tc.visible === false,
|
|
'nothing selected: the transform gizmo must still be hidden after a render — ' +
|
|
'_chrome(true) forces it visible, leaving a ghost gizmo floating in the viewport');
|
|
ok(nothingSelected._selected === null, 'the selection itself must not change across a render');
|
|
|
|
const calls2 = [];
|
|
const oneSelected = chromeStage(calls2, 'e1');
|
|
await finalRender(oneSelected, fakeTimeline(0.1, 30), { width: 640, height: 360, fps: 30 });
|
|
ok(oneSelected._tc.visible === true && oneSelected._tc.object === 'e1',
|
|
'something selected: its gizmo must come BACK after the render');
|
|
ok(oneSelected._selected === 'e1', 'selection preserved');
|
|
|
|
// film.js reloads a whole scene per shot: the remembered id can be gone by the
|
|
// time we put the gizmo back, and re-attaching to a dead entity is worse than
|
|
// no gizmo at all.
|
|
const calls3 = [];
|
|
const gone = chromeStage(calls3, 'e9', ['e1']); // e9 was in the PREVIOUS shot
|
|
await finalRender(gone, fakeTimeline(0.1, 30), { width: 640, height: 360, fps: 30 });
|
|
ok(gone._tc.visible === false && gone._selected === null,
|
|
'a selection whose entity no longer exists must end up deselected, not re-attached');
|
|
}
|
|
|
|
// --- 8. pause/resume are a REQUIREMENT, not a silent option ------------------
|
|
{
|
|
const noPause = fakeStage([]);
|
|
delete noPause.pause;
|
|
let threw = false;
|
|
try { await captureFrames(noPause, fakeTimeline(0.1, 30), { renderId: 'r1', from: 0, to: 1 }); }
|
|
catch (e) { threw = /pause/.test(e.message); }
|
|
ok(threw, 'a Stage without pause() must THROW — an optional-chained call would silently ' +
|
|
'bake the gizmo/grid/cones back into every mp4 with all tests green');
|
|
}
|
|
|
|
// --- 9. ...and the REAL Stage still has them --------------------------------
|
|
// pause/resume are not in PLAN §4.2's Stage contract, so nothing else pins them:
|
|
// this reads Lane A's live file so a rename/refactor fails HERE, not in an mp4.
|
|
{
|
|
const src = await readFile(new URL('../scenegod/web/stage.js', import.meta.url), 'utf8');
|
|
ok(/\bpause\s*\(\s*\)\s*\{[^}]*_paused\s*=\s*true/.test(src),
|
|
'web/stage.js must define pause() setting _paused = true (render.js depends on it)');
|
|
ok(/\bresume\s*\(\s*\)\s*\{[^}]*_paused\s*=\s*false/.test(src),
|
|
'web/stage.js must define resume() clearing _paused');
|
|
ok(/if\s*\(\s*this\._paused\s*\)\s*this\._chrome\(/.test(src),
|
|
'web/stage.js _render must gate editor chrome on _paused — that is what pause() buys');
|
|
ok(/this\._selected\s*=/.test(src),
|
|
'web/stage.js must keep the _selected field render.js re-asserts the gizmo from');
|
|
}
|
|
|
|
// --- 10. nothing anywhere above left a rejection on the floor ---------------
|
|
await new Promise(r => setTimeout(r, 50)); // let node flag any straggler
|
|
ok(unhandled.length === 0, `unhandled promise rejection(s): ${unhandled.join(', ')} — ` +
|
|
'in the render loop that means a frame POST failed and nobody noticed');
|
|
|
|
console.log(failures ? `FAILED: ${failures} assertion(s)` : 'OK: render.js client checks passed');
|
|
process.exit(failures ? 1 : 0);
|