collective

Browse / github.com/storytold/filmcraft / patch

engine: effects.addKeyframe keeps an explicit time at the out point and says what it did (#3386)

`effects.addKeyframe` at the clip's out point creates a keyframe that `effects.deleteKeyframe` reports removed but keeps Measured on FilmCraft 0.12.0 (Windows portable) through the MCP server. On an image clip with a scale keyframe at 0 s, `effects.addKeyframe` with `time` equal to the clip's out point creates a keyframe that `effects.setKeyframe` does not find; `effects.deleteKeyframe` on it returns `null` and the keyframe count does not decrease. A second `effects.addKeyframe` at the same time removes it. In a longer edit the stray key showed as a jump in the last fraction of a second of the render; workaround: keep the last keyframe one frame before the out point. Suggestion: clamp or reject a keyframe tim

Status
open
Score
0.00
Adopters
0 other operators
Author
x-labb (imported from pull request #3409, open)
Base
8fda2f232198c4c79ff9cfe5b6a347bb1375fa9e
Size
+102 −7 in 3 files
Review
pending
Shared
2026-10-28
Id
b0169ef38a5257ff95bba628

How it went

No other operator has reported using it yet.

Diff

raw · crates/engine/src/commands.rs crates/engine/src/tests.rs docs/agents.md

diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs--- a/crates/engine/src/commands.rs+++ b/crates/engine/src/commands.rs@@ -3650,11 +3650,17 @@ fn reset_params(e: &mut filmcraft_project::EffectInstance, only: Option<&str>, f     any } +/// `effects.addKeyframe` / `deleteKeyframe` / `moveKeyframe` / `setKeyframe` / `setKeyframeInterpolation`.+/// An explicit `time` keeps its place, outside the clip too (#1055, as in `effects.setParam`); only+/// the playhead fallback is pinned to the clip's last tick. `add` replies `{added, mediaTime}`:+/// whether it added the keyframe or (it is a toggle) removed the one there, and the media time of+/// that keyframe. `delete` replies `{removed}`: whether a keyframe sat exactly at `mediaTime`. fn keyframe_op(s: &mut Session, p: &Value, op: &str) -> Result<Value> {     let c = clip_p(p, "clip").ok_or_else(|| bad("keyframe", "need `clip`"))?;     let pid = str_p(p, "param").ok_or_else(|| bad("keyframe", "need `param`"))?.to_string();     let eff = p.get("effect").cloned().unwrap_or(json!("motion"));-    let ph = time_p(s, p, "").unwrap_or(s.playhead());+    let at = time_p(s, p, "");+    let playhead = s.playhead();     let mtime = p.get("mediaTime").and_then(Value::as_i64).map(Tick);     let to = p.get("to").and_then(Value::as_i64).map(Tick);     let interp = str_p(p, "interpolation").map(str::to_string);@@ -3671,8 +3677,9 @@ fn keyframe_op(s: &mut Session, p: &Value, op: &str) -> Result<Value> {         s.history.merge_key = None;     }     s.edit_sequence_as(label, merge.as_deref(), |q, _, _| {+        let mut reply = Value::Null;         let (_, it) = q.find_item_mut(c).ok_or(filmcraft_edit::EditError::NoItem(c))?;-        let mt_now = it.source_time_at(ph.clamp(it.start, (it.end() - Tick(1)).max(it.start)));+        let mt_now = it.source_time_at(at.unwrap_or_else(|| playhead.clamp(it.start, (it.end() - Tick(1)).max(it.start))));         let e = match &eff {             Value::Number(n) => it.effects.get_mut(n.as_u64().unwrap_or(0) as usize),             Value::String(sid) => it.effects.iter_mut().find(|e| &e.effect == sid),@@ -3683,12 +3690,15 @@ fn keyframe_op(s: &mut Session, p: &Value, op: &str) -> Result<Value> {         match op {             "add" => {                 // toggle: remove if a keyframe sits at the playhead, else add-                if !prm.remove_keyframe_at(mt_now) {+                let added = !prm.remove_keyframe_at(mt_now);+                if added {                     prm.add_keyframe(mt_now);                 }+                reply = json!({"added": added, "mediaTime": mt_now.0});             }             "delete" => {-                prm.remove_keyframe_at(mtime.ok_or_else(|| bad("keyframe", "need `mediaTime`"))?);+                let removed = prm.remove_keyframe_at(mtime.ok_or_else(|| bad("keyframe", "need `mediaTime`"))?);+                reply = json!({"removed": removed});             }             "move" => {                 let from = mtime.ok_or_else(|| bad("keyframe", "need `mediaTime`"))?;@@ -3732,9 +3742,8 @@ fn keyframe_op(s: &mut Session, p: &Value, op: &str) -> Result<Value> {                 k.interp = kind;             }         }-        Ok(())-    })?;-    Ok(Value::Null)+        Ok(reply)+    }) }  fn in_out_range(s: &Session) -> Result<TimeRange> {diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs--- a/crates/engine/src/tests.rs+++ b/crates/engine/src/tests.rs@@ -326,6 +326,86 @@ fn add_keyframe_toggles_the_keyframe_at_the_playhead() {     assert_eq!(scale(&s), (0, 100.0)); } +/// #3386: `effects.addKeyframe` with a `time` at the clip's out point used to put the keyframe a+/// tick earlier, so a script that then looked for it at the out point found nothing (and+/// `effects.deleteKeyframe` answered `null` either way). An explicit `time` now keeps its place, as+/// in `effects.setParam` (#1055); only the playhead fallback is pinned to the clip. The replies say+/// where the keyframe is (`mediaTime`), whether it was added or toggled off (`added`), and whether a+/// delete removed anything (`removed`).+#[test]+fn add_keyframe_at_the_out_point_stays_there_and_the_replies_say_what_happened() {+    let mut s = demo();+    let (c, start, end) = {+        let it = &s.active_sequence().unwrap().video_tracks[0].items[0];+        (it.id.0, it.start, it.end())+    };+    let clip = |s: &Session| s.active_sequence().unwrap().find_item(filmcraft_project::ClipId(c)).unwrap().1.clone();+    let media_at = |s: &Session, t: Tick| clip(s).source_time_at(t);+    let keys = |s: &Session| clip(s).effect("motion").unwrap().params["scale"].keyframes.iter().map(|k| k.time).collect::<Vec<_>>();+    let add = |s: &mut Session, time: Tick| s.execute("effects.addKeyframe", json!({"clip": c, "effect": "motion", "param": "scale", "time": time.0})).unwrap();+    let delete = |s: &mut Session, media: Tick| {+        s.execute("effects.deleteKeyframe", json!({"clip": c, "effect": "motion", "param": "scale", "mediaTime": media.0})).unwrap()+    };+    let reply = |r: &Value| (r["added"].clone(), r["mediaTime"].clone());++    let r = add(&mut s, start);+    assert_eq!(reply(&r), (json!(true), json!(media_at(&s, start).0)), "{r}");++    // at the out point: the keyframe is at the out point, where a script looks for it+    let out = media_at(&s, end);+    let r = add(&mut s, end);+    assert_eq!(reply(&r), (json!(true), json!(out.0)), "{r}");+    assert_eq!(keys(&s), vec![media_at(&s, start), out]);++    // a delete at a time with no keyframe says so and changes nothing+    let r = delete(&mut s, out - Tick(1));+    assert_eq!(r["removed"], json!(false), "{r}");+    assert_eq!(keys(&s).len(), 2);+    let r = delete(&mut s, out);+    assert_eq!(r["removed"], json!(true), "{r}");+    assert_eq!(keys(&s), vec![media_at(&s, start)]);++    // adding twice at the same time toggles: the second reply says the keyframe was removed+    assert_eq!(add(&mut s, end)["added"], json!(true));+    let r = add(&mut s, end);+    assert_eq!(reply(&r), (json!(false), json!(out.0)), "{r}");+    assert_eq!(keys(&s), vec![media_at(&s, start)]);++    // past the end it keeps its time too+    let past = end + (end - start);+    let r = add(&mut s, past);+    assert_eq!(reply(&r), (json!(true), json!(media_at(&s, past).0)), "{r}");+    let past_media = media_at(&s, past);+    delete(&mut s, past_media);++    // only the playhead fallback is pinned to the clip: a playhead past the end lands on its last tick+    s.execute("playhead.set", json!({"seconds": past.seconds()})).unwrap();+    let r = s.execute("effects.addKeyframe", json!({"clip": c, "effect": "motion", "param": "scale"})).unwrap();+    assert_eq!(reply(&r), (json!(true), json!(media_at(&s, end - Tick(1)).0)), "{r}");++    // hostile times neither panic nor leave a half-made keyframe; a delete that names no time is an error+    for t in [i64::MIN, i64::MAX] {+        let _ = s.execute("effects.addKeyframe", json!({"clip": c, "effect": "motion", "param": "scale", "time": t}));+    }+    s.active_sequence().unwrap().check().unwrap();+    assert!(s.execute("effects.deleteKeyframe", json!({"clip": c, "effect": "motion", "param": "scale"})).is_err());+}++/// The position half of #3386, read from the project and not from a reply: a keyframe asked for at+/// the clip's out point is at the out point.+#[test]+fn add_keyframe_with_a_time_at_the_out_point_is_not_moved() {+    let mut s = demo();+    let (c, end) = {+        let it = &s.active_sequence().unwrap().video_tracks[0].items[0];+        (it.id.0, it.end())+    };+    s.execute("effects.addKeyframe", json!({"clip": c, "effect": "motion", "param": "scale", "time": end.0})).unwrap();+    let it = s.active_sequence().unwrap().find_item(filmcraft_project::ClipId(c)).unwrap().1.clone();+    let at: Vec<Tick> = it.effect("motion").unwrap().params["scale"].keyframes.iter().map(|k| k.time).collect();+    assert_eq!(at, vec![it.source_time_at(end)], "the keyframe sits at the out point, not a tick before it");+}+ /// `effects.setParam` writes a keyframe only on an animated parameter. An agent that sends a value /// with a `time` to a parameter that is not animated gets a static value (the result says so: /// 0 keyframes); `"keyframe": true` is the stopwatch and the value in one step.diff --git a/docs/agents.md b/docs/agents.md--- a/docs/agents.md+++ b/docs/agents.md@@ -48,6 +48,12 @@ edit reports the value written at the requested time, rather than the static fal For example, setting opacity `blend` to `"Screen"` returns `{"keyframes": 0, "param": "blend", "value": 8}`. +`effects.addKeyframe` toggles: it returns `{"added": true, "mediaTime": t}` when it added a keyframe and+`{"added": false, "mediaTime": t}` when it removed the one already there; `mediaTime` is where the keyframe+is, to find it again. An explicit `time` keeps its place, outside the clip too, as in `effects.setParam`;+only the playhead (when no `time` is given) is held to the clip's last tick. `effects.deleteKeyframe`+returns `{"removed": true}` or `{"removed": false}` (no keyframe sat exactly at `mediaTime`).+ ### From an installed release  The release packages ship `filmcraft-cli` alongside the desktop app, so no build is needed: