use crate::clip::Clip;
use crate::edit::clip_footprint;
use crate::ids::{ClipId, TrackId};
use crate::timeline::Timeline;
use crate::track::Track;
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TimelineIssue {
ClipOverlap {
track: TrackId,
earlier: ClipId,
later: ClipId,
},
TrimOutBeforeIn {
clip: ClipId,
},
EmptyFootprint {
clip: ClipId,
},
GeneratedClipWithoutOutPoint {
clip: ClipId,
},
DanglingTransition {
track: TrackId,
clip: ClipId,
},
}
impl Timeline {
#[must_use]
pub fn validate(&self) -> Vec<TimelineIssue> {
let mut issues = Vec::new();
for track in self.video_tracks.iter().chain(self.audio_tracks.iter()) {
check_track(track, &mut issues);
}
issues
}
}
fn check_track(track: &Track, issues: &mut Vec<TimelineIssue>) {
for clip in &track.clips {
check_clip_trim(clip, issues);
if clip.source_path().is_none() && clip.out_point.is_none() {
issues.push(TimelineIssue::GeneratedClipWithoutOutPoint { clip: clip.id });
}
}
if let Some(first) = track.clips.first()
&& first.transition.is_some()
{
issues.push(TimelineIssue::DanglingTransition {
track: track.id,
clip: first.id,
});
}
check_overlaps(track, issues);
}
fn check_clip_trim(clip: &Clip, issues: &mut Vec<TimelineIssue>) {
if let (Some(in_point), Some(out_point)) = (clip.in_point, clip.out_point) {
if out_point < in_point {
issues.push(TimelineIssue::TrimOutBeforeIn { clip: clip.id });
} else if out_point == in_point {
issues.push(TimelineIssue::EmptyFootprint { clip: clip.id });
}
}
}
fn check_overlaps(track: &Track, issues: &mut Vec<TimelineIssue>) {
let mut spans: Vec<(ClipId, std::time::Duration, std::time::Duration)> = track
.clips
.iter()
.filter_map(|c| clip_footprint(c).map(|fp| (c.id, c.offset, c.offset.saturating_add(fp))))
.collect();
spans.sort_by_key(|&(_, start, _)| start);
for i in 0..spans.len() {
let (id_i, _start_i, end_i) = spans[i];
for &(id_j, start_j, _end_j) in &spans[i + 1..] {
if start_j >= end_i {
break;
}
issues.push(TimelineIssue::ClipOverlap {
track: track.id,
earlier: id_i,
later: id_j,
});
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use std::time::Duration;
use ff_filter::XfadeTransition;
use ff_format::Color;
use super::*;
fn base(clips: Vec<Clip>) -> crate::timeline::TimelineBuilder {
Timeline::builder()
.canvas(1920, 1080)
.frame_rate(30.0)
.video_track(clips)
}
#[test]
fn validate_clean_timeline_should_have_no_issues() {
let t = base(vec![
Clip::new("a.mp4").trim(Duration::ZERO, Duration::from_secs(4)),
Clip::new("b.mp4")
.trim(Duration::ZERO, Duration::from_secs(4))
.offset(Duration::from_secs(4)),
])
.build()
.unwrap();
assert!(
t.validate().is_empty(),
"clean timeline: {:?}",
t.validate()
);
}
#[test]
fn validate_should_detect_track_overlap() {
let t = base(vec![
Clip::new("a.mp4").trim(Duration::ZERO, Duration::from_secs(4)),
Clip::new("b.mp4")
.trim(Duration::ZERO, Duration::from_secs(4))
.offset(Duration::from_secs(2)),
])
.build()
.unwrap();
let ids: Vec<_> = t.video_tracks()[0].clips.iter().map(|c| c.id).collect();
assert!(t.validate().contains(&TimelineIssue::ClipOverlap {
track: t.video_tracks()[0].id,
earlier: ids[0],
later: ids[1],
}));
}
#[test]
fn validate_overlap_should_catch_a_long_clip_spanning_a_far_one() {
let t = base(vec![
Clip::new("a.mp4").trim(Duration::ZERO, Duration::from_secs(10)),
Clip::new("b.mp4")
.trim(Duration::ZERO, Duration::from_secs(2))
.offset(Duration::from_secs(2)),
Clip::new("c.mp4")
.trim(Duration::ZERO, Duration::from_secs(2))
.offset(Duration::from_secs(6)),
])
.build()
.unwrap();
let track = t.video_tracks()[0].id;
let ids: Vec<_> = t.video_tracks()[0].clips.iter().map(|c| c.id).collect();
let overlaps: Vec<_> = t
.validate()
.into_iter()
.filter(|i| matches!(i, TimelineIssue::ClipOverlap { .. }))
.collect();
assert!(overlaps.contains(&TimelineIssue::ClipOverlap {
track,
earlier: ids[0],
later: ids[1],
}));
assert!(
overlaps.contains(&TimelineIssue::ClipOverlap {
track,
earlier: ids[0],
later: ids[2],
}),
"the far a-c overlap must be detected"
);
assert_eq!(overlaps.len(), 2, "b and c do not overlap each other");
}
#[test]
fn validate_should_detect_trim_out_before_in() {
let t = base(vec![
Clip::new("a.mp4").trim(Duration::from_secs(5), Duration::from_secs(2)),
])
.build()
.unwrap();
let id = t.video_tracks()[0].clips[0].id;
assert!(
t.validate()
.contains(&TimelineIssue::TrimOutBeforeIn { clip: id })
);
}
#[test]
fn validate_should_detect_empty_footprint() {
let t = base(vec![
Clip::new("a.mp4").trim(Duration::from_secs(3), Duration::from_secs(3)),
])
.build()
.unwrap();
let id = t.video_tracks()[0].clips[0].id;
assert!(
t.validate()
.contains(&TimelineIssue::EmptyFootprint { clip: id })
);
}
#[test]
fn validate_should_detect_generated_clip_without_out_point() {
let t = base(vec![Clip::solid(Color::rgb(0, 0, 0))])
.build()
.unwrap();
let id = t.video_tracks()[0].clips[0].id;
assert!(
t.validate()
.contains(&TimelineIssue::GeneratedClipWithoutOutPoint { clip: id })
);
let ok = base(vec![
Clip::solid(Color::rgb(0, 0, 0)).trim(Duration::ZERO, Duration::from_secs(1)),
])
.build()
.unwrap();
assert!(
!ok.validate()
.iter()
.any(|i| matches!(i, TimelineIssue::GeneratedClipWithoutOutPoint { .. }))
);
}
#[test]
fn validate_should_detect_dangling_transition() {
let t = base(vec![
Clip::new("a.mp4")
.trim(Duration::ZERO, Duration::from_secs(4))
.with_transition(XfadeTransition::Fade, Duration::from_millis(500)),
])
.build()
.unwrap();
let id = t.video_tracks()[0].clips[0].id;
assert!(t.validate().contains(&TimelineIssue::DanglingTransition {
track: t.video_tracks()[0].id,
clip: id,
}));
}
#[test]
fn validate_should_not_open_source_files() {
let t = Timeline::builder()
.canvas(1920, 1080)
.frame_rate(30.0)
.video_track(vec![
Clip::new("does_not_exist_1.mp4").trim(Duration::ZERO, Duration::from_secs(2)),
])
.audio_track(vec![Clip::new("does_not_exist_2.mp3")])
.build()
.unwrap();
assert!(
!t.validate()
.iter()
.any(|i| matches!(i, TimelineIssue::GeneratedClipWithoutOutPoint { .. }))
);
}
}