// 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 = '' + '' + '' + ''; 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 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; } //