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, Rgb, ScaleAlgorithm, ToneMap, XfadeTransition, YadifMode,
9};
10
11use crate::animation::AnimatedValue;
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#[derive(Debug, Clone)]
34pub enum FilterStep {
35    /// Convert video to a suitable pixel format from the given options.
36    Format {
37        pix_fmts: Vec<PixelFormat>,
38        color_spaces: Vec<ColorSpace>,
39        color_ranges: Vec<ColorRange>,
40    },
41
42    /// Tag the stream's colour metadata via the `setparams` filter.
43    ///
44    /// Each field is emitted only when `Some` **and** its `FfmpegToken` is
45    /// `Some` (e.g. `Unknown` → skipped), so an all-`None` step yields empty
46    /// args. This is the consumer for `ColorPrimaries` / `ColorTransfer`, whose
47    /// tokens the `format` filter has no option for.
48    SetParams {
49        color_space: Option<ColorSpace>,
50        color_range: Option<ColorRange>,
51        color_primaries: Option<ColorPrimaries>,
52        color_trc: Option<ColorTransfer>,
53    },
54
55    /// Trim: keep only frames in `[start, end)` seconds.
56    Trim { start: f64, end: f64 },
57    /// Scale to a new resolution using the given resampling algorithm.
58    Scale {
59        width: u32,
60        height: u32,
61        algorithm: ScaleAlgorithm,
62    },
63    /// Crop a rectangular region.
64    Crop {
65        x: u32,
66        y: u32,
67        width: u32,
68        height: u32,
69    },
70    /// Overlay a second stream at position `(x, y)`.
71    Overlay { x: i32, y: i32 },
72    /// Fade-in from black starting at `start` seconds, over `duration` seconds.
73    FadeIn { start: f64, duration: f64 },
74    /// Fade-out to black starting at `start` seconds, over `duration` seconds.
75    FadeOut { start: f64, duration: f64 },
76    /// Audio fade-in from silence starting at `start` seconds, over `duration` seconds.
77    AFadeIn { start: f64, duration: f64 },
78    /// Audio fade-out to silence starting at `start` seconds, over `duration` seconds.
79    AFadeOut { start: f64, duration: f64 },
80    /// Fade-in from white starting at `start` seconds, over `duration` seconds.
81    FadeInWhite { start: f64, duration: f64 },
82    /// Fade-out to white starting at `start` seconds, over `duration` seconds.
83    FadeOutWhite { start: f64, duration: f64 },
84    /// Rotate clockwise by `angle_degrees`, filling exposed areas with `fill_color`.
85    Rotate {
86        angle_degrees: f64,
87        fill_color: String,
88    },
89    /// HDR-to-SDR tone mapping.
90    ToneMap(ToneMap),
91    /// Adjust audio volume (in dB; negative = quieter).
92    Volume(f64),
93    /// Mix `n` audio inputs together.
94    Amix(usize),
95    /// Multi-band parametric equalizer (low-shelf, high-shelf, or peak bands).
96    ///
97    /// Each band maps to its own `FFmpeg` filter node chained in sequence.
98    /// The `bands` vec must not be empty.
99    ParametricEq { bands: Vec<EqBand> },
100    /// Apply a 3D LUT from a `.cube` or `.3dl` file.
101    Lut3d { path: String },
102    /// Brightness/contrast/saturation adjustment via `FFmpeg` `eq` filter.
103    Eq {
104        brightness: f32,
105        contrast: f32,
106        saturation: f32,
107    },
108    /// Brightness / contrast / saturation / gamma via `FFmpeg` `eq` filter (optionally animated).
109    ///
110    /// Arguments are evaluated at [`Duration::ZERO`] for the initial graph build.
111    /// Per-frame updates are applied via `avfilter_graph_send_command` in #363.
112    EqAnimated {
113        /// Brightness offset. Range: −1.0 – 1.0 (neutral: 0.0).
114        brightness: AnimatedValue<f64>,
115        /// Contrast multiplier. Range: 0.0 – 3.0 (neutral: 1.0).
116        contrast: AnimatedValue<f64>,
117        /// Saturation multiplier. Range: 0.0 – 3.0 (neutral: 1.0; 0.0 = grayscale).
118        saturation: AnimatedValue<f64>,
119        /// Global gamma correction. Range: 0.1 – 10.0 (neutral: 1.0).
120        gamma: AnimatedValue<f64>,
121    },
122    /// Three-way color balance (shadows / midtones / highlights) via `FFmpeg` `colorbalance` filter
123    /// (optionally animated).
124    ///
125    /// Each tuple is `(R, G, B)`. Valid range per component: −1.0 – 1.0 (neutral: 0.0).
126    ///
127    /// Arguments are evaluated at [`Duration::ZERO`] for the initial graph build.
128    /// Per-frame updates are applied via `avfilter_graph_send_command` in #363.
129    ColorBalanceAnimated {
130        /// Shadows (lift) correction per channel. `FFmpeg` params: `"rs"`, `"gs"`, `"bs"`.
131        lift: AnimatedValue<(f64, f64, f64)>,
132        /// Midtones (gamma) correction per channel. `FFmpeg` params: `"rm"`, `"gm"`, `"bm"`.
133        gamma: AnimatedValue<(f64, f64, f64)>,
134        /// Highlights (gain) correction per channel. `FFmpeg` params: `"rh"`, `"gh"`, `"bh"`.
135        gain: AnimatedValue<(f64, f64, f64)>,
136    },
137    /// Per-channel RGB color curves adjustment.
138    Curves {
139        master: Vec<(f32, f32)>,
140        r: Vec<(f32, f32)>,
141        g: Vec<(f32, f32)>,
142        b: Vec<(f32, f32)>,
143    },
144    /// White balance correction via `colorchannelmixer`.
145    WhiteBalance { temperature_k: u32, tint: f32 },
146    /// Hue rotation by an arbitrary angle.
147    Hue { degrees: f32 },
148    /// Per-channel gamma correction via `FFmpeg` `eq` filter.
149    Gamma { r: f32, g: f32, b: f32 },
150    /// Three-way colour corrector (lift / gamma / gain) via `FFmpeg` `curves` filter.
151    ThreeWayCC {
152        /// Affects shadows (blacks). Neutral: `Rgb::NEUTRAL`.
153        lift: Rgb,
154        /// Affects midtones. Neutral: `Rgb::NEUTRAL`. All components must be > 0.0.
155        gamma: Rgb,
156        /// Affects highlights (whites). Neutral: `Rgb::NEUTRAL`.
157        gain: Rgb,
158    },
159    /// Vignette effect via `FFmpeg` `vignette` filter.
160    Vignette {
161        /// Radius angle in radians (valid range: 0.0 – π/2 ≈ 1.5708). Default: π/5 ≈ 0.628.
162        angle: f32,
163        /// Horizontal centre of the vignette. `0.0` maps to `w/2`.
164        x0: f32,
165        /// Vertical centre of the vignette. `0.0` maps to `h/2`.
166        y0: f32,
167    },
168    /// Horizontal flip (mirror left-right).
169    HFlip,
170    /// Vertical flip (mirror top-bottom).
171    VFlip,
172    /// Reverse video playback (buffers entire clip in memory — use only on short clips).
173    Reverse,
174    /// Reverse audio playback (buffers entire clip in memory — use only on short clips).
175    AReverse,
176    /// Pad to a target resolution with a fill color (letterbox / pillarbox).
177    Pad {
178        /// Target canvas width in pixels.
179        width: u32,
180        /// Target canvas height in pixels.
181        height: u32,
182        /// Horizontal offset of the source frame within the canvas.
183        /// Negative values are replaced with `(ow-iw)/2` (centred).
184        x: i32,
185        /// Vertical offset of the source frame within the canvas.
186        /// Negative values are replaced with `(oh-ih)/2` (centred).
187        y: i32,
188        /// Fill color (any `FFmpeg` color string, e.g. `"black"`, `"0x000000"`).
189        color: String,
190    },
191    /// Scale (preserving aspect ratio) then centre-pad to fill target dimensions
192    /// (letterbox or pillarbox as required).
193    ///
194    /// Implemented as a `scale` filter with `force_original_aspect_ratio=decrease`
195    /// followed by a `pad` filter that centres the scaled frame on the canvas.
196    FitToAspect {
197        /// Target canvas width in pixels.
198        width: u32,
199        /// Target canvas height in pixels.
200        height: u32,
201        /// Fill color for the bars (any `FFmpeg` color string, e.g. `"black"`).
202        color: String,
203    },
204    /// Gaussian blur with configurable radius.
205    ///
206    /// `sigma` is the blur radius. Valid range: 0.0 – 10.0 (values near 0.0 are
207    /// nearly a no-op; higher values produce a stronger blur).
208    GBlur {
209        /// Blur radius (standard deviation). Must be ≥ 0.0.
210        sigma: f32,
211    },
212    /// Crop with optionally animated boundaries (pixels, `f64` for sub-pixel precision).
213    ///
214    /// Arguments are evaluated at [`Duration::ZERO`] for the initial graph build.
215    /// Per-frame updates are applied via `avfilter_graph_send_command` in #363.
216    CropAnimated {
217        /// X offset of the top-left corner, in pixels.
218        x: AnimatedValue<f64>,
219        /// Y offset of the top-left corner, in pixels.
220        y: AnimatedValue<f64>,
221        /// Width of the cropped region. Must evaluate to > 0 at `Duration::ZERO`.
222        width: AnimatedValue<f64>,
223        /// Height of the cropped region. Must evaluate to > 0 at `Duration::ZERO`.
224        height: AnimatedValue<f64>,
225    },
226    /// Gaussian blur with an optionally animated sigma (blur radius).
227    ///
228    /// Arguments are evaluated at [`Duration::ZERO`] for the initial graph build.
229    /// Per-frame updates are applied via `avfilter_graph_send_command` in #363.
230    GBlurAnimated {
231        /// Blur radius (standard deviation). Must evaluate to ≥ 0.0 at `Duration::ZERO`.
232        sigma: AnimatedValue<f64>,
233    },
234    /// Sharpen or blur via unsharp mask (luma + chroma strength).
235    ///
236    /// Positive values sharpen; negative values blur. Valid range for each
237    /// component: −1.5 – 1.5.
238    Unsharp {
239        /// Luma (brightness) sharpening/blurring amount. Range: −1.5 – 1.5.
240        luma_strength: f32,
241        /// Chroma (colour) sharpening/blurring amount. Range: −1.5 – 1.5.
242        chroma_strength: f32,
243    },
244    /// High Quality 3D noise reduction (`hqdn3d`).
245    ///
246    /// Typical values: `luma_spatial=4.0`, `chroma_spatial=3.0`,
247    /// `luma_tmp=6.0`, `chroma_tmp=4.5`. All values must be ≥ 0.0.
248    Hqdn3d {
249        /// Spatial luma noise reduction strength. Must be ≥ 0.0.
250        luma_spatial: f32,
251        /// Spatial chroma noise reduction strength. Must be ≥ 0.0.
252        chroma_spatial: f32,
253        /// Temporal luma noise reduction strength. Must be ≥ 0.0.
254        luma_tmp: f32,
255        /// Temporal chroma noise reduction strength. Must be ≥ 0.0.
256        chroma_tmp: f32,
257    },
258    /// Non-local means noise reduction (`nlmeans`).
259    ///
260    /// `strength` controls the denoising intensity; range 1.0–30.0.
261    /// Higher values remove more noise but are significantly more CPU-intensive.
262    ///
263    /// NOTE: nlmeans is CPU-intensive; avoid for real-time pipelines.
264    Nlmeans {
265        /// Denoising strength. Must be in the range [1.0, 30.0].
266        strength: f32,
267    },
268    /// Deinterlace using the `yadif` filter.
269    Yadif {
270        /// Deinterlacing mode controlling output frame rate and spatial checks.
271        mode: YadifMode,
272    },
273    /// Cross-dissolve transition between two video streams (`xfade`).
274    ///
275    /// Requires two input slots: slot 0 is clip A, slot 1 is clip B.
276    /// `duration` is the overlap length in seconds; `offset` is the PTS
277    /// offset (in seconds) at which clip B begins.
278    XFade {
279        /// Transition style.
280        transition: XfadeTransition,
281        /// Overlap duration in seconds. Must be > 0.0.
282        duration: f64,
283        /// PTS offset (seconds) where clip B starts.
284        offset: f64,
285    },
286    /// Draw text onto the video using the `drawtext` filter.
287    DrawText {
288        /// Full set of drawtext parameters.
289        opts: DrawTextOptions,
290    },
291    /// Burn-in SRT subtitles (hard subtitles) using the `subtitles` filter.
292    SubtitlesSrt {
293        /// Absolute or relative path to the `.srt` file.
294        path: String,
295    },
296    /// Burn-in ASS/SSA styled subtitles using the `ass` filter.
297    SubtitlesAss {
298        /// Absolute or relative path to the `.ass` or `.ssa` file.
299        path: String,
300    },
301    /// Playback speed change using `setpts` (video) and chained `atempo` (audio).
302    ///
303    /// `factor > 1.0` = fast motion; `factor < 1.0` = slow motion.
304    /// Valid range: 0.1–100.0.
305    ///
306    /// Video path: `setpts=PTS/{factor}`.
307    /// Audio path: the `atempo` filter only accepts [0.5, 2.0] per instance;
308    /// `filter_inner` chains multiple instances to cover the full range.
309    Speed {
310        /// Speed multiplier. Must be in [0.1, 100.0].
311        factor: f64,
312    },
313    /// EBU R128 two-pass loudness normalization.
314    ///
315    /// Pass 1 measures integrated loudness with `ebur128=peak=true:metadata=1`.
316    /// Pass 2 applies a linear volume correction so the output reaches `target_lufs`.
317    /// All audio frames are buffered in memory between the two passes — use only
318    /// for clips that fit comfortably in RAM.
319    LoudnessNormalize {
320        /// Target integrated loudness in LUFS (e.g. −23.0). Must be < 0.0.
321        target_lufs: f32,
322        /// True-peak ceiling in dBTP (e.g. −1.0). Must be ≤ 0.0.
323        true_peak_db: f32,
324        /// Target loudness range in LU (e.g. 7.0). Must be > 0.0.
325        lra: f32,
326    },
327    /// Peak-level two-pass normalization using `astats`.
328    ///
329    /// Pass 1 measures the true peak with `astats=metadata=1`.
330    /// Pass 2 applies `volume={gain}dB` so the output peak reaches `target_db`.
331    /// All audio frames are buffered in memory between passes — use only
332    /// for clips that fit comfortably in RAM.
333    NormalizePeak {
334        /// Target peak level in dBFS (e.g. −1.0). Must be ≤ 0.0.
335        target_db: f32,
336    },
337    /// Noise gate via `FFmpeg`'s `agate` filter.
338    ///
339    /// Audio below `threshold_db` is attenuated; audio above passes through.
340    /// The threshold is converted from dBFS to the linear scale expected by
341    /// `agate`'s `threshold` parameter (`linear = 10^(dB/20)`).
342    ANoiseGate {
343        /// Gate open/close threshold in dBFS (e.g. −40.0).
344        threshold_db: f32,
345        /// Attack time in milliseconds — how quickly the gate opens. Must be > 0.0.
346        attack_ms: f32,
347        /// Release time in milliseconds — how quickly the gate closes. Must be > 0.0.
348        release_ms: f32,
349    },
350    /// Dynamic range compressor via `FFmpeg`'s `acompressor` filter.
351    ///
352    /// Reduces the dynamic range of the audio signal: peaks above
353    /// `threshold_db` are attenuated by `ratio`:1.  `makeup_db` applies
354    /// additional gain after compression to restore perceived loudness.
355    ACompressor {
356        /// Compression threshold in dBFS (e.g. −20.0).
357        threshold_db: f32,
358        /// Compression ratio (e.g. 4.0 = 4:1). Must be ≥ 1.0.
359        ratio: f32,
360        /// Attack time in milliseconds. Must be > 0.0.
361        attack_ms: f32,
362        /// Release time in milliseconds. Must be > 0.0.
363        release_ms: f32,
364        /// Make-up gain in dB applied after compression (e.g. 6.0).
365        makeup_db: f32,
366    },
367    /// Downmix stereo to mono via `FFmpeg`'s `pan` filter.
368    ///
369    /// Both channels are mixed with equal weight:
370    /// `mono|c0=0.5*c0+0.5*c1`.  The output has a single channel.
371    StereoToMono,
372    /// Remap audio channels using `FFmpeg`'s `channelmap` filter.
373    ///
374    /// `mapping` is a `|`-separated list of output channel names taken
375    /// from input channels, e.g. `"FR|FL"` swaps left and right.
376    /// Must not be empty.
377    ChannelMap {
378        /// `FFmpeg` channelmap mapping expression (e.g. `"FR|FL"`).
379        mapping: String,
380    },
381    /// A/V sync correction via audio delay or advance.
382    ///
383    /// Positive `ms`: uses `FFmpeg`'s `adelay` filter to shift audio later.
384    /// Negative `ms`: uses `FFmpeg`'s `atrim` filter to trim the audio start,
385    /// effectively advancing audio by `|ms|` milliseconds.
386    /// Zero `ms`: uses `adelay` with zero delay (no-op).
387    AudioDelay {
388        /// Delay in milliseconds. Positive = delay; negative = advance.
389        ms: f64,
390    },
391    /// Concatenate `n` sequential video input segments via `FFmpeg`'s `concat` filter.
392    ///
393    /// Requires `n` video input slots (0 through `n-1`). `n` must be ≥ 2.
394    ConcatVideo {
395        /// Number of video input segments to concatenate. Must be ≥ 2.
396        n: u32,
397    },
398    /// Concatenate `n` sequential audio input segments via `FFmpeg`'s `concat` filter.
399    ///
400    /// Requires `n` audio input slots (0 through `n-1`). `n` must be ≥ 2.
401    ConcatAudio {
402        /// Number of audio input segments to concatenate. Must be ≥ 2.
403        n: u32,
404    },
405    /// Freeze a single frame for a configurable duration using `FFmpeg`'s `loop` filter.
406    ///
407    /// The frame nearest to `pts` seconds is held for `duration` seconds, then
408    /// playback resumes. Frame numbers are approximated using a 25 fps assumption;
409    /// accuracy depends on the source stream's actual frame rate.
410    FreezeFrame {
411        /// Timestamp of the frame to freeze, in seconds. Must be >= 0.0.
412        pts: f64,
413        /// Duration to hold the frozen frame, in seconds. Must be > 0.0.
414        duration: f64,
415    },
416    /// Scrolling text ticker (right-to-left) using the `drawtext` filter.
417    ///
418    /// The text starts off-screen to the right and scrolls left at
419    /// `speed_px_per_sec` pixels per second using the expression
420    /// `x = w - t * speed`.
421    Ticker {
422        /// Text to display. Special characters (`\`, `:`, `'`) are escaped.
423        text: String,
424        /// Y position as an `FFmpeg` expression, e.g. `"h-50"` or `"10"`.
425        y: String,
426        /// Horizontal scroll speed in pixels per second (must be > 0.0).
427        speed_px_per_sec: f32,
428        /// Font size in points.
429        font_size: u32,
430        /// Font color as an `FFmpeg` color string, e.g. `"white"` or `"0xFFFFFF"`.
431        font_color: String,
432    },
433    /// Join two video clips with a cross-dissolve transition.
434    ///
435    /// Compound step — expands in `filter_inner` to:
436    /// ```text
437    /// in0 → trim(end=clip_a_end+dissolve_dur) → setpts → xfade[0]
438    /// in1 → trim(start=max(0, clip_b_start−dissolve_dur)) → setpts → xfade[1]
439    /// ```
440    ///
441    /// Requires two video input slots: slot 0 = clip A, slot 1 = clip B.
442    /// `clip_a_end` and `dissolve_dur` must be > 0.0.
443    JoinWithDissolve {
444        /// Timestamp (seconds) where clip A ends. Must be > 0.0.
445        clip_a_end: f64,
446        /// Timestamp (seconds) where clip B content starts (before the overlap).
447        clip_b_start: f64,
448        /// Cross-dissolve overlap duration in seconds. Must be > 0.0.
449        dissolve_dur: f64,
450    },
451    /// Composite a PNG image (watermark / logo) over video with optional opacity.
452    ///
453    /// This is a compound step: internally it creates a `movie` source,
454    /// a `lut` alpha-scaling filter, and an `overlay` compositing filter.
455    /// The image file is loaded once at graph construction time.
456    OverlayImage {
457        /// Absolute or relative path to the `.png` file.
458        path: String,
459        /// Horizontal position as an `FFmpeg` expression, e.g. `"10"` or `"W-w-10"`.
460        x: String,
461        /// Vertical position as an `FFmpeg` expression, e.g. `"10"` or `"H-h-10"`.
462        y: String,
463        /// Opacity 0.0 (fully transparent) to 1.0 (fully opaque).
464        opacity: f32,
465    },
466
467    /// Blend a `top` layer over the current stream (bottom) using the given mode.
468    ///
469    /// This is a compound step:
470    /// - **Normal** mode: `[top]colorchannelmixer=aa=<opacity>[top_faded];
471    ///   [bottom][top_faded]overlay=format=auto:shortest=1[out]`
472    ///   (the `colorchannelmixer` step is omitted when `opacity == 1.0`).
473    /// - All other modes return [`crate::FilterError::InvalidConfig`] from
474    ///   [`crate::FilterGraphBuilder::build`] until implemented.
475    ///
476    /// The `top` builder's steps are applied to the second input slot (`in1`).
477    /// `opacity` is clamped to `[0.0, 1.0]` by the builder method.
478    ///
479    /// `Box<FilterGraphBuilder>` is used to break the otherwise-recursive type:
480    /// `FilterStep` → `FilterGraphBuilder` → `Vec<FilterStep>`.
481    Blend {
482        /// Filter pipeline for the top (foreground) layer.
483        top: Box<FilterGraphBuilder>,
484        /// How the two layers are combined.
485        mode: BlendMode,
486        /// Opacity of the top layer in `[0.0, 1.0]`; 1.0 = fully opaque.
487        opacity: f32,
488        /// How the top layer's alpha is interpreted by the `overlay` filter
489        /// (`alpha=`). [`AlphaMode::Straight`] is the `FFmpeg` default.
490        alpha: AlphaMode,
491    },
492
493    /// Composite a `top` layer over the current stream (bottom) using a
494    /// Porter-Duff alpha-compositing [`CompositeOp`].
495    ///
496    /// This is a compound, two-input step (slot 0 = bottom, slot 1 = top with
497    /// the `top` builder's steps applied). `Over`/`Under` are built with the
498    /// `overlay` filter; the rest use `blend` with a per-channel expression.
499    ///
500    /// `Box<FilterGraphBuilder>` breaks the otherwise-recursive type, following
501    /// the same pattern as [`FilterStep::Blend`].
502    Composite {
503        /// The Porter-Duff operator combining the two layers.
504        op: CompositeOp,
505        /// Filter pipeline for the top (foreground) layer.
506        top: Box<FilterGraphBuilder>,
507        /// Opacity of the top layer in `[0.0, 1.0]`; 1.0 = fully opaque.
508        /// Only affects `Over`/`Under` (the expression operators ignore it).
509        opacity: f32,
510        /// How the top layer's alpha is interpreted by the `overlay` filter
511        /// (`alpha=`). [`AlphaMode::Straight`] is the `FFmpeg` default.
512        alpha: AlphaMode,
513    },
514
515    /// Remove pixels matching `color` using `FFmpeg`'s `chromakey` filter,
516    /// producing a `yuva420p` output with transparent areas where the key
517    /// color was detected.
518    ///
519    /// Use this for YCbCr-encoded sources (most video).  For RGB sources
520    /// use `colorkey` instead.
521    ChromaKey {
522        /// `FFmpeg` color string, e.g. `"green"`, `"0x00FF00"`, `"#00FF00"`.
523        color: String,
524        /// Match radius in `[0.0, 1.0]`; higher = more pixels removed.
525        similarity: f32,
526        /// Edge softness in `[0.0, 1.0]`; `0.0` = hard edge.
527        blend: f32,
528    },
529
530    /// Remove pixels matching `color` in RGB space using `FFmpeg`'s `colorkey`
531    /// filter, producing an `rgba` output with transparent areas where the key
532    /// color was detected.
533    ///
534    /// Use this for RGB-encoded sources.  For YCbCr-encoded video (most video)
535    /// use `chromakey` instead.
536    ColorKey {
537        /// `FFmpeg` color string, e.g. `"green"`, `"0x00FF00"`, `"#00FF00"`.
538        color: String,
539        /// Match radius in `[0.0, 1.0]`; higher = more pixels removed.
540        similarity: f32,
541        /// Edge softness in `[0.0, 1.0]`; `0.0` = hard edge.
542        blend: f32,
543    },
544
545    /// Reduce color spill from the key color on subject edges using `FFmpeg`'s
546    /// `hue` filter to desaturate the spill hue region.
547    ///
548    /// Applies `hue=h=0:s=(1.0 - strength)`.  `strength=0.0` leaves the image
549    /// unchanged; `strength=1.0` fully desaturates.
550    ///
551    /// `key_color` is stored for future use by a more targeted per-hue
552    /// implementation.
553    SpillSuppress {
554        /// `FFmpeg` color string identifying the spill color, e.g. `"green"`.
555        key_color: String,
556        /// Suppression intensity in `[0.0, 1.0]`; `0.0` = no effect, `1.0` = full suppression.
557        strength: f32,
558    },
559
560    /// Merge a grayscale `matte` as the alpha channel of the input video using
561    /// `FFmpeg`'s `alphamerge` filter.
562    ///
563    /// White (luma=255) in the matte produces fully opaque output; black (luma=0)
564    /// produces fully transparent output.
565    ///
566    /// This is a compound step: the `matte` builder's pipeline is applied to the
567    /// second input slot (`in1`) before the `alphamerge` filter is linked.
568    ///
569    /// `Box<FilterGraphBuilder>` breaks the otherwise-recursive type, following
570    /// the same pattern as [`FilterStep::Blend`].
571    AlphaMatte {
572        /// Pipeline for the grayscale matte stream (slot 1).
573        matte: Box<FilterGraphBuilder>,
574    },
575
576    /// Key out pixels by luminance value using `FFmpeg`'s `lumakey` filter.
577    ///
578    /// Pixels whose normalized luma is within `tolerance` of `threshold` are
579    /// made transparent.  When `invert` is `true`, a `geq` filter is appended
580    /// to negate the alpha channel, effectively swapping transparent and opaque
581    /// regions.
582    ///
583    /// - `threshold`: luma cutoff in `[0.0, 1.0]`; `0.0` = black, `1.0` = white.
584    /// - `tolerance`: match radius around the threshold in `[0.0, 1.0]`.
585    /// - `softness`: edge feather width in `[0.0, 1.0]`; `0.0` = hard edge.
586    /// - `invert`: when `false`, keys out bright regions (pixels matching the
587    ///   threshold); when `true`, the alpha is negated after keying, making
588    ///   the complementary region transparent instead.
589    ///
590    /// Output carries an alpha channel (`yuva420p`).
591    LumaKey {
592        /// Luma cutoff in `[0.0, 1.0]`.
593        threshold: f32,
594        /// Match radius around the threshold in `[0.0, 1.0]`.
595        tolerance: f32,
596        /// Edge feather width in `[0.0, 1.0]`; `0.0` = hard edge.
597        softness: f32,
598        /// When `true`, the alpha channel is negated after keying.
599        invert: bool,
600    },
601
602    /// Apply a rectangular alpha mask using `FFmpeg`'s `geq` filter.
603    ///
604    /// Pixels inside the rectangle defined by (`x`, `y`, `width`, `height`)
605    /// are made fully opaque (`alpha=255`); pixels outside are made fully
606    /// transparent (`alpha=0`).  When `invert` is `true` the roles are swapped:
607    /// inside becomes transparent and outside becomes opaque.
608    ///
609    /// - `x`, `y`: top-left corner of the rectangle (in pixels).
610    /// - `width`, `height`: rectangle dimensions (must be > 0).
611    /// - `invert`: when `false`, keeps the interior; when `true`, keeps the
612    ///   exterior.
613    ///
614    /// `width` and `height` are validated in [`build`](FilterGraphBuilder::build);
615    /// zero values return [`crate::FilterError::InvalidConfig`].
616    ///
617    /// The output carries an alpha channel (`rgba`).
618    RectMask {
619        /// Left edge of the rectangle (pixels from the left).
620        x: u32,
621        /// Top edge of the rectangle (pixels from the top).
622        y: u32,
623        /// Width of the rectangle in pixels (must be > 0).
624        width: u32,
625        /// Height of the rectangle in pixels (must be > 0).
626        height: u32,
627        /// When `true`, the mask is inverted: outside is opaque, inside is transparent.
628        invert: bool,
629    },
630
631    /// Feather (soften) the alpha channel edges using a Gaussian blur.
632    ///
633    /// Splits the stream into a color copy and an alpha copy, blurs the alpha
634    /// plane with `gblur=sigma=<radius>`, then re-merges:
635    ///
636    /// ```text
637    /// [in]split=2[color][with_alpha];
638    /// [with_alpha]alphaextract[alpha_only];
639    /// [alpha_only]gblur=sigma=<radius>[alpha_blurred];
640    /// [color][alpha_blurred]alphamerge[out]
641    /// ```
642    ///
643    /// `radius` is the blur kernel half-size in pixels and must be > 0.
644    /// Validated in [`build`](FilterGraphBuilder::build); `radius == 0` returns
645    /// [`crate::FilterError::InvalidConfig`].
646    ///
647    /// Typically chained after a keying or masking step
648    /// (e.g. [`FilterStep::ChromaKey`], [`FilterStep::RectMask`],
649    /// [`FilterStep::PolygonMatte`]).  Applying this step to a fully-opaque
650    /// video (no prior alpha) is a no-op because a uniform alpha of 255 blurs
651    /// to 255 everywhere.
652    FeatherMask {
653        /// Gaussian blur kernel half-size in pixels (must be > 0).
654        radius: u32,
655    },
656
657    /// Simulate motion blur by blending consecutive frames via `FFmpeg`'s `tblend` filter.
658    ///
659    /// `shutter_angle_degrees` controls the blend ratio; 360° equals a full
660    /// frame-period exposure (maximum blur). `sub_frames` is the number of
661    /// frames blended and must be in [2, 16]; it is validated by
662    /// [`FilterGraph::motion_blur`](crate::FilterGraph::motion_blur).
663    MotionBlur {
664        /// Shutter angle in degrees (0° = no blur, 360° = full-period blur).
665        shutter_angle_degrees: f32,
666        /// Number of frames blended. Must be in [2, 16].
667        sub_frames: u8,
668    },
669
670    /// Correct radial lens distortion using two polynomial coefficients via
671    /// `FFmpeg`'s `lenscorrection` filter.
672    ///
673    /// Negative values correct barrel distortion; positive values correct
674    /// pincushion distortion. Both `k1` and `k2` must be in [−1.0, 1.0];
675    /// validated by [`FilterGraph::lens_correction`](crate::FilterGraph::lens_correction).
676    LensCorrection {
677        /// First-order radial distortion coefficient. Range: [−1.0, 1.0].
678        k1: f32,
679        /// Second-order radial distortion coefficient. Range: [−1.0, 1.0].
680        k2: f32,
681    },
682
683    /// Add synthetic per-frame random film grain to luma and chroma channels
684    /// via `FFmpeg`'s `noise` filter.
685    ///
686    /// `luma_strength` and `chroma_strength` are clamped to [0.0, 100.0].
687    /// The `allf=t` flag varies the seed each frame to simulate real film grain.
688    FilmGrain {
689        /// Grain strength applied to the luma (Y) plane. Clamped to [0.0, 100.0].
690        luma_strength: f32,
691        /// Grain strength applied to the Cb and Cr planes. Clamped to [0.0, 100.0].
692        chroma_strength: f32,
693    },
694
695    /// Uniform scale by a fractional multiplier via `FFmpeg`'s `scale` filter.
696    ///
697    /// Both width and height are multiplied by `factor`. Used to hide warped
698    /// border pixels left after lens distortion correction.
699    ScaleMultiplier {
700        /// Scale factor applied to both dimensions (e.g. `1.05` = 5 % zoom-in).
701        factor: f32,
702    },
703
704    /// Reduce lateral chromatic aberration by independently shifting the R and B
705    /// channels via `FFmpeg`'s `rgbashift` filter.
706    ///
707    /// `rh` and `bh` are the horizontal pixel shifts for the red and blue
708    /// channels respectively. Derived from scale deviations by
709    /// [`FilterGraph::fix_chromatic_aberration`](crate::FilterGraph::fix_chromatic_aberration).
710    ChromaticAberration {
711        /// Horizontal shift for the red channel in pixels (positive = right).
712        rh: i32,
713        /// Horizontal shift for the blue channel in pixels (positive = right).
714        bh: i32,
715    },
716
717    /// Glow / bloom effect: blends blurred highlights back over the image via
718    /// `split`, `curves`, `gblur`, and `blend` filters.
719    ///
720    /// This is a compound step — see
721    /// [`FilterGraph::glow`](crate::FilterGraph::glow) for parameter semantics.
722    Glow {
723        /// Luminance threshold that triggers the glow (clamped to [0.0, 1.0]).
724        threshold: f32,
725        /// Gaussian blur radius in pixels (clamped to [0.5, 50.0]).
726        radius: f32,
727        /// Additive blend strength (clamped to [0.0, 2.0]).
728        intensity: f32,
729    },
730
731    /// Convolution reverb using an impulse response (IR) audio file.
732    ///
733    /// The IR is loaded via `FFmpeg`'s `amovie` filter, optionally delayed by
734    /// `pre_delay_ms` via `adelay`, then convolved with the main audio stream
735    /// via `FFmpeg`'s `afir` filter.
736    ///
737    /// This is a compound step — see
738    /// [`FilterGraph::reverb_ir`](crate::FilterGraph::reverb_ir) for parameter
739    /// semantics.
740    ReverbIr {
741        /// Absolute or relative path to the `.wav` or `.flac` IR file.
742        ir_path: String,
743        /// Wet (reverb) mix level in [0.0, 1.0].
744        wet: f32,
745        /// Dry (original) mix level in [0.0, 1.0].
746        dry: f32,
747        /// Pre-delay before the reverb tail in milliseconds (clamped to 0–500).
748        pre_delay_ms: u32,
749    },
750
751    /// Algorithmic multi-tap echo/reverb via `FFmpeg`'s `aecho` filter.
752    ///
753    /// `in_gain` and `out_gain` are amplitude multipliers clamped to [0.0, 1.0].
754    /// `delays` contains delay times in milliseconds (one per tap); `decays`
755    /// contains the corresponding decay factors in [0.0, 1.0].  Both vecs must
756    /// have equal length in the range 1–8; validated by
757    /// [`FilterGraph::reverb_echo`](crate::FilterGraph::reverb_echo).
758    ReverbEcho {
759        /// Input gain (amplitude multiplier). Clamped to [0.0, 1.0].
760        in_gain: f32,
761        /// Output gain (amplitude multiplier). Clamped to [0.0, 1.0].
762        out_gain: f32,
763        /// Delay times in milliseconds (one per tap).
764        delays: Vec<f32>,
765        /// Decay factors per tap. Clamped to [0.0, 1.0].
766        decays: Vec<f32>,
767    },
768
769    /// Pitch shift without tempo change.
770    ///
771    /// Shifts audio pitch by `semitones` semitones without altering playback
772    /// duration.  Implemented as `asetrate` (changes the declared sample rate
773    /// to shift pitch) followed by `atempo` (restores the original duration).
774    ///
775    /// Range: [−12.0, 12.0]; validated by
776    /// [`FilterGraph::pitch_shift`](crate::FilterGraph::pitch_shift).
777    ///
778    /// This is a compound step — `filter_name()` returns `"asetrate"` for
779    /// `validate_filter_steps`; the actual graph construction is handled by
780    /// `filter_inner::build::build_audio_graph`.
781    PitchShift {
782        /// Pitch shift in semitones. Range: [−12.0, 12.0].
783        semitones: f32,
784    },
785
786    /// Time-stretch audio without changing pitch via `FFmpeg`'s `atempo` filter.
787    ///
788    /// `factor < 1.0` = slower (longer duration); `factor > 1.0` = faster
789    /// (shorter duration).  Range: [0.1, 10.0].  Values outside [0.5, 2.0]
790    /// are realised by chaining multiple `atempo` instances (each in [0.5, 2.0]).
791    ///
792    /// Validated by [`FilterGraph::time_stretch`](crate::FilterGraph::time_stretch).
793    TimeStretch {
794        /// Speed / duration factor. 0.5 = 2× longer; 2.0 = 2× shorter. Range: [0.1, 10.0].
795        factor: f32,
796    },
797
798    /// Simultaneously change audio speed and pitch by the same factor.
799    ///
800    /// Equivalent to playing a tape at a different speed: `factor > 1.0` makes
801    /// audio faster and higher; `factor < 1.0` makes it slower and lower.
802    ///
803    /// Uses `FFmpeg`'s `asetrate` to multiply the declared sample rate by
804    /// `factor` without resampling.  Range: [0.1, 10.0]; validated by
805    /// [`FilterGraph::speed_change`](crate::FilterGraph::speed_change).
806    SpeedChange {
807        /// Speed/pitch multiplier. Range: [0.1, 10.0].
808        factor: f64,
809    },
810
811    /// Spectral noise reduction using a statistical noise-type model.
812    ///
813    /// Uses `FFmpeg`'s `afftdn` filter.  `noise_type_flag` is the single-letter
814    /// `nt` parameter (`"w"` = white, `"p"` = pink, `"b"` = brown).
815    /// `nr_level` is the reduction amount in dB, clamped to [0.0, 97.0].
816    ///
817    /// Created by [`FilterGraph::noise_reduce`](crate::FilterGraph::noise_reduce).
818    NoiseReduce {
819        /// `afftdn` `nt` flag: `"w"`, `"p"`, or `"b"`.
820        noise_type_flag: String,
821        /// Noise reduction amount in dB. Clamped to [0.0, 97.0].
822        nr_level: f32,
823    },
824
825    /// Spectral noise reduction using a captured noise profile.
826    ///
827    /// Uses `FFmpeg`'s `afftdn` with the `pl` (profile length) option: the
828    /// filter learns the noise profile from the first `profile_duration_secs`
829    /// seconds, then subtracts it from the rest of the stream.
830    /// `nr_level` is the reduction amount in dB, clamped to [0.0, 97.0].
831    ///
832    /// Created by
833    /// [`FilterGraph::noise_reduce_profile`](crate::FilterGraph::noise_reduce_profile).
834    NoiseReduceProfile {
835        /// Duration in seconds from which to capture the noise profile. Minimum 0.1.
836        profile_duration_secs: f32,
837        /// Noise reduction amount in dB. Clamped to [0.0, 97.0].
838        nr_level: f32,
839    },
840
841    /// Sidechain compression for audio ducking via `FFmpeg`'s `sidechaincompress` filter.
842    ///
843    /// Reduces the background audio level when the foreground (sidechain) signal
844    /// exceeds the threshold.  Push background audio to slot 0 and foreground
845    /// audio to slot 1.
846    ///
847    /// `threshold_linear` is the trigger level as a linear amplitude (pre-converted
848    /// from dBFS by [`FilterGraph::duck`](crate::FilterGraph::duck)).
849    /// `ratio`, `attack_ms`, and `release_ms` are validated by
850    /// [`FilterGraph::duck`](crate::FilterGraph::duck).
851    Duck {
852        /// Compression threshold as a linear amplitude ratio in (0.0, 1.0].
853        threshold_linear: f32,
854        /// Compression ratio (e.g. 20.0 for near hard-limiting). Must be >= 1.0.
855        ratio: f32,
856        /// Attack time in milliseconds. Must be >= 0.0.
857        attack_ms: f32,
858        /// Release time in milliseconds. Must be >= 0.0.
859        release_ms: f32,
860    },
861
862    /// Apply a polygon alpha mask using `FFmpeg`'s `geq` filter with a
863    /// crossing-number point-in-polygon test.
864    ///
865    /// Pixels inside the polygon are fully opaque (`alpha=255`); pixels outside
866    /// are fully transparent (`alpha=0`).  When `invert` is `true` the roles
867    /// are swapped.
868    ///
869    /// - `vertices`: polygon corners as `(x, y)` in `[0.0, 1.0]` (normalised
870    ///   to frame size).  Minimum 3, maximum 16.
871    /// - `invert`: when `false`, inside = opaque; when `true`, outside = opaque.
872    ///
873    /// Vertex count and coordinates are validated in
874    /// [`build`](FilterGraphBuilder::build); out-of-range values return
875    /// [`crate::FilterError::InvalidConfig`].
876    ///
877    /// The `geq` expression is constructed from the vertex list at graph
878    /// build time.  Degenerate polygons (zero area) produce a fully-transparent
879    /// mask.  The output carries an alpha channel (`rgba`).
880    PolygonMatte {
881        /// Polygon corners in normalised `[0.0, 1.0]` frame coordinates.
882        vertices: Vec<(f32, f32)>,
883        /// When `true`, the mask is inverted: outside is opaque, inside is transparent.
884        invert: bool,
885    },
886}
887
888/// Convert a color temperature in Kelvin to linear RGB multipliers using
889/// Tanner Helland's algorithm.
890///
891/// Returns `(r, g, b)` each in `[0.0, 1.0]`.
892fn kelvin_to_rgb(temp_k: u32) -> (f64, f64, f64) {
893    let t = (f64::from(temp_k) / 100.0).clamp(10.0, 400.0);
894    let r = if t <= 66.0 {
895        1.0
896    } else {
897        (329.698_727_446_4 * (t - 60.0).powf(-0.133_204_759_2) / 255.0).clamp(0.0, 1.0)
898    };
899    let g = if t <= 66.0 {
900        ((99.470_802_586_1 * t.ln() - 161.119_568_166_1) / 255.0).clamp(0.0, 1.0)
901    } else {
902        ((288.122_169_528_3 * (t - 60.0).powf(-0.075_514_849_2)) / 255.0).clamp(0.0, 1.0)
903    };
904    let b = if t >= 66.0 {
905        1.0
906    } else if t <= 19.0 {
907        0.0
908    } else {
909        ((138.517_731_223_1 * (t - 10.0).ln() - 305.044_792_730_7) / 255.0).clamp(0.0, 1.0)
910    };
911    (r, g, b)
912}
913
914impl FilterStep {
915    /// Returns the libavfilter filter name for this step.
916    pub(crate) fn filter_name(&self) -> &'static str {
917        match self {
918            Self::Format { .. } => "format",
919            Self::SetParams { .. } => "setparams",
920            Self::Trim { .. } => "trim",
921            Self::Scale { .. } => "scale",
922            Self::Crop { .. } => "crop",
923            Self::Overlay { .. } => "overlay",
924            Self::FadeIn { .. }
925            | Self::FadeOut { .. }
926            | Self::FadeInWhite { .. }
927            | Self::FadeOutWhite { .. } => "fade",
928            Self::AFadeIn { .. } | Self::AFadeOut { .. } => "afade",
929            Self::Rotate { .. } => "rotate",
930            Self::ToneMap(_) => "tonemap",
931            Self::Volume(_) => "volume",
932            Self::Amix(_) => "amix",
933            // ParametricEq is a compound step; "equalizer" is used only by
934            // validate_filter_steps as a best-effort existence check.  The
935            // actual nodes are built by `filter_inner::add_parametric_eq_chain`.
936            Self::ParametricEq { .. } => "equalizer",
937            Self::Lut3d { .. } => "lut3d",
938            Self::Eq { .. } => "eq",
939            Self::EqAnimated { .. } => "eq",
940            Self::ColorBalanceAnimated { .. } => "colorbalance",
941            Self::Curves { .. } => "curves",
942            Self::WhiteBalance { .. } => "colorchannelmixer",
943            Self::Hue { .. } => "hue",
944            Self::Gamma { .. } => "eq",
945            Self::ThreeWayCC { .. } => "curves",
946            Self::Vignette { .. } => "vignette",
947            Self::HFlip => "hflip",
948            Self::VFlip => "vflip",
949            Self::Reverse => "reverse",
950            Self::AReverse => "areverse",
951            Self::Pad { .. } => "pad",
952            // FitToAspect is implemented as scale + pad; "scale" is validated at
953            // build time.  The pad filter is inserted by filter_inner at graph
954            // construction time.
955            Self::FitToAspect { .. } => "scale",
956            Self::GBlur { .. } => "gblur",
957            Self::Unsharp { .. } => "unsharp",
958            Self::Hqdn3d { .. } => "hqdn3d",
959            Self::Nlmeans { .. } => "nlmeans",
960            Self::Yadif { .. } => "yadif",
961            Self::XFade { .. } => "xfade",
962            Self::DrawText { .. } | Self::Ticker { .. } => "drawtext",
963            // "setpts" is checked at build-time; the audio path uses "atempo"
964            // which is verified at graph-construction time in filter_inner.
965            Self::Speed { .. } => "setpts",
966            Self::FreezeFrame { .. } => "loop",
967            Self::LoudnessNormalize { .. } => "ebur128",
968            Self::NormalizePeak { .. } => "astats",
969            Self::ANoiseGate { .. } => "agate",
970            Self::ACompressor { .. } => "acompressor",
971            Self::StereoToMono => "pan",
972            Self::ChannelMap { .. } => "channelmap",
973            // AudioDelay dispatches to adelay (positive) or atrim (negative) at
974            // build time; "adelay" is returned here for validate_filter_steps only.
975            Self::AudioDelay { .. } => "adelay",
976            Self::ConcatVideo { .. } | Self::ConcatAudio { .. } => "concat",
977            // JoinWithDissolve is a compound step (trim+setpts → xfade ← setpts+trim);
978            // "xfade" is used by validate_filter_steps as the primary filter check.
979            Self::JoinWithDissolve { .. } => "xfade",
980            Self::SubtitlesSrt { .. } => "subtitles",
981            Self::SubtitlesAss { .. } => "ass",
982            // OverlayImage is a compound step (movie → lut → overlay); "overlay"
983            // is used only by validate_filter_steps as a best-effort existence
984            // check.  The actual graph construction is handled by
985            // `filter_inner::build::add_overlay_image_step`.
986            Self::OverlayImage { .. } => "overlay",
987            // Blend is a compound step; "overlay" is used as the primary filter
988            // for validate_filter_steps.  Unimplemented modes are caught by
989            // build() before validate_filter_steps is reached.
990            Self::Blend { .. } => "overlay",
991            // Composite shares the Blend construction: Over/Under use overlay,
992            // the expression operators use blend. validate_filter_steps only
993            // needs a real filter name to probe existence.
994            Self::Composite { op, .. } => match op {
995                CompositeOp::Over | CompositeOp::Under => "overlay",
996                CompositeOp::In | CompositeOp::Out | CompositeOp::Atop | CompositeOp::Xor => {
997                    "blend"
998                }
999            },
1000            Self::ChromaKey { .. } => "chromakey",
1001            Self::ColorKey { .. } => "colorkey",
1002            Self::SpillSuppress { .. } => "hue",
1003            // AlphaMatte is a compound step (matte pipeline → alphamerge);
1004            // "alphamerge" is used by validate_filter_steps as the primary check.
1005            Self::AlphaMatte { .. } => "alphamerge",
1006            // LumaKey is a compound step when invert=true (lumakey + geq);
1007            // "lumakey" is used here for validate_filter_steps.
1008            Self::LumaKey { .. } => "lumakey",
1009            // RectMask uses geq to set alpha per-pixel based on rectangle bounds.
1010            Self::RectMask { .. } => "geq",
1011            // FeatherMask is a compound step (split → alphaextract → gblur → alphamerge);
1012            // "alphaextract" is used by validate_filter_steps as the primary check.
1013            Self::FeatherMask { .. } => "alphaextract",
1014            // PolygonMatte uses geq with a crossing-number point-in-polygon expression.
1015            Self::PolygonMatte { .. } => "geq",
1016            Self::CropAnimated { .. } => "crop",
1017            Self::GBlurAnimated { .. } => "gblur",
1018            Self::MotionBlur { .. } => "tblend",
1019            Self::LensCorrection { .. } => "lenscorrection",
1020            Self::FilmGrain { .. } => "noise",
1021            Self::ScaleMultiplier { .. } => "scale",
1022            Self::ChromaticAberration { .. } => "rgbashift",
1023            // Glow is a compound step (split → curves → gblur → blend);
1024            // "split" is used by validate_filter_steps as the primary check.
1025            Self::Glow { .. } => "split",
1026            // ReverbIr is a compound step (amovie[+adelay] → afir);
1027            // "afir" is used by validate_filter_steps as the primary check.
1028            Self::ReverbIr { .. } => "afir",
1029            Self::ReverbEcho { .. } => "aecho",
1030            // PitchShift is a compound step (asetrate → atempo);
1031            // "asetrate" is used by validate_filter_steps as the primary check.
1032            Self::PitchShift { .. } => "asetrate",
1033            // TimeStretch uses one or more chained atempo filters.
1034            Self::TimeStretch { .. } => "atempo",
1035            // SpeedChange uses asetrate to shift speed and pitch together.
1036            Self::SpeedChange { .. } => "asetrate",
1037            Self::NoiseReduce { .. } | Self::NoiseReduceProfile { .. } => "afftdn",
1038            // Duck is a two-input compound step; "sidechaincompress" is checked at
1039            // build time by validate_filter_steps.
1040            Self::Duck { .. } => "sidechaincompress",
1041        }
1042    }
1043
1044    /// Returns the `args` string passed to `avfilter_graph_create_filter`.
1045    pub(crate) fn args(&self) -> String {
1046        match self {
1047            Self::Format {
1048                pix_fmts,
1049                color_spaces,
1050                color_ranges,
1051            } => {
1052                // Each option list uses the FFmpeg-canonical `FfmpegToken`, skipping values with no
1053                // FFmpeg equivalent (`None`); an option is emitted only when a token survives.
1054                fn render<T: FfmpegToken>(key: &str, values: &[T]) -> Option<String> {
1055                    let tokens: Vec<&str> = values
1056                        .iter()
1057                        .filter_map(FfmpegToken::ffmpeg_token)
1058                        .collect();
1059                    (!tokens.is_empty()).then(|| format!("{key}={}", tokens.join("|")))
1060                }
1061                [
1062                    render("pix_fmts", pix_fmts),
1063                    render("color_spaces", color_spaces),
1064                    render("color_ranges", color_ranges),
1065                ]
1066                .into_iter()
1067                .flatten()
1068                .collect::<Vec<_>>()
1069                .join(":")
1070            }
1071            Self::SetParams {
1072                color_space,
1073                color_range,
1074                color_primaries,
1075                color_trc,
1076            } => {
1077                // Each option is emitted from the FFmpeg-canonical `FfmpegToken`, only when the
1078                // value is `Some` and yields a token (`Unknown` → `None` → skipped). All-`None`
1079                // renders to the empty string.
1080                fn opt<T: FfmpegToken>(key: &str, v: Option<&T>) -> Option<String> {
1081                    v.and_then(FfmpegToken::ffmpeg_token)
1082                        .map(|tok| format!("{key}={tok}"))
1083                }
1084                [
1085                    opt("colorspace", color_space.as_ref()),
1086                    opt("range", color_range.as_ref()),
1087                    opt("color_primaries", color_primaries.as_ref()),
1088                    opt("color_trc", color_trc.as_ref()),
1089                ]
1090                .into_iter()
1091                .flatten()
1092                .collect::<Vec<_>>()
1093                .join(":")
1094            }
1095            Self::Trim { start, end } => format!("start={start}:end={end}"),
1096            Self::Scale {
1097                width,
1098                height,
1099                algorithm,
1100            } => format!("w={width}:h={height}:flags={}", algorithm.as_flags_str()),
1101            Self::Crop {
1102                x,
1103                y,
1104                width,
1105                height,
1106            } => {
1107                format!("x={x}:y={y}:w={width}:h={height}")
1108            }
1109            Self::Overlay { x, y } => format!("x={x}:y={y}"),
1110            Self::FadeIn { start, duration } => {
1111                format!("type=in:start_time={start}:duration={duration}")
1112            }
1113            Self::FadeOut { start, duration } => {
1114                format!("type=out:start_time={start}:duration={duration}")
1115            }
1116            Self::FadeInWhite { start, duration } => {
1117                format!("type=in:start_time={start}:duration={duration}:color=white")
1118            }
1119            Self::FadeOutWhite { start, duration } => {
1120                format!("type=out:start_time={start}:duration={duration}:color=white")
1121            }
1122            Self::AFadeIn { start, duration } => {
1123                format!("type=in:start_time={start}:duration={duration}")
1124            }
1125            Self::AFadeOut { start, duration } => {
1126                format!("type=out:start_time={start}:duration={duration}")
1127            }
1128            Self::Rotate {
1129                angle_degrees,
1130                fill_color,
1131            } => {
1132                format!(
1133                    "angle={}:fillcolor={fill_color}",
1134                    angle_degrees.to_radians()
1135                )
1136            }
1137            Self::ToneMap(algorithm) => format!("tonemap={}", algorithm.as_str()),
1138            Self::Volume(db) => format!("volume={db}dB"),
1139            Self::Amix(inputs) => format!("inputs={inputs}"),
1140            // args() for ParametricEq is not used by the build loop (which is
1141            // bypassed in favour of add_parametric_eq_chain); provided here for
1142            // completeness using the first band's args.
1143            Self::ParametricEq { bands } => bands.first().map(EqBand::args).unwrap_or_default(),
1144            Self::Lut3d { path } => {
1145                format!("file={}:interp=trilinear", escape_filter_path(path))
1146            }
1147            Self::Eq {
1148                brightness,
1149                contrast,
1150                saturation,
1151            } => format!("brightness={brightness}:contrast={contrast}:saturation={saturation}"),
1152            Self::EqAnimated {
1153                brightness,
1154                contrast,
1155                saturation,
1156                gamma,
1157            } => {
1158                let b = brightness.value_at(Duration::ZERO);
1159                let c = contrast.value_at(Duration::ZERO);
1160                let s = saturation.value_at(Duration::ZERO);
1161                let g = gamma.value_at(Duration::ZERO);
1162                format!("brightness={b}:contrast={c}:saturation={s}:gamma={g}")
1163            }
1164            Self::ColorBalanceAnimated { lift, gamma, gain } => {
1165                let (rl, gl, bl) = lift.value_at(Duration::ZERO);
1166                let (rm, gm, bm) = gamma.value_at(Duration::ZERO);
1167                let (rh, gh, bh) = gain.value_at(Duration::ZERO);
1168                format!("rs={rl}:gs={gl}:bs={bl}:rm={rm}:gm={gm}:bm={bm}:rh={rh}:gh={gh}:bh={bh}")
1169            }
1170            Self::Curves { master, r, g, b } => {
1171                let fmt = |pts: &[(f32, f32)]| -> String {
1172                    pts.iter()
1173                        .map(|(x, y)| format!("{x}/{y}"))
1174                        .collect::<Vec<_>>()
1175                        .join(" ")
1176                };
1177                [("master", master.as_slice()), ("r", r), ("g", g), ("b", b)]
1178                    .iter()
1179                    .filter(|(_, pts)| !pts.is_empty())
1180                    .map(|(name, pts)| format!("{name}='{}'", fmt(pts)))
1181                    .collect::<Vec<_>>()
1182                    .join(":")
1183            }
1184            Self::WhiteBalance {
1185                temperature_k,
1186                tint,
1187            } => {
1188                let (r, g, b) = kelvin_to_rgb(*temperature_k);
1189                let g_adj = (g + f64::from(*tint)).clamp(0.0, 2.0);
1190                format!("rr={r}:gg={g_adj}:bb={b}")
1191            }
1192            Self::Hue { degrees } => format!("h={degrees}"),
1193            Self::Gamma { r, g, b } => format!("gamma_r={r}:gamma_g={g}:gamma_b={b}"),
1194            Self::Vignette { angle, x0, y0 } => {
1195                let cx = if *x0 == 0.0 {
1196                    "w/2".to_string()
1197                } else {
1198                    x0.to_string()
1199                };
1200                let cy = if *y0 == 0.0 {
1201                    "h/2".to_string()
1202                } else {
1203                    y0.to_string()
1204                };
1205                format!("angle={angle}:x0={cx}:y0={cy}")
1206            }
1207            Self::ThreeWayCC { lift, gamma, gain } => {
1208                // Convert lift/gamma/gain to a 3-point per-channel curves representation.
1209                // The formula maps:
1210                //   input 0.0 → (lift - 1.0) * gain  (black point)
1211                //   input 0.5 → (0.5 * lift)^(1/gamma) * gain  (midtone)
1212                //   input 1.0 → gain  (white point)
1213                // All neutral (1.0) produces the identity curve 0/0 0.5/0.5 1/1.
1214                let curve = |l: f32, gm: f32, gn: f32| -> String {
1215                    let l = f64::from(l);
1216                    let gm = f64::from(gm);
1217                    let gn = f64::from(gn);
1218                    let black = ((l - 1.0) * gn).clamp(0.0, 1.0);
1219                    let mid = ((0.5 * l).powf(1.0 / gm) * gn).clamp(0.0, 1.0);
1220                    let white = gn.clamp(0.0, 1.0);
1221                    format!("0/{black} 0.5/{mid} 1/{white}")
1222                };
1223                format!(
1224                    "r='{}':g='{}':b='{}'",
1225                    curve(lift.r, gamma.r, gain.r),
1226                    curve(lift.g, gamma.g, gain.g),
1227                    curve(lift.b, gamma.b, gain.b),
1228                )
1229            }
1230            Self::HFlip | Self::VFlip | Self::Reverse | Self::AReverse => String::new(),
1231            Self::GBlur { sigma } => format!("sigma={sigma}"),
1232            Self::Unsharp {
1233                luma_strength,
1234                chroma_strength,
1235            } => format!(
1236                "luma_msize_x=5:luma_msize_y=5:luma_amount={luma_strength}:\
1237                 chroma_msize_x=5:chroma_msize_y=5:chroma_amount={chroma_strength}"
1238            ),
1239            Self::Hqdn3d {
1240                luma_spatial,
1241                chroma_spatial,
1242                luma_tmp,
1243                chroma_tmp,
1244            } => format!("{luma_spatial}:{chroma_spatial}:{luma_tmp}:{chroma_tmp}"),
1245            Self::Nlmeans { strength } => format!("s={strength}"),
1246            Self::Yadif { mode } => format!("mode={}", *mode as i32),
1247            Self::XFade {
1248                transition,
1249                duration,
1250                offset,
1251            } => {
1252                let t = transition.as_str();
1253                format!("transition={t}:duration={duration}:offset={offset}")
1254            }
1255            Self::DrawText { opts } => {
1256                // Escape special characters recognised by the drawtext filter.
1257                let escaped = opts
1258                    .text
1259                    .replace('\\', "\\\\")
1260                    .replace(':', "\\:")
1261                    .replace('\'', "\\'");
1262                let mut parts = vec![
1263                    format!("text='{escaped}'"),
1264                    format!("x={}", opts.x),
1265                    format!("y={}", opts.y),
1266                    format!("fontsize={}", opts.font_size),
1267                    format!("fontcolor={}@{:.2}", opts.font_color, opts.opacity),
1268                ];
1269                if let Some(ref ff) = opts.font_file {
1270                    parts.push(format!("fontfile={ff}"));
1271                }
1272                if let Some(ref bc) = opts.box_color {
1273                    parts.push("box=1".to_string());
1274                    parts.push(format!("boxcolor={bc}"));
1275                    parts.push(format!("boxborderw={}", opts.box_border_width));
1276                }
1277                parts.join(":")
1278            }
1279            Self::Ticker {
1280                text,
1281                y,
1282                speed_px_per_sec,
1283                font_size,
1284                font_color,
1285            } => {
1286                // Use the same escaping as DrawText.
1287                let escaped = text
1288                    .replace('\\', "\\\\")
1289                    .replace(':', "\\:")
1290                    .replace('\'', "\\'");
1291                // x = w - t * speed: at t=0 the text starts fully off the right
1292                // edge (x = w) and scrolls left by `speed` pixels per second.
1293                format!(
1294                    "text='{escaped}':x=w-t*{speed_px_per_sec}:y={y}:\
1295                     fontsize={font_size}:fontcolor={font_color}"
1296                )
1297            }
1298            // Video path: divide PTS by factor to change playback speed.
1299            // Audio path args are built by filter_inner (chained atempo).
1300            Self::Speed { factor } => format!("PTS/{factor}"),
1301            // args() is not used by the build loop for LoudnessNormalize (two-pass
1302            // is handled entirely in filter_inner); provided here for completeness.
1303            Self::LoudnessNormalize { .. } => "peak=true:metadata=1".to_string(),
1304            // args() is not used by the build loop for NormalizePeak (two-pass
1305            // is handled entirely in filter_inner); provided here for completeness.
1306            Self::NormalizePeak { .. } => "metadata=1".to_string(),
1307            Self::FreezeFrame { pts, duration } => {
1308                // The `loop` filter needs a frame index and a loop count, not PTS or
1309                // wall-clock duration.  We approximate both using 25 fps; accuracy
1310                // depends on the source stream's actual frame rate.
1311                #[allow(clippy::cast_possible_truncation)]
1312                let start = (*pts * 25.0) as i64;
1313                #[allow(clippy::cast_possible_truncation)]
1314                let loop_count = (*duration * 25.0) as i64;
1315                format!("loop={loop_count}:size=1:start={start}")
1316            }
1317            Self::SubtitlesSrt { path } | Self::SubtitlesAss { path } => {
1318                format!("filename={path}")
1319            }
1320            // args() for OverlayImage returns the overlay positional args (x:y).
1321            // These are not consumed by add_and_link_step (which is bypassed for
1322            // this compound step); they exist here only for completeness.
1323            Self::OverlayImage { x, y, .. } => format!("{x}:{y}"),
1324            // args() for Blend is not consumed by add_and_link_step (which is
1325            // bypassed in favour of add_blend_normal_step).  Provided for
1326            // completeness using the Normal-mode overlay args.
1327            Self::Blend { .. } => "format=auto:shortest=1".to_string(),
1328            // args() for Composite is not consumed by add_and_link_step (bypassed
1329            // for this compound two-input step); provided here only to satisfy the
1330            // exhaustive match.
1331            Self::Composite { .. } => String::new(),
1332            Self::ChromaKey {
1333                color,
1334                similarity,
1335                blend,
1336            } => format!("color={color}:similarity={similarity}:blend={blend}"),
1337            Self::ColorKey {
1338                color,
1339                similarity,
1340                blend,
1341            } => format!("color={color}:similarity={similarity}:blend={blend}"),
1342            Self::SpillSuppress { strength, .. } => format!("h=0:s={}", 1.0 - strength),
1343            // args() is not consumed by add_and_link_step (which is bypassed for
1344            // this compound step); provided here for completeness.
1345            Self::AlphaMatte { .. } => String::new(),
1346            Self::LumaKey {
1347                threshold,
1348                tolerance,
1349                softness,
1350                ..
1351            } => format!("threshold={threshold}:tolerance={tolerance}:softness={softness}"),
1352            // args() is not consumed by add_and_link_step (which is bypassed for
1353            // this compound step); provided here for completeness.
1354            Self::FeatherMask { .. } => String::new(),
1355            Self::RectMask {
1356                x,
1357                y,
1358                width,
1359                height,
1360                invert,
1361            } => {
1362                let xw = x + width - 1;
1363                let yh = y + height - 1;
1364                let (inside, outside) = if *invert { (0, 255) } else { (255, 0) };
1365                format!(
1366                    "r='r(X,Y)':g='g(X,Y)':b='b(X,Y)':\
1367                     a='if(between(X,{x},{xw})*between(Y,{y},{yh}),{inside},{outside})'"
1368                )
1369            }
1370            Self::PolygonMatte { vertices, invert } => {
1371                // Build a crossing-number point-in-polygon expression.
1372                // For each edge (ax,ay)→(bx,by), a horizontal ray from (X,Y) going
1373                // right crosses the edge when Y is in [min(ay,by), max(ay,by)) and
1374                // the intersection x > X.  Exact horizontal edges (dy==0) are skipped.
1375                let n = vertices.len();
1376                let mut edge_exprs = Vec::new();
1377                for i in 0..n {
1378                    let (ax, ay) = vertices[i];
1379                    let (bx, by) = vertices[(i + 1) % n];
1380                    let dy = by - ay;
1381                    if dy == 0.0 {
1382                        // Horizontal edge — never crosses a horizontal ray; skip.
1383                        continue;
1384                    }
1385                    let min_y = ay.min(by);
1386                    let max_y = ay.max(by);
1387                    let dx = bx - ax;
1388                    // x_intersect = ax*iw + (Y - ay*ih) * dx*iw / (dy*ih)
1389                    edge_exprs.push(format!(
1390                        "if(gte(Y,{min_y}*ih)*lt(Y,{max_y}*ih)*gt({ax}*iw+(Y-{ay}*ih)*{dx}*iw/({dy}*ih),X),1,0)"
1391                    ));
1392                }
1393                let sum = if edge_exprs.is_empty() {
1394                    "0".to_string()
1395                } else {
1396                    edge_exprs.join("+")
1397                };
1398                let (inside, outside) = if *invert { (0, 255) } else { (255, 0) };
1399                format!(
1400                    "r='r(X,Y)':g='g(X,Y)':b='b(X,Y)':\
1401                     a='if(gt(mod({sum},2),0),{inside},{outside})'"
1402                )
1403            }
1404            Self::FitToAspect { width, height, .. } => {
1405                // Scale to fit within the target dimensions, preserving the source
1406                // aspect ratio.  The accompanying pad filter (inserted by
1407                // filter_inner after this scale filter) centres the result on the
1408                // target canvas.
1409                format!("w={width}:h={height}:force_original_aspect_ratio=decrease")
1410            }
1411            Self::Pad {
1412                width,
1413                height,
1414                x,
1415                y,
1416                color,
1417            } => {
1418                let px = if *x < 0 {
1419                    "(ow-iw)/2".to_string()
1420                } else {
1421                    x.to_string()
1422                };
1423                let py = if *y < 0 {
1424                    "(oh-ih)/2".to_string()
1425                } else {
1426                    y.to_string()
1427                };
1428                format!("width={width}:height={height}:x={px}:y={py}:color={color}")
1429            }
1430            Self::ANoiseGate {
1431                threshold_db,
1432                attack_ms,
1433                release_ms,
1434            } => {
1435                // `agate` expects threshold as a linear amplitude ratio (0.0–1.0).
1436                let threshold_linear = 10f32.powf(threshold_db / 20.0);
1437                format!("threshold={threshold_linear:.6}:attack={attack_ms}:release={release_ms}")
1438            }
1439            Self::ACompressor {
1440                threshold_db,
1441                ratio,
1442                attack_ms,
1443                release_ms,
1444                makeup_db,
1445            } => {
1446                format!(
1447                    "threshold={threshold_db}dB:ratio={ratio}:attack={attack_ms}:\
1448                     release={release_ms}:makeup={makeup_db}dB"
1449                )
1450            }
1451            Self::StereoToMono => "mono|c0=0.5*c0+0.5*c1".to_string(),
1452            Self::ChannelMap { mapping } => format!("map={mapping}"),
1453            // args() is not used directly for AudioDelay — the audio build loop
1454            // dispatches to add_raw_filter_step with the correct filter name and
1455            // args based on the sign of ms.  These are provided for completeness.
1456            Self::AudioDelay { ms } => {
1457                if *ms >= 0.0 {
1458                    format!("delays={ms}:all=1")
1459                } else {
1460                    format!("start={}", -ms / 1000.0)
1461                }
1462            }
1463            Self::ConcatVideo { n } => format!("n={n}:v=1:a=0"),
1464            Self::ConcatAudio { n } => format!("n={n}:v=0:a=1"),
1465            // args() for JoinWithDissolve is not used by the build loop (which is
1466            // bypassed in favour of add_join_with_dissolve_step); provided here for
1467            // completeness using the xfade args.
1468            Self::JoinWithDissolve {
1469                clip_a_end,
1470                dissolve_dur,
1471                ..
1472            } => format!("transition=dissolve:duration={dissolve_dur}:offset={clip_a_end}"),
1473            Self::CropAnimated {
1474                x,
1475                y,
1476                width,
1477                height,
1478            } => {
1479                let x0 = x.value_at(Duration::ZERO);
1480                let y0 = y.value_at(Duration::ZERO);
1481                let w0 = width.value_at(Duration::ZERO);
1482                let h0 = height.value_at(Duration::ZERO);
1483                format!("x={x0}:y={y0}:w={w0}:h={h0}")
1484            }
1485            Self::GBlurAnimated { sigma } => {
1486                let s0 = sigma.value_at(Duration::ZERO);
1487                format!("sigma={s0}")
1488            }
1489            Self::MotionBlur {
1490                shutter_angle_degrees,
1491                ..
1492            } => {
1493                let alpha = f64::from(*shutter_angle_degrees / 360.0).clamp(0.0, 1.0);
1494                let keep = 1.0 - alpha;
1495                let blend = alpha;
1496                format!("all_expr='A*{keep}+B*{blend}'")
1497            }
1498            Self::LensCorrection { k1, k2 } => format!("k1={k1}:k2={k2}"),
1499            Self::FilmGrain {
1500                luma_strength,
1501                chroma_strength,
1502            } => {
1503                #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
1504                let ls = luma_strength.clamp(0.0, 100.0) as u32;
1505                #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
1506                let cs = chroma_strength.clamp(0.0, 100.0) as u32;
1507                format!("alls={ls}:c0s={cs}:c1s={cs}:allf=t")
1508            }
1509            Self::ScaleMultiplier { factor } => {
1510                format!("w=iw*{factor}:h=ih*{factor}")
1511            }
1512            Self::ChromaticAberration { rh, bh } => {
1513                format!("rh={rh}:bh={bh}:edge=smear")
1514            }
1515            // args() is not consumed by add_and_link_step (which is bypassed for
1516            // this compound step); provided here for completeness.
1517            Self::Glow {
1518                threshold,
1519                radius,
1520                intensity,
1521            } => {
1522                let t = threshold.clamp(0.0, 1.0);
1523                let r = radius.clamp(0.5, 50.0);
1524                let iv = intensity.clamp(0.0, 2.0);
1525                let hi_lo = format!("0/0 {t}/0 1/1");
1526                format!(
1527                    "split=2[base][hl];[hl]curves=all='{hi_lo}'[glow_src];\
1528                     [glow_src]gblur=sigma={r}[glow];\
1529                     [base][glow]blend=all_mode=addition:all_opacity={iv}"
1530                )
1531            }
1532            Self::ReverbEcho {
1533                in_gain,
1534                out_gain,
1535                delays,
1536                decays,
1537            } => {
1538                let delay_str = delays
1539                    .iter()
1540                    .map(|d| d.to_string())
1541                    .collect::<Vec<_>>()
1542                    .join("|");
1543                let decay_str = decays
1544                    .iter()
1545                    .map(|d| d.to_string())
1546                    .collect::<Vec<_>>()
1547                    .join("|");
1548                format!(
1549                    "in_gain={ig}:out_gain={og}:delays={ds}:decays={dec}",
1550                    ig = in_gain,
1551                    og = out_gain,
1552                    ds = delay_str,
1553                    dec = decay_str,
1554                )
1555            }
1556            // args() is not consumed by add_and_link_step (which is bypassed for
1557            // this compound step); provided here for completeness.
1558            Self::ReverbIr {
1559                ir_path,
1560                wet,
1561                dry,
1562                pre_delay_ms,
1563            } => {
1564                let delay = pre_delay_ms.min(&500);
1565                let delay_part = if *delay > 0 {
1566                    format!(",adelay={delay}:all=1")
1567                } else {
1568                    String::new()
1569                };
1570                format!("amovie={ir_path}{delay_part}[ir];[0:a][ir]afir=dry={dry}:wet={wet}")
1571            }
1572            // args() is not consumed by add_and_link_step (which is bypassed for
1573            // this compound step); provided here for completeness.
1574            Self::PitchShift { semitones } => {
1575                let rate = 2f64.powf(f64::from(*semitones) / 12.0);
1576                let atempo = 1.0 / rate;
1577                format!("asetrate=sr*{rate:.6},atempo={atempo:.6}")
1578            }
1579            // args() is not consumed by add_and_link_step (bypassed in favour of
1580            // add_atempo_chain); provided here for single-instance completeness.
1581            Self::TimeStretch { factor } => format!("{factor:.6}"),
1582            // args() is not consumed by add_and_link_step (bypassed; sample rate
1583            // is resolved from buffersrc_args at build time); provided for completeness.
1584            Self::SpeedChange { factor } => format!("asetrate=sr*{factor:.6}"),
1585            Self::NoiseReduce {
1586                noise_type_flag,
1587                nr_level,
1588            } => format!("nt={noise_type_flag}:nr={nr_level}"),
1589            Self::NoiseReduceProfile {
1590                profile_duration_secs,
1591                nr_level,
1592            } => format!("nr={nr_level}:nf=-25:nt=w:pl={profile_duration_secs}"),
1593            // args() is not consumed by add_and_link_step (bypassed for this
1594            // compound two-input step); provided for completeness.
1595            Self::Duck {
1596                threshold_linear,
1597                ratio,
1598                attack_ms,
1599                release_ms,
1600            } => format!(
1601                "threshold={threshold_linear}:ratio={ratio}:attack={attack_ms}:release={release_ms}"
1602            ),
1603        }
1604    }
1605}
1606
1607#[cfg(test)]
1608mod tests {
1609    use super::*;
1610
1611    #[test]
1612    fn escape_filter_path_should_escape_windows_drive_path() {
1613        // Backslashes → forward slashes; the drive colon is escaped as `\:` so
1614        // FFmpeg's filter-arg parser does not treat it as an option separator.
1615        assert_eq!(
1616            escape_filter_path(r"D:\dir\look.cube"),
1617            "D\\:/dir/look.cube"
1618        );
1619    }
1620
1621    #[test]
1622    fn escape_filter_path_should_leave_unix_path_unchanged() {
1623        assert_eq!(escape_filter_path("/home/u/look.cube"), "/home/u/look.cube");
1624    }
1625
1626    #[test]
1627    fn lut3d_args_should_escape_path() {
1628        let step = FilterStep::Lut3d {
1629            path: r"D:\luts\look.cube".to_string(),
1630        };
1631        let args = step.args();
1632        assert!(
1633            !args.contains(r"D:\"),
1634            "raw Windows path must not appear unescaped in args: {args}"
1635        );
1636        assert!(args.contains("D\\:/luts/look.cube"), "got: {args}");
1637        assert!(args.ends_with(":interp=trilinear"));
1638    }
1639
1640    #[test]
1641    fn setparams_filter_name_should_be_setparams() {
1642        let step = FilterStep::SetParams {
1643            color_space: None,
1644            color_range: None,
1645            color_primaries: None,
1646            color_trc: None,
1647        };
1648        assert_eq!(step.filter_name(), "setparams");
1649    }
1650
1651    #[test]
1652    fn setparams_args_should_emit_all_canonical_tokens() {
1653        let step = FilterStep::SetParams {
1654            color_space: Some(ColorSpace::Bt2020Ncl),
1655            color_range: Some(ColorRange::Limited),
1656            color_primaries: Some(ColorPrimaries::Bt2020),
1657            color_trc: Some(ColorTransfer::Hlg),
1658        };
1659        assert_eq!(
1660            step.args(),
1661            "colorspace=bt2020nc:range=tv:color_primaries=bt2020:color_trc=arib-std-b67"
1662        );
1663    }
1664
1665    #[test]
1666    fn setparams_args_should_emit_only_provided_options() {
1667        // HDR tagging often sets just primaries + transfer.
1668        let step = FilterStep::SetParams {
1669            color_space: None,
1670            color_range: None,
1671            color_primaries: Some(ColorPrimaries::Bt2020),
1672            color_trc: Some(ColorTransfer::Pq),
1673        };
1674        assert_eq!(step.args(), "color_primaries=bt2020:color_trc=smpte2084");
1675    }
1676
1677    #[test]
1678    fn setparams_args_should_skip_values_without_ffmpeg_token() {
1679        // `Unknown` renders to no token and must be skipped, not emitted as an invalid arg.
1680        let step = FilterStep::SetParams {
1681            color_space: Some(ColorSpace::Unknown),
1682            color_range: Some(ColorRange::Full),
1683            color_primaries: Some(ColorPrimaries::Unknown),
1684            color_trc: Some(ColorTransfer::Bt709),
1685        };
1686        assert_eq!(step.args(), "range=pc:color_trc=bt709");
1687    }
1688
1689    #[test]
1690    fn setparams_args_should_be_empty_when_all_none() {
1691        let step = FilterStep::SetParams {
1692            color_space: None,
1693            color_range: None,
1694            color_primaries: None,
1695            color_trc: None,
1696        };
1697        assert_eq!(step.args(), "");
1698    }
1699}