#[derive(Debug, Clone, PartialEq)]
pub(crate) struct Cue {
pub start_secs: f64,
pub end_secs: f64,
pub text: String,
}
const MILLIS_PER_SEC: f64 = 1000.0;
pub(crate) fn parse_clock(raw: &str) -> Option<f64> {
let mut secs = 0.0_f64;
let mut seen = 0_u32;
for part in raw.trim().split(':') {
let value: f64 = part.trim().parse().ok()?;
if value < 0.0 {
return None;
}
secs = secs * 60.0 + value;
seen += 1;
}
if (2..=3).contains(&seen) {
Some(secs)
} else {
None
}
}
pub(crate) fn srt_timestamp(secs: f64) -> String {
let total_ms = (secs.max(0.0) * MILLIS_PER_SEC).round() as u64;
let ms = total_ms % 1000;
let total_secs = total_ms / 1000;
let s = total_secs % 60;
let m = (total_secs / 60) % 60;
let h = total_secs / 3600;
format!("{h:02}:{m:02}:{s:02},{ms:03}")
}
pub(crate) fn render_srt(cues: &[Cue]) -> String {
let mut out = String::with_capacity(cues.len() * 64);
let mut index = 0_usize;
for cue in cues {
let text = cue.text.trim();
if text.is_empty() {
continue;
}
index += 1;
let start = cue.start_secs.max(0.0);
let end = if cue.end_secs > start {
cue.end_secs
} else {
start + min_cue_millis() as f64 / MILLIS_PER_SEC
};
out.push_str(&index.to_string());
out.push('\n');
out.push_str(&srt_timestamp(start));
out.push_str(" --> ");
out.push_str(&srt_timestamp(end));
out.push('\n');
out.push_str(text);
out.push_str("\n\n");
}
out
}
const DEFAULT_MIN_CUE_MILLIS: u64 = 100;
fn min_cue_millis() -> u64 {
crate::config::tuning_u64_in_range(
"providers.cue.min_cue_millis",
DEFAULT_MIN_CUE_MILLIS,
1,
600_000,
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_clock_reads_hours_minutes_seconds() {
assert_eq!(parse_clock("00:01:02"), Some(62.0));
assert_eq!(parse_clock("01:00:00"), Some(3600.0));
}
#[test]
fn parse_clock_reads_minutes_seconds() {
assert_eq!(parse_clock("02:03"), Some(123.0));
}
#[test]
fn parse_clock_honours_fractional_seconds() {
assert_eq!(parse_clock("00:00:01.5"), Some(1.5));
}
#[test]
fn parse_clock_rejects_malformed_input() {
assert_eq!(parse_clock(""), None);
assert_eq!(parse_clock("12"), None);
assert_eq!(parse_clock("aa:bb"), None);
assert_eq!(parse_clock("00:00:00:00"), None);
assert_eq!(parse_clock("-1:00"), None);
}
#[test]
fn srt_timestamp_pads_every_field() {
assert_eq!(srt_timestamp(0.0), "00:00:00,000");
assert_eq!(srt_timestamp(3661.5), "01:01:01,500");
}
#[test]
fn srt_timestamp_clamps_negative_input() {
assert_eq!(srt_timestamp(-5.0), "00:00:00,000");
}
#[test]
fn render_srt_numbers_cues_contiguously() {
let cues = vec![
Cue {
start_secs: 0.0,
end_secs: 1.0,
text: "one".to_string(),
},
Cue {
start_secs: 1.0,
end_secs: 2.0,
text: " ".to_string(),
},
Cue {
start_secs: 2.0,
end_secs: 3.0,
text: "two".to_string(),
},
];
let srt = render_srt(&cues);
assert!(srt.starts_with("1\n00:00:00,000 --> 00:00:01,000\none\n\n"));
assert!(
srt.contains("2\n00:00:02,000 --> 00:00:03,000\ntwo"),
"blank cue must not consume a sequence number: {srt}"
);
}
#[test]
fn render_srt_extends_a_zero_length_cue() {
let cues = vec![Cue {
start_secs: 5.0,
end_secs: 5.0,
text: "flash".to_string(),
}];
let srt = render_srt(&cues);
assert!(
srt.contains("00:00:05,000 --> 00:00:05,100"),
"zero-length cue must be widened: {srt}"
);
}
#[test]
fn render_srt_of_nothing_is_empty() {
assert!(render_srt(&[]).is_empty());
}
}