Skip to main content

oximedia_timecode/
timecode_calculator.rs

1//! Timecode arithmetic and conversion utilities.
2//!
3//! Provides `TimecodeValue` for frame-accurate timecode calculations,
4//! including addition, subtraction, and string formatting.
5
6use std::fmt;
7
8/// A timecode value with arithmetic support.
9#[derive(Debug, Clone, Copy, PartialEq)]
10#[allow(dead_code)]
11pub struct TimecodeValue {
12    /// Hours component (0-23)
13    pub hh: u8,
14    /// Minutes component (0-59)
15    pub mm: u8,
16    /// Seconds component (0-59)
17    pub ss: u8,
18    /// Frames component (0 to fps-1)
19    pub ff: u8,
20    /// Frames per second (e.g. 25.0, 29.97, 30.0)
21    pub fps: f32,
22    /// Whether this timecode uses drop-frame notation
23    pub drop_frame: bool,
24}
25
26impl TimecodeValue {
27    /// Create a new timecode value.
28    #[must_use]
29    pub fn new(hh: u8, mm: u8, ss: u8, ff: u8, fps: f32, drop_frame: bool) -> Self {
30        Self {
31            hh,
32            mm,
33            ss,
34            ff,
35            fps,
36            drop_frame,
37        }
38    }
39
40    /// Get the integer frames per second.
41    #[must_use]
42    fn fps_int(&self) -> u64 {
43        self.fps.ceil() as u64
44    }
45
46    /// Get total frames per day (wrapping boundary).
47    #[must_use]
48    fn frames_per_day(&self) -> u64 {
49        self.fps_int() * 3600 * 24
50    }
51
52    /// Convert to total frame count since 00:00:00:00.
53    ///
54    /// For drop-frame timecode, applies the standard SMPTE drop-frame adjustment.
55    #[must_use]
56    pub fn to_frame_count(&self) -> u64 {
57        let fps = self.fps_int();
58        let hh = u64::from(self.hh);
59        let mm = u64::from(self.mm);
60        let ss = u64::from(self.ss);
61        let ff = u64::from(self.ff);
62
63        let raw = hh * 3600 * fps + mm * 60 * fps + ss * fps + ff;
64
65        if self.drop_frame {
66            let total_minutes = hh * 60 + mm;
67            let dropped = 2 * (total_minutes - total_minutes / 10);
68            raw - dropped
69        } else {
70            raw
71        }
72    }
73
74    /// Create a timecode value from a frame count.
75    #[must_use]
76    pub fn from_frame_count(frames: u64, fps: f32, drop_frame: bool) -> Self {
77        let fps_int = fps.ceil() as u64;
78        let mut remaining = frames;
79
80        // Apply drop-frame adjustment
81        if drop_frame {
82            let frames_per_min = fps_int * 60 - 2;
83            let frames_per_10_min = frames_per_min * 9 + fps_int * 60;
84
85            let ten_min_blocks = remaining / frames_per_10_min;
86            remaining += ten_min_blocks * 18;
87
88            let remaining_in_block = remaining % frames_per_10_min;
89            if remaining_in_block >= fps_int * 60 {
90                let extra_minutes = (remaining_in_block - fps_int * 60) / frames_per_min;
91                remaining += (extra_minutes + 1) * 2;
92            }
93        }
94
95        let hh = ((remaining / (fps_int * 3600)) % 24) as u8;
96        remaining %= fps_int * 3600;
97        let mm = (remaining / (fps_int * 60)) as u8;
98        remaining %= fps_int * 60;
99        let ss = (remaining / fps_int) as u8;
100        let ff = (remaining % fps_int) as u8;
101
102        Self::new(hh, mm, ss, ff, fps, drop_frame)
103    }
104
105    /// Add a number of frames (positive or negative), wrapping at 24 hours.
106    #[must_use]
107    pub fn add_frames(&self, frames: i64) -> Self {
108        let total = self.to_frame_count() as i64;
109        let frames_per_day = self.frames_per_day() as i64;
110
111        // Add frames and wrap within [0, frames_per_day)
112        let new_total = ((total + frames) % frames_per_day + frames_per_day) % frames_per_day;
113
114        Self::from_frame_count(new_total as u64, self.fps, self.drop_frame)
115    }
116
117    /// Compute the signed frame difference between self and another timecode.
118    ///
119    /// Returns a positive value if self is later than `other`.
120    #[must_use]
121    pub fn subtract(&self, other: &Self) -> i64 {
122        self.to_frame_count() as i64 - other.to_frame_count() as i64
123    }
124
125    /// Convert to a formatted timecode string.
126    ///
127    /// Uses colons for non-drop-frame and semicolons for drop-frame.
128    #[must_use]
129    pub fn to_string_formatted(&self) -> String {
130        let sep = if self.drop_frame { ';' } else { ':' };
131        format!(
132            "{:02}:{:02}:{:02}{}{:02}",
133            self.hh, self.mm, self.ss, sep, self.ff
134        )
135    }
136
137    /// Parse a timecode string.
138    ///
139    /// Detects drop-frame from the presence of a semicolon before the frame count.
140    /// Format: `HH:MM:SS:FF` (NDF) or `HH:MM:SS;FF` (DF).
141    #[must_use]
142    pub fn parse(s: &str, fps: f32) -> Option<Self> {
143        // Detect drop frame: last separator is ';'
144        let drop_frame = s.contains(';');
145
146        // Replace semicolons with colons for uniform splitting
147        let normalized = s.replace(';', ":");
148        let parts: Vec<&str> = normalized.split(':').collect();
149
150        if parts.len() != 4 {
151            return None;
152        }
153
154        let hh: u8 = parts[0].parse().ok()?;
155        let mm: u8 = parts[1].parse().ok()?;
156        let ss: u8 = parts[2].parse().ok()?;
157        let ff: u8 = parts[3].parse().ok()?;
158
159        if hh > 23 || mm > 59 || ss > 59 {
160            return None;
161        }
162
163        Some(Self::new(hh, mm, ss, ff, fps, drop_frame))
164    }
165}
166
167impl fmt::Display for TimecodeValue {
168    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
169        write!(f, "{}", self.to_string_formatted())
170    }
171}
172
173/// Duration utilities for converting timecodes to wall-clock time.
174#[allow(dead_code)]
175pub struct Duration;
176
177impl Duration {
178    /// Convert a timecode value to elapsed time in seconds.
179    ///
180    /// For 29.97 fps drop-frame, this gives accurate real-time seconds.
181    #[must_use]
182    pub fn from_timecode(tc: &TimecodeValue) -> f64 {
183        let frame_count = tc.to_frame_count();
184        f64::from(frame_count as u32) / f64::from(tc.fps)
185    }
186
187    /// Convert seconds to a timecode value.
188    #[must_use]
189    pub fn to_timecode(seconds: f64, fps: f32, drop_frame: bool) -> TimecodeValue {
190        let total_frames = (seconds * f64::from(fps)).round() as u64;
191        TimecodeValue::from_frame_count(total_frames, fps, drop_frame)
192    }
193}
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198
199    #[test]
200    fn test_timecode_value_new() {
201        let tc = TimecodeValue::new(1, 2, 3, 4, 25.0, false);
202        assert_eq!(tc.hh, 1);
203        assert_eq!(tc.mm, 2);
204        assert_eq!(tc.ss, 3);
205        assert_eq!(tc.ff, 4);
206        assert!((tc.fps - 25.0).abs() < f32::EPSILON);
207        assert!(!tc.drop_frame);
208    }
209
210    #[test]
211    fn test_to_frame_count_ndf() {
212        let tc = TimecodeValue::new(0, 0, 1, 0, 25.0, false);
213        assert_eq!(tc.to_frame_count(), 25);
214    }
215
216    #[test]
217    fn test_to_frame_count_one_hour() {
218        let tc = TimecodeValue::new(1, 0, 0, 0, 30.0, false);
219        assert_eq!(tc.to_frame_count(), 3600 * 30);
220    }
221
222    #[test]
223    fn test_from_frame_count_ndf() {
224        let tc = TimecodeValue::from_frame_count(25, 25.0, false);
225        assert_eq!(tc.hh, 0);
226        assert_eq!(tc.mm, 0);
227        assert_eq!(tc.ss, 1);
228        assert_eq!(tc.ff, 0);
229    }
230
231    #[test]
232    fn test_frame_count_roundtrip_ndf() {
233        let original = TimecodeValue::new(1, 30, 45, 12, 25.0, false);
234        let frames = original.to_frame_count();
235        let recovered = TimecodeValue::from_frame_count(frames, 25.0, false);
236        assert_eq!(original, recovered);
237    }
238
239    #[test]
240    fn test_add_frames_forward() {
241        let tc = TimecodeValue::new(0, 0, 0, 0, 25.0, false);
242        let tc2 = tc.add_frames(25);
243        assert_eq!(tc2.ss, 1);
244        assert_eq!(tc2.ff, 0);
245    }
246
247    #[test]
248    fn test_add_frames_backward() {
249        let tc = TimecodeValue::new(0, 0, 1, 0, 25.0, false);
250        let tc2 = tc.add_frames(-25);
251        assert_eq!(tc2.hh, 0);
252        assert_eq!(tc2.mm, 0);
253        assert_eq!(tc2.ss, 0);
254        assert_eq!(tc2.ff, 0);
255    }
256
257    #[test]
258    fn test_add_frames_wrap_at_24h() {
259        // 24h in 25fps = 24 * 3600 * 25 frames
260        let tc = TimecodeValue::new(23, 59, 59, 24, 25.0, false);
261        let tc2 = tc.add_frames(1); // Should wrap to 00:00:00:00
262        assert_eq!(tc2.hh, 0);
263        assert_eq!(tc2.mm, 0);
264        assert_eq!(tc2.ss, 0);
265        assert_eq!(tc2.ff, 0);
266    }
267
268    #[test]
269    fn test_subtract() {
270        let tc1 = TimecodeValue::new(0, 0, 1, 0, 25.0, false);
271        let tc2 = TimecodeValue::new(0, 0, 0, 0, 25.0, false);
272        assert_eq!(tc1.subtract(&tc2), 25);
273        assert_eq!(tc2.subtract(&tc1), -25);
274    }
275
276    #[test]
277    fn test_display_ndf() {
278        let tc = TimecodeValue::new(1, 2, 3, 4, 25.0, false);
279        assert_eq!(tc.to_string(), "01:02:03:04");
280    }
281
282    #[test]
283    fn test_display_df() {
284        let tc = TimecodeValue::new(1, 2, 3, 4, 29.97, true);
285        assert_eq!(tc.to_string(), "01:02:03;04");
286    }
287
288    #[test]
289    fn test_parse_ndf() {
290        let tc = TimecodeValue::parse("01:02:03:04", 25.0).expect("valid timecode value");
291        assert_eq!(tc.hh, 1);
292        assert_eq!(tc.mm, 2);
293        assert_eq!(tc.ss, 3);
294        assert_eq!(tc.ff, 4);
295        assert!(!tc.drop_frame);
296    }
297
298    #[test]
299    fn test_parse_df() {
300        let tc = TimecodeValue::parse("01:02:03;04", 29.97).expect("valid timecode value");
301        assert_eq!(tc.hh, 1);
302        assert_eq!(tc.mm, 2);
303        assert_eq!(tc.ss, 3);
304        assert_eq!(tc.ff, 4);
305        assert!(tc.drop_frame);
306    }
307
308    #[test]
309    fn test_parse_invalid() {
310        assert!(TimecodeValue::parse("invalid", 25.0).is_none());
311        assert!(TimecodeValue::parse("25:00:00:00", 25.0).is_none()); // hours > 23
312        assert!(TimecodeValue::parse("", 25.0).is_none());
313    }
314
315    #[test]
316    fn test_duration_from_timecode() {
317        let tc = TimecodeValue::new(0, 0, 1, 0, 25.0, false);
318        let secs = Duration::from_timecode(&tc);
319        assert!((secs - 1.0).abs() < 0.01);
320    }
321
322    #[test]
323    fn test_duration_to_timecode() {
324        let tc = Duration::to_timecode(1.0, 25.0, false);
325        assert_eq!(tc.ss, 1);
326        assert_eq!(tc.ff, 0);
327    }
328
329    #[test]
330    fn test_parse_display_roundtrip() {
331        let original = "01:30:45:12";
332        let tc = TimecodeValue::parse(original, 25.0).expect("valid timecode value");
333        assert_eq!(tc.to_string(), original);
334    }
335}