#![allow(dead_code)]
#![allow(clippy::cast_precision_loss)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SubtitleAnchor {
BottomCenter,
TopCenter,
BottomLeft,
BottomRight,
Custom(u32, u32),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FontWeight {
Normal,
Bold,
}
#[derive(Debug, Clone)]
pub struct SubtitleFont {
pub family: String,
pub size_px: u32,
pub weight: FontWeight,
pub italic: bool,
pub color: (u8, u8, u8, u8),
pub outline_color: (u8, u8, u8, u8),
pub outline_px: u32,
}
impl SubtitleFont {
#[must_use]
pub fn new(family: impl Into<String>, size_px: u32) -> Self {
Self {
family: family.into(),
size_px,
weight: FontWeight::Normal,
italic: false,
color: (255, 255, 255, 255),
outline_color: (0, 0, 0, 200),
outline_px: 2,
}
}
#[must_use]
pub fn with_weight(mut self, weight: FontWeight) -> Self {
self.weight = weight;
self
}
#[must_use]
pub fn italic(mut self) -> Self {
self.italic = true;
self
}
#[must_use]
pub fn with_color(mut self, r: u8, g: u8, b: u8, a: u8) -> Self {
self.color = (r, g, b, a);
self
}
#[must_use]
pub fn with_outline(mut self, r: u8, g: u8, b: u8, a: u8, thickness_px: u32) -> Self {
self.outline_color = (r, g, b, a);
self.outline_px = thickness_px;
self
}
}
impl Default for SubtitleFont {
fn default() -> Self {
Self::new("Arial", 48)
}
}
#[derive(Debug, Clone)]
pub struct SubtitleEntry {
pub text: String,
pub start_ms: u64,
pub end_ms: u64,
pub anchor: SubtitleAnchor,
pub font: Option<SubtitleFont>,
pub margin_px: u32,
}
impl SubtitleEntry {
#[must_use]
pub fn new(text: impl Into<String>, start_ms: u64, end_ms: u64) -> Self {
Self {
text: text.into(),
start_ms,
end_ms,
anchor: SubtitleAnchor::BottomCenter,
font: None,
margin_px: 20,
}
}
#[must_use]
pub fn with_anchor(mut self, anchor: SubtitleAnchor) -> Self {
self.anchor = anchor;
self
}
#[must_use]
pub fn with_font(mut self, font: SubtitleFont) -> Self {
self.font = Some(font);
self
}
#[must_use]
pub fn duration_ms(&self) -> u64 {
self.end_ms.saturating_sub(self.start_ms)
}
#[must_use]
pub fn is_active_at(&self, timestamp_ms: u64) -> bool {
timestamp_ms >= self.start_ms && timestamp_ms < self.end_ms
}
}
#[derive(Debug, Clone)]
pub struct BurnSubsConfig {
pub entries: Vec<SubtitleEntry>,
pub default_font: SubtitleFont,
pub frame_width: u32,
pub frame_height: u32,
pub shadow_enabled: bool,
pub antialias: bool,
}
impl BurnSubsConfig {
#[must_use]
pub fn new(frame_width: u32, frame_height: u32) -> Self {
Self {
entries: Vec::new(),
default_font: SubtitleFont::default(),
frame_width,
frame_height,
shadow_enabled: true,
antialias: true,
}
}
#[must_use]
pub fn add_entry(mut self, entry: SubtitleEntry) -> Self {
self.entries.push(entry);
self
}
#[must_use]
pub fn with_default_font(mut self, font: SubtitleFont) -> Self {
self.default_font = font;
self
}
#[must_use]
pub fn active_at(&self, timestamp_ms: u64) -> Vec<&SubtitleEntry> {
self.entries
.iter()
.filter(|e| e.is_active_at(timestamp_ms))
.collect()
}
#[must_use]
pub fn compute_position(
&self,
entry: &SubtitleEntry,
text_width: u32,
text_height: u32,
) -> (u32, u32) {
let m = entry.margin_px;
let fw = self.frame_width;
let fh = self.frame_height;
match entry.anchor {
SubtitleAnchor::BottomCenter => {
let x = (fw.saturating_sub(text_width)) / 2;
let y = fh.saturating_sub(text_height).saturating_sub(m);
(x, y)
}
SubtitleAnchor::TopCenter => {
let x = (fw.saturating_sub(text_width)) / 2;
(x, m)
}
SubtitleAnchor::BottomLeft => (m, fh.saturating_sub(text_height).saturating_sub(m)),
SubtitleAnchor::BottomRight => {
let x = fw.saturating_sub(text_width).saturating_sub(m);
let y = fh.saturating_sub(text_height).saturating_sub(m);
(x, y)
}
SubtitleAnchor::Custom(cx, cy) => (cx, cy),
}
}
#[must_use]
pub fn estimate_text_size(&self, text: &str, font: &SubtitleFont) -> (u32, u32) {
let char_width = font.size_px * 6 / 10;
let line_height = font.size_px * 120 / 100;
let max_line_len = text.lines().map(|l| l.chars().count()).max().unwrap_or(0);
let line_count = text.lines().count().max(1);
(
char_width * max_line_len as u32,
line_height * line_count as u32,
)
}
#[must_use]
pub fn validate(&self) -> Vec<String> {
let mut errors = Vec::new();
for (i, entry) in self.entries.iter().enumerate() {
if entry.start_ms >= entry.end_ms {
errors.push(format!(
"Entry {i}: start_ms ({}) >= end_ms ({})",
entry.start_ms, entry.end_ms
));
}
if entry.text.is_empty() {
errors.push(format!("Entry {i}: text is empty"));
}
}
errors
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_subtitle_entry_duration() {
let entry = SubtitleEntry::new("Hello", 1000, 4000);
assert_eq!(entry.duration_ms(), 3000);
}
#[test]
fn test_subtitle_entry_is_active() {
let entry = SubtitleEntry::new("Hello", 1000, 4000);
assert!(!entry.is_active_at(999));
assert!(entry.is_active_at(1000));
assert!(entry.is_active_at(3999));
assert!(!entry.is_active_at(4000));
}
#[test]
fn test_active_at_returns_correct_entries() {
let config = BurnSubsConfig::new(1920, 1080)
.add_entry(SubtitleEntry::new("A", 0, 2000))
.add_entry(SubtitleEntry::new("B", 1500, 4000))
.add_entry(SubtitleEntry::new("C", 5000, 7000));
let active = config.active_at(1800);
assert_eq!(active.len(), 2);
let active_late = config.active_at(6000);
assert_eq!(active_late.len(), 1);
assert_eq!(active_late[0].text, "C");
}
#[test]
fn test_position_bottom_center() {
let config = BurnSubsConfig::new(1920, 1080);
let entry = SubtitleEntry::new("Hello", 0, 1000);
let (x, y) = config.compute_position(&entry, 400, 60);
assert_eq!(x, (1920 - 400) / 2);
assert_eq!(y, 1080 - 60 - 20);
}
#[test]
fn test_position_top_center() {
let config = BurnSubsConfig::new(1920, 1080);
let entry = SubtitleEntry::new("Super", 0, 1000).with_anchor(SubtitleAnchor::TopCenter);
let (_x, y) = config.compute_position(&entry, 400, 60);
assert_eq!(y, 20);
}
#[test]
fn test_position_custom() {
let config = BurnSubsConfig::new(1920, 1080);
let entry =
SubtitleEntry::new("Custom", 0, 1000).with_anchor(SubtitleAnchor::Custom(100, 200));
let (x, y) = config.compute_position(&entry, 400, 60);
assert_eq!(x, 100);
assert_eq!(y, 200);
}
#[test]
fn test_estimate_text_size() {
let config = BurnSubsConfig::new(1920, 1080);
let font = SubtitleFont::new("Arial", 48);
let (w, h) = config.estimate_text_size("Hello", &font);
let char_width = 48_u32 * 6 / 10;
let line_height = 48_u32 * 120 / 100;
assert_eq!(w, 5 * char_width);
assert_eq!(h, line_height);
}
#[test]
fn test_estimate_text_size_multiline() {
let config = BurnSubsConfig::new(1920, 1080);
let font = SubtitleFont::new("Arial", 48);
let (_, h) = config.estimate_text_size("Line1\nLine2", &font);
let line_height = 48_u32 * 120 / 100;
assert_eq!(h, 2 * line_height);
}
#[test]
fn test_validate_no_errors() {
let config =
BurnSubsConfig::new(1920, 1080).add_entry(SubtitleEntry::new("Hello", 0, 1000));
let errors = config.validate();
assert!(errors.is_empty());
}
#[test]
fn test_validate_invalid_timing() {
let mut config = BurnSubsConfig::new(1920, 1080);
config.entries.push(SubtitleEntry::new("Bad", 5000, 1000));
let errors = config.validate();
assert!(!errors.is_empty());
assert!(errors[0].contains("start_ms"));
}
#[test]
fn test_validate_empty_text() {
let mut config = BurnSubsConfig::new(1920, 1080);
config.entries.push(SubtitleEntry::new("", 0, 1000));
let errors = config.validate();
assert!(!errors.is_empty());
assert!(errors[0].contains("empty"));
}
#[test]
fn test_font_defaults() {
let font = SubtitleFont::default();
assert_eq!(font.family, "Arial");
assert_eq!(font.size_px, 48);
assert_eq!(font.color, (255, 255, 255, 255));
}
#[test]
fn test_font_with_outline() {
let font = SubtitleFont::new("Helvetica", 40).with_outline(255, 0, 0, 255, 4);
assert_eq!(font.outline_color, (255, 0, 0, 255));
assert_eq!(font.outline_px, 4);
}
#[test]
fn test_font_italic_bold() {
let font = SubtitleFont::new("Arial", 48)
.with_weight(FontWeight::Bold)
.italic();
assert_eq!(font.weight, FontWeight::Bold);
assert!(font.italic);
}
}