when formats carries one
_videoPath(it){
const vf = (it.formats || []).find(f => ['mp4','webm','mov'].includes(f));
return vf ? (it.path || '').replace(/\.\w+$/, '.' + vf) : null;
}
renderBrowser(){
this.list.innerHTML = '';
for(const t of TABS){ const b = this._tabBtns && this._tabBtns[t];
if(b) b.innerHTML = t.toUpperCase() + '' + ((this.tree[t] || []).length) + ''; }
const items = this.tree[this.tab] || [];
if(!items.length){
this.list.innerHTML = this._offline
? 'server offline β drag a .glb / .fbx / image onto the view
'
: this._loadErr
? `assets/tree failed
${esc(this._loadErr)}
`
// "drop banks into assets/β¦" was an instruction you cannot follow from a browser
: `no ${esc(this.tab)} on the server yet β drag a .glb / .fbx / image `
+ 'from your desktop onto the stage
';
return;
}
const icon = { characters:'πΊ', props:'πͺ', backdrops:'πΌ', animations:'π' }[this.tab];
for(const it of items){
const c = document.createElement('div'); c.className = 'card'; c.draggable = true;
const th = it.thumb
? `
`
: `${icon}
`;
const vp = this.tab === 'backdrops' ? this._videoPath(it) : null;
c.innerHTML = `${th}${vp ? 'π ' : ''}${esc(this._name(it))}
`;
c.onclick = () => this._addFromLibrary(it);
c.ondragstart = ev => ev.dataTransfer.setData('application/scenegod',
JSON.stringify({ tab: this.tab, path: this._path(it), name: this._name(it), video: vp }));
if(vp){ // second affordance on video plates: add as an in-set screen (TV/club/jumbotron)
const tv = document.createElement('button'); tv.className = 'tv'; tv.textContent = 'πΊ';
tv.title = 'add as in-set screen';
tv.onclick = ev => { ev.stopPropagation(); this._addScreen(vp, this._name(it)); };
c.appendChild(tv);
}
this.list.appendChild(c);
}
}
async _addFromLibrary(it){
const path = this._path(it);
try {
if(this.tab === 'animations'){
const en = this.stage.getEntity(this._selId);
if(!en || en.kind !== 'character') return this._toast('select a character first');
return this._applyClip(en.id, path, 0);
}
const kind = this.tab === 'characters' ? 'character' : this.tab === 'props' ? 'prop' : 'backdrop';
const desc = { kind, label: this._name(it), source: { type:'assets', path } };
if(kind === 'backdrop'){
const vp = this._videoPath(it); // video plate β video plane; else image plane
// M7: new plates auto-fit the camera frustum (saved scenes without `fit` keep their sizing)
desc.params = vp ? { mode:'video', image:vp, width:12, fit:'frustum' }
: { mode:'plane', image:path, width:10, fit:'frustum' };
if(vp) desc.source = { type:'assets', path: vp };
}
this._toast('loading ' + this._name(it) + 'β¦');
const en = await this.stage.addEntity(desc);
this.stage.select(en.id);
this._toast('');
} catch(e){ this._toast('failed: ' + String(e).slice(0, 100)); }
}
// clips go to the timeline once it's mounted; standalone preview only when it isn't
async _applyClip(id, path, clipIndex){
if(window.timeline){
dispatchEvent(new CustomEvent('scenegod:clipdrop', { detail:{ id, path, clipIndex } }));
this._toast('+ ' + path.split('/').pop() + ' β timeline');
return;
}
this._toast('baking clipβ¦');
const clip = await this.stage.prepareClip(id, path, clipIndex);
this.stage.playClip(id, clip);
this._toast('βΆ ' + path.split('/').pop());
}
// M6: video plate β `screen` entity (emissive in-set display, PLAN Β§4.1 params)
async _addScreen(path, name){
try {
this._toast('loading ' + name + 'β¦');
const en = await this.stage.addEntity({ kind:'screen', label:name,
source:{ type:'assets', path }, params:{ video:path, width:3, emissive:0.6 } });
this.stage.select(en.id); this._toast('');
} catch(e){ this._toast('failed: ' + String(e).slice(0, 100)); }
}
// cameras and lights have no asset file β spawn them from the toolbar
async _addRig(what){
// a camera spawns ON THE VIEW you composed β the old hardcoded [0,1.6,4] meant orbiting to a
// framing and then guessing it back with the gizmo.
const cs = what === 'camera' && this.stage.directorCamState ? this.stage.directorCamState() : null;
const D = {
camera: { kind:'camera', label:'camera', params:{ fov: cs ? cs.fov : 45 },
transform: cs ? { pos:cs.pos, rot:cs.rot, scale:1 } : { pos:[0,1.6,4], rot:[0,0,0], scale:1 } },
keylight: { kind:'light', label:'key light', params:{ type:'key', color:'#ffffff', intensity:2.2, castShadow:true }, transform:{ pos:[3,5,2], rot:[-0.9,0.5,0], scale:1 } },
ambient: { kind:'light', label:'ambient', params:{ type:'ambient', color:'#ffffff', intensity:1.0 } },
}[what];
const en = await this.stage.addEntity(D);
this.stage.select(en.id);
if(en.kind === 'camera') this.stage.setActiveCamera(en.id); // preview through the new cam
}
// ---- inspector ----
// EVERY inspector param edit goes through here. The value is live on the stage immediately, but
// the timeline keeps its OWN snapshot of each entity's params (taken once by _mirror, at drop
// time) and evaluates from that β so typing 2 into `vid start` moved the RENDERED frame
// (stage.syncVideos reads the stage) while the scrubbed viewport stayed on 0
// (Timeline._evalVideo reads the snapshot): the same param meaning two different things.
// syncFromStage is Lane B's own "write the stage back into the model" pass; running it on the
// edit instead of only at save time is what actually makes "what you scrub is what you render"
// true, rather than just sharing the arithmetic.
_param(id, key, value){
this.stage.setParam(id, key, value);
const tl = typeof window !== 'undefined' && window.timeline;
if(tl && tl.syncFromStage) tl.syncFromStage();
// β¦and if the channel is KEYED, that sync just threw the typed number away (and the next
// evaluate will overwrite the stage too). Silently. Say it: a light preset makes `exposure`
// a keyed param, and "type it into the ambient inspector" was the documented way to fix a
// level β it is now "βΊ key", and nothing said so.
// Said through tlui's toast channel (window.sgToast) when it exists, because that is where
// " is keyed β press βΊ keyβ¦" already goes for a gizmo drag: one channel, one banner,
// and a duplicate from the timeline's own side simply overwrites it rather than stacking.
if(keyedParam(tl, id, key)){
const m = `${key} is keyed β the key at the playhead wins. Press βΊ key to change it there.`;
(typeof window !== 'undefined' && window.sgToast) ? window.sgToast(m) : this._toast(m);
}
}
renderInspector(en){
this._selId = en ? en.id : null;
const el = this.insp; el.innerHTML = '';
if(!en){ el.innerHTML = 'nothing selected
'; return; }
const t = this.stage.entityTransform(en.id);
const h = document.createElement('div'); h.className = 'ihd';
h.innerHTML = `${esc(en.label)}${esc(en.kind)}${
en.source && en.source.type === 'upload' ? ' Β· upload' : ''}`;
el.appendChild(h);
// setTransform hard-returns for a frustum-fitted plate (the lens owns it), so nine number
// fields that look like everyone else's were silently discarding everything typed into them.
if(en.kind === 'backdrop' && en.params.fit === 'frustum'){
const n = document.createElement('div'); n.className = 'inote';
n.textContent = 'placed by the camera β switch fit to "free" to position it by hand';
el.appendChild(n);
} else if(en.kind === 'light'){
// A key light aims at the stage centre (stage.js _buildLight: the target is nailed to the
// origin so the ortho shadow box, measured from the origin, stays over the stage). Its
// ANGLE therefore IS its position β elevation/azimuth on a boom, exactly what the light
// presets solve. `rot`/`scale` fields would have been three number rows that change
// nothing, so they are a sentence instead. An ambient (hemisphere) has no transform at all.
const n = document.createElement('div'); n.className = 'inote';
if(en.params.type === 'ambient'){
n.textContent = 'ambient light β it comes from everywhere: no position, no angle';
} else {
el.appendChild(this._vec3('pos', t.pos, v => this.stage.setTransform(en.id, { pos:v })));
n.textContent = 'aims at the stage centre β MOVE it to change the light angle (rotating does nothing)';
}
el.appendChild(n);
} else {
el.appendChild(this._vec3('pos', t.pos, v => this.stage.setTransform(en.id, { pos:v })));
el.appendChild(this._vec3('rot', t.rot, v => this.stage.setTransform(en.id, { rot:v })));
el.appendChild(this._num('scale', t.scale, v => this.stage.setTransform(en.id, { scale:v })));
}
if(en.kind === 'character'){
const vis = this.stage.visemeTargets ? this.stage.visemeTargets(en.id) : [];
const badge = document.createElement('div');
badge.className = 'viseme' + (vis.length ? '' : ' off');
badge.textContent = vis.length ? `π£ visemes: ${vis.join(' ')}` : 'π£ no visemes';
badge.title = vis.length ? 'lip-sync targets detected' : 'no mouth morph targets on this rig';
el.appendChild(badge);
}
if(en.kind === 'camera'){
el.appendChild(this._num('fov', en.params.fov ?? 45, v => this._param(en.id, 'fov', v)));
const act = document.createElement('button'); act.textContent = 'β set active camera'; act.className = 'wide';
act.onclick = () => this.stage.setActiveCamera(en.id);
el.appendChild(act);
}
if(en.kind === 'light'){
el.appendChild(this._num('intensity', en.params.intensity ?? 1.5, v => this._param(en.id, 'intensity', v)));
el.appendChild(this._color('color', en.params.color || '#ffffff', v => this._param(en.id, 'color', v)));
if(en.params.type === 'ambient'){ // M7: keyable atmosphere (0 = off), coloured from the plate
el.appendChild(this._num('fog', en.params.fog ?? 0.02, v => this._param(en.id, 'fog', v)));
// ACES level for the WHOLE frame β rides the ambient entity like bg/fog (keyable, persists)
el.appendChild(this._num('exposure', en.params.exposure ?? 1,
v => this._param(en.id, 'exposure', v)));
}
}
if(en.kind === 'backdrop'){
el.appendChild(this._sel('mode', ['plane','corner','dome','video'], en.params.mode || 'plane',
v => this._param(en.id, 'mode', v))); // stage rebuilds the mesh on change
el.appendChild(this._sel('fit', ['frustum','free'], en.params.fit || 'free',
v => { this._param(en.id, 'fit', v); this.renderInspector(en); }));
if(en.params.fit === 'frustum'){
el.appendChild(this._num('fit dist', en.params.fitDist ?? 14, v => this._param(en.id, 'fitDist', v)));
// which fraction of the plate sits on the lens axis: 1 = top edge at eye level (void above),
// 0.5 = dead centre, 0.75 = a typical plate's horizon at eye level (SYNC7 default)
el.appendChild(this._range('anchor', en.params.fitAnchor ?? 0.75,
v => this._param(en.id, 'fitAnchor', v)));
const re = document.createElement('button'); re.textContent = 'β€’ refit to camera'; re.className = 'wide';
re.onclick = () => this._param(en.id, 'fit', 'frustum'); // after moving the camera
el.appendChild(re);
}
// plates are UNLIT (they are display-referred photographs, not surfaces) β this is their
// only level control, 1 = the image as shot.
el.appendChild(this._num('plate exp', en.params.plateExposure ?? 1,
v => this._param(en.id, 'plateExposure', Math.max(0, Math.min(2, v)))));
el.appendChild(this._chk('ground tint', en.params.groundTint ?? true,
v => this._param(en.id, 'groundTint', v)));
if(en.video) el.appendChild(this._num('vid start', en.params.videoStart ?? 0,
v => this._param(en.id, 'videoStart', v)));
}
if(en.kind === 'screen'){
el.appendChild(this._num('width', en.params.width ?? 3, v => this._param(en.id, 'width', v)));
el.appendChild(this._num('emissive', en.params.emissive ?? 0.6, v => this._param(en.id, 'emissive', v)));
el.appendChild(this._num('vid start', en.params.videoStart ?? 0,
v => this._param(en.id, 'videoStart', v)));
}
const row = document.createElement('div'); row.className = 'ibtns';
const key = document.createElement('button'); key.textContent = 'βΊ key'; key.title = 'capture keyframe';
key.onclick = () => dispatchEvent(new CustomEvent('scenegod:capturekey', { detail:{ id: en.id } }));
const del = document.createElement('button'); del.textContent = 'π delete'; del.className = 'danger';
del.onclick = () => {
const tl = window.timeline;
const w = deleteWarning(tl, en);
if(w.ask && !confirm(w.msg)) return;
this.stage.removeEntity(en.id);
// Timeline._mirror drops the entity and its tracks, but a cameraCut pointing at it survives
// and the scene then fails server validation ("cameraCut references non-camera entity") β
// the whole scene becomes unsaveable until the user hunts the cut down by hand. The cuts
// leave with the camera, and leave the same way it did: not undoably (see dropCuts).
dropCuts(tl, w.cutObjs);
this.renderInspector(null);
};
row.append(key, del); el.appendChild(row);
}
_field(label, inputEl){ const w = document.createElement('label'); w.className = 'fld';
w.innerHTML = `${esc(label)}`; w.appendChild(inputEl); return w; }
_num(label, val, cb){ const i = document.createElement('input'); i.type='number'; i.step='0.1'; i.value=val;
i.oninput = () => cb(parseFloat(i.value) || 0); return this._field(label, i); }
_color(label, val, cb){ const i = document.createElement('input'); i.type='color'; i.value=val;
i.oninput = () => cb(i.value); return this._field(label, i); }
_chk(label, val, cb){ const i = document.createElement('input'); i.type='checkbox'; i.checked=!!val;
i.onchange = () => cb(i.checked); return this._field(label, i); }
_range(label, val, cb, min=0, max=1, step=0.05){ // 0β1 slider + live readout
const i = document.createElement('input'); i.type='range'; i.min=min; i.max=max; i.step=step; i.value=val;
const w = this._field(label, i), n = document.createElement('b'); n.className='rv';
n.textContent = (+val).toFixed(2);
i.oninput = () => { n.textContent = (+i.value).toFixed(2); cb(parseFloat(i.value)); };
w.appendChild(n); return w; }
_sel(label, opts, val, cb){ const s = document.createElement('select');
for(const o of opts){ const op = document.createElement('option'); op.value=op.textContent=o; if(o===val) op.selected=true; s.appendChild(op); }
s.onchange = () => cb(s.value); return this._field(label, s); }
_vec3(label, arr, cb){ const w = document.createElement('div'); w.className = 'fld vec';
w.innerHTML = `${esc(label)}`;
const cur = [...arr];
['x','y','z'].forEach((_, k) => { const i = document.createElement('input'); i.type='number'; i.step='0.1'; i.value=+arr[k].toFixed(3);
i.oninput = () => { cur[k] = parseFloat(i.value) || 0; cb([...cur]); }; w.appendChild(i); });
return w; }
// ---- local file drop (upload / offline path) ----
_dropZone(){
addEventListener('dragover', e => { e.preventDefault(); document.body.classList.add('hot'); });
addEventListener('dragleave', e => { if(e.target === document.body) document.body.classList.remove('hot'); });
addEventListener('drop', async e => {
e.preventDefault(); document.body.classList.remove('hot');
for(const f of e.dataTransfer.files){
const nm = f.name.toLowerCase();
try {
if(/\.(png|jpe?g|webp)$/.test(nm)){
const url = URL.createObjectURL(f);
const en = await this.stage.addEntity({ kind:'backdrop', label:f.name,
source:{ type:'upload', path:f.name, _url:url }, params:{ mode:'plane', fit:'frustum' } });
this.stage.select(en.id);
} else if(/\.(mp4|webm|mov)$/.test(nm)){ // M6: dropped video β video plate (upload, warn on save)
const url = URL.createObjectURL(f);
const en = await this.stage.addEntity({ kind:'backdrop', label:f.name,
source:{ type:'upload', path:f.name, _url:url }, params:{ mode:'video', width:12, fit:'frustum' } });
this.stage.select(en.id);
} else if(/\.(glb|gltf|fbx|obj|bvh)$/.test(nm)){
const buf = await f.arrayBuffer();
// clip drop onto selected character β bake; else load as a character
const sel = this.stage.getEntity(this._selId);
const probe = await this._probe(buf, f.name);
if(sel && sel.kind === 'character' && probe.anims && !probe.meshes){
// uploaded clip has no server path β the timeline can't place a block for it
if(window.timeline){ this._toast('add clips from the dock, not local files, once the timeline is up'); }
else {
this._toast('baking ' + f.name + 'β¦');
const clip = await this.stage.prepareClipUpload(sel.id, buf, f.name, 0);
this.stage.playClip(sel.id, clip); this._toast('βΆ ' + f.name);
}
} else {
const en = await this.stage.addEntity({ kind:'character', label:f.name,
source:{ type:'upload', path:f.name }, _buf:buf });
this.stage.select(en.id); this._toast('');
}
}
} catch(err){ this._toast('drop failed: ' + String(err).slice(0, 100)); }
}
});
}
// cheap probe: is this file animation-only (clip) or a mesh (character)?
async _probe(buf, name){
const { parseAny } = await import('./room3d.js');
const { root, anims } = await parseAny(buf.slice(0), name);
const st = stats(root);
return { anims: anims.length > 0, meshes: st.meshes };
}
_toast(m){ let t = document.getElementById('toast');
if(!t){ t = document.createElement('div'); t.id = 'toast'; document.body.appendChild(t); }
t.textContent = m; t.style.display = m ? 'block' : 'none';
clearTimeout(this._th); if(m) this._th = setTimeout(() => t.style.display = 'none', 3500);
}
}