#![allow(dead_code)]
#![forbid(unsafe_code)]
use std::collections::HashMap;
use std::fmt;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum SubtitleFormat {
Srt,
WebVtt,
Ass,
Ttml,
}
impl SubtitleFormat {
#[must_use]
pub fn matroska_codec_id(&self) -> &'static str {
match self {
Self::Srt => "S_TEXT/UTF8",
Self::WebVtt => "S_TEXT/WEBVTT",
Self::Ass => "S_TEXT/ASS",
Self::Ttml => "S_TEXT/TTML",
}
}
#[must_use]
pub fn mime_type(&self) -> &'static str {
match self {
Self::Srt => "application/x-subrip",
Self::WebVtt => "text/vtt",
Self::Ass => "text/x-ssa",
Self::Ttml => "application/ttml+xml",
}
}
#[must_use]
pub fn extension(&self) -> &'static str {
match self {
Self::Srt => "srt",
Self::WebVtt => "vtt",
Self::Ass => "ass",
Self::Ttml => "ttml",
}
}
}
impl fmt::Display for SubtitleFormat {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.matroska_codec_id())
}
}
#[derive(Debug, Clone)]
pub struct SubtitleCue {
pub start_ms: u64,
pub end_ms: u64,
pub text: String,
pub style: Option<String>,
}
impl SubtitleCue {
#[must_use]
pub fn duration_ms(&self) -> u64 {
self.end_ms.saturating_sub(self.start_ms)
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.text.trim().is_empty()
}
#[must_use]
pub fn is_zero_duration(&self) -> bool {
self.end_ms <= self.start_ms
}
#[must_use]
pub fn overlaps(&self, other: &SubtitleCue) -> bool {
self.start_ms < other.end_ms && other.start_ms < self.end_ms
}
}
#[derive(Debug, Clone)]
pub struct AssStyle {
pub name: String,
pub fontname: String,
pub fontsize: u32,
pub primary_colour: String,
pub secondary_colour: String,
pub outline_colour: String,
pub back_colour: String,
pub bold: i32,
pub italic: i32,
pub border_style: u32,
pub outline: f32,
pub shadow: f32,
pub alignment: u32,
pub margin_l: u32,
pub margin_r: u32,
pub margin_v: u32,
}
impl Default for AssStyle {
fn default() -> Self {
Self {
name: "Default".to_owned(),
fontname: "Arial".to_owned(),
fontsize: 20,
primary_colour: "&H00FFFFFF".to_owned(),
secondary_colour: "&H000000FF".to_owned(),
outline_colour: "&H00000000".to_owned(),
back_colour: "&H00000000".to_owned(),
bold: 0,
italic: 0,
border_style: 1,
outline: 2.0,
shadow: 2.0,
alignment: 2,
margin_l: 10,
margin_r: 10,
margin_v: 10,
}
}
}
impl AssStyle {
#[must_use]
pub fn to_ass_line(&self) -> String {
format!(
"Style: {},{},{},{},{},{},{},{},{},0,{},{:.1},{:.1},{},{},{},{}",
self.name,
self.fontname,
self.fontsize,
self.primary_colour,
self.secondary_colour,
self.outline_colour,
self.back_colour,
self.bold,
self.italic,
self.border_style,
self.outline,
self.shadow,
self.alignment,
self.margin_l,
self.margin_r,
self.margin_v,
)
}
}
#[derive(Debug, Clone)]
pub struct AssScriptInfo {
pub title: String,
pub script_type: String,
pub play_res_x: u32,
pub play_res_y: u32,
pub timer: String,
}
impl Default for AssScriptInfo {
fn default() -> Self {
Self {
title: "OxiMedia Subtitles".to_owned(),
script_type: "v4.00+".to_owned(),
play_res_x: 1920,
play_res_y: 1080,
timer: "100.0000".to_owned(),
}
}
}
impl AssScriptInfo {
#[must_use]
pub fn to_section(&self) -> String {
let mut out = String::with_capacity(256);
out.push_str("[Script Info]\n");
out.push_str(&format!("Title: {}\n", self.title));
out.push_str(&format!("ScriptType: {}\n", self.script_type));
out.push_str(&format!("PlayResX: {}\n", self.play_res_x));
out.push_str(&format!("PlayResY: {}\n", self.play_res_y));
out.push_str(&format!("Timer: {}\n", self.timer));
out
}
}
#[derive(Debug, Clone)]
pub struct SubtitleBlock {
pub start_ms: u64,
pub duration_ms: u64,
pub payload: Vec<u8>,
}
impl SubtitleBlock {
#[must_use]
pub fn payload_str(&self) -> Option<&str> {
std::str::from_utf8(&self.payload).ok()
}
#[must_use]
pub fn is_non_empty(&self) -> bool {
!self.payload.is_empty()
}
}
#[derive(Debug, Clone)]
pub struct SubtitleTrackConfig {
pub track_id: u32,
pub format: SubtitleFormat,
pub language: String,
pub name: Option<String>,
pub is_default: bool,
}
impl SubtitleTrackConfig {
#[must_use]
pub fn codec_id(&self) -> &'static str {
self.format.matroska_codec_id()
}
}
#[derive(Debug, Clone, Default)]
pub struct WebVttCodecPrivate {
pub header_comments: Vec<String>,
pub style_blocks: Vec<String>,
}
impl WebVttCodecPrivate {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn with_comment(mut self, comment: impl Into<String>) -> Self {
self.header_comments.push(comment.into());
self
}
#[must_use]
pub fn with_style(mut self, css: impl Into<String>) -> Self {
self.style_blocks.push(css.into());
self
}
#[must_use]
pub fn serialize(&self) -> Vec<u8> {
let mut out = String::with_capacity(128);
out.push_str("WEBVTT\n");
for comment in &self.header_comments {
out.push_str(&format!("NOTE {comment}\n"));
}
out.push('\n');
for style in &self.style_blocks {
out.push_str("STYLE\n");
out.push_str(style);
out.push_str("\n\n");
}
out.into_bytes()
}
}
#[derive(Debug, Clone)]
pub struct AssCodecPrivate {
pub script_info: AssScriptInfo,
pub styles: Vec<AssStyle>,
}
impl Default for AssCodecPrivate {
fn default() -> Self {
Self {
script_info: AssScriptInfo::default(),
styles: vec![AssStyle::default()],
}
}
}
impl AssCodecPrivate {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn with_style(mut self, style: AssStyle) -> Self {
self.styles.push(style);
self
}
#[must_use]
pub fn serialize(&self) -> Vec<u8> {
let mut out = String::with_capacity(512);
out.push_str(&self.script_info.to_section());
out.push('\n');
out.push_str("[V4+ Styles]\n");
out.push_str("Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, StrikeOut, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV\n");
for style in &self.styles {
out.push_str(&style.to_ass_line());
out.push('\n');
}
out.push('\n');
out.push_str("[Events]\n");
out.push_str(
"Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\n",
);
out.into_bytes()
}
}
#[must_use]
fn format_srt_timestamp(ms: u64) -> String {
let hours = ms / 3_600_000;
let minutes = (ms % 3_600_000) / 60_000;
let seconds = (ms % 60_000) / 1000;
let millis = ms % 1000;
format!("{hours:02}:{minutes:02}:{seconds:02},{millis:03}")
}
#[must_use]
fn format_webvtt_timestamp(ms: u64) -> String {
let hours = ms / 3_600_000;
let minutes = (ms % 3_600_000) / 60_000;
let seconds = (ms % 60_000) / 1000;
let millis = ms % 1000;
format!("{hours:02}:{minutes:02}:{seconds:02}.{millis:03}")
}
#[must_use]
fn format_ass_timestamp(ms: u64) -> String {
let hours = ms / 3_600_000;
let minutes = (ms % 3_600_000) / 60_000;
let seconds = (ms % 60_000) / 1000;
let centis = (ms % 1000) / 10;
format!("{hours}:{minutes:02}:{seconds:02}.{centis:02}")
}
fn serialize_srt_block(cue: &SubtitleCue) -> Vec<u8> {
cue.text.as_bytes().to_vec()
}
fn serialize_webvtt_block(cue: &SubtitleCue) -> Vec<u8> {
let mut out = String::with_capacity(cue.text.len() + 32);
out.push_str("\n\n");
if let Some(ref style) = cue.style {
out.push_str(style);
}
out.push_str("\n\n");
out.push_str(&cue.text);
out.into_bytes()
}
fn serialize_ass_block(cue: &SubtitleCue, read_order: u32) -> Vec<u8> {
let style_name = cue.style.as_deref().unwrap_or("Default");
let line = format!("{read_order},0,{style_name},,0,0,0,,{}", cue.text);
line.into_bytes()
}
fn serialize_ttml_block(cue: &SubtitleCue) -> Vec<u8> {
let begin = format_ttml_timestamp(cue.start_ms);
let end = format_ttml_timestamp(cue.end_ms);
let text = xml_escape(&cue.text);
let xml = format!("<p begin=\"{begin}\" end=\"{end}\">{text}</p>");
xml.into_bytes()
}
#[must_use]
fn format_ttml_timestamp(ms: u64) -> String {
let hours = ms / 3_600_000;
let minutes = (ms % 3_600_000) / 60_000;
let seconds = (ms % 60_000) / 1000;
let millis = ms % 1000;
format!("{hours:02}:{minutes:02}:{seconds:02}.{millis:03}")
}
fn xml_escape(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for ch in s.chars() {
match ch {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'"' => out.push_str("""),
'\'' => out.push_str("'"),
_ => out.push(ch),
}
}
out
}
#[derive(Debug)]
pub struct SubtitleMuxer {
config: SubtitleTrackConfig,
cues: Vec<SubtitleCue>,
ass_private: Option<AssCodecPrivate>,
webvtt_private: Option<WebVttCodecPrivate>,
metadata: HashMap<String, String>,
}
impl SubtitleMuxer {
#[must_use]
pub fn new(config: SubtitleTrackConfig) -> Self {
Self {
config,
cues: Vec::new(),
ass_private: None,
webvtt_private: None,
metadata: HashMap::new(),
}
}
pub fn set_ass_private(&mut self, private: AssCodecPrivate) {
self.ass_private = Some(private);
}
pub fn set_webvtt_private(&mut self, private: WebVttCodecPrivate) {
self.webvtt_private = Some(private);
}
pub fn set_metadata(&mut self, key: impl Into<String>, value: impl Into<String>) {
self.metadata.insert(key.into(), value.into());
}
pub fn add_cue(&mut self, cue: SubtitleCue) {
self.cues.push(cue);
}
#[must_use]
pub fn cue_count(&self) -> usize {
self.cues.len()
}
#[must_use]
pub fn config(&self) -> &SubtitleTrackConfig {
&self.config
}
pub fn sort_cues(&mut self) {
self.cues.sort_by_key(|c| c.start_ms);
}
#[must_use]
pub fn cues(&self) -> &[SubtitleCue] {
&self.cues
}
pub fn remove_invalid_cues(&mut self) {
self.cues.retain(|c| !c.is_empty() && !c.is_zero_duration());
}
#[must_use]
pub fn total_duration_ms(&self) -> u64 {
let min_start = self.cues.iter().map(|c| c.start_ms).min().unwrap_or(0);
let max_end = self.cues.iter().map(|c| c.end_ms).max().unwrap_or(0);
max_end.saturating_sub(min_start)
}
#[must_use]
pub fn find_overlaps(&self) -> Vec<(usize, usize)> {
let mut overlaps = Vec::new();
for i in 0..self.cues.len() {
for j in (i + 1)..self.cues.len() {
if self.cues[i].overlaps(&self.cues[j]) {
overlaps.push((i, j));
}
}
}
overlaps
}
#[must_use]
pub fn codec_private_data(&self) -> Option<Vec<u8>> {
match self.config.format {
SubtitleFormat::Srt => None,
SubtitleFormat::WebVtt => {
let private = self
.webvtt_private
.as_ref()
.map_or_else(|| WebVttCodecPrivate::new().serialize(), |p| p.serialize());
Some(private)
}
SubtitleFormat::Ass => {
let private = self
.ass_private
.as_ref()
.map_or_else(|| AssCodecPrivate::new().serialize(), |p| p.serialize());
Some(private)
}
SubtitleFormat::Ttml => {
let header = concat!(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n",
"<tt xmlns=\"http://www.w3.org/ns/ttml\">\n",
"<body><div>\n"
);
Some(header.as_bytes().to_vec())
}
}
}
#[must_use]
pub fn serialize_blocks(&self) -> Vec<SubtitleBlock> {
let mut blocks = Vec::with_capacity(self.cues.len());
for (idx, cue) in self.cues.iter().enumerate() {
if cue.is_empty() || cue.is_zero_duration() {
continue;
}
let payload = match self.config.format {
SubtitleFormat::Srt => serialize_srt_block(cue),
SubtitleFormat::WebVtt => serialize_webvtt_block(cue),
SubtitleFormat::Ass => {
let read_order = idx as u32;
serialize_ass_block(cue, read_order)
}
SubtitleFormat::Ttml => serialize_ttml_block(cue),
};
blocks.push(SubtitleBlock {
start_ms: cue.start_ms,
duration_ms: cue.duration_ms(),
payload,
});
}
blocks
}
#[must_use]
pub fn serialize_to_file_string(&self) -> String {
match self.config.format {
SubtitleFormat::Srt => self.serialize_srt_file(),
SubtitleFormat::WebVtt => self.serialize_webvtt_file(),
SubtitleFormat::Ass => self.serialize_ass_file(),
SubtitleFormat::Ttml => self.serialize_ttml_file(),
}
}
fn serialize_srt_file(&self) -> String {
let mut out = String::with_capacity(self.cues.len() * 80);
for (idx, cue) in self.cues.iter().enumerate() {
if cue.is_empty() {
continue;
}
out.push_str(&format!("{}\n", idx + 1));
out.push_str(&format!(
"{} --> {}\n",
format_srt_timestamp(cue.start_ms),
format_srt_timestamp(cue.end_ms)
));
out.push_str(&cue.text);
out.push_str("\n\n");
}
out
}
fn serialize_webvtt_file(&self) -> String {
let mut out = String::with_capacity(self.cues.len() * 80 + 64);
out.push_str("WEBVTT\n\n");
for cue in &self.cues {
if cue.is_empty() {
continue;
}
out.push_str(&format!(
"{} --> {}",
format_webvtt_timestamp(cue.start_ms),
format_webvtt_timestamp(cue.end_ms)
));
if let Some(ref style) = cue.style {
out.push(' ');
out.push_str(style);
}
out.push('\n');
out.push_str(&cue.text);
out.push_str("\n\n");
}
out
}
fn serialize_ass_file(&self) -> String {
let private = self.ass_private.clone().unwrap_or_default();
let mut out = String::with_capacity(self.cues.len() * 100 + 512);
out.push_str(&String::from_utf8_lossy(&private.serialize()));
for (idx, cue) in self.cues.iter().enumerate() {
if cue.is_empty() {
continue;
}
let style_name = cue.style.as_deref().unwrap_or("Default");
out.push_str(&format!(
"Dialogue: 0,{},{},{style_name},,0,0,0,,{}\n",
format_ass_timestamp(cue.start_ms),
format_ass_timestamp(cue.end_ms),
cue.text
));
let _ = idx; }
out
}
fn serialize_ttml_file(&self) -> String {
let mut out = String::with_capacity(self.cues.len() * 100 + 256);
out.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
out.push_str("<tt xmlns=\"http://www.w3.org/ns/ttml\">\n");
out.push_str("<body><div>\n");
for cue in &self.cues {
if cue.is_empty() {
continue;
}
let begin = format_ttml_timestamp(cue.start_ms);
let end = format_ttml_timestamp(cue.end_ms);
let text = xml_escape(&cue.text);
out.push_str(&format!(
" <p begin=\"{begin}\" end=\"{end}\">{text}</p>\n"
));
}
out.push_str("</div></body>\n");
out.push_str("</tt>\n");
out
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_config(format: SubtitleFormat) -> SubtitleTrackConfig {
SubtitleTrackConfig {
track_id: 1,
format,
language: "en".to_owned(),
name: Some("English".to_owned()),
is_default: true,
}
}
fn sample_cues() -> Vec<SubtitleCue> {
vec![
SubtitleCue {
start_ms: 1000,
end_ms: 4000,
text: "Hello, world!".to_owned(),
style: None,
},
SubtitleCue {
start_ms: 5000,
end_ms: 8000,
text: "Second line".to_owned(),
style: None,
},
SubtitleCue {
start_ms: 9000,
end_ms: 12000,
text: "Third line".to_owned(),
style: Some("position:50%".to_owned()),
},
]
}
#[test]
fn test_subtitle_format_codec_ids() {
assert_eq!(SubtitleFormat::Srt.matroska_codec_id(), "S_TEXT/UTF8");
assert_eq!(SubtitleFormat::WebVtt.matroska_codec_id(), "S_TEXT/WEBVTT");
assert_eq!(SubtitleFormat::Ass.matroska_codec_id(), "S_TEXT/ASS");
assert_eq!(SubtitleFormat::Ttml.matroska_codec_id(), "S_TEXT/TTML");
}
#[test]
fn test_subtitle_format_mime_types() {
assert_eq!(SubtitleFormat::Srt.mime_type(), "application/x-subrip");
assert_eq!(SubtitleFormat::WebVtt.mime_type(), "text/vtt");
assert_eq!(SubtitleFormat::Ass.mime_type(), "text/x-ssa");
assert_eq!(SubtitleFormat::Ttml.mime_type(), "application/ttml+xml");
}
#[test]
fn test_subtitle_format_extensions() {
assert_eq!(SubtitleFormat::Srt.extension(), "srt");
assert_eq!(SubtitleFormat::WebVtt.extension(), "vtt");
assert_eq!(SubtitleFormat::Ass.extension(), "ass");
assert_eq!(SubtitleFormat::Ttml.extension(), "ttml");
}
#[test]
fn test_cue_duration_and_overlap() {
let cue1 = SubtitleCue {
start_ms: 1000,
end_ms: 3000,
text: "A".to_owned(),
style: None,
};
let cue2 = SubtitleCue {
start_ms: 2000,
end_ms: 5000,
text: "B".to_owned(),
style: None,
};
let cue3 = SubtitleCue {
start_ms: 4000,
end_ms: 6000,
text: "C".to_owned(),
style: None,
};
assert_eq!(cue1.duration_ms(), 2000);
assert!(cue1.overlaps(&cue2));
assert!(!cue1.overlaps(&cue3));
assert!(cue2.overlaps(&cue3));
}
#[test]
fn test_cue_empty_and_zero_duration() {
let empty = SubtitleCue {
start_ms: 0,
end_ms: 1000,
text: " ".to_owned(),
style: None,
};
assert!(empty.is_empty());
let zero_dur = SubtitleCue {
start_ms: 1000,
end_ms: 1000,
text: "text".to_owned(),
style: None,
};
assert!(zero_dur.is_zero_duration());
}
#[test]
fn test_muxer_srt_serialization() {
let mut muxer = SubtitleMuxer::new(make_config(SubtitleFormat::Srt));
for cue in sample_cues() {
muxer.add_cue(cue);
}
muxer.sort_cues();
let blocks = muxer.serialize_blocks();
assert_eq!(blocks.len(), 3);
assert_eq!(blocks[0].start_ms, 1000);
assert_eq!(blocks[0].duration_ms, 3000);
assert_eq!(blocks[0].payload_str(), Some("Hello, world!"));
assert!(muxer.codec_private_data().is_none());
}
#[test]
fn test_muxer_webvtt_serialization() {
let mut muxer = SubtitleMuxer::new(make_config(SubtitleFormat::WebVtt));
muxer.add_cue(SubtitleCue {
start_ms: 1000,
end_ms: 3000,
text: "WebVTT cue".to_owned(),
style: Some("align:center".to_owned()),
});
let blocks = muxer.serialize_blocks();
assert_eq!(blocks.len(), 1);
let payload = blocks[0].payload_str().expect("valid utf8");
assert!(payload.contains("align:center"));
assert!(payload.contains("WebVTT cue"));
let private = muxer.codec_private_data().expect("has private");
let private_str = std::str::from_utf8(&private).expect("valid utf8");
assert!(private_str.starts_with("WEBVTT"));
}
#[test]
fn test_muxer_ass_serialization() {
let mut muxer = SubtitleMuxer::new(make_config(SubtitleFormat::Ass));
let ass_private = AssCodecPrivate::new();
muxer.set_ass_private(ass_private);
muxer.add_cue(SubtitleCue {
start_ms: 0,
end_ms: 2000,
text: "ASS dialogue".to_owned(),
style: Some("CustomStyle".to_owned()),
});
let blocks = muxer.serialize_blocks();
assert_eq!(blocks.len(), 1);
let payload = blocks[0].payload_str().expect("valid utf8");
assert!(payload.contains("CustomStyle"));
assert!(payload.contains("ASS dialogue"));
assert!(payload.starts_with("0,0,"));
let private = muxer.codec_private_data().expect("has private");
let private_str = std::str::from_utf8(&private).expect("valid utf8");
assert!(private_str.contains("[Script Info]"));
assert!(private_str.contains("[V4+ Styles]"));
assert!(private_str.contains("[Events]"));
}
#[test]
fn test_muxer_ttml_serialization() {
let mut muxer = SubtitleMuxer::new(make_config(SubtitleFormat::Ttml));
muxer.add_cue(SubtitleCue {
start_ms: 500,
end_ms: 2500,
text: "TTML <bold> & text".to_owned(),
style: None,
});
let blocks = muxer.serialize_blocks();
assert_eq!(blocks.len(), 1);
let payload = blocks[0].payload_str().expect("valid utf8");
assert!(payload.contains("<p begin=\"00:00:00.500\""));
assert!(payload.contains("end=\"00:00:02.500\""));
assert!(payload.contains("<bold>"));
assert!(payload.contains("&"));
}
#[test]
fn test_muxer_remove_invalid_cues() {
let mut muxer = SubtitleMuxer::new(make_config(SubtitleFormat::Srt));
muxer.add_cue(SubtitleCue {
start_ms: 0,
end_ms: 1000,
text: "valid".to_owned(),
style: None,
});
muxer.add_cue(SubtitleCue {
start_ms: 1000,
end_ms: 1000,
text: "zero duration".to_owned(),
style: None,
});
muxer.add_cue(SubtitleCue {
start_ms: 2000,
end_ms: 3000,
text: " ".to_owned(),
style: None,
});
muxer.add_cue(SubtitleCue {
start_ms: 4000,
end_ms: 5000,
text: "also valid".to_owned(),
style: None,
});
assert_eq!(muxer.cue_count(), 4);
muxer.remove_invalid_cues();
assert_eq!(muxer.cue_count(), 2);
assert_eq!(muxer.cues()[0].text, "valid");
assert_eq!(muxer.cues()[1].text, "also valid");
}
#[test]
fn test_muxer_find_overlaps() {
let mut muxer = SubtitleMuxer::new(make_config(SubtitleFormat::Srt));
muxer.add_cue(SubtitleCue {
start_ms: 0,
end_ms: 3000,
text: "A".to_owned(),
style: None,
});
muxer.add_cue(SubtitleCue {
start_ms: 2000,
end_ms: 5000,
text: "B".to_owned(),
style: None,
});
muxer.add_cue(SubtitleCue {
start_ms: 6000,
end_ms: 8000,
text: "C".to_owned(),
style: None,
});
let overlaps = muxer.find_overlaps();
assert_eq!(overlaps.len(), 1);
assert_eq!(overlaps[0], (0, 1));
}
#[test]
fn test_muxer_total_duration() {
let mut muxer = SubtitleMuxer::new(make_config(SubtitleFormat::Srt));
for cue in sample_cues() {
muxer.add_cue(cue);
}
assert_eq!(muxer.total_duration_ms(), 11000); }
#[test]
fn test_serialize_srt_file() {
let mut muxer = SubtitleMuxer::new(make_config(SubtitleFormat::Srt));
muxer.add_cue(SubtitleCue {
start_ms: 1000,
end_ms: 3000,
text: "Line one".to_owned(),
style: None,
});
muxer.add_cue(SubtitleCue {
start_ms: 5000,
end_ms: 7000,
text: "Line two".to_owned(),
style: None,
});
let file = muxer.serialize_to_file_string();
assert!(file.contains("1\n00:00:01,000 --> 00:00:03,000\nLine one"));
assert!(file.contains("2\n00:00:05,000 --> 00:00:07,000\nLine two"));
}
#[test]
fn test_serialize_webvtt_file() {
let mut muxer = SubtitleMuxer::new(make_config(SubtitleFormat::WebVtt));
muxer.add_cue(SubtitleCue {
start_ms: 62_500,
end_ms: 65_000,
text: "Hello".to_owned(),
style: None,
});
let file = muxer.serialize_to_file_string();
assert!(file.starts_with("WEBVTT\n"));
assert!(file.contains("00:01:02.500 --> 00:01:05.000"));
}
#[test]
fn test_serialize_ass_file() {
let mut muxer = SubtitleMuxer::new(make_config(SubtitleFormat::Ass));
muxer.add_cue(SubtitleCue {
start_ms: 0,
end_ms: 2000,
text: "Hello".to_owned(),
style: None,
});
let file = muxer.serialize_to_file_string();
assert!(file.contains("[Script Info]"));
assert!(file.contains("[V4+ Styles]"));
assert!(file.contains("[Events]"));
assert!(file.contains("Dialogue: 0,0:00:00.00,0:00:02.00,Default,,0,0,0,,Hello"));
}
#[test]
fn test_serialize_ttml_file() {
let mut muxer = SubtitleMuxer::new(make_config(SubtitleFormat::Ttml));
muxer.add_cue(SubtitleCue {
start_ms: 0,
end_ms: 1000,
text: "Test & <value>".to_owned(),
style: None,
});
let file = muxer.serialize_to_file_string();
assert!(file.contains("xmlns=\"http://www.w3.org/ns/ttml\""));
assert!(file.contains("&"));
assert!(file.contains("<value>"));
assert!(file.contains("</tt>"));
}
#[test]
fn test_ass_style_serialization() {
let style = AssStyle::default();
let line = style.to_ass_line();
assert!(line.starts_with("Style: Default,Arial,20,"));
assert!(line.contains("&H00FFFFFF"));
}
#[test]
fn test_webvtt_codec_private_with_style() {
let private = WebVttCodecPrivate::new()
.with_comment("Generated by OxiMedia")
.with_style("::cue { color: white; }");
let data = private.serialize();
let text = std::str::from_utf8(&data).expect("valid utf8");
assert!(text.starts_with("WEBVTT\n"));
assert!(text.contains("NOTE Generated by OxiMedia"));
assert!(text.contains("STYLE\n::cue { color: white; }"));
}
#[test]
fn test_timestamp_formatting() {
assert_eq!(format_srt_timestamp(0), "00:00:00,000");
assert_eq!(format_srt_timestamp(3_661_500), "01:01:01,500");
assert_eq!(format_webvtt_timestamp(3_661_500), "01:01:01.500");
assert_eq!(format_ass_timestamp(3_661_500), "1:01:01.50");
assert_eq!(format_ttml_timestamp(3_661_500), "01:01:01.500");
}
#[test]
fn test_subtitle_block_payload_str() {
let block = SubtitleBlock {
start_ms: 0,
duration_ms: 1000,
payload: b"Hello".to_vec(),
};
assert_eq!(block.payload_str(), Some("Hello"));
assert!(block.is_non_empty());
let empty_block = SubtitleBlock {
start_ms: 0,
duration_ms: 0,
payload: vec![],
};
assert!(!empty_block.is_non_empty());
}
#[test]
fn test_muxer_metadata() {
let mut muxer = SubtitleMuxer::new(make_config(SubtitleFormat::Srt));
muxer.set_metadata("source", "broadcast");
assert_eq!(muxer.config().codec_id(), "S_TEXT/UTF8");
}
}