SCENEGOD/scenegod/web/presets.js
type-two 22c8a9a324 M12: look, render button, save integrity — 3 workflow rounds, 22 agents
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>
2026-07-28 22:41:17 +10:00

462 lines
27 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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 };
}