Skip to main content

oximedia_transcode/
watermark_overlay.rs

1#![allow(dead_code)]
2//! Watermark and graphic overlay embedding during transcoding.
3//!
4//! Supports text watermarks, image logos, and timed overlays that are burned
5//! into the output video at configurable positions, sizes, and opacity levels.
6
7use std::fmt;
8
9/// Anchor position for overlay placement.
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum OverlayPosition {
12    /// Top-left corner.
13    TopLeft,
14    /// Top-right corner.
15    TopRight,
16    /// Bottom-left corner.
17    BottomLeft,
18    /// Bottom-right corner.
19    BottomRight,
20    /// Centred horizontally and vertically.
21    Center,
22    /// Custom pixel offset from top-left.
23    Custom {
24        /// X offset in pixels.
25        x: u32,
26        /// Y offset in pixels.
27        y: u32,
28    },
29}
30
31impl fmt::Display for OverlayPosition {
32    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
33        match self {
34            Self::TopLeft => write!(f, "top-left"),
35            Self::TopRight => write!(f, "top-right"),
36            Self::BottomLeft => write!(f, "bottom-left"),
37            Self::BottomRight => write!(f, "bottom-right"),
38            Self::Center => write!(f, "center"),
39            Self::Custom { x, y } => write!(f, "custom({x},{y})"),
40        }
41    }
42}
43
44impl OverlayPosition {
45    /// Resolve to pixel coordinates given frame and overlay dimensions.
46    /// Returns `(x, y)` for the top-left corner of the overlay.
47    #[allow(clippy::cast_precision_loss)]
48    #[must_use]
49    pub fn resolve(self, frame_w: u32, frame_h: u32, overlay_w: u32, overlay_h: u32) -> (u32, u32) {
50        match self {
51            Self::TopLeft => (0, 0),
52            Self::TopRight => (frame_w.saturating_sub(overlay_w), 0),
53            Self::BottomLeft => (0, frame_h.saturating_sub(overlay_h)),
54            Self::BottomRight => (
55                frame_w.saturating_sub(overlay_w),
56                frame_h.saturating_sub(overlay_h),
57            ),
58            Self::Center => (
59                frame_w.saturating_sub(overlay_w) / 2,
60                frame_h.saturating_sub(overlay_h) / 2,
61            ),
62            Self::Custom { x, y } => (x, y),
63        }
64    }
65}
66
67/// Kind of watermark content.
68#[derive(Debug, Clone, PartialEq)]
69pub enum WatermarkContent {
70    /// A text string to render.
71    Text(String),
72    /// A path to an image file (PNG, etc.).
73    ImageFile(String),
74    /// Raw RGBA pixel data with width and height.
75    RawRgba {
76        /// Width of the raw image.
77        width: u32,
78        /// Height of the raw image.
79        height: u32,
80        /// Pixel data in RGBA order.
81        data: Vec<u8>,
82    },
83}
84
85/// Configuration for a single watermark overlay.
86#[derive(Debug, Clone)]
87pub struct WatermarkConfig {
88    /// Content to overlay.
89    pub content: WatermarkContent,
90    /// Position anchor.
91    pub position: OverlayPosition,
92    /// Opacity from 0.0 (invisible) to 1.0 (fully opaque).
93    pub opacity: f32,
94    /// Scale factor for the overlay (1.0 = original size).
95    pub scale: f32,
96    /// Optional margin from the anchor edge in pixels.
97    pub margin: u32,
98    /// Optional start time in seconds (overlay appears at this time).
99    pub start_time: Option<f64>,
100    /// Optional end time in seconds (overlay disappears at this time).
101    pub end_time: Option<f64>,
102}
103
104impl WatermarkConfig {
105    /// Create a text watermark with default settings.
106    pub fn text(text: impl Into<String>) -> Self {
107        Self {
108            content: WatermarkContent::Text(text.into()),
109            position: OverlayPosition::BottomRight,
110            opacity: 0.5,
111            scale: 1.0,
112            margin: 10,
113            start_time: None,
114            end_time: None,
115        }
116    }
117
118    /// Create an image-file watermark.
119    pub fn image(path: impl Into<String>) -> Self {
120        Self {
121            content: WatermarkContent::ImageFile(path.into()),
122            position: OverlayPosition::BottomRight,
123            opacity: 0.8,
124            scale: 1.0,
125            margin: 10,
126            start_time: None,
127            end_time: None,
128        }
129    }
130
131    /// Set position.
132    #[must_use]
133    pub fn with_position(mut self, pos: OverlayPosition) -> Self {
134        self.position = pos;
135        self
136    }
137
138    /// Set opacity (clamped to 0.0 - 1.0).
139    #[must_use]
140    pub fn with_opacity(mut self, opacity: f32) -> Self {
141        self.opacity = opacity.clamp(0.0, 1.0);
142        self
143    }
144
145    /// Set scale factor.
146    #[must_use]
147    pub fn with_scale(mut self, scale: f32) -> Self {
148        self.scale = scale.max(0.01);
149        self
150    }
151
152    /// Set margin.
153    #[must_use]
154    pub fn with_margin(mut self, margin: u32) -> Self {
155        self.margin = margin;
156        self
157    }
158
159    /// Set time range visibility.
160    #[must_use]
161    pub fn with_time_range(mut self, start: f64, end: f64) -> Self {
162        self.start_time = Some(start);
163        self.end_time = Some(end);
164        self
165    }
166
167    /// Check whether the watermark is visible at a given timestamp.
168    #[must_use]
169    pub fn is_visible_at(&self, time_seconds: f64) -> bool {
170        if let Some(start) = self.start_time {
171            if time_seconds < start {
172                return false;
173            }
174        }
175        if let Some(end) = self.end_time {
176            if time_seconds > end {
177                return false;
178            }
179        }
180        true
181    }
182
183    /// Compute effective opacity at the given timestamp.
184    #[must_use]
185    pub fn effective_opacity(&self, time_seconds: f64) -> f32 {
186        if self.is_visible_at(time_seconds) {
187            self.opacity
188        } else {
189            0.0
190        }
191    }
192}
193
194/// Aggregate overlay pipeline that composes multiple watermarks.
195#[derive(Debug, Clone)]
196pub struct OverlayPipeline {
197    /// Ordered list of watermark layers.
198    layers: Vec<WatermarkConfig>,
199    /// Output frame width.
200    frame_width: u32,
201    /// Output frame height.
202    frame_height: u32,
203}
204
205impl OverlayPipeline {
206    /// Create a new overlay pipeline for a given frame size.
207    #[must_use]
208    pub fn new(width: u32, height: u32) -> Self {
209        Self {
210            layers: Vec::new(),
211            frame_width: width,
212            frame_height: height,
213        }
214    }
215
216    /// Add a watermark layer.
217    pub fn add_layer(&mut self, config: WatermarkConfig) {
218        self.layers.push(config);
219    }
220
221    /// Return the number of layers.
222    #[must_use]
223    pub fn layer_count(&self) -> usize {
224        self.layers.len()
225    }
226
227    /// Return layers that are visible at the given timestamp.
228    #[must_use]
229    pub fn visible_layers_at(&self, time_seconds: f64) -> Vec<&WatermarkConfig> {
230        self.layers
231            .iter()
232            .filter(|l| l.is_visible_at(time_seconds))
233            .collect()
234    }
235
236    /// Clear all layers.
237    pub fn clear(&mut self) {
238        self.layers.clear();
239    }
240
241    /// Return the configured frame dimensions.
242    #[must_use]
243    pub fn frame_size(&self) -> (u32, u32) {
244        (self.frame_width, self.frame_height)
245    }
246}
247
248#[cfg(test)]
249mod tests {
250    use super::*;
251
252    #[test]
253    fn test_position_display() {
254        assert_eq!(OverlayPosition::TopLeft.to_string(), "top-left");
255        assert_eq!(OverlayPosition::TopRight.to_string(), "top-right");
256        assert_eq!(OverlayPosition::BottomLeft.to_string(), "bottom-left");
257        assert_eq!(OverlayPosition::BottomRight.to_string(), "bottom-right");
258        assert_eq!(OverlayPosition::Center.to_string(), "center");
259        assert_eq!(
260            OverlayPosition::Custom { x: 10, y: 20 }.to_string(),
261            "custom(10,20)"
262        );
263    }
264
265    #[test]
266    fn test_position_resolve_top_left() {
267        let (x, y) = OverlayPosition::TopLeft.resolve(1920, 1080, 100, 50);
268        assert_eq!((x, y), (0, 0));
269    }
270
271    #[test]
272    fn test_position_resolve_bottom_right() {
273        let (x, y) = OverlayPosition::BottomRight.resolve(1920, 1080, 100, 50);
274        assert_eq!((x, y), (1820, 1030));
275    }
276
277    #[test]
278    fn test_position_resolve_center() {
279        let (x, y) = OverlayPosition::Center.resolve(1920, 1080, 100, 80);
280        assert_eq!((x, y), (910, 500));
281    }
282
283    #[test]
284    fn test_position_resolve_custom() {
285        let (x, y) = OverlayPosition::Custom { x: 42, y: 99 }.resolve(1920, 1080, 100, 100);
286        assert_eq!((x, y), (42, 99));
287    }
288
289    #[test]
290    fn test_text_watermark_defaults() {
291        let wm = WatermarkConfig::text("(c) 2024");
292        assert_eq!(wm.position, OverlayPosition::BottomRight);
293        assert!((wm.opacity - 0.5).abs() < f32::EPSILON);
294        assert!((wm.scale - 1.0).abs() < f32::EPSILON);
295        assert_eq!(wm.margin, 10);
296        assert!(wm.start_time.is_none());
297    }
298
299    #[test]
300    fn test_image_watermark_defaults() {
301        let wm = WatermarkConfig::image("/logo.png");
302        assert!((wm.opacity - 0.8).abs() < f32::EPSILON);
303        match &wm.content {
304            WatermarkContent::ImageFile(p) => assert_eq!(p, "/logo.png"),
305            _ => panic!("expected ImageFile"),
306        }
307    }
308
309    #[test]
310    fn test_opacity_clamp() {
311        let wm = WatermarkConfig::text("x").with_opacity(2.0);
312        assert!((wm.opacity - 1.0).abs() < f32::EPSILON);
313        let wm2 = WatermarkConfig::text("x").with_opacity(-1.0);
314        assert!((wm2.opacity - 0.0).abs() < f32::EPSILON);
315    }
316
317    #[test]
318    fn test_visibility_always() {
319        let wm = WatermarkConfig::text("x");
320        assert!(wm.is_visible_at(0.0));
321        assert!(wm.is_visible_at(9999.0));
322    }
323
324    #[test]
325    fn test_visibility_timed() {
326        let wm = WatermarkConfig::text("x").with_time_range(5.0, 10.0);
327        assert!(!wm.is_visible_at(3.0));
328        assert!(wm.is_visible_at(7.0));
329        assert!(!wm.is_visible_at(12.0));
330    }
331
332    #[test]
333    fn test_effective_opacity() {
334        let wm = WatermarkConfig::text("x")
335            .with_opacity(0.75)
336            .with_time_range(5.0, 10.0);
337        assert!((wm.effective_opacity(7.0) - 0.75).abs() < f32::EPSILON);
338        assert!((wm.effective_opacity(1.0) - 0.0).abs() < f32::EPSILON);
339    }
340
341    #[test]
342    fn test_pipeline_add_layers() {
343        let mut pipeline = OverlayPipeline::new(1920, 1080);
344        assert_eq!(pipeline.layer_count(), 0);
345        pipeline.add_layer(WatermarkConfig::text("A"));
346        pipeline.add_layer(WatermarkConfig::text("B"));
347        assert_eq!(pipeline.layer_count(), 2);
348    }
349
350    #[test]
351    fn test_pipeline_visible_layers() {
352        let mut pipeline = OverlayPipeline::new(1920, 1080);
353        pipeline.add_layer(WatermarkConfig::text("always"));
354        pipeline.add_layer(WatermarkConfig::text("timed").with_time_range(5.0, 10.0));
355
356        assert_eq!(pipeline.visible_layers_at(0.0).len(), 1);
357        assert_eq!(pipeline.visible_layers_at(7.0).len(), 2);
358    }
359
360    #[test]
361    fn test_pipeline_clear() {
362        let mut pipeline = OverlayPipeline::new(1920, 1080);
363        pipeline.add_layer(WatermarkConfig::text("x"));
364        pipeline.clear();
365        assert_eq!(pipeline.layer_count(), 0);
366    }
367
368    #[test]
369    fn test_pipeline_frame_size() {
370        let pipeline = OverlayPipeline::new(3840, 2160);
371        assert_eq!(pipeline.frame_size(), (3840, 2160));
372    }
373}