From b6c604c104c00234c7c48ff543095cc58e226f70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20Cie=C5=9Blik?= <132496025+Hathor875@users.noreply.github.com> Date: Sat, 10 Oct 2026 04:21:06 +0200 Subject: [PATCH] clip.link: pair video clips with overlapping audio clips (#507) When linking a selection containing multiple video clips, create one link per video clip with its overlapping audio clips rather than a single link for all clips, matching Premiere Pro behavior. --- contributors/people.toml | 4 ++ crates/engine/src/commands.rs | 71 +++++++++++++++++++-- crates/engine/src/explicit_targets_tests.rs | 38 +++++++++++ 3 files changed, 109 insertions(+), 4 deletions(-) diff --git a/contributors/people.toml b/contributors/people.toml index 25cf020e..c7762744 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -40,3 +40,7 @@ sync_github_name = true [people.ItzBloxy] display_name = "Bloxy" + +[people.Hathor875] +real_name = "Krzysztof Cieślik" +sync_github_name = true diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index b5e4c847..d6065824 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -1342,10 +1342,73 @@ fn build() -> Vec { let sel = clips_p(s, p); let linked = sel.iter().all(|c| s.active_sequence().and_then(|q| q.find_item(*c)).is_some_and(|(_, i)| i.link.is_some())); s.edit_sequence(if linked { "Unlink" } else { "Link" }, |q, ctx, _| { - let l = if linked { None } else { Some(ctx.alloc()) }; - for c in &sel { - if let Some((_, i)) = q.find_item_mut(*c) { - i.link = l; + if linked { + for c in &sel { + if let Some((_, i)) = q.find_item_mut(*c) { + i.link = None; + } + } + } else { + let has_video = sel.iter().any(|c| q.find_item(*c).is_some_and(|(tid, _)| q.video_tracks.iter().any(|t| t.id == tid))); + let has_audio = sel.iter().any(|c| q.find_item(*c).is_some_and(|(tid, _)| q.audio_tracks.iter().any(|t| t.id == tid))); + let v_clips: Vec<(ClipId, filmcraft_project::ItemId, filmcraft_time::TimeRange)> = sel + .iter() + .filter_map(|c| { + let (tid, i) = q.find_item(*c)?; + q.video_tracks.iter().any(|t| t.id == tid).then_some((*c, i.item, i.range())) + }) + .collect(); + if has_video && has_audio && v_clips.len() > 1 { + let audio_specs: Vec<(ClipId, filmcraft_project::ItemId, filmcraft_time::TimeRange)> = sel + .iter() + .filter_map(|c| { + let (tid, i) = q.find_item(*c)?; + q.audio_tracks.iter().any(|t| t.id == tid).then_some((*c, i.item, i.range())) + }) + .collect(); + let mut linked_audios = std::collections::HashSet::new(); + for (vid, item_id, vrange) in &v_clips { + let l = ctx.alloc(); + if let Some((_, vi)) = q.find_item_mut(*vid) { + vi.link = Some(l); + } + // Pair with overlapping audio clips from the same item, or overlapping audio clips if no same-item audio + let matching: Vec = audio_specs + .iter() + .filter(|(aid, aitem, arange)| !linked_audios.contains(aid) && aitem == item_id && arange.overlaps(vrange)) + .map(|(aid, _, _)| *aid) + .collect(); + let to_link = if !matching.is_empty() { + matching + } else { + audio_specs + .iter() + .filter(|(aid, _, arange)| !linked_audios.contains(aid) && arange.overlaps(vrange)) + .map(|(aid, _, _)| *aid) + .collect() + }; + for aid in to_link { + linked_audios.insert(aid); + if let Some((_, ai)) = q.find_item_mut(aid) { + ai.link = Some(l); + } + } + } + // Any remaining selected audio clips get their own links + for (aid, _, _) in audio_specs { + if !linked_audios.contains(&aid) + && let Some((_, ai)) = q.find_item_mut(aid) + { + ai.link = Some(ctx.alloc()); + } + } + } else { + let l = ctx.alloc(); + for c in &sel { + if let Some((_, i)) = q.find_item_mut(*c) { + i.link = Some(l); + } + } } } Ok(()) diff --git a/crates/engine/src/explicit_targets_tests.rs b/crates/engine/src/explicit_targets_tests.rs index 1f2eca77..d581a495 100644 --- a/crates/engine/src/explicit_targets_tests.rs +++ b/crates/engine/src/explicit_targets_tests.rs @@ -211,3 +211,41 @@ fn project_delete_takes_explicit_bins_and_items_without_a_selection() { s.execute("edit.undo", json!({})).unwrap(); assert_eq!(s.project.items.len(), before); } + +#[test] +fn link_multiple_video_and_audio_pairs_creates_one_link_per_pair() { + let mut s = demo(); + let v_clips = v1(&s); + let a_clips = a1(&s); + assert!(v_clips.len() >= 3 && a_clips.len() >= 3); + let (v0, v1, v2) = (v_clips[0].id, v_clips[1].id, v_clips[2].id); + let (a0, a1, a2) = (a_clips[0].id, a_clips[1].id, a_clips[2].id); + + // First, unlink all three pairs + let r = s.execute("clip.link", json!({"clips": [v0.0, a0.0, v1.0, a1.0, v2.0, a2.0]})).unwrap(); + assert_eq!(r["linked"], false); + for cid in [v0, a0, v1, a1, v2, a2] { + assert_eq!(clip(&s, cid).unwrap().link, None); + } + + // Now, run clip.link on all six clips. In Premiere parity (#507), + // it should create 3 separate links (one per pair), not one big link for all six. + let r = s.execute("clip.link", json!({"clips": [v0.0, a0.0, v1.0, a1.0, v2.0, a2.0]})).unwrap(); + assert_eq!(r["linked"], true); + + let l0 = clip(&s, v0).unwrap().link; + let la0 = clip(&s, a0).unwrap().link; + let l1 = clip(&s, v1).unwrap().link; + let la1 = clip(&s, a1).unwrap().link; + let l2 = clip(&s, v2).unwrap().link; + let la2 = clip(&s, a2).unwrap().link; + + assert!(l0.is_some() && l1.is_some() && l2.is_some()); + assert_eq!(l0, la0, "v0 linked to a0"); + assert_eq!(l1, la1, "v1 linked to a1"); + assert_eq!(l2, la2, "v2 linked to a2"); + + assert_ne!(l0, l1, "different pairs must not share the same link id"); + assert_ne!(l1, l2, "different pairs must not share the same link id"); + assert_ne!(l0, l2, "different pairs must not share the same link id"); +}