Skip to main content

ff_filter/graph/
filter_step.rs

1//! Internal filter step representation.
2
3use super::builder::FilterGraphBuilder;
4use super::types::{
5    DrawTextOptions, EqBand, Rgb, ScaleAlgorithm, ToneMap, XfadeTransition, YadifMode,
6};
7use crate::blend::BlendMode;
8
9// ── FilterStep ────────────────────────────────────────────────────────────────
10
11/// A single step in a filter chain.
12///
13/// Used by [`crate::FilterGraphBuilder`] to build pipeline filter graphs, and by
14/// [`crate::AudioTrack::effects`] to attach per-track effects in a multi-track mix.
15#[derive(Debug, Clone)]
16pub enum FilterStep {
17    /// Trim: keep only frames in `[start, end)` seconds.
18    Trim { start: f64, end: f64 },
19    /// Scale to a new resolution using the given resampling algorithm.
20    Scale {
21        width: u32,
22        height: u32,
23        algorithm: ScaleAlgorithm,
24    },
25    /// Crop a rectangular region.
26    Crop {
27        x: u32,
28        y: u32,
29        width: u32,
30        height: u32,
31    },
32    /// Overlay a second stream at position `(x, y)`.
33    Overlay { x: i32, y: i32 },
34    /// Fade-in from black starting at `start` seconds, over `duration` seconds.
35    FadeIn { start: f64, duration: f64 },
36    /// Fade-out to black starting at `start` seconds, over `duration` seconds.
37    FadeOut { start: f64, duration: f64 },
38    /// Audio fade-in from silence starting at `start` seconds, over `duration` seconds.
39    AFadeIn { start: f64, duration: f64 },
40    /// Audio fade-out to silence starting at `start` seconds, over `duration` seconds.
41    AFadeOut { start: f64, duration: f64 },
42    /// Fade-in from white starting at `start` seconds, over `duration` seconds.
43    FadeInWhite { start: f64, duration: f64 },
44    /// Fade-out to white starting at `start` seconds, over `duration` seconds.
45    FadeOutWhite { start: f64, duration: f64 },
46    /// Rotate clockwise by `angle_degrees`, filling exposed areas with `fill_color`.
47    Rotate {
48        angle_degrees: f64,
49        fill_color: String,
50    },
51    /// HDR-to-SDR tone mapping.
52    ToneMap(ToneMap),
53    /// Adjust audio volume (in dB; negative = quieter).
54    Volume(f64),
55    /// Mix `n` audio inputs together.
56    Amix(usize),
57    /// Multi-band parametric equalizer (low-shelf, high-shelf, or peak bands).
58    ///
59    /// Each band maps to its own `FFmpeg` filter node chained in sequence.
60    /// The `bands` vec must not be empty.
61    ParametricEq { bands: Vec<EqBand> },
62    /// Apply a 3D LUT from a `.cube` or `.3dl` file.
63    Lut3d { path: String },
64    /// Brightness/contrast/saturation adjustment via `FFmpeg` `eq` filter.
65    Eq {
66        brightness: f32,
67        contrast: f32,
68        saturation: f32,
69    },
70    /// Per-channel RGB color curves adjustment.
71    Curves {
72        master: Vec<(f32, f32)>,
73        r: Vec<(f32, f32)>,
74        g: Vec<(f32, f32)>,
75        b: Vec<(f32, f32)>,
76    },
77    /// White balance correction via `colorchannelmixer`.
78    WhiteBalance { temperature_k: u32, tint: f32 },
79    /// Hue rotation by an arbitrary angle.
80    Hue { degrees: f32 },
81    /// Per-channel gamma correction via `FFmpeg` `eq` filter.
82    Gamma { r: f32, g: f32, b: f32 },
83    /// Three-way colour corrector (lift / gamma / gain) via `FFmpeg` `curves` filter.
84    ThreeWayCC {
85        /// Affects shadows (blacks). Neutral: `Rgb::NEUTRAL`.
86        lift: Rgb,
87        /// Affects midtones. Neutral: `Rgb::NEUTRAL`. All components must be > 0.0.
88        gamma: Rgb,
89        /// Affects highlights (whites). Neutral: `Rgb::NEUTRAL`.
90        gain: Rgb,
91    },
92    /// Vignette effect via `FFmpeg` `vignette` filter.
93    Vignette {
94        /// Radius angle in radians (valid range: 0.0 – π/2 ≈ 1.5708). Default: π/5 ≈ 0.628.
95        angle: f32,
96        /// Horizontal centre of the vignette. `0.0` maps to `w/2`.
97        x0: f32,
98        /// Vertical centre of the vignette. `0.0` maps to `h/2`.
99        y0: f32,
100    },
101    /// Horizontal flip (mirror left-right).
102    HFlip,
103    /// Vertical flip (mirror top-bottom).
104    VFlip,
105    /// Reverse video playback (buffers entire clip in memory — use only on short clips).
106    Reverse,
107    /// Reverse audio playback (buffers entire clip in memory — use only on short clips).
108    AReverse,
109    /// Pad to a target resolution with a fill color (letterbox / pillarbox).
110    Pad {
111        /// Target canvas width in pixels.
112        width: u32,
113        /// Target canvas height in pixels.
114        height: u32,
115        /// Horizontal offset of the source frame within the canvas.
116        /// Negative values are replaced with `(ow-iw)/2` (centred).
117        x: i32,
118        /// Vertical offset of the source frame within the canvas.
119        /// Negative values are replaced with `(oh-ih)/2` (centred).
120        y: i32,
121        /// Fill color (any `FFmpeg` color string, e.g. `"black"`, `"0x000000"`).
122        color: String,
123    },
124    /// Scale (preserving aspect ratio) then centre-pad to fill target dimensions
125    /// (letterbox or pillarbox as required).
126    ///
127    /// Implemented as a `scale` filter with `force_original_aspect_ratio=decrease`
128    /// followed by a `pad` filter that centres the scaled frame on the canvas.
129    FitToAspect {
130        /// Target canvas width in pixels.
131        width: u32,
132        /// Target canvas height in pixels.
133        height: u32,
134        /// Fill color for the bars (any `FFmpeg` color string, e.g. `"black"`).
135        color: String,
136    },
137    /// Gaussian blur with configurable radius.
138    ///
139    /// `sigma` is the blur radius. Valid range: 0.0 – 10.0 (values near 0.0 are
140    /// nearly a no-op; higher values produce a stronger blur).
141    GBlur {
142        /// Blur radius (standard deviation). Must be ≥ 0.0.
143        sigma: f32,
144    },
145    /// Sharpen or blur via unsharp mask (luma + chroma strength).
146    ///
147    /// Positive values sharpen; negative values blur. Valid range for each
148    /// component: −1.5 – 1.5.
149    Unsharp {
150        /// Luma (brightness) sharpening/blurring amount. Range: −1.5 – 1.5.
151        luma_strength: f32,
152        /// Chroma (colour) sharpening/blurring amount. Range: −1.5 – 1.5.
153        chroma_strength: f32,
154    },
155    /// High Quality 3D noise reduction (`hqdn3d`).
156    ///
157    /// Typical values: `luma_spatial=4.0`, `chroma_spatial=3.0`,
158    /// `luma_tmp=6.0`, `chroma_tmp=4.5`. All values must be ≥ 0.0.
159    Hqdn3d {
160        /// Spatial luma noise reduction strength. Must be ≥ 0.0.
161        luma_spatial: f32,
162        /// Spatial chroma noise reduction strength. Must be ≥ 0.0.
163        chroma_spatial: f32,
164        /// Temporal luma noise reduction strength. Must be ≥ 0.0.
165        luma_tmp: f32,
166        /// Temporal chroma noise reduction strength. Must be ≥ 0.0.
167        chroma_tmp: f32,
168    },
169    /// Non-local means noise reduction (`nlmeans`).
170    ///
171    /// `strength` controls the denoising intensity; range 1.0–30.0.
172    /// Higher values remove more noise but are significantly more CPU-intensive.
173    ///
174    /// NOTE: nlmeans is CPU-intensive; avoid for real-time pipelines.
175    Nlmeans {
176        /// Denoising strength. Must be in the range [1.0, 30.0].
177        strength: f32,
178    },
179    /// Deinterlace using the `yadif` filter.
180    Yadif {
181        /// Deinterlacing mode controlling output frame rate and spatial checks.
182        mode: YadifMode,
183    },
184    /// Cross-dissolve transition between two video streams (`xfade`).
185    ///
186    /// Requires two input slots: slot 0 is clip A, slot 1 is clip B.
187    /// `duration` is the overlap length in seconds; `offset` is the PTS
188    /// offset (in seconds) at which clip B begins.
189    XFade {
190        /// Transition style.
191        transition: XfadeTransition,
192        /// Overlap duration in seconds. Must be > 0.0.
193        duration: f64,
194        /// PTS offset (seconds) where clip B starts.
195        offset: f64,
196    },
197    /// Draw text onto the video using the `drawtext` filter.
198    DrawText {
199        /// Full set of drawtext parameters.
200        opts: DrawTextOptions,
201    },
202    /// Burn-in SRT subtitles (hard subtitles) using the `subtitles` filter.
203    SubtitlesSrt {
204        /// Absolute or relative path to the `.srt` file.
205        path: String,
206    },
207    /// Burn-in ASS/SSA styled subtitles using the `ass` filter.
208    SubtitlesAss {
209        /// Absolute or relative path to the `.ass` or `.ssa` file.
210        path: String,
211    },
212    /// Playback speed change using `setpts` (video) and chained `atempo` (audio).
213    ///
214    /// `factor > 1.0` = fast motion; `factor < 1.0` = slow motion.
215    /// Valid range: 0.1–100.0.
216    ///
217    /// Video path: `setpts=PTS/{factor}`.
218    /// Audio path: the `atempo` filter only accepts [0.5, 2.0] per instance;
219    /// `filter_inner` chains multiple instances to cover the full range.
220    Speed {
221        /// Speed multiplier. Must be in [0.1, 100.0].
222        factor: f64,
223    },
224    /// EBU R128 two-pass loudness normalization.
225    ///
226    /// Pass 1 measures integrated loudness with `ebur128=peak=true:metadata=1`.
227    /// Pass 2 applies a linear volume correction so the output reaches `target_lufs`.
228    /// All audio frames are buffered in memory between the two passes — use only
229    /// for clips that fit comfortably in RAM.
230    LoudnessNormalize {
231        /// Target integrated loudness in LUFS (e.g. −23.0). Must be < 0.0.
232        target_lufs: f32,
233        /// True-peak ceiling in dBTP (e.g. −1.0). Must be ≤ 0.0.
234        true_peak_db: f32,
235        /// Target loudness range in LU (e.g. 7.0). Must be > 0.0.
236        lra: f32,
237    },
238    /// Peak-level two-pass normalization using `astats`.
239    ///
240    /// Pass 1 measures the true peak with `astats=metadata=1`.
241    /// Pass 2 applies `volume={gain}dB` so the output peak reaches `target_db`.
242    /// All audio frames are buffered in memory between passes — use only
243    /// for clips that fit comfortably in RAM.
244    NormalizePeak {
245        /// Target peak level in dBFS (e.g. −1.0). Must be ≤ 0.0.
246        target_db: f32,
247    },
248    /// Noise gate via `FFmpeg`'s `agate` filter.
249    ///
250    /// Audio below `threshold_db` is attenuated; audio above passes through.
251    /// The threshold is converted from dBFS to the linear scale expected by
252    /// `agate`'s `threshold` parameter (`linear = 10^(dB/20)`).
253    ANoiseGate {
254        /// Gate open/close threshold in dBFS (e.g. −40.0).
255        threshold_db: f32,
256        /// Attack time in milliseconds — how quickly the gate opens. Must be > 0.0.
257        attack_ms: f32,
258        /// Release time in milliseconds — how quickly the gate closes. Must be > 0.0.
259        release_ms: f32,
260    },
261    /// Dynamic range compressor via `FFmpeg`'s `acompressor` filter.
262    ///
263    /// Reduces the dynamic range of the audio signal: peaks above
264    /// `threshold_db` are attenuated by `ratio`:1.  `makeup_db` applies
265    /// additional gain after compression to restore perceived loudness.
266    ACompressor {
267        /// Compression threshold in dBFS (e.g. −20.0).
268        threshold_db: f32,
269        /// Compression ratio (e.g. 4.0 = 4:1). Must be ≥ 1.0.
270        ratio: f32,
271        /// Attack time in milliseconds. Must be > 0.0.
272        attack_ms: f32,
273        /// Release time in milliseconds. Must be > 0.0.
274        release_ms: f32,
275        /// Make-up gain in dB applied after compression (e.g. 6.0).
276        makeup_db: f32,
277    },
278    /// Downmix stereo to mono via `FFmpeg`'s `pan` filter.
279    ///
280    /// Both channels are mixed with equal weight:
281    /// `mono|c0=0.5*c0+0.5*c1`.  The output has a single channel.
282    StereoToMono,
283    /// Remap audio channels using `FFmpeg`'s `channelmap` filter.
284    ///
285    /// `mapping` is a `|`-separated list of output channel names taken
286    /// from input channels, e.g. `"FR|FL"` swaps left and right.
287    /// Must not be empty.
288    ChannelMap {
289        /// `FFmpeg` channelmap mapping expression (e.g. `"FR|FL"`).
290        mapping: String,
291    },
292    /// A/V sync correction via audio delay or advance.
293    ///
294    /// Positive `ms`: uses `FFmpeg`'s `adelay` filter to shift audio later.
295    /// Negative `ms`: uses `FFmpeg`'s `atrim` filter to trim the audio start,
296    /// effectively advancing audio by `|ms|` milliseconds.
297    /// Zero `ms`: uses `adelay` with zero delay (no-op).
298    AudioDelay {
299        /// Delay in milliseconds. Positive = delay; negative = advance.
300        ms: f64,
301    },
302    /// Concatenate `n` sequential video input segments via `FFmpeg`'s `concat` filter.
303    ///
304    /// Requires `n` video input slots (0 through `n-1`). `n` must be ≥ 2.
305    ConcatVideo {
306        /// Number of video input segments to concatenate. Must be ≥ 2.
307        n: u32,
308    },
309    /// Concatenate `n` sequential audio input segments via `FFmpeg`'s `concat` filter.
310    ///
311    /// Requires `n` audio input slots (0 through `n-1`). `n` must be ≥ 2.
312    ConcatAudio {
313        /// Number of audio input segments to concatenate. Must be ≥ 2.
314        n: u32,
315    },
316    /// Freeze a single frame for a configurable duration using `FFmpeg`'s `loop` filter.
317    ///
318    /// The frame nearest to `pts` seconds is held for `duration` seconds, then
319    /// playback resumes. Frame numbers are approximated using a 25 fps assumption;
320    /// accuracy depends on the source stream's actual frame rate.
321    FreezeFrame {
322        /// Timestamp of the frame to freeze, in seconds. Must be >= 0.0.
323        pts: f64,
324        /// Duration to hold the frozen frame, in seconds. Must be > 0.0.
325        duration: f64,
326    },
327    /// Scrolling text ticker (right-to-left) using the `drawtext` filter.
328    ///
329    /// The text starts off-screen to the right and scrolls left at
330    /// `speed_px_per_sec` pixels per second using the expression
331    /// `x = w - t * speed`.
332    Ticker {
333        /// Text to display. Special characters (`\`, `:`, `'`) are escaped.
334        text: String,
335        /// Y position as an `FFmpeg` expression, e.g. `"h-50"` or `"10"`.
336        y: String,
337        /// Horizontal scroll speed in pixels per second (must be > 0.0).
338        speed_px_per_sec: f32,
339        /// Font size in points.
340        font_size: u32,
341        /// Font color as an `FFmpeg` color string, e.g. `"white"` or `"0xFFFFFF"`.
342        font_color: String,
343    },
344    /// Join two video clips with a cross-dissolve transition.
345    ///
346    /// Compound step — expands in `filter_inner` to:
347    /// ```text
348    /// in0 → trim(end=clip_a_end+dissolve_dur) → setpts → xfade[0]
349    /// in1 → trim(start=max(0, clip_b_start−dissolve_dur)) → setpts → xfade[1]
350    /// ```
351    ///
352    /// Requires two video input slots: slot 0 = clip A, slot 1 = clip B.
353    /// `clip_a_end` and `dissolve_dur` must be > 0.0.
354    JoinWithDissolve {
355        /// Timestamp (seconds) where clip A ends. Must be > 0.0.
356        clip_a_end: f64,
357        /// Timestamp (seconds) where clip B content starts (before the overlap).
358        clip_b_start: f64,
359        /// Cross-dissolve overlap duration in seconds. Must be > 0.0.
360        dissolve_dur: f64,
361    },
362    /// Composite a PNG image (watermark / logo) over video with optional opacity.
363    ///
364    /// This is a compound step: internally it creates a `movie` source,
365    /// a `lut` alpha-scaling filter, and an `overlay` compositing filter.
366    /// The image file is loaded once at graph construction time.
367    OverlayImage {
368        /// Absolute or relative path to the `.png` file.
369        path: String,
370        /// Horizontal position as an `FFmpeg` expression, e.g. `"10"` or `"W-w-10"`.
371        x: String,
372        /// Vertical position as an `FFmpeg` expression, e.g. `"10"` or `"H-h-10"`.
373        y: String,
374        /// Opacity 0.0 (fully transparent) to 1.0 (fully opaque).
375        opacity: f32,
376    },
377
378    /// Blend a `top` layer over the current stream (bottom) using the given mode.
379    ///
380    /// This is a compound step:
381    /// - **Normal** mode: `[top]colorchannelmixer=aa=<opacity>[top_faded];
382    ///   [bottom][top_faded]overlay=format=auto:shortest=1[out]`
383    ///   (the `colorchannelmixer` step is omitted when `opacity == 1.0`).
384    /// - All other modes return [`crate::FilterError::InvalidConfig`] from
385    ///   [`crate::FilterGraphBuilder::build`] until implemented.
386    ///
387    /// The `top` builder's steps are applied to the second input slot (`in1`).
388    /// `opacity` is clamped to `[0.0, 1.0]` by the builder method.
389    ///
390    /// `Box<FilterGraphBuilder>` is used to break the otherwise-recursive type:
391    /// `FilterStep` → `FilterGraphBuilder` → `Vec<FilterStep>`.
392    Blend {
393        /// Filter pipeline for the top (foreground) layer.
394        top: Box<FilterGraphBuilder>,
395        /// How the two layers are combined.
396        mode: BlendMode,
397        /// Opacity of the top layer in `[0.0, 1.0]`; 1.0 = fully opaque.
398        opacity: f32,
399    },
400
401    /// Remove pixels matching `color` using `FFmpeg`'s `chromakey` filter,
402    /// producing a `yuva420p` output with transparent areas where the key
403    /// color was detected.
404    ///
405    /// Use this for YCbCr-encoded sources (most video).  For RGB sources
406    /// use `colorkey` instead.
407    ChromaKey {
408        /// `FFmpeg` color string, e.g. `"green"`, `"0x00FF00"`, `"#00FF00"`.
409        color: String,
410        /// Match radius in `[0.0, 1.0]`; higher = more pixels removed.
411        similarity: f32,
412        /// Edge softness in `[0.0, 1.0]`; `0.0` = hard edge.
413        blend: f32,
414    },
415
416    /// Remove pixels matching `color` in RGB space using `FFmpeg`'s `colorkey`
417    /// filter, producing an `rgba` output with transparent areas where the key
418    /// color was detected.
419    ///
420    /// Use this for RGB-encoded sources.  For YCbCr-encoded video (most video)
421    /// use `chromakey` instead.
422    ColorKey {
423        /// `FFmpeg` color string, e.g. `"green"`, `"0x00FF00"`, `"#00FF00"`.
424        color: String,
425        /// Match radius in `[0.0, 1.0]`; higher = more pixels removed.
426        similarity: f32,
427        /// Edge softness in `[0.0, 1.0]`; `0.0` = hard edge.
428        blend: f32,
429    },
430
431    /// Reduce color spill from the key color on subject edges using `FFmpeg`'s
432    /// `hue` filter to desaturate the spill hue region.
433    ///
434    /// Applies `hue=h=0:s=(1.0 - strength)`.  `strength=0.0` leaves the image
435    /// unchanged; `strength=1.0` fully desaturates.
436    ///
437    /// `key_color` is stored for future use by a more targeted per-hue
438    /// implementation.
439    SpillSuppress {
440        /// `FFmpeg` color string identifying the spill color, e.g. `"green"`.
441        key_color: String,
442        /// Suppression intensity in `[0.0, 1.0]`; `0.0` = no effect, `1.0` = full suppression.
443        strength: f32,
444    },
445
446    /// Merge a grayscale `matte` as the alpha channel of the input video using
447    /// `FFmpeg`'s `alphamerge` filter.
448    ///
449    /// White (luma=255) in the matte produces fully opaque output; black (luma=0)
450    /// produces fully transparent output.
451    ///
452    /// This is a compound step: the `matte` builder's pipeline is applied to the
453    /// second input slot (`in1`) before the `alphamerge` filter is linked.
454    ///
455    /// `Box<FilterGraphBuilder>` breaks the otherwise-recursive type, following
456    /// the same pattern as [`FilterStep::Blend`].
457    AlphaMatte {
458        /// Pipeline for the grayscale matte stream (slot 1).
459        matte: Box<FilterGraphBuilder>,
460    },
461
462    /// Key out pixels by luminance value using `FFmpeg`'s `lumakey` filter.
463    ///
464    /// Pixels whose normalized luma is within `tolerance` of `threshold` are
465    /// made transparent.  When `invert` is `true`, a `geq` filter is appended
466    /// to negate the alpha channel, effectively swapping transparent and opaque
467    /// regions.
468    ///
469    /// - `threshold`: luma cutoff in `[0.0, 1.0]`; `0.0` = black, `1.0` = white.
470    /// - `tolerance`: match radius around the threshold in `[0.0, 1.0]`.
471    /// - `softness`: edge feather width in `[0.0, 1.0]`; `0.0` = hard edge.
472    /// - `invert`: when `false`, keys out bright regions (pixels matching the
473    ///   threshold); when `true`, the alpha is negated after keying, making
474    ///   the complementary region transparent instead.
475    ///
476    /// Output carries an alpha channel (`yuva420p`).
477    LumaKey {
478        /// Luma cutoff in `[0.0, 1.0]`.
479        threshold: f32,
480        /// Match radius around the threshold in `[0.0, 1.0]`.
481        tolerance: f32,
482        /// Edge feather width in `[0.0, 1.0]`; `0.0` = hard edge.
483        softness: f32,
484        /// When `true`, the alpha channel is negated after keying.
485        invert: bool,
486    },
487
488    /// Apply a rectangular alpha mask using `FFmpeg`'s `geq` filter.
489    ///
490    /// Pixels inside the rectangle defined by (`x`, `y`, `width`, `height`)
491    /// are made fully opaque (`alpha=255`); pixels outside are made fully
492    /// transparent (`alpha=0`).  When `invert` is `true` the roles are swapped:
493    /// inside becomes transparent and outside becomes opaque.
494    ///
495    /// - `x`, `y`: top-left corner of the rectangle (in pixels).
496    /// - `width`, `height`: rectangle dimensions (must be > 0).
497    /// - `invert`: when `false`, keeps the interior; when `true`, keeps the
498    ///   exterior.
499    ///
500    /// `width` and `height` are validated in [`build`](FilterGraphBuilder::build);
501    /// zero values return [`crate::FilterError::InvalidConfig`].
502    ///
503    /// The output carries an alpha channel (`rgba`).
504    RectMask {
505        /// Left edge of the rectangle (pixels from the left).
506        x: u32,
507        /// Top edge of the rectangle (pixels from the top).
508        y: u32,
509        /// Width of the rectangle in pixels (must be > 0).
510        width: u32,
511        /// Height of the rectangle in pixels (must be > 0).
512        height: u32,
513        /// When `true`, the mask is inverted: outside is opaque, inside is transparent.
514        invert: bool,
515    },
516
517    /// Feather (soften) the alpha channel edges using a Gaussian blur.
518    ///
519    /// Splits the stream into a color copy and an alpha copy, blurs the alpha
520    /// plane with `gblur=sigma=<radius>`, then re-merges:
521    ///
522    /// ```text
523    /// [in]split=2[color][with_alpha];
524    /// [with_alpha]alphaextract[alpha_only];
525    /// [alpha_only]gblur=sigma=<radius>[alpha_blurred];
526    /// [color][alpha_blurred]alphamerge[out]
527    /// ```
528    ///
529    /// `radius` is the blur kernel half-size in pixels and must be > 0.
530    /// Validated in [`build`](FilterGraphBuilder::build); `radius == 0` returns
531    /// [`crate::FilterError::InvalidConfig`].
532    ///
533    /// Typically chained after a keying or masking step
534    /// (e.g. [`FilterStep::ChromaKey`], [`FilterStep::RectMask`],
535    /// [`FilterStep::PolygonMatte`]).  Applying this step to a fully-opaque
536    /// video (no prior alpha) is a no-op because a uniform alpha of 255 blurs
537    /// to 255 everywhere.
538    FeatherMask {
539        /// Gaussian blur kernel half-size in pixels (must be > 0).
540        radius: u32,
541    },
542
543    /// Apply a polygon alpha mask using `FFmpeg`'s `geq` filter with a
544    /// crossing-number point-in-polygon test.
545    ///
546    /// Pixels inside the polygon are fully opaque (`alpha=255`); pixels outside
547    /// are fully transparent (`alpha=0`).  When `invert` is `true` the roles
548    /// are swapped.
549    ///
550    /// - `vertices`: polygon corners as `(x, y)` in `[0.0, 1.0]` (normalised
551    ///   to frame size).  Minimum 3, maximum 16.
552    /// - `invert`: when `false`, inside = opaque; when `true`, outside = opaque.
553    ///
554    /// Vertex count and coordinates are validated in
555    /// [`build`](FilterGraphBuilder::build); out-of-range values return
556    /// [`crate::FilterError::InvalidConfig`].
557    ///
558    /// The `geq` expression is constructed from the vertex list at graph
559    /// build time.  Degenerate polygons (zero area) produce a fully-transparent
560    /// mask.  The output carries an alpha channel (`rgba`).
561    PolygonMatte {
562        /// Polygon corners in normalised `[0.0, 1.0]` frame coordinates.
563        vertices: Vec<(f32, f32)>,
564        /// When `true`, the mask is inverted: outside is opaque, inside is transparent.
565        invert: bool,
566    },
567}
568
569/// Convert a color temperature in Kelvin to linear RGB multipliers using
570/// Tanner Helland's algorithm.
571///
572/// Returns `(r, g, b)` each in `[0.0, 1.0]`.
573fn kelvin_to_rgb(temp_k: u32) -> (f64, f64, f64) {
574    let t = (f64::from(temp_k) / 100.0).clamp(10.0, 400.0);
575    let r = if t <= 66.0 {
576        1.0
577    } else {
578        (329.698_727_446_4 * (t - 60.0).powf(-0.133_204_759_2) / 255.0).clamp(0.0, 1.0)
579    };
580    let g = if t <= 66.0 {
581        ((99.470_802_586_1 * t.ln() - 161.119_568_166_1) / 255.0).clamp(0.0, 1.0)
582    } else {
583        ((288.122_169_528_3 * (t - 60.0).powf(-0.075_514_849_2)) / 255.0).clamp(0.0, 1.0)
584    };
585    let b = if t >= 66.0 {
586        1.0
587    } else if t <= 19.0 {
588        0.0
589    } else {
590        ((138.517_731_223_1 * (t - 10.0).ln() - 305.044_792_730_7) / 255.0).clamp(0.0, 1.0)
591    };
592    (r, g, b)
593}
594
595impl FilterStep {
596    /// Returns the libavfilter filter name for this step.
597    pub(crate) fn filter_name(&self) -> &'static str {
598        match self {
599            Self::Trim { .. } => "trim",
600            Self::Scale { .. } => "scale",
601            Self::Crop { .. } => "crop",
602            Self::Overlay { .. } => "overlay",
603            Self::FadeIn { .. }
604            | Self::FadeOut { .. }
605            | Self::FadeInWhite { .. }
606            | Self::FadeOutWhite { .. } => "fade",
607            Self::AFadeIn { .. } | Self::AFadeOut { .. } => "afade",
608            Self::Rotate { .. } => "rotate",
609            Self::ToneMap(_) => "tonemap",
610            Self::Volume(_) => "volume",
611            Self::Amix(_) => "amix",
612            // ParametricEq is a compound step; "equalizer" is used only by
613            // validate_filter_steps as a best-effort existence check.  The
614            // actual nodes are built by `filter_inner::add_parametric_eq_chain`.
615            Self::ParametricEq { .. } => "equalizer",
616            Self::Lut3d { .. } => "lut3d",
617            Self::Eq { .. } => "eq",
618            Self::Curves { .. } => "curves",
619            Self::WhiteBalance { .. } => "colorchannelmixer",
620            Self::Hue { .. } => "hue",
621            Self::Gamma { .. } => "eq",
622            Self::ThreeWayCC { .. } => "curves",
623            Self::Vignette { .. } => "vignette",
624            Self::HFlip => "hflip",
625            Self::VFlip => "vflip",
626            Self::Reverse => "reverse",
627            Self::AReverse => "areverse",
628            Self::Pad { .. } => "pad",
629            // FitToAspect is implemented as scale + pad; "scale" is validated at
630            // build time.  The pad filter is inserted by filter_inner at graph
631            // construction time.
632            Self::FitToAspect { .. } => "scale",
633            Self::GBlur { .. } => "gblur",
634            Self::Unsharp { .. } => "unsharp",
635            Self::Hqdn3d { .. } => "hqdn3d",
636            Self::Nlmeans { .. } => "nlmeans",
637            Self::Yadif { .. } => "yadif",
638            Self::XFade { .. } => "xfade",
639            Self::DrawText { .. } | Self::Ticker { .. } => "drawtext",
640            // "setpts" is checked at build-time; the audio path uses "atempo"
641            // which is verified at graph-construction time in filter_inner.
642            Self::Speed { .. } => "setpts",
643            Self::FreezeFrame { .. } => "loop",
644            Self::LoudnessNormalize { .. } => "ebur128",
645            Self::NormalizePeak { .. } => "astats",
646            Self::ANoiseGate { .. } => "agate",
647            Self::ACompressor { .. } => "acompressor",
648            Self::StereoToMono => "pan",
649            Self::ChannelMap { .. } => "channelmap",
650            // AudioDelay dispatches to adelay (positive) or atrim (negative) at
651            // build time; "adelay" is returned here for validate_filter_steps only.
652            Self::AudioDelay { .. } => "adelay",
653            Self::ConcatVideo { .. } | Self::ConcatAudio { .. } => "concat",
654            // JoinWithDissolve is a compound step (trim+setpts → xfade ← setpts+trim);
655            // "xfade" is used by validate_filter_steps as the primary filter check.
656            Self::JoinWithDissolve { .. } => "xfade",
657            Self::SubtitlesSrt { .. } => "subtitles",
658            Self::SubtitlesAss { .. } => "ass",
659            // OverlayImage is a compound step (movie → lut → overlay); "overlay"
660            // is used only by validate_filter_steps as a best-effort existence
661            // check.  The actual graph construction is handled by
662            // `filter_inner::build::add_overlay_image_step`.
663            Self::OverlayImage { .. } => "overlay",
664            // Blend is a compound step; "overlay" is used as the primary filter
665            // for validate_filter_steps.  Unimplemented modes are caught by
666            // build() before validate_filter_steps is reached.
667            Self::Blend { .. } => "overlay",
668            Self::ChromaKey { .. } => "chromakey",
669            Self::ColorKey { .. } => "colorkey",
670            Self::SpillSuppress { .. } => "hue",
671            // AlphaMatte is a compound step (matte pipeline → alphamerge);
672            // "alphamerge" is used by validate_filter_steps as the primary check.
673            Self::AlphaMatte { .. } => "alphamerge",
674            // LumaKey is a compound step when invert=true (lumakey + geq);
675            // "lumakey" is used here for validate_filter_steps.
676            Self::LumaKey { .. } => "lumakey",
677            // RectMask uses geq to set alpha per-pixel based on rectangle bounds.
678            Self::RectMask { .. } => "geq",
679            // FeatherMask is a compound step (split → alphaextract → gblur → alphamerge);
680            // "alphaextract" is used by validate_filter_steps as the primary check.
681            Self::FeatherMask { .. } => "alphaextract",
682            // PolygonMatte uses geq with a crossing-number point-in-polygon expression.
683            Self::PolygonMatte { .. } => "geq",
684        }
685    }
686
687    /// Returns the `args` string passed to `avfilter_graph_create_filter`.
688    pub(crate) fn args(&self) -> String {
689        match self {
690            Self::Trim { start, end } => format!("start={start}:end={end}"),
691            Self::Scale {
692                width,
693                height,
694                algorithm,
695            } => format!("w={width}:h={height}:flags={}", algorithm.as_flags_str()),
696            Self::Crop {
697                x,
698                y,
699                width,
700                height,
701            } => {
702                format!("x={x}:y={y}:w={width}:h={height}")
703            }
704            Self::Overlay { x, y } => format!("x={x}:y={y}"),
705            Self::FadeIn { start, duration } => {
706                format!("type=in:start_time={start}:duration={duration}")
707            }
708            Self::FadeOut { start, duration } => {
709                format!("type=out:start_time={start}:duration={duration}")
710            }
711            Self::FadeInWhite { start, duration } => {
712                format!("type=in:start_time={start}:duration={duration}:color=white")
713            }
714            Self::FadeOutWhite { start, duration } => {
715                format!("type=out:start_time={start}:duration={duration}:color=white")
716            }
717            Self::AFadeIn { start, duration } => {
718                format!("type=in:start_time={start}:duration={duration}")
719            }
720            Self::AFadeOut { start, duration } => {
721                format!("type=out:start_time={start}:duration={duration}")
722            }
723            Self::Rotate {
724                angle_degrees,
725                fill_color,
726            } => {
727                format!(
728                    "angle={}:fillcolor={fill_color}",
729                    angle_degrees.to_radians()
730                )
731            }
732            Self::ToneMap(algorithm) => format!("tonemap={}", algorithm.as_str()),
733            Self::Volume(db) => format!("volume={db}dB"),
734            Self::Amix(inputs) => format!("inputs={inputs}"),
735            // args() for ParametricEq is not used by the build loop (which is
736            // bypassed in favour of add_parametric_eq_chain); provided here for
737            // completeness using the first band's args.
738            Self::ParametricEq { bands } => bands.first().map(EqBand::args).unwrap_or_default(),
739            Self::Lut3d { path } => format!("file={path}:interp=trilinear"),
740            Self::Eq {
741                brightness,
742                contrast,
743                saturation,
744            } => format!("brightness={brightness}:contrast={contrast}:saturation={saturation}"),
745            Self::Curves { master, r, g, b } => {
746                let fmt = |pts: &[(f32, f32)]| -> String {
747                    pts.iter()
748                        .map(|(x, y)| format!("{x}/{y}"))
749                        .collect::<Vec<_>>()
750                        .join(" ")
751                };
752                [("master", master.as_slice()), ("r", r), ("g", g), ("b", b)]
753                    .iter()
754                    .filter(|(_, pts)| !pts.is_empty())
755                    .map(|(name, pts)| format!("{name}='{}'", fmt(pts)))
756                    .collect::<Vec<_>>()
757                    .join(":")
758            }
759            Self::WhiteBalance {
760                temperature_k,
761                tint,
762            } => {
763                let (r, g, b) = kelvin_to_rgb(*temperature_k);
764                let g_adj = (g + f64::from(*tint)).clamp(0.0, 2.0);
765                format!("rr={r}:gg={g_adj}:bb={b}")
766            }
767            Self::Hue { degrees } => format!("h={degrees}"),
768            Self::Gamma { r, g, b } => format!("gamma_r={r}:gamma_g={g}:gamma_b={b}"),
769            Self::Vignette { angle, x0, y0 } => {
770                let cx = if *x0 == 0.0 {
771                    "w/2".to_string()
772                } else {
773                    x0.to_string()
774                };
775                let cy = if *y0 == 0.0 {
776                    "h/2".to_string()
777                } else {
778                    y0.to_string()
779                };
780                format!("angle={angle}:x0={cx}:y0={cy}")
781            }
782            Self::ThreeWayCC { lift, gamma, gain } => {
783                // Convert lift/gamma/gain to a 3-point per-channel curves representation.
784                // The formula maps:
785                //   input 0.0 → (lift - 1.0) * gain  (black point)
786                //   input 0.5 → (0.5 * lift)^(1/gamma) * gain  (midtone)
787                //   input 1.0 → gain  (white point)
788                // All neutral (1.0) produces the identity curve 0/0 0.5/0.5 1/1.
789                let curve = |l: f32, gm: f32, gn: f32| -> String {
790                    let l = f64::from(l);
791                    let gm = f64::from(gm);
792                    let gn = f64::from(gn);
793                    let black = ((l - 1.0) * gn).clamp(0.0, 1.0);
794                    let mid = ((0.5 * l).powf(1.0 / gm) * gn).clamp(0.0, 1.0);
795                    let white = gn.clamp(0.0, 1.0);
796                    format!("0/{black} 0.5/{mid} 1/{white}")
797                };
798                format!(
799                    "r='{}':g='{}':b='{}'",
800                    curve(lift.r, gamma.r, gain.r),
801                    curve(lift.g, gamma.g, gain.g),
802                    curve(lift.b, gamma.b, gain.b),
803                )
804            }
805            Self::HFlip | Self::VFlip | Self::Reverse | Self::AReverse => String::new(),
806            Self::GBlur { sigma } => format!("sigma={sigma}"),
807            Self::Unsharp {
808                luma_strength,
809                chroma_strength,
810            } => format!(
811                "luma_msize_x=5:luma_msize_y=5:luma_amount={luma_strength}:\
812                 chroma_msize_x=5:chroma_msize_y=5:chroma_amount={chroma_strength}"
813            ),
814            Self::Hqdn3d {
815                luma_spatial,
816                chroma_spatial,
817                luma_tmp,
818                chroma_tmp,
819            } => format!("{luma_spatial}:{chroma_spatial}:{luma_tmp}:{chroma_tmp}"),
820            Self::Nlmeans { strength } => format!("s={strength}"),
821            Self::Yadif { mode } => format!("mode={}", *mode as i32),
822            Self::XFade {
823                transition,
824                duration,
825                offset,
826            } => {
827                let t = transition.as_str();
828                format!("transition={t}:duration={duration}:offset={offset}")
829            }
830            Self::DrawText { opts } => {
831                // Escape special characters recognised by the drawtext filter.
832                let escaped = opts
833                    .text
834                    .replace('\\', "\\\\")
835                    .replace(':', "\\:")
836                    .replace('\'', "\\'");
837                let mut parts = vec![
838                    format!("text='{escaped}'"),
839                    format!("x={}", opts.x),
840                    format!("y={}", opts.y),
841                    format!("fontsize={}", opts.font_size),
842                    format!("fontcolor={}@{:.2}", opts.font_color, opts.opacity),
843                ];
844                if let Some(ref ff) = opts.font_file {
845                    parts.push(format!("fontfile={ff}"));
846                }
847                if let Some(ref bc) = opts.box_color {
848                    parts.push("box=1".to_string());
849                    parts.push(format!("boxcolor={bc}"));
850                    parts.push(format!("boxborderw={}", opts.box_border_width));
851                }
852                parts.join(":")
853            }
854            Self::Ticker {
855                text,
856                y,
857                speed_px_per_sec,
858                font_size,
859                font_color,
860            } => {
861                // Use the same escaping as DrawText.
862                let escaped = text
863                    .replace('\\', "\\\\")
864                    .replace(':', "\\:")
865                    .replace('\'', "\\'");
866                // x = w - t * speed: at t=0 the text starts fully off the right
867                // edge (x = w) and scrolls left by `speed` pixels per second.
868                format!(
869                    "text='{escaped}':x=w-t*{speed_px_per_sec}:y={y}:\
870                     fontsize={font_size}:fontcolor={font_color}"
871                )
872            }
873            // Video path: divide PTS by factor to change playback speed.
874            // Audio path args are built by filter_inner (chained atempo).
875            Self::Speed { factor } => format!("PTS/{factor}"),
876            // args() is not used by the build loop for LoudnessNormalize (two-pass
877            // is handled entirely in filter_inner); provided here for completeness.
878            Self::LoudnessNormalize { .. } => "peak=true:metadata=1".to_string(),
879            // args() is not used by the build loop for NormalizePeak (two-pass
880            // is handled entirely in filter_inner); provided here for completeness.
881            Self::NormalizePeak { .. } => "metadata=1".to_string(),
882            Self::FreezeFrame { pts, duration } => {
883                // The `loop` filter needs a frame index and a loop count, not PTS or
884                // wall-clock duration.  We approximate both using 25 fps; accuracy
885                // depends on the source stream's actual frame rate.
886                #[allow(clippy::cast_possible_truncation)]
887                let start = (*pts * 25.0) as i64;
888                #[allow(clippy::cast_possible_truncation)]
889                let loop_count = (*duration * 25.0) as i64;
890                format!("loop={loop_count}:size=1:start={start}")
891            }
892            Self::SubtitlesSrt { path } | Self::SubtitlesAss { path } => {
893                format!("filename={path}")
894            }
895            // args() for OverlayImage returns the overlay positional args (x:y).
896            // These are not consumed by add_and_link_step (which is bypassed for
897            // this compound step); they exist here only for completeness.
898            Self::OverlayImage { x, y, .. } => format!("{x}:{y}"),
899            // args() for Blend is not consumed by add_and_link_step (which is
900            // bypassed in favour of add_blend_normal_step).  Provided for
901            // completeness using the Normal-mode overlay args.
902            Self::Blend { .. } => "format=auto:shortest=1".to_string(),
903            Self::ChromaKey {
904                color,
905                similarity,
906                blend,
907            } => format!("color={color}:similarity={similarity}:blend={blend}"),
908            Self::ColorKey {
909                color,
910                similarity,
911                blend,
912            } => format!("color={color}:similarity={similarity}:blend={blend}"),
913            Self::SpillSuppress { strength, .. } => format!("h=0:s={}", 1.0 - strength),
914            // args() is not consumed by add_and_link_step (which is bypassed for
915            // this compound step); provided here for completeness.
916            Self::AlphaMatte { .. } => String::new(),
917            Self::LumaKey {
918                threshold,
919                tolerance,
920                softness,
921                ..
922            } => format!("threshold={threshold}:tolerance={tolerance}:softness={softness}"),
923            // args() is not consumed by add_and_link_step (which is bypassed for
924            // this compound step); provided here for completeness.
925            Self::FeatherMask { .. } => String::new(),
926            Self::RectMask {
927                x,
928                y,
929                width,
930                height,
931                invert,
932            } => {
933                let xw = x + width - 1;
934                let yh = y + height - 1;
935                let (inside, outside) = if *invert { (0, 255) } else { (255, 0) };
936                format!(
937                    "r='r(X,Y)':g='g(X,Y)':b='b(X,Y)':\
938                     a='if(between(X,{x},{xw})*between(Y,{y},{yh}),{inside},{outside})'"
939                )
940            }
941            Self::PolygonMatte { vertices, invert } => {
942                // Build a crossing-number point-in-polygon expression.
943                // For each edge (ax,ay)→(bx,by), a horizontal ray from (X,Y) going
944                // right crosses the edge when Y is in [min(ay,by), max(ay,by)) and
945                // the intersection x > X.  Exact horizontal edges (dy==0) are skipped.
946                let n = vertices.len();
947                let mut edge_exprs = Vec::new();
948                for i in 0..n {
949                    let (ax, ay) = vertices[i];
950                    let (bx, by) = vertices[(i + 1) % n];
951                    let dy = by - ay;
952                    if dy == 0.0 {
953                        // Horizontal edge — never crosses a horizontal ray; skip.
954                        continue;
955                    }
956                    let min_y = ay.min(by);
957                    let max_y = ay.max(by);
958                    let dx = bx - ax;
959                    // x_intersect = ax*iw + (Y - ay*ih) * dx*iw / (dy*ih)
960                    edge_exprs.push(format!(
961                        "if(gte(Y,{min_y}*ih)*lt(Y,{max_y}*ih)*gt({ax}*iw+(Y-{ay}*ih)*{dx}*iw/({dy}*ih),X),1,0)"
962                    ));
963                }
964                let sum = if edge_exprs.is_empty() {
965                    "0".to_string()
966                } else {
967                    edge_exprs.join("+")
968                };
969                let (inside, outside) = if *invert { (0, 255) } else { (255, 0) };
970                format!(
971                    "r='r(X,Y)':g='g(X,Y)':b='b(X,Y)':\
972                     a='if(gt(mod({sum},2),0),{inside},{outside})'"
973                )
974            }
975            Self::FitToAspect { width, height, .. } => {
976                // Scale to fit within the target dimensions, preserving the source
977                // aspect ratio.  The accompanying pad filter (inserted by
978                // filter_inner after this scale filter) centres the result on the
979                // target canvas.
980                format!("w={width}:h={height}:force_original_aspect_ratio=decrease")
981            }
982            Self::Pad {
983                width,
984                height,
985                x,
986                y,
987                color,
988            } => {
989                let px = if *x < 0 {
990                    "(ow-iw)/2".to_string()
991                } else {
992                    x.to_string()
993                };
994                let py = if *y < 0 {
995                    "(oh-ih)/2".to_string()
996                } else {
997                    y.to_string()
998                };
999                format!("width={width}:height={height}:x={px}:y={py}:color={color}")
1000            }
1001            Self::ANoiseGate {
1002                threshold_db,
1003                attack_ms,
1004                release_ms,
1005            } => {
1006                // `agate` expects threshold as a linear amplitude ratio (0.0–1.0).
1007                let threshold_linear = 10f32.powf(threshold_db / 20.0);
1008                format!("threshold={threshold_linear:.6}:attack={attack_ms}:release={release_ms}")
1009            }
1010            Self::ACompressor {
1011                threshold_db,
1012                ratio,
1013                attack_ms,
1014                release_ms,
1015                makeup_db,
1016            } => {
1017                format!(
1018                    "threshold={threshold_db}dB:ratio={ratio}:attack={attack_ms}:\
1019                     release={release_ms}:makeup={makeup_db}dB"
1020                )
1021            }
1022            Self::StereoToMono => "mono|c0=0.5*c0+0.5*c1".to_string(),
1023            Self::ChannelMap { mapping } => format!("map={mapping}"),
1024            // args() is not used directly for AudioDelay — the audio build loop
1025            // dispatches to add_raw_filter_step with the correct filter name and
1026            // args based on the sign of ms.  These are provided for completeness.
1027            Self::AudioDelay { ms } => {
1028                if *ms >= 0.0 {
1029                    format!("delays={ms}:all=1")
1030                } else {
1031                    format!("start={}", -ms / 1000.0)
1032                }
1033            }
1034            Self::ConcatVideo { n } => format!("n={n}:v=1:a=0"),
1035            Self::ConcatAudio { n } => format!("n={n}:v=0:a=1"),
1036            // args() for JoinWithDissolve is not used by the build loop (which is
1037            // bypassed in favour of add_join_with_dissolve_step); provided here for
1038            // completeness using the xfade args.
1039            Self::JoinWithDissolve {
1040                clip_a_end,
1041                dissolve_dur,
1042                ..
1043            } => format!("transition=dissolve:duration={dissolve_dur}:offset={clip_a_end}"),
1044        }
1045    }
1046}