Skip to main content

oximedia_timecode/
frame_rate.rs

1#![allow(dead_code)]
2//! Rational frame-rate representation independent of the SMPTE enum in lib.rs.
3
4/// A rational frame rate expressed as `numerator / denominator`.
5///
6/// This complements the [`crate::FrameRate`] enum with arbitrary-precision
7/// rational arithmetic so that custom frame rates (e.g. 48000/1001) can be
8/// represented without adding new enum variants.
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub struct FrameRateRatio {
11    /// Numerator of the frame rate fraction.
12    pub numerator: u32,
13    /// Denominator of the frame rate fraction.
14    pub denominator: u32,
15}
16
17impl FrameRateRatio {
18    /// Create a new [`FrameRateRatio`].
19    ///
20    /// Returns `None` if `denominator` is zero.
21    pub fn new(numerator: u32, denominator: u32) -> Option<Self> {
22        if denominator == 0 {
23            None
24        } else {
25            Some(Self {
26                numerator,
27                denominator,
28            })
29        }
30    }
31
32    /// Exact floating-point value of the frame rate.
33    #[allow(clippy::cast_precision_loss)]
34    pub fn fps_f64(&self) -> f64 {
35        self.numerator as f64 / self.denominator as f64
36    }
37
38    /// Whether this frame rate is compatible with SMPTE drop-frame timecode.
39    ///
40    /// Drop frame is only defined for the 30000/1001 (≈ 29.97) and
41    /// 60000/1001 (≈ 59.94) rates.
42    pub fn is_drop_frame_compatible(&self) -> bool {
43        matches!(
44            (self.numerator, self.denominator),
45            (30000, 1001) | (60000, 1001)
46        )
47    }
48
49    /// Whether this ratio is numerically equal to `other` (cross-multiply check).
50    pub fn matches(&self, other: &FrameRateRatio) -> bool {
51        // Cross-multiply to avoid floating-point comparison.
52        (self.numerator as u64) * (other.denominator as u64)
53            == (other.numerator as u64) * (self.denominator as u64)
54    }
55
56    /// A list of common broadcast frame rates.
57    pub fn common_frame_rates() -> Vec<FrameRateRatio> {
58        vec![
59            FrameRateRatio {
60                numerator: 24000,
61                denominator: 1001,
62            }, // 23.976
63            FrameRateRatio {
64                numerator: 24,
65                denominator: 1,
66            }, // 24
67            FrameRateRatio {
68                numerator: 25,
69                denominator: 1,
70            }, // 25
71            FrameRateRatio {
72                numerator: 30000,
73                denominator: 1001,
74            }, // 29.97
75            FrameRateRatio {
76                numerator: 30,
77                denominator: 1,
78            }, // 30
79            FrameRateRatio {
80                numerator: 50,
81                denominator: 1,
82            }, // 50
83            FrameRateRatio {
84                numerator: 60000,
85                denominator: 1001,
86            }, // 59.94
87            FrameRateRatio {
88                numerator: 60,
89                denominator: 1,
90            }, // 60
91        ]
92    }
93
94    /// Nominal integer frames per second (rounded).
95    #[allow(clippy::cast_possible_truncation)]
96    pub fn nominal_fps(&self) -> u32 {
97        ((self.numerator as f64 / self.denominator as f64).round()) as u32
98    }
99}
100
101impl std::fmt::Display for FrameRateRatio {
102    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
103        if self.denominator == 1 {
104            write!(f, "{}", self.numerator)
105        } else {
106            write!(f, "{}/{}", self.numerator, self.denominator)
107        }
108    }
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114
115    #[test]
116    fn test_new_valid() {
117        let fr = FrameRateRatio::new(25, 1).expect("valid frame rate ratio");
118        assert_eq!(fr.numerator, 25);
119        assert_eq!(fr.denominator, 1);
120    }
121
122    #[test]
123    fn test_new_zero_denominator() {
124        assert!(FrameRateRatio::new(30, 0).is_none());
125    }
126
127    #[test]
128    fn test_fps_f64_exact() {
129        let fr = FrameRateRatio::new(25, 1).expect("valid frame rate ratio");
130        assert!((fr.fps_f64() - 25.0).abs() < 1e-9);
131    }
132
133    #[test]
134    fn test_fps_f64_fractional() {
135        let fr = FrameRateRatio::new(30000, 1001).expect("valid frame rate ratio");
136        assert!((fr.fps_f64() - 29.97002997).abs() < 1e-6);
137    }
138
139    #[test]
140    fn test_is_drop_frame_compatible_true() {
141        let fr2997 = FrameRateRatio::new(30000, 1001).expect("valid frame rate ratio");
142        let fr5994 = FrameRateRatio::new(60000, 1001).expect("valid frame rate ratio");
143        assert!(fr2997.is_drop_frame_compatible());
144        assert!(fr5994.is_drop_frame_compatible());
145    }
146
147    #[test]
148    fn test_is_drop_frame_compatible_false() {
149        let fr = FrameRateRatio::new(25, 1).expect("valid frame rate ratio");
150        assert!(!fr.is_drop_frame_compatible());
151    }
152
153    #[test]
154    fn test_matches_equal() {
155        let a = FrameRateRatio::new(25, 1).expect("valid frame rate ratio");
156        let b = FrameRateRatio::new(50, 2).expect("valid frame rate ratio");
157        assert!(a.matches(&b));
158    }
159
160    #[test]
161    fn test_matches_not_equal() {
162        let a = FrameRateRatio::new(24, 1).expect("valid frame rate ratio");
163        let b = FrameRateRatio::new(25, 1).expect("valid frame rate ratio");
164        assert!(!a.matches(&b));
165    }
166
167    #[test]
168    fn test_common_frame_rates_count() {
169        let rates = FrameRateRatio::common_frame_rates();
170        assert_eq!(rates.len(), 8);
171    }
172
173    #[test]
174    fn test_common_frame_rates_contains_25() {
175        let rates = FrameRateRatio::common_frame_rates();
176        assert!(rates
177            .iter()
178            .any(|r| r.numerator == 25 && r.denominator == 1));
179    }
180
181    #[test]
182    fn test_nominal_fps_exact() {
183        let fr = FrameRateRatio::new(30, 1).expect("valid frame rate ratio");
184        assert_eq!(fr.nominal_fps(), 30);
185    }
186
187    #[test]
188    fn test_nominal_fps_rounded() {
189        let fr = FrameRateRatio::new(30000, 1001).expect("valid frame rate ratio");
190        // 29.97 rounds to 30
191        assert_eq!(fr.nominal_fps(), 30);
192    }
193
194    #[test]
195    fn test_display_integer() {
196        let fr = FrameRateRatio::new(25, 1).expect("valid frame rate ratio");
197        assert_eq!(fr.to_string(), "25");
198    }
199
200    #[test]
201    fn test_display_fractional() {
202        let fr = FrameRateRatio::new(30000, 1001).expect("valid frame rate ratio");
203        assert_eq!(fr.to_string(), "30000/1001");
204    }
205}