Skip to main content

oximedia_proxy/
proxy_sync.rs

1//! Proxy synchronization for OxiMedia proxy system.
2//!
3//! Provides proxy-to-original timecode alignment, sync verification,
4//! and drift detection for maintaining accurate proxy-original correspondence.
5
6#![allow(dead_code)]
7#![allow(clippy::cast_precision_loss)]
8
9/// A timecode value expressed as total frames at a given frame rate.
10#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
11pub struct FrameTimecode {
12    /// Total frame count from zero.
13    pub frame_number: u64,
14    /// Frame rate numerator.
15    pub fps_num: u32,
16    /// Frame rate denominator.
17    pub fps_den: u32,
18}
19
20impl FrameTimecode {
21    /// Create a new frame timecode.
22    #[must_use]
23    pub fn new(frame_number: u64, fps_num: u32, fps_den: u32) -> Self {
24        Self {
25            frame_number,
26            fps_num,
27            fps_den,
28        }
29    }
30
31    /// Create a timecode from hours, minutes, seconds, frames at 24fps.
32    #[must_use]
33    pub fn from_hmsf(hours: u64, minutes: u64, seconds: u64, frames: u64, fps: u64) -> Self {
34        let total_frames = ((hours * 3600 + minutes * 60 + seconds) * fps) + frames;
35        Self {
36            frame_number: total_frames,
37            fps_num: fps as u32,
38            fps_den: 1,
39        }
40    }
41
42    /// Frame rate as a floating-point value.
43    #[must_use]
44    pub fn fps_f64(&self) -> f64 {
45        self.fps_num as f64 / self.fps_den as f64
46    }
47
48    /// Duration in seconds.
49    #[must_use]
50    pub fn as_seconds(&self) -> f64 {
51        self.frame_number as f64 / self.fps_f64()
52    }
53
54    /// Compute the difference in frames from another timecode.
55    ///
56    /// Returns a positive value if `self` is later than `other`.
57    #[must_use]
58    pub fn frame_diff(&self, other: &Self) -> i64 {
59        self.frame_number as i64 - other.frame_number as i64
60    }
61}
62
63/// A sync point linking a proxy frame to an original frame.
64#[derive(Debug, Clone, PartialEq, Eq)]
65pub struct SyncPoint {
66    /// Timecode in the proxy media.
67    pub proxy_tc: FrameTimecode,
68    /// Corresponding timecode in the original media.
69    pub original_tc: FrameTimecode,
70    /// Whether this sync point was verified by checksum comparison.
71    pub verified: bool,
72}
73
74impl SyncPoint {
75    /// Create a new sync point.
76    #[must_use]
77    pub fn new(proxy_tc: FrameTimecode, original_tc: FrameTimecode) -> Self {
78        Self {
79            proxy_tc,
80            original_tc,
81            verified: false,
82        }
83    }
84
85    /// Mark this sync point as verified.
86    #[must_use]
87    pub fn verified(mut self) -> Self {
88        self.verified = true;
89        self
90    }
91
92    /// Frame offset between proxy and original (in proxy frames).
93    #[must_use]
94    pub fn frame_offset(&self) -> i64 {
95        self.proxy_tc.frame_number as i64 - self.original_tc.frame_number as i64
96    }
97}
98
99/// Result of a sync verification check.
100#[derive(Debug, Clone)]
101pub struct SyncVerificationResult {
102    /// Clip or session identifier.
103    pub clip_id: String,
104    /// Whether the sync is valid within tolerance.
105    pub in_sync: bool,
106    /// Maximum frame drift detected.
107    pub max_drift_frames: i64,
108    /// Number of sync points checked.
109    pub points_checked: usize,
110    /// Number of sync points that passed verification.
111    pub points_passed: usize,
112}
113
114impl SyncVerificationResult {
115    /// Fraction of sync points that passed [0.0, 1.0].
116    #[must_use]
117    pub fn pass_rate(&self) -> f32 {
118        if self.points_checked == 0 {
119            return 1.0;
120        }
121        self.points_passed as f32 / self.points_checked as f32
122    }
123}
124
125/// Tolerance settings for sync verification.
126#[derive(Debug, Clone, Copy)]
127pub struct SyncTolerance {
128    /// Maximum allowed frame drift (inclusive).
129    pub max_drift_frames: u32,
130    /// Minimum fraction of sync points that must pass.
131    pub min_pass_rate: f32,
132}
133
134impl SyncTolerance {
135    /// Create a tolerance with given drift and pass rate.
136    #[must_use]
137    pub fn new(max_drift_frames: u32, min_pass_rate: f32) -> Self {
138        Self {
139            max_drift_frames,
140            min_pass_rate: min_pass_rate.clamp(0.0, 1.0),
141        }
142    }
143
144    /// Strict tolerance: zero drift allowed, 100% pass rate required.
145    #[must_use]
146    pub fn strict() -> Self {
147        Self::new(0, 1.0)
148    }
149
150    /// Lenient tolerance: up to 2 frames drift, 90% pass rate.
151    #[must_use]
152    pub fn lenient() -> Self {
153        Self::new(2, 0.9)
154    }
155}
156
157impl Default for SyncTolerance {
158    fn default() -> Self {
159        Self::new(1, 0.95)
160    }
161}
162
163/// Verifies proxy-to-original synchronization.
164#[allow(dead_code)]
165pub struct ProxySyncVerifier {
166    /// Sync points to check.
167    sync_points: Vec<SyncPoint>,
168    /// Tolerance settings.
169    tolerance: SyncTolerance,
170}
171
172impl ProxySyncVerifier {
173    /// Create a new verifier with default tolerance.
174    #[must_use]
175    pub fn new() -> Self {
176        Self {
177            sync_points: Vec::new(),
178            tolerance: SyncTolerance::default(),
179        }
180    }
181
182    /// Set the tolerance.
183    #[must_use]
184    pub fn with_tolerance(mut self, tolerance: SyncTolerance) -> Self {
185        self.tolerance = tolerance;
186        self
187    }
188
189    /// Add a sync point.
190    pub fn add_sync_point(&mut self, point: SyncPoint) {
191        self.sync_points.push(point);
192    }
193
194    /// Run verification and return the result for the given clip id.
195    #[must_use]
196    pub fn verify(&self, clip_id: impl Into<String>) -> SyncVerificationResult {
197        let clip_id = clip_id.into();
198        if self.sync_points.is_empty() {
199            return SyncVerificationResult {
200                clip_id,
201                in_sync: true,
202                max_drift_frames: 0,
203                points_checked: 0,
204                points_passed: 0,
205            };
206        }
207
208        let mut max_drift = 0i64;
209        let mut passed = 0usize;
210
211        for sp in &self.sync_points {
212            let drift = sp.frame_offset().abs();
213            if drift > max_drift {
214                max_drift = drift;
215            }
216            if drift <= self.tolerance.max_drift_frames as i64 {
217                passed += 1;
218            }
219        }
220
221        let total = self.sync_points.len();
222        let pass_rate = passed as f32 / total as f32;
223
224        SyncVerificationResult {
225            clip_id,
226            in_sync: pass_rate >= self.tolerance.min_pass_rate,
227            max_drift_frames: max_drift,
228            points_checked: total,
229            points_passed: passed,
230        }
231    }
232
233    /// Number of sync points registered.
234    #[must_use]
235    pub fn point_count(&self) -> usize {
236        self.sync_points.len()
237    }
238}
239
240impl Default for ProxySyncVerifier {
241    fn default() -> Self {
242        Self::new()
243    }
244}
245
246/// Aligns proxy timecode to original timecode using a known offset.
247#[allow(dead_code)]
248pub struct TimecodeAligner {
249    /// Known frame offset (proxy_frame = original_frame + offset).
250    offset_frames: i64,
251    /// Frame rate used for alignment.
252    fps_num: u32,
253    /// Frame rate denominator.
254    fps_den: u32,
255}
256
257impl TimecodeAligner {
258    /// Create an aligner with a known offset.
259    #[must_use]
260    pub fn new(offset_frames: i64, fps_num: u32, fps_den: u32) -> Self {
261        Self {
262            offset_frames,
263            fps_num,
264            fps_den,
265        }
266    }
267
268    /// Create an aligner with zero offset.
269    #[must_use]
270    pub fn zero(fps_num: u32, fps_den: u32) -> Self {
271        Self::new(0, fps_num, fps_den)
272    }
273
274    /// Compute the proxy frame number for a given original frame number.
275    #[must_use]
276    pub fn original_to_proxy(&self, original_frame: u64) -> u64 {
277        (original_frame as i64 + self.offset_frames).max(0) as u64
278    }
279
280    /// Compute the original frame number for a given proxy frame number.
281    #[must_use]
282    pub fn proxy_to_original(&self, proxy_frame: u64) -> u64 {
283        (proxy_frame as i64 - self.offset_frames).max(0) as u64
284    }
285
286    /// Get the frame offset.
287    #[must_use]
288    pub fn offset_frames(&self) -> i64 {
289        self.offset_frames
290    }
291
292    /// Offset expressed in seconds.
293    #[must_use]
294    pub fn offset_seconds(&self) -> f64 {
295        self.offset_frames as f64 / (self.fps_num as f64 / self.fps_den as f64)
296    }
297}
298
299#[cfg(test)]
300mod tests {
301    use super::*;
302
303    #[test]
304    fn test_frame_timecode_fps_f64() {
305        let tc = FrameTimecode::new(0, 24, 1);
306        assert!((tc.fps_f64() - 24.0).abs() < 1e-10);
307    }
308
309    #[test]
310    fn test_frame_timecode_from_hmsf() {
311        // 1 hour at 24fps = 86400 frames
312        let tc = FrameTimecode::from_hmsf(1, 0, 0, 0, 24);
313        assert_eq!(tc.frame_number, 86400);
314    }
315
316    #[test]
317    fn test_frame_timecode_as_seconds() {
318        let tc = FrameTimecode::new(240, 24, 1);
319        assert!((tc.as_seconds() - 10.0).abs() < 1e-10);
320    }
321
322    #[test]
323    fn test_frame_timecode_frame_diff() {
324        let tc1 = FrameTimecode::new(100, 24, 1);
325        let tc2 = FrameTimecode::new(95, 24, 1);
326        assert_eq!(tc1.frame_diff(&tc2), 5);
327        assert_eq!(tc2.frame_diff(&tc1), -5);
328    }
329
330    #[test]
331    fn test_sync_point_frame_offset_zero() {
332        let tc = FrameTimecode::new(100, 24, 1);
333        let sp = SyncPoint::new(tc, tc);
334        assert_eq!(sp.frame_offset(), 0);
335    }
336
337    #[test]
338    fn test_sync_point_frame_offset_nonzero() {
339        let proxy_tc = FrameTimecode::new(105, 24, 1);
340        let orig_tc = FrameTimecode::new(100, 24, 1);
341        let sp = SyncPoint::new(proxy_tc, orig_tc);
342        assert_eq!(sp.frame_offset(), 5);
343    }
344
345    #[test]
346    fn test_sync_point_verified() {
347        let tc = FrameTimecode::new(100, 24, 1);
348        let sp = SyncPoint::new(tc, tc).verified();
349        assert!(sp.verified);
350    }
351
352    #[test]
353    fn test_sync_tolerance_default() {
354        let t = SyncTolerance::default();
355        assert_eq!(t.max_drift_frames, 1);
356        assert!((t.min_pass_rate - 0.95).abs() < 1e-5);
357    }
358
359    #[test]
360    fn test_sync_tolerance_strict() {
361        let t = SyncTolerance::strict();
362        assert_eq!(t.max_drift_frames, 0);
363        assert!((t.min_pass_rate - 1.0).abs() < 1e-5);
364    }
365
366    #[test]
367    fn test_proxy_sync_verifier_empty() {
368        let verifier = ProxySyncVerifier::new();
369        let result = verifier.verify("clip001");
370        assert!(result.in_sync);
371        assert_eq!(result.points_checked, 0);
372    }
373
374    #[test]
375    fn test_proxy_sync_verifier_all_in_sync() {
376        let mut verifier = ProxySyncVerifier::new();
377        let tc = FrameTimecode::new(100, 24, 1);
378        verifier.add_sync_point(SyncPoint::new(tc, tc));
379        verifier.add_sync_point(SyncPoint::new(
380            FrameTimecode::new(200, 24, 1),
381            FrameTimecode::new(200, 24, 1),
382        ));
383        let result = verifier.verify("clip001");
384        assert!(result.in_sync);
385        assert_eq!(result.max_drift_frames, 0);
386    }
387
388    #[test]
389    fn test_proxy_sync_verifier_drift_exceeds_tolerance() {
390        let mut verifier = ProxySyncVerifier::new().with_tolerance(SyncTolerance::strict());
391        verifier.add_sync_point(SyncPoint::new(
392            FrameTimecode::new(105, 24, 1),
393            FrameTimecode::new(100, 24, 1),
394        ));
395        let result = verifier.verify("clip001");
396        assert!(!result.in_sync);
397        assert_eq!(result.max_drift_frames, 5);
398    }
399
400    #[test]
401    fn test_timecode_aligner_original_to_proxy() {
402        let aligner = TimecodeAligner::new(10, 24, 1);
403        assert_eq!(aligner.original_to_proxy(100), 110);
404    }
405
406    #[test]
407    fn test_timecode_aligner_proxy_to_original() {
408        let aligner = TimecodeAligner::new(10, 24, 1);
409        assert_eq!(aligner.proxy_to_original(110), 100);
410    }
411
412    #[test]
413    fn test_timecode_aligner_zero() {
414        let aligner = TimecodeAligner::zero(25, 1);
415        assert_eq!(aligner.original_to_proxy(500), 500);
416        assert_eq!(aligner.proxy_to_original(500), 500);
417    }
418
419    #[test]
420    fn test_timecode_aligner_offset_seconds() {
421        let aligner = TimecodeAligner::new(48, 24, 1); // 2 seconds at 24fps
422        assert!((aligner.offset_seconds() - 2.0).abs() < 1e-10);
423    }
424
425    #[test]
426    fn test_sync_verification_pass_rate() {
427        let result = SyncVerificationResult {
428            clip_id: "c1".to_string(),
429            in_sync: true,
430            max_drift_frames: 0,
431            points_checked: 10,
432            points_passed: 9,
433        };
434        assert!((result.pass_rate() - 0.9).abs() < 1e-5);
435    }
436}