Lane A: grammar.js (shot/angle/focal/light/mark data), presets.js (bbox shot solver, light/mark appliers, DIRECT bar UI, NL box), stage video plates (VideoTexture plane/corner/dome, screen kind, syncVideos), dock video cards. Lane B: scenegod:direct → grouped-undo keys/cuts, 🗣 lip-sync from audio via /rhubarb, Cmd+wheel zoom / wheel scroll, audio trim, video clock-follow via nullable entityVideo seam. Lane C: POST /director (LLM proxy, server-side op validation vs vocab+scene, mock-tested), render.js awaits stage.syncVideos. Sync6 (orchestrator): VIDEO_EXTS in server tree/media types (video primary, img poster as thumb), stage.entityVideo accessor, syncVideos forces videoTex.needsUpdate (rVFC never fires in hidden tabs → black plates). Verified: 9 server groups + timeline + presets suites green; browser SYNC: one-click MCU framing lands camera+fov keys+cut (single undo), golden_hour relight keys, plate scrub-follow frame-exact, finalRender mp4 shows the Flow golden-hour street plate behind an animated character. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
62 lines
3.4 KiB
JavaScript
62 lines
3.4 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 } from './grammar.js';
|
|
import { fovForFocal, solveShot, applyLight } 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');
|
|
|
|
console.log('presets_test: ALL PASS');
|