SCENEGOD/scenegod/web/presets.js
type-two 128e24a7cb [deploy] digalot.fyi hosting: prefix-agnostic frontend, ship-check hardening, docker+nginx kit
- 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>
2026-07-21 02:43:26 +10:00

250 lines
13 KiB
JavaScript
Raw Permalink 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 };
}