diff --git a/lanes/A-stage.md b/lanes/A-stage.md index 212243d..7e80731 100644 --- a/lanes/A-stage.md +++ b/lanes/A-stage.md @@ -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. diff --git a/logs/laneA.md b/logs/laneA.md index dbac020..7538c62 100644 --- a/logs/laneA.md +++ b/logs/laneA.md @@ -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`): 0–1 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. diff --git a/scenegod-8020.log b/scenegod-8020.log index 43054f3..7b8baad 100644 --- a/scenegod-8020.log +++ b/scenegod-8020.log @@ -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 diff --git a/scenegod/web/dock.js b/scenegod/web/dock.js index b9ff34a..9c7268a 100644 --- a/scenegod/web/dock.js +++ b/scenegod/web/dock.js @@ -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){ // 0–1 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); } diff --git a/scenegod/web/grammar.js b/scenegod/web/grammar.js index 4aeb349..0cafb5a 100644 --- a/scenegod/web/grammar.js +++ b/scenegod/web/grammar.js @@ -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] }, diff --git a/scenegod/web/presets.js b/scenegod/web/presets.js index 2159a40..c4c8cfd 100644 --- a/scenegod/web/presets.js +++ b/scenegod/web/presets.js @@ -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 () => { diff --git a/scenegod/web/presets_test.mjs b/scenegod/web/presets_test.mjs index 9058fb8..15309c9 100644 --- a/scenegod/web/presets_test.mjs +++ b/scenegod/web/presets_test.mjs @@ -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)'); diff --git a/scenegod/web/stage.js b/scenegod/web/stage.js index f7979b9..4751936 100644 --- a/scenegod/web/stage.js +++ b/scenegod/web/stage.js @@ -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) diff --git a/scenegod/web/style.css b/scenegod/web/style.css index e5d718b..4f90bb8 100644 --- a/scenegod/web/style.css +++ b/scenegod/web/style.css @@ -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 */