Skip to main content

oximedia_timecode/
tc_convert.rs

1#![allow(dead_code)]
2//! Timecode format conversion utilities.
3//!
4//! Converts timecodes between different frame rates, between wall-clock time
5//! and timecode, and between SMPTE string representations and frame numbers.
6
7use crate::{FrameRate, Timecode, TimecodeError};
8
9/// Strategy for converting timecodes between different frame rates.
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum ConvertStrategy {
12    /// Preserve the wall-clock time as closely as possible.
13    PreserveTime,
14    /// Preserve the frame number (snap to nearest frame in target rate).
15    PreserveFrame,
16    /// Preserve the HH:MM:SS:FF display string (may change actual time).
17    PreserveDisplay,
18}
19
20/// Result of a timecode conversion.
21#[derive(Debug, Clone)]
22pub struct ConvertResult {
23    /// The converted timecode
24    pub timecode: Timecode,
25    /// The rounding error in seconds (positive means output is later)
26    pub rounding_error_secs: f64,
27    /// Whether the conversion was exact (no rounding)
28    pub exact: bool,
29}
30
31/// Converts a timecode from one frame rate to another.
32///
33/// # Errors
34///
35/// Returns an error if the target timecode is invalid (e.g., exceeds 24h).
36#[allow(clippy::cast_precision_loss)]
37pub fn convert_frame_rate(
38    tc: &Timecode,
39    target_rate: FrameRate,
40    strategy: ConvertStrategy,
41) -> Result<ConvertResult, TimecodeError> {
42    match strategy {
43        ConvertStrategy::PreserveTime => convert_preserve_time(tc, target_rate),
44        ConvertStrategy::PreserveFrame => convert_preserve_frame(tc, target_rate),
45        ConvertStrategy::PreserveDisplay => convert_preserve_display(tc, target_rate),
46    }
47}
48
49/// Converts preserving wall-clock time.
50#[allow(clippy::cast_precision_loss)]
51fn convert_preserve_time(
52    tc: &Timecode,
53    target_rate: FrameRate,
54) -> Result<ConvertResult, TimecodeError> {
55    let source_fps = if tc.frame_rate.drop_frame {
56        29.97
57    } else {
58        tc.frame_rate.fps as f64
59    };
60    let src_frames = tc.to_frames();
61    let time_secs = src_frames as f64 / source_fps;
62
63    let target_fps = target_rate.as_float();
64    let target_frames = (time_secs * target_fps).round() as u64;
65
66    let result_tc = Timecode::from_frames(target_frames, target_rate)?;
67    let result_time = target_frames as f64 / target_fps;
68    let error = result_time - time_secs;
69
70    Ok(ConvertResult {
71        timecode: result_tc,
72        rounding_error_secs: error,
73        exact: error.abs() < 1e-9,
74    })
75}
76
77/// Converts preserving the frame number (modulo target fps).
78#[allow(clippy::cast_precision_loss)]
79fn convert_preserve_frame(
80    tc: &Timecode,
81    target_rate: FrameRate,
82) -> Result<ConvertResult, TimecodeError> {
83    let src_frames = tc.to_frames();
84    let result_tc = Timecode::from_frames(src_frames, target_rate)?;
85    let source_fps = if tc.frame_rate.drop_frame {
86        29.97
87    } else {
88        tc.frame_rate.fps as f64
89    };
90    let target_fps = target_rate.as_float();
91    let error = src_frames as f64 * (1.0 / target_fps - 1.0 / source_fps);
92
93    Ok(ConvertResult {
94        timecode: result_tc,
95        rounding_error_secs: error,
96        exact: (source_fps - target_fps).abs() < 1e-9,
97    })
98}
99
100/// Converts preserving the HH:MM:SS:FF display.
101#[allow(clippy::cast_precision_loss)]
102fn convert_preserve_display(
103    tc: &Timecode,
104    target_rate: FrameRate,
105) -> Result<ConvertResult, TimecodeError> {
106    let target_fps = target_rate.frames_per_second() as u8;
107    let frames = if tc.frames >= target_fps {
108        target_fps - 1
109    } else {
110        tc.frames
111    };
112    let result_tc = Timecode::new(tc.hours, tc.minutes, tc.seconds, frames, target_rate)?;
113    let source_fps = if tc.frame_rate.drop_frame {
114        29.97
115    } else {
116        tc.frame_rate.fps as f64
117    };
118    let tfps = target_rate.as_float();
119    let src_time = tc.to_frames() as f64 / source_fps;
120    let dst_time = result_tc.to_frames() as f64 / tfps;
121
122    Ok(ConvertResult {
123        timecode: result_tc,
124        rounding_error_secs: dst_time - src_time,
125        exact: false,
126    })
127}
128
129/// Converts a wall-clock duration in seconds to a timecode.
130///
131/// # Errors
132///
133/// Returns an error if the duration exceeds 24 hours.
134#[allow(clippy::cast_precision_loss)]
135pub fn seconds_to_timecode(secs: f64, rate: FrameRate) -> Result<Timecode, TimecodeError> {
136    if secs < 0.0 {
137        return Err(TimecodeError::InvalidConfiguration);
138    }
139    let fps = rate.as_float();
140    let total_frames = (secs * fps).round() as u64;
141    Timecode::from_frames(total_frames, rate)
142}
143
144/// Converts a timecode to wall-clock seconds.
145#[allow(clippy::cast_precision_loss)]
146pub fn timecode_to_seconds(tc: &Timecode) -> f64 {
147    let fps = if tc.frame_rate.drop_frame {
148        29.97
149    } else {
150        tc.frame_rate.fps as f64
151    };
152    tc.to_frames() as f64 / fps
153}
154
155/// Parses a SMPTE timecode string like "01:02:03:04" or "01:02:03;04".
156///
157/// The separator between seconds and frames determines drop-frame vs non-drop:
158/// - `:` for non-drop frame
159/// - `;` for drop frame
160///
161/// # Errors
162///
163/// Returns an error if the string format is invalid.
164pub fn parse_smpte_string(s: &str, rate: FrameRate) -> Result<Timecode, TimecodeError> {
165    let s = s.trim();
166    if s.len() < 11 {
167        return Err(TimecodeError::InvalidConfiguration);
168    }
169    let parts: Vec<&str> = s.split([':', ';']).collect();
170    if parts.len() != 4 {
171        return Err(TimecodeError::InvalidConfiguration);
172    }
173    let hours: u8 = parts[0].parse().map_err(|_| TimecodeError::InvalidHours)?;
174    let minutes: u8 = parts[1]
175        .parse()
176        .map_err(|_| TimecodeError::InvalidMinutes)?;
177    let seconds: u8 = parts[2]
178        .parse()
179        .map_err(|_| TimecodeError::InvalidSeconds)?;
180    let frames: u8 = parts[3].parse().map_err(|_| TimecodeError::InvalidFrames)?;
181
182    Timecode::new(hours, minutes, seconds, frames, rate)
183}
184
185/// Formats a frame count as an SMPTE timecode string.
186///
187/// # Errors
188///
189/// Returns an error if the frame count produces an invalid timecode.
190pub fn frames_to_smpte_string(frames: u64, rate: FrameRate) -> Result<String, TimecodeError> {
191    let tc = Timecode::from_frames(frames, rate)?;
192    Ok(tc.to_string())
193}
194
195/// Converts a timecode to a total millisecond value.
196#[allow(clippy::cast_precision_loss)]
197pub fn timecode_to_millis(tc: &Timecode) -> u64 {
198    let secs = timecode_to_seconds(tc);
199    (secs * 1000.0).round() as u64
200}
201
202/// Converts milliseconds to a timecode.
203///
204/// # Errors
205///
206/// Returns an error if the milliseconds value exceeds 24 hours.
207pub fn millis_to_timecode(ms: u64, rate: FrameRate) -> Result<Timecode, TimecodeError> {
208    #[allow(clippy::cast_precision_loss)]
209    let secs = ms as f64 / 1000.0;
210    seconds_to_timecode(secs, rate)
211}
212
213/// Computes the number of real-time samples (at a given audio sample rate)
214/// that correspond to a timecode offset.
215#[allow(clippy::cast_precision_loss)]
216pub fn timecode_to_audio_samples(tc: &Timecode, sample_rate: u32) -> u64 {
217    let secs = timecode_to_seconds(tc);
218    (secs * sample_rate as f64).round() as u64
219}
220
221// ---------------------------------------------------------------------------
222// NDF ↔ DF conversion utilities
223// ---------------------------------------------------------------------------
224
225/// Convert a non-drop-frame (NDF) timecode to its drop-frame (DF) equivalent.
226///
227/// The conversion preserves the wall-clock time as closely as possible:
228/// the NDF frame count (at the integer fps) is treated as an absolute frame
229/// index, and the equivalent DF timecode at the same nominal fps is returned.
230///
231/// Currently supports 29.97 NDF → 29.97 DF and 59.94 NDF → 59.94 DF.
232///
233/// # Errors
234///
235/// Returns an error if the NDF timecode is not at a rate that has a
236/// corresponding DF variant, or if the resulting DF position would be invalid.
237pub fn ndf_to_df(tc: &Timecode) -> Result<Timecode, TimecodeError> {
238    if tc.frame_rate.drop_frame {
239        return Err(TimecodeError::InvalidConfiguration); // Already drop frame
240    }
241
242    let df_rate = match tc.frame_rate.fps {
243        30 => FrameRate::Fps2997DF,
244        60 => FrameRate::Fps5994DF,
245        24 => FrameRate::Fps23976DF,
246        48 => FrameRate::Fps47952DF,
247        _ => return Err(TimecodeError::InvalidConfiguration),
248    };
249
250    // The NDF frame count (counted as if purely integer fps) is the canonical
251    // frame index.  We reinterpret it as a DF frame index — the DF timecode
252    // display will differ slightly from the NDF display, which is the intended
253    // behaviour (DF displays real elapsed time, NDF does not).
254    Timecode::from_frames(tc.to_frames(), df_rate)
255}
256
257/// Convert a drop-frame (DF) timecode to its non-drop-frame (NDF) equivalent.
258///
259/// The total DF frame count is preserved; the resulting NDF timecode at the
260/// same nominal fps will generally display a slightly different
261/// HH:MM:SS:FF string because NDF ignores the frame-number skips.
262///
263/// # Errors
264///
265/// Returns an error if the timecode is already NDF, or if the fps has no
266/// NDF counterpart.
267pub fn df_to_ndf(tc: &Timecode) -> Result<Timecode, TimecodeError> {
268    if !tc.frame_rate.drop_frame {
269        return Err(TimecodeError::InvalidConfiguration); // Already non-drop frame
270    }
271
272    let ndf_rate = match tc.frame_rate.fps {
273        30 => FrameRate::Fps2997NDF,
274        60 => FrameRate::Fps5994,
275        24 => FrameRate::Fps23976,
276        48 => FrameRate::Fps47952,
277        _ => return Err(TimecodeError::InvalidConfiguration),
278    };
279
280    Timecode::from_frames(tc.to_frames(), ndf_rate)
281}
282
283#[cfg(test)]
284mod tests {
285    use super::*;
286
287    #[test]
288    fn test_seconds_to_timecode_25fps() {
289        let tc = seconds_to_timecode(3661.0, FrameRate::Fps25)
290            .expect("seconds to timecode should succeed");
291        assert_eq!(tc.hours, 1);
292        assert_eq!(tc.minutes, 1);
293        assert_eq!(tc.seconds, 1);
294        assert_eq!(tc.frames, 0);
295    }
296
297    #[test]
298    fn test_timecode_to_seconds() {
299        let tc = Timecode::new(1, 0, 0, 0, FrameRate::Fps25).expect("valid timecode");
300        let secs = timecode_to_seconds(&tc);
301        assert!((secs - 3600.0).abs() < 0.01);
302    }
303
304    #[test]
305    fn test_parse_smpte_ndf() {
306        let tc = parse_smpte_string("01:02:03:04", FrameRate::Fps25).expect("should succeed");
307        assert_eq!(tc.hours, 1);
308        assert_eq!(tc.minutes, 2);
309        assert_eq!(tc.seconds, 3);
310        assert_eq!(tc.frames, 4);
311    }
312
313    #[test]
314    fn test_parse_smpte_invalid() {
315        assert!(parse_smpte_string("bad", FrameRate::Fps25).is_err());
316    }
317
318    #[test]
319    fn test_frames_to_smpte_string() {
320        let s =
321            frames_to_smpte_string(25, FrameRate::Fps25).expect("frames to SMPTE should succeed");
322        assert_eq!(s, "00:00:01:00");
323    }
324
325    #[test]
326    fn test_millis_roundtrip() {
327        let tc = Timecode::new(0, 1, 30, 0, FrameRate::Fps25).expect("valid timecode");
328        let ms = timecode_to_millis(&tc);
329        let tc2 =
330            millis_to_timecode(ms, FrameRate::Fps25).expect("millis to timecode should succeed");
331        assert_eq!(tc.hours, tc2.hours);
332        assert_eq!(tc.minutes, tc2.minutes);
333        assert_eq!(tc.seconds, tc2.seconds);
334    }
335
336    #[test]
337    fn test_convert_preserve_time_same_rate() {
338        let tc = Timecode::new(1, 0, 0, 0, FrameRate::Fps25).expect("valid timecode");
339        let result = convert_frame_rate(&tc, FrameRate::Fps25, ConvertStrategy::PreserveTime)
340            .expect("conversion should succeed");
341        assert!(result.rounding_error_secs.abs() < 0.001);
342    }
343
344    #[test]
345    fn test_convert_preserve_time_25_to_30() {
346        let tc = Timecode::new(0, 0, 1, 0, FrameRate::Fps25).expect("valid timecode");
347        let result = convert_frame_rate(&tc, FrameRate::Fps30, ConvertStrategy::PreserveTime)
348            .expect("conversion should succeed");
349        assert_eq!(result.timecode.seconds, 1);
350        assert_eq!(result.timecode.frames, 0);
351    }
352
353    #[test]
354    fn test_convert_preserve_display() {
355        let tc = Timecode::new(1, 2, 3, 10, FrameRate::Fps30).expect("valid timecode");
356        let result = convert_frame_rate(&tc, FrameRate::Fps25, ConvertStrategy::PreserveDisplay)
357            .expect("conversion should succeed");
358        assert_eq!(result.timecode.hours, 1);
359        assert_eq!(result.timecode.minutes, 2);
360        assert_eq!(result.timecode.seconds, 3);
361        assert_eq!(result.timecode.frames, 10);
362    }
363
364    #[test]
365    fn test_convert_preserve_frame() {
366        let tc = Timecode::new(0, 0, 0, 10, FrameRate::Fps25).expect("valid timecode");
367        let result = convert_frame_rate(&tc, FrameRate::Fps30, ConvertStrategy::PreserveFrame)
368            .expect("conversion should succeed");
369        assert_eq!(result.timecode.frames, 10);
370    }
371
372    #[test]
373    fn test_audio_samples() {
374        let tc = Timecode::new(0, 0, 1, 0, FrameRate::Fps25).expect("valid timecode");
375        let samples = timecode_to_audio_samples(&tc, 48000);
376        assert_eq!(samples, 48000);
377    }
378
379    #[test]
380    fn test_negative_seconds_error() {
381        assert!(seconds_to_timecode(-1.0, FrameRate::Fps25).is_err());
382    }
383
384    #[test]
385    fn test_ndf_to_df_29_97() {
386        // A 29.97 NDF timecode → 29.97 DF: frame count is preserved
387        let ndf = Timecode::new(0, 1, 0, 0, FrameRate::Fps2997NDF).expect("valid NDF");
388        let df = ndf_to_df(&ndf).expect("ndf_to_df should succeed");
389        assert!(df.frame_rate.drop_frame);
390        assert_eq!(df.frame_rate.fps, 30);
391        // Frame counts must match
392        assert_eq!(ndf.to_frames(), df.to_frames());
393    }
394
395    #[test]
396    fn test_df_to_ndf_29_97() {
397        let df = Timecode::new(0, 1, 0, 2, FrameRate::Fps2997DF).expect("valid DF");
398        let ndf = df_to_ndf(&df).expect("df_to_ndf should succeed");
399        assert!(!ndf.frame_rate.drop_frame);
400        assert_eq!(ndf.frame_rate.fps, 30);
401        assert_eq!(df.to_frames(), ndf.to_frames());
402    }
403
404    #[test]
405    fn test_ndf_to_df_already_df_is_error() {
406        let df = Timecode::new(0, 1, 0, 2, FrameRate::Fps2997DF).expect("valid DF");
407        assert!(ndf_to_df(&df).is_err());
408    }
409
410    #[test]
411    fn test_df_to_ndf_already_ndf_is_error() {
412        let ndf = Timecode::new(0, 1, 0, 0, FrameRate::Fps2997NDF).expect("valid NDF");
413        assert!(df_to_ndf(&ndf).is_err());
414    }
415
416    #[test]
417    fn test_ndf_to_df_unsupported_rate_is_error() {
418        let tc = Timecode::new(0, 0, 1, 0, FrameRate::Fps25).expect("valid");
419        assert!(ndf_to_df(&tc).is_err());
420    }
421
422    #[test]
423    fn test_df_ndf_roundtrip_preserves_frame_count() {
424        // Roundtrip: NDF → DF → NDF must preserve the frame count
425        let ndf = Timecode::new(1, 23, 45, 12, FrameRate::Fps2997NDF).expect("valid NDF");
426        let df = ndf_to_df(&ndf).expect("ndf→df");
427        let back = df_to_ndf(&df).expect("df→ndf");
428        assert_eq!(ndf.to_frames(), back.to_frames());
429    }
430}