use crate::types::SpeakerTurn;
use std::io::{self, Write};
fn timecode(secs: f64, sep: char) -> String {
let total_ms = if secs.is_finite() && secs > 0.0 {
(secs * 1000.0).round() as u64
} else {
0
};
let ms = total_ms % 1000;
let total_s = total_ms / 1000;
let s = total_s % 60;
let m = (total_s / 60) % 60;
let h = total_s / 3600;
format!("{h:02}:{m:02}:{s:02}{sep}{ms:03}")
}
fn sanitize_cue_text(text: &str) -> String {
text.replace("\r\n", " ")
.replace(['\r', '\n'], " ")
.replace("-->", "-- >")
}
fn escape_vtt_text(text: &str) -> String {
text.replace('&', "&").replace('<', "<")
}
fn cue_label(turn: &SpeakerTurn) -> String {
match &turn.text {
Some(t) if !t.is_empty() => format!("{}: {}", turn.speaker, sanitize_cue_text(t)),
_ => turn.speaker.to_string(),
}
}
fn vtt_cue_payload(turn: &SpeakerTurn) -> String {
match &turn.text {
Some(t) if !t.is_empty() => format!(
"<v {}>{}</v>",
turn.speaker,
escape_vtt_text(&sanitize_cue_text(t))
),
_ => turn.speaker.to_string(),
}
}
pub fn write_srt<W: Write>(writer: &mut W, turns: &[SpeakerTurn]) -> io::Result<()> {
for (i, turn) in turns.iter().enumerate() {
writeln!(writer, "{}", i + 1)?;
writeln!(
writer,
"{} --> {}",
timecode(turn.time.start, ','),
timecode(turn.time.end, ',')
)?;
writeln!(writer, "{}", cue_label(turn))?;
writeln!(writer)?;
}
Ok(())
}
pub fn write_vtt<W: Write>(writer: &mut W, turns: &[SpeakerTurn]) -> io::Result<()> {
writeln!(writer, "WEBVTT")?;
writeln!(writer)?;
for turn in turns {
writeln!(
writer,
"{} --> {}",
timecode(turn.time.start, '.'),
timecode(turn.time.end, '.')
)?;
writeln!(writer, "{}", vtt_cue_payload(turn))?;
writeln!(writer)?;
}
Ok(())
}
pub fn write_txt<W: Write>(writer: &mut W, turns: &[SpeakerTurn]) -> io::Result<()> {
for turn in turns {
writeln!(
writer,
"[{:.2} - {:.2}] {}",
turn.time.start.max(0.0),
turn.time.end.max(0.0),
cue_label(turn)
)?;
}
Ok(())
}
#[allow(clippy::unwrap_used)]
#[cfg(test)]
mod tests {
use super::*;
use crate::types::{SpeakerId, TimeRange};
fn turn(id: u32, start: f64, end: f64, text: Option<&str>) -> SpeakerTurn {
SpeakerTurn {
speaker: SpeakerId(id),
time: TimeRange { start, end },
text: text.map(|s| s.to_owned()),
}
}
fn render<F>(f: F) -> String
where
F: Fn(&mut Vec<u8>) -> io::Result<()>,
{
let mut buf = Vec::new();
f(&mut buf).unwrap();
String::from_utf8(buf).unwrap()
}
#[test]
fn timecode_srt_uses_comma_vtt_uses_dot() {
assert_eq!(timecode(0.5, ','), "00:00:00,500");
assert_eq!(timecode(0.5, '.'), "00:00:00.500");
assert_eq!(timecode(3661.0, ','), "01:01:01,000");
assert_eq!(timecode(1.2345, ','), "00:00:01,235");
assert_eq!(timecode(-3.0, ','), "00:00:00,000");
assert_eq!(timecode(f64::NAN, '.'), "00:00:00.000");
}
#[test]
fn srt_blocks_are_numbered_and_lossless() {
let turns = vec![turn(0, 0.5, 2.8, None), turn(1, 3.0, 4.5, Some("hello"))];
let out = render(|w| write_srt(w, &turns));
let expected = "\
1
00:00:00,500 --> 00:00:02,800
SPEAKER_00
2
00:00:03,000 --> 00:00:04,500
SPEAKER_01: hello
";
assert_eq!(out, expected);
assert_eq!(out.matches(" --> ").count(), turns.len());
}
#[test]
fn vtt_has_header_and_dot_timecodes() {
let turns = vec![turn(0, 0.0, 1.25, Some("hi"))];
let out = render(|w| write_vtt(w, &turns));
assert!(out.starts_with("WEBVTT\n\n"));
assert!(out.contains("00:00:00.000 --> 00:00:01.250"));
assert!(out.contains("<v SPEAKER_00>hi</v>"));
assert_eq!(out.matches(" --> ").count(), turns.len());
}
#[test]
fn vtt_without_text_uses_bare_label() {
let turns = vec![turn(1, 0.0, 2.0, None)];
let out = render(|w| write_vtt(w, &turns));
assert!(out.contains("SPEAKER_01\n"));
assert!(!out.contains("<v "));
}
#[test]
fn vtt_escapes_markup_characters_in_text() {
let turns = vec![turn(0, 0.0, 1.0, Some("<unk> & co"))];
let out = render(|w| write_vtt(w, &turns));
assert!(out.contains("<v SPEAKER_00><unk> & co</v>"));
assert!(!out.contains("<unk>"));
}
#[test]
fn cue_text_newlines_and_arrows_stay_single_line() {
let turns = vec![turn(0, 0.0, 1.0, Some("a\n\nb --> c"))];
let srt = render(|w| write_srt(w, &turns));
let vtt = render(|w| write_vtt(w, &turns));
assert_eq!(srt.matches(" --> ").count(), 1);
assert_eq!(vtt.matches(" --> ").count(), 1);
assert!(srt.contains("SPEAKER_00: a b -- > c"));
assert!(vtt.contains("a b -- > c"));
}
#[test]
fn txt_is_readable_per_turn() {
let turns = vec![turn(0, 0.5, 2.8, None), turn(1, 3.0, 4.5, Some("hello"))];
let out = render(|w| write_txt(w, &turns));
let expected = "\
[0.50 - 2.80] SPEAKER_00
[3.00 - 4.50] SPEAKER_01: hello
";
assert_eq!(out, expected);
assert_eq!(out.lines().count(), turns.len());
}
#[test]
fn empty_turns_produce_minimal_output() {
assert_eq!(render(|w| write_srt(w, &[])), "");
assert_eq!(render(|w| write_vtt(w, &[])), "WEBVTT\n\n");
assert_eq!(render(|w| write_txt(w, &[])), "");
}
}