From 9891fd11a49d33f4e02fd89e4e429588e4c38e2b Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Sun, 11 Oct 2026 22:59:16 +1100 Subject: [PATCH] Keep timeline clips when Project Clear removes used media. Clear used to strip every track item that referenced a deleted Project file. Those clips now stay on the sequence as Media Offline, and media.relink still finds the item. Unused media, bins, and generators are still removed. --- crates/engine/src/commands.rs | 39 ++++++++++++++++++--- crates/engine/src/explicit_targets_tests.rs | 6 ++-- crates/engine/src/project_tools_tests.rs | 30 ++++++++++++++-- 3 files changed, 64 insertions(+), 11 deletions(-) diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 6609afddf..c91910105 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -2820,20 +2820,48 @@ fn build() -> Vec { } items.sort_by_key(|i| i.0); items.dedup(); + let mut used = std::collections::HashSet::new(); + for it in s.project.sequences() { + if let Some(q) = it.as_sequence() { + for t in q.all_tracks() { + used.extend(t.items.iter().map(|x| x.item)); + } + } + } + let mut gone = Vec::new(); + let mut offlined = Vec::new(); + for i in items { + let keep = used.contains(&i) + && s.project.item(i).and_then(filmcraft_project::ProjectItem::as_media).is_some_and(|m| matches!(m.media, MediaRef::File { .. })); + if keep { + offlined.push(i); + } else { + gone.push(i); + } + } s.edit("Clear", |pr, st| { + for i in &offlined { + if let Some(m) = pr.item_mut(*i).and_then(filmcraft_project::ProjectItem::as_media_mut) { + m.offline = true; + } + } for b in &bins { pr.root.remove_bin(*b); } - for i in &items { + for i in &offlined { + if pr.root.parent_of(*i).is_none() { + pr.root.children.push(filmcraft_project::BinEntry::Item(*i)); + } + } + for i in &gone { pr.items.remove(i); pr.root.remove_item(*i); } - // remove track items that referenced deleted media let seq_ids: Vec = pr.sequences().map(|q| q.id).collect(); for sid in seq_ids { if let Some(q) = pr.sequence_mut(sid) { for t in q.all_tracks_mut() { - t.items.retain(|x| !items.contains(&x.item)); + t.items.retain(|x| !gone.contains(&x.item)); filmcraft_edit::remove_orphan_transitions(t); } } @@ -2841,11 +2869,12 @@ fn build() -> Vec { st.project_selection.clear(); Ok(()) })?; - for i in &items { + for i in gone.iter().chain(&offlined) { s.media.remove(*i); } + crate::relink::refresh(s); s.fix_state(); - Ok(json!({"items": items.len(), "bins": bins.len()})) + Ok(json!({"items": gone.len(), "bins": bins.len(), "offline": offlined.len()})) }), cmd!("project.moveToBin", "Move to Bin", [], None, r#"{"items":[id|binId]?,"bin":binId|null}"#, always, |s, p| { let items: Vec = match p.get("items").and_then(Value::as_array) { diff --git a/crates/engine/src/explicit_targets_tests.rs b/crates/engine/src/explicit_targets_tests.rs index c3df07e18..2abbcee12 100644 --- a/crates/engine/src/explicit_targets_tests.rs +++ b/crates/engine/src/explicit_targets_tests.rs @@ -234,15 +234,15 @@ fn project_delete_takes_explicit_bins_and_items_without_a_selection() { assert_eq!(disabled(s.execute("project.delete", json!({}))), "select an item in the Project panel"); // an empty bin, by its id alone let r = s.execute("project.delete", json!({"items": [empty]})).unwrap(); - assert_eq!(r, json!({"items": 0, "bins": 1})); + assert_eq!(r, json!({"items": 0, "bins": 1, "offline": 0})); assert!(s.project.root.find_bin(filmcraft_project::BinId(empty)).is_none()); // a bin with a clip in it, and an item, each by id alone let r = s.execute("project.delete", json!({"items": [outer]})).unwrap(); - assert_eq!(r, json!({"items": 1, "bins": 1})); + assert_eq!(r, json!({"items": 1, "bins": 1, "offline": 0})); assert!(s.project.item(ocean).is_none()); let dunes = item_named(&s, "Desert_Dunes.mp4"); let r = s.execute("project.delete", json!({"items": [dunes.0]})).unwrap(); - assert_eq!(r, json!({"items": 1, "bins": 0})); + assert_eq!(r, json!({"items": 1, "bins": 0, "offline": 0})); assert_eq!(s.project.items.len(), before - 2); assert!(s.state.project_selection.is_empty(), "the selection is left as it was"); assert!(!s.is_enabled("project.delete"), "menu enablement still follows the selection"); diff --git a/crates/engine/src/project_tools_tests.rs b/crates/engine/src/project_tools_tests.rs index e00af7326..fd5ca4041 100644 --- a/crates/engine/src/project_tools_tests.rs +++ b/crates/engine/src/project_tools_tests.rs @@ -469,13 +469,13 @@ fn clear_deletes_a_bin_with_everything_in_it() { select(&mut s); // an empty bin, by its id: gone, nothing else touched let r = s.execute("project.delete", json!({"items": [empty]})).unwrap(); - assert_eq!(r, json!({"items": 0, "bins": 1})); + assert_eq!(r, json!({"items": 0, "bins": 1, "offline": 0})); assert!(s.project.root.find_bin(filmcraft_project::BinId(empty)).is_none()); assert_eq!(s.project.items.len(), before); // a bin holding a sub-bin holding a clip: all three go select(&mut s); let r = s.execute("project.delete", json!({"items": [outer]})).unwrap(); - assert_eq!(r, json!({"items": 1, "bins": 1})); + assert_eq!(r, json!({"items": 1, "bins": 1, "offline": 0})); assert!(s.project.root.find_bin(filmcraft_project::BinId(outer)).is_none()); assert!(s.project.root.find_bin(filmcraft_project::BinId(inner)).is_none()); assert!(s.project.item(ocean).is_none()); @@ -487,7 +487,7 @@ fn clear_deletes_a_bin_with_everything_in_it() { // the clip, its bin and the bin around it all named: each counted once select(&mut s); let r = s.execute("project.delete", json!({"items": [ocean.0, inner, outer]})).unwrap(); - assert_eq!(r, json!({"items": 1, "bins": 1})); + assert_eq!(r, json!({"items": 1, "bins": 1, "offline": 0})); assert_eq!(s.project.items.len(), before - 1); s.execute("edit.undo", json!({})).unwrap(); // the project's own top bin is never cleared @@ -496,3 +496,27 @@ fn clear_deletes_a_bin_with_everything_in_it() { s.execute("project.delete", json!({"items": [root]})).unwrap(); assert_eq!(s.project.items.len(), before); } + +#[test] +fn deleting_media_used_on_the_timeline_makes_it_offline() { + let mut s = Session::default(); + s.execute("file.newProject", json!({"name": "Offline"})).unwrap(); + s.execute("file.newSequence", json!({"name": "Seq", "fps": 25, "video": 1, "audio": 0, "width": 16, "height": 16})).unwrap(); + let item = s.execute("file.newOfflineFile", json!({"name": "Clip", "seconds": 2, "fps": 25, "video": true, "audio": false, "width": 16, "height": 16})).unwrap()["item"] + .as_u64() + .unwrap(); + s.execute("timeline.place", json!({"item": item, "track": "V1", "time": 0})).unwrap(); + let clips_before = s.project.sequences().next().unwrap().as_sequence().unwrap().video_tracks[0].items.len(); + let r = s.execute("project.delete", json!({"items": [item]})).unwrap(); + assert_eq!(r["offline"], 1); + assert_eq!(r["items"], 0); + assert!(s.project.item(ItemId(item)).unwrap().as_media().unwrap().offline); + assert_eq!(s.project.sequences().next().unwrap().as_sequence().unwrap().video_tracks[0].items.len(), clips_before); + let unused = s.execute("file.newOfflineFile", json!({"name": "Spare", "seconds": 1, "fps": 25, "video": true, "audio": false, "width": 16, "height": 16})).unwrap()["item"] + .as_u64() + .unwrap(); + let r = s.execute("project.delete", json!({"items": [unused]})).unwrap(); + assert_eq!(r["items"], 1); + assert_eq!(r["offline"], 0); + assert!(s.project.item(ItemId(unused)).is_none()); +}