[m11-A] camera moves, + angle, fitAnchor in the inspector

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>
This commit is contained in:
type-two 2026-07-26 18:58:10 +10:00
parent 838edd0e1f
commit f20a8d5d4e
9 changed files with 594 additions and 20 deletions

View File

@ -242,3 +242,18 @@ don't revert:
NEXT for Lane A (not urgent): expose `fitAnchor`; the hard plate/floor horizon seam could use a
soft gradient; and lady's hips-travel `ratio` 0 (Z-up CC export) still means CC clips play in
place — fine for machinima (keyframe the walk) but worth a look if a walk cycle ever needs root motion.
### 2026-07-26 — M11-A ACCEPTED at SYNC 14. Move geometry verified live on music-video.
Fired all four families through the real DIRECT bar and measured the emitted payloads
against the subject: push_in travels 0.496m and closes 1.975→1.588m with fov FIXED; zoom_in
has pos AND rot deep-equal with travel 0.000 and fov 37.85→26.99; orbit_L travels 0.698m at
constant distance (1.975→1.973); truck_R keeps rot identical and shifts distance only 3.6%.
That is exactly how those four read on screen, and the dolly-vs-zoom pair is now honest in
the data, not just in a comment — the right call, and the assertion that pins it is the most
valuable one in presets_test.
Frame-relative amounts (multiples of span·H/2) so a move reads the same on a CU as a WS was
also the right instinct; it's what makes these presets rather than magic numbers.
Preferring the LIVE camera in 🎥 frame after "+ angle" is correct and you were right to
flag it — without it the new-angle button was a trap that silently edited camera 1.
NEXT for Lane A (unqueued ideas): the plate/floor horizon is still a hard seam and could
take a soft gradient; and contact shadows under characters would ground them further.

View File

@ -411,3 +411,133 @@ NEXT / orchestrator SYNC checklist (browser, :8020):
refits again. PiP should show the same filled frame.
5. Regression watch: existing saved scenes (`sync2-sunset`) have no `fit` param → plates must load at
their old size/place. Lighting will look stronger than before (see the `_refreshDefaults` note).
## 2026-07-26 session 7 (M11-A CAMERA MOVEMENT + "+ angle" + fitAnchor)
DONE — all three items, headless-verified (no browser, no git, :8020 untouched per orders):
**M11-A1 — move presets.** Every shot the app makes was locked off; now the solver places a camera
AND moves it. `grammar.js:60` MOVES (10, pure data) + `SHOT_ORDER` (:74) + `EASES` (:77) +
`MOVE_MAX_DUR` 6s (:78) + `DOLLY_CLAMP` (:79). `presets.js:87` `solveMove()` returns the same shape
`solveShot` does, twice: `{move, from:{pos,rot,fov}, to:{pos,rot,fov}, aim, dist, shot, angle, focal,
endShot, endFocal}`. START is byte-for-byte what 🎥 frame would solve for the panel's
{shot,angle,focal}; END is that state displaced. Per kind:
- **dolly** (push_in/pull_out): distance of one rung along SHOT_ORDER (MS→MCU 1.61m→1.12m on a 1.7m
subject at 35mm), travelling ALONG the start→aim ray — `rot` and `fov` untouched. At ECU/EWS the
rung runs out and it clamps by DOLLY_CLAMP (÷1.4 / ×1.4) so the button always does something.
- **truck** (L/R): lateral along level camera-right, `rot` COPIED (a truck stays parallel — it is not
a pan and not an orbit). Amount = 0.55 × the half-frame WIDTH, so the subject slides to ~the
outer third and stays in frame on any lens, and distance-to-subject shifts <6%.
- **crane** (up/down): vertical only, then re-aimed (`lookAtEuler`) so the subject stays framed;
amount = 1.2 × the half-frame HEIGHT. Floor-clamped at y=0.05, and the clamp re-aims too.
- **orbit** (L/R): rotates the horizontal offset about the aim, y held → distance constant to 1e-9,
sweep exactly ±25° (`degrees` overridable).
- **zoom** (in/out): one rung along FOCALS (35→50 / 35→24; past the ends ×1.6) — `pos` and `rot`
returned as identical arrays. A zoom is not a dolly and the code makes that structural, not a
comment.
Amounts are FRAME-RELATIVE (multiples of span·H/2) rather than metres, so a move reads the same on a
CU and a WS, an 18mm and an 85mm — asserted, not assumed. `moveCamera()` (`presets.js:143`) is the
applier: solves, then puts the camera on the START state immediately (viewport shows frame 1 of the
move) and returns the pair. `moveDuration()` (`:79`) = scene remaining from the playhead, min 0.5s,
capped at 6s; no timeline / past the end → the cap. UI: a MOVE segment in the DIRECT bar
(`presets.js:339`) — move select · dur box (blank = auto) · ease select · **🎬 move**.
**M11-A2 — "+ angle"** (`presets.js:331`, next to 🎥 frame). New op flag `newCam:true` on the
EXISTING `shot` op, so panel and /director still share one code path: `_camFor()` (`:235`) makes a
fresh camera labelled by `_camLabel()` (`:225` → cam2, cam3, … skipping taken labels), solves the
current shot/angle/lens onto it, activates it, touches nothing else. Emits an ordinary `shot` event,
so Lane B keys + cuts it exactly as today — coverage now takes one click instead of a console call.
**M11-A3 — fitAnchor** exposed (`dock.js:228`): 01 slider + live readout (new `_range` helper,
`dock.js:262`; styles `style.css:53`), shown next to `fit dist` when `fit:'frustum'`.
`stage.js:507` now refits on `fitAnchor` too (it was inert from the inspector before).
VERIFICATION (headless):
- `node scenegod/web/presets_test.mjs` → **ALL PASS (10 moves; push in 1.61m → 1.12m, orbit ±25°,
zoom 35→50mm at a fixed 1.61m)**. New blocks assert the geometry, not the plumbing: an
independent `aimsAt()` built from three's own `Euler('XYZ')` third column
(`[sin y, sin x·cos y, cos x·cos y]`) — NOT from `lookAtEuler`'s internals — plus a
`assert.throws` proving that checker fails a camera turned 17° off its subject. Then: push-in ends
closer / pull-out farther with both states still aimed; the push-in end distance EQUALS the MCU
solve; travel is parallel to the view axis; ECU push-in still pushes; truck distance within 10%,
displacement ⟂ to the view axis, level, rot identical, inside the half-frame width, L/R exact
mirrors, same metres at 85mm as at 35mm, farther on a WS; crane changes only y, re-aims, pitch
crosses from ~0 to negative (up) / positive (down); orbit distance equal to 1e-9, sweep exactly
±25°, a 90° orbit lands square to the side, orbit-left agrees with truck-left; zoom `pos`/`rot`
deepEqual and distance unchanged while fov moves — with the explicit push-in-vs-zoom-in pair
("position, not lens" / "lens, not position"). Plus the ladders, `moveDuration` in 5 states, and
an applyOps block (stub stage + shimmed `dispatchEvent`/`window.timeline`) asserting the exact
`move` event keys, t0 = playhead 2s, dur = 6 (8s remaining capped), explicit dur/ease honoured,
unknown ease → `inout`, the START state actually landing on the camera, no camera spam, and the
"+ angle" sequence cam2/cam3 with the other cameras' transforms byte-identical.
- `node scenegod/web/stage_test.mjs` and `room3d_test.mjs` unchanged → ALL PASS. Lane B's
`timeline_test.mjs` also still green (read-only run; their `applyDirect` ignores an unknown op, so
today's build no-ops on `move` instead of throwing).
- `node --check` clean: grammar.js, presets.js, stage.js, dock.js, presets_test.mjs.
**THE `move` OP CONTRACT (Lane B, next session) — emitted once per 🎬 move:**
```js
CustomEvent('scenegod:direct', { detail: {
op: 'move', camId, t0, dur, ease, // ease ∈ 'inout' | 'linear'
from: { pos:[x,y,z], rot:[x,y,z], fov }, // ALREADY applied to the live camera by Lane A
to: { pos:[x,y,z], rot:[x,y,z], fov },
entityIds: [camId],
// informational, safe to ignore: move, subjectId, shot, angle, focal
}})
```
Expected handling: inside one `group()``addKey(camId,'transform',{t:t0, ...from, ease})` +
`addKey(camId,'transform',{t:t0+dur, ...to, ease})`, and the same pair on `params` `fov` (fov is the
ONLY thing that changes on a zoom — key it always, it costs one key and keeps every move uniform).
A cut is your call: `addCut(t0, camId)` matches what `shot` does and is what I'd expect, but the
payload doesn't presume it. `t0` is my read of your playhead (`window.timeline.time`, 0 without a
timeline) — prefer your own if they ever disagree. `dur` is already clamped to the scene remainder,
so `t0+dur` never lands past `scene.duration`.
DECISIONS:
- **Push-in is a pure axis dolly** (rot + fov fixed), not "solve the tighter shot and interpolate".
Re-solving would also raise the aim (MS centre .65H → MCU .75H) — a dolly plus a covert tilt. The
rung is used for the DISTANCE only. It keeps the dolly/zoom pair a controlled experiment: same
intent, one variable each. A director who wants MCU composition picks MCU.
- **Frame-relative amounts, not metres.** Truck/crane amounts multiply span·H/2 (and ×aspect for the
truck), which is the framed half-height by construction. A fixed 1.5m truck is fine on a WS and
throws the subject out of frame on an 85mm CU; this can't.
- **The truck does not re-aim.** The lane brief says both "stays parallel" and "keeps the subject
framed"; those only co-exist if the lateral is bounded by the frame, so it is (0.55 of half-width)
and the test asserts the subject is still inside. Re-aiming would make it a tracking pan, which is
a different move and should be a different button if John wants one.
- **🎥 frame now prefers the LIVE camera** (`_camFor`, `presets.js:235`), falling back to
`shotcam`→first→create, i.e. the old order whenever no scene camera is active. Without this, "+
angle" was a trap: build cam2, adjust the size, and the edit silently lands on camera 1. Behaviour
is unchanged in every scene that existed before (🎥 frame activates the camera it frames).
- Both new Stage getters are one-liners and read-only: `activeCamera()` (`stage.js:587`) and
`viewAspect()` (`:634`, so the truck is bounded by the REAL frame, not an assumed 16:9). No
existing contract touched — Stage API, `syncVideos`/`entityVideo`, the shot/light/mark payloads,
scene-JSON round-trip and relative URLs are all as they were.
- `newCam` rides the existing `shot` op instead of a new `angle` op: one code path for buttons and
/director, and Lane B needs no change to key coverage cameras.
- Coverage cameras are numbered (cam2/cam3), not named `wide`/`close`: the label outlives the shot
size that produced it, and a lying label is worse than a boring one.
BLOCKED/REQUESTS:
- **@Lane B (the one thing that makes this real)**: implement `applyDirect` for `op:'move'` per the
contract above. Until then the camera snaps to the move's START and the END exists only in the
event — nothing animates.
- @orchestrator: no PLAN §4.1 schema change needed — moves land as ordinary transform/params keys.
Worth adding MOVES/EASES to the M5 grammar bullet in §5 when you next touch it.
- Still owed by John: a viseme-bearing test character (carried from session 4).
NEXT / orchestrator SYNC checklist (browser, :8020):
1. Select man → shot MS, lens 35mm → **🎬 move** with `push in`, dur blank, ease inout. Camera jumps
to the MS start and the PiP shows it. Console proof of the payload:
`addEventListener('scenegod:direct', e => console.log(e.detail), {once:true})` before pressing —
expect `op:'move'`, `t0` = the playhead, `dur` = min(6, scene remaining), `from`/`to` with `to`
closer. Once Lane B lands its handler, play should push in over the duration.
2. `zoom in` on the same subject: `from.pos` and `to.pos` must be IDENTICAL in that console line and
only `fov` differs (35→50mm ⇒ 37.85°→27.0°). That is the dolly-vs-zoom distinction on screen.
3. `orbit left` 25° then `crane up`: the subject stays centred through both ends (apply the END by
hand if B isn't ready: `stage.setTransform(camId,{pos:d.to.pos,rot:d.to.rot})`).
4. **+ angle**: with man selected, MS → 🎥 frame (cam becomes `shotcam`), then CU low 85mm →
**+ angle** → a NEW `cam2` appears in the dock/timeline rows, goes live, and `shotcam` has not
moved. Press 🎥 frame again → it now adjusts cam2 (the live one), creating nothing.
`scenes/music-video.json` (4 cams) is the scene to sanity-check that nothing else shifts.
5. Load a plate with `fit: frustum` → inspector shows **anchor** under `fit dist`; drag it 0.75→0.4
and the plate slides down live (horizon drops); 1.0 puts its top edge on the lens axis.

View File

@ -2171,3 +2171,22 @@ INFO: 127.0.0.1:59773 - "POST /render/ea9cb4e30f8744b7b3a8ba2534baa0df/frame
INFO: 127.0.0.1:59773 - "POST /render/ea9cb4e30f8744b7b3a8ba2534baa0df/frame/479 HTTP/1.1" 200 OK
INFO: 127.0.0.1:59773 - "POST /render/ea9cb4e30f8744b7b3a8ba2534baa0df/end HTTP/1.1" 200 OK
INFO: 127.0.0.1:59773 - "GET /render/ea9cb4e30f8744b7b3a8ba2534baa0df/status HTTP/1.1" 200 OK
INFO: 127.0.0.1:61806 - "GET /?cb=m11 HTTP/1.1" 200 OK
INFO: 127.0.0.1:61806 - "GET /web/style.css HTTP/1.1" 200 OK
INFO: 127.0.0.1:61807 - "GET /web/stage.js HTTP/1.1" 200 OK
INFO: 127.0.0.1:61808 - "GET /web/dock.js HTTP/1.1" 200 OK
INFO: 127.0.0.1:61807 - "GET /web/room3d.js HTTP/1.1" 200 OK
INFO: 127.0.0.1:61808 - "GET /web/presets.js HTTP/1.1" 200 OK
INFO: 127.0.0.1:61806 - "GET /favicon.ico HTTP/1.1" 404 Not Found
INFO: 127.0.0.1:61807 - "GET /assets/tree HTTP/1.1" 200 OK
INFO: 127.0.0.1:61807 - "GET /web/grammar.js HTTP/1.1" 200 OK
INFO: 127.0.0.1:61807 - "GET /director HTTP/1.1" 405 Method Not Allowed
INFO: 127.0.0.1:61808 - "GET /web/timeline.js HTTP/1.1" 200 OK
INFO: 127.0.0.1:61808 - "GET /web/tlui.js HTTP/1.1" 200 OK
INFO: 127.0.0.1:61935 - "GET /scenes/music-video HTTP/1.1" 200 OK
INFO: 127.0.0.1:61935 - "GET /assets/thumb?path=backdrops%2Fvideo%2Fscenegod_plates%2Fsuburb_strip_goldenhour.mp4 HTTP/1.1" 200 OK
INFO: 127.0.0.1:61935 - "GET /assets/file?path=characters%2Ftest%2Fman.fbx HTTP/1.1" 200 OK
INFO: 127.0.0.1:61935 - "GET /assets/file?path=characters%2Ftest%2Flady.glb HTTP/1.1" 200 OK
INFO: 127.0.0.1:61935 - "GET /assets/file?path=animations%2Ftest%2Fcarrying.fbx HTTP/1.1" 200 OK
INFO: 127.0.0.1:62391 - "GET /scenes/music-video HTTP/1.1" 200 OK
INFO: 127.0.0.1:62391 - "GET /assets/file?path=characters%2Ftest%2Flady.glb HTTP/1.1" 200 OK

View File

@ -223,6 +223,10 @@ export class Dock {
v => { this.stage.setParam(en.id, 'fit', v); this.renderInspector(en); }));
if(en.params.fit === 'frustum'){
el.appendChild(this._num('fit dist', en.params.fitDist ?? 14, v => this.stage.setParam(en.id, 'fitDist', v)));
// which fraction of the plate sits on the lens axis: 1 = top edge at eye level (void above),
// 0.5 = dead centre, 0.75 = a typical plate's horizon at eye level (SYNC7 default)
el.appendChild(this._range('anchor', en.params.fitAnchor ?? 0.75,
v => this.stage.setParam(en.id, 'fitAnchor', v)));
const re = document.createElement('button'); re.textContent = '⤢ refit to camera'; re.className = 'wide';
re.onclick = () => this.stage.setParam(en.id, 'fit', 'frustum'); // after moving the camera
el.appendChild(re);
@ -255,6 +259,12 @@ export class Dock {
i.oninput = () => cb(i.value); return this._field(label, i); }
_chk(label, val, cb){ const i = document.createElement('input'); i.type='checkbox'; i.checked=!!val;
i.onchange = () => cb(i.checked); return this._field(label, i); }
_range(label, val, cb, min=0, max=1, step=0.05){ // 01 slider + live readout
const i = document.createElement('input'); i.type='range'; i.min=min; i.max=max; i.step=step; i.value=val;
const w = this._field(label, i), n = document.createElement('b'); n.className='rv';
n.textContent = (+val).toFixed(2);
i.oninput = () => { n.textContent = (+i.value).toFixed(2); cb(parseFloat(i.value)); };
w.appendChild(n); return w; }
_sel(label, opts, val, cb){ const s = document.createElement('select');
for(const o of opts){ const op = document.createElement('option'); op.value=op.textContent=o; if(o===val) op.selected=true; s.appendChild(op); }
s.onchange = () => cb(s.value); return this._field(label, s); }

View File

@ -47,6 +47,37 @@ export const LIGHTS = {
ambient: { color: '#c7cdd6', intensity: 1.4 }, bg: '#9aa3ad' },
};
// Camera MOVES (M11): each one solves to a START and an END camera state off the same subject.
// `kind` selects the solver in presets.js. Amounts are FRAME-RELATIVE (multiples of the framed
// half-height / half-width, which is span·H/2 by definition) so a move reads the same on a
// close-up and a wide, on an 18mm and an 85mm — never a magic metre count.
// dolly push/pull along the camera's own view axis; distance of one shot size (rot+fov fixed)
// truck lateral, camera stays PARALLEL (rot fixed); amount = fraction of the half-frame WIDTH
// crane vertical, re-aims at the subject; amount = fraction of the half-frame HEIGHT
// orbit arc around the subject at constant distance; degrees of sweep
// zoom focal length ONLY — pos+rot identical, fov changes (a zoom is not a dolly)
// dir: 1 = camera-left / down, +1 = camera-right / up. step: rungs along SHOT_ORDER / FOCALS.
export const MOVES = {
push_in: { kind:'dolly', step: 1, label:'push in' },
pull_out: { kind:'dolly', step: -1, label:'pull out' },
truck_L: { kind:'truck', dir: -1, amount: 0.55, label:'truck left' },
truck_R: { kind:'truck', dir: 1, amount: 0.55, label:'truck right' },
crane_up: { kind:'crane', dir: 1, amount: 1.2, label:'crane up' },
crane_down: { kind:'crane', dir: -1, amount: 1.2, label:'crane down' },
orbit_L: { kind:'orbit', dir: -1, degrees: 25, label:'orbit left' },
orbit_R: { kind:'orbit', dir: 1, degrees: 25, label:'orbit right' },
zoom_in: { kind:'zoom', step: 1, label:'zoom in' },
zoom_out: { kind:'zoom', step: -1, label:'zoom out' },
};
// Shot ladder, wide → tight. push_in/pull_out step one rung along it; ends clamp (see DOLLY_CLAMP).
export const SHOT_ORDER = ['EWS', 'WS', 'MWS', 'MS', 'MCU', 'CU', 'ECU'];
// Move timing: `inout` for anything with a human on the wheel, `linear` for mechanical/motion-control.
export const EASES = ['inout', 'linear'];
export const MOVE_MAX_DUR = 6; // seconds — default duration is the scene's remainder, capped here
export const DOLLY_CLAMP = 1.4; // already at ECU/EWS → dolly this factor closer (÷) / farther (×)
// Blocking marks, stage-relative. pos marks move (keep current y); face marks rotate.
export const MARKS = {
center: { pos: [0, 0, 0] },

View File

@ -3,7 +3,7 @@
// inlined, so the solvers run headless under `node` (presets_test.mjs). Pure functions RETURN
// the values they apply — Lane B keys those values at the playhead off `scenegod:direct`
// events; this file never touches the timeline.
import { SHOTS, ANGLES, FOCALS, LIGHTS, MARKS } from './grammar.js';
import { SHOTS, ANGLES, FOCALS, LIGHTS, MARKS, MOVES, SHOT_ORDER, EASES, MOVE_MAX_DUR, DOLLY_CLAMP } from './grammar.js';
const D2R = Math.PI / 180;
@ -54,6 +54,73 @@ export function solveShot({ bboxMin, bboxMax, yaw = 0, shot = 'MS', angle = 'eye
return { pos, rot: lookAtEuler(pos, aim, A.roll), fov, dist, aim };
}
// ---- M11 camera moves: solveShot twice (a START and an END state) ----
const _sub = (a, b) => [a[0]-b[0], a[1]-b[1], a[2]-b[2]];
const _len = v => Math.hypot(v[0], v[1], v[2]);
// camera-right, level with the floor (a truck is a floor move — dutch roll doesn't tilt the rails)
function _right(pos, aim){
const v = _sub(aim, pos), n = _len(v) || 1, d = [v[0]/n, v[1]/n, v[2]/n];
const r = [-d[2], 0, d[0]], rn = Math.hypot(r[0], r[2]);
return rn < 1e-6 ? [1, 0, 0] : [r[0]/rn, 0, r[2]/rn]; // straight down → arbitrary but stable
}
// one rung along the ladders; returns the input when there's no rung left (caller clamps by factor)
export function stepShot(shot, step){
const i = SHOT_ORDER.indexOf(shot); if(i < 0) return shot;
const j = i + step; return (j < 0 || j >= SHOT_ORDER.length) ? shot : SHOT_ORDER[j];
}
export function stepFocal(mm, step){
let i = 0; for(let k = 1; k < FOCALS.length; k++)
if(Math.abs(FOCALS[k] - mm) < Math.abs(FOCALS[i] - mm)) i = k;
const j = i + step;
return (j < 0 || j >= FOCALS.length) ? mm * (step > 0 ? 1.6 : 1/1.6) : FOCALS[j];
}
// Default move length: what's left of the scene from the playhead, capped (a 40s creep is a bug,
// not a move); no timeline / playhead past the end → the cap.
export function moveDuration(t0, sceneDur, cap = MOVE_MAX_DUR){
const rest = (sceneDur || 0) - (t0 || 0);
return rest > 0 ? Math.max(0.5, Math.min(cap, rest)) : cap;
}
// Deterministic move solver — START is exactly what 🎥 frame would give for {shot,angle,focal},
// END is that state displaced by the move. Both states are {pos, rot, fov}, same shape solveShot
// returns, so Lane B keys `from` at t0 and `to` at t0+dur and the camera does the rest.
export function solveMove({ bboxMin, bboxMax, yaw = 0, shot = 'MS', angle = 'eye', focal = 35,
otherPos = null, move = 'push_in', aspect = 16/9,
degrees = null, amount = null }){
const M = MOVES[move]; if(!M) throw new Error('unknown move: ' + move);
const A = ANGLES[angle] || ANGLES.eye;
const base = { bboxMin, bboxMax, yaw, angle, focal, otherPos };
const a = solveShot({ ...base, shot });
const aim = a.aim, H = Math.max(0.01, bboxMax[1] - bboxMin[1]);
const half = (SHOTS[shot] || SHOTS.MS).span * H / 2; // half the framed height, metres
const from = { pos: a.pos.slice(), rot: a.rot.slice(), fov: a.fov };
const reaim = pos => ({ pos, rot: lookAtEuler(pos, aim, A.roll), fov: a.fov });
let to, endShot = shot, endFocal = focal;
if(M.kind === 'dolly'){ // along the view axis: rot + fov FIXED
endShot = stepShot(shot, M.step);
const endDist = endShot === shot ? a.dist * (M.step > 0 ? 1/DOLLY_CLAMP : DOLLY_CLAMP)
: solveShot({ ...base, shot: endShot }).dist;
const v = _sub(a.pos, aim), n = _len(v) || 1, k = endDist / n;
const pos = [aim[0] + v[0]*k, Math.max(0.05, aim[1] + v[1]*k), aim[2] + v[2]*k];
to = reaim(pos); // same ray ⇒ same rot; only a floor-clamped pull-out re-aims at all
} else if(M.kind === 'truck'){ // parallel: rot copied, never re-aimed
const lat = (amount == null ? M.amount : amount) * half * aspect * M.dir;
const r = _right(a.pos, aim);
to = { pos: [a.pos[0] + r[0]*lat, a.pos[1], a.pos[2] + r[2]*lat], rot: from.rot.slice(), fov: a.fov };
} else if(M.kind === 'crane'){ // vertical, re-aimed so it stays framed
const rise = (amount == null ? M.amount : amount) * half * M.dir;
to = reaim([a.pos[0], Math.max(0.05, a.pos[1] + rise), a.pos[2]]);
} else if(M.kind === 'orbit'){ // arc at constant distance
const th = (degrees == null ? M.degrees : degrees) * M.dir * D2R;
const v = _sub(a.pos, aim), c = Math.cos(th), s = Math.sin(th);
to = reaim([aim[0] + v[0]*c + v[2]*s, a.pos[1], aim[2] - v[0]*s + v[2]*c]);
} else { // zoom: focal ONLY. Not a dolly.
endFocal = stepFocal(focal, M.step);
to = { pos: from.pos.slice(), rot: from.rot.slice(), fov: fovForFocal(endFocal) };
}
return { move, from, to, aim, dist: a.dist, shot, angle, focal, endShot, endFocal };
}
const _fire = detail => { if(typeof dispatchEvent !== 'undefined')
dispatchEvent(new CustomEvent('scenegod:direct', { detail })); };
@ -70,6 +137,23 @@ export function frameSubject(stage, camId, subjectId, { shot = 'MS', angle = 'ey
return { camId, subjectId, otherId, shot, angle, focal, ...solved };
}
// Solve a move on `camId` and put the camera on its START state now (the viewport shows the
// move's first frame the moment you press the button — same deal as frameSubject). The END state
// only exists as data: Lane B keys it at t0+dur off the `move` event.
export function moveCamera(stage, camId, subjectId, { move = 'push_in', shot = 'MS', angle = 'eye',
focal = 35, otherId = null, degrees = null, amount = null } = {}){
const bb = stage.entityBBox(subjectId);
if(!bb) throw new Error('moveCamera: no bbox for ' + subjectId);
const yaw = (stage.entityTransform(subjectId)?.rot || [0,0,0])[1];
const otherPos = otherId ? stage.entityTransform(otherId)?.pos : null;
const aspect = (stage.viewAspect && stage.viewAspect()) || 16/9; // truck is framed in the real frame
const solved = solveMove({ bboxMin: bb.min, bboxMax: bb.max, yaw, shot, angle, focal,
otherPos, move, aspect, degrees, amount });
stage.setTransform(camId, { pos: solved.from.pos, rot: solved.from.rot, scale: 1 });
stage.setParam(camId, 'fov', solved.from.fov);
return { camId, subjectId, otherId, ...solved };
}
// Relight the whole scene from one named look. Ensures a sun (key) + ambient entity exist,
// writes LIGHTS[name] verbatim (returned for keying), aims the sun boom at the stage center.
export async function applyLight(stage, name){
@ -127,7 +211,8 @@ export function applyMark(stage, id, mark){
}
// ---- op runner: ONE path for panel buttons and /director NL ops ----
// shapes: {op:'shot', subject, shot, angle, focal, other?} {op:'light', preset}
// shapes: {op:'shot', subject, shot, angle, focal, other?, newCam?} {op:'light', preset}
// {op:'move', subject, move, shot, angle, focal, dur?, ease?, t0?, newCam?}
// {op:'mark', subject, mark} — subject/other resolve by entity id or label.
function _resolve(stage, ref){
if(ref == null) return null;
@ -136,6 +221,29 @@ function _resolve(stage, ref){
const en = stage.entities().find(e => (e.label || '').toLowerCase() === s);
return en ? en.id : null;
}
// next free cam2/cam3/… — coverage cameras are numbered, never named after a shot that will change
function _camLabel(stage){
const cams = stage.entities().filter(e => e.kind === 'camera');
const taken = new Set(cams.map(e => String(e.label || '').toLowerCase()));
let n = cams.length + 1;
while(taken.has('cam' + n)) n++;
return 'cam' + n;
}
// `newCam` = the "+ angle" case: a brand-new camera for coverage, every existing one untouched.
// Otherwise reframe what you're looking at: the LIVE camera first (once + angle exists, falling back
// to 'shotcam' would silently redirect a 4-camera scene's edits to camera 1), then shotcam, then any.
async function _camFor(stage, o){
if(o.newCam) return stage.addEntity({ kind:'camera', label:_camLabel(stage), params:{ fov:45 } });
const live = stage.activeCamera && stage.activeCamera();
return (live && stage.getEntity(live)?.kind === 'camera' ? stage.getEntity(live) : null)
|| stage.entities().find(e => e.kind === 'camera' && e.label === 'shotcam')
|| stage.entities().find(e => e.kind === 'camera')
|| await stage.addEntity({ kind:'camera', label:'shotcam', params:{ fov:45 } });
}
// playhead / scene length, when a timeline is mounted (moves default to "the rest of the scene")
const _tl = () => (typeof window !== 'undefined' && window.timeline) || null;
const _playhead = () => { const t = _tl(); return t && typeof t.time === 'number' ? t.time : 0; };
const _sceneDur = () => { const t = _tl(); return t && typeof t.duration === 'number' ? t.duration : 0; };
export async function applyOps(stage, ops, selectedId = null){
const applied = [];
for(const o of (ops || [])){
@ -150,20 +258,32 @@ export async function applyOps(stage, ops, selectedId = null){
const v = applyMark(stage, id, o.mark);
_fire({ op:'mark', ...v, entityIds:[id] });
applied.push(v);
} else if(o.op === 'shot'){
} else if(o.op === 'shot' || o.op === 'move'){
const subjectId = _resolve(stage, o.subject) ?? selectedId;
if(!subjectId) throw new Error('shot: no subject');
if(!subjectId) throw new Error(o.op + ': no subject');
const A = ANGLES[o.angle];
const otherId = _resolve(stage, o.other)
?? ((A && A.ots) ? stage.entities().find(e => e.kind === 'character' && e.id !== subjectId)?.id : null);
let cam = stage.entities().find(e => e.kind === 'camera' && e.label === 'shotcam')
|| stage.entities().find(e => e.kind === 'camera');
if(!cam) cam = await stage.addEntity({ kind:'camera', label:'shotcam', params:{ fov:45 } });
const v = frameSubject(stage, cam.id, subjectId,
{ shot:o.shot, angle:o.angle, focal:o.focal, otherId });
stage.setActiveCamera(cam.id);
_fire({ op:'shot', ...v, entityIds:[cam.id, subjectId] });
applied.push(v);
const cam = await _camFor(stage, o);
if(o.op === 'shot'){
const v = frameSubject(stage, cam.id, subjectId,
{ shot:o.shot, angle:o.angle, focal:o.focal, otherId });
stage.setActiveCamera(cam.id);
_fire({ op:'shot', ...v, entityIds:[cam.id, subjectId] });
applied.push(v);
} else {
const t0 = o.t0 != null ? +o.t0 : _playhead();
const dur = o.dur != null && +o.dur > 0 ? +o.dur : moveDuration(t0, _sceneDur());
const ease = EASES.includes(o.ease) ? o.ease : 'inout';
const v = moveCamera(stage, cam.id, subjectId, { move:o.move, shot:o.shot, angle:o.angle,
focal:o.focal, otherId, degrees:o.degrees, amount:o.amount });
stage.setActiveCamera(cam.id);
// CONTRACT (Lane B): camId/t0/dur/ease/from/to/entityIds are load-bearing — key `from` at
// t0 and `to` at t0+dur (transform + fov, `ease` on both). The rest is informational.
_fire({ op:'move', camId:cam.id, t0, dur, ease, from:v.from, to:v.to, entityIds:[cam.id],
move:v.move, subjectId, shot:v.shot, angle:v.angle, focal:v.focal });
applied.push({ ...v, t0, dur, ease });
}
} else throw new Error('unknown op: ' + o.op);
} catch(err){ applied.push({ error: String(err), op: o.op }); }
}
@ -172,7 +292,7 @@ export async function applyOps(stage, ops, selectedId = null){
// ---- DIRECT panel UI (browser only — never imported by the tests) ----
export function mountDirectPanel(stage, el){
const st = { shot:'MS', angle:'eye', focal:35, sel:null };
const st = { shot:'MS', angle:'eye', focal:35, move:'push_in', ease:'inout', sel:null };
stage.onSelect(en => { st.sel = en ? en.id : null; });
el.innerHTML = '';
@ -199,12 +319,43 @@ export function mountDirectPanel(stage, el){
for(const f of FOCALS){ const o = document.createElement('option');
o.value = f; o.textContent = f + 'mm'; if(f === st.focal) o.selected = true; sel.appendChild(o); }
sel.onchange = () => st.focal = +sel.value; lens.appendChild(sel);
btn(lens, '🎥 frame', async () => {
const subject = () => { // shot/move/angle all need a framable subject selected
const en = st.sel && stage.getEntity(st.sel);
if(!en || (en.kind !== 'character' && en.kind !== 'prop')) return note('select a character/prop first');
return (en && (en.kind === 'character' || en.kind === 'prop')) ? en : null;
};
btn(lens, '🎥 frame', async () => {
const en = subject(); if(!en) return note('select a character/prop first');
const [r] = await applyOps(stage, [{ op:'shot', subject:en.id, shot:st.shot, angle:st.angle, focal:st.focal }]);
note(r.error ? String(r.error) : `${st.shot} ${st.angle} ${st.focal}mm`);
}, 'solve + move the shot camera');
}, 'solve + move the ACTIVE shot camera');
btn(lens, '+ angle', async () => {
const en = subject(); if(!en) return note('select a character/prop first');
const [r] = await applyOps(stage, [{ op:'shot', subject:en.id, shot:st.shot, angle:st.angle,
focal:st.focal, newCam:true }]);
note(r.error ? String(r.error) : `+ ${stage.getEntity(r.camId)?.label || 'camera'} · ${st.shot} ${st.angle} ${st.focal}mm`);
}, 'NEW camera on this subject — coverage; every existing camera stays put');
// MOVE: solve a start+end pair. The camera jumps to the start now; Lane B keys the end at t0+dur.
const mv = seg('moves', 'move');
const msel = document.createElement('select');
for(const k of Object.keys(MOVES)){ const o = document.createElement('option');
o.value = k; o.textContent = MOVES[k].label; if(k === st.move) o.selected = true; msel.appendChild(o); }
msel.onchange = () => st.move = msel.value; mv.appendChild(msel);
const dur = document.createElement('input');
dur.type = 'number'; dur.step = '0.5'; dur.min = '0.1'; dur.placeholder = 'auto'; dur.className = 'dur';
dur.title = 'seconds — blank = the rest of the scene, capped at ' + MOVE_MAX_DUR + 's';
mv.appendChild(dur);
const esel = document.createElement('select');
for(const e of EASES){ const o = document.createElement('option'); o.value = o.textContent = e;
if(e === st.ease) o.selected = true; esel.appendChild(o); }
esel.onchange = () => st.ease = esel.value; mv.appendChild(esel);
btn(mv, '🎬 move', async () => {
const en = subject(); if(!en) return note('select a character/prop first');
const d = parseFloat(dur.value);
const [r] = await applyOps(stage, [{ op:'move', subject:en.id, move:st.move, shot:st.shot,
angle:st.angle, focal:st.focal, ease:st.ease, dur: isFinite(d) && d > 0 ? d : undefined }]);
note(r.error ? String(r.error) : `${MOVES[st.move].label} · ${r.dur.toFixed(1)}s ${r.ease}`);
}, 'solve the move: the start goes live now, the timeline keys the end');
const lights = seg('lights', 'light');
for(const k of Object.keys(LIGHTS)) btn(lights, k.replace('_',' '), async () => {

View File

@ -2,8 +2,9 @@
// (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';
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;
@ -58,4 +59,215 @@ assert.ok(calls.some(c => c[0] === 'param' && c[1] === v.ambientId && c[2] === '
await applyLight(stub, 'noir');
assert.equal(stub._ents.length, 2, 'presets reuse the sun/ambient pair');
console.log('presets_test: ALL PASS');
// ================= 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)');

View File

@ -504,7 +504,7 @@ export class Stage {
}
if(en.kind === 'backdrop'){
if(key === 'fit') value === 'frustum' ? this._fitBackdrop(en) : this._unfitBackdrop(en);
if(key === 'fitDist') this._fitBackdrop(en);
if(key === 'fitDist' || key === 'fitAnchor') this._fitBackdrop(en);
if(key === 'groundTint') this._applyWorld();
}
const rebuild = () => this._rebuild(en).catch(e => console.warn('media rebuild failed:', en.id, e));
@ -584,6 +584,7 @@ export class Stage {
this._pip.style.display = (id && this._entities.get(id)?.cam) ? 'block' : 'none';
this.fitBackdrops(); // auto-fit plates belong to whichever camera is shooting
}
activeCamera(){ return this._activeCamId || null; } // entity id, or null = director orbit cam
_activeCam(){ const en = this._activeCamId && this._entities.get(this._activeCamId);
return en && en.cam ? en.cam : this._dirCam; }
// render `cam` to the main WebGL canvas at the target's aspect; if `canvas` given, blit there and
@ -630,6 +631,7 @@ export class Stage {
cameraWorldPos(){ // active camera if set, else the director orbit cam
const p = new THREE.Vector3(); this._activeCam().getWorldPosition(p); return p.toArray();
}
viewAspect(){ return this._activeAspect(); } // M11: a truck is framed in the REAL frame, not 16:9
// ---- video plates (M6): master-clock sync + deterministic render seeks ----
videos(){ return this.entities().filter(e => e.video)

View File

@ -50,6 +50,8 @@ header .tag{font-family:var(--mono);font-size:10px;color:var(--teal);letter-spac
border-radius:6px;padding:5px 7px;font-family:var(--mono);font-size:11px}
.fld input[type=color]{padding:2px;height:26px}
.fld input[type=checkbox]{flex:0 0 auto;width:15px;height:15px;accent-color:var(--teal)}
.fld input[type=range]{padding:0;height:22px;border:0;background:transparent;accent-color:var(--teal)}
.fld .rv{flex:0 0 auto;width:30px;text-align:right;color:var(--ink);font-weight:400}
.fld.vec input{width:100%}
.fld.vec{align-items:center}
.ibtns{display:flex;gap:8px;margin-top:14px}
@ -96,6 +98,8 @@ body.hot::after{content:'drop to load';position:fixed;inset:0;display:flex;align
padding:4px 6px;font-family:var(--mono);font-size:10.5px}
.dseg.nl input{background:var(--panel);border:1px solid var(--line2);color:var(--ink);border-radius:6px;
padding:4px 8px;font-family:var(--mono);font-size:11px;width:230px}
.dseg input.dur{background:var(--panel);border:1px solid var(--line2);color:var(--ink);border-radius:6px;
padding:4px 6px;font-family:var(--mono);font-size:10.5px;width:56px}
.dinfo{font-family:var(--mono);font-size:10.5px;color:var(--teal);margin-left:auto;padding-left:10px}
/* M6 video plates: 📺 add-as-screen button on dock cards */