#!/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);