1#![allow(dead_code)]
20
21use crate::clip::Clip;
22use crate::timeline::Timeline;
23
24#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
30pub enum IssueSeverity {
31 Info,
33 Warning,
35 Error,
37}
38
39impl IssueSeverity {
40 #[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#[derive(Clone, Debug, PartialEq)]
57pub enum IssueKind {
58 OverlappingClips {
60 track_index: usize,
62 clip_a: u64,
64 clip_b: u64,
66 },
67 Gap {
69 track_index: usize,
71 gap_start: i64,
73 gap_end: i64,
75 },
76 ZeroDurationClip {
78 track_index: usize,
80 clip_id: u64,
82 },
83 InvalidSourceRange {
85 track_index: usize,
87 clip_id: u64,
89 source_in: i64,
91 source_out: i64,
93 },
94 NegativeTimelineStart {
96 track_index: usize,
98 clip_id: u64,
100 start: i64,
102 },
103 InvalidSpeed {
105 track_index: usize,
107 clip_id: u64,
109 speed: f64,
111 },
112 DuplicateClipId {
114 clip_id: u64,
116 },
117 OpacityOutOfRange {
119 track_index: usize,
121 clip_id: u64,
123 opacity: f32,
125 },
126 EmptyTrack {
128 track_index: usize,
130 },
131 DurationMismatch {
133 reported: i64,
135 actual: i64,
137 },
138}
139
140#[derive(Clone, Debug)]
146pub struct ValidationIssue {
147 pub severity: IssueSeverity,
149 pub kind: IssueKind,
151 pub message: String,
153}
154
155impl ValidationIssue {
156 #[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#[derive(Clone, Debug, Default)]
173pub struct ValidationReport {
174 pub issues: Vec<ValidationIssue>,
176}
177
178impl ValidationReport {
179 #[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 #[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 #[must_use]
199 pub fn is_valid(&self) -> bool {
200 self.error_count() == 0
201 }
202
203 #[must_use]
205 pub fn is_clean(&self) -> bool {
206 self.issues.is_empty()
207 }
208
209 #[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
219pub struct TimelineValidator;
225
226impl TimelineValidator {
227 #[must_use]
229 pub fn validate(timeline: &Timeline) -> Vec<ValidationIssue> {
230 let report = Self::validate_full(timeline);
231 report.issues
232 }
233
234 #[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 fn check_clips(track_idx: usize, clips: &[Clip], report: &mut ValidationReport) {
263 for clip in clips {
264 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 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 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 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 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 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 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 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 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 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 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; 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; let report = TimelineValidator::validate_full(&tl);
550 assert!(report.is_valid()); 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 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}