oximedia_timecode/
tc_list.rs1use crate::{FrameRate, Timecode, TimecodeError};
23
24pub struct TcList;
26
27impl TcList {
28 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 pub fn from_csv(csv: &str) -> Vec<(Timecode, Timecode)> {
61 Self::from_csv_with_rate(csv, FrameRate::Fps25)
62 }
63
64 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
96fn parse_timecode_str(s: &str, frame_rate: FrameRate) -> Result<Timecode, TimecodeError> {
104 if s.len() < 11 {
108 return Err(TimecodeError::InvalidConfiguration);
109 }
110
111 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 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 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
148fn 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}