1#![allow(dead_code)]
2use crate::{FrameRate, Timecode, TimecodeError};
9
10#[derive(Debug, Clone, Copy, PartialEq)]
12pub struct DriftSample {
13 pub wall_time_secs: f64,
15 pub observed_frames: u64,
17 pub expected_frames: u64,
19}
20
21impl DriftSample {
22 pub fn new(wall_time_secs: f64, observed_frames: u64, expected_frames: u64) -> Self {
24 Self {
25 wall_time_secs,
26 observed_frames,
27 expected_frames,
28 }
29 }
30
31 #[allow(clippy::cast_precision_loss)]
33 pub fn drift_frames(&self) -> i64 {
34 self.observed_frames as i64 - self.expected_frames as i64
35 }
36
37 #[allow(clippy::cast_precision_loss)]
39 pub fn drift_ratio(&self) -> f64 {
40 if self.expected_frames == 0 {
41 return 0.0;
42 }
43 (self.observed_frames as f64 - self.expected_frames as f64) / self.expected_frames as f64
44 }
45}
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub enum CorrectionStrategy {
50 None,
52 FrameDropRepeat,
54 RateAdjust,
56 PhaseShift,
58}
59
60#[derive(Debug, Clone)]
62pub struct DriftAnalysis {
63 pub drift_frames_per_hour: f64,
65 pub drift_ppm: f64,
67 pub max_drift_frames: i64,
69 pub within_tolerance: bool,
71 pub sample_count: usize,
73 pub recommended_strategy: CorrectionStrategy,
75}
76
77#[derive(Debug, Clone)]
79pub struct DriftConfig {
80 pub frame_rate: FrameRate,
82 pub tolerance_frames: u32,
84 pub min_samples: usize,
86 pub ppm_threshold: f64,
88}
89
90impl DriftConfig {
91 pub fn new(frame_rate: FrameRate) -> Self {
93 Self {
94 frame_rate,
95 tolerance_frames: 2,
96 min_samples: 3,
97 ppm_threshold: 100.0,
98 }
99 }
100
101 pub fn with_tolerance(mut self, frames: u32) -> Self {
103 self.tolerance_frames = frames;
104 self
105 }
106
107 pub fn with_min_samples(mut self, n: usize) -> Self {
109 self.min_samples = n;
110 self
111 }
112
113 pub fn with_ppm_threshold(mut self, ppm: f64) -> Self {
115 self.ppm_threshold = ppm;
116 self
117 }
118}
119
120#[derive(Debug, Clone)]
122pub struct DriftDetector {
123 config: DriftConfig,
125 samples: Vec<DriftSample>,
127}
128
129impl DriftDetector {
130 pub fn new(config: DriftConfig) -> Self {
132 Self {
133 config,
134 samples: Vec::new(),
135 }
136 }
137
138 pub fn add_sample(&mut self, sample: DriftSample) {
140 self.samples.push(sample);
141 }
142
143 pub fn sample_count(&self) -> usize {
145 self.samples.len()
146 }
147
148 pub fn clear_samples(&mut self) {
150 self.samples.clear();
151 }
152
153 pub fn latest_drift(&self) -> Option<i64> {
155 self.samples.last().map(DriftSample::drift_frames)
156 }
157
158 #[allow(clippy::cast_precision_loss)]
160 pub fn analyze(&self) -> Option<DriftAnalysis> {
161 if self.samples.len() < self.config.min_samples {
162 return None;
163 }
164
165 let n = self.samples.len() as f64;
166
167 let sum_t: f64 = self.samples.iter().map(|s| s.wall_time_secs).sum();
169 let sum_d: f64 = self.samples.iter().map(|s| s.drift_frames() as f64).sum();
170 let sum_td: f64 = self
171 .samples
172 .iter()
173 .map(|s| s.wall_time_secs * s.drift_frames() as f64)
174 .sum();
175 let sum_t2: f64 = self
176 .samples
177 .iter()
178 .map(|s| s.wall_time_secs * s.wall_time_secs)
179 .sum();
180
181 let denom = n * sum_t2 - sum_t * sum_t;
182 let slope = if denom.abs() > 1e-12 {
183 (n * sum_td - sum_t * sum_d) / denom
184 } else {
185 0.0
186 };
187
188 let drift_frames_per_hour = slope * 3600.0;
190 let fps = self.config.frame_rate.as_float();
191 let drift_ppm = if fps > 0.0 {
192 (slope / fps) * 1_000_000.0
193 } else {
194 0.0
195 };
196
197 let max_drift_frames = self
198 .samples
199 .iter()
200 .map(|s| s.drift_frames().unsigned_abs() as i64)
201 .max()
202 .unwrap_or(0);
203
204 let within_tolerance = max_drift_frames <= self.config.tolerance_frames as i64;
205
206 let recommended_strategy = if within_tolerance {
207 CorrectionStrategy::None
208 } else if drift_ppm.abs() > self.config.ppm_threshold {
209 CorrectionStrategy::RateAdjust
210 } else if max_drift_frames <= 5 {
211 CorrectionStrategy::PhaseShift
212 } else {
213 CorrectionStrategy::FrameDropRepeat
214 };
215
216 Some(DriftAnalysis {
217 drift_frames_per_hour,
218 drift_ppm,
219 max_drift_frames,
220 within_tolerance,
221 sample_count: self.samples.len(),
222 recommended_strategy,
223 })
224 }
225
226 pub fn correct_timecode(
228 &self,
229 observed: &Timecode,
230 correction_frames: i64,
231 ) -> Result<Timecode, TimecodeError> {
232 let frame_rate = self.config.frame_rate;
233 let current = observed.to_frames();
234 let corrected = if correction_frames >= 0 {
235 current + correction_frames as u64
236 } else {
237 current.saturating_sub(correction_frames.unsigned_abs())
238 };
239 Timecode::from_frames(corrected, frame_rate)
240 }
241
242 pub fn frame_rate(&self) -> FrameRate {
244 self.config.frame_rate
245 }
246}
247
248#[allow(clippy::cast_precision_loss)]
250pub fn compute_ppm(reference_frames: u64, observed_frames: u64) -> f64 {
251 if reference_frames == 0 {
252 return 0.0;
253 }
254 let diff = observed_frames as f64 - reference_frames as f64;
255 (diff / reference_frames as f64) * 1_000_000.0
256}
257
258#[cfg(test)]
259mod tests {
260 use super::*;
261
262 #[test]
263 fn test_drift_sample_creation() {
264 let s = DriftSample::new(1.0, 25, 25);
265 assert_eq!(s.wall_time_secs, 1.0);
266 assert_eq!(s.observed_frames, 25);
267 assert_eq!(s.expected_frames, 25);
268 }
269
270 #[test]
271 fn test_drift_sample_zero_drift() {
272 let s = DriftSample::new(1.0, 100, 100);
273 assert_eq!(s.drift_frames(), 0);
274 assert!((s.drift_ratio()).abs() < 1e-10);
275 }
276
277 #[test]
278 fn test_drift_sample_positive() {
279 let s = DriftSample::new(1.0, 102, 100);
280 assert_eq!(s.drift_frames(), 2);
281 assert!((s.drift_ratio() - 0.02).abs() < 1e-10);
282 }
283
284 #[test]
285 fn test_drift_sample_negative() {
286 let s = DriftSample::new(1.0, 98, 100);
287 assert_eq!(s.drift_frames(), -2);
288 assert!((s.drift_ratio() + 0.02).abs() < 1e-10);
289 }
290
291 #[test]
292 fn test_drift_sample_zero_expected() {
293 let s = DriftSample::new(0.0, 0, 0);
294 assert!((s.drift_ratio()).abs() < 1e-10);
295 }
296
297 #[test]
298 fn test_config_defaults() {
299 let c = DriftConfig::new(FrameRate::Fps25);
300 assert_eq!(c.tolerance_frames, 2);
301 assert_eq!(c.min_samples, 3);
302 assert!((c.ppm_threshold - 100.0).abs() < 1e-10);
303 }
304
305 #[test]
306 fn test_config_builder() {
307 let c = DriftConfig::new(FrameRate::Fps25)
308 .with_tolerance(5)
309 .with_min_samples(10)
310 .with_ppm_threshold(50.0);
311 assert_eq!(c.tolerance_frames, 5);
312 assert_eq!(c.min_samples, 10);
313 assert!((c.ppm_threshold - 50.0).abs() < 1e-10);
314 }
315
316 #[test]
317 fn test_detector_no_samples() {
318 let det = DriftDetector::new(DriftConfig::new(FrameRate::Fps25));
319 assert_eq!(det.sample_count(), 0);
320 assert!(det.latest_drift().is_none());
321 assert!(det.analyze().is_none());
322 }
323
324 #[test]
325 fn test_detector_add_sample() {
326 let mut det = DriftDetector::new(DriftConfig::new(FrameRate::Fps25));
327 det.add_sample(DriftSample::new(0.0, 0, 0));
328 det.add_sample(DriftSample::new(1.0, 25, 25));
329 assert_eq!(det.sample_count(), 2);
330 assert_eq!(det.latest_drift(), Some(0));
331 }
332
333 #[test]
334 fn test_analyze_no_drift() {
335 let mut det = DriftDetector::new(DriftConfig::new(FrameRate::Fps25));
336 det.add_sample(DriftSample::new(0.0, 0, 0));
337 det.add_sample(DriftSample::new(1.0, 25, 25));
338 det.add_sample(DriftSample::new(2.0, 50, 50));
339 let analysis = det.analyze().expect("analysis should succeed");
340 assert!(analysis.within_tolerance);
341 assert!((analysis.drift_ppm).abs() < 1.0);
342 assert_eq!(analysis.recommended_strategy, CorrectionStrategy::None);
343 }
344
345 #[test]
346 fn test_analyze_with_drift() {
347 let config = DriftConfig::new(FrameRate::Fps25).with_tolerance(1);
348 let mut det = DriftDetector::new(config);
349 det.add_sample(DriftSample::new(0.0, 0, 0));
350 det.add_sample(DriftSample::new(1.0, 26, 25));
351 det.add_sample(DriftSample::new(2.0, 52, 50));
352 let analysis = det.analyze().expect("analysis should succeed");
353 assert!(!analysis.within_tolerance);
354 assert!(analysis.max_drift_frames >= 1);
355 }
356
357 #[test]
358 fn test_correct_timecode_forward() {
359 let det = DriftDetector::new(DriftConfig::new(FrameRate::Fps25));
360 let tc = Timecode::new(0, 0, 1, 0, FrameRate::Fps25).expect("valid timecode");
361 let corrected = det
362 .correct_timecode(&tc, 5)
363 .expect("correction should succeed");
364 assert_eq!(corrected.seconds, 1);
365 assert_eq!(corrected.frames, 5);
366 }
367
368 #[test]
369 fn test_correct_timecode_backward() {
370 let det = DriftDetector::new(DriftConfig::new(FrameRate::Fps25));
371 let tc = Timecode::new(0, 0, 1, 5, FrameRate::Fps25).expect("valid timecode");
372 let corrected = det
373 .correct_timecode(&tc, -5)
374 .expect("correction should succeed");
375 assert_eq!(corrected.seconds, 1);
376 assert_eq!(corrected.frames, 0);
377 }
378
379 #[test]
380 fn test_compute_ppm_zero() {
381 assert!((compute_ppm(1000, 1000)).abs() < 1e-10);
382 }
383
384 #[test]
385 fn test_compute_ppm_positive() {
386 let ppm = compute_ppm(1000, 1001);
388 assert!((ppm - 1000.0).abs() < 1e-6);
389 }
390
391 #[test]
392 fn test_compute_ppm_zero_reference() {
393 assert!((compute_ppm(0, 100)).abs() < 1e-10);
394 }
395
396 #[test]
397 fn test_clear_samples() {
398 let mut det = DriftDetector::new(DriftConfig::new(FrameRate::Fps25));
399 det.add_sample(DriftSample::new(0.0, 0, 0));
400 assert_eq!(det.sample_count(), 1);
401 det.clear_samples();
402 assert_eq!(det.sample_count(), 0);
403 }
404}