Lane B: New... in the scene bar fetches the templates API and applies through a new single apply path (applyScene = stage.applyState + load + preload, now shared with Load), confirm-before-clobber via hasContent(), thumbs, toasts on failure. ? overlay documents 24 real bindings read out of the code across STAGE/TIMELINE/SCENE BAR, ignores ? while typing. Audio head-trim (left edge eats into the source, one grouped undo). applyScene strips the template key so saved scenes stay plain scene JSON. Orchestrator SYNC-9 fix: help overlay dt column was auto-width + nowrap, so long terms overflowed and the three columns overlapped visually. Verified live: picker lists 3 (2 thumbs), no confirm when empty / confirms when dirty, loads 7 entities + 2 cuts with bar refresh, no template key in toJSON, overlay opens on ? and Escape closes, no hijack while typing. 5 suites green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
759 lines
40 KiB
JavaScript
759 lines
40 KiB
JavaScript
// tlui.js — SCENEGOD timeline panel (Lane B). ALL DOM/canvas lives here;
|
||
// timeline.js stays headless. Mounts into #timeline, drives a Timeline.
|
||
//
|
||
// Layout: [scene bar] / [names column | ruler+canvas lanes]. One <canvas> for
|
||
// all lanes (decision: simpler than canvas-per-lane; hit-testing via a rebuilt
|
||
// hitbox list each draw). Time axis fits to width at zoom 1; Cmd/Ctrl+wheel
|
||
// zooms around the cursor, plain wheel is left to the page (scroll), and
|
||
// trackpad horizontal wheel pans when zoomed in.
|
||
|
||
const ROW_H = 28;
|
||
const RULER_H = 22;
|
||
const KEY_R = 5;
|
||
|
||
// ponytail: styles injected here, not written into Lane A's web/style.css —
|
||
// keeps Lane B inside its own files (orchestrator confirmed this is permanent).
|
||
const CSS = `
|
||
#timeline{display:flex;flex-direction:column;background:#161a20;color:#c9d1d9;font:12px/1.4 ui-monospace,Menlo,monospace;border-top:1px solid #2b323c;user-select:none}
|
||
#timeline .tl-bar{display:flex;gap:8px;align-items:center;padding:6px 8px;border-bottom:1px solid #2b323c}
|
||
#timeline .tl-bar input{background:#0d1117;border:1px solid #2b323c;color:#c9d1d9;padding:2px 6px;border-radius:3px;font:inherit}
|
||
#timeline .tl-bar input.name{width:150px}
|
||
#timeline .tl-bar input.num{width:56px}
|
||
#timeline .tl-bar select{background:#0d1117;border:1px solid #2b323c;color:#c9d1d9;padding:2px 4px;border-radius:3px;font:inherit}
|
||
#timeline .tl-bar label{display:flex;align-items:center;gap:3px;color:#8b949e;cursor:pointer}
|
||
#timeline .tl-bar button{background:#21262d;border:1px solid #2b323c;color:#c9d1d9;padding:3px 10px;border-radius:3px;cursor:pointer;font:inherit}
|
||
#timeline .tl-bar button:hover{background:#2b323c}
|
||
#timeline .tl-bar .spring{flex:1}
|
||
#timeline .tl-bar .clock{color:#7aa2f7;min-width:64px;text-align:right}
|
||
#timeline .tl-body{display:flex}
|
||
#timeline .tl-names{width:150px;flex:none;overflow:hidden;border-right:1px solid #2b323c;background:#12161c}
|
||
#timeline .tl-names .row{height:${ROW_H}px;display:flex;align-items:center;padding:0 8px;box-sizing:border-box;border-bottom:1px solid #1b2027;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||
#timeline .tl-names .row.head{margin-top:${RULER_H}px}
|
||
#timeline .tl-names .row .k{color:#565f6b;margin-right:6px;font-size:10px}
|
||
#timeline .tl-names .row.sub{color:#8b949e;padding-left:20px;font-size:11px}
|
||
#timeline .tl-names .row input.gain{width:38px;margin-left:auto;background:#0d1117;border:1px solid #2b323c;color:#c9d1d9;font:10px ui-monospace;border-radius:3px;padding:1px 2px}
|
||
#timeline .tl-names .row button.lip{margin-left:4px;background:#21262d;border:1px solid #2b323c;border-radius:3px;color:#c9d1d9;cursor:pointer;font-size:11px;padding:0 4px}
|
||
#timeline .tl-names .row button.lip:hover{background:#2b323c}
|
||
#timeline .tl-lanes{flex:1;position:relative;overflow:hidden}
|
||
#timeline canvas{display:block;width:100%;height:100%}
|
||
.tl-menu{position:fixed;z-index:9999;background:#12161c;border:1px solid #2b323c;border-radius:4px;max-height:240px;overflow:auto;font:12px ui-monospace;color:#c9d1d9;min-width:160px}
|
||
.tl-menu .item{padding:4px 10px;cursor:pointer;white-space:nowrap}
|
||
.tl-menu .item:hover{background:#2b323c}
|
||
.tl-menu .empty{padding:6px 10px;color:#565f6b}
|
||
.tl-menu.tmpl{max-width:420px}
|
||
.tl-menu.tmpl .item{display:flex;gap:9px;align-items:flex-start;padding:8px 10px;white-space:normal;border-bottom:1px solid #1b2027}
|
||
.tl-menu.tmpl .item img{width:68px;height:42px;flex:none;object-fit:cover;border-radius:3px;background:#0d1117}
|
||
.tl-menu.tmpl .t{color:#e0e6ed}
|
||
.tl-menu.tmpl .d{color:#8b949e;font-size:11px;line-height:1.35;margin-top:2px}
|
||
.tl-help{position:fixed;left:50%;top:50%;transform:translate(-50%,-50%);z-index:10001;background:#12161c;border:1px solid #2b323c;border-radius:6px;box-shadow:0 10px 44px #000b;padding:14px 18px 16px;color:#c9d1d9;font:12px/1.5 ui-monospace,Menlo,monospace;max-width:min(880px,94vw);max-height:84vh;overflow:auto}
|
||
.tl-help h3{margin:0 0 2px;font-size:13px;color:#7aa2f7}
|
||
.tl-help .hint{color:#565f6b;margin-bottom:10px}
|
||
.tl-help .cols{display:flex;gap:28px;flex-wrap:wrap;align-items:flex-start}
|
||
.tl-help section{min-width:250px;flex:1}
|
||
.tl-help h4{margin:8px 0 4px;font-size:11px;color:#e0af68;letter-spacing:.07em}
|
||
/* SYNC9: dt was auto-width + nowrap, so long terms ("drag an animation onto a
|
||
character") blew past the grid cell and the three columns overlapped. Cap the
|
||
term column and let it wrap. */
|
||
.tl-help dl{display:grid;grid-template-columns:minmax(0,11em) minmax(0,1fr);gap:3px 12px;margin:0}
|
||
.tl-help dt{color:#57c99a;white-space:normal;overflow-wrap:anywhere}
|
||
.tl-help dd{margin:0;color:#8b949e}
|
||
.tl-help .close{position:absolute;top:6px;right:10px;background:none;border:0;color:#565f6b;font:14px ui-monospace;cursor:pointer}
|
||
.tl-help .close:hover{color:#c9d1d9}
|
||
.tl-toast{position:fixed;left:50%;bottom:18px;transform:translateX(-50%);z-index:10000;background:#12161c;border:1px solid #e0af68;color:#e0e6ed;padding:6px 14px;border-radius:4px;font:12px ui-monospace;max-width:70vw}
|
||
`;
|
||
|
||
export class TimelineUI {
|
||
constructor(timeline, mountEl) {
|
||
this.tl = timeline;
|
||
this.stage = timeline.stage;
|
||
this.el = typeof mountEl === 'string' ? document.querySelector(mountEl) : mountEl;
|
||
// REVIEW-FIX: shared failure surface — timeline._fail (and any other module)
|
||
// toasts through here instead of dying silently in the console.
|
||
window.sgToast = (m) => this._toast(m);
|
||
this._hits = [];
|
||
this._drag = null;
|
||
this._selKeys = []; // box-selected keys: [{id, track, key}]
|
||
this._snapOn = true;
|
||
this._snapStep = 'frame'; // 'frame' | seconds string
|
||
this._zoom = 1; // 1 = fit-to-width; Cmd/Ctrl+wheel zooms
|
||
this._panT = 0; // left edge of the view, in seconds
|
||
this._audioBuf = new Map(); // path -> decoded AudioBuffer
|
||
this._audioSrcs = []; // live BufferSourceNodes during playback
|
||
this._wasPlaying = false;
|
||
this._injectCSS();
|
||
this._build();
|
||
this.tl.onTick(() => { // natural end-of-play stops audio (timeline auto-pauses)
|
||
this.draw();
|
||
if (this._wasPlaying && !this.tl.playing) this._audioStop();
|
||
this._wasPlaying = this.tl.playing;
|
||
});
|
||
this.tl.onLoad(() => { this._syncRows(); this._refreshBar(); }); // SYNC1 #3a/#3b
|
||
if (this.stage.onChange) this.stage.onChange(() => this._syncRows());
|
||
this._syncRows();
|
||
this.draw();
|
||
}
|
||
|
||
_refreshBar() {
|
||
if (this.$name) this.$name.value = this.tl.scene.name || '';
|
||
if (this.$dur) this.$dur.value = this.tl.duration;
|
||
}
|
||
|
||
_injectCSS() {
|
||
if (document.getElementById('laneB-tl-css')) return;
|
||
const s = document.createElement('style');
|
||
s.id = 'laneB-tl-css';
|
||
s.textContent = CSS;
|
||
document.head.appendChild(s);
|
||
}
|
||
|
||
_build() {
|
||
this.el.innerHTML = '';
|
||
const bar = document.createElement('div');
|
||
bar.className = 'tl-bar';
|
||
bar.innerHTML = `
|
||
<button data-act="play">▶</button>
|
||
<span class="clock">0.00s</span>
|
||
<input class="name" placeholder="scene name" value="${this.tl.scene.name || ''}">
|
||
<span>dur</span><input class="num dur" type="number" min="0" step="0.5" value="${this.tl.duration}">
|
||
<label><input type="checkbox" class="snap" checked> snap</label>
|
||
<select class="snapstep" title="snap increment">
|
||
<option value="frame">frame</option><option value="0.1">0.1s</option>
|
||
<option value="0.25">0.25s</option><option value="1">1s</option></select>
|
||
<button data-act="audio" title="add audio clip at playhead">🎵</button>
|
||
<span class="spring"></span>
|
||
<button data-act="help" title="keyboard shortcuts (?)">?</button>
|
||
<button data-act="tmpl" title="start a new scene from a starter template">New…</button>
|
||
<button data-act="save">Save</button>
|
||
<button data-act="load">Load</button>`;
|
||
this.el.appendChild(bar);
|
||
this.$play = bar.querySelector('[data-act="play"]');
|
||
this.$clock = bar.querySelector('.clock');
|
||
this.$name = bar.querySelector('.name');
|
||
this.$dur = bar.querySelector('.dur');
|
||
this.$snap = bar.querySelector('.snap');
|
||
this.$snapStep = bar.querySelector('.snapstep');
|
||
this.$play.onclick = () => this._toggle();
|
||
bar.querySelector('[data-act="save"]').onclick = () => this.save();
|
||
bar.querySelector('[data-act="load"]').onclick = () => this.load(this.$name.value);
|
||
this.$audioBtn = bar.querySelector('[data-act="audio"]');
|
||
this.$audioBtn.onclick = () => this._pickAudio();
|
||
this.$tmplBtn = bar.querySelector('[data-act="tmpl"]');
|
||
this.$tmplBtn.onclick = () => this._pickTemplate();
|
||
this.$helpBtn = bar.querySelector('[data-act="help"]');
|
||
this.$helpBtn.onclick = () => this._toggleHelp();
|
||
this.$dur.onchange = () => this.tl.setDuration(this.$dur.value); // clamps + refreshes bar
|
||
this.$name.onchange = () => { this.tl.scene.name = this.$name.value; };
|
||
this.$snap.onchange = () => { this._snapOn = this.$snap.checked; };
|
||
this.$snapStep.onchange = () => { this._snapStep = this.$snapStep.value; };
|
||
|
||
const body = document.createElement('div');
|
||
body.className = 'tl-body';
|
||
this.$names = document.createElement('div'); this.$names.className = 'tl-names';
|
||
const lanes = document.createElement('div'); lanes.className = 'tl-lanes';
|
||
this.canvas = document.createElement('canvas');
|
||
lanes.appendChild(this.canvas);
|
||
body.appendChild(this.$names); body.appendChild(lanes);
|
||
this.el.appendChild(body);
|
||
this.lanesEl = lanes;
|
||
|
||
this.canvas.addEventListener('mousedown', (e) => this._down(e));
|
||
this.canvas.addEventListener('wheel', (e) => this._wheel(e), { passive: false });
|
||
window.addEventListener('mousemove', (e) => this._move(e));
|
||
window.addEventListener('mouseup', () => this._up());
|
||
this.canvas.addEventListener('dblclick', (e) => this._dbl(e));
|
||
this.canvas.addEventListener('contextmenu', (e) => this._ctx(e));
|
||
window.addEventListener('keydown', (e) => this._key(e));
|
||
window.addEventListener('resize', () => this.draw());
|
||
}
|
||
|
||
// rows: [{type:'transform'|'params'|'clip'|'cameras', id?, label}].
|
||
// Built from the timeline's entities (authoritative for tracks) merged with
|
||
// any stage entities not yet keyframed (dock-added but not in a loaded scene).
|
||
_rows() {
|
||
const rows = [];
|
||
const list = [...this.tl.scene.entities];
|
||
for (const se of this.stage.entities()) if (!list.find((e) => e.id === se.id)) list.push(se);
|
||
for (const e of list) {
|
||
rows.push({ type: 'transform', id: e.id, kind: e.kind, label: e.label || e.id });
|
||
if (e.kind === 'character') rows.push({ type: 'clip', id: e.id, label: '↳ clips' });
|
||
const paramable = (e.tracks?.params?.length > 0) || e.kind === 'camera' || e.kind === 'light';
|
||
if (paramable) rows.push({ type: 'params', id: e.id, label: '↳ params' });
|
||
// viseme lane only when the character actually has morph targets (M4-B)
|
||
const hasMorphs = (e.tracks?.morphs?.length > 0) ||
|
||
(e.kind === 'character' && this.stage.visemeTargets && (this.stage.visemeTargets(e.id) || []).length > 0);
|
||
if (hasMorphs) rows.push({ type: 'morphs', id: e.id, label: '↳ visemes' });
|
||
}
|
||
rows.push({ type: 'cameras', label: 'Cameras' });
|
||
for (const clip of (this.tl.scene.audio || [])) {
|
||
rows.push({ type: 'audio', clip, label: '🎵 ' + (clip.path || '').split('/').pop() });
|
||
}
|
||
return rows;
|
||
}
|
||
|
||
_syncRows() {
|
||
this.rows = this._rows();
|
||
this.$names.innerHTML = '';
|
||
this.rows.forEach((r, i) => {
|
||
const d = document.createElement('div');
|
||
const sub = r.type === 'clip' || r.type === 'params' || r.type === 'morphs' || r.type === 'audio';
|
||
d.className = 'row' + (i === 0 ? ' head' : '') + (sub ? ' sub' : '');
|
||
if (r.type === 'transform') d.innerHTML = `<span class="k">${(r.kind || '?')[0].toUpperCase()}</span>${r.label}`;
|
||
else if (r.type === 'audio') {
|
||
d.append(r.label);
|
||
const g = document.createElement('input');
|
||
g.type = 'number'; g.className = 'gain'; g.step = '0.1'; g.min = '0'; g.title = 'gain';
|
||
g.value = r.clip.gain ?? 1;
|
||
g.onchange = () => this.tl.setAudioGain(r.clip, Math.max(0, +g.value || 0));
|
||
d.appendChild(g);
|
||
const lip = document.createElement('button'); // M5: lip-sync from this audio
|
||
lip.className = 'lip'; lip.textContent = '🗣';
|
||
lip.title = 'lip-sync: rhubarb this audio → viseme keys on a character';
|
||
lip.onclick = () => this._lipSync(r.clip, lip);
|
||
d.appendChild(lip);
|
||
} else d.textContent = r.label;
|
||
this.$names.appendChild(d);
|
||
});
|
||
// size lanes to content so no row (esp. Cameras) is clipped as rows grow.
|
||
// ponytail: grow-to-content; add a max-height+scroll only if a scene ever
|
||
// has so many entities the panel dominates the viewport.
|
||
const hpx = RULER_H + this.rows.length * ROW_H;
|
||
this.$names.style.height = hpx + 'px';
|
||
this.lanesEl.style.height = hpx + 'px';
|
||
this.draw();
|
||
}
|
||
|
||
// ---- geometry ----
|
||
_dims() {
|
||
const w = this.canvas.clientWidth, h = this.canvas.clientHeight;
|
||
const dpr = window.devicePixelRatio || 1;
|
||
if (this.canvas.width !== w * dpr || this.canvas.height !== h * dpr) {
|
||
this.canvas.width = w * dpr; this.canvas.height = h * dpr;
|
||
}
|
||
return { w, h, dpr, pps: (w / (this.tl.duration || 1)) * this._zoom };
|
||
}
|
||
_x2t(x) { const { pps } = this._dims(); return Math.max(0, Math.min(this.tl.duration, this._panT + x / pps)); }
|
||
_t2x(t) { const { pps } = this._dims(); return (t - this._panT) * pps; }
|
||
// UX (session-5 nit): plain wheel is NOT captured — the page scrolls as users
|
||
// expect. Cmd/Ctrl+wheel (incl. trackpad pinch → ctrlKey) zooms around the
|
||
// cursor; horizontal wheel pans when zoomed in.
|
||
_wheel(e) {
|
||
const zoomG = e.ctrlKey || e.metaKey;
|
||
const panG = this._zoom > 1 && Math.abs(e.deltaX) > Math.abs(e.deltaY);
|
||
if (!zoomG && !panG) return;
|
||
e.preventDefault();
|
||
const { x } = this._local(e);
|
||
const { w, pps } = this._dims();
|
||
if (zoomG) {
|
||
const tAt = this._panT + x / pps; // time under the cursor
|
||
this._zoom = Math.max(1, Math.min(64, this._zoom * Math.pow(1.0015, -e.deltaY)));
|
||
this._panT = tAt - x / ((w / (this.tl.duration || 1)) * this._zoom); // keep it under the cursor
|
||
} else {
|
||
this._panT += e.deltaX / pps;
|
||
}
|
||
const view = (this.tl.duration || 1) / this._zoom; // visible seconds
|
||
this._panT = Math.max(0, Math.min(this.tl.duration - view, this._panT));
|
||
this.draw();
|
||
}
|
||
_rowAtY(y) { const i = Math.floor((y - RULER_H) / ROW_H); return (i >= 0 && i < this.rows.length) ? i : -1; }
|
||
|
||
// ---- draw ----
|
||
draw() {
|
||
if (!this.rows) this.rows = this._rows();
|
||
const { w, h, dpr } = this._dims();
|
||
const c = this.canvas.getContext('2d');
|
||
c.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||
c.clearRect(0, 0, w, h);
|
||
this._hits = [];
|
||
|
||
// ruler
|
||
c.fillStyle = '#12161c'; c.fillRect(0, 0, w, RULER_H);
|
||
c.fillStyle = '#565f6b'; c.font = '10px ui-monospace';
|
||
const dur = this.tl.duration || 1;
|
||
const stepSec = dur <= 5 ? 0.5 : dur <= 20 ? 1 : 5;
|
||
for (let t = 0; t <= dur + 1e-6; t += stepSec) {
|
||
const x = this._t2x(t);
|
||
c.strokeStyle = '#1b2027'; c.beginPath(); c.moveTo(x, 0); c.lineTo(x, h); c.stroke();
|
||
c.fillText(t.toFixed(stepSec < 1 ? 1 : 0) + 's', x + 2, 12);
|
||
}
|
||
|
||
// lanes
|
||
this.rows.forEach((r, i) => {
|
||
const y = RULER_H + i * ROW_H;
|
||
c.fillStyle = i % 2 ? '#161a20' : '#13171d';
|
||
c.fillRect(0, y, w, ROW_H);
|
||
c.strokeStyle = '#1b2027'; c.beginPath(); c.moveTo(0, y + ROW_H); c.lineTo(w, y + ROW_H); c.stroke();
|
||
if (r.type === 'transform') this._drawKeys(c, r, y);
|
||
else if (r.type === 'params') this._drawParams(c, r, y);
|
||
else if (r.type === 'morphs') this._drawMorphs(c, r, y);
|
||
else if (r.type === 'clip') this._drawClips(c, r, y);
|
||
else if (r.type === 'cameras') this._drawCuts(c, y);
|
||
else if (r.type === 'audio') this._drawAudio(c, r, y);
|
||
});
|
||
|
||
// box-select overlay
|
||
const d = this._drag;
|
||
if (d && d.kind === 'box' && d.moved) {
|
||
c.fillStyle = 'rgba(122,162,247,0.15)'; c.strokeStyle = '#7aa2f7';
|
||
const x = Math.min(d.x0, d.x), y = Math.min(d.y0, d.y);
|
||
c.fillRect(x, y, Math.abs(d.x - d.x0), Math.abs(d.y - d.y0));
|
||
c.strokeRect(x + 0.5, y + 0.5, Math.abs(d.x - d.x0), Math.abs(d.y - d.y0));
|
||
}
|
||
|
||
// playhead
|
||
const px = this._t2x(this.tl.time);
|
||
c.strokeStyle = '#f7768e'; c.lineWidth = 1.5;
|
||
c.beginPath(); c.moveTo(px, 0); c.lineTo(px, h); c.stroke(); c.lineWidth = 1;
|
||
c.fillStyle = '#f7768e'; c.beginPath(); c.moveTo(px - 4, 0); c.lineTo(px + 4, 0); c.lineTo(px, 6); c.fill();
|
||
|
||
this.$clock.textContent = this.tl.time.toFixed(2) + 's';
|
||
this.$play.textContent = this.tl.playing ? '❚❚' : '▶';
|
||
}
|
||
|
||
_entity(id) { return this.stage.getEntity(id); } // stage: live transform/existence
|
||
_te(id) { return this.tl.scene.entities.find((e) => e.id === id); } // timeline: authoritative tracks
|
||
_selected(k) { return this._selKeys.some((s) => s.key === k); }
|
||
|
||
_diamond(c, x, cy, r, fill, sel) {
|
||
c.fillStyle = fill;
|
||
c.beginPath(); c.moveTo(x, cy - r); c.lineTo(x + r, cy); c.lineTo(x, cy + r); c.lineTo(x - r, cy); c.closePath(); c.fill();
|
||
if (sel) { c.strokeStyle = '#fff'; c.lineWidth = 1.5; c.stroke(); c.lineWidth = 1; }
|
||
}
|
||
_drawKeys(c, r, y) {
|
||
const cy = y + ROW_H / 2;
|
||
const keys = (this._te(r.id)?.tracks?.transform) || [];
|
||
for (const k of keys) {
|
||
const x = this._t2x(k.t);
|
||
this._diamond(c, x, cy, KEY_R, '#7aa2f7', this._selected(k));
|
||
this._hits.push({ kind: 'key', id: r.id, track: 'transform', key: k, x, y: cy });
|
||
}
|
||
}
|
||
_paramColor(name) { let h = 0; for (const ch of name) h = (h * 31 + ch.charCodeAt(0)) % 360; return `hsl(${h},55%,62%)`; }
|
||
_drawParams(c, r, y) {
|
||
const cy = y + ROW_H / 2;
|
||
const keys = (this._te(r.id)?.tracks?.params) || [];
|
||
for (const k of keys) {
|
||
const x = this._t2x(k.t);
|
||
this._diamond(c, x, cy, 4, this._paramColor(k.key), this._selected(k));
|
||
this._hits.push({ kind: 'key', id: r.id, track: 'params', key: k, x, y: cy });
|
||
}
|
||
}
|
||
_drawClips(c, r, y) {
|
||
const blocks = (this._te(r.id)?.tracks?.clips) || [];
|
||
for (const b of blocks) {
|
||
const span = (b.out - b.in) || 0.0001, end = b.start + span * (b.loop || 1);
|
||
const x0 = this._t2x(b.start), x1 = this._t2x(end), bw = Math.max(2, x1 - x0);
|
||
c.fillStyle = '#3d5a80'; c.fillRect(x0, y + 5, bw, ROW_H - 10);
|
||
c.strokeStyle = '#98c1d9'; c.strokeRect(x0 + 0.5, y + 5.5, bw - 1, ROW_H - 11);
|
||
if (b.fade > 0) { // fade-out wedge at the tail
|
||
const fx = this._t2x(end - b.fade);
|
||
c.fillStyle = 'rgba(231,175,104,0.35)'; c.fillRect(fx, y + 5, x1 - fx, ROW_H - 10);
|
||
}
|
||
c.fillStyle = '#e0e6ed'; c.font = '10px ui-monospace';
|
||
c.save(); c.beginPath(); c.rect(x0, y, x1 - x0, ROW_H); c.clip();
|
||
c.fillText((b.path || '').split('/').pop(), x0 + 4, y + ROW_H / 2 + 3); c.restore();
|
||
this._hits.push({ kind: 'block', id: r.id, block: b, x0, x1, y });
|
||
this._hits.push({ kind: 'edge', id: r.id, block: b, side: 'out', x: x1, y });
|
||
}
|
||
}
|
||
_drawCuts(c, y) {
|
||
const cy = y + ROW_H / 2;
|
||
for (const cut of this.tl.cameraCuts) {
|
||
const x = this._t2x(cut.t);
|
||
c.fillStyle = '#e0af68'; c.fillRect(x - 1, y + 4, 2, ROW_H - 8);
|
||
c.beginPath(); c.arc(x, cy, 4, 0, Math.PI * 2); c.fill();
|
||
c.fillStyle = '#161a20'; c.font = '9px ui-monospace'; c.fillText(cut.camera || '?', x + 6, cy + 3);
|
||
this._hits.push({ kind: 'cut', cut, x, y: cy });
|
||
}
|
||
}
|
||
_drawMorphs(c, r, y) {
|
||
const cy = y + ROW_H / 2;
|
||
const keys = (this._te(r.id)?.tracks?.morphs) || [];
|
||
for (const k of keys) {
|
||
const x = this._t2x(k.t);
|
||
this._diamond(c, x, cy, 4, this._paramColor(k.key), this._selected(k));
|
||
this._hits.push({ kind: 'key', id: r.id, track: 'morphs', key: k, x, y: cy });
|
||
}
|
||
}
|
||
_drawAudio(c, r, y) {
|
||
const clip = r.clip;
|
||
const srcDur = this._audioDur(clip.path);
|
||
const inT = clip.in || 0; // trim within the source (like clip blocks)
|
||
const outT = clip.out ?? srcDur;
|
||
const len = outT != null ? Math.max(0.05, outT - inT) : null;
|
||
const x0 = this._t2x(clip.start);
|
||
const x1 = len != null ? this._t2x(clip.start + len) : x0 + 60; // fallback width until decoded
|
||
const bw = Math.max(3, x1 - x0);
|
||
c.fillStyle = '#2e7d5b'; c.fillRect(x0, y + 5, bw, ROW_H - 10);
|
||
c.strokeStyle = '#57c99a'; c.strokeRect(x0 + 0.5, y + 5.5, bw - 1, ROW_H - 11);
|
||
c.fillStyle = '#dff5ea'; c.font = '10px ui-monospace';
|
||
c.save(); c.beginPath(); c.rect(x0, y, bw, ROW_H); c.clip();
|
||
const trimmed = clip.in != null || clip.out != null;
|
||
c.fillText((trimmed ? '✂ ' : '') + (clip.path || '').split('/').pop() + (srcDur == null ? ' …' : ''), x0 + 4, y + ROW_H / 2 + 3); c.restore();
|
||
this._hits.push({ kind: 'audioblock', clip, x0, x1, y });
|
||
if (len != null) { // drag an edge = trim (head / tail)
|
||
this._hits.push({ kind: 'aedge', clip, side: 'out', x: x1, y });
|
||
this._hits.push({ kind: 'aedge', clip, side: 'in', x: x0, y });
|
||
}
|
||
}
|
||
|
||
// ---- interaction ----
|
||
_local(e) { const r = this.canvas.getBoundingClientRect(); return { x: e.clientX - r.left, y: e.clientY - r.top }; }
|
||
_snap(t, e) {
|
||
if ((e && e.altKey) || !this._snapOn) return t;
|
||
const step = this._snapStep === 'frame' ? 1 / this.tl.fps : (+this._snapStep || 1 / this.tl.fps);
|
||
return Math.round(t / step) * step;
|
||
}
|
||
_hitAt(x, y) {
|
||
for (const h of this._hits) {
|
||
if (h.kind === 'key' || h.kind === 'cut') { if (Math.abs(h.x - x) < 7 && Math.abs(h.y - y) < 9) return h; }
|
||
else if (h.kind === 'edge' || h.kind === 'aedge') { if (Math.abs(h.x - x) < 5 && y > h.y && y < h.y + ROW_H) return h; }
|
||
}
|
||
for (const h of this._hits) if ((h.kind === 'block' || h.kind === 'audioblock') && x >= h.x0 && x <= h.x1 && y > h.y && y < h.y + ROW_H) return h;
|
||
return null;
|
||
}
|
||
_seek(t) { this.tl.seek(t); if (this.tl.playing) this._audioStart(); } // user seek: reschedule audio
|
||
_keysInRect(x0, y0, x1, y1) {
|
||
const ax = Math.min(x0, x1), bx = Math.max(x0, x1), ay = Math.min(y0, y1), by = Math.max(y0, y1);
|
||
const out = [];
|
||
for (const h of this._hits) if (h.kind === 'key' && h.x >= ax && h.x <= bx && h.y >= ay && h.y <= by) out.push({ id: h.id, track: h.track, key: h.key });
|
||
return out;
|
||
}
|
||
|
||
_down(e) {
|
||
const { x, y } = this._local(e);
|
||
if (y < RULER_H) { this._drag = { kind: 'seek' }; this._seek(this._x2t(x)); return; }
|
||
const h = this._hitAt(x, y);
|
||
if (!h) { this._drag = { kind: 'box', x0: x, y0: y, x, y, moved: false }; return; } // empty → box/seek
|
||
if (h.kind === 'key') {
|
||
if (this._selected(h.key) && this._selKeys.length > 1) {
|
||
this._drag = { kind: 'multikey', grabT: this._x2t(x), orig: this._selKeys.map((s) => ({ ...s, t0: s.key.t })) };
|
||
} else { this._selKeys = []; this._drag = { kind: 'key', h }; }
|
||
} else if (h.kind === 'edge') this._drag = { kind: 'trim', h };
|
||
else if (h.kind === 'aedge') this._drag = { kind: 'atrim', h };
|
||
else if (h.kind === 'block') this._drag = { kind: 'block', h, grabT: this._x2t(x) - h.block.start };
|
||
else if (h.kind === 'audioblock') this._drag = { kind: 'audio', h, grabT: this._x2t(x) - h.clip.start };
|
||
else if (h.kind === 'cut') this._drag = { kind: 'cut', h };
|
||
}
|
||
_move(e) {
|
||
if (!this._drag) return;
|
||
const { x, y } = this._local(e);
|
||
const t = this._x2t(x), d = this._drag;
|
||
if (d.kind === 'seek') this._seek(t);
|
||
else if (d.kind === 'key') { this.tl.moveKey(d.h.id, d.h.track, d.h.key, this._snap(t, e)); this.draw(); }
|
||
else if (d.kind === 'multikey') { for (const s of d.orig) this.tl.moveKey(s.id, s.track, s.key, this._snap(s.t0 + (t - d.grabT), e)); this.draw(); }
|
||
else if (d.kind === 'cut') { d.h.cut.t = this._snap(t, e); this.tl.cameraCuts.sort((a, b) => a.t - b.t); this.tl._activeCam = undefined; this.draw(); }
|
||
else if (d.kind === 'block') { this.tl.moveClipBlock(d.h.id, d.h.block, Math.max(0, this._snap(t - d.grabT, e))); this.draw(); }
|
||
else if (d.kind === 'audio') { this.tl.moveAudio(d.h.clip, this._snap(t - d.grabT, e)); this.draw(); }
|
||
else if (d.kind === 'trim') { const b = d.h.block; this.tl.trimClipBlock(d.h.id, b, { out: Math.max(b.in + 0.05, b.in + (t - b.start)) }); this.draw(); }
|
||
else if (d.kind === 'atrim') {
|
||
const cl = d.h.clip, srcDur = this._audioDur(cl.path);
|
||
if (d.h.side === 'in') this.tl.trimAudioIn(cl, this._snap(t, e), srcDur); // head trim: in + start together
|
||
else {
|
||
let out = (cl.in || 0) + (t - cl.start);
|
||
if (srcDur != null) out = Math.min(out, srcDur);
|
||
this.tl.trimAudio(cl, { out });
|
||
}
|
||
this.draw();
|
||
}
|
||
else if (d.kind === 'box') { d.x = x; d.y = y; d.moved = Math.abs(x - d.x0) > 4 || Math.abs(y - d.y0) > 4; this.draw(); }
|
||
}
|
||
_up() {
|
||
const d = this._drag; this._drag = null;
|
||
if (!d) return;
|
||
if (d.kind === 'box') {
|
||
if (!d.moved) this._seek(this._x2t(d.x0)); // click on empty = seek
|
||
else { this._selKeys = this._keysInRect(d.x0, d.y0, d.x, d.y); this.draw(); }
|
||
}
|
||
}
|
||
|
||
_dbl(e) {
|
||
const { x, y } = this._local(e);
|
||
const i = this._rowAtY(y); if (i < 0) return;
|
||
const r = this.rows[i];
|
||
const t = this._snap(this._x2t(x), e);
|
||
if (r.type === 'transform') {
|
||
const cur = this.stage.entityTransform(r.id);
|
||
this.tl.addKey(r.id, 'transform', { t, pos: cur.pos, rot: cur.rot, scale: cur.scale, ease: 'inout' });
|
||
this.draw();
|
||
} else if (r.type === 'params') {
|
||
// capture every keyable param at its current value (mirrors transform capture)
|
||
const params = (this._entity(r.id)?.params) || {};
|
||
for (const [key, value] of Object.entries(params)) {
|
||
if (key === 'type' || typeof value === 'boolean') continue; // non-keyable descriptors
|
||
this.tl.addKey(r.id, 'params', { t, key, value, ease: 'inout' });
|
||
}
|
||
this.draw();
|
||
} else if (r.type === 'cameras') {
|
||
const cams = this.stage.entities().filter((en) => en.kind === 'camera');
|
||
const cam = this.tl._activeCam || (cams[0] && cams[0].id);
|
||
if (cam) { this.tl.addCut(t, cam); this.tl.seek(this.tl.time); this.draw(); }
|
||
}
|
||
}
|
||
_ctx(e) {
|
||
e.preventDefault();
|
||
const { x, y } = this._local(e);
|
||
const h = this._hitAt(x, y);
|
||
if (h && h.kind === 'key') { this.tl.deleteKey(h.id, h.track, h.key); this._selKeys = this._selKeys.filter((s) => s.key !== h.key); this.draw(); }
|
||
else if (h && h.kind === 'cut') { const i = this.tl.cameraCuts.indexOf(h.cut); if (i >= 0) this.tl.cameraCuts.splice(i, 1); this.tl._activeCam = undefined; this.draw(); }
|
||
else if (h && h.kind === 'audioblock') { this.tl.removeAudio(h.clip); this._syncRows(); }
|
||
}
|
||
_key(e) {
|
||
if (e.target && (/INPUT|TEXTAREA|SELECT/.test(e.target.tagName) || e.target.isContentEditable)) return;
|
||
if (e.key === '?') { e.preventDefault(); this._toggleHelp(); } // never hijacked while typing
|
||
else if (e.key === 'Escape') this._helpClose();
|
||
else if (e.code === 'Space') { e.preventDefault(); this._toggle(); }
|
||
else if (e.key === 'Home') this._seek(0);
|
||
else if (e.key === 'End') this._seek(this.tl.duration);
|
||
else if ((e.ctrlKey || e.metaKey) && e.key === 'z') { e.preventDefault(); this.tl.undo(); this.draw(); }
|
||
else if ((e.key === 'Delete' || e.key === 'Backspace') && this._selKeys.length) {
|
||
e.preventDefault();
|
||
for (const s of this._selKeys) this.tl.deleteKey(s.id, s.track, s.key);
|
||
this._selKeys = []; this.draw();
|
||
}
|
||
}
|
||
_toggle() {
|
||
if (this.tl.playing) { this.tl.pause(); this._audioStop(); }
|
||
else { this.tl.play(); this._audioStart(); }
|
||
this._wasPlaying = this.tl.playing;
|
||
this.draw();
|
||
}
|
||
|
||
// ---- Web Audio (draft playback synced to the master clock). step()/render
|
||
// NEVER touch this — the server muxes from scene.audio deterministically. ----
|
||
_ensureCtx() {
|
||
if (!this._audioCtx) { const C = window.AudioContext || window.webkitAudioContext; if (C) this._audioCtx = new C(); }
|
||
return this._audioCtx;
|
||
}
|
||
_audioDur(path) { const b = this._audioBuf.get(path); return b ? b.duration : null; }
|
||
async _decode(path) {
|
||
if (this._audioBuf.has(path)) return this._audioBuf.get(path);
|
||
const ctx = this._ensureCtx(); if (!ctx) return null;
|
||
try {
|
||
const res = await fetch('assets/file?path=' + encodeURIComponent(path));
|
||
if (!res.ok) throw new Error(res.status);
|
||
const buf = await ctx.decodeAudioData(await res.arrayBuffer());
|
||
this._audioBuf.set(path, buf);
|
||
this.draw(); // width now known
|
||
return buf;
|
||
} catch { return null; } // keep the block at fallback width
|
||
}
|
||
_decodeAll() { for (const clip of (this.tl.scene.audio || [])) this._decode(clip.path); }
|
||
_audioStart() {
|
||
const ctx = this._ensureCtx(); if (!ctx) return;
|
||
if (ctx.state === 'suspended') ctx.resume();
|
||
this._audioStop();
|
||
const now = ctx.currentTime, t = this.tl.time;
|
||
for (const clip of (this.tl.scene.audio || [])) {
|
||
const buf = this._audioBuf.get(clip.path); if (!buf) continue;
|
||
const inT = Math.min(clip.in || 0, buf.duration); // trim within source
|
||
const len = Math.min(clip.out ?? buf.duration, buf.duration) - inT;
|
||
const offset = Math.max(0, t - clip.start); // seconds into the (trimmed) block at playhead
|
||
if (len <= 0 || offset >= len) continue; // already finished
|
||
const src = ctx.createBufferSource(); src.buffer = buf;
|
||
const g = ctx.createGain(); g.gain.value = clip.gain ?? 1;
|
||
src.connect(g).connect(ctx.destination);
|
||
const when = now + Math.max(0, clip.start - t);
|
||
src.start(when, inT + offset, len - offset);
|
||
this._audioSrcs.push(src);
|
||
}
|
||
}
|
||
_audioStop() { for (const s of this._audioSrcs) { try { s.stop(); } catch { /* already stopped */ } } this._audioSrcs = []; }
|
||
|
||
async _pickAudio() {
|
||
let list = [];
|
||
try { const r = await fetch('assets/tree'); if (r.ok) list = (await r.json()).audio || []; } catch { /* offline */ }
|
||
const menu = document.createElement('div'); menu.className = 'tl-menu';
|
||
if (!list.length) menu.innerHTML = '<div class="empty">no audio in /assets/tree</div>';
|
||
for (const it of list) {
|
||
const path = it.path || it;
|
||
const item = document.createElement('div'); item.className = 'item';
|
||
item.textContent = (it.name || path).split('/').pop();
|
||
item.onclick = async () => {
|
||
this.tl.addAudio({ path, start: this.tl.time, gain: 1 });
|
||
menu.remove();
|
||
await this._decode(path);
|
||
this._syncRows();
|
||
};
|
||
menu.appendChild(item);
|
||
}
|
||
document.body.appendChild(menu);
|
||
const rect = this.$audioBtn.getBoundingClientRect();
|
||
menu.style.left = rect.left + 'px'; menu.style.top = (rect.bottom + 2) + 'px';
|
||
const close = (ev) => { if (!menu.contains(ev.target)) { menu.remove(); document.removeEventListener('mousedown', close); } };
|
||
setTimeout(() => document.addEventListener('mousedown', close), 0);
|
||
}
|
||
|
||
// ---- M7-B: new from template (Lane C's read-only /templates) ----
|
||
// All URLs stay RELATIVE — the app is served under /scenegod/ in prod.
|
||
async _pickTemplate() {
|
||
let list = [];
|
||
try { const r = await fetch('templates'); if (r.ok) list = await r.json(); } catch { /* offline */ }
|
||
const menu = document.createElement('div'); menu.className = 'tl-menu tmpl';
|
||
if (!Array.isArray(list) || !list.length) menu.innerHTML = '<div class="empty">no starter templates on the server</div>';
|
||
for (const t of (list || [])) {
|
||
const item = document.createElement('div'); item.className = 'item';
|
||
if (t.thumb) {
|
||
const img = document.createElement('img');
|
||
img.src = 'assets/file?path=' + encodeURIComponent(t.thumb);
|
||
img.onerror = () => img.remove(); // thumb missing → text-only row
|
||
item.appendChild(img);
|
||
}
|
||
const txt = document.createElement('div');
|
||
const ti = document.createElement('div'); ti.className = 't'; ti.textContent = t.title || t.name;
|
||
txt.appendChild(ti);
|
||
if (t.description) { const d = document.createElement('div'); d.className = 'd'; d.textContent = t.description; txt.appendChild(d); }
|
||
item.appendChild(txt);
|
||
item.onclick = () => { menu.remove(); this.newFromTemplate(t.name); };
|
||
menu.appendChild(item);
|
||
}
|
||
document.body.appendChild(menu);
|
||
const rect = this.$tmplBtn.getBoundingClientRect();
|
||
menu.style.top = (rect.bottom + 2) + 'px';
|
||
menu.style.left = Math.max(4, Math.min(rect.left, window.innerWidth - menu.offsetWidth - 6)) + 'px';
|
||
const close = (ev) => { if (!menu.contains(ev.target)) { menu.remove(); document.removeEventListener('mousedown', close); } };
|
||
setTimeout(() => document.addEventListener('mousedown', close), 0);
|
||
}
|
||
|
||
// Applies exactly like load(): stage.applyState → tl.load → preload (all three
|
||
// inside tl.applyScene, which also strips the presentation-only `template` key).
|
||
async newFromTemplate(name) {
|
||
if (this.tl.hasContent() && !confirm('Replace the current scene with this template? Unsaved work is lost.')) return;
|
||
let json;
|
||
try {
|
||
const res = await fetch('templates/' + encodeURIComponent(name));
|
||
if (!res.ok) throw new Error(res.status);
|
||
json = await res.json();
|
||
} catch (e) { this._toast('template "' + name + '" failed to load: ' + ((e && e.message) || e)); return; }
|
||
try { await this.tl.applyScene(json); }
|
||
catch (e) { this._toast('template failed to apply: ' + ((e && e.message) || e)); return; }
|
||
this._decodeAll();
|
||
this.draw();
|
||
}
|
||
|
||
// ---- M7-B: ? shortcuts overlay. Documents what the code ACTUALLY binds
|
||
// (stage.js gizmo keys + dock drops + everything in _key/_down/_dbl/_ctx/
|
||
// _wheel below). Toggle with ?, close with Escape or a click outside. ----
|
||
_toggleHelp() {
|
||
if (this._help) { this._helpClose(); return; }
|
||
const S = (h, rows) => `<section><h4>${h}</h4><dl>` +
|
||
rows.map(([k, v]) => `<dt>${k}</dt><dd>${v}</dd>`).join('') + '</dl></section>';
|
||
const el = document.createElement('div'); el.className = 'tl-help';
|
||
el.innerHTML = `<button class="close" title="close">✕</button>
|
||
<h3>SCENEGOD shortcuts</h3><div class="hint">? toggles this panel · Escape closes it</div><div class="cols">` +
|
||
S('STAGE', [
|
||
['drag / right-drag / wheel', 'orbit · pan · dolly the director cam'],
|
||
['click', 'select an object'],
|
||
['W / E / R', 'gizmo: move · rotate · scale (on the selection)'],
|
||
['drag a dock card in', 'place a character, prop or backdrop'],
|
||
['drag an animation onto a character', 'retarget + drop a clip block at the playhead'],
|
||
['drop files on the page', 'local glb/fbx/image/video (upload — not saved)'],
|
||
]) +
|
||
S('TIMELINE', [
|
||
['Space', 'play / pause'],
|
||
['Home / End', 'jump to start / end'],
|
||
['click or drag the ruler', 'scrub'],
|
||
['double-click a transform lane', 'key the object where it stands now'],
|
||
['double-click a params lane', 'key its current params (fov, intensity, colour…)'],
|
||
['double-click the Cameras row', 'cut to the active camera'],
|
||
['drag a key', 'move it — snaps to the increment, Alt = free'],
|
||
['drag empty lane space', 'box-select keys; drag one of them to move them all'],
|
||
['Delete / Backspace', 'delete the selected keys'],
|
||
['right-click', 'delete a key, a cut, or an audio block'],
|
||
['drag a clip / audio block', 'move it; drag an edge to trim (audio trims either end)'],
|
||
['Cmd/Ctrl + wheel', 'zoom the time axis 1–64× around the cursor'],
|
||
['two-finger sideways', 'pan when zoomed in (plain wheel scrolls the page)'],
|
||
['Cmd/Ctrl + Z', 'undo — one director preset = one undo'],
|
||
]) +
|
||
S('SCENE BAR', [
|
||
['New…', 'start from a starter template (asks before clobbering)'],
|
||
['Save / Load', 'scenes by name (falls back to localStorage offline)'],
|
||
['dur / snap', 'scene length · snap increment for drags'],
|
||
['🎵 / 🗣', 'add audio at the playhead · lip-sync that clip onto a character'],
|
||
]) + '</div>';
|
||
el.querySelector('.close').onclick = () => this._helpClose();
|
||
document.body.appendChild(el);
|
||
this._help = el;
|
||
// the ? button closes via _toggleHelp — don't let click-outside close first,
|
||
// or the button would just reopen the panel on every click.
|
||
this._helpAway = (ev) => { if (!el.contains(ev.target) && ev.target !== this.$helpBtn) this._helpClose(); };
|
||
setTimeout(() => document.addEventListener('mousedown', this._helpAway), 0);
|
||
}
|
||
_helpClose() {
|
||
if (!this._help) return;
|
||
document.removeEventListener('mousedown', this._helpAway);
|
||
this._help.remove(); this._help = null;
|
||
}
|
||
|
||
// ---- M5: lip-sync from audio (🗣 on an audio row) ----
|
||
// GET /rhubarb?path=… → importRhubarb onto a viseme-bearing character.
|
||
// >1 candidate → picker menu; 503 → toast the install hint from `detail`.
|
||
async _lipSync(clip, anchorEl) {
|
||
const vt = (id) => (this.stage.visemeTargets && this.stage.visemeTargets(id)) || [];
|
||
const chars = this.stage.entities().filter((en) => en.kind === 'character' && vt(en.id).length > 0);
|
||
if (!chars.length) { this._toast('no character with viseme morph targets in the scene'); return; }
|
||
const id = chars.length === 1 ? chars[0].id
|
||
: await this._choose(chars.map((c) => ({ label: c.label || c.id, value: c.id })), anchorEl);
|
||
if (!id) return;
|
||
let res;
|
||
try { res = await fetch('rhubarb?path=' + encodeURIComponent(clip.path)); }
|
||
catch { this._toast('server unreachable — /rhubarb needs Lane C running'); return; }
|
||
if (!res.ok) {
|
||
let msg = 'rhubarb failed: ' + res.status;
|
||
try { const j = await res.json(); if (j.detail) msg = j.detail; } catch { /* non-json body */ }
|
||
this._toast(msg); return; // 503 carries the rhubarb install hint
|
||
}
|
||
const json = await res.json();
|
||
this.tl.importRhubarb(id, json, clip.start || 0); // grouped: one undo reverts the import
|
||
this._toast(`🗣 ${(json.mouthCues || []).length} cues → viseme keys on ${id}`);
|
||
this.tl.seek(this.tl.time);
|
||
this._syncRows(); // viseme lane may be new
|
||
}
|
||
_choose(items, anchorEl) { // small promise-menu (resolves value | null)
|
||
return new Promise((resolve) => {
|
||
const menu = document.createElement('div'); menu.className = 'tl-menu';
|
||
for (const it of items) {
|
||
const el = document.createElement('div'); el.className = 'item'; el.textContent = it.label;
|
||
el.onclick = () => { menu.remove(); resolve(it.value); };
|
||
menu.appendChild(el);
|
||
}
|
||
document.body.appendChild(menu);
|
||
const rect = anchorEl.getBoundingClientRect();
|
||
menu.style.left = rect.left + 'px'; menu.style.top = (rect.bottom + 2) + 'px';
|
||
const close = (ev) => { if (!menu.contains(ev.target)) { menu.remove(); document.removeEventListener('mousedown', close); resolve(null); } };
|
||
setTimeout(() => document.addEventListener('mousedown', close), 0);
|
||
});
|
||
}
|
||
_toast(msg) {
|
||
const d = document.createElement('div'); d.className = 'tl-toast'; d.textContent = msg;
|
||
document.body.appendChild(d);
|
||
setTimeout(() => d.remove(), 4000);
|
||
}
|
||
|
||
// ---- persistence (Lane C endpoints; localStorage fallback) ----
|
||
async save() {
|
||
const name = (this.$name.value || 'untitled').trim();
|
||
this.tl.scene.name = name;
|
||
const json = this.tl.toJSON();
|
||
try {
|
||
const res = await fetch(`scenes/${encodeURIComponent(name)}`, {
|
||
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(json),
|
||
});
|
||
if (!res.ok) throw new Error(res.status);
|
||
} catch {
|
||
localStorage.setItem('scenegod:scene:' + name, JSON.stringify(json)); // fallback until Lane C reachable
|
||
}
|
||
}
|
||
async load(name) {
|
||
name = (name || '').trim(); if (!name) return;
|
||
let json = null;
|
||
try { const res = await fetch(`scenes/${encodeURIComponent(name)}`); if (res.ok) json = await res.json(); } catch { /* fall through */ }
|
||
if (!json) { const raw = localStorage.getItem('scenegod:scene:' + name); if (raw) json = JSON.parse(raw); }
|
||
if (!json) return;
|
||
await this.tl.applyScene(json); // stage.applyState → tl.load (onLoad → rows+bar) → preload
|
||
this._decodeAll(); // decode audio for lane widths + playback
|
||
this.draw();
|
||
}
|
||
}
|
||
|
||
export default TimelineUI;
|