1#![allow(dead_code)]
7#![allow(clippy::cast_precision_loss)]
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum SubtitleAnchor {
12 BottomCenter,
14 TopCenter,
16 BottomLeft,
18 BottomRight,
20 Custom(u32, u32),
22}
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum FontWeight {
27 Normal,
29 Bold,
31}
32
33#[derive(Debug, Clone)]
35pub struct SubtitleFont {
36 pub family: String,
38 pub size_px: u32,
40 pub weight: FontWeight,
42 pub italic: bool,
44 pub color: (u8, u8, u8, u8),
46 pub outline_color: (u8, u8, u8, u8),
48 pub outline_px: u32,
50}
51
52impl SubtitleFont {
53 #[must_use]
55 pub fn new(family: impl Into<String>, size_px: u32) -> Self {
56 Self {
57 family: family.into(),
58 size_px,
59 weight: FontWeight::Normal,
60 italic: false,
61 color: (255, 255, 255, 255),
62 outline_color: (0, 0, 0, 200),
63 outline_px: 2,
64 }
65 }
66
67 #[must_use]
69 pub fn with_weight(mut self, weight: FontWeight) -> Self {
70 self.weight = weight;
71 self
72 }
73
74 #[must_use]
76 pub fn italic(mut self) -> Self {
77 self.italic = true;
78 self
79 }
80
81 #[must_use]
83 pub fn with_color(mut self, r: u8, g: u8, b: u8, a: u8) -> Self {
84 self.color = (r, g, b, a);
85 self
86 }
87
88 #[must_use]
90 pub fn with_outline(mut self, r: u8, g: u8, b: u8, a: u8, thickness_px: u32) -> Self {
91 self.outline_color = (r, g, b, a);
92 self.outline_px = thickness_px;
93 self
94 }
95}
96
97impl Default for SubtitleFont {
98 fn default() -> Self {
99 Self::new("Arial", 48)
100 }
101}
102
103#[derive(Debug, Clone)]
105pub struct SubtitleEntry {
106 pub text: String,
108 pub start_ms: u64,
110 pub end_ms: u64,
112 pub anchor: SubtitleAnchor,
114 pub font: Option<SubtitleFont>,
116 pub margin_px: u32,
118}
119
120impl SubtitleEntry {
121 #[must_use]
123 pub fn new(text: impl Into<String>, start_ms: u64, end_ms: u64) -> Self {
124 Self {
125 text: text.into(),
126 start_ms,
127 end_ms,
128 anchor: SubtitleAnchor::BottomCenter,
129 font: None,
130 margin_px: 20,
131 }
132 }
133
134 #[must_use]
136 pub fn with_anchor(mut self, anchor: SubtitleAnchor) -> Self {
137 self.anchor = anchor;
138 self
139 }
140
141 #[must_use]
143 pub fn with_font(mut self, font: SubtitleFont) -> Self {
144 self.font = Some(font);
145 self
146 }
147
148 #[must_use]
150 pub fn duration_ms(&self) -> u64 {
151 self.end_ms.saturating_sub(self.start_ms)
152 }
153
154 #[must_use]
156 pub fn is_active_at(&self, timestamp_ms: u64) -> bool {
157 timestamp_ms >= self.start_ms && timestamp_ms < self.end_ms
158 }
159}
160
161#[derive(Debug, Clone)]
163pub struct BurnSubsConfig {
164 pub entries: Vec<SubtitleEntry>,
166 pub default_font: SubtitleFont,
168 pub frame_width: u32,
170 pub frame_height: u32,
172 pub shadow_enabled: bool,
174 pub antialias: bool,
176}
177
178impl BurnSubsConfig {
179 #[must_use]
181 pub fn new(frame_width: u32, frame_height: u32) -> Self {
182 Self {
183 entries: Vec::new(),
184 default_font: SubtitleFont::default(),
185 frame_width,
186 frame_height,
187 shadow_enabled: true,
188 antialias: true,
189 }
190 }
191
192 #[must_use]
194 pub fn add_entry(mut self, entry: SubtitleEntry) -> Self {
195 self.entries.push(entry);
196 self
197 }
198
199 #[must_use]
201 pub fn with_default_font(mut self, font: SubtitleFont) -> Self {
202 self.default_font = font;
203 self
204 }
205
206 #[must_use]
208 pub fn active_at(&self, timestamp_ms: u64) -> Vec<&SubtitleEntry> {
209 self.entries
210 .iter()
211 .filter(|e| e.is_active_at(timestamp_ms))
212 .collect()
213 }
214
215 #[must_use]
217 pub fn compute_position(
218 &self,
219 entry: &SubtitleEntry,
220 text_width: u32,
221 text_height: u32,
222 ) -> (u32, u32) {
223 let m = entry.margin_px;
224 let fw = self.frame_width;
225 let fh = self.frame_height;
226 match entry.anchor {
227 SubtitleAnchor::BottomCenter => {
228 let x = (fw.saturating_sub(text_width)) / 2;
229 let y = fh.saturating_sub(text_height).saturating_sub(m);
230 (x, y)
231 }
232 SubtitleAnchor::TopCenter => {
233 let x = (fw.saturating_sub(text_width)) / 2;
234 (x, m)
235 }
236 SubtitleAnchor::BottomLeft => (m, fh.saturating_sub(text_height).saturating_sub(m)),
237 SubtitleAnchor::BottomRight => {
238 let x = fw.saturating_sub(text_width).saturating_sub(m);
239 let y = fh.saturating_sub(text_height).saturating_sub(m);
240 (x, y)
241 }
242 SubtitleAnchor::Custom(cx, cy) => (cx, cy),
243 }
244 }
245
246 #[must_use]
250 pub fn estimate_text_size(&self, text: &str, font: &SubtitleFont) -> (u32, u32) {
251 let char_width = font.size_px * 6 / 10;
252 let line_height = font.size_px * 120 / 100;
253 let max_line_len = text.lines().map(|l| l.chars().count()).max().unwrap_or(0);
254 let line_count = text.lines().count().max(1);
255 (
256 char_width * max_line_len as u32,
257 line_height * line_count as u32,
258 )
259 }
260
261 #[must_use]
265 pub fn validate(&self) -> Vec<String> {
266 let mut errors = Vec::new();
267 for (i, entry) in self.entries.iter().enumerate() {
268 if entry.start_ms >= entry.end_ms {
269 errors.push(format!(
270 "Entry {i}: start_ms ({}) >= end_ms ({})",
271 entry.start_ms, entry.end_ms
272 ));
273 }
274 if entry.text.is_empty() {
275 errors.push(format!("Entry {i}: text is empty"));
276 }
277 }
278 errors
279 }
280}
281
282#[cfg(test)]
283mod tests {
284 use super::*;
285
286 #[test]
287 fn test_subtitle_entry_duration() {
288 let entry = SubtitleEntry::new("Hello", 1000, 4000);
289 assert_eq!(entry.duration_ms(), 3000);
290 }
291
292 #[test]
293 fn test_subtitle_entry_is_active() {
294 let entry = SubtitleEntry::new("Hello", 1000, 4000);
295 assert!(!entry.is_active_at(999));
296 assert!(entry.is_active_at(1000));
297 assert!(entry.is_active_at(3999));
298 assert!(!entry.is_active_at(4000));
299 }
300
301 #[test]
302 fn test_active_at_returns_correct_entries() {
303 let config = BurnSubsConfig::new(1920, 1080)
304 .add_entry(SubtitleEntry::new("A", 0, 2000))
305 .add_entry(SubtitleEntry::new("B", 1500, 4000))
306 .add_entry(SubtitleEntry::new("C", 5000, 7000));
307 let active = config.active_at(1800);
308 assert_eq!(active.len(), 2);
309 let active_late = config.active_at(6000);
310 assert_eq!(active_late.len(), 1);
311 assert_eq!(active_late[0].text, "C");
312 }
313
314 #[test]
315 fn test_position_bottom_center() {
316 let config = BurnSubsConfig::new(1920, 1080);
317 let entry = SubtitleEntry::new("Hello", 0, 1000);
318 let (x, y) = config.compute_position(&entry, 400, 60);
319 assert_eq!(x, (1920 - 400) / 2);
320 assert_eq!(y, 1080 - 60 - 20);
321 }
322
323 #[test]
324 fn test_position_top_center() {
325 let config = BurnSubsConfig::new(1920, 1080);
326 let entry = SubtitleEntry::new("Super", 0, 1000).with_anchor(SubtitleAnchor::TopCenter);
327 let (_x, y) = config.compute_position(&entry, 400, 60);
328 assert_eq!(y, 20);
329 }
330
331 #[test]
332 fn test_position_custom() {
333 let config = BurnSubsConfig::new(1920, 1080);
334 let entry =
335 SubtitleEntry::new("Custom", 0, 1000).with_anchor(SubtitleAnchor::Custom(100, 200));
336 let (x, y) = config.compute_position(&entry, 400, 60);
337 assert_eq!(x, 100);
338 assert_eq!(y, 200);
339 }
340
341 #[test]
342 fn test_estimate_text_size() {
343 let config = BurnSubsConfig::new(1920, 1080);
344 let font = SubtitleFont::new("Arial", 48);
345 let (w, h) = config.estimate_text_size("Hello", &font);
346 let char_width = 48_u32 * 6 / 10;
349 let line_height = 48_u32 * 120 / 100;
350 assert_eq!(w, 5 * char_width);
351 assert_eq!(h, line_height);
352 }
353
354 #[test]
355 fn test_estimate_text_size_multiline() {
356 let config = BurnSubsConfig::new(1920, 1080);
357 let font = SubtitleFont::new("Arial", 48);
358 let (_, h) = config.estimate_text_size("Line1\nLine2", &font);
359 let line_height = 48_u32 * 120 / 100;
361 assert_eq!(h, 2 * line_height);
362 }
363
364 #[test]
365 fn test_validate_no_errors() {
366 let config =
367 BurnSubsConfig::new(1920, 1080).add_entry(SubtitleEntry::new("Hello", 0, 1000));
368 let errors = config.validate();
369 assert!(errors.is_empty());
370 }
371
372 #[test]
373 fn test_validate_invalid_timing() {
374 let mut config = BurnSubsConfig::new(1920, 1080);
375 config.entries.push(SubtitleEntry::new("Bad", 5000, 1000));
376 let errors = config.validate();
377 assert!(!errors.is_empty());
378 assert!(errors[0].contains("start_ms"));
379 }
380
381 #[test]
382 fn test_validate_empty_text() {
383 let mut config = BurnSubsConfig::new(1920, 1080);
384 config.entries.push(SubtitleEntry::new("", 0, 1000));
385 let errors = config.validate();
386 assert!(!errors.is_empty());
387 assert!(errors[0].contains("empty"));
388 }
389
390 #[test]
391 fn test_font_defaults() {
392 let font = SubtitleFont::default();
393 assert_eq!(font.family, "Arial");
394 assert_eq!(font.size_px, 48);
395 assert_eq!(font.color, (255, 255, 255, 255));
396 }
397
398 #[test]
399 fn test_font_with_outline() {
400 let font = SubtitleFont::new("Helvetica", 40).with_outline(255, 0, 0, 255, 4);
401 assert_eq!(font.outline_color, (255, 0, 0, 255));
402 assert_eq!(font.outline_px, 4);
403 }
404
405 #[test]
406 fn test_font_italic_bold() {
407 let font = SubtitleFont::new("Arial", 48)
408 .with_weight(FontWeight::Bold)
409 .italic();
410 assert_eq!(font.weight, FontWeight::Bold);
411 assert!(font.italic);
412 }
413}