Skip to main content

oxihuman_export/
srt_export.rs

1// Copyright (C) 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3#![allow(dead_code)]
4
5//! SRT subtitle export.
6
7/// A single SRT subtitle entry.
8#[derive(Debug, Clone)]
9pub struct SrtEntry {
10    /// 1-based sequence number.
11    pub index: u32,
12    /// Start time in milliseconds.
13    pub start_ms: u64,
14    /// End time in milliseconds.
15    pub end_ms: u64,
16    /// Subtitle text lines.
17    pub text: Vec<String>,
18}
19
20/// An SRT document.
21#[derive(Debug, Clone, Default)]
22pub struct SrtDocument {
23    pub entries: Vec<SrtEntry>,
24}
25
26impl SrtDocument {
27    /// Add a subtitle entry.
28    pub fn add_entry(&mut self, start_ms: u64, end_ms: u64, text: impl Into<String>) {
29        let index = self.entries.len() as u32 + 1;
30        self.entries.push(SrtEntry {
31            index,
32            start_ms,
33            end_ms,
34            text: vec![text.into()],
35        });
36    }
37
38    /// Number of entries.
39    pub fn entry_count(&self) -> usize {
40        self.entries.len()
41    }
42}
43
44/// Format milliseconds as SRT timestamp `HH:MM:SS,mmm`.
45pub fn ms_to_srt_time(ms: u64) -> String {
46    let h = ms / 3_600_000;
47    let m = (ms % 3_600_000) / 60_000;
48    let s = (ms % 60_000) / 1_000;
49    let ms = ms % 1_000;
50    format!("{h:02}:{m:02}:{s:02},{ms:03}")
51}
52
53/// Render an SrtDocument to a String in SRT format.
54pub fn render_srt(doc: &SrtDocument) -> String {
55    let mut out = String::new();
56    for entry in &doc.entries {
57        out.push_str(&format!("{}\n", entry.index));
58        out.push_str(&format!(
59            "{} --> {}\n",
60            ms_to_srt_time(entry.start_ms),
61            ms_to_srt_time(entry.end_ms)
62        ));
63        for line in &entry.text {
64            out.push_str(line);
65            out.push('\n');
66        }
67        out.push('\n');
68    }
69    out
70}
71
72/// Validate that all entries have increasing timestamps and non-empty text.
73pub fn validate_srt(doc: &SrtDocument) -> bool {
74    doc.entries
75        .iter()
76        .all(|e| e.start_ms < e.end_ms && !e.text.is_empty())
77}
78
79/// Total duration of the subtitle file in milliseconds.
80pub fn total_duration_ms(doc: &SrtDocument) -> u64 {
81    doc.entries.iter().map(|e| e.end_ms).max().unwrap_or(0)
82}
83
84#[cfg(test)]
85mod tests {
86    use super::*;
87
88    fn sample_doc() -> SrtDocument {
89        let mut doc = SrtDocument::default();
90        doc.add_entry(0, 2000, "Hello world");
91        doc.add_entry(3000, 5000, "Second line");
92        doc
93    }
94
95    #[test]
96    fn entry_count_correct() {
97        /* two entries */
98        assert_eq!(sample_doc().entry_count(), 2);
99    }
100
101    #[test]
102    fn indices_start_at_one() {
103        /* first entry index = 1 */
104        assert_eq!(sample_doc().entries[0].index, 1);
105    }
106
107    #[test]
108    fn ms_to_srt_time_format() {
109        /* 3723456 ms → 01:02:03,456 */
110        assert_eq!(ms_to_srt_time(3_723_456), "01:02:03,456");
111    }
112
113    #[test]
114    fn ms_to_srt_zero() {
115        assert_eq!(ms_to_srt_time(0), "00:00:00,000");
116    }
117
118    #[test]
119    fn render_contains_arrow() {
120        /* rendered output contains --> */
121        let s = render_srt(&sample_doc());
122        assert!(s.contains("-->"));
123    }
124
125    #[test]
126    fn render_contains_text() {
127        let s = render_srt(&sample_doc());
128        assert!(s.contains("Hello world"));
129    }
130
131    #[test]
132    fn validate_ok() {
133        assert!(validate_srt(&sample_doc()));
134    }
135
136    #[test]
137    fn validate_bad_timestamps() {
138        /* start >= end is invalid */
139        let mut doc = SrtDocument::default();
140        doc.entries.push(SrtEntry {
141            index: 1,
142            start_ms: 5000,
143            end_ms: 3000,
144            text: vec!["bad".to_string()],
145        });
146        assert!(!validate_srt(&doc));
147    }
148
149    #[test]
150    fn total_duration_correct() {
151        assert_eq!(total_duration_ms(&sample_doc()), 5000);
152    }
153
154    #[test]
155    fn empty_doc_duration_zero() {
156        assert_eq!(total_duration_ms(&SrtDocument::default()), 0);
157    }
158}