Skip to main content

oximedia_timecode/
timecode_overlay.rs

1//! Timecode overlay module for rendering timecode as text overlay on video frames.
2//!
3//! This module provides the ability to render timecode values as text overlays
4//! that can be composited onto video frames. It integrates with the burn-in
5//! module and supports multiple display styles, positions, and formatting.
6//!
7//! # Features
8//!
9//! - Configurable overlay position (corners, center, custom coordinates)
10//! - Multiple font size presets (small, medium, large, custom)
11//! - Background box rendering with configurable opacity
12//! - Drop-frame and non-drop-frame visual indicators
13//! - Field dominance indicators for interlaced content
14//! - Timecode + metadata (reel name, scene/take) overlay
15//!
16//! # Example
17//!
18//! ```rust
19//! use oximedia_timecode::{Timecode, FrameRate};
20//! use oximedia_timecode::timecode_overlay::{OverlayConfig, OverlayPosition, render_overlay};
21//!
22//! let tc = Timecode::new(1, 30, 0, 12, FrameRate::Fps25).expect("valid tc");
23//! let config = OverlayConfig::default();
24//! let overlay = render_overlay(&tc, &config);
25//! assert!(!overlay.text.is_empty());
26//! ```
27
28#![allow(dead_code)]
29#![allow(clippy::cast_possible_truncation)]
30
31use crate::Timecode;
32
33/// Position of the timecode overlay on the video frame.
34#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
35pub enum OverlayPosition {
36    /// Top-left corner.
37    TopLeft,
38    /// Top-center.
39    TopCenter,
40    /// Top-right corner.
41    TopRight,
42    /// Bottom-left corner.
43    BottomLeft,
44    /// Bottom-center.
45    BottomCenter,
46    /// Bottom-right corner.
47    BottomRight,
48    /// Center of the frame.
49    Center,
50    /// Custom position in pixels from top-left origin.
51    Custom { x: u32, y: u32 },
52}
53
54impl Default for OverlayPosition {
55    fn default() -> Self {
56        Self::BottomLeft
57    }
58}
59
60/// Font size preset for the overlay text.
61#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
62pub enum FontSize {
63    /// Small (suitable for monitoring).
64    Small,
65    /// Medium (general purpose).
66    Medium,
67    /// Large (visible on small screens).
68    Large,
69    /// Custom pixel height.
70    Custom(u32),
71}
72
73impl FontSize {
74    /// Return the pixel height for this font size at the given frame height.
75    #[must_use]
76    pub fn pixel_height(&self, frame_height: u32) -> u32 {
77        match self {
78            FontSize::Small => frame_height / 40,
79            FontSize::Medium => frame_height / 25,
80            FontSize::Large => frame_height / 15,
81            FontSize::Custom(h) => *h,
82        }
83    }
84}
85
86impl Default for FontSize {
87    fn default() -> Self {
88        Self::Medium
89    }
90}
91
92/// RGBA colour (0-255 per channel).
93#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
94pub struct Rgba {
95    pub r: u8,
96    pub g: u8,
97    pub b: u8,
98    pub a: u8,
99}
100
101impl Rgba {
102    /// Opaque white.
103    pub const WHITE: Self = Self {
104        r: 255,
105        g: 255,
106        b: 255,
107        a: 255,
108    };
109    /// Opaque black.
110    pub const BLACK: Self = Self {
111        r: 0,
112        g: 0,
113        b: 0,
114        a: 255,
115    };
116    /// Semi-transparent black (for background boxes).
117    pub const SEMI_BLACK: Self = Self {
118        r: 0,
119        g: 0,
120        b: 0,
121        a: 180,
122    };
123    /// Opaque red (for drop-frame indicator).
124    pub const RED: Self = Self {
125        r: 255,
126        g: 0,
127        b: 0,
128        a: 255,
129    };
130
131    /// Create a new colour.
132    #[must_use]
133    pub const fn new(r: u8, g: u8, b: u8, a: u8) -> Self {
134        Self { r, g, b, a }
135    }
136
137    /// Blend this colour over a background colour using alpha compositing.
138    #[must_use]
139    pub fn blend_over(&self, bg: &Rgba) -> Rgba {
140        let sa = self.a as u32;
141        let da = bg.a as u32;
142        let inv_sa = 255 - sa;
143
144        let out_a = sa + da * inv_sa / 255;
145        if out_a == 0 {
146            return Rgba::new(0, 0, 0, 0);
147        }
148
149        let blend = |fg: u8, bg_ch: u8| -> u8 {
150            let v = (fg as u32 * sa + bg_ch as u32 * da * inv_sa / 255) / out_a;
151            v.min(255) as u8
152        };
153
154        Rgba {
155            r: blend(self.r, bg.r),
156            g: blend(self.g, bg.g),
157            b: blend(self.b, bg.b),
158            a: out_a.min(255) as u8,
159        }
160    }
161}
162
163/// Configuration for the timecode overlay.
164#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
165pub struct OverlayConfig {
166    /// Position of the overlay on screen.
167    pub position: OverlayPosition,
168    /// Font size.
169    pub font_size: FontSize,
170    /// Foreground (text) colour.
171    pub fg_color: Rgba,
172    /// Background box colour (set alpha to 0 for no background).
173    pub bg_color: Rgba,
174    /// Margin in pixels from the edge of the frame.
175    pub margin: u32,
176    /// Whether to show a drop-frame indicator (red dot or "DF" suffix).
177    pub show_df_indicator: bool,
178    /// Whether to render a background box behind the text.
179    pub show_background: bool,
180    /// Optional prefix text (e.g., reel name or "REC").
181    pub prefix: Option<String>,
182    /// Optional suffix text (e.g., scene/take number).
183    pub suffix: Option<String>,
184    /// Whether to show field indicator (F1/F2) for interlaced content.
185    pub show_field_indicator: bool,
186    /// Current field (1 or 2) when `show_field_indicator` is true.
187    pub current_field: u8,
188}
189
190impl Default for OverlayConfig {
191    fn default() -> Self {
192        Self {
193            position: OverlayPosition::default(),
194            font_size: FontSize::default(),
195            fg_color: Rgba::WHITE,
196            bg_color: Rgba::SEMI_BLACK,
197            margin: 16,
198            show_df_indicator: true,
199            show_background: true,
200            prefix: None,
201            suffix: None,
202            show_field_indicator: false,
203            current_field: 1,
204        }
205    }
206}
207
208impl OverlayConfig {
209    /// Create a config for monitoring (small, bottom-left, semi-transparent BG).
210    #[must_use]
211    pub fn monitoring() -> Self {
212        Self {
213            font_size: FontSize::Small,
214            position: OverlayPosition::BottomLeft,
215            ..Self::default()
216        }
217    }
218
219    /// Create a config for burn-in (large, top-center, opaque BG).
220    #[must_use]
221    pub fn burn_in() -> Self {
222        Self {
223            font_size: FontSize::Large,
224            position: OverlayPosition::TopCenter,
225            bg_color: Rgba::BLACK,
226            ..Self::default()
227        }
228    }
229
230    /// Create a config with no background box.
231    #[must_use]
232    pub fn no_background() -> Self {
233        Self {
234            show_background: false,
235            bg_color: Rgba::new(0, 0, 0, 0),
236            ..Self::default()
237        }
238    }
239
240    /// Set a prefix string.
241    #[must_use]
242    pub fn with_prefix(mut self, prefix: impl Into<String>) -> Self {
243        self.prefix = Some(prefix.into());
244        self
245    }
246
247    /// Set a suffix string.
248    #[must_use]
249    pub fn with_suffix(mut self, suffix: impl Into<String>) -> Self {
250        self.suffix = Some(suffix.into());
251        self
252    }
253
254    /// Enable field indicator display.
255    #[must_use]
256    pub fn with_field(mut self, field: u8) -> Self {
257        self.show_field_indicator = true;
258        self.current_field = field.clamp(1, 2);
259        self
260    }
261}
262
263/// Rendered overlay result containing text, position info, and styling.
264#[derive(Debug, Clone)]
265pub struct RenderedOverlay {
266    /// The formatted timecode text to display.
267    pub text: String,
268    /// The overlay position.
269    pub position: OverlayPosition,
270    /// Foreground colour.
271    pub fg_color: Rgba,
272    /// Background colour.
273    pub bg_color: Rgba,
274    /// Font pixel height (for a 1080p frame).
275    pub font_height: u32,
276    /// Margin in pixels.
277    pub margin: u32,
278    /// Whether a background box should be rendered.
279    pub show_background: bool,
280    /// Approximate text width in characters.
281    pub text_char_width: usize,
282}
283
284/// Compute pixel coordinates for the overlay given frame dimensions.
285///
286/// Returns `(x, y)` coordinates for the top-left corner of the overlay text.
287#[must_use]
288pub fn compute_position(
289    position: &OverlayPosition,
290    frame_width: u32,
291    frame_height: u32,
292    text_width_px: u32,
293    text_height_px: u32,
294    margin: u32,
295) -> (u32, u32) {
296    match position {
297        OverlayPosition::TopLeft => (margin, margin),
298        OverlayPosition::TopCenter => {
299            let x = frame_width.saturating_sub(text_width_px) / 2;
300            (x, margin)
301        }
302        OverlayPosition::TopRight => {
303            let x = frame_width.saturating_sub(text_width_px + margin);
304            (x, margin)
305        }
306        OverlayPosition::BottomLeft => {
307            let y = frame_height.saturating_sub(text_height_px + margin);
308            (margin, y)
309        }
310        OverlayPosition::BottomCenter => {
311            let x = frame_width.saturating_sub(text_width_px) / 2;
312            let y = frame_height.saturating_sub(text_height_px + margin);
313            (x, y)
314        }
315        OverlayPosition::BottomRight => {
316            let x = frame_width.saturating_sub(text_width_px + margin);
317            let y = frame_height.saturating_sub(text_height_px + margin);
318            (x, y)
319        }
320        OverlayPosition::Center => {
321            let x = frame_width.saturating_sub(text_width_px) / 2;
322            let y = frame_height.saturating_sub(text_height_px) / 2;
323            (x, y)
324        }
325        OverlayPosition::Custom { x, y } => (*x, *y),
326    }
327}
328
329/// Render a timecode overlay with the given configuration.
330///
331/// This produces a [`RenderedOverlay`] containing the formatted text and
332/// all styling information needed to composite the overlay onto a video frame.
333#[must_use]
334pub fn render_overlay(tc: &Timecode, config: &OverlayConfig) -> RenderedOverlay {
335    let separator = if tc.frame_rate.drop_frame { ';' } else { ':' };
336
337    let mut text = String::new();
338
339    // Prefix
340    if let Some(ref prefix) = config.prefix {
341        text.push_str(prefix);
342        text.push(' ');
343    }
344
345    // Timecode
346    text.push_str(&format!(
347        "{:02}:{:02}:{:02}{}{:02}",
348        tc.hours, tc.minutes, tc.seconds, separator, tc.frames
349    ));
350
351    // Drop-frame indicator
352    if config.show_df_indicator && tc.frame_rate.drop_frame {
353        text.push_str(" DF");
354    }
355
356    // Field indicator
357    if config.show_field_indicator {
358        text.push_str(&format!(" F{}", config.current_field));
359    }
360
361    // Suffix
362    if let Some(ref suffix) = config.suffix {
363        text.push(' ');
364        text.push_str(suffix);
365    }
366
367    let font_height = config.font_size.pixel_height(1080);
368    let text_char_width = text.len();
369
370    RenderedOverlay {
371        text,
372        position: config.position,
373        fg_color: config.fg_color,
374        bg_color: config.bg_color,
375        font_height,
376        margin: config.margin,
377        show_background: config.show_background,
378        text_char_width,
379    }
380}
381
382/// Estimate the pixel width of the overlay text for a monospaced font.
383///
384/// Assumes each character is approximately 0.6x the font height.
385#[must_use]
386pub fn estimate_text_width(text_len: usize, font_height: u32) -> u32 {
387    let char_width = (font_height as f64 * 0.6).ceil() as u32;
388    text_len as u32 * char_width
389}
390
391/// Render a background box behind the overlay text.
392///
393/// Returns `(x, y, width, height)` of the background rectangle with padding.
394#[must_use]
395pub fn background_rect(
396    text_x: u32,
397    text_y: u32,
398    text_width_px: u32,
399    font_height: u32,
400    padding: u32,
401) -> (u32, u32, u32, u32) {
402    let x = text_x.saturating_sub(padding);
403    let y = text_y.saturating_sub(padding);
404    let w = text_width_px + padding * 2;
405    let h = font_height + padding * 2;
406    (x, y, w, h)
407}
408
409/// Batch render overlays for a sequence of timecodes.
410///
411/// Useful for pre-computing overlay data for an entire timeline segment.
412#[must_use]
413pub fn render_batch(timecodes: &[Timecode], config: &OverlayConfig) -> Vec<RenderedOverlay> {
414    timecodes
415        .iter()
416        .map(|tc| render_overlay(tc, config))
417        .collect()
418}
419
420/// A timecode overlay layer that can stamp successive frames.
421///
422/// Holds configuration and provides a stateful interface for frame-by-frame
423/// overlay rendering with optional automatic timecode advancement.
424#[derive(Debug, Clone)]
425pub struct OverlayStamper {
426    config: OverlayConfig,
427    frame_width: u32,
428    frame_height: u32,
429}
430
431impl OverlayStamper {
432    /// Create a new overlay stamper for a given frame size.
433    #[must_use]
434    pub fn new(config: OverlayConfig, frame_width: u32, frame_height: u32) -> Self {
435        Self {
436            config,
437            frame_width,
438            frame_height,
439        }
440    }
441
442    /// Render overlay for a single frame and return pixel coordinates + text.
443    #[must_use]
444    pub fn stamp(&self, tc: &Timecode) -> (u32, u32, RenderedOverlay) {
445        let overlay = render_overlay(tc, &self.config);
446        let text_width = estimate_text_width(overlay.text_char_width, overlay.font_height);
447        let (x, y) = compute_position(
448            &overlay.position,
449            self.frame_width,
450            self.frame_height,
451            text_width,
452            overlay.font_height,
453            overlay.margin,
454        );
455        (x, y, overlay)
456    }
457
458    /// Get the frame dimensions.
459    #[must_use]
460    pub fn frame_size(&self) -> (u32, u32) {
461        (self.frame_width, self.frame_height)
462    }
463
464    /// Get a reference to the configuration.
465    #[must_use]
466    pub fn config(&self) -> &OverlayConfig {
467        &self.config
468    }
469}
470
471#[cfg(test)]
472mod tests {
473    use super::*;
474    use crate::FrameRate;
475
476    fn tc25(h: u8, m: u8, s: u8, f: u8) -> Timecode {
477        Timecode::new(h, m, s, f, FrameRate::Fps25).expect("valid tc")
478    }
479
480    fn tc_df(h: u8, m: u8, s: u8, f: u8) -> Timecode {
481        Timecode::new(h, m, s, f, FrameRate::Fps2997DF).expect("valid tc")
482    }
483
484    #[test]
485    fn test_render_overlay_basic() {
486        let tc = tc25(1, 30, 0, 12);
487        let config = OverlayConfig::default();
488        let overlay = render_overlay(&tc, &config);
489        assert_eq!(overlay.text, "01:30:00:12");
490        assert_eq!(overlay.position, OverlayPosition::BottomLeft);
491    }
492
493    #[test]
494    fn test_render_overlay_drop_frame_indicator() {
495        let tc = tc_df(0, 1, 0, 2);
496        let config = OverlayConfig::default();
497        let overlay = render_overlay(&tc, &config);
498        assert!(overlay.text.contains(';'));
499        assert!(overlay.text.contains("DF"));
500    }
501
502    #[test]
503    fn test_render_overlay_no_df_indicator() {
504        let tc = tc_df(0, 1, 0, 2);
505        let mut config = OverlayConfig::default();
506        config.show_df_indicator = false;
507        let overlay = render_overlay(&tc, &config);
508        assert!(!overlay.text.contains("DF"));
509    }
510
511    #[test]
512    fn test_render_overlay_with_prefix_and_suffix() {
513        let tc = tc25(0, 0, 0, 0);
514        let config = OverlayConfig::default()
515            .with_prefix("REC")
516            .with_suffix("SC1/TK3");
517        let overlay = render_overlay(&tc, &config);
518        assert!(overlay.text.starts_with("REC "));
519        assert!(overlay.text.ends_with("SC1/TK3"));
520    }
521
522    #[test]
523    fn test_render_overlay_with_field_indicator() {
524        let tc = tc25(0, 0, 0, 0);
525        let config = OverlayConfig::default().with_field(2);
526        let overlay = render_overlay(&tc, &config);
527        assert!(overlay.text.contains("F2"));
528    }
529
530    #[test]
531    fn test_compute_position_corners() {
532        let (x, y) = compute_position(&OverlayPosition::TopLeft, 1920, 1080, 200, 40, 16);
533        assert_eq!(x, 16);
534        assert_eq!(y, 16);
535
536        let (x, y) = compute_position(&OverlayPosition::BottomRight, 1920, 1080, 200, 40, 16);
537        assert_eq!(x, 1920 - 200 - 16);
538        assert_eq!(y, 1080 - 40 - 16);
539    }
540
541    #[test]
542    fn test_compute_position_center() {
543        let (x, y) = compute_position(&OverlayPosition::Center, 1920, 1080, 200, 40, 16);
544        assert_eq!(x, (1920 - 200) / 2);
545        assert_eq!(y, (1080 - 40) / 2);
546    }
547
548    #[test]
549    fn test_compute_position_custom() {
550        let (x, y) = compute_position(
551            &OverlayPosition::Custom { x: 100, y: 200 },
552            1920,
553            1080,
554            200,
555            40,
556            16,
557        );
558        assert_eq!(x, 100);
559        assert_eq!(y, 200);
560    }
561
562    #[test]
563    fn test_font_size_pixel_height() {
564        assert_eq!(FontSize::Small.pixel_height(1080), 27);
565        assert_eq!(FontSize::Medium.pixel_height(1080), 43);
566        assert_eq!(FontSize::Large.pixel_height(1080), 72);
567        assert_eq!(FontSize::Custom(50).pixel_height(1080), 50);
568    }
569
570    #[test]
571    fn test_estimate_text_width() {
572        let w = estimate_text_width(11, 40); // "01:30:00:12" = 11 chars
573        assert_eq!(w, 11 * 24); // 0.6 * 40 = 24
574    }
575
576    #[test]
577    fn test_background_rect() {
578        let (x, y, w, h) = background_rect(100, 200, 264, 40, 8);
579        assert_eq!(x, 92);
580        assert_eq!(y, 192);
581        assert_eq!(w, 280);
582        assert_eq!(h, 56);
583    }
584
585    #[test]
586    fn test_render_batch() {
587        let tcs = vec![tc25(0, 0, 0, 0), tc25(0, 0, 0, 1), tc25(0, 0, 0, 2)];
588        let config = OverlayConfig::default();
589        let overlays = render_batch(&tcs, &config);
590        assert_eq!(overlays.len(), 3);
591        assert!(overlays[1].text.contains("01"));
592    }
593
594    #[test]
595    fn test_overlay_stamper() {
596        let config = OverlayConfig::monitoring();
597        let stamper = OverlayStamper::new(config, 1920, 1080);
598        let tc = tc25(12, 0, 0, 0);
599        let (x, y, overlay) = stamper.stamp(&tc);
600        assert!(x < 1920);
601        assert!(y < 1080);
602        assert!(overlay.text.contains("12:00:00:00"));
603        assert_eq!(stamper.frame_size(), (1920, 1080));
604    }
605
606    #[test]
607    fn test_rgba_blend_over() {
608        let fg = Rgba::new(255, 0, 0, 128);
609        let bg = Rgba::WHITE;
610        let result = fg.blend_over(&bg);
611        // Partially red over white should be pinkish
612        assert!(result.r > result.g);
613        assert_eq!(result.a, 255);
614    }
615
616    #[test]
617    fn test_monitoring_preset() {
618        let config = OverlayConfig::monitoring();
619        assert_eq!(config.font_size, FontSize::Small);
620        assert_eq!(config.position, OverlayPosition::BottomLeft);
621    }
622
623    #[test]
624    fn test_burn_in_preset() {
625        let config = OverlayConfig::burn_in();
626        assert_eq!(config.font_size, FontSize::Large);
627        assert_eq!(config.position, OverlayPosition::TopCenter);
628        assert_eq!(config.bg_color, Rgba::BLACK);
629    }
630}