SCENEGOD/scenegod/web/presets_test.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

395 lines
26 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// presets_test.mjs — Lane A M5 self-check. Runs under plain `node`, no deps, no three.js
// (presets.js takes bboxes as plain arrays; expected values below are computed inline).
// node scenegod/web/presets_test.mjs
import assert from 'node:assert/strict';
import fs from 'node:fs';
import { LIGHTS, SHOTS, MOVES, FOCALS, MOVE_MAX_DUR } from './grammar.js';
import { fovForFocal, solveShot, applyLight, solveMove, moveDuration,
stepShot, stepFocal, applyOps, aspectFromSize } from './presets.js';
const near = (a, b, eps = 1e-9) => Math.abs(a - b) <= eps;
const R2D = 180 / Math.PI;
// ---- fov from focal: 2·atan(12/f) full-frame vertical ----
for(const [mm, want] of [[18, 2*Math.atan(12/18)*R2D], [35, 2*Math.atan(12/35)*R2D], [85, 2*Math.atan(12/85)*R2D]])
assert.ok(near(fovForFocal(mm), want, 1e-9), `fov(${mm}mm) = ${fovForFocal(mm)} != ${want}`);
assert.ok(near(fovForFocal(35), 37.849, 0.001), '35mm ≈ 37.849° vertical');
// ---- shot distance: CU frames tighter than MS on a 1.7m subject → shorter camera distance ----
const bbox = { bboxMin: [-0.3, 0, -0.2], bboxMax: [0.3, 1.7, 0.2] };
const cu = solveShot({ ...bbox, shot: 'CU', focal: 35 });
const ms = solveShot({ ...bbox, shot: 'MS', focal: 35 });
assert.ok(cu.dist < ms.dist, `CU dist ${cu.dist} !< MS dist ${ms.dist}`);
// exact: dist = (span·H/2) / tan(fovV/2)
const expDist = s => (SHOTS[s].span * 1.7 / 2) / Math.tan(Math.atan(12/35));
assert.ok(near(cu.dist, expDist('CU'), 1e-9) && near(ms.dist, expDist('MS'), 1e-9), 'dist formula');
// aim height sits at the shot's center fraction of subject height
assert.ok(near(cu.aim[1], 0.87 * 1.7, 1e-9), 'CU centers at .87H');
// ---- dutch angle sets roll (euler z), straight-on shot keeps yaw/pitch 0 ----
const dutch = solveShot({ ...bbox, shot: 'MS', angle: 'dutch', focal: 35 });
assert.ok(near(dutch.rot[2], 8 * Math.PI/180, 1e-9), `dutch roll ${dutch.rot[2]*R2D}° != 8°`);
assert.ok(near(dutch.rot[0], 0, 1e-9) && near(dutch.rot[1], 0, 1e-9), 'dutch: no stray pitch/yaw');
const eye = solveShot({ ...bbox, shot: 'MS', angle: 'eye', focal: 35 });
assert.ok(near(eye.rot[2], 0, 1e-9), 'eye level has no roll');
// low angle puts the camera below the aim point, looking up (positive pitch)
const low = solveShot({ ...bbox, shot: 'MS', angle: 'low', focal: 35 });
assert.ok(low.pos[1] < low.aim[1] && low.rot[0] > 0, 'low angle: camera below aim, pitched up');
// ---- applyLight('golden_hour') returns the grammar.js values verbatim ----
const calls = [];
const stub = {
_ents: [],
entities(){ return this._ents; },
async addEntity(d){ const en = { id: 'e' + (this._ents.length + 1), kind: d.kind,
label: d.label, params: { ...d.params } }; this._ents.push(en); return en; },
setParam(id, k, v){ calls.push(['param', id, k, v]); },
setTransform(id, t){ calls.push(['tf', id, t]); },
};
const g = LIGHTS.golden_hour;
const v = await applyLight(stub, 'golden_hour');
assert.deepEqual(v.sun, g.sun, 'sun values from grammar');
assert.deepEqual(v.ambient, g.ambient, 'ambient values from grammar');
assert.equal(v.bg, g.bg, 'bg from grammar');
assert.ok(v.sunId && v.ambientId, 'sun + ambient entities created');
assert.ok(calls.some(c => c[0] === 'param' && c[1] === v.sunId && c[2] === 'intensity' && c[3] === g.sun.intensity),
'sun intensity applied to stage');
assert.ok(calls.some(c => c[0] === 'param' && c[1] === v.ambientId && c[2] === 'bg' && c[3] === g.bg),
'bg applied via ambient entity');
// ---- per-preset ACES exposure (the renderer level that rebalances presets ~6 stops apart) ----
for(const [k, L] of Object.entries(LIGHTS)){
assert.ok(Number.isFinite(L.exposure), `LIGHTS.${k} has no finite exposure`);
assert.ok(L.exposure >= 0.5 && L.exposure <= 2, `LIGHTS.${k}.exposure ${L.exposure} outside [0.5, 2]`);
}
// the dark looks must be lifted relative to day, or ACES just crushes them (that is the point)
assert.ok(LIGHTS.night.exposure > LIGHTS.day.exposure, 'night is exposed up against day');
assert.ok(LIGHTS.horror.exposure > LIGHTS.day.exposure, 'horror is exposed up against day');
// second call reuses the same entities (no light spam)
const before = calls.length;
const nv = await applyLight(stub, 'noir');
assert.equal(stub._ents.length, 2, 'presets reuse the sun/ambient pair');
assert.equal(nv.exposure, LIGHTS.noir.exposure, 'applyLight returns the preset exposure');
assert.ok(calls.slice(before).some(c => c[0] === 'param' && c[1] === nv.ambientId
&& c[2] === 'exposure' && c[3] === LIGHTS.noir.exposure),
'exposure applied via the ambient entity (like bg → keyable + persists)');
// ================= M11 CAMERA MOVES =================
// Independent check that a camera state actually LOOKS AT a point: three's Euler('XYZ') builds
// the camera's +Z column as [sin y, sin x·cos y, cos x·cos y] (Matrix4.makeRotationFromEuler),
// and a camera looks down Z. So (pos aim) normalised must equal that column. Derived from
// three's matrix, NOT from lookAtEuler's internals — so it catches a wrong solver, not a typo.
const zAxis = ([x, y, z]) => [Math.sin(y), -Math.sin(x)*Math.cos(y), Math.cos(x)*Math.cos(y)];
const unit = v => { const n = Math.hypot(...v) || 1; return v.map(c => c/n); };
const dist = (a, b) => Math.hypot(a[0]-b[0], a[1]-b[1], a[2]-b[2]);
function aimsAt(state, aim, what){
const want = unit([state.pos[0]-aim[0], state.pos[1]-aim[1], state.pos[2]-aim[2]]), got = zAxis(state.rot);
for(let i = 0; i < 3; i++) assert.ok(near(want[i], got[i], 1e-9),
`${what}: camera does not aim at the subject (axis ${i}: ${got[i]} vs ${want[i]})`);
}
const SUBJ = { bboxMin: [-0.3, 0, -0.2], bboxMax: [0.3, 1.7, 0.2] }; // a 1.7m person at the origin
const mv = (move, o = {}) => solveMove({ ...SUBJ, shot: 'MS', angle: 'eye', focal: 35, move, ...o });
// the aim checker must have teeth: a camera turned 17° off its subject has to fail it
assert.throws(() => aimsAt({ pos: [0, 1.1, 3], rot: [0, 0.3, 0] }, [0, 1.1, 0], 'rigged'),
/does not aim/, 'aimsAt would catch a mis-aimed camera');
// ---- ladders ----
assert.equal(stepShot('MS', 1), 'MCU', 'push in: MS → MCU');
assert.equal(stepShot('MS', -1), 'MWS', 'pull out: MS → MWS');
assert.equal(stepShot('ECU', 1), 'ECU', 'tightest rung clamps');
assert.equal(stepFocal(35, 1), 50, 'zoom in: 35 → 50mm');
assert.equal(stepFocal(35, -1), 24, 'zoom out: 35 → 24mm');
assert.ok(stepFocal(85, 1) > 85 && stepFocal(18, -1) < 18, 'past the ends: keep going by factor');
// ---- PUSH IN / PULL OUT: dolly along the view axis ----
const push = mv('push_in'), pull = mv('pull_out');
assert.ok(dist(push.to.pos, push.aim) < dist(push.from.pos, push.aim), 'push in ends CLOSER');
assert.ok(dist(pull.to.pos, pull.aim) > dist(pull.from.pos, pull.aim), 'pull out ends FARTHER');
aimsAt(push.from, push.aim, 'push in start'); aimsAt(push.to, push.aim, 'push in end');
aimsAt(pull.from, pull.aim, 'pull out start'); aimsAt(pull.to, pull.aim, 'pull out end');
// the END distance is exactly the next rung's shot distance (MS → MCU), and it is a DOLLY:
// rotation and fov are untouched, and the move is purely along the start→aim ray.
assert.ok(near(dist(push.to.pos, push.aim), solveShot({ ...SUBJ, shot: 'MCU', focal: 35 }).dist, 1e-9),
'push in lands at the MCU distance');
for(let i = 0; i < 3; i++) assert.ok(near(push.to.rot[i], push.from.rot[i], 1e-12), 'dolly does not re-aim');
assert.equal(push.to.fov, push.from.fov, 'a dolly does NOT change fov');
const along = unit([push.aim[0]-push.from.pos[0], push.aim[1]-push.from.pos[1], push.aim[2]-push.from.pos[2]]);
const trav = [push.to.pos[0]-push.from.pos[0], push.to.pos[1]-push.from.pos[1], push.to.pos[2]-push.from.pos[2]];
const travU = unit(trav);
for(let i = 0; i < 3; i++) assert.ok(near(travU[i], along[i], 1e-9), 'push in travels along the view axis');
// already at the tightest rung → still moves in (clamp factor), still aims
const ecu = mv('push_in', { shot: 'ECU' });
assert.ok(dist(ecu.to.pos, ecu.aim) < dist(ecu.from.pos, ecu.aim), 'ECU push in still pushes');
aimsAt(ecu.to, ecu.aim, 'ECU push in end');
// ---- TRUCK: lateral, parallel, subject stays framed ----
for(const [name, sign] of [['truck_L', -1], ['truck_R', 1]]){
const t = mv(name);
const d0 = dist(t.from.pos, t.aim), d1 = dist(t.to.pos, t.aim);
assert.ok(Math.abs(d1/d0 - 1) < 0.1, `${name}: distance to subject roughly constant (${d0}${d1})`);
assert.deepEqual(t.to.rot, t.from.rot, `${name}: camera stays PARALLEL (no re-aim)`);
assert.equal(t.to.fov, t.from.fov, `${name}: no lens change`);
const dv = [t.to.pos[0]-t.from.pos[0], t.to.pos[1]-t.from.pos[1], t.to.pos[2]-t.from.pos[2]];
const view = unit([t.aim[0]-t.from.pos[0], t.aim[1]-t.from.pos[1], t.aim[2]-t.from.pos[2]]);
assert.ok(Math.hypot(...dv) > 0.2, `${name}: actually moves`);
assert.ok(near(dv[0]*view[0] + dv[1]*view[1] + dv[2]*view[2], 0, 1e-9), `${name}: purely lateral`);
assert.ok(near(dv[1], 0, 1e-12), `${name}: stays level`);
// the subject must still be inside the frame: lateral offset < half the frame width at that distance
const halfW = Math.tan(t.from.fov/2 * Math.PI/180) * d0 * (16/9);
assert.ok(Math.hypot(...dv) < halfW, `${name}: subject stays in frame (${Math.hypot(...dv)} < ${halfW})`);
// the subject faces +Z at yaw 0, so the camera sits on +Z looking Z: its own right is +X
assert.equal(Math.sign(dv[0]), sign, `${name}: goes to the camera's own ${sign < 0 ? 'left' : 'right'}`);
}
const tl = mv('truck_L'), tr = mv('truck_R');
for(const i of [0, 2]) assert.ok(near(tl.to.pos[i] - tl.from.pos[i], -(tr.to.pos[i] - tr.from.pos[i]), 1e-12),
'truck left is exactly the mirror of truck right');
// framing is span-locked, so the lateral METRES are the same on any lens (what changes is the angle
// swept). That is the whole point of frame-relative amounts — assert it, don't assume it.
const lat = m => m.to.pos[0] - m.from.pos[0];
assert.ok(near(lat(mv('truck_R', { focal: 85 })), lat(tr), 1e-9), 'truck is frame-relative, not lens-relative');
assert.ok(lat(mv('truck_R', { shot: 'WS' })) > lat(tr), 'a wider shot trucks farther');
// ---- CRANE: vertical + re-aim ----
const up = mv('crane_up'), down = mv('crane_down');
assert.ok(up.to.pos[1] > up.from.pos[1], 'crane up rises');
assert.ok(down.to.pos[1] < down.from.pos[1], 'crane down drops');
for(const i of [0, 2]){
assert.ok(near(up.to.pos[i], up.from.pos[i], 1e-12), 'crane is vertical only');
assert.ok(near(down.to.pos[i], down.from.pos[i], 1e-12), 'crane is vertical only');
}
aimsAt(up.to, up.aim, 'crane up end'); aimsAt(down.to, down.aim, 'crane down end');
// eye level starts flat; rising tips the lens DOWN (negative pitch), dropping tips it UP
assert.ok(near(up.from.rot[0], 0, 1e-9), 'eye-level start is flat');
assert.ok(up.to.rot[0] < -0.01, `crane up pitches down (${up.to.rot[0]})`);
assert.ok(down.to.rot[0] > 0.01, `crane down pitches up (${down.to.rot[0]})`);
assert.equal(up.to.fov, up.from.fov, 'crane changes no lens');
// ---- ORBIT: constant distance, exact sweep ----
for(const [name, sign] of [['orbit_L', -1], ['orbit_R', 1]]){
const o = mv(name);
assert.ok(near(dist(o.to.pos, o.aim), dist(o.from.pos, o.aim), 1e-9), `${name}: constant distance`);
assert.ok(near(o.to.pos[1], o.from.pos[1], 1e-12), `${name}: stays at its height`);
aimsAt(o.to, o.aim, `${name} end`);
const ang = p => Math.atan2(p[0] - o.aim[0], p[2] - o.aim[2]);
const swept = ((ang(o.to.pos) - ang(o.from.pos)) * 180/Math.PI + 540) % 360 - 180;
assert.ok(near(swept, sign * MOVES[name].degrees, 1e-9), `${name}: swept ${swept}° != ${sign*25}°`);
assert.equal(o.to.fov, o.from.fov, `${name}: no lens change`);
}
// a custom sweep is honoured (a 90° orbit_R on a subject at the origin lands due +X, still framed)
const q = mv('orbit_R', { degrees: 90 });
assert.ok(near(q.to.pos[0] - q.aim[0], dist(q.from.pos, q.aim), 1e-9) && near(q.to.pos[2], q.aim[2], 1e-9),
'a 90° orbit ends square to the side, at the same distance');
aimsAt(q.to, q.aim, '90° orbit end');
// one mental model: orbit left and truck left both leave toward the camera's left
assert.equal(Math.sign(mv('orbit_L').to.pos[0] - mv('orbit_L').from.pos[0]),
Math.sign(tl.to.pos[0] - tl.from.pos[0]), 'orbit left goes the same way as truck left');
// ---- ZOOM: fov ONLY. This is the assertion that keeps a zoom honestly distinct from a dolly. ----
const zin = mv('zoom_in'), zout = mv('zoom_out');
for(const [n, z] of [['zoom_in', zin], ['zoom_out', zout]]){
assert.deepEqual(z.to.pos, z.from.pos, `${n}: the camera does NOT move`);
assert.deepEqual(z.to.rot, z.from.rot, `${n}: the camera does NOT turn`);
assert.ok(near(dist(z.to.pos, z.aim), dist(z.from.pos, z.aim), 0), `${n}: distance to subject unchanged`);
}
assert.ok(zin.to.fov < zin.from.fov, 'zoom in narrows the fov');
assert.ok(zout.to.fov > zout.from.fov, 'zoom out widens it');
assert.ok(near(zin.to.fov, fovForFocal(50), 1e-12) && zin.endFocal === 50, 'zoom in = the next lens up');
// and the pair that proves the two are not the same button: push in moves and keeps fov,
// zoom in keeps position and changes fov — same intent, opposite mechanism.
assert.ok(push.to.fov === push.from.fov && !near(dist(push.to.pos, push.aim), dist(push.from.pos, push.aim), 1e-6),
'push in = position, not lens');
assert.ok(zin.to.pos.every((v, i) => v === zin.from.pos[i]) && zin.to.fov !== zin.from.fov,
'zoom in = lens, not position');
// ---- durations ----
assert.equal(moveDuration(0, 10), MOVE_MAX_DUR, 'whole scene → capped at the max');
assert.equal(moveDuration(8, 10), 2, 'default = what is left of the scene');
assert.equal(moveDuration(9.9, 10), 0.5, 'never shorter than half a second');
assert.equal(moveDuration(0, 0), MOVE_MAX_DUR, 'no timeline → the cap');
assert.equal(moveDuration(12, 10), MOVE_MAX_DUR, 'playhead past the end → the cap');
// ---- applyOps: the `move` event contract Lane B consumes, and "+ angle" ----
const fired = [];
globalThis.dispatchEvent = ev => { fired.push(ev.detail); return true; };
globalThis.window = { timeline: { time: 2, duration: 10 } }; // playhead at 2s in a 10s scene
const sstub = {
_ents: [], _n: 0, _active: null, _tf: {},
entities(){ return this._ents; },
getEntity(id){ return this._ents.find(e => e.id === id); },
async addEntity(d){ const en = { id: 'e' + (++this._n), kind: d.kind, label: d.label,
params: { ...d.params } }; this._ents.push(en); this._tf[en.id] = { pos:[0,0,0], rot:[0,0,0], scale:1 };
return en; },
entityTransform(id){ return this._tf[id]; },
entityBBox(id){ return id === 'subj' ? { min: SUBJ.bboxMin, max: SUBJ.bboxMax } : null; },
setTransform(id, t){ this._tf[id] = { ...this._tf[id], ...t }; },
setParam(id, k, v){ const e = this.getEntity(id); if(e) e.params[k] = v; },
setActiveCamera(id){ this._active = id; },
activeCamera(){ return this._active; },
viewAspect(){ return 16/9; },
};
sstub._ents.push({ id: 'subj', kind: 'character', label: 'man', params: {} });
sstub._tf.subj = { pos: [0,0,0], rot: [0,0,0], scale: 1 };
const [m1] = await applyOps(sstub, [{ op:'move', subject:'man', move:'push_in', shot:'MS', focal:35 }]);
assert.ok(!m1.error, 'move op applied: ' + m1.error);
const ev = fired.at(-1);
assert.equal(ev.op, 'move', 'op name');
for(const k of ['camId', 't0', 'dur', 'ease', 'from', 'to', 'entityIds'])
assert.ok(ev[k] !== undefined, 'move event carries ' + k);
assert.equal(ev.t0, 2, 't0 = the playhead');
assert.equal(ev.dur, 6, 'dur = rest of scene (8s) capped at 6');
assert.equal(ev.ease, 'inout', 'default ease');
assert.deepEqual(ev.entityIds, [ev.camId], 'entityIds = the camera');
for(const s of [ev.from, ev.to]){
assert.ok(Array.isArray(s.pos) && s.pos.length === 3 && s.pos.every(Number.isFinite), 'state pos');
assert.ok(Array.isArray(s.rot) && s.rot.length === 3 && s.rot.every(Number.isFinite), 'state rot');
assert.ok(Number.isFinite(s.fov), 'state fov');
}
// the START state is LIVE on the camera (the viewport shows the move's first frame)
assert.deepEqual(sstub.entityTransform(ev.camId).pos, ev.from.pos, 'start pos applied to the camera');
assert.deepEqual(sstub.entityTransform(ev.camId).rot, ev.from.rot, 'start rot applied to the camera');
assert.equal(sstub.getEntity(ev.camId).params.fov, ev.from.fov, 'start fov applied to the camera');
assert.equal(sstub._active, ev.camId, 'the move camera goes live');
// explicit dur/ease win; a bad ease falls back rather than shipping garbage to the timeline
const [m2] = await applyOps(sstub, [{ op:'move', subject:'man', move:'orbit_L', dur:2.5, ease:'linear' }]);
assert.equal(fired.at(-1).dur, 2.5, 'explicit dur'); assert.equal(fired.at(-1).ease, 'linear', 'linear ease');
await applyOps(sstub, [{ op:'move', subject:'man', move:'orbit_L', ease:'bouncy' }]);
assert.equal(fired.at(-1).ease, 'inout', 'unknown ease → inout');
assert.equal(m2.camId, ev.camId, 'a second move reuses the same shot camera');
assert.equal(sstub.entities().filter(e => e.kind === 'camera').length, 1, 'no camera spam');
// "+ angle" (newCam) — a NEW camera, existing ones untouched
const camA = ev.camId, posA = [...sstub.entityTransform(camA).pos];
const [s2] = await applyOps(sstub, [{ op:'shot', subject:'man', shot:'CU', angle:'low', focal:85, newCam:true }]);
assert.ok(!s2.error, '+ angle applied: ' + s2.error);
assert.notEqual(s2.camId, camA, '+ angle made a NEW camera');
assert.equal(sstub.getEntity(s2.camId).label, 'cam2', 'auto label cam2');
assert.deepEqual(sstub.entityTransform(camA).pos, posA, '+ angle leaves the existing camera alone');
assert.equal(sstub._active, s2.camId, 'the new angle goes live');
assert.equal(fired.at(-1).op, 'shot', '+ angle still emits a shot op (B keys + cuts it)');
const [s3] = await applyOps(sstub, [{ op:'shot', subject:'man', shot:'WS', newCam:true }]);
assert.equal(sstub.getEntity(s3.camId).label, 'cam3', 'next one is cam3');
assert.equal(sstub.entities().filter(e => e.kind === 'camera').length, 3, 'three cameras of coverage');
// 🎥 frame keeps adjusting WHAT YOU ARE LOOKING AT: with cam3 live it reframes cam3, makes no
// camera, and leaves the other two where they are (this is the bug + angle would otherwise cause).
const posB = [...sstub.entityTransform(camA).pos], pos2 = [...sstub.entityTransform(s2.camId).pos];
const [s4] = await applyOps(sstub, [{ op:'shot', subject:'man', shot:'MCU' }]);
assert.equal(s4.camId, s3.camId, '🎥 frame reframes the LIVE camera');
assert.equal(sstub.entities().filter(e => e.kind === 'camera').length, 3, 'no camera created');
assert.deepEqual(sstub.entityTransform(camA).pos, posB, 'other cameras untouched');
assert.deepEqual(sstub.entityTransform(s2.camId).pos, pos2, 'other cameras untouched');
sstub.setActiveCamera(null); // director orbit cam live → fall back to the old shotcam-or-first
const [s5] = await applyOps(sstub, [{ op:'shot', subject:'man', shot:'MS' }]);
assert.equal(s5.camId, camA, 'no active camera → the original shotcam, as before');
// ---- LIGHT ops: `exposure` must land as a KEY, not just on the renderer -----------------------
// The ACES level is a RENDERER global. Applying it per preset without keying it meant a second
// preset later in the scene rendered the whole EARLIER stretch at the LATER preset's level — in
// the viewport and in the mp4 — and saved it that way. Lane B's light branch keys sun
// colour/intensity/transform + ambient colour/intensity/bg and nothing else, so the exposure key
// rides the GENERIC op shape of the same handler: {op:'light', entityIds, params} with no
// sunId/sun (timeline.js applyDirect: `if (d.sunId != null && d.sun) … else` = the generic branch).
fired.length = 0;
const [lv] = await applyOps(sstub, [{ op:'light', preset:'night' }]);
assert.ok(!lv.error, 'light op applied: ' + lv.error);
const lightEvents = fired.filter(d => d.op === 'light');
const mainEv = lightEvents.find(d => d.sunId != null && d.sun);
assert.ok(mainEv, 'the preset still emits Lane B\'s light payload (sun + ambient + bg)');
const expEv = lightEvents.find(d => d.sunId == null && !d.sun);
assert.ok(expEv, 'a preset ALSO emits a generic-shape op so `exposure` gets keyed');
assert.deepEqual(expEv.entityIds, [lv.ambientId], 'keyed on the ambient entity, like bg');
assert.equal(expEv.params.exposure, LIGHTS.night.exposure, 'carrying the preset\'s exposure verbatim');
assert.deepEqual(Object.keys(expEv.params), ['exposure'],
'and ONLY exposure — the generic branch applies every param to every id in entityIds');
// …and end-to-end through Lane B's real Timeline when it is present (the page tolerates its
// absence, so this suite does too): two presets at different times, scrub back, correct level.
let Timeline = null;
try { ({ Timeline } = await import('./timeline.js')); } catch { /* Lane B absent — payload check above stands */ }
if(Timeline){
const chg = [];
const lstub = {
_ents: [], _n: 0, exposure: 1, bg: null,
entities(){ return this._ents; },
getEntity(id){ return this._ents.find(e => e.id === id); },
async addEntity(d){ const en = { id:'L' + (++this._n), kind:d.kind, label:d.label, params:{ ...d.params } };
this._ents.push(en); for(const cb of chg) cb(en); return en; },
entityTransform(id){ return this.getEntity(id)?._tf || { pos:[0,0,0], rot:[0,0,0], scale:1 }; },
setTransform(id, t){ const e = this.getEntity(id); if(e) e._tf = { pos:[0,0,0], rot:[0,0,0], scale:1, ...t }; },
setParam(id, k, v){ const e = this.getEntity(id); if(!e) return; e.params[k] = v;
if(k === 'exposure') this.exposure = v; if(k === 'bg') this.bg = v; },
entityBBox(){ return null; }, setActiveCamera(){}, activeCamera(){ return null; },
viewAspect(){ return 16/9; }, onChange(cb){ chg.push(cb); },
entityVideo(){ return null; }, entityMixer(){ return null; },
};
const prevWindow = globalThis.window, prevDispatch = globalThis.dispatchEvent;
globalThis.window = { addEventListener(){} };
const tl = new Timeline(lstub);
globalThis.window.timeline = tl;
globalThis.dispatchEvent = ev => { tl.applyDirect(ev.detail); return true; };
tl.seek(0); await applyOps(lstub, [{ op:'light', preset:'day' }]);
tl.seek(5); await applyOps(lstub, [{ op:'light', preset:'night' }]);
const ambRec = tl.scene.entities.find(x => x.params && x.params.type === 'ambient');
const expKeys = (ambRec.tracks.params || []).filter(k => k.key === 'exposure');
assert.equal(expKeys.length, 2, 'one exposure key per preset press (t=0 and t=5)');
tl.seek(0);
assert.equal(lstub.exposure, LIGHTS.day.exposure,
'scrubbing back to the day preset restores DAY\'s exposure, not night\'s');
assert.equal(lstub.bg, LIGHTS.day.bg, 'bg was always fine — exposure now behaves the same way');
tl.seek(5);
assert.equal(lstub.exposure, LIGHTS.night.exposure, 'and the night stretch is still night');
// ONE PRESS = ONE Ctrl+Z. A preset has to go out as TWO `scenegod:direct` events (Lane B's light
// payload, then the generic op that carries `exposure` — one event gets one branch), and Lane B
// gives every event its own group(), i.e. its own undo entry. So the first Ctrl+Z popped the
// exposure half ONLY: the preset's colours stayed keyed while the whole stretch rendered at the
// PREVIOUS preset's ACES level — under-exposed in the viewport and in the mp4, saved that way,
// with nothing in the UI to show it and no second undo that means "finish the job".
const sunRec = tl.scene.entities.find(x => x.params && x.params.type === 'key');
const keysAt = (rec, t) => (rec.tracks.params || []).filter(k => Math.abs(k.t - t) < 1e-9)
.map(k => k.key).sort();
const depth0 = tl.undoStack.length;
tl.seek(9); await applyOps(lstub, [{ op:'light', preset:'noir' }]);
assert.equal(tl.undoStack.length, depth0 + 1, 'a light preset costs exactly ONE undo entry');
assert.deepEqual(keysAt(ambRec, 9), ['bg', 'color', 'exposure', 'intensity'],
'the press keyed the whole ambient look at the playhead');
assert.deepEqual(keysAt(sunRec, 9), ['color', 'intensity'], '…and the sun with it');
tl.undo();
assert.deepEqual(keysAt(ambRec, 9), [], 'ONE undo takes the WHOLE look back — never just exposure');
assert.deepEqual(keysAt(sunRec, 9), [], '…including the half that came in on the other event');
assert.equal(tl.undoStack.length, depth0, '…and gives the stack back exactly as it found it');
tl.seek(5);
assert.equal(lstub.exposure, LIGHTS.night.exposure, 'the presses before it are untouched');
assert.equal(lstub.bg, LIGHTS.night.bg, 'colour and level still agree with each other');
globalThis.window = prevWindow; globalThis.dispatchEvent = prevDispatch;
}
// ---- ⬚ frame guide follows the DELIVERY size ---------------------------------------------------
// stage.setFrameAspect had no caller anywhere in the repo: the guide was hardwired to 16:9 and
// would have masked the wrong rectangle the moment a square/scope render size existed.
assert.ok(near(aspectFromSize('1920x1080'), 16/9, 1e-12), '1920x1080 → 16:9');
assert.equal(aspectFromSize('1080x1080'), 1, 'a square delivery is a square gate');
assert.ok(near(aspectFromSize('2048×858'), 2048/858, 1e-12), 'the × character parses too');
for(const bad of ['', null, undefined, 'nonsense', '1920x0', '0x1080', '1920-1080'])
assert.equal(aspectFromSize(bad), null, `garbage (${bad}) → null, never a NaN aspect`);
const presetsSrc = fs.readFileSync(new URL('./presets.js', import.meta.url), 'utf8');
assert.match(presetsSrc, /stage\.setFrameAspect\(/, 'presets.js actually calls setFrameAspect');
assert.match(presetsSrc, /select\.rsize/, '…off the render-size selector, not a hardcoded aspect');
// The first read cannot be scheduled on a timer: `select.rsize` is built by TimelineUI's
// constructor, which index.html reaches only after awaiting TWO dynamic imports — strictly later
// than the next macrotask, so a setTimeout(…, 0) always found null and the guide only ever
// followed the selector from its first `change` onward. mountDirectPanel returns the read; the
// page performs it the moment the timeline is up.
assert.doesNotMatch(presetsSrc.replace(/^\s*\/\/.*$/gm, ''), // …the CODE; the comment says why
/setTimeout\(\s*(readSize|syncFrameAspect)/,
'the first frame-aspect read is not racing two module imports on a 0ms timer');
assert.match(presetsSrc, /syncFrameAspect\s*\}/, 'mountDirectPanel hands the read back to its mounter');
const pageSrc = fs.readFileSync(new URL('./index.html', import.meta.url), 'utf8');
assert.match(pageSrc, /syncFrameAspect\?\.\(\)/, 'index.html performs that read');
assert.ok(pageSrc.indexOf('syncFrameAspect?.()') > pageSrc.indexOf('new TimelineUI('),
'…AFTER TimelineUI has created the selector, which is the whole point');
console.log('presets_test: ALL PASS (' + Object.keys(MOVES).length + ' moves; push in '
+ dist(push.from.pos, push.aim).toFixed(2) + 'm → ' + dist(push.to.pos, push.aim).toFixed(2)
+ 'm, orbit ±' + MOVES.orbit_R.degrees + '°, zoom ' + FOCALS[FOCALS.indexOf(35)] + '→'
+ zin.endFocal + 'mm at a fixed ' + dist(zin.from.pos, zin.aim).toFixed(2) + 'm)');