[laneB] M2 session 3: absorb _mirror + tests, setDuration, snap-step, param capture

- 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 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-18 20:41:39 +10:00
parent 41f2c9fa8d
commit 6200f945b0
4 changed files with 65 additions and 4 deletions

View File

@ -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()]; }

View File

@ -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 }) {

View File

@ -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');

View File

@ -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 {
<input class="name" placeholder="scene name" value="${this.tl.scene.name || ''}">
<span>dur</span><input class="num dur" type="number" min="0" step="0.5" value="${this.tl.duration}">
<label><input type="checkbox" class="snap" checked> snap</label>
<select class="snapstep" title="snap increment">
<option value="frame">frame</option><option value="0.1">0.1s</option>
<option value="0.25">0.25s</option><option value="1">1s</option></select>
<span class="spring"></span>
<button data-act="save">Save</button>
<button data-act="load">Load</button>`;
@ -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);