Skip to main content

oximedia_proxy/
proxy_compare.rs

1#![allow(dead_code)]
2
3//! Proxy vs original quality comparison and validation metrics.
4//!
5//! This module provides tools for comparing proxy files against their
6//! original source media. It computes resolution ratios, bitrate ratios,
7//! frame rate matches, and generates comparison reports used during
8//! quality-control workflows.
9
10use std::collections::HashMap;
11
12/// Resolution of a media file.
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub struct Resolution {
15    /// Width in pixels.
16    pub width: u32,
17    /// Height in pixels.
18    pub height: u32,
19}
20
21impl Resolution {
22    /// Create a new resolution.
23    pub const fn new(width: u32, height: u32) -> Self {
24        Self { width, height }
25    }
26
27    /// Total number of pixels.
28    pub const fn pixel_count(&self) -> u64 {
29        self.width as u64 * self.height as u64
30    }
31
32    /// Compute the ratio of this resolution's pixel count to another's.
33    #[allow(clippy::cast_precision_loss)]
34    pub fn ratio_to(&self, other: &Resolution) -> f64 {
35        if other.pixel_count() == 0 {
36            return 0.0;
37        }
38        self.pixel_count() as f64 / other.pixel_count() as f64
39    }
40}
41
42impl std::fmt::Display for Resolution {
43    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
44        write!(f, "{}x{}", self.width, self.height)
45    }
46}
47
48/// Metadata for a media file used in comparison.
49#[derive(Debug, Clone, PartialEq)]
50pub struct MediaInfo {
51    /// File path.
52    pub path: String,
53    /// Resolution.
54    pub resolution: Resolution,
55    /// Bitrate in bits per second.
56    pub bitrate_bps: u64,
57    /// Frame rate (frames per second).
58    pub frame_rate: f64,
59    /// Duration in milliseconds.
60    pub duration_ms: u64,
61    /// Codec name.
62    pub codec: String,
63    /// File size in bytes.
64    pub file_size_bytes: u64,
65}
66
67impl MediaInfo {
68    /// Create new media info.
69    pub fn new(path: &str) -> Self {
70        Self {
71            path: path.to_string(),
72            resolution: Resolution::new(0, 0),
73            bitrate_bps: 0,
74            frame_rate: 0.0,
75            duration_ms: 0,
76            codec: String::new(),
77            file_size_bytes: 0,
78        }
79    }
80
81    /// Set the resolution.
82    pub fn with_resolution(mut self, w: u32, h: u32) -> Self {
83        self.resolution = Resolution::new(w, h);
84        self
85    }
86
87    /// Set the bitrate.
88    pub fn with_bitrate(mut self, bps: u64) -> Self {
89        self.bitrate_bps = bps;
90        self
91    }
92
93    /// Set the frame rate.
94    pub fn with_frame_rate(mut self, fps: f64) -> Self {
95        self.frame_rate = fps;
96        self
97    }
98
99    /// Set the duration.
100    pub fn with_duration_ms(mut self, ms: u64) -> Self {
101        self.duration_ms = ms;
102        self
103    }
104
105    /// Set the codec name.
106    pub fn with_codec(mut self, codec: &str) -> Self {
107        self.codec = codec.to_string();
108        self
109    }
110
111    /// Set the file size.
112    pub fn with_file_size(mut self, bytes: u64) -> Self {
113        self.file_size_bytes = bytes;
114        self
115    }
116}
117
118/// Result of comparing a proxy against its original.
119#[derive(Debug, Clone)]
120pub struct ComparisonResult {
121    /// Proxy media info.
122    pub proxy: MediaInfo,
123    /// Original media info.
124    pub original: MediaInfo,
125    /// Resolution ratio (proxy pixels / original pixels).
126    pub resolution_ratio: f64,
127    /// Bitrate ratio (proxy / original).
128    pub bitrate_ratio: f64,
129    /// Whether frame rates match.
130    pub frame_rate_match: bool,
131    /// Whether durations are within tolerance.
132    pub duration_match: bool,
133    /// File size reduction ratio (proxy size / original size).
134    pub size_ratio: f64,
135    /// Duration difference in milliseconds.
136    pub duration_diff_ms: i64,
137}
138
139/// Tolerance settings for proxy comparison.
140#[derive(Debug, Clone)]
141pub struct ComparisonTolerance {
142    /// Maximum allowed frame rate difference (fps).
143    pub frame_rate_tolerance: f64,
144    /// Maximum allowed duration difference (ms).
145    pub duration_tolerance_ms: u64,
146    /// Maximum allowed resolution ratio (e.g., 0.5 for half-res).
147    pub max_resolution_ratio: f64,
148    /// Minimum acceptable resolution ratio.
149    pub min_resolution_ratio: f64,
150}
151
152impl Default for ComparisonTolerance {
153    fn default() -> Self {
154        Self {
155            frame_rate_tolerance: 0.01,
156            duration_tolerance_ms: 100,
157            max_resolution_ratio: 1.0,
158            min_resolution_ratio: 0.01,
159        }
160    }
161}
162
163/// Engine that compares proxy and original media.
164#[derive(Debug)]
165pub struct ProxyCompareEngine {
166    /// Tolerance settings.
167    tolerance: ComparisonTolerance,
168}
169
170impl ProxyCompareEngine {
171    /// Create a new comparison engine with default tolerances.
172    pub fn new() -> Self {
173        Self {
174            tolerance: ComparisonTolerance::default(),
175        }
176    }
177
178    /// Create a comparison engine with custom tolerances.
179    pub fn with_tolerance(tolerance: ComparisonTolerance) -> Self {
180        Self { tolerance }
181    }
182
183    /// Compare a proxy to its original source.
184    #[allow(clippy::cast_precision_loss)]
185    pub fn compare(&self, proxy: &MediaInfo, original: &MediaInfo) -> ComparisonResult {
186        let resolution_ratio = proxy.resolution.ratio_to(&original.resolution);
187
188        let bitrate_ratio = if original.bitrate_bps > 0 {
189            proxy.bitrate_bps as f64 / original.bitrate_bps as f64
190        } else {
191            0.0
192        };
193
194        let frame_rate_match =
195            (proxy.frame_rate - original.frame_rate).abs() <= self.tolerance.frame_rate_tolerance;
196
197        let duration_diff_ms = proxy.duration_ms as i64 - original.duration_ms as i64;
198        let duration_match =
199            (duration_diff_ms.unsigned_abs()) <= self.tolerance.duration_tolerance_ms;
200
201        let size_ratio = if original.file_size_bytes > 0 {
202            proxy.file_size_bytes as f64 / original.file_size_bytes as f64
203        } else {
204            0.0
205        };
206
207        ComparisonResult {
208            proxy: proxy.clone(),
209            original: original.clone(),
210            resolution_ratio,
211            bitrate_ratio,
212            frame_rate_match,
213            duration_match,
214            size_ratio,
215            duration_diff_ms,
216        }
217    }
218
219    /// Check whether a comparison result passes all quality gates.
220    pub fn passes_qc(&self, result: &ComparisonResult) -> bool {
221        result.frame_rate_match
222            && result.duration_match
223            && result.resolution_ratio >= self.tolerance.min_resolution_ratio
224            && result.resolution_ratio <= self.tolerance.max_resolution_ratio
225    }
226
227    /// Compare a batch of proxy-original pairs and return results.
228    pub fn compare_batch(&self, pairs: &[(MediaInfo, MediaInfo)]) -> Vec<ComparisonResult> {
229        pairs
230            .iter()
231            .map(|(proxy, original)| self.compare(proxy, original))
232            .collect()
233    }
234
235    /// Compute aggregate statistics from a batch of comparison results.
236    #[allow(clippy::cast_precision_loss)]
237    pub fn aggregate_stats(results: &[ComparisonResult]) -> ComparisonStats {
238        if results.is_empty() {
239            return ComparisonStats::default();
240        }
241        let total = results.len();
242        let frame_rate_matches = results.iter().filter(|r| r.frame_rate_match).count();
243        let duration_matches = results.iter().filter(|r| r.duration_match).count();
244        let avg_resolution_ratio: f64 =
245            results.iter().map(|r| r.resolution_ratio).sum::<f64>() / total as f64;
246        let avg_bitrate_ratio: f64 =
247            results.iter().map(|r| r.bitrate_ratio).sum::<f64>() / total as f64;
248        let avg_size_ratio: f64 = results.iter().map(|r| r.size_ratio).sum::<f64>() / total as f64;
249
250        ComparisonStats {
251            total,
252            frame_rate_matches,
253            duration_matches,
254            avg_resolution_ratio,
255            avg_bitrate_ratio,
256            avg_size_ratio,
257        }
258    }
259
260    /// Group comparison results by codec.
261    pub fn group_by_codec(results: &[ComparisonResult]) -> HashMap<String, Vec<usize>> {
262        let mut groups: HashMap<String, Vec<usize>> = HashMap::new();
263        for (i, r) in results.iter().enumerate() {
264            groups.entry(r.proxy.codec.clone()).or_default().push(i);
265        }
266        groups
267    }
268}
269
270impl Default for ProxyCompareEngine {
271    fn default() -> Self {
272        Self::new()
273    }
274}
275
276/// Aggregate statistics from a batch comparison.
277#[derive(Debug, Clone, Default)]
278pub struct ComparisonStats {
279    /// Total comparisons.
280    pub total: usize,
281    /// How many had matching frame rates.
282    pub frame_rate_matches: usize,
283    /// How many had matching durations.
284    pub duration_matches: usize,
285    /// Average resolution ratio across all comparisons.
286    pub avg_resolution_ratio: f64,
287    /// Average bitrate ratio across all comparisons.
288    pub avg_bitrate_ratio: f64,
289    /// Average file size ratio across all comparisons.
290    pub avg_size_ratio: f64,
291}
292
293#[cfg(test)]
294mod tests {
295    use super::*;
296
297    fn make_original() -> MediaInfo {
298        MediaInfo::new("/src/clip.mxf")
299            .with_resolution(3840, 2160)
300            .with_bitrate(100_000_000)
301            .with_frame_rate(23.976)
302            .with_duration_ms(60_000)
303            .with_codec("ProRes")
304            .with_file_size(750_000_000)
305    }
306
307    fn make_proxy() -> MediaInfo {
308        MediaInfo::new("/proxy/clip.mp4")
309            .with_resolution(1920, 1080)
310            .with_bitrate(5_000_000)
311            .with_frame_rate(23.976)
312            .with_duration_ms(60_000)
313            .with_codec("H264")
314            .with_file_size(37_500_000)
315    }
316
317    #[test]
318    fn test_resolution_pixel_count() {
319        let r = Resolution::new(1920, 1080);
320        assert_eq!(r.pixel_count(), 2_073_600);
321    }
322
323    #[test]
324    fn test_resolution_ratio() {
325        let proxy = Resolution::new(1920, 1080);
326        let original = Resolution::new(3840, 2160);
327        let ratio = proxy.ratio_to(&original);
328        assert!((ratio - 0.25).abs() < 0.001);
329    }
330
331    #[test]
332    fn test_resolution_ratio_zero() {
333        let a = Resolution::new(1920, 1080);
334        let zero = Resolution::new(0, 0);
335        assert!((a.ratio_to(&zero) - 0.0).abs() < f64::EPSILON);
336    }
337
338    #[test]
339    fn test_resolution_display() {
340        let r = Resolution::new(1920, 1080);
341        assert_eq!(format!("{r}"), "1920x1080");
342    }
343
344    #[test]
345    fn test_compare_frame_rate_match() {
346        let engine = ProxyCompareEngine::new();
347        let result = engine.compare(&make_proxy(), &make_original());
348        assert!(result.frame_rate_match);
349    }
350
351    #[test]
352    fn test_compare_duration_match() {
353        let engine = ProxyCompareEngine::new();
354        let result = engine.compare(&make_proxy(), &make_original());
355        assert!(result.duration_match);
356        assert_eq!(result.duration_diff_ms, 0);
357    }
358
359    #[test]
360    fn test_compare_resolution_ratio() {
361        let engine = ProxyCompareEngine::new();
362        let result = engine.compare(&make_proxy(), &make_original());
363        assert!((result.resolution_ratio - 0.25).abs() < 0.001);
364    }
365
366    #[test]
367    fn test_compare_bitrate_ratio() {
368        let engine = ProxyCompareEngine::new();
369        let result = engine.compare(&make_proxy(), &make_original());
370        assert!((result.bitrate_ratio - 0.05).abs() < 0.001);
371    }
372
373    #[test]
374    fn test_compare_size_ratio() {
375        let engine = ProxyCompareEngine::new();
376        let result = engine.compare(&make_proxy(), &make_original());
377        assert!((result.size_ratio - 0.05).abs() < 0.001);
378    }
379
380    #[test]
381    fn test_passes_qc_default() {
382        let engine = ProxyCompareEngine::new();
383        let result = engine.compare(&make_proxy(), &make_original());
384        assert!(engine.passes_qc(&result));
385    }
386
387    #[test]
388    fn test_fails_qc_frame_rate_mismatch() {
389        let engine = ProxyCompareEngine::new();
390        let proxy = make_proxy().with_frame_rate(30.0);
391        let result = engine.compare(&proxy, &make_original());
392        assert!(!result.frame_rate_match);
393        assert!(!engine.passes_qc(&result));
394    }
395
396    #[test]
397    fn test_fails_qc_duration_mismatch() {
398        let engine = ProxyCompareEngine::new();
399        let proxy = make_proxy().with_duration_ms(65_000);
400        let result = engine.compare(&proxy, &make_original());
401        assert!(!result.duration_match);
402    }
403
404    #[test]
405    fn test_compare_batch() {
406        let engine = ProxyCompareEngine::new();
407        let pairs = vec![
408            (make_proxy(), make_original()),
409            (make_proxy(), make_original()),
410        ];
411        let results = engine.compare_batch(&pairs);
412        assert_eq!(results.len(), 2);
413    }
414
415    #[test]
416    fn test_aggregate_stats() {
417        let engine = ProxyCompareEngine::new();
418        let results = vec![
419            engine.compare(&make_proxy(), &make_original()),
420            engine.compare(&make_proxy(), &make_original()),
421        ];
422        let stats = ProxyCompareEngine::aggregate_stats(&results);
423        assert_eq!(stats.total, 2);
424        assert_eq!(stats.frame_rate_matches, 2);
425        assert_eq!(stats.duration_matches, 2);
426    }
427
428    #[test]
429    fn test_aggregate_stats_empty() {
430        let stats = ProxyCompareEngine::aggregate_stats(&[]);
431        assert_eq!(stats.total, 0);
432    }
433
434    #[test]
435    fn test_group_by_codec() {
436        let engine = ProxyCompareEngine::new();
437        let results = vec![
438            engine.compare(&make_proxy(), &make_original()),
439            engine.compare(&make_proxy().with_codec("VP9"), &make_original()),
440        ];
441        let groups = ProxyCompareEngine::group_by_codec(&results);
442        assert!(groups.contains_key("H264"));
443        assert!(groups.contains_key("VP9"));
444    }
445
446    #[test]
447    fn test_custom_tolerance() {
448        let tolerance = ComparisonTolerance {
449            frame_rate_tolerance: 1.0,
450            duration_tolerance_ms: 5000,
451            max_resolution_ratio: 1.0,
452            min_resolution_ratio: 0.001,
453        };
454        let engine = ProxyCompareEngine::with_tolerance(tolerance);
455        let proxy = make_proxy().with_frame_rate(24.0);
456        let result = engine.compare(&proxy, &make_original());
457        assert!(result.frame_rate_match);
458    }
459
460    #[test]
461    fn test_default_engine() {
462        let engine = ProxyCompareEngine::default();
463        assert!((engine.tolerance.frame_rate_tolerance - 0.01).abs() < f64::EPSILON);
464    }
465}