// 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 } 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 }; } 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 }; } // 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?} {op:'light', preset} // {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; } 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'){ const subjectId = _resolve(stage, o.subject) ?? selectedId; if(!subjectId) throw new Error('shot: 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); } 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, 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); btn(lens, '🎥 frame', async () => { const en = st.sel && stage.getEntity(st.sel); if(!en || (en.kind !== 'character' && en.kind !== 'prop')) 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'); 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 }; }