MOVES grammar + solveMove(): push in/pull out (one rung along the shot ladder,
along the aim ray), truck L/R (lateral, parallel, no re-aim), crane up/down
(vertical then re-aim, floor-clamped), orbit L/R (constant distance, +/-25deg),
zoom in/out (focal only - pos and rot returned identical). Amounts are
frame-relative so a move reads the same on a CU as a WS. Emits a new 'move' op
on scenegod:direct with explicit from/to states, pre-clamped duration and ease;
the START is applied to the live camera immediately.
'+ angle' creates a NEW camera from the current shot settings instead of
reframing the active one - the friction hit while building a 4-camera scene by
hand. 🎥 frame now prefers the live camera so it adjusts what you just made
rather than silently editing camera 1. fitAnchor exposed in the backdrop
inspector (it was inert from the UI).
Verified live: push_in 1.975->1.588m fov fixed; zoom_in travel 0.000 with
pos/rot identical, fov 37.85->26.99; orbit_L constant distance; truck_R
parallel. 4 suites green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
401 lines
23 KiB
JavaScript
401 lines
23 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; }
|
||
|
||
// 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 } });
|
||
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
|
||
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,
|
||
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; };
|
||
export async function applyOps(stage, ops, selectedId = null){
|
||
const applied = [];
|
||
for(const o of (ops || [])){
|
||
try {
|
||
if(o.op === 'light'){
|
||
const v = await applyLight(stage, o.preset);
|
||
_fire({ op:'light', ...v, entityIds:[v.sunId, v.ambientId] });
|
||
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');
|
||
|
||
// 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 };
|
||
}
|