use crate::{FrameRate, Timecode, TimecodeError};
pub struct TcList;
impl TcList {
pub fn from_csv_with_rate(csv: &str, frame_rate: FrameRate) -> Vec<(Timecode, Timecode)> {
csv.lines()
.filter(|l| {
let trimmed = l.trim();
!trimmed.is_empty() && !trimmed.starts_with('#')
})
.filter_map(|line| {
let parts: Vec<&str> = if line.contains('\t') {
line.splitn(3, '\t').collect()
} else {
line.splitn(3, ',').collect()
};
if parts.len() < 2 {
return None;
}
let tc_in = parse_timecode_str(parts[0].trim(), frame_rate).ok()?;
let tc_out = parse_timecode_str(parts[1].trim(), frame_rate).ok()?;
Some((tc_in, tc_out))
})
.collect()
}
pub fn from_csv(csv: &str) -> Vec<(Timecode, Timecode)> {
Self::from_csv_with_rate(csv, FrameRate::Fps25)
}
pub fn to_csv(pairs: &[(Timecode, Timecode)]) -> String {
let mut out = String::new();
for (tc_in, tc_out) in pairs {
let sep_in = if tc_in.frame_rate.drop_frame {
';'
} else {
':'
};
let sep_out = if tc_out.frame_rate.drop_frame {
';'
} else {
':'
};
out.push_str(&format!(
"{:02}:{:02}:{:02}{sep_in}{:02},{:02}:{:02}:{:02}{sep_out}{:02}\n",
tc_in.hours,
tc_in.minutes,
tc_in.seconds,
tc_in.frames,
tc_out.hours,
tc_out.minutes,
tc_out.seconds,
tc_out.frames,
));
}
out
}
}
fn parse_timecode_str(s: &str, frame_rate: FrameRate) -> Result<Timecode, TimecodeError> {
if s.len() < 11 {
return Err(TimecodeError::InvalidConfiguration);
}
let last_sep = s
.char_indices()
.filter(|(_, c)| *c == ':' || *c == ';')
.last();
let (last_sep_pos, last_sep_char) = last_sep.ok_or(TimecodeError::InvalidConfiguration)?;
let tc_part = &s[..last_sep_pos];
let ff_str = &s[(last_sep_pos + 1)..];
let mut colon_parts = tc_part.splitn(4, ':');
let hh: u8 = colon_parts
.next()
.and_then(|p| p.parse().ok())
.ok_or(TimecodeError::InvalidHours)?;
let mm: u8 = colon_parts
.next()
.and_then(|p| p.parse().ok())
.ok_or(TimecodeError::InvalidMinutes)?;
let ss: u8 = colon_parts
.next()
.and_then(|p| p.parse().ok())
.ok_or(TimecodeError::InvalidSeconds)?;
let ff: u8 = ff_str.parse().map_err(|_| TimecodeError::InvalidFrames)?;
let effective_rate = if last_sep_char == ';' {
to_drop_frame_variant(frame_rate)
} else {
frame_rate
};
Timecode::new(hh, mm, ss, ff, effective_rate)
}
fn to_drop_frame_variant(rate: FrameRate) -> FrameRate {
match rate {
FrameRate::Fps2997NDF | FrameRate::Fps2997DF => FrameRate::Fps2997DF,
FrameRate::Fps23976 | FrameRate::Fps23976DF => FrameRate::Fps23976DF,
FrameRate::Fps5994 | FrameRate::Fps5994DF => FrameRate::Fps5994DF,
FrameRate::Fps47952 | FrameRate::Fps47952DF => FrameRate::Fps47952DF,
other => other,
}
}
#[cfg(test)]
mod tests {
use super::*;
const SAMPLE_CSV: &str = "\
# comment line
00:00:01:00,00:00:04:00
00:01:00:00,00:01:30:00
";
#[test]
fn parse_two_pairs() {
let pairs = TcList::from_csv(SAMPLE_CSV);
assert_eq!(pairs.len(), 2);
}
#[test]
fn first_pair_values() {
let pairs = TcList::from_csv(SAMPLE_CSV);
let (tc_in, tc_out) = &pairs[0];
assert_eq!(tc_in.seconds, 1);
assert_eq!(tc_out.seconds, 4);
}
#[test]
fn second_pair_values() {
let pairs = TcList::from_csv(SAMPLE_CSV);
let (tc_in, tc_out) = &pairs[1];
assert_eq!(tc_in.minutes, 1);
assert_eq!(tc_out.minutes, 1);
assert_eq!(tc_out.seconds, 30);
}
#[test]
fn empty_and_comment_lines_skipped() {
let csv = "\n# ignored\n\n00:00:00:00,00:00:01:00\n";
let pairs = TcList::from_csv(csv);
assert_eq!(pairs.len(), 1);
}
#[test]
fn invalid_row_silently_skipped() {
let csv = "bad,data\n00:00:01:00,00:00:02:00\n";
let pairs = TcList::from_csv(csv);
assert_eq!(pairs.len(), 1);
}
#[test]
fn drop_frame_semicolon_parsed() {
let csv = "00:00:01;00,00:00:04;00\n";
let pairs = TcList::from_csv_with_rate(csv, FrameRate::Fps2997NDF);
assert_eq!(pairs.len(), 1);
assert!(pairs[0].0.frame_rate.drop_frame);
}
#[test]
fn tab_separated_accepted() {
let csv = "00:00:01:00\t00:00:04:00\n";
let pairs = TcList::from_csv(csv);
assert_eq!(pairs.len(), 1);
}
#[test]
fn round_trip_csv() {
let original = TcList::from_csv(SAMPLE_CSV);
let serialised = TcList::to_csv(&original);
let reparsed = TcList::from_csv(&serialised);
assert_eq!(original.len(), reparsed.len());
for (a, b) in original.iter().zip(reparsed.iter()) {
assert_eq!(a.0, b.0);
assert_eq!(a.1, b.1);
}
}
}