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            // High-bit-depth Y'CbCr (HEVC Main 10, VP9 profile 2, ...) is
843            // narrowed to 8 bits first, then converted like any 8-bit frame.
844            (from, to) if from.bit_depth() > 8 && to.bit_depth() == 8 => {
845                let narrowed = narrow_to_8(&src)?;
846                if narrowed.format == to {
847                    Ok(narrowed)
848                } else {
849                    self.filter(narrowed)
850                }
851            }
852            (from, to) => Err(Error::unsupported(format!(
853                "format: {} → {} not supported",
854                from.name(),
855                to.name()
856            ))),
857        }
858    }
859}
860
861fn clamp_u8(v: f32) -> u8 {
862    v.round().clamp(0.0, 255.0) as u8
863}
864
865/// Packed RGB(A) → planar 4:2:0 (chroma averaged over each 2×2 block).
866fn rgb_to_yuv420(src: &VideoFrame, bpp: usize, spec: ColorSpec) -> Result<VideoFrame> {
867    let (w, h) = (src.width as usize, src.height as usize);
868    let stride = src.strides[0];
869    let rgb = &src.planes[0];
870    let px = |i: usize, j: usize| {
871        let p = j * stride + i * bpp;
872        (rgb[p] as f32, rgb[p + 1] as f32, rgb[p + 2] as f32)
873    };
874
875    // Forward coeffs from (Kr, Kb): Y = yoff + yscale·(Kr·R+Kg·G+Kb·B),
876    // U = 128 + cscale·(ur·R+ug·G+0.5·B), V = 128 + cscale·(0.5·R+vg·G+vb·B).
877    let (kr, kb, kg) = (spec.kr, spec.kb, spec.kg());
878    let (yoff, yscale, cscale) = if spec.limited {
879        (16.0, 219.0 / 255.0, 224.0 / 255.0)
880    } else {
881        (0.0, 1.0, 1.0)
882    };
883    let (ur, ug) = (-0.5 * kr / (1.0 - kb), -0.5 * kg / (1.0 - kb));
884    let (vg, vb) = (-0.5 * kg / (1.0 - kr), -0.5 * kb / (1.0 - kr));
885
886    let mut y = vec![0u8; w * h];
887    for j in 0..h {
888        for i in 0..w {
889            let (r, g, b) = px(i, j);
890            y[j * w + i] = clamp_u8(yoff + yscale * (kr * r + kg * g + kb * b));
891        }
892    }
893
894    let (cw, ch) = (w.div_ceil(2), h.div_ceil(2));
895    let (mut u, mut v) = (vec![0u8; cw * ch], vec![0u8; cw * ch]);
896    for cj in 0..ch {
897        for ci in 0..cw {
898            // Average the (up to) 2×2 RGB block, then convert once.
899            let (mut sr, mut sg, mut sb, mut n) = (0.0, 0.0, 0.0, 0.0);
900            for dy in 0..2 {
901                for dx in 0..2 {
902                    let (i, j) = (ci * 2 + dx, cj * 2 + dy);
903                    if i < w && j < h {
904                        let (r, g, b) = px(i, j);
905                        sr += r;
906                        sg += g;
907                        sb += b;
908                        n += 1.0;
909                    }
910                }
911            }
912            let (r, g, b) = (sr / n, sg / n, sb / n);
913            u[cj * cw + ci] = clamp_u8(128.0 + cscale * (ur * r + ug * g + 0.5 * b));
914            v[cj * cw + ci] = clamp_u8(128.0 + cscale * (0.5 * r + vg * g + vb * b));
915        }
916    }
917
918    Ok(VideoFrame {
919        width: src.width,
920        height: src.height,
921        format: PixelFormat::Yuv420p,
922        planes: vec![y, u, v],
923        strides: vec![w, cw, cw],
924        pts: src.pts,
925    })
926}
927
928/// Planar 4:2:0 → packed RGB (chroma upsampled nearest-neighbour).
929/// Chroma subsampling `(horizontal, vertical)` of a planar 8-bit Y'CbCr format.
930fn planar_subsampling(format: PixelFormat) -> Option<(usize, usize)> {
931    match format {
932        PixelFormat::Yuv444p | PixelFormat::Yuv444p10 | PixelFormat::Yuv444p12 => Some((1, 1)),
933        PixelFormat::Yuv422p | PixelFormat::Yuv422p10 | PixelFormat::Yuv422p12 => Some((2, 1)),
934        PixelFormat::Yuv420p | PixelFormat::Yuv420p10 | PixelFormat::Yuv420p12 => Some((2, 2)),
935        _ => None,
936    }
937}
938
939/// Planar Y'CbCr (4:2:0 / 4:2:2 / 4:4:4) → packed RGB24.
940///
941/// Chroma is upsampled by replication. That is what the nearest-neighbour
942/// reading of a subsampled plane means, and it keeps this exactly invertible
943/// against the encoder's point-sampling.
944fn yuv_planar_to_rgb(src: &VideoFrame, spec: ColorSpec) -> Result<VideoFrame> {
945    let (sh, sv) = planar_subsampling(src.format).ok_or_else(|| {
946        Error::unsupported(format!(
947            "format: {} is not planar Y'CbCr",
948            src.format.name()
949        ))
950    })?;
951    let (w, h) = (src.width as usize, src.height as usize);
952    let (ys, us, vs) = (src.strides[0], src.strides[1], src.strides[2]);
953    let (yp, up, vp) = (&src.planes[0], &src.planes[1], &src.planes[2]);
954
955    let kg = spec.kg();
956    let (yoff, yscale, cscale) = if spec.limited {
957        (16.0, 255.0 / 219.0, 255.0 / 224.0)
958    } else {
959        (0.0, 1.0, 1.0)
960    };
961    let kr_c = 2.0 * (1.0 - spec.kr) * cscale;
962    let kb_c = 2.0 * (1.0 - spec.kb) * cscale;
963    let kgv = -2.0 * spec.kr * (1.0 - spec.kr) / kg * cscale;
964    let kgu = -2.0 * spec.kb * (1.0 - spec.kb) / kg * cscale;
965
966    let mut out = vec![0u8; w * h * 3];
967    for j in 0..h {
968        for i in 0..w {
969            let ya = yscale * (yp[j * ys + i] as f32 - yoff);
970            let cu = up[(j / sv) * us + i / sh] as f32 - 128.0;
971            let cv = vp[(j / sv) * vs + i / sh] as f32 - 128.0;
972            let o = (j * w + i) * 3;
973            out[o] = clamp_u8(ya + kr_c * cv);
974            out[o + 1] = clamp_u8(ya + kgu * cu + kgv * cv);
975            out[o + 2] = clamp_u8(ya + kb_c * cu);
976        }
977    }
978    Ok(VideoFrame {
979        width: src.width,
980        height: src.height,
981        format: PixelFormat::Rgb24,
982        planes: vec![out],
983        strides: vec![w * 3],
984        pts: src.pts,
985    })
986}
987
988/// Narrows a planar Y'CbCr frame with 10- or 12-bit samples (little-endian
989/// `u16`) to its 8-bit sibling, rounding rather than truncating.
990fn narrow_to_8(src: &VideoFrame) -> Result<VideoFrame> {
991    let (target, shift) = match src.format {
992        PixelFormat::Yuv420p10 => (PixelFormat::Yuv420p, 2),
993        PixelFormat::Yuv422p10 => (PixelFormat::Yuv422p, 2),
994        PixelFormat::Yuv444p10 => (PixelFormat::Yuv444p, 2),
995        PixelFormat::Yuv420p12 => (PixelFormat::Yuv420p, 4),
996        PixelFormat::Yuv422p12 => (PixelFormat::Yuv422p, 4),
997        PixelFormat::Yuv444p12 => (PixelFormat::Yuv444p, 4),
998        other => {
999            return Err(Error::unsupported(format!(
1000                "format: {} cannot be narrowed to 8 bits",
1001                other.name()
1002            )))
1003        }
1004    };
1005    let (sh, sv) = planar_subsampling(src.format).unwrap_or((1, 1));
1006    let (w, h) = (src.width as usize, src.height as usize);
1007    let round = 1u32 << (shift - 1);
1008    let mut planes = Vec::with_capacity(3);
1009    let mut strides = Vec::with_capacity(3);
1010    for (i, plane) in src.planes.iter().enumerate().take(3) {
1011        let (pw, ph) = if i == 0 {
1012            (w, h)
1013        } else {
1014            (w.div_ceil(sh), h.div_ceil(sv))
1015        };
1016        let stride = src.strides.get(i).copied().unwrap_or(pw * 2);
1017        let mut out = Vec::with_capacity(pw * ph);
1018        for y in 0..ph {
1019            let row = plane.get(y * stride..y * stride + pw * 2).ok_or_else(|| {
1020                Error::invalid("narrow_to_8: plane shorter than its declared geometry")
1021            })?;
1022            out.extend(row.chunks_exact(2).map(|c| {
1023                let v = u16::from_le_bytes([c[0], c[1]]) as u32;
1024                ((v + round) >> shift).min(255) as u8
1025            }));
1026        }
1027        strides.push(pw);
1028        planes.push(out);
1029    }
1030    Ok(VideoFrame {
1031        width: src.width,
1032        height: src.height,
1033        format: target,
1034        planes,
1035        strides,
1036        pts: src.pts,
1037    })
1038}
1039
1040/// Planar Y'CbCr → planar 4:2:0, **averaging** the chroma it drops.
1041///
1042/// Averaging rather than point-sampling matters: dropping 3 of every 4 chroma
1043/// samples without a low-pass aliases, and the artefacts land on exactly the
1044/// saturated edges people look at.
1045fn yuv_planar_to_420(src: &VideoFrame) -> Result<VideoFrame> {
1046    if src.format.bit_depth() > 8 {
1047        return Err(Error::unsupported(format!(
1048            "format: {} must be narrowed to 8 bits first",
1049            src.format.name()
1050        )));
1051    }
1052    let (sh, sv) = planar_subsampling(src.format).ok_or_else(|| {
1053        Error::unsupported(format!(
1054            "format: {} is not planar Y'CbCr",
1055            src.format.name()
1056        ))
1057    })?;
1058    if (sh, sv) == (2, 2) {
1059        return Ok(src.clone());
1060    }
1061    let (w, h) = (src.width as usize, src.height as usize);
1062    let (cw, ch) = (w.div_ceil(2), h.div_ceil(2));
1063    // Source chroma plane dimensions.
1064    let (scw, sch) = (w.div_ceil(sh), h.div_ceil(sv));
1065
1066    // Luma is untouched; repack it to a tight stride.
1067    let ys = src.strides[0];
1068    let mut luma = Vec::with_capacity(w * h);
1069    for j in 0..h {
1070        luma.extend_from_slice(&src.planes[0][j * ys..j * ys + w]);
1071    }
1072
1073    let mut out_planes = vec![luma];
1074    for plane_idx in 1..3 {
1075        let stride = src.strides[plane_idx];
1076        let plane = &src.planes[plane_idx];
1077        let mut dst = vec![0u8; cw * ch];
1078        // How many source chroma samples collapse into one output sample.
1079        let (fx, fy) = (2 / sh, 2 / sv);
1080        for j in 0..ch {
1081            for i in 0..cw {
1082                let mut sum = 0u32;
1083                let mut n = 0u32;
1084                for dy in 0..fy.max(1) {
1085                    let sy = (j * fy + dy).min(sch - 1);
1086                    for dx in 0..fx.max(1) {
1087                        let sx = (i * fx + dx).min(scw - 1);
1088                        sum += plane[sy * stride + sx] as u32;
1089                        n += 1;
1090                    }
1091                }
1092                dst[j * cw + i] = ((sum + n / 2) / n) as u8;
1093            }
1094        }
1095        out_planes.push(dst);
1096    }
1097    Ok(VideoFrame {
1098        width: src.width,
1099        height: src.height,
1100        format: PixelFormat::Yuv420p,
1101        planes: out_planes,
1102        strides: vec![w, cw, cw],
1103        pts: src.pts,
1104    })
1105}
1106
1107fn yuv420_to_rgb(src: &VideoFrame, spec: ColorSpec) -> Result<VideoFrame> {
1108    let (w, h) = (src.width as usize, src.height as usize);
1109    let (ys, us, vs) = (src.strides[0], src.strides[1], src.strides[2]);
1110    let (yp, up, vp) = (&src.planes[0], &src.planes[1], &src.planes[2]);
1111
1112    // Derive from (Kr, Kb): R = ya + kr_c·cv, G = ya + kgu·cu + kgv·cv,
1113    // B = ya + kb_c·cu, where ya = yscale·(Y − yoff), cu = Cu−128, cv = Cv−128.
1114    let kg = spec.kg();
1115    let (yoff, yscale, cscale) = if spec.limited {
1116        (16.0, 255.0 / 219.0, 255.0 / 224.0)
1117    } else {
1118        (0.0, 1.0, 1.0)
1119    };
1120    let kr_c = 2.0 * (1.0 - spec.kr) * cscale;
1121    let kb_c = 2.0 * (1.0 - spec.kb) * cscale;
1122    let kgv = -2.0 * spec.kr * (1.0 - spec.kr) / kg * cscale;
1123    let kgu = -2.0 * spec.kb * (1.0 - spec.kb) / kg * cscale;
1124
1125    let mut out = vec![0u8; w * h * 3];
1126    for j in 0..h {
1127        for i in 0..w {
1128            let ya = yscale * (yp[j * ys + i] as f32 - yoff);
1129            let cu = up[(j / 2) * us + i / 2] as f32 - 128.0;
1130            let cv = vp[(j / 2) * vs + i / 2] as f32 - 128.0;
1131            let o = (j * w + i) * 3;
1132            out[o] = clamp_u8(ya + kr_c * cv);
1133            out[o + 1] = clamp_u8(ya + kgu * cu + kgv * cv);
1134            out[o + 2] = clamp_u8(ya + kb_c * cu);
1135        }
1136    }
1137    Ok(VideoFrame {
1138        width: src.width,
1139        height: src.height,
1140        format: PixelFormat::Rgb24,
1141        planes: vec![out],
1142        strides: vec![w * 3],
1143        pts: src.pts,
1144    })
1145}
1146
1147/// Drop the alpha channel: packed RGBA → packed RGB.
1148fn rgba_to_rgb(src: &VideoFrame) -> Result<VideoFrame> {
1149    let (w, h) = (src.width as usize, src.height as usize);
1150    let stride = src.strides[0];
1151    let rgba = &src.planes[0];
1152    let mut out = vec![0u8; w * h * 3];
1153    for j in 0..h {
1154        for i in 0..w {
1155            let s = j * stride + i * 4;
1156            let d = (j * w + i) * 3;
1157            out[d..d + 3].copy_from_slice(&rgba[s..s + 3]);
1158        }
1159    }
1160    Ok(VideoFrame {
1161        width: src.width,
1162        height: src.height,
1163        format: PixelFormat::Rgb24,
1164        planes: vec![out],
1165        strides: vec![w * 3],
1166        pts: src.pts,
1167    })
1168}
1169
1170// ---------------------------------------------------------------------------
1171// negate / grayscale
1172// ---------------------------------------------------------------------------
1173
1174/// Repack a plane into a tight buffer, applying `f` to every sample.
1175fn map_plane(plane: &[u8], stride: usize, pw: usize, ph: usize, f: impl Fn(u8) -> u8) -> Vec<u8> {
1176    let mut out = vec![0u8; pw * ph];
1177    for y in 0..ph {
1178        let row = &plane[y * stride..y * stride + pw];
1179        let dst = &mut out[y * pw..(y + 1) * pw];
1180        for x in 0..pw {
1181            dst[x] = f(row[x]);
1182        }
1183    }
1184    out
1185}
1186
1187/// `negate` — invert every sample (`255 - v`), luma and chroma.
1188struct Negate;
1189
1190impl Filter for Negate {
1191    fn name(&self) -> &'static str {
1192        "negate"
1193    }
1194
1195    fn filter(&mut self, src: VideoFrame) -> Result<VideoFrame> {
1196        ensure_planar_yuv(&src, "negate")?;
1197        let mut planes = Vec::with_capacity(src.planes.len());
1198        let mut strides = Vec::with_capacity(src.planes.len());
1199        for (i, plane) in src.planes.iter().enumerate() {
1200            let (pw, ph) = plane_dims(src.format, src.width, src.height, i)?;
1201            planes.push(map_plane(
1202                plane,
1203                src.strides[i],
1204                pw as usize,
1205                ph as usize,
1206                |v| 255 - v,
1207            ));
1208            strides.push(pw as usize);
1209        }
1210        Ok(VideoFrame {
1211            planes,
1212            strides,
1213            ..src
1214        })
1215    }
1216}
1217
1218/// `grayscale` — keep luma, neutralize chroma (set both chroma planes to 128).
1219/// `setrange=full|limited` — rescale planar Y'CbCr between limited ("TV",
1220/// Y 16–235 / C 16–240) and full ("PC", 0–255) range.
1221///
1222/// This is a value rescale only: no colour-space change, no chroma resampling,
1223/// planes keep their dimensions. It exists because JPEG is defined on full-range
1224/// samples while most video is limited-range, so handing a limited-range plane
1225/// straight to a JPEG encoder encodes the wrong signal. The transcode pipeline
1226/// inserts this automatically when the target codec needs full range; the CLI
1227/// name is exposed for when you need to override that.
1228///
1229/// RGB frames pass through untouched — the distinction is a Y'CbCr one.
1230struct SetRange {
1231    to_full: bool,
1232}
1233
1234impl SetRange {
1235    fn parse(args: &[&str]) -> Result<SetRange> {
1236        match args.first().copied().unwrap_or("").trim() {
1237            "full" | "pc" | "jpeg" => Ok(SetRange { to_full: true }),
1238            "limited" | "tv" | "mpeg" => Ok(SetRange { to_full: false }),
1239            other => Err(Error::unsupported(format!(
1240                "setrange: `{other}` (want full/pc/jpeg or limited/tv/mpeg)"
1241            ))),
1242        }
1243    }
1244
1245    /// 256-entry map for one component. Luma and chroma differ: limited luma is
1246    /// 16–235, limited chroma 16–240.
1247    fn lut(&self, chroma: bool) -> [u8; 256] {
1248        let (lo, span) = if chroma { (16.0, 224.0) } else { (16.0, 219.0) };
1249        let mut lut = [0u8; 256];
1250        for (v, out) in lut.iter_mut().enumerate() {
1251            let v = v as f32;
1252            let mapped = if self.to_full {
1253                (v - lo) * 255.0 / span
1254            } else {
1255                v * span / 255.0 + lo
1256            };
1257            *out = mapped.round().clamp(0.0, 255.0) as u8;
1258        }
1259        lut
1260    }
1261}
1262
1263impl Filter for SetRange {
1264    fn name(&self) -> &'static str {
1265        "setrange"
1266    }
1267
1268    fn output_color_range(&self) -> Option<ColorRange> {
1269        Some(if self.to_full {
1270            ColorRange::Full
1271        } else {
1272            ColorRange::Limited
1273        })
1274    }
1275
1276    fn filter(&mut self, mut frame: VideoFrame) -> Result<VideoFrame> {
1277        // Only planar Y'CbCr has a range to convert.
1278        if ensure_planar_yuv(&frame, "setrange").is_err() {
1279            return Ok(frame);
1280        }
1281        let luma = self.lut(false);
1282        let chroma = self.lut(true);
1283        for (i, plane) in frame.planes.iter_mut().enumerate() {
1284            let lut = if i == 0 { &luma } else { &chroma };
1285            for b in plane.iter_mut() {
1286                *b = lut[*b as usize];
1287            }
1288        }
1289        Ok(frame)
1290    }
1291}
1292
1293struct Grayscale;
1294
1295impl Filter for Grayscale {
1296    fn name(&self) -> &'static str {
1297        "grayscale"
1298    }
1299
1300    fn filter(&mut self, src: VideoFrame) -> Result<VideoFrame> {
1301        ensure_planar_yuv(&src, "grayscale")?;
1302        let mut planes = Vec::with_capacity(src.planes.len());
1303        let mut strides = Vec::with_capacity(src.planes.len());
1304        for (i, plane) in src.planes.iter().enumerate() {
1305            let (pw, ph) = plane_dims(src.format, src.width, src.height, i)?;
1306            let (pw, ph) = (pw as usize, ph as usize);
1307            if i == 0 {
1308                planes.push(map_plane(plane, src.strides[i], pw, ph, |v| v));
1309            } else {
1310                planes.push(vec![128u8; pw * ph]);
1311            }
1312            strides.push(pw);
1313        }
1314        Ok(VideoFrame {
1315            planes,
1316            strides,
1317            ..src
1318        })
1319    }
1320}
1321
1322#[cfg(test)]
1323mod tests {
1324    use super::*;
1325
1326    /// A W×H 4:2:0 frame with a per-pixel luma function and flat chroma.
1327    fn frame(w: u32, h: u32, luma: impl Fn(u32, u32) -> u8) -> VideoFrame {
1328        let (wi, hi) = (w as usize, h as usize);
1329        let mut y = vec![0u8; wi * hi];
1330        for row in 0..h {
1331            for col in 0..w {
1332                y[row as usize * wi + col as usize] = luma(col, row);
1333            }
1334        }
1335        let chroma = vec![128u8; (wi / 2) * (hi / 2)];
1336        VideoFrame {
1337            width: w,
1338            height: h,
1339            format: PixelFormat::Yuv420p,
1340            planes: vec![y, chroma.clone(), chroma],
1341            strides: vec![wi, wi / 2, wi / 2],
1342            pts: Some(0),
1343        }
1344    }
1345
1346    #[test]
1347    fn scale_changes_dimensions_and_keeps_planes_consistent() {
1348        let mut chain = FilterChain::parse("scale=32:16").unwrap();
1349        assert_eq!(chain.output_dims(64, 64), (32, 16));
1350        let out = chain.apply(frame(64, 64, |x, _| x as u8)).unwrap();
1351        assert_eq!((out.width, out.height), (32, 16));
1352        // Y plane is dw*dh; chroma is half each way.
1353        assert_eq!(out.planes[0].len(), 32 * 16);
1354        assert_eq!(out.planes[1].len(), 16 * 8);
1355        assert_eq!(out.strides, vec![32, 16, 16]);
1356    }
1357
1358    #[test]
1359    fn scale_preserves_a_flat_field_exactly() {
1360        // A constant plane must resample to the same constant (no ringing).
1361        let mut chain = FilterChain::parse("scale=20:30").unwrap();
1362        let out = chain.apply(frame(64, 48, |_, _| 200)).unwrap();
1363        assert!(out.planes[0].iter().all(|&v| v == 200));
1364    }
1365
1366    #[test]
1367    fn crop_extracts_the_right_rectangle() {
1368        // Luma encodes the column index, so a crop at x=8 should start at 8.
1369        let mut chain = FilterChain::parse("crop=16:16:8:4").unwrap();
1370        assert_eq!(chain.output_dims(64, 64), (16, 16));
1371        let out = chain.apply(frame(64, 64, |x, _| x as u8)).unwrap();
1372        assert_eq!((out.width, out.height), (16, 16));
1373        assert_eq!(out.planes[0][0], 8); // top-left came from source column 8
1374        assert_eq!(out.planes[0][15], 23); // 8 + 15
1375    }
1376
1377    #[test]
1378    fn crop_rejects_out_of_bounds_and_misalignment() {
1379        let mut oob = FilterChain::parse("crop=64:64:8:8").unwrap();
1380        assert!(oob.apply(frame(64, 64, |_, _| 0)).is_err());
1381        let mut odd = FilterChain::parse("crop=15:15:1:1").unwrap();
1382        assert!(odd.apply(frame(64, 64, |_, _| 0)).is_err());
1383    }
1384
1385    #[test]
1386    fn rejects_unknown_filter() {
1387        assert!(FilterChain::parse("frobnicate=1").is_err());
1388    }
1389
1390    #[test]
1391    fn negate_inverts_samples() {
1392        let mut chain = FilterChain::parse("negate").unwrap();
1393        let out = chain.apply(frame(4, 4, |x, _| x as u8)).unwrap();
1394        assert_eq!(out.planes[0][0], 255); // 255 - 0
1395        assert_eq!(out.planes[0][3], 252); // 255 - 3
1396        assert_eq!(out.planes[1][0], 127); // chroma 128 → 127
1397    }
1398
1399    #[test]
1400    fn grayscale_neutralizes_chroma() {
1401        // Frame with saturated chroma; grayscale must flatten it to 128.
1402        let mut f = frame(8, 8, |x, _| x as u8);
1403        for c in [1, 2] {
1404            f.planes[c].iter_mut().for_each(|p| *p = 200);
1405        }
1406        let luma_before = f.planes[0].clone();
1407        let mut chain = FilterChain::parse("grayscale").unwrap();
1408        let out = chain.apply(f).unwrap();
1409        assert_eq!(out.planes[0], luma_before); // luma untouched
1410        assert!(out.planes[1].iter().all(|&v| v == 128));
1411        assert!(out.planes[2].iter().all(|&v| v == 128));
1412    }
1413
1414    #[test]
1415    fn scale_minus_one_preserves_aspect() {
1416        let chain = FilterChain::parse("scale=-1:32").unwrap();
1417        // 64×48 → width = 64*32/48 = 42 (rounded even), height = 32.
1418        assert_eq!(chain.output_dims(64, 48), (42, 32));
1419        assert!(FilterChain::parse("scale=-1:-1").is_err());
1420    }
1421
1422    #[test]
1423    fn hflip_mirrors_columns() {
1424        let mut chain = FilterChain::parse("hflip").unwrap();
1425        let out = chain.apply(frame(8, 4, |x, _| x as u8)).unwrap();
1426        assert_eq!((out.width, out.height), (8, 4));
1427        assert_eq!(out.planes[0][0], 7); // first column came from the last
1428        assert_eq!(out.planes[0][7], 0);
1429    }
1430
1431    #[test]
1432    fn vflip_mirrors_rows() {
1433        let mut chain = FilterChain::parse("vflip").unwrap();
1434        let out = chain.apply(frame(4, 8, |_, y| y as u8)).unwrap();
1435        assert_eq!(out.planes[0][0], 7); // top row came from the bottom
1436        assert_eq!(out.planes[0][7 * 4], 0);
1437    }
1438
1439    #[test]
1440    fn transpose_clock_then_cclock_is_identity() {
1441        // A unique value per pixel; clockwise then counter-clockwise must
1442        // restore the original exactly (no interpolation involved).
1443        let original = frame(8, 4, |x, y| (x * 4 + y) as u8);
1444        let expected = original.planes[0].clone();
1445        let mut chain = FilterChain::parse("transpose=clock,transpose=cclock").unwrap();
1446        let out = chain.apply(original).unwrap();
1447        assert_eq!((out.width, out.height), (8, 4));
1448        assert_eq!(out.planes[0], expected);
1449    }
1450
1451    #[test]
1452    fn transpose_swaps_dimensions() {
1453        let chain = FilterChain::parse("transpose=1").unwrap();
1454        assert_eq!(chain.output_dims(96, 64), (64, 96));
1455    }
1456
1457    #[test]
1458    fn format_rgb_yuv_roundtrip_is_close() {
1459        // A smooth RGB gradient survives RGB→YUV420→RGB within tolerance.
1460        let (w, h) = (32usize, 32usize);
1461        let mut rgb = vec![0u8; w * h * 3];
1462        for j in 0..h {
1463            for i in 0..w {
1464                let o = (j * w + i) * 3;
1465                rgb[o] = (i * 255 / (w - 1)) as u8; // R ramps across
1466                rgb[o + 1] = (j * 255 / (h - 1)) as u8; // G ramps down
1467                rgb[o + 2] = 96;
1468            }
1469        }
1470        let src = VideoFrame {
1471            width: w as u32,
1472            height: h as u32,
1473            format: PixelFormat::Rgb24,
1474            planes: vec![rgb.clone()],
1475            strides: vec![w * 3],
1476            pts: Some(0),
1477        };
1478
1479        let mut to_yuv = FilterChain::parse("format=yuv420p").unwrap();
1480        let yuv = to_yuv.apply(src).unwrap();
1481        assert_eq!(yuv.format, PixelFormat::Yuv420p);
1482        assert_eq!(yuv.planes.len(), 3);
1483
1484        let mut to_rgb = FilterChain::parse("format=rgb24").unwrap();
1485        let back = to_rgb.apply(yuv).unwrap();
1486        assert_eq!(back.format, PixelFormat::Rgb24);
1487
1488        let total: u64 = rgb
1489            .iter()
1490            .zip(&back.planes[0])
1491            .map(|(a, b)| (*a as i16 - *b as i16).unsigned_abs() as u64)
1492            .sum();
1493        let mean = total as f64 / (w * h * 3) as f64;
1494        assert!(mean < 8.0, "rgb↔yuv round-trip drifted too far: {mean:.2}");
1495    }
1496
1497    #[test]
1498    fn colorspace_matrices_and_range() {
1499        // A 2×2 solid RGB frame (chroma-subsample averages to the same value → exact).
1500        let solid = |r: u8, g: u8, b: u8| VideoFrame {
1501            width: 2,
1502            height: 2,
1503            format: PixelFormat::Rgb24,
1504            planes: vec![[r, g, b].repeat(4)],
1505            strides: vec![6],
1506            pts: Some(0),
1507        };
1508        let to_yuv = |opts: &str, f: VideoFrame| {
1509            let mut c = FilterChain::parse(&format!("format=yuv420p{opts}")).unwrap();
1510            let y = c.apply(f).unwrap();
1511            (y.planes[0][0], y.planes[1][0], y.planes[2][0])
1512        };
1513        // Range anchors (matrix-independent): limited white→235/black→16; full→255/0.
1514        assert_eq!(to_yuv(":limited", solid(255, 255, 255)).0, 235);
1515        assert_eq!(to_yuv(":limited", solid(0, 0, 0)).0, 16);
1516        assert_eq!(to_yuv(":full", solid(255, 255, 255)).0, 255);
1517        assert_eq!(to_yuv(":full", solid(0, 0, 0)).0, 0);
1518        // Matrix matters: pure-red luma is higher in BT.601 (0.299) than BT.709 (0.2126).
1519        let y601 = to_yuv(":bt601:full", solid(255, 0, 0)).0;
1520        let y709 = to_yuv(":bt709:full", solid(255, 0, 0)).0;
1521        assert!(y601 > y709, "601 red-luma {y601} should exceed 709 {y709}");
1522        // Greyscale is chroma-neutral (U=V=128) in every mode (luma scales with range).
1523        for m in [":full", ":limited", ":bt709:limited"] {
1524            let (_, u, v) = to_yuv(m, solid(128, 128, 128));
1525            assert_eq!((u, v), (128, 128), "grey chroma ({m})");
1526        }
1527        // Each colorspace round-trips tightly (the decode-display path).
1528        for m in [":bt709:limited", ":bt601:limited", ":bt709:full"] {
1529            let mut to_y = FilterChain::parse(&format!("format=yuv420p{m}")).unwrap();
1530            let mut to_r = FilterChain::parse(&format!("format=rgb24{m}")).unwrap();
1531            let src = solid(200, 100, 50);
1532            let back = to_r.apply(to_y.apply(src.clone()).unwrap()).unwrap();
1533            let drift: u32 = src.planes[0]
1534                .iter()
1535                .zip(&back.planes[0])
1536                .map(|(a, b)| (*a as i16 - *b as i16).unsigned_abs() as u32)
1537                .sum::<u32>()
1538                / 12;
1539            assert!(drift < 6, "round-trip drift for {m}: {drift}");
1540        }
1541    }
1542
1543    #[test]
1544    fn pad_centers_input_on_black_canvas() {
1545        let mut chain = FilterChain::parse("pad=16:16:4:4").unwrap();
1546        assert_eq!(chain.output_dims(8, 8), (16, 16));
1547        let out = chain.apply(frame(8, 8, |_, _| 200)).unwrap();
1548        assert_eq!((out.width, out.height), (16, 16));
1549        assert_eq!(out.planes[0][0], 0); // border luma is black
1550        assert_eq!(out.planes[0][4 * 16 + 4], 200); // input top-left at (4,4)
1551        assert_eq!(out.planes[1][0], 128); // border chroma is neutral
1552        assert!(chain.apply(frame(64, 64, |_, _| 0)).is_err()); // doesn't fit
1553    }
1554
1555    #[test]
1556    fn filter_complex_parses_overlay_offset() {
1557        let fc = FilterComplex::parse("[0:v][1:v]overlay=16:8[out]").unwrap();
1558        assert_eq!(fc.overlay, Some((16, 8)));
1559        // Bare overlay (no offset) defaults to the top-left corner.
1560        assert_eq!(
1561            FilterComplex::parse("overlay").unwrap().overlay,
1562            Some((0, 0))
1563        );
1564        assert!(FilterComplex::parse("[0][1]hstack").is_err());
1565    }
1566
1567    #[test]
1568    fn overlay_composites_at_offset() {
1569        // Base luma = 10 everywhere; overlay luma = 200 everywhere. After an
1570        // overlay at (8, 4), the 8×8 patch there must read 200, elsewhere 10.
1571        let base = frame(32, 32, |_, _| 10);
1572        let over = frame(8, 8, |_, _| 200);
1573        let out = overlay(base, &over, 8, 4).unwrap();
1574        assert_eq!((out.width, out.height), (32, 32));
1575        assert_eq!(out.planes[0][4 * 32 + 8], 200); // top-left of the patch
1576        assert_eq!(out.planes[0][11 * 32 + 15], 200); // bottom-right of the patch
1577        assert_eq!(out.planes[0][0], 10); // outside, untouched
1578        assert_eq!(out.planes[0][12 * 32 + 8], 10); // one row below the patch
1579    }
1580
1581    #[test]
1582    fn overlay_clips_to_base_bounds() {
1583        // An overlay placed near the corner spills past the edge; the copy must
1584        // clip rather than panic, and the in-bounds corner still lands.
1585        let base = frame(16, 16, |_, _| 0);
1586        let over = frame(8, 8, |_, _| 99);
1587        let out = overlay(base, &over, 12, 12).unwrap();
1588        assert_eq!(out.planes[0][12 * 16 + 12], 99); // the 4×4 visible corner
1589        assert_eq!(out.planes[0][15 * 16 + 15], 99);
1590    }
1591}
1592
1593#[cfg(test)]
1594mod setrange_tests {
1595    use super::*;
1596
1597    fn planar(y: Vec<u8>, u: Vec<u8>, v: Vec<u8>) -> VideoFrame {
1598        VideoFrame {
1599            width: 2,
1600            height: 2,
1601            format: PixelFormat::Yuv420p,
1602            planes: vec![y, u, v],
1603            strides: vec![2, 1, 1],
1604            pts: None,
1605        }
1606    }
1607
1608    #[test]
1609    fn limited_to_full_hits_the_anchors() {
1610        let mut f = SetRange { to_full: true };
1611        // Limited black/white (16/235) must land on full black/white (0/255).
1612        let out = f
1613            .filter(planar(vec![16, 235, 16, 235], vec![16], vec![240]))
1614            .unwrap();
1615        assert_eq!(out.planes[0], vec![0, 255, 0, 255]);
1616        // Limited chroma spans 16..240.
1617        assert_eq!(out.planes[1], vec![0]);
1618        assert_eq!(out.planes[2], vec![255]);
1619    }
1620
1621    #[test]
1622    fn full_to_limited_hits_the_anchors() {
1623        let mut f = SetRange { to_full: false };
1624        let out = f
1625            .filter(planar(vec![0, 255, 0, 255], vec![0], vec![255]))
1626            .unwrap();
1627        assert_eq!(out.planes[0], vec![16, 235, 16, 235]);
1628        assert_eq!(out.planes[1], vec![16]);
1629        assert_eq!(out.planes[2], vec![240]);
1630    }
1631
1632    #[test]
1633    fn out_of_range_values_clamp_not_wrap() {
1634        // Below limited black / above limited white must saturate, not wrap.
1635        let mut f = SetRange { to_full: true };
1636        let out = f
1637            .filter(planar(vec![0, 255, 8, 240], vec![0], vec![255]))
1638            .unwrap();
1639        assert_eq!(out.planes[0][0], 0);
1640        assert_eq!(out.planes[0][1], 255);
1641    }
1642
1643    #[test]
1644    fn rgb_frames_pass_through_untouched() {
1645        let mut f = SetRange { to_full: true };
1646        let rgb = VideoFrame {
1647            width: 1,
1648            height: 1,
1649            format: PixelFormat::Rgb24,
1650            planes: vec![vec![10, 20, 30]],
1651            strides: vec![3],
1652            pts: None,
1653        };
1654        let out = f.filter(rgb).unwrap();
1655        assert_eq!(out.planes[0], vec![10, 20, 30]);
1656    }
1657
1658    #[test]
1659    fn reports_the_range_it_produces() {
1660        assert_eq!(
1661            SetRange { to_full: true }.output_color_range(),
1662            Some(ColorRange::Full)
1663        );
1664        assert_eq!(
1665            SetRange { to_full: false }.output_color_range(),
1666            Some(ColorRange::Limited)
1667        );
1668    }
1669
1670    #[test]
1671    fn format_conv_takes_the_source_range_unless_pinned() {
1672        // Not pinned: the chain's declared source range wins.
1673        let mut chain = FilterChain::parse("format=rgb24").unwrap();
1674        chain.set_source_color_range(ColorRange::Limited);
1675        // Pinned in the spec: the declaration must NOT override it.
1676        let mut pinned = FilterChain::parse("format=rgb24:full").unwrap();
1677        pinned.set_source_color_range(ColorRange::Limited);
1678
1679        // Limited black (16,128,128) is true black only under the limited reading.
1680        let frame = || VideoFrame {
1681            width: 2,
1682            height: 2,
1683            format: PixelFormat::Yuv420p,
1684            planes: vec![vec![16; 4], vec![128], vec![128]],
1685            strides: vec![2, 1, 1],
1686            pts: None,
1687        };
1688        let a = chain.apply(frame()).unwrap();
1689        let b = pinned.apply(frame()).unwrap();
1690        assert_eq!(a.planes[0][0], 0, "limited reading maps 16 -> 0");
1691        assert!(b.planes[0][0] > 0, "full reading leaves 16 above black");
1692    }
1693}