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