Skip to main content

oximedia_edit/
title_overlay.rs

1//! Title and text overlay generation for timeline editing.
2//!
3//! Renders styled text overlays onto video frames using a built-in
4//! pixel-font rasteriser (no external font libraries required).
5//! The font uses a 5×7 bitmap glyph table for ASCII printable characters.
6
7#![allow(dead_code)]
8
9use std::collections::HashMap;
10
11// ─────────────────────────────────────────────────────────────────────────────
12// Glyph bitmap table (5 columns × 7 rows per glyph, ASCII 32–126)
13// ─────────────────────────────────────────────────────────────────────────────
14
15/// 5×7 pixel bitmap for ASCII printable characters (space = 0x20 through '~' = 0x7E).
16/// Each glyph is stored as 7 rows of 5 bits packed into a `[u8; 7]`.
17/// Bit 4 (MSB of the low nibble) is the leftmost pixel.
18const FONT5X7: [[u8; 7]; 95] = [
19    // Space (0x20)
20    [0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00],
21    // ! (0x21)
22    [0x04, 0x04, 0x04, 0x04, 0x00, 0x04, 0x00],
23    // " (0x22)
24    [0x0A, 0x0A, 0x00, 0x00, 0x00, 0x00, 0x00],
25    // # (0x23)
26    [0x0A, 0x1F, 0x0A, 0x0A, 0x1F, 0x0A, 0x00],
27    // $ (0x24)
28    [0x04, 0x0F, 0x14, 0x0E, 0x05, 0x1E, 0x04],
29    // % (0x25)
30    [0x18, 0x19, 0x02, 0x04, 0x13, 0x03, 0x00],
31    // & (0x26)
32    [0x08, 0x14, 0x08, 0x15, 0x12, 0x0D, 0x00],
33    // ' (0x27)
34    [0x04, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00],
35    // ( (0x28)
36    [0x02, 0x04, 0x08, 0x08, 0x08, 0x04, 0x02],
37    // ) (0x29)
38    [0x08, 0x04, 0x02, 0x02, 0x02, 0x04, 0x08],
39    // * (0x2A)
40    [0x00, 0x04, 0x15, 0x0E, 0x15, 0x04, 0x00],
41    // + (0x2B)
42    [0x00, 0x04, 0x04, 0x1F, 0x04, 0x04, 0x00],
43    // , (0x2C)
44    [0x00, 0x00, 0x00, 0x00, 0x04, 0x04, 0x08],
45    // - (0x2D)
46    [0x00, 0x00, 0x00, 0x1F, 0x00, 0x00, 0x00],
47    // . (0x2E)
48    [0x00, 0x00, 0x00, 0x00, 0x00, 0x04, 0x00],
49    // / (0x2F)
50    [0x01, 0x02, 0x02, 0x04, 0x08, 0x10, 0x00],
51    // 0 (0x30)
52    [0x0E, 0x11, 0x13, 0x15, 0x19, 0x11, 0x0E],
53    // 1 (0x31)
54    [0x04, 0x0C, 0x04, 0x04, 0x04, 0x04, 0x0E],
55    // 2 (0x32)
56    [0x0E, 0x11, 0x01, 0x02, 0x04, 0x08, 0x1F],
57    // 3 (0x33)
58    [0x1F, 0x02, 0x04, 0x02, 0x01, 0x11, 0x0E],
59    // 4 (0x34)
60    [0x02, 0x06, 0x0A, 0x12, 0x1F, 0x02, 0x02],
61    // 5 (0x35)
62    [0x1F, 0x10, 0x1E, 0x01, 0x01, 0x11, 0x0E],
63    // 6 (0x36)
64    [0x06, 0x08, 0x10, 0x1E, 0x11, 0x11, 0x0E],
65    // 7 (0x37)
66    [0x1F, 0x01, 0x02, 0x04, 0x08, 0x08, 0x08],
67    // 8 (0x38)
68    [0x0E, 0x11, 0x11, 0x0E, 0x11, 0x11, 0x0E],
69    // 9 (0x39)
70    [0x0E, 0x11, 0x11, 0x0F, 0x01, 0x02, 0x0C],
71    // : (0x3A)
72    [0x00, 0x04, 0x00, 0x00, 0x04, 0x00, 0x00],
73    // ; (0x3B)
74    [0x00, 0x04, 0x00, 0x00, 0x04, 0x04, 0x08],
75    // < (0x3C)
76    [0x02, 0x04, 0x08, 0x10, 0x08, 0x04, 0x02],
77    // = (0x3D)
78    [0x00, 0x00, 0x1F, 0x00, 0x1F, 0x00, 0x00],
79    // > (0x3E)
80    [0x10, 0x08, 0x04, 0x02, 0x04, 0x08, 0x10],
81    // ? (0x3F)
82    [0x0E, 0x11, 0x01, 0x02, 0x04, 0x00, 0x04],
83    // @ (0x40)
84    [0x0E, 0x11, 0x01, 0x0D, 0x15, 0x15, 0x0E],
85    // A (0x41)
86    [0x0E, 0x11, 0x11, 0x1F, 0x11, 0x11, 0x11],
87    // B (0x42)
88    [0x1E, 0x11, 0x11, 0x1E, 0x11, 0x11, 0x1E],
89    // C (0x43)
90    [0x0E, 0x11, 0x10, 0x10, 0x10, 0x11, 0x0E],
91    // D (0x44)
92    [0x1C, 0x12, 0x11, 0x11, 0x11, 0x12, 0x1C],
93    // E (0x45)
94    [0x1F, 0x10, 0x10, 0x1E, 0x10, 0x10, 0x1F],
95    // F (0x46)
96    [0x1F, 0x10, 0x10, 0x1E, 0x10, 0x10, 0x10],
97    // G (0x47)
98    [0x0E, 0x11, 0x10, 0x17, 0x11, 0x11, 0x0F],
99    // H (0x48)
100    [0x11, 0x11, 0x11, 0x1F, 0x11, 0x11, 0x11],
101    // I (0x49)
102    [0x0E, 0x04, 0x04, 0x04, 0x04, 0x04, 0x0E],
103    // J (0x4A)
104    [0x07, 0x02, 0x02, 0x02, 0x02, 0x12, 0x0C],
105    // K (0x4B)
106    [0x11, 0x12, 0x14, 0x18, 0x14, 0x12, 0x11],
107    // L (0x4C)
108    [0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x1F],
109    // M (0x4D)
110    [0x11, 0x1B, 0x15, 0x15, 0x11, 0x11, 0x11],
111    // N (0x4E)
112    [0x11, 0x11, 0x19, 0x15, 0x13, 0x11, 0x11],
113    // O (0x4F)
114    [0x0E, 0x11, 0x11, 0x11, 0x11, 0x11, 0x0E],
115    // P (0x50)
116    [0x1E, 0x11, 0x11, 0x1E, 0x10, 0x10, 0x10],
117    // Q (0x51)
118    [0x0E, 0x11, 0x11, 0x11, 0x15, 0x12, 0x0D],
119    // R (0x52)
120    [0x1E, 0x11, 0x11, 0x1E, 0x14, 0x12, 0x11],
121    // S (0x53)
122    [0x0F, 0x10, 0x10, 0x0E, 0x01, 0x01, 0x1E],
123    // T (0x54)
124    [0x1F, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04],
125    // U (0x55)
126    [0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x0E],
127    // V (0x56)
128    [0x11, 0x11, 0x11, 0x11, 0x11, 0x0A, 0x04],
129    // W (0x57)
130    [0x11, 0x11, 0x11, 0x15, 0x15, 0x1B, 0x11],
131    // X (0x58)
132    [0x11, 0x11, 0x0A, 0x04, 0x0A, 0x11, 0x11],
133    // Y (0x59)
134    [0x11, 0x11, 0x0A, 0x04, 0x04, 0x04, 0x04],
135    // Z (0x5A)
136    [0x1F, 0x01, 0x02, 0x04, 0x08, 0x10, 0x1F],
137    // [ (0x5B)
138    [0x0E, 0x08, 0x08, 0x08, 0x08, 0x08, 0x0E],
139    // \ (0x5C)
140    [0x10, 0x08, 0x08, 0x04, 0x02, 0x01, 0x00],
141    // ] (0x5D)
142    [0x0E, 0x02, 0x02, 0x02, 0x02, 0x02, 0x0E],
143    // ^ (0x5E)
144    [0x04, 0x0A, 0x11, 0x00, 0x00, 0x00, 0x00],
145    // _ (0x5F)
146    [0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x1F],
147    // ` (0x60)
148    [0x08, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00],
149    // a (0x61)
150    [0x00, 0x00, 0x0E, 0x01, 0x0F, 0x11, 0x0F],
151    // b (0x62)
152    [0x10, 0x10, 0x1E, 0x11, 0x11, 0x11, 0x1E],
153    // c (0x63)
154    [0x00, 0x00, 0x0E, 0x10, 0x10, 0x10, 0x0E],
155    // d (0x64)
156    [0x01, 0x01, 0x0F, 0x11, 0x11, 0x11, 0x0F],
157    // e (0x65)
158    [0x00, 0x00, 0x0E, 0x11, 0x1F, 0x10, 0x0E],
159    // f (0x66)
160    [0x06, 0x09, 0x08, 0x1C, 0x08, 0x08, 0x08],
161    // g (0x67)
162    [0x00, 0x00, 0x0F, 0x11, 0x0F, 0x01, 0x0E],
163    // h (0x68)
164    [0x10, 0x10, 0x16, 0x19, 0x11, 0x11, 0x11],
165    // i (0x69)
166    [0x04, 0x00, 0x0C, 0x04, 0x04, 0x04, 0x0E],
167    // j (0x6A)
168    [0x02, 0x00, 0x06, 0x02, 0x02, 0x12, 0x0C],
169    // k (0x6B)
170    [0x10, 0x10, 0x12, 0x14, 0x18, 0x14, 0x12],
171    // l (0x6C)
172    [0x0C, 0x04, 0x04, 0x04, 0x04, 0x04, 0x0E],
173    // m (0x6D)
174    [0x00, 0x00, 0x1A, 0x15, 0x15, 0x11, 0x11],
175    // n (0x6E)
176    [0x00, 0x00, 0x16, 0x19, 0x11, 0x11, 0x11],
177    // o (0x6F)
178    [0x00, 0x00, 0x0E, 0x11, 0x11, 0x11, 0x0E],
179    // p (0x70)
180    [0x00, 0x00, 0x1E, 0x11, 0x1E, 0x10, 0x10],
181    // q (0x71)
182    [0x00, 0x00, 0x0F, 0x11, 0x0F, 0x01, 0x01],
183    // r (0x72)
184    [0x00, 0x00, 0x16, 0x19, 0x10, 0x10, 0x10],
185    // s (0x73)
186    [0x00, 0x00, 0x0E, 0x10, 0x0E, 0x01, 0x1E],
187    // t (0x74)
188    [0x08, 0x08, 0x1C, 0x08, 0x08, 0x09, 0x06],
189    // u (0x75)
190    [0x00, 0x00, 0x11, 0x11, 0x11, 0x13, 0x0D],
191    // v (0x76)
192    [0x00, 0x00, 0x11, 0x11, 0x11, 0x0A, 0x04],
193    // w (0x77)
194    [0x00, 0x00, 0x11, 0x11, 0x15, 0x15, 0x0A],
195    // x (0x78)
196    [0x00, 0x00, 0x11, 0x0A, 0x04, 0x0A, 0x11],
197    // y (0x79)
198    [0x00, 0x00, 0x11, 0x11, 0x0F, 0x01, 0x0E],
199    // z (0x7A)
200    [0x00, 0x00, 0x1F, 0x02, 0x04, 0x08, 0x1F],
201    // { (0x7B)
202    [0x03, 0x04, 0x04, 0x18, 0x04, 0x04, 0x03],
203    // | (0x7C)
204    [0x04, 0x04, 0x04, 0x00, 0x04, 0x04, 0x04],
205    // } (0x7D)
206    [0x18, 0x04, 0x04, 0x03, 0x04, 0x04, 0x18],
207    // ~ (0x7E)
208    [0x00, 0x08, 0x15, 0x02, 0x00, 0x00, 0x00],
209];
210
211/// Width of a single glyph in pixels.
212pub const GLYPH_W: u32 = 5;
213/// Height of a single glyph in pixels.
214pub const GLYPH_H: u32 = 7;
215/// Gap between glyphs in pixels.
216pub const GLYPH_GAP: u32 = 1;
217
218/// Return the 5×7 bitmap for `ch`, or the bitmap for '?' if out of range.
219fn glyph(ch: char) -> [u8; 7] {
220    let code = ch as u32;
221    if code >= 0x20 && code <= 0x7E {
222        FONT5X7[(code - 0x20) as usize]
223    } else {
224        FONT5X7[('?' as u32 - 0x20) as usize]
225    }
226}
227
228/// Returns `true` if pixel `(col, row)` in a 5×7 glyph bitmap is set.
229/// `col` is in `0..5`, `row` is in `0..7`.
230fn glyph_pixel(bitmap: [u8; 7], col: u32, row: u32) -> bool {
231    if col >= GLYPH_W || row >= GLYPH_H {
232        return false;
233    }
234    // Bit 4 is leftmost; bit 0 is rightmost.
235    (bitmap[row as usize] >> (4 - col)) & 1 == 1
236}
237
238// ─────────────────────────────────────────────────────────────────────────────
239// Text style
240// ─────────────────────────────────────────────────────────────────────────────
241
242/// RGBA colour (each component 0–255).
243#[derive(Debug, Clone, Copy, PartialEq, Eq)]
244pub struct Rgba {
245    /// Red channel.
246    pub r: u8,
247    /// Green channel.
248    pub g: u8,
249    /// Blue channel.
250    pub b: u8,
251    /// Alpha channel (0 = transparent, 255 = opaque).
252    pub a: u8,
253}
254
255impl Rgba {
256    /// Construct an RGBA colour.
257    #[must_use]
258    pub const fn new(r: u8, g: u8, b: u8, a: u8) -> Self {
259        Self { r, g, b, a }
260    }
261
262    /// Opaque white.
263    #[must_use]
264    pub const fn white() -> Self {
265        Self::new(255, 255, 255, 255)
266    }
267
268    /// Opaque black.
269    #[must_use]
270    pub const fn black() -> Self {
271        Self::new(0, 0, 0, 255)
272    }
273
274    /// Transparent.
275    #[must_use]
276    pub const fn transparent() -> Self {
277        Self::new(0, 0, 0, 0)
278    }
279}
280
281/// Horizontal alignment for text within the overlay bounding box.
282#[derive(Debug, Clone, Copy, PartialEq, Eq)]
283pub enum HAlign {
284    /// Left-aligned.
285    Left,
286    /// Centred.
287    Center,
288    /// Right-aligned.
289    Right,
290}
291
292/// Vertical alignment for text within the overlay bounding box.
293#[derive(Debug, Clone, Copy, PartialEq, Eq)]
294pub enum VAlign {
295    /// Top-aligned.
296    Top,
297    /// Vertically centred.
298    Middle,
299    /// Bottom-aligned.
300    Bottom,
301}
302
303/// Scale factor for the built-in pixel font.
304///
305/// Each glyph pixel is rendered as an `n×n` block.
306#[derive(Debug, Clone, Copy, PartialEq, Eq)]
307pub enum FontScale {
308    /// 1× (original 5×7 pixels).
309    One,
310    /// 2× (10×14 pixels per glyph).
311    Two,
312    /// 3× (15×21 pixels per glyph).
313    Three,
314    /// 4× (20×28 pixels per glyph).
315    Four,
316}
317
318impl FontScale {
319    /// Pixel multiplier.
320    #[must_use]
321    pub fn factor(self) -> u32 {
322        match self {
323            Self::One => 1,
324            Self::Two => 2,
325            Self::Three => 3,
326            Self::Four => 4,
327        }
328    }
329}
330
331/// Full style description for an overlay.
332#[derive(Debug, Clone)]
333pub struct TextStyle {
334    /// Foreground (text) colour.
335    pub color: Rgba,
336    /// Background colour (drawn behind the bounding box if `Some`).
337    pub background: Option<Rgba>,
338    /// Drop-shadow colour (drawn 1 pixel down-right if `Some`).
339    pub shadow: Option<Rgba>,
340    /// Font scale.
341    pub scale: FontScale,
342    /// Horizontal text alignment within the bounding box.
343    pub halign: HAlign,
344    /// Vertical text alignment within the bounding box.
345    pub valign: VAlign,
346    /// Padding in pixels added around the text.
347    pub padding: u32,
348}
349
350impl Default for TextStyle {
351    fn default() -> Self {
352        Self {
353            color: Rgba::white(),
354            background: None,
355            shadow: Some(Rgba::new(0, 0, 0, 180)),
356            scale: FontScale::Two,
357            halign: HAlign::Center,
358            valign: VAlign::Bottom,
359            padding: 8,
360        }
361    }
362}
363
364// ─────────────────────────────────────────────────────────────────────────────
365// TitleOverlay
366// ─────────────────────────────────────────────────────────────────────────────
367
368/// Identifier for a title overlay.
369pub type OverlayId = u64;
370
371/// A single title/text element that can be composited over a video frame.
372///
373/// The overlay stores the text content, position, style, and optional
374/// keyframes for animated position or opacity.
375#[derive(Debug, Clone)]
376pub struct TitleOverlay {
377    /// Unique overlay identifier.
378    pub id: OverlayId,
379    /// Text to render.
380    pub text: String,
381    /// Top-left X position of the bounding box in pixels.
382    pub x: i32,
383    /// Top-left Y position of the bounding box in pixels.
384    pub y: i32,
385    /// Bounding box width (0 = auto-size).
386    pub width: u32,
387    /// Bounding box height (0 = auto-size).
388    pub height: u32,
389    /// Visual style.
390    pub style: TextStyle,
391    /// Timeline start position (in timebase units).
392    pub start: i64,
393    /// Timeline end position (in timebase units).
394    pub end: i64,
395    /// Opacity (0.0–1.0), applied on top of the style colour alpha.
396    pub opacity: f32,
397    /// Position keyframes: `(timeline_pos, x, y)` sorted by position.
398    pub position_keyframes: Vec<(i64, i32, i32)>,
399}
400
401impl TitleOverlay {
402    /// Create a new overlay with default style.
403    #[must_use]
404    pub fn new(
405        id: OverlayId,
406        text: impl Into<String>,
407        x: i32,
408        y: i32,
409        start: i64,
410        end: i64,
411    ) -> Self {
412        Self {
413            id,
414            text: text.into(),
415            x,
416            y,
417            width: 0,
418            height: 0,
419            style: TextStyle::default(),
420            start,
421            end,
422            opacity: 1.0,
423            position_keyframes: Vec::new(),
424        }
425    }
426
427    /// Builder: set bounding box dimensions.
428    #[must_use]
429    pub fn with_dimensions(mut self, width: u32, height: u32) -> Self {
430        self.width = width;
431        self.height = height;
432        self
433    }
434
435    /// Builder: set style.
436    #[must_use]
437    pub fn with_style(mut self, style: TextStyle) -> Self {
438        self.style = style;
439        self
440    }
441
442    /// Builder: set opacity.
443    #[must_use]
444    pub fn with_opacity(mut self, opacity: f32) -> Self {
445        self.opacity = opacity.clamp(0.0, 1.0);
446        self
447    }
448
449    /// Add a position keyframe.
450    pub fn add_position_keyframe(&mut self, time: i64, x: i32, y: i32) {
451        self.position_keyframes.push((time, x, y));
452        self.position_keyframes.sort_by_key(|&(t, _, _)| t);
453    }
454
455    /// Compute interpolated `(x, y)` at `time` using the keyframe list.
456    ///
457    /// If there are no keyframes the overlay's static `x`/`y` are returned.
458    /// If `time` is before the first keyframe, the first keyframe's position
459    /// is returned.  If after the last, the last keyframe's position is returned.
460    #[allow(clippy::cast_precision_loss)]
461    #[must_use]
462    pub fn position_at(&self, time: i64) -> (i32, i32) {
463        if self.position_keyframes.is_empty() {
464            return (self.x, self.y);
465        }
466        let kf = &self.position_keyframes;
467        if time <= kf[0].0 {
468            return (kf[0].1, kf[0].2);
469        }
470        let last = kf[kf.len() - 1];
471        if time >= last.0 {
472            return (last.1, last.2);
473        }
474        // Linear interpolation between surrounding keyframes.
475        let idx = kf.partition_point(|&(t, _, _)| t <= time) - 1;
476        let (t0, x0, y0) = kf[idx];
477        let (t1, x1, y1) = kf[idx + 1];
478        let span = (t1 - t0) as f64;
479        let alpha = if span > 0.0 {
480            (time - t0) as f64 / span
481        } else {
482            0.0
483        };
484        let ix = (x0 as f64 + alpha * (x1 - x0) as f64).round() as i32;
485        let iy = (y0 as f64 + alpha * (y1 - y0) as f64).round() as i32;
486        (ix, iy)
487    }
488
489    /// Returns `true` if the overlay is active at `time`.
490    #[must_use]
491    pub fn is_active_at(&self, time: i64) -> bool {
492        time >= self.start && time < self.end
493    }
494
495    /// Compute the rendered text width in pixels (before scale).
496    #[must_use]
497    pub fn text_width_px(&self) -> u32 {
498        let n = self.text.chars().count() as u32;
499        if n == 0 {
500            0
501        } else {
502            n * (GLYPH_W + GLYPH_GAP) - GLYPH_GAP
503        }
504    }
505
506    /// Compute the rendered text height in pixels (always `GLYPH_H`).
507    #[must_use]
508    pub fn text_height_px(&self) -> u32 {
509        GLYPH_H
510    }
511}
512
513// ─────────────────────────────────────────────────────────────────────────────
514// OverlayRenderer
515// ─────────────────────────────────────────────────────────────────────────────
516
517/// Renders [`TitleOverlay`]s into an RGBA pixel buffer.
518///
519/// The buffer is row-major with 4 bytes per pixel (R, G, B, A).
520/// (0, 0) is the top-left corner.
521pub struct OverlayRenderer {
522    /// Target frame width in pixels.
523    pub frame_width: u32,
524    /// Target frame height in pixels.
525    pub frame_height: u32,
526}
527
528impl OverlayRenderer {
529    /// Create a renderer for frames of the given dimensions.
530    #[must_use]
531    pub fn new(frame_width: u32, frame_height: u32) -> Self {
532        Self {
533            frame_width,
534            frame_height,
535        }
536    }
537
538    /// Composite `overlay` onto the RGBA `buffer` at `time`.
539    ///
540    /// `buffer` must have length `frame_width * frame_height * 4`.
541    /// Nothing is written when the overlay is not active at `time`.
542    pub fn composite(&self, buffer: &mut [u8], overlay: &TitleOverlay, time: i64) {
543        if !overlay.is_active_at(time) {
544            return;
545        }
546        let scale = overlay.style.scale.factor();
547        let glyph_w_s = (GLYPH_W + GLYPH_GAP) * scale;
548        let glyph_h_s = GLYPH_H * scale;
549        let text_w = overlay.text_width_px() * scale;
550        let text_h = glyph_h_s;
551
552        let (ox, oy) = overlay.position_at(time);
553        let padding = overlay.style.padding;
554
555        let bbox_w = if overlay.width > 0 {
556            overlay.width
557        } else {
558            text_w + padding * 2
559        };
560        let bbox_h = if overlay.height > 0 {
561            overlay.height
562        } else {
563            text_h + padding * 2
564        };
565
566        // Draw background if requested.
567        if let Some(bg) = overlay.style.background {
568            self.fill_rect(buffer, ox, oy, bbox_w, bbox_h, bg, overlay.opacity);
569        }
570
571        // Compute text start position within bounding box.
572        let text_start_x = match overlay.style.halign {
573            HAlign::Left => ox + padding as i32,
574            HAlign::Center => ox + (bbox_w.saturating_sub(text_w) / 2) as i32,
575            HAlign::Right => ox + bbox_w as i32 - text_w as i32 - padding as i32,
576        };
577        let text_start_y = match overlay.style.valign {
578            VAlign::Top => oy + padding as i32,
579            VAlign::Middle => oy + (bbox_h.saturating_sub(text_h) / 2) as i32,
580            VAlign::Bottom => oy + bbox_h as i32 - text_h as i32 - padding as i32,
581        };
582
583        // Render each character.
584        let mut cursor_x = text_start_x;
585        for ch in overlay.text.chars() {
586            let bitmap = glyph(ch);
587            // Shadow.
588            if let Some(shadow) = overlay.style.shadow {
589                self.draw_glyph(
590                    buffer,
591                    bitmap,
592                    cursor_x + 1,
593                    text_start_y + 1,
594                    scale,
595                    shadow,
596                    overlay.opacity,
597                );
598            }
599            // Foreground.
600            self.draw_glyph(
601                buffer,
602                bitmap,
603                cursor_x,
604                text_start_y,
605                scale,
606                overlay.style.color,
607                overlay.opacity,
608            );
609            cursor_x += glyph_w_s as i32;
610        }
611    }
612
613    /// Draw a filled rectangle.
614    fn fill_rect(
615        &self,
616        buffer: &mut [u8],
617        x: i32,
618        y: i32,
619        w: u32,
620        h: u32,
621        color: Rgba,
622        opacity: f32,
623    ) {
624        for row in 0..h {
625            for col in 0..w {
626                let px = x + col as i32;
627                let py = y + row as i32;
628                self.blend_pixel(buffer, px, py, color, opacity);
629            }
630        }
631    }
632
633    /// Draw a single scaled glyph.
634    fn draw_glyph(
635        &self,
636        buffer: &mut [u8],
637        bitmap: [u8; 7],
638        x: i32,
639        y: i32,
640        scale: u32,
641        color: Rgba,
642        opacity: f32,
643    ) {
644        for row in 0..GLYPH_H {
645            for col in 0..GLYPH_W {
646                if !glyph_pixel(bitmap, col, row) {
647                    continue;
648                }
649                for sy in 0..scale {
650                    for sx in 0..scale {
651                        let px = x + (col * scale + sx) as i32;
652                        let py = y + (row * scale + sy) as i32;
653                        self.blend_pixel(buffer, px, py, color, opacity);
654                    }
655                }
656            }
657        }
658    }
659
660    /// Alpha-composite `color` with `opacity` onto `buffer` at pixel `(x, y)`.
661    #[allow(
662        clippy::cast_possible_truncation,
663        clippy::cast_sign_loss,
664        clippy::cast_precision_loss
665    )]
666    fn blend_pixel(&self, buffer: &mut [u8], x: i32, y: i32, color: Rgba, opacity: f32) {
667        if x < 0 || y < 0 {
668            return;
669        }
670        let px = x as u32;
671        let py = y as u32;
672        if px >= self.frame_width || py >= self.frame_height {
673            return;
674        }
675        let idx = ((py * self.frame_width + px) * 4) as usize;
676        if idx + 3 >= buffer.len() {
677            return;
678        }
679        let src_a = (color.a as f32 / 255.0) * opacity;
680        let dst_a = 1.0 - src_a;
681        buffer[idx] = (color.r as f32 * src_a + buffer[idx] as f32 * dst_a).round() as u8;
682        buffer[idx + 1] = (color.g as f32 * src_a + buffer[idx + 1] as f32 * dst_a).round() as u8;
683        buffer[idx + 2] = (color.b as f32 * src_a + buffer[idx + 2] as f32 * dst_a).round() as u8;
684        buffer[idx + 3] = (src_a * 255.0 + buffer[idx + 3] as f32 * dst_a).round() as u8;
685    }
686}
687
688// ─────────────────────────────────────────────────────────────────────────────
689// OverlayManager
690// ─────────────────────────────────────────────────────────────────────────────
691
692/// Manages a collection of title overlays for a project.
693#[derive(Debug, Default)]
694pub struct OverlayManager {
695    overlays: HashMap<OverlayId, TitleOverlay>,
696    next_id: OverlayId,
697}
698
699impl OverlayManager {
700    /// Create an empty manager.
701    #[must_use]
702    pub fn new() -> Self {
703        Self {
704            overlays: HashMap::new(),
705            next_id: 1,
706        }
707    }
708
709    /// Add an overlay and return its assigned ID.
710    pub fn add(&mut self, mut overlay: TitleOverlay) -> OverlayId {
711        let id = self.next_id;
712        self.next_id += 1;
713        overlay.id = id;
714        self.overlays.insert(id, overlay);
715        id
716    }
717
718    /// Remove an overlay by ID.
719    pub fn remove(&mut self, id: OverlayId) -> Option<TitleOverlay> {
720        self.overlays.remove(&id)
721    }
722
723    /// Get a reference to an overlay.
724    #[must_use]
725    pub fn get(&self, id: OverlayId) -> Option<&TitleOverlay> {
726        self.overlays.get(&id)
727    }
728
729    /// Get a mutable reference.
730    pub fn get_mut(&mut self, id: OverlayId) -> Option<&mut TitleOverlay> {
731        self.overlays.get_mut(&id)
732    }
733
734    /// Return all overlays active at `time`, sorted by ID for deterministic order.
735    #[must_use]
736    pub fn active_at(&self, time: i64) -> Vec<&TitleOverlay> {
737        let mut active: Vec<&TitleOverlay> = self
738            .overlays
739            .values()
740            .filter(|o| o.is_active_at(time))
741            .collect();
742        active.sort_by_key(|o| o.id);
743        active
744    }
745
746    /// Total overlay count.
747    #[must_use]
748    pub fn len(&self) -> usize {
749        self.overlays.len()
750    }
751
752    /// Returns `true` if there are no overlays.
753    #[must_use]
754    pub fn is_empty(&self) -> bool {
755        self.overlays.is_empty()
756    }
757}
758
759// ─────────────────────────────────────────────────────────────────────────────
760// Tests
761// ─────────────────────────────────────────────────────────────────────────────
762
763#[cfg(test)]
764mod tests {
765    use super::*;
766
767    #[test]
768    fn test_glyph_pixel_space_is_blank() {
769        let bm = glyph(' ');
770        for row in 0..GLYPH_H {
771            for col in 0..GLYPH_W {
772                assert!(!glyph_pixel(bm, col, row), "space should be blank");
773            }
774        }
775    }
776
777    #[test]
778    fn test_glyph_pixel_uppercase_a_has_pixels() {
779        let bm = glyph('A');
780        let set_count: u32 = (0..GLYPH_H)
781            .flat_map(|row| (0..GLYPH_W).map(move |col| (row, col)))
782            .filter(|&(r, c)| glyph_pixel(bm, c, r))
783            .count() as u32;
784        assert!(
785            set_count > 5,
786            "A should have many set pixels, got {set_count}"
787        );
788    }
789
790    #[test]
791    fn test_glyph_unknown_char_returns_question_mark() {
792        let bm_q = glyph('?');
793        let bm_unknown = glyph('\x01');
794        assert_eq!(bm_q, bm_unknown);
795    }
796
797    #[test]
798    fn test_title_overlay_is_active_at() {
799        let o = TitleOverlay::new(1, "Hello", 0, 0, 1000, 5000);
800        assert!(o.is_active_at(1000));
801        assert!(o.is_active_at(4999));
802        assert!(!o.is_active_at(5000));
803        assert!(!o.is_active_at(999));
804    }
805
806    #[test]
807    fn test_title_overlay_text_width() {
808        let o = TitleOverlay::new(1, "AB", 0, 0, 0, 1000);
809        // 2 chars × (5 + 1) - 1 = 11 pixels
810        assert_eq!(o.text_width_px(), 11);
811    }
812
813    #[test]
814    fn test_title_overlay_text_width_empty() {
815        let o = TitleOverlay::new(1, "", 0, 0, 0, 1000);
816        assert_eq!(o.text_width_px(), 0);
817    }
818
819    #[test]
820    fn test_position_keyframes_interpolation() {
821        let mut o = TitleOverlay::new(1, "Hi", 0, 0, 0, 10000);
822        o.add_position_keyframe(0, 0, 100);
823        o.add_position_keyframe(1000, 200, 100);
824        let (x, y) = o.position_at(500);
825        assert_eq!(x, 100);
826        assert_eq!(y, 100);
827    }
828
829    #[test]
830    fn test_position_keyframes_before_first() {
831        let mut o = TitleOverlay::new(1, "Hi", 0, 0, 0, 10000);
832        o.add_position_keyframe(500, 10, 20);
833        let pos = o.position_at(0);
834        assert_eq!(pos, (10, 20));
835    }
836
837    #[test]
838    fn test_position_no_keyframes_returns_static() {
839        let o = TitleOverlay::new(1, "Hi", 42, 99, 0, 1000);
840        assert_eq!(o.position_at(500), (42, 99));
841    }
842
843    #[test]
844    fn test_overlay_renderer_composites_white_pixel() {
845        let width = 32u32;
846        let height = 32u32;
847        let mut buf = vec![0u8; (width * height * 4) as usize];
848        let renderer = OverlayRenderer::new(width, height);
849        let overlay = TitleOverlay::new(1, "A", 0, 0, 0, 1000).with_style(TextStyle {
850            color: Rgba::white(),
851            background: None,
852            shadow: None,
853            scale: FontScale::One,
854            padding: 0,
855            ..TextStyle::default()
856        });
857        renderer.composite(&mut buf, &overlay, 0);
858        // At least some pixels should be non-zero (letter A is set).
859        let any_set = buf.iter().any(|&b| b > 0);
860        assert!(any_set, "Expected rendered pixels after compositing 'A'");
861    }
862
863    #[test]
864    fn test_overlay_renderer_inactive_not_rendered() {
865        let width = 32u32;
866        let height = 32u32;
867        let mut buf = vec![0u8; (width * height * 4) as usize];
868        let renderer = OverlayRenderer::new(width, height);
869        let overlay = TitleOverlay::new(1, "A", 0, 0, 5000, 10000);
870        renderer.composite(&mut buf, &overlay, 0);
871        // Buffer should remain all zeros.
872        assert!(buf.iter().all(|&b| b == 0));
873    }
874
875    #[test]
876    fn test_overlay_manager_add_remove() {
877        let mut mgr = OverlayManager::new();
878        let o = TitleOverlay::new(0, "Test", 0, 0, 0, 1000);
879        let id = mgr.add(o);
880        assert_eq!(mgr.len(), 1);
881        assert!(mgr.get(id).is_some());
882        assert!(mgr.remove(id).is_some());
883        assert!(mgr.is_empty());
884    }
885
886    #[test]
887    fn test_overlay_manager_active_at() {
888        let mut mgr = OverlayManager::new();
889        let o1 = TitleOverlay::new(0, "A", 0, 0, 0, 1000);
890        let o2 = TitleOverlay::new(0, "B", 0, 50, 500, 2000);
891        mgr.add(o1);
892        mgr.add(o2);
893        let active = mgr.active_at(600);
894        assert_eq!(active.len(), 2);
895        let active_early = mgr.active_at(200);
896        assert_eq!(active_early.len(), 1);
897    }
898
899    #[test]
900    fn test_rgba_white_black() {
901        let w = Rgba::white();
902        assert_eq!((w.r, w.g, w.b, w.a), (255, 255, 255, 255));
903        let b = Rgba::black();
904        assert_eq!((b.r, b.g, b.b, b.a), (0, 0, 0, 255));
905    }
906
907    #[test]
908    fn test_font_scale_factors() {
909        assert_eq!(FontScale::One.factor(), 1);
910        assert_eq!(FontScale::Two.factor(), 2);
911        assert_eq!(FontScale::Three.factor(), 3);
912        assert_eq!(FontScale::Four.factor(), 4);
913    }
914
915    #[test]
916    fn test_background_fills_buffer() {
917        let w = 20u32;
918        let h = 20u32;
919        let mut buf = vec![0u8; (w * h * 4) as usize];
920        let renderer = OverlayRenderer::new(w, h);
921        let style = TextStyle {
922            background: Some(Rgba::new(255, 0, 0, 255)),
923            color: Rgba::transparent(),
924            shadow: None,
925            scale: FontScale::One,
926            halign: HAlign::Left,
927            valign: VAlign::Top,
928            padding: 0,
929        };
930        let overlay = TitleOverlay::new(1, " ", 0, 0, 0, 1000).with_style(style);
931        renderer.composite(&mut buf, &overlay, 500);
932        // Background is red so at least some red bytes must be set.
933        let has_red = buf.chunks(4).any(|p| p[0] > 0);
934        assert!(has_red);
935    }
936}