SCENEGOD/scripts/test_render_client.mjs
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

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