Skip to main content

ff_filter/graph/
filter_step.rs

1//! Internal filter step representation.
2
3use super::types::{
4    DrawTextOptions, EqBand, Rgb, ScaleAlgorithm, ToneMap, XfadeTransition, YadifMode,
5};
6
7// ── FilterStep ────────────────────────────────────────────────────────────────
8
9/// A single step in a filter chain.
10///
11/// Used by [`crate::FilterGraphBuilder`] to build pipeline filter graphs, and by
12/// [`crate::AudioTrack::effects`] to attach per-track effects in a multi-track mix.
13#[derive(Debug, Clone)]
14pub enum FilterStep {
15    /// Trim: keep only frames in `[start, end)` seconds.
16    Trim { start: f64, end: f64 },
17    /// Scale to a new resolution using the given resampling algorithm.
18    Scale {
19        width: u32,
20        height: u32,
21        algorithm: ScaleAlgorithm,
22    },
23    /// Crop a rectangular region.
24    Crop {
25        x: u32,
26        y: u32,
27        width: u32,
28        height: u32,
29    },
30    /// Overlay a second stream at position `(x, y)`.
31    Overlay { x: i32, y: i32 },
32    /// Fade-in from black starting at `start` seconds, over `duration` seconds.
33    FadeIn { start: f64, duration: f64 },
34    /// Fade-out to black starting at `start` seconds, over `duration` seconds.
35    FadeOut { start: f64, duration: f64 },
36    /// Audio fade-in from silence starting at `start` seconds, over `duration` seconds.
37    AFadeIn { start: f64, duration: f64 },
38    /// Audio fade-out to silence starting at `start` seconds, over `duration` seconds.
39    AFadeOut { start: f64, duration: f64 },
40    /// Fade-in from white starting at `start` seconds, over `duration` seconds.
41    FadeInWhite { start: f64, duration: f64 },
42    /// Fade-out to white starting at `start` seconds, over `duration` seconds.
43    FadeOutWhite { start: f64, duration: f64 },
44    /// Rotate clockwise by `angle_degrees`, filling exposed areas with `fill_color`.
45    Rotate {
46        angle_degrees: f64,
47        fill_color: String,
48    },
49    /// HDR-to-SDR tone mapping.
50    ToneMap(ToneMap),
51    /// Adjust audio volume (in dB; negative = quieter).
52    Volume(f64),
53    /// Mix `n` audio inputs together.
54    Amix(usize),
55    /// Multi-band parametric equalizer (low-shelf, high-shelf, or peak bands).
56    ///
57    /// Each band maps to its own `FFmpeg` filter node chained in sequence.
58    /// The `bands` vec must not be empty.
59    ParametricEq { bands: Vec<EqBand> },
60    /// Apply a 3D LUT from a `.cube` or `.3dl` file.
61    Lut3d { path: String },
62    /// Brightness/contrast/saturation adjustment via `FFmpeg` `eq` filter.
63    Eq {
64        brightness: f32,
65        contrast: f32,
66        saturation: f32,
67    },
68    /// Per-channel RGB color curves adjustment.
69    Curves {
70        master: Vec<(f32, f32)>,
71        r: Vec<(f32, f32)>,
72        g: Vec<(f32, f32)>,
73        b: Vec<(f32, f32)>,
74    },
75    /// White balance correction via `colorchannelmixer`.
76    WhiteBalance { temperature_k: u32, tint: f32 },
77    /// Hue rotation by an arbitrary angle.
78    Hue { degrees: f32 },
79    /// Per-channel gamma correction via `FFmpeg` `eq` filter.
80    Gamma { r: f32, g: f32, b: f32 },
81    /// Three-way colour corrector (lift / gamma / gain) via `FFmpeg` `curves` filter.
82    ThreeWayCC {
83        /// Affects shadows (blacks). Neutral: `Rgb::NEUTRAL`.
84        lift: Rgb,
85        /// Affects midtones. Neutral: `Rgb::NEUTRAL`. All components must be > 0.0.
86        gamma: Rgb,
87        /// Affects highlights (whites). Neutral: `Rgb::NEUTRAL`.
88        gain: Rgb,
89    },
90    /// Vignette effect via `FFmpeg` `vignette` filter.
91    Vignette {
92        /// Radius angle in radians (valid range: 0.0 – π/2 ≈ 1.5708). Default: π/5 ≈ 0.628.
93        angle: f32,
94        /// Horizontal centre of the vignette. `0.0` maps to `w/2`.
95        x0: f32,
96        /// Vertical centre of the vignette. `0.0` maps to `h/2`.
97        y0: f32,
98    },
99    /// Horizontal flip (mirror left-right).
100    HFlip,
101    /// Vertical flip (mirror top-bottom).
102    VFlip,
103    /// Reverse video playback (buffers entire clip in memory — use only on short clips).
104    Reverse,
105    /// Reverse audio playback (buffers entire clip in memory — use only on short clips).
106    AReverse,
107    /// Pad to a target resolution with a fill color (letterbox / pillarbox).
108    Pad {
109        /// Target canvas width in pixels.
110        width: u32,
111        /// Target canvas height in pixels.
112        height: u32,
113        /// Horizontal offset of the source frame within the canvas.
114        /// Negative values are replaced with `(ow-iw)/2` (centred).
115        x: i32,
116        /// Vertical offset of the source frame within the canvas.
117        /// Negative values are replaced with `(oh-ih)/2` (centred).
118        y: i32,
119        /// Fill color (any `FFmpeg` color string, e.g. `"black"`, `"0x000000"`).
120        color: String,
121    },
122    /// Scale (preserving aspect ratio) then centre-pad to fill target dimensions
123    /// (letterbox or pillarbox as required).
124    ///
125    /// Implemented as a `scale` filter with `force_original_aspect_ratio=decrease`
126    /// followed by a `pad` filter that centres the scaled frame on the canvas.
127    FitToAspect {
128        /// Target canvas width in pixels.
129        width: u32,
130        /// Target canvas height in pixels.
131        height: u32,
132        /// Fill color for the bars (any `FFmpeg` color string, e.g. `"black"`).
133        color: String,
134    },
135    /// Gaussian blur with configurable radius.
136    ///
137    /// `sigma` is the blur radius. Valid range: 0.0 – 10.0 (values near 0.0 are
138    /// nearly a no-op; higher values produce a stronger blur).
139    GBlur {
140        /// Blur radius (standard deviation). Must be ≥ 0.0.
141        sigma: f32,
142    },
143    /// Sharpen or blur via unsharp mask (luma + chroma strength).
144    ///
145    /// Positive values sharpen; negative values blur. Valid range for each
146    /// component: −1.5 – 1.5.
147    Unsharp {
148        /// Luma (brightness) sharpening/blurring amount. Range: −1.5 – 1.5.
149        luma_strength: f32,
150        /// Chroma (colour) sharpening/blurring amount. Range: −1.5 – 1.5.
151        chroma_strength: f32,
152    },
153    /// High Quality 3D noise reduction (`hqdn3d`).
154    ///
155    /// Typical values: `luma_spatial=4.0`, `chroma_spatial=3.0`,
156    /// `luma_tmp=6.0`, `chroma_tmp=4.5`. All values must be ≥ 0.0.
157    Hqdn3d {
158        /// Spatial luma noise reduction strength. Must be ≥ 0.0.
159        luma_spatial: f32,
160        /// Spatial chroma noise reduction strength. Must be ≥ 0.0.
161        chroma_spatial: f32,
162        /// Temporal luma noise reduction strength. Must be ≥ 0.0.
163        luma_tmp: f32,
164        /// Temporal chroma noise reduction strength. Must be ≥ 0.0.
165        chroma_tmp: f32,
166    },
167    /// Non-local means noise reduction (`nlmeans`).
168    ///
169    /// `strength` controls the denoising intensity; range 1.0–30.0.
170    /// Higher values remove more noise but are significantly more CPU-intensive.
171    ///
172    /// NOTE: nlmeans is CPU-intensive; avoid for real-time pipelines.
173    Nlmeans {
174        /// Denoising strength. Must be in the range [1.0, 30.0].
175        strength: f32,
176    },
177    /// Deinterlace using the `yadif` filter.
178    Yadif {
179        /// Deinterlacing mode controlling output frame rate and spatial checks.
180        mode: YadifMode,
181    },
182    /// Cross-dissolve transition between two video streams (`xfade`).
183    ///
184    /// Requires two input slots: slot 0 is clip A, slot 1 is clip B.
185    /// `duration` is the overlap length in seconds; `offset` is the PTS
186    /// offset (in seconds) at which clip B begins.
187    XFade {
188        /// Transition style.
189        transition: XfadeTransition,
190        /// Overlap duration in seconds. Must be > 0.0.
191        duration: f64,
192        /// PTS offset (seconds) where clip B starts.
193        offset: f64,
194    },
195    /// Draw text onto the video using the `drawtext` filter.
196    DrawText {
197        /// Full set of drawtext parameters.
198        opts: DrawTextOptions,
199    },
200    /// Burn-in SRT subtitles (hard subtitles) using the `subtitles` filter.
201    SubtitlesSrt {
202        /// Absolute or relative path to the `.srt` file.
203        path: String,
204    },
205    /// Burn-in ASS/SSA styled subtitles using the `ass` filter.
206    SubtitlesAss {
207        /// Absolute or relative path to the `.ass` or `.ssa` file.
208        path: String,
209    },
210    /// Playback speed change using `setpts` (video) and chained `atempo` (audio).
211    ///
212    /// `factor > 1.0` = fast motion; `factor < 1.0` = slow motion.
213    /// Valid range: 0.1–100.0.
214    ///
215    /// Video path: `setpts=PTS/{factor}`.
216    /// Audio path: the `atempo` filter only accepts [0.5, 2.0] per instance;
217    /// `filter_inner` chains multiple instances to cover the full range.
218    Speed {
219        /// Speed multiplier. Must be in [0.1, 100.0].
220        factor: f64,
221    },
222    /// EBU R128 two-pass loudness normalization.
223    ///
224    /// Pass 1 measures integrated loudness with `ebur128=peak=true:metadata=1`.
225    /// Pass 2 applies a linear volume correction so the output reaches `target_lufs`.
226    /// All audio frames are buffered in memory between the two passes — use only
227    /// for clips that fit comfortably in RAM.
228    LoudnessNormalize {
229        /// Target integrated loudness in LUFS (e.g. −23.0). Must be < 0.0.
230        target_lufs: f32,
231        /// True-peak ceiling in dBTP (e.g. −1.0). Must be ≤ 0.0.
232        true_peak_db: f32,
233        /// Target loudness range in LU (e.g. 7.0). Must be > 0.0.
234        lra: f32,
235    },
236    /// Peak-level two-pass normalization using `astats`.
237    ///
238    /// Pass 1 measures the true peak with `astats=metadata=1`.
239    /// Pass 2 applies `volume={gain}dB` so the output peak reaches `target_db`.
240    /// All audio frames are buffered in memory between passes — use only
241    /// for clips that fit comfortably in RAM.
242    NormalizePeak {
243        /// Target peak level in dBFS (e.g. −1.0). Must be ≤ 0.0.
244        target_db: f32,
245    },
246    /// Noise gate via `FFmpeg`'s `agate` filter.
247    ///
248    /// Audio below `threshold_db` is attenuated; audio above passes through.
249    /// The threshold is converted from dBFS to the linear scale expected by
250    /// `agate`'s `threshold` parameter (`linear = 10^(dB/20)`).
251    ANoiseGate {
252        /// Gate open/close threshold in dBFS (e.g. −40.0).
253        threshold_db: f32,
254        /// Attack time in milliseconds — how quickly the gate opens. Must be > 0.0.
255        attack_ms: f32,
256        /// Release time in milliseconds — how quickly the gate closes. Must be > 0.0.
257        release_ms: f32,
258    },
259    /// Dynamic range compressor via `FFmpeg`'s `acompressor` filter.
260    ///
261    /// Reduces the dynamic range of the audio signal: peaks above
262    /// `threshold_db` are attenuated by `ratio`:1.  `makeup_db` applies
263    /// additional gain after compression to restore perceived loudness.
264    ACompressor {
265        /// Compression threshold in dBFS (e.g. −20.0).
266        threshold_db: f32,
267        /// Compression ratio (e.g. 4.0 = 4:1). Must be ≥ 1.0.
268        ratio: f32,
269        /// Attack time in milliseconds. Must be > 0.0.
270        attack_ms: f32,
271        /// Release time in milliseconds. Must be > 0.0.
272        release_ms: f32,
273        /// Make-up gain in dB applied after compression (e.g. 6.0).
274        makeup_db: f32,
275    },
276    /// Downmix stereo to mono via `FFmpeg`'s `pan` filter.
277    ///
278    /// Both channels are mixed with equal weight:
279    /// `mono|c0=0.5*c0+0.5*c1`.  The output has a single channel.
280    StereoToMono,
281    /// Remap audio channels using `FFmpeg`'s `channelmap` filter.
282    ///
283    /// `mapping` is a `|`-separated list of output channel names taken
284    /// from input channels, e.g. `"FR|FL"` swaps left and right.
285    /// Must not be empty.
286    ChannelMap {
287        /// `FFmpeg` channelmap mapping expression (e.g. `"FR|FL"`).
288        mapping: String,
289    },
290    /// A/V sync correction via audio delay or advance.
291    ///
292    /// Positive `ms`: uses `FFmpeg`'s `adelay` filter to shift audio later.
293    /// Negative `ms`: uses `FFmpeg`'s `atrim` filter to trim the audio start,
294    /// effectively advancing audio by `|ms|` milliseconds.
295    /// Zero `ms`: uses `adelay` with zero delay (no-op).
296    AudioDelay {
297        /// Delay in milliseconds. Positive = delay; negative = advance.
298        ms: f64,
299    },
300    /// Concatenate `n` sequential video input segments via `FFmpeg`'s `concat` filter.
301    ///
302    /// Requires `n` video input slots (0 through `n-1`). `n` must be ≥ 2.
303    ConcatVideo {
304        /// Number of video input segments to concatenate. Must be ≥ 2.
305        n: u32,
306    },
307    /// Concatenate `n` sequential audio input segments via `FFmpeg`'s `concat` filter.
308    ///
309    /// Requires `n` audio input slots (0 through `n-1`). `n` must be ≥ 2.
310    ConcatAudio {
311        /// Number of audio input segments to concatenate. Must be ≥ 2.
312        n: u32,
313    },
314    /// Freeze a single frame for a configurable duration using `FFmpeg`'s `loop` filter.
315    ///
316    /// The frame nearest to `pts` seconds is held for `duration` seconds, then
317    /// playback resumes. Frame numbers are approximated using a 25 fps assumption;
318    /// accuracy depends on the source stream's actual frame rate.
319    FreezeFrame {
320        /// Timestamp of the frame to freeze, in seconds. Must be >= 0.0.
321        pts: f64,
322        /// Duration to hold the frozen frame, in seconds. Must be > 0.0.
323        duration: f64,
324    },
325    /// Scrolling text ticker (right-to-left) using the `drawtext` filter.
326    ///
327    /// The text starts off-screen to the right and scrolls left at
328    /// `speed_px_per_sec` pixels per second using the expression
329    /// `x = w - t * speed`.
330    Ticker {
331        /// Text to display. Special characters (`\`, `:`, `'`) are escaped.
332        text: String,
333        /// Y position as an `FFmpeg` expression, e.g. `"h-50"` or `"10"`.
334        y: String,
335        /// Horizontal scroll speed in pixels per second (must be > 0.0).
336        speed_px_per_sec: f32,
337        /// Font size in points.
338        font_size: u32,
339        /// Font color as an `FFmpeg` color string, e.g. `"white"` or `"0xFFFFFF"`.
340        font_color: String,
341    },
342    /// Join two video clips with a cross-dissolve transition.
343    ///
344    /// Compound step — expands in `filter_inner` to:
345    /// ```text
346    /// in0 → trim(end=clip_a_end+dissolve_dur) → setpts → xfade[0]
347    /// in1 → trim(start=max(0, clip_b_start−dissolve_dur)) → setpts → xfade[1]
348    /// ```
349    ///
350    /// Requires two video input slots: slot 0 = clip A, slot 1 = clip B.
351    /// `clip_a_end` and `dissolve_dur` must be > 0.0.
352    JoinWithDissolve {
353        /// Timestamp (seconds) where clip A ends. Must be > 0.0.
354        clip_a_end: f64,
355        /// Timestamp (seconds) where clip B content starts (before the overlap).
356        clip_b_start: f64,
357        /// Cross-dissolve overlap duration in seconds. Must be > 0.0.
358        dissolve_dur: f64,
359    },
360    /// Composite a PNG image (watermark / logo) over video with optional opacity.
361    ///
362    /// This is a compound step: internally it creates a `movie` source,
363    /// a `lut` alpha-scaling filter, and an `overlay` compositing filter.
364    /// The image file is loaded once at graph construction time.
365    OverlayImage {
366        /// Absolute or relative path to the `.png` file.
367        path: String,
368        /// Horizontal position as an `FFmpeg` expression, e.g. `"10"` or `"W-w-10"`.
369        x: String,
370        /// Vertical position as an `FFmpeg` expression, e.g. `"10"` or `"H-h-10"`.
371        y: String,
372        /// Opacity 0.0 (fully transparent) to 1.0 (fully opaque).
373        opacity: f32,
374    },
375}
376
377/// Convert a color temperature in Kelvin to linear RGB multipliers using
378/// Tanner Helland's algorithm.
379///
380/// Returns `(r, g, b)` each in `[0.0, 1.0]`.
381fn kelvin_to_rgb(temp_k: u32) -> (f64, f64, f64) {
382    let t = (f64::from(temp_k) / 100.0).clamp(10.0, 400.0);
383    let r = if t <= 66.0 {
384        1.0
385    } else {
386        (329.698_727_446_4 * (t - 60.0).powf(-0.133_204_759_2) / 255.0).clamp(0.0, 1.0)
387    };
388    let g = if t <= 66.0 {
389        ((99.470_802_586_1 * t.ln() - 161.119_568_166_1) / 255.0).clamp(0.0, 1.0)
390    } else {
391        ((288.122_169_528_3 * (t - 60.0).powf(-0.075_514_849_2)) / 255.0).clamp(0.0, 1.0)
392    };
393    let b = if t >= 66.0 {
394        1.0
395    } else if t <= 19.0 {
396        0.0
397    } else {
398        ((138.517_731_223_1 * (t - 10.0).ln() - 305.044_792_730_7) / 255.0).clamp(0.0, 1.0)
399    };
400    (r, g, b)
401}
402
403impl FilterStep {
404    /// Returns the libavfilter filter name for this step.
405    pub(crate) fn filter_name(&self) -> &'static str {
406        match self {
407            Self::Trim { .. } => "trim",
408            Self::Scale { .. } => "scale",
409            Self::Crop { .. } => "crop",
410            Self::Overlay { .. } => "overlay",
411            Self::FadeIn { .. }
412            | Self::FadeOut { .. }
413            | Self::FadeInWhite { .. }
414            | Self::FadeOutWhite { .. } => "fade",
415            Self::AFadeIn { .. } | Self::AFadeOut { .. } => "afade",
416            Self::Rotate { .. } => "rotate",
417            Self::ToneMap(_) => "tonemap",
418            Self::Volume(_) => "volume",
419            Self::Amix(_) => "amix",
420            // ParametricEq is a compound step; "equalizer" is used only by
421            // validate_filter_steps as a best-effort existence check.  The
422            // actual nodes are built by `filter_inner::add_parametric_eq_chain`.
423            Self::ParametricEq { .. } => "equalizer",
424            Self::Lut3d { .. } => "lut3d",
425            Self::Eq { .. } => "eq",
426            Self::Curves { .. } => "curves",
427            Self::WhiteBalance { .. } => "colorchannelmixer",
428            Self::Hue { .. } => "hue",
429            Self::Gamma { .. } => "eq",
430            Self::ThreeWayCC { .. } => "curves",
431            Self::Vignette { .. } => "vignette",
432            Self::HFlip => "hflip",
433            Self::VFlip => "vflip",
434            Self::Reverse => "reverse",
435            Self::AReverse => "areverse",
436            Self::Pad { .. } => "pad",
437            // FitToAspect is implemented as scale + pad; "scale" is validated at
438            // build time.  The pad filter is inserted by filter_inner at graph
439            // construction time.
440            Self::FitToAspect { .. } => "scale",
441            Self::GBlur { .. } => "gblur",
442            Self::Unsharp { .. } => "unsharp",
443            Self::Hqdn3d { .. } => "hqdn3d",
444            Self::Nlmeans { .. } => "nlmeans",
445            Self::Yadif { .. } => "yadif",
446            Self::XFade { .. } => "xfade",
447            Self::DrawText { .. } | Self::Ticker { .. } => "drawtext",
448            // "setpts" is checked at build-time; the audio path uses "atempo"
449            // which is verified at graph-construction time in filter_inner.
450            Self::Speed { .. } => "setpts",
451            Self::FreezeFrame { .. } => "loop",
452            Self::LoudnessNormalize { .. } => "ebur128",
453            Self::NormalizePeak { .. } => "astats",
454            Self::ANoiseGate { .. } => "agate",
455            Self::ACompressor { .. } => "acompressor",
456            Self::StereoToMono => "pan",
457            Self::ChannelMap { .. } => "channelmap",
458            // AudioDelay dispatches to adelay (positive) or atrim (negative) at
459            // build time; "adelay" is returned here for validate_filter_steps only.
460            Self::AudioDelay { .. } => "adelay",
461            Self::ConcatVideo { .. } | Self::ConcatAudio { .. } => "concat",
462            // JoinWithDissolve is a compound step (trim+setpts → xfade ← setpts+trim);
463            // "xfade" is used by validate_filter_steps as the primary filter check.
464            Self::JoinWithDissolve { .. } => "xfade",
465            Self::SubtitlesSrt { .. } => "subtitles",
466            Self::SubtitlesAss { .. } => "ass",
467            // OverlayImage is a compound step (movie → lut → overlay); "overlay"
468            // is used only by validate_filter_steps as a best-effort existence
469            // check.  The actual graph construction is handled by
470            // `filter_inner::build::add_overlay_image_step`.
471            Self::OverlayImage { .. } => "overlay",
472        }
473    }
474
475    /// Returns the `args` string passed to `avfilter_graph_create_filter`.
476    pub(crate) fn args(&self) -> String {
477        match self {
478            Self::Trim { start, end } => format!("start={start}:end={end}"),
479            Self::Scale {
480                width,
481                height,
482                algorithm,
483            } => format!("w={width}:h={height}:flags={}", algorithm.as_flags_str()),
484            Self::Crop {
485                x,
486                y,
487                width,
488                height,
489            } => {
490                format!("x={x}:y={y}:w={width}:h={height}")
491            }
492            Self::Overlay { x, y } => format!("x={x}:y={y}"),
493            Self::FadeIn { start, duration } => {
494                format!("type=in:start_time={start}:duration={duration}")
495            }
496            Self::FadeOut { start, duration } => {
497                format!("type=out:start_time={start}:duration={duration}")
498            }
499            Self::FadeInWhite { start, duration } => {
500                format!("type=in:start_time={start}:duration={duration}:color=white")
501            }
502            Self::FadeOutWhite { start, duration } => {
503                format!("type=out:start_time={start}:duration={duration}:color=white")
504            }
505            Self::AFadeIn { start, duration } => {
506                format!("type=in:start_time={start}:duration={duration}")
507            }
508            Self::AFadeOut { start, duration } => {
509                format!("type=out:start_time={start}:duration={duration}")
510            }
511            Self::Rotate {
512                angle_degrees,
513                fill_color,
514            } => {
515                format!(
516                    "angle={}:fillcolor={fill_color}",
517                    angle_degrees.to_radians()
518                )
519            }
520            Self::ToneMap(algorithm) => format!("tonemap={}", algorithm.as_str()),
521            Self::Volume(db) => format!("volume={db}dB"),
522            Self::Amix(inputs) => format!("inputs={inputs}"),
523            // args() for ParametricEq is not used by the build loop (which is
524            // bypassed in favour of add_parametric_eq_chain); provided here for
525            // completeness using the first band's args.
526            Self::ParametricEq { bands } => bands.first().map(EqBand::args).unwrap_or_default(),
527            Self::Lut3d { path } => format!("file={path}:interp=trilinear"),
528            Self::Eq {
529                brightness,
530                contrast,
531                saturation,
532            } => format!("brightness={brightness}:contrast={contrast}:saturation={saturation}"),
533            Self::Curves { master, r, g, b } => {
534                let fmt = |pts: &[(f32, f32)]| -> String {
535                    pts.iter()
536                        .map(|(x, y)| format!("{x}/{y}"))
537                        .collect::<Vec<_>>()
538                        .join(" ")
539                };
540                [("master", master.as_slice()), ("r", r), ("g", g), ("b", b)]
541                    .iter()
542                    .filter(|(_, pts)| !pts.is_empty())
543                    .map(|(name, pts)| format!("{name}='{}'", fmt(pts)))
544                    .collect::<Vec<_>>()
545                    .join(":")
546            }
547            Self::WhiteBalance {
548                temperature_k,
549                tint,
550            } => {
551                let (r, g, b) = kelvin_to_rgb(*temperature_k);
552                let g_adj = (g + f64::from(*tint)).clamp(0.0, 2.0);
553                format!("rr={r}:gg={g_adj}:bb={b}")
554            }
555            Self::Hue { degrees } => format!("h={degrees}"),
556            Self::Gamma { r, g, b } => format!("gamma_r={r}:gamma_g={g}:gamma_b={b}"),
557            Self::Vignette { angle, x0, y0 } => {
558                let cx = if *x0 == 0.0 {
559                    "w/2".to_string()
560                } else {
561                    x0.to_string()
562                };
563                let cy = if *y0 == 0.0 {
564                    "h/2".to_string()
565                } else {
566                    y0.to_string()
567                };
568                format!("angle={angle}:x0={cx}:y0={cy}")
569            }
570            Self::ThreeWayCC { lift, gamma, gain } => {
571                // Convert lift/gamma/gain to a 3-point per-channel curves representation.
572                // The formula maps:
573                //   input 0.0 → (lift - 1.0) * gain  (black point)
574                //   input 0.5 → (0.5 * lift)^(1/gamma) * gain  (midtone)
575                //   input 1.0 → gain  (white point)
576                // All neutral (1.0) produces the identity curve 0/0 0.5/0.5 1/1.
577                let curve = |l: f32, gm: f32, gn: f32| -> String {
578                    let l = f64::from(l);
579                    let gm = f64::from(gm);
580                    let gn = f64::from(gn);
581                    let black = ((l - 1.0) * gn).clamp(0.0, 1.0);
582                    let mid = ((0.5 * l).powf(1.0 / gm) * gn).clamp(0.0, 1.0);
583                    let white = gn.clamp(0.0, 1.0);
584                    format!("0/{black} 0.5/{mid} 1/{white}")
585                };
586                format!(
587                    "r='{}':g='{}':b='{}'",
588                    curve(lift.r, gamma.r, gain.r),
589                    curve(lift.g, gamma.g, gain.g),
590                    curve(lift.b, gamma.b, gain.b),
591                )
592            }
593            Self::HFlip | Self::VFlip | Self::Reverse | Self::AReverse => String::new(),
594            Self::GBlur { sigma } => format!("sigma={sigma}"),
595            Self::Unsharp {
596                luma_strength,
597                chroma_strength,
598            } => format!(
599                "luma_msize_x=5:luma_msize_y=5:luma_amount={luma_strength}:\
600                 chroma_msize_x=5:chroma_msize_y=5:chroma_amount={chroma_strength}"
601            ),
602            Self::Hqdn3d {
603                luma_spatial,
604                chroma_spatial,
605                luma_tmp,
606                chroma_tmp,
607            } => format!("{luma_spatial}:{chroma_spatial}:{luma_tmp}:{chroma_tmp}"),
608            Self::Nlmeans { strength } => format!("s={strength}"),
609            Self::Yadif { mode } => format!("mode={}", *mode as i32),
610            Self::XFade {
611                transition,
612                duration,
613                offset,
614            } => {
615                let t = transition.as_str();
616                format!("transition={t}:duration={duration}:offset={offset}")
617            }
618            Self::DrawText { opts } => {
619                // Escape special characters recognised by the drawtext filter.
620                let escaped = opts
621                    .text
622                    .replace('\\', "\\\\")
623                    .replace(':', "\\:")
624                    .replace('\'', "\\'");
625                let mut parts = vec![
626                    format!("text='{escaped}'"),
627                    format!("x={}", opts.x),
628                    format!("y={}", opts.y),
629                    format!("fontsize={}", opts.font_size),
630                    format!("fontcolor={}@{:.2}", opts.font_color, opts.opacity),
631                ];
632                if let Some(ref ff) = opts.font_file {
633                    parts.push(format!("fontfile={ff}"));
634                }
635                if let Some(ref bc) = opts.box_color {
636                    parts.push("box=1".to_string());
637                    parts.push(format!("boxcolor={bc}"));
638                    parts.push(format!("boxborderw={}", opts.box_border_width));
639                }
640                parts.join(":")
641            }
642            Self::Ticker {
643                text,
644                y,
645                speed_px_per_sec,
646                font_size,
647                font_color,
648            } => {
649                // Use the same escaping as DrawText.
650                let escaped = text
651                    .replace('\\', "\\\\")
652                    .replace(':', "\\:")
653                    .replace('\'', "\\'");
654                // x = w - t * speed: at t=0 the text starts fully off the right
655                // edge (x = w) and scrolls left by `speed` pixels per second.
656                format!(
657                    "text='{escaped}':x=w-t*{speed_px_per_sec}:y={y}:\
658                     fontsize={font_size}:fontcolor={font_color}"
659                )
660            }
661            // Video path: divide PTS by factor to change playback speed.
662            // Audio path args are built by filter_inner (chained atempo).
663            Self::Speed { factor } => format!("PTS/{factor}"),
664            // args() is not used by the build loop for LoudnessNormalize (two-pass
665            // is handled entirely in filter_inner); provided here for completeness.
666            Self::LoudnessNormalize { .. } => "peak=true:metadata=1".to_string(),
667            // args() is not used by the build loop for NormalizePeak (two-pass
668            // is handled entirely in filter_inner); provided here for completeness.
669            Self::NormalizePeak { .. } => "metadata=1".to_string(),
670            Self::FreezeFrame { pts, duration } => {
671                // The `loop` filter needs a frame index and a loop count, not PTS or
672                // wall-clock duration.  We approximate both using 25 fps; accuracy
673                // depends on the source stream's actual frame rate.
674                #[allow(clippy::cast_possible_truncation)]
675                let start = (*pts * 25.0) as i64;
676                #[allow(clippy::cast_possible_truncation)]
677                let loop_count = (*duration * 25.0) as i64;
678                format!("loop={loop_count}:size=1:start={start}")
679            }
680            Self::SubtitlesSrt { path } | Self::SubtitlesAss { path } => {
681                format!("filename={path}")
682            }
683            // args() for OverlayImage returns the overlay positional args (x:y).
684            // These are not consumed by add_and_link_step (which is bypassed for
685            // this compound step); they exist here only for completeness.
686            Self::OverlayImage { x, y, .. } => format!("{x}:{y}"),
687            Self::FitToAspect { width, height, .. } => {
688                // Scale to fit within the target dimensions, preserving the source
689                // aspect ratio.  The accompanying pad filter (inserted by
690                // filter_inner after this scale filter) centres the result on the
691                // target canvas.
692                format!("w={width}:h={height}:force_original_aspect_ratio=decrease")
693            }
694            Self::Pad {
695                width,
696                height,
697                x,
698                y,
699                color,
700            } => {
701                let px = if *x < 0 {
702                    "(ow-iw)/2".to_string()
703                } else {
704                    x.to_string()
705                };
706                let py = if *y < 0 {
707                    "(oh-ih)/2".to_string()
708                } else {
709                    y.to_string()
710                };
711                format!("width={width}:height={height}:x={px}:y={py}:color={color}")
712            }
713            Self::ANoiseGate {
714                threshold_db,
715                attack_ms,
716                release_ms,
717            } => {
718                // `agate` expects threshold as a linear amplitude ratio (0.0–1.0).
719                let threshold_linear = 10f32.powf(threshold_db / 20.0);
720                format!("threshold={threshold_linear:.6}:attack={attack_ms}:release={release_ms}")
721            }
722            Self::ACompressor {
723                threshold_db,
724                ratio,
725                attack_ms,
726                release_ms,
727                makeup_db,
728            } => {
729                format!(
730                    "threshold={threshold_db}dB:ratio={ratio}:attack={attack_ms}:\
731                     release={release_ms}:makeup={makeup_db}dB"
732                )
733            }
734            Self::StereoToMono => "mono|c0=0.5*c0+0.5*c1".to_string(),
735            Self::ChannelMap { mapping } => format!("map={mapping}"),
736            // args() is not used directly for AudioDelay — the audio build loop
737            // dispatches to add_raw_filter_step with the correct filter name and
738            // args based on the sign of ms.  These are provided for completeness.
739            Self::AudioDelay { ms } => {
740                if *ms >= 0.0 {
741                    format!("delays={ms}:all=1")
742                } else {
743                    format!("start={}", -ms / 1000.0)
744                }
745            }
746            Self::ConcatVideo { n } => format!("n={n}:v=1:a=0"),
747            Self::ConcatAudio { n } => format!("n={n}:v=0:a=1"),
748            // args() for JoinWithDissolve is not used by the build loop (which is
749            // bypassed in favour of add_join_with_dissolve_step); provided here for
750            // completeness using the xfade args.
751            Self::JoinWithDissolve {
752                clip_a_end,
753                dissolve_dur,
754                ..
755            } => format!("transition=dissolve:duration={dissolve_dur}:offset={clip_a_end}"),
756        }
757    }
758}