oximedia_container/timecode/
track.rs1#![forbid(unsafe_code)]
6
7use oximedia_core::{OxiError, OxiResult};
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum TimecodeFormat {
12 Fps24,
14 Fps25,
16 Fps30NonDrop,
18 Fps30Drop,
20 Fps60NonDrop,
22 Fps60Drop,
24}
25
26impl TimecodeFormat {
27 #[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 #[must_use]
42 pub const fn is_drop_frame(&self) -> bool {
43 matches!(self, Self::Fps30Drop | Self::Fps60Drop)
44 }
45}
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub struct Timecode {
50 pub hours: u8,
52 pub minutes: u8,
54 pub seconds: u8,
56 pub frames: u8,
58 pub format: TimecodeFormat,
60}
61
62impl Timecode {
63 pub fn new(
69 hours: u8,
70 minutes: u8,
71 seconds: u8,
72 frames: u8,
73 format: TimecodeFormat,
74 ) -> OxiResult<Self> {
75 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 #[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 #[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 #[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 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#[derive(Debug, Clone)]
187pub struct TimecodeTrack {
188 format: TimecodeFormat,
189 start_timecode: Timecode,
190 timecodes: Vec<(u64, Timecode)>, }
192
193impl TimecodeTrack {
194 #[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 pub fn add_timecode(&mut self, sample_number: u64, timecode: Timecode) {
206 self.timecodes.push((sample_number, timecode));
207 }
208
209 #[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 #[must_use]
221 pub const fn start_timecode(&self) -> &Timecode {
222 &self.start_timecode
223 }
224
225 #[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 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}