Skip to main content

oximedia_timecode/
burn_in.rs

1//! Timecode burn-in overlay module
2//!
3//! Provides types and helpers for rendering timecode text onto video frames.
4
5#[allow(dead_code)]
6/// Visual style of the timecode burn-in
7#[derive(Debug, Clone, Copy, PartialEq)]
8pub enum BurnInStyle {
9    /// Classic broadcast style (large, bold)
10    Classic,
11    /// Modern minimalist style
12    Modern,
13    /// Minimal style (smallest footprint)
14    Minimal,
15}
16
17impl BurnInStyle {
18    /// Returns a relative font scale factor for this style
19    #[allow(clippy::cast_precision_loss)]
20    #[must_use]
21    pub fn font_scale(&self) -> f32 {
22        match self {
23            BurnInStyle::Classic => 2.0,
24            BurnInStyle::Modern => 1.5,
25            BurnInStyle::Minimal => 1.0,
26        }
27    }
28}
29
30#[allow(dead_code)]
31/// Where to anchor the timecode overlay on the frame
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum BurnInPosition {
34    /// Top-left corner
35    TopLeft,
36    /// Top-center
37    TopCenter,
38    /// Top-right corner
39    TopRight,
40    /// Bottom-left corner
41    BottomLeft,
42    /// Bottom-center
43    BottomCenter,
44    /// Bottom-right corner
45    BottomRight,
46}
47
48impl BurnInPosition {
49    /// Returns `true` if the position is in the top half of the frame
50    #[must_use]
51    pub fn is_top(&self) -> bool {
52        matches!(
53            self,
54            BurnInPosition::TopLeft | BurnInPosition::TopCenter | BurnInPosition::TopRight
55        )
56    }
57
58    /// Returns `true` if the position is on the right side of the frame
59    #[must_use]
60    pub fn is_right(&self) -> bool {
61        matches!(self, BurnInPosition::TopRight | BurnInPosition::BottomRight)
62    }
63}
64
65#[allow(dead_code)]
66/// Complete overlay specification for timecode burn-in
67#[derive(Debug, Clone)]
68pub struct TimecodeOverlay {
69    /// Visual style
70    pub style: BurnInStyle,
71    /// Position on frame
72    pub position: BurnInPosition,
73    /// Background rectangle alpha (0.0 = transparent, 1.0 = opaque)
74    pub background_alpha: f32,
75    /// Text colour as [R, G, B]
76    pub color: [u8; 3],
77}
78
79impl TimecodeOverlay {
80    /// Standard broadcast overlay: Classic style, bottom-center, semi-transparent black
81    #[must_use]
82    pub fn default_broadcast() -> Self {
83        Self {
84            style: BurnInStyle::Classic,
85            position: BurnInPosition::BottomCenter,
86            background_alpha: 0.5,
87            color: [255, 255, 255],
88        }
89    }
90
91    /// Dailies overlay: Modern style, top-left, no background
92    #[must_use]
93    pub fn default_dailies() -> Self {
94        Self {
95            style: BurnInStyle::Modern,
96            position: BurnInPosition::TopLeft,
97            background_alpha: 0.0,
98            color: [255, 255, 0],
99        }
100    }
101}
102
103/// Compute the (x, y) pixel position for a timecode string overlay.
104///
105/// The returned coordinates represent the top-left corner of the rendered text
106/// box. `tc` is expected to be a formatted timecode string such as "01:02:03:04".
107/// `frame_width` and `frame_height` are the frame dimensions in pixels.
108#[allow(clippy::cast_precision_loss)]
109#[must_use]
110pub fn render_timecode_text(
111    tc: &str,
112    frame_width: u32,
113    frame_height: u32,
114    overlay: &TimecodeOverlay,
115) -> (u32, u32) {
116    // Approximate glyph dimensions based on font scale
117    let char_w = (12.0 * overlay.style.font_scale()) as u32;
118    let char_h = (20.0 * overlay.style.font_scale()) as u32;
119    let text_w = char_w * tc.len() as u32;
120    let margin = 16u32;
121
122    let x = match overlay.position {
123        BurnInPosition::TopLeft | BurnInPosition::BottomLeft => margin,
124        BurnInPosition::TopCenter | BurnInPosition::BottomCenter => {
125            frame_width.saturating_sub(text_w) / 2
126        }
127        BurnInPosition::TopRight | BurnInPosition::BottomRight => {
128            frame_width.saturating_sub(text_w + margin)
129        }
130    };
131
132    let y = if overlay.position.is_top() {
133        margin
134    } else {
135        frame_height.saturating_sub(char_h + margin)
136    };
137
138    (x, y)
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144
145    #[test]
146    fn test_burn_in_style_classic_scale() {
147        assert!((BurnInStyle::Classic.font_scale() - 2.0).abs() < f32::EPSILON);
148    }
149
150    #[test]
151    fn test_burn_in_style_modern_scale() {
152        assert!((BurnInStyle::Modern.font_scale() - 1.5).abs() < f32::EPSILON);
153    }
154
155    #[test]
156    fn test_burn_in_style_minimal_scale() {
157        assert!((BurnInStyle::Minimal.font_scale() - 1.0).abs() < f32::EPSILON);
158    }
159
160    #[test]
161    fn test_position_is_top_true() {
162        assert!(BurnInPosition::TopLeft.is_top());
163        assert!(BurnInPosition::TopCenter.is_top());
164        assert!(BurnInPosition::TopRight.is_top());
165    }
166
167    #[test]
168    fn test_position_is_top_false() {
169        assert!(!BurnInPosition::BottomLeft.is_top());
170        assert!(!BurnInPosition::BottomCenter.is_top());
171        assert!(!BurnInPosition::BottomRight.is_top());
172    }
173
174    #[test]
175    fn test_position_is_right_true() {
176        assert!(BurnInPosition::TopRight.is_right());
177        assert!(BurnInPosition::BottomRight.is_right());
178    }
179
180    #[test]
181    fn test_position_is_right_false() {
182        assert!(!BurnInPosition::TopLeft.is_right());
183        assert!(!BurnInPosition::BottomCenter.is_right());
184    }
185
186    #[test]
187    fn test_default_broadcast_style() {
188        let o = TimecodeOverlay::default_broadcast();
189        assert_eq!(o.style, BurnInStyle::Classic);
190        assert_eq!(o.position, BurnInPosition::BottomCenter);
191        assert!(!o.position.is_top());
192    }
193
194    #[test]
195    fn test_default_dailies_style() {
196        let o = TimecodeOverlay::default_dailies();
197        assert_eq!(o.style, BurnInStyle::Modern);
198        assert_eq!(o.position, BurnInPosition::TopLeft);
199        assert!(o.position.is_top());
200    }
201
202    #[test]
203    fn test_render_timecode_text_bottom_center() {
204        let overlay = TimecodeOverlay::default_broadcast();
205        let (x, y) = render_timecode_text("01:02:03:04", 1920, 1080, &overlay);
206        // x should be roughly centered
207        assert!(x < 1920);
208        // y should be in lower portion
209        assert!(y > 1080 / 2);
210    }
211
212    #[test]
213    fn test_render_timecode_text_top_left() {
214        let overlay = TimecodeOverlay::default_dailies();
215        let (x, y) = render_timecode_text("01:02:03:04", 1920, 1080, &overlay);
216        // x should be near left margin
217        assert!(x < 100);
218        // y should be near top
219        assert!(y < 100);
220    }
221
222    #[test]
223    fn test_render_timecode_text_top_right() {
224        let overlay = TimecodeOverlay {
225            style: BurnInStyle::Minimal,
226            position: BurnInPosition::TopRight,
227            background_alpha: 0.0,
228            color: [255, 255, 255],
229        };
230        let (x, _y) = render_timecode_text("01:02:03:04", 1920, 1080, &overlay);
231        // x should be towards right side
232        assert!(x > 1920 / 2);
233    }
234}