Skip to main content

oximedia_transcode/
burn_subs.rs

1//! Subtitle and caption burn-in transcoding.
2//!
3//! Provides position calculation, font rasterization mocking, and
4//! subtitle timing/styling for burn-in operations.
5
6#![allow(dead_code)]
7#![allow(clippy::cast_precision_loss)]
8
9/// Subtitle position anchors on the frame.
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum SubtitleAnchor {
12    /// Bottom center (most common for subtitles).
13    BottomCenter,
14    /// Top center (for on-screen graphics / supers).
15    TopCenter,
16    /// Bottom left.
17    BottomLeft,
18    /// Bottom right.
19    BottomRight,
20    /// Custom pixel position.
21    Custom(u32, u32),
22}
23
24/// Font weight for subtitle rendering.
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum FontWeight {
27    /// Normal weight.
28    Normal,
29    /// Bold weight.
30    Bold,
31}
32
33/// A font style specification for subtitle rendering.
34#[derive(Debug, Clone)]
35pub struct SubtitleFont {
36    /// Font family name (e.g., "Arial", "Helvetica").
37    pub family: String,
38    /// Font size in pixels at the output resolution.
39    pub size_px: u32,
40    /// Font weight.
41    pub weight: FontWeight,
42    /// Whether italic is enabled.
43    pub italic: bool,
44    /// Text color as (R, G, B, A).
45    pub color: (u8, u8, u8, u8),
46    /// Outline/shadow color as (R, G, B, A).
47    pub outline_color: (u8, u8, u8, u8),
48    /// Outline thickness in pixels.
49    pub outline_px: u32,
50}
51
52impl SubtitleFont {
53    /// Creates a new subtitle font with default settings.
54    #[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    /// Sets the font weight.
68    #[must_use]
69    pub fn with_weight(mut self, weight: FontWeight) -> Self {
70        self.weight = weight;
71        self
72    }
73
74    /// Enables italic rendering.
75    #[must_use]
76    pub fn italic(mut self) -> Self {
77        self.italic = true;
78        self
79    }
80
81    /// Sets the text color.
82    #[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    /// Sets the outline color and thickness.
89    #[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/// A single subtitle entry with text, timing, and style.
104#[derive(Debug, Clone)]
105pub struct SubtitleEntry {
106    /// Subtitle text (may contain newlines for multi-line).
107    pub text: String,
108    /// Start time in milliseconds.
109    pub start_ms: u64,
110    /// End time in milliseconds.
111    pub end_ms: u64,
112    /// Position anchor.
113    pub anchor: SubtitleAnchor,
114    /// Font style override (uses config default if None).
115    pub font: Option<SubtitleFont>,
116    /// Margin from the frame edge in pixels.
117    pub margin_px: u32,
118}
119
120impl SubtitleEntry {
121    /// Creates a new subtitle entry.
122    #[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    /// Sets the position anchor.
135    #[must_use]
136    pub fn with_anchor(mut self, anchor: SubtitleAnchor) -> Self {
137        self.anchor = anchor;
138        self
139    }
140
141    /// Sets a font override for this entry.
142    #[must_use]
143    pub fn with_font(mut self, font: SubtitleFont) -> Self {
144        self.font = Some(font);
145        self
146    }
147
148    /// Returns the duration of this subtitle entry in milliseconds.
149    #[must_use]
150    pub fn duration_ms(&self) -> u64 {
151        self.end_ms.saturating_sub(self.start_ms)
152    }
153
154    /// Returns true if this subtitle is active at the given timestamp.
155    #[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/// Configuration for the subtitle burn-in operation.
162#[derive(Debug, Clone)]
163pub struct BurnSubsConfig {
164    /// All subtitle entries to burn in.
165    pub entries: Vec<SubtitleEntry>,
166    /// Default font for all entries without a font override.
167    pub default_font: SubtitleFont,
168    /// Video frame width in pixels.
169    pub frame_width: u32,
170    /// Video frame height in pixels.
171    pub frame_height: u32,
172    /// Whether to enable soft-shadow rendering.
173    pub shadow_enabled: bool,
174    /// Whether to enable anti-aliased text rendering.
175    pub antialias: bool,
176}
177
178impl BurnSubsConfig {
179    /// Creates a new burn-subs configuration.
180    #[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    /// Adds a subtitle entry.
193    #[must_use]
194    pub fn add_entry(mut self, entry: SubtitleEntry) -> Self {
195        self.entries.push(entry);
196        self
197    }
198
199    /// Sets the default font.
200    #[must_use]
201    pub fn with_default_font(mut self, font: SubtitleFont) -> Self {
202        self.default_font = font;
203        self
204    }
205
206    /// Returns all entries active at the given timestamp.
207    #[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    /// Computes the pixel position for an entry based on its anchor.
216    #[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    /// Mock font rasterization: returns estimated text dimensions.
247    ///
248    /// In a real implementation this would call a font rendering library.
249    #[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    /// Validates all subtitle entries.
262    ///
263    /// Returns a list of validation errors (empty if valid).
264    #[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        // char_width = 48 * 6 / 10 = 28 (integer truncation); 5 chars * 28 = 140
347        // line_height = 48 * 120 / 100 = 57; 1 line * 57 = 57
348        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        // line_height = 48 * 120 / 100 = 57 (integer truncation); 2 lines * 57 = 114
360        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}