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}