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).unwrap();
148        assert_eq!(fmt.format_frames(1234), "1234");
149    }
150
151    #[test]
152    fn test_format_frames_seconds() {
153        let fmt = TimecodeFormatter::new(TimecodeFormat::Seconds, 25, false).unwrap();
154        // 25 frames at 25 fps = 1.000 s
155        assert_eq!(fmt.format_frames(25), "1.000");
156    }
157
158    #[test]
159    fn test_format_frames_feet() {
160        let fmt = TimecodeFormatter::new(TimecodeFormat::Feet, 24, false).unwrap();
161        // 16 frames = 1 foot + 0 frames
162        assert_eq!(fmt.format_frames(16), "1+00");
163        // 17 frames = 1 foot + 1 frame
164        assert_eq!(fmt.format_frames(17), "1+01");
165    }
166
167    #[test]
168    fn test_format_frames_smpte_ndf() {
169        let fmt = TimecodeFormatter::new(TimecodeFormat::Smpte, 25, false).unwrap();
170        // 1 hour = 25 * 3600 = 90000 frames
171        assert_eq!(fmt.format_frames(90000), "01:00:00:00");
172    }
173
174    #[test]
175    fn test_format_frames_smpte_df() {
176        let fmt = TimecodeFormatter::new(TimecodeFormat::Smpte, 30, true).unwrap();
177        // Frame 30 → 0h 0m 1s 0f
178        assert_eq!(fmt.format_frames(30), "00:00:01;00");
179    }
180
181    #[test]
182    fn test_format_frames_smpte_mixed() {
183        let fmt = TimecodeFormatter::new(TimecodeFormat::Smpte, 25, false).unwrap();
184        // 01:02:03:04 = (1*3600 + 2*60 + 3)*25 + 4 = (3723)*25+4 = 93079
185        assert_eq!(fmt.format_frames(93079), "01:02:03:04");
186    }
187
188    #[test]
189    fn test_parse_smpte_valid_colon() {
190        let fmt = TimecodeFormatter::new(TimecodeFormat::Smpte, 25, false).unwrap();
191        let frames = fmt.parse_smpte("01:02:03:04").unwrap();
192        // Should round-trip with format_frames
193        assert_eq!(fmt.format_frames(frames), "01:02:03:04");
194    }
195
196    #[test]
197    fn test_parse_smpte_valid_semicolon() {
198        let fmt = TimecodeFormatter::new(TimecodeFormat::Smpte, 30, true).unwrap();
199        let frames = fmt.parse_smpte("00:00:01;00").unwrap();
200        assert_eq!(frames, 30);
201    }
202
203    #[test]
204    fn test_parse_smpte_invalid_too_few_parts() {
205        let fmt = TimecodeFormatter::new(TimecodeFormat::Smpte, 25, false).unwrap();
206        assert!(fmt.parse_smpte("01:02:03").is_none());
207    }
208
209    #[test]
210    fn test_parse_smpte_invalid_frames_exceed_fps() {
211        let fmt = TimecodeFormatter::new(TimecodeFormat::Smpte, 25, false).unwrap();
212        assert!(fmt.parse_smpte("00:00:00:25").is_none());
213    }
214
215    #[test]
216    fn test_parse_smpte_invalid_minutes() {
217        let fmt = TimecodeFormatter::new(TimecodeFormat::Smpte, 25, false).unwrap();
218        assert!(fmt.parse_smpte("00:60:00:00").is_none());
219    }
220}