#![allow(dead_code)]
use crate::{FrameRate, Timecode, TimecodeError};
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct DriftSample {
pub wall_time_secs: f64,
pub observed_frames: u64,
pub expected_frames: u64,
}
impl DriftSample {
pub fn new(wall_time_secs: f64, observed_frames: u64, expected_frames: u64) -> Self {
Self {
wall_time_secs,
observed_frames,
expected_frames,
}
}
#[allow(clippy::cast_precision_loss)]
pub fn drift_frames(&self) -> i64 {
self.observed_frames as i64 - self.expected_frames as i64
}
#[allow(clippy::cast_precision_loss)]
pub fn drift_ratio(&self) -> f64 {
if self.expected_frames == 0 {
return 0.0;
}
(self.observed_frames as f64 - self.expected_frames as f64) / self.expected_frames as f64
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CorrectionStrategy {
None,
FrameDropRepeat,
RateAdjust,
PhaseShift,
}
#[derive(Debug, Clone)]
pub struct DriftAnalysis {
pub drift_frames_per_hour: f64,
pub drift_ppm: f64,
pub max_drift_frames: i64,
pub within_tolerance: bool,
pub sample_count: usize,
pub recommended_strategy: CorrectionStrategy,
}
#[derive(Debug, Clone)]
pub struct DriftConfig {
pub frame_rate: FrameRate,
pub tolerance_frames: u32,
pub min_samples: usize,
pub ppm_threshold: f64,
}
impl DriftConfig {
pub fn new(frame_rate: FrameRate) -> Self {
Self {
frame_rate,
tolerance_frames: 2,
min_samples: 3,
ppm_threshold: 100.0,
}
}
pub fn with_tolerance(mut self, frames: u32) -> Self {
self.tolerance_frames = frames;
self
}
pub fn with_min_samples(mut self, n: usize) -> Self {
self.min_samples = n;
self
}
pub fn with_ppm_threshold(mut self, ppm: f64) -> Self {
self.ppm_threshold = ppm;
self
}
}
#[derive(Debug, Clone)]
pub struct DriftDetector {
config: DriftConfig,
samples: Vec<DriftSample>,
}
impl DriftDetector {
pub fn new(config: DriftConfig) -> Self {
Self {
config,
samples: Vec::new(),
}
}
pub fn add_sample(&mut self, sample: DriftSample) {
self.samples.push(sample);
}
pub fn sample_count(&self) -> usize {
self.samples.len()
}
pub fn clear_samples(&mut self) {
self.samples.clear();
}
pub fn latest_drift(&self) -> Option<i64> {
self.samples.last().map(DriftSample::drift_frames)
}
#[allow(clippy::cast_precision_loss)]
pub fn analyze(&self) -> Option<DriftAnalysis> {
if self.samples.len() < self.config.min_samples {
return None;
}
let n = self.samples.len() as f64;
let sum_t: f64 = self.samples.iter().map(|s| s.wall_time_secs).sum();
let sum_d: f64 = self.samples.iter().map(|s| s.drift_frames() as f64).sum();
let sum_td: f64 = self
.samples
.iter()
.map(|s| s.wall_time_secs * s.drift_frames() as f64)
.sum();
let sum_t2: f64 = self
.samples
.iter()
.map(|s| s.wall_time_secs * s.wall_time_secs)
.sum();
let denom = n * sum_t2 - sum_t * sum_t;
let slope = if denom.abs() > 1e-12 {
(n * sum_td - sum_t * sum_d) / denom
} else {
0.0
};
let drift_frames_per_hour = slope * 3600.0;
let fps = self.config.frame_rate.as_float();
let drift_ppm = if fps > 0.0 {
(slope / fps) * 1_000_000.0
} else {
0.0
};
let max_drift_frames = self
.samples
.iter()
.map(|s| s.drift_frames().unsigned_abs() as i64)
.max()
.unwrap_or(0);
let within_tolerance = max_drift_frames <= self.config.tolerance_frames as i64;
let recommended_strategy = if within_tolerance {
CorrectionStrategy::None
} else if drift_ppm.abs() > self.config.ppm_threshold {
CorrectionStrategy::RateAdjust
} else if max_drift_frames <= 5 {
CorrectionStrategy::PhaseShift
} else {
CorrectionStrategy::FrameDropRepeat
};
Some(DriftAnalysis {
drift_frames_per_hour,
drift_ppm,
max_drift_frames,
within_tolerance,
sample_count: self.samples.len(),
recommended_strategy,
})
}
pub fn correct_timecode(
&self,
observed: &Timecode,
correction_frames: i64,
) -> Result<Timecode, TimecodeError> {
let frame_rate = self.config.frame_rate;
let current = observed.to_frames();
let corrected = if correction_frames >= 0 {
current + correction_frames as u64
} else {
current.saturating_sub(correction_frames.unsigned_abs())
};
Timecode::from_frames(corrected, frame_rate)
}
pub fn frame_rate(&self) -> FrameRate {
self.config.frame_rate
}
}
#[allow(clippy::cast_precision_loss)]
pub fn compute_ppm(reference_frames: u64, observed_frames: u64) -> f64 {
if reference_frames == 0 {
return 0.0;
}
let diff = observed_frames as f64 - reference_frames as f64;
(diff / reference_frames as f64) * 1_000_000.0
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_drift_sample_creation() {
let s = DriftSample::new(1.0, 25, 25);
assert_eq!(s.wall_time_secs, 1.0);
assert_eq!(s.observed_frames, 25);
assert_eq!(s.expected_frames, 25);
}
#[test]
fn test_drift_sample_zero_drift() {
let s = DriftSample::new(1.0, 100, 100);
assert_eq!(s.drift_frames(), 0);
assert!((s.drift_ratio()).abs() < 1e-10);
}
#[test]
fn test_drift_sample_positive() {
let s = DriftSample::new(1.0, 102, 100);
assert_eq!(s.drift_frames(), 2);
assert!((s.drift_ratio() - 0.02).abs() < 1e-10);
}
#[test]
fn test_drift_sample_negative() {
let s = DriftSample::new(1.0, 98, 100);
assert_eq!(s.drift_frames(), -2);
assert!((s.drift_ratio() + 0.02).abs() < 1e-10);
}
#[test]
fn test_drift_sample_zero_expected() {
let s = DriftSample::new(0.0, 0, 0);
assert!((s.drift_ratio()).abs() < 1e-10);
}
#[test]
fn test_config_defaults() {
let c = DriftConfig::new(FrameRate::Fps25);
assert_eq!(c.tolerance_frames, 2);
assert_eq!(c.min_samples, 3);
assert!((c.ppm_threshold - 100.0).abs() < 1e-10);
}
#[test]
fn test_config_builder() {
let c = DriftConfig::new(FrameRate::Fps25)
.with_tolerance(5)
.with_min_samples(10)
.with_ppm_threshold(50.0);
assert_eq!(c.tolerance_frames, 5);
assert_eq!(c.min_samples, 10);
assert!((c.ppm_threshold - 50.0).abs() < 1e-10);
}
#[test]
fn test_detector_no_samples() {
let det = DriftDetector::new(DriftConfig::new(FrameRate::Fps25));
assert_eq!(det.sample_count(), 0);
assert!(det.latest_drift().is_none());
assert!(det.analyze().is_none());
}
#[test]
fn test_detector_add_sample() {
let mut det = DriftDetector::new(DriftConfig::new(FrameRate::Fps25));
det.add_sample(DriftSample::new(0.0, 0, 0));
det.add_sample(DriftSample::new(1.0, 25, 25));
assert_eq!(det.sample_count(), 2);
assert_eq!(det.latest_drift(), Some(0));
}
#[test]
fn test_analyze_no_drift() {
let mut det = DriftDetector::new(DriftConfig::new(FrameRate::Fps25));
det.add_sample(DriftSample::new(0.0, 0, 0));
det.add_sample(DriftSample::new(1.0, 25, 25));
det.add_sample(DriftSample::new(2.0, 50, 50));
let analysis = det.analyze().expect("analysis should succeed");
assert!(analysis.within_tolerance);
assert!((analysis.drift_ppm).abs() < 1.0);
assert_eq!(analysis.recommended_strategy, CorrectionStrategy::None);
}
#[test]
fn test_analyze_with_drift() {
let config = DriftConfig::new(FrameRate::Fps25).with_tolerance(1);
let mut det = DriftDetector::new(config);
det.add_sample(DriftSample::new(0.0, 0, 0));
det.add_sample(DriftSample::new(1.0, 26, 25));
det.add_sample(DriftSample::new(2.0, 52, 50));
let analysis = det.analyze().expect("analysis should succeed");
assert!(!analysis.within_tolerance);
assert!(analysis.max_drift_frames >= 1);
}
#[test]
fn test_correct_timecode_forward() {
let det = DriftDetector::new(DriftConfig::new(FrameRate::Fps25));
let tc = Timecode::new(0, 0, 1, 0, FrameRate::Fps25).expect("valid timecode");
let corrected = det
.correct_timecode(&tc, 5)
.expect("correction should succeed");
assert_eq!(corrected.seconds, 1);
assert_eq!(corrected.frames, 5);
}
#[test]
fn test_correct_timecode_backward() {
let det = DriftDetector::new(DriftConfig::new(FrameRate::Fps25));
let tc = Timecode::new(0, 0, 1, 5, FrameRate::Fps25).expect("valid timecode");
let corrected = det
.correct_timecode(&tc, -5)
.expect("correction should succeed");
assert_eq!(corrected.seconds, 1);
assert_eq!(corrected.frames, 0);
}
#[test]
fn test_compute_ppm_zero() {
assert!((compute_ppm(1000, 1000)).abs() < 1e-10);
}
#[test]
fn test_compute_ppm_positive() {
let ppm = compute_ppm(1000, 1001);
assert!((ppm - 1000.0).abs() < 1e-6);
}
#[test]
fn test_compute_ppm_zero_reference() {
assert!((compute_ppm(0, 100)).abs() < 1e-10);
}
#[test]
fn test_clear_samples() {
let mut det = DriftDetector::new(DriftConfig::new(FrameRate::Fps25));
det.add_sample(DriftSample::new(0.0, 0, 0));
assert_eq!(det.sample_count(), 1);
det.clear_samples();
assert_eq!(det.sample_count(), 0);
}
}