${row("Content", ``, "", "text")}
${row(hasTextBox(p) && p.boxFit ? "Max size" : "Font size",
- `
- ${p.fontSize}px`, "fontSize")}
+ `
+ ${fmtInspNum(p.fontSize, 1)}px`, "fontSize")}
${row("Box W/H", `
@@ -3618,13 +3702,18 @@ function renderInspector(lite) {
state.dirtyTimeline = true;
}
}
+ else if (ANIMATABLE.includes(k)) setAnimProp(c, k, v);
else { c.props[k] = v; if (k === "text") state.dirtyTimeline = true; }
const valEl = els.inspector.querySelector(`[data-val="${k}"]`);
- if (valEl) valEl.textContent = input.value;
+ if (valEl) valEl.textContent = input.value + (valEl.dataset.unit || "");
if (state.audioHold && (k === "volume" || k === "pan")) scheduleAudioHoldRefresh();
scheduleSave();
+ if (ANIMATABLE.includes(k)) syncInspectorPlayhead();
});
- input.addEventListener("focus", () => pushUndo(), { once: true });
+ input.addEventListener("focus", () => {
+ pushUndo();
+ if (ANIMATABLE.includes(k) && state.playing) pause();
+ }, { once: true });
});
els.inspector.querySelectorAll("[data-action]").forEach((btn) => {
btn.addEventListener("click", () => {
@@ -3663,17 +3752,8 @@ function renderInspector(lite) {
});
els.inspector.querySelectorAll("[data-kf]").forEach((btn) => {
btn.addEventListener("click", () => {
- const k = btn.dataset.kf;
- const input = els.inspector.querySelector(`[data-k="${k}"]`);
- const v = input ? parseFloat(input.value) : +(c.props[k] || 0);
- if (isNaN(v)) return;
pushUndo();
- if (!c.keyframes) c.keyframes = {};
- const arr = (c.keyframes[k] = c.keyframes[k] || []);
- const lt = +clamp(state.time - c.start, 0, c.duration).toFixed(3);
- const near = arr.find((kf) => Math.abs(kf.t - lt) < 0.5 / projectFps());
- if (near) near.v = v; else arr.push({ t: lt, v });
- arr.sort((a, b) => a.t - b.t);
+ toggleKfAtPlayhead(c, btn.dataset.kf);
state.dirtyTimeline = true;
scheduleSave(); renderInspector();
});
@@ -3702,6 +3782,41 @@ function renderInspector(lite) {
renderKfGraphsPanel();
}
+/* Patch inspector fields to the playhead (no innerHTML rebuild — keeps focus). */
+function syncInspectorPlayhead() {
+ const root = els && els.inspector;
+ if (!root) return;
+ const c = getClip(state.selId);
+ if (!c) return;
+ const p = propsAtPlayhead(c);
+ const active = document.activeElement;
+ for (const input of root.querySelectorAll("[data-k]")) {
+ const k = input.dataset.k;
+ if (!ANIMATABLE.includes(k)) continue;
+ if (active === input) continue;
+ const v = p[k];
+ if (typeof v !== "number" || isNaN(v)) continue;
+ const next = fmtInspNum(v, input.type === "range" ? input.step : undefined);
+ if (Math.abs(+input.value - +next) > 1e-6) input.value = next;
+ const valEl = root.querySelector(`[data-val="${k}"]`);
+ if (valEl) {
+ const text = next + (valEl.dataset.unit || "");
+ if (valEl.textContent !== text) valEl.textContent = text;
+ }
+ }
+ for (const btn of root.querySelectorAll("[data-kf]")) {
+ const k = btn.dataset.kf;
+ const n = (c.keyframes?.[k] && c.keyframes[k].length) || 0;
+ const on = !!kfAtPlayhead(c, k);
+ btn.classList.toggle("has", n > 0);
+ btn.classList.toggle("on", on);
+ const label = "◆" + (n || "");
+ if (btn.textContent !== label) btn.textContent = label;
+ const title = on ? "Remove keyframe at playhead" : "Set keyframe at playhead";
+ if (btn.title !== title) btn.title = title;
+ }
+}
+
/* ── Keyframe graphs (program-monitor left gutter) ── */
const KF_GRAPH_LABEL = {
x: "Pos X", y: "Pos Y", scale: "Scale", rotation: "Rotation", opacity: "Opacity",
@@ -5990,6 +6105,7 @@ function loop(ts) {
drawRuler();
updateSafeOverlay();
updateKfGraphs();
+ syncInspectorPlayhead();
updateMeterUI(dt);
els.tcCurrent.textContent = fmt(state.time);
els.tcTotal.textContent = fmt(dur);
diff --git a/index.html b/index.html
index b85146e..15547d2 100644
--- a/index.html
+++ b/index.html
@@ -285,7 +285,7 @@
Keyboard shortcuts
Ctrl+click clip
Add / remove from selection
Ctrl+A / Esc
Select all / deselect
←→
Step 1 frame (⇧ = 1 second)
-
Ctrl+← / Ctrl+→
Go to previous / next keyframe
+
Ctrl+← / Ctrl+→
Go to previous / next keyframe (inspector follows)
[ / ]
Trim selected in / out to playhead
Home / End
Jump to start / end
diff --git a/live.json b/live.json
new file mode 100644
index 0000000..d09f918
--- /dev/null
+++ b/live.json
@@ -0,0 +1,8 @@
+{
+ "media": {
+ "m_hni37sx": {
+ "duration": 88190.6831015625,
+ "liveOrigin": "2026-09-08T12:01:55.444Z"
+ }
+ }
+}
\ No newline at end of file
diff --git a/style.css b/style.css
index a55603f..85851e9 100644
--- a/style.css
+++ b/style.css
@@ -1333,6 +1333,12 @@ input[type=range] {
border-color: #ffd16666;
}
+.kf-btn.has.on {
+ background: #ffd166;
+ color: #1a1a22;
+ border-color: #ffd166;
+}
+
/* ── Timeline ── */
.timeline-toolbar {
display: flex;
From 479ede4b1f89989e6f4729f819380e8c156ea377 Mon Sep 17 00:00:00 2001
From: Tomasz Plonka <4361591+PlkMarudny@users.noreply.github.com>
Date: Thu, 10 Sep 2026 15:28:50 +0300
Subject: [PATCH 2/7] feat: remove keyframe on label Shift+Click
---
README.md | 2 +-
app.js | 74 ++++++++++++++++++++++++++++++++++++++----------------
index.html | 2 ++
3 files changed, 55 insertions(+), 23 deletions(-)
diff --git a/README.md b/README.md
index 8dc163b..512ac48 100644
--- a/README.md
+++ b/README.md
@@ -105,7 +105,7 @@ same time.
- **Zoom to selection** (⇧Z) frames all selected clips, not just one
- **IN/OUT work area** — set markers with i and o (⇧I / ⇧O to clear). Enabling **Limit** constrains playback to the marked range and maps Home / End to the IN and OUT positions rather than the full timeline. t splits clips at the markers; ⇧t trims clips to the work (between marker in and marker out) area.
- **Find & close gaps** — a gap is a stretch where every enabled track is empty (black frames). g jumps the playhead to the next shared gap (wraps; respects IN/OUT when both are set). ⇧G closes the gap under the playhead by pulling later clips left on all enabled tracks.
-- **Reset a property** — Ctrl/Cmd+click an inspector **label** to restore that effect/prop to its default (paired fields like Crop L/R reset together). Matching keyframes for the prop are cleared too; transition labels clear the in/out transition.
+- **Reset a property** — Ctrl/Cmd+click an inspector **label** restores that effect/prop to its default *and* clears every keyframe on the channel (paired fields like Crop L/R reset together; transition labels clear the in/out transition). Shift+click the same label is playhead-local: if you are parked on a keyframe it removes **that** keyframe only; otherwise it sets the value at the playhead to the default (auto-keys if the channel is already animated).
- **Replace media** — the inspector's **Source** button (any video/audio/image/svg
clip) swaps the underlying file while keeping position, trim, keyframes,
transitions and every effect. Pick another item already in the bin or
diff --git a/app.js b/app.js
index 35a0d83..c58453f 100644
--- a/app.js
+++ b/app.js
@@ -633,6 +633,44 @@ function setAnimProp(c, k, v) {
arr.sort((a, b) => a.t - b.t);
state.dirtyTimeline = true;
}
+/* Wipe a property: factory default + delete that channel's keyframes. */
+function resetPropChannel(c, k) {
+ if (!c || !k) return;
+ if (k === "transIn" || k === "transOut") {
+ c[k === "transIn" ? "transitionIn" : "transitionOut"] = undefined;
+ state.dirtyTimeline = true;
+ return;
+ }
+ if (!Object.hasOwn(DEFAULT_PROPS, k)) return;
+ c.props[k] = DEFAULT_PROPS[k];
+ if (c.keyframes?.[k]) {
+ delete c.keyframes[k];
+ if (!Object.keys(c.keyframes).length) c.keyframes = undefined;
+ state.dirtyTimeline = true;
+ }
+ if (k === "text" || k === "font") state.dirtyTimeline = true;
+ if (k === "font") ensureFont(String(DEFAULT_PROPS.font));
+}
+/* Playhead-local reset: remove the keyframe under the playhead, else set the
+ value at the playhead to the property default (auto-keys if already keyed). */
+function resetPropAtPlayhead(c, k) {
+ if (!c || !k) return;
+ if (k === "transIn" || k === "transOut") {
+ resetPropChannel(c, k);
+ return;
+ }
+ if (!Object.hasOwn(DEFAULT_PROPS, k)) return;
+ if (ANIMATABLE.includes(k) && kfAtPlayhead(c, k)) {
+ toggleKfAtPlayhead(c, k);
+ state.dirtyTimeline = true;
+ return;
+ }
+ const def = DEFAULT_PROPS[k];
+ if (ANIMATABLE.includes(k) && c.keyframes?.[k]?.length) setAnimProp(c, k, def);
+ else c.props[k] = def;
+ if (k === "text" || k === "font") state.dirtyTimeline = true;
+ if (k === "font") ensureFont(String(def));
+}
/* ◆ : add a keyframe at the playhead, or remove the one already there. */
function toggleKfAtPlayhead(c, k) {
if (!c || !ANIMATABLE.includes(k)) return;
@@ -3479,7 +3517,8 @@ function renderInspector(lite) {
};
/* Label carries two affordances that key off different click modifiers:
plain click toggles the keyframe graph (animatable props), Ctrl/Cmd-click
- resets the prop(s). `reset` overrides which keys reset; defaults to k. */
+ resets the whole channel, Shift-click resets at the playhead / removes
+ that keyframe. `reset` overrides which keys reset; defaults to k. */
const propLabel = (label, k = "", reset) => {
const keys = reset !== undefined ? reset : k;
const list = (Array.isArray(keys) ? keys : String(keys || "").split(",")).map((s) => s.trim()).filter(Boolean);
@@ -3493,8 +3532,10 @@ function renderInspector(lite) {
canReset ? "insp-reset" : "",
].filter(Boolean).join(" ");
const attrs = (isGraph ? ` data-kfgraph="${k}"` : "") + (canReset ? ` data-reset="${list.join(",")}"` : "");
- const title = isGraph && canReset ? "Click: keyframe graph · Ctrl-click: reset"
- : isGraph ? "Show / hide keyframe graph" : "Ctrl-click to reset";
+ const title = isGraph && canReset
+ ? "Click: keyframe graph · Ctrl-click: reset channel · Shift-click: reset at playhead / remove keyframe"
+ : isGraph ? "Show / hide keyframe graph"
+ : "Ctrl-click: reset channel · Shift-click: reset at playhead / remove keyframe";
return ``;
};
const row = (label, inner, k = "", reset) =>
@@ -3584,7 +3625,7 @@ function renderInspector(lite) {
}
const tsel = (label, key, tr) => {
const active = state.transFocus === (key === "transIn" ? "in" : "out");
- return `
+ return `
`;
};
@@ -3649,27 +3690,16 @@ function renderInspector(lite) {
els.inspector.innerHTML = html;
els.inspector.querySelectorAll("label.insp-reset[data-reset]").forEach((lab) => {
lab.addEventListener("click", (e) => {
- if (!(e.ctrlKey || e.metaKey)) return;
+ const all = e.ctrlKey || e.metaKey;
+ const local = e.shiftKey && !all;
+ if (!all && !local) return;
e.preventDefault();
const keys = lab.dataset.reset.split(",").map((s) => s.trim()).filter(Boolean);
if (!keys.length) return;
pushUndo();
- for (const k of keys) {
- if (k === "transIn" || k === "transOut") {
- c[k === "transIn" ? "transitionIn" : "transitionOut"] = undefined;
- state.dirtyTimeline = true;
- continue;
- }
- if (!Object.hasOwn(DEFAULT_PROPS, k)) continue;
- c.props[k] = DEFAULT_PROPS[k];
- if (c.keyframes?.[k]) {
- delete c.keyframes[k];
- if (!Object.keys(c.keyframes).length) c.keyframes = undefined;
- state.dirtyTimeline = true;
- }
- if (k === "text" || k === "font") state.dirtyTimeline = true;
- if (k === "font") ensureFont(String(DEFAULT_PROPS.font));
- }
+ for (const k of keys) (all ? resetPropChannel : resetPropAtPlayhead)(c, k);
+ if (state.audioHold && keys.some((k) => k === "volume" || k === "pan"))
+ scheduleAudioHoldRefresh();
scheduleSave();
renderInspector();
});
@@ -3774,7 +3804,7 @@ function renderInspector(lite) {
}
els.inspector.querySelectorAll("[data-kfgraph]").forEach((lab) => {
lab.addEventListener("click", (e) => {
- if (e.ctrlKey || e.metaKey) return; // Ctrl/Cmd-click is reserved for prop reset
+ if (e.ctrlKey || e.metaKey || e.shiftKey) return; // modifiers reserved for prop reset
e.preventDefault();
toggleKfGraph(lab.dataset.kfgraph);
});
diff --git a/index.html b/index.html
index 15547d2..c43e8b4 100644
--- a/index.html
+++ b/index.html
@@ -286,6 +286,8 @@
Keyboard shortcuts
Ctrl+A / Esc
Select all / deselect
←→
Step 1 frame (⇧ = 1 second)
Ctrl+← / Ctrl+→
Go to previous / next keyframe (inspector follows)
+
Ctrl+click inspector label
Reset property + clear all its keyframes
+
⇧+click inspector label
Reset value at playhead, or remove that keyframe
[ / ]
Trim selected in / out to playhead
Home / End
Jump to start / end
From 91f771f12a9a5e83df8fccdad0370ec96f038cbc Mon Sep 17 00:00:00 2001
From: Tomasz Plonka <4361591+PlkMarudny@users.noreply.github.com>
Date: Thu, 10 Sep 2026 15:28:50 +0300
Subject: [PATCH 3/7] feat: route program monitor drags through setAnimProp()
---
README.md | 3 ++-
app.js | 34 +++++++++++++++++++---------------
2 files changed, 21 insertions(+), 16 deletions(-)
diff --git a/README.md b/README.md
index 512ac48..5914582 100644
--- a/README.md
+++ b/README.md
@@ -138,7 +138,8 @@ same time.
the previous / next keyframe (selected clips first, else clips under the
playhead). Inspector fields show the interpolated value at the playhead;
changing one updates the keyframe you’re on, or inserts one if that channel
- is already keyed. The ◆ button adds a keyframe at the playhead, or removes
+ is already keyed. Dragging a clip in the program monitor (move / scale /
+ rotate) writes the same way. The ◆ button adds a keyframe at the playhead, or removes
the one you’re parked on; ✕ clears the whole channel
- **Keyframe graphs** — toggle a property’s curve in the inspector to show an
interpolated value graph beside the program monitor; click the graph to seek
diff --git a/app.js b/app.js
index c58453f..7ec58d9 100644
--- a/app.js
+++ b/app.js
@@ -5384,7 +5384,8 @@ els.preview.addEventListener("pointerdown", (e) => {
const b = clipBounds(cur, evalProps(cur, state.time), W, H), lp = toLocal(pt, b);
const hd = overlayHandles(b, W, H), grab = hd.hs * 1.8;
if (Math.hypot(pt.x - hd.rotate.x, pt.y - hd.rotate.y) <= grab) {
- canvasDrag = { mode: "rotate", id: cur.id, startRot: +cur.props.rotation || 0, startAng: Math.atan2(pt.y - b.cy, pt.x - b.cx) };
+ const ep = propsAtPlayhead(cur);
+ canvasDrag = { mode: "rotate", id: cur.id, startRot: +ep.rotation || 0, startAng: Math.atan2(pt.y - b.cy, pt.x - b.cx), cx: b.cx, cy: b.cy };
} else if (hd.corners.some((h) => Math.abs(pt.x - h.x) <= grab && Math.abs(pt.y - h.y) <= grab)) {
if (cur.kind === "text") {
ensureTextBox(cur);
@@ -5402,17 +5403,19 @@ els.preview.addEventListener("pointerdown", (e) => {
aspect: Math.max(0.05, (b2.hw * 2) / Math.max(1e-6, b2.hh * 2)),
};
} else {
- canvasDrag = { mode: "scale", id: cur.id, startScale: +cur.props.scale || 1, startDist: Math.hypot(lp.x, lp.y) || 1 };
+ canvasDrag = { mode: "scale", id: cur.id, startScale: +(propsAtPlayhead(cur).scale) || 1, startDist: Math.hypot(lp.x, lp.y) || 1 };
}
} else if (Math.abs(lp.x) <= b.hw && Math.abs(lp.y) <= b.hh) {
- canvasDrag = { mode: "move", id: cur.id, startX: +cur.props.x || 0, startY: +cur.props.y || 0, startPt: pt };
+ const ep = propsAtPlayhead(cur);
+ canvasDrag = { mode: "move", id: cur.id, startX: +ep.x || 0, startY: +ep.y || 0, startPt: pt };
}
}
if (!canvasDrag) {
const hit = pickClipAt(pt, W, H);
if (!hit) return;
if (hit.id !== state.selId) { selectClip(hit.id); renderInspector(); }
- canvasDrag = { mode: "move", id: hit.id, startX: +hit.props.x || 0, startY: +hit.props.y || 0, startPt: pt };
+ const ep = propsAtPlayhead(hit);
+ canvasDrag = { mode: "move", id: hit.id, startX: +ep.x || 0, startY: +ep.y || 0, startPt: pt };
}
canvasDidMove = false;
if (canvasDrag.mode === "move") els.preview.style.cursor = "move";
@@ -5456,8 +5459,8 @@ els.preview.addEventListener("pointermove", (e) => {
const W = els.preview.width, H = els.preview.height, pt = canvasPt(e);
if (!canvasDidMove) { pushUndo(); canvasDidMove = true; } // one undo per drag, only if it actually moves
if (canvasDrag.mode === "move") {
- c.props.x = Math.round(canvasDrag.startX + (pt.x - canvasDrag.startPt.x));
- c.props.y = Math.round(canvasDrag.startY + (pt.y - canvasDrag.startPt.y));
+ setAnimProp(c, "x", Math.round(canvasDrag.startX + (pt.x - canvasDrag.startPt.x)));
+ setAnimProp(c, "y", Math.round(canvasDrag.startY + (pt.y - canvasDrag.startPt.y)));
} else if (canvasDrag.mode === "box") {
const aspect = canvasDrag.aspect || 1;
const lockAR = e.shiftKey;
@@ -5501,19 +5504,19 @@ els.preview.addEventListener("pointermove", (e) => {
const c2 = Math.cos(rot), s2 = Math.sin(rot);
const freeX = fix.x + ldx * c2 - ldy * s2;
const freeY = fix.y + ldx * s2 + ldy * c2;
- c.props.x = Math.round((fix.x + freeX) / 2 - W / 2);
- c.props.y = Math.round((fix.y + freeY) / 2 - H / 2);
+ setAnimProp(c, "x", Math.round((fix.x + freeX) / 2 - W / 2));
+ setAnimProp(c, "y", Math.round((fix.y + freeY) / 2 - H / 2));
c.props.boxW = +Math.abs(ldx).toFixed(1);
c.props.boxH = +Math.abs(ldy).toFixed(1);
}
} else if (canvasDrag.mode === "scale") {
const b = clipBounds(c, evalProps(c, state.time), W, H), lp = toLocal(pt, b);
- c.props.scale = clamp(+(canvasDrag.startScale * (Math.hypot(lp.x, lp.y) / canvasDrag.startDist)).toFixed(3), 0.05, 12);
+ setAnimProp(c, "scale", clamp(+(canvasDrag.startScale * (Math.hypot(lp.x, lp.y) / canvasDrag.startDist)).toFixed(3), 0.05, 12));
} else {
- const cx = W / 2 + (+c.props.x || 0), cy = H / 2 + (+c.props.y || 0);
+ const cx = canvasDrag.cx, cy = canvasDrag.cy;
let deg = canvasDrag.startRot + (Math.atan2(pt.y - cy, pt.x - cx) - canvasDrag.startAng) * 180 / Math.PI;
if (e.shiftKey) deg = Math.round(deg / 15) * 15;
- c.props.rotation = Math.round(deg);
+ setAnimProp(c, "rotation", Math.round(deg));
}
});
function endCanvasDrag(e) {
@@ -5797,11 +5800,12 @@ function measureTextHalfSize(p) {
/* First corner-drag on a hug-content title: create a box from current bounds. */
function ensureTextBox(c) {
if (c.kind !== "text" || hasTextBox(c.props)) return;
- const half = measureTextHalfSize(c.props);
- const sc = +c.props.scale || 1;
+ const p = propsAtPlayhead(c);
+ const half = measureTextHalfSize(p);
+ const sc = +p.scale || 1;
if (Math.abs(sc - 1) > 0.01) {
- c.props.fontSize = Math.round((+c.props.fontSize || 72) * sc);
- c.props.scale = 1;
+ setAnimProp(c, "fontSize", Math.round((+p.fontSize || 72) * sc));
+ setAnimProp(c, "scale", 1);
}
c.props.boxW = Math.max(40, +(half.hw * 2).toFixed(1));
c.props.boxH = Math.max(24, +(half.hh * 2).toFixed(1));
From 62fa68f66c4f3752bc268a34499e07a81ea82a44 Mon Sep 17 00:00:00 2001
From: Tomasz Plonka <4361591+PlkMarudny@users.noreply.github.com>
Date: Thu, 10 Sep 2026 15:28:50 +0300
Subject: [PATCH 4/7] fix: fix 7 code review issues
---
.gitignore | 1 +
AGENTS.md | 2 +-
README.md | 5 +-
app.js | 176 +++++++++---
live.json | 8 -
style.css | 9 +-
test/keyframes.test.js | 629 +++++++++++++++++++++++++++++++++++++++++
7 files changed, 780 insertions(+), 50 deletions(-)
delete mode 100644 live.json
create mode 100644 test/keyframes.test.js
diff --git a/.gitignore b/.gitignore
index 8306e2b..6a42f5a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,5 +1,6 @@
# Runtime user data — recreated automatically
project.json
+live.json
media/*
!media/.gitkeep
exports/*
diff --git a/AGENTS.md b/AGENTS.md
index 8b2f8b3..2c3c8fc 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -8,7 +8,7 @@ The complete agent manual is in [CLAUDE.md](CLAUDE.md). Read it before changing
- Keep preview and export on the same compositor path.
- Prefer small, focused changes and preserve the existing terse browser-native style.
- If a schema, prop, text animation, API, or MCP surface changes, update `CLAUDE.md` and the English `README.md` in the same change.
-- Run `node --check server.js && node --check app.js && node --check mcp-server.js` before opening a PR.
+- Run `node --check server.js && node --check app.js && node --check mcp-server.js && node --test` before opening a PR.
## MCP entry point
diff --git a/README.md b/README.md
index 5914582..efd9ac4 100644
--- a/README.md
+++ b/README.md
@@ -140,7 +140,10 @@ same time.
changing one updates the keyframe you’re on, or inserts one if that channel
is already keyed. Dragging a clip in the program monitor (move / scale /
rotate) writes the same way. The ◆ button adds a keyframe at the playhead, or removes
- the one you’re parked on; ✕ clears the whole channel
+ the one you’re parked on; ✕ clears the whole channel. When the playhead is
+ outside the selected clip, keyframed fields and ◆ buttons are disabled (they
+ show the nearest edge value) — keyframe edits only apply where the playhead
+ actually is; unanimated properties stay editable anywhere
- **Keyframe graphs** — toggle a property’s curve in the inspector to show an
interpolated value graph beside the program monitor; click the graph to seek
- **Speed ramps** — keyframe `speed` and the engine time-remaps video *and* the
diff --git a/app.js b/app.js
index 7ec58d9..48da808 100644
--- a/app.js
+++ b/app.js
@@ -580,6 +580,12 @@ function kfChannel(c, key, local, fallback) {
return fallback;
}
const kfTimeEps = () => 0.5 / projectFps();
+/* Is the playhead over the clip (± half a frame)? Keyframe edits are only
+ meaningful then — off-clip writes would land clamped on the clip's edge. */
+function playheadOverClip(c) {
+ const eps = kfTimeEps();
+ return state.time >= c.start - eps && state.time <= c.start + c.duration + eps;
+}
/* Keyframe on this channel whose absolute time matches the playhead. */
function kfAtPlayhead(c, k) {
const arr = c.keyframes?.[k];
@@ -614,24 +620,47 @@ function fmtInspNum(v, step) {
if (Math.abs(n - Math.round(n)) < 1e-6) return String(Math.round(n));
return String(+n.toFixed(3));
}
+/* Inspector playhead-sync cache: the rAF loop re-syncs inspector fields only
+ when the playhead, selection, or keyed values changed since the last sync.
+ Mutators that don't re-render the inspector bump inspPropGen. */
+let inspSyncStamp = "";
+let inspPropGen = 0;
+const inspStampNow = () => state.time + "|" + state.selId + "|" + inspPropGen;
+/* Audio hold loops one frame of audio built from volume / pan / the speed
+ remap — a write to any of them must re-cut it. The mutators own this (like
+ dirtyTimeline); scheduleAudioHoldRefresh itself no-ops unless holding. */
+function refreshAudioHoldFor(k) {
+ if (k === "volume" || k === "pan" || k === "speed") scheduleAudioHoldRefresh();
+}
/* Write an animatable prop: static if the channel has no keyframes; otherwise
- update the keyframe under the playhead or insert one (auto-key). */
+ update the keyframe under the playhead or insert one (auto-key). Returns
+ false when the write was refused — a keyed channel with the playhead off
+ the clip would corrupt the edge keyframe, so it must not be written.
+ dirtyTimeline ownership: the keyframe mutators (setAnimProp /
+ toggleKfAtPlayhead / resetProp*) set it themselves when a keyframe appears
+ or disappears (clip markers move); value-only writes skip it — the graphs
+ redraw every rAF anyway. Callers never set it for these. The same goes for
+ refreshAudioHoldFor() on volume/pan/speed writes. */
function setAnimProp(c, k, v) {
- if (!c || !ANIMATABLE.includes(k) || typeof v !== "number" || isNaN(v)) return;
+ if (!c || !ANIMATABLE.includes(k) || typeof v !== "number" || isNaN(v)) return false;
const arr = c.keyframes?.[k];
+ if (Array.isArray(arr) && arr.length && !playheadOverClip(c)) return false;
+ inspPropGen++;
+ refreshAudioHoldFor(k);
if (!Array.isArray(arr) || !arr.length) {
c.props[k] = v;
- return;
+ return true;
}
const near = kfAtPlayhead(c, k);
- if (near) { near.v = v; return; }
+ if (near) { near.v = v; return true; }
const lt = +clamp(state.time - c.start, 0, c.duration).toFixed(3);
const eps = kfTimeEps();
const dup = arr.find((kf) => Math.abs(kf.t - lt) < eps);
- if (dup) { dup.v = v; return; }
+ if (dup) { dup.v = v; return true; }
arr.push({ t: lt, v });
arr.sort((a, b) => a.t - b.t);
state.dirtyTimeline = true;
+ return true;
}
/* Wipe a property: factory default + delete that channel's keyframes. */
function resetPropChannel(c, k) {
@@ -643,6 +672,7 @@ function resetPropChannel(c, k) {
}
if (!Object.hasOwn(DEFAULT_PROPS, k)) return;
c.props[k] = DEFAULT_PROPS[k];
+ refreshAudioHoldFor(k);
if (c.keyframes?.[k]) {
delete c.keyframes[k];
if (!Object.keys(c.keyframes).length) c.keyframes = undefined;
@@ -652,30 +682,33 @@ function resetPropChannel(c, k) {
if (k === "font") ensureFont(String(DEFAULT_PROPS.font));
}
/* Playhead-local reset: remove the keyframe under the playhead, else set the
- value at the playhead to the property default (auto-keys if already keyed). */
+ value at the playhead to the property default (auto-keys if already keyed).
+ Returns false when refused (keyed channel, playhead off the clip). */
function resetPropAtPlayhead(c, k) {
- if (!c || !k) return;
+ if (!c || !k) return false;
if (k === "transIn" || k === "transOut") {
resetPropChannel(c, k);
- return;
- }
- if (!Object.hasOwn(DEFAULT_PROPS, k)) return;
- if (ANIMATABLE.includes(k) && kfAtPlayhead(c, k)) {
- toggleKfAtPlayhead(c, k);
- state.dirtyTimeline = true;
- return;
+ return true;
}
+ if (!Object.hasOwn(DEFAULT_PROPS, k)) return false;
+ if (ANIMATABLE.includes(k) && kfAtPlayhead(c, k)) return toggleKfAtPlayhead(c, k);
const def = DEFAULT_PROPS[k];
- if (ANIMATABLE.includes(k) && c.keyframes?.[k]?.length) setAnimProp(c, k, def);
- else c.props[k] = def;
+ if (ANIMATABLE.includes(k) && c.keyframes?.[k]?.length) {
+ if (!setAnimProp(c, k, def)) return false;
+ } else { c.props[k] = def; refreshAudioHoldFor(k); }
if (k === "text" || k === "font") state.dirtyTimeline = true;
if (k === "font") ensureFont(String(def));
+ return true;
}
-/* ◆ : add a keyframe at the playhead, or remove the one already there. */
+/* ◆ : add a keyframe at the playhead, or remove the one already there.
+ Refused (false) when the playhead is off the clip — there is no "at the
+ playhead" then, and clamping would plant a keyframe on the clip's edge. */
function toggleKfAtPlayhead(c, k) {
- if (!c || !ANIMATABLE.includes(k)) return;
+ if (!c || !ANIMATABLE.includes(k) || !playheadOverClip(c)) return false;
const near = kfAtPlayhead(c, k);
if (near) {
+ inspPropGen++;
+ refreshAudioHoldFor(k);
const rest = c.keyframes[k].filter((kf) => kf !== near);
if (rest.length) c.keyframes[k] = rest;
else {
@@ -683,17 +716,25 @@ function toggleKfAtPlayhead(c, k) {
delete c.keyframes[k];
if (!Object.keys(c.keyframes).length) c.keyframes = undefined;
}
- return;
+ state.dirtyTimeline = true; // a diamond left the clip — mutators own this flag
+ return true;
}
const fallback = +(c.props?.[k] ?? DEFAULT_PROPS[k] ?? 0);
const v = kfChannel(c, k, state.time - c.start, fallback);
- if (typeof v !== "number" || isNaN(v)) return;
+ if (typeof v !== "number" || isNaN(v)) return false;
+ inspPropGen++;
+ refreshAudioHoldFor(k);
if (!c.keyframes) c.keyframes = {};
const arr = (c.keyframes[k] = c.keyframes[k] || []);
const lt = +clamp(state.time - c.start, 0, c.duration).toFixed(3);
const dup = arr.find((kf) => Math.abs(kf.t - lt) < kfTimeEps());
- if (dup) dup.v = v;
- else { arr.push({ t: lt, v }); arr.sort((a, b) => a.t - b.t); }
+ if (dup) dup.v = v; // value-only: no marker moves, no timeline rebuild
+ else {
+ arr.push({ t: lt, v });
+ arr.sort((a, b) => a.t - b.t);
+ state.dirtyTimeline = true;
+ }
+ return true;
}
function hasSpeedRamp(c) {
return Array.isArray(c.keyframes?.speed) && c.keyframes.speed.length > 0;
@@ -3211,7 +3252,6 @@ function setTime(t) {
state.time = clamp(t, 0, Math.max(projDur(), 0));
seekMediaWhilePaused();
if (state.audioHold) scheduleAudioHoldRefresh();
- syncInspectorPlayhead();
}
/* Absolute timeline times of every keyframe on the given clips (deduped). */
@@ -3688,6 +3728,7 @@ function renderInspector(lite) {
`;
}
els.inspector.innerHTML = html;
+ inspSyncStamp = inspStampNow(); // full rebuild already reflects this state
els.inspector.querySelectorAll("label.insp-reset[data-reset]").forEach((lab) => {
lab.addEventListener("click", (e) => {
const all = e.ctrlKey || e.metaKey;
@@ -3697,9 +3738,12 @@ function renderInspector(lite) {
const keys = lab.dataset.reset.split(",").map((s) => s.trim()).filter(Boolean);
if (!keys.length) return;
pushUndo();
- for (const k of keys) (all ? resetPropChannel : resetPropAtPlayhead)(c, k);
- if (state.audioHold && keys.some((k) => k === "volume" || k === "pan"))
- scheduleAudioHoldRefresh();
+ let refused = false;
+ for (const k of keys) {
+ if (all) resetPropChannel(c, k); // channel-wide: playhead-independent
+ else refused = !resetPropAtPlayhead(c, k) || refused;
+ }
+ if (refused) toast("Move the playhead over the clip to edit its keyframes");
scheduleSave();
renderInspector();
});
@@ -3713,8 +3757,8 @@ function renderInspector(lite) {
if (k === "weight") v = +v || 0;
if (k === "font") ensureFont(String(v));
if (k === "name") { c.name = String(v); state.dirtyTimeline = true; }
- else if (k === "start") { c.start = Math.max(0, +v || 0); state.dirtyTimeline = true; }
- else if (k === "duration") { c.duration = Math.max(MIN_DUR, +v || MIN_DUR); state.dirtyTimeline = true; }
+ else if (k === "start") { c.start = Math.max(0, +v || 0); state.dirtyTimeline = true; inspPropGen++; }
+ else if (k === "duration") { c.duration = Math.max(MIN_DUR, +v || MIN_DUR); state.dirtyTimeline = true; inspPropGen++; }
else if (k === "transIn" || k === "transOut") {
const key = k === "transIn" ? "transitionIn" : "transitionOut";
const side = k === "transIn" ? "in" : "out";
@@ -3732,11 +3776,13 @@ function renderInspector(lite) {
state.dirtyTimeline = true;
}
}
- else if (ANIMATABLE.includes(k)) setAnimProp(c, k, v);
+ else if (ANIMATABLE.includes(k)) {
+ if (!setAnimProp(c, k, v))
+ toast("Move the playhead over the clip to edit its keyframes");
+ }
else { c.props[k] = v; if (k === "text") state.dirtyTimeline = true; }
const valEl = els.inspector.querySelector(`[data-val="${k}"]`);
if (valEl) valEl.textContent = input.value + (valEl.dataset.unit || "");
- if (state.audioHold && (k === "volume" || k === "pan")) scheduleAudioHoldRefresh();
scheduleSave();
if (ANIMATABLE.includes(k)) syncInspectorPlayhead();
});
@@ -3783,15 +3829,16 @@ function renderInspector(lite) {
els.inspector.querySelectorAll("[data-kf]").forEach((btn) => {
btn.addEventListener("click", () => {
pushUndo();
- toggleKfAtPlayhead(c, btn.dataset.kf);
- state.dirtyTimeline = true;
+ if (!toggleKfAtPlayhead(c, btn.dataset.kf)) // owns dirtyTimeline on success
+ toast("Move the playhead over the clip to add or remove keyframes");
scheduleSave(); renderInspector();
});
});
els.inspector.querySelectorAll("[data-kfclear]").forEach((btn) => {
btn.addEventListener("click", () => {
pushUndo();
- delete c.keyframes[btn.dataset.kfclear];
+ delete c.keyframes[btn.dataset.kfclear]; // raw mutation — owns its own side effects
+ refreshAudioHoldFor(btn.dataset.kfclear);
if (!Object.keys(c.keyframes).length) c.keyframes = undefined;
state.dirtyTimeline = true;
scheduleSave(); renderInspector();
@@ -3809,15 +3856,39 @@ function renderInspector(lite) {
toggleKfGraph(lab.dataset.kfgraph);
});
});
+ syncInspectorOffClip(c);
renderKfGraphsPanel();
}
-/* Patch inspector fields to the playhead (no innerHTML rebuild — keeps focus). */
+/* Off the clip, keyframed fields show their edge value but must not be
+ editable — a write would land clamped on the clip's edge. Disables those
+ inputs and the ◆ buttons. Called on every inspector sync and after each
+ full renderInspector rebuild. */
+function syncInspectorOffClip(c) {
+ const off = !playheadOverClip(c);
+ const active = document.activeElement;
+ for (const input of els.inspector.querySelectorAll("[data-k]")) {
+ const k = input.dataset.k;
+ if (!ANIMATABLE.includes(k) || input === active) continue; // don't yank focus mid-edit
+ input.disabled = off && !!(c.keyframes?.[k]?.length);
+ }
+ for (const btn of els.inspector.querySelectorAll("[data-kf]")) {
+ btn.disabled = off;
+ btn.title = off ? "Move the playhead over the clip to add or remove keyframes"
+ : btn.classList.contains("on") ? "Remove keyframe at playhead" : "Set keyframe at playhead";
+ }
+}
+/* Patch inspector fields to the playhead (no innerHTML rebuild — keeps focus).
+ Runs every rAF tick but exits early unless time, selection, or keyed values
+ actually changed since the last sync. */
function syncInspectorPlayhead() {
const root = els && els.inspector;
if (!root) return;
const c = getClip(state.selId);
if (!c) return;
+ const stamp = inspStampNow();
+ if (stamp === inspSyncStamp) return;
+ inspSyncStamp = stamp;
const p = propsAtPlayhead(c);
const active = document.activeElement;
for (const input of root.querySelectorAll("[data-k]")) {
@@ -3827,7 +3898,14 @@ function syncInspectorPlayhead() {
const v = p[k];
if (typeof v !== "number" || isNaN(v)) continue;
const next = fmtInspNum(v, input.type === "range" ? input.step : undefined);
- if (Math.abs(+input.value - +next) > 1e-6) input.value = next;
+ if (input.type === "range") {
+ // Thumb saturates at the slider's ends; the label keeps the true value,
+ // so an out-of-range keyframe doesn't churn the input every frame.
+ const mn = +input.min, mx = +input.max;
+ const shown = Number.isFinite(mn) && +next < mn ? input.min
+ : Number.isFinite(mx) && +next > mx ? input.max : next;
+ if (Math.abs(+input.value - +shown) > 1e-6) input.value = shown;
+ } else if (Math.abs(+input.value - +next) > 1e-6) input.value = next;
const valEl = root.querySelector(`[data-val="${k}"]`);
if (valEl) {
const text = next + (valEl.dataset.unit || "");
@@ -3842,9 +3920,8 @@ function syncInspectorPlayhead() {
btn.classList.toggle("on", on);
const label = "◆" + (n || "");
if (btn.textContent !== label) btn.textContent = label;
- const title = on ? "Remove keyframe at playhead" : "Set keyframe at playhead";
- if (btn.title !== title) btn.title = title;
}
+ syncInspectorOffClip(c); // after the class pass, so ◆ titles read the fresh "on" state
}
/* ── Keyframe graphs (program-monitor left gutter) ── */
@@ -4974,6 +5051,22 @@ function applyTransition(p, type, k, W, H, dir) {
break;
}
}
+/* Translation the in/out transition envelopes add on top of the keyframed
+ props at timeline time t — probed through applyTransition itself, so it can
+ never disagree with the compositor. Canvas box drags write resting
+ (envelope-free) geometry, so displayed-space results must have this
+ subtracted back out. */
+function transOffsetAt(c, t) {
+ const p = { x: 0, y: 0, scale: 1, opacity: 1, volume: 1, rotation: 0, blur: 0, rgbSplit: 0 };
+ const local = t - c.start, W = els.preview.width, H = els.preview.height;
+ const tin = c.transitionIn, tout = c.transitionOut;
+ if (tin && tin.duration > 0 && local < tin.duration)
+ applyTransition(p, tin.type, 1 - EASE["ease-out"](clamp(local / tin.duration, 0, 1)), W, H, -1);
+ if (tout && tout.duration > 0 && local > c.duration - tout.duration)
+ applyTransition(p, tout.type,
+ EASE["ease-in"](clamp((local - (c.duration - tout.duration)) / tout.duration, 0, 1)), W, H, 1);
+ return { x: +p.x || 0, y: +p.y || 0 };
+}
/* Rebase clip-local keyframe times by -offset, dropping ones outside [0, dur] */
function shiftKF(kfs, offset, dur) {
if (!kfs) return undefined;
@@ -5464,6 +5557,11 @@ els.preview.addEventListener("pointermove", (e) => {
} else if (canvasDrag.mode === "box") {
const aspect = canvasDrag.aspect || 1;
const lockAR = e.shiftKey;
+ // The displayed box is the resting box plus the transition envelope; the
+ // writes below target the resting geometry, so strip the envelope's
+ // translation back out (boxed text ignores scale, and the center midpoint
+ // is rotation-invariant — translation is the only component that leaks).
+ const env = transOffsetAt(c, state.time);
if (e.ctrlKey || e.metaKey) {
// Ctrl/Cmd: resize from center (all corners move).
const b = clipBounds(c, evalProps(c, state.time), W, H), lp = toLocal(pt, b);
@@ -5504,8 +5602,8 @@ els.preview.addEventListener("pointermove", (e) => {
const c2 = Math.cos(rot), s2 = Math.sin(rot);
const freeX = fix.x + ldx * c2 - ldy * s2;
const freeY = fix.y + ldx * s2 + ldy * c2;
- setAnimProp(c, "x", Math.round((fix.x + freeX) / 2 - W / 2));
- setAnimProp(c, "y", Math.round((fix.y + freeY) / 2 - H / 2));
+ setAnimProp(c, "x", Math.round((fix.x + freeX) / 2 - W / 2 - env.x));
+ setAnimProp(c, "y", Math.round((fix.y + freeY) / 2 - H / 2 - env.y));
c.props.boxW = +Math.abs(ldx).toFixed(1);
c.props.boxH = +Math.abs(ldy).toFixed(1);
}
diff --git a/live.json b/live.json
deleted file mode 100644
index d09f918..0000000
--- a/live.json
+++ /dev/null
@@ -1,8 +0,0 @@
-{
- "media": {
- "m_hni37sx": {
- "duration": 88190.6831015625,
- "liveOrigin": "2026-09-08T12:01:55.444Z"
- }
- }
-}
\ No newline at end of file
diff --git a/style.css b/style.css
index 85851e9..bc33913 100644
--- a/style.css
+++ b/style.css
@@ -1323,7 +1323,7 @@ input[type=range] {
line-height: 1;
}
-.kf-btn:hover {
+.kf-btn:hover:not(:disabled) {
color: #fff;
border-color: #4a4a56;
}
@@ -1339,6 +1339,13 @@ input[type=range] {
border-color: #ffd166;
}
+/* Playhead off the clip: keyframed fields are read-only (edge value shown) */
+.inspector input:disabled,
+.inspector .kf-btn:disabled {
+ opacity: .4;
+ cursor: not-allowed;
+}
+
/* ── Timeline ── */
.timeline-toolbar {
display: flex;
diff --git a/test/keyframes.test.js b/test/keyframes.test.js
new file mode 100644
index 0000000..430adce
--- /dev/null
+++ b/test/keyframes.test.js
@@ -0,0 +1,629 @@
+/* Unit tests for the keyframe-editing core in app.js: channel evaluation,
+ playhead-local writes (setAnimProp / toggleKfAtPlayhead / resets), the
+ inspector playhead sync (stamp gate, slider saturation, off-clip lock-out)
+ and the transition-envelope probe the canvas box drag uses to invert the
+ compositor's envelope (transOffsetAt vs evalProps).
+
+ app.js is a browser script with no exports, so — like meter-worklet.test.js —
+ we run the relevant sections in Node. The pure-logic block
+ (kfChannel … toggleKfAtPlayhead) and the inspector-sync block
+ (syncInspectorOffClip / syncInspectorPlayhead) are sliced out of the source
+ by function-name markers and evaluated with stubs; the constants they close
+ over (ANIMATABLE, DEFAULT_PROPS, EASE) are lifted verbatim from the same
+ file, so the tests can never drift away from the real tables. */
+"use strict";
+const assert = require("node:assert/strict");
+const test = require("node:test");
+const path = require("node:path");
+const fs = require("node:fs");
+const { ROOT } = require("./helpers");
+
+const SRC = fs.readFileSync(path.join(ROOT, "app.js"), "utf8");
+
+/* Slice the source between two unique markers, failing loudly when a rename
+ breaks the harness (a silent empty slice would pass vacuously). */
+function slice(startMarker, endMarker) {
+ const a = SRC.indexOf(startMarker);
+ const b = SRC.indexOf(endMarker, a);
+ assert.ok(a >= 0, `start marker not found: ${startMarker}`);
+ assert.ok(b > a, `end marker not found after it: ${endMarker}`);
+ return SRC.slice(a, b);
+}
+
+/* Evaluate an object/array literal lifted from the source. */
+const lift = (re) => {
+ const m = re.exec(SRC);
+ assert.ok(m, `literal not found: ${re}`);
+ return new Function(`return (${m[1]});`)();
+};
+
+const DEFAULT_PROPS = lift(/const DEFAULT_PROPS = (\{[\s\S]*?\n\});/);
+const ANIMATABLE = lift(/const ANIMATABLE = (\[[\s\S]*?\]);/);
+const EASE = lift(/const EASE = (\{[\s\S]*?\n\});/);
+
+const LOGIC = slice("function kfChannel(", "function hasSpeedRamp(");
+const SYNC = slice("function syncInspectorOffClip(", "/* ── Keyframe graphs");
+
+/* Build the sandbox. Each test gets a fresh one: `state` and the inspector
+ stamp/gen counters live in the closure and must not leak between tests. */
+function makeSandbox({ fps = 50, clips = [] } = {}) {
+ const state = { time: 0, dirtyTimeline: false, selId: null };
+ const sandbox = { els: { inspector: null }, document: { activeElement: null }, holdRefreshes: 0 };
+ const bindings = new Function(
+ "ANIMATABLE", "DEFAULT_PROPS", "EASE", "clamp", "state", "els", "document",
+ "getClip", "projectFps", "ensureFont", "scheduleAudioHoldRefresh",
+ `${LOGIC}\n${SYNC}\nreturn {
+ kfChannel, kfTimeEps, playheadOverClip, kfAtPlayhead, propsAtPlayhead,
+ fmtInspNum, setAnimProp, resetPropChannel, resetPropAtPlayhead,
+ toggleKfAtPlayhead, syncInspectorOffClip, syncInspectorPlayhead,
+ inspStampNow, gen: () => inspPropGen, stamp: () => inspSyncStamp,
+ };`
+ )(
+ ANIMATABLE, DEFAULT_PROPS, EASE,
+ (v, a, b) => Math.min(b, Math.max(a, v)), // clamp, as in app.js
+ state, sandbox.els, sandbox.document,
+ (id) => clips.find((c) => c.id === id) || null,
+ () => fps, () => {},
+ () => { sandbox.holdRefreshes++; }, // the real one no-ops unless holding
+ );
+ return {
+ state, els: sandbox.els, document: sandbox.document,
+ get holdRefreshes() { return sandbox.holdRefreshes; }, // live — the stub bumps it after construction
+ ...bindings,
+ };
+}
+
+/* ── Minimal fake DOM for the inspector sync ── */
+class FakeEl {
+ constructor(attrs = {}) {
+ this.dataset = attrs.dataset || {};
+ this.type = attrs.type || "number";
+ this.step = attrs.step;
+ this.min = attrs.min;
+ this.max = attrs.max;
+ this.disabled = false;
+ this.title = "";
+ this.textContent = "";
+ this.classes = new Set(attrs.classes || []);
+ this.classList = {
+ toggle: (cls, on) => (on ? this.classes.add(cls) : this.classes.delete(cls)),
+ contains: (cls) => this.classes.has(cls),
+ };
+ this.writes = 0;
+ this._value = String(attrs.value ?? "0");
+ }
+ get value() { return this._value; }
+ set value(v) { this.writes++; this._value = String(v); }
+}
+function fakeInspector({ inputs = [], buttons = [], vals = {} }) {
+ return {
+ querySelectorAll(sel) {
+ if (sel === "[data-k]") return inputs;
+ if (sel === "[data-kf]") return buttons;
+ return [];
+ },
+ querySelector(sel) {
+ const m = /^\[data-val="(.+)"\]$/.exec(sel);
+ return m ? vals[m[1]] || null : null;
+ },
+ };
+}
+const kfBtn = (k) => new FakeEl({ dataset: { kf: k } });
+const scaleRow = (value = "1") => ({
+ input: new FakeEl({ dataset: { k: "scale" }, type: "range", min: "0.1", max: "4", step: "0.01", value }),
+ val: new FakeEl({ dataset: { unit: "" } }),
+});
+
+/* A clip on [10, 15] with a linear scale ramp 1 → 3 over local t 2 → 4. */
+function keyedClip(over = {}) {
+ return {
+ id: "c1", start: 10, duration: 5,
+ props: { ...DEFAULT_PROPS, scale: 0.8 },
+ keyframes: { scale: [{ t: 2, v: 1 }, { t: 4, v: 3, ease: "linear" }] },
+ ...over,
+ };
+}
+
+/* ── kfChannel ── */
+test("kfChannel: fallback, edge holds, interpolation, easing", () => {
+ const { kfChannel } = makeSandbox();
+ const c = keyedClip();
+ assert.equal(kfChannel(c, "rotation", 2, 0.5), 0.5); // no channel → fallback
+ assert.equal(kfChannel(c, "scale", 0, 0), 1); // before first keyframe
+ assert.equal(kfChannel(c, "scale", 99, 0), 3); // after last keyframe
+ assert.equal(kfChannel(c, "scale", 3, 0), 2); // linear midpoint
+ c.keyframes.scale[1].ease = "ease-in";
+ assert.equal(kfChannel(c, "scale", 3, 0), 1 + 2 * 0.25); // u² at u=0.5
+ c.keyframes.scale[1].ease = "bogus";
+ assert.equal(kfChannel(c, "scale", 3, 0), 2); // unknown ease → linear
+});
+
+/* ── playheadOverClip / kfAtPlayhead ── */
+test("playheadOverClip: inside, edge tolerance, outside", () => {
+ const { state, playheadOverClip, kfTimeEps } = makeSandbox();
+ const c = keyedClip();
+ const eps = kfTimeEps();
+ state.time = 12;
+ assert.equal(playheadOverClip(c), true);
+ state.time = 10 - eps / 2;
+ assert.equal(playheadOverClip(c), true); // half a frame before start still counts
+ state.time = 15 + eps / 2;
+ assert.equal(playheadOverClip(c), true); // half a frame after end still counts
+ state.time = 10 - eps * 2;
+ assert.equal(playheadOverClip(c), false);
+ state.time = 16;
+ assert.equal(playheadOverClip(c), false);
+});
+
+test("kfAtPlayhead: matches absolute playhead time, never clamps off-clip", () => {
+ const { state, kfAtPlayhead, kfTimeEps } = makeSandbox();
+ const c = keyedClip();
+ state.time = 12 + kfTimeEps() / 2; // within eps of the t=2 keyframe (abs 12)
+ assert.equal(kfAtPlayhead(c, "scale"), c.keyframes.scale[0]);
+ state.time = 12.5;
+ assert.equal(kfAtPlayhead(c, "scale"), null);
+ state.time = 0; // off-clip: must NOT match the edge keyframe via clamping
+ assert.equal(kfAtPlayhead(c, "scale"), null);
+ assert.equal(kfAtPlayhead(c, "rotation"), null); // no channel
+});
+
+/* ── propsAtPlayhead ── */
+test("propsAtPlayhead: keyed channels interpolate, statics pass through", () => {
+ const { state, propsAtPlayhead } = makeSandbox();
+ const c = keyedClip();
+ state.time = 13; // local 3 → midpoint of 1→3
+ const p = propsAtPlayhead(c);
+ assert.equal(p.scale, 2);
+ assert.equal(p.rotation, 0); // unkeyed static untouched
+ assert.equal(p.opacity, 1); // default untouched
+ state.time = 0; // off-clip → nearest edge value, for display only
+ assert.equal(propsAtPlayhead(c).scale, 1);
+});
+
+/* ── fmtInspNum ── */
+test("fmtInspNum: integers, step rounding, fallback precision, junk", () => {
+ const { fmtInspNum } = makeSandbox();
+ assert.equal(fmtInspNum(1, "0.01"), "1");
+ assert.equal(fmtInspNum(1.234567, "0.01"), "1.23");
+ assert.equal(fmtInspNum(12, "1"), "12");
+ assert.equal(fmtInspNum(0.123456), "0.123"); // no step → 3 decimals
+ assert.equal(fmtInspNum(7.0000001), "7");
+ assert.equal(fmtInspNum(NaN), "0");
+});
+
+/* ── setAnimProp ── */
+test("setAnimProp: unkeyed channel writes the static prop", () => {
+ const { state, setAnimProp, gen } = makeSandbox();
+ const c = keyedClip();
+ state.time = 0; // off-clip — static writes are playhead-independent
+ const g = gen();
+ assert.equal(setAnimProp(c, "rotation", 45), true);
+ assert.equal(c.props.rotation, 45);
+ assert.ok(gen() > g, "a write bumps the generation");
+});
+
+test("setAnimProp: keyed channel updates the keyframe under the playhead", () => {
+ const { state, setAnimProp } = makeSandbox();
+ const c = keyedClip();
+ state.time = 12; // local 2 — on the first keyframe
+ assert.equal(setAnimProp(c, "scale", 2.5), true);
+ assert.deepEqual(c.keyframes.scale.map((k) => [k.t, k.v]), [[2, 2.5], [4, 3]]);
+});
+
+test("setAnimProp: keyed channel off a keyframe auto-keys, sorted", () => {
+ const { state, setAnimProp } = makeSandbox();
+ const c = keyedClip();
+ state.time = 13; // local 3 — between keyframes
+ assert.equal(setAnimProp(c, "scale", 9), true);
+ assert.deepEqual(c.keyframes.scale.map((k) => [k.t, k.v]), [[2, 1], [3, 9], [4, 3]]);
+ assert.equal(state.dirtyTimeline, true);
+});
+
+test("setAnimProp: off-clip keyed write is refused without touching anything", () => {
+ const { state, setAnimProp, gen } = makeSandbox();
+ const c = keyedClip();
+ state.time = 0;
+ const before = JSON.stringify(c.keyframes);
+ const g = gen();
+ assert.equal(setAnimProp(c, "scale", 9), false);
+ assert.equal(JSON.stringify(c.keyframes), before); // the old bug: edge keyframe rewritten to 9
+ assert.equal(c.props.scale, 0.8);
+ assert.equal(gen(), g, "a refusal must not bump the generation");
+});
+
+test("setAnimProp: invalid input refused", () => {
+ const { state, setAnimProp, gen } = makeSandbox();
+ const c = keyedClip();
+ state.time = 12;
+ const g = gen();
+ assert.equal(setAnimProp(c, "scale", NaN), false);
+ assert.equal(setAnimProp(c, "notAProp", 1), false);
+ assert.equal(setAnimProp(null, "scale", 1), false);
+ assert.equal(gen(), g);
+});
+
+/* ── toggleKfAtPlayhead ── */
+test("toggleKfAtPlayhead: adds a keyframe holding the playhead value", () => {
+ const { state, toggleKfAtPlayhead } = makeSandbox();
+ const c = keyedClip();
+ state.time = 13; // rotation unkeyed; current static is 0
+ assert.equal(toggleKfAtPlayhead(c, "rotation"), true);
+ assert.deepEqual(c.keyframes.rotation, [{ t: 3, v: 0 }]);
+});
+
+test("toggleKfAtPlayhead: removes the keyframe under the playhead", () => {
+ const { state, toggleKfAtPlayhead } = makeSandbox();
+ const c = keyedClip();
+ state.time = 12;
+ assert.equal(toggleKfAtPlayhead(c, "scale"), true);
+ assert.deepEqual(c.keyframes.scale, [{ t: 4, v: 3, ease: "linear" }]);
+});
+
+test("toggleKfAtPlayhead: removing the last keyframe promotes it to static", () => {
+ const { state, toggleKfAtPlayhead } = makeSandbox();
+ const c = keyedClip({ keyframes: { scale: [{ t: 2, v: 1.7 }] } });
+ state.time = 12;
+ assert.equal(toggleKfAtPlayhead(c, "scale"), true);
+ assert.equal(c.keyframes, undefined); // channel (and empty map) gone
+ assert.equal(c.props.scale, 1.7); // value survives as the static prop
+});
+
+test("toggleKfAtPlayhead: refused off-clip, no phantom edge keyframe", () => {
+ const { state, toggleKfAtPlayhead, gen } = makeSandbox();
+ const c = keyedClip({ keyframes: undefined });
+ state.time = 0;
+ const g = gen();
+ assert.equal(toggleKfAtPlayhead(c, "scale"), false);
+ assert.equal(c.keyframes, undefined); // the old bug: keyframe planted at t=0
+ assert.equal(gen(), g);
+});
+
+test("toggleKfAtPlayhead: eps-duplicate updates instead of inserting", () => {
+ const { state, toggleKfAtPlayhead, kfTimeEps } = makeSandbox();
+ const c = keyedClip();
+ state.time = 12 + kfTimeEps() / 2; // sits on the first keyframe → removes it
+ assert.equal(toggleKfAtPlayhead(c, "scale"), true);
+ assert.equal(c.keyframes.scale.length, 1);
+});
+
+/* ── resets ── */
+test("resetPropChannel: factory default + channel keyframes wiped", () => {
+ const { resetPropChannel } = makeSandbox();
+ const c = keyedClip();
+ c.props.scale = 2.2;
+ resetPropChannel(c, "scale");
+ assert.equal(c.props.scale, 1);
+ assert.equal(c.keyframes, undefined);
+});
+
+test("resetPropChannel: clears transitions", () => {
+ const { resetPropChannel } = makeSandbox();
+ const c = keyedClip({ transitionIn: { type: "fade", duration: 1 } });
+ resetPropChannel(c, "transIn");
+ assert.equal(c.transitionIn, undefined);
+});
+
+test("resetPropAtPlayhead: parked on a keyframe removes just that one", () => {
+ const { state, resetPropAtPlayhead } = makeSandbox();
+ const c = keyedClip();
+ state.time = 12;
+ assert.equal(resetPropAtPlayhead(c, "scale"), true);
+ assert.deepEqual(c.keyframes.scale, [{ t: 4, v: 3, ease: "linear" }]);
+});
+
+test("resetPropAtPlayhead: keyed off a keyframe sets default at the playhead", () => {
+ const { state, resetPropAtPlayhead } = makeSandbox();
+ const c = keyedClip();
+ state.time = 13; // local 3, between keyframes
+ assert.equal(resetPropAtPlayhead(c, "scale"), true);
+ assert.deepEqual(c.keyframes.scale.map((k) => [k.t, k.v]), [[2, 1], [3, 1], [4, 3]]);
+});
+
+test("resetPropAtPlayhead: unkeyed writes the static default even off-clip", () => {
+ const { state, resetPropAtPlayhead } = makeSandbox();
+ const c = keyedClip();
+ c.props.rotation = 77;
+ state.time = 0;
+ assert.equal(resetPropAtPlayhead(c, "rotation"), true);
+ assert.equal(c.props.rotation, 0);
+});
+
+test("resetPropAtPlayhead: keyed off-clip is refused", () => {
+ const { state, resetPropAtPlayhead } = makeSandbox();
+ const c = keyedClip();
+ state.time = 0;
+ const before = JSON.stringify(c.keyframes);
+ assert.equal(resetPropAtPlayhead(c, "scale"), false);
+ assert.equal(JSON.stringify(c.keyframes), before);
+});
+
+/* ── dirtyTimeline ownership ──
+ The keyframe mutators own state.dirtyTimeline: they set it when a keyframe
+ appears or disappears (clip markers must be rebuilt) and only then —
+ value-only writes skip it because the graphs redraw every rAF regardless.
+ Callers never set it on the mutators' behalf. */
+test("dirtyTimeline: setAnimProp dirties on insert, not on value-only writes", () => {
+ const { state, setAnimProp } = makeSandbox();
+ const c = keyedClip();
+ state.time = 12; // on the first keyframe → value update
+ assert.equal(setAnimProp(c, "scale", 2), true);
+ assert.equal(state.dirtyTimeline, false, "value update: no marker moved");
+ state.time = 0; // off-clip → static unkeyed write
+ assert.equal(setAnimProp(c, "rotation", 5), true);
+ assert.equal(state.dirtyTimeline, false, "static write: no keyframes involved");
+ state.time = 13; // between keyframes → insert
+ assert.equal(setAnimProp(c, "scale", 9), true);
+ assert.equal(state.dirtyTimeline, true, "insert: a marker appears");
+});
+
+test("dirtyTimeline: toggleKfAtPlayhead dirties on add and on remove", () => {
+ const { state, toggleKfAtPlayhead } = makeSandbox();
+ const c = keyedClip();
+ state.time = 13;
+ assert.equal(toggleKfAtPlayhead(c, "rotation"), true);
+ assert.equal(state.dirtyTimeline, true, "add: a marker appears");
+ state.dirtyTimeline = false;
+ state.time = 12; // parked on an existing scale keyframe
+ assert.equal(toggleKfAtPlayhead(c, "scale"), true);
+ assert.equal(state.dirtyTimeline, true, "remove: a marker disappears");
+});
+
+test("dirtyTimeline: refused mutations stay clean", () => {
+ const { state, toggleKfAtPlayhead, setAnimProp, resetPropAtPlayhead } = makeSandbox();
+ const c = keyedClip();
+ state.time = 0; // off-clip
+ assert.equal(toggleKfAtPlayhead(c, "scale"), false);
+ assert.equal(setAnimProp(c, "scale", 9), false);
+ assert.equal(resetPropAtPlayhead(c, "scale"), false);
+ assert.equal(state.dirtyTimeline, false, "nothing changed → no rebuild");
+});
+
+test("dirtyTimeline: resetPropAtPlayhead inherits it from the delegate", () => {
+ const { state, resetPropAtPlayhead } = makeSandbox();
+ const c = keyedClip();
+ state.time = 12; // on a keyframe → removal via toggleKfAtPlayhead
+ assert.equal(resetPropAtPlayhead(c, "scale"), true);
+ assert.equal(state.dirtyTimeline, true);
+});
+
+/* ── audio-hold refresh ownership ──
+ Audio hold loops one frame of audio built from volume/pan/speed (the speed
+ keyframes remap media time). The mutators must re-cut it on any such write —
+ no matter which UI surface triggered it — and never for unrelated props. */
+test("audio hold: setAnimProp refreshes on volume/pan/speed only", () => {
+ const sb = makeSandbox();
+ const c = keyedClip();
+ sb.state.time = 12;
+ sb.setAnimProp(c, "volume", 0.5);
+ assert.equal(sb.holdRefreshes, 1);
+ sb.setAnimProp(c, "scale", 2);
+ assert.equal(sb.holdRefreshes, 1, "visual props don't touch the hold");
+ sb.setAnimProp(c, "pan", -1);
+ sb.setAnimProp(c, "speed", 2); // remaps which frame of audio is held
+ assert.equal(sb.holdRefreshes, 3);
+});
+
+test("audio hold: toggleKfAtPlayhead refreshes on add and remove", () => {
+ const sb = makeSandbox();
+ const c = keyedClip();
+ sb.state.time = 13;
+ assert.equal(sb.toggleKfAtPlayhead(c, "volume"), true);
+ assert.equal(sb.holdRefreshes, 1, "add re-cuts the hold");
+ sb.state.time = 13; // still parked on the new keyframe
+ assert.equal(sb.toggleKfAtPlayhead(c, "volume"), true);
+ assert.equal(sb.holdRefreshes, 2, "remove re-cuts it too");
+ sb.toggleKfAtPlayhead(c, "scale");
+ assert.equal(sb.holdRefreshes, 2);
+});
+
+test("audio hold: resets refresh through the mutators", () => {
+ const sb = makeSandbox();
+ const c = keyedClip({ keyframes: { pan: [{ t: 2, v: -1 }] } });
+ sb.state.time = 12;
+ sb.resetPropChannel(c, "pan"); // Ctrl-click path
+ assert.equal(sb.holdRefreshes, 1);
+ sb.resetPropChannel(c, "contrast");
+ assert.equal(sb.holdRefreshes, 1);
+ sb.resetPropAtPlayhead(c, "volume"); // unkeyed static path
+ assert.equal(sb.holdRefreshes, 2);
+ c.keyframes = { volume: [{ t: 2, v: 0.5 }] };
+ sb.resetPropAtPlayhead(c, "volume"); // parked on it → removal path
+ assert.equal(sb.holdRefreshes, 3);
+});
+
+test("audio hold: refused writes never refresh", () => {
+ const sb = makeSandbox();
+ const c = keyedClip({ keyframes: { volume: [{ t: 2, v: 1 }] } });
+ sb.state.time = 0; // off-clip
+ assert.equal(sb.setAnimProp(c, "volume", 0.1), false);
+ assert.equal(sb.toggleKfAtPlayhead(c, "volume"), false);
+ assert.equal(sb.resetPropAtPlayhead(c, "volume"), false);
+ assert.equal(sb.holdRefreshes, 0);
+});
+
+/* ── inspector sync (fake DOM) ── */
+test("syncInspectorPlayhead: fields show playhead values, ◆ state follows", () => {
+ const sb = makeSandbox({ clips: [keyedClip()] });
+ const row = scaleRow();
+ const btn = kfBtn("scale");
+ sb.els.inspector = fakeInspector({ inputs: [row.input], buttons: [btn], vals: { scale: row.val } });
+ sb.state.selId = "c1";
+ sb.state.time = 13; // midpoint → 2
+ sb.syncInspectorPlayhead();
+ assert.equal(row.input.value, "2");
+ assert.equal(row.val.textContent, "2");
+ assert.equal(btn.classes.has("has"), true);
+ assert.equal(btn.classes.has("on"), false);
+ assert.equal(btn.textContent, "◆2");
+ sb.state.time = 12; // parked on the first keyframe
+ sb.syncInspectorPlayhead();
+ assert.equal(row.input.value, "1");
+ assert.equal(btn.classes.has("on"), true);
+ assert.equal(btn.title, "Remove keyframe at playhead");
+});
+
+test("syncInspectorPlayhead: unchanged stamp is a no-op", () => {
+ const sb = makeSandbox({ clips: [keyedClip()] });
+ const row = scaleRow();
+ sb.els.inspector = fakeInspector({ inputs: [row.input], vals: { scale: row.val } });
+ sb.state.selId = "c1";
+ sb.state.time = 13;
+ sb.syncInspectorPlayhead();
+ const writes = row.input.writes;
+ assert.ok(writes > 0, "first sync writes");
+ sb.syncInspectorPlayhead();
+ sb.syncInspectorPlayhead();
+ assert.equal(row.input.writes, writes, "gated syncs write nothing");
+ sb.state.time = 13.5; // a real change re-arms the sync
+ sb.syncInspectorPlayhead();
+ assert.ok(row.input.writes > writes);
+ assert.equal(row.input.value, "2.5"); // local 3.5: 1 + (3-1)·0.75
+});
+
+test("syncInspectorPlayhead: out-of-range keyframe saturates the thumb, label keeps truth", () => {
+ const c = keyedClip({ keyframes: { scale: [{ t: 2, v: 12 }] } });
+ const sb = makeSandbox({ clips: [c] });
+ const row = scaleRow();
+ sb.els.inspector = fakeInspector({ inputs: [row.input], vals: { scale: row.val } });
+ sb.state.selId = "c1";
+ sb.state.time = 12;
+ sb.syncInspectorPlayhead();
+ assert.equal(row.input.value, "4"); // slider max
+ assert.equal(row.val.textContent, "12");
+ const writes = row.input.writes;
+ sb.state.time = 12.004; // value stays 12 (single keyframe) → still saturated
+ sb.syncInspectorPlayhead();
+ assert.equal(row.input.value, "4");
+ assert.equal(row.input.writes, writes, "no per-frame churn against the clamp");
+});
+
+test("syncInspectorPlayhead: focused field is never rewritten", () => {
+ const sb = makeSandbox({ clips: [keyedClip()] });
+ const row = scaleRow();
+ row.val.textContent = "typed…"; // the input handler owns the label while focused
+ sb.els.inspector = fakeInspector({ inputs: [row.input], vals: { scale: row.val } });
+ sb.state.selId = "c1";
+ sb.state.time = 12;
+ sb.document.activeElement = row.input;
+ sb.syncInspectorPlayhead();
+ assert.equal(row.input.writes, 0, "focused input untouched");
+ assert.equal(row.val.textContent, "typed…", "its label is left to the input handler");
+ assert.equal(row.input.disabled, false, "focus is never yanked mid-edit");
+});
+
+test("syncInspectorPlayhead: off the clip, keyframed fields lock, statics stay editable", () => {
+ const sb = makeSandbox({ clips: [keyedClip()] });
+ const scale = scaleRow();
+ const rot = { input: new FakeEl({ dataset: { k: "rotation" } }), val: new FakeEl({ dataset: { unit: "" } }) };
+ const btnScale = kfBtn("scale"), btnRot = kfBtn("rotation");
+ sb.els.inspector = fakeInspector({
+ inputs: [scale.input, rot.input],
+ buttons: [btnScale, btnRot],
+ vals: { scale: scale.val, rotation: rot.val },
+ });
+ sb.state.selId = "c1";
+ sb.state.time = 0; // off-clip
+ sb.syncInspectorPlayhead();
+ assert.equal(scale.input.disabled, true, "keyed channel locked");
+ assert.equal(scale.input.value, "1", "shows the nearest edge value");
+ assert.equal(rot.input.disabled, false, "static prop stays editable anywhere");
+ assert.equal(btnScale.disabled, true);
+ assert.equal(btnRot.disabled, true, "no keyframe can be planted off-clip either");
+ assert.equal(btnScale.title, "Move the playhead over the clip to add or remove keyframes");
+ assert.equal(btnScale.classes.has("on"), false, "no false 'parked' state off-clip");
+ // back on-clip → everything unlocks
+ sb.state.time = 12;
+ sb.syncInspectorPlayhead();
+ assert.equal(scale.input.disabled, false);
+ assert.equal(btnScale.disabled, false);
+});
+
+/* ── transition envelope probe (canvas box-drag inversion) ──
+ The selection overlay and hit-test work in displayed space (evalProps —
+ transition envelopes included), but a box drag writes resting geometry:
+ the displayed center minus whatever the envelopes currently add. That only
+ works if transOffsetAt reports EXACTLY what applyTransition adds inside
+ evalProps — including glitch's deterministic jitter — so the probe is
+ tested against the compositor itself, type by type. */
+const BACKOUT = lift(/const backOut = (\(u\) => \{[^\n]*\});/);
+const TRANS = slice("function evalProps(", "function shiftKF(");
+
+function makeTransSandbox({ W = 1280, H = 720 } = {}) {
+ return new Function(
+ "DEFAULT_PROPS", "EASE", "FILTER_PRESETS", "backOut", "clamp", "els",
+ `${TRANS}\nreturn { evalProps, applyTransition, transOffsetAt };`
+ )(
+ DEFAULT_PROPS, EASE, { none: {} }, BACKOUT,
+ (v, a, b) => Math.min(b, Math.max(a, v)), // clamp, as in app.js
+ { preview: { width: W, height: H } },
+ );
+}
+
+/* Clip on [10, 15] with non-default transform props and a keyframed x, so the
+ envelope has real values to mix with (the keyframes must cancel out of the
+ delta, exactly as they do in the drag math). */
+function transClip(over = {}) {
+ return {
+ id: "c1", start: 10, duration: 5,
+ props: {
+ ...DEFAULT_PROPS, x: 37, y: -12, scale: 1.6, rotation: 15,
+ opacity: 0.8, volume: 0.7, blur: 2, rgbSplit: 1,
+ },
+ keyframes: { x: [{ t: 0, v: 100 }, { t: 5, v: -100, ease: "linear" }] },
+ ...over,
+ };
+}
+
+test("transOffsetAt: no transitions, or outside their windows, is zero", () => {
+ const { transOffsetAt } = makeTransSandbox();
+ assert.deepEqual(transOffsetAt(transClip(), 12), { x: 0, y: 0 });
+ const c = transClip({
+ transitionIn: { type: "slide-left", duration: 0.8 },
+ transitionOut: { type: "zoom", duration: 1 },
+ });
+ assert.deepEqual(transOffsetAt(c, 12), { x: 0, y: 0 }, "mid-clip: outside both windows");
+ assert.deepEqual(transOffsetAt(c, 10.8), { x: 0, y: 0 }, "in window is [start, start+dur) — boundary clean");
+ assert.deepEqual(transOffsetAt(c, 14), { x: 0, y: 0 }, "out window is (end-dur, end] — boundary clean");
+});
+
+test("transOffsetAt: mirrors the compositor envelope for every transition type", () => {
+ const { evalProps, transOffsetAt } = makeTransSandbox();
+ const types = ["fade", "slide-left", "slide-right", "slide-up", "slide-down",
+ "zoom", "wipe", "wipe-right", "wipe-up", "wipe-down", "iris", "spin",
+ "blur", "whip", "glitch", "pop"];
+ const base = transClip();
+ for (const type of types) {
+ for (const side of ["In", "Out"]) {
+ const c = transClip({ ["transition" + side]: { type, duration: 0.8 } });
+ for (const f of [0.1, 0.5, 0.9]) {
+ const t = side === "In" ? 10 + f * 0.8 : 15 - f * 0.8;
+ const env = transOffsetAt(c, t);
+ assert.ok(Math.abs(evalProps(c, t).x - evalProps(base, t).x - env.x) < 1e-9,
+ `${type} ${side} @${f}: probe x ≠ compositor x delta`);
+ assert.ok(Math.abs(evalProps(c, t).y - evalProps(base, t).y - env.y) < 1e-9,
+ `${type} ${side} @${f}: probe y ≠ compositor y delta`);
+ }
+ }
+ }
+});
+
+test("box drag during a transition: the box lands where the pointer left it", () => {
+ const { evalProps, transOffsetAt } = makeTransSandbox({ W: 1280, H: 720 });
+ const c = transClip({ keyframes: undefined, transitionIn: { type: "slide-left", duration: 1 } });
+ const t = 10.5; // mid-transition: easeOut(0.5) → k=0.25 → envelope x = +320
+ const env = transOffsetAt(c, t);
+ assert.ok(env.x > 100, "the envelope really is displacing the box");
+ // The user drags the displayed box until its center sits at canvas
+ // (900, 500) and releases. The drag stores the resting center:
+ // displayed midpoint − canvas center − envelope (app.js box branch).
+ c.props.x = Math.round(900 - 1280 / 2 - env.x);
+ c.props.y = Math.round(500 - 720 / 2 - env.y);
+ assert.ok(Math.abs(1280 / 2 + evalProps(c, t).x - 900) < 1, "displayed center x ≈ release point");
+ assert.ok(Math.abs(720 / 2 + evalProps(c, t).y - 500) < 1, "displayed center y ≈ release point");
+ // The pre-fix write (no envelope subtraction) displaced the box by the
+ // envelope on the spot, and left it there for good once the transition ended.
+ const buggy = transClip({ keyframes: undefined, transitionIn: { type: "slide-left", duration: 1 } });
+ buggy.props.x = Math.round(900 - 1280 / 2);
+ assert.ok(Math.abs(1280 / 2 + evalProps(buggy, t).x - 900) > 100,
+ "without the probe the box is off by the full envelope");
+});
From bb0ccf7cf6eed6ea8e6c260b3b9340b7d2efd667 Mon Sep 17 00:00:00 2001
From: Tomasz Plonka <4361591+PlkMarudny@users.noreply.github.com>
Date: Thu, 10 Sep 2026 16:00:47 +0300
Subject: [PATCH 5/7] fix: use Ctrl/Cmd in key labels
---
index.html | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/index.html b/index.html
index c43e8b4..ba3ca98 100644
--- a/index.html
+++ b/index.html
@@ -285,8 +285,8 @@
Keyboard shortcuts
Ctrl+click clip
Add / remove from selection
Ctrl+A / Esc
Select all / deselect
←→
Step 1 frame (⇧ = 1 second)
-
Ctrl+← / Ctrl+→
Go to previous / next keyframe (inspector follows)
-
Ctrl+click inspector label
Reset property + clear all its keyframes
+
Ctrl/Cmd+← / Ctrl/Cmd+→
Go to previous / next keyframe (inspector follows)
+
Ctrl/Cmd+click inspector label
Reset property + clear all its keyframes
⇧+click inspector label
Reset value at playhead, or remove that keyframe
[ / ]
Trim selected in / out to playhead
Home / End
Jump to start / end
From 632ac42b8037875d78758b8aaded4ae4961e01e4 Mon Sep 17 00:00:00 2001
From: Tomasz Plonka <4361591+PlkMarudny@users.noreply.github.com>
Date: Thu, 10 Sep 2026 16:06:18 +0300
Subject: [PATCH 6/7] fix: conversion now waits until the pointer moves ~3px
Undo snapshot is taken first, then the box is seeded (valid PR review issue)
---
app.js | 46 +++++++++++++++++++++++++++++++++-------------
1 file changed, 33 insertions(+), 13 deletions(-)
diff --git a/app.js b/app.js
index 48da808..29a0ef9 100644
--- a/app.js
+++ b/app.js
@@ -5481,19 +5481,10 @@ els.preview.addEventListener("pointerdown", (e) => {
canvasDrag = { mode: "rotate", id: cur.id, startRot: +ep.rotation || 0, startAng: Math.atan2(pt.y - b.cy, pt.x - b.cx), cx: b.cx, cy: b.cy };
} else if (hd.corners.some((h) => Math.abs(pt.x - h.x) <= grab && Math.abs(pt.y - h.y) <= grab)) {
if (cur.kind === "text") {
- ensureTextBox(cur);
- // Recompute bounds after seeding the box; pin the opposite corner.
- const b2 = clipBounds(cur, evalProps(cur, state.time), W, H);
- const hd2 = overlayHandles(b2, W, H);
- const ci = hd2.corners.findIndex((h) => Math.abs(pt.x - h.x) <= grab && Math.abs(pt.y - h.y) <= grab);
- const signs = [[-1, -1], [1, -1], [1, 1], [-1, 1]];
- const [dsx, dsy] = signs[ci >= 0 ? ci : 0];
- const cs = Math.cos(b2.rot), sn = Math.sin(b2.rot);
- const ox = -dsx * b2.hw, oy = -dsy * b2.hh; // opposite corner in local space
canvasDrag = {
- mode: "box", id: cur.id, rot: b2.rot, dragSX: dsx, dragSY: dsy,
- fix: { x: b2.cx + ox * cs - oy * sn, y: b2.cy + ox * sn + oy * cs },
- aspect: Math.max(0.05, (b2.hw * 2) / Math.max(1e-6, b2.hh * 2)),
+ ...beginTextBoxDrag(cur, pt, W, H),
+ seedBox: !hasTextBox(cur.props),
+ startClient: { x: e.clientX, y: e.clientY },
};
} else {
canvasDrag = { mode: "scale", id: cur.id, startScale: +(propsAtPlayhead(cur).scale) || 1, startDist: Math.hypot(lp.x, lp.y) || 1 };
@@ -5550,7 +5541,20 @@ els.preview.addEventListener("pointermove", (e) => {
if (!canvasDrag) { updateCanvasCursor(e); return; }
const c = getClip(canvasDrag.id); if (!c) return;
const W = els.preview.width, H = els.preview.height, pt = canvasPt(e);
- if (!canvasDidMove) { pushUndo(); canvasDidMove = true; } // one undo per drag, only if it actually moves
+ // Hug-content titles: don't seed a box until the pointer actually moves
+ // (same ~3px deadzone as clip drags), so a click-release is a no-op.
+ if (canvasDrag.seedBox && !canvasDidMove) {
+ const s = canvasDrag.startClient;
+ if (s && Math.hypot(e.clientX - s.x, e.clientY - s.y) < 3) return;
+ }
+ if (!canvasDidMove) {
+ pushUndo(); // snapshot includes hug-content, before any box seed
+ canvasDidMove = true;
+ if (canvasDrag.seedBox) {
+ ensureTextBox(c);
+ Object.assign(canvasDrag, beginTextBoxDrag(c, pt, W, H), { seedBox: false });
+ }
+ }
if (canvasDrag.mode === "move") {
setAnimProp(c, "x", Math.round(canvasDrag.startX + (pt.x - canvasDrag.startPt.x)));
setAnimProp(c, "y", Math.round(canvasDrag.startY + (pt.y - canvasDrag.startPt.y)));
@@ -5908,6 +5912,22 @@ function ensureTextBox(c) {
c.props.boxW = Math.max(40, +(half.hw * 2).toFixed(1));
c.props.boxH = Math.max(24, +(half.hh * 2).toFixed(1));
}
+/* Pin the opposite corner for a boxed-text resize (call after any seed). */
+function beginTextBoxDrag(c, pt, W, H) {
+ const b2 = clipBounds(c, evalProps(c, state.time), W, H);
+ const hd2 = overlayHandles(b2, W, H);
+ const grab = hd2.hs * 1.8;
+ const ci = hd2.corners.findIndex((h) => Math.abs(pt.x - h.x) <= grab && Math.abs(pt.y - h.y) <= grab);
+ const signs = [[-1, -1], [1, -1], [1, 1], [-1, 1]];
+ const [dsx, dsy] = signs[ci >= 0 ? ci : 0];
+ const cs = Math.cos(b2.rot), sn = Math.sin(b2.rot);
+ const ox = -dsx * b2.hw, oy = -dsy * b2.hh;
+ return {
+ mode: "box", id: c.id, rot: b2.rot, dragSX: dsx, dragSY: dsy,
+ fix: { x: b2.cx + ox * cs - oy * sn, y: b2.cy + ox * sn + oy * cs },
+ aspect: Math.max(0.05, (b2.hw * 2) / Math.max(1e-6, b2.hh * 2)),
+ };
+}
function drawText(c, p, local) {
const useBox = hasTextBox(p);
const boxW = +p.boxW, boxH = +p.boxH;
From 79f9fa5669cf2ac0ab7f53f72123840b414fa507 Mon Sep 17 00:00:00 2001
From: Tomasz Plonka <4361591+PlkMarudny@users.noreply.github.com>
Date: Thu, 10 Sep 2026 16:32:30 +0300
Subject: [PATCH 7/7] fix: keyframe value is non-editable on clip chnging
position
---
app.js | 10 ++++++++--
test/keyframes.test.js | 31 +++++++++++++++++++++++++++++++
2 files changed, 39 insertions(+), 2 deletions(-)
diff --git a/app.js b/app.js
index 29a0ef9..2a24d92 100644
--- a/app.js
+++ b/app.js
@@ -621,11 +621,16 @@ function fmtInspNum(v, step) {
return String(+n.toFixed(3));
}
/* Inspector playhead-sync cache: the rAF loop re-syncs inspector fields only
- when the playhead, selection, or keyed values changed since the last sync.
+ when the playhead, selection, keyed values, or the selected clip's start /
+ duration changed since the last sync.
Mutators that don't re-render the inspector bump inspPropGen. */
let inspSyncStamp = "";
let inspPropGen = 0;
-const inspStampNow = () => state.time + "|" + state.selId + "|" + inspPropGen;
+const inspStampNow = () => {
+ const c = getClip(state.selId);
+ return state.time + "|" + state.selId + "|" + inspPropGen + "|"
+ + (c ? c.start : "") + "|" + (c ? c.duration : "");
+};
/* Audio hold loops one frame of audio built from volume / pan / the speed
remap — a write to any of them must re-cut it. The mutators own this (like
dirtyTimeline); scheduleAudioHoldRefresh itself no-ops unless holding. */
@@ -3546,6 +3551,7 @@ function renderInspector(lite) {
const s = els.inspector.querySelector("[data-k=start]"), d = els.inspector.querySelector("[data-k=duration]");
if (s) s.value = c.start.toFixed(2);
if (d) d.value = c.duration.toFixed(2);
+ syncInspectorPlayhead(); // start/duration are in the stamp — refresh keyed fields + off-clip lock
return;
}
const p = propsAtPlayhead(c);
diff --git a/test/keyframes.test.js b/test/keyframes.test.js
index 430adce..d60842f 100644
--- a/test/keyframes.test.js
+++ b/test/keyframes.test.js
@@ -480,6 +480,37 @@ test("syncInspectorPlayhead: unchanged stamp is a no-op", () => {
assert.equal(row.input.value, "2.5"); // local 3.5: 1 + (3-1)·0.75
});
+test("syncInspectorPlayhead: clip start/duration change invalidates the stamp", () => {
+ const c = keyedClip();
+ const sb = makeSandbox({ clips: [c] });
+ const row = scaleRow();
+ const btn = kfBtn("scale");
+ sb.els.inspector = fakeInspector({
+ inputs: [row.input], buttons: [btn], vals: { scale: row.val },
+ });
+ sb.state.selId = "c1";
+ sb.state.time = 13; // on-clip, local 3 → scale 2
+ sb.syncInspectorPlayhead();
+ assert.equal(row.input.value, "2");
+ assert.equal(row.input.disabled, false);
+ const writes = row.input.writes;
+ c.start = 11; // still on-clip, local 2 → scale 1; time/sel/gen unchanged
+ sb.syncInspectorPlayhead();
+ assert.ok(row.input.writes > writes, "start change re-syncs");
+ assert.equal(row.input.value, "1");
+ c.start = 20; // playhead now off the clip
+ sb.syncInspectorPlayhead();
+ assert.equal(row.input.disabled, true, "off-clip lock after drag");
+ assert.equal(btn.disabled, true);
+ c.start = 10;
+ c.duration = 2; // 10–12, playhead 13 still off
+ sb.syncInspectorPlayhead();
+ assert.equal(row.input.disabled, true, "duration trim can push the playhead off");
+ c.duration = 5;
+ sb.syncInspectorPlayhead();
+ assert.equal(row.input.disabled, false);
+});
+
test("syncInspectorPlayhead: out-of-range keyframe saturates the thumb, label keeps truth", () => {
const c = keyedClip({ keyframes: { scale: [{ t: 2, v: 12 }] } });
const sb = makeSandbox({ clips: [c] });