Skip to main content

oximedia_timecode/
tc_list.rs

1// Copyright 2025 OxiMedia Contributors
2// Licensed under the Apache License, Version 2.0
3
4//! Timecode list import / export.
5//!
6//! `TcList` parses a CSV file (or string) containing IN,OUT timecode pairs and
7//! returns them as `Vec<(Timecode, Timecode)>`.
8//!
9//! # CSV format
10//!
11//! ```csv
12//! # optional comment lines are ignored
13//! 00:00:01:00,00:00:04:00
14//! 00:01:00;00,00:01:30;00
15//! ```
16//!
17//! - Separator: `,` (comma) or `\t` (tab).
18//! - Timecode format: `HH:MM:SS:FF` (NDF) or `HH:MM:SS;FF` (DF with semicolon).
19//! - Lines starting with `#` or empty lines are skipped.
20//! - Additional trailing fields on a line are ignored.
21
22use crate::{FrameRate, Timecode, TimecodeError};
23
24/// A collection of IN/OUT timecode pairs.
25pub struct TcList;
26
27impl TcList {
28    /// Parse a CSV (or TSV) string and return all valid IN/OUT pairs.
29    ///
30    /// Invalid rows (parse errors, missing fields, etc.) are silently skipped.
31    ///
32    /// The `frame_rate` parameter is applied to all parsed timecodes.
33    pub fn from_csv_with_rate(csv: &str, frame_rate: FrameRate) -> Vec<(Timecode, Timecode)> {
34        csv.lines()
35            .filter(|l| {
36                let trimmed = l.trim();
37                !trimmed.is_empty() && !trimmed.starts_with('#')
38            })
39            .filter_map(|line| {
40                let parts: Vec<&str> = if line.contains('\t') {
41                    line.splitn(3, '\t').collect()
42                } else {
43                    line.splitn(3, ',').collect()
44                };
45
46                if parts.len() < 2 {
47                    return None;
48                }
49
50                let tc_in = parse_timecode_str(parts[0].trim(), frame_rate).ok()?;
51                let tc_out = parse_timecode_str(parts[1].trim(), frame_rate).ok()?;
52                Some((tc_in, tc_out))
53            })
54            .collect()
55    }
56
57    /// Parse CSV using a default frame rate of 25 fps (PAL).
58    ///
59    /// Convenience wrapper around [`from_csv_with_rate`][Self::from_csv_with_rate].
60    pub fn from_csv(csv: &str) -> Vec<(Timecode, Timecode)> {
61        Self::from_csv_with_rate(csv, FrameRate::Fps25)
62    }
63
64    /// Serialise a list of IN/OUT pairs back to CSV.
65    ///
66    /// Uses `HH:MM:SS:FF` format for NDF and `HH:MM:SS;FF` for DF rates.
67    pub fn to_csv(pairs: &[(Timecode, Timecode)]) -> String {
68        let mut out = String::new();
69        for (tc_in, tc_out) in pairs {
70            let sep_in = if tc_in.frame_rate.drop_frame {
71                ';'
72            } else {
73                ':'
74            };
75            let sep_out = if tc_out.frame_rate.drop_frame {
76                ';'
77            } else {
78                ':'
79            };
80            out.push_str(&format!(
81                "{:02}:{:02}:{:02}{sep_in}{:02},{:02}:{:02}:{:02}{sep_out}{:02}\n",
82                tc_in.hours,
83                tc_in.minutes,
84                tc_in.seconds,
85                tc_in.frames,
86                tc_out.hours,
87                tc_out.minutes,
88                tc_out.seconds,
89                tc_out.frames,
90            ));
91        }
92        out
93    }
94}
95
96/// Parse a timecode string in `HH:MM:SS:FF` or `HH:MM:SS;FF` format.
97///
98/// The final separator (`:` or `;`) determines whether the timecode is
99/// interpreted as drop-frame (`;`) or non-drop-frame (`:`).
100///
101/// The `frame_rate` supplied is used unless the timecode is clearly drop-frame
102/// (`;` separator), in which case the corresponding DF variant is chosen.
103fn parse_timecode_str(s: &str, frame_rate: FrameRate) -> Result<Timecode, TimecodeError> {
104    // Expected formats:
105    //   HH:MM:SS:FF  (colon-colon-colon — NDF)
106    //   HH:MM:SS;FF  (colon-colon-semicolon — DF)
107    if s.len() < 11 {
108        return Err(TimecodeError::InvalidConfiguration);
109    }
110
111    // Find the last non-digit, non-leading separator
112    let last_sep = s
113        .char_indices()
114        .filter(|(_, c)| *c == ':' || *c == ';')
115        .last();
116
117    let (last_sep_pos, last_sep_char) = last_sep.ok_or(TimecodeError::InvalidConfiguration)?;
118
119    // Split into "HH:MM:SS" and "FF"
120    let tc_part = &s[..last_sep_pos];
121    let ff_str = &s[(last_sep_pos + 1)..];
122
123    let mut colon_parts = tc_part.splitn(4, ':');
124    let hh: u8 = colon_parts
125        .next()
126        .and_then(|p| p.parse().ok())
127        .ok_or(TimecodeError::InvalidHours)?;
128    let mm: u8 = colon_parts
129        .next()
130        .and_then(|p| p.parse().ok())
131        .ok_or(TimecodeError::InvalidMinutes)?;
132    let ss: u8 = colon_parts
133        .next()
134        .and_then(|p| p.parse().ok())
135        .ok_or(TimecodeError::InvalidSeconds)?;
136    let ff: u8 = ff_str.parse().map_err(|_| TimecodeError::InvalidFrames)?;
137
138    // Select drop-frame variant when ';' is used
139    let effective_rate = if last_sep_char == ';' {
140        to_drop_frame_variant(frame_rate)
141    } else {
142        frame_rate
143    };
144
145    Timecode::new(hh, mm, ss, ff, effective_rate)
146}
147
148/// Return the drop-frame variant of `rate` if one exists; otherwise return `rate`.
149fn to_drop_frame_variant(rate: FrameRate) -> FrameRate {
150    match rate {
151        FrameRate::Fps2997NDF | FrameRate::Fps2997DF => FrameRate::Fps2997DF,
152        FrameRate::Fps23976 | FrameRate::Fps23976DF => FrameRate::Fps23976DF,
153        FrameRate::Fps5994 | FrameRate::Fps5994DF => FrameRate::Fps5994DF,
154        FrameRate::Fps47952 | FrameRate::Fps47952DF => FrameRate::Fps47952DF,
155        other => other,
156    }
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162
163    const SAMPLE_CSV: &str = "\
164# comment line
16500:00:01:00,00:00:04:00
16600:01:00:00,00:01:30:00
167";
168
169    #[test]
170    fn parse_two_pairs() {
171        let pairs = TcList::from_csv(SAMPLE_CSV);
172        assert_eq!(pairs.len(), 2);
173    }
174
175    #[test]
176    fn first_pair_values() {
177        let pairs = TcList::from_csv(SAMPLE_CSV);
178        let (tc_in, tc_out) = &pairs[0];
179        assert_eq!(tc_in.seconds, 1);
180        assert_eq!(tc_out.seconds, 4);
181    }
182
183    #[test]
184    fn second_pair_values() {
185        let pairs = TcList::from_csv(SAMPLE_CSV);
186        let (tc_in, tc_out) = &pairs[1];
187        assert_eq!(tc_in.minutes, 1);
188        assert_eq!(tc_out.minutes, 1);
189        assert_eq!(tc_out.seconds, 30);
190    }
191
192    #[test]
193    fn empty_and_comment_lines_skipped() {
194        let csv = "\n# ignored\n\n00:00:00:00,00:00:01:00\n";
195        let pairs = TcList::from_csv(csv);
196        assert_eq!(pairs.len(), 1);
197    }
198
199    #[test]
200    fn invalid_row_silently_skipped() {
201        let csv = "bad,data\n00:00:01:00,00:00:02:00\n";
202        let pairs = TcList::from_csv(csv);
203        assert_eq!(pairs.len(), 1);
204    }
205
206    #[test]
207    fn drop_frame_semicolon_parsed() {
208        let csv = "00:00:01;00,00:00:04;00\n";
209        let pairs = TcList::from_csv_with_rate(csv, FrameRate::Fps2997NDF);
210        assert_eq!(pairs.len(), 1);
211        assert!(pairs[0].0.frame_rate.drop_frame);
212    }
213
214    #[test]
215    fn tab_separated_accepted() {
216        let csv = "00:00:01:00\t00:00:04:00\n";
217        let pairs = TcList::from_csv(csv);
218        assert_eq!(pairs.len(), 1);
219    }
220
221    #[test]
222    fn round_trip_csv() {
223        let original = TcList::from_csv(SAMPLE_CSV);
224        let serialised = TcList::to_csv(&original);
225        let reparsed = TcList::from_csv(&serialised);
226        assert_eq!(original.len(), reparsed.len());
227        for (a, b) in original.iter().zip(reparsed.iter()) {
228            assert_eq!(a.0, b.0);
229            assert_eq!(a.1, b.1);
230        }
231    }
232}