diff --git a/lanes/B-timeline.md b/lanes/B-timeline.md index 6f0b933..9d698ec 100644 --- a/lanes/B-timeline.md +++ b/lanes/B-timeline.md @@ -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. diff --git a/logs/laneB.md b/logs/laneB.md index f8aa3fb..54091f3 100644 --- a/logs/laneB.md +++ b/logs/laneB.md @@ -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. diff --git a/scenegod-8020.log b/scenegod-8020.log index ae665f1..ac31200 100644 --- a/scenegod-8020.log +++ b/scenegod-8020.log @@ -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 diff --git a/scenegod/web/timeline.js b/scenegod/web/timeline.js index 5202270..8151ab4 100644 --- a/scenegod/web/timeline.js +++ b/scenegod/web/timeline.js @@ -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. diff --git a/scenegod/web/timeline_test.mjs b/scenegod/web/timeline_test.mjs index 42ea815..cdc865b 100644 --- a/scenegod/web/timeline_test.mjs +++ b/scenegod/web/timeline_test.mjs @@ -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'); diff --git a/scenegod/web/tlui.js b/scenegod/web/tlui.js index df6ec89..fa8a7bf 100644 --- a/scenegod/web/tlui.js +++ b/scenegod/web/tlui.js @@ -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 { + + `; 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 = '
no starter templates on the server
'; + 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) => `

${h}

` + + rows.map(([k, v]) => `
${k}
${v}
`).join('') + '
'; + const el = document.createElement('div'); el.className = 'tl-help'; + el.innerHTML = ` +

SCENEGOD shortcuts

? toggles this panel · Escape closes it
` + + 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'], + ]) + '
'; + 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(); }