Skip to main content

oximedia_timecode/
drop_frame.rs

1//! Drop-frame timecode calculations for 29.97 fps NTSC.
2//!
3//! Drop-frame timecode is a system that keeps timecode aligned with actual
4//! elapsed time for 29.97 fps video by "dropping" (skipping) frame numbers
5//! 00 and 01 at the start of each minute, except every 10th minute.
6//!
7//! Note: "drop frame" refers to dropping frame *numbers*, not actual video frames.
8
9use std::fmt;
10
11/// Drop-frame timecode calculator for 29.97 fps.
12///
13/// 29.97 fps = 30000/1001 fps. To keep timecode aligned with real time,
14/// 2 frame numbers are dropped per minute, except every 10th minute.
15/// Actual frame count per 24-hour day: 24 * 107892 = 2,589,408 frames.
16#[allow(dead_code)]
17pub struct DropFrameCalc;
18
19impl DropFrameCalc {
20    // Constants for drop-frame calculation
21    const FRAMES_PER_SEC: u64 = 30;
22    const DROP_PER_MIN: u64 = 2;
23    // Actual frames in one minute (29 drop minutes) = 30*60 - 2 = 1798
24    const FRAMES_PER_DROP_MIN: u64 = Self::FRAMES_PER_SEC * 60 - Self::DROP_PER_MIN; // 1798
25                                                                                     // Actual frames in 10 minutes = 9 drop minutes + 1 non-drop minute
26                                                                                     // = 9 * 1798 + 1800 = 17982
27    const FRAMES_PER_10_MIN: u64 = Self::FRAMES_PER_DROP_MIN * 9 + Self::FRAMES_PER_SEC * 60; // 17982
28                                                                                              // Actual frames per hour = 6 * 17982 = 107892
29    const FRAMES_PER_HOUR: u64 = Self::FRAMES_PER_10_MIN * 6; // 107892
30
31    /// Convert a frame count to drop-frame timecode (hh, mm, ss, ff).
32    ///
33    /// Uses the standard SMPTE drop-frame algorithm.
34    ///
35    /// Reference algorithm (from SMPTE 12M):
36    /// D = frame_count
37    /// D_f = D + 2 * (D / 17982) + 2 * ((D % 17982 - 2) / 1798)  [only if remainder >= 2]
38    #[must_use]
39    pub fn frame_count_to_df(frame_count: u64) -> (u8, u8, u8, u8) {
40        // Wrap at 24 hours
41        let d = frame_count % (Self::FRAMES_PER_HOUR * 24);
42
43        // Number of complete 10-minute blocks
44        let d_ten = d / Self::FRAMES_PER_10_MIN;
45        let d_in_ten = d % Self::FRAMES_PER_10_MIN;
46
47        // Within each 10-minute block:
48        // First 1800 frames (minute 0 of block) = non-drop minute
49        // Remaining 9 * 1798 frames = 9 drop minutes
50        let (min_in_ten, d_in_min) = if d_in_ten < Self::FRAMES_PER_SEC * 60 {
51            (0u64, d_in_ten)
52        } else {
53            let d_after_first = d_in_ten - Self::FRAMES_PER_SEC * 60;
54            let extra_min = d_after_first / Self::FRAMES_PER_DROP_MIN;
55            let d_in_drop_min = d_after_first % Self::FRAMES_PER_DROP_MIN;
56            (extra_min + 1, d_in_drop_min)
57        };
58
59        let total_minutes = d_ten * 10 + min_in_ten;
60        let hh = (total_minutes / 60) as u8;
61        let mm = (total_minutes % 60) as u8;
62
63        // Within the (drop) minute:
64        // For non-first minutes within a 10-min block, frames 0 and 1 are dropped,
65        // so the minute starts at frame number 2.
66        let (ss, ff) = if min_in_ten > 0 {
67            // Frame numbers 0 and 1 were skipped; actual frames start at 2
68            // d_in_min=0 corresponds to display frame 2 at second 0
69            let adjusted = d_in_min + Self::DROP_PER_MIN;
70            let ss = adjusted / Self::FRAMES_PER_SEC;
71            let ff = adjusted % Self::FRAMES_PER_SEC;
72            (ss as u8, ff as u8)
73        } else {
74            // Non-drop minute (first of every 10)
75            let ss = d_in_min / Self::FRAMES_PER_SEC;
76            let ff = d_in_min % Self::FRAMES_PER_SEC;
77            (ss as u8, ff as u8)
78        };
79
80        (hh, mm, ss, ff)
81    }
82
83    /// Convert drop-frame timecode (hh, mm, ss, ff) to a frame count.
84    ///
85    /// Standard SMPTE formula:
86    /// frame_count = 108000*hh + 1800*mm + 30*ss + ff
87    ///               - 2*(total_minutes - total_minutes/10)
88    ///
89    /// Note: 108000 = 30 * 3600 (raw 30fps hour count, NOT the drop-frame hour count).
90    #[must_use]
91    pub fn df_to_frame_count(hh: u8, mm: u8, ss: u8, ff: u8) -> u64 {
92        let hh = u64::from(hh);
93        let mm = u64::from(mm);
94        let ss = u64::from(ss);
95        let ff = u64::from(ff);
96
97        let total_minutes = hh * 60 + mm;
98
99        // Raw count as if 30 fps non-drop (108000 per hour, 1800 per minute)
100        let raw = hh * 108000 + mm * 1800 + ss * 30 + ff;
101
102        // Subtract 2 frames per minute except every 10th minute
103        let dropped = Self::DROP_PER_MIN * (total_minutes - total_minutes / 10);
104
105        raw - dropped
106    }
107
108    /// Format a frame count as a drop-frame timecode string.
109    ///
110    /// Drop-frame timecode uses semicolons (;) as separators.
111    #[must_use]
112    pub fn format_df(frame_count: u64) -> String {
113        let (hh, mm, ss, ff) = Self::frame_count_to_df(frame_count);
114        format!("{hh:02};{mm:02};{ss:02};{ff:02}")
115    }
116
117    /// Parse a drop-frame timecode string (HH;MM;SS;FF) into a frame count.
118    ///
119    /// Returns `None` if the string is not a valid drop-frame timecode.
120    #[must_use]
121    pub fn parse_df(tc: &str) -> Option<u64> {
122        let parts: Vec<&str> = tc.split(';').collect();
123        if parts.len() != 4 {
124            return None;
125        }
126
127        let hh: u8 = parts[0].parse().ok()?;
128        let mm: u8 = parts[1].parse().ok()?;
129        let ss: u8 = parts[2].parse().ok()?;
130        let ff: u8 = parts[3].parse().ok()?;
131
132        if hh > 23 || mm > 59 || ss > 59 || ff > 29 {
133            return None;
134        }
135
136        Some(Self::df_to_frame_count(hh, mm, ss, ff))
137    }
138
139    /// Check whether a given timecode position is a dropped frame number.
140    ///
141    /// Frames 0 and 1 at the start of each minute (except multiples of 10) are dropped.
142    #[must_use]
143    pub fn is_dropped_frame(hh: u8, mm: u8, ss: u8, ff: u8) -> bool {
144        let _ = hh; // Hours don't affect drop-frame logic
145        ss == 0 && ff < 2 && !mm.is_multiple_of(10)
146    }
147}
148
149/// Frame counter that supports both drop-frame and non-drop-frame modes.
150#[derive(Debug, Clone)]
151#[allow(dead_code)]
152pub struct TotalFrameCounter {
153    /// Current frame count
154    frame_count: u64,
155    /// Whether to use drop-frame mode
156    drop_frame: bool,
157    /// Frame rate (integer, typically 30 for DF)
158    fps: u8,
159}
160
161impl TotalFrameCounter {
162    /// Create a new frame counter in drop-frame mode.
163    #[must_use]
164    pub fn new_drop_frame() -> Self {
165        Self {
166            frame_count: 0,
167            drop_frame: true,
168            fps: 30,
169        }
170    }
171
172    /// Create a new frame counter in non-drop-frame mode.
173    #[must_use]
174    pub fn new_non_drop_frame(fps: u8) -> Self {
175        Self {
176            frame_count: 0,
177            drop_frame: false,
178            fps,
179        }
180    }
181
182    /// Add frames to the counter.
183    pub fn add_frames(&mut self, n: u64) {
184        self.frame_count = self.frame_count.wrapping_add(n);
185    }
186
187    /// Get the current frame count.
188    #[must_use]
189    pub fn frame_count(&self) -> u64 {
190        self.frame_count
191    }
192
193    /// Reset the counter.
194    pub fn reset(&mut self) {
195        self.frame_count = 0;
196    }
197
198    /// Check if drop-frame mode is active.
199    #[must_use]
200    pub fn is_drop_frame(&self) -> bool {
201        self.drop_frame
202    }
203}
204
205impl fmt::Display for TotalFrameCounter {
206    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
207        if self.drop_frame {
208            write!(f, "{}", DropFrameCalc::format_df(self.frame_count))
209        } else {
210            // Non-drop-frame: use colons
211            let fps = u64::from(self.fps);
212            let seconds_total = self.frame_count / fps;
213            let frames = self.frame_count % fps;
214            let seconds = seconds_total % 60;
215            let minutes_total = seconds_total / 60;
216            let minutes = minutes_total % 60;
217            let hours = (minutes_total / 60) % 24;
218            write!(f, "{hours:02}:{minutes:02}:{seconds:02}:{frames:02}")
219        }
220    }
221}
222
223#[cfg(test)]
224mod tests {
225    use super::*;
226
227    #[test]
228    fn test_df_zero() {
229        let (hh, mm, ss, ff) = DropFrameCalc::frame_count_to_df(0);
230        assert_eq!((hh, mm, ss, ff), (0, 0, 0, 0));
231    }
232
233    #[test]
234    fn test_df_one_frame() {
235        let (hh, mm, ss, ff) = DropFrameCalc::frame_count_to_df(1);
236        assert_eq!((hh, mm, ss, ff), (0, 0, 0, 1));
237    }
238
239    #[test]
240    fn test_df_one_second() {
241        let (hh, mm, ss, ff) = DropFrameCalc::frame_count_to_df(30);
242        assert_eq!((hh, mm, ss, ff), (0, 0, 1, 0));
243    }
244
245    #[test]
246    fn test_df_one_minute() {
247        // Minute 0 has 1800 frames (no drop), frame 1800 = first frame of minute 1
248        // At minute 1, frames 0 and 1 are dropped, so it starts at 00;01;00;02
249        let (hh, mm, ss, ff) = DropFrameCalc::frame_count_to_df(1800);
250        assert_eq!(hh, 0);
251        assert_eq!(mm, 1);
252        assert_eq!(ss, 0);
253        assert_eq!(ff, 2); // Frames 0 and 1 are dropped
254    }
255
256    #[test]
257    fn test_df_ten_minutes() {
258        // After 10 minutes - no drop at that boundary
259        let frames = DropFrameCalc::df_to_frame_count(0, 10, 0, 0);
260        let (hh, mm, ss, ff) = DropFrameCalc::frame_count_to_df(frames);
261        assert_eq!((hh, mm, ss, ff), (0, 10, 0, 0));
262    }
263
264    #[test]
265    fn test_df_roundtrip() {
266        let test_cases = [
267            (0u8, 0u8, 0u8, 0u8),
268            (0, 0, 0, 15),
269            (0, 0, 30, 0),
270            (0, 10, 0, 0), // 10th minute - no drop
271            (1, 0, 0, 0),
272        ];
273
274        for (hh, mm, ss, ff) in test_cases {
275            let frame_count = DropFrameCalc::df_to_frame_count(hh, mm, ss, ff);
276            let (rhh, rmm, rss, rff) = DropFrameCalc::frame_count_to_df(frame_count);
277            assert_eq!(
278                (hh, mm, ss, ff),
279                (rhh, rmm, rss, rff),
280                "Roundtrip failed for {hh:02};{mm:02};{ss:02};{ff:02}"
281            );
282        }
283    }
284
285    #[test]
286    fn test_format_df() {
287        let s = DropFrameCalc::format_df(0);
288        assert_eq!(s, "00;00;00;00");
289    }
290
291    #[test]
292    fn test_parse_df_valid() {
293        let count = DropFrameCalc::parse_df("00;00;00;00").unwrap();
294        assert_eq!(count, 0);
295    }
296
297    #[test]
298    fn test_parse_df_invalid() {
299        assert!(DropFrameCalc::parse_df("00:00:00:00").is_none()); // colons, not semicolons
300        assert!(DropFrameCalc::parse_df("25;00;00;00").is_none()); // hours > 23
301        assert!(DropFrameCalc::parse_df("not;a;timecode;x").is_none());
302        assert!(DropFrameCalc::parse_df("").is_none());
303    }
304
305    #[test]
306    fn test_is_dropped_frame() {
307        // Minute 1, second 0, frames 0 and 1 are dropped
308        assert!(DropFrameCalc::is_dropped_frame(0, 1, 0, 0));
309        assert!(DropFrameCalc::is_dropped_frame(0, 1, 0, 1));
310        assert!(!DropFrameCalc::is_dropped_frame(0, 1, 0, 2));
311
312        // Minute 10 is NOT dropped
313        assert!(!DropFrameCalc::is_dropped_frame(0, 10, 0, 0));
314        assert!(!DropFrameCalc::is_dropped_frame(0, 10, 0, 1));
315
316        // Non-zero second is never dropped
317        assert!(!DropFrameCalc::is_dropped_frame(0, 1, 1, 0));
318    }
319
320    #[test]
321    fn test_total_frame_counter_drop_frame() {
322        let mut counter = TotalFrameCounter::new_drop_frame();
323        assert!(counter.is_drop_frame());
324        counter.add_frames(100);
325        assert_eq!(counter.frame_count(), 100);
326        let s = counter.to_string();
327        assert!(s.contains(';')); // Drop frame uses semicolons
328    }
329
330    #[test]
331    fn test_total_frame_counter_non_drop_frame() {
332        let mut counter = TotalFrameCounter::new_non_drop_frame(25);
333        assert!(!counter.is_drop_frame());
334        counter.add_frames(25); // One second
335        assert_eq!(counter.frame_count(), 25);
336        let s = counter.to_string();
337        assert!(s.contains(':'));
338        assert_eq!(s, "00:00:01:00");
339    }
340
341    #[test]
342    fn test_total_frame_counter_reset() {
343        let mut counter = TotalFrameCounter::new_drop_frame();
344        counter.add_frames(1000);
345        counter.reset();
346        assert_eq!(counter.frame_count(), 0);
347    }
348
349    #[test]
350    fn test_parse_format_roundtrip() {
351        let original = "01;05;30;15";
352        let frame_count = DropFrameCalc::parse_df(original).unwrap();
353        let formatted = DropFrameCalc::format_df(frame_count);
354        assert_eq!(formatted, original);
355    }
356}