Skip to main content

rff_filter/
lib.rs

1//! `rff-filter` — frame filters, the `libavfilter` equivalent.
2//!
3//! A [`FilterChain`] is the engine's `-vf` graph: an ordered list of filters
4//! that each transform a raw [`VideoFrame`] into another. Built from an
5//! FFmpeg-style spec string (`scale=320:240,crop=100:100:0:0`) and applied
6//! between decode and encode in the transcode loop.
7//!
8//! Filters operate on **8-bit planar YUV** (4:2:0 / 4:2:2 / 4:4:4) for now;
9//! other layouts return [`Error::Unsupported`]. Each filter is a 1-in/1-out
10//! transform.
11
12use rff_core::{ColorRange, Error, PixelFormat, Result, VideoFrame};
13
14/// One frame transform. Implementors map a video frame to a new video frame.
15pub trait Filter: Send {
16    /// The filter's CLI name (`scale`, `crop`, ...).
17    fn name(&self) -> &'static str;
18
19    /// Transform one frame.
20    fn filter(&mut self, frame: VideoFrame) -> Result<VideoFrame>;
21
22    /// Report the output dimensions this filter produces for a given input
23    /// size, without running it (so the muxer can size the output stream up
24    /// front). Defaults to unchanged.
25    fn output_dims(&self, in_w: u32, in_h: u32) -> (u32, u32) {
26        (in_w, in_h)
27    }
28
29    /// The luma/chroma range this filter's output is in, when the filter is the
30    /// one that decides it. Only a colour conversion knows this; everything else
31    /// passes the incoming range through, hence the `None` default.
32    ///
33    /// The muxer needs it: writing full-range samples into a container that
34    /// labels them limited-range silently rescales them on the way out.
35    fn output_color_range(&self) -> Option<ColorRange> {
36        None
37    }
38
39    /// Tell the filter what range its input is in. Only a colour conversion
40    /// cares, and only when the spec string did not pin the range itself.
41    fn set_source_color_range(&mut self, _range: ColorRange) {}
42}
43
44/// An ordered chain of filters (the `-vf` graph).
45#[derive(Default)]
46pub struct FilterChain {
47    filters: Vec<Box<dyn Filter>>,
48}
49
50impl FilterChain {
51    /// Parse an FFmpeg-style filter spec: filters separated by `,`, each
52    /// `name=a:b:c`. An empty/blank spec yields an empty (pass-through) chain.
53    pub fn parse(spec: &str) -> Result<FilterChain> {
54        let mut filters: Vec<Box<dyn Filter>> = Vec::new();
55        for token in spec.split(',') {
56            let token = token.trim();
57            if token.is_empty() {
58                continue;
59            }
60            let (name, args) = token.split_once('=').unwrap_or((token, ""));
61            let parts: Vec<&str> = if args.is_empty() {
62                Vec::new()
63            } else {
64                args.split(':').collect()
65            };
66            match name {
67                "scale" => filters.push(Box::new(Scale::parse(&parts)?)),
68                "crop" => filters.push(Box::new(Crop::parse(&parts)?)),
69                "hflip" => filters.push(Box::new(HFlip)),
70                "vflip" => filters.push(Box::new(VFlip)),
71                "transpose" => filters.push(Box::new(Transpose::parse(&parts)?)),
72                "pad" => filters.push(Box::new(Pad::parse(&parts)?)),
73                "format" => filters.push(Box::new(FormatConv::parse(&parts)?)),
74                "setrange" => filters.push(Box::new(SetRange::parse(&parts)?)),
75                "negate" => filters.push(Box::new(Negate)),
76                "gray" | "grayscale" => filters.push(Box::new(Grayscale)),
77                other => return Err(Error::unsupported(format!("unknown filter `{other}`"))),
78            }
79        }
80        Ok(FilterChain { filters })
81    }
82
83    pub fn is_empty(&self) -> bool {
84        self.filters.is_empty()
85    }
86
87    /// Insert a range conversion at the FRONT of the chain, so every later
88    /// filter (notably a colour conversion) sees full-range input.
89    pub fn prepend_range_conversion(&mut self, to_full: bool) {
90        self.filters.insert(0, Box::new(SetRange { to_full }));
91    }
92
93    /// Declare the range of the frames entering the chain, so colour
94    /// conversions interpret Y'CbCr correctly instead of assuming full-range.
95    pub fn set_source_color_range(&mut self, range: ColorRange) {
96        for f in &mut self.filters {
97            f.set_source_color_range(range);
98        }
99    }
100
101    /// The colour range the chain's output is in, if any filter in it decides.
102    /// Later filters win, matching evaluation order.
103    pub fn output_color_range(&self) -> Option<ColorRange> {
104        self.filters
105            .iter()
106            .rev()
107            .find_map(|f| f.output_color_range())
108    }
109
110    /// Run every filter in order.
111    pub fn apply(&mut self, mut frame: VideoFrame) -> Result<VideoFrame> {
112        for filter in &mut self.filters {
113            frame = filter.filter(frame)?;
114        }
115        Ok(frame)
116    }
117
118    /// The dimensions a frame of `(w, h)` ends up with after the whole chain.
119    pub fn output_dims(&self, mut w: u32, mut h: u32) -> (u32, u32) {
120        for filter in &self.filters {
121            (w, h) = filter.output_dims(w, h);
122        }
123        (w, h)
124    }
125}
126
127// ---------------------------------------------------------------------------
128// filter_complex (multi-input graphs)
129// ---------------------------------------------------------------------------
130
131/// A `-filter_complex` graph. For now it models the most-used multi-input
132/// operation: **overlay** — compositing a second input over the first (logo /
133/// watermark / picture-in-picture). The labels (`[0:v][1:v]...`) are parsed but
134/// the wiring is fixed: input 0 is the base, input 1 the overlay.
135pub struct FilterComplex {
136    /// `(x, y)` placement of the overlay's top-left on the base, or `None` if
137    /// the graph has no overlay.
138    pub overlay: Option<(u32, u32)>,
139}
140
141impl FilterComplex {
142    /// Parse a filter_complex spec. Recognizes `overlay=X:Y` (numeric, plus the
143    /// common corner shorthands `W-w`, `H-h` resolved against the base size at
144    /// apply time — for now numeric offsets only).
145    pub fn parse(spec: &str) -> Result<FilterComplex> {
146        if let Some(pos) = spec.find("overlay") {
147            let after = spec[pos + "overlay".len()..].trim_start_matches('=');
148            // Stop at the next filter (`,`) or output label (`[`).
149            let args = after.split([',', '[', ';']).next().unwrap_or("");
150            let mut parts = args.split(':');
151            let x = parts
152                .next()
153                .and_then(|s| s.trim().parse().ok())
154                .unwrap_or(0);
155            let y = parts
156                .next()
157                .and_then(|s| s.trim().parse().ok())
158                .unwrap_or(0);
159            return Ok(FilterComplex {
160                overlay: Some((x, y)),
161            });
162        }
163        Err(Error::unsupported(format!(
164            "filter_complex: unsupported graph `{spec}` (only `overlay` so far)"
165        )))
166    }
167}
168
169/// Composite `over` onto a copy of `base` at `(x, y)`, both 8-bit planar YUV of
170/// the same format. Overlay pixels replace the base (YUV carries no alpha);
171/// the overlay is clipped to the base bounds and `(x, y)` snaps down to the
172/// chroma grid so the planes stay aligned.
173pub fn overlay(base: VideoFrame, over: &VideoFrame, x: u32, y: u32) -> Result<VideoFrame> {
174    ensure_planar_yuv(&base, "overlay")?;
175    ensure_planar_yuv(over, "overlay")?;
176    if base.format != over.format {
177        return Err(Error::unsupported(format!(
178            "overlay: base {} vs overlay {} — convert with the `format` filter first",
179            base.format.name(),
180            over.format.name()
181        )));
182    }
183    let (sx, sy) = subsampling(base.format)?;
184    let (x, y) = (x - x % sx, y - y % sy);
185    let mut base = base;
186    for i in 0..3 {
187        let (div_x, div_y) = if i == 0 { (1, 1) } else { (sx, sy) };
188        let (ox, oy) = ((x / div_x) as usize, (y / div_y) as usize);
189        let (ow, oh) = plane_dims(over.format, over.width, over.height, i)?;
190        let (bw, bh) = plane_dims(base.format, base.width, base.height, i)?;
191        let (bw, bh) = (bw as usize, bh as usize);
192        let (bstride, ostride) = (base.strides[i], over.strides[i]);
193        for row in 0..oh as usize {
194            let by = oy + row;
195            if by >= bh {
196                break;
197            }
198            let copy_w = (ow as usize).min(bw.saturating_sub(ox));
199            if copy_w == 0 {
200                break;
201            }
202            let bstart = by * bstride + ox;
203            let ostart = row * ostride;
204            base.planes[i][bstart..bstart + copy_w]
205                .copy_from_slice(&over.planes[i][ostart..ostart + copy_w]);
206        }
207    }
208    Ok(base)
209}
210
211// ---------------------------------------------------------------------------
212// Pixel-format helpers
213// ---------------------------------------------------------------------------
214
215/// Chroma subsampling factors `(x, y)` for a planar YUV format.
216fn subsampling(format: PixelFormat) -> Result<(u32, u32)> {
217    match format {
218        PixelFormat::Yuv420p => Ok((2, 2)),
219        PixelFormat::Yuv422p => Ok((2, 1)),
220        PixelFormat::Yuv444p => Ok((1, 1)),
221        other => Err(Error::unsupported(format!(
222            "filter: pixel format `{}` (only 8-bit planar YUV is supported)",
223            other.name()
224        ))),
225    }
226}
227
228/// Width/height of plane `index` for `format` at frame size `(w, h)`.
229fn plane_dims(format: PixelFormat, w: u32, h: u32, index: usize) -> Result<(u32, u32)> {
230    if index == 0 {
231        Ok((w, h))
232    } else {
233        let (sx, sy) = subsampling(format)?;
234        Ok((w.div_ceil(sx), h.div_ceil(sy)))
235    }
236}
237
238fn ensure_planar_yuv(frame: &VideoFrame, op: &str) -> Result<()> {
239    subsampling(frame.format)?; // rejects non-planar / 10-bit
240    if frame.planes.len() < 3 || frame.strides.len() < 3 {
241        return Err(Error::invalid(format!(
242            "{op}: expected 3 planes, got {}",
243            frame.planes.len()
244        )));
245    }
246    Ok(())
247}
248
249// ---------------------------------------------------------------------------
250// scale (bilinear)
251// ---------------------------------------------------------------------------
252
253/// `scale=W:H` — resize to an exact width and height (bilinear). Either
254/// dimension may be `-1` to preserve the aspect ratio (rounded to an even size
255/// so chroma stays aligned); both `-1` is rejected.
256struct Scale {
257    width: i32,
258    height: i32,
259}
260
261impl Scale {
262    fn parse(args: &[&str]) -> Result<Scale> {
263        let [w, h] = args else {
264            return Err(Error::Option("scale: expected scale=W:H".into()));
265        };
266        let parse = |s: &str| {
267            s.trim()
268                .parse::<i32>()
269                .ok()
270                .filter(|&v| v == -1 || v > 0)
271                .ok_or_else(|| Error::Option(format!("scale: bad dimension `{s}`")))
272        };
273        let (width, height) = (parse(w)?, parse(h)?);
274        if width == -1 && height == -1 {
275            return Err(Error::Option(
276                "scale: at least one of W/H must be a fixed size".into(),
277            ));
278        }
279        Ok(Scale { width, height })
280    }
281
282    /// Resolve target dimensions, filling a `-1` from the source aspect ratio.
283    fn resolve(&self, in_w: u32, in_h: u32) -> (u32, u32) {
284        let even = |v: u32| {
285            let v = v.max(2);
286            if v % 2 == 0 {
287                v
288            } else {
289                v + 1
290            }
291        };
292        match (self.width, self.height) {
293            (w, h) if w > 0 && h > 0 => (w as u32, h as u32),
294            (-1, h) => {
295                let h = h as u32;
296                let w = (in_w as u64 * h as u64 / in_h.max(1) as u64) as u32;
297                (even(w), h)
298            }
299            (w, _) => {
300                let w = w as u32;
301                let h = (in_h as u64 * w as u64 / in_w.max(1) as u64) as u32;
302                (w, even(h))
303            }
304        }
305    }
306}
307
308impl Filter for Scale {
309    fn name(&self) -> &'static str {
310        "scale"
311    }
312
313    fn output_dims(&self, in_w: u32, in_h: u32) -> (u32, u32) {
314        self.resolve(in_w, in_h)
315    }
316
317    fn filter(&mut self, src: VideoFrame) -> Result<VideoFrame> {
318        ensure_planar_yuv(&src, "scale")?;
319        let (out_w, out_h) = self.resolve(src.width, src.height);
320        let mut planes = Vec::with_capacity(src.planes.len());
321        let mut strides = Vec::with_capacity(src.planes.len());
322        for (i, plane) in src.planes.iter().enumerate() {
323            let (sw, sh) = plane_dims(src.format, src.width, src.height, i)?;
324            let (dw, dh) = plane_dims(src.format, out_w, out_h, i)?;
325            planes.push(bilinear(plane, src.strides[i], sw, sh, dw, dh));
326            strides.push(dw as usize);
327        }
328        Ok(VideoFrame {
329            width: out_w,
330            height: out_h,
331            format: src.format,
332            planes,
333            strides,
334            pts: src.pts,
335        })
336    }
337}
338
339/// Bilinear-resample one 8-bit plane from `(sw, sh)` to `(dw, dh)`. Uses
340/// half-pixel-centered sampling and edge clamping; output is tightly packed.
341fn bilinear(src: &[u8], src_stride: usize, sw: u32, sh: u32, dw: u32, dh: u32) -> Vec<u8> {
342    let mut out = vec![0u8; (dw * dh) as usize];
343    if sw == 0 || sh == 0 || dw == 0 || dh == 0 {
344        return out;
345    }
346    let x_ratio = sw as f32 / dw as f32;
347    let y_ratio = sh as f32 / dh as f32;
348    let at = |x: u32, y: u32| src[y as usize * src_stride + x as usize] as f32;
349
350    for dy in 0..dh {
351        let fy = (((dy as f32 + 0.5) * y_ratio) - 0.5).max(0.0);
352        let y0 = fy.floor() as u32;
353        let y1 = (y0 + 1).min(sh - 1);
354        let wy = fy - y0 as f32;
355        for dx in 0..dw {
356            let fx = (((dx as f32 + 0.5) * x_ratio) - 0.5).max(0.0);
357            let x0 = fx.floor() as u32;
358            let x1 = (x0 + 1).min(sw - 1);
359            let wx = fx - x0 as f32;
360
361            let top = at(x0, y0) * (1.0 - wx) + at(x1, y0) * wx;
362            let bottom = at(x0, y1) * (1.0 - wx) + at(x1, y1) * wx;
363            let value = top * (1.0 - wy) + bottom * wy;
364            out[(dy * dw + dx) as usize] = value.round().clamp(0.0, 255.0) as u8;
365        }
366    }
367    out
368}
369
370// ---------------------------------------------------------------------------
371// crop
372// ---------------------------------------------------------------------------
373
374/// `crop=W:H:X:Y` — extract a rectangle. Offsets/size must align to the chroma
375/// grid (e.g. even for 4:2:0) so the chroma planes stay sample-aligned.
376struct Crop {
377    w: u32,
378    h: u32,
379    x: u32,
380    y: u32,
381}
382
383impl Crop {
384    fn parse(args: &[&str]) -> Result<Crop> {
385        let [w, h, x, y] = args else {
386            return Err(Error::Option("crop: expected crop=W:H:X:Y".into()));
387        };
388        let num = |s: &str| {
389            s.trim()
390                .parse::<u32>()
391                .map_err(|_| Error::Option(format!("crop: bad value `{s}`")))
392        };
393        Ok(Crop {
394            w: num(w)?,
395            h: num(h)?,
396            x: num(x)?,
397            y: num(y)?,
398        })
399    }
400}
401
402impl Filter for Crop {
403    fn name(&self) -> &'static str {
404        "crop"
405    }
406
407    fn output_dims(&self, _in_w: u32, _in_h: u32) -> (u32, u32) {
408        (self.w, self.h)
409    }
410
411    fn filter(&mut self, src: VideoFrame) -> Result<VideoFrame> {
412        ensure_planar_yuv(&src, "crop")?;
413        if self.x + self.w > src.width || self.y + self.h > src.height {
414            return Err(Error::Option(format!(
415                "crop: rectangle {}x{}+{}+{} exceeds frame {}x{}",
416                self.w, self.h, self.x, self.y, src.width, src.height
417            )));
418        }
419        let (sx, sy) = subsampling(src.format)?;
420        if self.x % sx != 0 || self.w % sx != 0 || self.y % sy != 0 || self.h % sy != 0 {
421            return Err(Error::Option(
422                "crop: offset/size must align to the chroma grid".into(),
423            ));
424        }
425
426        let mut planes = Vec::with_capacity(src.planes.len());
427        let mut strides = Vec::with_capacity(src.planes.len());
428        for (i, plane) in src.planes.iter().enumerate() {
429            // Plane 0 uses full-res geometry; chroma planes scale by subsampling.
430            let (div_x, div_y) = if i == 0 { (1, 1) } else { (sx, sy) };
431            let (px, py) = (self.x / div_x, self.y / div_y);
432            let (pw, ph) = (self.w / div_x, self.h / div_y);
433
434            let mut out = Vec::with_capacity((pw * ph) as usize);
435            for row in 0..ph {
436                let start = (py + row) as usize * src.strides[i] + px as usize;
437                out.extend_from_slice(&plane[start..start + pw as usize]);
438            }
439            planes.push(out);
440            strides.push(pw as usize);
441        }
442        Ok(VideoFrame {
443            width: self.w,
444            height: self.h,
445            format: src.format,
446            planes,
447            strides,
448            pts: src.pts,
449        })
450    }
451}
452
453// ---------------------------------------------------------------------------
454// hflip / vflip
455// ---------------------------------------------------------------------------
456
457/// `hflip` — mirror each row left-to-right. Dimensions and format unchanged.
458struct HFlip;
459
460impl Filter for HFlip {
461    fn name(&self) -> &'static str {
462        "hflip"
463    }
464
465    fn filter(&mut self, src: VideoFrame) -> Result<VideoFrame> {
466        ensure_planar_yuv(&src, "hflip")?;
467        let mut planes = Vec::with_capacity(src.planes.len());
468        let mut strides = Vec::with_capacity(src.planes.len());
469        for (i, plane) in src.planes.iter().enumerate() {
470            let (pw, ph) = plane_dims(src.format, src.width, src.height, i)?;
471            let (pw, ph, stride) = (pw as usize, ph as usize, src.strides[i]);
472            let mut out = vec![0u8; pw * ph];
473            for y in 0..ph {
474                let row = &plane[y * stride..y * stride + pw];
475                let dst = &mut out[y * pw..(y + 1) * pw];
476                for x in 0..pw {
477                    dst[x] = row[pw - 1 - x];
478                }
479            }
480            planes.push(out);
481            strides.push(pw);
482        }
483        Ok(VideoFrame {
484            width: src.width,
485            height: src.height,
486            format: src.format,
487            planes,
488            strides,
489            pts: src.pts,
490        })
491    }
492}
493
494/// `vflip` — mirror rows top-to-bottom. Dimensions and format unchanged.
495struct VFlip;
496
497impl Filter for VFlip {
498    fn name(&self) -> &'static str {
499        "vflip"
500    }
501
502    fn filter(&mut self, src: VideoFrame) -> Result<VideoFrame> {
503        ensure_planar_yuv(&src, "vflip")?;
504        let mut planes = Vec::with_capacity(src.planes.len());
505        let mut strides = Vec::with_capacity(src.planes.len());
506        for (i, plane) in src.planes.iter().enumerate() {
507            let (pw, ph) = plane_dims(src.format, src.width, src.height, i)?;
508            let (pw, ph, stride) = (pw as usize, ph as usize, src.strides[i]);
509            let mut out = vec![0u8; pw * ph];
510            for y in 0..ph {
511                let src_row = &plane[(ph - 1 - y) * stride..(ph - 1 - y) * stride + pw];
512                out[y * pw..(y + 1) * pw].copy_from_slice(src_row);
513            }
514            planes.push(out);
515            strides.push(pw);
516        }
517        Ok(VideoFrame {
518            width: src.width,
519            height: src.height,
520            format: src.format,
521            planes,
522            strides,
523            pts: src.pts,
524        })
525    }
526}
527
528// ---------------------------------------------------------------------------
529// transpose (90° rotations)
530// ---------------------------------------------------------------------------
531
532/// `transpose=DIR` — a 90° rotation (output dimensions are swapped). `DIR` is
533/// `0`/`cclock_flip`, `1`/`clock`, `2`/`cclock`, `3`/`clock_flip` (default
534/// `clock`). Requires symmetric chroma (4:2:0 or 4:4:4) so the layout survives.
535struct Transpose {
536    dir: u8,
537}
538
539impl Transpose {
540    fn parse(args: &[&str]) -> Result<Transpose> {
541        let dir = match args.first().map(|s| s.trim()) {
542            None | Some("") => 1, // default: clockwise
543            Some("cclock_flip") | Some("0") => 0,
544            Some("clock") | Some("1") => 1,
545            Some("cclock") | Some("2") => 2,
546            Some("clock_flip") | Some("3") => 3,
547            Some(other) => {
548                return Err(Error::Option(format!(
549                    "transpose: unknown direction `{other}`"
550                )))
551            }
552        };
553        Ok(Transpose { dir })
554    }
555
556    /// Map a destination coordinate back to its source `(x, y)` for plane size
557    /// `(w, h)` (the *source* plane dimensions).
558    fn source(&self, dx: u32, dy: u32, w: u32, h: u32) -> (u32, u32) {
559        match self.dir {
560            0 => (dy, dx),                 // transpose (cclock + vflip)
561            1 => (dy, h - 1 - dx),         // 90° clockwise
562            2 => (w - 1 - dy, dx),         // 90° counter-clockwise
563            _ => (w - 1 - dy, h - 1 - dx), // clockwise + vflip
564        }
565    }
566}
567
568impl Filter for Transpose {
569    fn name(&self) -> &'static str {
570        "transpose"
571    }
572
573    fn output_dims(&self, in_w: u32, in_h: u32) -> (u32, u32) {
574        (in_h, in_w)
575    }
576
577    fn filter(&mut self, src: VideoFrame) -> Result<VideoFrame> {
578        ensure_planar_yuv(&src, "transpose")?;
579        let (sx, sy) = subsampling(src.format)?;
580        if sx != sy {
581            return Err(Error::unsupported(
582                "transpose: needs symmetric chroma (4:2:0 or 4:4:4)",
583            ));
584        }
585        let mut planes = Vec::with_capacity(src.planes.len());
586        let mut strides = Vec::with_capacity(src.planes.len());
587        for (i, plane) in src.planes.iter().enumerate() {
588            let (pw, ph) = plane_dims(src.format, src.width, src.height, i)?;
589            let (dw, dh) = (ph, pw); // dimensions swap
590            let stride = src.strides[i];
591            let mut out = vec![0u8; (dw * dh) as usize];
592            for dy in 0..dh {
593                for dx in 0..dw {
594                    let (mx, my) = self.source(dx, dy, pw, ph);
595                    out[(dy * dw + dx) as usize] = plane[my as usize * stride + mx as usize];
596                }
597            }
598            planes.push(out);
599            strides.push(dw as usize);
600        }
601        Ok(VideoFrame {
602            width: src.height,
603            height: src.width,
604            format: src.format,
605            planes,
606            strides,
607            pts: src.pts,
608        })
609    }
610}
611
612// ---------------------------------------------------------------------------
613// pad
614// ---------------------------------------------------------------------------
615
616/// `pad=W:H:X:Y` — place the input on a `W×H` black canvas at offset `(X, Y)`.
617/// Offsets/size must align to the chroma grid. Fill is full-range black
618/// (luma 0, chroma 128).
619struct Pad {
620    w: u32,
621    h: u32,
622    x: u32,
623    y: u32,
624}
625
626impl Pad {
627    fn parse(args: &[&str]) -> Result<Pad> {
628        let [w, h, x, y] = args else {
629            return Err(Error::Option("pad: expected pad=W:H:X:Y".into()));
630        };
631        let num = |s: &str| {
632            s.trim()
633                .parse::<u32>()
634                .map_err(|_| Error::Option(format!("pad: bad value `{s}`")))
635        };
636        Ok(Pad {
637            w: num(w)?,
638            h: num(h)?,
639            x: num(x)?,
640            y: num(y)?,
641        })
642    }
643}
644
645impl Filter for Pad {
646    fn name(&self) -> &'static str {
647        "pad"
648    }
649
650    fn output_dims(&self, _in_w: u32, _in_h: u32) -> (u32, u32) {
651        (self.w, self.h)
652    }
653
654    fn filter(&mut self, src: VideoFrame) -> Result<VideoFrame> {
655        ensure_planar_yuv(&src, "pad")?;
656        if self.x + src.width > self.w || self.y + src.height > self.h {
657            return Err(Error::Option(format!(
658                "pad: {}x{} input at +{}+{} doesn't fit a {}x{} canvas",
659                src.width, src.height, self.x, self.y, self.w, self.h
660            )));
661        }
662        let (sx, sy) = subsampling(src.format)?;
663        if self.x % sx != 0 || self.w % sx != 0 || self.y % sy != 0 || self.h % sy != 0 {
664            return Err(Error::Option(
665                "pad: offset/size must align to the chroma grid".into(),
666            ));
667        }
668
669        let mut planes = Vec::with_capacity(src.planes.len());
670        let mut strides = Vec::with_capacity(src.planes.len());
671        for (i, plane) in src.planes.iter().enumerate() {
672            let (cw, ch) = plane_dims(src.format, self.w, self.h, i)?; // canvas
673            let (sw, sh) = plane_dims(src.format, src.width, src.height, i)?; // source
674            let (div_x, div_y) = if i == 0 { (1, 1) } else { (sx, sy) };
675            let (px, py) = (self.x / div_x, self.y / div_y);
676            let fill = if i == 0 { 0u8 } else { 128u8 }; // black: Y=0, chroma=128
677
678            let (cw, ch) = (cw as usize, ch as usize);
679            let mut out = vec![fill; cw * ch];
680            for row in 0..sh as usize {
681                let dst_start = (py as usize + row) * cw + px as usize;
682                let src_start = row * src.strides[i];
683                out[dst_start..dst_start + sw as usize]
684                    .copy_from_slice(&plane[src_start..src_start + sw as usize]);
685            }
686            planes.push(out);
687            strides.push(cw);
688        }
689        Ok(VideoFrame {
690            width: self.w,
691            height: self.h,
692            format: src.format,
693            planes,
694            strides,
695            pts: src.pts,
696        })
697    }
698}
699
700// ---------------------------------------------------------------------------
701// format (pixel-format / colorspace conversion)
702// ---------------------------------------------------------------------------
703
704/// Colour matrix + quantisation range for YUV↔RGB. Coefficients derive from the
705/// luma weights `(Kr, Kb)`: BT.601 = (0.299, 0.114), BT.709 = (0.2126, 0.0722).
706/// Limited ("TV", Y 16–235 / C 16–240) vs full ("PC", 0–255) range scales Y and
707/// chroma accordingly. H.264/AVC video is BT.709 for HD and BT.601 for SD, almost
708/// always limited-range — so the BT.601/full default is correct only for full-range
709/// sources (PNG/JPEG bridges); pick the right one with `format=rgb24:bt709:limited`.
710#[derive(Clone, Copy)]
711struct ColorSpec {
712    kr: f32,
713    kb: f32,
714    limited: bool,
715}
716impl Default for ColorSpec {
717    fn default() -> Self {
718        ColorSpec {
719            kr: 0.299,
720            kb: 0.114,
721            limited: false,
722        } // BT.601 full-range (legacy)
723    }
724}
725impl ColorSpec {
726    #[inline]
727    fn kg(&self) -> f32 {
728        1.0 - self.kr - self.kb
729    }
730}
731
732/// `format=PIXFMT[:MATRIX][:RANGE]` — convert between `rgb24` and `yuv420p`.
733/// MATRIX ∈ {`bt601`, `bt709`}; RANGE ∈ {`full`/`pc`, `limited`/`tv`}. Defaults to
734/// BT.601 full range for backward compatibility.
735struct FormatConv {
736    target: PixelFormat,
737    spec: ColorSpec,
738    /// Did the spec string pin the range explicitly? If not, the chain fills it
739    /// in from the source stream — assuming full-range for everything was a
740    /// silent 8 dB error on limited-range YUV sources.
741    range_explicit: bool,
742}
743
744impl FormatConv {
745    fn parse(args: &[&str]) -> Result<FormatConv> {
746        let name = args.first().copied().unwrap_or("").trim();
747        let target = match name {
748            "yuv420p" => PixelFormat::Yuv420p,
749            "rgb24" => PixelFormat::Rgb24,
750            other => {
751                return Err(Error::unsupported(format!(
752                    "format: target `{other}` (only yuv420p and rgb24)"
753                )))
754            }
755        };
756        let mut spec = ColorSpec::default();
757        let mut range_explicit = false;
758        for opt in args.iter().skip(1).map(|s| s.trim()) {
759            match opt {
760                "" => {}
761                "bt601" | "601" | "smpte170m" => {
762                    spec.kr = 0.299;
763                    spec.kb = 0.114;
764                }
765                "bt709" | "709" => {
766                    spec.kr = 0.2126;
767                    spec.kb = 0.0722;
768                }
769                "full" | "pc" | "jpeg" => {
770                    spec.limited = false;
771                    range_explicit = true;
772                }
773                "limited" | "tv" | "mpeg" => {
774                    spec.limited = true;
775                    range_explicit = true;
776                }
777                other => {
778                    return Err(Error::unsupported(format!(
779                        "format: option `{other}` (matrix bt601/bt709, range full/limited)"
780                    )))
781                }
782            }
783        }
784        Ok(FormatConv {
785            target,
786            spec,
787            range_explicit,
788        })
789    }
790}
791
792impl Filter for FormatConv {
793    fn name(&self) -> &'static str {
794        "format"
795    }
796
797    fn set_source_color_range(&mut self, range: ColorRange) {
798        // `spec.limited` always describes the Y'CbCr side of the conversion,
799        // whichever direction it runs, so one flag serves both.
800        if !self.range_explicit {
801            match range {
802                ColorRange::Limited => self.spec.limited = true,
803                ColorRange::Full => self.spec.limited = false,
804                ColorRange::Unspecified => {}
805            }
806        }
807    }
808
809    /// Only meaningful when the target is Y'CbCr — RGB has no limited/full
810    /// distinction, so converting *to* RGB leaves the question to whoever
811    /// converts back.
812    fn output_color_range(&self) -> Option<ColorRange> {
813        match self.target {
814            PixelFormat::Yuv420p | PixelFormat::Yuv422p | PixelFormat::Yuv444p => {
815                Some(if self.spec.limited {
816                    ColorRange::Limited
817                } else {
818                    ColorRange::Full
819                })
820            }
821            _ => None,
822        }
823    }
824
825    fn filter(&mut self, src: VideoFrame) -> Result<VideoFrame> {
826        if src.format == self.target {
827            return Ok(src);
828        }
829        match (src.format, self.target) {
830            (PixelFormat::Rgb24, PixelFormat::Yuv420p) => rgb_to_yuv420(&src, 3, self.spec),
831            (PixelFormat::Rgba, PixelFormat::Yuv420p) => rgb_to_yuv420(&src, 4, self.spec),
832            (PixelFormat::Rgba, PixelFormat::Rgb24) => rgba_to_rgb(&src),
833            (PixelFormat::Yuv420p, PixelFormat::Rgb24) => yuv420_to_rgb(&src, self.spec),
834            (PixelFormat::Yuv422p, PixelFormat::Rgb24)
835            | (PixelFormat::Yuv444p, PixelFormat::Rgb24) => yuv_planar_to_rgb(&src, self.spec),
836            (PixelFormat::Yuv422p, PixelFormat::Yuv420p)
837            | (PixelFormat::Yuv444p, PixelFormat::Yuv420p) => yuv_planar_to_420(&src),
838            (from, to) => Err(Error::unsupported(format!(
839                "format: {} → {} not supported",
840                from.name(),
841                to.name()
842            ))),
843        }
844    }
845}
846
847fn clamp_u8(v: f32) -> u8 {
848    v.round().clamp(0.0, 255.0) as u8
849}
850
851/// Packed RGB(A) → planar 4:2:0 (chroma averaged over each 2×2 block).
852fn rgb_to_yuv420(src: &VideoFrame, bpp: usize, spec: ColorSpec) -> Result<VideoFrame> {
853    let (w, h) = (src.width as usize, src.height as usize);
854    let stride = src.strides[0];
855    let rgb = &src.planes[0];
856    let px = |i: usize, j: usize| {
857        let p = j * stride + i * bpp;
858        (rgb[p] as f32, rgb[p + 1] as f32, rgb[p + 2] as f32)
859    };
860
861    // Forward coeffs from (Kr, Kb): Y = yoff + yscale·(Kr·R+Kg·G+Kb·B),
862    // U = 128 + cscale·(ur·R+ug·G+0.5·B), V = 128 + cscale·(0.5·R+vg·G+vb·B).
863    let (kr, kb, kg) = (spec.kr, spec.kb, spec.kg());
864    let (yoff, yscale, cscale) = if spec.limited {
865        (16.0, 219.0 / 255.0, 224.0 / 255.0)
866    } else {
867        (0.0, 1.0, 1.0)
868    };
869    let (ur, ug) = (-0.5 * kr / (1.0 - kb), -0.5 * kg / (1.0 - kb));
870    let (vg, vb) = (-0.5 * kg / (1.0 - kr), -0.5 * kb / (1.0 - kr));
871
872    let mut y = vec![0u8; w * h];
873    for j in 0..h {
874        for i in 0..w {
875            let (r, g, b) = px(i, j);
876            y[j * w + i] = clamp_u8(yoff + yscale * (kr * r + kg * g + kb * b));
877        }
878    }
879
880    let (cw, ch) = (w.div_ceil(2), h.div_ceil(2));
881    let (mut u, mut v) = (vec![0u8; cw * ch], vec![0u8; cw * ch]);
882    for cj in 0..ch {
883        for ci in 0..cw {
884            // Average the (up to) 2×2 RGB block, then convert once.
885            let (mut sr, mut sg, mut sb, mut n) = (0.0, 0.0, 0.0, 0.0);
886            for dy in 0..2 {
887                for dx in 0..2 {
888                    let (i, j) = (ci * 2 + dx, cj * 2 + dy);
889                    if i < w && j < h {
890                        let (r, g, b) = px(i, j);
891                        sr += r;
892                        sg += g;
893                        sb += b;
894                        n += 1.0;
895                    }
896                }
897            }
898            let (r, g, b) = (sr / n, sg / n, sb / n);
899            u[cj * cw + ci] = clamp_u8(128.0 + cscale * (ur * r + ug * g + 0.5 * b));
900            v[cj * cw + ci] = clamp_u8(128.0 + cscale * (0.5 * r + vg * g + vb * b));
901        }
902    }
903
904    Ok(VideoFrame {
905        width: src.width,
906        height: src.height,
907        format: PixelFormat::Yuv420p,
908        planes: vec![y, u, v],
909        strides: vec![w, cw, cw],
910        pts: src.pts,
911    })
912}
913
914/// Planar 4:2:0 → packed RGB (chroma upsampled nearest-neighbour).
915/// Chroma subsampling `(horizontal, vertical)` of a planar 8-bit Y'CbCr format.
916fn planar_subsampling(format: PixelFormat) -> Option<(usize, usize)> {
917    match format {
918        PixelFormat::Yuv444p => Some((1, 1)),
919        PixelFormat::Yuv422p => Some((2, 1)),
920        PixelFormat::Yuv420p => Some((2, 2)),
921        _ => None,
922    }
923}
924
925/// Planar Y'CbCr (4:2:0 / 4:2:2 / 4:4:4) → packed RGB24.
926///
927/// Chroma is upsampled by replication. That is what the nearest-neighbour
928/// reading of a subsampled plane means, and it keeps this exactly invertible
929/// against the encoder's point-sampling.
930fn yuv_planar_to_rgb(src: &VideoFrame, spec: ColorSpec) -> Result<VideoFrame> {
931    let (sh, sv) = planar_subsampling(src.format).ok_or_else(|| {
932        Error::unsupported(format!(
933            "format: {} is not planar Y'CbCr",
934            src.format.name()
935        ))
936    })?;
937    let (w, h) = (src.width as usize, src.height as usize);
938    let (ys, us, vs) = (src.strides[0], src.strides[1], src.strides[2]);
939    let (yp, up, vp) = (&src.planes[0], &src.planes[1], &src.planes[2]);
940
941    let kg = spec.kg();
942    let (yoff, yscale, cscale) = if spec.limited {
943        (16.0, 255.0 / 219.0, 255.0 / 224.0)
944    } else {
945        (0.0, 1.0, 1.0)
946    };
947    let kr_c = 2.0 * (1.0 - spec.kr) * cscale;
948    let kb_c = 2.0 * (1.0 - spec.kb) * cscale;
949    let kgv = -2.0 * spec.kr * (1.0 - spec.kr) / kg * cscale;
950    let kgu = -2.0 * spec.kb * (1.0 - spec.kb) / kg * cscale;
951
952    let mut out = vec![0u8; w * h * 3];
953    for j in 0..h {
954        for i in 0..w {
955            let ya = yscale * (yp[j * ys + i] as f32 - yoff);
956            let cu = up[(j / sv) * us + i / sh] as f32 - 128.0;
957            let cv = vp[(j / sv) * vs + i / sh] as f32 - 128.0;
958            let o = (j * w + i) * 3;
959            out[o] = clamp_u8(ya + kr_c * cv);
960            out[o + 1] = clamp_u8(ya + kgu * cu + kgv * cv);
961            out[o + 2] = clamp_u8(ya + kb_c * cu);
962        }
963    }
964    Ok(VideoFrame {
965        width: src.width,
966        height: src.height,
967        format: PixelFormat::Rgb24,
968        planes: vec![out],
969        strides: vec![w * 3],
970        pts: src.pts,
971    })
972}
973
974/// Planar Y'CbCr → planar 4:2:0, **averaging** the chroma it drops.
975///
976/// Averaging rather than point-sampling matters: dropping 3 of every 4 chroma
977/// samples without a low-pass aliases, and the artefacts land on exactly the
978/// saturated edges people look at.
979fn yuv_planar_to_420(src: &VideoFrame) -> Result<VideoFrame> {
980    let (sh, sv) = planar_subsampling(src.format).ok_or_else(|| {
981        Error::unsupported(format!(
982            "format: {} is not planar Y'CbCr",
983            src.format.name()
984        ))
985    })?;
986    if (sh, sv) == (2, 2) {
987        return Ok(src.clone());
988    }
989    let (w, h) = (src.width as usize, src.height as usize);
990    let (cw, ch) = (w.div_ceil(2), h.div_ceil(2));
991    // Source chroma plane dimensions.
992    let (scw, sch) = (w.div_ceil(sh), h.div_ceil(sv));
993
994    // Luma is untouched; repack it to a tight stride.
995    let ys = src.strides[0];
996    let mut luma = Vec::with_capacity(w * h);
997    for j in 0..h {
998        luma.extend_from_slice(&src.planes[0][j * ys..j * ys + w]);
999    }
1000
1001    let mut out_planes = vec![luma];
1002    for plane_idx in 1..3 {
1003        let stride = src.strides[plane_idx];
1004        let plane = &src.planes[plane_idx];
1005        let mut dst = vec![0u8; cw * ch];
1006        // How many source chroma samples collapse into one output sample.
1007        let (fx, fy) = (2 / sh, 2 / sv);
1008        for j in 0..ch {
1009            for i in 0..cw {
1010                let mut sum = 0u32;
1011                let mut n = 0u32;
1012                for dy in 0..fy.max(1) {
1013                    let sy = (j * fy + dy).min(sch - 1);
1014                    for dx in 0..fx.max(1) {
1015                        let sx = (i * fx + dx).min(scw - 1);
1016                        sum += plane[sy * stride + sx] as u32;
1017                        n += 1;
1018                    }
1019                }
1020                dst[j * cw + i] = ((sum + n / 2) / n) as u8;
1021            }
1022        }
1023        out_planes.push(dst);
1024    }
1025    Ok(VideoFrame {
1026        width: src.width,
1027        height: src.height,
1028        format: PixelFormat::Yuv420p,
1029        planes: out_planes,
1030        strides: vec![w, cw, cw],
1031        pts: src.pts,
1032    })
1033}
1034
1035fn yuv420_to_rgb(src: &VideoFrame, spec: ColorSpec) -> Result<VideoFrame> {
1036    let (w, h) = (src.width as usize, src.height as usize);
1037    let (ys, us, vs) = (src.strides[0], src.strides[1], src.strides[2]);
1038    let (yp, up, vp) = (&src.planes[0], &src.planes[1], &src.planes[2]);
1039
1040    // Derive from (Kr, Kb): R = ya + kr_c·cv, G = ya + kgu·cu + kgv·cv,
1041    // B = ya + kb_c·cu, where ya = yscale·(Y − yoff), cu = Cu−128, cv = Cv−128.
1042    let kg = spec.kg();
1043    let (yoff, yscale, cscale) = if spec.limited {
1044        (16.0, 255.0 / 219.0, 255.0 / 224.0)
1045    } else {
1046        (0.0, 1.0, 1.0)
1047    };
1048    let kr_c = 2.0 * (1.0 - spec.kr) * cscale;
1049    let kb_c = 2.0 * (1.0 - spec.kb) * cscale;
1050    let kgv = -2.0 * spec.kr * (1.0 - spec.kr) / kg * cscale;
1051    let kgu = -2.0 * spec.kb * (1.0 - spec.kb) / kg * cscale;
1052
1053    let mut out = vec![0u8; w * h * 3];
1054    for j in 0..h {
1055        for i in 0..w {
1056            let ya = yscale * (yp[j * ys + i] as f32 - yoff);
1057            let cu = up[(j / 2) * us + i / 2] as f32 - 128.0;
1058            let cv = vp[(j / 2) * vs + i / 2] as f32 - 128.0;
1059            let o = (j * w + i) * 3;
1060            out[o] = clamp_u8(ya + kr_c * cv);
1061            out[o + 1] = clamp_u8(ya + kgu * cu + kgv * cv);
1062            out[o + 2] = clamp_u8(ya + kb_c * cu);
1063        }
1064    }
1065    Ok(VideoFrame {
1066        width: src.width,
1067        height: src.height,
1068        format: PixelFormat::Rgb24,
1069        planes: vec![out],
1070        strides: vec![w * 3],
1071        pts: src.pts,
1072    })
1073}
1074
1075/// Drop the alpha channel: packed RGBA → packed RGB.
1076fn rgba_to_rgb(src: &VideoFrame) -> Result<VideoFrame> {
1077    let (w, h) = (src.width as usize, src.height as usize);
1078    let stride = src.strides[0];
1079    let rgba = &src.planes[0];
1080    let mut out = vec![0u8; w * h * 3];
1081    for j in 0..h {
1082        for i in 0..w {
1083            let s = j * stride + i * 4;
1084            let d = (j * w + i) * 3;
1085            out[d..d + 3].copy_from_slice(&rgba[s..s + 3]);
1086        }
1087    }
1088    Ok(VideoFrame {
1089        width: src.width,
1090        height: src.height,
1091        format: PixelFormat::Rgb24,
1092        planes: vec![out],
1093        strides: vec![w * 3],
1094        pts: src.pts,
1095    })
1096}
1097
1098// ---------------------------------------------------------------------------
1099// negate / grayscale
1100// ---------------------------------------------------------------------------
1101
1102/// Repack a plane into a tight buffer, applying `f` to every sample.
1103fn map_plane(plane: &[u8], stride: usize, pw: usize, ph: usize, f: impl Fn(u8) -> u8) -> Vec<u8> {
1104    let mut out = vec![0u8; pw * ph];
1105    for y in 0..ph {
1106        let row = &plane[y * stride..y * stride + pw];
1107        let dst = &mut out[y * pw..(y + 1) * pw];
1108        for x in 0..pw {
1109            dst[x] = f(row[x]);
1110        }
1111    }
1112    out
1113}
1114
1115/// `negate` — invert every sample (`255 - v`), luma and chroma.
1116struct Negate;
1117
1118impl Filter for Negate {
1119    fn name(&self) -> &'static str {
1120        "negate"
1121    }
1122
1123    fn filter(&mut self, src: VideoFrame) -> Result<VideoFrame> {
1124        ensure_planar_yuv(&src, "negate")?;
1125        let mut planes = Vec::with_capacity(src.planes.len());
1126        let mut strides = Vec::with_capacity(src.planes.len());
1127        for (i, plane) in src.planes.iter().enumerate() {
1128            let (pw, ph) = plane_dims(src.format, src.width, src.height, i)?;
1129            planes.push(map_plane(
1130                plane,
1131                src.strides[i],
1132                pw as usize,
1133                ph as usize,
1134                |v| 255 - v,
1135            ));
1136            strides.push(pw as usize);
1137        }
1138        Ok(VideoFrame {
1139            planes,
1140            strides,
1141            ..src
1142        })
1143    }
1144}
1145
1146/// `grayscale` — keep luma, neutralize chroma (set both chroma planes to 128).
1147/// `setrange=full|limited` — rescale planar Y'CbCr between limited ("TV",
1148/// Y 16–235 / C 16–240) and full ("PC", 0–255) range.
1149///
1150/// This is a value rescale only: no colour-space change, no chroma resampling,
1151/// planes keep their dimensions. It exists because JPEG is defined on full-range
1152/// samples while most video is limited-range, so handing a limited-range plane
1153/// straight to a JPEG encoder encodes the wrong signal. The transcode pipeline
1154/// inserts this automatically when the target codec needs full range; the CLI
1155/// name is exposed for when you need to override that.
1156///
1157/// RGB frames pass through untouched — the distinction is a Y'CbCr one.
1158struct SetRange {
1159    to_full: bool,
1160}
1161
1162impl SetRange {
1163    fn parse(args: &[&str]) -> Result<SetRange> {
1164        match args.first().copied().unwrap_or("").trim() {
1165            "full" | "pc" | "jpeg" => Ok(SetRange { to_full: true }),
1166            "limited" | "tv" | "mpeg" => Ok(SetRange { to_full: false }),
1167            other => Err(Error::unsupported(format!(
1168                "setrange: `{other}` (want full/pc/jpeg or limited/tv/mpeg)"
1169            ))),
1170        }
1171    }
1172
1173    /// 256-entry map for one component. Luma and chroma differ: limited luma is
1174    /// 16–235, limited chroma 16–240.
1175    fn lut(&self, chroma: bool) -> [u8; 256] {
1176        let (lo, span) = if chroma { (16.0, 224.0) } else { (16.0, 219.0) };
1177        let mut lut = [0u8; 256];
1178        for (v, out) in lut.iter_mut().enumerate() {
1179            let v = v as f32;
1180            let mapped = if self.to_full {
1181                (v - lo) * 255.0 / span
1182            } else {
1183                v * span / 255.0 + lo
1184            };
1185            *out = mapped.round().clamp(0.0, 255.0) as u8;
1186        }
1187        lut
1188    }
1189}
1190
1191impl Filter for SetRange {
1192    fn name(&self) -> &'static str {
1193        "setrange"
1194    }
1195
1196    fn output_color_range(&self) -> Option<ColorRange> {
1197        Some(if self.to_full {
1198            ColorRange::Full
1199        } else {
1200            ColorRange::Limited
1201        })
1202    }
1203
1204    fn filter(&mut self, mut frame: VideoFrame) -> Result<VideoFrame> {
1205        // Only planar Y'CbCr has a range to convert.
1206        if ensure_planar_yuv(&frame, "setrange").is_err() {
1207            return Ok(frame);
1208        }
1209        let luma = self.lut(false);
1210        let chroma = self.lut(true);
1211        for (i, plane) in frame.planes.iter_mut().enumerate() {
1212            let lut = if i == 0 { &luma } else { &chroma };
1213            for b in plane.iter_mut() {
1214                *b = lut[*b as usize];
1215            }
1216        }
1217        Ok(frame)
1218    }
1219}
1220
1221struct Grayscale;
1222
1223impl Filter for Grayscale {
1224    fn name(&self) -> &'static str {
1225        "grayscale"
1226    }
1227
1228    fn filter(&mut self, src: VideoFrame) -> Result<VideoFrame> {
1229        ensure_planar_yuv(&src, "grayscale")?;
1230        let mut planes = Vec::with_capacity(src.planes.len());
1231        let mut strides = Vec::with_capacity(src.planes.len());
1232        for (i, plane) in src.planes.iter().enumerate() {
1233            let (pw, ph) = plane_dims(src.format, src.width, src.height, i)?;
1234            let (pw, ph) = (pw as usize, ph as usize);
1235            if i == 0 {
1236                planes.push(map_plane(plane, src.strides[i], pw, ph, |v| v));
1237            } else {
1238                planes.push(vec![128u8; pw * ph]);
1239            }
1240            strides.push(pw);
1241        }
1242        Ok(VideoFrame {
1243            planes,
1244            strides,
1245            ..src
1246        })
1247    }
1248}
1249
1250#[cfg(test)]
1251mod tests {
1252    use super::*;
1253
1254    /// A W×H 4:2:0 frame with a per-pixel luma function and flat chroma.
1255    fn frame(w: u32, h: u32, luma: impl Fn(u32, u32) -> u8) -> VideoFrame {
1256        let (wi, hi) = (w as usize, h as usize);
1257        let mut y = vec![0u8; wi * hi];
1258        for row in 0..h {
1259            for col in 0..w {
1260                y[row as usize * wi + col as usize] = luma(col, row);
1261            }
1262        }
1263        let chroma = vec![128u8; (wi / 2) * (hi / 2)];
1264        VideoFrame {
1265            width: w,
1266            height: h,
1267            format: PixelFormat::Yuv420p,
1268            planes: vec![y, chroma.clone(), chroma],
1269            strides: vec![wi, wi / 2, wi / 2],
1270            pts: Some(0),
1271        }
1272    }
1273
1274    #[test]
1275    fn scale_changes_dimensions_and_keeps_planes_consistent() {
1276        let mut chain = FilterChain::parse("scale=32:16").unwrap();
1277        assert_eq!(chain.output_dims(64, 64), (32, 16));
1278        let out = chain.apply(frame(64, 64, |x, _| x as u8)).unwrap();
1279        assert_eq!((out.width, out.height), (32, 16));
1280        // Y plane is dw*dh; chroma is half each way.
1281        assert_eq!(out.planes[0].len(), 32 * 16);
1282        assert_eq!(out.planes[1].len(), 16 * 8);
1283        assert_eq!(out.strides, vec![32, 16, 16]);
1284    }
1285
1286    #[test]
1287    fn scale_preserves_a_flat_field_exactly() {
1288        // A constant plane must resample to the same constant (no ringing).
1289        let mut chain = FilterChain::parse("scale=20:30").unwrap();
1290        let out = chain.apply(frame(64, 48, |_, _| 200)).unwrap();
1291        assert!(out.planes[0].iter().all(|&v| v == 200));
1292    }
1293
1294    #[test]
1295    fn crop_extracts_the_right_rectangle() {
1296        // Luma encodes the column index, so a crop at x=8 should start at 8.
1297        let mut chain = FilterChain::parse("crop=16:16:8:4").unwrap();
1298        assert_eq!(chain.output_dims(64, 64), (16, 16));
1299        let out = chain.apply(frame(64, 64, |x, _| x as u8)).unwrap();
1300        assert_eq!((out.width, out.height), (16, 16));
1301        assert_eq!(out.planes[0][0], 8); // top-left came from source column 8
1302        assert_eq!(out.planes[0][15], 23); // 8 + 15
1303    }
1304
1305    #[test]
1306    fn crop_rejects_out_of_bounds_and_misalignment() {
1307        let mut oob = FilterChain::parse("crop=64:64:8:8").unwrap();
1308        assert!(oob.apply(frame(64, 64, |_, _| 0)).is_err());
1309        let mut odd = FilterChain::parse("crop=15:15:1:1").unwrap();
1310        assert!(odd.apply(frame(64, 64, |_, _| 0)).is_err());
1311    }
1312
1313    #[test]
1314    fn rejects_unknown_filter() {
1315        assert!(FilterChain::parse("frobnicate=1").is_err());
1316    }
1317
1318    #[test]
1319    fn negate_inverts_samples() {
1320        let mut chain = FilterChain::parse("negate").unwrap();
1321        let out = chain.apply(frame(4, 4, |x, _| x as u8)).unwrap();
1322        assert_eq!(out.planes[0][0], 255); // 255 - 0
1323        assert_eq!(out.planes[0][3], 252); // 255 - 3
1324        assert_eq!(out.planes[1][0], 127); // chroma 128 → 127
1325    }
1326
1327    #[test]
1328    fn grayscale_neutralizes_chroma() {
1329        // Frame with saturated chroma; grayscale must flatten it to 128.
1330        let mut f = frame(8, 8, |x, _| x as u8);
1331        for c in [1, 2] {
1332            f.planes[c].iter_mut().for_each(|p| *p = 200);
1333        }
1334        let luma_before = f.planes[0].clone();
1335        let mut chain = FilterChain::parse("grayscale").unwrap();
1336        let out = chain.apply(f).unwrap();
1337        assert_eq!(out.planes[0], luma_before); // luma untouched
1338        assert!(out.planes[1].iter().all(|&v| v == 128));
1339        assert!(out.planes[2].iter().all(|&v| v == 128));
1340    }
1341
1342    #[test]
1343    fn scale_minus_one_preserves_aspect() {
1344        let chain = FilterChain::parse("scale=-1:32").unwrap();
1345        // 64×48 → width = 64*32/48 = 42 (rounded even), height = 32.
1346        assert_eq!(chain.output_dims(64, 48), (42, 32));
1347        assert!(FilterChain::parse("scale=-1:-1").is_err());
1348    }
1349
1350    #[test]
1351    fn hflip_mirrors_columns() {
1352        let mut chain = FilterChain::parse("hflip").unwrap();
1353        let out = chain.apply(frame(8, 4, |x, _| x as u8)).unwrap();
1354        assert_eq!((out.width, out.height), (8, 4));
1355        assert_eq!(out.planes[0][0], 7); // first column came from the last
1356        assert_eq!(out.planes[0][7], 0);
1357    }
1358
1359    #[test]
1360    fn vflip_mirrors_rows() {
1361        let mut chain = FilterChain::parse("vflip").unwrap();
1362        let out = chain.apply(frame(4, 8, |_, y| y as u8)).unwrap();
1363        assert_eq!(out.planes[0][0], 7); // top row came from the bottom
1364        assert_eq!(out.planes[0][7 * 4], 0);
1365    }
1366
1367    #[test]
1368    fn transpose_clock_then_cclock_is_identity() {
1369        // A unique value per pixel; clockwise then counter-clockwise must
1370        // restore the original exactly (no interpolation involved).
1371        let original = frame(8, 4, |x, y| (x * 4 + y) as u8);
1372        let expected = original.planes[0].clone();
1373        let mut chain = FilterChain::parse("transpose=clock,transpose=cclock").unwrap();
1374        let out = chain.apply(original).unwrap();
1375        assert_eq!((out.width, out.height), (8, 4));
1376        assert_eq!(out.planes[0], expected);
1377    }
1378
1379    #[test]
1380    fn transpose_swaps_dimensions() {
1381        let chain = FilterChain::parse("transpose=1").unwrap();
1382        assert_eq!(chain.output_dims(96, 64), (64, 96));
1383    }
1384
1385    #[test]
1386    fn format_rgb_yuv_roundtrip_is_close() {
1387        // A smooth RGB gradient survives RGB→YUV420→RGB within tolerance.
1388        let (w, h) = (32usize, 32usize);
1389        let mut rgb = vec![0u8; w * h * 3];
1390        for j in 0..h {
1391            for i in 0..w {
1392                let o = (j * w + i) * 3;
1393                rgb[o] = (i * 255 / (w - 1)) as u8; // R ramps across
1394                rgb[o + 1] = (j * 255 / (h - 1)) as u8; // G ramps down
1395                rgb[o + 2] = 96;
1396            }
1397        }
1398        let src = VideoFrame {
1399            width: w as u32,
1400            height: h as u32,
1401            format: PixelFormat::Rgb24,
1402            planes: vec![rgb.clone()],
1403            strides: vec![w * 3],
1404            pts: Some(0),
1405        };
1406
1407        let mut to_yuv = FilterChain::parse("format=yuv420p").unwrap();
1408        let yuv = to_yuv.apply(src).unwrap();
1409        assert_eq!(yuv.format, PixelFormat::Yuv420p);
1410        assert_eq!(yuv.planes.len(), 3);
1411
1412        let mut to_rgb = FilterChain::parse("format=rgb24").unwrap();
1413        let back = to_rgb.apply(yuv).unwrap();
1414        assert_eq!(back.format, PixelFormat::Rgb24);
1415
1416        let total: u64 = rgb
1417            .iter()
1418            .zip(&back.planes[0])
1419            .map(|(a, b)| (*a as i16 - *b as i16).unsigned_abs() as u64)
1420            .sum();
1421        let mean = total as f64 / (w * h * 3) as f64;
1422        assert!(mean < 8.0, "rgb↔yuv round-trip drifted too far: {mean:.2}");
1423    }
1424
1425    #[test]
1426    fn colorspace_matrices_and_range() {
1427        // A 2×2 solid RGB frame (chroma-subsample averages to the same value → exact).
1428        let solid = |r: u8, g: u8, b: u8| VideoFrame {
1429            width: 2,
1430            height: 2,
1431            format: PixelFormat::Rgb24,
1432            planes: vec![[r, g, b].repeat(4)],
1433            strides: vec![6],
1434            pts: Some(0),
1435        };
1436        let to_yuv = |opts: &str, f: VideoFrame| {
1437            let mut c = FilterChain::parse(&format!("format=yuv420p{opts}")).unwrap();
1438            let y = c.apply(f).unwrap();
1439            (y.planes[0][0], y.planes[1][0], y.planes[2][0])
1440        };
1441        // Range anchors (matrix-independent): limited white→235/black→16; full→255/0.
1442        assert_eq!(to_yuv(":limited", solid(255, 255, 255)).0, 235);
1443        assert_eq!(to_yuv(":limited", solid(0, 0, 0)).0, 16);
1444        assert_eq!(to_yuv(":full", solid(255, 255, 255)).0, 255);
1445        assert_eq!(to_yuv(":full", solid(0, 0, 0)).0, 0);
1446        // Matrix matters: pure-red luma is higher in BT.601 (0.299) than BT.709 (0.2126).
1447        let y601 = to_yuv(":bt601:full", solid(255, 0, 0)).0;
1448        let y709 = to_yuv(":bt709:full", solid(255, 0, 0)).0;
1449        assert!(y601 > y709, "601 red-luma {y601} should exceed 709 {y709}");
1450        // Greyscale is chroma-neutral (U=V=128) in every mode (luma scales with range).
1451        for m in [":full", ":limited", ":bt709:limited"] {
1452            let (_, u, v) = to_yuv(m, solid(128, 128, 128));
1453            assert_eq!((u, v), (128, 128), "grey chroma ({m})");
1454        }
1455        // Each colorspace round-trips tightly (the decode-display path).
1456        for m in [":bt709:limited", ":bt601:limited", ":bt709:full"] {
1457            let mut to_y = FilterChain::parse(&format!("format=yuv420p{m}")).unwrap();
1458            let mut to_r = FilterChain::parse(&format!("format=rgb24{m}")).unwrap();
1459            let src = solid(200, 100, 50);
1460            let back = to_r.apply(to_y.apply(src.clone()).unwrap()).unwrap();
1461            let drift: u32 = src.planes[0]
1462                .iter()
1463                .zip(&back.planes[0])
1464                .map(|(a, b)| (*a as i16 - *b as i16).unsigned_abs() as u32)
1465                .sum::<u32>()
1466                / 12;
1467            assert!(drift < 6, "round-trip drift for {m}: {drift}");
1468        }
1469    }
1470
1471    #[test]
1472    fn pad_centers_input_on_black_canvas() {
1473        let mut chain = FilterChain::parse("pad=16:16:4:4").unwrap();
1474        assert_eq!(chain.output_dims(8, 8), (16, 16));
1475        let out = chain.apply(frame(8, 8, |_, _| 200)).unwrap();
1476        assert_eq!((out.width, out.height), (16, 16));
1477        assert_eq!(out.planes[0][0], 0); // border luma is black
1478        assert_eq!(out.planes[0][4 * 16 + 4], 200); // input top-left at (4,4)
1479        assert_eq!(out.planes[1][0], 128); // border chroma is neutral
1480        assert!(chain.apply(frame(64, 64, |_, _| 0)).is_err()); // doesn't fit
1481    }
1482
1483    #[test]
1484    fn filter_complex_parses_overlay_offset() {
1485        let fc = FilterComplex::parse("[0:v][1:v]overlay=16:8[out]").unwrap();
1486        assert_eq!(fc.overlay, Some((16, 8)));
1487        // Bare overlay (no offset) defaults to the top-left corner.
1488        assert_eq!(
1489            FilterComplex::parse("overlay").unwrap().overlay,
1490            Some((0, 0))
1491        );
1492        assert!(FilterComplex::parse("[0][1]hstack").is_err());
1493    }
1494
1495    #[test]
1496    fn overlay_composites_at_offset() {
1497        // Base luma = 10 everywhere; overlay luma = 200 everywhere. After an
1498        // overlay at (8, 4), the 8×8 patch there must read 200, elsewhere 10.
1499        let base = frame(32, 32, |_, _| 10);
1500        let over = frame(8, 8, |_, _| 200);
1501        let out = overlay(base, &over, 8, 4).unwrap();
1502        assert_eq!((out.width, out.height), (32, 32));
1503        assert_eq!(out.planes[0][4 * 32 + 8], 200); // top-left of the patch
1504        assert_eq!(out.planes[0][11 * 32 + 15], 200); // bottom-right of the patch
1505        assert_eq!(out.planes[0][0], 10); // outside, untouched
1506        assert_eq!(out.planes[0][12 * 32 + 8], 10); // one row below the patch
1507    }
1508
1509    #[test]
1510    fn overlay_clips_to_base_bounds() {
1511        // An overlay placed near the corner spills past the edge; the copy must
1512        // clip rather than panic, and the in-bounds corner still lands.
1513        let base = frame(16, 16, |_, _| 0);
1514        let over = frame(8, 8, |_, _| 99);
1515        let out = overlay(base, &over, 12, 12).unwrap();
1516        assert_eq!(out.planes[0][12 * 16 + 12], 99); // the 4×4 visible corner
1517        assert_eq!(out.planes[0][15 * 16 + 15], 99);
1518    }
1519}
1520
1521#[cfg(test)]
1522mod setrange_tests {
1523    use super::*;
1524
1525    fn planar(y: Vec<u8>, u: Vec<u8>, v: Vec<u8>) -> VideoFrame {
1526        VideoFrame {
1527            width: 2,
1528            height: 2,
1529            format: PixelFormat::Yuv420p,
1530            planes: vec![y, u, v],
1531            strides: vec![2, 1, 1],
1532            pts: None,
1533        }
1534    }
1535
1536    #[test]
1537    fn limited_to_full_hits_the_anchors() {
1538        let mut f = SetRange { to_full: true };
1539        // Limited black/white (16/235) must land on full black/white (0/255).
1540        let out = f
1541            .filter(planar(vec![16, 235, 16, 235], vec![16], vec![240]))
1542            .unwrap();
1543        assert_eq!(out.planes[0], vec![0, 255, 0, 255]);
1544        // Limited chroma spans 16..240.
1545        assert_eq!(out.planes[1], vec![0]);
1546        assert_eq!(out.planes[2], vec![255]);
1547    }
1548
1549    #[test]
1550    fn full_to_limited_hits_the_anchors() {
1551        let mut f = SetRange { to_full: false };
1552        let out = f
1553            .filter(planar(vec![0, 255, 0, 255], vec![0], vec![255]))
1554            .unwrap();
1555        assert_eq!(out.planes[0], vec![16, 235, 16, 235]);
1556        assert_eq!(out.planes[1], vec![16]);
1557        assert_eq!(out.planes[2], vec![240]);
1558    }
1559
1560    #[test]
1561    fn out_of_range_values_clamp_not_wrap() {
1562        // Below limited black / above limited white must saturate, not wrap.
1563        let mut f = SetRange { to_full: true };
1564        let out = f
1565            .filter(planar(vec![0, 255, 8, 240], vec![0], vec![255]))
1566            .unwrap();
1567        assert_eq!(out.planes[0][0], 0);
1568        assert_eq!(out.planes[0][1], 255);
1569    }
1570
1571    #[test]
1572    fn rgb_frames_pass_through_untouched() {
1573        let mut f = SetRange { to_full: true };
1574        let rgb = VideoFrame {
1575            width: 1,
1576            height: 1,
1577            format: PixelFormat::Rgb24,
1578            planes: vec![vec![10, 20, 30]],
1579            strides: vec![3],
1580            pts: None,
1581        };
1582        let out = f.filter(rgb).unwrap();
1583        assert_eq!(out.planes[0], vec![10, 20, 30]);
1584    }
1585
1586    #[test]
1587    fn reports_the_range_it_produces() {
1588        assert_eq!(
1589            SetRange { to_full: true }.output_color_range(),
1590            Some(ColorRange::Full)
1591        );
1592        assert_eq!(
1593            SetRange { to_full: false }.output_color_range(),
1594            Some(ColorRange::Limited)
1595        );
1596    }
1597
1598    #[test]
1599    fn format_conv_takes_the_source_range_unless_pinned() {
1600        // Not pinned: the chain's declared source range wins.
1601        let mut chain = FilterChain::parse("format=rgb24").unwrap();
1602        chain.set_source_color_range(ColorRange::Limited);
1603        // Pinned in the spec: the declaration must NOT override it.
1604        let mut pinned = FilterChain::parse("format=rgb24:full").unwrap();
1605        pinned.set_source_color_range(ColorRange::Limited);
1606
1607        // Limited black (16,128,128) is true black only under the limited reading.
1608        let frame = || VideoFrame {
1609            width: 2,
1610            height: 2,
1611            format: PixelFormat::Yuv420p,
1612            planes: vec![vec![16; 4], vec![128], vec![128]],
1613            strides: vec![2, 1, 1],
1614            pts: None,
1615        };
1616        let a = chain.apply(frame()).unwrap();
1617        let b = pinned.apply(frame()).unwrap();
1618        assert_eq!(a.planes[0][0], 0, "limited reading maps 16 -> 0");
1619        assert!(b.planes[0][0] > 0, "full reading leaves 16 above black");
1620    }
1621}