ff_filter/graph/filter_step.rs
1//! Internal filter step representation.
2
3use std::time::Duration;
4
5use super::FfmpegToken;
6use super::builder::FilterGraphBuilder;
7use super::types::{
8 DrawTextOptions, EqBand, PitchAlgo, Rgb, ScaleAlgorithm, ToneMap, XfadeTransition, YadifMode,
9};
10
11use crate::animation::{AnimatedValue, AnimationEntry, AnimationTrack, Keyframe};
12use crate::blend::BlendMode;
13use crate::composite::CompositeOp;
14use ff_format::{AlphaMode, ColorPrimaries, ColorRange, ColorSpace, ColorTransfer, PixelFormat};
15
16/// Escapes a filesystem path for use as a value inside an `FFmpeg` filter
17/// argument string (e.g. `lut3d`'s `file=` option).
18///
19/// `FFmpeg`'s filter-argument parser treats `:` as an option separator and `\`
20/// as an escape character, so Windows paths like `D:\dir\file.cube` break the
21/// parser. Normalising backslashes to forward slashes (accepted on Windows) and
22/// escaping the drive colon as `\:` yields a value the parser accepts.
23pub(crate) fn escape_filter_path(path: &str) -> String {
24 path.replace('\\', "/").replace(':', "\\:")
25}
26
27// FilterStep
28
29/// A single step in a filter chain.
30///
31/// Used by [`crate::FilterGraphBuilder`] to build pipeline filter graphs, and by
32/// [`crate::AudioTrack::effects`] to attach per-track effects in a multi-track mix.
33// Open catalog: new filter steps are added over time (e.g. `Raw`), so downstream
34// matches must carry a `_` arm.
35#[non_exhaustive]
36#[derive(Debug, Clone)]
37#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
38pub enum FilterStep {
39 /// Convert video to a suitable pixel format from the given options.
40 Format {
41 pix_fmts: Vec<PixelFormat>,
42 color_spaces: Vec<ColorSpace>,
43 color_ranges: Vec<ColorRange>,
44 },
45
46 /// Tag the stream's colour metadata via the `setparams` filter.
47 ///
48 /// Each field is emitted only when `Some` **and** its `FfmpegToken` is
49 /// `Some` (e.g. `Unknown` → skipped), so an all-`None` step yields empty
50 /// args. This is the consumer for `ColorPrimaries` / `ColorTransfer`, whose
51 /// tokens the `format` filter has no option for.
52 SetParams {
53 color_space: Option<ColorSpace>,
54 color_range: Option<ColorRange>,
55 color_primaries: Option<ColorPrimaries>,
56 color_trc: Option<ColorTransfer>,
57 },
58
59 /// Trim: keep only frames in `[start, end)` seconds. Either bound may be
60 /// `None` for an open range (`start=None` keeps from 0; `end=None` keeps to
61 /// end-of-stream).
62 Trim {
63 start: Option<f64>,
64 end: Option<f64>,
65 },
66 /// Reset timestamps to start at zero (`setpts=PTS-STARTPTS`). Typically
67 /// follows a [`Trim`](Self::Trim) so a placed clip's timeline offset is
68 /// applied from zero.
69 ResetPts,
70 /// Shift every frame's presentation timestamp forward by `seconds`
71 /// (`setpts=PTS+{seconds}/TB`). Places a layer later on the output timeline.
72 OffsetPts { seconds: f64 },
73 /// Scale to a new resolution using the given resampling algorithm.
74 Scale {
75 width: u32,
76 height: u32,
77 algorithm: ScaleAlgorithm,
78 },
79 /// Crop a rectangular region.
80 Crop {
81 x: u32,
82 y: u32,
83 width: u32,
84 height: u32,
85 },
86 /// Overlay a second stream at position `(x, y)`.
87 Overlay { x: i32, y: i32 },
88 /// Fade-in from black starting at `start` seconds, over `duration` seconds.
89 FadeIn { start: f64, duration: f64 },
90 /// Fade-out to black starting at `start` seconds, over `duration` seconds.
91 FadeOut { start: f64, duration: f64 },
92 /// Audio fade-in from silence starting at `start` seconds, over `duration` seconds.
93 AFadeIn { start: f64, duration: f64 },
94 /// Audio fade-out to silence starting at `start` seconds, over `duration` seconds.
95 AFadeOut { start: f64, duration: f64 },
96 /// Fade-in from white starting at `start` seconds, over `duration` seconds.
97 FadeInWhite { start: f64, duration: f64 },
98 /// Fade-out to white starting at `start` seconds, over `duration` seconds.
99 FadeOutWhite { start: f64, duration: f64 },
100 /// Rotate clockwise by `angle_degrees`, filling exposed areas with `fill_color`.
101 Rotate {
102 angle_degrees: f64,
103 fill_color: String,
104 },
105 /// HDR-to-SDR tone mapping.
106 ToneMap(ToneMap),
107 /// Adjust audio volume (in dB; negative = quieter).
108 Volume(f64),
109 /// Mix `n` audio inputs together (additive: the inputs are summed, not
110 /// averaged, via `normalize=0`).
111 Amix(usize),
112 /// Multi-band parametric equalizer (low-shelf, high-shelf, or peak bands).
113 ///
114 /// Each band maps to its own `FFmpeg` filter node chained in sequence.
115 /// The `bands` vec must not be empty.
116 ParametricEq { bands: Vec<EqBand> },
117 /// Apply a 3D LUT from a `.cube` or `.3dl` file.
118 Lut3d { path: String },
119 /// Brightness/contrast/saturation adjustment via `FFmpeg` `eq` filter.
120 ///
121 /// `temperature`/`tint` are carried for the derive-to-ff-render `ColorGradeNode`
122 /// mapping (GPU-default path) and are **not** applied by the CPU `eq` filter, which
123 /// has no such option; they are a GPU-only enrichment (like `MotionBlur.sub_frames`
124 /// on the CPU `tblend`). Range −1.0..=1.0 each, neutral 0.0.
125 Eq {
126 brightness: f32,
127 contrast: f32,
128 saturation: f32,
129 /// Colour temperature offset (−1.0 cool/blue, +1.0 warm/orange). GPU-only.
130 temperature: f32,
131 /// Colour tint offset (−1.0 magenta, +1.0 green). GPU-only.
132 tint: f32,
133 },
134 /// Brightness / contrast / saturation / gamma via `FFmpeg` `eq` filter (optionally animated).
135 ///
136 /// Arguments are evaluated at [`Duration::ZERO`] for the initial graph build.
137 /// Per-frame updates are applied via `avfilter_graph_send_command` in #363.
138 ///
139 /// `temperature`/`tint` are carried for the GPU `ColorGradeNode` mapping and are
140 /// **not** applied by the CPU `eq` filter (a GPU-only enrichment; see [`Self::Eq`]).
141 EqAnimated {
142 /// Brightness offset. Range: −1.0 – 1.0 (neutral: 0.0).
143 brightness: AnimatedValue<f64>,
144 /// Contrast multiplier. Range: 0.0 – 3.0 (neutral: 1.0).
145 contrast: AnimatedValue<f64>,
146 /// Saturation multiplier. Range: 0.0 – 3.0 (neutral: 1.0; 0.0 = grayscale).
147 saturation: AnimatedValue<f64>,
148 /// Global gamma correction. Range: 0.1 – 10.0 (neutral: 1.0).
149 gamma: AnimatedValue<f64>,
150 /// Colour temperature offset (−1.0 – 1.0; neutral 0.0). GPU-only.
151 temperature: AnimatedValue<f64>,
152 /// Colour tint offset (−1.0 – 1.0; neutral 0.0). GPU-only.
153 tint: AnimatedValue<f64>,
154 },
155 /// Three-way color balance (shadows / midtones / highlights) via `FFmpeg` `colorbalance` filter
156 /// (optionally animated).
157 ///
158 /// Each tuple is `(R, G, B)`. Valid range per component: −1.0 – 1.0 (neutral: 0.0).
159 ///
160 /// Arguments are evaluated at [`Duration::ZERO`] for the initial graph build.
161 /// Per-frame updates are applied via `avfilter_graph_send_command` in #363.
162 ColorBalanceAnimated {
163 /// Shadows (lift) correction per channel. `FFmpeg` params: `"rs"`, `"gs"`, `"bs"`.
164 lift: AnimatedValue<(f64, f64, f64)>,
165 /// Midtones (gamma) correction per channel. `FFmpeg` params: `"rm"`, `"gm"`, `"bm"`.
166 gamma: AnimatedValue<(f64, f64, f64)>,
167 /// Highlights (gain) correction per channel. `FFmpeg` params: `"rh"`, `"gh"`, `"bh"`.
168 gain: AnimatedValue<(f64, f64, f64)>,
169 },
170 /// Per-channel RGB color curves adjustment.
171 Curves {
172 master: Vec<(f32, f32)>,
173 r: Vec<(f32, f32)>,
174 g: Vec<(f32, f32)>,
175 b: Vec<(f32, f32)>,
176 },
177 /// White balance correction via `colorchannelmixer`.
178 WhiteBalance { temperature_k: u32, tint: f32 },
179 /// Hue rotation by an arbitrary angle.
180 Hue { degrees: f32 },
181 /// Per-channel gamma correction via `FFmpeg` `eq` filter.
182 Gamma { r: f32, g: f32, b: f32 },
183 /// Three-way colour corrector (lift / gamma / gain) via `FFmpeg` `curves` filter.
184 ThreeWayCC {
185 /// Affects shadows (blacks). Neutral: `Rgb::NEUTRAL`.
186 lift: Rgb,
187 /// Affects midtones. Neutral: `Rgb::NEUTRAL`. All components must be > 0.0.
188 gamma: Rgb,
189 /// Affects highlights (whites). Neutral: `Rgb::NEUTRAL`.
190 gain: Rgb,
191 },
192 /// Three-way (lift/gamma/gain) colour corrector with optionally animated
193 /// per-channel parameters.
194 ///
195 /// Parameters are evaluated at [`Duration::ZERO`] for the graph build. `curves`
196 /// takes string curve options rather than scalars, so it is not driven by
197 /// `send_command` here: the CPU (`libavfilter`) path renders the `Duration::ZERO`
198 /// curves statically; the GPU path (`ff_render::ColorWheelsNode`) animates the
199 /// parameters per frame. Each `[R, G, B]` array carries the per-channel tracks,
200 /// with the same `Rgb::NEUTRAL` (1.0) convention as [`ThreeWayCC`](Self::ThreeWayCC).
201 ThreeWayCCAnimated {
202 /// Shadows (lift) per channel `[R, G, B]`. Neutral: 1.0.
203 lift: [AnimatedValue<f64>; 3],
204 /// Midtones (gamma) per channel `[R, G, B]`. Neutral: 1.0; must be > 0.0.
205 gamma: [AnimatedValue<f64>; 3],
206 /// Highlights (gain) per channel `[R, G, B]`. Neutral: 1.0.
207 gain: [AnimatedValue<f64>; 3],
208 },
209 /// Vignette effect via `FFmpeg` `vignette` filter.
210 Vignette {
211 /// Radius angle in radians (valid range: 0.0 – π/2 ≈ 1.5708). Default: π/5 ≈ 0.628.
212 angle: f32,
213 /// Horizontal centre of the vignette. `0.0` maps to `w/2`.
214 x0: f32,
215 /// Vertical centre of the vignette. `0.0` maps to `h/2`.
216 y0: f32,
217 },
218 /// Vignette with an optionally animated normalised amount.
219 ///
220 /// `amount` is a normalised darkening strength in `[0, 1]`; it maps to the
221 /// `vignette` filter's `angle` (radians) as `amount * PI/2`. `vignette`
222 /// re-evaluates `angle` per frame under `eval=frame`, so a `Track` self-animates
223 /// via a `t`-expression (like [`RotateAnimated`](Self::RotateAnimated)); a
224 /// `Static` renders a plain static vignette.
225 VignetteAnimated {
226 /// Normalised darkening amount, evaluated to `[0, 1]` at `Duration::ZERO`.
227 amount: AnimatedValue<f64>,
228 /// Horizontal centre. `0.0` maps to `w/2`.
229 x0: f32,
230 /// Vertical centre. `0.0` maps to `h/2`.
231 y0: f32,
232 },
233 /// HSL adjustment via `FFmpeg` `hue` filter (`ff_render::HslNode` on the GPU).
234 ///
235 /// `hue` works in YUV (a chroma rotation for hue/saturation, a luma add for
236 /// brightness), so it approximates the GPU node's HSL-space adjustment within a
237 /// documented tolerance. `lightness` is scaled to the filter's `b` brightness.
238 Hsl {
239 /// Hue rotation in degrees.
240 hue: f32,
241 /// Saturation multiplier (neutral `1.0`).
242 saturation: f32,
243 /// Lightness offset in `[-1, 1]` (scaled to the `hue` filter's brightness).
244 lightness: f32,
245 },
246 /// HSL adjustment with optionally animated parameters.
247 ///
248 /// The GPU animates per frame; the CPU (`libavfilter`) path renders the
249 /// `Duration::ZERO` values (`hue`'s options accept `t`-expressions, but the CPU
250 /// path is kept static so the GPU-default path is the single animated one).
251 HslAnimated {
252 /// Hue rotation in degrees, evaluated at `Duration::ZERO`.
253 hue: AnimatedValue<f64>,
254 /// Saturation multiplier, evaluated at `Duration::ZERO`.
255 saturation: AnimatedValue<f64>,
256 /// Lightness offset in `[-1, 1]`, evaluated at `Duration::ZERO`.
257 lightness: AnimatedValue<f64>,
258 },
259 /// Horizontal flip (mirror left-right).
260 HFlip,
261 /// Vertical flip (mirror top-bottom).
262 VFlip,
263 /// Reverse video playback (buffers entire clip in memory — use only on short clips).
264 Reverse,
265 /// Reverse audio playback (buffers entire clip in memory — use only on short clips).
266 AReverse,
267 /// Pad to a target resolution with a fill color (letterbox / pillarbox).
268 Pad {
269 /// Target canvas width in pixels.
270 width: u32,
271 /// Target canvas height in pixels.
272 height: u32,
273 /// Horizontal offset of the source frame within the canvas.
274 /// Negative values are replaced with `(ow-iw)/2` (centred).
275 x: i32,
276 /// Vertical offset of the source frame within the canvas.
277 /// Negative values are replaced with `(oh-ih)/2` (centred).
278 y: i32,
279 /// Fill color (any `FFmpeg` color string, e.g. `"black"`, `"0x000000"`).
280 color: String,
281 },
282 /// Scale (preserving aspect ratio) then centre-pad to fill target dimensions
283 /// (letterbox or pillarbox as required).
284 ///
285 /// Implemented as a `scale` filter with `force_original_aspect_ratio=decrease`
286 /// followed by a `pad` filter that centres the scaled frame on the canvas.
287 FitToAspect {
288 /// Target canvas width in pixels.
289 width: u32,
290 /// Target canvas height in pixels.
291 height: u32,
292 /// Fill color for the bars (any `FFmpeg` color string, e.g. `"black"`).
293 color: String,
294 },
295 /// Scale (preserving aspect ratio) to *cover* the target dimensions, then
296 /// centre-crop the overflow (no bars).
297 ///
298 /// Implemented as a `scale` filter with `force_original_aspect_ratio=increase`
299 /// followed by a `crop` filter that centres the scaled frame on the canvas.
300 /// The counterpart to [`FitToAspect`](Self::FitToAspect) (cover vs contain).
301 FillToAspect {
302 /// Target canvas width in pixels.
303 width: u32,
304 /// Target canvas height in pixels.
305 height: u32,
306 },
307 /// Gaussian blur with configurable radius.
308 ///
309 /// `sigma` is the blur radius. Valid range: 0.0 – 10.0 (values near 0.0 are
310 /// nearly a no-op; higher values produce a stronger blur).
311 GBlur {
312 /// Blur radius (standard deviation). Must be ≥ 0.0.
313 sigma: f32,
314 },
315 /// Crop with optionally animated boundaries (pixels, `f64` for sub-pixel precision).
316 ///
317 /// Arguments are evaluated at [`Duration::ZERO`] for the initial graph build.
318 /// Per-frame updates are applied via `avfilter_graph_send_command` in #363.
319 CropAnimated {
320 /// X offset of the top-left corner, in pixels.
321 x: AnimatedValue<f64>,
322 /// Y offset of the top-left corner, in pixels.
323 y: AnimatedValue<f64>,
324 /// Width of the cropped region. Must evaluate to > 0 at `Duration::ZERO`.
325 width: AnimatedValue<f64>,
326 /// Height of the cropped region. Must evaluate to > 0 at `Duration::ZERO`.
327 height: AnimatedValue<f64>,
328 },
329 /// Gaussian blur with an optionally animated sigma (blur radius).
330 ///
331 /// Arguments are evaluated at [`Duration::ZERO`] for the initial graph build.
332 /// Per-frame updates are applied via `avfilter_graph_send_command` in #363.
333 GBlurAnimated {
334 /// Blur radius (standard deviation). Must evaluate to ≥ 0.0 at `Duration::ZERO`.
335 sigma: AnimatedValue<f64>,
336 },
337 /// Scale (resize / zoom) with optionally animated width/height, in pixels.
338 ///
339 /// Unlike `crop`, `scale` cannot be driven by `send_command` (its output size is
340 /// fixed at init); instead this **self-animates** via `scale=eval=frame` with the
341 /// width/height tracks compiled to `t`-expressions
342 /// ([`AnimationTrack::to_ffmpeg_expr`](crate::animation::AnimationTrack::to_ffmpeg_expr)).
343 /// The same expression is placed in both the preview and export graphs, so it
344 /// animates identically. When both are `Static` it renders a plain static `scale`.
345 ScaleAnimated {
346 /// Target width in pixels (expression when a `Track`).
347 width: AnimatedValue<f64>,
348 /// Target height in pixels (expression when a `Track`).
349 height: AnimatedValue<f64>,
350 /// Resampling algorithm (`flags=`).
351 algorithm: ScaleAlgorithm,
352 },
353 /// Rotate with an optionally animated angle (degrees), filling exposed corners
354 /// with `fill_color`.
355 ///
356 /// `rotate`'s `angle` option is an expression re-evaluated every frame, so this
357 /// **self-animates** via `rotate=angle=EXPR`: the angle track is compiled to a
358 /// `t`-expression ([`AnimationTrack::to_ffmpeg_expr`](crate::animation::AnimationTrack::to_ffmpeg_expr))
359 /// and converted degrees→radians. The same expression drives preview and export.
360 /// Output size stays `iw`×`ih` (corners clipped / filled). `Static` → a plain
361 /// rotate.
362 RotateAnimated {
363 /// Rotation angle in **degrees** (clockwise); an expression when a `Track`.
364 angle: AnimatedValue<f64>,
365 /// Colour for exposed corners (e.g. `"black"`, `"none"` for transparent).
366 fill_color: String,
367 },
368 /// Sharpen or blur via unsharp mask (luma + chroma strength).
369 ///
370 /// Positive values sharpen; negative values blur. Valid range for each
371 /// component: −1.5 – 1.5.
372 Unsharp {
373 /// Luma (brightness) sharpening/blurring amount. Range: −1.5 – 1.5.
374 luma_strength: f32,
375 /// Chroma (colour) sharpening/blurring amount. Range: −1.5 – 1.5.
376 chroma_strength: f32,
377 },
378 /// Unsharp mask with an optionally animated luma/chroma amount.
379 ///
380 /// Arguments are evaluated at [`Duration::ZERO`] for the graph build. Unlike
381 /// [`GBlurAnimated`](Self::GBlurAnimated), `FFmpeg`'s `unsharp` exposes **no
382 /// runtime-settable parameter** (verified against the pinned 7.1/8.0 source),
383 /// so it cannot be driven by `send_command` and it has no per-frame expression.
384 /// On the CPU (`libavfilter`) path it therefore renders the `Duration::ZERO`
385 /// value statically; the GPU path animates it by re-evaluating per frame.
386 UnsharpAnimated {
387 /// Luma amount. Evaluated to −1.5 – 1.5 at `Duration::ZERO`.
388 luma_strength: AnimatedValue<f64>,
389 /// Chroma amount. Evaluated to −1.5 – 1.5 at `Duration::ZERO`.
390 chroma_strength: AnimatedValue<f64>,
391 },
392 /// High Quality 3D noise reduction (`hqdn3d`).
393 ///
394 /// Typical values: `luma_spatial=4.0`, `chroma_spatial=3.0`,
395 /// `luma_tmp=6.0`, `chroma_tmp=4.5`. All values must be ≥ 0.0.
396 Hqdn3d {
397 /// Spatial luma noise reduction strength. Must be ≥ 0.0.
398 luma_spatial: f32,
399 /// Spatial chroma noise reduction strength. Must be ≥ 0.0.
400 chroma_spatial: f32,
401 /// Temporal luma noise reduction strength. Must be ≥ 0.0.
402 luma_tmp: f32,
403 /// Temporal chroma noise reduction strength. Must be ≥ 0.0.
404 chroma_tmp: f32,
405 },
406 /// Non-local means noise reduction (`nlmeans`).
407 ///
408 /// `strength` controls the denoising intensity; range 1.0–30.0.
409 /// Higher values remove more noise but are significantly more CPU-intensive.
410 ///
411 /// NOTE: nlmeans is CPU-intensive; avoid for real-time pipelines.
412 Nlmeans {
413 /// Denoising strength. Must be in the range [1.0, 30.0].
414 strength: f32,
415 },
416 /// Deinterlace using the `yadif` filter.
417 Yadif {
418 /// Deinterlacing mode controlling output frame rate and spatial checks.
419 mode: YadifMode,
420 },
421 /// Cross-dissolve transition between two video streams (`xfade`).
422 ///
423 /// Requires two input slots: slot 0 is clip A, slot 1 is clip B.
424 /// `duration` is the overlap length in seconds; `offset` is the PTS
425 /// offset (in seconds) at which clip B begins.
426 XFade {
427 /// Transition style.
428 transition: XfadeTransition,
429 /// Overlap duration in seconds. Must be > 0.0.
430 duration: f64,
431 /// PTS offset (seconds) where clip B starts.
432 offset: f64,
433 },
434 /// Draw text onto the video using the `drawtext` filter.
435 DrawText {
436 /// Full set of drawtext parameters.
437 opts: DrawTextOptions,
438 },
439 /// Burn-in SRT subtitles (hard subtitles) using the `subtitles` filter.
440 SubtitlesSrt {
441 /// Absolute or relative path to the `.srt` file.
442 path: String,
443 /// Optional ASS style override for the `subtitles` filter's `force_style`
444 /// option, e.g. `"Fontsize=24,PrimaryColour=&H00FFFFFF&,Alignment=2"`.
445 /// `None` uses the file's default styling. (The `ass` filter has no
446 /// `force_style`; ASS files carry their own styles.)
447 force_style: Option<String>,
448 },
449 /// Burn-in ASS/SSA styled subtitles using the `ass` filter.
450 SubtitlesAss {
451 /// Absolute or relative path to the `.ass` or `.ssa` file.
452 path: String,
453 },
454 /// Playback speed change using `setpts` (video) and chained `atempo` (audio).
455 ///
456 /// `factor > 1.0` = fast motion; `factor < 1.0` = slow motion.
457 /// Valid range: 0.1–100.0.
458 ///
459 /// Video path: `setpts=PTS/{factor}`.
460 /// Audio path: the `atempo` filter only accepts [0.5, 2.0] per instance;
461 /// `filter_inner` chains multiple instances to cover the full range.
462 Speed {
463 /// Speed multiplier. Must be in [0.1, 100.0].
464 factor: f64,
465 },
466 /// EBU R128 two-pass loudness normalization.
467 ///
468 /// Pass 1 measures integrated loudness with `ebur128=peak=true:metadata=1`.
469 /// Pass 2 applies a linear volume correction so the output reaches `target_lufs`.
470 /// All audio frames are buffered in memory between the two passes — use only
471 /// for clips that fit comfortably in RAM.
472 LoudnessNormalize {
473 /// Target integrated loudness in LUFS (e.g. −23.0). Must be < 0.0.
474 target_lufs: f32,
475 /// True-peak ceiling in dBTP (e.g. −1.0). Must be ≤ 0.0.
476 true_peak_db: f32,
477 /// Target loudness range in LU (e.g. 7.0). Must be > 0.0.
478 lra: f32,
479 },
480 /// Peak-level two-pass normalization using `astats`.
481 ///
482 /// Pass 1 measures the true peak with `astats=metadata=1`.
483 /// Pass 2 applies `volume={gain}dB` so the output peak reaches `target_db`.
484 /// All audio frames are buffered in memory between passes — use only
485 /// for clips that fit comfortably in RAM.
486 NormalizePeak {
487 /// Target peak level in dBFS (e.g. −1.0). Must be ≤ 0.0.
488 target_db: f32,
489 },
490 /// Noise gate via `FFmpeg`'s `agate` filter.
491 ///
492 /// Audio below `threshold_db` is attenuated; audio above passes through.
493 /// The threshold is converted from dBFS to the linear scale expected by
494 /// `agate`'s `threshold` parameter (`linear = 10^(dB/20)`).
495 ANoiseGate {
496 /// Gate open/close threshold in dBFS (e.g. −40.0).
497 threshold_db: f32,
498 /// Attack time in milliseconds — how quickly the gate opens. Must be > 0.0.
499 attack_ms: f32,
500 /// Release time in milliseconds — how quickly the gate closes. Must be > 0.0.
501 release_ms: f32,
502 },
503 /// Dynamic range compressor via `FFmpeg`'s `acompressor` filter.
504 ///
505 /// Reduces the dynamic range of the audio signal: peaks above
506 /// `threshold_db` are attenuated by `ratio`:1. `makeup_db` applies
507 /// additional gain after compression to restore perceived loudness.
508 ACompressor {
509 /// Compression threshold in dBFS (e.g. −20.0).
510 threshold_db: f32,
511 /// Compression ratio (e.g. 4.0 = 4:1). Must be ≥ 1.0.
512 ratio: f32,
513 /// Attack time in milliseconds. Must be > 0.0.
514 attack_ms: f32,
515 /// Release time in milliseconds. Must be > 0.0.
516 release_ms: f32,
517 /// Make-up gain in dB applied after compression (e.g. 6.0).
518 makeup_db: f32,
519 },
520 /// Downmix stereo to mono via `FFmpeg`'s `pan` filter.
521 ///
522 /// Both channels are mixed with equal weight:
523 /// `mono|c0=0.5*c0+0.5*c1`. The output has a single channel.
524 StereoToMono,
525 /// Remap audio channels using `FFmpeg`'s `channelmap` filter.
526 ///
527 /// `mapping` is a `|`-separated list of output channel names taken
528 /// from input channels, e.g. `"FR|FL"` swaps left and right.
529 /// Must not be empty.
530 ChannelMap {
531 /// `FFmpeg` channelmap mapping expression (e.g. `"FR|FL"`).
532 mapping: String,
533 },
534 /// A/V sync correction via audio delay or advance.
535 ///
536 /// Positive `ms`: uses `FFmpeg`'s `adelay` filter to shift audio later.
537 /// Negative `ms`: uses `FFmpeg`'s `atrim` filter to trim the audio start,
538 /// effectively advancing audio by `|ms|` milliseconds.
539 /// Zero `ms`: uses `adelay` with zero delay (no-op).
540 AudioDelay {
541 /// Delay in milliseconds. Positive = delay; negative = advance.
542 ms: f64,
543 },
544 /// Audio trim: keep only samples in `[start, end)` seconds (`atrim`). Either
545 /// bound may be `None` for an open range. The audio counterpart of
546 /// [`Trim`](Self::Trim).
547 ATrim {
548 start: Option<f64>,
549 end: Option<f64>,
550 },
551 /// Reset audio timestamps to start at zero (`asetpts=PTS-STARTPTS`). Typically
552 /// follows an [`ATrim`](Self::ATrim) so a placed track's delay is applied from
553 /// zero. The audio counterpart of [`ResetPts`](Self::ResetPts).
554 AResetPts,
555 /// Concatenate `n` sequential video input segments via `FFmpeg`'s `concat` filter.
556 ///
557 /// Requires `n` video input slots (0 through `n-1`). `n` must be ≥ 2.
558 ConcatVideo {
559 /// Number of video input segments to concatenate. Must be ≥ 2.
560 n: u32,
561 },
562 /// Concatenate `n` sequential audio input segments via `FFmpeg`'s `concat` filter.
563 ///
564 /// Requires `n` audio input slots (0 through `n-1`). `n` must be ≥ 2.
565 ConcatAudio {
566 /// Number of audio input segments to concatenate. Must be ≥ 2.
567 n: u32,
568 },
569 /// Freeze a single frame for a configurable duration using `FFmpeg`'s `loop` filter.
570 ///
571 /// The frame nearest to `pts` seconds is held for `duration` seconds, then
572 /// playback resumes. Frame numbers are approximated using a 25 fps assumption;
573 /// accuracy depends on the source stream's actual frame rate.
574 FreezeFrame {
575 /// Timestamp of the frame to freeze, in seconds. Must be >= 0.0.
576 pts: f64,
577 /// Duration to hold the frozen frame, in seconds. Must be > 0.0.
578 duration: f64,
579 },
580 /// Scrolling text ticker (right-to-left) using the `drawtext` filter.
581 ///
582 /// The text starts off-screen to the right and scrolls left at
583 /// `speed_px_per_sec` pixels per second using the expression
584 /// `x = w - t * speed`.
585 Ticker {
586 /// Text to display. Special characters (`\`, `:`, `'`) are escaped.
587 text: String,
588 /// Y position as an `FFmpeg` expression, e.g. `"h-50"` or `"10"`.
589 y: String,
590 /// Horizontal scroll speed in pixels per second (must be > 0.0).
591 speed_px_per_sec: f32,
592 /// Font size in points.
593 font_size: u32,
594 /// Font color as an `FFmpeg` color string, e.g. `"white"` or `"0xFFFFFF"`.
595 font_color: String,
596 },
597 /// Composite a PNG image (watermark / logo) over video with optional opacity.
598 ///
599 /// This is a compound step: internally it creates a `movie` source, an
600 /// optional `scale` filter, a `lut` alpha-scaling filter, and an `overlay`
601 /// compositing filter. The image file is loaded once at graph construction
602 /// time.
603 OverlayImage {
604 /// Absolute or relative path to the `.png` file.
605 path: String,
606 /// Horizontal position as an `FFmpeg` expression, e.g. `"10"` or `"W-w-10"`.
607 x: String,
608 /// Vertical position as an `FFmpeg` expression, e.g. `"10"` or `"H-h-10"`.
609 y: String,
610 /// Opacity 0.0 (fully transparent) to 1.0 (fully opaque).
611 opacity: f32,
612 /// Optional overlay width as an `FFmpeg` `scale` expression, e.g. `"300"`
613 /// or `"iw*0.15"` (`iw`/`ih` are the overlay's own dimensions). `-1`
614 /// preserves aspect ratio. `None` keeps the image's native width.
615 width: Option<String>,
616 /// Optional overlay height as an `FFmpeg` `scale` expression. `-1`
617 /// preserves aspect ratio. `None` keeps the image's native height.
618 height: Option<String>,
619 },
620
621 /// Blend a `top` layer over the current stream (bottom) using the given mode.
622 ///
623 /// This is a compound step:
624 /// - **Normal** mode: `[top]colorchannelmixer=aa=<opacity>[top_faded];
625 /// [bottom][top_faded]overlay=format=auto:shortest=1[out]`
626 /// (the `colorchannelmixer` step is omitted when `opacity == 1.0`).
627 /// - All other modes return [`crate::FilterError::InvalidConfig`] from
628 /// [`crate::FilterGraphBuilder::build`] until implemented.
629 ///
630 /// The `top` builder's steps are applied to the second input slot (`in1`).
631 /// `opacity` is clamped to `[0.0, 1.0]` by the builder method.
632 ///
633 /// `Box<FilterGraphBuilder>` is used to break the otherwise-recursive type:
634 /// `FilterStep` → `FilterGraphBuilder` → `Vec<FilterStep>`.
635 ///
636 /// Not serialized: this compositor-internal variant carries a
637 /// `FilterGraphBuilder` (which cannot round-trip serde), and it never appears
638 /// in the editing model's persisted effect chains.
639 #[cfg_attr(feature = "serde", serde(skip))]
640 Blend {
641 /// Filter pipeline for the top (foreground) layer.
642 top: Box<FilterGraphBuilder>,
643 /// How the two layers are combined.
644 mode: BlendMode,
645 /// Opacity of the top layer in `[0.0, 1.0]`; 1.0 = fully opaque.
646 opacity: f32,
647 /// How the top layer's alpha is interpreted by the `overlay` filter
648 /// (`alpha=`). [`AlphaMode::Straight`] is the `FFmpeg` default.
649 alpha: AlphaMode,
650 },
651
652 /// Composite a `top` layer over the current stream (bottom) using a
653 /// Porter-Duff alpha-compositing [`CompositeOp`].
654 ///
655 /// This is a compound, two-input step (slot 0 = bottom, slot 1 = top with
656 /// the `top` builder's steps applied). `Over`/`Under` are built with the
657 /// `overlay` filter; the rest use `blend` with a per-channel expression.
658 ///
659 /// `Box<FilterGraphBuilder>` breaks the otherwise-recursive type, following
660 /// the same pattern as [`FilterStep::Blend`].
661 ///
662 /// Not serialized (compositor-internal; see [`FilterStep::Blend`]).
663 #[cfg_attr(feature = "serde", serde(skip))]
664 Composite {
665 /// The Porter-Duff operator combining the two layers.
666 op: CompositeOp,
667 /// Filter pipeline for the top (foreground) layer.
668 top: Box<FilterGraphBuilder>,
669 /// Opacity of the top layer in `[0.0, 1.0]`; 1.0 = fully opaque.
670 /// Only affects `Over`/`Under` (the expression operators ignore it).
671 opacity: f32,
672 /// How the top layer's alpha is interpreted by the `overlay` filter
673 /// (`alpha=`). [`AlphaMode::Straight`] is the `FFmpeg` default.
674 alpha: AlphaMode,
675 },
676
677 /// Remove pixels matching `color` using `FFmpeg`'s `chromakey` filter,
678 /// producing a `yuva420p` output with transparent areas where the key
679 /// color was detected.
680 ///
681 /// Use this for YCbCr-encoded sources (most video). For RGB sources
682 /// use `colorkey` instead.
683 ChromaKey {
684 /// `FFmpeg` color string, e.g. `"green"`, `"0x00FF00"`, `"#00FF00"`.
685 color: String,
686 /// Match radius in `[0.0, 1.0]`; higher = more pixels removed.
687 similarity: f32,
688 /// Edge softness in `[0.0, 1.0]`; `0.0` = hard edge.
689 blend: f32,
690 },
691
692 /// [`ChromaKey`](Self::ChromaKey) with optionally animated `similarity` /
693 /// `blend`.
694 ///
695 /// The GPU animates per frame; the CPU (`libavfilter`) path renders the
696 /// `Duration::ZERO` values (`chromakey`'s options are static, so the CPU path
697 /// is kept static and the GPU-default path is the single animated one, matching
698 /// [`HslAnimated`](Self::HslAnimated)).
699 ChromaKeyAnimated {
700 /// `FFmpeg` color string, e.g. `"0x00FF00"`.
701 color: String,
702 /// Match radius in `[0.0, 1.0]`, evaluated at `Duration::ZERO` on the CPU.
703 similarity: AnimatedValue<f64>,
704 /// Edge softness in `[0.0, 1.0]`, evaluated at `Duration::ZERO` on the CPU.
705 blend: AnimatedValue<f64>,
706 },
707
708 /// Remove pixels matching `color` in RGB space using `FFmpeg`'s `colorkey`
709 /// filter, producing an `rgba` output with transparent areas where the key
710 /// color was detected.
711 ///
712 /// Use this for RGB-encoded sources. For YCbCr-encoded video (most video)
713 /// use `chromakey` instead.
714 ColorKey {
715 /// `FFmpeg` color string, e.g. `"green"`, `"0x00FF00"`, `"#00FF00"`.
716 color: String,
717 /// Match radius in `[0.0, 1.0]`; higher = more pixels removed.
718 similarity: f32,
719 /// Edge softness in `[0.0, 1.0]`; `0.0` = hard edge.
720 blend: f32,
721 },
722
723 /// Reduce color spill from the key color on subject edges using `FFmpeg`'s
724 /// `hue` filter to desaturate the spill hue region.
725 ///
726 /// Applies `hue=h=0:s=(1.0 - strength)`. `strength=0.0` leaves the image
727 /// unchanged; `strength=1.0` fully desaturates.
728 ///
729 /// `key_color` is stored for future use by a more targeted per-hue
730 /// implementation.
731 SpillSuppress {
732 /// `FFmpeg` color string identifying the spill color, e.g. `"green"`.
733 key_color: String,
734 /// Suppression intensity in `[0.0, 1.0]`; `0.0` = no effect, `1.0` = full suppression.
735 strength: f32,
736 },
737
738 /// Merge a grayscale `matte` as the alpha channel of the input video using
739 /// `FFmpeg`'s `alphamerge` filter.
740 ///
741 /// White (luma=255) in the matte produces fully opaque output; black (luma=0)
742 /// produces fully transparent output.
743 ///
744 /// This is a compound step: the `matte` builder's pipeline is applied to the
745 /// second input slot (`in1`) before the `alphamerge` filter is linked.
746 ///
747 /// `Box<FilterGraphBuilder>` breaks the otherwise-recursive type, following
748 /// the same pattern as [`FilterStep::Blend`].
749 ///
750 /// Not serialized (compositor-internal; see [`FilterStep::Blend`]).
751 #[cfg_attr(feature = "serde", serde(skip))]
752 AlphaMatte {
753 /// Pipeline for the grayscale matte stream (slot 1).
754 matte: Box<FilterGraphBuilder>,
755 },
756
757 /// Key out pixels by luminance value using `FFmpeg`'s `lumakey` filter.
758 ///
759 /// Pixels whose normalized luma is within `tolerance` of `threshold` are
760 /// made transparent. When `invert` is `true`, a `geq` filter is appended
761 /// to negate the alpha channel, effectively swapping transparent and opaque
762 /// regions.
763 ///
764 /// - `threshold`: luma cutoff in `[0.0, 1.0]`; `0.0` = black, `1.0` = white.
765 /// - `tolerance`: match radius around the threshold in `[0.0, 1.0]`.
766 /// - `softness`: edge feather width in `[0.0, 1.0]`; `0.0` = hard edge.
767 /// - `invert`: when `false`, keys out bright regions (pixels matching the
768 /// threshold); when `true`, the alpha is negated after keying, making
769 /// the complementary region transparent instead.
770 ///
771 /// Output carries an alpha channel (`yuva420p`).
772 LumaKey {
773 /// Luma cutoff in `[0.0, 1.0]`.
774 threshold: f32,
775 /// Match radius around the threshold in `[0.0, 1.0]`.
776 tolerance: f32,
777 /// Edge feather width in `[0.0, 1.0]`; `0.0` = hard edge.
778 softness: f32,
779 /// When `true`, the alpha channel is negated after keying.
780 invert: bool,
781 },
782
783 /// Apply a rectangular alpha mask using `FFmpeg`'s `geq` filter.
784 ///
785 /// Pixels inside the rectangle defined by (`x`, `y`, `width`, `height`)
786 /// are made fully opaque (`alpha=255`); pixels outside are made fully
787 /// transparent (`alpha=0`). When `invert` is `true` the roles are swapped:
788 /// inside becomes transparent and outside becomes opaque.
789 ///
790 /// - `x`, `y`: top-left corner of the rectangle (in pixels).
791 /// - `width`, `height`: rectangle dimensions (must be > 0).
792 /// - `invert`: when `false`, keeps the interior; when `true`, keeps the
793 /// exterior.
794 ///
795 /// `width` and `height` are validated in [`build`](FilterGraphBuilder::build);
796 /// zero values return [`crate::FilterError::InvalidConfig`].
797 ///
798 /// The output carries an alpha channel (`rgba`).
799 RectMask {
800 /// Left edge of the rectangle (pixels from the left).
801 x: u32,
802 /// Top edge of the rectangle (pixels from the top).
803 y: u32,
804 /// Width of the rectangle in pixels (must be > 0).
805 width: u32,
806 /// Height of the rectangle in pixels (must be > 0).
807 height: u32,
808 /// When `true`, the mask is inverted: outside is opaque, inside is transparent.
809 invert: bool,
810 },
811
812 /// [`RectMask`](Self::RectMask) with optionally animated `x` / `y` / `width` /
813 /// `height`.
814 ///
815 /// The GPU animates the rectangle per frame; the CPU (`libavfilter`) path renders
816 /// the `Duration::ZERO` values (the `geq` rectangle bounds are static, so the CPU
817 /// path is kept static and the GPU-default path is the animated one, matching
818 /// [`ChromaKeyAnimated`](Self::ChromaKeyAnimated)). `width` / `height` are clamped
819 /// to at least `1` at render time so a zero-size frame never fails `geq` build.
820 RectMaskAnimated {
821 /// Left edge in pixels, evaluated at `Duration::ZERO` on the CPU.
822 x: AnimatedValue<f64>,
823 /// Top edge in pixels, evaluated at `Duration::ZERO` on the CPU.
824 y: AnimatedValue<f64>,
825 /// Width in pixels (clamped to `>= 1`), evaluated at `Duration::ZERO` on the CPU.
826 width: AnimatedValue<f64>,
827 /// Height in pixels (clamped to `>= 1`), evaluated at `Duration::ZERO` on the CPU.
828 height: AnimatedValue<f64>,
829 /// When `true`, the mask is inverted: outside is opaque, inside is transparent.
830 invert: bool,
831 },
832
833 /// Set the alpha channel to the frame's own BT.709 luma using `FFmpeg`'s `geq`
834 /// filter (a self-referential continuous luma mask).
835 ///
836 /// `alpha = luma`, where `luma = 0.2126·R + 0.7152·G + 0.0722·B` on the frame's
837 /// own gamma-encoded RGB. Bright pixels stay opaque, dark pixels become
838 /// transparent. When `invert` is `true`, `255 - luma` is used, so dark pixels
839 /// stay opaque. This is the CPU pair of `ff_render::LumaMaskNode` (mask = the
840 /// frame itself, which multiplies the existing alpha by `luma`); on an opaque
841 /// source `alpha = luma` and `alpha *= luma` coincide, and the coefficients
842 /// match exactly. (`geq` exposes no alpha-read function here, so the existing
843 /// alpha is replaced rather than multiplied.)
844 ///
845 /// The output carries an alpha channel (`rgba`).
846 LumaMask {
847 /// When `true`, use `255 - luma` (dark pixels stay opaque).
848 invert: bool,
849 },
850
851 /// Feather (soften) the alpha channel edges using a Gaussian blur.
852 ///
853 /// Splits the stream into a color copy and an alpha copy, blurs the alpha
854 /// plane with `gblur=sigma=<radius>`, then re-merges:
855 ///
856 /// ```text
857 /// [in]split=2[color][with_alpha];
858 /// [with_alpha]alphaextract[alpha_only];
859 /// [alpha_only]gblur=sigma=<radius>[alpha_blurred];
860 /// [color][alpha_blurred]alphamerge[out]
861 /// ```
862 ///
863 /// `radius` is the blur kernel half-size in pixels and must be > 0.
864 /// Validated in [`build`](FilterGraphBuilder::build); `radius == 0` returns
865 /// [`crate::FilterError::InvalidConfig`].
866 ///
867 /// Typically chained after a keying or masking step
868 /// (e.g. [`FilterStep::ChromaKey`], [`FilterStep::RectMask`],
869 /// [`FilterStep::PolygonMatte`]). Applying this step to a fully-opaque
870 /// video (no prior alpha) is a no-op because a uniform alpha of 255 blurs
871 /// to 255 everywhere.
872 FeatherMask {
873 /// Gaussian blur kernel half-size in pixels (must be > 0).
874 radius: u32,
875 },
876
877 /// Simulate motion blur by blending consecutive frames via `FFmpeg`'s `tblend` filter.
878 ///
879 /// `shutter_angle_degrees` controls the blend ratio; 360° equals a full
880 /// frame-period exposure (maximum blur). `sub_frames` is the number of
881 /// frames blended and must be in [2, 16]; it is validated by
882 /// [`FilterGraph::motion_blur`](crate::FilterGraph::motion_blur).
883 MotionBlur {
884 /// Shutter angle in degrees (0° = no blur, 360° = full-period blur).
885 shutter_angle_degrees: f32,
886 /// Number of frames blended. Must be in [2, 16].
887 sub_frames: u8,
888 },
889
890 /// Motion blur with an optionally animated shutter angle.
891 ///
892 /// `tblend`'s blend is written as an expression (`all_expr`), and `blend`
893 /// re-evaluates that expression per frame with `T` bound to the frame's
894 /// presentation time in seconds. So this **self-animates**: the shutter track is
895 /// compiled to a `T`-expression
896 /// ([`AnimationTrack::to_ffmpeg_expr`](crate::animation::AnimationTrack::to_ffmpeg_expr))
897 /// and folded into the same `A`/`B` mix the constant form uses. The same
898 /// expression drives preview and export.
899 ///
900 /// **`T`, not `t`.** `blend`'s expression variables are upper-case (`X Y W H SW
901 /// SH T N A B TOP BOTTOM`); a lower-case `t` is not among them and would
902 /// evaluate as an unknown.
903 ///
904 /// A `Static` shutter renders exactly what [`MotionBlur`](Self::MotionBlur)
905 /// renders, so the two agree where they overlap.
906 MotionBlurAnimated {
907 /// Shutter angle in degrees (0° = no blur, 360° = full-period blur); an
908 /// expression when a `Track`.
909 shutter_angle: AnimatedValue<f64>,
910 /// Number of frames blended. Must be in [2, 16].
911 sub_frames: u8,
912 },
913
914 /// Correct radial lens distortion using two polynomial coefficients via
915 /// `FFmpeg`'s `lenscorrection` filter.
916 ///
917 /// Negative values correct barrel distortion; positive values correct
918 /// pincushion distortion. Both `k1` and `k2` must be in [−1.0, 1.0];
919 /// validated by [`FilterGraph::lens_correction`](crate::FilterGraph::lens_correction).
920 LensCorrection {
921 /// First-order radial distortion coefficient. Range: [−1.0, 1.0].
922 k1: f32,
923 /// Second-order radial distortion coefficient. Range: [−1.0, 1.0].
924 k2: f32,
925 },
926
927 /// Add synthetic per-frame random film grain to luma and chroma channels
928 /// via `FFmpeg`'s `noise` filter.
929 ///
930 /// `luma_strength` and `chroma_strength` are clamped to [0.0, 100.0].
931 /// The `allf=t` flag varies the seed each frame to simulate real film grain.
932 FilmGrain {
933 /// Grain strength applied to the luma (Y) plane. Clamped to [0.0, 100.0].
934 luma_strength: f32,
935 /// Grain strength applied to the Cb and Cr planes. Clamped to [0.0, 100.0].
936 chroma_strength: f32,
937 },
938 /// Temporal film grain (the `noise` filter) with an optionally animated strength.
939 ///
940 /// Strengths are evaluated at [`Duration::ZERO`] for the graph build. Like
941 /// [`UnsharpAnimated`](Self::UnsharpAnimated), `noise` exposes no
942 /// runtime-settable parameter, so the CPU (`libavfilter`) path renders the
943 /// `Duration::ZERO` strength statically; the GPU path animates it per frame. The
944 /// grain *pattern* is temporal on both paths regardless (`allf=t` on the CPU, the
945 /// per-frame seed on the GPU).
946 FilmGrainAnimated {
947 /// Luma-plane grain strength. Evaluated to [0.0, 100.0] at `Duration::ZERO`.
948 luma_strength: AnimatedValue<f64>,
949 /// Chroma-plane grain strength. Evaluated to [0.0, 100.0] at `Duration::ZERO`.
950 chroma_strength: AnimatedValue<f64>,
951 },
952
953 /// Uniform scale by a fractional multiplier via `FFmpeg`'s `scale` filter.
954 ///
955 /// Both width and height are multiplied by `factor`. Used to hide warped
956 /// border pixels left after lens distortion correction.
957 ScaleMultiplier {
958 /// Scale factor applied to both dimensions (e.g. `1.05` = 5 % zoom-in).
959 factor: f32,
960 },
961
962 /// Reduce lateral chromatic aberration by independently shifting the R and B
963 /// channels via `FFmpeg`'s `rgbashift` filter.
964 ///
965 /// `rh` and `bh` are the horizontal pixel shifts for the red and blue
966 /// channels respectively. Derived from scale deviations by
967 /// [`FilterGraph::fix_chromatic_aberration`](crate::FilterGraph::fix_chromatic_aberration).
968 ChromaticAberration {
969 /// Horizontal shift for the red channel in pixels (positive = right).
970 rh: i32,
971 /// Horizontal shift for the blue channel in pixels (positive = right).
972 bh: i32,
973 },
974
975 /// Glow / bloom effect: blends blurred highlights back over the image via
976 /// `split`, `curves`, `gblur`, and `blend` filters.
977 ///
978 /// This is a compound step — see
979 /// [`FilterGraph::glow`](crate::FilterGraph::glow) for parameter semantics.
980 Glow {
981 /// Luminance threshold that triggers the glow (clamped to [0.0, 1.0]).
982 threshold: f32,
983 /// Gaussian blur radius in pixels (clamped to [0.5, 50.0]).
984 radius: f32,
985 /// Additive blend strength (clamped to [0.0, 2.0]).
986 intensity: f32,
987 },
988 /// Glow / bloom (the compound `split`/`curves`/`gblur`/`blend` chain) with
989 /// optionally animated parameters.
990 ///
991 /// Parameters are evaluated at [`Duration::ZERO`] for the graph build. None of
992 /// the sub-filters expose a runtime-settable glow parameter, so the CPU path
993 /// renders the `Duration::ZERO` values statically; the GPU path
994 /// (`ff_render::GlowNode`) animates them per frame.
995 GlowAnimated {
996 /// Luminance threshold. Evaluated to [0.0, 1.0] at `Duration::ZERO`.
997 threshold: AnimatedValue<f64>,
998 /// Gaussian blur radius in pixels. Evaluated to [0.5, 50.0] at `Duration::ZERO`.
999 radius: AnimatedValue<f64>,
1000 /// Additive blend strength. Evaluated to [0.0, 2.0] at `Duration::ZERO`.
1001 intensity: AnimatedValue<f64>,
1002 },
1003
1004 /// Convolution reverb using an impulse response (IR) audio file.
1005 ///
1006 /// The IR is loaded via `FFmpeg`'s `amovie` filter, optionally delayed by
1007 /// `pre_delay_ms` via `adelay`, then convolved with the main audio stream
1008 /// via `FFmpeg`'s `afir` filter.
1009 ///
1010 /// This is a compound step — see
1011 /// [`FilterGraph::reverb_ir`](crate::FilterGraph::reverb_ir) for parameter
1012 /// semantics.
1013 ReverbIr {
1014 /// Absolute or relative path to the `.wav` or `.flac` IR file.
1015 ir_path: String,
1016 /// Wet (reverb) mix level in [0.0, 1.0].
1017 wet: f32,
1018 /// Dry (original) mix level in [0.0, 1.0].
1019 dry: f32,
1020 /// Pre-delay before the reverb tail in milliseconds (clamped to 0–500).
1021 pre_delay_ms: u32,
1022 },
1023
1024 /// Algorithmic multi-tap echo/reverb via `FFmpeg`'s `aecho` filter.
1025 ///
1026 /// `in_gain` and `out_gain` are amplitude multipliers clamped to [0.0, 1.0].
1027 /// `delays` contains delay times in milliseconds (one per tap); `decays`
1028 /// contains the corresponding decay factors in [0.0, 1.0]. Both vecs must
1029 /// have equal length in the range 1–8; validated by
1030 /// [`FilterGraph::reverb_echo`](crate::FilterGraph::reverb_echo).
1031 ReverbEcho {
1032 /// Input gain (amplitude multiplier). Clamped to [0.0, 1.0].
1033 in_gain: f32,
1034 /// Output gain (amplitude multiplier). Clamped to [0.0, 1.0].
1035 out_gain: f32,
1036 /// Delay times in milliseconds (one per tap).
1037 delays: Vec<f32>,
1038 /// Decay factors per tap. Clamped to [0.0, 1.0].
1039 decays: Vec<f32>,
1040 },
1041
1042 /// Pitch shift without tempo change.
1043 ///
1044 /// Shifts audio pitch by `semitones` semitones without altering playback
1045 /// duration. Implemented as `asetrate` (changes the declared sample rate
1046 /// to shift pitch) followed by `atempo` (restores the original duration).
1047 ///
1048 /// Range: [−24.0, 24.0]; validated by
1049 /// [`FilterGraph::pitch_shift`](crate::FilterGraph::pitch_shift).
1050 ///
1051 /// This is a compound step — `filter_name()` returns `"asetrate"` for
1052 /// `validate_filter_steps`; the actual graph construction is handled by
1053 /// `filter_inner::build::build_audio_graph`.
1054 PitchShift {
1055 /// Pitch shift in semitones. Range: [−24.0, 24.0].
1056 semitones: f32,
1057 /// Backend algorithm. [`PitchAlgo::Rubberband`] uses the formant-preserving
1058 /// `rubberband` filter when available (else falls back to the signal path).
1059 algo: PitchAlgo,
1060 },
1061
1062 /// Time-stretch audio without changing pitch via `FFmpeg`'s `atempo` filter.
1063 ///
1064 /// `factor < 1.0` = slower (longer duration); `factor > 1.0` = faster
1065 /// (shorter duration). Range: [0.1, 10.0]. Values outside [0.5, 2.0]
1066 /// are realised by chaining multiple `atempo` instances (each in [0.5, 2.0]).
1067 ///
1068 /// Validated by [`FilterGraph::time_stretch`](crate::FilterGraph::time_stretch).
1069 TimeStretch {
1070 /// Speed / duration factor. 0.5 = 2× longer; 2.0 = 2× shorter. Range: [0.1, 10.0].
1071 factor: f32,
1072 /// Backend algorithm. [`PitchAlgo::Rubberband`] uses the higher-quality
1073 /// `rubberband` filter when available (else falls back to the signal path).
1074 algo: PitchAlgo,
1075 },
1076
1077 /// Simultaneously change audio speed and pitch by the same factor.
1078 ///
1079 /// Equivalent to playing a tape at a different speed: `factor > 1.0` makes
1080 /// audio faster and higher; `factor < 1.0` makes it slower and lower.
1081 ///
1082 /// Uses `FFmpeg`'s `asetrate` to multiply the declared sample rate by
1083 /// `factor` without resampling. Range: [0.1, 10.0]; validated by
1084 /// [`FilterGraph::speed_change`](crate::FilterGraph::speed_change).
1085 SpeedChange {
1086 /// Speed/pitch multiplier. Range: [0.1, 10.0].
1087 factor: f64,
1088 },
1089
1090 /// Spectral noise reduction using a statistical noise-type model.
1091 ///
1092 /// Uses `FFmpeg`'s `afftdn` filter. `noise_type_flag` is the single-letter
1093 /// `nt` parameter (`"w"` = white, `"p"` = pink, `"b"` = brown).
1094 /// `nr_level` is the reduction amount in dB, clamped to [0.0, 97.0].
1095 ///
1096 /// Created by [`FilterGraph::noise_reduce`](crate::FilterGraph::noise_reduce).
1097 NoiseReduce {
1098 /// `afftdn` `nt` flag: `"w"`, `"p"`, or `"b"`.
1099 noise_type_flag: String,
1100 /// Noise reduction amount in dB. Clamped to [0.0, 97.0].
1101 nr_level: f32,
1102 },
1103
1104 /// Spectral noise reduction using a captured noise profile.
1105 ///
1106 /// Uses `FFmpeg`'s `afftdn` with the `pl` (profile length) option: the
1107 /// filter learns the noise profile from the first `profile_duration_secs`
1108 /// seconds, then subtracts it from the rest of the stream.
1109 /// `nr_level` is the reduction amount in dB, clamped to [0.0, 97.0].
1110 ///
1111 /// Created by
1112 /// [`FilterGraph::noise_reduce_profile`](crate::FilterGraph::noise_reduce_profile).
1113 NoiseReduceProfile {
1114 /// Duration in seconds from which to capture the noise profile. Minimum 0.1.
1115 profile_duration_secs: f32,
1116 /// Noise reduction amount in dB. Clamped to [0.0, 97.0].
1117 nr_level: f32,
1118 },
1119
1120 /// Sidechain compression for audio ducking via `FFmpeg`'s `sidechaincompress` filter.
1121 ///
1122 /// Reduces the background audio level when the foreground (sidechain) signal
1123 /// exceeds the threshold. Push background audio to slot 0 and foreground
1124 /// audio to slot 1.
1125 ///
1126 /// `threshold_linear` is the trigger level as a linear amplitude (pre-converted
1127 /// from dBFS by [`FilterGraph::duck`](crate::FilterGraph::duck)).
1128 /// `ratio`, `attack_ms`, and `release_ms` are validated by
1129 /// [`FilterGraph::duck`](crate::FilterGraph::duck).
1130 Duck {
1131 /// Compression threshold as a linear amplitude ratio in (0.0, 1.0].
1132 threshold_linear: f32,
1133 /// Compression ratio (e.g. 20.0 for near hard-limiting). Must be >= 1.0.
1134 ratio: f32,
1135 /// Attack time in milliseconds. Must be >= 0.0.
1136 attack_ms: f32,
1137 /// Release time in milliseconds. Must be >= 0.0.
1138 release_ms: f32,
1139 },
1140
1141 /// Apply a polygon alpha mask using `FFmpeg`'s `geq` filter with a
1142 /// crossing-number point-in-polygon test.
1143 ///
1144 /// Pixels inside the polygon are fully opaque (`alpha=255`); pixels outside
1145 /// are fully transparent (`alpha=0`). When `invert` is `true` the roles
1146 /// are swapped.
1147 ///
1148 /// - `vertices`: polygon corners as `(x, y)` in `[0.0, 1.0]` (normalised
1149 /// to frame size). Minimum 3, maximum 16.
1150 /// - `invert`: when `false`, inside = opaque; when `true`, outside = opaque.
1151 ///
1152 /// Vertex count and coordinates are validated in
1153 /// [`build`](FilterGraphBuilder::build); out-of-range values return
1154 /// [`crate::FilterError::InvalidConfig`].
1155 ///
1156 /// The `geq` expression is constructed from the vertex list at graph
1157 /// build time. Degenerate polygons (zero area) produce a fully-transparent
1158 /// mask. The output carries an alpha channel (`rgba`).
1159 PolygonMatte {
1160 /// Polygon corners in normalised `[0.0, 1.0]` frame coordinates.
1161 vertices: Vec<(f32, f32)>,
1162 /// When `true`, the mask is inverted: outside is opaque, inside is transparent.
1163 invert: bool,
1164 },
1165
1166 /// Apply an arbitrary `FFmpeg` avfilter to the current stream — the escape
1167 /// hatch for filters not covered by the typed builder methods.
1168 ///
1169 /// `filter` is the avfilter name (e.g. `"selectivecolor"`, `"pseudocolor"`)
1170 /// and `args` its option string (e.g. `"reds=…:blues=…"`; empty when the
1171 /// filter takes no options). It is emitted as a single node `filter=args`
1172 /// and linked in chain order like any other step, so it accepts one input
1173 /// and produces one output.
1174 ///
1175 /// The filter name is checked for existence when the graph is built
1176 /// ([`build`](FilterGraphBuilder::build)); the `args` are validated by
1177 /// `FFmpeg` on the first [`push_video`](crate::FilterGraph::push_video) /
1178 /// [`push_audio`](crate::FilterGraph::push_audio), exactly as for the typed
1179 /// steps. Prefer the typed builder methods where they exist; use this for
1180 /// filters they do not cover.
1181 Raw {
1182 /// The avfilter name (e.g. `"selectivecolor"`).
1183 filter: String,
1184 /// The avfilter option string (e.g. `"reds=…"`); empty for none.
1185 args: String,
1186 },
1187
1188 /// Splice a whole libavfilter *description* into the chain — the escape
1189 /// hatch for graph shapes the step list cannot express.
1190 ///
1191 /// `desc` takes the same syntax as `ffmpeg -vf`, so it may chain several
1192 /// filters (`"scale=64:48,hue=s=0"`) and may use labels to branch and
1193 /// rejoin (`"split[a][b];[a]hue=s=0[c];[b][c]overlay"`). It is parsed with
1194 /// `avfilter_graph_parse2` and spliced in as one step.
1195 ///
1196 /// Use [`Raw`](Self::Raw) for a *single* untyped filter; that is what it is
1197 /// for, and it needs no parser. This variant exists for the two things
1198 /// `Raw` cannot do: take a whole chain as one string, and describe a
1199 /// non-linear graph.
1200 ///
1201 /// The description must have exactly one open input and one open output, so
1202 /// it links into the chain like any other step. Sources (`"color=c=red"`,
1203 /// zero open inputs) and sinks are rejected.
1204 ///
1205 /// Video only, like [`Raw`](Self::Raw): the audio graph builds from an
1206 /// allow-list of audio steps, which neither variant is in.
1207 ParseDesc {
1208 /// The filter description (e.g. `"scale=64:48,hue=s=0"`).
1209 desc: String,
1210 },
1211}
1212
1213/// Convert a color temperature in Kelvin to linear RGB multipliers using
1214/// Tanner Helland's algorithm.
1215///
1216/// Returns `(r, g, b)` each in `[0.0, 1.0]`.
1217/// Renders the three-way (lift/gamma/gain) colour corrector as `curves` `r/g/b`
1218/// options. Shared by [`FilterStep::ThreeWayCC`] and
1219/// [`FilterStep::ThreeWayCCAnimated`].
1220///
1221/// The formula maps each channel to a 3-point curve:
1222/// input 0.0 -> (lift - 1.0) * gain (black point)
1223/// input 0.5 -> (0.5 * lift)^(1/gamma) * gain (midtone)
1224/// input 1.0 -> gain (white point)
1225/// All neutral (1.0) produces the identity curve `0/0 0.5/0.5 1/1`.
1226fn three_way_cc_args(lift: Rgb, gamma: Rgb, gain: Rgb) -> String {
1227 let curve = |l: f32, gm: f32, gn: f32| -> String {
1228 let l = f64::from(l);
1229 let gm = f64::from(gm);
1230 let gn = f64::from(gn);
1231 let black = ((l - 1.0) * gn).clamp(0.0, 1.0);
1232 let mid = ((0.5 * l).powf(1.0 / gm) * gn).clamp(0.0, 1.0);
1233 let white = gn.clamp(0.0, 1.0);
1234 format!("0/{black} 0.5/{mid} 1/{white}")
1235 };
1236 format!(
1237 "r='{}':g='{}':b='{}'",
1238 curve(lift.r, gamma.r, gain.r),
1239 curve(lift.g, gamma.g, gain.g),
1240 curve(lift.b, gamma.b, gain.b),
1241 )
1242}
1243
1244/// Renders the compound glow filter chain (`split` -> `curves` -> `gblur` ->
1245/// `blend`) as a filtergraph string. Shared by [`FilterStep::Glow`] and
1246/// [`FilterStep::GlowAnimated`]. (The real build goes through `add_glow_step`; this
1247/// is for the `args()` completeness path.)
1248fn glow_compound_args(threshold: f32, radius: f32, intensity: f32) -> String {
1249 let t = threshold.clamp(0.0, 1.0);
1250 let r = radius.clamp(0.5, 50.0);
1251 let iv = intensity.clamp(0.0, 2.0);
1252 let hi_lo = format!("0/0 {t}/0 1/1");
1253 format!(
1254 "split=2[base][hl];[hl]curves=all='{hi_lo}'[glow_src];\
1255 [glow_src]gblur=sigma={r}[glow];\
1256 [base][glow]blend=all_mode=addition:all_opacity={iv}"
1257 )
1258}
1259
1260/// Lightness offset `[-1, 1]` -> the `hue` filter's brightness `[-10, 10]`. The
1261/// filter adds `b * 25.5` to 8-bit luma, so `±1` lightness maps to `±10` b, a
1262/// roughly full-range luma shift. This YUV luma-add only approximates the GPU
1263/// node's HSL-space lightness, hence the wide parity tolerance.
1264const HSL_LIGHTNESS_TO_BRIGHTNESS: f32 = 10.0;
1265
1266/// Renders the `hue` filter args for an HSL adjustment (hue degrees, saturation
1267/// multiplier, lightness scaled to the filter's brightness).
1268fn hsl_args(hue: f32, saturation: f32, lightness: f32) -> String {
1269 let b = lightness * HSL_LIGHTNESS_TO_BRIGHTNESS;
1270 format!("h={hue}:s={saturation}:b={b}")
1271}
1272
1273fn kelvin_to_rgb(temp_k: u32) -> (f64, f64, f64) {
1274 let t = (f64::from(temp_k) / 100.0).clamp(10.0, 400.0);
1275 let r = if t <= 66.0 {
1276 1.0
1277 } else {
1278 (329.698_727_446_4 * (t - 60.0).powf(-0.133_204_759_2) / 255.0).clamp(0.0, 1.0)
1279 };
1280 let g = if t <= 66.0 {
1281 ((99.470_802_586_1 * t.ln() - 161.119_568_166_1) / 255.0).clamp(0.0, 1.0)
1282 } else {
1283 ((288.122_169_528_3 * (t - 60.0).powf(-0.075_514_849_2)) / 255.0).clamp(0.0, 1.0)
1284 };
1285 let b = if t >= 66.0 {
1286 1.0
1287 } else if t <= 19.0 {
1288 0.0
1289 } else {
1290 ((138.517_731_223_1 * (t - 10.0).ln() - 305.044_792_730_7) / 255.0).clamp(0.0, 1.0)
1291 };
1292 (r, g, b)
1293}
1294
1295impl FilterStep {
1296 /// Returns the libavfilter filter name for this step.
1297 pub(crate) fn filter_name(&self) -> &str {
1298 match self {
1299 // Escape hatch: the runtime filter name is borrowed from `self`.
1300 // (The `&'static str` literals below coerce to the borrowed `&str`.)
1301 Self::Raw { filter, .. } => filter,
1302 // ParseDesc is a whole description, not one filter, so it has no
1303 // name to give. `add_and_link_step` dispatches it before the
1304 // single-filter path and `validate_filter_steps` skips it, so this
1305 // is never read; `null` (the identity filter) keeps a hypothetical
1306 // future fallback inert rather than wrong.
1307 Self::ParseDesc { .. } => "null",
1308 Self::Format { .. } => "format",
1309 Self::SetParams { .. } => "setparams",
1310 Self::Trim { .. } => "trim",
1311 // "setpts" is checked at build-time (see Speed's note below).
1312 Self::ResetPts | Self::OffsetPts { .. } => "setpts",
1313 Self::Scale { .. } => "scale",
1314 Self::Crop { .. } => "crop",
1315 Self::Overlay { .. } => "overlay",
1316 Self::FadeIn { .. }
1317 | Self::FadeOut { .. }
1318 | Self::FadeInWhite { .. }
1319 | Self::FadeOutWhite { .. } => "fade",
1320 Self::AFadeIn { .. } | Self::AFadeOut { .. } => "afade",
1321 Self::Rotate { .. } => "rotate",
1322 Self::ToneMap(_) => "tonemap",
1323 Self::Volume(_) => "volume",
1324 Self::Amix(_) => "amix",
1325 // ParametricEq is a compound step; "equalizer" is used only by
1326 // validate_filter_steps as a best-effort existence check. The
1327 // actual nodes are built by `filter_inner::add_parametric_eq_chain`.
1328 Self::ParametricEq { .. } => "equalizer",
1329 Self::Lut3d { .. } => "lut3d",
1330 Self::Eq { .. } => "eq",
1331 Self::EqAnimated { .. } => "eq",
1332 Self::ColorBalanceAnimated { .. } => "colorbalance",
1333 Self::Curves { .. } => "curves",
1334 Self::WhiteBalance { .. } => "colorchannelmixer",
1335 Self::Hue { .. } => "hue",
1336 Self::Hsl { .. } => "hue",
1337 Self::HslAnimated { .. } => "hue",
1338 Self::Gamma { .. } => "eq",
1339 Self::ThreeWayCC { .. } => "curves",
1340 Self::ThreeWayCCAnimated { .. } => "curves",
1341 Self::Vignette { .. } => "vignette",
1342 Self::HFlip => "hflip",
1343 Self::VFlip => "vflip",
1344 Self::Reverse => "reverse",
1345 Self::AReverse => "areverse",
1346 Self::Pad { .. } => "pad",
1347 // FitToAspect is implemented as scale + pad; "scale" is validated at
1348 // build time. The pad filter is inserted by filter_inner at graph
1349 // construction time.
1350 Self::FitToAspect { .. } => "scale",
1351 // FillToAspect is scale (cover) + crop; "scale" is validated at build
1352 // time. The crop filter is inserted by filter_inner at graph
1353 // construction time.
1354 Self::FillToAspect { .. } => "scale",
1355 Self::GBlur { .. } => "gblur",
1356 Self::Unsharp { .. } => "unsharp",
1357 Self::Hqdn3d { .. } => "hqdn3d",
1358 Self::Nlmeans { .. } => "nlmeans",
1359 Self::Yadif { .. } => "yadif",
1360 Self::XFade { .. } => "xfade",
1361 Self::DrawText { .. } | Self::Ticker { .. } => "drawtext",
1362 // "setpts" is checked at build-time; the audio path uses "atempo"
1363 // which is verified at graph-construction time in filter_inner.
1364 Self::Speed { .. } => "setpts",
1365 Self::FreezeFrame { .. } => "loop",
1366 Self::LoudnessNormalize { .. } => "ebur128",
1367 Self::NormalizePeak { .. } => "astats",
1368 Self::ANoiseGate { .. } => "agate",
1369 Self::ACompressor { .. } => "acompressor",
1370 Self::StereoToMono => "pan",
1371 Self::ChannelMap { .. } => "channelmap",
1372 // AudioDelay dispatches to adelay (positive) or atrim (negative) at
1373 // build time; "adelay" is returned here for validate_filter_steps only.
1374 Self::AudioDelay { .. } => "adelay",
1375 Self::ATrim { .. } => "atrim",
1376 // "asetpts" is checked at build-time (audio counterpart of ResetPts).
1377 Self::AResetPts => "asetpts",
1378 Self::ConcatVideo { .. } | Self::ConcatAudio { .. } => "concat",
1379 Self::SubtitlesSrt { .. } => "subtitles",
1380 Self::SubtitlesAss { .. } => "ass",
1381 // OverlayImage is a compound step (movie → lut → overlay); "overlay"
1382 // is used only by validate_filter_steps as a best-effort existence
1383 // check. The actual graph construction is handled by
1384 // `filter_inner::build::add_overlay_image_step`.
1385 Self::OverlayImage { .. } => "overlay",
1386 // Blend is a compound step; "overlay" is used as the primary filter
1387 // for validate_filter_steps. Unimplemented modes are caught by
1388 // build() before validate_filter_steps is reached.
1389 Self::Blend { .. } => "overlay",
1390 // Composite shares the Blend construction: Over/Under use overlay,
1391 // the expression operators use blend. validate_filter_steps only
1392 // needs a real filter name to probe existence.
1393 Self::Composite { op, .. } => match op {
1394 CompositeOp::Over | CompositeOp::Under => "overlay",
1395 CompositeOp::In | CompositeOp::Out | CompositeOp::Atop | CompositeOp::Xor => {
1396 "blend"
1397 }
1398 },
1399 Self::ChromaKey { .. } => "chromakey",
1400 Self::ChromaKeyAnimated { .. } => "chromakey",
1401 Self::ColorKey { .. } => "colorkey",
1402 Self::SpillSuppress { .. } => "hue",
1403 // AlphaMatte is a compound step (matte pipeline → alphamerge);
1404 // "alphamerge" is used by validate_filter_steps as the primary check.
1405 Self::AlphaMatte { .. } => "alphamerge",
1406 // LumaKey is a compound step when invert=true (lumakey + geq);
1407 // "lumakey" is used here for validate_filter_steps.
1408 Self::LumaKey { .. } => "lumakey",
1409 // RectMask uses geq to set alpha per-pixel based on rectangle bounds.
1410 Self::RectMask { .. } => "geq",
1411 Self::RectMaskAnimated { .. } => "geq",
1412 Self::LumaMask { .. } => "geq",
1413 // FeatherMask is a compound step (split → alphaextract → gblur → alphamerge);
1414 // "alphaextract" is used by validate_filter_steps as the primary check.
1415 Self::FeatherMask { .. } => "alphaextract",
1416 // PolygonMatte uses geq with a crossing-number point-in-polygon expression.
1417 Self::PolygonMatte { .. } => "geq",
1418 Self::CropAnimated { .. } => "crop",
1419 Self::GBlurAnimated { .. } => "gblur",
1420 Self::UnsharpAnimated { .. } => "unsharp",
1421 Self::ScaleAnimated { .. } => "scale",
1422 Self::RotateAnimated { .. } => "rotate",
1423 Self::VignetteAnimated { .. } => "vignette",
1424 Self::MotionBlur { .. } | Self::MotionBlurAnimated { .. } => "tblend",
1425 Self::LensCorrection { .. } => "lenscorrection",
1426 Self::FilmGrain { .. } => "noise",
1427 Self::FilmGrainAnimated { .. } => "noise",
1428 Self::ScaleMultiplier { .. } => "scale",
1429 Self::ChromaticAberration { .. } => "rgbashift",
1430 // Glow is a compound step (split → curves → gblur → blend);
1431 // "split" is used by validate_filter_steps as the primary check.
1432 Self::Glow { .. } => "split",
1433 Self::GlowAnimated { .. } => "split",
1434 // ReverbIr is a compound step (amovie[+adelay] → afir);
1435 // "afir" is used by validate_filter_steps as the primary check.
1436 Self::ReverbIr { .. } => "afir",
1437 Self::ReverbEcho { .. } => "aecho",
1438 // PitchShift is a compound step (asetrate → atempo);
1439 // "asetrate" is used by validate_filter_steps as the primary check.
1440 Self::PitchShift { .. } => "asetrate",
1441 // TimeStretch uses one or more chained atempo filters.
1442 Self::TimeStretch { .. } => "atempo",
1443 // SpeedChange uses asetrate to shift speed and pitch together.
1444 Self::SpeedChange { .. } => "asetrate",
1445 Self::NoiseReduce { .. } | Self::NoiseReduceProfile { .. } => "afftdn",
1446 // Duck is a two-input compound step; "sidechaincompress" is checked at
1447 // build time by validate_filter_steps.
1448 Self::Duck { .. } => "sidechaincompress",
1449 }
1450 }
1451
1452 /// Returns the `args` string passed to `avfilter_graph_create_filter`.
1453 pub(crate) fn args(&self) -> String {
1454 match self {
1455 // Escape hatch: the option string is passed through verbatim.
1456 Self::Raw { args, .. } => args.clone(),
1457 // See `filter_name`: ParseDesc never reaches the single-filter path.
1458 Self::ParseDesc { .. } => String::new(),
1459 Self::Format {
1460 pix_fmts,
1461 color_spaces,
1462 color_ranges,
1463 } => {
1464 // Each option list uses the FFmpeg-canonical `FfmpegToken`, skipping values with no
1465 // FFmpeg equivalent (`None`); an option is emitted only when a token survives.
1466 fn render<T: FfmpegToken>(key: &str, values: &[T]) -> Option<String> {
1467 let tokens: Vec<&str> = values
1468 .iter()
1469 .filter_map(FfmpegToken::ffmpeg_token)
1470 .collect();
1471 (!tokens.is_empty()).then(|| format!("{key}={}", tokens.join("|")))
1472 }
1473 [
1474 render("pix_fmts", pix_fmts),
1475 render("color_spaces", color_spaces),
1476 render("color_ranges", color_ranges),
1477 ]
1478 .into_iter()
1479 .flatten()
1480 .collect::<Vec<_>>()
1481 .join(":")
1482 }
1483 Self::SetParams {
1484 color_space,
1485 color_range,
1486 color_primaries,
1487 color_trc,
1488 } => {
1489 // Each option is emitted from the FFmpeg-canonical `FfmpegToken`, only when the
1490 // value is `Some` and yields a token (`Unknown` → `None` → skipped). All-`None`
1491 // renders to the empty string.
1492 fn opt<T: FfmpegToken>(key: &str, v: Option<&T>) -> Option<String> {
1493 v.and_then(FfmpegToken::ffmpeg_token)
1494 .map(|tok| format!("{key}={tok}"))
1495 }
1496 [
1497 opt("colorspace", color_space.as_ref()),
1498 opt("range", color_range.as_ref()),
1499 opt("color_primaries", color_primaries.as_ref()),
1500 opt("color_trc", color_trc.as_ref()),
1501 ]
1502 .into_iter()
1503 .flatten()
1504 .collect::<Vec<_>>()
1505 .join(":")
1506 }
1507 Self::Trim { start, end } => match (start, end) {
1508 (Some(s), Some(e)) => format!("start={s}:end={e}"),
1509 (Some(s), None) => format!("start={s}"),
1510 (None, Some(e)) => format!("end={e}"),
1511 (None, None) => String::new(),
1512 },
1513 Self::ResetPts => "PTS-STARTPTS".to_string(),
1514 Self::OffsetPts { seconds } => format!("PTS+{seconds}/TB"),
1515 Self::Scale {
1516 width,
1517 height,
1518 algorithm,
1519 } => format!("w={width}:h={height}:flags={}", algorithm.as_flags_str()),
1520 Self::Crop {
1521 x,
1522 y,
1523 width,
1524 height,
1525 } => {
1526 format!("x={x}:y={y}:w={width}:h={height}")
1527 }
1528 Self::Overlay { x, y } => format!("x={x}:y={y}"),
1529 Self::FadeIn { start, duration } => {
1530 format!("type=in:start_time={start}:duration={duration}")
1531 }
1532 Self::FadeOut { start, duration } => {
1533 format!("type=out:start_time={start}:duration={duration}")
1534 }
1535 Self::FadeInWhite { start, duration } => {
1536 format!("type=in:start_time={start}:duration={duration}:color=white")
1537 }
1538 Self::FadeOutWhite { start, duration } => {
1539 format!("type=out:start_time={start}:duration={duration}:color=white")
1540 }
1541 Self::AFadeIn { start, duration } => {
1542 format!("type=in:start_time={start}:duration={duration}")
1543 }
1544 Self::AFadeOut { start, duration } => {
1545 format!("type=out:start_time={start}:duration={duration}")
1546 }
1547 Self::Rotate {
1548 angle_degrees,
1549 fill_color,
1550 } => {
1551 format!(
1552 "angle={}:fillcolor={fill_color}",
1553 angle_degrees.to_radians()
1554 )
1555 }
1556 Self::ToneMap(algorithm) => format!("tonemap={}", algorithm.as_str()),
1557 Self::Volume(db) => format!("volume={db}dB"),
1558 // `normalize=0` sums the inputs (additive) instead of averaging by the
1559 // active-input count, matching the multi-track mixer's convention so a
1560 // mix has the same level however it is built.
1561 Self::Amix(inputs) => format!("inputs={inputs}:normalize=0"),
1562 // args() for ParametricEq is not used by the build loop (which is
1563 // bypassed in favour of add_parametric_eq_chain); provided here for
1564 // completeness using the first band's args.
1565 Self::ParametricEq { bands } => bands.first().map(EqBand::args).unwrap_or_default(),
1566 Self::Lut3d { path } => {
1567 format!("file={}:interp=trilinear", escape_filter_path(path))
1568 }
1569 // `temperature`/`tint` are GPU-only (the CPU `eq` filter has no such option),
1570 // so they are intentionally not emitted into the argument string.
1571 Self::Eq {
1572 brightness,
1573 contrast,
1574 saturation,
1575 temperature: _,
1576 tint: _,
1577 } => format!("brightness={brightness}:contrast={contrast}:saturation={saturation}"),
1578 Self::EqAnimated {
1579 brightness,
1580 contrast,
1581 saturation,
1582 gamma,
1583 temperature: _,
1584 tint: _,
1585 } => {
1586 let b = brightness.value_at(Duration::ZERO);
1587 let c = contrast.value_at(Duration::ZERO);
1588 let s = saturation.value_at(Duration::ZERO);
1589 let g = gamma.value_at(Duration::ZERO);
1590 format!("brightness={b}:contrast={c}:saturation={s}:gamma={g}")
1591 }
1592 Self::ColorBalanceAnimated { lift, gamma, gain } => {
1593 let (rl, gl, bl) = lift.value_at(Duration::ZERO);
1594 let (rm, gm, bm) = gamma.value_at(Duration::ZERO);
1595 let (rh, gh, bh) = gain.value_at(Duration::ZERO);
1596 format!("rs={rl}:gs={gl}:bs={bl}:rm={rm}:gm={gm}:bm={bm}:rh={rh}:gh={gh}:bh={bh}")
1597 }
1598 Self::Curves { master, r, g, b } => {
1599 let fmt = |pts: &[(f32, f32)]| -> String {
1600 pts.iter()
1601 .map(|(x, y)| format!("{x}/{y}"))
1602 .collect::<Vec<_>>()
1603 .join(" ")
1604 };
1605 [("master", master.as_slice()), ("r", r), ("g", g), ("b", b)]
1606 .iter()
1607 .filter(|(_, pts)| !pts.is_empty())
1608 .map(|(name, pts)| format!("{name}='{}'", fmt(pts)))
1609 .collect::<Vec<_>>()
1610 .join(":")
1611 }
1612 Self::WhiteBalance {
1613 temperature_k,
1614 tint,
1615 } => {
1616 let (r, g, b) = kelvin_to_rgb(*temperature_k);
1617 let g_adj = (g + f64::from(*tint)).clamp(0.0, 2.0);
1618 format!("rr={r}:gg={g_adj}:bb={b}")
1619 }
1620 Self::Hue { degrees } => format!("h={degrees}"),
1621 Self::Hsl {
1622 hue,
1623 saturation,
1624 lightness,
1625 } => hsl_args(*hue, *saturation, *lightness),
1626 Self::HslAnimated {
1627 hue,
1628 saturation,
1629 lightness,
1630 } => {
1631 // The CPU path is static (the GPU animates per frame): render the
1632 // `Duration::ZERO` values.
1633 #[allow(clippy::cast_possible_truncation)]
1634 hsl_args(
1635 hue.value_at(Duration::ZERO) as f32,
1636 saturation.value_at(Duration::ZERO) as f32,
1637 lightness.value_at(Duration::ZERO) as f32,
1638 )
1639 }
1640 Self::Gamma { r, g, b } => format!("gamma_r={r}:gamma_g={g}:gamma_b={b}"),
1641 Self::Vignette { angle, x0, y0 } => {
1642 let cx = if *x0 == 0.0 {
1643 "w/2".to_string()
1644 } else {
1645 x0.to_string()
1646 };
1647 let cy = if *y0 == 0.0 {
1648 "h/2".to_string()
1649 } else {
1650 y0.to_string()
1651 };
1652 format!("angle={angle}:x0={cx}:y0={cy}")
1653 }
1654 Self::ThreeWayCC { lift, gamma, gain } => three_way_cc_args(*lift, *gamma, *gain),
1655 Self::ThreeWayCCAnimated { lift, gamma, gain } => {
1656 // The compound `curves` string is built from the `Duration::ZERO`
1657 // values (the CPU path is static; the GPU animates per frame).
1658 #[allow(clippy::cast_possible_truncation)]
1659 let at0 = |a: &[AnimatedValue<f64>; 3]| Rgb {
1660 r: a[0].value_at(Duration::ZERO) as f32,
1661 g: a[1].value_at(Duration::ZERO) as f32,
1662 b: a[2].value_at(Duration::ZERO) as f32,
1663 };
1664 three_way_cc_args(at0(lift), at0(gamma), at0(gain))
1665 }
1666 Self::HFlip | Self::VFlip | Self::Reverse | Self::AReverse => String::new(),
1667 Self::GBlur { sigma } => format!("sigma={sigma}"),
1668 Self::Unsharp {
1669 luma_strength,
1670 chroma_strength,
1671 } => format!(
1672 "luma_msize_x=5:luma_msize_y=5:luma_amount={luma_strength}:\
1673 chroma_msize_x=5:chroma_msize_y=5:chroma_amount={chroma_strength}"
1674 ),
1675 Self::Hqdn3d {
1676 luma_spatial,
1677 chroma_spatial,
1678 luma_tmp,
1679 chroma_tmp,
1680 } => format!("{luma_spatial}:{chroma_spatial}:{luma_tmp}:{chroma_tmp}"),
1681 Self::Nlmeans { strength } => format!("s={strength}"),
1682 Self::Yadif { mode } => format!("mode={}", *mode as i32),
1683 Self::XFade {
1684 transition,
1685 duration,
1686 offset,
1687 } => {
1688 let t = transition.as_str();
1689 format!("transition={t}:duration={duration}:offset={offset}")
1690 }
1691 Self::DrawText { opts } => {
1692 // Escape special characters recognised by the drawtext filter.
1693 let escaped = opts
1694 .text
1695 .replace('\\', "\\\\")
1696 .replace(':', "\\:")
1697 .replace('\'', "\\'");
1698 let mut parts = vec![
1699 format!("text='{escaped}'"),
1700 // The text is drawn literally; disable `%{...}` expansion so a
1701 // user string cannot inject drawtext expansions (`%{gmtime}`, ...).
1702 "expansion=none".to_string(),
1703 format!("x={}", opts.x),
1704 format!("y={}", opts.y),
1705 format!("fontsize={}", opts.font_size),
1706 format!("fontcolor={}@{:.2}", opts.font_color, opts.opacity),
1707 ];
1708 if let Some(ref ff) = opts.font_file {
1709 // Escape like a filter path: an unescaped `:` (e.g. a Windows
1710 // drive letter) would be read as an option separator and could
1711 // inject drawtext options (`textfile=`, ...).
1712 let ff_escaped = ff
1713 .replace('\\', "/")
1714 .replace(':', "\\:")
1715 .replace('\'', "\\'");
1716 parts.push(format!("fontfile={ff_escaped}"));
1717 }
1718 if let Some(ref bc) = opts.box_color {
1719 parts.push("box=1".to_string());
1720 parts.push(format!("boxcolor={bc}"));
1721 parts.push(format!("boxborderw={}", opts.box_border_width));
1722 }
1723 parts.join(":")
1724 }
1725 Self::Ticker {
1726 text,
1727 y,
1728 speed_px_per_sec,
1729 font_size,
1730 font_color,
1731 } => {
1732 // Use the same escaping as DrawText.
1733 let escaped = text
1734 .replace('\\', "\\\\")
1735 .replace(':', "\\:")
1736 .replace('\'', "\\'");
1737 // x = w - t * speed: at t=0 the text starts fully off the right
1738 // edge (x = w) and scrolls left by `speed` pixels per second.
1739 format!(
1740 "text='{escaped}':x=w-t*{speed_px_per_sec}:y={y}:\
1741 fontsize={font_size}:fontcolor={font_color}"
1742 )
1743 }
1744 // Video path: divide PTS by factor to change playback speed.
1745 // Audio path args are built by filter_inner (chained atempo).
1746 Self::Speed { factor } => format!("PTS/{factor}"),
1747 // args() is not used by the build loop for LoudnessNormalize (two-pass
1748 // is handled entirely in filter_inner); provided here for completeness.
1749 Self::LoudnessNormalize { .. } => "peak=true:metadata=1".to_string(),
1750 // args() is not used by the build loop for NormalizePeak (two-pass
1751 // is handled entirely in filter_inner); provided here for completeness.
1752 Self::NormalizePeak { .. } => "metadata=1".to_string(),
1753 Self::FreezeFrame { pts, duration } => {
1754 // The `loop` filter needs a frame index and a loop count, not PTS or
1755 // wall-clock duration. We approximate both using 25 fps; accuracy
1756 // depends on the source stream's actual frame rate.
1757 #[allow(clippy::cast_possible_truncation)]
1758 let start = (*pts * 25.0) as i64;
1759 #[allow(clippy::cast_possible_truncation)]
1760 let loop_count = (*duration * 25.0) as i64;
1761 format!("loop={loop_count}:size=1:start={start}")
1762 }
1763 Self::SubtitlesSrt { path, force_style } => {
1764 // Escape for the filter's `:`-separated option parser: normalise
1765 // backslashes to `/` and escape the drive colon, else an absolute
1766 // Windows path (`C:\subs.srt`) breaks parsing at the colon.
1767 let escaped = path.replace('\\', "/").replace(':', "\\:");
1768 match force_style {
1769 Some(fs) if !fs.is_empty() => {
1770 // Single-quote the style value so its `,`/`=` are read as
1771 // one option value by the parser.
1772 format!("filename={escaped}:force_style='{fs}'")
1773 }
1774 _ => format!("filename={escaped}"),
1775 }
1776 }
1777 Self::SubtitlesAss { path } => {
1778 let escaped = path.replace('\\', "/").replace(':', "\\:");
1779 format!("filename={escaped}")
1780 }
1781 // args() for OverlayImage returns the overlay positional args (x:y).
1782 // These are not consumed by add_and_link_step (which is bypassed for
1783 // this compound step); they exist here only for completeness.
1784 Self::OverlayImage { x, y, .. } => format!("{x}:{y}"),
1785 // args() for Blend is not consumed by add_and_link_step (which is
1786 // bypassed in favour of add_blend_normal_step). Provided for
1787 // completeness using the Normal-mode overlay args.
1788 Self::Blend { .. } => "format=auto:shortest=1".to_string(),
1789 // args() for Composite is not consumed by add_and_link_step (bypassed
1790 // for this compound two-input step); provided here only to satisfy the
1791 // exhaustive match.
1792 Self::Composite { .. } => String::new(),
1793 Self::ChromaKey {
1794 color,
1795 similarity,
1796 blend,
1797 } => format!("color={color}:similarity={similarity}:blend={blend}"),
1798 Self::ChromaKeyAnimated {
1799 color,
1800 similarity,
1801 blend,
1802 } => {
1803 // The CPU path is static (the GPU animates per frame): render the
1804 // `Duration::ZERO` values.
1805 #[allow(clippy::cast_possible_truncation)]
1806 let (s, b) = (
1807 similarity.value_at(Duration::ZERO) as f32,
1808 blend.value_at(Duration::ZERO) as f32,
1809 );
1810 format!("color={color}:similarity={s}:blend={b}")
1811 }
1812 Self::ColorKey {
1813 color,
1814 similarity,
1815 blend,
1816 } => format!("color={color}:similarity={similarity}:blend={blend}"),
1817 Self::SpillSuppress { strength, .. } => format!("h=0:s={}", 1.0 - strength),
1818 // args() is not consumed by add_and_link_step (which is bypassed for
1819 // this compound step); provided here for completeness.
1820 Self::AlphaMatte { .. } => String::new(),
1821 Self::LumaKey {
1822 threshold,
1823 tolerance,
1824 softness,
1825 ..
1826 } => format!("threshold={threshold}:tolerance={tolerance}:softness={softness}"),
1827 // args() is not consumed by add_and_link_step (which is bypassed for
1828 // this compound step); provided here for completeness.
1829 Self::FeatherMask { .. } => String::new(),
1830 Self::RectMask {
1831 x,
1832 y,
1833 width,
1834 height,
1835 invert,
1836 } => {
1837 let xw = x + width - 1;
1838 let yh = y + height - 1;
1839 let (inside, outside) = if *invert { (0, 255) } else { (255, 0) };
1840 format!(
1841 "r='r(X,Y)':g='g(X,Y)':b='b(X,Y)':\
1842 a='if(between(X,{x},{xw})*between(Y,{y},{yh}),{inside},{outside})'"
1843 )
1844 }
1845 Self::RectMaskAnimated {
1846 x,
1847 y,
1848 width,
1849 height,
1850 invert,
1851 } => {
1852 // The CPU path is static (the GPU animates per frame): render the
1853 // `Duration::ZERO` rectangle. width/height are clamped to >= 1 so a
1854 // degenerate frame never fails `geq` build.
1855 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
1856 let px = |v: &AnimatedValue<f64>, min: f64| {
1857 v.value_at(Duration::ZERO).max(min).round() as u32
1858 };
1859 let (x, y) = (px(x, 0.0), px(y, 0.0));
1860 let (width, height) = (px(width, 1.0), px(height, 1.0));
1861 let xw = x + width - 1;
1862 let yh = y + height - 1;
1863 let (inside, outside) = if *invert { (0, 255) } else { (255, 0) };
1864 format!(
1865 "r='r(X,Y)':g='g(X,Y)':b='b(X,Y)':\
1866 a='if(between(X,{x},{xw})*between(Y,{y},{yh}),{inside},{outside})'"
1867 )
1868 }
1869 Self::LumaMask { invert } => {
1870 // BT.709 luma of the frame's own RGB (0..255) becomes the alpha.
1871 // Matches `ff_render::LumaMaskNode` on an opaque source. `invert`
1872 // uses `255 - luma` so dark pixels stay opaque.
1873 let luma = "0.2126*r(X,Y)+0.7152*g(X,Y)+0.0722*b(X,Y)";
1874 let alpha = if *invert {
1875 format!("255-({luma})")
1876 } else {
1877 luma.to_string()
1878 };
1879 format!("r='r(X,Y)':g='g(X,Y)':b='b(X,Y)':a='{alpha}'")
1880 }
1881 Self::PolygonMatte { vertices, invert } => {
1882 // Build a crossing-number point-in-polygon expression.
1883 // For each edge (ax,ay)→(bx,by), a horizontal ray from (X,Y) going
1884 // right crosses the edge when Y is in [min(ay,by), max(ay,by)) and
1885 // the intersection x > X. Exact horizontal edges (dy==0) are skipped.
1886 let n = vertices.len();
1887 let mut edge_exprs = Vec::new();
1888 for i in 0..n {
1889 let (ax, ay) = vertices[i];
1890 let (bx, by) = vertices[(i + 1) % n];
1891 let dy = by - ay;
1892 if dy == 0.0 {
1893 // Horizontal edge — never crosses a horizontal ray; skip.
1894 continue;
1895 }
1896 let min_y = ay.min(by);
1897 let max_y = ay.max(by);
1898 let dx = bx - ax;
1899 // x_intersect = ax*W + (Y - ay*H) * dx*W / (dy*H)
1900 // NOTE: the `geq` filter's dimension constants are `W`/`H` (plane
1901 // width/height) — it has no `iw`/`ih` (those belong to scale/overlay),
1902 // so using them makes `geq` fail with "Undefined constant".
1903 // `dx`/`dy` can be negative; parenthesise them so the expression
1904 // never contains a bare `*-`/`/-`.
1905 edge_exprs.push(format!(
1906 "if(gte(Y,{min_y}*H)*lt(Y,{max_y}*H)*gt({ax}*W+(Y-{ay}*H)*({dx})*W/(({dy})*H),X),1,0)"
1907 ));
1908 }
1909 let sum = if edge_exprs.is_empty() {
1910 "0".to_string()
1911 } else {
1912 edge_exprs.join("+")
1913 };
1914 let (inside, outside) = if *invert { (0, 255) } else { (255, 0) };
1915 format!(
1916 "r='r(X,Y)':g='g(X,Y)':b='b(X,Y)':\
1917 a='if(gt(mod({sum},2),0),{inside},{outside})'"
1918 )
1919 }
1920 Self::FitToAspect { width, height, .. } => {
1921 // Scale to fit within the target dimensions, preserving the source
1922 // aspect ratio. The accompanying pad filter (inserted by
1923 // filter_inner after this scale filter) centres the result on the
1924 // target canvas.
1925 format!("w={width}:h={height}:force_original_aspect_ratio=decrease")
1926 }
1927 Self::FillToAspect { width, height } => {
1928 // Scale to cover the target dimensions, preserving the source
1929 // aspect ratio. The accompanying crop filter (inserted by
1930 // filter_inner after this scale filter) centres the result on the
1931 // target canvas.
1932 format!("w={width}:h={height}:force_original_aspect_ratio=increase")
1933 }
1934 Self::Pad {
1935 width,
1936 height,
1937 x,
1938 y,
1939 color,
1940 } => {
1941 let px = if *x < 0 {
1942 "(ow-iw)/2".to_string()
1943 } else {
1944 x.to_string()
1945 };
1946 let py = if *y < 0 {
1947 "(oh-ih)/2".to_string()
1948 } else {
1949 y.to_string()
1950 };
1951 format!("width={width}:height={height}:x={px}:y={py}:color={color}")
1952 }
1953 Self::ANoiseGate {
1954 threshold_db,
1955 attack_ms,
1956 release_ms,
1957 } => {
1958 // `agate` expects threshold as a linear amplitude ratio (0.0–1.0).
1959 let threshold_linear = 10f32.powf(threshold_db / 20.0);
1960 format!("threshold={threshold_linear:.6}:attack={attack_ms}:release={release_ms}")
1961 }
1962 Self::ACompressor {
1963 threshold_db,
1964 ratio,
1965 attack_ms,
1966 release_ms,
1967 makeup_db,
1968 } => {
1969 format!(
1970 "threshold={threshold_db}dB:ratio={ratio}:attack={attack_ms}:\
1971 release={release_ms}:makeup={makeup_db}dB"
1972 )
1973 }
1974 Self::StereoToMono => "mono|c0=0.5*c0+0.5*c1".to_string(),
1975 Self::ChannelMap { mapping } => format!("map={mapping}"),
1976 // args() is not used directly for AudioDelay — the audio build loop
1977 // dispatches to add_raw_filter_step with the correct filter name and
1978 // args based on the sign of ms. These are provided for completeness.
1979 Self::AudioDelay { ms } => {
1980 if *ms >= 0.0 {
1981 format!("delays={ms}:all=1")
1982 } else {
1983 format!("start={}", -ms / 1000.0)
1984 }
1985 }
1986 Self::ATrim { start, end } => match (start, end) {
1987 (Some(s), Some(e)) => format!("start={s:.6}:end={e:.6}"),
1988 (Some(s), None) => format!("start={s:.6}"),
1989 (None, Some(e)) => format!("end={e:.6}"),
1990 (None, None) => String::new(),
1991 },
1992 Self::AResetPts => "PTS-STARTPTS".to_string(),
1993 Self::ConcatVideo { n } => format!("n={n}:v=1:a=0"),
1994 Self::ConcatAudio { n } => format!("n={n}:v=0:a=1"),
1995 Self::CropAnimated {
1996 x,
1997 y,
1998 width,
1999 height,
2000 } => {
2001 let x0 = x.value_at(Duration::ZERO);
2002 let y0 = y.value_at(Duration::ZERO);
2003 let w0 = width.value_at(Duration::ZERO);
2004 let h0 = height.value_at(Duration::ZERO);
2005 // No `eval=frame`: this FFmpeg's `crop` has no `eval` option and already
2006 // re-evaluates the x/y position expressions per frame, so `send_command`
2007 // updates to x/y take effect. (w/h are fixed at init — animate pan, not
2008 // zoom, via crop; zoom is done by scaling.)
2009 format!("x={x0}:y={y0}:w={w0}:h={h0}")
2010 }
2011 Self::GBlurAnimated { sigma } => {
2012 let s0 = sigma.value_at(Duration::ZERO);
2013 format!("sigma={s0}")
2014 }
2015 Self::UnsharpAnimated {
2016 luma_strength,
2017 chroma_strength,
2018 } => {
2019 // No `eval=frame` / `send_command`: `unsharp` has no runtime param,
2020 // so the CPU path renders the `Duration::ZERO` value statically.
2021 let l0 = luma_strength.value_at(Duration::ZERO);
2022 let c0 = chroma_strength.value_at(Duration::ZERO);
2023 format!(
2024 "luma_msize_x=5:luma_msize_y=5:luma_amount={l0}:\
2025 chroma_msize_x=5:chroma_msize_y=5:chroma_amount={c0}"
2026 )
2027 }
2028 Self::ScaleAnimated {
2029 width,
2030 height,
2031 algorithm,
2032 } => {
2033 let flags = algorithm.as_flags_str();
2034 let animated = matches!(width, AnimatedValue::Track(_))
2035 || matches!(height, AnimatedValue::Track(_));
2036 if animated {
2037 // Self-animate via per-frame expressions. A Static side is a
2038 // constant expression; a Track side compiles to a `t`-expression.
2039 let expr = |v: &AnimatedValue<f64>| match v {
2040 AnimatedValue::Track(t) => t.to_ffmpeg_expr("t"),
2041 AnimatedValue::Static(s) => format!("{s:.6}"),
2042 };
2043 format!(
2044 "w={}:h={}:flags={flags}:eval=frame",
2045 expr(width),
2046 expr(height)
2047 )
2048 } else {
2049 let w0 = width.value_at(Duration::ZERO);
2050 let h0 = height.value_at(Duration::ZERO);
2051 format!("w={w0}:h={h0}:flags={flags}")
2052 }
2053 }
2054 Self::RotateAnimated { angle, fill_color } => match angle {
2055 // `rotate` re-evaluates `angle` per frame, so a Track self-animates.
2056 // Compile the degrees track to a `t`-expression, converted to radians.
2057 AnimatedValue::Track(track) => {
2058 let deg = track.to_ffmpeg_expr("t");
2059 format!("angle=({deg})*PI/180:fillcolor={fill_color}")
2060 }
2061 AnimatedValue::Static(deg) => {
2062 format!("angle={}:fillcolor={fill_color}", deg.to_radians())
2063 }
2064 },
2065 Self::VignetteAnimated { amount, x0, y0 } => {
2066 let cx = if *x0 == 0.0 {
2067 "w/2".to_string()
2068 } else {
2069 x0.to_string()
2070 };
2071 let cy = if *y0 == 0.0 {
2072 "h/2".to_string()
2073 } else {
2074 y0.to_string()
2075 };
2076 match amount {
2077 // `vignette` re-evaluates `angle` per frame under `eval=frame`, so a
2078 // Track self-animates. The normalised amount is scaled to the angle
2079 // (radians) in the expression: `amount * PI/2`.
2080 AnimatedValue::Track(track) => {
2081 let a = track.to_ffmpeg_expr("t");
2082 format!("angle=({a})*PI/2:x0={cx}:y0={cy}:eval=frame")
2083 }
2084 AnimatedValue::Static(a) => {
2085 format!("angle={}:x0={cx}:y0={cy}", a * std::f64::consts::FRAC_PI_2)
2086 }
2087 }
2088 }
2089 Self::MotionBlur {
2090 shutter_angle_degrees,
2091 ..
2092 } => {
2093 let alpha = f64::from(*shutter_angle_degrees / 360.0).clamp(0.0, 1.0);
2094 let keep = 1.0 - alpha;
2095 let blend = alpha;
2096 format!("all_expr='A*{keep}+B*{blend}'")
2097 }
2098 Self::MotionBlurAnimated { shutter_angle, .. } => match shutter_angle {
2099 // Same mix as the constant form with the two constants replaced by
2100 // one clamped sub-expression in `T`. `clip` is FFmpeg's own
2101 // three-argument clamp, so the shutter stays in `[0, 360]` however
2102 // the track is authored.
2103 //
2104 // `A` is the **current** frame and `B` the previous one, so `alpha`
2105 // weights the previous: a shutter of 0 renders the current frame
2106 // untouched and 360 holds the previous one. Measured through the
2107 // real filter, because the direction is not obvious from `tblend`'s
2108 // documentation and getting it backwards inverts the effect.
2109 AnimatedValue::Track(track) => {
2110 let deg = track.to_ffmpeg_expr("T");
2111 let alpha = format!("clip(({deg})/360,0,1)");
2112 format!("all_expr='A*(1-({alpha}))+B*({alpha})'")
2113 }
2114 AnimatedValue::Static(deg) => {
2115 // Deliberately the *same arithmetic* as `MotionBlur`: an f32
2116 // divide widened to f64. Dividing in f64 here would render a
2117 // different string for any angle that is not exact in f32 (137
2118 // gives 0.3805555555555556 against 0.3805555701255798), so the
2119 // two forms would silently disagree where they should overlap.
2120 #[allow(clippy::cast_possible_truncation)]
2121 let alpha = f64::from((*deg as f32) / 360.0).clamp(0.0, 1.0);
2122 let keep = 1.0 - alpha;
2123 format!("all_expr='A*{keep}+B*{alpha}'")
2124 }
2125 },
2126 Self::LensCorrection { k1, k2 } => format!("k1={k1}:k2={k2}"),
2127 Self::FilmGrain {
2128 luma_strength,
2129 chroma_strength,
2130 } => {
2131 #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
2132 let ls = luma_strength.clamp(0.0, 100.0) as u32;
2133 #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
2134 let cs = chroma_strength.clamp(0.0, 100.0) as u32;
2135 format!("alls={ls}:c0s={cs}:c1s={cs}:allf=t")
2136 }
2137 Self::FilmGrainAnimated {
2138 luma_strength,
2139 chroma_strength,
2140 } => {
2141 // No `eval=frame` / `send_command`: `noise` has no runtime parameter,
2142 // so the CPU path renders the `Duration::ZERO` strength statically. The
2143 // pattern is still temporal via `allf=t`.
2144 #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
2145 let ls = luma_strength.value_at(Duration::ZERO).clamp(0.0, 100.0) as u32;
2146 #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
2147 let cs = chroma_strength.value_at(Duration::ZERO).clamp(0.0, 100.0) as u32;
2148 format!("alls={ls}:c0s={cs}:c1s={cs}:allf=t")
2149 }
2150 Self::ScaleMultiplier { factor } => {
2151 format!("w=iw*{factor}:h=ih*{factor}")
2152 }
2153 Self::ChromaticAberration { rh, bh } => {
2154 format!("rh={rh}:bh={bh}:edge=smear")
2155 }
2156 // args() is not consumed by add_and_link_step (which is bypassed for
2157 // this compound step); provided here for completeness.
2158 Self::Glow {
2159 threshold,
2160 radius,
2161 intensity,
2162 } => glow_compound_args(*threshold, *radius, *intensity),
2163 // The compound step is built by `add_glow_step`, not from this string;
2164 // the args are provided for completeness (rendered at `Duration::ZERO`).
2165 Self::GlowAnimated {
2166 threshold,
2167 radius,
2168 intensity,
2169 } => {
2170 #[allow(clippy::cast_possible_truncation)]
2171 let out = glow_compound_args(
2172 threshold.value_at(Duration::ZERO) as f32,
2173 radius.value_at(Duration::ZERO) as f32,
2174 intensity.value_at(Duration::ZERO) as f32,
2175 );
2176 out
2177 }
2178 Self::ReverbEcho {
2179 in_gain,
2180 out_gain,
2181 delays,
2182 decays,
2183 } => {
2184 let delay_str = delays
2185 .iter()
2186 .map(|d| d.to_string())
2187 .collect::<Vec<_>>()
2188 .join("|");
2189 let decay_str = decays
2190 .iter()
2191 .map(|d| d.to_string())
2192 .collect::<Vec<_>>()
2193 .join("|");
2194 format!(
2195 "in_gain={ig}:out_gain={og}:delays={ds}:decays={dec}",
2196 ig = in_gain,
2197 og = out_gain,
2198 ds = delay_str,
2199 dec = decay_str,
2200 )
2201 }
2202 // args() is not consumed by add_and_link_step (which is bypassed for
2203 // this compound step); provided here for completeness.
2204 Self::ReverbIr {
2205 ir_path,
2206 wet,
2207 dry,
2208 pre_delay_ms,
2209 } => {
2210 let delay = pre_delay_ms.min(&500);
2211 let delay_part = if *delay > 0 {
2212 format!(",adelay={delay}:all=1")
2213 } else {
2214 String::new()
2215 };
2216 format!("amovie={ir_path}{delay_part}[ir];[0:a][ir]afir=dry={dry}:wet={wet}")
2217 }
2218 // args() is not consumed by add_and_link_step (which is bypassed for
2219 // this compound step); provided here for completeness.
2220 Self::PitchShift { semitones, .. } => {
2221 let rate = 2f64.powf(f64::from(*semitones) / 12.0);
2222 let atempo = 1.0 / rate;
2223 format!("asetrate=sr*{rate:.6},atempo={atempo:.6}")
2224 }
2225 // args() is not consumed by add_and_link_step (bypassed in favour of
2226 // add_atempo_chain); provided here for single-instance completeness.
2227 Self::TimeStretch { factor, .. } => format!("{factor:.6}"),
2228 // args() is not consumed by add_and_link_step (bypassed; sample rate
2229 // is resolved from buffersrc_args at build time); provided for completeness.
2230 Self::SpeedChange { factor } => format!("asetrate=sr*{factor:.6}"),
2231 Self::NoiseReduce {
2232 noise_type_flag,
2233 nr_level,
2234 } => format!("nt={noise_type_flag}:nr={nr_level}"),
2235 Self::NoiseReduceProfile {
2236 profile_duration_secs,
2237 nr_level,
2238 } => format!("nr={nr_level}:nf=-25:nt=w:pl={profile_duration_secs}"),
2239 // args() is not consumed by add_and_link_step (bypassed for this
2240 // compound two-input step); provided for completeness.
2241 Self::Duck {
2242 threshold_linear,
2243 ratio,
2244 attack_ms,
2245 release_ms,
2246 } => format!(
2247 "threshold={threshold_linear}:ratio={ratio}:attack={attack_ms}:release={release_ms}"
2248 ),
2249 }
2250 }
2251
2252 /// Animation entries this step contributes for per-frame `send_command`, when it
2253 /// is an animated variant with `Track` params.
2254 ///
2255 /// `node_name` must equal the filter instance name created for this step (the
2256 /// `send_command` target). Returns empty for non-animated steps and for `Static`
2257 /// params. Used by [`crate::filter_inner::add_and_link_step`] to surface effect
2258 /// animations to the composition builders; the standalone [`FilterGraphBuilder`]
2259 /// registers the equivalent entries in its own builder methods.
2260 pub(crate) fn animation_entries(&self, node_name: &str) -> Vec<AnimationEntry> {
2261 let mut entries = Vec::new();
2262 match self {
2263 Self::CropAnimated {
2264 x,
2265 y,
2266 width,
2267 height,
2268 } => {
2269 push_scalar_entry(&mut entries, node_name, "x", x);
2270 push_scalar_entry(&mut entries, node_name, "y", y);
2271 push_scalar_entry(&mut entries, node_name, "w", width);
2272 push_scalar_entry(&mut entries, node_name, "h", height);
2273 }
2274 Self::GBlurAnimated { sigma } => {
2275 push_scalar_entry(&mut entries, node_name, "sigma", sigma);
2276 }
2277 // `temperature`/`tint` are GPU-only; the `eq` filter has no such runtime
2278 // command, so they are not pushed here.
2279 Self::EqAnimated {
2280 brightness,
2281 contrast,
2282 saturation,
2283 gamma,
2284 temperature: _,
2285 tint: _,
2286 } => {
2287 push_scalar_entry(&mut entries, node_name, "brightness", brightness);
2288 push_scalar_entry(&mut entries, node_name, "contrast", contrast);
2289 push_scalar_entry(&mut entries, node_name, "saturation", saturation);
2290 push_scalar_entry(&mut entries, node_name, "gamma", gamma);
2291 }
2292 Self::ColorBalanceAnimated { lift, gamma, gain } => {
2293 push_tuple_entries(&mut entries, node_name, ["rs", "gs", "bs"], lift);
2294 push_tuple_entries(&mut entries, node_name, ["rm", "gm", "bm"], gamma);
2295 push_tuple_entries(&mut entries, node_name, ["rh", "gh", "bh"], gain);
2296 }
2297 _ => {}
2298 }
2299 entries
2300 }
2301}
2302
2303/// Pushes an [`AnimationEntry`] for a scalar animated param when it is a `Track`.
2304fn push_scalar_entry(
2305 entries: &mut Vec<AnimationEntry>,
2306 node_name: &str,
2307 param: &'static str,
2308 av: &AnimatedValue<f64>,
2309) {
2310 if let AnimatedValue::Track(track) = av {
2311 entries.push(AnimationEntry {
2312 node_name: node_name.to_owned(),
2313 param,
2314 track: track.clone(),
2315 suffix: "",
2316 });
2317 }
2318}
2319
2320/// Pushes three per-channel [`AnimationEntry`]s for a `(R, G, B)` tuple animated param
2321/// when it is a `Track`, splitting the tuple track into one scalar track per channel.
2322fn push_tuple_entries(
2323 entries: &mut Vec<AnimationEntry>,
2324 node_name: &str,
2325 params: [&'static str; 3],
2326 av: &AnimatedValue<(f64, f64, f64)>,
2327) {
2328 let AnimatedValue::Track(track) = av else {
2329 return;
2330 };
2331 for (i, param) in params.into_iter().enumerate() {
2332 let scalar = track
2333 .keyframes()
2334 .iter()
2335 .fold(AnimationTrack::new(), |t, kf| {
2336 let v = match i {
2337 0 => kf.value.0,
2338 1 => kf.value.1,
2339 _ => kf.value.2,
2340 };
2341 t.push(Keyframe {
2342 timestamp: kf.timestamp,
2343 value: v,
2344 easing: kf.easing.clone(),
2345 })
2346 });
2347 entries.push(AnimationEntry {
2348 node_name: node_name.to_owned(),
2349 param,
2350 track: scalar,
2351 suffix: "",
2352 });
2353 }
2354}
2355
2356#[cfg(test)]
2357mod tests {
2358 use super::*;
2359 use crate::animation::{AnimationTrack, Easing, Keyframe};
2360
2361 #[test]
2362 fn escape_filter_path_should_escape_windows_drive_path() {
2363 // Backslashes → forward slashes; the drive colon is escaped as `\:` so
2364 // FFmpeg's filter-arg parser does not treat it as an option separator.
2365 assert_eq!(
2366 escape_filter_path(r"D:\dir\look.cube"),
2367 "D\\:/dir/look.cube"
2368 );
2369 }
2370
2371 #[test]
2372 fn escape_filter_path_should_leave_unix_path_unchanged() {
2373 assert_eq!(escape_filter_path("/home/u/look.cube"), "/home/u/look.cube");
2374 }
2375
2376 /// The animated form checked against the linked `FFmpeg`, not against a string.
2377 ///
2378 /// The animated shutter leans on one fact about `blend`: its expression is
2379 /// re-evaluated per frame with `T` bound to the frame's presentation time
2380 /// (`vf_blend.c`, `values[VAR_T] = pts * time_base`). A string-equality test
2381 /// cannot tell whether the linked build agrees, so these push frames through the
2382 /// real filter (RK-005). They live here rather than in `tests/` because `args()`
2383 /// is `pub(crate)` and widening it for a test would be the wrong trade.
2384 mod ffmpeg_reference {
2385 use super::*;
2386 use crate::FilterGraph;
2387 use ff_format::{PixelFormat, PooledBuffer, Rational, Timestamp, VideoFrame};
2388
2389 const W: u32 = 8;
2390 const H: u32 = 8;
2391 const FPS: u32 = 30;
2392
2393 /// A solid-luma frame at `frame_index / FPS` seconds.
2394 fn frame_at(luma: u8, frame_index: u32) -> VideoFrame {
2395 VideoFrame::new(
2396 vec![
2397 PooledBuffer::standalone(vec![luma; (W * H) as usize]),
2398 PooledBuffer::standalone(vec![128u8; ((W / 2) * (H / 2)) as usize]),
2399 PooledBuffer::standalone(vec![128u8; ((W / 2) * (H / 2)) as usize]),
2400 ],
2401 vec![W as usize, (W / 2) as usize, (W / 2) as usize],
2402 W,
2403 H,
2404 PixelFormat::Yuv420p,
2405 // PTS in 1/FPS units, so `blend`'s `T` (pts * time_base) is the
2406 // frame's time in seconds, which is what the expression reads.
2407 Timestamp::new(i64::from(frame_index), Rational::new(1, FPS as i32)),
2408 true,
2409 )
2410 .unwrap()
2411 }
2412
2413 /// Pushes `count` frames alternating white/black through `tblend=<args>` and
2414 /// returns the red channel of every frame that came out.
2415 ///
2416 /// Alternating matters: `tblend` mixes *consecutive inputs*, not an
2417 /// accumulating output, so a single seed is washed out after one step and
2418 /// every later frame would read the same whatever the shutter is. With
2419 /// alternating input there is contrast at every step, so the mix ratio is
2420 /// visible at any time.
2421 ///
2422 /// `None` means this build could not run the graph, which the callers treat
2423 /// as a skip (RK-002).
2424 fn run_tblend(args: &str, count: u32) -> Option<Vec<u8>> {
2425 let desc = format!("format=rgba,tblend={args},format=rgba");
2426 let mut graph = FilterGraph::parse_desc(&desc).ok()?;
2427 let mut out = Vec::new();
2428 for i in 0..count {
2429 let luma = if i % 2 == 0 { 235 } else { 16 };
2430 graph.push_video(0, &frame_at(luma, i)).ok()?;
2431 while let Ok(Some(frame)) = graph.pull_video() {
2432 out.push(frame.plane(0).map(|p| p[0])?);
2433 }
2434 }
2435 Some(out)
2436 }
2437
2438 fn constant_args(degrees: f32) -> String {
2439 FilterStep::MotionBlur {
2440 shutter_angle_degrees: degrees,
2441 sub_frames: 4,
2442 }
2443 .args()
2444 }
2445
2446 fn ramp_args(from: f64, to: f64) -> String {
2447 let track = AnimationTrack::new()
2448 .push(Keyframe::new(Duration::ZERO, from, Easing::Linear))
2449 .push(Keyframe::new(Duration::from_secs(1), to, Easing::Linear));
2450 FilterStep::MotionBlurAnimated {
2451 shutter_angle: AnimatedValue::Track(track),
2452 sub_frames: 4,
2453 }
2454 .args()
2455 }
2456
2457 #[test]
2458 fn ffmpeg_should_accept_the_animated_shutter_expression() {
2459 let Some(_) = run_tblend(&constant_args(180.0), 3) else {
2460 println!("skipping: this FFmpeg build cannot run tblend");
2461 return;
2462 };
2463 assert!(
2464 run_tblend(&ramp_args(360.0, 0.0), 3).is_some(),
2465 "FFmpeg rejected the animated shutter expression: {}",
2466 ramp_args(360.0, 0.0)
2467 );
2468 }
2469
2470 #[test]
2471 fn an_animated_shutter_should_vary_across_frames_where_a_constant_one_does_not() {
2472 // The point of the animated form: with the same alternating input, a
2473 // constant shutter gives the same mix at every step, while a ramp gives a
2474 // different one as `T` advances. A build that ignored `T` would produce
2475 // the constant's flat answer for both.
2476 let Some(flat) = run_tblend(&constant_args(180.0), FPS) else {
2477 println!("skipping: this FFmpeg build cannot run tblend");
2478 return;
2479 };
2480 let Some(ramped) = run_tblend(&ramp_args(0.0, 360.0), FPS) else {
2481 return;
2482 };
2483 // `tblend` emits from the second input on, so output `k` is input frame
2484 // `k + 1`. Both indices below are odd, so both outputs come from an even
2485 // input frame and share the same pair (current white, previous black);
2486 // only the shutter differs. Mixing parities compares a white-over-black
2487 // step against a black-over-white one, where the two effects cancel and
2488 // the swing reads as small.
2489 //
2490 // Measured, and matching the algebra exactly: output 3 is input frame 4
2491 // (T = 0.133 s, shutter 48, alpha 0.133) -> 255*0.867 = 221, and output
2492 // 27 is frame 28 (alpha 0.933) -> 255*0.067 = 17.
2493 let (early, late) = (3usize, 27usize);
2494 assert_eq!(early % 2, late % 2, "the two samples must share a parity");
2495 println!(
2496 "tblend same-parity frames {early} / {late}: constant180={:?} ramp0to360={:?}",
2497 (flat.get(early), flat.get(late)),
2498 (ramped.get(early), ramped.get(late))
2499 );
2500 let (fe, fl) = (flat[early], flat[late]);
2501 let (re, rl) = (ramped[early], ramped[late]);
2502 assert!(
2503 fe.abs_diff(fl) <= 2,
2504 "a constant shutter must give the same mix at both frames: {fe} vs {fl}"
2505 );
2506 assert!(
2507 re.abs_diff(rl) > 32,
2508 "a ramped shutter must give a different mix as T advances: {re} vs {rl}"
2509 );
2510 }
2511 }
2512
2513 #[test]
2514 fn motion_blur_animated_static_should_render_what_the_constant_form_renders() {
2515 // The two forms overlap at a constant shutter, and 137 is chosen precisely
2516 // because it is not exact in f32: an f64 divide here would render
2517 // 0.3805555555555556 where the constant form renders 0.3805555701255798,
2518 // and the paths would disagree for every ordinary angle.
2519 let constant = FilterStep::MotionBlur {
2520 shutter_angle_degrees: 137.0,
2521 sub_frames: 4,
2522 };
2523 let animated = FilterStep::MotionBlurAnimated {
2524 shutter_angle: AnimatedValue::Static(137.0),
2525 sub_frames: 4,
2526 };
2527 assert_eq!(constant.args(), animated.args());
2528 assert_eq!(constant.filter_name(), animated.filter_name());
2529 }
2530
2531 #[test]
2532 fn motion_blur_animated_track_should_render_an_uppercase_t_expression() {
2533 // `blend`'s expression variables are upper-case (`X Y W H SW SH T N A B TOP
2534 // BOTTOM`). A lower-case `t` is not among them and would evaluate as an
2535 // unknown, so the case is part of the contract, not a style choice.
2536 let track = AnimationTrack::new()
2537 .push(Keyframe::new(Duration::ZERO, 0.0, Easing::Linear))
2538 .push(Keyframe::new(Duration::from_secs(1), 360.0, Easing::Linear));
2539 let step = FilterStep::MotionBlurAnimated {
2540 shutter_angle: AnimatedValue::Track(track),
2541 sub_frames: 4,
2542 };
2543 let args = step.args();
2544 assert!(
2545 args.contains('T'),
2546 "the expression must reference T: {args}"
2547 );
2548 assert!(
2549 !args.contains(" t") && !args.contains("(t"),
2550 "a lower-case t is not a blend variable: {args}"
2551 );
2552 assert!(
2553 args.contains("clip("),
2554 "the shutter must stay clamped to [0, 360]: {args}"
2555 );
2556 assert!(
2557 args.starts_with("all_expr='A*(1-(") && args.ends_with("'"),
2558 "the mix must keep the A/B form the constant version uses: {args}"
2559 );
2560 }
2561
2562 #[test]
2563 fn motion_blur_animated_should_be_a_tblend_step() {
2564 let step = FilterStep::MotionBlurAnimated {
2565 shutter_angle: AnimatedValue::Static(180.0),
2566 sub_frames: 4,
2567 };
2568 assert_eq!(step.filter_name(), "tblend");
2569 }
2570
2571 #[test]
2572 fn lut3d_args_should_escape_path() {
2573 let step = FilterStep::Lut3d {
2574 path: r"D:\luts\look.cube".to_string(),
2575 };
2576 let args = step.args();
2577 assert!(
2578 !args.contains(r"D:\"),
2579 "raw Windows path must not appear unescaped in args: {args}"
2580 );
2581 assert!(args.contains("D\\:/luts/look.cube"), "got: {args}");
2582 assert!(args.ends_with(":interp=trilinear"));
2583 }
2584
2585 #[test]
2586 fn setparams_filter_name_should_be_setparams() {
2587 let step = FilterStep::SetParams {
2588 color_space: None,
2589 color_range: None,
2590 color_primaries: None,
2591 color_trc: None,
2592 };
2593 assert_eq!(step.filter_name(), "setparams");
2594 }
2595
2596 #[test]
2597 fn raw_filter_name_should_be_the_given_filter() {
2598 let step = FilterStep::Raw {
2599 filter: "selectivecolor".to_string(),
2600 args: "reds=0.1 0 0".to_string(),
2601 };
2602 assert_eq!(step.filter_name(), "selectivecolor");
2603 }
2604
2605 #[test]
2606 fn fill_to_aspect_args_should_cover_with_increase() {
2607 let step = FilterStep::FillToAspect {
2608 width: 1920,
2609 height: 1080,
2610 };
2611 assert_eq!(
2612 step.args(),
2613 "w=1920:h=1080:force_original_aspect_ratio=increase"
2614 );
2615 }
2616
2617 #[test]
2618 fn fill_to_aspect_filter_name_should_be_scale() {
2619 let step = FilterStep::FillToAspect {
2620 width: 1920,
2621 height: 1080,
2622 };
2623 assert_eq!(step.filter_name(), "scale");
2624 }
2625
2626 #[test]
2627 fn raw_args_should_pass_through_verbatim() {
2628 let step = FilterStep::Raw {
2629 filter: "selectivecolor".to_string(),
2630 args: "reds=0.1 0 0:blues=0 0 0.2".to_string(),
2631 };
2632 assert_eq!(step.args(), "reds=0.1 0 0:blues=0 0 0.2");
2633 }
2634
2635 #[test]
2636 fn raw_with_empty_args_should_render_empty_args() {
2637 let step = FilterStep::Raw {
2638 filter: "hflip".to_string(),
2639 args: String::new(),
2640 };
2641 assert_eq!(step.filter_name(), "hflip");
2642 assert_eq!(step.args(), "");
2643 }
2644
2645 #[test]
2646 fn setparams_args_should_emit_all_canonical_tokens() {
2647 let step = FilterStep::SetParams {
2648 color_space: Some(ColorSpace::Bt2020Ncl),
2649 color_range: Some(ColorRange::Limited),
2650 color_primaries: Some(ColorPrimaries::Bt2020),
2651 color_trc: Some(ColorTransfer::Hlg),
2652 };
2653 assert_eq!(
2654 step.args(),
2655 "colorspace=bt2020nc:range=tv:color_primaries=bt2020:color_trc=arib-std-b67"
2656 );
2657 }
2658
2659 #[test]
2660 fn setparams_args_should_emit_only_provided_options() {
2661 // HDR tagging often sets just primaries + transfer.
2662 let step = FilterStep::SetParams {
2663 color_space: None,
2664 color_range: None,
2665 color_primaries: Some(ColorPrimaries::Bt2020),
2666 color_trc: Some(ColorTransfer::Pq),
2667 };
2668 assert_eq!(step.args(), "color_primaries=bt2020:color_trc=smpte2084");
2669 }
2670
2671 #[test]
2672 fn setparams_args_should_skip_values_without_ffmpeg_token() {
2673 // `Unknown` renders to no token and must be skipped, not emitted as an invalid arg.
2674 let step = FilterStep::SetParams {
2675 color_space: Some(ColorSpace::Unknown),
2676 color_range: Some(ColorRange::Full),
2677 color_primaries: Some(ColorPrimaries::Unknown),
2678 color_trc: Some(ColorTransfer::Bt709),
2679 };
2680 assert_eq!(step.args(), "range=pc:color_trc=bt709");
2681 }
2682
2683 #[test]
2684 fn setparams_args_should_be_empty_when_all_none() {
2685 let step = FilterStep::SetParams {
2686 color_space: None,
2687 color_range: None,
2688 color_primaries: None,
2689 color_trc: None,
2690 };
2691 assert_eq!(step.args(), "");
2692 }
2693
2694 #[test]
2695 fn chroma_key_animated_should_render_static_zero_values_via_chromakey() {
2696 // The CPU path is static: args() renders the Duration::ZERO values through
2697 // the same `chromakey` filter as the const variant.
2698 let step = FilterStep::ChromaKeyAnimated {
2699 color: "0x00FF00".to_string(),
2700 similarity: AnimatedValue::Static(0.3),
2701 blend: AnimatedValue::Static(0.1),
2702 };
2703 assert_eq!(step.filter_name(), "chromakey");
2704 assert_eq!(step.args(), "color=0x00FF00:similarity=0.3:blend=0.1");
2705 }
2706
2707 #[test]
2708 fn rect_mask_animated_should_render_static_zero_bounds_via_geq() {
2709 // The CPU path is static: args() renders the Duration::ZERO rectangle through
2710 // the same geq expression as the const RectMask variant.
2711 let step = FilterStep::RectMaskAnimated {
2712 x: AnimatedValue::Static(10.0),
2713 y: AnimatedValue::Static(20.0),
2714 width: AnimatedValue::Static(30.0),
2715 height: AnimatedValue::Static(40.0),
2716 invert: false,
2717 };
2718 assert_eq!(step.filter_name(), "geq");
2719 // xw = 10 + 30 - 1 = 39, yh = 20 + 40 - 1 = 59.
2720 assert_eq!(
2721 step.args(),
2722 "r='r(X,Y)':g='g(X,Y)':b='b(X,Y)':\
2723 a='if(between(X,10,39)*between(Y,20,59),255,0)'"
2724 );
2725 }
2726
2727 #[test]
2728 fn rect_mask_animated_invert_should_swap_inside_outside() {
2729 let step = FilterStep::RectMaskAnimated {
2730 x: AnimatedValue::Static(0.0),
2731 y: AnimatedValue::Static(0.0),
2732 width: AnimatedValue::Static(8.0),
2733 height: AnimatedValue::Static(8.0),
2734 invert: true,
2735 };
2736 assert_eq!(
2737 step.args(),
2738 "r='r(X,Y)':g='g(X,Y)':b='b(X,Y)':\
2739 a='if(between(X,0,7)*between(Y,0,7),0,255)'"
2740 );
2741 }
2742
2743 #[test]
2744 fn luma_mask_should_render_bt709_self_luma_geq() {
2745 // Non-invert: alpha becomes the frame's own BT.709 luma via geq. (geq exposes
2746 // no alpha-read function here, so alpha is set to the luma rather than
2747 // multiplied; on the opaque source this equals LumaMaskNode's alpha *= luma.)
2748 let step = FilterStep::LumaMask { invert: false };
2749 assert_eq!(step.filter_name(), "geq");
2750 assert_eq!(
2751 step.args(),
2752 "r='r(X,Y)':g='g(X,Y)':b='b(X,Y)':\
2753 a='0.2126*r(X,Y)+0.7152*g(X,Y)+0.0722*b(X,Y)'"
2754 );
2755 }
2756
2757 #[test]
2758 fn luma_mask_invert_should_use_one_minus_luma() {
2759 // Invert: `255 - luma`, so dark pixels stay opaque.
2760 let step = FilterStep::LumaMask { invert: true };
2761 assert_eq!(
2762 step.args(),
2763 "r='r(X,Y)':g='g(X,Y)':b='b(X,Y)':\
2764 a='255-(0.2126*r(X,Y)+0.7152*g(X,Y)+0.0722*b(X,Y))'"
2765 );
2766 }
2767
2768 #[test]
2769 fn animation_entries_maps_only_track_params_to_the_node() {
2770 use crate::animation::{AnimatedValue, AnimationTrack, Easing, Keyframe};
2771 let ramp = || {
2772 AnimationTrack::new()
2773 .push(Keyframe::new(Duration::ZERO, 0.0, Easing::Linear))
2774 .push(Keyframe::new(Duration::from_secs(1), 10.0, Easing::Linear))
2775 };
2776 // Only x and h are Track → exactly two entries, both on the given node.
2777 let step = FilterStep::CropAnimated {
2778 x: AnimatedValue::Track(ramp()),
2779 y: AnimatedValue::Static(0.0),
2780 width: AnimatedValue::Static(4.0),
2781 height: AnimatedValue::Track(ramp()),
2782 };
2783 let entries = step.animation_entries("veff0");
2784 assert_eq!(entries.len(), 2);
2785 assert!(entries.iter().all(|e| e.node_name == "veff0"));
2786 let params: Vec<&str> = entries.iter().map(|e| e.param).collect();
2787 assert!(params.contains(&"x") && params.contains(&"h"));
2788
2789 // A fully static animated step registers nothing.
2790 let static_step = FilterStep::CropAnimated {
2791 x: AnimatedValue::Static(0.0),
2792 y: AnimatedValue::Static(0.0),
2793 width: AnimatedValue::Static(4.0),
2794 height: AnimatedValue::Static(8.0),
2795 };
2796 assert!(static_step.animation_entries("veff0").is_empty());
2797
2798 // A non-animated step never contributes entries.
2799 assert!(
2800 FilterStep::Crop {
2801 x: 0,
2802 y: 0,
2803 width: 4,
2804 height: 8,
2805 }
2806 .animation_entries("veff0")
2807 .is_empty()
2808 );
2809 }
2810}