Skip to main content

oximedia_transcode/
output_verify.rs

1#![allow(dead_code)]
2//! Output file verification: constraint checking for transcode deliverables.
3
4/// A constraint that a transcode output must satisfy.
5#[derive(Debug, Clone, PartialEq)]
6pub enum OutputConstraint {
7    /// Video bitrate must not exceed the given value (bps).
8    MaxVideoBitrate(u64),
9    /// Video bitrate must be at least the given value (bps).
10    MinVideoBitrate(u64),
11    /// Audio bitrate must not exceed the given value (bps).
12    MaxAudioBitrate(u64),
13    /// Video width must equal the given pixel count.
14    ExactWidth(u32),
15    /// Video height must equal the given pixel count.
16    ExactHeight(u32),
17    /// Output file must not exceed this size in bytes.
18    MaxFileSizeBytes(u64),
19    /// Duration must be within `tolerance` seconds of `expected`.
20    DurationWithinTolerance {
21        /// Expected duration in seconds.
22        expected: f64,
23        /// Allowed tolerance in seconds.
24        tolerance: f64,
25    },
26    /// The file must carry an audio track.
27    HasAudio,
28    /// The file must carry a video track.
29    HasVideo,
30}
31
32impl OutputConstraint {
33    /// A short identifier used in reports.
34    #[must_use]
35    pub fn constraint_name(&self) -> &'static str {
36        match self {
37            Self::MaxVideoBitrate(_) => "max_video_bitrate",
38            Self::MinVideoBitrate(_) => "min_video_bitrate",
39            Self::MaxAudioBitrate(_) => "max_audio_bitrate",
40            Self::ExactWidth(_) => "exact_width",
41            Self::ExactHeight(_) => "exact_height",
42            Self::MaxFileSizeBytes(_) => "max_file_size_bytes",
43            Self::DurationWithinTolerance { .. } => "duration_within_tolerance",
44            Self::HasAudio => "has_audio",
45            Self::HasVideo => "has_video",
46        }
47    }
48
49    /// Returns `true` for constraints that relate to bitrate.
50    #[must_use]
51    pub fn is_bitrate_constraint(&self) -> bool {
52        matches!(
53            self,
54            Self::MaxVideoBitrate(_) | Self::MinVideoBitrate(_) | Self::MaxAudioBitrate(_)
55        )
56    }
57
58    /// Returns `true` for constraints about presence of a stream.
59    #[must_use]
60    pub fn is_stream_presence(&self) -> bool {
61        matches!(self, Self::HasAudio | Self::HasVideo)
62    }
63}
64
65/// A single constraint violation found during output verification.
66#[derive(Debug, Clone)]
67pub struct OutputViolation {
68    /// The constraint that was violated.
69    pub constraint: OutputConstraint,
70    /// Human-readable description of what was found vs. what was expected.
71    pub description: String,
72    /// Whether this violation blocks delivery (true) or is merely advisory.
73    pub blocking: bool,
74}
75
76impl OutputViolation {
77    /// Create a new violation.
78    #[must_use]
79    pub fn new(
80        constraint: OutputConstraint,
81        description: impl Into<String>,
82        blocking: bool,
83    ) -> Self {
84        Self {
85            constraint,
86            description: description.into(),
87            blocking,
88        }
89    }
90
91    /// Returns `true` if this violation must be fixed before delivery.
92    #[must_use]
93    pub fn is_critical(&self) -> bool {
94        self.blocking
95    }
96}
97
98/// Properties of an output file used during constraint checking.
99#[derive(Debug, Clone, Default)]
100pub struct OutputFileInfo {
101    /// Video bitrate in bits per second.
102    pub video_bitrate: u64,
103    /// Audio bitrate in bits per second.
104    pub audio_bitrate: u64,
105    /// Width in pixels.
106    pub width: u32,
107    /// Height in pixels.
108    pub height: u32,
109    /// File size in bytes.
110    pub file_size_bytes: u64,
111    /// Duration in seconds.
112    pub duration_seconds: f64,
113    /// Whether an audio track is present.
114    pub has_audio: bool,
115    /// Whether a video track is present.
116    pub has_video: bool,
117}
118
119/// Verifies that an output file satisfies a list of constraints.
120#[derive(Debug, Default)]
121pub struct OutputVerifier {
122    constraints: Vec<OutputConstraint>,
123}
124
125impl OutputVerifier {
126    /// Create an empty verifier.
127    #[must_use]
128    pub fn new() -> Self {
129        Self::default()
130    }
131
132    /// Add a constraint to be checked.
133    pub fn add_constraint(&mut self, constraint: OutputConstraint) {
134        self.constraints.push(constraint);
135    }
136
137    /// Check a simulated file (represented by [`OutputFileInfo`]) against all constraints.
138    #[must_use]
139    pub fn check_file(&self, info: &OutputFileInfo) -> OutputVerifyReport {
140        let mut violations = Vec::new();
141
142        for constraint in &self.constraints {
143            if let Some(v) = self.evaluate(constraint, info) {
144                violations.push(v);
145            }
146        }
147
148        OutputVerifyReport { violations }
149    }
150
151    fn evaluate(
152        &self,
153        constraint: &OutputConstraint,
154        info: &OutputFileInfo,
155    ) -> Option<OutputViolation> {
156        match constraint {
157            OutputConstraint::MaxVideoBitrate(max) => {
158                if info.video_bitrate > *max {
159                    Some(OutputViolation::new(
160                        constraint.clone(),
161                        format!(
162                            "video bitrate {} bps exceeds limit {} bps",
163                            info.video_bitrate, max
164                        ),
165                        true,
166                    ))
167                } else {
168                    None
169                }
170            }
171            OutputConstraint::MinVideoBitrate(min) => {
172                if info.video_bitrate < *min {
173                    Some(OutputViolation::new(
174                        constraint.clone(),
175                        format!(
176                            "video bitrate {} bps below minimum {} bps",
177                            info.video_bitrate, min
178                        ),
179                        false,
180                    ))
181                } else {
182                    None
183                }
184            }
185            OutputConstraint::MaxAudioBitrate(max) => {
186                if info.audio_bitrate > *max {
187                    Some(OutputViolation::new(
188                        constraint.clone(),
189                        format!(
190                            "audio bitrate {} bps exceeds limit {} bps",
191                            info.audio_bitrate, max
192                        ),
193                        true,
194                    ))
195                } else {
196                    None
197                }
198            }
199            OutputConstraint::ExactWidth(w) => {
200                if info.width == *w {
201                    None
202                } else {
203                    Some(OutputViolation::new(
204                        constraint.clone(),
205                        format!("width {} != required {}", info.width, w),
206                        true,
207                    ))
208                }
209            }
210            OutputConstraint::ExactHeight(h) => {
211                if info.height == *h {
212                    None
213                } else {
214                    Some(OutputViolation::new(
215                        constraint.clone(),
216                        format!("height {} != required {}", info.height, h),
217                        true,
218                    ))
219                }
220            }
221            OutputConstraint::MaxFileSizeBytes(max) => {
222                if info.file_size_bytes > *max {
223                    Some(OutputViolation::new(
224                        constraint.clone(),
225                        format!(
226                            "file size {} bytes exceeds limit {} bytes",
227                            info.file_size_bytes, max
228                        ),
229                        true,
230                    ))
231                } else {
232                    None
233                }
234            }
235            OutputConstraint::DurationWithinTolerance {
236                expected,
237                tolerance,
238            } => {
239                let diff = (info.duration_seconds - expected).abs();
240                if diff > *tolerance {
241                    Some(OutputViolation::new(
242                        constraint.clone(),
243                        format!(
244                            "duration {:.3}s differs from expected {:.3}s by {:.3}s (tolerance {:.3}s)",
245                            info.duration_seconds, expected, diff, tolerance
246                        ),
247                        false,
248                    ))
249                } else {
250                    None
251                }
252            }
253            OutputConstraint::HasAudio => {
254                if info.has_audio {
255                    None
256                } else {
257                    Some(OutputViolation::new(
258                        constraint.clone(),
259                        "no audio track present".to_string(),
260                        true,
261                    ))
262                }
263            }
264            OutputConstraint::HasVideo => {
265                if info.has_video {
266                    None
267                } else {
268                    Some(OutputViolation::new(
269                        constraint.clone(),
270                        "no video track present".to_string(),
271                        true,
272                    ))
273                }
274            }
275        }
276    }
277}
278
279/// The result of running an [`OutputVerifier`] against a file.
280#[derive(Debug)]
281pub struct OutputVerifyReport {
282    violations: Vec<OutputViolation>,
283}
284
285impl OutputVerifyReport {
286    /// All violations found.
287    #[must_use]
288    pub fn violations(&self) -> &[OutputViolation] {
289        &self.violations
290    }
291
292    /// Only violations that block delivery.
293    #[must_use]
294    pub fn blocking_violations(&self) -> Vec<&OutputViolation> {
295        self.violations.iter().filter(|v| v.is_critical()).collect()
296    }
297
298    /// Returns `true` when there are no violations.
299    #[must_use]
300    pub fn is_ok(&self) -> bool {
301        self.violations.is_empty()
302    }
303
304    /// Returns `true` when there are no blocking violations.
305    #[must_use]
306    pub fn is_deliverable(&self) -> bool {
307        self.blocking_violations().is_empty()
308    }
309
310    /// Total number of violations.
311    #[must_use]
312    pub fn violation_count(&self) -> usize {
313        self.violations.len()
314    }
315}
316
317#[cfg(test)]
318mod tests {
319    use super::*;
320
321    fn base_info() -> OutputFileInfo {
322        OutputFileInfo {
323            video_bitrate: 5_000_000,
324            audio_bitrate: 128_000,
325            width: 1920,
326            height: 1080,
327            file_size_bytes: 100_000_000,
328            duration_seconds: 60.0,
329            has_audio: true,
330            has_video: true,
331        }
332    }
333
334    #[test]
335    fn test_constraint_name_max_video_bitrate() {
336        assert_eq!(
337            OutputConstraint::MaxVideoBitrate(5_000_000).constraint_name(),
338            "max_video_bitrate"
339        );
340    }
341
342    #[test]
343    fn test_constraint_is_bitrate_constraint() {
344        assert!(OutputConstraint::MaxVideoBitrate(0).is_bitrate_constraint());
345        assert!(OutputConstraint::MinVideoBitrate(0).is_bitrate_constraint());
346        assert!(!OutputConstraint::HasAudio.is_bitrate_constraint());
347    }
348
349    #[test]
350    fn test_constraint_is_stream_presence() {
351        assert!(OutputConstraint::HasAudio.is_stream_presence());
352        assert!(OutputConstraint::HasVideo.is_stream_presence());
353        assert!(!OutputConstraint::ExactWidth(1920).is_stream_presence());
354    }
355
356    #[test]
357    fn test_violation_is_critical() {
358        let v = OutputViolation::new(OutputConstraint::HasAudio, "no audio", true);
359        assert!(v.is_critical());
360        let v2 = OutputViolation::new(OutputConstraint::MinVideoBitrate(0), "low", false);
361        assert!(!v2.is_critical());
362    }
363
364    #[test]
365    fn test_verifier_no_violations_on_pass() {
366        let mut v = OutputVerifier::new();
367        v.add_constraint(OutputConstraint::MaxVideoBitrate(10_000_000));
368        v.add_constraint(OutputConstraint::ExactWidth(1920));
369        let report = v.check_file(&base_info());
370        assert!(report.is_ok());
371    }
372
373    #[test]
374    fn test_verifier_max_video_bitrate_violation() {
375        let mut v = OutputVerifier::new();
376        v.add_constraint(OutputConstraint::MaxVideoBitrate(4_000_000));
377        let report = v.check_file(&base_info());
378        assert!(!report.is_ok());
379        assert_eq!(report.violation_count(), 1);
380    }
381
382    #[test]
383    fn test_verifier_exact_width_violation() {
384        let mut v = OutputVerifier::new();
385        v.add_constraint(OutputConstraint::ExactWidth(3840));
386        let report = v.check_file(&base_info());
387        assert!(!report.is_deliverable());
388    }
389
390    #[test]
391    fn test_verifier_has_audio_missing() {
392        let mut info = base_info();
393        info.has_audio = false;
394        let mut v = OutputVerifier::new();
395        v.add_constraint(OutputConstraint::HasAudio);
396        let report = v.check_file(&info);
397        assert!(!report.is_ok());
398        assert_eq!(report.blocking_violations().len(), 1);
399    }
400
401    #[test]
402    fn test_verifier_has_video_ok() {
403        let mut v = OutputVerifier::new();
404        v.add_constraint(OutputConstraint::HasVideo);
405        let report = v.check_file(&base_info());
406        assert!(report.is_ok());
407    }
408
409    #[test]
410    fn test_verifier_file_size_violation() {
411        let mut v = OutputVerifier::new();
412        v.add_constraint(OutputConstraint::MaxFileSizeBytes(50_000_000));
413        let report = v.check_file(&base_info());
414        assert!(!report.is_ok());
415    }
416
417    #[test]
418    fn test_verifier_duration_within_tolerance_ok() {
419        let mut v = OutputVerifier::new();
420        v.add_constraint(OutputConstraint::DurationWithinTolerance {
421            expected: 60.0,
422            tolerance: 1.0,
423        });
424        let report = v.check_file(&base_info());
425        assert!(report.is_ok());
426    }
427
428    #[test]
429    fn test_verifier_duration_outside_tolerance() {
430        let mut info = base_info();
431        info.duration_seconds = 58.0;
432        let mut v = OutputVerifier::new();
433        v.add_constraint(OutputConstraint::DurationWithinTolerance {
434            expected: 60.0,
435            tolerance: 0.5,
436        });
437        let report = v.check_file(&info);
438        assert!(!report.is_ok());
439        // duration violation is non-blocking
440        assert!(report.is_deliverable());
441    }
442
443    #[test]
444    fn test_verifier_min_video_bitrate_advisory() {
445        let mut v = OutputVerifier::new();
446        v.add_constraint(OutputConstraint::MinVideoBitrate(8_000_000));
447        let report = v.check_file(&base_info());
448        assert!(!report.is_ok());
449        assert!(report.is_deliverable()); // advisory only
450    }
451
452    #[test]
453    fn test_report_blocking_vs_total() {
454        let mut v = OutputVerifier::new();
455        v.add_constraint(OutputConstraint::MaxVideoBitrate(4_000_000)); // blocking
456        v.add_constraint(OutputConstraint::MinVideoBitrate(8_000_000)); // advisory
457        let report = v.check_file(&base_info());
458        assert_eq!(report.violation_count(), 2);
459        assert_eq!(report.blocking_violations().len(), 1);
460    }
461}