Skip to main content

oximedia_edit/
timeline_validator.rs

1//! Timeline validation: detect structural issues such as overlapping clips,
2//! gaps, zero-duration clips, inconsistent source ranges, and orphan references.
3//!
4//! [`TimelineValidator`] inspects a [`crate::timeline::Timeline`] and produces
5//! a list of [`ValidationIssue`]s categorized by [`IssueSeverity`].
6//!
7//! # Example
8//! ```rust
9//! use oximedia_edit::timeline::Timeline;
10//! use oximedia_edit::timeline_validator::{TimelineValidator, IssueSeverity};
11//! use oximedia_core::Rational;
12//!
13//! let timeline = Timeline::new(Rational::new(1, 1000), Rational::new(30, 1));
14//! let issues = TimelineValidator::validate(&timeline);
15//! let errors = issues.iter().filter(|i| i.severity == IssueSeverity::Error).count();
16//! assert_eq!(errors, 0);
17//! ```
18
19#![allow(dead_code)]
20
21use crate::clip::Clip;
22use crate::timeline::Timeline;
23
24// ─────────────────────────────────────────────────────────────────────────────
25// IssueSeverity
26// ─────────────────────────────────────────────────────────────────────────────
27
28/// How critical a validation finding is.
29#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
30pub enum IssueSeverity {
31    /// Informational — something unusual but not harmful.
32    Info,
33    /// Warning — may cause unexpected behaviour during playback/export.
34    Warning,
35    /// Error — will cause incorrect output or crashes.
36    Error,
37}
38
39impl IssueSeverity {
40    /// Human-readable label.
41    #[must_use]
42    pub fn label(self) -> &'static str {
43        match self {
44            Self::Info => "info",
45            Self::Warning => "warning",
46            Self::Error => "error",
47        }
48    }
49}
50
51// ─────────────────────────────────────────────────────────────────────────────
52// IssueKind
53// ─────────────────────────────────────────────────────────────────────────────
54
55/// Specific type of validation finding.
56#[derive(Clone, Debug, PartialEq)]
57pub enum IssueKind {
58    /// Two clips on the same track overlap in time.
59    OverlappingClips {
60        /// Track index.
61        track_index: usize,
62        /// First clip ID.
63        clip_a: u64,
64        /// Second clip ID.
65        clip_b: u64,
66    },
67    /// A gap exists between consecutive clips on a track.
68    Gap {
69        /// Track index.
70        track_index: usize,
71        /// Gap start position (timebase units).
72        gap_start: i64,
73        /// Gap end position (timebase units).
74        gap_end: i64,
75    },
76    /// A clip has zero or negative duration.
77    ZeroDurationClip {
78        /// Track index.
79        track_index: usize,
80        /// Offending clip ID.
81        clip_id: u64,
82    },
83    /// A clip's source_in >= source_out.
84    InvalidSourceRange {
85        /// Track index.
86        track_index: usize,
87        /// Offending clip ID.
88        clip_id: u64,
89        /// Source in point.
90        source_in: i64,
91        /// Source out point.
92        source_out: i64,
93    },
94    /// A clip has a negative timeline start.
95    NegativeTimelineStart {
96        /// Track index.
97        track_index: usize,
98        /// Offending clip ID.
99        clip_id: u64,
100        /// The negative start position.
101        start: i64,
102    },
103    /// Clip speed is zero or negative.
104    InvalidSpeed {
105        /// Track index.
106        track_index: usize,
107        /// Offending clip ID.
108        clip_id: u64,
109        /// The invalid speed value.
110        speed: f64,
111    },
112    /// Duplicate clip ID found across the timeline.
113    DuplicateClipId {
114        /// The duplicated clip ID.
115        clip_id: u64,
116    },
117    /// A clip's opacity is outside [0.0, 1.0].
118    OpacityOutOfRange {
119        /// Track index.
120        track_index: usize,
121        /// Offending clip ID.
122        clip_id: u64,
123        /// The out-of-range opacity.
124        opacity: f32,
125    },
126    /// An empty track (no clips).
127    EmptyTrack {
128        /// Track index.
129        track_index: usize,
130    },
131    /// Timeline reported duration does not match actual clip extents.
132    DurationMismatch {
133        /// Duration stored on the timeline.
134        reported: i64,
135        /// Actual maximum clip end.
136        actual: i64,
137    },
138}
139
140// ─────────────────────────────────────────────────────────────────────────────
141// ValidationIssue
142// ─────────────────────────────────────────────────────────────────────────────
143
144/// A single validation finding.
145#[derive(Clone, Debug)]
146pub struct ValidationIssue {
147    /// Severity of the issue.
148    pub severity: IssueSeverity,
149    /// What was found.
150    pub kind: IssueKind,
151    /// Human-readable description.
152    pub message: String,
153}
154
155impl ValidationIssue {
156    /// Create a new issue.
157    #[must_use]
158    fn new(severity: IssueSeverity, kind: IssueKind, message: impl Into<String>) -> Self {
159        Self {
160            severity,
161            kind,
162            message: message.into(),
163        }
164    }
165}
166
167// ─────────────────────────────────────────────────────────────────────────────
168// ValidationReport
169// ─────────────────────────────────────────────────────────────────────────────
170
171/// Aggregated validation result.
172#[derive(Clone, Debug, Default)]
173pub struct ValidationReport {
174    /// All issues found.
175    pub issues: Vec<ValidationIssue>,
176}
177
178impl ValidationReport {
179    /// Number of errors.
180    #[must_use]
181    pub fn error_count(&self) -> usize {
182        self.issues
183            .iter()
184            .filter(|i| i.severity == IssueSeverity::Error)
185            .count()
186    }
187
188    /// Number of warnings.
189    #[must_use]
190    pub fn warning_count(&self) -> usize {
191        self.issues
192            .iter()
193            .filter(|i| i.severity == IssueSeverity::Warning)
194            .count()
195    }
196
197    /// Whether the timeline passed validation with no errors.
198    #[must_use]
199    pub fn is_valid(&self) -> bool {
200        self.error_count() == 0
201    }
202
203    /// Whether the timeline is fully clean (no issues at all).
204    #[must_use]
205    pub fn is_clean(&self) -> bool {
206        self.issues.is_empty()
207    }
208
209    /// Filter issues by severity.
210    #[must_use]
211    pub fn issues_of(&self, severity: IssueSeverity) -> Vec<&ValidationIssue> {
212        self.issues
213            .iter()
214            .filter(|i| i.severity == severity)
215            .collect()
216    }
217}
218
219// ─────────────────────────────────────────────────────────────────────────────
220// TimelineValidator
221// ─────────────────────────────────────────────────────────────────────────────
222
223/// Validates a [`Timeline`] for structural correctness.
224pub struct TimelineValidator;
225
226impl TimelineValidator {
227    /// Run all validation checks on `timeline` and return a flat list of issues.
228    #[must_use]
229    pub fn validate(timeline: &Timeline) -> Vec<ValidationIssue> {
230        let report = Self::validate_full(timeline);
231        report.issues
232    }
233
234    /// Run all validation checks and return a [`ValidationReport`].
235    #[must_use]
236    pub fn validate_full(timeline: &Timeline) -> ValidationReport {
237        let mut report = ValidationReport::default();
238
239        Self::check_duplicate_ids(timeline, &mut report);
240        Self::check_duration_mismatch(timeline, &mut report);
241
242        for (track_idx, track) in timeline.tracks.iter().enumerate() {
243            if track.clips.is_empty() {
244                report.issues.push(ValidationIssue::new(
245                    IssueSeverity::Info,
246                    IssueKind::EmptyTrack {
247                        track_index: track_idx,
248                    },
249                    format!("Track {track_idx} has no clips"),
250                ));
251                continue;
252            }
253
254            Self::check_clips(track_idx, &track.clips, &mut report);
255            Self::check_overlaps_and_gaps(track_idx, &track.clips, &mut report);
256        }
257
258        report
259    }
260
261    /// Check per-clip invariants.
262    fn check_clips(track_idx: usize, clips: &[Clip], report: &mut ValidationReport) {
263        for clip in clips {
264            // Zero or negative duration
265            if clip.timeline_duration <= 0 {
266                report.issues.push(ValidationIssue::new(
267                    IssueSeverity::Error,
268                    IssueKind::ZeroDurationClip {
269                        track_index: track_idx,
270                        clip_id: clip.id,
271                    },
272                    format!(
273                        "Clip {} on track {track_idx} has duration {}",
274                        clip.id, clip.timeline_duration
275                    ),
276                ));
277            }
278
279            // Negative start
280            if clip.timeline_start < 0 {
281                report.issues.push(ValidationIssue::new(
282                    IssueSeverity::Warning,
283                    IssueKind::NegativeTimelineStart {
284                        track_index: track_idx,
285                        clip_id: clip.id,
286                        start: clip.timeline_start,
287                    },
288                    format!(
289                        "Clip {} on track {track_idx} starts at negative position {}",
290                        clip.id, clip.timeline_start
291                    ),
292                ));
293            }
294
295            // Invalid source range
296            if clip.source_in >= clip.source_out {
297                report.issues.push(ValidationIssue::new(
298                    IssueSeverity::Error,
299                    IssueKind::InvalidSourceRange {
300                        track_index: track_idx,
301                        clip_id: clip.id,
302                        source_in: clip.source_in,
303                        source_out: clip.source_out,
304                    },
305                    format!(
306                        "Clip {} on track {track_idx}: source_in ({}) >= source_out ({})",
307                        clip.id, clip.source_in, clip.source_out
308                    ),
309                ));
310            }
311
312            // Invalid speed
313            if clip.speed <= 0.0 {
314                report.issues.push(ValidationIssue::new(
315                    IssueSeverity::Error,
316                    IssueKind::InvalidSpeed {
317                        track_index: track_idx,
318                        clip_id: clip.id,
319                        speed: clip.speed,
320                    },
321                    format!(
322                        "Clip {} on track {track_idx} has non-positive speed {}",
323                        clip.id, clip.speed
324                    ),
325                ));
326            }
327
328            // Opacity out of range
329            if !(0.0..=1.0).contains(&clip.opacity) {
330                report.issues.push(ValidationIssue::new(
331                    IssueSeverity::Warning,
332                    IssueKind::OpacityOutOfRange {
333                        track_index: track_idx,
334                        clip_id: clip.id,
335                        opacity: clip.opacity,
336                    },
337                    format!(
338                        "Clip {} on track {track_idx} has opacity {} (expected 0.0–1.0)",
339                        clip.id, clip.opacity
340                    ),
341                ));
342            }
343        }
344    }
345
346    /// Check for overlapping or gapped clips on a single track.
347    fn check_overlaps_and_gaps(track_idx: usize, clips: &[Clip], report: &mut ValidationReport) {
348        for window in clips.windows(2) {
349            let a = &window[0];
350            let b = &window[1];
351            let a_end = a.timeline_start + a.timeline_duration;
352
353            if a_end > b.timeline_start {
354                // Overlap
355                report.issues.push(ValidationIssue::new(
356                    IssueSeverity::Error,
357                    IssueKind::OverlappingClips {
358                        track_index: track_idx,
359                        clip_a: a.id,
360                        clip_b: b.id,
361                    },
362                    format!(
363                        "Clips {} and {} overlap on track {track_idx} ({}..{} vs {}..{})",
364                        a.id,
365                        b.id,
366                        a.timeline_start,
367                        a_end,
368                        b.timeline_start,
369                        b.timeline_start + b.timeline_duration,
370                    ),
371                ));
372            } else if a_end < b.timeline_start {
373                // Gap
374                report.issues.push(ValidationIssue::new(
375                    IssueSeverity::Info,
376                    IssueKind::Gap {
377                        track_index: track_idx,
378                        gap_start: a_end,
379                        gap_end: b.timeline_start,
380                    },
381                    format!(
382                        "Gap on track {track_idx} between clips {} and {} ({}..{})",
383                        a.id, b.id, a_end, b.timeline_start,
384                    ),
385                ));
386            }
387        }
388    }
389
390    /// Check for duplicate clip IDs across the entire timeline.
391    fn check_duplicate_ids(timeline: &Timeline, report: &mut ValidationReport) {
392        let mut seen = std::collections::HashSet::new();
393        for track in &timeline.tracks {
394            for clip in &track.clips {
395                if !seen.insert(clip.id) {
396                    report.issues.push(ValidationIssue::new(
397                        IssueSeverity::Error,
398                        IssueKind::DuplicateClipId { clip_id: clip.id },
399                        format!("Duplicate clip ID {} found in timeline", clip.id),
400                    ));
401                }
402            }
403        }
404    }
405
406    /// Check if reported timeline duration matches actual extent.
407    fn check_duration_mismatch(timeline: &Timeline, report: &mut ValidationReport) {
408        let mut actual_end: i64 = 0;
409        for track in &timeline.tracks {
410            for clip in &track.clips {
411                let end = clip.timeline_start + clip.timeline_duration;
412                if end > actual_end {
413                    actual_end = end;
414                }
415            }
416        }
417        if timeline.duration != actual_end {
418            report.issues.push(ValidationIssue::new(
419                IssueSeverity::Warning,
420                IssueKind::DurationMismatch {
421                    reported: timeline.duration,
422                    actual: actual_end,
423                },
424                format!(
425                    "Timeline duration ({}) does not match actual clip extent ({})",
426                    timeline.duration, actual_end,
427                ),
428            ));
429        }
430    }
431}
432
433#[cfg(test)]
434mod tests {
435    use super::*;
436    use crate::clip::{Clip, ClipType};
437    use crate::timeline::{Timeline, TrackType};
438    use oximedia_core::Rational;
439
440    fn make_timeline() -> Timeline {
441        Timeline::new(Rational::new(1, 1000), Rational::new(30, 1))
442    }
443
444    #[test]
445    fn test_empty_timeline_is_valid() {
446        let tl = make_timeline();
447        let report = TimelineValidator::validate_full(&tl);
448        assert!(report.is_valid());
449        assert!(report.is_clean());
450    }
451
452    #[test]
453    fn test_single_clip_no_issues() {
454        let mut tl = make_timeline();
455        let track = tl.add_track(TrackType::Video);
456        let clip = Clip::new(1, ClipType::Video, 0, 1000);
457        tl.add_clip(track, clip).ok();
458        let report = TimelineValidator::validate_full(&tl);
459        assert!(report.is_valid());
460    }
461
462    #[test]
463    fn test_overlapping_clips_detected() {
464        let mut tl = make_timeline();
465        let track = tl.add_track(TrackType::Video);
466        // Manually push overlapping clips (bypass add_clip validation)
467        tl.tracks[track]
468            .clips
469            .push(Clip::new(10, ClipType::Video, 0, 500));
470        tl.tracks[track]
471            .clips
472            .push(Clip::new(11, ClipType::Video, 400, 500));
473        let report = TimelineValidator::validate_full(&tl);
474        assert!(!report.is_valid());
475        let overlaps: Vec<_> = report
476            .issues
477            .iter()
478            .filter(|i| matches!(i.kind, IssueKind::OverlappingClips { .. }))
479            .collect();
480        assert_eq!(overlaps.len(), 1);
481    }
482
483    #[test]
484    fn test_gap_detected() {
485        let mut tl = make_timeline();
486        let track = tl.add_track(TrackType::Video);
487        let c1 = Clip::new(1, ClipType::Video, 0, 100);
488        let c2 = Clip::new(2, ClipType::Video, 200, 100);
489        tl.add_clip(track, c1).ok();
490        tl.add_clip(track, c2).ok();
491        let issues = TimelineValidator::validate(&tl);
492        let gaps: Vec<_> = issues
493            .iter()
494            .filter(|i| matches!(i.kind, IssueKind::Gap { .. }))
495            .collect();
496        assert_eq!(gaps.len(), 1);
497    }
498
499    #[test]
500    fn test_zero_duration_clip_error() {
501        let mut tl = make_timeline();
502        let track = tl.add_track(TrackType::Video);
503        tl.tracks[track]
504            .clips
505            .push(Clip::new(50, ClipType::Video, 0, 0));
506        let report = TimelineValidator::validate_full(&tl);
507        assert!(!report.is_valid());
508        assert!(report
509            .issues
510            .iter()
511            .any(|i| matches!(i.kind, IssueKind::ZeroDurationClip { clip_id: 50, .. })));
512    }
513
514    #[test]
515    fn test_invalid_source_range_error() {
516        let mut tl = make_timeline();
517        let track = tl.add_track(TrackType::Video);
518        let mut clip = Clip::new(60, ClipType::Video, 0, 100);
519        clip.source_in = 200;
520        clip.source_out = 100; // invalid: in >= out
521        tl.tracks[track].clips.push(clip);
522        let report = TimelineValidator::validate_full(&tl);
523        assert!(!report.is_valid());
524        assert!(report
525            .issues
526            .iter()
527            .any(|i| matches!(i.kind, IssueKind::InvalidSourceRange { clip_id: 60, .. })));
528    }
529
530    #[test]
531    fn test_negative_speed_error() {
532        let mut tl = make_timeline();
533        let track = tl.add_track(TrackType::Video);
534        let mut clip = Clip::new(70, ClipType::Video, 0, 100);
535        clip.speed = -1.0;
536        tl.tracks[track].clips.push(clip);
537        let report = TimelineValidator::validate_full(&tl);
538        assert!(!report.is_valid());
539    }
540
541    #[test]
542    fn test_opacity_out_of_range_warning() {
543        let mut tl = make_timeline();
544        let track = tl.add_track(TrackType::Video);
545        let mut clip = Clip::new(80, ClipType::Video, 0, 100);
546        clip.opacity = 1.5;
547        tl.tracks[track].clips.push(clip);
548        tl.duration = 100; // match actual extent to avoid duration mismatch warning
549        let report = TimelineValidator::validate_full(&tl);
550        assert!(report.is_valid()); // warnings don't make it invalid
551        assert_eq!(report.warning_count(), 1);
552    }
553
554    #[test]
555    fn test_duplicate_clip_id_error() {
556        let mut tl = make_timeline();
557        let t1 = tl.add_track(TrackType::Video);
558        let t2 = tl.add_track(TrackType::Audio);
559        tl.tracks[t1]
560            .clips
561            .push(Clip::new(99, ClipType::Video, 0, 100));
562        tl.tracks[t2]
563            .clips
564            .push(Clip::new(99, ClipType::Audio, 0, 100));
565        let report = TimelineValidator::validate_full(&tl);
566        assert!(!report.is_valid());
567        assert!(report
568            .issues
569            .iter()
570            .any(|i| matches!(i.kind, IssueKind::DuplicateClipId { clip_id: 99 })));
571    }
572
573    #[test]
574    fn test_empty_track_info() {
575        let mut tl = make_timeline();
576        tl.add_track(TrackType::Video);
577        let report = TimelineValidator::validate_full(&tl);
578        assert!(report.is_valid());
579        let infos = report.issues_of(IssueSeverity::Info);
580        assert!(!infos.is_empty());
581        assert!(infos
582            .iter()
583            .any(|i| matches!(i.kind, IssueKind::EmptyTrack { track_index: 0 })));
584    }
585
586    #[test]
587    fn test_duration_mismatch_warning() {
588        let mut tl = make_timeline();
589        let track = tl.add_track(TrackType::Video);
590        let clip = Clip::new(1, ClipType::Video, 0, 500);
591        tl.add_clip(track, clip).ok();
592        // Tamper with reported duration
593        tl.duration = 999;
594        let report = TimelineValidator::validate_full(&tl);
595        assert!(report.issues.iter().any(|i| matches!(
596            i.kind,
597            IssueKind::DurationMismatch {
598                reported: 999,
599                actual: 500
600            }
601        )));
602    }
603
604    #[test]
605    fn test_negative_timeline_start_warning() {
606        let mut tl = make_timeline();
607        let track = tl.add_track(TrackType::Video);
608        let clip = Clip::new(42, ClipType::Video, -100, 200);
609        tl.tracks[track].clips.push(clip);
610        let report = TimelineValidator::validate_full(&tl);
611        assert!(report.warning_count() >= 1);
612        assert!(report.issues.iter().any(|i| matches!(
613            i.kind,
614            IssueKind::NegativeTimelineStart {
615                clip_id: 42,
616                start: -100,
617                ..
618            }
619        )));
620    }
621
622    #[test]
623    fn test_report_is_clean_when_no_issues() {
624        let tl = make_timeline();
625        let report = TimelineValidator::validate_full(&tl);
626        assert!(report.is_clean());
627        assert_eq!(report.error_count(), 0);
628        assert_eq!(report.warning_count(), 0);
629    }
630
631    #[test]
632    fn test_severity_ordering() {
633        assert!(IssueSeverity::Info < IssueSeverity::Warning);
634        assert!(IssueSeverity::Warning < IssueSeverity::Error);
635    }
636
637    #[test]
638    fn test_severity_label() {
639        assert_eq!(IssueSeverity::Info.label(), "info");
640        assert_eq!(IssueSeverity::Warning.label(), "warning");
641        assert_eq!(IssueSeverity::Error.label(), "error");
642    }
643}