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 2d22144..efd9ac4 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
@@ -136,7 +136,14 @@ 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. 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. 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 1dec82e..2a24d92 100644
--- a/app.js
+++ b/app.js
@@ -579,6 +579,168 @@ 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];
+ 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));
+}
+/* Inspector playhead-sync cache: the rAF loop re-syncs inspector fields only
+ 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 = () => {
+ 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. */
+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). 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 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 true;
+ }
+ const near = kfAtPlayhead(c, k);
+ 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 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) {
+ 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];
+ refreshAudioHoldFor(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).
+ Returns false when refused (keyed channel, playhead off the clip). */
+function resetPropAtPlayhead(c, k) {
+ if (!c || !k) return false;
+ if (k === "transIn" || k === "transOut") {
+ resetPropChannel(c, k);
+ 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) {
+ 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.
+ 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) || !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 {
+ c.props[k] = near.v;
+ delete c.keyframes[k];
+ if (!Object.keys(c.keyframes).length) c.keyframes = undefined;
+ }
+ 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 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; // 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;
}
@@ -3125,7 +3287,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; }
@@ -3389,15 +3551,20 @@ 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 = 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. */
+ 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);
@@ -3411,16 +3578,20 @@ 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) =>
`