From 6200f945b058fb029d1a030912b54ead11427575 Mon Sep 17 00:00:00 2001 From: type-two Date: Sat, 18 Jul 2026 20:41:39 +1000 Subject: [PATCH] [laneB] M2 session 3: absorb _mirror + tests, setDuration, snap-step, param capture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - stagestub.js: removeEntity now fires onChange WITH the removed entity (real Stage contract) so _mirror's getEntity(id)===null removal path is testable. - timeline.js: setDuration(x) — clamps the playhead, keeps out-of-range keys (lossless), notifies UI via onLoad. (Reviewed orchestrator's _mirror seam.) - tlui.js: dur field → setDuration; snap-increment select (frame/0.1/0.25/1s); keyable-param add via dblclick on a params lane (captures current param values, skips 'type'/bool descriptors); params lane only when keys exist or kind is camera/light (no empty lane for characters). - timeline_test.mjs: _mirror lifecycle (add→keyframe→remove) + setDuration. Verified in browser (throwaway harness, not committed): dock-adds mirror into rows, param capture, setDuration clamp+bar refresh, snap-step math. Co-Authored-By: Claude Opus 4.8 --- scenegod/web/stagestub.js | 6 +++++- scenegod/web/timeline.js | 12 ++++++++++++ scenegod/web/timeline_test.mjs | 26 ++++++++++++++++++++++++++ scenegod/web/tlui.js | 25 ++++++++++++++++++++++--- 4 files changed, 65 insertions(+), 4 deletions(-) diff --git a/scenegod/web/stagestub.js b/scenegod/web/stagestub.js index 78af37d..865d913 100644 --- a/scenegod/web/stagestub.js +++ b/scenegod/web/stagestub.js @@ -54,7 +54,11 @@ export class StageStub { this._fireChange(e); return e; } - removeEntity(id) { this._entities.delete(id); this._mixers.delete(id); this._fireChange(null); } + removeEntity(id) { + const e = this._entities.get(id); + this._entities.delete(id); this._mixers.delete(id); + this._fireChange(e || { id }); // fire WITH the removed entity; getEntity(id) now null (real Stage contract) + } getEntity(id) { return this._entities.get(id) || null; } entities() { return [...this._entities.values()]; } diff --git a/scenegod/web/timeline.js b/scenegod/web/timeline.js index 70ff1bb..2a0d20c 100644 --- a/scenegod/web/timeline.js +++ b/scenegod/web/timeline.js @@ -145,6 +145,18 @@ export class Timeline { onLoad(cb) { this._loadCbs.push(cb); } toJSON() { return structuredClone(this.scene); } + // 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 + + // resync the scene bar. + setDuration(x) { + this.scene.duration = Math.max(0.1, +x || 0.1); + if (this.time > this.scene.duration) this.time = this.scene.duration; + for (const cb of this._loadCbs) cb(this.scene); + this.seek(this.time); + return this.scene.duration; + } + // Lane A clip-drop: fetch the clip (for its duration), drop a block at the // playhead, add a default crossfade where it abuts a neighbour, preload. async addClipDrop({ id, path, clipIndex = 0 }) { diff --git a/scenegod/web/timeline_test.mjs b/scenegod/web/timeline_test.mjs index dac2355..b00ba79 100644 --- a/scenegod/web/timeline_test.mjs +++ b/scenegod/web/timeline_test.mjs @@ -137,4 +137,30 @@ assert.ok(wa && near(wa.weight, 0.5, 1e-6), `A fade-out weight ~0.5, got ${wa && assert.ok(wb && near(wb.weight, 0.5, 1e-6), `B fade-in weight ~0.5, got ${wb && wb.weight}`); assert.ok(near(wa.weight + wb.weight, 1, 1e-6), 'crossfade weights sum to 1'); +// ---- SYNC2: _mirror entity lifecycle (stub add → mutate → remove) ---- +const mStage = new StageStub(); +const tlM = new Timeline(mStage); // ctor subscribes _mirror to stage.onChange +await mStage.addEntity({ id: 'p1', kind: 'prop', label: 'crate' }); // fires onChange → mirrored +let me = tlM.scene.entities.find((x) => x.id === 'p1'); +assert.ok(me, 'dock-added stage entity must be mirrored into the timeline'); +assert.deepEqual(me.tracks, { transform: [], params: [], clips: [] }, 'mirrored entity starts with empty tracks'); +tlM.addKey('p1', 'transform', { t: 0, pos: [1, 0, 0], rot: [0, 0, 0], scale: 1, ease: 'linear' }); +assert.equal(tlM.scene.entities.find((x) => x.id === 'p1').tracks.transform.length, 1, 'can keyframe a mirrored entity'); +mStage.removeEntity('p1'); // fires onChange w/ removed entity; getEntity → null +assert.ok(!tlM.scene.entities.find((x) => x.id === 'p1'), 'removing from stage drops the entity + its tracks'); + +// ---- SYNC2: setDuration clamps the playhead + keeps keys, fires onLoad ---- +let durNotified = 0; +const tlDur = new Timeline(new StageStub()); +tlDur.load({ version: 1, fps: 30, duration: 10, entities: [ + { id: 'e', kind: 'prop', tracks: { transform: [{ t: 8, pos: [0, 0, 0], rot: [0, 0, 0], scale: 1, ease: 'linear' }] } }, +], cameraCuts: [] }); +tlDur.onLoad(() => durNotified++); +tlDur.seek(9); +tlDur.setDuration(5); +assert.equal(tlDur.duration, 5, 'setDuration updates duration'); +assert.equal(tlDur.time, 5, 'setDuration clamps the playhead into range'); +assert.equal(tlDur.scene.entities[0].tracks.transform.length, 1, 'keys past the new end are kept (lossless)'); +assert.ok(durNotified >= 1, 'setDuration notifies the UI via onLoad'); + console.log('OK — timeline_test.mjs: all assertions passed'); diff --git a/scenegod/web/tlui.js b/scenegod/web/tlui.js index 170a25f..3e7c329 100644 --- a/scenegod/web/tlui.js +++ b/scenegod/web/tlui.js @@ -17,6 +17,7 @@ const CSS = ` #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} @@ -41,6 +42,7 @@ export class TimelineUI { this._drag = null; this._selKeys = []; // box-selected keys: [{id, track, key}] this._snapOn = true; + this._snapStep = 'frame'; // 'frame' | seconds string this._injectCSS(); this._build(); this.tl.onTick(() => this.draw()); @@ -73,6 +75,9 @@ export class TimelineUI { dur + `; @@ -82,12 +87,14 @@ export class TimelineUI { 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.$dur.onchange = () => { this.tl.scene.duration = Math.max(0.1, +this.$dur.value || 1); this.draw(); }; + 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'; @@ -118,7 +125,7 @@ export class TimelineUI { 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 && e.tracks.params) || e.kind === 'camera' || e.kind === 'light'; + const paramable = (e.tracks?.params?.length > 0) || e.kind === 'camera' || e.kind === 'light'; if (paramable) rows.push({ type: 'params', id: e.id, label: '↳ params' }); } rows.push({ type: 'cameras', label: 'Cameras' }); @@ -268,7 +275,11 @@ export class TimelineUI { // ---- interaction ---- _local(e) { const r = this.canvas.getBoundingClientRect(); return { x: e.clientX - r.left, y: e.clientY - r.top }; } - _snap(t, e) { return (e && e.altKey) || !this._snapOn ? t : Math.round(t * this.tl.fps) / this.tl.fps; } + _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; } @@ -327,6 +338,14 @@ export class TimelineUI { 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);