From ba3f666bb03bc4be5811ca8ab2d85e56a1f7f911 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 1/7] feat: allow keyframe value editing --- README.md | 5 +- app.js | 164 +++++++++++++++++++++++++++++++++++++++++++++-------- index.html | 2 +- live.json | 8 +++ style.css | 6 ++ 5 files changed, 159 insertions(+), 26 deletions(-) create mode 100644 live.json diff --git a/README.md b/README.md index 2d22144..8dc163b 100644 --- a/README.md +++ b/README.md @@ -136,7 +136,10 @@ same time. keyframe time (tooltip lists channels; a count badge when several share a time). Ctrl/Cmd+← / Ctrl/Cmd+→ jumps the playhead to the previous / next keyframe (selected clips first, else clips under the - playhead) + 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 + 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 - **Speed ramps** — keyframe `speed` and the engine time-remaps video *and* the diff --git a/app.js b/app.js index 1dec82e..35a0d83 100644 --- a/app.js +++ b/app.js @@ -579,6 +579,84 @@ function kfChannel(c, key, local, fallback) { } return fallback; } +const kfTimeEps = () => 0.5 / projectFps(); +/* Keyframe on this channel whose absolute time matches the playhead. */ +function kfAtPlayhead(c, k) { + const arr = c.keyframes?.[k]; + if (!Array.isArray(arr) || !arr.length) return null; + const eps = kfTimeEps(); + const abs = state.time; + return arr.find((kf) => Math.abs(c.start + kf.t - abs) < eps) || null; +} +/* Static props with keyed channels replaced by the value at the playhead + (no transition envelopes — those would fake a keyframe in the inspector). */ +function propsAtPlayhead(c) { + const p = { ...c.props }; + if (!c.keyframes) return p; + const local = state.time - c.start; + for (const k of ANIMATABLE) { + const kfs = c.keyframes[k]; + if (!Array.isArray(kfs) || !kfs.length) continue; + const v = kfChannel(c, k, local, +(p[k] ?? DEFAULT_PROPS[k] ?? 0)); + if (typeof v === "number" && !isNaN(v)) p[k] = v; + } + return p; +} +function fmtInspNum(v, step) { + const n = +v; + if (!Number.isFinite(n)) return "0"; + const s = +step; + if (Number.isFinite(s) && s > 0) { + if (s >= 1) return String(Math.round(n / s) * s); + const dec = Math.min(6, Math.max(0, Math.ceil(-Math.log10(s) - 1e-9))); + return String(+n.toFixed(dec)); + } + if (Math.abs(n - Math.round(n)) < 1e-6) return String(Math.round(n)); + return String(+n.toFixed(3)); +} +/* Write an animatable prop: static if the channel has no keyframes; otherwise + update the keyframe under the playhead or insert one (auto-key). */ +function setAnimProp(c, k, v) { + if (!c || !ANIMATABLE.includes(k) || typeof v !== "number" || isNaN(v)) return; + const arr = c.keyframes?.[k]; + if (!Array.isArray(arr) || !arr.length) { + c.props[k] = v; + return; + } + const near = kfAtPlayhead(c, k); + if (near) { near.v = v; return; } + 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; } + arr.push({ t: lt, v }); + arr.sort((a, b) => a.t - b.t); + state.dirtyTimeline = true; +} +/* ◆ : add a keyframe at the playhead, or remove the one already there. */ +function toggleKfAtPlayhead(c, k) { + if (!c || !ANIMATABLE.includes(k)) return; + const near = kfAtPlayhead(c, k); + if (near) { + const rest = c.keyframes[k].filter((kf) => kf !== near); + if (rest.length) c.keyframes[k] = rest; + else { + c.props[k] = near.v; + delete c.keyframes[k]; + if (!Object.keys(c.keyframes).length) c.keyframes = undefined; + } + return; + } + 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 (!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); } +} function hasSpeedRamp(c) { return Array.isArray(c.keyframes?.speed) && c.keyframes.speed.length > 0; } @@ -3095,6 +3173,7 @@ 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). */ @@ -3125,7 +3204,7 @@ function goToKeyframe(dir) { } const times = keyframeTimelineTimes(clips); if (!times.length) { toast("No keyframes"); return; } - const eps = 0.5 / projectFps(); + const eps = kfTimeEps(); if (dir > 0) { const next = times.find((t) => t > state.time + eps); if (next == null) { toast("No next keyframe"); return; } @@ -3391,10 +3470,13 @@ function renderInspector(lite) { if (d) d.value = c.duration.toFixed(2); return; } - const p = c.props; + const p = propsAtPlayhead(c); const kfCount = (k) => (c.keyframes && c.keyframes[k] ? c.keyframes[k].length : 0); - const kfCtl = (k) => !ANIMATABLE.includes(k) ? "" : - `${kfCount(k) ? `` : ""}`; + const kfCtl = (k) => { + if (!ANIMATABLE.includes(k)) return ""; + const n = kfCount(k), on = !!kfAtPlayhead(c, k); + return `${n ? `` : ""}`; + }; /* 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. */ @@ -3417,10 +3499,12 @@ function renderInspector(lite) { }; const row = (label, inner, k = "", reset) => `
${propLabel(label, k, reset)}${inner}${k ? kfCtl(k) : ""}
`; - const slider = (k, min, max, step, val, unit = "") => - row(k[0].toUpperCase() + k.slice(1), - ` - ${val}${unit}`, k); + const slider = (k, min, max, step, val, unit = "") => { + const shown = fmtInspNum(val, step); + return row(k[0].toUpperCase() + k.slice(1), + ` + ${shown}${unit}`, k); + }; let html = (state.selIds.size > 1 ? `
${state.selIds.size} clips selected — drag moves them together, Del deletes all. Fields below edit the primary (white-outlined) clip.
` : "") + `

Clip — ${c.kind}

@@ -3438,8 +3522,8 @@ function renderInspector(lite) {
`; } else if (c.kind !== "audio") { html += `

Transform

- ${row("Position X", ``, "x")} - ${row("Position Y", ``, "y")} + ${row("Position X", ``, "x")} + ${row("Position Y", ``, "y")} ${slider("scale", 0.1, 4, 0.01, p.scale)} ${slider("rotation", -180, 180, 1, p.rotation, "°")} ${slider("opacity", 0, 1, 0.01, p.opacity)} @@ -3515,8 +3599,8 @@ function renderInspector(lite) { html += `

Text

${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 clipAdd / remove from selection Ctrl+A / EscSelect 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 / EndJump 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 / EscSelect all / deselect Step 1 frame (⇧ = 1 second) Ctrl+ / Ctrl+Go to previous / next keyframe (inspector follows) + Ctrl+click inspector labelReset property + clear all its keyframes + +click inspector labelReset value at playhead, or remove that keyframe [ / ]Trim selected in / out to playhead Home / EndJump 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 clipAdd / remove from selection Ctrl+A / EscSelect all / deselect Step 1 frame (⇧ = 1 second) - Ctrl+ / Ctrl+Go to previous / next keyframe (inspector follows) - Ctrl+click inspector labelReset property + clear all its keyframes + Ctrl/Cmd+ / Ctrl/Cmd+Go to previous / next keyframe (inspector follows) + Ctrl/Cmd+click inspector labelReset property + clear all its keyframes +click inspector labelReset value at playhead, or remove that keyframe [ / ]Trim selected in / out to playhead Home / EndJump 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] });