MOVES grammar + solveMove(): push in/pull out (one rung along the shot ladder,
along the aim ray), truck L/R (lateral, parallel, no re-aim), crane up/down
(vertical then re-aim, floor-clamped), orbit L/R (constant distance, +/-25deg),
zoom in/out (focal only - pos and rot returned identical). Amounts are
frame-relative so a move reads the same on a CU as a WS. Emits a new 'move' op
on scenegod:direct with explicit from/to states, pre-clamped duration and ease;
the START is applied to the live camera immediately.
'+ angle' creates a NEW camera from the current shot settings instead of
reframing the active one - the friction hit while building a 4-camera scene by
hand. 🎥 frame now prefers the live camera so it adjusts what you just made
rather than silently editing camera 1. fitAnchor exposed in the backdrop
inspector (it was inert from the UI).
Verified live: push_in 1.975->1.588m fov fixed; zoom_in travel 0.000 with
pos/rot identical, fov 37.85->26.99; orbit_L constant distance; truck_R
parallel. 4 suites green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
274 lines
18 KiB
JavaScript
274 lines
18 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 { LIGHTS, SHOTS, MOVES, FOCALS, MOVE_MAX_DUR } from './grammar.js';
|
||
import { fovForFocal, solveShot, applyLight, solveMove, moveDuration,
|
||
stepShot, stepFocal, applyOps } 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');
|
||
// second call reuses the same entities (no light spam)
|
||
await applyLight(stub, 'noir');
|
||
assert.equal(stub._ents.length, 2, 'presets reuse the sun/ambient pair');
|
||
|
||
// ================= 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');
|
||
|
||
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)');
|