FEATURES (from a 5-lens audit: UX friction, feature gaps, bug hunt, visual quality, discoverability): - ACES filmic tone mapping + per-preset exposure. grammar.js runs physical sun intensities of 1.2-3.4; with NoToneMapping every lit face clipped to white and a warm key rendered neutral. Single biggest look win in the app. - Real shadows: PCFSoft, a shadow box fitted to the scene's bounds, and plates that no longer double as the shadow catcher (dedicated ShadowMaterial). - A Render button in the scene bar. finalRender had ZERO callers - the app's deliverable was console-only. - Save actually saves the stage you built (gizmo moves, inspector edits), and Save/Load stop lying about what happened. - 2x supersample + lanczos downscale; correct bt709 tagging and faststart. - Frame guide, camera-from-view, dock counts, delete confirmations. DEFECTS FOUND BY ADVERSARIAL REVIEW AND FIXED (all reproduced first): - BLOCKER: save wrote the playhead pose over the authored rest transform of keyed entities, so every save produced a different file. - renders were converted with the bt601 matrix while tagged bt709. - deleting an unkeyed camera stranded its cut -> scene 422s forever. - corner plates showed one photo at two exposures across the fold. - exposure was advertised as keyable but nothing keyed it, so a second lighting preset permanently poisoned the first. - the audio pre-flight could never fire (FastAPI does not route HEAD -> 405). - the two render buttons were not mutually exclusive. - frame guide letterboxed a narrow viewport that the render does not crop. ORCHESTRATOR (round 3): dangling camera cuts are pruned in Timeline.toJSON and skipped by cutCameraAt, rather than trying to keep every undo history clean - deleting a camera spans Stage (no undo) and Timeline (undo), so any older entry can resurrect a cut for a dead camera. Verified against the real validator. Also replaced a VACUOUS test that drained a stack whose only entry was a seeded no-op; timeline_test.mjs now replays both real histories and discriminates. Verified: 4 JS suites + 23 server groups + render client green; blocker repro now preserves the authored rest at every playhead position; save round-trips 200 with zero dangling cuts; zero console errors. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
989 lines
56 KiB
JavaScript
989 lines
56 KiB
JavaScript
// stage.js — the 3D stage: renderer, IBL scene, entities on wrappers, gizmo, retarget cache.
|
||
// Timeline (Lane B) talks to the stage ONLY through this API (PLAN §4.2).
|
||
import * as THREE from 'three';
|
||
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
||
import { TransformControls } from 'three/addons/controls/TransformControls.js';
|
||
import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js';
|
||
import { parseAny, captureRest, disposeRoot, stats, bakeRetarget } from './room3d.js';
|
||
|
||
const ASSET_URL = p => 'assets/file?path=' + encodeURIComponent(p);
|
||
|
||
// Loose viseme matcher: map a character's morph-target names (CC "V_Open", ARKit "jawOpen", plain
|
||
// "AA"…) to a canonical set the timeline drives. Detection only — Rhubarb→keyframes is Lane B's.
|
||
const VISEMES = {
|
||
A: [/\bah\b/, /(^|_)aa?($|_)/, /jaw[_ ]?open/, /(^|[_-])open($|[_-])/, /mouth_?open/],
|
||
E: [/\bee\b/, /(^|_)e[hr]?($|_)/, /wide/, /smile/],
|
||
I: [/(^|_)i[hy]?($|_)/, /affric/],
|
||
O: [/\boh\b/, /(^|_)o($|_)/, /tight[_-]?o/, /round/],
|
||
U: [/\boo\b/, /(^|_)u[w]?($|_)/, /pucker/, /funnel/, /w[_-]?oo/, /(^|_)tight($|_)/],
|
||
MBP: [/mbp/, /b[_-]?m[_-]?p/, /explos/, /mouth_?close/, /press/, /lip[_-]?open/, /(^|_)pp($|_)/],
|
||
FV: [/(^|_)f[_-]?v($|_)/, /dental[_-]?lip/, /(^|_)ff($|_)/],
|
||
L: [/t[_-]?l[_-]?d[_-]?n/, /tongue/, /(^|_)l($|_)/, /(^|_)dd($|_)/, /(^|_)nn($|_)/],
|
||
};
|
||
function detectVisemes(root){
|
||
const morphs = new Map(); // rawName(lower) -> [{mesh, idx}] (head, teeth, tongue often share a name)
|
||
root.traverse(o => { if(o.isMesh && o.morphTargetDictionary)
|
||
for(const [name, idx] of Object.entries(o.morphTargetDictionary)){
|
||
const k = name.toLowerCase(); (morphs.get(k) || morphs.set(k, []).get(k)).push({mesh:o, idx}); } });
|
||
const visemes = {};
|
||
// SYNC5: ARKit side suffixes (_L/_R) made /(^|_)l($|_)/ swallow eyeblink_l & co —
|
||
// never match non-mouth regions, and match against the side-stripped name.
|
||
const NONMOUTH = /eye|blink|brow|squint|look|nose|cheek/;
|
||
for(const [canon, pats] of Object.entries(VISEMES)){
|
||
const hits = [...morphs.keys()].filter(k => !NONMOUTH.test(k)
|
||
&& pats.some(p => p.test(k.replace(/(.)[_-][lr]$/, '$1'))));
|
||
if(hits.length) visemes[canon] = hits;
|
||
}
|
||
return { morphs, visemes };
|
||
}
|
||
// runnable check for the matcher (assert-based). Call visemeSelfCheck() from the console.
|
||
export function visemeSelfCheck(){
|
||
const dict = {'V_Open':0,'V_Explosive':1,'jawOpen':2,'mouthPucker':3,'mouthFunnel':4,
|
||
'mouthClose':5,'V_Dental_Lip':6,'V_Tongue_up':7,'EyeBlink_L':8,'browDown_L':9,'mouthSmile_L':10};
|
||
const mesh = { isMesh:true, morphTargetDictionary:dict, morphTargetInfluences:new Array(11).fill(0) };
|
||
const { morphs, visemes } = detectVisemes({ traverse(cb){ cb(mesh); } });
|
||
const has = v => v in visemes;
|
||
const detectOk = ['A','U','MBP','FV','L'].every(has) // open/jaw, pucker/funnel, explos/close, dental, tongue
|
||
&& !has('I') && !has('O')
|
||
// SYNC5 regression guard: side-suffixed ARKit names must land on the RIGHT viseme —
|
||
// L is tongue only (no blink/brow/smile bleed), E gets the side-stripped smile.
|
||
&& visemes.L.length === 1 && visemes.L[0] === 'v_tongue_up'
|
||
&& has('E') && visemes.E.includes('mouthsmile_l');
|
||
// canonical 'A' drives every mesh morph mapped to it (jawOpen idx2 + V_Open idx0)
|
||
for(const r of visemes.A) for(const {idx} of morphs.get(r)) mesh.morphTargetInfluences[idx] = 0.7;
|
||
const driveOk = mesh.morphTargetInfluences[0] === 0.7 && mesh.morphTargetInfluences[2] === 0.7
|
||
&& mesh.morphTargetInfluences[8] === 0; // blink untouched
|
||
return { ok: detectOk && driveOk, found: Object.keys(visemes).sort().join(','), visemes };
|
||
}
|
||
|
||
// M7 backdrop auto-fit, kept pure so node can check the algebra (stage_test.mjs): the frustum is
|
||
// `fh` tall at `dist`, and the plate scales to COVER it (either axis, whichever is tighter) + bleed.
|
||
export const BLEED = 1.06;
|
||
export function fitScale(fovDeg, aspect, dist, planeW, planeH){
|
||
const fh = 2 * Math.tan(fovDeg * Math.PI / 360) * dist, fw = fh * aspect;
|
||
return { fh, fw, s: Math.max(fw / planeW, fh / planeH) * BLEED };
|
||
}
|
||
|
||
// ACES exposure lives on the RENDERER, so a keyed lerp through 0 would black the whole film —
|
||
// clamp it. 0.05–4 is ±~4.3 stops around 1.0, more than any preset asks for.
|
||
export const EXPOSURE_RANGE = [0.05, 4];
|
||
export const clampExposure = v =>
|
||
Math.min(EXPOSURE_RANGE[1], Math.max(EXPOSURE_RANGE[0], Number.isFinite(+v) ? +v : 1));
|
||
|
||
// The frame's ACES level belongs to the scene's ambient light (same deal as `bg`), so it is a
|
||
// property of WHAT IS ON STAGE — never of whichever light happened to be built or deleted last.
|
||
// It used to be written straight from _buildLight/removeEntity: pressing `night` (1.6) and then
|
||
// `+ ambient` in the dock dropped the whole image to 1.0 with no visible cause (the new entity
|
||
// carries no `exposure`), and deleting any ambient reset the frame even when another one was
|
||
// still holding 1.6. Recompute from the entity list instead: the last ambient that actually
|
||
// states an exposure wins, and 1.0 only when nothing states one.
|
||
export function sceneExposure(entities){
|
||
let v = null;
|
||
for(const en of (entities || []))
|
||
if(en && en.kind === 'light' && en.params && en.params.type === 'ambient' && en.params.exposure != null)
|
||
v = en.params.exposure;
|
||
return clampExposure(v == null ? 1 : v);
|
||
}
|
||
|
||
// Which light edit actually MOVES the shadow box. Colour/intensity/bg/fog/exposure change how the
|
||
// frame LOOKS, not where the box goes — and Timeline._evalParams re-applies every keyed param on
|
||
// every tick, so treating any light key as dirty ran a Box3.setFromObject sweep over every
|
||
// character on every frame: exactly the per-frame cost the `ponytail:` note in setTransform
|
||
// claimed to be avoiding.
|
||
export function shadowAffectingParam(key){ return key === 'castShadow' || key === 'type'; }
|
||
// …and a transform is only dirty when it actually MOVED. The timeline re-applies the same pose
|
||
// every tick, so a single keyed sun would otherwise refit the box 30×/second for nothing.
|
||
export function samePos(a, b, eps = 1e-6){
|
||
if(!a || !b) return false;
|
||
for(let i = 0; i < 3; i++) if(!(Math.abs(a[i] - b[i]) < eps)) return false;
|
||
return true;
|
||
}
|
||
|
||
// Shadow-box fit. A directional light's shadow camera is an ORTHO box centred on the light→target
|
||
// axis, and every key light here aims at the stage ORIGIN — enforced, not assumed: _buildLight
|
||
// parents each light's target to the SCENE at (0,0,0), never to the light's own wrapper, so
|
||
// gizmo-translating a light can't walk the box off the stage. The half-extent is therefore
|
||
// measured from the origin, not from the entities' own centre (a lone character standing 8m
|
||
// off-axis needs a box that reaches him, and half his own diagonal does not). Union the bboxes
|
||
// WITH the origin, take half the diagonal, add margin for the ground the shadows land on.
|
||
// three's default is ±5.
|
||
export function shadowHalfExtent(bboxes, margin = 6){
|
||
const mn = [0, 0, 0], mx = [0, 0, 0]; // seeded with the origin = the axis the box sits on
|
||
let seen = false;
|
||
for(const b of (bboxes || [])){
|
||
if(!b || !b.min || !b.max) continue;
|
||
seen = true;
|
||
for(let i = 0; i < 3; i++){
|
||
if(Number.isFinite(b.min[i])) mn[i] = Math.min(mn[i], b.min[i]);
|
||
if(Number.isFinite(b.max[i])) mx[i] = Math.max(mx[i], b.max[i]);
|
||
}
|
||
}
|
||
if(!seen) return 4; // bare floor — nothing to cast, nothing to fit
|
||
return Math.max(4, Math.hypot(mx[0]-mn[0], mx[1]-mn[1], mx[2]-mn[2]) / 2 + margin);
|
||
}
|
||
|
||
// One clamp shared by syncVideos (the CAPTURED frame) and Timeline._evalVideo (the viewport), so
|
||
// what you scrub is what you render. Wrapping instead of clamping made a plate with videoStart:2
|
||
// show frame 0 live and the 8.0s frame in the mp4.
|
||
export function videoTimeAt(t, start, dur){
|
||
return (Number.isFinite(dur) && dur > 0) ? Math.max(0, t - (start || 0)) % dur : 0;
|
||
}
|
||
|
||
// Frame guide bar thicknesses (px) that mask a viewport down to the delivery aspect.
|
||
// A camera's fov in three is VERTICAL, and rendering at another size only rewrites cam.aspect
|
||
// (_render) — so the encoded frame ALWAYS shows exactly the vertical extent the viewport shows,
|
||
// and differs only in WIDTH:
|
||
// viewport wider than delivery → the render is narrower ⇒ mask left/right (pillarbox).
|
||
// viewport narrower → the render is WIDER ⇒ there is nothing to mask; the viewport
|
||
// is showing a crop of the frame, and `overscan` says by how
|
||
// much (delivery ÷ viewport aspect) so the UI can disclose it.
|
||
// Top/bottom bars are therefore ALWAYS zero. Letterboxing a tall viewport (what this used to do)
|
||
// masked content that ends up in the mp4 and implied side content that does not exist.
|
||
// `side` is the SIGNED viewport-px inset of the encoded frame's side edges — the same number as
|
||
// left/right when the viewport is wider, NEGATIVE when it is narrower (the frame runs off-screen).
|
||
// It is what positions the gate rectangle, and the gate is what the thirds and the action-safe box
|
||
// are drawn as percentages OF: with the gate clamped to the visible area, a tall viewport drew the
|
||
// left third ~6% of the frame width away from where it actually falls, which is the one thing a
|
||
// director composes with. Pure geometry so node can assert it; the guide itself is DOM, never
|
||
// WebGL (see _layoutGuide).
|
||
export function guideBars(viewW, viewH, aspect){
|
||
const z = { top:0, bottom:0, left:0, right:0, overscan:1, side:0 };
|
||
if(!(viewW > 0 && viewH > 0 && aspect > 0)) return z; // hidden panel → no divide by zero
|
||
const va = viewW / viewH;
|
||
const side = (viewW - viewH * aspect) / 2; // frame is always viewH tall (vertical fov)
|
||
if(va > aspect) return { ...z, left:side, right:side, side };
|
||
return { ...z, overscan: aspect / va, side };
|
||
}
|
||
|
||
export class Stage {
|
||
constructor(viewEl){
|
||
this.view = viewEl;
|
||
this._entities = new Map();
|
||
this._selCbs = []; this._changeCbs = [];
|
||
this._selected = null;
|
||
this._activeCamId = null;
|
||
this._mixerAuto = true; // Stage advances mixers each frame until Timeline takes over
|
||
this._idc = 0;
|
||
this._clipSrc = new Map(); // path -> {root, rest, anims} (parse+rest cached)
|
||
this._baked = new Map(); // id|path|index -> AnimationClip
|
||
|
||
const r = this.renderer = new THREE.WebGLRenderer({antialias:true, preserveDrawingBuffer:true});
|
||
r.setPixelRatio(devicePixelRatio);
|
||
r.shadowMap.enabled = true;
|
||
r.shadowMap.type = THREE.PCFSoftShadowMap; // free: the hard-edged default read as aliasing
|
||
// grammar.js runs physical sun intensities of 1.2–3.4; with NoToneMapping every lit face
|
||
// clipped to 255,255,255 and a warm key went neutral white. ACES + a per-preset exposure
|
||
// (the ambient entity's `exposure` param) is the single biggest look win in the app.
|
||
r.toneMapping = THREE.ACESFilmicToneMapping; r.toneMappingExposure = 1.0;
|
||
this.view.appendChild(r.domElement);
|
||
this._paused = false; // M3 final render freezes the director loop, then drives renderActiveCamera
|
||
|
||
// PiP overlay — the active camera's view (bottom-right). CSS sizes it (~24% w); the buffer
|
||
// stays small. Click to swap which view is main vs inset.
|
||
const pip = this._pip = document.createElement('canvas');
|
||
pip.width = 320; pip.height = 180; pip.className = 'pip'; pip.style.display = 'none';
|
||
pip.title = 'click to swap with the main view';
|
||
this._pipSwap = false;
|
||
pip.addEventListener('click', () => { this._pipSwap = !this._pipSwap; });
|
||
this.view.appendChild(pip);
|
||
|
||
// ⬚ frame guide — DOM ONLY. The viewport is whatever shape the CSS grid gives it and _render
|
||
// rewrites cam.aspect every draw, so headroom / lead room / a plate's fitAnchor horizon were
|
||
// judged in a rectangle that is never the encoded one. Bars mask it down to the delivery
|
||
// aspect. Being DOM (z BELOW the PiP, pointer-events:none) it can never reach a captured frame.
|
||
const fg = this._fg = document.createElement('div'); fg.className = 'frameguide';
|
||
fg.innerHTML = '<i class="fgb t"></i><i class="fgb b"></i><i class="fgb l"></i><i class="fgb r"></i>'
|
||
+ '<i class="fggate"><i class="fgv" style="left:33.333%"></i><i class="fgv" style="left:66.667%"></i>'
|
||
+ '<i class="fgh" style="top:33.333%"></i><i class="fgh" style="top:66.667%"></i>'
|
||
+ '<i class="fgsafe"></i></i>';
|
||
this._frameAspect = 16/9;
|
||
this.view.appendChild(fg);
|
||
|
||
const scene = this.scene = new THREE.Scene();
|
||
const cam = this._dirCam = new THREE.PerspectiveCamera(45, 1, 0.01, 3000);
|
||
cam.position.set(0, 1.6, 5);
|
||
const ctr = this.controls = new OrbitControls(cam, r.domElement);
|
||
ctr.enableDamping = true; ctr.target.set(0, 1.0, 0);
|
||
|
||
const pmrem = new THREE.PMREMGenerator(r);
|
||
scene.environment = pmrem.fromScene(new RoomEnvironment(), 0.04).texture; pmrem.dispose();
|
||
// default lights — replaced/dimmed once the scene carries light entities
|
||
this._defHemi = new THREE.HemisphereLight(0xffffff, 0x555a66, 2.2); scene.add(this._defHemi);
|
||
// the default key CASTS: without this nothing threw a shadow until the user added a light
|
||
// entity, so characters hovered on an untouched stage despite shadowMap.enabled.
|
||
this._defKey = new THREE.DirectionalLight(0xffffff, 1.8); this._defKey.position.set(3, 5, 2);
|
||
this._shadowRig(this._defKey); scene.add(this._defKey);
|
||
this._shadowDirty = true; // consumed once per DRAWN frame in _render — never fit per light-key
|
||
|
||
const floor = this._floor = new THREE.Mesh(new THREE.CircleGeometry(30, 64).rotateX(-Math.PI/2),
|
||
new THREE.MeshStandardMaterial({color:0x141a20, roughness:.97}));
|
||
floor.receiveShadow = true; scene.add(floor);
|
||
this._floorBase = new THREE.Color(0x141a20);
|
||
const grid = this._grid = new THREE.GridHelper(60, 60, 0x2a323b, 0x1c232b);
|
||
grid.material.transparent = true; scene.add(grid);
|
||
|
||
// gizmo
|
||
const tc = this._tc = new TransformControls(cam, r.domElement);
|
||
tc.addEventListener('dragging-changed', e => { ctr.enabled = !e.value; });
|
||
tc.addEventListener('mouseUp', () => { if(this._selected) this._fireChange(this.getEntity(this._selected)); });
|
||
scene.add(tc);
|
||
addEventListener('keydown', e => {
|
||
if(!this._selected) return;
|
||
// A light has exactly ONE degree of freedom that does anything: position. Its target is
|
||
// nailed to the stage origin (_buildLight/_aimLight), so `e`/`r` used to swing a gizmo,
|
||
// fire onChange and key a rotation that changed no pixel. Refuse the mode instead.
|
||
const posOnly = this.getEntity(this._selected)?.kind === 'light';
|
||
if(e.key==='w') tc.setMode('translate');
|
||
else if(e.key==='e' && !posOnly) tc.setMode('rotate');
|
||
else if(e.key==='r' && !posOnly) tc.setMode('scale');
|
||
});
|
||
|
||
r.domElement.addEventListener('click', e => this._pick(e));
|
||
this.clock = new THREE.Clock();
|
||
this._resize(); addEventListener('resize', () => this._resize());
|
||
const loop = () => { requestAnimationFrame(loop);
|
||
const dt = this.clock.getDelta();
|
||
if(this._paused) return; // M3: caller drives rendering deterministically (_render refits)
|
||
ctr.update();
|
||
if(this._mixerAuto) for(const en of this._entities.values()) en.mixer && en.mixer.update(dt);
|
||
// inset first (it scribbles onto the main canvas), then the main view last so it wins
|
||
if(this._activeCamId && pip.style.display !== 'none')
|
||
this._render(this._pipSwap ? this._dirCam : this._activeCam(), pip);
|
||
this._render((this._pipSwap && this._activeCamId) ? this._activeCam() : this._dirCam, null);
|
||
};
|
||
loop();
|
||
}
|
||
|
||
_resize(){ const w=this.view.clientWidth, h=this.view.clientHeight;
|
||
this.renderer.setSize(w, h, false); this._dirCam.aspect = w/h; this._dirCam.updateProjectionMatrix();
|
||
// size the inset here — CSS aspect-ratio/% is unreliable on a <canvas>
|
||
const pw = Math.max(180, Math.min(340, w*0.24));
|
||
this._pip.style.width = Math.round(pw)+'px'; this._pip.style.height = Math.round(pw*9/16)+'px';
|
||
this._layoutGuide();
|
||
this.fitBackdrops(); // frame aspect changed → auto-fit plates no longer fill it
|
||
}
|
||
|
||
// ---- ⬚ frame guide (DOM overlay; see the constructor) ----
|
||
_layoutGuide(){
|
||
const fg = this._fg; if(!fg) return;
|
||
const b = guideBars(this.view.clientWidth, this.view.clientHeight, this._frameAspect);
|
||
const px = n => Math.round(n) + 'px';
|
||
const [t, bo, l, r, gate] = fg.children;
|
||
t.style.height = px(b.top); bo.style.height = px(b.bottom);
|
||
l.style.width = px(b.left); r.style.width = px(b.right);
|
||
gate.style.top = px(b.top); gate.style.bottom = px(b.bottom);
|
||
// THE GATE IS THE ENCODED FRAME, even when that frame is wider than the window: a negative
|
||
// inset runs it off both sides (.frameguide clips it) so its thirds and action-safe box —
|
||
// percentages of the gate — mark the real frame instead of the visible crop. Clamped to the
|
||
// viewport they were ~6% of the frame width out on a tall window, and off by more the taller
|
||
// it got, which is precisely when a director needs them.
|
||
gate.style.left = px(b.side); gate.style.right = px(b.side);
|
||
// overscan: the delivery frame is WIDER than this viewport, so its sides are off-screen and
|
||
// no bar can show that. Say it (the dashed edges + note live on the guide, not on the gate,
|
||
// because the gate's own edges are now off-screen).
|
||
fg.classList.toggle('over', b.overscan > 1.001);
|
||
}
|
||
setFrameAspect(a){ if(a > 0){ this._frameAspect = a; this._layoutGuide(); } return this._frameAspect; }
|
||
setFrameGuide(on){ if(this._fg) this._fg.style.display = on ? '' : 'none'; return !!on; }
|
||
frameGuide(){ return !!this._fg && this._fg.style.display !== 'none'; }
|
||
frameAspect(){ return this._frameAspect; }
|
||
|
||
// ---- shadows ----
|
||
// Shared config for every shadow-casting directional light (the default key + scene keys).
|
||
// normalBias kills the acne a 2048 map still shows on a 1.7m character at a grazing sun.
|
||
_shadowRig(dir){
|
||
dir.castShadow = true;
|
||
dir.shadow.mapSize.set(2048, 2048);
|
||
dir.shadow.camera.far = 60;
|
||
dir.shadow.normalBias = 0.02; dir.shadow.bias = -0.0005;
|
||
}
|
||
// Fit every shadow camera's ortho box to what is actually on stage. three's default ±5 units
|
||
// sliced shadows off dead straight the moment two characters stood more than ~10m apart, and
|
||
// left near/far unrelated to where the boom is. Recomputed at most once per frame (_shadowDirty).
|
||
_fitShadows(){
|
||
const boxes = [];
|
||
for(const en of this._entities.values()){
|
||
if(en.kind !== 'character' && en.kind !== 'prop' && en.kind !== 'screen') continue;
|
||
const b = this.entityBBox(en.id); if(b) boxes.push(b);
|
||
}
|
||
const h = shadowHalfExtent(boxes);
|
||
const p = new THREE.Vector3();
|
||
const fit = dir => {
|
||
if(!dir || !dir.shadow) return;
|
||
const c = dir.shadow.camera;
|
||
c.left = -h; c.right = h; c.top = h; c.bottom = -h;
|
||
c.near = 0.5;
|
||
dir.updateWorldMatrix(true, false); dir.getWorldPosition(p);
|
||
c.far = p.length() + 2 * h; // boom distance + the depth of the box behind the stage
|
||
c.updateProjectionMatrix();
|
||
};
|
||
fit(this._defKey);
|
||
for(const en of this._entities.values())
|
||
if(en.light && en.light.isDirectionalLight) fit(en.light);
|
||
}
|
||
|
||
// ---- callbacks ----
|
||
onSelect(cb){ this._selCbs.push(cb); }
|
||
onChange(cb){ this._changeCbs.push(cb); }
|
||
_fireChange(en){ for(const cb of this._changeCbs) cb(en); }
|
||
|
||
// ---- entity lifecycle ----
|
||
entities(){ return [...this._entities.values()]; }
|
||
getEntity(id){ return this._entities.get(id); }
|
||
|
||
async addEntity(desc){
|
||
const id = desc.id || ('e' + (++this._idc));
|
||
if(this._idc <= (parseInt(String(id).slice(1)) || 0)) this._idc = parseInt(String(id).slice(1)) || this._idc;
|
||
const wrapper = new THREE.Group();
|
||
wrapper.userData.entityId = id;
|
||
const noAsset = desc.kind === 'camera' || desc.kind === 'light'; // rig entities have no file
|
||
const en = { id, kind: desc.kind, label: desc.label || desc.kind,
|
||
source: desc.source || (noAsset ? {type:'none'} : null), params: {...(desc.params||{})},
|
||
wrapper, root:null, mixer:null, rest:null, cam:null, light:null, morphs:null, visemes:null,
|
||
video:null, ground:null };
|
||
|
||
if(desc.kind === 'character' || desc.kind === 'prop'){
|
||
const { root } = await this._loadAsset(desc);
|
||
this._normalize(root, desc.kind === 'character');
|
||
root.traverse(o=>{ if(o.isMesh){ o.castShadow = true; o.receiveShadow = true; } if(o.isSkinnedMesh) o.frustumCulled=false; });
|
||
wrapper.add(root); en.root = root;
|
||
if(desc.kind === 'character'){ en.rest = captureRest(root); en.mixer = new THREE.AnimationMixer(root);
|
||
const v = detectVisemes(root); en.morphs = v.morphs; en.visemes = v.visemes; }
|
||
} else if(desc.kind === 'backdrop'){
|
||
en.root = await this._buildBackdrop(en); wrapper.add(en.root);
|
||
} else if(desc.kind === 'screen'){
|
||
en.root = await this._buildScreen(en); wrapper.add(en.root);
|
||
} else if(desc.kind === 'camera'){
|
||
const fov = en.params.fov || 45;
|
||
en.cam = new THREE.PerspectiveCamera(fov, 16/9, 0.05, 3000);
|
||
wrapper.add(en.cam);
|
||
const proxy = new THREE.Mesh(new THREE.ConeGeometry(0.18, 0.35, 4).rotateX(-Math.PI/2),
|
||
new THREE.MeshBasicMaterial({color:0x43c0cf, wireframe:true}));
|
||
proxy.position.z = 0.18; proxy.userData.chrome = true; // SYNC7: editor-only, never in a render
|
||
wrapper.add(proxy); en.proxy = proxy;
|
||
} else if(desc.kind === 'light'){
|
||
this._buildLight(en);
|
||
} else throw new Error('unknown kind: ' + desc.kind);
|
||
|
||
this.scene.add(wrapper);
|
||
this._entities.set(id, en);
|
||
if(desc.transform) this.setTransform(id, desc.transform);
|
||
if(en.kind === 'backdrop'){ this._fitBackdrop(en); this._sampleGround(en); } // M7: world tie-in
|
||
// _buildLight ran BEFORE the entity was registered, so its _refreshDefaults couldn't see it —
|
||
// without this the built-in hemi stayed at 2.2 and washed every lighting preset out.
|
||
if(en.kind === 'light'){ this._refreshDefaults(); this._applyExposure(); this._applyWorld(); }
|
||
this._shadowDirty = true;
|
||
this._fireChange(en); // tlui/dock build a row per entity off onChange (incl. applyState loads)
|
||
return en;
|
||
}
|
||
|
||
removeEntity(id){
|
||
const en = this._entities.get(id); if(!en) return;
|
||
if(this._selected === id) this.select(null);
|
||
// A dead active-camera id kept the PiP drawing a duplicate of the director view and made
|
||
// renderActiveCamera() silently shoot the DIRECTOR cam into the mp4. Fall back to the orbit
|
||
// cam, which is what `null` has always meant.
|
||
if(this._activeCamId === id) this.setActiveCamera(null);
|
||
this._dropVideo(en);
|
||
if(en.root) disposeRoot(en.root);
|
||
if(en.light) en.light.parent && en.light.parent.remove(en.light);
|
||
if(en.target) this.scene.remove(en.target); // the light's aim point lives on the scene
|
||
this.scene.remove(en.wrapper);
|
||
this._entities.delete(id);
|
||
for(const k of [...this._baked.keys()]) if(k.startsWith(id+'|')) this._baked.delete(k);
|
||
// exposure is recomputed from the ambients that are STILL on stage (sceneExposure) — deleting
|
||
// one of two ambients used to reset the whole frame to 1.0 and never restore the survivor's.
|
||
if(en.kind === 'light'){ this._refreshDefaults(); this._applyExposure(); }
|
||
if(en.kind === 'backdrop' || en.kind === 'light') this._applyWorld(); // floor/grid/fog follow the plates
|
||
this._shadowDirty = true;
|
||
this._fireChange(en); // trigger a row resync so the deleted entity drops out
|
||
}
|
||
|
||
async _loadAsset(desc){
|
||
let buf, name;
|
||
if(desc.source && desc.source.type === 'upload'){
|
||
if(!desc._buf) throw new Error('upload entity has no buffer');
|
||
buf = desc._buf; name = desc.source.path;
|
||
} else {
|
||
const path = desc.source.path;
|
||
const res = await fetch(ASSET_URL(path));
|
||
if(!res.ok) throw new Error('asset fetch ' + res.status + ': ' + path);
|
||
buf = await res.arrayBuffer(); name = path;
|
||
}
|
||
return parseAny(buf, name);
|
||
}
|
||
|
||
_normalize(root, isChar){
|
||
root.updateMatrixWorld(true);
|
||
const box = new THREE.Box3().setFromObject(root);
|
||
if(box.isEmpty() || !isFinite(box.min.x)) return;
|
||
if(isChar){
|
||
const size = box.getSize(new THREE.Vector3());
|
||
const s = 1.7 / (Math.max(size.x, size.y, size.z) || 1);
|
||
root.scale.setScalar(s); root.updateMatrixWorld(true);
|
||
}
|
||
const b = new THREE.Box3().setFromObject(root);
|
||
root.position.x -= (b.min.x + b.max.x)/2;
|
||
root.position.z -= (b.min.z + b.max.z)/2;
|
||
root.position.y -= b.min.y; // feet on the floor
|
||
}
|
||
|
||
// Backdrop plate — image OR video (M6). params.mode: plane|corner|dome|video ('video' = a
|
||
// plane; the other modes also accept .mp4 sources). Video plates show their poster jpg (the
|
||
// server's same-stem sidecar) until first play/seek, then swap to the live VideoTexture.
|
||
async _buildBackdrop(en){
|
||
const p = en.params;
|
||
const src = p.image || (en.source && en.source.path) || '';
|
||
const upload = en.source && en.source.type === 'upload';
|
||
const url = upload ? en.source._url : ASSET_URL(src);
|
||
const isVideo = p.mode === 'video' || /\.(mp4|webm|mov)$/i.test(src);
|
||
let tex, aspect, vidTex = null, poster = null;
|
||
if(isVideo){
|
||
const { video, texture } = await this._makeVideo(url, src);
|
||
en.video = video; en.videoTex = vidTex = texture;
|
||
aspect = (video.videoWidth / video.videoHeight) || 16/9;
|
||
poster = upload ? null : await this._posterTex(src);
|
||
tex = poster || vidTex;
|
||
} else {
|
||
tex = await new THREE.TextureLoader().loadAsync(url);
|
||
tex.colorSpace = THREE.SRGBColorSpace;
|
||
aspect = (tex.image.width / tex.image.height) || 1;
|
||
}
|
||
const w = p.width || 10, h = w / aspect;
|
||
const grp = new THREE.Group(), mats = [];
|
||
if(p.mode === 'dome'){
|
||
const dm = this._plateMat(tex, {side:THREE.BackSide}); mats.push(dm);
|
||
grp.add(new THREE.Mesh(new THREE.SphereGeometry(40, 40, 24), dm));
|
||
} else {
|
||
const mat = this._plateMat(tex); mats.push(mat);
|
||
const wall = new THREE.Mesh(new THREE.PlaneGeometry(w, h), mat);
|
||
wall.position.y = h/2; grp.add(wall);
|
||
if(p.mode === 'corner'){ // cheap "L": duplicate lower third laid flat as ground
|
||
// The ground is the SAME PHOTOGRAPH as the wall, so it gets the SAME material. It used to
|
||
// be a MeshStandardMaterial "because it catches the shadow", which rendered one image at
|
||
// two levels across the fold — lit + tone-mapped below, unlit + untonemapped above, a hard
|
||
// seam at the L junction (under `noir`, ambient 0.15, the ground copy went near-black
|
||
// while the wall stayed at full brightness). The shadow now lands on a separate
|
||
// ShadowMaterial catcher laid 2mm over it: invisible except where something shades it.
|
||
const gh = h/3, gm = this._plateMat(tex); mats.push(gm);
|
||
const g = new THREE.Mesh(new THREE.PlaneGeometry(w, gh), gm);
|
||
g.rotation.x = -Math.PI/2; g.position.z = gh/2; grp.add(g);
|
||
// fog:false for the same reason the plate has it: _applyWorld installs a FogExp2 from the
|
||
// plate's own ground colour, and three's default (fog:true) would have tinted and faded
|
||
// the SHADOW with distance while the photograph it lands on stayed fog-free — one image,
|
||
// two atmospheres, which is the seam this catcher was introduced to remove.
|
||
const catcher = new THREE.Mesh(new THREE.PlaneGeometry(w, gh),
|
||
new THREE.ShadowMaterial({opacity:0.45, fog:false}));
|
||
catcher.rotation.x = -Math.PI/2; catcher.position.set(0, 0.002, gh/2);
|
||
catcher.receiveShadow = true; catcher.userData.shadowCatcher = true; grp.add(catcher);
|
||
wall.position.z = -gh/2;
|
||
}
|
||
}
|
||
this._plateLevel(mats, p.plateExposure);
|
||
if(vidTex && poster) this._swapOnPlay(en.video, vidTex, mats);
|
||
if(en.video && this._mixerAuto) en.video.play().catch(()=>{}); // ambient preview until a timeline owns the clock
|
||
return grp;
|
||
}
|
||
|
||
// In-set display (M6 `screen` kind): a video plane with an emissive boost so TVs/club
|
||
// screens/jumbotrons read "on" under any lighting preset. params {video, width, emissive}.
|
||
// ponytail: CRT curve skipped — flat plane until a real TV asset asks for it.
|
||
async _buildScreen(en){
|
||
const p = en.params;
|
||
const src = p.video || (en.source && en.source.path) || '';
|
||
const upload = en.source && en.source.type === 'upload';
|
||
const { video, texture } = await this._makeVideo(upload ? en.source._url : ASSET_URL(src), src);
|
||
en.video = video; en.videoTex = texture;
|
||
const aspect = (video.videoWidth / video.videoHeight) || 16/9;
|
||
const poster = upload ? null : await this._posterTex(src);
|
||
const tex = poster || texture;
|
||
const w = p.width || 3, h = w / aspect;
|
||
const mat = new THREE.MeshStandardMaterial({ map:tex, emissive:0xffffff, emissiveMap:tex,
|
||
emissiveIntensity: p.emissive ?? 0.6, roughness:0.4, metalness:0, toneMapped:false });
|
||
if(poster) this._swapOnPlay(video, texture, [mat]);
|
||
if(this._mixerAuto) video.play().catch(()=>{});
|
||
const mesh = new THREE.Mesh(new THREE.PlaneGeometry(w, h), mat);
|
||
mesh.position.y = h/2;
|
||
const grp = new THREE.Group(); grp.add(mesh);
|
||
return grp;
|
||
}
|
||
|
||
// <video> element + VideoTexture, resolved once metadata (dimensions/duration) is in
|
||
_makeVideo(url, srcPath){
|
||
return new Promise((resolve, reject) => {
|
||
const v = document.createElement('video');
|
||
v.muted = true; v.loop = true; v.playsInline = true; v.preload = 'auto'; v.crossOrigin = 'anonymous';
|
||
v.src = url;
|
||
const texture = new THREE.VideoTexture(v);
|
||
texture.colorSpace = THREE.SRGBColorSpace;
|
||
v.addEventListener('loadedmetadata', () => resolve({ video: v, texture }), { once:true });
|
||
v.addEventListener('error', () => reject(new Error('video load failed: ' + srcPath)), { once:true });
|
||
});
|
||
}
|
||
_posterTex(src){ // same-stem jpg via the server's sidecar route; null when absent
|
||
return new THREE.TextureLoader().loadAsync('assets/thumb?path=' + encodeURIComponent(src))
|
||
.then(t => { t.colorSpace = THREE.SRGBColorSpace; return t; })
|
||
.catch(() => null);
|
||
}
|
||
_swapOnPlay(video, texture, mats){ // poster stays up until the video actually shows a frame
|
||
const swap = () => {
|
||
video.removeEventListener('playing', swap); video.removeEventListener('seeked', swap);
|
||
for(const m of mats){ m.map = texture; if(m.emissiveMap) m.emissiveMap = texture; m.needsUpdate = true; }
|
||
};
|
||
video.addEventListener('playing', swap); video.addEventListener('seeked', swap);
|
||
}
|
||
_dropVideo(en){
|
||
if(!en.video) return;
|
||
en.video.pause(); en.video.removeAttribute('src'); en.video.load(); en.video = null;
|
||
}
|
||
|
||
// ---- world-integrated backdrops (M7) ---------------------------------------------------------
|
||
// A plate used to read as a billboard in the void: a gap between its bottom edge and the grid, a
|
||
// floor that belongs to a different scene, no air between camera and horizon. Three cheap fixes,
|
||
// all recomputed on load / param change / camera change — NEVER per frame.
|
||
// params.fit:'frustum' size+place the plate so it fills the active camera's frustum (+bleed)
|
||
// params.groundTint tint the stage floor to the plate's bottom rows (default on)
|
||
// params `fog` on the ambient light keyable FogExp2 density, coloured from the plate
|
||
|
||
_activeAspect(){ const c = this._activeCam();
|
||
return c.aspect || (this.view.clientWidth / this.view.clientHeight) || 16/9; }
|
||
|
||
fitBackdrops(){ for(const en of this._entities.values())
|
||
if(en.kind === 'backdrop') this._fitBackdrop(en); }
|
||
|
||
// Scale/park the plate on the active camera's axis so it covers the frame at params.fitDist.
|
||
// ponytail: fit OWNS the backdrop's wrapper transform — keyframe a plate only with fit:'free'.
|
||
_fitBackdrop(en){
|
||
const p = en.params, root = en.root;
|
||
if(!root || p.fit !== 'frustum') return;
|
||
if(p.mode === 'dome' || p.mode === 'corner') return; // dome already wraps; corner needs its floor flat
|
||
const mesh = root.children[0], g = mesh && mesh.geometry && mesh.geometry.parameters;
|
||
if(!g || !g.width) return;
|
||
const cam = this._activeCam(); cam.updateMatrixWorld(true);
|
||
const d = p.fitDist || 14; // far enough to read as background
|
||
const { s } = fitScale(cam.fov, this._activeAspect(), d, g.width, g.height);
|
||
root.scale.setScalar(s);
|
||
// SYNC7: which row of the plate sits at eye level. 1.0 parked the plate's TOP EDGE on the
|
||
// camera axis (void above centre-frame); 0.5 put the image's middle — road tarmac, for a
|
||
// street plate — at eye height. params.fitAnchor is the fraction of image height at the
|
||
// lens axis: 0.75 lands a typical plate's horizon near eye level and still fills upward.
|
||
const anchor = p.fitAnchor == null ? 0.75 : p.fitAnchor;
|
||
root.position.set(0, (0.5 - anchor) * g.height * s, 0);
|
||
const camPos = cam.getWorldPosition(new THREE.Vector3());
|
||
const fwd = new THREE.Vector3(0, 0, -1).applyQuaternion(cam.getWorldQuaternion(new THREE.Quaternion()));
|
||
en.wrapper.position.copy(camPos).addScaledVector(fwd, d);
|
||
en.wrapper.lookAt(camPos); // plane normal is +Z
|
||
en.wrapper.scale.setScalar(1);
|
||
}
|
||
_unfitBackdrop(en){ if(en.root){ en.root.scale.setScalar(1); en.root.position.set(0,0,0); } }
|
||
|
||
// ONE material for every surface that shows the photograph — wall, corner ground, dome.
|
||
// fog:false — the plate IS the distance; atmosphere is for what stands in front of it.
|
||
// toneMapped:false + unlit — a photograph/Flow frame is DISPLAY-REFERRED, already lit and
|
||
// graded; re-lighting it meant `noir` (ambient 0.15) blacked the photograph out, and running
|
||
// ACES over it a second time muddies its blacks. Level is params.plateExposure (_plateLevel),
|
||
// never the key light. Anything that shows this image and does NOT come through here will
|
||
// read at a different level than the rest of the plate.
|
||
_plateMat(tex, extra){
|
||
return new THREE.MeshBasicMaterial({map:tex, fog:false, toneMapped:false, ...extra});
|
||
}
|
||
|
||
// plate level: an unlit plate has no key to expose it, so params.plateExposure (default 1) rides
|
||
// the material colour — same knob for every plate surface (never the shadow catcher, which has
|
||
// no map and whose `color` is the shadow's own tint).
|
||
_plateLevel(mats, v){
|
||
const n = +v, s = Number.isFinite(n) ? Math.max(0, Math.min(4, n)) : 1;
|
||
for(const m of (mats || [])) if(m && m.color) m.color.setScalar(s);
|
||
}
|
||
|
||
// average colour of the plate's bottom rows — the ground the character should be standing on.
|
||
// Works for images and for video (poster or current frame); same-origin, so the canvas is clean.
|
||
_plateGround(en){
|
||
const mesh = en.root && en.root.children[0], tex = mesh && mesh.material && mesh.material.map;
|
||
const img = tex && tex.image;
|
||
if(!img || !(img.width || img.videoWidth)) return null;
|
||
if(img.videoWidth && img.readyState < 2) return null; // no decoded frame yet → sample black
|
||
try {
|
||
const c = document.createElement('canvas'); c.width = c.height = 24;
|
||
const g = c.getContext('2d', { willReadFrequently: true });
|
||
g.drawImage(img, 0, 0, 24, 24);
|
||
const d = g.getImageData(0, 17, 24, 7).data; // bottom ~quarter
|
||
let r = 0, gr = 0, b = 0; const n = d.length / 4;
|
||
for(let i = 0; i < d.length; i += 4){ r += d[i]; gr += d[i+1]; b += d[i+2]; }
|
||
return new THREE.Color().setRGB(r/n/255, gr/n/255, b/n/255, THREE.SRGBColorSpace);
|
||
} catch(e){ return null; } // tainted canvas / no frame yet
|
||
}
|
||
_sampleGround(en){
|
||
const col = this._plateGround(en);
|
||
if(col) en.ground = col;
|
||
else if(en.video) // no decoded frame yet — take the first one that lands
|
||
en.video.addEventListener('loadeddata', () => this._sampleGround(en), { once:true });
|
||
this._applyWorld(); // grid/fog follow the plate even when sampling came back empty
|
||
}
|
||
|
||
// floor tint + grid fade + fog, derived from whatever plates are on stage
|
||
_applyWorld(){
|
||
const plates = this.entities().filter(e => e.kind === 'backdrop');
|
||
const tint = plates.filter(e => e.ground && (e.params.groundTint ?? true)).pop();
|
||
const col = tint ? tint.ground : null;
|
||
this._floor.material.color.copy(col ? col.clone().multiplyScalar(0.85) : this._floorBase);
|
||
this._grid.material.opacity = plates.length ? 0.12 : 1; // fade the studio grid out under a plate
|
||
const amb = this.entities().find(e => e.kind === 'light' && e.params.type === 'ambient');
|
||
const dens = amb && amb.params.fog != null ? amb.params.fog : (col ? 0.02 : 0); // subtle default
|
||
const had = !!this.scene.fog;
|
||
if(col && dens > 0){ // Lane B can key `fog` per tick — mutate, don't reallocate
|
||
if(had){ this.scene.fog.color.copy(col); this.scene.fog.density = dens; }
|
||
else this.scene.fog = new THREE.FogExp2(col.getHex(), dens);
|
||
} else this.scene.fog = null;
|
||
// toggling fog on/off changes the shader program — materials must recompile (once, not per frame)
|
||
if(had !== !!this.scene.fog) this.scene.traverse(o => { const m = o.material;
|
||
if(m) (Array.isArray(m) ? m : [m]).forEach(x => x.needsUpdate = true); });
|
||
}
|
||
|
||
_buildLight(en){
|
||
const p = en.params;
|
||
if(p.type === 'ambient'){
|
||
en.light = new THREE.HemisphereLight(new THREE.Color(p.color||'#ffffff'), 0x444450, p.intensity ?? 1.0);
|
||
en.wrapper.add(en.light);
|
||
if(p.bg) this.scene.background = new THREE.Color(p.bg); // saved lighting preset restores its backdrop color
|
||
// exposure rides the ambient entity exactly like bg (keyable, persists in the scene JSON),
|
||
// but building a light does NOT set the frame's level — _applyExposure() does, from the
|
||
// ambients that are on stage, once addEntity has registered this one. Writing it here meant
|
||
// a dock "+ ambient" (which carries no exposure) stomped a preset's 1.6 down to 1.0.
|
||
} else {
|
||
const dir = en.light = new THREE.DirectionalLight(new THREE.Color(p.color||'#ffffff'), p.intensity ?? 1.5);
|
||
this._shadowRig(dir);
|
||
dir.castShadow = p.castShadow ?? true;
|
||
dir.position.set(0,0,0);
|
||
// The aim point is nailed to the STAGE ORIGIN and lives on the SCENE, not on the light's
|
||
// wrapper. As a child at z=−1 it travelled with the light, so gizmo-translating a key light
|
||
// 15m along X carried the whole light→target axis with it: the ortho shadow box (which
|
||
// shadowHalfExtent measures from the origin) went with it and the character silently lost
|
||
// his shadow. A sun's aim IS its position — elevation/azimuth on a boom, exactly what
|
||
// applyLight solves.
|
||
const tgt = en.target = new THREE.Object3D();
|
||
this.scene.add(tgt); dir.target = tgt;
|
||
this._aimLight(en);
|
||
en.wrapper.add(dir);
|
||
const proxy = new THREE.Mesh(new THREE.SphereGeometry(0.15, 12, 8),
|
||
new THREE.MeshBasicMaterial({color:0xffe08a}));
|
||
proxy.userData.chrome = true; // editor-only, like the camera cone — never in a render
|
||
en.wrapper.add(proxy);
|
||
}
|
||
this._refreshDefaults();
|
||
this._shadowDirty = true;
|
||
}
|
||
|
||
// Point a key light's target. It sits at the stage origin — that is what makes a light's ANGLE
|
||
// its position and keeps the ortho shadow box (measured from the origin) over the stage. The one
|
||
// position that has no direction is the origin itself: gizmo-dragging a sun onto (0,0,0) made
|
||
// light.position === target.position, a zero-length direction three resolves to NaN (no shadow,
|
||
// undefined shading). A light standing ON the mark shines straight down.
|
||
_aimLight(en){
|
||
if(!en || !en.target) return;
|
||
const p = en.wrapper.position;
|
||
en.target.position.set(0, Math.hypot(p.x, p.y, p.z) < 1e-3 ? -1 : 0, 0);
|
||
}
|
||
|
||
// the ACES level, recomputed from the ambients on stage (see sceneExposure)
|
||
_applyExposure(){ this.renderer.toneMappingExposure = sceneExposure(this.entities()); }
|
||
|
||
// dim built-in lights once scene lights exist
|
||
_refreshDefaults(){
|
||
const hasKey = this.entities().some(e => e.kind==='light' && e.params.type!=='ambient');
|
||
const hasAmb = this.entities().some(e => e.kind==='light' && e.params.type==='ambient');
|
||
this._defKey.visible = !hasKey;
|
||
this._defHemi.intensity = hasAmb ? 0 : 2.2;
|
||
}
|
||
|
||
// ---- selection + gizmo ----
|
||
select(id){
|
||
this._selected = id;
|
||
const en = id == null ? null : this._entities.get(id);
|
||
// A frustum-fitted plate is placed BY THE LENS: dragging its gizmo visibly moved it until the
|
||
// next refit yanked it back with no explanation. No gizmo — selection/inspector still work.
|
||
if(!en || (en.kind === 'backdrop' && en.params && en.params.fit === 'frustum')) this._tc.detach();
|
||
else {
|
||
this._tc.attach(en.wrapper);
|
||
// …and never hand a light a rotate/scale gizmo left over from the last selection (see the
|
||
// `w`/`e`/`r` handler: only its position means anything).
|
||
if(en.kind === 'light') this._tc.setMode?.('translate');
|
||
}
|
||
for(const cb of this._selCbs) cb(id ? this._entities.get(id) : null);
|
||
}
|
||
_pick(e){
|
||
if(this._tc.dragging) return;
|
||
const rect = this.renderer.domElement.getBoundingClientRect();
|
||
const ndc = new THREE.Vector2(
|
||
((e.clientX - rect.left)/rect.width)*2 - 1,
|
||
-((e.clientY - rect.top)/rect.height)*2 + 1);
|
||
const rc = new THREE.Raycaster(); rc.setFromCamera(ndc, this._dirCam);
|
||
const wraps = this.entities().map(en => en.wrapper);
|
||
const hits = rc.intersectObjects(wraps, true);
|
||
for(const h of hits){
|
||
let o = h.object; while(o && o.userData.entityId == null) o = o.parent;
|
||
if(o){ this.select(o.userData.entityId); return; }
|
||
}
|
||
this.select(null);
|
||
}
|
||
|
||
// ---- transforms / params (Timeline drives these every tick) ----
|
||
entityTransform(id){
|
||
const w = this._entities.get(id)?.wrapper; if(!w) return null;
|
||
return { pos:[w.position.x, w.position.y, w.position.z],
|
||
rot:[w.rotation.x, w.rotation.y, w.rotation.z],
|
||
scale: w.scale.x };
|
||
}
|
||
setTransform(id, {pos, rot, scale}){
|
||
const en = this._entities.get(id); const w = en?.wrapper; if(!w) return;
|
||
// SYNC7: a frustum-fitted plate is placed BY THE LENS, not by the user. The timeline
|
||
// re-applies every entity's rest transform each tick, which dragged the plate back to
|
||
// its authored pos and left a black bar at frame top. Camera owns it; ignore the rest.
|
||
if(en.kind === 'backdrop' && en.params && en.params.fit === 'frustum'){ this._fitBackdrop(en); return; }
|
||
// ponytail: only a light that ACTUALLY MOVED refits (the box's far plane is the boom
|
||
// distance). The timeline re-applies every entity's transform each tick, so flagging
|
||
// characters — or flagging a keyed sun that lands on the same pos every frame — would run a
|
||
// Box3.setFromObject sweep over skinned meshes every frame; add/remove already covers the
|
||
// cases that change the box's size.
|
||
const moved = en.kind === 'light' && pos && !samePos(pos, [w.position.x, w.position.y, w.position.z]);
|
||
if(pos) w.position.set(pos[0], pos[1], pos[2]);
|
||
if(rot) w.rotation.set(rot[0], rot[1], rot[2]);
|
||
if(scale != null) w.scale.setScalar(scale);
|
||
if(moved){ this._aimLight(en); this._shadowDirty = true; }
|
||
}
|
||
setParam(id, key, value){
|
||
const en = this._entities.get(id); if(!en) return;
|
||
en.params[key] = value;
|
||
if(en.kind === 'light' && shadowAffectingParam(key)) this._shadowDirty = true;
|
||
if(en.cam && key === 'fov'){ en.cam.fov = value; en.cam.updateProjectionMatrix();
|
||
if(en.id === this._activeCamId) this.fitBackdrops(); } // the frame changed shape
|
||
if(en.light){
|
||
if(key === 'intensity') en.light.intensity = value;
|
||
if(key === 'color') en.light.color.set(value);
|
||
if(key === 'castShadow' && en.light.isDirectionalLight) en.light.castShadow = !!value;
|
||
if(key === 'bg') this.scene.background = new THREE.Color(value); // bg rides the ambient entity (keyable, persists)
|
||
if(key === 'fog') this._applyWorld(); // keyable atmosphere, same pattern as bg
|
||
// overall level for the whole frame. Goes through the SAME rule as add/remove (the last
|
||
// ambient that states an exposure owns it) so there is one answer, and it is clamped so a
|
||
// keyed lerp through 0 can't black the film out.
|
||
if(key === 'exposure') this._applyExposure();
|
||
}
|
||
if(en.kind === 'backdrop'){
|
||
if(key === 'fit'){ value === 'frustum' ? this._fitBackdrop(en) : this._unfitBackdrop(en);
|
||
if(this._selected === en.id) this.select(en.id); } // frustum ⇄ free swaps the gizmo
|
||
if(key === 'fitDist' || key === 'fitAnchor') this._fitBackdrop(en);
|
||
if(key === 'groundTint') this._applyWorld();
|
||
if(key === 'plateExposure' && en.root){
|
||
// map-bearing materials only: the corner shadow catcher is a ShadowMaterial whose
|
||
// `color` is the shadow tint — setScalar(1) on it would erase the shadow.
|
||
const mats = []; en.root.traverse(o => { if(o.isMesh && o.material && o.material.map) mats.push(o.material); });
|
||
this._plateLevel(mats, value);
|
||
}
|
||
}
|
||
const rebuild = () => this._rebuild(en).catch(e => console.warn('media rebuild failed:', en.id, e));
|
||
if(en.kind === 'backdrop' && (key === 'mode' || key === 'image' || key === 'width'))
|
||
rebuild(); // geometry depends on these — rebuild the mesh
|
||
if(en.kind === 'screen'){
|
||
if(key === 'emissive' && en.root)
|
||
en.root.traverse(o => { if(o.isMesh) o.material.emissiveIntensity = value; });
|
||
if(key === 'video' || key === 'width') rebuild();
|
||
}
|
||
// videoStart is data for the timeline/syncVideos — nothing to rebuild
|
||
}
|
||
async _rebuild(en){ // backdrop/screen media rebuild (mode/image/video/width changed)
|
||
const old = en.root;
|
||
this._dropVideo(en);
|
||
en.root = en.kind === 'screen' ? await this._buildScreen(en) : await this._buildBackdrop(en);
|
||
en.wrapper.add(en.root);
|
||
if(old){ en.wrapper.remove(old); disposeRoot(old); }
|
||
if(en.kind === 'backdrop'){ this._fitBackdrop(en); en.ground = null; this._sampleGround(en); }
|
||
}
|
||
|
||
// ---- clips (bake is expensive — cache per path parse and per (entity,path,index) bake) ----
|
||
async prepareClip(id, path, clipIndex=0){
|
||
const en = this._entities.get(id);
|
||
if(!en || en.kind !== 'character') throw new Error('prepareClip: not a character: ' + id);
|
||
const bkey = `${id}|${path}|${clipIndex}`;
|
||
if(this._baked.has(bkey)) return this._baked.get(bkey);
|
||
let src = this._clipSrc.get(path);
|
||
if(!src){
|
||
const res = await fetch(ASSET_URL(path));
|
||
if(!res.ok) throw new Error('clip fetch ' + res.status + ': ' + path);
|
||
const { root, anims } = await parseAny(await res.arrayBuffer(), path);
|
||
src = { root, anims, rest: captureRest(root) };
|
||
this._clipSrc.set(path, src);
|
||
}
|
||
const clip = src.anims[clipIndex];
|
||
if(!clip) throw new Error('no clip index ' + clipIndex + ' in ' + path);
|
||
const baked = bakeRetarget(clip, src.root, src.rest, en.root, en.rest);
|
||
this._baked.set(bkey, baked);
|
||
return baked;
|
||
}
|
||
// same bake, from in-memory bytes (dropped clip file) instead of a server path
|
||
async prepareClipUpload(id, buf, name, clipIndex=0){
|
||
const en = this._entities.get(id);
|
||
if(!en || en.kind !== 'character') throw new Error('prepareClipUpload: not a character: ' + id);
|
||
const { root, anims } = await parseAny(buf, name);
|
||
const clip = anims[clipIndex];
|
||
if(!clip) throw new Error('no animation tracks in ' + name);
|
||
return bakeRetarget(clip, root, captureRest(root), en.root, en.rest);
|
||
}
|
||
entityMixer(id){ return this._entities.get(id)?.mixer || null; }
|
||
|
||
// ---- visemes / morph targets (lip sync) ----
|
||
visemeTargets(id){ return Object.keys(this._entities.get(id)?.visemes || {}); }
|
||
// drive a canonical viseme (A/E/I/O/U/MBP/FV/L) or a raw morph name; sets every mapped mesh morph
|
||
setMorph(id, name, weight){
|
||
const en = this._entities.get(id); if(!en || !en.morphs) return;
|
||
const raws = (en.visemes && en.visemes[name]) || (en.morphs.has(name.toLowerCase()) ? [name.toLowerCase()] : []);
|
||
for(const r of raws) for(const {mesh, idx} of en.morphs.get(r))
|
||
if(mesh.morphTargetInfluences) mesh.morphTargetInfluences[idx] = weight;
|
||
}
|
||
|
||
// M1 temp helper: play a prepared clip on a character (Timeline replaces this in M2).
|
||
playClip(id, clip, {loop=true} = {}){
|
||
const en = this._entities.get(id); if(!en || !en.mixer) return;
|
||
en.mixer.stopAllAction();
|
||
const a = en.mixer.clipAction(clip);
|
||
a.setLoop(loop ? THREE.LoopRepeat : THREE.LoopOnce, Infinity);
|
||
a.clampWhenFinished = !loop; a.reset().play();
|
||
}
|
||
|
||
setMixerAuto(on){ this._mixerAuto = on; } // Timeline calls setMixerAuto(false) at integration
|
||
|
||
// ---- cameras ----
|
||
setActiveCamera(id){
|
||
this._activeCamId = id;
|
||
this._pip.style.display = (id && this._entities.get(id)?.cam) ? 'block' : 'none';
|
||
this.fitBackdrops(); // auto-fit plates belong to whichever camera is shooting
|
||
}
|
||
activeCamera(){ return this._activeCamId || null; } // entity id, or null = director orbit cam
|
||
_activeCam(){ const en = this._activeCamId && this._entities.get(this._activeCamId);
|
||
return en && en.cam ? en.cam : this._dirCam; }
|
||
// render `cam` to the main WebGL canvas at the target's aspect; if `canvas` given, blit there and
|
||
// restore the cam's aspect (so using a cam for the inset never distorts it in the main view).
|
||
// SYNC7: gizmo + helpers were being BAKED INTO THE FINAL MP4 (a green translate
|
||
// arrow across every frame). Anything that's editor chrome hides while paused —
|
||
// pause() is exactly "the caller is rendering output now".
|
||
_chrome(visible){
|
||
const set = o => { if(o) o.visible = visible; };
|
||
set(this._tc); set(this._grid);
|
||
this.scene.traverse(o => { if(o.userData.chrome || o.isTransformControls || o.type === 'GridHelper'
|
||
|| o.isCameraHelper || o.isDirectionalLightHelper || o.isBox3Helper || o.isSkeletonHelper) o.visible = visible; });
|
||
for(const en of this._entities.values()) if(en.helper) en.helper.visible = visible;
|
||
}
|
||
_render(cam, canvas){
|
||
// Consume the shadow refit HERE rather than in the rAF loop: the final render drives _render
|
||
// directly (renderActiveCamera, while _paused), so a fit that only fired on a rAF callback
|
||
// made which frames saw the new ortho box depend on browser timing instead of on
|
||
// timeline.step(). Still ≤1 refit per drawn frame — the PiP pass clears the flag for the main.
|
||
if(this._shadowDirty){ this._shadowDirty = false; this._fitShadows(); }
|
||
const w = canvas ? canvas.width : this.renderer.domElement.width;
|
||
const h = canvas ? canvas.height : this.renderer.domElement.height;
|
||
const prev = cam.aspect;
|
||
if(cam.isPerspectiveCamera){ cam.aspect = w/h; cam.updateProjectionMatrix(); }
|
||
// SYNC7: plates are fitted to the LIVE viewport aspect, but output renders at their own
|
||
// (960x540 etc). A narrower output frame is taller than what the plate was sized for →
|
||
// black bars top and bottom of every rendered film. Refit to the aspect we're about to
|
||
// draw at. Pure algebra (no texture sampling), so per-frame is fine.
|
||
if(cam.isPerspectiveCamera && Math.abs((prev || 0) - cam.aspect) > 1e-4) this.fitBackdrops();
|
||
if(this._paused) this._chrome(false);
|
||
this.renderer.render(this.scene, cam);
|
||
if(this._paused) this._chrome(true);
|
||
if(canvas){ canvas.getContext('2d').drawImage(this.renderer.domElement, 0, 0, canvas.width, canvas.height);
|
||
if(cam.isPerspectiveCamera && cam.aspect !== prev){ cam.aspect = prev; cam.updateProjectionMatrix(); } }
|
||
}
|
||
// PiP: pass the small overlay canvas. Final render (M3): pass null after pause() — the active cam
|
||
// lands on the main WebGL canvas at its current size for readPixels/toBlob. Hard contract, keep it.
|
||
renderActiveCamera(canvas){ this._render(this._activeCam(), canvas); }
|
||
pause(){ this._paused = true; }
|
||
resume(){ this._paused = false; this.clock.getDelta(); } // swallow the paused gap so mixers don't jump
|
||
|
||
// ---- director-mode helpers (M5 presets.js solvers are three-free — plain arrays) ----
|
||
entityBBox(id){
|
||
const en = this._entities.get(id); if(!en) return null;
|
||
en.wrapper.updateMatrixWorld(true);
|
||
const box = new THREE.Box3().setFromObject(en.wrapper);
|
||
return (box.isEmpty() || !isFinite(box.min.x)) ? null : { min: box.min.toArray(), max: box.max.toArray() };
|
||
}
|
||
cameraWorldPos(){ // active camera if set, else the director orbit cam
|
||
const p = new THREE.Vector3(); this._activeCam().getWorldPosition(p); return p.toArray();
|
||
}
|
||
viewAspect(){ return this._activeAspect(); } // M11: a truck is framed in the REAL frame, not 16:9
|
||
// "stamp a camera on the view you are looking at" — the orbit cam's own state, so a new camera
|
||
// lands where you already composed instead of at a hardcoded [0,1.6,4] you then guess back.
|
||
directorCamState(){
|
||
const c = this._dirCam, p = c.position, r = c.rotation; // .rotation is already an XYZ euler
|
||
return { pos:[p.x, p.y, p.z], rot:[r.x, r.y, r.z], fov: c.fov };
|
||
}
|
||
|
||
// ---- video plates (M6): master-clock sync + deterministic render seeks ----
|
||
videos(){ return this.entities().filter(e => e.video)
|
||
.map(e => ({ id: e.id, video: e.video, start: e.params.videoStart || 0 })); }
|
||
entityVideo(id){ return this._entities.get(id)?.video || null; } // SYNC6: Lane B's per-entity seam
|
||
setVideoPlaying(on){ for(const { video } of this.videos()) on ? video.play().catch(()=>{}) : video.pause(); }
|
||
// Seek every stage video to timeline time t and resolve once all fire 'seeked' —
|
||
// render.js awaits this per frame so plates are frame-exact, never mid-decode. Hard contract.
|
||
async syncVideos(t){
|
||
const jobs = [];
|
||
// SYNC6: r160 VideoTexture auto-updates via requestVideoFrameCallback, which never
|
||
// fires in a hidden/background tab — force the GPU upload or renders show black plates.
|
||
for(const en of this._entities.values()) if(en.videoTex) en.videoTex.needsUpdate = true;
|
||
for(const { video, start } of this.videos()){
|
||
if(video.readyState < 1 || !isFinite(video.duration) || video.duration <= 0) continue;
|
||
// videoTimeAt CLAMPS (matching Timeline._evalVideo) — it used to WRAP here, so a plate with
|
||
// videoStart:2 on a 10s clip showed frame 0 while scrubbing and the 8.0s frame in the mp4.
|
||
const vt = videoTimeAt(t, start, video.duration);
|
||
if(!video.paused) video.pause();
|
||
if(Math.abs(video.currentTime - vt) < 0.004) continue; // already on this frame
|
||
jobs.push(new Promise(res => {
|
||
const done = () => { video.removeEventListener('seeked', done); res(); };
|
||
video.addEventListener('seeked', done);
|
||
setTimeout(done, 1500); // ponytail: never hang a render on a stuck decoder
|
||
video.currentTime = vt;
|
||
}));
|
||
}
|
||
await Promise.all(jobs);
|
||
}
|
||
|
||
// ---- pointer→world helpers (drag-from-dock placement) ----
|
||
screenToFloor(clientX, clientY){
|
||
const rect = this.renderer.domElement.getBoundingClientRect();
|
||
const ndc = new THREE.Vector2(((clientX-rect.left)/rect.width)*2-1, -((clientY-rect.top)/rect.height)*2+1);
|
||
const rc = new THREE.Raycaster(); rc.setFromCamera(ndc, this._dirCam);
|
||
const hit = new THREE.Vector3();
|
||
return rc.ray.intersectPlane(new THREE.Plane(new THREE.Vector3(0,1,0), 0), hit) ? [hit.x, 0, hit.z] : [0,0,0];
|
||
}
|
||
entityAt(clientX, clientY, kind){
|
||
const rect = this.renderer.domElement.getBoundingClientRect();
|
||
const ndc = new THREE.Vector2(((clientX-rect.left)/rect.width)*2-1, -((clientY-rect.top)/rect.height)*2+1);
|
||
const rc = new THREE.Raycaster(); rc.setFromCamera(ndc, this._dirCam);
|
||
const hits = rc.intersectObjects(this.entities().map(e=>e.wrapper), true);
|
||
for(const h of hits){ let o=h.object; while(o && o.userData.entityId==null) o=o.parent;
|
||
if(o){ const en=this._entities.get(o.userData.entityId); if(en && (!kind || en.kind===kind)) return en.id; } }
|
||
return null;
|
||
}
|
||
|
||
// ---- scene JSON round-trip (entities only; Timeline owns tracks/duration/cuts) ----
|
||
captureState(){
|
||
return this.entities().map(en => ({
|
||
id: en.id, kind: en.kind, label: en.label,
|
||
source: en.source, params: {...en.params},
|
||
transform: this.entityTransform(en.id),
|
||
upload: en.source && en.source.type === 'upload' || undefined,
|
||
}));
|
||
}
|
||
async applyState(sceneJson){
|
||
for(const en of this.entities()) this.removeEntity(en.id);
|
||
for(const d of (sceneJson.entities || [])){
|
||
// skip only what needs asset BYTES we don't have: an 'upload' mesh, backdrop or screen.
|
||
// camera/light use source:{type:'none'} (legacy scenes: 'upload') and always rebuild.
|
||
const needsBytes = d.kind === 'character' || d.kind === 'prop' || d.kind === 'backdrop' || d.kind === 'screen';
|
||
if(d.source && d.source.type === 'upload' && needsBytes){
|
||
console.warn('skipping upload entity (no bytes):', d.id); continue; }
|
||
try { await this.addEntity(d); } catch(err){ console.error('addEntity failed', d.id, err); }
|
||
}
|
||
this.fitBackdrops(); // cameras usually load after their plates
|
||
}
|
||
}
|