[m7-B] New-from-template picker + ? shortcuts overlay (M7 complete)

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>
This commit is contained in:
type-two 2026-07-25 22:47:29 +10:00
parent 586a3ecbd2
commit 16eb96a60e
6 changed files with 371 additions and 9 deletions

View File

@ -177,3 +177,16 @@ videoTex.needsUpdate — r160 VideoTexture rides requestVideoFrameCallback which
never fires in hidden tabs → black plates in renders. Backlog carried: honor
audio in/out at mux (C), walk-to-mark prevPos pre-apply (A), live viseme asset
still owed by John.
### 2026-07-25 — M7-B ACCEPTED at SYNC 9. **M7 COMPLETE.**
Verified live: scene bar reads `? · New… · Save · Load`; picker lists all three templates
(2 with thumbs, golden-hour first); NO confirm on an empty scene, confirm DOES fire on a
dirty one; the pick loads 7 entities + 2 cuts and refreshes name/dur; `toJSON()` carries no
`template` key. Help overlay: opens on `?`, 24 real bindings across STAGE/TIMELINE/SCENE BAR,
Escape closes, and it correctly does NOT hijack `?` while the name field has focus.
One orchestrator fix absorbed: `.tl-help dt` was `auto`-width + `white-space:nowrap`, so long
terms overflowed and the three columns overlapped on screen — now a capped, wrapping term
column (tlui.js CSS). Your call to strip the `template` key inside `applyScene` rather than
lean on round-trip behaviour was the right one; PLAN §4.1 stays honest.
Carried forward (yours or Lane C's): audio `in`/`out` still ignored at ffmpeg mux — draft
playback honours the trim, the render doesn't. Worth closing before anyone cuts to music.

View File

@ -249,3 +249,70 @@ NEXT: SYNC — browser-verify DIRECT panel button → keys+cut land + single
undo; 🗣 on real vo.wav → viseme keys on a blendshape character (and the
503 toast with rhubarb absent); Cmd+wheel zoom feel; video backdrop
scrub-follow once Lane A ships entityVideo. Holding at M6-B boundary.
## 2026-07-25 session 6 (M7-B: the way in — templates + shortcuts overlay)
DONE: both required items + one backlog item, all headless-verified.
1. **New from template** (scene bar, left of Save/Load — `tlui.js:122`).
`_pickTemplate()` (tlui.js:585) — `GET templates` → floating `.tl-menu.tmpl`
(same pattern as the 🎵 picker) with title + description + thumb
(`assets/file?path=`, `img.onerror` → text-only row). All URLs RELATIVE.
`newFromTemplate(name)` (tlui.js:616): warn-before-clobber via
`tl.hasContent()``confirm()`, then `GET templates/{name}`
`tl.applyScene(json)`, `_decodeAll()`, `draw()`. Any failure (fetch, !ok,
apply) toasts through `_toast` (window.sgToast still points at it).
2. **`?` shortcuts overlay** (tlui.js:633 `_toggleHelp` / `_helpClose`,
CSS `.tl-help` at tlui.js:48). Opens with `?` or the `?` button
(tlui.js:121), closes on Escape / click-outside / ✕. Three columns —
STAGE, TIMELINE, SCENE BAR — written by READING the code, not from memory:
stage.js:118-122 (W/E/R gizmo, selection-gated), dock.js drops, and every
binding in my `_key/_down/_dbl/_ctx/_wheel`. Deliberately NOT listed:
right-click on a clip block (only keys/cuts/audio blocks delete), left-edge
trim on animation clip blocks (only the right edge is a hit target).
3. Backlog: **audio HEAD trim** — dragging an audio block's LEFT edge now eats
into the source (`in` and `start` move together so the audible part stays
put) instead of being dead space. `timeline.js:trimAudioIn` (grouped: one
undo for the pair, clamps at the file head, at t=0, and 0.05s before `out`);
tlui `aedge` hits now carry `side` ('in' | 'out').
Model seams added to timeline.js: `applyScene(json)` (timeline.js:173) —
stage.applyState → load → preload, the ONE apply path now shared by Load and
New-from-template; and `hasContent()` (timeline.js:185).
TEST: `node scenegod/web/timeline_test.mjs` → green, +5 new blocks:
hasContent (empty / cuts-only / audio-only / dock-added entity), applyScene
happy path (entities on stage, tracks in the model, onLoad once, camera live,
clip preloaded and playing at t=1, `template` key stripped, caller's json not
mutated), a loop that applies EVERY shipped `templates/*.json` through
applyScene against the stub ("3 shipped templates applied clean"), and
head-trim (pair + single undo + both clamps). `node --check` clean on
timeline.js, tlui.js, stagestub.js, timeline_test.mjs.
DECISIONS:
- `tlui.load()` now routes through `tl.applyScene` too, so Load and templates
cannot drift apart (the ask was "apply it the same way Load does" — made it
literally the same code rather than a copy).
- `applyScene` deletes the `template` key before loading. server.py:264 claims
that metadata "never survives a Timeline round-trip" — it WOULD have, since
load() structuredClones unknown fields on purpose (lossless round-trip is a
tested feature). Stripping it in one place makes Lane C's comment true and
keeps saved scenes plain scene JSON.
- `hasContent()` counts ANY entity, cut or audio clip — a dock-added,
not-yet-keyframed character is still work worth a confirm. A fresh page has
zero entities (Stage creates none in its ctor), so the first New… never
nags. Used plain `confirm()` — a modal I'd have to build is not more honest
than the browser's.
- `_key` now also ignores SELECT + contentEditable targets, so `?` (and Space)
can't be hijacked while the snap dropdown or any text field has focus.
- The `?` button is excluded from the overlay's click-outside handler,
otherwise mousedown closed it and the click reopened it.
BLOCKED/REQUESTS:
- **Lane C / orchestrator (nit)**: `GET /templates` returns thumbs that are
ASSET-relative (`backdrops/video/scenegod_plates/*.jpg`) — I render them via
`assets/file?path=`. If a future template ever ships a thumb that lives
outside SCENEGOD_ASSETS, the picker will show a broken (auto-removed) image;
keep thumbs inside the assets root.
- **Lane C (still open from session 5)**: audio `in`/`out` at ffmpeg mux.
Head trim makes it much easier to hit — a head-trimmed clip now renders from
the file start server-side while the draft playback starts at `in`.
NEXT: nothing required. Remaining polish if a session 7 lands: box-select
multi-drag refinements (marquee currently only selects keys — clip/audio
blocks and cuts are not box-selectable), and a per-drag undo coalesce (a
mousemove drag pushes one undo entry per frame; Ctrl+Z walks it back a pixel
at a time). Holding at the M7-B boundary.

View File

@ -727,3 +727,40 @@ INFO: 127.0.0.1:49200 - "GET /assets/file?path=backdrops%2Fvideo%2Fscenegod_
INFO: 127.0.0.1:49200 - "GET /assets/file?path=backdrops%2Fvideo%2Fscenegod_plates%2Frecord_store_interior.mp4 HTTP/1.1" 206 Partial Content
INFO: 127.0.0.1:49201 - "GET /assets/thumb?path=backdrops%2Fvideo%2Fscenegod_plates%2Frecord_store_interior.mp4 HTTP/1.1" 200 OK
INFO: 127.0.0.1:49200 - "GET /templates/empty-stage HTTP/1.1" 200 OK
INFO: 127.0.0.1:50001 - "GET /templates HTTP/1.1" 200 OK
INFO: 127.0.0.1:50003 - "GET /templates/empty-stage HTTP/1.1" 200 OK
INFO: 127.0.0.1:51183 - "GET /web/tlui.js HTTP/1.1" 200 OK
INFO: 127.0.0.1:51227 - "GET /web/tlui.js HTTP/1.1" 200 OK
INFO: 127.0.0.1:51229 - "GET /templates HTTP/1.1" 200 OK
INFO: 127.0.0.1:51724 - "GET / HTTP/1.1" 200 OK
INFO: 127.0.0.1:51724 - "GET /web/style.css HTTP/1.1" 200 OK
INFO: 127.0.0.1:51725 - "GET /web/dock.js HTTP/1.1" 200 OK
INFO: 127.0.0.1:51724 - "GET /web/stage.js HTTP/1.1" 200 OK
INFO: 127.0.0.1:51724 - "GET /web/room3d.js HTTP/1.1" 200 OK
INFO: 127.0.0.1:51725 - "GET /web/presets.js HTTP/1.1" 200 OK
INFO: 127.0.0.1:51724 - "GET /assets/tree HTTP/1.1" 200 OK
INFO: 127.0.0.1:51725 - "GET /favicon.ico HTTP/1.1" 404 Not Found
INFO: 127.0.0.1:51725 - "GET /web/grammar.js HTTP/1.1" 200 OK
INFO: 127.0.0.1:51725 - "GET /director HTTP/1.1" 405 Method Not Allowed
INFO: 127.0.0.1:51724 - "GET /web/timeline.js HTTP/1.1" 200 OK
INFO: 127.0.0.1:51724 - "GET /web/tlui.js HTTP/1.1" 200 OK
INFO: 127.0.0.1:51726 - "GET /templates HTTP/1.1" 200 OK
INFO: 127.0.0.1:51726 - "GET /assets/file?path=backdrops%2Fvideo%2Fscenegod_plates%2Fsuburb_strip_goldenhour.jpg HTTP/1.1" 200 OK
INFO: 127.0.0.1:51856 - "GET /assets/file?path=backdrops%2Fvideo%2Fscenegod_plates%2Frecord_store_interior.jpg HTTP/1.1" 200 OK
INFO: 127.0.0.1:51726 - "GET /templates/two-hander-golden-hour HTTP/1.1" 200 OK
INFO: 127.0.0.1:51726 - "GET /assets/file?path=characters%2Ftest%2Flady.glb HTTP/1.1" 200 OK
INFO: 127.0.0.1:51726 - "GET /templates HTTP/1.1" 200 OK
INFO: 127.0.0.1:51726 - "GET /templates/two-hander-golden-hour HTTP/1.1" 200 OK
INFO: 127.0.0.1:51726 - "GET /assets/file?path=characters%2Ftest%2Flady.glb HTTP/1.1" 200 OK
INFO: 127.0.0.1:53065 - "GET / HTTP/1.1" 200 OK
INFO: 127.0.0.1:53065 - "GET /web/style.css HTTP/1.1" 200 OK
INFO: 127.0.0.1:53065 - "GET /web/stage.js HTTP/1.1" 200 OK
INFO: 127.0.0.1:53066 - "GET /web/dock.js HTTP/1.1" 200 OK
INFO: 127.0.0.1:53065 - "GET /web/room3d.js HTTP/1.1" 200 OK
INFO: 127.0.0.1:53066 - "GET /web/presets.js HTTP/1.1" 200 OK
INFO: 127.0.0.1:53065 - "GET /assets/tree HTTP/1.1" 200 OK
INFO: 127.0.0.1:53067 - "GET /favicon.ico HTTP/1.1" 404 Not Found
INFO: 127.0.0.1:53066 - "GET /web/grammar.js HTTP/1.1" 200 OK
INFO: 127.0.0.1:53066 - "GET /director HTTP/1.1" 405 Method Not Allowed
INFO: 127.0.0.1:53067 - "GET /web/timeline.js HTTP/1.1" 200 OK
INFO: 127.0.0.1:53067 - "GET /web/tlui.js HTTP/1.1" 200 OK

View File

@ -165,6 +165,31 @@ export class Timeline {
onLoad(cb) { this._loadCbs.push(cb); }
toJSON() { return structuredClone(this.scene); }
// Apply a whole scene JSON: stage first (builds the entity objects), then the
// tracks, then clip preload. Load and "new from template" share this so the
// two paths can never drift apart. `template` (Lane C's presentation metadata:
// title/description/thumb/order) is dropped here — it must never ride into a
// saved scene through the lossless round-trip.
async applyScene(json) {
const scene = structuredClone(json);
delete scene.template;
if (this.stage.applyState) await this.stage.applyState(scene);
this.load(scene); // fires onLoad → UI rebuilds rows + scene bar
await this.preload();
return scene;
}
// "Is there anything here the user would hate to lose?" — the confirm-before-
// clobber predicate for New-from-template. Any entity at all counts (a dock-
// added, not-yet-keyframed character is still work), as do cuts and audio.
hasContent() {
const s = this.scene;
if ((s.entities || []).length) return true;
if ((s.cameraCuts || []).length) return true;
if ((s.audio || []).length) return true;
return false;
}
// Programmatic duration change (dur field or code). Keys past the new end are
// kept (lossless) but unreachable; clamp the playhead and refresh the UI —
// plain `scene.duration = x` skips both. Reuses the onLoad hook to redraw +
@ -537,6 +562,19 @@ export class Timeline {
} });
}
// Head trim: dragging an audio block's LEFT edge eats into the source rather
// than moving the block — `in` grows by exactly what `start` does, so what you
// still hear stays put on the timeline. The pair is ONE undo entry.
trimAudioIn(clip, newStart, srcDur = null) {
const inT = clip.in || 0;
const outT = clip.out ?? srcDur;
const lo = -Math.min(inT, clip.start); // can't rewind past the file head or t=0
const hi = outT != null ? Math.max(0, outT - inT - 0.05) : Infinity; // always leave a sliver
const d = Math.max(lo, Math.min(hi, newStart - clip.start));
if (!d) return;
this.group(() => { this.trimAudio(clip, { in: inT + d }); this.moveAudio(clip, clip.start + d); });
}
// Rhubarb lip-sync JSON → morph keys. { mouthCues: [{start, end, value}] }.
// ponytail: per-cue ramp (shape 1 at cue start → 0 at cue end); hold/crossfade
// tuning deferred until it looks wrong on a real blendshape character.

View File

@ -202,6 +202,20 @@ tlA.undo(); tlA.undo();
assert.ok(!('in' in ac) && !('out' in ac), 'undo removes trim fields entirely (schema stays clean)');
assert.deepEqual(tlA.toJSON().audio, [{ path: 'audio/vo1.wav', start: 3.5, gain: 0.4 }], 'untrimmed clip round-trips without in/out');
// ---- M7-B polish: audio HEAD trim — in and start move together, one undo ----
const hc = tlA.scene.audio[0]; // {path, start: 3.5, gain: 0.4}, untrimmed
const hUndo = tlA.undoStack.length;
tlA.trimAudioIn(hc, 4.2, 8); // drag the left edge 0.7s right, 8s source
assert.ok(near(hc.in, 0.7) && near(hc.start, 4.2), `head trim: in=0.7 start=4.2, got ${hc.in}/${hc.start}`);
assert.equal(tlA.undoStack.length, hUndo + 1, 'head trim is a single undo entry');
tlA.undo();
assert.ok(!('in' in hc) && near(hc.start, 3.5), 'undo restores the pristine (untrimmed) clip');
tlA.trimAudioIn(hc, -5, 8); // nothing before the file head → no-op (use the body to move)
assert.ok(near(hc.start, 3.5) && !hc.in, `head trim clamps at the source head, got start=${hc.start} in=${hc.in}`);
tlA.trimAudio(hc, { out: 2 }); // 3.5..(2s of source)
tlA.trimAudioIn(hc, 99, 8); // dragging past the tail leaves a sliver
assert.ok(hc.in <= 2 - 0.05 + 1e-9 && hc.in > 0, `head trim stops before out, got in=${hc.in}`);
// ---- M5: importRhubarb is grouped — ONE undo reverts the whole import ----
const preLen = tlMo.scene.entities[0].tracks.morphs.length;
const preUndo = tlMo.undoStack.length;
@ -317,4 +331,64 @@ assert.equal(vid.paused, false, 'video plays with the clock');
tlV.pause(); // pause() syncs videos
assert.equal(vid.paused, true, 'pause() pauses the video');
// ---- M7-B: hasContent() — the confirm-before-clobber predicate ----
const tlH = new Timeline(new StageStub());
assert.equal(tlH.hasContent(), false, 'a fresh timeline is empty (no confirm on first template)');
tlH.load({ version: 1, fps: 30, duration: 5, entities: [], cameraCuts: [{ t: 0, camera: 'cam1' }], audio: [] });
assert.equal(tlH.hasContent(), true, 'a camera cut counts as content');
tlH.load({ version: 1, fps: 30, duration: 5, entities: [], cameraCuts: [], audio: [{ path: 'a.wav', start: 0, gain: 1 }] });
assert.equal(tlH.hasContent(), true, 'an audio clip counts as content');
tlH.load({ version: 1, fps: 30, duration: 5, entities: [], cameraCuts: [], audio: [] });
assert.equal(tlH.hasContent(), false, 'loading an empty scene clears content');
// a dock-added, not-yet-keyframed entity is still work worth confirming over
const hStage = new StageStub();
const tlH2 = new Timeline(hStage);
await hStage.addEntity({ id: 'p9', kind: 'prop', label: 'crate' }); // _mirror → scene.entities
assert.equal(tlH2.hasContent(), true, 'a dock-added entity counts as content');
// ---- M7-B: applyScene() — the shared Load / new-from-template path ----
const tStage = new StageStub();
const tlT = new Timeline(tStage);
let tLoads = 0; tlT.onLoad(() => tLoads++);
const tmpl = {
version: 1, name: 'two-hander', fps: 30, duration: 8,
template: { title: 'Two-hander', description: 'demo', thumb: 'backdrops/x.jpg', order: 1 },
entities: [
{ id: 'cam1', kind: 'camera', source: { type: 'none' }, params: { fov: 40 },
transform: { pos: [0, 1.6, 6], rot: [0, 0, 0], scale: 1 }, tracks: { transform: [], params: [], clips: [] } },
{ id: 'lady', kind: 'character', source: { type: 'assets', path: 'characters/lady.glb' },
transform: { pos: [0, 0, 0], rot: [0, 0, 0], scale: 1 },
tracks: { transform: [{ t: 0, pos: [0, 0, 0], rot: [0, 0, 0], scale: 1, ease: 'inout' }],
clips: [{ path: 'animations/walk.fbx', clipIndex: 0, start: 0, in: 0, out: 2.4, loop: 1, fade: 0 }] } },
],
cameraCuts: [{ t: 0, camera: 'cam1' }], audio: [],
};
const applied = await tlT.applyScene(tmpl);
assert.equal(tStage.entities().length, 2, 'applyScene builds the entities on the stage');
assert.equal(tlT.scene.entities.length, 2, 'applyScene loads the tracks into the timeline');
assert.equal(tLoads, 1, 'applyScene fires onLoad once (UI rebuilds rows + scene bar)');
assert.equal(tlT.duration, 8, 'applyScene takes the template duration');
assert.equal(tStage._active, 'cam1', 'applyScene evaluates: the template camera is live');
assert.ok(!('template' in applied) && !('template' in tlT.toJSON()),
'presentation metadata is stripped — a saved scene stays plain scene JSON');
assert.ok('template' in tmpl, 'applyScene does not mutate the caller\'s json');
assert.equal(tlT._clipActions.size, 1, 'applyScene preloads clip blocks (playable immediately)');
tlT.step(30); // t=1 → the template clip is running
assert.ok(lastIn(tStage.applied, (a) => a.type === 'clip' && a.id === 'lady'), 'template clip plays after applyScene');
assert.equal(tlT.hasContent(), true, 'after a template the scene is non-empty (next New… confirms)');
// the templates Lane C actually ships must survive the same path
const tmplDir = new URL('../../templates/', import.meta.url);
let shipped = [];
try { shipped = (await import('node:fs')).readdirSync(tmplDir).filter((f) => f.endsWith('.json')); } catch { /* no templates dir */ }
for (const f of shipped) {
const json = JSON.parse((await import('node:fs')).readFileSync(new URL(f, tmplDir), 'utf8'));
const s = new StageStub(), t2 = new Timeline(s);
await t2.applyScene(json);
assert.equal(s.entities().length, (json.entities || []).length, `${f}: every entity reaches the stage`);
assert.ok(!('template' in t2.toJSON()), `${f}: template metadata stripped`);
assert.ok(t2.duration > 0, `${f}: has a duration`);
}
console.log(` (${shipped.length} shipped templates applied clean)`);
console.log('OK — timeline_test.mjs: all assertions passed');

View File

@ -40,6 +40,25 @@ const CSS = `
.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}
`;
@ -102,6 +121,8 @@ export class TimelineUI {
<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);
@ -116,6 +137,10 @@ export class TimelineUI {
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; };
@ -365,7 +390,10 @@ export class TimelineUI {
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) this._hits.push({ kind: 'aedge', clip, x: x1, y }); // drag = trim tail
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 ----
@ -419,9 +447,13 @@ export class TimelineUI {
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);
let out = (cl.in || 0) + (t - cl.start);
if (srcDur != null) out = Math.min(out, srcDur);
this.tl.trimAudio(cl, { out }); this.draw();
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(); }
}
@ -466,8 +498,10 @@ export class TimelineUI {
else if (h && h.kind === 'audioblock') { this.tl.removeAudio(h.clip); this._syncRows(); }
}
_key(e) {
if (e.target && /INPUT|TEXTAREA/.test(e.target.tagName)) return;
if (e.code === 'Space') { e.preventDefault(); this._toggle(); }
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(); }
@ -549,6 +583,107 @@ export class TimelineUI {
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 164× 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`.
@ -614,9 +749,7 @@ export class TimelineUI {
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;
if (this.stage.applyState) await this.stage.applyState(json);
this.tl.load(json); // fires onLoad → _syncRows + _refreshBar
await this.tl.preload();
await this.tl.applyScene(json); // stage.applyState → tl.load (onLoad → rows+bar) → preload
this._decodeAll(); // decode audio for lane widths + playback
this.draw();
}