Skip to main content

codec/
filter.rs

1//! Video filters — per-frame transforms applied to decoded frames **before**
2//! per-rung scaling and encoding.
3//!
4//! The canonical representation is a list of [`VideoFilter`] **values**. Two
5//! kinds:
6//!
7//! - **Stateless** filters ([`apply`] runs them directly): crop, pad, hflip,
8//!   vflip, rotate, grayscale (geometry, any bit depth) + invert, brightness,
9//!   contrast, saturation (colour, 8-bit).
10//! - **Resource** filters need a one-time setup before they can run per frame —
11//!   `overlay` loads its PNG and converts it to YUV + alpha. Build a
12//!   [`FilterChain`] with [`FilterChain::prepare`] (loads overlays once) and
13//!   call [`FilterChain::apply`] per frame.
14//!
15//! Two interchangeable serializations (they round-trip:
16//! `parse_chain(&chain_to_string(c)) == c`):
17//!
18//! - **Structured** objects (serde feature) — a YAML/JSON DSL writes a chain as
19//!   a list of objects: `[{crop: {w,h}}, hflip, {overlay: {image: "logo.png"}}]`.
20//! - **Textual** ffmpeg-`-vf` style — [`parse_chain`] / [`Display`]:
21//!   `crop=1280:720,hflip,overlay=logo.png:24:24`.
22//!
23//! Geometric ops are pure sample rearrangement (run on raw bytes, any bit
24//! depth). Colour + overlay ops work on 8-bit `Yuv420p` (the default SDR output).
25
26use std::fmt;
27
28use anyhow::{Context, Result, bail};
29use bytes::BytesMut;
30
31use crate::frame::{PixelFormat, VideoFrame};
32
33/// One video-filter step. The canonical, code-interpreted representation.
34#[derive(Debug, Clone, PartialEq)]
35#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
36#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
37pub enum VideoFilter {
38    /// Crop a `w×h` region. Centred when `x`/`y` are omitted, else at `(x, y)`.
39    Crop {
40        w: u32,
41        h: u32,
42        #[cfg_attr(feature = "serde", serde(default, skip_serializing_if = "Option::is_none"))]
43        x: Option<u32>,
44        #[cfg_attr(feature = "serde", serde(default, skip_serializing_if = "Option::is_none"))]
45        y: Option<u32>,
46    },
47    /// Pad into a `w×h` canvas (neutral black). Centred when `x`/`y` are omitted.
48    Pad {
49        w: u32,
50        h: u32,
51        #[cfg_attr(feature = "serde", serde(default, skip_serializing_if = "Option::is_none"))]
52        x: Option<u32>,
53        #[cfg_attr(feature = "serde", serde(default, skip_serializing_if = "Option::is_none"))]
54        y: Option<u32>,
55    },
56    /// Mirror horizontally (left↔right).
57    #[cfg_attr(feature = "serde", serde(rename = "hflip"))]
58    HFlip,
59    /// Mirror vertically (top↔bottom).
60    #[cfg_attr(feature = "serde", serde(rename = "vflip"))]
61    VFlip,
62    /// Rotate clockwise by 90, 180, or 270 degrees (90/270 swap width↔height).
63    Rotate(u32),
64    /// Drop chroma — set U/V to neutral so the image is grayscale.
65    Grayscale,
66    /// Alpha-composite a PNG (logo / watermark) at top-left `(x, y)`. 8-bit only.
67    Overlay {
68        /// Path to a PNG image (with or without an alpha channel).
69        image: String,
70        #[cfg_attr(feature = "serde", serde(default))]
71        x: u32,
72        #[cfg_attr(feature = "serde", serde(default))]
73        y: u32,
74    },
75    /// Invert (negate) luma + chroma. 8-bit only.
76    Invert,
77    /// Add a luma offset (`-255..=255`); brighten/darken. 8-bit only.
78    Brightness(i32),
79    /// Scale luma contrast around mid-grey (`1.0` = unchanged). 8-bit only.
80    Contrast(f32),
81    /// Scale chroma saturation around neutral (`0` = grayscale, `1.0` = unchanged). 8-bit only.
82    Saturation(f32),
83}
84
85impl fmt::Display for VideoFilter {
86    /// The textual (ffmpeg-`-vf`) token for this filter.
87    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
88        match self {
89            VideoFilter::Crop { w, h, x: Some(x), y: Some(y) } => write!(f, "crop={w}:{h}:{x}:{y}"),
90            VideoFilter::Crop { w, h, .. } => write!(f, "crop={w}:{h}"),
91            VideoFilter::Pad { w, h, x: Some(x), y: Some(y) } => write!(f, "pad={w}:{h}:{x}:{y}"),
92            VideoFilter::Pad { w, h, .. } => write!(f, "pad={w}:{h}"),
93            VideoFilter::HFlip => write!(f, "hflip"),
94            VideoFilter::VFlip => write!(f, "vflip"),
95            VideoFilter::Rotate(d) => write!(f, "rotate={d}"),
96            VideoFilter::Grayscale => write!(f, "grayscale"),
97            VideoFilter::Overlay { image, x, y } => write!(f, "overlay={image}:{x}:{y}"),
98            VideoFilter::Invert => write!(f, "invert"),
99            VideoFilter::Brightness(b) => write!(f, "brightness={b}"),
100            VideoFilter::Contrast(c) => write!(f, "contrast={c}"),
101            VideoFilter::Saturation(s) => write!(f, "saturation={s}"),
102        }
103    }
104}
105
106/// A whole chain as a comma-separated textual string (the inverse of
107/// [`parse_chain`]).
108pub fn chain_to_string(chain: &[VideoFilter]) -> String {
109    chain.iter().map(|f| f.to_string()).collect::<Vec<_>>().join(",")
110}
111
112/// A filter chain in either form, for a DSL field that should accept both a
113/// structured list or a string. Resolve with [`FilterSpec::resolve`].
114#[cfg(feature = "serde")]
115#[derive(Debug, Clone)]
116#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
117#[serde(untagged)]
118pub enum FilterSpec {
119    /// An ffmpeg-`-vf`-style chain string, e.g. `"crop=1280:720,hflip"`.
120    Chain(String),
121    /// A structured list of filters.
122    List(Vec<VideoFilter>),
123}
124
125#[cfg(feature = "serde")]
126impl FilterSpec {
127    /// Resolve to the concrete, **validated** filter list. The string form is
128    /// validated by [`parse_chain`]; the structured form is validated by
129    /// round-tripping through its textual rendering, so e.g. `rotate: 45` is
130    /// rejected at config time rather than at apply time.
131    pub fn resolve(&self) -> Result<Vec<VideoFilter>> {
132        match self {
133            FilterSpec::Chain(s) => parse_chain(s),
134            FilterSpec::List(v) => parse_chain(&chain_to_string(v)),
135        }
136    }
137
138    /// Collapse to the chain-string form (for string-only surfaces).
139    pub fn to_chain(&self) -> String {
140        match self {
141            FilterSpec::Chain(s) => s.clone(),
142            FilterSpec::List(v) => chain_to_string(v),
143        }
144    }
145}
146
147/// Parse an ffmpeg-`-vf`-style chain, e.g. `"crop=1280:720,hflip"`.
148pub fn parse_chain(s: &str) -> Result<Vec<VideoFilter>> {
149    let mut out = Vec::new();
150    for part in s.split(',').map(str::trim).filter(|p| !p.is_empty()) {
151        out.push(parse_one(part)?);
152    }
153    if out.is_empty() {
154        bail!("empty filter chain");
155    }
156    Ok(out)
157}
158
159fn parse_one(spec: &str) -> Result<VideoFilter> {
160    let (name, args) = match spec.split_once('=') {
161        Some((n, a)) => (n.trim(), a.trim()),
162        None => (spec.trim(), ""),
163    };
164    let parts: Vec<&str> = args.split(':').map(str::trim).filter(|s| !s.is_empty()).collect();
165    let nums = || -> Result<Vec<u32>> {
166        parts
167            .iter()
168            .map(|s| s.parse::<u32>().map_err(|_| anyhow::anyhow!("bad number '{s}' in '{spec}'")))
169            .collect()
170    };
171    let one_f32 = || -> Result<f32> {
172        parts
173            .first()
174            .ok_or_else(|| anyhow::anyhow!("'{name}' needs a value"))?
175            .parse::<f32>()
176            .map_err(|_| anyhow::anyhow!("bad number in '{spec}'"))
177    };
178    let f = match name {
179        "crop" => match nums()?.as_slice() {
180            [w, h] => VideoFilter::Crop { w: *w, h: *h, x: None, y: None },
181            [w, h, x, y] => VideoFilter::Crop { w: *w, h: *h, x: Some(*x), y: Some(*y) },
182            _ => bail!("crop wants W:H or W:H:X:Y, got '{args}'"),
183        },
184        "pad" => match nums()?.as_slice() {
185            [w, h] => VideoFilter::Pad { w: *w, h: *h, x: None, y: None },
186            [w, h, x, y] => VideoFilter::Pad { w: *w, h: *h, x: Some(*x), y: Some(*y) },
187            _ => bail!("pad wants W:H or W:H:X:Y, got '{args}'"),
188        },
189        "hflip" => VideoFilter::HFlip,
190        "vflip" => VideoFilter::VFlip,
191        "rotate" | "transpose" => {
192            let deg = if name == "transpose" {
193                90
194            } else {
195                *nums()?.first().unwrap_or(&90)
196            };
197            if !matches!(deg, 90 | 180 | 270) {
198                bail!("rotate wants 90|180|270, got {deg}");
199            }
200            VideoFilter::Rotate(deg)
201        }
202        "grayscale" | "gray" => VideoFilter::Grayscale,
203        "overlay" => {
204            // overlay=PATH[:X:Y] — PATH must not contain ':'.
205            let image = parts.first().ok_or_else(|| anyhow::anyhow!("overlay needs a PATH"))?.to_string();
206            let x = parts.get(1).map(|s| s.parse::<u32>()).transpose().map_err(|_| anyhow::anyhow!("bad overlay x in '{spec}'"))?.unwrap_or(0);
207            let y = parts.get(2).map(|s| s.parse::<u32>()).transpose().map_err(|_| anyhow::anyhow!("bad overlay y in '{spec}'"))?.unwrap_or(0);
208            VideoFilter::Overlay { image, x, y }
209        }
210        "invert" | "negate" => VideoFilter::Invert,
211        "brightness" => {
212            let b: i32 = parts.first().ok_or_else(|| anyhow::anyhow!("brightness needs a value"))?.parse().map_err(|_| anyhow::anyhow!("bad brightness in '{spec}'"))?;
213            VideoFilter::Brightness(b)
214        }
215        "contrast" => VideoFilter::Contrast(one_f32()?),
216        "saturation" => VideoFilter::Saturation(one_f32()?),
217        o => bail!("unknown filter '{o}'"),
218    };
219    Ok(f)
220}
221
222/// Apply a whole **stateless** chain to a frame, in order. Returns an error if
223/// the chain contains an `overlay` (use [`FilterChain`] for that).
224pub fn apply_chain(frame: VideoFrame, chain: &[VideoFilter]) -> Result<VideoFrame> {
225    let mut f = frame;
226    for filter in chain {
227        f = apply(&f, filter)?;
228    }
229    Ok(f)
230}
231
232/// Bytes-per-sample for the supported 4:2:0 formats.
233fn bps(format: PixelFormat) -> Result<usize> {
234    match format {
235        PixelFormat::Yuv420p => Ok(1),
236        PixelFormat::Yuv420p10le => Ok(2),
237        other => bail!("video filters need Yuv420p / Yuv420p10le, got {other:?}"),
238    }
239}
240
241/// Split a frame into its (Y, U, V) plane byte slices for a `w×h` 4:2:0 frame.
242fn planes(frame: &VideoFrame, bps: usize) -> Result<(&[u8], &[u8], &[u8])> {
243    let w = frame.width as usize;
244    let h = frame.height as usize;
245    let y_len = w * h * bps;
246    let c_len = (w / 2) * (h / 2) * bps;
247    if frame.data.len() < y_len + 2 * c_len {
248        bail!("frame data too small: {} < {} for {}x{}", frame.data.len(), y_len + 2 * c_len, w, h);
249    }
250    let (y, rest) = frame.data.split_at(y_len);
251    let (u, v) = rest.split_at(c_len);
252    Ok((y, &u[..c_len], &v[..c_len]))
253}
254
255/// Reassemble a frame from new Y/U/V planes + new dims.
256fn assemble(src: &VideoFrame, w: u32, h: u32, y: Vec<u8>, u: Vec<u8>, v: Vec<u8>) -> VideoFrame {
257    let mut data = BytesMut::with_capacity(y.len() + u.len() + v.len());
258    data.extend_from_slice(&y);
259    data.extend_from_slice(&u);
260    data.extend_from_slice(&v);
261    VideoFrame::new(data.freeze(), w, h, src.format, src.color_space, src.pts)
262}
263
264/// Require 8-bit `Yuv420p` for the colour / overlay filters and return the planes.
265fn planes_8bit(frame: &VideoFrame, what: &str) -> Result<(Vec<u8>, Vec<u8>, Vec<u8>)> {
266    if frame.format != PixelFormat::Yuv420p {
267        bail!("the `{what}` filter needs an 8-bit Yuv420p frame (got {:?}); it applies to SDR output", frame.format);
268    }
269    let (y, u, v) = planes(frame, 1)?;
270    Ok((y.to_vec(), u.to_vec(), v.to_vec()))
271}
272
273/// Apply one **stateless** filter. (`Overlay` errors here — use [`FilterChain`].)
274pub fn apply(frame: &VideoFrame, filter: &VideoFilter) -> Result<VideoFrame> {
275    let bps = bps(frame.format)?;
276    let w = frame.width as usize;
277    let h = frame.height as usize;
278
279    match filter {
280        VideoFilter::Crop { w: cw, h: ch, x, y: cy } => match (x, cy) {
281            (Some(x), Some(cy)) => crop(frame, *x, *cy, *cw, *ch),
282            _ => {
283                let cw = even((*cw).min(frame.width));
284                let ch = even((*ch).min(frame.height));
285                let cx = even(frame.width.saturating_sub(cw) / 2);
286                let cyc = even(frame.height.saturating_sub(ch) / 2);
287                crop(frame, cx, cyc, cw, ch)
288            }
289        },
290        VideoFilter::Pad { w: pw, h: ph, x, y: py } => {
291            let pw = even((*pw).max(frame.width));
292            let ph = even((*ph).max(frame.height));
293            let px = x.map(even).unwrap_or_else(|| even(pw.saturating_sub(frame.width) / 2));
294            let pyc = py.map(even).unwrap_or_else(|| even(ph.saturating_sub(frame.height) / 2));
295            pad(frame, pw, ph, px, pyc)
296        }
297        VideoFilter::HFlip | VideoFilter::VFlip | VideoFilter::Rotate(_) | VideoFilter::Grayscale => {
298            let (y, u, v) = planes(frame, bps)?;
299            geometric(frame, filter, y, u, v, w, h, bps)
300        }
301        VideoFilter::Invert => {
302            let (mut y, mut u, mut v) = planes_8bit(frame, "invert")?;
303            for b in y.iter_mut().chain(u.iter_mut()).chain(v.iter_mut()) {
304                *b = 255 - *b;
305            }
306            Ok(assemble(frame, frame.width, frame.height, y, u, v))
307        }
308        VideoFilter::Brightness(delta) => {
309            let (mut y, u, v) = planes_8bit(frame, "brightness")?;
310            for p in y.iter_mut() {
311                *p = (*p as i32 + delta).clamp(0, 255) as u8;
312            }
313            Ok(assemble(frame, frame.width, frame.height, y, u, v))
314        }
315        VideoFilter::Contrast(c) => {
316            let (mut y, u, v) = planes_8bit(frame, "contrast")?;
317            for p in y.iter_mut() {
318                *p = (((*p as f32 - 128.0) * c) + 128.0).round().clamp(0.0, 255.0) as u8;
319            }
320            Ok(assemble(frame, frame.width, frame.height, y, u, v))
321        }
322        VideoFilter::Saturation(s) => {
323            let (y, mut u, mut v) = planes_8bit(frame, "saturation")?;
324            for p in u.iter_mut().chain(v.iter_mut()) {
325                *p = (((*p as f32 - 128.0) * s) + 128.0).round().clamp(0.0, 255.0) as u8;
326            }
327            Ok(assemble(frame, frame.width, frame.height, y, u, v))
328        }
329        VideoFilter::Overlay { .. } => {
330            bail!("overlay is a resource filter — build a FilterChain::prepare(..) and call .apply()")
331        }
332    }
333}
334
335/// The geometric filters (flip / rotate / grayscale), given the planes.
336fn geometric(
337    frame: &VideoFrame,
338    filter: &VideoFilter,
339    y: &[u8],
340    u: &[u8],
341    v: &[u8],
342    w: usize,
343    h: usize,
344    bps: usize,
345) -> Result<VideoFrame> {
346    Ok(match filter {
347        VideoFilter::HFlip => assemble(
348            frame, frame.width, frame.height,
349            hflip(y, w, h, bps), hflip(u, w / 2, h / 2, bps), hflip(v, w / 2, h / 2, bps),
350        ),
351        VideoFilter::VFlip => assemble(
352            frame, frame.width, frame.height,
353            vflip(y, w, h, bps), vflip(u, w / 2, h / 2, bps), vflip(v, w / 2, h / 2, bps),
354        ),
355        VideoFilter::Rotate(180) => assemble(
356            frame, frame.width, frame.height,
357            vflip(&hflip(y, w, h, bps), w, h, bps),
358            vflip(&hflip(u, w / 2, h / 2, bps), w / 2, h / 2, bps),
359            vflip(&hflip(v, w / 2, h / 2, bps), w / 2, h / 2, bps),
360        ),
361        VideoFilter::Rotate(90) => assemble(
362            frame, frame.height, frame.width,
363            rot90(y, w, h, bps), rot90(u, w / 2, h / 2, bps), rot90(v, w / 2, h / 2, bps),
364        ),
365        VideoFilter::Rotate(270) => assemble(
366            frame, frame.height, frame.width,
367            rot270(y, w, h, bps), rot270(u, w / 2, h / 2, bps), rot270(v, w / 2, h / 2, bps),
368        ),
369        VideoFilter::Rotate(d) => bail!("rotate must be 90|180|270, got {d}"),
370        VideoFilter::Grayscale => {
371            let neutral = neutral_chroma(frame.format);
372            let mut uu = u.to_vec();
373            let mut vv = v.to_vec();
374            fill(&mut uu, &neutral);
375            fill(&mut vv, &neutral);
376            assemble(frame, frame.width, frame.height, y.to_vec(), uu, vv)
377        }
378        _ => unreachable!("geometric() called with a non-geometric filter"),
379    })
380}
381
382fn even(n: u32) -> u32 {
383    n & !1
384}
385
386fn crop(frame: &VideoFrame, x: u32, y: u32, w: u32, h: u32) -> Result<VideoFrame> {
387    let (x, y, w, h) = (even(x), even(y), even(w), even(h));
388    if w == 0 || h == 0 || x + w > frame.width || y + h > frame.height {
389        bail!("crop {w}x{h}+{x}+{y} out of bounds for {}x{}", frame.width, frame.height);
390    }
391    let bps = bps(frame.format)?;
392    let (yp, up, vp) = planes(frame, bps)?;
393    let fw = frame.width as usize;
394    let y_new = crop_plane(yp, fw, x as usize, y as usize, w as usize, h as usize, bps);
395    let u_new = crop_plane(up, fw / 2, (x / 2) as usize, (y / 2) as usize, (w / 2) as usize, (h / 2) as usize, bps);
396    let v_new = crop_plane(vp, fw / 2, (x / 2) as usize, (y / 2) as usize, (w / 2) as usize, (h / 2) as usize, bps);
397    Ok(assemble(frame, w, h, y_new, u_new, v_new))
398}
399
400fn pad(frame: &VideoFrame, pw: u32, ph: u32, x: u32, y: u32) -> Result<VideoFrame> {
401    let (pw, ph, x, y) = (even(pw), even(ph), even(x), even(y));
402    if x + frame.width > pw || y + frame.height > ph {
403        bail!("pad {pw}x{ph} with frame {}x{} at +{x}+{y} overflows", frame.width, frame.height);
404    }
405    let bps = bps(frame.format)?;
406    let (yp, up, vp) = planes(frame, bps)?;
407    let (luma_fill, chroma_fill) = black_fill(frame.format);
408    let fw = frame.width as usize;
409    let fh = frame.height as usize;
410    let y_new = pad_plane(yp, fw, fh, pw as usize, ph as usize, x as usize, y as usize, bps, &luma_fill);
411    let u_new = pad_plane(up, fw / 2, fh / 2, (pw / 2) as usize, (ph / 2) as usize, (x / 2) as usize, (y / 2) as usize, bps, &chroma_fill);
412    let v_new = pad_plane(vp, fw / 2, fh / 2, (pw / 2) as usize, (ph / 2) as usize, (x / 2) as usize, (y / 2) as usize, bps, &chroma_fill);
413    Ok(assemble(frame, pw, ph, y_new, u_new, v_new))
414}
415
416// ── overlay (image with alpha) ──────────────────────────────────────────────
417
418/// A loaded overlay image, pre-converted to 8-bit YUV 4:2:0 + per-sample alpha,
419/// ready to alpha-composite onto frames. Built once by [`FilterChain::prepare`].
420#[derive(Debug, Clone)]
421struct PreparedOverlay {
422    w: usize,
423    h: usize,
424    x: usize,
425    y: usize,
426    y_o: Vec<u8>,
427    u_o: Vec<u8>,
428    v_o: Vec<u8>,
429    a_y: Vec<u8>, // luma-resolution alpha
430    a_c: Vec<u8>, // chroma-resolution alpha (2×2 averaged)
431}
432
433fn clamp8(v: i32) -> u8 {
434    v.clamp(0, 255) as u8
435}
436
437impl PreparedOverlay {
438    /// Convert a row-major RGBA8 buffer (`src_w × src_h`) to a prepared overlay
439    /// positioned at `(x, y)`. BT.709 limited-range YUV.
440    fn from_rgba(rgba: &[u8], src_w: u32, src_h: u32, x: u32, y: u32) -> Result<Self> {
441        let w = (src_w & !1) as usize; // even for 4:2:0
442        let h = (src_h & !1) as usize;
443        if w == 0 || h == 0 {
444            bail!("overlay image is too small ({src_w}x{src_h})");
445        }
446        let stride = src_w as usize * 4;
447        let mut y_o = vec![0u8; w * h];
448        let mut a_y = vec![0u8; w * h];
449        let (cw, ch) = (w / 2, h / 2);
450        let mut u_o = vec![0u8; cw * ch];
451        let mut v_o = vec![0u8; cw * ch];
452        let mut a_c = vec![0u8; cw * ch];
453        for r in 0..h {
454            for c in 0..w {
455                let p = r * stride + c * 4;
456                let (rr, gg, bb) = (rgba[p] as i32, rgba[p + 1] as i32, rgba[p + 2] as i32);
457                y_o[r * w + c] = clamp8(16 + ((47 * rr + 157 * gg + 16 * bb) >> 8));
458                a_y[r * w + c] = rgba[p + 3];
459            }
460        }
461        for r in 0..ch {
462            for c in 0..cw {
463                let (mut sr, mut sg, mut sb, mut sa) = (0i32, 0i32, 0i32, 0i32);
464                for dy in 0..2 {
465                    for dx in 0..2 {
466                        let p = (r * 2 + dy) * stride + (c * 2 + dx) * 4;
467                        sr += rgba[p] as i32;
468                        sg += rgba[p + 1] as i32;
469                        sb += rgba[p + 2] as i32;
470                        sa += rgba[p + 3] as i32;
471                    }
472                }
473                let (rr, gg, bb) = (sr / 4, sg / 4, sb / 4);
474                u_o[r * cw + c] = clamp8(128 + ((-26 * rr - 87 * gg + 112 * bb) >> 8));
475                v_o[r * cw + c] = clamp8(128 + ((112 * rr - 102 * gg - 10 * bb) >> 8));
476                a_c[r * cw + c] = (sa / 4) as u8;
477            }
478        }
479        Ok(Self { w, h, x: (x & !1) as usize, y: (y & !1) as usize, y_o, u_o, v_o, a_y, a_c })
480    }
481
482    /// Alpha-composite onto an 8-bit Yuv420p frame: `out = src·(1−α) + ovl·α`.
483    fn composite(&self, frame: &VideoFrame) -> Result<VideoFrame> {
484        let (mut y, mut u, mut v) = planes_8bit(frame, "overlay")?;
485        let (fw, fh) = (frame.width as usize, frame.height as usize);
486        for r in 0..self.h {
487            let fy = self.y + r;
488            if fy >= fh {
489                break;
490            }
491            for c in 0..self.w {
492                let fx = self.x + c;
493                if fx >= fw {
494                    continue;
495                }
496                let a = self.a_y[r * self.w + c] as u32;
497                if a == 0 {
498                    continue;
499                }
500                let i = fy * fw + fx;
501                y[i] = ((y[i] as u32 * (255 - a) + self.y_o[r * self.w + c] as u32 * a + 127) / 255) as u8;
502            }
503        }
504        let (cw, ch) = (self.w / 2, self.h / 2);
505        let (fcw, fch) = (fw / 2, fh / 2);
506        let (ocx, ocy) = (self.x / 2, self.y / 2);
507        for r in 0..ch {
508            let fy = ocy + r;
509            if fy >= fch {
510                break;
511            }
512            for c in 0..cw {
513                let fx = ocx + c;
514                if fx >= fcw {
515                    continue;
516                }
517                let a = self.a_c[r * cw + c] as u32;
518                if a == 0 {
519                    continue;
520                }
521                let i = fy * fcw + fx;
522                u[i] = ((u[i] as u32 * (255 - a) + self.u_o[r * cw + c] as u32 * a + 127) / 255) as u8;
523                v[i] = ((v[i] as u32 * (255 - a) + self.v_o[r * cw + c] as u32 * a + 127) / 255) as u8;
524            }
525        }
526        Ok(assemble(frame, frame.width, frame.height, y, u, v))
527    }
528}
529
530// ── prepared chain (loads overlays once, then applies per frame) ─────────────
531
532enum Step {
533    Plain(VideoFilter),
534    Overlay(PreparedOverlay),
535}
536
537/// A filter chain with its resources prepared (overlay PNGs loaded + converted).
538/// Build once with [`prepare`](FilterChain::prepare), then [`apply`](FilterChain::apply)
539/// per frame.
540pub struct FilterChain {
541    steps: Vec<Step>,
542}
543
544impl FilterChain {
545    /// Prepare a chain: load + convert every `overlay` image (the rest pass
546    /// through). Fails if an overlay image can't be read or decoded.
547    pub fn prepare(filters: &[VideoFilter]) -> Result<Self> {
548        let mut steps = Vec::with_capacity(filters.len());
549        for f in filters {
550            match f {
551                VideoFilter::Overlay { image, x, y } => {
552                    let img = image::ImageReader::open(image)
553                        .with_context(|| format!("opening overlay image '{image}'"))?
554                        .decode()
555                        .with_context(|| format!("decoding overlay image '{image}'"))?
556                        .to_rgba8();
557                    let (w, h) = (img.width(), img.height());
558                    steps.push(Step::Overlay(PreparedOverlay::from_rgba(img.as_raw(), w, h, *x, *y)?));
559                }
560                other => steps.push(Step::Plain(other.clone())),
561            }
562        }
563        Ok(Self { steps })
564    }
565
566    /// Apply the whole chain to a frame, in order.
567    pub fn apply(&self, frame: VideoFrame) -> Result<VideoFrame> {
568        let mut f = frame;
569        for step in &self.steps {
570            f = match step {
571                Step::Plain(filt) => apply(&f, filt)?,
572                Step::Overlay(ov) => ov.composite(&f)?,
573            };
574        }
575        Ok(f)
576    }
577
578    /// No filters → applying is a no-op.
579    pub fn is_empty(&self) -> bool {
580        self.steps.is_empty()
581    }
582}
583
584// ── plane primitives (sample = `bps` bytes; pure rearrangement) ──
585
586fn crop_plane(src: &[u8], pw: usize, x: usize, y: usize, cw: usize, ch: usize, bps: usize) -> Vec<u8> {
587    let mut out = Vec::with_capacity(cw * ch * bps);
588    for row in 0..ch {
589        let start = ((y + row) * pw + x) * bps;
590        out.extend_from_slice(&src[start..start + cw * bps]);
591    }
592    out
593}
594
595fn pad_plane(src: &[u8], sw: usize, sh: usize, dw: usize, dh: usize, ox: usize, oy: usize, bps: usize, fill_sample: &[u8]) -> Vec<u8> {
596    let mut out = Vec::with_capacity(dw * dh * bps);
597    for _ in 0..dw * dh {
598        out.extend_from_slice(fill_sample);
599    }
600    for row in 0..sh {
601        let s = row * sw * bps;
602        let d = ((oy + row) * dw + ox) * bps;
603        out[d..d + sw * bps].copy_from_slice(&src[s..s + sw * bps]);
604    }
605    out
606}
607
608fn hflip(src: &[u8], w: usize, h: usize, bps: usize) -> Vec<u8> {
609    let mut out = vec![0u8; w * h * bps];
610    for row in 0..h {
611        let base = row * w * bps;
612        for col in 0..w {
613            let s = base + col * bps;
614            let d = base + (w - 1 - col) * bps;
615            out[d..d + bps].copy_from_slice(&src[s..s + bps]);
616        }
617    }
618    out
619}
620
621fn vflip(src: &[u8], w: usize, h: usize, bps: usize) -> Vec<u8> {
622    let rb = w * bps;
623    let mut out = vec![0u8; w * h * bps];
624    for row in 0..h {
625        let s = row * rb;
626        let d = (h - 1 - row) * rb;
627        out[d..d + rb].copy_from_slice(&src[s..s + rb]);
628    }
629    out
630}
631
632/// Rotate 90° clockwise: src `w×h` → dst `h×w`. dst(r,c) = src(h-1-c, r).
633fn rot90(src: &[u8], w: usize, h: usize, bps: usize) -> Vec<u8> {
634    let (dw, dh) = (h, w);
635    let mut out = vec![0u8; dw * dh * bps];
636    for r in 0..dh {
637        for c in 0..dw {
638            let s = ((h - 1 - c) * w + r) * bps;
639            let d = (r * dw + c) * bps;
640            out[d..d + bps].copy_from_slice(&src[s..s + bps]);
641        }
642    }
643    out
644}
645
646/// Rotate 270° clockwise: src `w×h` → dst `h×w`. dst(r,c) = src(c, w-1-r).
647fn rot270(src: &[u8], w: usize, h: usize, bps: usize) -> Vec<u8> {
648    let (dw, dh) = (h, w);
649    let mut out = vec![0u8; dw * dh * bps];
650    for r in 0..dh {
651        for c in 0..dw {
652            let s = (c * w + (w - 1 - r)) * bps;
653            let d = (r * dw + c) * bps;
654            out[d..d + bps].copy_from_slice(&src[s..s + bps]);
655        }
656    }
657    out
658}
659
660fn fill(buf: &mut [u8], sample: &[u8]) {
661    for chunk in buf.chunks_exact_mut(sample.len()) {
662        chunk.copy_from_slice(sample);
663    }
664}
665
666/// Neutral chroma sample bytes (mid-range): 128 for 8-bit, 512 for 10-bit LE.
667fn neutral_chroma(format: PixelFormat) -> Vec<u8> {
668    match format {
669        PixelFormat::Yuv420p => vec![128],
670        _ => (512u16).to_le_bytes().to_vec(),
671    }
672}
673
674/// Limited-range black: luma 16, chroma 128 (8-bit); luma 64, chroma 512 (10-bit).
675fn black_fill(format: PixelFormat) -> (Vec<u8>, Vec<u8>) {
676    match format {
677        PixelFormat::Yuv420p => (vec![16], vec![128]),
678        _ => ((64u16).to_le_bytes().to_vec(), (512u16).to_le_bytes().to_vec()),
679    }
680}
681
682#[cfg(test)]
683mod tests {
684    use super::*;
685    use crate::frame::ColorSpace;
686    use bytes::Bytes;
687
688    fn frame(w: u32, h: u32) -> VideoFrame {
689        let (wu, hu) = (w as usize, h as usize);
690        let mut data = Vec::new();
691        for r in 0..hu {
692            for c in 0..wu {
693                data.push((r * wu + c) as u8);
694            }
695        }
696        data.extend(std::iter::repeat(100).take((wu / 2) * (hu / 2)));
697        data.extend(std::iter::repeat(200).take((wu / 2) * (hu / 2)));
698        VideoFrame::new(Bytes::from(data), w, h, PixelFormat::Yuv420p, ColorSpace::Bt709, 0)
699    }
700    fn flat(w: u32, h: u32, yv: u8, uv: u8, vv: u8) -> VideoFrame {
701        let (wu, hu) = (w as usize, h as usize);
702        let mut data = vec![yv; wu * hu];
703        data.extend(std::iter::repeat(uv).take((wu / 2) * (hu / 2)));
704        data.extend(std::iter::repeat(vv).take((wu / 2) * (hu / 2)));
705        VideoFrame::new(Bytes::from(data), w, h, PixelFormat::Yuv420p, ColorSpace::Bt709, 0)
706    }
707    fn luma(f: &VideoFrame) -> &[u8] {
708        &f.data[..(f.width * f.height) as usize]
709    }
710
711    #[test]
712    fn parse_and_display_round_trip() {
713        let c = parse_chain("crop=1280:720,hflip,overlay=logo.png:24:24,brightness=10,saturation=1.5,invert").unwrap();
714        assert_eq!(c[0], VideoFilter::Crop { w: 1280, h: 720, x: None, y: None });
715        assert_eq!(c[2], VideoFilter::Overlay { image: "logo.png".into(), x: 24, y: 24 });
716        assert_eq!(c[3], VideoFilter::Brightness(10));
717        assert_eq!(c[4], VideoFilter::Saturation(1.5));
718        assert_eq!(c[5], VideoFilter::Invert);
719        assert_eq!(chain_to_string(&c), "crop=1280:720,hflip,overlay=logo.png:24:24,brightness=10,saturation=1.5,invert");
720        assert_eq!(parse_chain("overlay=a.png").unwrap()[0], VideoFilter::Overlay { image: "a.png".into(), x: 0, y: 0 });
721        assert_eq!(parse_chain("negate").unwrap()[0], VideoFilter::Invert);
722        assert_eq!(parse_chain("contrast=1.2").unwrap()[0], VideoFilter::Contrast(1.2));
723        assert!(parse_chain("brightness=x").is_err());
724        assert!(parse_chain("rotate=45").is_err());
725    }
726
727    #[cfg(feature = "serde")]
728    #[test]
729    fn structured_json_round_trips() {
730        let json = r#"[{"crop":{"w":1280,"h":720}},"hflip",{"overlay":{"image":"logo.png","x":24,"y":24}},{"brightness":10},"invert"]"#;
731        let from_list: FilterSpec = serde_json::from_str(json).unwrap();
732        let expect = vec![
733            VideoFilter::Crop { w: 1280, h: 720, x: None, y: None },
734            VideoFilter::HFlip,
735            VideoFilter::Overlay { image: "logo.png".into(), x: 24, y: 24 },
736            VideoFilter::Brightness(10),
737            VideoFilter::Invert,
738        ];
739        assert_eq!(from_list.resolve().unwrap(), expect);
740        assert_eq!(parse_chain(&chain_to_string(&expect)).unwrap(), expect);
741    }
742
743    #[test]
744    fn hflip_reverses_rows() {
745        let out = apply(&frame(4, 2), &VideoFilter::HFlip).unwrap();
746        assert_eq!(&luma(&out)[..4], &[3, 2, 1, 0]);
747    }
748
749    #[test]
750    fn rotate_dims_and_roundtrip() {
751        let f = frame(4, 2);
752        let r90 = apply(&f, &VideoFilter::Rotate(90)).unwrap();
753        assert_eq!((r90.width, r90.height), (2, 4));
754        let back = apply(&r90, &VideoFilter::Rotate(270)).unwrap();
755        assert_eq!(luma(&back), luma(&f));
756        assert!(apply(&f, &VideoFilter::Rotate(45)).is_err());
757    }
758
759    #[test]
760    fn color_filters() {
761        // brightness: +20 on a flat-100 luma → 120
762        let b = apply(&flat(4, 4, 100, 128, 128), &VideoFilter::Brightness(20)).unwrap();
763        assert!(luma(&b).iter().all(|&p| p == 120));
764        // invert: 100 → 155, chroma 128 → 127
765        let inv = apply(&flat(2, 2, 100, 128, 128), &VideoFilter::Invert).unwrap();
766        assert_eq!(luma(&inv)[0], 155);
767        assert_eq!(inv.data[4], 127);
768        // saturation 0 → chroma collapses to 128 (grayscale)
769        let s0 = apply(&flat(4, 4, 100, 200, 60), &VideoFilter::Saturation(0.0)).unwrap();
770        assert!(s0.data[16..].iter().all(|&p| p == 128));
771        // brightness on a 10-bit frame is rejected
772        let ten = VideoFrame::new(Bytes::from(vec![0u8; 2 * (4 * 4 + 2 * 4)]), 4, 4, PixelFormat::Yuv420p10le, ColorSpace::Bt709, 0);
773        assert!(apply(&ten, &VideoFilter::Brightness(10)).is_err());
774    }
775
776    #[test]
777    fn overlay_composites_with_alpha() {
778        // 2×2 RGBA overlay: top row opaque red, bottom row fully transparent.
779        let red = [255u8, 0, 0, 255];
780        let clear = [0u8, 0, 0, 0];
781        let mut rgba = Vec::new();
782        rgba.extend_from_slice(&red);
783        rgba.extend_from_slice(&red);
784        rgba.extend_from_slice(&clear);
785        rgba.extend_from_slice(&clear);
786        let ov = PreparedOverlay::from_rgba(&rgba, 2, 2, 0, 0).unwrap();
787        // composite onto a 4×4 flat grey frame
788        let base = flat(4, 4, 100, 128, 128);
789        let out = ov.composite(&base).unwrap();
790        let y = luma(&out);
791        // opaque red top-left → red's luma (≈ 16 + 0.183*255 ≈ 63), NOT 100
792        assert!(y[0] > 50 && y[0] < 90, "opaque red luma was {}", y[0]);
793        // transparent bottom row → unchanged grey 100
794        assert_eq!(y[2 * 4], 100);
795        // out-of-overlay region (col ≥ 2) unchanged
796        assert_eq!(y[2], 100);
797    }
798
799    #[test]
800    fn overlay_via_apply_errors_without_prepare() {
801        let r = apply(&flat(4, 4, 100, 128, 128), &VideoFilter::Overlay { image: "x.png".into(), x: 0, y: 0 });
802        assert!(r.is_err());
803    }
804
805    #[test]
806    fn filter_chain_prepare_missing_image_errors() {
807        let r = FilterChain::prepare(&[VideoFilter::Overlay { image: "/nope/missing.png".into(), x: 0, y: 0 }]);
808        assert!(r.is_err());
809    }
810
811    #[test]
812    fn filter_chain_applies_stateless() {
813        let chain = FilterChain::prepare(&[VideoFilter::HFlip, VideoFilter::Brightness(10)]).unwrap();
814        assert!(!chain.is_empty());
815        let out = chain.apply(frame(4, 2)).unwrap();
816        assert_eq!((out.width, out.height), (4, 2));
817    }
818
819    #[test]
820    fn ten_bit_geometric_still_works() {
821        let mut data: Vec<u8> = Vec::new();
822        for s in [0u16, 1, 2, 3] {
823            data.extend_from_slice(&s.to_le_bytes());
824        }
825        data.extend_from_slice(&(512u16).to_le_bytes());
826        data.extend_from_slice(&(512u16).to_le_bytes());
827        let f = VideoFrame::new(Bytes::from(data), 2, 2, PixelFormat::Yuv420p10le, ColorSpace::Bt709, 0);
828        let out = apply(&f, &VideoFilter::HFlip).unwrap();
829        assert_eq!(&out.data[0..2], &1u16.to_le_bytes());
830    }
831}