Skip to main content

oximedia_timecode/
timecode_format.rs

1#![allow(dead_code)]
2//! Timecode display format and parsing utilities.
3
4/// The visual format used to display a timecode value.
5#[derive(Debug, Clone, Copy, PartialEq, Eq)]
6pub enum TimecodeFormat {
7    /// SMPTE HH:MM:SS:FF / HH:MM:SS;FF notation.
8    Smpte,
9    /// Film-style feet+frames (e.g. `1234+08`).
10    Feet,
11    /// Absolute frame count as a plain integer.
12    Frames,
13    /// Wall-clock seconds expressed as a decimal (e.g. `3723.04`).
14    Seconds,
15}
16
17impl TimecodeFormat {
18    /// The separator character used between the seconds and frames fields
19    /// for SMPTE notation.  Non-SMPTE formats return `':'` as a placeholder.
20    pub fn separator(&self) -> char {
21        match self {
22            TimecodeFormat::Smpte => ':',
23            _ => ':',
24        }
25    }
26
27    /// Separator for drop-frame SMPTE timecode.
28    pub fn drop_frame_separator(&self) -> char {
29        match self {
30            TimecodeFormat::Smpte => ';',
31            _ => ':',
32        }
33    }
34}
35
36/// Formats and parses timecode values in various [`TimecodeFormat`]s.
37#[derive(Debug, Clone)]
38pub struct TimecodeFormatter {
39    /// The display format to use.
40    pub format: TimecodeFormat,
41    /// Frames per second (nominal integer, e.g. 25 or 30).
42    pub fps: u32,
43    /// Whether the timecode uses drop-frame counting.
44    pub drop_frame: bool,
45}
46
47impl TimecodeFormatter {
48    /// Create a new formatter.
49    ///
50    /// `fps` must be non-zero; returns `None` otherwise.
51    pub fn new(format: TimecodeFormat, fps: u32, drop_frame: bool) -> Option<Self> {
52        if fps == 0 {
53            None
54        } else {
55            Some(Self {
56                format,
57                fps,
58                drop_frame,
59            })
60        }
61    }
62
63    /// Convert an absolute frame count into a human-readable string.
64    pub fn format_frames(&self, total_frames: u64) -> String {
65        match self.format {
66            TimecodeFormat::Frames => format!("{}", total_frames),
67
68            TimecodeFormat::Seconds => {
69                let secs = total_frames as f64 / self.fps as f64;
70                format!("{:.3}", secs)
71            }
72
73            TimecodeFormat::Feet => {
74                // 35 mm film: 16 frames per foot.
75                let feet = total_frames / 16;
76                let rem = total_frames % 16;
77                format!("{}+{:02}", feet, rem)
78            }
79
80            TimecodeFormat::Smpte => {
81                let fps = self.fps as u64;
82                let hours = total_frames / (fps * 3600);
83                let rem = total_frames % (fps * 3600);
84                let minutes = rem / (fps * 60);
85                let rem = rem % (fps * 60);
86                let seconds = rem / fps;
87                let frames = rem % fps;
88
89                let sep = if self.drop_frame { ';' } else { ':' };
90                format!(
91                    "{:02}:{:02}:{:02}{}{:02}",
92                    hours, minutes, seconds, sep, frames
93                )
94            }
95        }
96    }
97
98    /// Parse a SMPTE timecode string (HH:MM:SS:FF or HH:MM:SS;FF) into total
99    /// frames using the formatter's `fps`.
100    ///
101    /// Returns `None` if the string is not valid SMPTE notation.
102    pub fn parse_smpte(&self, s: &str) -> Option<u64> {
103        // Accept both ':' and ';' as separators between SS and FF.
104        let normalized: String = s.chars().map(|c| if c == ';' { ':' } else { c }).collect();
105        let parts: Vec<&str> = normalized.split(':').collect();
106        if parts.len() != 4 {
107            return None;
108        }
109
110        let h: u64 = parts[0].parse().ok()?;
111        let m: u64 = parts[1].parse().ok()?;
112        let sec: u64 = parts[2].parse().ok()?;
113        let f: u64 = parts[3].parse().ok()?;
114
115        if m >= 60 || sec >= 60 || f >= self.fps as u64 {
116            return None;
117        }
118
119        let fps = self.fps as u64;
120        Some(h * 3600 * fps + m * 60 * fps + sec * fps + f)
121    }
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127
128    #[test]
129    fn test_format_enum_separator() {
130        assert_eq!(TimecodeFormat::Smpte.separator(), ':');
131        assert_eq!(TimecodeFormat::Frames.separator(), ':');
132    }
133
134    #[test]
135    fn test_drop_frame_separator() {
136        assert_eq!(TimecodeFormat::Smpte.drop_frame_separator(), ';');
137        assert_eq!(TimecodeFormat::Feet.drop_frame_separator(), ':');
138    }
139
140    #[test]
141    fn test_formatter_new_zero_fps_returns_none() {
142        assert!(TimecodeFormatter::new(TimecodeFormat::Smpte, 0, false).is_none());
143    }
144
145    #[test]
146    fn test_format_frames_frames() {
147        let fmt = TimecodeFormatter::new(TimecodeFormat::Frames, 25, false)
148            .expect("valid timecode formatter");
149        assert_eq!(fmt.format_frames(1234), "1234");
150    }
151
152    #[test]
153    fn test_format_frames_seconds() {
154        let fmt = TimecodeFormatter::new(TimecodeFormat::Seconds, 25, false)
155            .expect("valid timecode formatter");
156        // 25 frames at 25 fps = 1.000 s
157        assert_eq!(fmt.format_frames(25), "1.000");
158    }
159
160    #[test]
161    fn test_format_frames_feet() {
162        let fmt = TimecodeFormatter::new(TimecodeFormat::Feet, 24, false)
163            .expect("valid timecode formatter");
164        // 16 frames = 1 foot + 0 frames
165        assert_eq!(fmt.format_frames(16), "1+00");
166        // 17 frames = 1 foot + 1 frame
167        assert_eq!(fmt.format_frames(17), "1+01");
168    }
169
170    #[test]
171    fn test_format_frames_smpte_ndf() {
172        let fmt = TimecodeFormatter::new(TimecodeFormat::Smpte, 25, false)
173            .expect("valid timecode formatter");
174        // 1 hour = 25 * 3600 = 90000 frames
175        assert_eq!(fmt.format_frames(90000), "01:00:00:00");
176    }
177
178    #[test]
179    fn test_format_frames_smpte_df() {
180        let fmt = TimecodeFormatter::new(TimecodeFormat::Smpte, 30, true)
181            .expect("valid timecode formatter");
182        // Frame 30 → 0h 0m 1s 0f
183        assert_eq!(fmt.format_frames(30), "00:00:01;00");
184    }
185
186    #[test]
187    fn test_format_frames_smpte_mixed() {
188        let fmt = TimecodeFormatter::new(TimecodeFormat::Smpte, 25, false)
189            .expect("valid timecode formatter");
190        // 01:02:03:04 = (1*3600 + 2*60 + 3)*25 + 4 = (3723)*25+4 = 93079
191        assert_eq!(fmt.format_frames(93079), "01:02:03:04");
192    }
193
194    #[test]
195    fn test_parse_smpte_valid_colon() {
196        let fmt = TimecodeFormatter::new(TimecodeFormat::Smpte, 25, false)
197            .expect("valid timecode formatter");
198        let frames = fmt.parse_smpte("01:02:03:04").expect("valid SMPTE parse");
199        // Should round-trip with format_frames
200        assert_eq!(fmt.format_frames(frames), "01:02:03:04");
201    }
202
203    #[test]
204    fn test_parse_smpte_valid_semicolon() {
205        let fmt = TimecodeFormatter::new(TimecodeFormat::Smpte, 30, true)
206            .expect("valid timecode formatter");
207        let frames = fmt.parse_smpte("00:00:01;00").expect("valid SMPTE parse");
208        assert_eq!(frames, 30);
209    }
210
211    #[test]
212    fn test_parse_smpte_invalid_too_few_parts() {
213        let fmt = TimecodeFormatter::new(TimecodeFormat::Smpte, 25, false)
214            .expect("valid timecode formatter");
215        assert!(fmt.parse_smpte("01:02:03").is_none());
216    }
217
218    #[test]
219    fn test_parse_smpte_invalid_frames_exceed_fps() {
220        let fmt = TimecodeFormatter::new(TimecodeFormat::Smpte, 25, false)
221            .expect("valid timecode formatter");
222        assert!(fmt.parse_smpte("00:00:00:25").is_none());
223    }
224
225    #[test]
226    fn test_parse_smpte_invalid_minutes() {
227        let fmt = TimecodeFormatter::new(TimecodeFormat::Smpte, 25, false)
228            .expect("valid timecode formatter");
229        assert!(fmt.parse_smpte("00:60:00:00").is_none());
230    }
231}