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#[cfg(test)]
222mod tests {
223    use super::*;
224
225    #[test]
226    fn test_seconds_to_timecode_25fps() {
227        let tc = seconds_to_timecode(3661.0, FrameRate::Fps25)
228            .expect("seconds to timecode should succeed");
229        assert_eq!(tc.hours, 1);
230        assert_eq!(tc.minutes, 1);
231        assert_eq!(tc.seconds, 1);
232        assert_eq!(tc.frames, 0);
233    }
234
235    #[test]
236    fn test_timecode_to_seconds() {
237        let tc = Timecode::new(1, 0, 0, 0, FrameRate::Fps25).expect("valid timecode");
238        let secs = timecode_to_seconds(&tc);
239        assert!((secs - 3600.0).abs() < 0.01);
240    }
241
242    #[test]
243    fn test_parse_smpte_ndf() {
244        let tc = parse_smpte_string("01:02:03:04", FrameRate::Fps25).expect("should succeed");
245        assert_eq!(tc.hours, 1);
246        assert_eq!(tc.minutes, 2);
247        assert_eq!(tc.seconds, 3);
248        assert_eq!(tc.frames, 4);
249    }
250
251    #[test]
252    fn test_parse_smpte_invalid() {
253        assert!(parse_smpte_string("bad", FrameRate::Fps25).is_err());
254    }
255
256    #[test]
257    fn test_frames_to_smpte_string() {
258        let s =
259            frames_to_smpte_string(25, FrameRate::Fps25).expect("frames to SMPTE should succeed");
260        assert_eq!(s, "00:00:01:00");
261    }
262
263    #[test]
264    fn test_millis_roundtrip() {
265        let tc = Timecode::new(0, 1, 30, 0, FrameRate::Fps25).expect("valid timecode");
266        let ms = timecode_to_millis(&tc);
267        let tc2 =
268            millis_to_timecode(ms, FrameRate::Fps25).expect("millis to timecode should succeed");
269        assert_eq!(tc.hours, tc2.hours);
270        assert_eq!(tc.minutes, tc2.minutes);
271        assert_eq!(tc.seconds, tc2.seconds);
272    }
273
274    #[test]
275    fn test_convert_preserve_time_same_rate() {
276        let tc = Timecode::new(1, 0, 0, 0, FrameRate::Fps25).expect("valid timecode");
277        let result = convert_frame_rate(&tc, FrameRate::Fps25, ConvertStrategy::PreserveTime)
278            .expect("conversion should succeed");
279        assert!(result.rounding_error_secs.abs() < 0.001);
280    }
281
282    #[test]
283    fn test_convert_preserve_time_25_to_30() {
284        let tc = Timecode::new(0, 0, 1, 0, FrameRate::Fps25).expect("valid timecode");
285        let result = convert_frame_rate(&tc, FrameRate::Fps30, ConvertStrategy::PreserveTime)
286            .expect("conversion should succeed");
287        assert_eq!(result.timecode.seconds, 1);
288        assert_eq!(result.timecode.frames, 0);
289    }
290
291    #[test]
292    fn test_convert_preserve_display() {
293        let tc = Timecode::new(1, 2, 3, 10, FrameRate::Fps30).expect("valid timecode");
294        let result = convert_frame_rate(&tc, FrameRate::Fps25, ConvertStrategy::PreserveDisplay)
295            .expect("conversion should succeed");
296        assert_eq!(result.timecode.hours, 1);
297        assert_eq!(result.timecode.minutes, 2);
298        assert_eq!(result.timecode.seconds, 3);
299        assert_eq!(result.timecode.frames, 10);
300    }
301
302    #[test]
303    fn test_convert_preserve_frame() {
304        let tc = Timecode::new(0, 0, 0, 10, FrameRate::Fps25).expect("valid timecode");
305        let result = convert_frame_rate(&tc, FrameRate::Fps30, ConvertStrategy::PreserveFrame)
306            .expect("conversion should succeed");
307        assert_eq!(result.timecode.frames, 10);
308    }
309
310    #[test]
311    fn test_audio_samples() {
312        let tc = Timecode::new(0, 0, 1, 0, FrameRate::Fps25).expect("valid timecode");
313        let samples = timecode_to_audio_samples(&tc, 48000);
314        assert_eq!(samples, 48000);
315    }
316
317    #[test]
318    fn test_negative_seconds_error() {
319        assert!(seconds_to_timecode(-1.0, FrameRate::Fps25).is_err());
320    }
321}