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