[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:
parent
41f2c9fa8d
commit
6200f945b0
@ -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()]; }
|
||||
|
||||
|
||||
@ -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 }) {
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user