1#![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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
36pub enum PadColor {
37 Black,
39 White,
41 Gray,
43 YuvColor {
45 y: u8,
47 u: u8,
49 v: u8,
51 },
52 RgbColor {
54 r: u8,
56 g: u8,
58 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 #[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 #[must_use]
97 pub fn yuv(y: u8, u: u8, v: u8) -> Self {
98 Self::YuvColor { y, u, v }
99 }
100
101 #[must_use]
103 pub fn rgb(r: u8, g: u8, b: u8) -> Self {
104 Self::RgbColor { r, g, b }
105 }
106}
107
108fn 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#[derive(Clone, Debug)]
127pub struct PadConfig {
128 pub left: u32,
130 pub top: u32,
132 pub right: u32,
134 pub bottom: u32,
136 pub color: PadColor,
138 pub target_width: Option<u32>,
140 pub target_height: Option<u32>,
142 pub target_aspect: Option<f64>,
144}
145
146impl PadConfig {
147 #[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 #[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 #[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 #[must_use]
194 pub fn with_color(mut self, color: PadColor) -> Self {
195 self.color = color;
196 self
197 }
198
199 #[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 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 calculate_aspect_padding(src_width, src_height, target_aspect)
216 } else {
217 PadValues {
219 left: self.left,
220 top: self.top,
221 right: self.right,
222 bottom: self.bottom,
223 }
224 }
225 }
226
227 #[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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
240pub struct PadValues {
241 pub left: u32,
243 pub top: u32,
245 pub right: u32,
247 pub bottom: u32,
249}
250
251impl PadValues {
252 #[must_use]
254 pub fn horizontal(&self) -> u32 {
255 self.left + self.right
256 }
257
258 #[must_use]
260 pub fn vertical(&self) -> u32 {
261 self.top + self.bottom
262 }
263
264 #[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 #[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
282fn 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 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 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
309pub 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 #[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 #[must_use]
353 pub fn config(&self) -> &PadConfig {
354 &self.config
355 }
356
357 pub fn set_config(&mut self, config: PadConfig) {
359 self.config = config;
360 }
361
362 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 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 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#[must_use]
474pub fn letterbox_16_9() -> PadConfig {
475 PadConfig::for_aspect(16.0 / 9.0).with_color(PadColor::Black)
476}
477
478#[must_use]
480pub fn letterbox_4_3() -> PadConfig {
481 PadConfig::for_aspect(4.0 / 3.0).with_color(PadColor::Black)
482}
483
484#[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 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); let (y, u, v) = color.to_yuv(true);
534 assert!(y > 50);
536 assert!(u < 128);
537 assert!(v > 128);
538 }
539
540 #[test]
541 fn test_rgb_to_yuv() {
542 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 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 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 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); assert_eq!(frame.height, 520); } 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}