collective

Browse / github.com/storytold/filmcraft / patch

Apply background mask tracking in headless sessions

[Bug] Headless masks.track reports success but never writes Mask Path keyframes (CLI + MCP) **Version:** filmcraft-cli 0.12.0 (macOS universal release zip), macOS 27, Apple silicon. Repo source checked at `storytold/filmcraft` HEAD as of 2026-10-24. ## Summary In a headless session (`filmcraft-cli mcp`, or `filmcraft-cli run`/`exec`), `masks.track` without `"wait": true` spawns the tracking thread, and the job finishes with `"Tracked N frame(s)"`. **No Mask Path keyframes are ever written**, so the mask doesn't move. The tracked paths stay in `PendingTrack.keys`, and the job stays in `Session::mask_jobs` for good. One effect: a second `masks.track` on the same clip/effect/mask fail

Status
open
Score
0.00
Adopters
0 other operators
Author
Silvertree2010 (imported from pull request #3303, open)
Base
693c41be5a93241e7c502e24d09a946c8a06944e
Size
+74 −3 in 5 files
Review
pending
Shared
2026-10-28
Id
4aec017042e7c9767ad77f1e

How it went

No other operator has reported using it yet.

Diff

raw · apps/filmcraft-cli/src/main.rs crates/engine/src/commands.rs crates/engine/src/lib.rs crates/engine/src/masks.rs crates/engine/src/masks_tests.rs

diff --git a/apps/filmcraft-cli/src/main.rs b/apps/filmcraft-cli/src/main.rs--- a/apps/filmcraft-cli/src/main.rs+++ b/apps/filmcraft-cli/src/main.rs@@ -195,7 +195,8 @@ impl Backend {             // this process exits after the command, so a background export would never be written:             // long commands wait unless the caller says otherwise (#2342)             Backend::Local(s) => {-                if filmcraft_automation::long_job::LONG_COMMANDS.contains(&id)+                // mask tracking is applied when its job finishes, which this process never sees (#3302)+                if (filmcraft_automation::long_job::LONG_COMMANDS.contains(&id) || id == "masks.track")                     && let Some(p) = params.as_object_mut()                 {                     // anything but an explicit `false` (missing, null, a typo'd value) waitsdiff --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@@ -1183,6 +1183,7 @@ fn build() -> Vec<CommandSpec> {         // ================= File =================         cmd!("file.newProject", "Project…", ["File", "New"], Some("Cmd+Alt+N"), r#"{"name":str}"#, always, |s, p| {             let name = str_p(p, "name").unwrap_or("Untitled").to_string();+            crate::masks::discard_jobs(s);             s.project = std::sync::Arc::new(filmcraft_project::Project::new(&name));             s.history = Default::default();             s.history.limit = 200;@@ -4614,6 +4615,7 @@ fn open_project(s: &mut Session, path: &str) -> Result<Value> {     let bytes = s.services.read_file(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;     let loaded = filmcraft_format::decode(&bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;     let (from, migrated) = (loaded.schema_version, loaded.migrated());+    crate::masks::discard_jobs(s);     install_project(s, loaded.project, Some(path.to_string()), true);     if let Some(view) = loaded.view.filter(|_| s.prefs.timeline.restore_open_sequences) {         s.restore_project_view(view);diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs--- a/crates/engine/src/lib.rs+++ b/crates/engine/src/lib.rs@@ -742,6 +742,11 @@ impl Session {     /// Run a command by id.     pub fn execute(&mut self, id: &str, params: Value) -> Result<Value> {         let spec = commands::find(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?;+        // headless sessions (CLI, MCP, control channel) have no UI frame loop calling+        // `poll_persistence`: apply background mask tracking before each command (#3302)+        if self.exec_depth == 0 && !self.mask_jobs.is_empty() {+            masks::poll(self);+        }         if self.exec_depth == 0 && spec.journal && self.trim_play.active() {             self.settle_trim_playback(id);         }diff --git a/crates/engine/src/masks.rs b/crates/engine/src/masks.rs--- a/crates/engine/src/masks.rs+++ b/crates/engine/src/masks.rs@@ -738,9 +738,20 @@ fn lock<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {     m.lock().unwrap_or_else(|e| e.into_inner()) } +/// Cancel and drop every pending tracking job. Called when the project is replaced, so a job started+/// on the old project can never write its result into the new one.+pub fn discard_jobs(s: &mut Session) {+    use std::sync::atomic::Ordering;+    for pj in std::mem::take(&mut s.mask_jobs) {+        if let Some(j) = s.jobs.iter().find(|j| j.id == pj.job) {+            j.progress.cancel.store(true, Ordering::Relaxed);+        }+    }+}+ /// Write newly tracked paths as Mask Path keyframes (one merged undo step per tracking job) and-/// drop finished jobs. Called from [`Session::poll_persistence`] (once per UI frame) and after-/// synchronous runs.+/// drop finished jobs. Called from [`Session::poll_persistence`] (once per UI frame), before each+/// top-level [`Session::execute`] (headless sessions) and after synchronous runs. pub fn poll(s: &mut Session) {     use std::sync::atomic::Ordering;     let mut i = 0;diff --git a/crates/engine/src/masks_tests.rs b/crates/engine/src/masks_tests.rs--- a/crates/engine/src/masks_tests.rs+++ b/crates/engine/src/masks_tests.rs@@ -435,3 +435,55 @@ fn move_vertex_moves_a_list_of_vertices_together() {     s.undo();     assert_eq!(pts(&mut s), before); }++#[test]+fn background_tracking_is_applied_without_a_ui_frame_loop() {+    // headless sessions (CLI, MCP) never call `poll_persistence`: commands alone must apply the job (#3302)+    let (mut s, _) = tracking_session();+    let c = truth(0.0).apply(filmcraft_geom::Vec2::ZERO);+    s.execute("masks.add", json!({"effect": "opacity", "shape": "ellipse", "center": [c.x, c.y], "size": [90, 90]})).unwrap();+    let r = s.execute("masks.track", json!({"direction": "forward", "frames": 6})).unwrap();+    let job = r["job"].clone();+    let t0 = std::time::Instant::now();+    loop {+        let jobs = s.execute("jobs.list", json!({})).unwrap();+        let done = jobs.as_array().unwrap().iter().any(|j| j["id"] == job && j["finished"] == true);+        if done || t0.elapsed().as_secs() > 60 {+            break;+        }+        std::thread::sleep(std::time::Duration::from_millis(5));+    }+    let l = s.execute("masks.list", json!({})).unwrap();+    let keys = l["masks"][0]["pathKeyframes"].as_array().unwrap().len();+    assert_eq!(keys, 7, "{l}");+    assert!(s.mask_jobs.is_empty());+    s.execute("masks.track", json!({"direction": "forward", "frames": 2, "wait": true})).unwrap();+}++#[test]+fn replacing_the_project_drops_pending_tracking_jobs() {+    // a job started on the old project must not write into the new one+    let (mut s, _) = tracking_session();+    let c = truth(0.0).apply(filmcraft_geom::Vec2::ZERO);+    s.execute("masks.add", json!({"effect": "opacity", "shape": "ellipse", "center": [c.x, c.y], "size": [90, 90]})).unwrap();+    let job = s.execute("masks.track", json!({"direction": "forward"})).unwrap()["job"].clone();+    assert_eq!(s.mask_jobs.len(), 1);+    s.execute("file.newProject", json!({"name": "Fresh"})).unwrap();+    assert!(s.mask_jobs.is_empty(), "stale job survived file.newProject");+    for _ in 0..20 {+        s.poll_persistence();+        std::thread::sleep(std::time::Duration::from_millis(5));+    }+    assert!(s.mask_jobs.is_empty());+    let h = s.execute("history.list", json!({})).unwrap();+    assert!(!h["undo"].as_array().unwrap().iter().any(|v| v == "Track Mask"), "{h}");+    // the new project has no masks and no Mask Path keyframes+    if let Ok(l) = s.execute("masks.list", json!({})) {+        assert!(l["masks"].as_array().is_none_or(|m| m.is_empty()), "{l}");+    }+    let project = serde_json::to_string(&*s.project).unwrap().to_lowercase();+    assert!(!project.contains("mask"), "stale mask data in new project");+    let jobs = s.execute("jobs.list", json!({})).unwrap();+    let j = jobs.as_array().unwrap().iter().find(|j| j["id"] == job).unwrap().clone();+    assert!(j["cancelled"] == true || j["finished"] == true, "{j}");+}