#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SubtitleEntry {
pub start_ms: u64,
pub end_ms: u64,
pub text: String,
}
impl SubtitleEntry {
#[must_use]
pub fn new(start_ms: u64, end_ms: u64, text: impl Into<String>) -> Self {
Self {
start_ms,
end_ms,
text: text.into(),
}
}
#[must_use]
pub fn duration_ms(&self) -> u64 {
self.end_ms.saturating_sub(self.start_ms)
}
#[must_use]
pub fn line_count(&self) -> usize {
if self.text.is_empty() {
return 0;
}
self.text.lines().count()
}
}
#[must_use]
pub fn format_ms_to_srt(ms: u64) -> String {
let millis = ms % 1000;
let total_secs = ms / 1000;
let secs = total_secs % 60;
let total_mins = total_secs / 60;
let mins = total_mins % 60;
let hours = total_mins / 60;
format!("{hours:02}:{mins:02}:{secs:02},{millis:03}")
}
#[must_use]
pub fn format_ms_to_vtt(ms: u64) -> String {
let millis = ms % 1000;
let total_secs = ms / 1000;
let secs = total_secs % 60;
let total_mins = total_secs / 60;
let mins = total_mins % 60;
let hours = total_mins / 60;
format!("{hours:02}:{mins:02}:{secs:02}.{millis:03}")
}
fn parse_srt_timestamp(s: &str) -> Option<u64> {
let s = s.trim();
let (time_part, millis_part) = s.split_once(',')?;
let millis: u64 = millis_part.trim().parse().ok()?;
let parts: Vec<&str> = time_part.split(':').collect();
if parts.len() != 3 {
return None;
}
let hours: u64 = parts[0].trim().parse().ok()?;
let mins: u64 = parts[1].trim().parse().ok()?;
let secs: u64 = parts[2].trim().parse().ok()?;
Some(((hours * 3600 + mins * 60 + secs) * 1000) + millis)
}
pub struct SrtSerializer;
impl SrtSerializer {
#[must_use]
pub fn to_srt(entries: &[SubtitleEntry]) -> String {
let mut out = String::new();
for (i, entry) in entries.iter().enumerate() {
let n = i + 1;
let start = format_ms_to_srt(entry.start_ms);
let end = format_ms_to_srt(entry.end_ms);
out.push_str(&format!("{n}\n{start} --> {end}\n{}\n\n", entry.text));
}
out
}
}
pub struct SrtParser;
impl SrtParser {
pub fn from_srt(input: &str) -> Result<Vec<SubtitleEntry>, String> {
let mut entries = Vec::new();
let blocks: Vec<&str> = input
.split("\n\n")
.map(str::trim)
.filter(|b| !b.is_empty())
.collect();
for block in &blocks {
let lines: Vec<&str> = block.lines().collect();
if lines.len() < 3 {
if lines.len() < 2 {
continue;
}
}
let _seq: u64 = lines[0]
.trim()
.parse()
.map_err(|_| format!("Expected sequence number, got: {:?}", lines[0]))?;
let timing_line = lines[1];
let parts: Vec<&str> = timing_line.splitn(2, "-->").collect();
if parts.len() != 2 {
return Err(format!("Invalid timing line: {timing_line:?}"));
}
let start_ms = parse_srt_timestamp(parts[0])
.ok_or_else(|| format!("Invalid start timestamp: {:?}", parts[0]))?;
let end_ms = parse_srt_timestamp(parts[1])
.ok_or_else(|| format!("Invalid end timestamp: {:?}", parts[1]))?;
let text = lines[2..].join("\n");
entries.push(SubtitleEntry {
start_ms,
end_ms,
text,
});
}
Ok(entries)
}
}
pub struct VttSerializer;
impl VttSerializer {
#[must_use]
pub fn to_vtt(entries: &[SubtitleEntry]) -> String {
let mut out = String::from("WEBVTT\n\n");
for entry in entries {
let start = format_ms_to_vtt(entry.start_ms);
let end = format_ms_to_vtt(entry.end_ms);
out.push_str(&format!("{start} --> {end}\n{}\n\n", entry.text));
}
out
}
}
fn parse_vtt_timestamp(s: &str) -> Option<u64> {
let s = s.trim();
let (time_part, millis_part) = s.split_once('.')?;
let millis: u64 = millis_part.trim().parse().ok()?;
let parts: Vec<&str> = time_part.split(':').collect();
let (hours, mins, secs) = match parts.len() {
3 => {
let h: u64 = parts[0].trim().parse().ok()?;
let m: u64 = parts[1].trim().parse().ok()?;
let s: u64 = parts[2].trim().parse().ok()?;
(h, m, s)
}
2 => {
let m: u64 = parts[0].trim().parse().ok()?;
let s: u64 = parts[1].trim().parse().ok()?;
(0, m, s)
}
_ => return None,
};
Some(((hours * 3600 + mins * 60 + secs) * 1000) + millis)
}
pub struct VttParser;
impl VttParser {
pub fn from_vtt(input: &str) -> Result<Vec<SubtitleEntry>, String> {
let mut entries = Vec::new();
let blocks: Vec<&str> = input
.split("\n\n")
.map(str::trim)
.filter(|b| !b.is_empty())
.collect();
for block in &blocks {
if block.starts_with("WEBVTT") || block.starts_with("NOTE") {
continue;
}
let lines: Vec<&str> = block.lines().collect();
if lines.is_empty() {
continue;
}
let timing_line_idx = if lines[0].contains("-->") { 0 } else { 1 };
let timing_line = match lines.get(timing_line_idx) {
Some(l) => l,
None => continue,
};
if !timing_line.contains("-->") {
continue;
}
let arrow_pos = timing_line
.find("-->")
.ok_or_else(|| format!("missing --> in timing line: {timing_line:?}"))?;
let start_str = &timing_line[..arrow_pos];
let after_arrow = timing_line[arrow_pos + 3..].trim();
let end_str = after_arrow.split_whitespace().next().unwrap_or(after_arrow);
let start_ms = parse_vtt_timestamp(start_str)
.ok_or_else(|| format!("invalid WebVTT start: {start_str:?}"))?;
let end_ms = parse_vtt_timestamp(end_str)
.ok_or_else(|| format!("invalid WebVTT end: {end_str:?}"))?;
let text_start = timing_line_idx + 1;
let text = lines[text_start..].join("\n");
if text.is_empty() {
continue;
}
entries.push(SubtitleEntry {
start_ms,
end_ms,
text,
});
}
Ok(entries)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_entry_duration_ms() {
let e = SubtitleEntry::new(1000, 4500, "Hello");
assert_eq!(e.duration_ms(), 3500);
}
#[test]
fn test_entry_duration_saturating() {
let e = SubtitleEntry::new(5000, 3000, "Bad order");
assert_eq!(e.duration_ms(), 0);
}
#[test]
fn test_entry_line_count_single() {
let e = SubtitleEntry::new(0, 1000, "Hello");
assert_eq!(e.line_count(), 1);
}
#[test]
fn test_entry_line_count_multi() {
let e = SubtitleEntry::new(0, 1000, "Line one\nLine two");
assert_eq!(e.line_count(), 2);
}
#[test]
fn test_entry_line_count_empty() {
let e = SubtitleEntry::new(0, 1000, "");
assert_eq!(e.line_count(), 0);
}
#[test]
fn test_format_ms_to_srt_zero() {
assert_eq!(format_ms_to_srt(0), "00:00:00,000");
}
#[test]
fn test_format_ms_to_srt_one_hour() {
assert_eq!(format_ms_to_srt(3_600_000), "01:00:00,000");
}
#[test]
fn test_format_ms_to_srt_complex() {
let ms = 3600_000 + 2 * 60_000 + 3_000 + 456;
assert_eq!(format_ms_to_srt(ms), "01:02:03,456");
}
#[test]
fn test_format_ms_to_vtt_zero() {
assert_eq!(format_ms_to_vtt(0), "00:00:00.000");
}
#[test]
fn test_format_ms_to_vtt_complex() {
let ms = 3600_000 + 2 * 60_000 + 3_000 + 789;
assert_eq!(format_ms_to_vtt(ms), "01:02:03.789");
}
#[test]
fn test_srt_serializer_basic() {
let entries = vec![
SubtitleEntry::new(1_000, 4_000, "Hello, world!"),
SubtitleEntry::new(5_000, 8_000, "Second line."),
];
let srt = SrtSerializer::to_srt(&entries);
assert!(srt.contains("1\n"));
assert!(srt.contains("00:00:01,000 --> 00:00:04,000"));
assert!(srt.contains("Hello, world!"));
assert!(srt.contains("2\n"));
assert!(srt.contains("Second line."));
}
#[test]
fn test_srt_serializer_empty() {
let srt = SrtSerializer::to_srt(&[]);
assert!(srt.is_empty());
}
#[test]
fn test_srt_parser_roundtrip() {
let entries = vec![SubtitleEntry::new(1_000, 4_000, "Hello!")];
let srt = SrtSerializer::to_srt(&entries);
let parsed = SrtParser::from_srt(&srt).expect("should succeed in test");
assert_eq!(parsed.len(), 1);
assert_eq!(parsed[0].start_ms, 1_000);
assert_eq!(parsed[0].end_ms, 4_000);
assert_eq!(parsed[0].text, "Hello!");
}
#[test]
fn test_srt_parser_multi_entry() {
let input = "1\n00:00:01,000 --> 00:00:04,000\nHello\n\n2\n00:00:05,000 --> 00:00:08,000\nWorld\n\n";
let entries = SrtParser::from_srt(input).expect("should succeed in test");
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].text, "Hello");
assert_eq!(entries[1].text, "World");
assert_eq!(entries[1].start_ms, 5_000);
}
#[test]
fn test_srt_parser_invalid_sequence() {
let bad = "abc\n00:00:01,000 --> 00:00:04,000\nText\n\n";
let result = SrtParser::from_srt(bad);
assert!(result.is_err());
}
#[test]
fn test_vtt_serializer_header() {
let entries = vec![SubtitleEntry::new(1_000, 4_000, "VTT line")];
let vtt = VttSerializer::to_vtt(&entries);
assert!(vtt.starts_with("WEBVTT"));
}
#[test]
fn test_vtt_serializer_timing_format() {
let entries = vec![SubtitleEntry::new(1_000, 4_000, "Hello")];
let vtt = VttSerializer::to_vtt(&entries);
assert!(vtt.contains("00:00:01.000 --> 00:00:04.000"));
}
#[test]
fn test_vtt_serializer_empty() {
let vtt = VttSerializer::to_vtt(&[]);
assert_eq!(vtt, "WEBVTT\n\n");
}
#[test]
fn test_vtt_parser_basic() {
let vtt = "WEBVTT\n\n00:00:01.000 --> 00:00:04.000\nHello VTT\n\n";
let entries = VttParser::from_vtt(vtt).expect("parse should succeed");
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].start_ms, 1_000);
assert_eq!(entries[0].end_ms, 4_000);
assert_eq!(entries[0].text, "Hello VTT");
}
#[test]
fn test_vtt_parser_with_cue_id() {
let vtt = "WEBVTT\n\ncue1\n00:00:01.000 --> 00:00:04.000\nHello\n\n";
let entries = VttParser::from_vtt(vtt).expect("parse should succeed");
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].start_ms, 1_000);
}
#[test]
fn test_vtt_parser_skips_note_blocks() {
let vtt = "WEBVTT\n\nNOTE This is a comment\n\n00:00:01.000 --> 00:00:02.000\nText\n\n";
let entries = VttParser::from_vtt(vtt).expect("parse should succeed");
assert_eq!(entries.len(), 1);
}
#[test]
fn test_srt_webvtt_roundtrip_ms_precision() {
let mut original: Vec<SubtitleEntry> = Vec::with_capacity(50);
for i in 0u64..50 {
let start_ms = i * 3_617 + (i * 127) % 1000;
let end_ms = start_ms + 1_234 + (i * 37) % 999;
original.push(SubtitleEntry::new(
start_ms,
end_ms,
format!("Cue {i} text"),
));
}
let srt_text = SrtSerializer::to_srt(&original);
let from_srt = SrtParser::from_srt(&srt_text).expect("SRT parse should succeed");
assert_eq!(from_srt.len(), 50, "should recover all 50 entries from SRT");
let vtt_text = VttSerializer::to_vtt(&from_srt);
let from_vtt = VttParser::from_vtt(&vtt_text).expect("VTT parse should succeed");
assert_eq!(from_vtt.len(), 50, "should recover all 50 entries from VTT");
for (i, (orig, recovered)) in original.iter().zip(from_vtt.iter()).enumerate() {
assert_eq!(
orig.start_ms, recovered.start_ms,
"start_ms mismatch at cue {i}: original={} recovered={}",
orig.start_ms, recovered.start_ms
);
assert_eq!(
orig.end_ms, recovered.end_ms,
"end_ms mismatch at cue {i}: original={} recovered={}",
orig.end_ms, recovered.end_ms
);
assert_eq!(orig.text, recovered.text, "text mismatch at cue {i}");
}
}
#[test]
fn test_srt_webvtt_roundtrip_boundary_timestamps() {
let edge_cases: &[(u64, u64, &str)] = &[
(0, 1000, "zero start"),
(1000, 2000, "exact second"),
(60_000, 61_000, "exact minute"),
(3_600_000, 3_601_000, "exact hour"),
(3_599_999, 3_600_001, "straddles hour boundary"),
(86_399_000, 86_400_000, "24h boundary"),
];
let entries: Vec<SubtitleEntry> = edge_cases
.iter()
.map(|(s, e, t)| SubtitleEntry::new(*s, *e, t.to_string()))
.collect();
let srt = SrtSerializer::to_srt(&entries);
let from_srt = SrtParser::from_srt(&srt).expect("SRT parse");
let vtt = VttSerializer::to_vtt(&from_srt);
let from_vtt = VttParser::from_vtt(&vtt).expect("VTT parse");
assert_eq!(from_vtt.len(), entries.len());
for (orig, got) in entries.iter().zip(from_vtt.iter()) {
assert_eq!(orig.start_ms, got.start_ms, "start_ms edge: {}", orig.text);
assert_eq!(orig.end_ms, got.end_ms, "end_ms edge: {}", orig.text);
}
}
}