crosssubtitle-ai/src-tauri/src/subtitle.rs
2026-03-18 22:14:49 +08:00

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}")
}