- web: all URLs relative (imports ./web/..., fetches assets/...) so the app serves at / locally and under /scenegod/ behind nginx; esc() on dock innerHTML interpolations (ship-check XSS class) - server: capped_body on every POST (scenes 5MB, frames 25MB, meta/director 256KB-1MB), render begin sanity (fps<=60, dims<=4096, frame idx<=20000) - deploy/: Dockerfile (slim+ffmpeg), compose (project 'scenegod' — avoids the shared-'deploy'-project orphan trap with meshgod), nginx location (suite basic-auth), deploy.sh (rsync model, meshgod-style) - live: digalot.fyi/scenegod/ -> 401 unauthed, app healthy on dealgod_default Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
250 lines
13 KiB
JavaScript
250 lines
13 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 } 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 };
|
||
}
|