[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:
parent
586a3ecbd2
commit
16eb96a60e
@ -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.
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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 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`.
|
||||
@ -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();
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user