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>
395 lines
26 KiB
JavaScript
395 lines
26 KiB
JavaScript
// 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)');
|