FEATURES (from a 5-lens audit: UX friction, feature gaps, bug hunt, visual quality, discoverability): - ACES filmic tone mapping + per-preset exposure. grammar.js runs physical sun intensities of 1.2-3.4; with NoToneMapping every lit face clipped to white and a warm key rendered neutral. Single biggest look win in the app. - Real shadows: PCFSoft, a shadow box fitted to the scene's bounds, and plates that no longer double as the shadow catcher (dedicated ShadowMaterial). - A Render button in the scene bar. finalRender had ZERO callers - the app's deliverable was console-only. - Save actually saves the stage you built (gizmo moves, inspector edits), and Save/Load stop lying about what happened. - 2x supersample + lanczos downscale; correct bt709 tagging and faststart. - Frame guide, camera-from-view, dock counts, delete confirmations. DEFECTS FOUND BY ADVERSARIAL REVIEW AND FIXED (all reproduced first): - BLOCKER: save wrote the playhead pose over the authored rest transform of keyed entities, so every save produced a different file. - renders were converted with the bt601 matrix while tagged bt709. - deleting an unkeyed camera stranded its cut -> scene 422s forever. - corner plates showed one photo at two exposures across the fold. - exposure was advertised as keyable but nothing keyed it, so a second lighting preset permanently poisoned the first. - the audio pre-flight could never fire (FastAPI does not route HEAD -> 405). - the two render buttons were not mutually exclusive. - frame guide letterboxed a narrow viewport that the render does not crop. ORCHESTRATOR (round 3): dangling camera cuts are pruned in Timeline.toJSON and skipped by cutCameraAt, rather than trying to keep every undo history clean - deleting a camera spans Stage (no undo) and Timeline (undo), so any older entry can resurrect a cut for a dead camera. Verified against the real validator. Also replaced a VACUOUS test that drained a stack whose only entry was a seeded no-op; timeline_test.mjs now replays both real histories and discriminates. Verified: 4 JS suites + 23 server groups + render client green; blocker repro now preserves the authored rest at every playhead position; save round-trips 200 with zero dangling cuts; zero console errors. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
462 lines
27 KiB
JavaScript
462 lines
27 KiB
JavaScript
// presets.js — DIRECTOR MODE (M5): deterministic cinema-grammar solvers + the DIRECT panel.
|
||
// NO three.js import — bbox comes from stage.entityBBox as plain arrays, all vector math is
|
||
// 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, MOVES, SHOT_ORDER, EASES, MOVE_MAX_DUR, DOLLY_CLAMP } from './grammar.js';
|
||
|
||
const D2R = Math.PI / 180;
|
||
|
||
// full-frame vertical fov (degrees) for a focal length in mm (24mm-tall sensor)
|
||
export function fovForFocal(mm){ return 2 * Math.atan(12 / mm) / D2R; }
|
||
|
||
// "1920x1080" → 1.777… — the value shape of tlui's render-size selector, so the ⬚ frame guide can
|
||
// mask to the size that will actually be encoded. null for anything that isn't a positive W×H.
|
||
export function aspectFromSize(v){
|
||
const m = /^\s*(\d+)\s*[x×]\s*(\d+)\s*$/.exec(String(v == null ? '' : v));
|
||
const a = m ? +m[1] / +m[2] : NaN;
|
||
return Number.isFinite(a) && a > 0 ? a : null;
|
||
}
|
||
|
||
// XYZ euler (radians) that points a camera (looks down −Z) at `aim`, plus dutch roll.
|
||
export function lookAtEuler(pos, aim, rollDeg = 0){
|
||
let z = [pos[0]-aim[0], pos[1]-aim[1], pos[2]-aim[2]]; // camera +Z = away from aim
|
||
const zn = Math.hypot(z[0], z[1], z[2]) || 1; z = z.map(v => v/zn);
|
||
let x = [z[2], 0, -z[0]]; // up × z
|
||
const xn = Math.hypot(x[0], x[1], x[2]);
|
||
x = xn < 1e-6 ? [1, 0, 0] : x.map(v => v/xn); // looking straight up/down
|
||
let y = [ z[1]*x[2]-z[2]*x[1], z[2]*x[0]-z[0]*x[2], z[0]*x[1]-z[1]*x[0] ]; // z × x
|
||
if(rollDeg){
|
||
const c = Math.cos(rollDeg*D2R), s = Math.sin(rollDeg*D2R);
|
||
const rx = x.map((v,i) => v*c + y[i]*s), ry = y.map((v,i) => v*c - x[i]*s);
|
||
x = rx; y = ry;
|
||
}
|
||
// extraction matches three's Euler.setFromRotationMatrix('XYZ') on the column basis [x y z]
|
||
const m13 = z[0], ey = Math.asin(Math.max(-1, Math.min(1, m13)));
|
||
let ex, ez;
|
||
if(Math.abs(m13) < 0.9999999){ ex = Math.atan2(-z[1], z[2]); ez = Math.atan2(-y[0], x[0]); }
|
||
else { ex = Math.atan2(y[2], y[1]); ez = 0; }
|
||
return [ex, ey, ez];
|
||
}
|
||
|
||
// Deterministic shot solver: subject bbox (world, plain arrays) → camera pos/rot/fov so the
|
||
// shot's vertical span fills the frame. yaw = subject facing (rot.y); OTS aims down the line
|
||
// from the OTHER subject instead, offset over their shoulder.
|
||
export function solveShot({ bboxMin, bboxMax, yaw = 0, shot = 'MS', angle = 'eye', focal = 35, otherPos = null }){
|
||
const S = SHOTS[shot] || SHOTS.MS, A = ANGLES[angle] || ANGLES.eye;
|
||
const H = Math.max(0.01, bboxMax[1] - bboxMin[1]);
|
||
const aim = [ (bboxMin[0]+bboxMax[0])/2, bboxMin[1] + S.center*H, (bboxMin[2]+bboxMax[2])/2 ];
|
||
const fov = fovForFocal(focal);
|
||
const dist = (S.span * H / 2) / Math.tan(fov/2 * D2R);
|
||
let dir; // subject → camera, horizontal
|
||
if(A.ots && otherPos){
|
||
dir = [otherPos[0]-aim[0], 0, otherPos[2]-aim[2]];
|
||
const n = Math.hypot(dir[0], dir[2]) || 1; dir = [dir[0]/n, 0, dir[2]/n];
|
||
} else dir = [Math.sin(yaw), 0, Math.cos(yaw)]; // characters face +Z at yaw 0
|
||
const el = A.elev * D2R, hd = dist * Math.cos(el);
|
||
const pos = [ aim[0] + dir[0]*hd, Math.max(0.05, aim[1] + dist*Math.sin(el)), aim[2] + dir[2]*hd ];
|
||
if(A.ots){ // slide over the near shoulder
|
||
const side = A.ots === 'L' ? -1 : 1;
|
||
pos[0] += side * 0.35 * dir[2]; pos[2] -= side * 0.35 * dir[0]; pos[1] += 0.1;
|
||
}
|
||
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 })); };
|
||
|
||
// ---- appliers: apply to the stage AND return the values (Lane B keys them) ----
|
||
|
||
export function frameSubject(stage, camId, subjectId, { shot = 'MS', angle = 'eye', focal = 35, otherId = null } = {}){
|
||
const bb = stage.entityBBox(subjectId);
|
||
if(!bb) throw new Error('frameSubject: no bbox for ' + subjectId);
|
||
const yaw = (stage.entityTransform(subjectId)?.rot || [0,0,0])[1];
|
||
const otherPos = otherId ? stage.entityTransform(otherId)?.pos : null;
|
||
const solved = solveShot({ bboxMin: bb.min, bboxMax: bb.max, yaw, shot, angle, focal, otherPos });
|
||
stage.setTransform(camId, { pos: solved.pos, rot: solved.rot, scale: 1 });
|
||
stage.setParam(camId, 'fov', solved.fov);
|
||
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){
|
||
const L = LIGHTS[name];
|
||
if(!L) throw new Error('unknown light preset: ' + name);
|
||
const ents = stage.entities();
|
||
let sun = ents.find(e => e.kind === 'light' && e.params.type !== 'ambient');
|
||
let amb = ents.find(e => e.kind === 'light' && e.params.type === 'ambient');
|
||
if(!sun) sun = await stage.addEntity({ kind:'light', label:'sun',
|
||
params:{ type:'key', color:L.sun.color, intensity:L.sun.intensity, castShadow:true } });
|
||
if(!amb) amb = await stage.addEntity({ kind:'light', label:'ambient',
|
||
params:{ type:'ambient', color:L.ambient.color, intensity:L.ambient.intensity, exposure:L.exposure } });
|
||
stage.setParam(sun.id, 'color', L.sun.color);
|
||
stage.setParam(sun.id, 'intensity', L.sun.intensity);
|
||
stage.setParam(amb.id, 'color', L.ambient.color);
|
||
stage.setParam(amb.id, 'intensity', L.ambient.intensity);
|
||
stage.setParam(amb.id, 'bg', L.bg); // background rides the ambient entity → keyable + persists
|
||
stage.setParam(amb.id, 'exposure', L.exposure); // …and so does the ACES level (applyOps keys it)
|
||
const e = L.sun.elev*D2R, a = L.sun.azim*D2R, R = 20; // 20m boom aimed at chest height
|
||
const pos = [R*Math.cos(e)*Math.sin(a), R*Math.sin(e), R*Math.cos(e)*Math.cos(a)];
|
||
stage.setTransform(sun.id, { pos, rot: lookAtEuler(pos, [0, 1, 0]), scale: 1 });
|
||
return { preset: name, sun: { ...L.sun }, ambient: { ...L.ambient }, bg: L.bg, exposure: L.exposure,
|
||
sunId: sun.id, ambientId: amb.id, sunPos: pos };
|
||
}
|
||
|
||
// Blocking marks: pos marks move (stage-relative, keep current y), face marks yaw the subject
|
||
// toward the active camera / the nearest other character.
|
||
export function applyMark(stage, id, mark){
|
||
const M = MARKS[mark];
|
||
if(!M) throw new Error('unknown mark: ' + mark);
|
||
const t = stage.entityTransform(id);
|
||
if(!t) throw new Error('applyMark: no entity ' + id);
|
||
const out = { mark, id };
|
||
if(M.pos){
|
||
const pos = [M.pos[0], t.pos[1], M.pos[2]];
|
||
stage.setTransform(id, { pos }); out.pos = pos;
|
||
}
|
||
if(M.face){
|
||
let target = null;
|
||
if(M.face === 'camera') target = stage.cameraWorldPos();
|
||
else {
|
||
let best = Infinity;
|
||
for(const e of stage.entities()){
|
||
if(e.id === id || e.kind !== 'character') continue;
|
||
const p = stage.entityTransform(e.id).pos;
|
||
const d = Math.hypot(p[0]-t.pos[0], p[2]-t.pos[2]);
|
||
if(d < best){ best = d; target = p; }
|
||
}
|
||
}
|
||
if(target){
|
||
const rot = [0, Math.atan2(target[0]-t.pos[0], target[2]-t.pos[2]), 0]; // face +Z toward target
|
||
stage.setTransform(id, { rot }); out.rot = rot;
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
|
||
// ---- op runner: ONE path for panel buttons and /director NL ops ----
|
||
// 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;
|
||
if(stage.getEntity(ref)) return ref;
|
||
const s = String(ref).toLowerCase();
|
||
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; };
|
||
// ONE BUTTON PRESS = ONE Ctrl+Z. Lane B gives every `scenegod:direct` event its own group(), i.e.
|
||
// its own undo entry — so an op that has to be sent as TWO events (below: the light payload Lane B
|
||
// keys, then the ACES `exposure` its light branch does not) came apart under the first undo,
|
||
// leaving a preset's colours at the previous preset's level: a half-applied look, which is the
|
||
// mismatched-exposure bug wearing a different hat. `collapseUndo` is Lane B's own tool for edits
|
||
// that span EVENTS rather than a function call (it is how tlui turns a 50-mousemove drag into one
|
||
// entry): mark the stack before the first event, collapse after the last. No-op for 0 or 1 entries,
|
||
// so the day Lane B keys `d.exposure` in its light branch, the second _fire and this pair of
|
||
// helpers can be deleted with nothing else to change.
|
||
const _undoMark = () => { const t = _tl(); return t && Array.isArray(t.undoStack) ? t.undoStack.length : -1; };
|
||
const _undoJoin = mark => { const t = _tl();
|
||
if(mark >= 0 && t && typeof t.collapseUndo === 'function') t.collapseUndo(mark); };
|
||
export async function applyOps(stage, ops, selectedId = null){
|
||
const applied = [];
|
||
for(const o of (ops || [])){
|
||
try {
|
||
if(o.op === 'light'){
|
||
const mark = _undoMark(); // …everything below is ONE undo entry (see _undoJoin)
|
||
const v = await applyLight(stage, o.preset);
|
||
_fire({ op:'light', ...v, entityIds:[v.sunId, v.ambientId] });
|
||
// `exposure` is the one preset value Lane B's light branch does not key (it keys sun
|
||
// colour/intensity + transform and ambient colour/intensity/bg). Unkeyed, a second preset
|
||
// later in the scene left the whole EARLIER stretch rendering at the LATER preset's ACES
|
||
// level — in the viewport and in the mp4 — because the renderer is global and nothing
|
||
// ever put it back. The generic op shape ({entityIds, params}) is a documented branch of
|
||
// the same handler, so this lands an ordinary `exposure` param key at the playhead with
|
||
// no change on Lane B's side. It is a SECOND event only because one event gets one
|
||
// branch; the undo cost that used to come with that is gone (_undoJoin).
|
||
if(v.exposure != null)
|
||
_fire({ op:'light', entityIds:[v.ambientId], params:{ exposure: v.exposure } });
|
||
_undoJoin(mark);
|
||
applied.push(v);
|
||
} else if(o.op === 'mark'){
|
||
const id = _resolve(stage, o.subject) ?? selectedId;
|
||
if(!id) throw new Error('mark: no subject');
|
||
const v = applyMark(stage, id, o.mark);
|
||
_fire({ op:'mark', ...v, entityIds:[id] });
|
||
applied.push(v);
|
||
} else if(o.op === 'shot' || o.op === 'move'){
|
||
const subjectId = _resolve(stage, o.subject) ?? selectedId;
|
||
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);
|
||
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 }); }
|
||
}
|
||
return applied;
|
||
}
|
||
|
||
// ---- DIRECT panel UI (browser only — never imported by the tests) ----
|
||
export function mountDirectPanel(stage, el){
|
||
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 = '';
|
||
const seg = (cls, title) => { const d = document.createElement('div');
|
||
d.className = 'dseg ' + cls; if(title){ const s = document.createElement('span');
|
||
s.className = 'dlab'; s.textContent = title; d.appendChild(s); } el.appendChild(d); return d; };
|
||
const btn = (parent, text, cb, title) => { const b = document.createElement('button');
|
||
b.textContent = text; if(title) b.title = title; b.onclick = cb; parent.appendChild(b); return b; };
|
||
const note = m => { info.textContent = m; clearTimeout(note._t);
|
||
if(m) note._t = setTimeout(() => info.textContent = '', 4000); };
|
||
|
||
const shots = seg('shots', 'shot');
|
||
for(const k of Object.keys(SHOTS)) btn(shots, k, e => {
|
||
st.shot = k; [...shots.querySelectorAll('button')].forEach(b => b.classList.toggle('on', b === e.target));
|
||
}, SHOTS[k].label).classList.toggle('on', k === st.shot);
|
||
|
||
const angles = seg('angles', 'angle');
|
||
for(const k of Object.keys(ANGLES)) btn(angles, k.replace('_',' '), e => {
|
||
st.angle = k; [...angles.querySelectorAll('button')].forEach(b => b.classList.toggle('on', b === e.target));
|
||
}).classList.toggle('on', k === st.angle);
|
||
|
||
const lens = seg('lens', 'lens');
|
||
const sel = document.createElement('select');
|
||
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);
|
||
const subject = () => { // shot/move/angle all need a framable subject selected
|
||
const en = st.sel && stage.getEntity(st.sel);
|
||
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 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');
|
||
// ⬚ frame guide: mask the viewport down to the delivery aspect so headroom/lead room are judged
|
||
// in the frame that actually gets encoded. DOM overlay in the Stage — never reaches a render.
|
||
if(stage.setFrameGuide){
|
||
const fgb = btn(lens, '⬚ frame', () => fgb.classList.toggle('on', stage.setFrameGuide(!stage.frameGuide())),
|
||
'mask the viewport to the delivery aspect + thirds/action-safe guides (never rendered)');
|
||
fgb.classList.add('on'); // default ON — composing in the wrong rectangle is the whole problem
|
||
}
|
||
// …and that delivery aspect is whatever the render will ACTUALLY be encoded at: follow tlui's
|
||
// render-size selector. Without this `setFrameAspect` had no caller anywhere in the repo, so the
|
||
// guide was permanently 16:9 and would quietly lie the first time a square or scope size existed.
|
||
// Delegated on the window because the timeline (and its selector) mounts AFTER this panel.
|
||
// The FIRST read cannot be a timer: `select.rsize` is created by TimelineUI's constructor, which
|
||
// index.html only reaches after awaiting two dynamic module imports — strictly later than the
|
||
// next macrotask, so `setTimeout(readSize, 0)` always lost that race and found null. It is
|
||
// returned instead, and index.html calls it once the timeline is up (a no-op when Lane B's
|
||
// files are absent). Harmless while every size is 16:9; a lie the first time one isn't.
|
||
const syncFrameAspect = () => {
|
||
if(!stage.setFrameAspect || typeof document === 'undefined') return null;
|
||
const el = document.querySelector('select.rsize');
|
||
const a = el && aspectFromSize(el.value);
|
||
if(a) stage.setFrameAspect(a);
|
||
return a || null;
|
||
};
|
||
if(stage.setFrameAspect && typeof document !== 'undefined'){
|
||
addEventListener('change', e => { const t = e.target;
|
||
if(t && t.classList && t.classList.contains('rsize')) syncFrameAspect(); }, true);
|
||
syncFrameAspect(); // in case the selector is somehow already there
|
||
}
|
||
|
||
// 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 () => {
|
||
const [r] = await applyOps(stage, [{ op:'light', preset:k }]);
|
||
note(r.error ? String(r.error) : 'light: ' + k);
|
||
});
|
||
|
||
const marks = seg('marks', 'mark');
|
||
for(const k of Object.keys(MARKS)) btn(marks, k.replace(/_/g,' '), async () => {
|
||
if(!st.sel) return note('select an entity first');
|
||
const [r] = await applyOps(stage, [{ op:'mark', subject:st.sel, mark:k }], st.sel);
|
||
note(r.error ? String(r.error) : 'mark: ' + k);
|
||
});
|
||
|
||
// NL box → POST /director (Lane C, LLM-optional). Hidden until the endpoint exists;
|
||
// hides itself again on 503 (LLM unset). Buttons above never depend on this lane.
|
||
const nl = seg('nl', null); nl.style.display = 'none';
|
||
const inp = document.createElement('input');
|
||
inp.placeholder = '“medium two-shot, golden hour…”'; nl.appendChild(inp);
|
||
const send = async () => {
|
||
const text = inp.value.trim(); if(!text) return;
|
||
note('directing…');
|
||
try {
|
||
const r = await fetch('director', { method:'POST', headers:{ 'Content-Type':'application/json' },
|
||
body: JSON.stringify({ text, selected: st.sel,
|
||
entities: stage.entities().map(e => ({ id:e.id, kind:e.kind, label:e.label })) }) });
|
||
if(r.status === 503){ nl.style.display = 'none'; return note('director LLM offline'); }
|
||
if(!r.ok) return note('director: HTTP ' + r.status);
|
||
const { ops } = await r.json();
|
||
const res = await applyOps(stage, ops, st.sel);
|
||
const bad = res.filter(x => x.error);
|
||
note(bad.length ? bad[0].error : `applied ${res.length} op${res.length === 1 ? '' : 's'}`);
|
||
if(!bad.length) inp.value = '';
|
||
} catch(e){ note('director: ' + String(e).slice(0, 80)); }
|
||
};
|
||
btn(nl, '➤', send); inp.addEventListener('keydown', e => { if(e.key === 'Enter') send(); });
|
||
// probe: FastAPI answers 405 on GET when only POST exists → endpoint present, show the box
|
||
fetch('director').then(r => { if(r.status === 405 || r.ok) nl.style.display = ''; }).catch(() => {});
|
||
|
||
const info = document.createElement('span'); info.className = 'dinfo'; el.appendChild(info);
|
||
return { state: st, syncFrameAspect };
|
||
}
|