Skip to main content

oximedia_graph/filters/video/
scale.rs

1//! Video scaling filter.
2//!
3//! This filter rescales video frames to a target resolution using various
4//! resampling algorithms including Lanczos, Bicubic, Bilinear, and Nearest Neighbor.
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 std::f64::consts::PI;
29
30use crate::error::{GraphError, GraphResult};
31use crate::frame::FilterFrame;
32use crate::node::{Node, NodeId, NodeState, NodeType};
33use crate::port::{InputPort, OutputPort, PortFormat, PortId, PortType, VideoPortFormat};
34use oximedia_codec::{Plane, VideoFrame};
35
36/// Scaling algorithm for image resampling.
37#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
38pub enum ScaleAlgorithm {
39    /// Nearest neighbor - fastest, lowest quality, good for pixel art.
40    Nearest,
41    /// Bilinear interpolation - fast, moderate quality.
42    Bilinear,
43    /// Bicubic interpolation using Mitchell-Netravali coefficients.
44    #[default]
45    Bicubic,
46    /// Bicubic interpolation using Catmull-Rom spline.
47    CatmullRom,
48    /// Lanczos-2 - high quality, 2-tap sinc window.
49    Lanczos2,
50    /// Lanczos-3 - higher quality, 3-tap sinc window.
51    Lanczos3,
52    /// Lanczos-4 - highest quality, 4-tap sinc window.
53    Lanczos4,
54}
55
56impl ScaleAlgorithm {
57    /// Get the filter support (radius in source pixels).
58    #[must_use]
59    pub fn support(&self) -> f64 {
60        match self {
61            Self::Nearest => 0.5,
62            Self::Bilinear => 1.0,
63            Self::Bicubic | Self::CatmullRom => 2.0,
64            Self::Lanczos2 => 2.0,
65            Self::Lanczos3 => 3.0,
66            Self::Lanczos4 => 4.0,
67        }
68    }
69
70    /// Calculate the kernel value at position x.
71    #[must_use]
72    pub fn kernel(&self, x: f64) -> f64 {
73        let x = x.abs();
74        match self {
75            Self::Nearest => {
76                if x < 0.5 {
77                    1.0
78                } else {
79                    0.0
80                }
81            }
82            Self::Bilinear => bilinear_kernel(x),
83            Self::Bicubic => mitchell_netravali_kernel(x),
84            Self::CatmullRom => catmull_rom_kernel(x),
85            Self::Lanczos2 => lanczos_kernel(x, 2.0),
86            Self::Lanczos3 => lanczos_kernel(x, 3.0),
87            Self::Lanczos4 => lanczos_kernel(x, 4.0),
88        }
89    }
90}
91
92/// Bilinear interpolation kernel.
93fn bilinear_kernel(x: f64) -> f64 {
94    if x < 1.0 {
95        1.0 - x
96    } else {
97        0.0
98    }
99}
100
101/// Mitchell-Netravali bicubic kernel with B=1/3, C=1/3.
102fn mitchell_netravali_kernel(x: f64) -> f64 {
103    const B: f64 = 1.0 / 3.0;
104    const C: f64 = 1.0 / 3.0;
105
106    let x2 = x * x;
107    let x3 = x2 * x;
108
109    if x < 1.0 {
110        ((12.0 - 9.0 * B - 6.0 * C) * x3 + (-18.0 + 12.0 * B + 6.0 * C) * x2 + (6.0 - 2.0 * B))
111            / 6.0
112    } else if x < 2.0 {
113        ((-B - 6.0 * C) * x3
114            + (6.0 * B + 30.0 * C) * x2
115            + (-12.0 * B - 48.0 * C) * x
116            + (8.0 * B + 24.0 * C))
117            / 6.0
118    } else {
119        0.0
120    }
121}
122
123/// Catmull-Rom bicubic kernel (B=0, C=0.5).
124fn catmull_rom_kernel(x: f64) -> f64 {
125    let x2 = x * x;
126    let x3 = x2 * x;
127
128    if x < 1.0 {
129        1.5 * x3 - 2.5 * x2 + 1.0
130    } else if x < 2.0 {
131        -0.5 * x3 + 2.5 * x2 - 4.0 * x + 2.0
132    } else {
133        0.0
134    }
135}
136
137/// Lanczos windowed sinc kernel.
138fn lanczos_kernel(x: f64, a: f64) -> f64 {
139    if x == 0.0 {
140        1.0
141    } else if x < a {
142        sinc(x) * sinc(x / a)
143    } else {
144        0.0
145    }
146}
147
148/// Normalized sinc function.
149fn sinc(x: f64) -> f64 {
150    if x == 0.0 {
151        1.0
152    } else {
153        let pix = PI * x;
154        pix.sin() / pix
155    }
156}
157
158/// Configuration for the scale filter.
159#[derive(Clone, Debug)]
160pub struct ScaleConfig {
161    /// Target width.
162    pub width: u32,
163    /// Target height.
164    pub height: u32,
165    /// Scaling algorithm.
166    pub algorithm: ScaleAlgorithm,
167    /// Enable anti-aliasing for downscaling.
168    pub antialias: bool,
169    /// Preserve aspect ratio (letterbox/pillarbox as needed).
170    pub preserve_aspect: bool,
171}
172
173impl ScaleConfig {
174    /// Create a new scale configuration.
175    #[must_use]
176    pub fn new(width: u32, height: u32) -> Self {
177        Self {
178            width,
179            height,
180            algorithm: ScaleAlgorithm::default(),
181            antialias: true,
182            preserve_aspect: false,
183        }
184    }
185
186    /// Set the scaling algorithm.
187    #[must_use]
188    pub fn with_algorithm(mut self, algorithm: ScaleAlgorithm) -> Self {
189        self.algorithm = algorithm;
190        self
191    }
192
193    /// Enable or disable anti-aliasing.
194    #[must_use]
195    pub fn with_antialias(mut self, enabled: bool) -> Self {
196        self.antialias = enabled;
197        self
198    }
199
200    /// Enable or disable aspect ratio preservation.
201    #[must_use]
202    pub fn with_preserve_aspect(mut self, enabled: bool) -> Self {
203        self.preserve_aspect = enabled;
204        self
205    }
206}
207
208/// Video scaling filter.
209///
210/// Rescales video frames to a target resolution using configurable
211/// resampling algorithms.
212///
213/// # Example
214///
215/// ```ignore
216/// use oximedia_graph::filters::video::{ScaleFilter, ScaleConfig, ScaleAlgorithm};
217/// use oximedia_graph::node::NodeId;
218///
219/// let config = ScaleConfig::new(1280, 720)
220///     .with_algorithm(ScaleAlgorithm::Lanczos3)
221///     .with_antialias(true);
222///
223/// let filter = ScaleFilter::new(NodeId(0), "scale", config);
224/// ```
225pub struct ScaleFilter {
226    id: NodeId,
227    name: String,
228    state: NodeState,
229    inputs: Vec<InputPort>,
230    outputs: Vec<OutputPort>,
231    config: ScaleConfig,
232    /// Precomputed horizontal filter coefficients.
233    h_coefficients: Vec<FilterCoefficients>,
234    /// Precomputed vertical filter coefficients.
235    v_coefficients: Vec<FilterCoefficients>,
236    /// Source dimensions (cached for coefficient reuse).
237    cached_src_dims: Option<(u32, u32)>,
238}
239
240/// Filter coefficients for a single output pixel.
241#[derive(Clone, Debug)]
242struct FilterCoefficients {
243    /// Starting position in source.
244    start: usize,
245    /// Coefficient weights.
246    weights: Vec<f64>,
247}
248
249impl ScaleFilter {
250    /// Create a new scale filter.
251    #[must_use]
252    pub fn new(id: NodeId, name: impl Into<String>, config: ScaleConfig) -> Self {
253        let output_format =
254            PortFormat::Video(VideoPortFormat::any().with_dimensions(config.width, config.height));
255
256        Self {
257            id,
258            name: name.into(),
259            state: NodeState::Idle,
260            inputs: vec![InputPort::new(PortId(0), "input", PortType::Video)
261                .with_format(PortFormat::Video(VideoPortFormat::any()))],
262            outputs: vec![
263                OutputPort::new(PortId(0), "output", PortType::Video).with_format(output_format)
264            ],
265            config,
266            h_coefficients: Vec::new(),
267            v_coefficients: Vec::new(),
268            cached_src_dims: None,
269        }
270    }
271
272    /// Get the current configuration.
273    #[must_use]
274    pub fn config(&self) -> &ScaleConfig {
275        &self.config
276    }
277
278    /// Update the target dimensions.
279    pub fn set_dimensions(&mut self, width: u32, height: u32) {
280        if self.config.width != width || self.config.height != height {
281            self.config.width = width;
282            self.config.height = height;
283            self.cached_src_dims = None;
284            self.h_coefficients.clear();
285            self.v_coefficients.clear();
286        }
287    }
288
289    /// Precompute filter coefficients for a given source and target size.
290    fn compute_coefficients(&mut self, src_width: u32, src_height: u32) {
291        if self.cached_src_dims == Some((src_width, src_height)) {
292            return;
293        }
294
295        self.h_coefficients =
296            Self::compute_1d_coefficients(src_width, self.config.width, &self.config);
297        self.v_coefficients =
298            Self::compute_1d_coefficients(src_height, self.config.height, &self.config);
299        self.cached_src_dims = Some((src_width, src_height));
300    }
301
302    /// Compute 1D filter coefficients.
303    fn compute_1d_coefficients(
304        src_size: u32,
305        dst_size: u32,
306        config: &ScaleConfig,
307    ) -> Vec<FilterCoefficients> {
308        let mut coefficients = Vec::with_capacity(dst_size as usize);
309        let scale = src_size as f64 / dst_size as f64;
310        let algorithm = config.algorithm;
311
312        // For downscaling with antialiasing, expand the filter support
313        let filter_scale = if config.antialias && scale > 1.0 {
314            scale
315        } else {
316            1.0
317        };
318
319        let support = algorithm.support() * filter_scale;
320
321        for dst_pos in 0..dst_size {
322            let center = (dst_pos as f64 + 0.5) * scale - 0.5;
323            let start = ((center - support).floor() as i64).max(0) as usize;
324            let end = ((center + support).ceil() as i64).min(src_size as i64) as usize;
325
326            let mut weights = Vec::with_capacity(end - start);
327            let mut sum = 0.0;
328
329            for src_pos in start..end {
330                let distance = (src_pos as f64 - center) / filter_scale;
331                let weight = algorithm.kernel(distance);
332                weights.push(weight);
333                sum += weight;
334            }
335
336            // Normalize weights
337            if sum != 0.0 {
338                for w in &mut weights {
339                    *w /= sum;
340                }
341            }
342
343            coefficients.push(FilterCoefficients { start, weights });
344        }
345
346        coefficients
347    }
348
349    /// Scale a single plane.
350    #[allow(clippy::too_many_arguments)]
351    fn scale_plane(
352        &self,
353        src: &Plane,
354        src_width: u32,
355        src_height: u32,
356        dst_width: u32,
357        dst_height: u32,
358    ) -> Plane {
359        // Create intermediate buffer for horizontal pass
360        let mut intermediate = vec![0.0f64; dst_width as usize * src_height as usize];
361
362        // Horizontal pass
363        for y in 0..src_height as usize {
364            let src_row = src.row(y);
365            for (x, coef) in self.h_coefficients.iter().enumerate() {
366                let mut sum = 0.0;
367                for (i, &weight) in coef.weights.iter().enumerate() {
368                    let src_x = (coef.start + i).min(src_width as usize - 1);
369                    sum += src_row.get(src_x).copied().unwrap_or(0) as f64 * weight;
370                }
371                intermediate[y * dst_width as usize + x] = sum;
372            }
373        }
374
375        // Vertical pass
376        let mut dst_data = vec![0u8; dst_width as usize * dst_height as usize];
377
378        for y in 0..dst_height as usize {
379            let coef = &self.v_coefficients[y];
380            for x in 0..dst_width as usize {
381                let mut sum = 0.0;
382                for (i, &weight) in coef.weights.iter().enumerate() {
383                    let src_y = (coef.start + i).min(src_height as usize - 1);
384                    sum += intermediate[src_y * dst_width as usize + x] * weight;
385                }
386                dst_data[y * dst_width as usize + x] = sum.round().clamp(0.0, 255.0) as u8;
387            }
388        }
389
390        Plane::new(dst_data, dst_width as usize)
391    }
392
393    /// Scale a video frame.
394    fn scale_frame(&mut self, input: &VideoFrame) -> VideoFrame {
395        self.compute_coefficients(input.width, input.height);
396
397        let mut output = VideoFrame::new(input.format, self.config.width, self.config.height);
398        output.timestamp = input.timestamp;
399        output.frame_type = input.frame_type;
400        output.color_info = input.color_info;
401
402        // Scale each plane
403        for (i, src_plane) in input.planes.iter().enumerate() {
404            let (src_w, src_h) = input.plane_dimensions(i);
405            let (dst_w, dst_h) = output.plane_dimensions(i);
406
407            // For chroma planes, we need to compute separate coefficients
408            if i > 0 && input.format.is_yuv() {
409                let old_h = self.h_coefficients.clone();
410                let old_v = self.v_coefficients.clone();
411                let old_cached = self.cached_src_dims;
412
413                self.h_coefficients = Self::compute_1d_coefficients(src_w, dst_w, &self.config);
414                self.v_coefficients = Self::compute_1d_coefficients(src_h, dst_h, &self.config);
415
416                let plane = self.scale_plane(src_plane, src_w, src_h, dst_w, dst_h);
417                output.planes.push(plane);
418
419                self.h_coefficients = old_h;
420                self.v_coefficients = old_v;
421                self.cached_src_dims = old_cached;
422            } else {
423                let plane = self.scale_plane(src_plane, src_w, src_h, dst_w, dst_h);
424                output.planes.push(plane);
425            }
426        }
427
428        output
429    }
430}
431
432impl Node for ScaleFilter {
433    fn id(&self) -> NodeId {
434        self.id
435    }
436
437    fn name(&self) -> &str {
438        &self.name
439    }
440
441    fn node_type(&self) -> NodeType {
442        NodeType::Filter
443    }
444
445    fn state(&self) -> NodeState {
446        self.state
447    }
448
449    fn set_state(&mut self, state: NodeState) -> GraphResult<()> {
450        if !self.state.can_transition_to(state) {
451            return Err(GraphError::InvalidStateTransition {
452                node: self.id,
453                from: self.state.to_string(),
454                to: state.to_string(),
455            });
456        }
457        self.state = state;
458        Ok(())
459    }
460
461    fn inputs(&self) -> &[InputPort] {
462        &self.inputs
463    }
464
465    fn outputs(&self) -> &[OutputPort] {
466        &self.outputs
467    }
468
469    fn process(&mut self, input: Option<FilterFrame>) -> GraphResult<Option<FilterFrame>> {
470        match input {
471            Some(FilterFrame::Video(frame)) => {
472                let scaled = self.scale_frame(&frame);
473                Ok(Some(FilterFrame::Video(scaled)))
474            }
475            Some(_) => Err(GraphError::PortTypeMismatch {
476                expected: "Video".to_string(),
477                actual: "Audio".to_string(),
478            }),
479            None => Ok(None),
480        }
481    }
482}
483
484/// Nearest neighbor scaler for fast, low-quality scaling.
485#[derive(Debug)]
486pub struct NearestNeighborScaler {
487    dst_width: u32,
488    dst_height: u32,
489}
490
491impl NearestNeighborScaler {
492    /// Create a new nearest neighbor scaler.
493    #[must_use]
494    pub fn new(dst_width: u32, dst_height: u32) -> Self {
495        Self {
496            dst_width,
497            dst_height,
498        }
499    }
500
501    /// Scale a plane using nearest neighbor interpolation.
502    #[must_use]
503    pub fn scale_plane(&self, src: &Plane, src_width: u32, src_height: u32) -> Plane {
504        let mut dst_data = vec![0u8; self.dst_width as usize * self.dst_height as usize];
505
506        let x_ratio = src_width as f64 / self.dst_width as f64;
507        let y_ratio = src_height as f64 / self.dst_height as f64;
508
509        for y in 0..self.dst_height as usize {
510            let src_y = ((y as f64 + 0.5) * y_ratio).floor() as usize;
511            let src_y = src_y.min(src_height as usize - 1);
512            let src_row = src.row(src_y);
513
514            for x in 0..self.dst_width as usize {
515                let src_x = ((x as f64 + 0.5) * x_ratio).floor() as usize;
516                let src_x = src_x.min(src_width as usize - 1);
517                dst_data[y * self.dst_width as usize + x] =
518                    src_row.get(src_x).copied().unwrap_or(0);
519            }
520        }
521
522        Plane::new(dst_data, self.dst_width as usize)
523    }
524}
525
526/// Bilinear scaler for moderate quality scaling.
527#[derive(Debug)]
528pub struct BilinearScaler {
529    dst_width: u32,
530    dst_height: u32,
531}
532
533impl BilinearScaler {
534    /// Create a new bilinear scaler.
535    #[must_use]
536    pub fn new(dst_width: u32, dst_height: u32) -> Self {
537        Self {
538            dst_width,
539            dst_height,
540        }
541    }
542
543    /// Scale a plane using bilinear interpolation.
544    #[must_use]
545    pub fn scale_plane(&self, src: &Plane, src_width: u32, src_height: u32) -> Plane {
546        let mut dst_data = vec![0u8; self.dst_width as usize * self.dst_height as usize];
547
548        let x_ratio = (src_width as f64 - 1.0) / (self.dst_width as f64 - 1.0).max(1.0);
549        let y_ratio = (src_height as f64 - 1.0) / (self.dst_height as f64 - 1.0).max(1.0);
550
551        for y in 0..self.dst_height as usize {
552            let src_y = y as f64 * y_ratio;
553            let y0 = src_y.floor() as usize;
554            let y1 = (y0 + 1).min(src_height as usize - 1);
555            let y_frac = src_y - y0 as f64;
556
557            let row0 = src.row(y0);
558            let row1 = src.row(y1);
559
560            for x in 0..self.dst_width as usize {
561                let src_x = x as f64 * x_ratio;
562                let x0 = src_x.floor() as usize;
563                let x1 = (x0 + 1).min(src_width as usize - 1);
564                let x_frac = src_x - x0 as f64;
565
566                let p00 = row0.get(x0).copied().unwrap_or(0) as f64;
567                let p10 = row0.get(x1).copied().unwrap_or(0) as f64;
568                let p01 = row1.get(x0).copied().unwrap_or(0) as f64;
569                let p11 = row1.get(x1).copied().unwrap_or(0) as f64;
570
571                let top = p00 * (1.0 - x_frac) + p10 * x_frac;
572                let bottom = p01 * (1.0 - x_frac) + p11 * x_frac;
573                let value = top * (1.0 - y_frac) + bottom * y_frac;
574
575                dst_data[y * self.dst_width as usize + x] = value.round().clamp(0.0, 255.0) as u8;
576            }
577        }
578
579        Plane::new(dst_data, self.dst_width as usize)
580    }
581}
582
583/// Calculate the aspect ratio preserving dimensions.
584#[must_use]
585pub fn calculate_aspect_fit(
586    src_width: u32,
587    src_height: u32,
588    dst_width: u32,
589    dst_height: u32,
590) -> (u32, u32) {
591    let src_aspect = src_width as f64 / src_height as f64;
592    let dst_aspect = dst_width as f64 / dst_height as f64;
593
594    if src_aspect > dst_aspect {
595        // Width limited
596        let new_height = (dst_width as f64 / src_aspect).round() as u32;
597        (dst_width, new_height)
598    } else {
599        // Height limited
600        let new_width = (dst_height as f64 * src_aspect).round() as u32;
601        (new_width, dst_height)
602    }
603}
604
605/// Calculate the aspect ratio preserving dimensions for fill mode.
606#[must_use]
607pub fn calculate_aspect_fill(
608    src_width: u32,
609    src_height: u32,
610    dst_width: u32,
611    dst_height: u32,
612) -> (u32, u32) {
613    let src_aspect = src_width as f64 / src_height as f64;
614    let dst_aspect = dst_width as f64 / dst_height as f64;
615
616    if src_aspect < dst_aspect {
617        // Width limited
618        let new_height = (dst_width as f64 / src_aspect).round() as u32;
619        (dst_width, new_height)
620    } else {
621        // Height limited
622        let new_width = (dst_height as f64 * src_aspect).round() as u32;
623        (new_width, dst_height)
624    }
625}
626
627#[cfg(test)]
628mod tests {
629    use super::*;
630    use oximedia_core::PixelFormat;
631
632    fn create_test_frame(width: u32, height: u32) -> VideoFrame {
633        let mut frame = VideoFrame::new(PixelFormat::Yuv420p, width, height);
634        frame.allocate();
635
636        // Fill with a gradient pattern
637        if let Some(plane) = frame.planes.get_mut(0) {
638            let mut data = vec![0u8; width as usize * height as usize];
639            for y in 0..height as usize {
640                for x in 0..width as usize {
641                    data[y * width as usize + x] = ((x + y) % 256) as u8;
642                }
643            }
644            *plane = Plane::new(data, width as usize);
645        }
646
647        frame
648    }
649
650    #[test]
651    fn test_scale_filter_creation() {
652        let config = ScaleConfig::new(1280, 720)
653            .with_algorithm(ScaleAlgorithm::Lanczos3)
654            .with_antialias(true);
655
656        let filter = ScaleFilter::new(NodeId(0), "scale", config);
657
658        assert_eq!(filter.id(), NodeId(0));
659        assert_eq!(filter.name(), "scale");
660        assert_eq!(filter.config().width, 1280);
661        assert_eq!(filter.config().height, 720);
662        assert_eq!(filter.config().algorithm, ScaleAlgorithm::Lanczos3);
663    }
664
665    #[test]
666    fn test_scale_algorithms() {
667        assert_eq!(ScaleAlgorithm::Nearest.support(), 0.5);
668        assert_eq!(ScaleAlgorithm::Bilinear.support(), 1.0);
669        assert_eq!(ScaleAlgorithm::Bicubic.support(), 2.0);
670        assert_eq!(ScaleAlgorithm::Lanczos2.support(), 2.0);
671        assert_eq!(ScaleAlgorithm::Lanczos3.support(), 3.0);
672        assert_eq!(ScaleAlgorithm::Lanczos4.support(), 4.0);
673    }
674
675    #[test]
676    fn test_kernel_values() {
677        // Nearest at center should be 1
678        assert!((ScaleAlgorithm::Nearest.kernel(0.0) - 1.0).abs() < 0.001);
679        assert!((ScaleAlgorithm::Nearest.kernel(0.6) - 0.0).abs() < 0.001);
680
681        // Bilinear at center should be 1
682        assert!((ScaleAlgorithm::Bilinear.kernel(0.0) - 1.0).abs() < 0.001);
683        assert!((ScaleAlgorithm::Bilinear.kernel(0.5) - 0.5).abs() < 0.001);
684        assert!((ScaleAlgorithm::Bilinear.kernel(1.0) - 0.0).abs() < 0.001);
685
686        // Lanczos at center should be 1
687        assert!((ScaleAlgorithm::Lanczos3.kernel(0.0) - 1.0).abs() < 0.001);
688    }
689
690    #[test]
691    fn test_scale_downscale() {
692        let config = ScaleConfig::new(80, 60);
693        let mut filter = ScaleFilter::new(NodeId(0), "scale", config);
694
695        let input = create_test_frame(160, 120);
696        let result = filter.scale_frame(&input);
697
698        assert_eq!(result.width, 80);
699        assert_eq!(result.height, 60);
700        assert_eq!(result.planes.len(), input.planes.len());
701    }
702
703    #[test]
704    fn test_scale_upscale() {
705        let config = ScaleConfig::new(320, 240);
706        let mut filter = ScaleFilter::new(NodeId(0), "scale", config);
707
708        let input = create_test_frame(160, 120);
709        let result = filter.scale_frame(&input);
710
711        assert_eq!(result.width, 320);
712        assert_eq!(result.height, 240);
713    }
714
715    #[test]
716    fn test_nearest_neighbor_scaler() {
717        let scaler = NearestNeighborScaler::new(320, 240);
718
719        let src_data = vec![128u8; 640 * 480];
720        let src_plane = Plane::new(src_data, 640);
721
722        let result = scaler.scale_plane(&src_plane, 640, 480);
723        assert_eq!(result.stride, 320);
724    }
725
726    #[test]
727    fn test_bilinear_scaler() {
728        let scaler = BilinearScaler::new(320, 240);
729
730        let src_data = vec![128u8; 640 * 480];
731        let src_plane = Plane::new(src_data, 640);
732
733        let result = scaler.scale_plane(&src_plane, 640, 480);
734        assert_eq!(result.stride, 320);
735    }
736
737    #[test]
738    fn test_aspect_fit() {
739        // 16:9 source into 4:3 container
740        let (w, h) = calculate_aspect_fit(1920, 1080, 640, 480);
741        assert_eq!(w, 640);
742        assert!(h <= 480);
743
744        // 4:3 source into 16:9 container
745        let (w, h) = calculate_aspect_fit(640, 480, 1920, 1080);
746        assert!(w <= 1920);
747        assert_eq!(h, 1080);
748    }
749
750    #[test]
751    fn test_aspect_fill() {
752        // 16:9 source into 4:3 container (will crop width)
753        let (w, h) = calculate_aspect_fill(1920, 1080, 640, 480);
754        assert!(w >= 640 || h >= 480);
755    }
756
757    #[test]
758    fn test_sinc_function() {
759        assert!((sinc(0.0) - 1.0).abs() < 0.001);
760        // sinc(1) should be 0
761        assert!(sinc(1.0).abs() < 0.001);
762    }
763
764    #[test]
765    fn test_node_trait_implementation() {
766        let config = ScaleConfig::new(1280, 720);
767        let mut filter = ScaleFilter::new(NodeId(42), "test_scale", config);
768
769        assert_eq!(filter.node_type(), NodeType::Filter);
770        assert_eq!(filter.state(), NodeState::Idle);
771        assert_eq!(filter.inputs().len(), 1);
772        assert_eq!(filter.outputs().len(), 1);
773
774        filter
775            .set_state(NodeState::Processing)
776            .expect("set_state should succeed");
777        assert_eq!(filter.state(), NodeState::Processing);
778    }
779
780    #[test]
781    fn test_process_none_input() {
782        let config = ScaleConfig::new(1280, 720);
783        let mut filter = ScaleFilter::new(NodeId(0), "scale", config);
784
785        let result = filter.process(None).expect("process should succeed");
786        assert!(result.is_none());
787    }
788
789    #[test]
790    fn test_scale_config_builder() {
791        let config = ScaleConfig::new(1920, 1080)
792            .with_algorithm(ScaleAlgorithm::CatmullRom)
793            .with_antialias(false)
794            .with_preserve_aspect(true);
795
796        assert_eq!(config.width, 1920);
797        assert_eq!(config.height, 1080);
798        assert_eq!(config.algorithm, ScaleAlgorithm::CatmullRom);
799        assert!(!config.antialias);
800        assert!(config.preserve_aspect);
801    }
802}