diff --git a/crates/engine/src/edit.rs b/crates/engine/src/edit.rs index e7a8e977..dbecab48 100644 --- a/crates/engine/src/edit.rs +++ b/crates/engine/src/edit.rs @@ -405,7 +405,8 @@ pub fn trim_end_to(s: &mut Session, track: TrackId, at: Samples) -> usize { n } -/// Heal separations inside `range`: merge neighbouring audio clips that continue the same file. +/// Heal separations inside `range`: merge neighbouring audio clips that continue the same file, +/// either touching (a split) or with an empty gap that matches the gap in the source (a cut). pub fn heal(s: &mut Session, track: TrackId, range: Range) -> usize { let Some(pl) = s.track_mut(track).and_then(|t| t.playlist_mut()) else { return 0 }; pl.sort(); @@ -413,13 +414,16 @@ pub fn heal(s: &mut Session, track: TrackId, range: Range) -> usize { let mut healed = 0; for c in pl.clips.drain(..) { if let Some(prev) = out.last_mut() { - let contiguous = prev.end() == c.start && c.start >= range.start && c.start <= range.end; + let contiguous = c.start >= prev.end() && prev.end() >= range.start && c.start <= range.end; + let gap = if contiguous { c.start.saturating_sub(prev.end()) } else { 0 }; let same = match (&prev.content, &c.content) { - (ClipContent::Audio { source: a, offset: oa }, ClipContent::Audio { source: b, offset: ob }) => a == b && oa + prev.length == *ob, + (ClipContent::Audio { source: a, offset: oa }, ClipContent::Audio { source: b, offset: ob }) => { + a == b && oa.saturating_add(prev.length).saturating_add(gap) == *ob + } _ => false, }; if contiguous && same && (prev.gain_db - c.gain_db).abs() < 1e-6 { - prev.length += c.length; + prev.length = c.end().saturating_sub(prev.start); prev.fade_out = c.fade_out; healed += 1; continue; @@ -880,6 +884,37 @@ mod tests { assert_eq!(clips(&s, t), vec![(1000, 4000, 1000)]); } + #[test] + fn heal_rejoins_cut_separation() { + let (mut s, t) = session_with_clip(); + clear_range(&mut s, t, Range::new(2000, 3000), false); + assert_eq!(heal(&mut s, t, Range::new(1500, 3500)), 1); + assert_eq!(clips(&s, t), vec![(1000, 4000, 1000)]); + // A selection that does not span the whole gap leaves it alone. + let (mut s, t) = session_with_clip(); + clear_range(&mut s, t, Range::new(2000, 3000), false); + assert_eq!(heal(&mut s, t, Range::new(2500, 3500)), 0); + // Shuffled pieces no longer line up with their source and stay apart. + let (mut s, t) = session_with_clip(); + clear_range(&mut s, t, Range::new(2000, 3000), true); + assert_eq!(heal(&mut s, t, Range::new(0, 10_000)), 0); + // Moved apart: the timeline gap no longer matches the source gap. + let (mut s, t) = session_with_clip(); + clear_range(&mut s, t, Range::new(2000, 3000), false); + if let Some(pl) = s.track_mut(t).and_then(|t| t.playlist_mut()) { + pl.clips[1].start = 3500; + } + assert_eq!(heal(&mut s, t, Range::new(0, 10_000)), 0); + // Overlapping neighbours whose offsets line up are not a separation. + let (mut s, t) = session_with_clip(); + assert_eq!(separate_at(&mut s, t, 3000), 1); + if let Some(pl) = s.track_mut(t).and_then(|t| t.playlist_mut()) { + pl.clips[1].start = 2500; + } + assert_eq!(heal(&mut s, t, Range::new(0, 10_000)), 0); + assert_eq!(clips(&s, t).len(), 2); + } + #[test] fn clear_slip_and_shuffle() { let (mut s, t) = session_with_clip();