Skip to main content

oximedia_graph/filters/video/
pad.rs

1//! Video padding filter.
2//!
3//! This filter adds padding (borders) around video frames, useful for
4//! letterboxing, pillarboxing, and aspect ratio adjustment.
5
6#![forbid(unsafe_code)]
7#![allow(clippy::cast_lossless)]
8#![allow(clippy::cast_precision_loss)]
9#![allow(clippy::cast_possible_truncation)]
10#![allow(clippy::cast_sign_loss)]
11#![allow(clippy::cast_possible_wrap)]
12#![allow(clippy::similar_names)]
13#![allow(clippy::many_single_char_names)]
14#![allow(clippy::missing_errors_doc)]
15#![allow(clippy::match_same_arms)]
16#![allow(clippy::doc_markdown)]
17#![allow(clippy::unused_self)]
18#![allow(clippy::unnecessary_cast)]
19#![allow(clippy::bool_to_int_with_if)]
20#![allow(clippy::needless_range_loop)]
21#![allow(clippy::too_many_lines)]
22#![allow(clippy::unnecessary_wraps)]
23#![allow(clippy::map_unwrap_or)]
24#![allow(clippy::no_effect_underscore_binding)]
25#![allow(clippy::unreadable_literal)]
26#![allow(dead_code)]
27
28use crate::error::{GraphError, GraphResult};
29use crate::frame::FilterFrame;
30use crate::node::{Node, NodeId, NodeState, NodeType};
31use crate::port::{InputPort, OutputPort, PortFormat, PortId, PortType, VideoPortFormat};
32use oximedia_codec::{Plane, VideoFrame};
33
34/// Color for padding.
35#[derive(Clone, Copy, Debug, PartialEq, Eq)]
36pub enum PadColor {
37    /// Black (Y=0 or 16 for limited range, UV=128).
38    Black,
39    /// White (Y=255 or 235 for limited range, UV=128).
40    White,
41    /// Gray (Y=128, UV=128).
42    Gray,
43    /// Custom YUV color.
44    YuvColor {
45        /// Y (luma) component.
46        y: u8,
47        /// U (Cb) component.
48        u: u8,
49        /// V (Cr) component.
50        v: u8,
51    },
52    /// Custom RGB color (will be converted to YUV internally).
53    RgbColor {
54        /// Red component.
55        r: u8,
56        /// Green component.
57        g: u8,
58        /// Blue component.
59        b: u8,
60    },
61}
62
63#[allow(clippy::derivable_impls)]
64impl Default for PadColor {
65    fn default() -> Self {
66        Self::Black
67    }
68}
69
70impl PadColor {
71    /// Get the YUV values for this color.
72    #[must_use]
73    pub fn to_yuv(&self, full_range: bool) -> (u8, u8, u8) {
74        match self {
75            Self::Black => {
76                if full_range {
77                    (0, 128, 128)
78                } else {
79                    (16, 128, 128)
80                }
81            }
82            Self::White => {
83                if full_range {
84                    (255, 128, 128)
85                } else {
86                    (235, 128, 128)
87                }
88            }
89            Self::Gray => (128, 128, 128),
90            Self::YuvColor { y, u, v } => (*y, *u, *v),
91            Self::RgbColor { r, g, b } => rgb_to_yuv(*r, *g, *b),
92        }
93    }
94
95    /// Create a custom YUV color.
96    #[must_use]
97    pub fn yuv(y: u8, u: u8, v: u8) -> Self {
98        Self::YuvColor { y, u, v }
99    }
100
101    /// Create a custom RGB color.
102    #[must_use]
103    pub fn rgb(r: u8, g: u8, b: u8) -> Self {
104        Self::RgbColor { r, g, b }
105    }
106}
107
108/// Convert RGB to YUV (BT.601 full range).
109fn rgb_to_yuv(r: u8, g: u8, b: u8) -> (u8, u8, u8) {
110    let r = r as f64;
111    let g = g as f64;
112    let b = b as f64;
113
114    let y = 0.299 * r + 0.587 * g + 0.114 * b;
115    let u = -0.169 * r - 0.331 * g + 0.500 * b + 128.0;
116    let v = 0.500 * r - 0.419 * g - 0.081 * b + 128.0;
117
118    (
119        y.round().clamp(0.0, 255.0) as u8,
120        u.round().clamp(0.0, 255.0) as u8,
121        v.round().clamp(0.0, 255.0) as u8,
122    )
123}
124
125/// Configuration for the pad filter.
126#[derive(Clone, Debug)]
127pub struct PadConfig {
128    /// Padding on the left side (pixels).
129    pub left: u32,
130    /// Padding on the top side (pixels).
131    pub top: u32,
132    /// Padding on the right side (pixels).
133    pub right: u32,
134    /// Padding on the bottom side (pixels).
135    pub bottom: u32,
136    /// Color for padding.
137    pub color: PadColor,
138    /// Target width (if auto-padding is enabled).
139    pub target_width: Option<u32>,
140    /// Target height (if auto-padding is enabled).
141    pub target_height: Option<u32>,
142    /// Target aspect ratio for auto-padding.
143    pub target_aspect: Option<f64>,
144}
145
146impl PadConfig {
147    /// Create a new pad configuration with explicit padding values.
148    #[must_use]
149    pub fn new(left: u32, top: u32, right: u32, bottom: u32) -> Self {
150        Self {
151            left,
152            top,
153            right,
154            bottom,
155            color: PadColor::default(),
156            target_width: None,
157            target_height: None,
158            target_aspect: None,
159        }
160    }
161
162    /// Create padding to reach a target size.
163    #[must_use]
164    pub fn to_size(target_width: u32, target_height: u32) -> Self {
165        Self {
166            left: 0,
167            top: 0,
168            right: 0,
169            bottom: 0,
170            color: PadColor::default(),
171            target_width: Some(target_width),
172            target_height: Some(target_height),
173            target_aspect: None,
174        }
175    }
176
177    /// Create padding for a target aspect ratio (letterbox/pillarbox).
178    #[must_use]
179    pub fn for_aspect(aspect: f64) -> Self {
180        Self {
181            left: 0,
182            top: 0,
183            right: 0,
184            bottom: 0,
185            color: PadColor::default(),
186            target_width: None,
187            target_height: None,
188            target_aspect: Some(aspect),
189        }
190    }
191
192    /// Set the padding color.
193    #[must_use]
194    pub fn with_color(mut self, color: PadColor) -> Self {
195        self.color = color;
196        self
197    }
198
199    /// Calculate the actual padding values for given source dimensions.
200    #[must_use]
201    pub fn calculate_padding(&self, src_width: u32, src_height: u32) -> PadValues {
202        if let (Some(target_w), Some(target_h)) = (self.target_width, self.target_height) {
203            // Pad to exact target size (centered)
204            let h_pad = target_w.saturating_sub(src_width);
205            let v_pad = target_h.saturating_sub(src_height);
206
207            PadValues {
208                left: h_pad / 2,
209                top: v_pad / 2,
210                right: h_pad - (h_pad / 2),
211                bottom: v_pad - (v_pad / 2),
212            }
213        } else if let Some(target_aspect) = self.target_aspect {
214            // Pad to match aspect ratio
215            calculate_aspect_padding(src_width, src_height, target_aspect)
216        } else {
217            // Use explicit padding values
218            PadValues {
219                left: self.left,
220                top: self.top,
221                right: self.right,
222                bottom: self.bottom,
223            }
224        }
225    }
226
227    /// Get the output dimensions for given source dimensions.
228    #[must_use]
229    pub fn output_dimensions(&self, src_width: u32, src_height: u32) -> (u32, u32) {
230        let pad = self.calculate_padding(src_width, src_height);
231        (
232            src_width + pad.left + pad.right,
233            src_height + pad.top + pad.bottom,
234        )
235    }
236}
237
238/// Calculated padding values.
239#[derive(Clone, Copy, Debug, PartialEq, Eq)]
240pub struct PadValues {
241    /// Left padding.
242    pub left: u32,
243    /// Top padding.
244    pub top: u32,
245    /// Right padding.
246    pub right: u32,
247    /// Bottom padding.
248    pub bottom: u32,
249}
250
251impl PadValues {
252    /// Get total horizontal padding.
253    #[must_use]
254    pub fn horizontal(&self) -> u32 {
255        self.left + self.right
256    }
257
258    /// Get total vertical padding.
259    #[must_use]
260    pub fn vertical(&self) -> u32 {
261        self.top + self.bottom
262    }
263
264    /// Check if any padding is applied.
265    #[must_use]
266    pub fn has_padding(&self) -> bool {
267        self.left > 0 || self.top > 0 || self.right > 0 || self.bottom > 0
268    }
269
270    /// Scale padding values for chroma planes.
271    #[must_use]
272    pub fn scale_for_chroma(&self, h_ratio: u32, v_ratio: u32) -> Self {
273        Self {
274            left: self.left / h_ratio,
275            top: self.top / v_ratio,
276            right: self.right / h_ratio,
277            bottom: self.bottom / v_ratio,
278        }
279    }
280}
281
282/// Calculate padding to achieve target aspect ratio.
283fn calculate_aspect_padding(src_width: u32, src_height: u32, target_aspect: f64) -> PadValues {
284    let src_aspect = src_width as f64 / src_height as f64;
285
286    if src_aspect > target_aspect {
287        // Source is wider than target, add vertical padding (letterbox)
288        let target_height = (src_width as f64 / target_aspect).round() as u32;
289        let v_pad = target_height.saturating_sub(src_height);
290        PadValues {
291            left: 0,
292            top: v_pad / 2,
293            right: 0,
294            bottom: v_pad - (v_pad / 2),
295        }
296    } else {
297        // Source is taller than target, add horizontal padding (pillarbox)
298        let target_width = (src_height as f64 * target_aspect).round() as u32;
299        let h_pad = target_width.saturating_sub(src_width);
300        PadValues {
301            left: h_pad / 2,
302            top: 0,
303            right: h_pad - (h_pad / 2),
304            bottom: 0,
305        }
306    }
307}
308
309/// Video padding filter.
310///
311/// Adds padding around video frames for letterboxing, pillarboxing,
312/// or reaching a target resolution.
313///
314/// # Example
315///
316/// ```ignore
317/// use oximedia_graph::filters::video::{PadFilter, PadConfig, PadColor};
318/// use oximedia_graph::node::NodeId;
319///
320/// // Create a letterbox for 16:9 aspect ratio
321/// let config = PadConfig::for_aspect(16.0 / 9.0)
322///     .with_color(PadColor::Black);
323///
324/// let filter = PadFilter::new(NodeId(0), "letterbox", config);
325/// ```
326pub struct PadFilter {
327    id: NodeId,
328    name: String,
329    state: NodeState,
330    inputs: Vec<InputPort>,
331    outputs: Vec<OutputPort>,
332    config: PadConfig,
333}
334
335impl PadFilter {
336    /// Create a new pad filter.
337    #[must_use]
338    pub fn new(id: NodeId, name: impl Into<String>, config: PadConfig) -> Self {
339        Self {
340            id,
341            name: name.into(),
342            state: NodeState::Idle,
343            inputs: vec![InputPort::new(PortId(0), "input", PortType::Video)
344                .with_format(PortFormat::Video(VideoPortFormat::any()))],
345            outputs: vec![OutputPort::new(PortId(0), "output", PortType::Video)
346                .with_format(PortFormat::Video(VideoPortFormat::any()))],
347            config,
348        }
349    }
350
351    /// Get the current configuration.
352    #[must_use]
353    pub fn config(&self) -> &PadConfig {
354        &self.config
355    }
356
357    /// Update the pad configuration.
358    pub fn set_config(&mut self, config: PadConfig) {
359        self.config = config;
360    }
361
362    /// Pad a single plane.
363    fn pad_plane(
364        &self,
365        src: &Plane,
366        src_width: u32,
367        src_height: u32,
368        pad: &PadValues,
369        fill: u8,
370    ) -> Plane {
371        let dst_width = src_width + pad.left + pad.right;
372        let dst_height = src_height + pad.top + pad.bottom;
373        let mut dst_data = vec![fill; dst_width as usize * dst_height as usize];
374
375        // Copy source data into padded area
376        for y in 0..src_height as usize {
377            let src_row = src.row(y);
378            let dst_y = y + pad.top as usize;
379            let dst_start = dst_y * dst_width as usize + pad.left as usize;
380
381            for x in 0..src_width as usize {
382                dst_data[dst_start + x] = src_row.get(x).copied().unwrap_or(fill);
383            }
384        }
385
386        Plane::new(dst_data, dst_width as usize)
387    }
388
389    /// Pad a video frame.
390    fn pad_frame(&self, input: &VideoFrame) -> VideoFrame {
391        let pad = self.config.calculate_padding(input.width, input.height);
392        let (dst_width, dst_height) = self.config.output_dimensions(input.width, input.height);
393
394        let mut output = VideoFrame::new(input.format, dst_width, dst_height);
395        output.timestamp = input.timestamp;
396        output.frame_type = input.frame_type;
397        output.color_info = input.color_info;
398
399        let (y_fill, u_fill, v_fill) = self.config.color.to_yuv(input.color_info.full_range);
400
401        for (i, src_plane) in input.planes.iter().enumerate() {
402            let (src_w, src_h) = input.plane_dimensions(i);
403
404            let (plane_pad, fill) = if i > 0 && input.format.is_yuv() {
405                let (h_ratio, v_ratio) = input.format.chroma_subsampling();
406                let fill = if i == 1 { u_fill } else { v_fill };
407                (pad.scale_for_chroma(h_ratio, v_ratio), fill)
408            } else {
409                (pad, y_fill)
410            };
411
412            let plane = self.pad_plane(src_plane, src_w, src_h, &plane_pad, fill);
413            output.planes.push(plane);
414        }
415
416        output
417    }
418}
419
420impl Node for PadFilter {
421    fn id(&self) -> NodeId {
422        self.id
423    }
424
425    fn name(&self) -> &str {
426        &self.name
427    }
428
429    fn node_type(&self) -> NodeType {
430        NodeType::Filter
431    }
432
433    fn state(&self) -> NodeState {
434        self.state
435    }
436
437    fn set_state(&mut self, state: NodeState) -> GraphResult<()> {
438        if !self.state.can_transition_to(state) {
439            return Err(GraphError::InvalidStateTransition {
440                node: self.id,
441                from: self.state.to_string(),
442                to: state.to_string(),
443            });
444        }
445        self.state = state;
446        Ok(())
447    }
448
449    fn inputs(&self) -> &[InputPort] {
450        &self.inputs
451    }
452
453    fn outputs(&self) -> &[OutputPort] {
454        &self.outputs
455    }
456
457    fn process(&mut self, input: Option<FilterFrame>) -> GraphResult<Option<FilterFrame>> {
458        match input {
459            Some(FilterFrame::Video(frame)) => {
460                let padded = self.pad_frame(&frame);
461                Ok(Some(FilterFrame::Video(padded)))
462            }
463            Some(_) => Err(GraphError::PortTypeMismatch {
464                expected: "Video".to_string(),
465                actual: "Audio".to_string(),
466            }),
467            None => Ok(None),
468        }
469    }
470}
471
472/// Create a letterbox configuration for 16:9 content.
473#[must_use]
474pub fn letterbox_16_9() -> PadConfig {
475    PadConfig::for_aspect(16.0 / 9.0).with_color(PadColor::Black)
476}
477
478/// Create a letterbox configuration for 4:3 content.
479#[must_use]
480pub fn letterbox_4_3() -> PadConfig {
481    PadConfig::for_aspect(4.0 / 3.0).with_color(PadColor::Black)
482}
483
484/// Create a letterbox configuration for 2.35:1 (CinemaScope) content.
485#[must_use]
486pub fn letterbox_cinemascope() -> PadConfig {
487    PadConfig::for_aspect(2.35).with_color(PadColor::Black)
488}
489
490#[cfg(test)]
491mod tests {
492    use super::*;
493    use oximedia_core::PixelFormat;
494
495    fn create_test_frame(width: u32, height: u32) -> VideoFrame {
496        let mut frame = VideoFrame::new(PixelFormat::Yuv420p, width, height);
497        frame.allocate();
498
499        // Fill Y plane with mid-gray
500        if let Some(plane) = frame.planes.get_mut(0) {
501            let data = vec![128u8; width as usize * height as usize];
502            *plane = Plane::new(data, width as usize);
503        }
504
505        frame
506    }
507
508    #[test]
509    fn test_pad_color_to_yuv() {
510        let (y, u, v) = PadColor::Black.to_yuv(true);
511        assert_eq!((y, u, v), (0, 128, 128));
512
513        let (y, u, v) = PadColor::Black.to_yuv(false);
514        assert_eq!((y, u, v), (16, 128, 128));
515
516        let (y, u, v) = PadColor::White.to_yuv(true);
517        assert_eq!((y, u, v), (255, 128, 128));
518
519        let (y, u, v) = PadColor::Gray.to_yuv(true);
520        assert_eq!((y, u, v), (128, 128, 128));
521    }
522
523    #[test]
524    fn test_pad_color_custom_yuv() {
525        let color = PadColor::yuv(100, 150, 200);
526        let (y, u, v) = color.to_yuv(true);
527        assert_eq!((y, u, v), (100, 150, 200));
528    }
529
530    #[test]
531    fn test_pad_color_rgb() {
532        let color = PadColor::rgb(255, 0, 0); // Red
533        let (y, u, v) = color.to_yuv(true);
534        // Red in YUV should have high Y, low U, high V
535        assert!(y > 50);
536        assert!(u < 128);
537        assert!(v > 128);
538    }
539
540    #[test]
541    fn test_rgb_to_yuv() {
542        // White
543        let (y, u, v) = rgb_to_yuv(255, 255, 255);
544        assert_eq!(y, 255);
545        assert_eq!(u, 128);
546        assert_eq!(v, 128);
547
548        // Black
549        let (y, u, v) = rgb_to_yuv(0, 0, 0);
550        assert_eq!(y, 0);
551        assert_eq!(u, 128);
552        assert_eq!(v, 128);
553    }
554
555    #[test]
556    fn test_pad_config_explicit() {
557        let config = PadConfig::new(10, 20, 30, 40);
558        assert_eq!(config.left, 10);
559        assert_eq!(config.top, 20);
560        assert_eq!(config.right, 30);
561        assert_eq!(config.bottom, 40);
562    }
563
564    #[test]
565    fn test_pad_config_to_size() {
566        let config = PadConfig::to_size(1920, 1080);
567        let pad = config.calculate_padding(1280, 720);
568
569        let dst_width = 1280 + pad.left + pad.right;
570        let dst_height = 720 + pad.top + pad.bottom;
571
572        assert_eq!(dst_width, 1920);
573        assert_eq!(dst_height, 1080);
574    }
575
576    #[test]
577    fn test_pad_config_for_aspect() {
578        // 4:3 to 16:9 (pillarbox)
579        let config = PadConfig::for_aspect(16.0 / 9.0);
580        let pad = config.calculate_padding(640, 480);
581
582        assert!(pad.left > 0);
583        assert!(pad.right > 0);
584        assert_eq!(pad.top, 0);
585        assert_eq!(pad.bottom, 0);
586
587        // 16:9 to 4:3 (letterbox)
588        let config = PadConfig::for_aspect(4.0 / 3.0);
589        let pad = config.calculate_padding(1920, 1080);
590
591        assert_eq!(pad.left, 0);
592        assert_eq!(pad.right, 0);
593        assert!(pad.top > 0);
594        assert!(pad.bottom > 0);
595    }
596
597    #[test]
598    fn test_pad_values_methods() {
599        let pad = PadValues {
600            left: 10,
601            top: 20,
602            right: 30,
603            bottom: 40,
604        };
605
606        assert_eq!(pad.horizontal(), 40);
607        assert_eq!(pad.vertical(), 60);
608        assert!(pad.has_padding());
609
610        let no_pad = PadValues {
611            left: 0,
612            top: 0,
613            right: 0,
614            bottom: 0,
615        };
616        assert!(!no_pad.has_padding());
617    }
618
619    #[test]
620    fn test_pad_values_scale_for_chroma() {
621        let pad = PadValues {
622            left: 100,
623            top: 200,
624            right: 100,
625            bottom: 200,
626        };
627
628        let scaled = pad.scale_for_chroma(2, 2);
629        assert_eq!(scaled.left, 50);
630        assert_eq!(scaled.top, 100);
631        assert_eq!(scaled.right, 50);
632        assert_eq!(scaled.bottom, 100);
633    }
634
635    #[test]
636    fn test_pad_filter_creation() {
637        let config = PadConfig::new(10, 20, 10, 20);
638        let filter = PadFilter::new(NodeId(0), "pad", config);
639
640        assert_eq!(filter.id(), NodeId(0));
641        assert_eq!(filter.name(), "pad");
642        assert_eq!(filter.node_type(), NodeType::Filter);
643    }
644
645    #[test]
646    fn test_pad_filter_process() {
647        let config = PadConfig::new(10, 20, 10, 20);
648        let mut filter = PadFilter::new(NodeId(0), "pad", config);
649
650        let input = create_test_frame(640, 480);
651        let result = filter
652            .process(Some(FilterFrame::Video(input)))
653            .expect("operation should succeed")
654            .expect("operation should succeed");
655
656        if let FilterFrame::Video(frame) = result {
657            assert_eq!(frame.width, 660); // 640 + 10 + 10
658            assert_eq!(frame.height, 520); // 480 + 20 + 20
659        } else {
660            panic!("Expected video frame");
661        }
662    }
663
664    #[test]
665    fn test_letterbox_presets() {
666        let config = letterbox_16_9();
667        assert!(config.target_aspect.is_some());
668
669        let config = letterbox_4_3();
670        assert!(config.target_aspect.is_some());
671
672        let config = letterbox_cinemascope();
673        assert!(config.target_aspect.is_some());
674    }
675
676    #[test]
677    fn test_node_state_transitions() {
678        let config = PadConfig::new(10, 10, 10, 10);
679        let mut filter = PadFilter::new(NodeId(0), "pad", config);
680
681        assert_eq!(filter.state(), NodeState::Idle);
682        filter
683            .set_state(NodeState::Processing)
684            .expect("set_state should succeed");
685        assert_eq!(filter.state(), NodeState::Processing);
686    }
687
688    #[test]
689    fn test_process_none_input() {
690        let config = PadConfig::new(10, 10, 10, 10);
691        let mut filter = PadFilter::new(NodeId(0), "pad", config);
692
693        let result = filter.process(None).expect("process should succeed");
694        assert!(result.is_none());
695    }
696
697    #[test]
698    fn test_output_dimensions() {
699        let config = PadConfig::to_size(1920, 1080);
700        let (w, h) = config.output_dimensions(1280, 720);
701        assert_eq!(w, 1920);
702        assert_eq!(h, 1080);
703    }
704}