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