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