Skip to main content

oximedia_container/timecode/
track.rs

1//! Timecode track handling.
2//!
3//! Professional timecode support for broadcast workflows.
4
5#![forbid(unsafe_code)]
6
7use oximedia_core::{OxiError, OxiResult};
8
9/// Timecode format.
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum TimecodeFormat {
12    /// 24 fps non-drop.
13    Fps24,
14    /// 25 fps (PAL).
15    Fps25,
16    /// 30 fps non-drop.
17    Fps30NonDrop,
18    /// 30 fps drop-frame (29.97).
19    Fps30Drop,
20    /// 60 fps non-drop.
21    Fps60NonDrop,
22    /// 60 fps drop-frame (59.94).
23    Fps60Drop,
24}
25
26impl TimecodeFormat {
27    /// Returns the frame rate as frames per second.
28    #[must_use]
29    pub const fn fps(&self) -> f64 {
30        match self {
31            Self::Fps24 => 24.0,
32            Self::Fps25 => 25.0,
33            Self::Fps30NonDrop => 30.0,
34            Self::Fps30Drop => 29.97,
35            Self::Fps60NonDrop => 60.0,
36            Self::Fps60Drop => 59.94,
37        }
38    }
39
40    /// Returns true if this is a drop-frame format.
41    #[must_use]
42    pub const fn is_drop_frame(&self) -> bool {
43        matches!(self, Self::Fps30Drop | Self::Fps60Drop)
44    }
45}
46
47/// A timecode value.
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub struct Timecode {
50    /// Hours (0-23).
51    pub hours: u8,
52    /// Minutes (0-59).
53    pub minutes: u8,
54    /// Seconds (0-59).
55    pub seconds: u8,
56    /// Frames (0-fps-1).
57    pub frames: u8,
58    /// Format.
59    pub format: TimecodeFormat,
60}
61
62impl Timecode {
63    /// Creates a new timecode.
64    ///
65    /// # Errors
66    ///
67    /// Returns `Err` if hours >= 24, minutes >= 60, seconds >= 60, or frames >= fps.
68    pub fn new(
69        hours: u8,
70        minutes: u8,
71        seconds: u8,
72        frames: u8,
73        format: TimecodeFormat,
74    ) -> OxiResult<Self> {
75        // Validate ranges
76        if hours >= 24 {
77            return Err(OxiError::InvalidData("Hours must be 0-23".into()));
78        }
79        if minutes >= 60 {
80            return Err(OxiError::InvalidData("Minutes must be 0-59".into()));
81        }
82        if seconds >= 60 {
83            return Err(OxiError::InvalidData("Seconds must be 0-59".into()));
84        }
85
86        #[allow(clippy::cast_possible_truncation)]
87        #[allow(clippy::cast_sign_loss)]
88        let max_frames = format.fps() as u8;
89
90        if frames >= max_frames {
91            return Err(OxiError::InvalidData(format!(
92                "Frames must be 0-{}",
93                max_frames - 1
94            )));
95        }
96
97        Ok(Self {
98            hours,
99            minutes,
100            seconds,
101            frames,
102            format,
103        })
104    }
105
106    /// Creates a timecode from frame count.
107    #[must_use]
108    #[allow(clippy::cast_possible_truncation)]
109    #[allow(clippy::cast_sign_loss)]
110    pub fn from_frame_count(frame_count: u64, format: TimecodeFormat) -> Self {
111        let fps = format.fps() as u64;
112        let total_seconds = frame_count / fps;
113        let frames = (frame_count % fps) as u8;
114
115        let hours = (total_seconds / 3600) as u8;
116        let minutes = ((total_seconds % 3600) / 60) as u8;
117        let seconds = (total_seconds % 60) as u8;
118
119        Self {
120            hours,
121            minutes,
122            seconds,
123            frames,
124            format,
125        }
126    }
127
128    /// Converts timecode to frame count.
129    #[must_use]
130    #[allow(clippy::cast_precision_loss)]
131    #[allow(clippy::cast_possible_truncation)]
132    #[allow(clippy::cast_sign_loss)]
133    pub fn to_frame_count(&self) -> u64 {
134        let fps = self.format.fps();
135        let total_seconds =
136            u64::from(self.hours) * 3600 + u64::from(self.minutes) * 60 + u64::from(self.seconds);
137        (total_seconds as f64 * fps) as u64 + u64::from(self.frames)
138    }
139
140    /// Formats the timecode as a string.
141    #[must_use]
142    pub fn format_string(&self) -> String {
143        let separator = if self.format.is_drop_frame() {
144            ';'
145        } else {
146            ':'
147        };
148        format!(
149            "{:02}:{:02}:{:02}{}{:02}",
150            self.hours, self.minutes, self.seconds, separator, self.frames
151        )
152    }
153
154    /// Parses a timecode string.
155    ///
156    /// # Errors
157    ///
158    /// Returns `Err` if the string is not in `HH:MM:SS:FF` format or contains invalid values.
159    pub fn from_string(s: &str, format: TimecodeFormat) -> OxiResult<Self> {
160        let parts: Vec<&str> = s.split([':', ';']).collect();
161
162        if parts.len() != 4 {
163            return Err(OxiError::InvalidData(
164                "Timecode must be in format HH:MM:SS:FF".into(),
165            ));
166        }
167
168        let hours = parts[0]
169            .parse()
170            .map_err(|_| OxiError::InvalidData("Invalid hours".into()))?;
171        let minutes = parts[1]
172            .parse()
173            .map_err(|_| OxiError::InvalidData("Invalid minutes".into()))?;
174        let seconds = parts[2]
175            .parse()
176            .map_err(|_| OxiError::InvalidData("Invalid seconds".into()))?;
177        let frames = parts[3]
178            .parse()
179            .map_err(|_| OxiError::InvalidData("Invalid frames".into()))?;
180
181        Self::new(hours, minutes, seconds, frames, format)
182    }
183}
184
185/// Timecode track in a container.
186#[derive(Debug, Clone)]
187pub struct TimecodeTrack {
188    format: TimecodeFormat,
189    start_timecode: Timecode,
190    timecodes: Vec<(u64, Timecode)>, // (sample_number, timecode)
191}
192
193impl TimecodeTrack {
194    /// Creates a new timecode track.
195    #[must_use]
196    pub const fn new(format: TimecodeFormat, start_timecode: Timecode) -> Self {
197        Self {
198            format,
199            start_timecode,
200            timecodes: Vec::new(),
201        }
202    }
203
204    /// Adds a timecode at a specific sample.
205    pub fn add_timecode(&mut self, sample_number: u64, timecode: Timecode) {
206        self.timecodes.push((sample_number, timecode));
207    }
208
209    /// Gets the timecode at a specific sample.
210    #[must_use]
211    pub fn get_timecode(&self, sample_number: u64) -> Option<&Timecode> {
212        self.timecodes
213            .iter()
214            .rev()
215            .find(|(s, _)| *s <= sample_number)
216            .map(|(_, tc)| tc)
217    }
218
219    /// Returns the start timecode.
220    #[must_use]
221    pub const fn start_timecode(&self) -> &Timecode {
222        &self.start_timecode
223    }
224
225    /// Returns the format.
226    #[must_use]
227    pub const fn format(&self) -> TimecodeFormat {
228        self.format
229    }
230}
231
232#[cfg(test)]
233mod tests {
234    use super::*;
235
236    #[test]
237    fn test_timecode_format() {
238        assert_eq!(TimecodeFormat::Fps24.fps(), 24.0);
239        assert_eq!(TimecodeFormat::Fps30Drop.fps(), 29.97);
240        assert!(TimecodeFormat::Fps30Drop.is_drop_frame());
241        assert!(!TimecodeFormat::Fps24.is_drop_frame());
242    }
243
244    #[test]
245    fn test_timecode_creation() {
246        let tc =
247            Timecode::new(1, 30, 45, 12, TimecodeFormat::Fps24).expect("operation should succeed");
248        assert_eq!(tc.hours, 1);
249        assert_eq!(tc.minutes, 30);
250        assert_eq!(tc.seconds, 45);
251        assert_eq!(tc.frames, 12);
252
253        // Invalid values
254        assert!(Timecode::new(24, 0, 0, 0, TimecodeFormat::Fps24).is_err());
255        assert!(Timecode::new(0, 60, 0, 0, TimecodeFormat::Fps24).is_err());
256    }
257
258    #[test]
259    fn test_timecode_frame_count() {
260        let tc = Timecode::from_frame_count(100, TimecodeFormat::Fps24);
261        assert_eq!(tc.seconds, 4);
262        assert_eq!(tc.frames, 4);
263
264        let frame_count = tc.to_frame_count();
265        assert_eq!(frame_count, 100);
266    }
267
268    #[test]
269    fn test_timecode_string() {
270        let tc =
271            Timecode::new(1, 30, 45, 12, TimecodeFormat::Fps24).expect("operation should succeed");
272        assert_eq!(tc.format_string(), "01:30:45:12");
273
274        let tc_drop = Timecode::new(1, 30, 45, 12, TimecodeFormat::Fps30Drop)
275            .expect("operation should succeed");
276        assert_eq!(tc_drop.format_string(), "01:30:45;12");
277    }
278
279    #[test]
280    fn test_timecode_parse() {
281        let tc = Timecode::from_string("01:30:45:12", TimecodeFormat::Fps24)
282            .expect("operation should succeed");
283        assert_eq!(tc.hours, 1);
284        assert_eq!(tc.minutes, 30);
285        assert_eq!(tc.seconds, 45);
286        assert_eq!(tc.frames, 12);
287
288        assert!(Timecode::from_string("invalid", TimecodeFormat::Fps24).is_err());
289    }
290
291    #[test]
292    fn test_timecode_track() {
293        let start_tc =
294            Timecode::new(0, 0, 0, 0, TimecodeFormat::Fps24).expect("operation should succeed");
295        let mut track = TimecodeTrack::new(TimecodeFormat::Fps24, start_tc);
296
297        let tc1 =
298            Timecode::new(0, 0, 1, 0, TimecodeFormat::Fps24).expect("operation should succeed");
299        track.add_timecode(24, tc1);
300
301        let retrieved = track.get_timecode(24);
302        assert!(retrieved.is_some());
303        assert_eq!(retrieved.expect("operation should succeed").seconds, 1);
304    }
305}