Skip to main content

ff_filter/graph/
filter_step.rs

1//! Internal filter step representation.
2
3use std::time::Duration;
4
5use super::FfmpegToken;
6use super::builder::FilterGraphBuilder;
7use super::types::{
8    DrawTextOptions, EqBand, PitchAlgo, Rgb, ScaleAlgorithm, ToneMap, XfadeTransition, YadifMode,
9};
10
11use crate::animation::{AnimatedValue, AnimationEntry, AnimationTrack, Keyframe};
12use crate::blend::BlendMode;
13use crate::composite::CompositeOp;
14use ff_format::{AlphaMode, ColorPrimaries, ColorRange, ColorSpace, ColorTransfer, PixelFormat};
15
16/// Escapes a filesystem path for use as a value inside an `FFmpeg` filter
17/// argument string (e.g. `lut3d`'s `file=` option).
18///
19/// `FFmpeg`'s filter-argument parser treats `:` as an option separator and `\`
20/// as an escape character, so Windows paths like `D:\dir\file.cube` break the
21/// parser. Normalising backslashes to forward slashes (accepted on Windows) and
22/// escaping the drive colon as `\:` yields a value the parser accepts.
23pub(crate) fn escape_filter_path(path: &str) -> String {
24    path.replace('\\', "/").replace(':', "\\:")
25}
26
27// FilterStep
28
29/// A single step in a filter chain.
30///
31/// Used by [`crate::FilterGraphBuilder`] to build pipeline filter graphs, and by
32/// [`crate::AudioTrack::effects`] to attach per-track effects in a multi-track mix.
33// Open catalog: new filter steps are added over time (e.g. `Raw`), so downstream
34// matches must carry a `_` arm.
35#[non_exhaustive]
36#[derive(Debug, Clone)]
37#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
38pub enum FilterStep {
39    /// Convert video to a suitable pixel format from the given options.
40    Format {
41        pix_fmts: Vec<PixelFormat>,
42        color_spaces: Vec<ColorSpace>,
43        color_ranges: Vec<ColorRange>,
44    },
45
46    /// Tag the stream's colour metadata via the `setparams` filter.
47    ///
48    /// Each field is emitted only when `Some` **and** its `FfmpegToken` is
49    /// `Some` (e.g. `Unknown` → skipped), so an all-`None` step yields empty
50    /// args. This is the consumer for `ColorPrimaries` / `ColorTransfer`, whose
51    /// tokens the `format` filter has no option for.
52    SetParams {
53        color_space: Option<ColorSpace>,
54        color_range: Option<ColorRange>,
55        color_primaries: Option<ColorPrimaries>,
56        color_trc: Option<ColorTransfer>,
57    },
58
59    /// Trim: keep only frames in `[start, end)` seconds. Either bound may be
60    /// `None` for an open range (`start=None` keeps from 0; `end=None` keeps to
61    /// end-of-stream).
62    Trim {
63        start: Option<f64>,
64        end: Option<f64>,
65    },
66    /// Reset timestamps to start at zero (`setpts=PTS-STARTPTS`). Typically
67    /// follows a [`Trim`](Self::Trim) so a placed clip's timeline offset is
68    /// applied from zero.
69    ResetPts,
70    /// Shift every frame's presentation timestamp forward by `seconds`
71    /// (`setpts=PTS+{seconds}/TB`). Places a layer later on the output timeline.
72    OffsetPts { seconds: f64 },
73    /// Scale to a new resolution using the given resampling algorithm.
74    Scale {
75        width: u32,
76        height: u32,
77        algorithm: ScaleAlgorithm,
78    },
79    /// Crop a rectangular region.
80    Crop {
81        x: u32,
82        y: u32,
83        width: u32,
84        height: u32,
85    },
86    /// Overlay a second stream at position `(x, y)`.
87    Overlay { x: i32, y: i32 },
88    /// Fade-in from black starting at `start` seconds, over `duration` seconds.
89    FadeIn { start: f64, duration: f64 },
90    /// Fade-out to black starting at `start` seconds, over `duration` seconds.
91    FadeOut { start: f64, duration: f64 },
92    /// Audio fade-in from silence starting at `start` seconds, over `duration` seconds.
93    AFadeIn { start: f64, duration: f64 },
94    /// Audio fade-out to silence starting at `start` seconds, over `duration` seconds.
95    AFadeOut { start: f64, duration: f64 },
96    /// Fade-in from white starting at `start` seconds, over `duration` seconds.
97    FadeInWhite { start: f64, duration: f64 },
98    /// Fade-out to white starting at `start` seconds, over `duration` seconds.
99    FadeOutWhite { start: f64, duration: f64 },
100    /// Rotate clockwise by `angle_degrees`, filling exposed areas with `fill_color`.
101    Rotate {
102        angle_degrees: f64,
103        fill_color: String,
104    },
105    /// HDR-to-SDR tone mapping.
106    ToneMap(ToneMap),
107    /// Adjust audio volume (in dB; negative = quieter).
108    Volume(f64),
109    /// Mix `n` audio inputs together (additive: the inputs are summed, not
110    /// averaged, via `normalize=0`).
111    Amix(usize),
112    /// Multi-band parametric equalizer (low-shelf, high-shelf, or peak bands).
113    ///
114    /// Each band maps to its own `FFmpeg` filter node chained in sequence.
115    /// The `bands` vec must not be empty.
116    ParametricEq { bands: Vec<EqBand> },
117    /// Apply a 3D LUT from a `.cube` or `.3dl` file.
118    Lut3d { path: String },
119    /// Brightness/contrast/saturation adjustment via `FFmpeg` `eq` filter.
120    ///
121    /// `temperature`/`tint` are carried for the derive-to-ff-render `ColorGradeNode`
122    /// mapping (GPU-default path) and are **not** applied by the CPU `eq` filter, which
123    /// has no such option; they are a GPU-only enrichment (like `MotionBlur.sub_frames`
124    /// on the CPU `tblend`). Range −1.0..=1.0 each, neutral 0.0.
125    Eq {
126        brightness: f32,
127        contrast: f32,
128        saturation: f32,
129        /// Colour temperature offset (−1.0 cool/blue, +1.0 warm/orange). GPU-only.
130        temperature: f32,
131        /// Colour tint offset (−1.0 magenta, +1.0 green). GPU-only.
132        tint: f32,
133    },
134    /// Brightness / contrast / saturation / gamma via `FFmpeg` `eq` filter (optionally animated).
135    ///
136    /// Arguments are evaluated at [`Duration::ZERO`] for the initial graph build.
137    /// Per-frame updates are applied via `avfilter_graph_send_command` in #363.
138    ///
139    /// `temperature`/`tint` are carried for the GPU `ColorGradeNode` mapping and are
140    /// **not** applied by the CPU `eq` filter (a GPU-only enrichment; see [`Self::Eq`]).
141    EqAnimated {
142        /// Brightness offset. Range: −1.0 – 1.0 (neutral: 0.0).
143        brightness: AnimatedValue<f64>,
144        /// Contrast multiplier. Range: 0.0 – 3.0 (neutral: 1.0).
145        contrast: AnimatedValue<f64>,
146        /// Saturation multiplier. Range: 0.0 – 3.0 (neutral: 1.0; 0.0 = grayscale).
147        saturation: AnimatedValue<f64>,
148        /// Global gamma correction. Range: 0.1 – 10.0 (neutral: 1.0).
149        gamma: AnimatedValue<f64>,
150        /// Colour temperature offset (−1.0 – 1.0; neutral 0.0). GPU-only.
151        temperature: AnimatedValue<f64>,
152        /// Colour tint offset (−1.0 – 1.0; neutral 0.0). GPU-only.
153        tint: AnimatedValue<f64>,
154    },
155    /// Three-way color balance (shadows / midtones / highlights) via `FFmpeg` `colorbalance` filter
156    /// (optionally animated).
157    ///
158    /// Each tuple is `(R, G, B)`. Valid range per component: −1.0 – 1.0 (neutral: 0.0).
159    ///
160    /// Arguments are evaluated at [`Duration::ZERO`] for the initial graph build.
161    /// Per-frame updates are applied via `avfilter_graph_send_command` in #363.
162    ColorBalanceAnimated {
163        /// Shadows (lift) correction per channel. `FFmpeg` params: `"rs"`, `"gs"`, `"bs"`.
164        lift: AnimatedValue<(f64, f64, f64)>,
165        /// Midtones (gamma) correction per channel. `FFmpeg` params: `"rm"`, `"gm"`, `"bm"`.
166        gamma: AnimatedValue<(f64, f64, f64)>,
167        /// Highlights (gain) correction per channel. `FFmpeg` params: `"rh"`, `"gh"`, `"bh"`.
168        gain: AnimatedValue<(f64, f64, f64)>,
169    },
170    /// Per-channel RGB color curves adjustment.
171    Curves {
172        master: Vec<(f32, f32)>,
173        r: Vec<(f32, f32)>,
174        g: Vec<(f32, f32)>,
175        b: Vec<(f32, f32)>,
176    },
177    /// White balance correction via `colorchannelmixer`.
178    WhiteBalance { temperature_k: u32, tint: f32 },
179    /// Hue rotation by an arbitrary angle.
180    Hue { degrees: f32 },
181    /// Per-channel gamma correction via `FFmpeg` `eq` filter.
182    Gamma { r: f32, g: f32, b: f32 },
183    /// Three-way colour corrector (lift / gamma / gain) via `FFmpeg` `curves` filter.
184    ThreeWayCC {
185        /// Affects shadows (blacks). Neutral: `Rgb::NEUTRAL`.
186        lift: Rgb,
187        /// Affects midtones. Neutral: `Rgb::NEUTRAL`. All components must be > 0.0.
188        gamma: Rgb,
189        /// Affects highlights (whites). Neutral: `Rgb::NEUTRAL`.
190        gain: Rgb,
191    },
192    /// Three-way (lift/gamma/gain) colour corrector with optionally animated
193    /// per-channel parameters.
194    ///
195    /// Parameters are evaluated at [`Duration::ZERO`] for the graph build. `curves`
196    /// takes string curve options rather than scalars, so it is not driven by
197    /// `send_command` here: the CPU (`libavfilter`) path renders the `Duration::ZERO`
198    /// curves statically; the GPU path (`ff_render::ColorWheelsNode`) animates the
199    /// parameters per frame. Each `[R, G, B]` array carries the per-channel tracks,
200    /// with the same `Rgb::NEUTRAL` (1.0) convention as [`ThreeWayCC`](Self::ThreeWayCC).
201    ThreeWayCCAnimated {
202        /// Shadows (lift) per channel `[R, G, B]`. Neutral: 1.0.
203        lift: [AnimatedValue<f64>; 3],
204        /// Midtones (gamma) per channel `[R, G, B]`. Neutral: 1.0; must be > 0.0.
205        gamma: [AnimatedValue<f64>; 3],
206        /// Highlights (gain) per channel `[R, G, B]`. Neutral: 1.0.
207        gain: [AnimatedValue<f64>; 3],
208    },
209    /// Vignette effect via `FFmpeg` `vignette` filter.
210    Vignette {
211        /// Radius angle in radians (valid range: 0.0 – π/2 ≈ 1.5708). Default: π/5 ≈ 0.628.
212        angle: f32,
213        /// Horizontal centre of the vignette. `0.0` maps to `w/2`.
214        x0: f32,
215        /// Vertical centre of the vignette. `0.0` maps to `h/2`.
216        y0: f32,
217    },
218    /// Vignette with an optionally animated normalised amount.
219    ///
220    /// `amount` is a normalised darkening strength in `[0, 1]`; it maps to the
221    /// `vignette` filter's `angle` (radians) as `amount * PI/2`. `vignette`
222    /// re-evaluates `angle` per frame under `eval=frame`, so a `Track` self-animates
223    /// via a `t`-expression (like [`RotateAnimated`](Self::RotateAnimated)); a
224    /// `Static` renders a plain static vignette.
225    VignetteAnimated {
226        /// Normalised darkening amount, evaluated to `[0, 1]` at `Duration::ZERO`.
227        amount: AnimatedValue<f64>,
228        /// Horizontal centre. `0.0` maps to `w/2`.
229        x0: f32,
230        /// Vertical centre. `0.0` maps to `h/2`.
231        y0: f32,
232    },
233    /// HSL adjustment via `FFmpeg` `hue` filter (`ff_render::HslNode` on the GPU).
234    ///
235    /// `hue` works in YUV (a chroma rotation for hue/saturation, a luma add for
236    /// brightness), so it approximates the GPU node's HSL-space adjustment within a
237    /// documented tolerance. `lightness` is scaled to the filter's `b` brightness.
238    Hsl {
239        /// Hue rotation in degrees.
240        hue: f32,
241        /// Saturation multiplier (neutral `1.0`).
242        saturation: f32,
243        /// Lightness offset in `[-1, 1]` (scaled to the `hue` filter's brightness).
244        lightness: f32,
245    },
246    /// HSL adjustment with optionally animated parameters.
247    ///
248    /// The GPU animates per frame; the CPU (`libavfilter`) path renders the
249    /// `Duration::ZERO` values (`hue`'s options accept `t`-expressions, but the CPU
250    /// path is kept static so the GPU-default path is the single animated one).
251    HslAnimated {
252        /// Hue rotation in degrees, evaluated at `Duration::ZERO`.
253        hue: AnimatedValue<f64>,
254        /// Saturation multiplier, evaluated at `Duration::ZERO`.
255        saturation: AnimatedValue<f64>,
256        /// Lightness offset in `[-1, 1]`, evaluated at `Duration::ZERO`.
257        lightness: AnimatedValue<f64>,
258    },
259    /// Horizontal flip (mirror left-right).
260    HFlip,
261    /// Vertical flip (mirror top-bottom).
262    VFlip,
263    /// Reverse video playback (buffers entire clip in memory — use only on short clips).
264    Reverse,
265    /// Reverse audio playback (buffers entire clip in memory — use only on short clips).
266    AReverse,
267    /// Pad to a target resolution with a fill color (letterbox / pillarbox).
268    Pad {
269        /// Target canvas width in pixels.
270        width: u32,
271        /// Target canvas height in pixels.
272        height: u32,
273        /// Horizontal offset of the source frame within the canvas.
274        /// Negative values are replaced with `(ow-iw)/2` (centred).
275        x: i32,
276        /// Vertical offset of the source frame within the canvas.
277        /// Negative values are replaced with `(oh-ih)/2` (centred).
278        y: i32,
279        /// Fill color (any `FFmpeg` color string, e.g. `"black"`, `"0x000000"`).
280        color: String,
281    },
282    /// Scale (preserving aspect ratio) then centre-pad to fill target dimensions
283    /// (letterbox or pillarbox as required).
284    ///
285    /// Implemented as a `scale` filter with `force_original_aspect_ratio=decrease`
286    /// followed by a `pad` filter that centres the scaled frame on the canvas.
287    FitToAspect {
288        /// Target canvas width in pixels.
289        width: u32,
290        /// Target canvas height in pixels.
291        height: u32,
292        /// Fill color for the bars (any `FFmpeg` color string, e.g. `"black"`).
293        color: String,
294    },
295    /// Scale (preserving aspect ratio) to *cover* the target dimensions, then
296    /// centre-crop the overflow (no bars).
297    ///
298    /// Implemented as a `scale` filter with `force_original_aspect_ratio=increase`
299    /// followed by a `crop` filter that centres the scaled frame on the canvas.
300    /// The counterpart to [`FitToAspect`](Self::FitToAspect) (cover vs contain).
301    FillToAspect {
302        /// Target canvas width in pixels.
303        width: u32,
304        /// Target canvas height in pixels.
305        height: u32,
306    },
307    /// Gaussian blur with configurable radius.
308    ///
309    /// `sigma` is the blur radius. Valid range: 0.0 – 10.0 (values near 0.0 are
310    /// nearly a no-op; higher values produce a stronger blur).
311    GBlur {
312        /// Blur radius (standard deviation). Must be ≥ 0.0.
313        sigma: f32,
314    },
315    /// Crop with optionally animated boundaries (pixels, `f64` for sub-pixel precision).
316    ///
317    /// Arguments are evaluated at [`Duration::ZERO`] for the initial graph build.
318    /// Per-frame updates are applied via `avfilter_graph_send_command` in #363.
319    CropAnimated {
320        /// X offset of the top-left corner, in pixels.
321        x: AnimatedValue<f64>,
322        /// Y offset of the top-left corner, in pixels.
323        y: AnimatedValue<f64>,
324        /// Width of the cropped region. Must evaluate to > 0 at `Duration::ZERO`.
325        width: AnimatedValue<f64>,
326        /// Height of the cropped region. Must evaluate to > 0 at `Duration::ZERO`.
327        height: AnimatedValue<f64>,
328    },
329    /// Gaussian blur with an optionally animated sigma (blur radius).
330    ///
331    /// Arguments are evaluated at [`Duration::ZERO`] for the initial graph build.
332    /// Per-frame updates are applied via `avfilter_graph_send_command` in #363.
333    GBlurAnimated {
334        /// Blur radius (standard deviation). Must evaluate to ≥ 0.0 at `Duration::ZERO`.
335        sigma: AnimatedValue<f64>,
336    },
337    /// Scale (resize / zoom) with optionally animated width/height, in pixels.
338    ///
339    /// Unlike `crop`, `scale` cannot be driven by `send_command` (its output size is
340    /// fixed at init); instead this **self-animates** via `scale=eval=frame` with the
341    /// width/height tracks compiled to `t`-expressions
342    /// ([`AnimationTrack::to_ffmpeg_expr`](crate::animation::AnimationTrack::to_ffmpeg_expr)).
343    /// The same expression is placed in both the preview and export graphs, so it
344    /// animates identically. When both are `Static` it renders a plain static `scale`.
345    ScaleAnimated {
346        /// Target width in pixels (expression when a `Track`).
347        width: AnimatedValue<f64>,
348        /// Target height in pixels (expression when a `Track`).
349        height: AnimatedValue<f64>,
350        /// Resampling algorithm (`flags=`).
351        algorithm: ScaleAlgorithm,
352    },
353    /// Rotate with an optionally animated angle (degrees), filling exposed corners
354    /// with `fill_color`.
355    ///
356    /// `rotate`'s `angle` option is an expression re-evaluated every frame, so this
357    /// **self-animates** via `rotate=angle=EXPR`: the angle track is compiled to a
358    /// `t`-expression ([`AnimationTrack::to_ffmpeg_expr`](crate::animation::AnimationTrack::to_ffmpeg_expr))
359    /// and converted degrees→radians. The same expression drives preview and export.
360    /// Output size stays `iw`×`ih` (corners clipped / filled). `Static` → a plain
361    /// rotate.
362    RotateAnimated {
363        /// Rotation angle in **degrees** (clockwise); an expression when a `Track`.
364        angle: AnimatedValue<f64>,
365        /// Colour for exposed corners (e.g. `"black"`, `"none"` for transparent).
366        fill_color: String,
367    },
368    /// Sharpen or blur via unsharp mask (luma + chroma strength).
369    ///
370    /// Positive values sharpen; negative values blur. Valid range for each
371    /// component: −1.5 – 1.5.
372    Unsharp {
373        /// Luma (brightness) sharpening/blurring amount. Range: −1.5 – 1.5.
374        luma_strength: f32,
375        /// Chroma (colour) sharpening/blurring amount. Range: −1.5 – 1.5.
376        chroma_strength: f32,
377    },
378    /// Unsharp mask with an optionally animated luma/chroma amount.
379    ///
380    /// Arguments are evaluated at [`Duration::ZERO`] for the graph build. Unlike
381    /// [`GBlurAnimated`](Self::GBlurAnimated), `FFmpeg`'s `unsharp` exposes **no
382    /// runtime-settable parameter** (verified against the pinned 7.1/8.0 source),
383    /// so it cannot be driven by `send_command` and it has no per-frame expression.
384    /// On the CPU (`libavfilter`) path it therefore renders the `Duration::ZERO`
385    /// value statically; the GPU path animates it by re-evaluating per frame.
386    UnsharpAnimated {
387        /// Luma amount. Evaluated to −1.5 – 1.5 at `Duration::ZERO`.
388        luma_strength: AnimatedValue<f64>,
389        /// Chroma amount. Evaluated to −1.5 – 1.5 at `Duration::ZERO`.
390        chroma_strength: AnimatedValue<f64>,
391    },
392    /// High Quality 3D noise reduction (`hqdn3d`).
393    ///
394    /// Typical values: `luma_spatial=4.0`, `chroma_spatial=3.0`,
395    /// `luma_tmp=6.0`, `chroma_tmp=4.5`. All values must be ≥ 0.0.
396    Hqdn3d {
397        /// Spatial luma noise reduction strength. Must be ≥ 0.0.
398        luma_spatial: f32,
399        /// Spatial chroma noise reduction strength. Must be ≥ 0.0.
400        chroma_spatial: f32,
401        /// Temporal luma noise reduction strength. Must be ≥ 0.0.
402        luma_tmp: f32,
403        /// Temporal chroma noise reduction strength. Must be ≥ 0.0.
404        chroma_tmp: f32,
405    },
406    /// Non-local means noise reduction (`nlmeans`).
407    ///
408    /// `strength` controls the denoising intensity; range 1.0–30.0.
409    /// Higher values remove more noise but are significantly more CPU-intensive.
410    ///
411    /// NOTE: nlmeans is CPU-intensive; avoid for real-time pipelines.
412    Nlmeans {
413        /// Denoising strength. Must be in the range [1.0, 30.0].
414        strength: f32,
415    },
416    /// Deinterlace using the `yadif` filter.
417    Yadif {
418        /// Deinterlacing mode controlling output frame rate and spatial checks.
419        mode: YadifMode,
420    },
421    /// Cross-dissolve transition between two video streams (`xfade`).
422    ///
423    /// Requires two input slots: slot 0 is clip A, slot 1 is clip B.
424    /// `duration` is the overlap length in seconds; `offset` is the PTS
425    /// offset (in seconds) at which clip B begins.
426    XFade {
427        /// Transition style.
428        transition: XfadeTransition,
429        /// Overlap duration in seconds. Must be > 0.0.
430        duration: f64,
431        /// PTS offset (seconds) where clip B starts.
432        offset: f64,
433    },
434    /// Draw text onto the video using the `drawtext` filter.
435    DrawText {
436        /// Full set of drawtext parameters.
437        opts: DrawTextOptions,
438    },
439    /// Burn-in SRT subtitles (hard subtitles) using the `subtitles` filter.
440    SubtitlesSrt {
441        /// Absolute or relative path to the `.srt` file.
442        path: String,
443        /// Optional ASS style override for the `subtitles` filter's `force_style`
444        /// option, e.g. `"Fontsize=24,PrimaryColour=&H00FFFFFF&,Alignment=2"`.
445        /// `None` uses the file's default styling. (The `ass` filter has no
446        /// `force_style`; ASS files carry their own styles.)
447        force_style: Option<String>,
448    },
449    /// Burn-in ASS/SSA styled subtitles using the `ass` filter.
450    SubtitlesAss {
451        /// Absolute or relative path to the `.ass` or `.ssa` file.
452        path: String,
453    },
454    /// Playback speed change using `setpts` (video) and chained `atempo` (audio).
455    ///
456    /// `factor > 1.0` = fast motion; `factor < 1.0` = slow motion.
457    /// Valid range: 0.1–100.0.
458    ///
459    /// Video path: `setpts=PTS/{factor}`.
460    /// Audio path: the `atempo` filter only accepts [0.5, 2.0] per instance;
461    /// `filter_inner` chains multiple instances to cover the full range.
462    Speed {
463        /// Speed multiplier. Must be in [0.1, 100.0].
464        factor: f64,
465    },
466    /// EBU R128 two-pass loudness normalization.
467    ///
468    /// Pass 1 measures integrated loudness with `ebur128=peak=true:metadata=1`.
469    /// Pass 2 applies a linear volume correction so the output reaches `target_lufs`.
470    /// All audio frames are buffered in memory between the two passes — use only
471    /// for clips that fit comfortably in RAM.
472    LoudnessNormalize {
473        /// Target integrated loudness in LUFS (e.g. −23.0). Must be < 0.0.
474        target_lufs: f32,
475        /// True-peak ceiling in dBTP (e.g. −1.0). Must be ≤ 0.0.
476        true_peak_db: f32,
477        /// Target loudness range in LU (e.g. 7.0). Must be > 0.0.
478        lra: f32,
479    },
480    /// Peak-level two-pass normalization using `astats`.
481    ///
482    /// Pass 1 measures the true peak with `astats=metadata=1`.
483    /// Pass 2 applies `volume={gain}dB` so the output peak reaches `target_db`.
484    /// All audio frames are buffered in memory between passes — use only
485    /// for clips that fit comfortably in RAM.
486    NormalizePeak {
487        /// Target peak level in dBFS (e.g. −1.0). Must be ≤ 0.0.
488        target_db: f32,
489    },
490    /// Noise gate via `FFmpeg`'s `agate` filter.
491    ///
492    /// Audio below `threshold_db` is attenuated; audio above passes through.
493    /// The threshold is converted from dBFS to the linear scale expected by
494    /// `agate`'s `threshold` parameter (`linear = 10^(dB/20)`).
495    ANoiseGate {
496        /// Gate open/close threshold in dBFS (e.g. −40.0).
497        threshold_db: f32,
498        /// Attack time in milliseconds — how quickly the gate opens. Must be > 0.0.
499        attack_ms: f32,
500        /// Release time in milliseconds — how quickly the gate closes. Must be > 0.0.
501        release_ms: f32,
502    },
503    /// Dynamic range compressor via `FFmpeg`'s `acompressor` filter.
504    ///
505    /// Reduces the dynamic range of the audio signal: peaks above
506    /// `threshold_db` are attenuated by `ratio`:1.  `makeup_db` applies
507    /// additional gain after compression to restore perceived loudness.
508    ACompressor {
509        /// Compression threshold in dBFS (e.g. −20.0).
510        threshold_db: f32,
511        /// Compression ratio (e.g. 4.0 = 4:1). Must be ≥ 1.0.
512        ratio: f32,
513        /// Attack time in milliseconds. Must be > 0.0.
514        attack_ms: f32,
515        /// Release time in milliseconds. Must be > 0.0.
516        release_ms: f32,
517        /// Make-up gain in dB applied after compression (e.g. 6.0).
518        makeup_db: f32,
519    },
520    /// Downmix stereo to mono via `FFmpeg`'s `pan` filter.
521    ///
522    /// Both channels are mixed with equal weight:
523    /// `mono|c0=0.5*c0+0.5*c1`.  The output has a single channel.
524    StereoToMono,
525    /// Remap audio channels using `FFmpeg`'s `channelmap` filter.
526    ///
527    /// `mapping` is a `|`-separated list of output channel names taken
528    /// from input channels, e.g. `"FR|FL"` swaps left and right.
529    /// Must not be empty.
530    ChannelMap {
531        /// `FFmpeg` channelmap mapping expression (e.g. `"FR|FL"`).
532        mapping: String,
533    },
534    /// A/V sync correction via audio delay or advance.
535    ///
536    /// Positive `ms`: uses `FFmpeg`'s `adelay` filter to shift audio later.
537    /// Negative `ms`: uses `FFmpeg`'s `atrim` filter to trim the audio start,
538    /// effectively advancing audio by `|ms|` milliseconds.
539    /// Zero `ms`: uses `adelay` with zero delay (no-op).
540    AudioDelay {
541        /// Delay in milliseconds. Positive = delay; negative = advance.
542        ms: f64,
543    },
544    /// Audio trim: keep only samples in `[start, end)` seconds (`atrim`). Either
545    /// bound may be `None` for an open range. The audio counterpart of
546    /// [`Trim`](Self::Trim).
547    ATrim {
548        start: Option<f64>,
549        end: Option<f64>,
550    },
551    /// Reset audio timestamps to start at zero (`asetpts=PTS-STARTPTS`). Typically
552    /// follows an [`ATrim`](Self::ATrim) so a placed track's delay is applied from
553    /// zero. The audio counterpart of [`ResetPts`](Self::ResetPts).
554    AResetPts,
555    /// Concatenate `n` sequential video input segments via `FFmpeg`'s `concat` filter.
556    ///
557    /// Requires `n` video input slots (0 through `n-1`). `n` must be ≥ 2.
558    ConcatVideo {
559        /// Number of video input segments to concatenate. Must be ≥ 2.
560        n: u32,
561    },
562    /// Concatenate `n` sequential audio input segments via `FFmpeg`'s `concat` filter.
563    ///
564    /// Requires `n` audio input slots (0 through `n-1`). `n` must be ≥ 2.
565    ConcatAudio {
566        /// Number of audio input segments to concatenate. Must be ≥ 2.
567        n: u32,
568    },
569    /// Freeze a single frame for a configurable duration using `FFmpeg`'s `loop` filter.
570    ///
571    /// The frame nearest to `pts` seconds is held for `duration` seconds, then
572    /// playback resumes. Frame numbers are approximated using a 25 fps assumption;
573    /// accuracy depends on the source stream's actual frame rate.
574    FreezeFrame {
575        /// Timestamp of the frame to freeze, in seconds. Must be >= 0.0.
576        pts: f64,
577        /// Duration to hold the frozen frame, in seconds. Must be > 0.0.
578        duration: f64,
579    },
580    /// Scrolling text ticker (right-to-left) using the `drawtext` filter.
581    ///
582    /// The text starts off-screen to the right and scrolls left at
583    /// `speed_px_per_sec` pixels per second using the expression
584    /// `x = w - t * speed`.
585    Ticker {
586        /// Text to display. Special characters (`\`, `:`, `'`) are escaped.
587        text: String,
588        /// Y position as an `FFmpeg` expression, e.g. `"h-50"` or `"10"`.
589        y: String,
590        /// Horizontal scroll speed in pixels per second (must be > 0.0).
591        speed_px_per_sec: f32,
592        /// Font size in points.
593        font_size: u32,
594        /// Font color as an `FFmpeg` color string, e.g. `"white"` or `"0xFFFFFF"`.
595        font_color: String,
596    },
597    /// Composite a PNG image (watermark / logo) over video with optional opacity.
598    ///
599    /// This is a compound step: internally it creates a `movie` source, an
600    /// optional `scale` filter, a `lut` alpha-scaling filter, and an `overlay`
601    /// compositing filter. The image file is loaded once at graph construction
602    /// time.
603    OverlayImage {
604        /// Absolute or relative path to the `.png` file.
605        path: String,
606        /// Horizontal position as an `FFmpeg` expression, e.g. `"10"` or `"W-w-10"`.
607        x: String,
608        /// Vertical position as an `FFmpeg` expression, e.g. `"10"` or `"H-h-10"`.
609        y: String,
610        /// Opacity 0.0 (fully transparent) to 1.0 (fully opaque).
611        opacity: f32,
612        /// Optional overlay width as an `FFmpeg` `scale` expression, e.g. `"300"`
613        /// or `"iw*0.15"` (`iw`/`ih` are the overlay's own dimensions). `-1`
614        /// preserves aspect ratio. `None` keeps the image's native width.
615        width: Option<String>,
616        /// Optional overlay height as an `FFmpeg` `scale` expression. `-1`
617        /// preserves aspect ratio. `None` keeps the image's native height.
618        height: Option<String>,
619    },
620
621    /// Blend a `top` layer over the current stream (bottom) using the given mode.
622    ///
623    /// This is a compound step:
624    /// - **Normal** mode: `[top]colorchannelmixer=aa=<opacity>[top_faded];
625    ///   [bottom][top_faded]overlay=format=auto:shortest=1[out]`
626    ///   (the `colorchannelmixer` step is omitted when `opacity == 1.0`).
627    /// - All other modes return [`crate::FilterError::InvalidConfig`] from
628    ///   [`crate::FilterGraphBuilder::build`] until implemented.
629    ///
630    /// The `top` builder's steps are applied to the second input slot (`in1`).
631    /// `opacity` is clamped to `[0.0, 1.0]` by the builder method.
632    ///
633    /// `Box<FilterGraphBuilder>` is used to break the otherwise-recursive type:
634    /// `FilterStep` → `FilterGraphBuilder` → `Vec<FilterStep>`.
635    ///
636    /// Not serialized: this compositor-internal variant carries a
637    /// `FilterGraphBuilder` (which cannot round-trip serde), and it never appears
638    /// in the editing model's persisted effect chains.
639    #[cfg_attr(feature = "serde", serde(skip))]
640    Blend {
641        /// Filter pipeline for the top (foreground) layer.
642        top: Box<FilterGraphBuilder>,
643        /// How the two layers are combined.
644        mode: BlendMode,
645        /// Opacity of the top layer in `[0.0, 1.0]`; 1.0 = fully opaque.
646        opacity: f32,
647        /// How the top layer's alpha is interpreted by the `overlay` filter
648        /// (`alpha=`). [`AlphaMode::Straight`] is the `FFmpeg` default.
649        alpha: AlphaMode,
650    },
651
652    /// Composite a `top` layer over the current stream (bottom) using a
653    /// Porter-Duff alpha-compositing [`CompositeOp`].
654    ///
655    /// This is a compound, two-input step (slot 0 = bottom, slot 1 = top with
656    /// the `top` builder's steps applied). `Over`/`Under` are built with the
657    /// `overlay` filter; the rest use `blend` with a per-channel expression.
658    ///
659    /// `Box<FilterGraphBuilder>` breaks the otherwise-recursive type, following
660    /// the same pattern as [`FilterStep::Blend`].
661    ///
662    /// Not serialized (compositor-internal; see [`FilterStep::Blend`]).
663    #[cfg_attr(feature = "serde", serde(skip))]
664    Composite {
665        /// The Porter-Duff operator combining the two layers.
666        op: CompositeOp,
667        /// Filter pipeline for the top (foreground) layer.
668        top: Box<FilterGraphBuilder>,
669        /// Opacity of the top layer in `[0.0, 1.0]`; 1.0 = fully opaque.
670        /// Only affects `Over`/`Under` (the expression operators ignore it).
671        opacity: f32,
672        /// How the top layer's alpha is interpreted by the `overlay` filter
673        /// (`alpha=`). [`AlphaMode::Straight`] is the `FFmpeg` default.
674        alpha: AlphaMode,
675    },
676
677    /// Remove pixels matching `color` using `FFmpeg`'s `chromakey` filter,
678    /// producing a `yuva420p` output with transparent areas where the key
679    /// color was detected.
680    ///
681    /// Use this for YCbCr-encoded sources (most video).  For RGB sources
682    /// use `colorkey` instead.
683    ChromaKey {
684        /// `FFmpeg` color string, e.g. `"green"`, `"0x00FF00"`, `"#00FF00"`.
685        color: String,
686        /// Match radius in `[0.0, 1.0]`; higher = more pixels removed.
687        similarity: f32,
688        /// Edge softness in `[0.0, 1.0]`; `0.0` = hard edge.
689        blend: f32,
690    },
691
692    /// [`ChromaKey`](Self::ChromaKey) with optionally animated `similarity` /
693    /// `blend`.
694    ///
695    /// The GPU animates per frame; the CPU (`libavfilter`) path renders the
696    /// `Duration::ZERO` values (`chromakey`'s options are static, so the CPU path
697    /// is kept static and the GPU-default path is the single animated one, matching
698    /// [`HslAnimated`](Self::HslAnimated)).
699    ChromaKeyAnimated {
700        /// `FFmpeg` color string, e.g. `"0x00FF00"`.
701        color: String,
702        /// Match radius in `[0.0, 1.0]`, evaluated at `Duration::ZERO` on the CPU.
703        similarity: AnimatedValue<f64>,
704        /// Edge softness in `[0.0, 1.0]`, evaluated at `Duration::ZERO` on the CPU.
705        blend: AnimatedValue<f64>,
706    },
707
708    /// Remove pixels matching `color` in RGB space using `FFmpeg`'s `colorkey`
709    /// filter, producing an `rgba` output with transparent areas where the key
710    /// color was detected.
711    ///
712    /// Use this for RGB-encoded sources.  For YCbCr-encoded video (most video)
713    /// use `chromakey` instead.
714    ColorKey {
715        /// `FFmpeg` color string, e.g. `"green"`, `"0x00FF00"`, `"#00FF00"`.
716        color: String,
717        /// Match radius in `[0.0, 1.0]`; higher = more pixels removed.
718        similarity: f32,
719        /// Edge softness in `[0.0, 1.0]`; `0.0` = hard edge.
720        blend: f32,
721    },
722
723    /// Reduce color spill from the key color on subject edges using `FFmpeg`'s
724    /// `hue` filter to desaturate the spill hue region.
725    ///
726    /// Applies `hue=h=0:s=(1.0 - strength)`.  `strength=0.0` leaves the image
727    /// unchanged; `strength=1.0` fully desaturates.
728    ///
729    /// `key_color` is stored for future use by a more targeted per-hue
730    /// implementation.
731    SpillSuppress {
732        /// `FFmpeg` color string identifying the spill color, e.g. `"green"`.
733        key_color: String,
734        /// Suppression intensity in `[0.0, 1.0]`; `0.0` = no effect, `1.0` = full suppression.
735        strength: f32,
736    },
737
738    /// Merge a grayscale `matte` as the alpha channel of the input video using
739    /// `FFmpeg`'s `alphamerge` filter.
740    ///
741    /// White (luma=255) in the matte produces fully opaque output; black (luma=0)
742    /// produces fully transparent output.
743    ///
744    /// This is a compound step: the `matte` builder's pipeline is applied to the
745    /// second input slot (`in1`) before the `alphamerge` filter is linked.
746    ///
747    /// `Box<FilterGraphBuilder>` breaks the otherwise-recursive type, following
748    /// the same pattern as [`FilterStep::Blend`].
749    ///
750    /// Not serialized (compositor-internal; see [`FilterStep::Blend`]).
751    #[cfg_attr(feature = "serde", serde(skip))]
752    AlphaMatte {
753        /// Pipeline for the grayscale matte stream (slot 1).
754        matte: Box<FilterGraphBuilder>,
755    },
756
757    /// Key out pixels by luminance value using `FFmpeg`'s `lumakey` filter.
758    ///
759    /// Pixels whose normalized luma is within `tolerance` of `threshold` are
760    /// made transparent.  When `invert` is `true`, a `geq` filter is appended
761    /// to negate the alpha channel, effectively swapping transparent and opaque
762    /// regions.
763    ///
764    /// - `threshold`: luma cutoff in `[0.0, 1.0]`; `0.0` = black, `1.0` = white.
765    /// - `tolerance`: match radius around the threshold in `[0.0, 1.0]`.
766    /// - `softness`: edge feather width in `[0.0, 1.0]`; `0.0` = hard edge.
767    /// - `invert`: when `false`, keys out bright regions (pixels matching the
768    ///   threshold); when `true`, the alpha is negated after keying, making
769    ///   the complementary region transparent instead.
770    ///
771    /// Output carries an alpha channel (`yuva420p`).
772    LumaKey {
773        /// Luma cutoff in `[0.0, 1.0]`.
774        threshold: f32,
775        /// Match radius around the threshold in `[0.0, 1.0]`.
776        tolerance: f32,
777        /// Edge feather width in `[0.0, 1.0]`; `0.0` = hard edge.
778        softness: f32,
779        /// When `true`, the alpha channel is negated after keying.
780        invert: bool,
781    },
782
783    /// Apply a rectangular alpha mask using `FFmpeg`'s `geq` filter.
784    ///
785    /// Pixels inside the rectangle defined by (`x`, `y`, `width`, `height`)
786    /// are made fully opaque (`alpha=255`); pixels outside are made fully
787    /// transparent (`alpha=0`).  When `invert` is `true` the roles are swapped:
788    /// inside becomes transparent and outside becomes opaque.
789    ///
790    /// - `x`, `y`: top-left corner of the rectangle (in pixels).
791    /// - `width`, `height`: rectangle dimensions (must be > 0).
792    /// - `invert`: when `false`, keeps the interior; when `true`, keeps the
793    ///   exterior.
794    ///
795    /// `width` and `height` are validated in [`build`](FilterGraphBuilder::build);
796    /// zero values return [`crate::FilterError::InvalidConfig`].
797    ///
798    /// The output carries an alpha channel (`rgba`).
799    RectMask {
800        /// Left edge of the rectangle (pixels from the left).
801        x: u32,
802        /// Top edge of the rectangle (pixels from the top).
803        y: u32,
804        /// Width of the rectangle in pixels (must be > 0).
805        width: u32,
806        /// Height of the rectangle in pixels (must be > 0).
807        height: u32,
808        /// When `true`, the mask is inverted: outside is opaque, inside is transparent.
809        invert: bool,
810    },
811
812    /// [`RectMask`](Self::RectMask) with optionally animated `x` / `y` / `width` /
813    /// `height`.
814    ///
815    /// The GPU animates the rectangle per frame; the CPU (`libavfilter`) path renders
816    /// the `Duration::ZERO` values (the `geq` rectangle bounds are static, so the CPU
817    /// path is kept static and the GPU-default path is the animated one, matching
818    /// [`ChromaKeyAnimated`](Self::ChromaKeyAnimated)). `width` / `height` are clamped
819    /// to at least `1` at render time so a zero-size frame never fails `geq` build.
820    RectMaskAnimated {
821        /// Left edge in pixels, evaluated at `Duration::ZERO` on the CPU.
822        x: AnimatedValue<f64>,
823        /// Top edge in pixels, evaluated at `Duration::ZERO` on the CPU.
824        y: AnimatedValue<f64>,
825        /// Width in pixels (clamped to `>= 1`), evaluated at `Duration::ZERO` on the CPU.
826        width: AnimatedValue<f64>,
827        /// Height in pixels (clamped to `>= 1`), evaluated at `Duration::ZERO` on the CPU.
828        height: AnimatedValue<f64>,
829        /// When `true`, the mask is inverted: outside is opaque, inside is transparent.
830        invert: bool,
831    },
832
833    /// Set the alpha channel to the frame's own BT.709 luma using `FFmpeg`'s `geq`
834    /// filter (a self-referential continuous luma mask).
835    ///
836    /// `alpha = luma`, where `luma = 0.2126·R + 0.7152·G + 0.0722·B` on the frame's
837    /// own gamma-encoded RGB. Bright pixels stay opaque, dark pixels become
838    /// transparent. When `invert` is `true`, `255 - luma` is used, so dark pixels
839    /// stay opaque. This is the CPU pair of `ff_render::LumaMaskNode` (mask = the
840    /// frame itself, which multiplies the existing alpha by `luma`); on an opaque
841    /// source `alpha = luma` and `alpha *= luma` coincide, and the coefficients
842    /// match exactly. (`geq` exposes no alpha-read function here, so the existing
843    /// alpha is replaced rather than multiplied.)
844    ///
845    /// The output carries an alpha channel (`rgba`).
846    LumaMask {
847        /// When `true`, use `255 - luma` (dark pixels stay opaque).
848        invert: bool,
849    },
850
851    /// Feather (soften) the alpha channel edges using a Gaussian blur.
852    ///
853    /// Splits the stream into a color copy and an alpha copy, blurs the alpha
854    /// plane with `gblur=sigma=<radius>`, then re-merges:
855    ///
856    /// ```text
857    /// [in]split=2[color][with_alpha];
858    /// [with_alpha]alphaextract[alpha_only];
859    /// [alpha_only]gblur=sigma=<radius>[alpha_blurred];
860    /// [color][alpha_blurred]alphamerge[out]
861    /// ```
862    ///
863    /// `radius` is the blur kernel half-size in pixels and must be > 0.
864    /// Validated in [`build`](FilterGraphBuilder::build); `radius == 0` returns
865    /// [`crate::FilterError::InvalidConfig`].
866    ///
867    /// Typically chained after a keying or masking step
868    /// (e.g. [`FilterStep::ChromaKey`], [`FilterStep::RectMask`],
869    /// [`FilterStep::PolygonMatte`]).  Applying this step to a fully-opaque
870    /// video (no prior alpha) is a no-op because a uniform alpha of 255 blurs
871    /// to 255 everywhere.
872    FeatherMask {
873        /// Gaussian blur kernel half-size in pixels (must be > 0).
874        radius: u32,
875    },
876
877    /// Simulate motion blur by blending consecutive frames via `FFmpeg`'s `tblend` filter.
878    ///
879    /// `shutter_angle_degrees` controls the blend ratio; 360° equals a full
880    /// frame-period exposure (maximum blur). `sub_frames` is the number of
881    /// frames blended and must be in [2, 16]; it is validated by
882    /// [`FilterGraph::motion_blur`](crate::FilterGraph::motion_blur).
883    MotionBlur {
884        /// Shutter angle in degrees (0° = no blur, 360° = full-period blur).
885        shutter_angle_degrees: f32,
886        /// Number of frames blended. Must be in [2, 16].
887        sub_frames: u8,
888    },
889
890    /// Motion blur with an optionally animated shutter angle.
891    ///
892    /// `tblend`'s blend is written as an expression (`all_expr`), and `blend`
893    /// re-evaluates that expression per frame with `T` bound to the frame's
894    /// presentation time in seconds. So this **self-animates**: the shutter track is
895    /// compiled to a `T`-expression
896    /// ([`AnimationTrack::to_ffmpeg_expr`](crate::animation::AnimationTrack::to_ffmpeg_expr))
897    /// and folded into the same `A`/`B` mix the constant form uses. The same
898    /// expression drives preview and export.
899    ///
900    /// **`T`, not `t`.** `blend`'s expression variables are upper-case (`X Y W H SW
901    /// SH T N A B TOP BOTTOM`); a lower-case `t` is not among them and would
902    /// evaluate as an unknown.
903    ///
904    /// A `Static` shutter renders exactly what [`MotionBlur`](Self::MotionBlur)
905    /// renders, so the two agree where they overlap.
906    MotionBlurAnimated {
907        /// Shutter angle in degrees (0° = no blur, 360° = full-period blur); an
908        /// expression when a `Track`.
909        shutter_angle: AnimatedValue<f64>,
910        /// Number of frames blended. Must be in [2, 16].
911        sub_frames: u8,
912    },
913
914    /// Correct radial lens distortion using two polynomial coefficients via
915    /// `FFmpeg`'s `lenscorrection` filter.
916    ///
917    /// Negative values correct barrel distortion; positive values correct
918    /// pincushion distortion. Both `k1` and `k2` must be in [−1.0, 1.0];
919    /// validated by [`FilterGraph::lens_correction`](crate::FilterGraph::lens_correction).
920    LensCorrection {
921        /// First-order radial distortion coefficient. Range: [−1.0, 1.0].
922        k1: f32,
923        /// Second-order radial distortion coefficient. Range: [−1.0, 1.0].
924        k2: f32,
925    },
926
927    /// Add synthetic per-frame random film grain to luma and chroma channels
928    /// via `FFmpeg`'s `noise` filter.
929    ///
930    /// `luma_strength` and `chroma_strength` are clamped to [0.0, 100.0].
931    /// The `allf=t` flag varies the seed each frame to simulate real film grain.
932    FilmGrain {
933        /// Grain strength applied to the luma (Y) plane. Clamped to [0.0, 100.0].
934        luma_strength: f32,
935        /// Grain strength applied to the Cb and Cr planes. Clamped to [0.0, 100.0].
936        chroma_strength: f32,
937    },
938    /// Temporal film grain (the `noise` filter) with an optionally animated strength.
939    ///
940    /// Strengths are evaluated at [`Duration::ZERO`] for the graph build. Like
941    /// [`UnsharpAnimated`](Self::UnsharpAnimated), `noise` exposes no
942    /// runtime-settable parameter, so the CPU (`libavfilter`) path renders the
943    /// `Duration::ZERO` strength statically; the GPU path animates it per frame. The
944    /// grain *pattern* is temporal on both paths regardless (`allf=t` on the CPU, the
945    /// per-frame seed on the GPU).
946    FilmGrainAnimated {
947        /// Luma-plane grain strength. Evaluated to [0.0, 100.0] at `Duration::ZERO`.
948        luma_strength: AnimatedValue<f64>,
949        /// Chroma-plane grain strength. Evaluated to [0.0, 100.0] at `Duration::ZERO`.
950        chroma_strength: AnimatedValue<f64>,
951    },
952
953    /// Uniform scale by a fractional multiplier via `FFmpeg`'s `scale` filter.
954    ///
955    /// Both width and height are multiplied by `factor`. Used to hide warped
956    /// border pixels left after lens distortion correction.
957    ScaleMultiplier {
958        /// Scale factor applied to both dimensions (e.g. `1.05` = 5 % zoom-in).
959        factor: f32,
960    },
961
962    /// Reduce lateral chromatic aberration by independently shifting the R and B
963    /// channels via `FFmpeg`'s `rgbashift` filter.
964    ///
965    /// `rh` and `bh` are the horizontal pixel shifts for the red and blue
966    /// channels respectively. Derived from scale deviations by
967    /// [`FilterGraph::fix_chromatic_aberration`](crate::FilterGraph::fix_chromatic_aberration).
968    ChromaticAberration {
969        /// Horizontal shift for the red channel in pixels (positive = right).
970        rh: i32,
971        /// Horizontal shift for the blue channel in pixels (positive = right).
972        bh: i32,
973    },
974
975    /// Glow / bloom effect: blends blurred highlights back over the image via
976    /// `split`, `curves`, `gblur`, and `blend` filters.
977    ///
978    /// This is a compound step — see
979    /// [`FilterGraph::glow`](crate::FilterGraph::glow) for parameter semantics.
980    Glow {
981        /// Luminance threshold that triggers the glow (clamped to [0.0, 1.0]).
982        threshold: f32,
983        /// Gaussian blur radius in pixels (clamped to [0.5, 50.0]).
984        radius: f32,
985        /// Additive blend strength (clamped to [0.0, 2.0]).
986        intensity: f32,
987    },
988    /// Glow / bloom (the compound `split`/`curves`/`gblur`/`blend` chain) with
989    /// optionally animated parameters.
990    ///
991    /// Parameters are evaluated at [`Duration::ZERO`] for the graph build. None of
992    /// the sub-filters expose a runtime-settable glow parameter, so the CPU path
993    /// renders the `Duration::ZERO` values statically; the GPU path
994    /// (`ff_render::GlowNode`) animates them per frame.
995    GlowAnimated {
996        /// Luminance threshold. Evaluated to [0.0, 1.0] at `Duration::ZERO`.
997        threshold: AnimatedValue<f64>,
998        /// Gaussian blur radius in pixels. Evaluated to [0.5, 50.0] at `Duration::ZERO`.
999        radius: AnimatedValue<f64>,
1000        /// Additive blend strength. Evaluated to [0.0, 2.0] at `Duration::ZERO`.
1001        intensity: AnimatedValue<f64>,
1002    },
1003
1004    /// Convolution reverb using an impulse response (IR) audio file.
1005    ///
1006    /// The IR is loaded via `FFmpeg`'s `amovie` filter, optionally delayed by
1007    /// `pre_delay_ms` via `adelay`, then convolved with the main audio stream
1008    /// via `FFmpeg`'s `afir` filter.
1009    ///
1010    /// This is a compound step — see
1011    /// [`FilterGraph::reverb_ir`](crate::FilterGraph::reverb_ir) for parameter
1012    /// semantics.
1013    ReverbIr {
1014        /// Absolute or relative path to the `.wav` or `.flac` IR file.
1015        ir_path: String,
1016        /// Wet (reverb) mix level in [0.0, 1.0].
1017        wet: f32,
1018        /// Dry (original) mix level in [0.0, 1.0].
1019        dry: f32,
1020        /// Pre-delay before the reverb tail in milliseconds (clamped to 0–500).
1021        pre_delay_ms: u32,
1022    },
1023
1024    /// Algorithmic multi-tap echo/reverb via `FFmpeg`'s `aecho` filter.
1025    ///
1026    /// `in_gain` and `out_gain` are amplitude multipliers clamped to [0.0, 1.0].
1027    /// `delays` contains delay times in milliseconds (one per tap); `decays`
1028    /// contains the corresponding decay factors in [0.0, 1.0].  Both vecs must
1029    /// have equal length in the range 1–8; validated by
1030    /// [`FilterGraph::reverb_echo`](crate::FilterGraph::reverb_echo).
1031    ReverbEcho {
1032        /// Input gain (amplitude multiplier). Clamped to [0.0, 1.0].
1033        in_gain: f32,
1034        /// Output gain (amplitude multiplier). Clamped to [0.0, 1.0].
1035        out_gain: f32,
1036        /// Delay times in milliseconds (one per tap).
1037        delays: Vec<f32>,
1038        /// Decay factors per tap. Clamped to [0.0, 1.0].
1039        decays: Vec<f32>,
1040    },
1041
1042    /// Pitch shift without tempo change.
1043    ///
1044    /// Shifts audio pitch by `semitones` semitones without altering playback
1045    /// duration.  Implemented as `asetrate` (changes the declared sample rate
1046    /// to shift pitch) followed by `atempo` (restores the original duration).
1047    ///
1048    /// Range: [−24.0, 24.0]; validated by
1049    /// [`FilterGraph::pitch_shift`](crate::FilterGraph::pitch_shift).
1050    ///
1051    /// This is a compound step — `filter_name()` returns `"asetrate"` for
1052    /// `validate_filter_steps`; the actual graph construction is handled by
1053    /// `filter_inner::build::build_audio_graph`.
1054    PitchShift {
1055        /// Pitch shift in semitones. Range: [−24.0, 24.0].
1056        semitones: f32,
1057        /// Backend algorithm. [`PitchAlgo::Rubberband`] uses the formant-preserving
1058        /// `rubberband` filter when available (else falls back to the signal path).
1059        algo: PitchAlgo,
1060    },
1061
1062    /// Time-stretch audio without changing pitch via `FFmpeg`'s `atempo` filter.
1063    ///
1064    /// `factor < 1.0` = slower (longer duration); `factor > 1.0` = faster
1065    /// (shorter duration).  Range: [0.1, 10.0].  Values outside [0.5, 2.0]
1066    /// are realised by chaining multiple `atempo` instances (each in [0.5, 2.0]).
1067    ///
1068    /// Validated by [`FilterGraph::time_stretch`](crate::FilterGraph::time_stretch).
1069    TimeStretch {
1070        /// Speed / duration factor. 0.5 = 2× longer; 2.0 = 2× shorter. Range: [0.1, 10.0].
1071        factor: f32,
1072        /// Backend algorithm. [`PitchAlgo::Rubberband`] uses the higher-quality
1073        /// `rubberband` filter when available (else falls back to the signal path).
1074        algo: PitchAlgo,
1075    },
1076
1077    /// Simultaneously change audio speed and pitch by the same factor.
1078    ///
1079    /// Equivalent to playing a tape at a different speed: `factor > 1.0` makes
1080    /// audio faster and higher; `factor < 1.0` makes it slower and lower.
1081    ///
1082    /// Uses `FFmpeg`'s `asetrate` to multiply the declared sample rate by
1083    /// `factor` without resampling.  Range: [0.1, 10.0]; validated by
1084    /// [`FilterGraph::speed_change`](crate::FilterGraph::speed_change).
1085    SpeedChange {
1086        /// Speed/pitch multiplier. Range: [0.1, 10.0].
1087        factor: f64,
1088    },
1089
1090    /// Spectral noise reduction using a statistical noise-type model.
1091    ///
1092    /// Uses `FFmpeg`'s `afftdn` filter.  `noise_type_flag` is the single-letter
1093    /// `nt` parameter (`"w"` = white, `"p"` = pink, `"b"` = brown).
1094    /// `nr_level` is the reduction amount in dB, clamped to [0.0, 97.0].
1095    ///
1096    /// Created by [`FilterGraph::noise_reduce`](crate::FilterGraph::noise_reduce).
1097    NoiseReduce {
1098        /// `afftdn` `nt` flag: `"w"`, `"p"`, or `"b"`.
1099        noise_type_flag: String,
1100        /// Noise reduction amount in dB. Clamped to [0.0, 97.0].
1101        nr_level: f32,
1102    },
1103
1104    /// Spectral noise reduction using a captured noise profile.
1105    ///
1106    /// Uses `FFmpeg`'s `afftdn` with the `pl` (profile length) option: the
1107    /// filter learns the noise profile from the first `profile_duration_secs`
1108    /// seconds, then subtracts it from the rest of the stream.
1109    /// `nr_level` is the reduction amount in dB, clamped to [0.0, 97.0].
1110    ///
1111    /// Created by
1112    /// [`FilterGraph::noise_reduce_profile`](crate::FilterGraph::noise_reduce_profile).
1113    NoiseReduceProfile {
1114        /// Duration in seconds from which to capture the noise profile. Minimum 0.1.
1115        profile_duration_secs: f32,
1116        /// Noise reduction amount in dB. Clamped to [0.0, 97.0].
1117        nr_level: f32,
1118    },
1119
1120    /// Sidechain compression for audio ducking via `FFmpeg`'s `sidechaincompress` filter.
1121    ///
1122    /// Reduces the background audio level when the foreground (sidechain) signal
1123    /// exceeds the threshold.  Push background audio to slot 0 and foreground
1124    /// audio to slot 1.
1125    ///
1126    /// `threshold_linear` is the trigger level as a linear amplitude (pre-converted
1127    /// from dBFS by [`FilterGraph::duck`](crate::FilterGraph::duck)).
1128    /// `ratio`, `attack_ms`, and `release_ms` are validated by
1129    /// [`FilterGraph::duck`](crate::FilterGraph::duck).
1130    Duck {
1131        /// Compression threshold as a linear amplitude ratio in (0.0, 1.0].
1132        threshold_linear: f32,
1133        /// Compression ratio (e.g. 20.0 for near hard-limiting). Must be >= 1.0.
1134        ratio: f32,
1135        /// Attack time in milliseconds. Must be >= 0.0.
1136        attack_ms: f32,
1137        /// Release time in milliseconds. Must be >= 0.0.
1138        release_ms: f32,
1139    },
1140
1141    /// Apply a polygon alpha mask using `FFmpeg`'s `geq` filter with a
1142    /// crossing-number point-in-polygon test.
1143    ///
1144    /// Pixels inside the polygon are fully opaque (`alpha=255`); pixels outside
1145    /// are fully transparent (`alpha=0`).  When `invert` is `true` the roles
1146    /// are swapped.
1147    ///
1148    /// - `vertices`: polygon corners as `(x, y)` in `[0.0, 1.0]` (normalised
1149    ///   to frame size).  Minimum 3, maximum 16.
1150    /// - `invert`: when `false`, inside = opaque; when `true`, outside = opaque.
1151    ///
1152    /// Vertex count and coordinates are validated in
1153    /// [`build`](FilterGraphBuilder::build); out-of-range values return
1154    /// [`crate::FilterError::InvalidConfig`].
1155    ///
1156    /// The `geq` expression is constructed from the vertex list at graph
1157    /// build time.  Degenerate polygons (zero area) produce a fully-transparent
1158    /// mask.  The output carries an alpha channel (`rgba`).
1159    PolygonMatte {
1160        /// Polygon corners in normalised `[0.0, 1.0]` frame coordinates.
1161        vertices: Vec<(f32, f32)>,
1162        /// When `true`, the mask is inverted: outside is opaque, inside is transparent.
1163        invert: bool,
1164    },
1165
1166    /// Apply an arbitrary `FFmpeg` avfilter to the current stream — the escape
1167    /// hatch for filters not covered by the typed builder methods.
1168    ///
1169    /// `filter` is the avfilter name (e.g. `"selectivecolor"`, `"pseudocolor"`)
1170    /// and `args` its option string (e.g. `"reds=…:blues=…"`; empty when the
1171    /// filter takes no options). It is emitted as a single node `filter=args`
1172    /// and linked in chain order like any other step, so it accepts one input
1173    /// and produces one output.
1174    ///
1175    /// The filter name is checked for existence when the graph is built
1176    /// ([`build`](FilterGraphBuilder::build)); the `args` are validated by
1177    /// `FFmpeg` on the first [`push_video`](crate::FilterGraph::push_video) /
1178    /// [`push_audio`](crate::FilterGraph::push_audio), exactly as for the typed
1179    /// steps. Prefer the typed builder methods where they exist; use this for
1180    /// filters they do not cover.
1181    Raw {
1182        /// The avfilter name (e.g. `"selectivecolor"`).
1183        filter: String,
1184        /// The avfilter option string (e.g. `"reds=…"`); empty for none.
1185        args: String,
1186    },
1187
1188    /// Splice a whole libavfilter *description* into the chain — the escape
1189    /// hatch for graph shapes the step list cannot express.
1190    ///
1191    /// `desc` takes the same syntax as `ffmpeg -vf`, so it may chain several
1192    /// filters (`"scale=64:48,hue=s=0"`) and may use labels to branch and
1193    /// rejoin (`"split[a][b];[a]hue=s=0[c];[b][c]overlay"`). It is parsed with
1194    /// `avfilter_graph_parse2` and spliced in as one step.
1195    ///
1196    /// Use [`Raw`](Self::Raw) for a *single* untyped filter; that is what it is
1197    /// for, and it needs no parser. This variant exists for the two things
1198    /// `Raw` cannot do: take a whole chain as one string, and describe a
1199    /// non-linear graph.
1200    ///
1201    /// The description must have exactly one open input and one open output, so
1202    /// it links into the chain like any other step. Sources (`"color=c=red"`,
1203    /// zero open inputs) and sinks are rejected.
1204    ///
1205    /// Video only, like [`Raw`](Self::Raw): the audio graph builds from an
1206    /// allow-list of audio steps, which neither variant is in.
1207    ParseDesc {
1208        /// The filter description (e.g. `"scale=64:48,hue=s=0"`).
1209        desc: String,
1210    },
1211}
1212
1213/// Convert a color temperature in Kelvin to linear RGB multipliers using
1214/// Tanner Helland's algorithm.
1215///
1216/// Returns `(r, g, b)` each in `[0.0, 1.0]`.
1217/// Renders the three-way (lift/gamma/gain) colour corrector as `curves` `r/g/b`
1218/// options. Shared by [`FilterStep::ThreeWayCC`] and
1219/// [`FilterStep::ThreeWayCCAnimated`].
1220///
1221/// The formula maps each channel to a 3-point curve:
1222///   input 0.0 -> (lift - 1.0) * gain  (black point)
1223///   input 0.5 -> (0.5 * lift)^(1/gamma) * gain  (midtone)
1224///   input 1.0 -> gain  (white point)
1225/// All neutral (1.0) produces the identity curve `0/0 0.5/0.5 1/1`.
1226fn three_way_cc_args(lift: Rgb, gamma: Rgb, gain: Rgb) -> String {
1227    let curve = |l: f32, gm: f32, gn: f32| -> String {
1228        let l = f64::from(l);
1229        let gm = f64::from(gm);
1230        let gn = f64::from(gn);
1231        let black = ((l - 1.0) * gn).clamp(0.0, 1.0);
1232        let mid = ((0.5 * l).powf(1.0 / gm) * gn).clamp(0.0, 1.0);
1233        let white = gn.clamp(0.0, 1.0);
1234        format!("0/{black} 0.5/{mid} 1/{white}")
1235    };
1236    format!(
1237        "r='{}':g='{}':b='{}'",
1238        curve(lift.r, gamma.r, gain.r),
1239        curve(lift.g, gamma.g, gain.g),
1240        curve(lift.b, gamma.b, gain.b),
1241    )
1242}
1243
1244/// Renders the compound glow filter chain (`split` -> `curves` -> `gblur` ->
1245/// `blend`) as a filtergraph string. Shared by [`FilterStep::Glow`] and
1246/// [`FilterStep::GlowAnimated`]. (The real build goes through `add_glow_step`; this
1247/// is for the `args()` completeness path.)
1248fn glow_compound_args(threshold: f32, radius: f32, intensity: f32) -> String {
1249    let t = threshold.clamp(0.0, 1.0);
1250    let r = radius.clamp(0.5, 50.0);
1251    let iv = intensity.clamp(0.0, 2.0);
1252    let hi_lo = format!("0/0 {t}/0 1/1");
1253    format!(
1254        "split=2[base][hl];[hl]curves=all='{hi_lo}'[glow_src];\
1255         [glow_src]gblur=sigma={r}[glow];\
1256         [base][glow]blend=all_mode=addition:all_opacity={iv}"
1257    )
1258}
1259
1260/// Lightness offset `[-1, 1]` -> the `hue` filter's brightness `[-10, 10]`. The
1261/// filter adds `b * 25.5` to 8-bit luma, so `±1` lightness maps to `±10` b, a
1262/// roughly full-range luma shift. This YUV luma-add only approximates the GPU
1263/// node's HSL-space lightness, hence the wide parity tolerance.
1264const HSL_LIGHTNESS_TO_BRIGHTNESS: f32 = 10.0;
1265
1266/// Renders the `hue` filter args for an HSL adjustment (hue degrees, saturation
1267/// multiplier, lightness scaled to the filter's brightness).
1268fn hsl_args(hue: f32, saturation: f32, lightness: f32) -> String {
1269    let b = lightness * HSL_LIGHTNESS_TO_BRIGHTNESS;
1270    format!("h={hue}:s={saturation}:b={b}")
1271}
1272
1273fn kelvin_to_rgb(temp_k: u32) -> (f64, f64, f64) {
1274    let t = (f64::from(temp_k) / 100.0).clamp(10.0, 400.0);
1275    let r = if t <= 66.0 {
1276        1.0
1277    } else {
1278        (329.698_727_446_4 * (t - 60.0).powf(-0.133_204_759_2) / 255.0).clamp(0.0, 1.0)
1279    };
1280    let g = if t <= 66.0 {
1281        ((99.470_802_586_1 * t.ln() - 161.119_568_166_1) / 255.0).clamp(0.0, 1.0)
1282    } else {
1283        ((288.122_169_528_3 * (t - 60.0).powf(-0.075_514_849_2)) / 255.0).clamp(0.0, 1.0)
1284    };
1285    let b = if t >= 66.0 {
1286        1.0
1287    } else if t <= 19.0 {
1288        0.0
1289    } else {
1290        ((138.517_731_223_1 * (t - 10.0).ln() - 305.044_792_730_7) / 255.0).clamp(0.0, 1.0)
1291    };
1292    (r, g, b)
1293}
1294
1295impl FilterStep {
1296    /// Returns the libavfilter filter name for this step.
1297    pub(crate) fn filter_name(&self) -> &str {
1298        match self {
1299            // Escape hatch: the runtime filter name is borrowed from `self`.
1300            // (The `&'static str` literals below coerce to the borrowed `&str`.)
1301            Self::Raw { filter, .. } => filter,
1302            // ParseDesc is a whole description, not one filter, so it has no
1303            // name to give. `add_and_link_step` dispatches it before the
1304            // single-filter path and `validate_filter_steps` skips it, so this
1305            // is never read; `null` (the identity filter) keeps a hypothetical
1306            // future fallback inert rather than wrong.
1307            Self::ParseDesc { .. } => "null",
1308            Self::Format { .. } => "format",
1309            Self::SetParams { .. } => "setparams",
1310            Self::Trim { .. } => "trim",
1311            // "setpts" is checked at build-time (see Speed's note below).
1312            Self::ResetPts | Self::OffsetPts { .. } => "setpts",
1313            Self::Scale { .. } => "scale",
1314            Self::Crop { .. } => "crop",
1315            Self::Overlay { .. } => "overlay",
1316            Self::FadeIn { .. }
1317            | Self::FadeOut { .. }
1318            | Self::FadeInWhite { .. }
1319            | Self::FadeOutWhite { .. } => "fade",
1320            Self::AFadeIn { .. } | Self::AFadeOut { .. } => "afade",
1321            Self::Rotate { .. } => "rotate",
1322            Self::ToneMap(_) => "tonemap",
1323            Self::Volume(_) => "volume",
1324            Self::Amix(_) => "amix",
1325            // ParametricEq is a compound step; "equalizer" is used only by
1326            // validate_filter_steps as a best-effort existence check.  The
1327            // actual nodes are built by `filter_inner::add_parametric_eq_chain`.
1328            Self::ParametricEq { .. } => "equalizer",
1329            Self::Lut3d { .. } => "lut3d",
1330            Self::Eq { .. } => "eq",
1331            Self::EqAnimated { .. } => "eq",
1332            Self::ColorBalanceAnimated { .. } => "colorbalance",
1333            Self::Curves { .. } => "curves",
1334            Self::WhiteBalance { .. } => "colorchannelmixer",
1335            Self::Hue { .. } => "hue",
1336            Self::Hsl { .. } => "hue",
1337            Self::HslAnimated { .. } => "hue",
1338            Self::Gamma { .. } => "eq",
1339            Self::ThreeWayCC { .. } => "curves",
1340            Self::ThreeWayCCAnimated { .. } => "curves",
1341            Self::Vignette { .. } => "vignette",
1342            Self::HFlip => "hflip",
1343            Self::VFlip => "vflip",
1344            Self::Reverse => "reverse",
1345            Self::AReverse => "areverse",
1346            Self::Pad { .. } => "pad",
1347            // FitToAspect is implemented as scale + pad; "scale" is validated at
1348            // build time.  The pad filter is inserted by filter_inner at graph
1349            // construction time.
1350            Self::FitToAspect { .. } => "scale",
1351            // FillToAspect is scale (cover) + crop; "scale" is validated at build
1352            // time.  The crop filter is inserted by filter_inner at graph
1353            // construction time.
1354            Self::FillToAspect { .. } => "scale",
1355            Self::GBlur { .. } => "gblur",
1356            Self::Unsharp { .. } => "unsharp",
1357            Self::Hqdn3d { .. } => "hqdn3d",
1358            Self::Nlmeans { .. } => "nlmeans",
1359            Self::Yadif { .. } => "yadif",
1360            Self::XFade { .. } => "xfade",
1361            Self::DrawText { .. } | Self::Ticker { .. } => "drawtext",
1362            // "setpts" is checked at build-time; the audio path uses "atempo"
1363            // which is verified at graph-construction time in filter_inner.
1364            Self::Speed { .. } => "setpts",
1365            Self::FreezeFrame { .. } => "loop",
1366            Self::LoudnessNormalize { .. } => "ebur128",
1367            Self::NormalizePeak { .. } => "astats",
1368            Self::ANoiseGate { .. } => "agate",
1369            Self::ACompressor { .. } => "acompressor",
1370            Self::StereoToMono => "pan",
1371            Self::ChannelMap { .. } => "channelmap",
1372            // AudioDelay dispatches to adelay (positive) or atrim (negative) at
1373            // build time; "adelay" is returned here for validate_filter_steps only.
1374            Self::AudioDelay { .. } => "adelay",
1375            Self::ATrim { .. } => "atrim",
1376            // "asetpts" is checked at build-time (audio counterpart of ResetPts).
1377            Self::AResetPts => "asetpts",
1378            Self::ConcatVideo { .. } | Self::ConcatAudio { .. } => "concat",
1379            Self::SubtitlesSrt { .. } => "subtitles",
1380            Self::SubtitlesAss { .. } => "ass",
1381            // OverlayImage is a compound step (movie → lut → overlay); "overlay"
1382            // is used only by validate_filter_steps as a best-effort existence
1383            // check.  The actual graph construction is handled by
1384            // `filter_inner::build::add_overlay_image_step`.
1385            Self::OverlayImage { .. } => "overlay",
1386            // Blend is a compound step; "overlay" is used as the primary filter
1387            // for validate_filter_steps.  Unimplemented modes are caught by
1388            // build() before validate_filter_steps is reached.
1389            Self::Blend { .. } => "overlay",
1390            // Composite shares the Blend construction: Over/Under use overlay,
1391            // the expression operators use blend. validate_filter_steps only
1392            // needs a real filter name to probe existence.
1393            Self::Composite { op, .. } => match op {
1394                CompositeOp::Over | CompositeOp::Under => "overlay",
1395                CompositeOp::In | CompositeOp::Out | CompositeOp::Atop | CompositeOp::Xor => {
1396                    "blend"
1397                }
1398            },
1399            Self::ChromaKey { .. } => "chromakey",
1400            Self::ChromaKeyAnimated { .. } => "chromakey",
1401            Self::ColorKey { .. } => "colorkey",
1402            Self::SpillSuppress { .. } => "hue",
1403            // AlphaMatte is a compound step (matte pipeline → alphamerge);
1404            // "alphamerge" is used by validate_filter_steps as the primary check.
1405            Self::AlphaMatte { .. } => "alphamerge",
1406            // LumaKey is a compound step when invert=true (lumakey + geq);
1407            // "lumakey" is used here for validate_filter_steps.
1408            Self::LumaKey { .. } => "lumakey",
1409            // RectMask uses geq to set alpha per-pixel based on rectangle bounds.
1410            Self::RectMask { .. } => "geq",
1411            Self::RectMaskAnimated { .. } => "geq",
1412            Self::LumaMask { .. } => "geq",
1413            // FeatherMask is a compound step (split → alphaextract → gblur → alphamerge);
1414            // "alphaextract" is used by validate_filter_steps as the primary check.
1415            Self::FeatherMask { .. } => "alphaextract",
1416            // PolygonMatte uses geq with a crossing-number point-in-polygon expression.
1417            Self::PolygonMatte { .. } => "geq",
1418            Self::CropAnimated { .. } => "crop",
1419            Self::GBlurAnimated { .. } => "gblur",
1420            Self::UnsharpAnimated { .. } => "unsharp",
1421            Self::ScaleAnimated { .. } => "scale",
1422            Self::RotateAnimated { .. } => "rotate",
1423            Self::VignetteAnimated { .. } => "vignette",
1424            Self::MotionBlur { .. } | Self::MotionBlurAnimated { .. } => "tblend",
1425            Self::LensCorrection { .. } => "lenscorrection",
1426            Self::FilmGrain { .. } => "noise",
1427            Self::FilmGrainAnimated { .. } => "noise",
1428            Self::ScaleMultiplier { .. } => "scale",
1429            Self::ChromaticAberration { .. } => "rgbashift",
1430            // Glow is a compound step (split → curves → gblur → blend);
1431            // "split" is used by validate_filter_steps as the primary check.
1432            Self::Glow { .. } => "split",
1433            Self::GlowAnimated { .. } => "split",
1434            // ReverbIr is a compound step (amovie[+adelay] → afir);
1435            // "afir" is used by validate_filter_steps as the primary check.
1436            Self::ReverbIr { .. } => "afir",
1437            Self::ReverbEcho { .. } => "aecho",
1438            // PitchShift is a compound step (asetrate → atempo);
1439            // "asetrate" is used by validate_filter_steps as the primary check.
1440            Self::PitchShift { .. } => "asetrate",
1441            // TimeStretch uses one or more chained atempo filters.
1442            Self::TimeStretch { .. } => "atempo",
1443            // SpeedChange uses asetrate to shift speed and pitch together.
1444            Self::SpeedChange { .. } => "asetrate",
1445            Self::NoiseReduce { .. } | Self::NoiseReduceProfile { .. } => "afftdn",
1446            // Duck is a two-input compound step; "sidechaincompress" is checked at
1447            // build time by validate_filter_steps.
1448            Self::Duck { .. } => "sidechaincompress",
1449        }
1450    }
1451
1452    /// Returns the `args` string passed to `avfilter_graph_create_filter`.
1453    pub(crate) fn args(&self) -> String {
1454        match self {
1455            // Escape hatch: the option string is passed through verbatim.
1456            Self::Raw { args, .. } => args.clone(),
1457            // See `filter_name`: ParseDesc never reaches the single-filter path.
1458            Self::ParseDesc { .. } => String::new(),
1459            Self::Format {
1460                pix_fmts,
1461                color_spaces,
1462                color_ranges,
1463            } => {
1464                // Each option list uses the FFmpeg-canonical `FfmpegToken`, skipping values with no
1465                // FFmpeg equivalent (`None`); an option is emitted only when a token survives.
1466                fn render<T: FfmpegToken>(key: &str, values: &[T]) -> Option<String> {
1467                    let tokens: Vec<&str> = values
1468                        .iter()
1469                        .filter_map(FfmpegToken::ffmpeg_token)
1470                        .collect();
1471                    (!tokens.is_empty()).then(|| format!("{key}={}", tokens.join("|")))
1472                }
1473                [
1474                    render("pix_fmts", pix_fmts),
1475                    render("color_spaces", color_spaces),
1476                    render("color_ranges", color_ranges),
1477                ]
1478                .into_iter()
1479                .flatten()
1480                .collect::<Vec<_>>()
1481                .join(":")
1482            }
1483            Self::SetParams {
1484                color_space,
1485                color_range,
1486                color_primaries,
1487                color_trc,
1488            } => {
1489                // Each option is emitted from the FFmpeg-canonical `FfmpegToken`, only when the
1490                // value is `Some` and yields a token (`Unknown` → `None` → skipped). All-`None`
1491                // renders to the empty string.
1492                fn opt<T: FfmpegToken>(key: &str, v: Option<&T>) -> Option<String> {
1493                    v.and_then(FfmpegToken::ffmpeg_token)
1494                        .map(|tok| format!("{key}={tok}"))
1495                }
1496                [
1497                    opt("colorspace", color_space.as_ref()),
1498                    opt("range", color_range.as_ref()),
1499                    opt("color_primaries", color_primaries.as_ref()),
1500                    opt("color_trc", color_trc.as_ref()),
1501                ]
1502                .into_iter()
1503                .flatten()
1504                .collect::<Vec<_>>()
1505                .join(":")
1506            }
1507            Self::Trim { start, end } => match (start, end) {
1508                (Some(s), Some(e)) => format!("start={s}:end={e}"),
1509                (Some(s), None) => format!("start={s}"),
1510                (None, Some(e)) => format!("end={e}"),
1511                (None, None) => String::new(),
1512            },
1513            Self::ResetPts => "PTS-STARTPTS".to_string(),
1514            Self::OffsetPts { seconds } => format!("PTS+{seconds}/TB"),
1515            Self::Scale {
1516                width,
1517                height,
1518                algorithm,
1519            } => format!("w={width}:h={height}:flags={}", algorithm.as_flags_str()),
1520            Self::Crop {
1521                x,
1522                y,
1523                width,
1524                height,
1525            } => {
1526                format!("x={x}:y={y}:w={width}:h={height}")
1527            }
1528            Self::Overlay { x, y } => format!("x={x}:y={y}"),
1529            Self::FadeIn { start, duration } => {
1530                format!("type=in:start_time={start}:duration={duration}")
1531            }
1532            Self::FadeOut { start, duration } => {
1533                format!("type=out:start_time={start}:duration={duration}")
1534            }
1535            Self::FadeInWhite { start, duration } => {
1536                format!("type=in:start_time={start}:duration={duration}:color=white")
1537            }
1538            Self::FadeOutWhite { start, duration } => {
1539                format!("type=out:start_time={start}:duration={duration}:color=white")
1540            }
1541            Self::AFadeIn { start, duration } => {
1542                format!("type=in:start_time={start}:duration={duration}")
1543            }
1544            Self::AFadeOut { start, duration } => {
1545                format!("type=out:start_time={start}:duration={duration}")
1546            }
1547            Self::Rotate {
1548                angle_degrees,
1549                fill_color,
1550            } => {
1551                format!(
1552                    "angle={}:fillcolor={fill_color}",
1553                    angle_degrees.to_radians()
1554                )
1555            }
1556            Self::ToneMap(algorithm) => format!("tonemap={}", algorithm.as_str()),
1557            Self::Volume(db) => format!("volume={db}dB"),
1558            // `normalize=0` sums the inputs (additive) instead of averaging by the
1559            // active-input count, matching the multi-track mixer's convention so a
1560            // mix has the same level however it is built.
1561            Self::Amix(inputs) => format!("inputs={inputs}:normalize=0"),
1562            // args() for ParametricEq is not used by the build loop (which is
1563            // bypassed in favour of add_parametric_eq_chain); provided here for
1564            // completeness using the first band's args.
1565            Self::ParametricEq { bands } => bands.first().map(EqBand::args).unwrap_or_default(),
1566            Self::Lut3d { path } => {
1567                format!("file={}:interp=trilinear", escape_filter_path(path))
1568            }
1569            // `temperature`/`tint` are GPU-only (the CPU `eq` filter has no such option),
1570            // so they are intentionally not emitted into the argument string.
1571            Self::Eq {
1572                brightness,
1573                contrast,
1574                saturation,
1575                temperature: _,
1576                tint: _,
1577            } => format!("brightness={brightness}:contrast={contrast}:saturation={saturation}"),
1578            Self::EqAnimated {
1579                brightness,
1580                contrast,
1581                saturation,
1582                gamma,
1583                temperature: _,
1584                tint: _,
1585            } => {
1586                let b = brightness.value_at(Duration::ZERO);
1587                let c = contrast.value_at(Duration::ZERO);
1588                let s = saturation.value_at(Duration::ZERO);
1589                let g = gamma.value_at(Duration::ZERO);
1590                format!("brightness={b}:contrast={c}:saturation={s}:gamma={g}")
1591            }
1592            Self::ColorBalanceAnimated { lift, gamma, gain } => {
1593                let (rl, gl, bl) = lift.value_at(Duration::ZERO);
1594                let (rm, gm, bm) = gamma.value_at(Duration::ZERO);
1595                let (rh, gh, bh) = gain.value_at(Duration::ZERO);
1596                format!("rs={rl}:gs={gl}:bs={bl}:rm={rm}:gm={gm}:bm={bm}:rh={rh}:gh={gh}:bh={bh}")
1597            }
1598            Self::Curves { master, r, g, b } => {
1599                let fmt = |pts: &[(f32, f32)]| -> String {
1600                    pts.iter()
1601                        .map(|(x, y)| format!("{x}/{y}"))
1602                        .collect::<Vec<_>>()
1603                        .join(" ")
1604                };
1605                [("master", master.as_slice()), ("r", r), ("g", g), ("b", b)]
1606                    .iter()
1607                    .filter(|(_, pts)| !pts.is_empty())
1608                    .map(|(name, pts)| format!("{name}='{}'", fmt(pts)))
1609                    .collect::<Vec<_>>()
1610                    .join(":")
1611            }
1612            Self::WhiteBalance {
1613                temperature_k,
1614                tint,
1615            } => {
1616                let (r, g, b) = kelvin_to_rgb(*temperature_k);
1617                let g_adj = (g + f64::from(*tint)).clamp(0.0, 2.0);
1618                format!("rr={r}:gg={g_adj}:bb={b}")
1619            }
1620            Self::Hue { degrees } => format!("h={degrees}"),
1621            Self::Hsl {
1622                hue,
1623                saturation,
1624                lightness,
1625            } => hsl_args(*hue, *saturation, *lightness),
1626            Self::HslAnimated {
1627                hue,
1628                saturation,
1629                lightness,
1630            } => {
1631                // The CPU path is static (the GPU animates per frame): render the
1632                // `Duration::ZERO` values.
1633                #[allow(clippy::cast_possible_truncation)]
1634                hsl_args(
1635                    hue.value_at(Duration::ZERO) as f32,
1636                    saturation.value_at(Duration::ZERO) as f32,
1637                    lightness.value_at(Duration::ZERO) as f32,
1638                )
1639            }
1640            Self::Gamma { r, g, b } => format!("gamma_r={r}:gamma_g={g}:gamma_b={b}"),
1641            Self::Vignette { angle, x0, y0 } => {
1642                let cx = if *x0 == 0.0 {
1643                    "w/2".to_string()
1644                } else {
1645                    x0.to_string()
1646                };
1647                let cy = if *y0 == 0.0 {
1648                    "h/2".to_string()
1649                } else {
1650                    y0.to_string()
1651                };
1652                format!("angle={angle}:x0={cx}:y0={cy}")
1653            }
1654            Self::ThreeWayCC { lift, gamma, gain } => three_way_cc_args(*lift, *gamma, *gain),
1655            Self::ThreeWayCCAnimated { lift, gamma, gain } => {
1656                // The compound `curves` string is built from the `Duration::ZERO`
1657                // values (the CPU path is static; the GPU animates per frame).
1658                #[allow(clippy::cast_possible_truncation)]
1659                let at0 = |a: &[AnimatedValue<f64>; 3]| Rgb {
1660                    r: a[0].value_at(Duration::ZERO) as f32,
1661                    g: a[1].value_at(Duration::ZERO) as f32,
1662                    b: a[2].value_at(Duration::ZERO) as f32,
1663                };
1664                three_way_cc_args(at0(lift), at0(gamma), at0(gain))
1665            }
1666            Self::HFlip | Self::VFlip | Self::Reverse | Self::AReverse => String::new(),
1667            Self::GBlur { sigma } => format!("sigma={sigma}"),
1668            Self::Unsharp {
1669                luma_strength,
1670                chroma_strength,
1671            } => format!(
1672                "luma_msize_x=5:luma_msize_y=5:luma_amount={luma_strength}:\
1673                 chroma_msize_x=5:chroma_msize_y=5:chroma_amount={chroma_strength}"
1674            ),
1675            Self::Hqdn3d {
1676                luma_spatial,
1677                chroma_spatial,
1678                luma_tmp,
1679                chroma_tmp,
1680            } => format!("{luma_spatial}:{chroma_spatial}:{luma_tmp}:{chroma_tmp}"),
1681            Self::Nlmeans { strength } => format!("s={strength}"),
1682            Self::Yadif { mode } => format!("mode={}", *mode as i32),
1683            Self::XFade {
1684                transition,
1685                duration,
1686                offset,
1687            } => {
1688                let t = transition.as_str();
1689                format!("transition={t}:duration={duration}:offset={offset}")
1690            }
1691            Self::DrawText { opts } => {
1692                // Escape special characters recognised by the drawtext filter.
1693                let escaped = opts
1694                    .text
1695                    .replace('\\', "\\\\")
1696                    .replace(':', "\\:")
1697                    .replace('\'', "\\'");
1698                let mut parts = vec![
1699                    format!("text='{escaped}'"),
1700                    // The text is drawn literally; disable `%{...}` expansion so a
1701                    // user string cannot inject drawtext expansions (`%{gmtime}`, ...).
1702                    "expansion=none".to_string(),
1703                    format!("x={}", opts.x),
1704                    format!("y={}", opts.y),
1705                    format!("fontsize={}", opts.font_size),
1706                    format!("fontcolor={}@{:.2}", opts.font_color, opts.opacity),
1707                ];
1708                if let Some(ref ff) = opts.font_file {
1709                    // Escape like a filter path: an unescaped `:` (e.g. a Windows
1710                    // drive letter) would be read as an option separator and could
1711                    // inject drawtext options (`textfile=`, ...).
1712                    let ff_escaped = ff
1713                        .replace('\\', "/")
1714                        .replace(':', "\\:")
1715                        .replace('\'', "\\'");
1716                    parts.push(format!("fontfile={ff_escaped}"));
1717                }
1718                if let Some(ref bc) = opts.box_color {
1719                    parts.push("box=1".to_string());
1720                    parts.push(format!("boxcolor={bc}"));
1721                    parts.push(format!("boxborderw={}", opts.box_border_width));
1722                }
1723                parts.join(":")
1724            }
1725            Self::Ticker {
1726                text,
1727                y,
1728                speed_px_per_sec,
1729                font_size,
1730                font_color,
1731            } => {
1732                // Use the same escaping as DrawText.
1733                let escaped = text
1734                    .replace('\\', "\\\\")
1735                    .replace(':', "\\:")
1736                    .replace('\'', "\\'");
1737                // x = w - t * speed: at t=0 the text starts fully off the right
1738                // edge (x = w) and scrolls left by `speed` pixels per second.
1739                format!(
1740                    "text='{escaped}':x=w-t*{speed_px_per_sec}:y={y}:\
1741                     fontsize={font_size}:fontcolor={font_color}"
1742                )
1743            }
1744            // Video path: divide PTS by factor to change playback speed.
1745            // Audio path args are built by filter_inner (chained atempo).
1746            Self::Speed { factor } => format!("PTS/{factor}"),
1747            // args() is not used by the build loop for LoudnessNormalize (two-pass
1748            // is handled entirely in filter_inner); provided here for completeness.
1749            Self::LoudnessNormalize { .. } => "peak=true:metadata=1".to_string(),
1750            // args() is not used by the build loop for NormalizePeak (two-pass
1751            // is handled entirely in filter_inner); provided here for completeness.
1752            Self::NormalizePeak { .. } => "metadata=1".to_string(),
1753            Self::FreezeFrame { pts, duration } => {
1754                // The `loop` filter needs a frame index and a loop count, not PTS or
1755                // wall-clock duration.  We approximate both using 25 fps; accuracy
1756                // depends on the source stream's actual frame rate.
1757                #[allow(clippy::cast_possible_truncation)]
1758                let start = (*pts * 25.0) as i64;
1759                #[allow(clippy::cast_possible_truncation)]
1760                let loop_count = (*duration * 25.0) as i64;
1761                format!("loop={loop_count}:size=1:start={start}")
1762            }
1763            Self::SubtitlesSrt { path, force_style } => {
1764                // Escape for the filter's `:`-separated option parser: normalise
1765                // backslashes to `/` and escape the drive colon, else an absolute
1766                // Windows path (`C:\subs.srt`) breaks parsing at the colon.
1767                let escaped = path.replace('\\', "/").replace(':', "\\:");
1768                match force_style {
1769                    Some(fs) if !fs.is_empty() => {
1770                        // Single-quote the style value so its `,`/`=` are read as
1771                        // one option value by the parser.
1772                        format!("filename={escaped}:force_style='{fs}'")
1773                    }
1774                    _ => format!("filename={escaped}"),
1775                }
1776            }
1777            Self::SubtitlesAss { path } => {
1778                let escaped = path.replace('\\', "/").replace(':', "\\:");
1779                format!("filename={escaped}")
1780            }
1781            // args() for OverlayImage returns the overlay positional args (x:y).
1782            // These are not consumed by add_and_link_step (which is bypassed for
1783            // this compound step); they exist here only for completeness.
1784            Self::OverlayImage { x, y, .. } => format!("{x}:{y}"),
1785            // args() for Blend is not consumed by add_and_link_step (which is
1786            // bypassed in favour of add_blend_normal_step).  Provided for
1787            // completeness using the Normal-mode overlay args.
1788            Self::Blend { .. } => "format=auto:shortest=1".to_string(),
1789            // args() for Composite is not consumed by add_and_link_step (bypassed
1790            // for this compound two-input step); provided here only to satisfy the
1791            // exhaustive match.
1792            Self::Composite { .. } => String::new(),
1793            Self::ChromaKey {
1794                color,
1795                similarity,
1796                blend,
1797            } => format!("color={color}:similarity={similarity}:blend={blend}"),
1798            Self::ChromaKeyAnimated {
1799                color,
1800                similarity,
1801                blend,
1802            } => {
1803                // The CPU path is static (the GPU animates per frame): render the
1804                // `Duration::ZERO` values.
1805                #[allow(clippy::cast_possible_truncation)]
1806                let (s, b) = (
1807                    similarity.value_at(Duration::ZERO) as f32,
1808                    blend.value_at(Duration::ZERO) as f32,
1809                );
1810                format!("color={color}:similarity={s}:blend={b}")
1811            }
1812            Self::ColorKey {
1813                color,
1814                similarity,
1815                blend,
1816            } => format!("color={color}:similarity={similarity}:blend={blend}"),
1817            Self::SpillSuppress { strength, .. } => format!("h=0:s={}", 1.0 - strength),
1818            // args() is not consumed by add_and_link_step (which is bypassed for
1819            // this compound step); provided here for completeness.
1820            Self::AlphaMatte { .. } => String::new(),
1821            Self::LumaKey {
1822                threshold,
1823                tolerance,
1824                softness,
1825                ..
1826            } => format!("threshold={threshold}:tolerance={tolerance}:softness={softness}"),
1827            // args() is not consumed by add_and_link_step (which is bypassed for
1828            // this compound step); provided here for completeness.
1829            Self::FeatherMask { .. } => String::new(),
1830            Self::RectMask {
1831                x,
1832                y,
1833                width,
1834                height,
1835                invert,
1836            } => {
1837                let xw = x + width - 1;
1838                let yh = y + height - 1;
1839                let (inside, outside) = if *invert { (0, 255) } else { (255, 0) };
1840                format!(
1841                    "r='r(X,Y)':g='g(X,Y)':b='b(X,Y)':\
1842                     a='if(between(X,{x},{xw})*between(Y,{y},{yh}),{inside},{outside})'"
1843                )
1844            }
1845            Self::RectMaskAnimated {
1846                x,
1847                y,
1848                width,
1849                height,
1850                invert,
1851            } => {
1852                // The CPU path is static (the GPU animates per frame): render the
1853                // `Duration::ZERO` rectangle. width/height are clamped to >= 1 so a
1854                // degenerate frame never fails `geq` build.
1855                #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
1856                let px = |v: &AnimatedValue<f64>, min: f64| {
1857                    v.value_at(Duration::ZERO).max(min).round() as u32
1858                };
1859                let (x, y) = (px(x, 0.0), px(y, 0.0));
1860                let (width, height) = (px(width, 1.0), px(height, 1.0));
1861                let xw = x + width - 1;
1862                let yh = y + height - 1;
1863                let (inside, outside) = if *invert { (0, 255) } else { (255, 0) };
1864                format!(
1865                    "r='r(X,Y)':g='g(X,Y)':b='b(X,Y)':\
1866                     a='if(between(X,{x},{xw})*between(Y,{y},{yh}),{inside},{outside})'"
1867                )
1868            }
1869            Self::LumaMask { invert } => {
1870                // BT.709 luma of the frame's own RGB (0..255) becomes the alpha.
1871                // Matches `ff_render::LumaMaskNode` on an opaque source. `invert`
1872                // uses `255 - luma` so dark pixels stay opaque.
1873                let luma = "0.2126*r(X,Y)+0.7152*g(X,Y)+0.0722*b(X,Y)";
1874                let alpha = if *invert {
1875                    format!("255-({luma})")
1876                } else {
1877                    luma.to_string()
1878                };
1879                format!("r='r(X,Y)':g='g(X,Y)':b='b(X,Y)':a='{alpha}'")
1880            }
1881            Self::PolygonMatte { vertices, invert } => {
1882                // Build a crossing-number point-in-polygon expression.
1883                // For each edge (ax,ay)→(bx,by), a horizontal ray from (X,Y) going
1884                // right crosses the edge when Y is in [min(ay,by), max(ay,by)) and
1885                // the intersection x > X.  Exact horizontal edges (dy==0) are skipped.
1886                let n = vertices.len();
1887                let mut edge_exprs = Vec::new();
1888                for i in 0..n {
1889                    let (ax, ay) = vertices[i];
1890                    let (bx, by) = vertices[(i + 1) % n];
1891                    let dy = by - ay;
1892                    if dy == 0.0 {
1893                        // Horizontal edge — never crosses a horizontal ray; skip.
1894                        continue;
1895                    }
1896                    let min_y = ay.min(by);
1897                    let max_y = ay.max(by);
1898                    let dx = bx - ax;
1899                    // x_intersect = ax*W + (Y - ay*H) * dx*W / (dy*H)
1900                    // NOTE: the `geq` filter's dimension constants are `W`/`H` (plane
1901                    // width/height) — it has no `iw`/`ih` (those belong to scale/overlay),
1902                    // so using them makes `geq` fail with "Undefined constant".
1903                    // `dx`/`dy` can be negative; parenthesise them so the expression
1904                    // never contains a bare `*-`/`/-`.
1905                    edge_exprs.push(format!(
1906                        "if(gte(Y,{min_y}*H)*lt(Y,{max_y}*H)*gt({ax}*W+(Y-{ay}*H)*({dx})*W/(({dy})*H),X),1,0)"
1907                    ));
1908                }
1909                let sum = if edge_exprs.is_empty() {
1910                    "0".to_string()
1911                } else {
1912                    edge_exprs.join("+")
1913                };
1914                let (inside, outside) = if *invert { (0, 255) } else { (255, 0) };
1915                format!(
1916                    "r='r(X,Y)':g='g(X,Y)':b='b(X,Y)':\
1917                     a='if(gt(mod({sum},2),0),{inside},{outside})'"
1918                )
1919            }
1920            Self::FitToAspect { width, height, .. } => {
1921                // Scale to fit within the target dimensions, preserving the source
1922                // aspect ratio.  The accompanying pad filter (inserted by
1923                // filter_inner after this scale filter) centres the result on the
1924                // target canvas.
1925                format!("w={width}:h={height}:force_original_aspect_ratio=decrease")
1926            }
1927            Self::FillToAspect { width, height } => {
1928                // Scale to cover the target dimensions, preserving the source
1929                // aspect ratio.  The accompanying crop filter (inserted by
1930                // filter_inner after this scale filter) centres the result on the
1931                // target canvas.
1932                format!("w={width}:h={height}:force_original_aspect_ratio=increase")
1933            }
1934            Self::Pad {
1935                width,
1936                height,
1937                x,
1938                y,
1939                color,
1940            } => {
1941                let px = if *x < 0 {
1942                    "(ow-iw)/2".to_string()
1943                } else {
1944                    x.to_string()
1945                };
1946                let py = if *y < 0 {
1947                    "(oh-ih)/2".to_string()
1948                } else {
1949                    y.to_string()
1950                };
1951                format!("width={width}:height={height}:x={px}:y={py}:color={color}")
1952            }
1953            Self::ANoiseGate {
1954                threshold_db,
1955                attack_ms,
1956                release_ms,
1957            } => {
1958                // `agate` expects threshold as a linear amplitude ratio (0.0–1.0).
1959                let threshold_linear = 10f32.powf(threshold_db / 20.0);
1960                format!("threshold={threshold_linear:.6}:attack={attack_ms}:release={release_ms}")
1961            }
1962            Self::ACompressor {
1963                threshold_db,
1964                ratio,
1965                attack_ms,
1966                release_ms,
1967                makeup_db,
1968            } => {
1969                format!(
1970                    "threshold={threshold_db}dB:ratio={ratio}:attack={attack_ms}:\
1971                     release={release_ms}:makeup={makeup_db}dB"
1972                )
1973            }
1974            Self::StereoToMono => "mono|c0=0.5*c0+0.5*c1".to_string(),
1975            Self::ChannelMap { mapping } => format!("map={mapping}"),
1976            // args() is not used directly for AudioDelay — the audio build loop
1977            // dispatches to add_raw_filter_step with the correct filter name and
1978            // args based on the sign of ms.  These are provided for completeness.
1979            Self::AudioDelay { ms } => {
1980                if *ms >= 0.0 {
1981                    format!("delays={ms}:all=1")
1982                } else {
1983                    format!("start={}", -ms / 1000.0)
1984                }
1985            }
1986            Self::ATrim { start, end } => match (start, end) {
1987                (Some(s), Some(e)) => format!("start={s:.6}:end={e:.6}"),
1988                (Some(s), None) => format!("start={s:.6}"),
1989                (None, Some(e)) => format!("end={e:.6}"),
1990                (None, None) => String::new(),
1991            },
1992            Self::AResetPts => "PTS-STARTPTS".to_string(),
1993            Self::ConcatVideo { n } => format!("n={n}:v=1:a=0"),
1994            Self::ConcatAudio { n } => format!("n={n}:v=0:a=1"),
1995            Self::CropAnimated {
1996                x,
1997                y,
1998                width,
1999                height,
2000            } => {
2001                let x0 = x.value_at(Duration::ZERO);
2002                let y0 = y.value_at(Duration::ZERO);
2003                let w0 = width.value_at(Duration::ZERO);
2004                let h0 = height.value_at(Duration::ZERO);
2005                // No `eval=frame`: this FFmpeg's `crop` has no `eval` option and already
2006                // re-evaluates the x/y position expressions per frame, so `send_command`
2007                // updates to x/y take effect. (w/h are fixed at init — animate pan, not
2008                // zoom, via crop; zoom is done by scaling.)
2009                format!("x={x0}:y={y0}:w={w0}:h={h0}")
2010            }
2011            Self::GBlurAnimated { sigma } => {
2012                let s0 = sigma.value_at(Duration::ZERO);
2013                format!("sigma={s0}")
2014            }
2015            Self::UnsharpAnimated {
2016                luma_strength,
2017                chroma_strength,
2018            } => {
2019                // No `eval=frame` / `send_command`: `unsharp` has no runtime param,
2020                // so the CPU path renders the `Duration::ZERO` value statically.
2021                let l0 = luma_strength.value_at(Duration::ZERO);
2022                let c0 = chroma_strength.value_at(Duration::ZERO);
2023                format!(
2024                    "luma_msize_x=5:luma_msize_y=5:luma_amount={l0}:\
2025                     chroma_msize_x=5:chroma_msize_y=5:chroma_amount={c0}"
2026                )
2027            }
2028            Self::ScaleAnimated {
2029                width,
2030                height,
2031                algorithm,
2032            } => {
2033                let flags = algorithm.as_flags_str();
2034                let animated = matches!(width, AnimatedValue::Track(_))
2035                    || matches!(height, AnimatedValue::Track(_));
2036                if animated {
2037                    // Self-animate via per-frame expressions. A Static side is a
2038                    // constant expression; a Track side compiles to a `t`-expression.
2039                    let expr = |v: &AnimatedValue<f64>| match v {
2040                        AnimatedValue::Track(t) => t.to_ffmpeg_expr("t"),
2041                        AnimatedValue::Static(s) => format!("{s:.6}"),
2042                    };
2043                    format!(
2044                        "w={}:h={}:flags={flags}:eval=frame",
2045                        expr(width),
2046                        expr(height)
2047                    )
2048                } else {
2049                    let w0 = width.value_at(Duration::ZERO);
2050                    let h0 = height.value_at(Duration::ZERO);
2051                    format!("w={w0}:h={h0}:flags={flags}")
2052                }
2053            }
2054            Self::RotateAnimated { angle, fill_color } => match angle {
2055                // `rotate` re-evaluates `angle` per frame, so a Track self-animates.
2056                // Compile the degrees track to a `t`-expression, converted to radians.
2057                AnimatedValue::Track(track) => {
2058                    let deg = track.to_ffmpeg_expr("t");
2059                    format!("angle=({deg})*PI/180:fillcolor={fill_color}")
2060                }
2061                AnimatedValue::Static(deg) => {
2062                    format!("angle={}:fillcolor={fill_color}", deg.to_radians())
2063                }
2064            },
2065            Self::VignetteAnimated { amount, x0, y0 } => {
2066                let cx = if *x0 == 0.0 {
2067                    "w/2".to_string()
2068                } else {
2069                    x0.to_string()
2070                };
2071                let cy = if *y0 == 0.0 {
2072                    "h/2".to_string()
2073                } else {
2074                    y0.to_string()
2075                };
2076                match amount {
2077                    // `vignette` re-evaluates `angle` per frame under `eval=frame`, so a
2078                    // Track self-animates. The normalised amount is scaled to the angle
2079                    // (radians) in the expression: `amount * PI/2`.
2080                    AnimatedValue::Track(track) => {
2081                        let a = track.to_ffmpeg_expr("t");
2082                        format!("angle=({a})*PI/2:x0={cx}:y0={cy}:eval=frame")
2083                    }
2084                    AnimatedValue::Static(a) => {
2085                        format!("angle={}:x0={cx}:y0={cy}", a * std::f64::consts::FRAC_PI_2)
2086                    }
2087                }
2088            }
2089            Self::MotionBlur {
2090                shutter_angle_degrees,
2091                ..
2092            } => {
2093                let alpha = f64::from(*shutter_angle_degrees / 360.0).clamp(0.0, 1.0);
2094                let keep = 1.0 - alpha;
2095                let blend = alpha;
2096                format!("all_expr='A*{keep}+B*{blend}'")
2097            }
2098            Self::MotionBlurAnimated { shutter_angle, .. } => match shutter_angle {
2099                // Same mix as the constant form with the two constants replaced by
2100                // one clamped sub-expression in `T`. `clip` is FFmpeg's own
2101                // three-argument clamp, so the shutter stays in `[0, 360]` however
2102                // the track is authored.
2103                //
2104                // `A` is the **current** frame and `B` the previous one, so `alpha`
2105                // weights the previous: a shutter of 0 renders the current frame
2106                // untouched and 360 holds the previous one. Measured through the
2107                // real filter, because the direction is not obvious from `tblend`'s
2108                // documentation and getting it backwards inverts the effect.
2109                AnimatedValue::Track(track) => {
2110                    let deg = track.to_ffmpeg_expr("T");
2111                    let alpha = format!("clip(({deg})/360,0,1)");
2112                    format!("all_expr='A*(1-({alpha}))+B*({alpha})'")
2113                }
2114                AnimatedValue::Static(deg) => {
2115                    // Deliberately the *same arithmetic* as `MotionBlur`: an f32
2116                    // divide widened to f64. Dividing in f64 here would render a
2117                    // different string for any angle that is not exact in f32 (137
2118                    // gives 0.3805555555555556 against 0.3805555701255798), so the
2119                    // two forms would silently disagree where they should overlap.
2120                    #[allow(clippy::cast_possible_truncation)]
2121                    let alpha = f64::from((*deg as f32) / 360.0).clamp(0.0, 1.0);
2122                    let keep = 1.0 - alpha;
2123                    format!("all_expr='A*{keep}+B*{alpha}'")
2124                }
2125            },
2126            Self::LensCorrection { k1, k2 } => format!("k1={k1}:k2={k2}"),
2127            Self::FilmGrain {
2128                luma_strength,
2129                chroma_strength,
2130            } => {
2131                #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
2132                let ls = luma_strength.clamp(0.0, 100.0) as u32;
2133                #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
2134                let cs = chroma_strength.clamp(0.0, 100.0) as u32;
2135                format!("alls={ls}:c0s={cs}:c1s={cs}:allf=t")
2136            }
2137            Self::FilmGrainAnimated {
2138                luma_strength,
2139                chroma_strength,
2140            } => {
2141                // No `eval=frame` / `send_command`: `noise` has no runtime parameter,
2142                // so the CPU path renders the `Duration::ZERO` strength statically. The
2143                // pattern is still temporal via `allf=t`.
2144                #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
2145                let ls = luma_strength.value_at(Duration::ZERO).clamp(0.0, 100.0) as u32;
2146                #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
2147                let cs = chroma_strength.value_at(Duration::ZERO).clamp(0.0, 100.0) as u32;
2148                format!("alls={ls}:c0s={cs}:c1s={cs}:allf=t")
2149            }
2150            Self::ScaleMultiplier { factor } => {
2151                format!("w=iw*{factor}:h=ih*{factor}")
2152            }
2153            Self::ChromaticAberration { rh, bh } => {
2154                format!("rh={rh}:bh={bh}:edge=smear")
2155            }
2156            // args() is not consumed by add_and_link_step (which is bypassed for
2157            // this compound step); provided here for completeness.
2158            Self::Glow {
2159                threshold,
2160                radius,
2161                intensity,
2162            } => glow_compound_args(*threshold, *radius, *intensity),
2163            // The compound step is built by `add_glow_step`, not from this string;
2164            // the args are provided for completeness (rendered at `Duration::ZERO`).
2165            Self::GlowAnimated {
2166                threshold,
2167                radius,
2168                intensity,
2169            } => {
2170                #[allow(clippy::cast_possible_truncation)]
2171                let out = glow_compound_args(
2172                    threshold.value_at(Duration::ZERO) as f32,
2173                    radius.value_at(Duration::ZERO) as f32,
2174                    intensity.value_at(Duration::ZERO) as f32,
2175                );
2176                out
2177            }
2178            Self::ReverbEcho {
2179                in_gain,
2180                out_gain,
2181                delays,
2182                decays,
2183            } => {
2184                let delay_str = delays
2185                    .iter()
2186                    .map(|d| d.to_string())
2187                    .collect::<Vec<_>>()
2188                    .join("|");
2189                let decay_str = decays
2190                    .iter()
2191                    .map(|d| d.to_string())
2192                    .collect::<Vec<_>>()
2193                    .join("|");
2194                format!(
2195                    "in_gain={ig}:out_gain={og}:delays={ds}:decays={dec}",
2196                    ig = in_gain,
2197                    og = out_gain,
2198                    ds = delay_str,
2199                    dec = decay_str,
2200                )
2201            }
2202            // args() is not consumed by add_and_link_step (which is bypassed for
2203            // this compound step); provided here for completeness.
2204            Self::ReverbIr {
2205                ir_path,
2206                wet,
2207                dry,
2208                pre_delay_ms,
2209            } => {
2210                let delay = pre_delay_ms.min(&500);
2211                let delay_part = if *delay > 0 {
2212                    format!(",adelay={delay}:all=1")
2213                } else {
2214                    String::new()
2215                };
2216                format!("amovie={ir_path}{delay_part}[ir];[0:a][ir]afir=dry={dry}:wet={wet}")
2217            }
2218            // args() is not consumed by add_and_link_step (which is bypassed for
2219            // this compound step); provided here for completeness.
2220            Self::PitchShift { semitones, .. } => {
2221                let rate = 2f64.powf(f64::from(*semitones) / 12.0);
2222                let atempo = 1.0 / rate;
2223                format!("asetrate=sr*{rate:.6},atempo={atempo:.6}")
2224            }
2225            // args() is not consumed by add_and_link_step (bypassed in favour of
2226            // add_atempo_chain); provided here for single-instance completeness.
2227            Self::TimeStretch { factor, .. } => format!("{factor:.6}"),
2228            // args() is not consumed by add_and_link_step (bypassed; sample rate
2229            // is resolved from buffersrc_args at build time); provided for completeness.
2230            Self::SpeedChange { factor } => format!("asetrate=sr*{factor:.6}"),
2231            Self::NoiseReduce {
2232                noise_type_flag,
2233                nr_level,
2234            } => format!("nt={noise_type_flag}:nr={nr_level}"),
2235            Self::NoiseReduceProfile {
2236                profile_duration_secs,
2237                nr_level,
2238            } => format!("nr={nr_level}:nf=-25:nt=w:pl={profile_duration_secs}"),
2239            // args() is not consumed by add_and_link_step (bypassed for this
2240            // compound two-input step); provided for completeness.
2241            Self::Duck {
2242                threshold_linear,
2243                ratio,
2244                attack_ms,
2245                release_ms,
2246            } => format!(
2247                "threshold={threshold_linear}:ratio={ratio}:attack={attack_ms}:release={release_ms}"
2248            ),
2249        }
2250    }
2251
2252    /// Animation entries this step contributes for per-frame `send_command`, when it
2253    /// is an animated variant with `Track` params.
2254    ///
2255    /// `node_name` must equal the filter instance name created for this step (the
2256    /// `send_command` target). Returns empty for non-animated steps and for `Static`
2257    /// params. Used by [`crate::filter_inner::add_and_link_step`] to surface effect
2258    /// animations to the composition builders; the standalone [`FilterGraphBuilder`]
2259    /// registers the equivalent entries in its own builder methods.
2260    pub(crate) fn animation_entries(&self, node_name: &str) -> Vec<AnimationEntry> {
2261        let mut entries = Vec::new();
2262        match self {
2263            Self::CropAnimated {
2264                x,
2265                y,
2266                width,
2267                height,
2268            } => {
2269                push_scalar_entry(&mut entries, node_name, "x", x);
2270                push_scalar_entry(&mut entries, node_name, "y", y);
2271                push_scalar_entry(&mut entries, node_name, "w", width);
2272                push_scalar_entry(&mut entries, node_name, "h", height);
2273            }
2274            Self::GBlurAnimated { sigma } => {
2275                push_scalar_entry(&mut entries, node_name, "sigma", sigma);
2276            }
2277            // `temperature`/`tint` are GPU-only; the `eq` filter has no such runtime
2278            // command, so they are not pushed here.
2279            Self::EqAnimated {
2280                brightness,
2281                contrast,
2282                saturation,
2283                gamma,
2284                temperature: _,
2285                tint: _,
2286            } => {
2287                push_scalar_entry(&mut entries, node_name, "brightness", brightness);
2288                push_scalar_entry(&mut entries, node_name, "contrast", contrast);
2289                push_scalar_entry(&mut entries, node_name, "saturation", saturation);
2290                push_scalar_entry(&mut entries, node_name, "gamma", gamma);
2291            }
2292            Self::ColorBalanceAnimated { lift, gamma, gain } => {
2293                push_tuple_entries(&mut entries, node_name, ["rs", "gs", "bs"], lift);
2294                push_tuple_entries(&mut entries, node_name, ["rm", "gm", "bm"], gamma);
2295                push_tuple_entries(&mut entries, node_name, ["rh", "gh", "bh"], gain);
2296            }
2297            _ => {}
2298        }
2299        entries
2300    }
2301}
2302
2303/// Pushes an [`AnimationEntry`] for a scalar animated param when it is a `Track`.
2304fn push_scalar_entry(
2305    entries: &mut Vec<AnimationEntry>,
2306    node_name: &str,
2307    param: &'static str,
2308    av: &AnimatedValue<f64>,
2309) {
2310    if let AnimatedValue::Track(track) = av {
2311        entries.push(AnimationEntry {
2312            node_name: node_name.to_owned(),
2313            param,
2314            track: track.clone(),
2315            suffix: "",
2316        });
2317    }
2318}
2319
2320/// Pushes three per-channel [`AnimationEntry`]s for a `(R, G, B)` tuple animated param
2321/// when it is a `Track`, splitting the tuple track into one scalar track per channel.
2322fn push_tuple_entries(
2323    entries: &mut Vec<AnimationEntry>,
2324    node_name: &str,
2325    params: [&'static str; 3],
2326    av: &AnimatedValue<(f64, f64, f64)>,
2327) {
2328    let AnimatedValue::Track(track) = av else {
2329        return;
2330    };
2331    for (i, param) in params.into_iter().enumerate() {
2332        let scalar = track
2333            .keyframes()
2334            .iter()
2335            .fold(AnimationTrack::new(), |t, kf| {
2336                let v = match i {
2337                    0 => kf.value.0,
2338                    1 => kf.value.1,
2339                    _ => kf.value.2,
2340                };
2341                t.push(Keyframe {
2342                    timestamp: kf.timestamp,
2343                    value: v,
2344                    easing: kf.easing.clone(),
2345                })
2346            });
2347        entries.push(AnimationEntry {
2348            node_name: node_name.to_owned(),
2349            param,
2350            track: scalar,
2351            suffix: "",
2352        });
2353    }
2354}
2355
2356#[cfg(test)]
2357mod tests {
2358    use super::*;
2359    use crate::animation::{AnimationTrack, Easing, Keyframe};
2360
2361    #[test]
2362    fn escape_filter_path_should_escape_windows_drive_path() {
2363        // Backslashes → forward slashes; the drive colon is escaped as `\:` so
2364        // FFmpeg's filter-arg parser does not treat it as an option separator.
2365        assert_eq!(
2366            escape_filter_path(r"D:\dir\look.cube"),
2367            "D\\:/dir/look.cube"
2368        );
2369    }
2370
2371    #[test]
2372    fn escape_filter_path_should_leave_unix_path_unchanged() {
2373        assert_eq!(escape_filter_path("/home/u/look.cube"), "/home/u/look.cube");
2374    }
2375
2376    /// The animated form checked against the linked `FFmpeg`, not against a string.
2377    ///
2378    /// The animated shutter leans on one fact about `blend`: its expression is
2379    /// re-evaluated per frame with `T` bound to the frame's presentation time
2380    /// (`vf_blend.c`, `values[VAR_T] = pts * time_base`). A string-equality test
2381    /// cannot tell whether the linked build agrees, so these push frames through the
2382    /// real filter (RK-005). They live here rather than in `tests/` because `args()`
2383    /// is `pub(crate)` and widening it for a test would be the wrong trade.
2384    mod ffmpeg_reference {
2385        use super::*;
2386        use crate::FilterGraph;
2387        use ff_format::{PixelFormat, PooledBuffer, Rational, Timestamp, VideoFrame};
2388
2389        const W: u32 = 8;
2390        const H: u32 = 8;
2391        const FPS: u32 = 30;
2392
2393        /// A solid-luma frame at `frame_index / FPS` seconds.
2394        fn frame_at(luma: u8, frame_index: u32) -> VideoFrame {
2395            VideoFrame::new(
2396                vec![
2397                    PooledBuffer::standalone(vec![luma; (W * H) as usize]),
2398                    PooledBuffer::standalone(vec![128u8; ((W / 2) * (H / 2)) as usize]),
2399                    PooledBuffer::standalone(vec![128u8; ((W / 2) * (H / 2)) as usize]),
2400                ],
2401                vec![W as usize, (W / 2) as usize, (W / 2) as usize],
2402                W,
2403                H,
2404                PixelFormat::Yuv420p,
2405                // PTS in 1/FPS units, so `blend`'s `T` (pts * time_base) is the
2406                // frame's time in seconds, which is what the expression reads.
2407                Timestamp::new(i64::from(frame_index), Rational::new(1, FPS as i32)),
2408                true,
2409            )
2410            .unwrap()
2411        }
2412
2413        /// Pushes `count` frames alternating white/black through `tblend=<args>` and
2414        /// returns the red channel of every frame that came out.
2415        ///
2416        /// Alternating matters: `tblend` mixes *consecutive inputs*, not an
2417        /// accumulating output, so a single seed is washed out after one step and
2418        /// every later frame would read the same whatever the shutter is. With
2419        /// alternating input there is contrast at every step, so the mix ratio is
2420        /// visible at any time.
2421        ///
2422        /// `None` means this build could not run the graph, which the callers treat
2423        /// as a skip (RK-002).
2424        fn run_tblend(args: &str, count: u32) -> Option<Vec<u8>> {
2425            let desc = format!("format=rgba,tblend={args},format=rgba");
2426            let mut graph = FilterGraph::parse_desc(&desc).ok()?;
2427            let mut out = Vec::new();
2428            for i in 0..count {
2429                let luma = if i % 2 == 0 { 235 } else { 16 };
2430                graph.push_video(0, &frame_at(luma, i)).ok()?;
2431                while let Ok(Some(frame)) = graph.pull_video() {
2432                    out.push(frame.plane(0).map(|p| p[0])?);
2433                }
2434            }
2435            Some(out)
2436        }
2437
2438        fn constant_args(degrees: f32) -> String {
2439            FilterStep::MotionBlur {
2440                shutter_angle_degrees: degrees,
2441                sub_frames: 4,
2442            }
2443            .args()
2444        }
2445
2446        fn ramp_args(from: f64, to: f64) -> String {
2447            let track = AnimationTrack::new()
2448                .push(Keyframe::new(Duration::ZERO, from, Easing::Linear))
2449                .push(Keyframe::new(Duration::from_secs(1), to, Easing::Linear));
2450            FilterStep::MotionBlurAnimated {
2451                shutter_angle: AnimatedValue::Track(track),
2452                sub_frames: 4,
2453            }
2454            .args()
2455        }
2456
2457        #[test]
2458        fn ffmpeg_should_accept_the_animated_shutter_expression() {
2459            let Some(_) = run_tblend(&constant_args(180.0), 3) else {
2460                println!("skipping: this FFmpeg build cannot run tblend");
2461                return;
2462            };
2463            assert!(
2464                run_tblend(&ramp_args(360.0, 0.0), 3).is_some(),
2465                "FFmpeg rejected the animated shutter expression: {}",
2466                ramp_args(360.0, 0.0)
2467            );
2468        }
2469
2470        #[test]
2471        fn an_animated_shutter_should_vary_across_frames_where_a_constant_one_does_not() {
2472            // The point of the animated form: with the same alternating input, a
2473            // constant shutter gives the same mix at every step, while a ramp gives a
2474            // different one as `T` advances. A build that ignored `T` would produce
2475            // the constant's flat answer for both.
2476            let Some(flat) = run_tblend(&constant_args(180.0), FPS) else {
2477                println!("skipping: this FFmpeg build cannot run tblend");
2478                return;
2479            };
2480            let Some(ramped) = run_tblend(&ramp_args(0.0, 360.0), FPS) else {
2481                return;
2482            };
2483            // `tblend` emits from the second input on, so output `k` is input frame
2484            // `k + 1`. Both indices below are odd, so both outputs come from an even
2485            // input frame and share the same pair (current white, previous black);
2486            // only the shutter differs. Mixing parities compares a white-over-black
2487            // step against a black-over-white one, where the two effects cancel and
2488            // the swing reads as small.
2489            //
2490            // Measured, and matching the algebra exactly: output 3 is input frame 4
2491            // (T = 0.133 s, shutter 48, alpha 0.133) -> 255*0.867 = 221, and output
2492            // 27 is frame 28 (alpha 0.933) -> 255*0.067 = 17.
2493            let (early, late) = (3usize, 27usize);
2494            assert_eq!(early % 2, late % 2, "the two samples must share a parity");
2495            println!(
2496                "tblend same-parity frames {early} / {late}: constant180={:?} ramp0to360={:?}",
2497                (flat.get(early), flat.get(late)),
2498                (ramped.get(early), ramped.get(late))
2499            );
2500            let (fe, fl) = (flat[early], flat[late]);
2501            let (re, rl) = (ramped[early], ramped[late]);
2502            assert!(
2503                fe.abs_diff(fl) <= 2,
2504                "a constant shutter must give the same mix at both frames: {fe} vs {fl}"
2505            );
2506            assert!(
2507                re.abs_diff(rl) > 32,
2508                "a ramped shutter must give a different mix as T advances: {re} vs {rl}"
2509            );
2510        }
2511    }
2512
2513    #[test]
2514    fn motion_blur_animated_static_should_render_what_the_constant_form_renders() {
2515        // The two forms overlap at a constant shutter, and 137 is chosen precisely
2516        // because it is not exact in f32: an f64 divide here would render
2517        // 0.3805555555555556 where the constant form renders 0.3805555701255798,
2518        // and the paths would disagree for every ordinary angle.
2519        let constant = FilterStep::MotionBlur {
2520            shutter_angle_degrees: 137.0,
2521            sub_frames: 4,
2522        };
2523        let animated = FilterStep::MotionBlurAnimated {
2524            shutter_angle: AnimatedValue::Static(137.0),
2525            sub_frames: 4,
2526        };
2527        assert_eq!(constant.args(), animated.args());
2528        assert_eq!(constant.filter_name(), animated.filter_name());
2529    }
2530
2531    #[test]
2532    fn motion_blur_animated_track_should_render_an_uppercase_t_expression() {
2533        // `blend`'s expression variables are upper-case (`X Y W H SW SH T N A B TOP
2534        // BOTTOM`). A lower-case `t` is not among them and would evaluate as an
2535        // unknown, so the case is part of the contract, not a style choice.
2536        let track = AnimationTrack::new()
2537            .push(Keyframe::new(Duration::ZERO, 0.0, Easing::Linear))
2538            .push(Keyframe::new(Duration::from_secs(1), 360.0, Easing::Linear));
2539        let step = FilterStep::MotionBlurAnimated {
2540            shutter_angle: AnimatedValue::Track(track),
2541            sub_frames: 4,
2542        };
2543        let args = step.args();
2544        assert!(
2545            args.contains('T'),
2546            "the expression must reference T: {args}"
2547        );
2548        assert!(
2549            !args.contains(" t") && !args.contains("(t"),
2550            "a lower-case t is not a blend variable: {args}"
2551        );
2552        assert!(
2553            args.contains("clip("),
2554            "the shutter must stay clamped to [0, 360]: {args}"
2555        );
2556        assert!(
2557            args.starts_with("all_expr='A*(1-(") && args.ends_with("'"),
2558            "the mix must keep the A/B form the constant version uses: {args}"
2559        );
2560    }
2561
2562    #[test]
2563    fn motion_blur_animated_should_be_a_tblend_step() {
2564        let step = FilterStep::MotionBlurAnimated {
2565            shutter_angle: AnimatedValue::Static(180.0),
2566            sub_frames: 4,
2567        };
2568        assert_eq!(step.filter_name(), "tblend");
2569    }
2570
2571    #[test]
2572    fn lut3d_args_should_escape_path() {
2573        let step = FilterStep::Lut3d {
2574            path: r"D:\luts\look.cube".to_string(),
2575        };
2576        let args = step.args();
2577        assert!(
2578            !args.contains(r"D:\"),
2579            "raw Windows path must not appear unescaped in args: {args}"
2580        );
2581        assert!(args.contains("D\\:/luts/look.cube"), "got: {args}");
2582        assert!(args.ends_with(":interp=trilinear"));
2583    }
2584
2585    #[test]
2586    fn setparams_filter_name_should_be_setparams() {
2587        let step = FilterStep::SetParams {
2588            color_space: None,
2589            color_range: None,
2590            color_primaries: None,
2591            color_trc: None,
2592        };
2593        assert_eq!(step.filter_name(), "setparams");
2594    }
2595
2596    #[test]
2597    fn raw_filter_name_should_be_the_given_filter() {
2598        let step = FilterStep::Raw {
2599            filter: "selectivecolor".to_string(),
2600            args: "reds=0.1 0 0".to_string(),
2601        };
2602        assert_eq!(step.filter_name(), "selectivecolor");
2603    }
2604
2605    #[test]
2606    fn fill_to_aspect_args_should_cover_with_increase() {
2607        let step = FilterStep::FillToAspect {
2608            width: 1920,
2609            height: 1080,
2610        };
2611        assert_eq!(
2612            step.args(),
2613            "w=1920:h=1080:force_original_aspect_ratio=increase"
2614        );
2615    }
2616
2617    #[test]
2618    fn fill_to_aspect_filter_name_should_be_scale() {
2619        let step = FilterStep::FillToAspect {
2620            width: 1920,
2621            height: 1080,
2622        };
2623        assert_eq!(step.filter_name(), "scale");
2624    }
2625
2626    #[test]
2627    fn raw_args_should_pass_through_verbatim() {
2628        let step = FilterStep::Raw {
2629            filter: "selectivecolor".to_string(),
2630            args: "reds=0.1 0 0:blues=0 0 0.2".to_string(),
2631        };
2632        assert_eq!(step.args(), "reds=0.1 0 0:blues=0 0 0.2");
2633    }
2634
2635    #[test]
2636    fn raw_with_empty_args_should_render_empty_args() {
2637        let step = FilterStep::Raw {
2638            filter: "hflip".to_string(),
2639            args: String::new(),
2640        };
2641        assert_eq!(step.filter_name(), "hflip");
2642        assert_eq!(step.args(), "");
2643    }
2644
2645    #[test]
2646    fn setparams_args_should_emit_all_canonical_tokens() {
2647        let step = FilterStep::SetParams {
2648            color_space: Some(ColorSpace::Bt2020Ncl),
2649            color_range: Some(ColorRange::Limited),
2650            color_primaries: Some(ColorPrimaries::Bt2020),
2651            color_trc: Some(ColorTransfer::Hlg),
2652        };
2653        assert_eq!(
2654            step.args(),
2655            "colorspace=bt2020nc:range=tv:color_primaries=bt2020:color_trc=arib-std-b67"
2656        );
2657    }
2658
2659    #[test]
2660    fn setparams_args_should_emit_only_provided_options() {
2661        // HDR tagging often sets just primaries + transfer.
2662        let step = FilterStep::SetParams {
2663            color_space: None,
2664            color_range: None,
2665            color_primaries: Some(ColorPrimaries::Bt2020),
2666            color_trc: Some(ColorTransfer::Pq),
2667        };
2668        assert_eq!(step.args(), "color_primaries=bt2020:color_trc=smpte2084");
2669    }
2670
2671    #[test]
2672    fn setparams_args_should_skip_values_without_ffmpeg_token() {
2673        // `Unknown` renders to no token and must be skipped, not emitted as an invalid arg.
2674        let step = FilterStep::SetParams {
2675            color_space: Some(ColorSpace::Unknown),
2676            color_range: Some(ColorRange::Full),
2677            color_primaries: Some(ColorPrimaries::Unknown),
2678            color_trc: Some(ColorTransfer::Bt709),
2679        };
2680        assert_eq!(step.args(), "range=pc:color_trc=bt709");
2681    }
2682
2683    #[test]
2684    fn setparams_args_should_be_empty_when_all_none() {
2685        let step = FilterStep::SetParams {
2686            color_space: None,
2687            color_range: None,
2688            color_primaries: None,
2689            color_trc: None,
2690        };
2691        assert_eq!(step.args(), "");
2692    }
2693
2694    #[test]
2695    fn chroma_key_animated_should_render_static_zero_values_via_chromakey() {
2696        // The CPU path is static: args() renders the Duration::ZERO values through
2697        // the same `chromakey` filter as the const variant.
2698        let step = FilterStep::ChromaKeyAnimated {
2699            color: "0x00FF00".to_string(),
2700            similarity: AnimatedValue::Static(0.3),
2701            blend: AnimatedValue::Static(0.1),
2702        };
2703        assert_eq!(step.filter_name(), "chromakey");
2704        assert_eq!(step.args(), "color=0x00FF00:similarity=0.3:blend=0.1");
2705    }
2706
2707    #[test]
2708    fn rect_mask_animated_should_render_static_zero_bounds_via_geq() {
2709        // The CPU path is static: args() renders the Duration::ZERO rectangle through
2710        // the same geq expression as the const RectMask variant.
2711        let step = FilterStep::RectMaskAnimated {
2712            x: AnimatedValue::Static(10.0),
2713            y: AnimatedValue::Static(20.0),
2714            width: AnimatedValue::Static(30.0),
2715            height: AnimatedValue::Static(40.0),
2716            invert: false,
2717        };
2718        assert_eq!(step.filter_name(), "geq");
2719        // xw = 10 + 30 - 1 = 39, yh = 20 + 40 - 1 = 59.
2720        assert_eq!(
2721            step.args(),
2722            "r='r(X,Y)':g='g(X,Y)':b='b(X,Y)':\
2723             a='if(between(X,10,39)*between(Y,20,59),255,0)'"
2724        );
2725    }
2726
2727    #[test]
2728    fn rect_mask_animated_invert_should_swap_inside_outside() {
2729        let step = FilterStep::RectMaskAnimated {
2730            x: AnimatedValue::Static(0.0),
2731            y: AnimatedValue::Static(0.0),
2732            width: AnimatedValue::Static(8.0),
2733            height: AnimatedValue::Static(8.0),
2734            invert: true,
2735        };
2736        assert_eq!(
2737            step.args(),
2738            "r='r(X,Y)':g='g(X,Y)':b='b(X,Y)':\
2739             a='if(between(X,0,7)*between(Y,0,7),0,255)'"
2740        );
2741    }
2742
2743    #[test]
2744    fn luma_mask_should_render_bt709_self_luma_geq() {
2745        // Non-invert: alpha becomes the frame's own BT.709 luma via geq. (geq exposes
2746        // no alpha-read function here, so alpha is set to the luma rather than
2747        // multiplied; on the opaque source this equals LumaMaskNode's alpha *= luma.)
2748        let step = FilterStep::LumaMask { invert: false };
2749        assert_eq!(step.filter_name(), "geq");
2750        assert_eq!(
2751            step.args(),
2752            "r='r(X,Y)':g='g(X,Y)':b='b(X,Y)':\
2753             a='0.2126*r(X,Y)+0.7152*g(X,Y)+0.0722*b(X,Y)'"
2754        );
2755    }
2756
2757    #[test]
2758    fn luma_mask_invert_should_use_one_minus_luma() {
2759        // Invert: `255 - luma`, so dark pixels stay opaque.
2760        let step = FilterStep::LumaMask { invert: true };
2761        assert_eq!(
2762            step.args(),
2763            "r='r(X,Y)':g='g(X,Y)':b='b(X,Y)':\
2764             a='255-(0.2126*r(X,Y)+0.7152*g(X,Y)+0.0722*b(X,Y))'"
2765        );
2766    }
2767
2768    #[test]
2769    fn animation_entries_maps_only_track_params_to_the_node() {
2770        use crate::animation::{AnimatedValue, AnimationTrack, Easing, Keyframe};
2771        let ramp = || {
2772            AnimationTrack::new()
2773                .push(Keyframe::new(Duration::ZERO, 0.0, Easing::Linear))
2774                .push(Keyframe::new(Duration::from_secs(1), 10.0, Easing::Linear))
2775        };
2776        // Only x and h are Track → exactly two entries, both on the given node.
2777        let step = FilterStep::CropAnimated {
2778            x: AnimatedValue::Track(ramp()),
2779            y: AnimatedValue::Static(0.0),
2780            width: AnimatedValue::Static(4.0),
2781            height: AnimatedValue::Track(ramp()),
2782        };
2783        let entries = step.animation_entries("veff0");
2784        assert_eq!(entries.len(), 2);
2785        assert!(entries.iter().all(|e| e.node_name == "veff0"));
2786        let params: Vec<&str> = entries.iter().map(|e| e.param).collect();
2787        assert!(params.contains(&"x") && params.contains(&"h"));
2788
2789        // A fully static animated step registers nothing.
2790        let static_step = FilterStep::CropAnimated {
2791            x: AnimatedValue::Static(0.0),
2792            y: AnimatedValue::Static(0.0),
2793            width: AnimatedValue::Static(4.0),
2794            height: AnimatedValue::Static(8.0),
2795        };
2796        assert!(static_step.animation_entries("veff0").is_empty());
2797
2798        // A non-animated step never contributes entries.
2799        assert!(
2800            FilterStep::Crop {
2801                x: 0,
2802                y: 0,
2803                width: 4,
2804                height: 8,
2805            }
2806            .animation_entries("veff0")
2807            .is_empty()
2808        );
2809    }
2810}