SCENEGOD/scenegod/web/presets.js
type-two 20af1b20c2 [m5+m6] DIRECTOR MODE + FLOW VIDEO PLATES — all lanes + sync6 seam fixes
Lane A: grammar.js (shot/angle/focal/light/mark data), presets.js (bbox shot
solver, light/mark appliers, DIRECT bar UI, NL box), stage video plates
(VideoTexture plane/corner/dome, screen kind, syncVideos), dock video cards.
Lane B: scenegod:direct → grouped-undo keys/cuts, 🗣 lip-sync from audio via
/rhubarb, Cmd+wheel zoom / wheel scroll, audio trim, video clock-follow via
nullable entityVideo seam. Lane C: POST /director (LLM proxy, server-side op
validation vs vocab+scene, mock-tested), render.js awaits stage.syncVideos.
Sync6 (orchestrator): VIDEO_EXTS in server tree/media types (video primary,
img poster as thumb), stage.entityVideo accessor, syncVideos forces
videoTex.needsUpdate (rVFC never fires in hidden tabs → black plates).

Verified: 9 server groups + timeline + presets suites green; browser SYNC:
one-click MCU framing lands camera+fov keys+cut (single undo), golden_hour
relight keys, plate scrub-follow frame-exact, finalRender mp4 shows the Flow
golden-hour street plate behind an animated character.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 17:51:28 +10:00

250 lines
13 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 } 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 };
}