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, Debug)]
36pub struct CropConfig {
37 pub left: u32,
39 pub top: u32,
41 pub width: u32,
43 pub height: u32,
45 pub auto_center: bool,
47 pub preserve_aspect: bool,
49 pub target_aspect: Option<f64>,
51}
52
53impl CropConfig {
54 #[must_use]
56 pub fn new(left: u32, top: u32, width: u32, height: u32) -> Self {
57 Self {
58 left,
59 top,
60 width,
61 height,
62 auto_center: false,
63 preserve_aspect: false,
64 target_aspect: None,
65 }
66 }
67
68 #[must_use]
70 pub fn centered(width: u32, height: u32) -> Self {
71 Self {
72 left: 0,
73 top: 0,
74 width,
75 height,
76 auto_center: true,
77 preserve_aspect: false,
78 target_aspect: None,
79 }
80 }
81
82 #[must_use]
84 pub fn with_aspect_ratio(width: u32, height: u32, aspect: f64) -> Self {
85 Self {
86 left: 0,
87 top: 0,
88 width,
89 height,
90 auto_center: true,
91 preserve_aspect: true,
92 target_aspect: Some(aspect),
93 }
94 }
95
96 #[must_use]
98 pub fn with_auto_center(mut self, enabled: bool) -> Self {
99 self.auto_center = enabled;
100 self
101 }
102
103 #[must_use]
105 pub fn with_target_aspect(mut self, aspect: f64) -> Self {
106 self.preserve_aspect = true;
107 self.target_aspect = Some(aspect);
108 self
109 }
110
111 pub fn validate(&self, src_width: u32, src_height: u32) -> GraphResult<()> {
113 if self.width == 0 || self.height == 0 {
114 return Err(GraphError::ConfigurationError(
115 "Crop dimensions cannot be zero".to_string(),
116 ));
117 }
118
119 if !self.auto_center {
120 if self.left + self.width > src_width {
121 return Err(GraphError::ConfigurationError(format!(
122 "Crop region exceeds source width: {} + {} > {}",
123 self.left, self.width, src_width
124 )));
125 }
126
127 if self.top + self.height > src_height {
128 return Err(GraphError::ConfigurationError(format!(
129 "Crop region exceeds source height: {} + {} > {}",
130 self.top, self.height, src_height
131 )));
132 }
133 }
134
135 Ok(())
136 }
137
138 #[must_use]
140 pub fn calculate_region(&self, src_width: u32, src_height: u32) -> CropRegion {
141 let (width, height) = if self.preserve_aspect {
142 if let Some(target_aspect) = self.target_aspect {
143 calculate_aspect_crop(src_width, src_height, target_aspect)
144 } else {
145 (self.width.min(src_width), self.height.min(src_height))
146 }
147 } else {
148 (self.width.min(src_width), self.height.min(src_height))
149 };
150
151 let (left, top) = if self.auto_center {
152 let left = (src_width.saturating_sub(width)) / 2;
153 let top = (src_height.saturating_sub(height)) / 2;
154 (left, top)
155 } else {
156 (
157 self.left.min(src_width.saturating_sub(width)),
158 self.top.min(src_height.saturating_sub(height)),
159 )
160 };
161
162 CropRegion {
163 left,
164 top,
165 width,
166 height,
167 }
168 }
169}
170
171#[derive(Clone, Copy, Debug, PartialEq, Eq)]
173pub struct CropRegion {
174 pub left: u32,
176 pub top: u32,
178 pub width: u32,
180 pub height: u32,
182}
183
184impl CropRegion {
185 #[must_use]
187 pub fn right(&self) -> u32 {
188 self.left + self.width
189 }
190
191 #[must_use]
193 pub fn bottom(&self) -> u32 {
194 self.top + self.height
195 }
196
197 #[must_use]
199 pub fn contains(&self, x: u32, y: u32) -> bool {
200 x >= self.left && x < self.right() && y >= self.top && y < self.bottom()
201 }
202
203 #[must_use]
205 pub fn scale_for_chroma(&self, h_ratio: u32, v_ratio: u32) -> Self {
206 Self {
207 left: self.left / h_ratio,
208 top: self.top / v_ratio,
209 width: self.width / h_ratio,
210 height: self.height / v_ratio,
211 }
212 }
213}
214
215fn calculate_aspect_crop(src_width: u32, src_height: u32, target_aspect: f64) -> (u32, u32) {
217 let src_aspect = src_width as f64 / src_height as f64;
218
219 if src_aspect > target_aspect {
220 let new_width = (src_height as f64 * target_aspect).round() as u32;
222 (new_width, src_height)
223 } else {
224 let new_height = (src_width as f64 / target_aspect).round() as u32;
226 (src_width, new_height)
227 }
228}
229
230pub struct CropFilter {
250 id: NodeId,
251 name: String,
252 state: NodeState,
253 inputs: Vec<InputPort>,
254 outputs: Vec<OutputPort>,
255 config: CropConfig,
256}
257
258impl CropFilter {
259 #[must_use]
261 pub fn new(id: NodeId, name: impl Into<String>, config: CropConfig) -> Self {
262 let output_format =
263 PortFormat::Video(VideoPortFormat::any().with_dimensions(config.width, config.height));
264
265 Self {
266 id,
267 name: name.into(),
268 state: NodeState::Idle,
269 inputs: vec![InputPort::new(PortId(0), "input", PortType::Video)
270 .with_format(PortFormat::Video(VideoPortFormat::any()))],
271 outputs: vec![
272 OutputPort::new(PortId(0), "output", PortType::Video).with_format(output_format)
273 ],
274 config,
275 }
276 }
277
278 #[must_use]
280 pub fn config(&self) -> &CropConfig {
281 &self.config
282 }
283
284 pub fn set_config(&mut self, config: CropConfig) {
286 self.config = config;
287 }
288
289 fn crop_plane(&self, src: &Plane, _src_width: u32, region: &CropRegion) -> Plane {
291 let mut dst_data = vec![0u8; region.width as usize * region.height as usize];
292
293 for y in 0..region.height as usize {
294 let src_y = region.top as usize + y;
295 let src_row = src.row(src_y);
296 let dst_start = y * region.width as usize;
297
298 for x in 0..region.width as usize {
299 let src_x = region.left as usize + x;
300 dst_data[dst_start + x] = src_row.get(src_x).copied().unwrap_or(0);
301 }
302 }
303
304 Plane::new(dst_data, region.width as usize)
305 }
306
307 fn crop_frame(&self, input: &VideoFrame) -> GraphResult<VideoFrame> {
309 let region = self.config.calculate_region(input.width, input.height);
310
311 if region.right() > input.width || region.bottom() > input.height {
313 return Err(GraphError::ConfigurationError(
314 "Crop region exceeds frame dimensions".to_string(),
315 ));
316 }
317
318 let mut output = VideoFrame::new(input.format, region.width, region.height);
319 output.timestamp = input.timestamp;
320 output.frame_type = input.frame_type;
321 output.color_info = input.color_info;
322
323 for (i, src_plane) in input.planes.iter().enumerate() {
324 let (src_w, _src_h) = input.plane_dimensions(i);
325
326 let plane_region = if i > 0 && input.format.is_yuv() {
327 let (h_ratio, v_ratio) = input.format.chroma_subsampling();
328 region.scale_for_chroma(h_ratio, v_ratio)
329 } else {
330 region
331 };
332
333 let plane = self.crop_plane(src_plane, src_w, &plane_region);
334 output.planes.push(plane);
335 }
336
337 Ok(output)
338 }
339}
340
341impl Node for CropFilter {
342 fn id(&self) -> NodeId {
343 self.id
344 }
345
346 fn name(&self) -> &str {
347 &self.name
348 }
349
350 fn node_type(&self) -> NodeType {
351 NodeType::Filter
352 }
353
354 fn state(&self) -> NodeState {
355 self.state
356 }
357
358 fn set_state(&mut self, state: NodeState) -> GraphResult<()> {
359 if !self.state.can_transition_to(state) {
360 return Err(GraphError::InvalidStateTransition {
361 node: self.id,
362 from: self.state.to_string(),
363 to: state.to_string(),
364 });
365 }
366 self.state = state;
367 Ok(())
368 }
369
370 fn inputs(&self) -> &[InputPort] {
371 &self.inputs
372 }
373
374 fn outputs(&self) -> &[OutputPort] {
375 &self.outputs
376 }
377
378 fn process(&mut self, input: Option<FilterFrame>) -> GraphResult<Option<FilterFrame>> {
379 match input {
380 Some(FilterFrame::Video(frame)) => {
381 let cropped = self.crop_frame(&frame)?;
382 Ok(Some(FilterFrame::Video(cropped)))
383 }
384 Some(_) => Err(GraphError::PortTypeMismatch {
385 expected: "Video".to_string(),
386 actual: "Audio".to_string(),
387 }),
388 None => Ok(None),
389 }
390 }
391}
392
393#[derive(Debug)]
395pub struct BorderDetector {
396 pub threshold: u8,
398 pub min_border_size: u32,
400}
401
402impl Default for BorderDetector {
403 fn default() -> Self {
404 Self {
405 threshold: 16,
406 min_border_size: 4,
407 }
408 }
409}
410
411impl BorderDetector {
412 #[must_use]
414 pub fn new(threshold: u8, min_border_size: u32) -> Self {
415 Self {
416 threshold,
417 min_border_size,
418 }
419 }
420
421 #[must_use]
423 pub fn detect(&self, frame: &VideoFrame) -> CropRegion {
424 if frame.planes.is_empty() {
425 return CropRegion {
426 left: 0,
427 top: 0,
428 width: frame.width,
429 height: frame.height,
430 };
431 }
432
433 let luma = &frame.planes[0];
434
435 let mut top = 0u32;
437 for y in 0..frame.height {
438 if !self.is_row_black(luma, y, frame.width) {
439 top = y;
440 break;
441 }
442 }
443
444 let mut bottom = frame.height;
446 for y in (0..frame.height).rev() {
447 if !self.is_row_black(luma, y, frame.width) {
448 bottom = y + 1;
449 break;
450 }
451 }
452
453 let mut left = 0u32;
455 for x in 0..frame.width {
456 if !self.is_column_black(luma, x, frame.height) {
457 left = x;
458 break;
459 }
460 }
461
462 let mut right = frame.width;
464 for x in (0..frame.width).rev() {
465 if !self.is_column_black(luma, x, frame.height) {
466 right = x + 1;
467 break;
468 }
469 }
470
471 if top < self.min_border_size {
473 top = 0;
474 }
475 if (frame.height - bottom) < self.min_border_size {
476 bottom = frame.height;
477 }
478 if left < self.min_border_size {
479 left = 0;
480 }
481 if (frame.width - right) < self.min_border_size {
482 right = frame.width;
483 }
484
485 CropRegion {
486 left,
487 top,
488 width: right.saturating_sub(left),
489 height: bottom.saturating_sub(top),
490 }
491 }
492
493 fn is_row_black(&self, plane: &Plane, y: u32, width: u32) -> bool {
495 let row = plane.row(y as usize);
496 for x in 0..width as usize {
497 if row.get(x).copied().unwrap_or(0) > self.threshold {
498 return false;
499 }
500 }
501 true
502 }
503
504 fn is_column_black(&self, plane: &Plane, x: u32, height: u32) -> bool {
506 for y in 0..height {
507 let row = plane.row(y as usize);
508 if row.get(x as usize).copied().unwrap_or(0) > self.threshold {
509 return false;
510 }
511 }
512 true
513 }
514}
515
516#[cfg(test)]
517mod tests {
518 use super::*;
519 use oximedia_core::PixelFormat;
520
521 fn create_test_frame(width: u32, height: u32) -> VideoFrame {
522 let mut frame = VideoFrame::new(PixelFormat::Yuv420p, width, height);
523 frame.allocate();
524
525 if let Some(plane) = frame.planes.get_mut(0) {
527 let mut data = vec![0u8; width as usize * height as usize];
528 for y in 0..height as usize {
529 for x in 0..width as usize {
530 data[y * width as usize + x] = ((x + y) % 256) as u8;
531 }
532 }
533 *plane = Plane::new(data, width as usize);
534 }
535
536 frame
537 }
538
539 #[test]
540 fn test_crop_config_creation() {
541 let config = CropConfig::new(10, 20, 100, 80);
542 assert_eq!(config.left, 10);
543 assert_eq!(config.top, 20);
544 assert_eq!(config.width, 100);
545 assert_eq!(config.height, 80);
546 assert!(!config.auto_center);
547 }
548
549 #[test]
550 fn test_crop_config_centered() {
551 let config = CropConfig::centered(640, 480);
552 assert!(config.auto_center);
553 assert_eq!(config.width, 640);
554 assert_eq!(config.height, 480);
555 }
556
557 #[test]
558 fn test_crop_config_with_aspect() {
559 let config = CropConfig::with_aspect_ratio(0, 0, 16.0 / 9.0);
560 assert!(config.preserve_aspect);
561 assert!(config.target_aspect.is_some());
562 }
563
564 #[test]
565 fn test_crop_region_calculation() {
566 let config = CropConfig::centered(320, 240);
567 let region = config.calculate_region(640, 480);
568
569 assert_eq!(region.left, 160);
570 assert_eq!(region.top, 120);
571 assert_eq!(region.width, 320);
572 assert_eq!(region.height, 240);
573 }
574
575 #[test]
576 fn test_crop_region_contains() {
577 let region = CropRegion {
578 left: 10,
579 top: 20,
580 width: 100,
581 height: 80,
582 };
583
584 assert!(region.contains(10, 20));
585 assert!(region.contains(50, 50));
586 assert!(region.contains(109, 99));
587 assert!(!region.contains(9, 20));
588 assert!(!region.contains(110, 50));
589 }
590
591 #[test]
592 fn test_crop_region_scale_for_chroma() {
593 let region = CropRegion {
594 left: 100,
595 top: 200,
596 width: 400,
597 height: 300,
598 };
599
600 let scaled = region.scale_for_chroma(2, 2);
601 assert_eq!(scaled.left, 50);
602 assert_eq!(scaled.top, 100);
603 assert_eq!(scaled.width, 200);
604 assert_eq!(scaled.height, 150);
605 }
606
607 #[test]
608 fn test_crop_filter_creation() {
609 let config = CropConfig::new(0, 0, 640, 480);
610 let filter = CropFilter::new(NodeId(0), "crop", config);
611
612 assert_eq!(filter.id(), NodeId(0));
613 assert_eq!(filter.name(), "crop");
614 assert_eq!(filter.node_type(), NodeType::Filter);
615 }
616
617 #[test]
618 fn test_crop_filter_process() {
619 let config = CropConfig::centered(320, 240);
620 let mut filter = CropFilter::new(NodeId(0), "crop", config);
621
622 let input = create_test_frame(640, 480);
623 let result = filter
624 .process(Some(FilterFrame::Video(input)))
625 .expect("operation should succeed")
626 .expect("operation should succeed");
627
628 if let FilterFrame::Video(frame) = result {
629 assert_eq!(frame.width, 320);
630 assert_eq!(frame.height, 240);
631 } else {
632 panic!("Expected video frame");
633 }
634 }
635
636 #[test]
637 fn test_crop_config_validation() {
638 let config = CropConfig::new(0, 0, 0, 100);
639 assert!(config.validate(640, 480).is_err());
640
641 let config = CropConfig::new(600, 0, 100, 100);
642 assert!(config.validate(640, 480).is_err());
643
644 let config = CropConfig::new(0, 0, 320, 240);
645 assert!(config.validate(640, 480).is_ok());
646 }
647
648 #[test]
649 fn test_aspect_crop_calculation() {
650 let (w, h) = calculate_aspect_crop(640, 480, 16.0 / 9.0);
652 let result_aspect = w as f64 / h as f64;
653 assert!((result_aspect - 16.0 / 9.0).abs() < 0.01);
654
655 let (w, h) = calculate_aspect_crop(1920, 1080, 4.0 / 3.0);
657 let result_aspect = w as f64 / h as f64;
658 assert!((result_aspect - 4.0 / 3.0).abs() < 0.01);
659 }
660
661 #[test]
662 fn test_border_detector_default() {
663 let detector = BorderDetector::default();
664 assert_eq!(detector.threshold, 16);
665 assert_eq!(detector.min_border_size, 4);
666 }
667
668 #[test]
669 fn test_border_detector_on_test_frame() {
670 let detector = BorderDetector::new(16, 1);
671 let frame = create_test_frame(640, 480);
672
673 let region = detector.detect(&frame);
674 assert!(region.width > 0);
676 assert!(region.height > 0);
677 }
678
679 #[test]
680 fn test_node_state_transitions() {
681 let config = CropConfig::centered(320, 240);
682 let mut filter = CropFilter::new(NodeId(0), "crop", config);
683
684 assert_eq!(filter.state(), NodeState::Idle);
685 filter
686 .set_state(NodeState::Processing)
687 .expect("set_state should succeed");
688 assert_eq!(filter.state(), NodeState::Processing);
689 }
690
691 #[test]
692 fn test_process_none_input() {
693 let config = CropConfig::centered(320, 240);
694 let mut filter = CropFilter::new(NodeId(0), "crop", config);
695
696 let result = filter.process(None).expect("process should succeed");
697 assert!(result.is_none());
698 }
699}