150 lines
4.8 KiB
Rust
150 lines
4.8 KiB
Rust
use anyhow::{anyhow, Result};
|
|
|
|
use crate::models::SubtitleSegment;
|
|
|
|
#[derive(Debug, Clone, Copy)]
|
|
pub enum SubtitleFormat {
|
|
Srt,
|
|
Vtt,
|
|
Ass,
|
|
}
|
|
|
|
impl SubtitleFormat {
|
|
pub fn extension(&self) -> &'static str {
|
|
match self {
|
|
Self::Srt => "srt",
|
|
Self::Vtt => "vtt",
|
|
Self::Ass => "ass",
|
|
}
|
|
}
|
|
}
|
|
|
|
impl TryFrom<&str> for SubtitleFormat {
|
|
type Error = anyhow::Error;
|
|
|
|
fn try_from(value: &str) -> Result<Self> {
|
|
match value.to_lowercase().as_str() {
|
|
"srt" => Ok(Self::Srt),
|
|
"vtt" => Ok(Self::Vtt),
|
|
"ass" => Ok(Self::Ass),
|
|
_ => Err(anyhow!("unsupported subtitle format: {value}")),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct AssStyle {
|
|
pub name: String,
|
|
pub font_name: String,
|
|
pub font_size: u32,
|
|
pub primary_colour: String,
|
|
pub outline_colour: String,
|
|
}
|
|
|
|
impl Default for AssStyle {
|
|
fn default() -> Self {
|
|
Self {
|
|
name: "Default".to_string(),
|
|
font_name: "Arial".to_string(),
|
|
font_size: 22,
|
|
primary_colour: "&H00FFFFFF".to_string(),
|
|
outline_colour: "&H00000000".to_string(),
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn render(segments: &[SubtitleSegment], format: SubtitleFormat, bilingual: bool) -> String {
|
|
match format {
|
|
SubtitleFormat::Srt => render_srt(segments, bilingual),
|
|
SubtitleFormat::Vtt => render_vtt(segments, bilingual),
|
|
SubtitleFormat::Ass => render_ass(segments, AssStyle::default(), bilingual),
|
|
}
|
|
}
|
|
|
|
fn render_srt(segments: &[SubtitleSegment], bilingual: bool) -> String {
|
|
segments
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(index, segment)| {
|
|
format!(
|
|
"{}\n{} --> {}\n{}\n",
|
|
index + 1,
|
|
format_timestamp(segment.start, ","),
|
|
format_timestamp(segment.end, ","),
|
|
compose_subtitle_text(segment, bilingual)
|
|
)
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.join("\n")
|
|
}
|
|
|
|
fn render_vtt(segments: &[SubtitleSegment], bilingual: bool) -> String {
|
|
let body = segments
|
|
.iter()
|
|
.map(|segment| {
|
|
format!(
|
|
"{} --> {}\n{}\n",
|
|
format_timestamp(segment.start, "."),
|
|
format_timestamp(segment.end, "."),
|
|
compose_subtitle_text(segment, bilingual)
|
|
)
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.join("\n");
|
|
|
|
format!("WEBVTT\n\n{}", body)
|
|
}
|
|
|
|
fn render_ass(segments: &[SubtitleSegment], style: AssStyle, bilingual: bool) -> String {
|
|
let header = format!(
|
|
"[Script Info]\nScriptType: v4.00+\nCollisions: Normal\nPlayResX: 1280\nPlayResY: 720\n\n[V4+ Styles]\nFormat: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\nStyle: {},{},{},{},&H000000FF,{},&H64000000,0,0,0,0,100,100,0,0,1,2,1,2,32,32,24,1\n\n[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\n",
|
|
style.name, style.font_name, style.font_size, style.primary_colour, style.outline_colour
|
|
);
|
|
|
|
let body = segments
|
|
.iter()
|
|
.map(|segment| {
|
|
let text = compose_subtitle_text(segment, bilingual).replace('\n', "\\N");
|
|
format!(
|
|
"Dialogue: 0,{}, {},{},{},0,0,0,,{}",
|
|
format_ass_timestamp(segment.start),
|
|
format_ass_timestamp(segment.end),
|
|
style.name,
|
|
"",
|
|
text
|
|
)
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.join("\n");
|
|
|
|
format!("{header}{body}\n")
|
|
}
|
|
|
|
fn compose_subtitle_text(segment: &SubtitleSegment, bilingual: bool) -> String {
|
|
match (bilingual, segment.translated_text.as_deref()) {
|
|
(true, Some(translated)) if !translated.trim().is_empty() => {
|
|
format!("{}\n{}", segment.source_text.trim(), translated.trim())
|
|
}
|
|
(_, Some(translated)) if !translated.trim().is_empty() => translated.trim().to_string(),
|
|
_ => segment.source_text.trim().to_string(),
|
|
}
|
|
}
|
|
|
|
fn format_timestamp(seconds: f32, separator: &str) -> String {
|
|
let millis = (seconds * 1000.0).round() as u64;
|
|
let hours = millis / 3_600_000;
|
|
let minutes = (millis % 3_600_000) / 60_000;
|
|
let secs = (millis % 60_000) / 1_000;
|
|
let ms = millis % 1_000;
|
|
format!("{hours:02}:{minutes:02}:{secs:02}{separator}{ms:03}")
|
|
}
|
|
|
|
fn format_ass_timestamp(seconds: f32) -> String {
|
|
let centis = (seconds * 100.0).round() as u64;
|
|
let hours = centis / 360_000;
|
|
let minutes = (centis % 360_000) / 6_000;
|
|
let secs = (centis % 6_000) / 100;
|
|
let cs = centis % 100;
|
|
format!("{hours}:{minutes:02}:{secs:02}.{cs:02}")
|
|
}
|