Skip to main content

ff_filter/graph/builder/
mod.rs

1//! [`FilterGraphBuilder`] — consuming builder for filter graphs.
2
3use std::path::Path;
4use std::time::Duration;
5
6pub(super) use super::FilterGraph;
7pub(super) use super::filter_step::FilterStep;
8pub(super) use super::types::{
9    DrawTextOptions, EqBand, HwAccel, Rgb, ScaleAlgorithm, ToneMap, XfadeTransition, YadifMode,
10};
11pub(super) use crate::animation::{AnimatedValue, AnimationEntry};
12pub(super) use crate::blend::BlendMode;
13pub(super) use crate::error::FilterError;
14use crate::filter_inner::{FilterGraphInner, MIN_INPUT_FRAME_RATE};
15
16mod audio;
17mod video;
18
19// FilterGraphBuilder
20
21/// Builder for constructing a [`FilterGraph`].
22///
23/// Create one with [`FilterGraph::builder()`], chain the desired filter
24/// methods, then call [`build`](Self::build) to obtain the graph.
25///
26/// # Examples
27///
28/// ```ignore
29/// use ff_filter::{FilterGraph, ToneMap};
30///
31/// let graph = FilterGraph::builder()
32///     .scale(1280, 720)
33///     .tone_map(ToneMap::Hable)
34///     .build()?;
35/// ```
36#[derive(Debug, Default, Clone)]
37pub struct FilterGraphBuilder {
38    pub(super) steps: Vec<FilterStep>,
39    pub(super) hw: Option<HwAccel>,
40    /// Registered animation entries, transferred to [`FilterGraph`] on [`build()`](Self::build).
41    pub(super) animations: Vec<AnimationEntry>,
42    /// Frame rate declared on the video buffersrc. `None` leaves it unset, which is what
43    /// every filter but the constant-frame-rate ones wants.
44    pub(super) input_frame_rate: Option<f64>,
45}
46
47impl FilterGraphBuilder {
48    /// Creates an empty builder.
49    #[must_use]
50    pub fn new() -> Self {
51        Self::default()
52    }
53
54    /// Returns the accumulated filter steps.
55    ///
56    /// Used by `filter_inner` to build sub-graphs (e.g. the top layer of a
57    /// [`FilterStep::Blend`] compound step).
58    pub(crate) fn steps(&self) -> &[FilterStep] {
59        &self.steps
60    }
61
62    /// Appends a raw [`FilterStep`] to the chain.
63    ///
64    /// Lets callers build a graph from a `FilterStep` list they already hold —
65    /// e.g. the same per-clip effects passed to `Clip::with_video_effect` — so a
66    /// host can run the identical chain on a preview frame without reconstructing
67    /// it through the typed builder methods.
68    #[must_use]
69    pub fn add_step(mut self, step: FilterStep) -> Self {
70        self.steps.push(step);
71        self
72    }
73
74    /// Append an arbitrary `FFmpeg` avfilter as an effect step on the current
75    /// stream — the escape hatch for filters not covered by the typed builder
76    /// methods.
77    ///
78    /// `filter` is the avfilter name (e.g. `"selectivecolor"`) and `args` its
79    /// option string (empty for none). The filter name is checked for existence
80    /// at [`build`](Self::build); the `args` are validated by `FFmpeg` on the
81    /// first push. Equivalent to
82    /// `add_step(FilterStep::Raw { filter, args })`.
83    #[must_use]
84    pub fn raw_filter(self, filter: impl Into<String>, args: impl Into<String>) -> Self {
85        self.add_step(FilterStep::Raw {
86            filter: filter.into(),
87            args: args.into(),
88        })
89    }
90
91    /// Append a whole `FFmpeg` filter *description* to the chain — the escape
92    /// hatch for graph shapes the typed steps cannot express.
93    ///
94    /// `desc` uses the same syntax as `ffmpeg -vf`, so it may chain several
95    /// filters and may use labels to branch and rejoin:
96    ///
97    /// ```ignore
98    /// use ff_filter::FilterGraph;
99    ///
100    /// let graph = FilterGraph::builder()
101    ///     .scale(1280, 720)                                   // typed
102    ///     .parse_desc("split[a][b];[a]hue=s=0[c];[b][c]overlay") // escape hatch
103    ///     .build()?;
104    /// ```
105    ///
106    /// **This is an escape hatch, not the primary model.** The typed builder
107    /// methods stay the recommended path: they are checked at compile time,
108    /// while a description is an opaque string with **no compile-time checking
109    /// whatsoever** — a typo in a filter name or option is a runtime error, and
110    /// nothing here tells you an option changed meaning between `FFmpeg`
111    /// versions. Prefer a typed method wherever one exists, and
112    /// [`raw_filter`](Self::raw_filter) for a *single* untyped filter; reach for
113    /// this only for a whole chain or a non-linear description.
114    ///
115    /// # What is checked, and when
116    ///
117    /// [`build`](Self::build) checks the description's syntax, the existence of
118    /// every filter it names, its pad arity, **and its options** — both their
119    /// names and any value `av_opt_set` or expression evaluation rejects.
120    /// `avfilter_graph_parse2` applies options while parsing, so
121    /// `"hue=nosuchopt=1"` and `"hue=s=notanumber"` both fail at `build()`. This
122    /// is *more* than the typed steps and than
123    /// [`raw_filter`](Self::raw_filter) check there, not the same:
124    /// `raw_filter("hue", "nosuchopt=1")` builds and fails on the first push.
125    ///
126    /// What still waits for the first push is what cannot be known until the
127    /// links are configured — format negotiation and anything a filter decides
128    /// in `config_props`.
129    ///
130    /// The description must leave exactly one open input and one open output, so
131    /// it links into the chain like any other step; a source such as
132    /// `"color=c=red"` has none and is rejected. Video only, like
133    /// [`raw_filter`](Self::raw_filter): the audio graph is built from an
134    /// allow-list of audio steps that neither is part of.
135    ///
136    /// A `scale` *inside* a description is invisible to
137    /// [`FilterGraph::output_resolution`](crate::FilterGraph::output_resolution),
138    /// which only tracks the typed [`scale`](Self::scale) step. A consumer that
139    /// sizes an encoder from it — `ff-pipeline` does — will fall back to the
140    /// source resolution. Set the output resolution explicitly, or use the typed
141    /// step, when the description resizes.
142    ///
143    /// The `build()`-time check applies to this builder. A description that
144    /// reaches the realtime compositor instead is checked when *its* graph is
145    /// constructed, and the same diagnosis arrives wrapped in that path's error.
146    #[must_use]
147    pub fn parse_desc(self, desc: impl Into<String>) -> Self {
148        self.add_step(FilterStep::ParseDesc { desc: desc.into() })
149    }
150
151    /// Declare the frame rate of the frames that will be pushed, in frames per second.
152    ///
153    /// Only the filters that require a constant frame rate need it — [`xfade`](Self::xfade)
154    /// is one, and rejects a graph whose input rate is `0/1`, which is what an undeclared
155    /// buffersrc reports. Leaving it unset is correct for everything else, so this is
156    /// opt-in; [`build`](Self::build) rejects a graph that needs a rate and has none.
157    ///
158    /// The rate declares the *timing contract* of the input link. Frame presentation
159    /// still comes from each frame's own timestamp.
160    #[must_use]
161    pub fn input_frame_rate(mut self, fps: f64) -> Self {
162        self.input_frame_rate = Some(fps);
163        self
164    }
165
166    /// Enable hardware-accelerated filtering.
167    ///
168    /// When set, `hwupload` and `hwdownload` filters are inserted around the
169    /// filter chain automatically.
170    #[must_use]
171    pub fn hardware(mut self, hw: HwAccel) -> Self {
172        self.hw = Some(hw);
173        self
174    }
175
176    // Build
177
178    /// Build the [`FilterGraph`].
179    ///
180    /// # Errors
181    ///
182    /// Returns [`FilterError::BuildFailed`] if `steps` is empty (there is
183    /// nothing to filter). The actual `FFmpeg` graph is constructed lazily on the
184    /// first [`push_video`](FilterGraph::push_video) or
185    /// [`push_audio`](FilterGraph::push_audio) call.
186    ///
187    /// [`parse_desc`](Self::parse_desc) is the exception: its description is
188    /// parsed here — filter names, pad arity **and options included** — and a bad
189    /// one returns [`FilterError::InvalidConfig`] naming it, rather than failing
190    /// on the first push.
191    pub fn build(self) -> Result<FilterGraph, FilterError> {
192        if self.steps.is_empty() {
193            return Err(FilterError::BuildFailed);
194        }
195
196        // Validate overlay coordinates: negative x or y places the overlay
197        // entirely off-screen, which is almost always a misconfiguration
198        // (e.g. a watermark larger than the video). Catch it early with a
199        // descriptive error rather than silently producing invisible output.
200        for step in &self.steps {
201            if let FilterStep::ParametricEq { bands } = step
202                && bands.is_empty()
203            {
204                return Err(FilterError::InvalidConfig {
205                    reason: "equalizer bands must not be empty".to_string(),
206                });
207            }
208            if let FilterStep::Speed { factor } = step
209                && !(0.1..=100.0).contains(factor)
210            {
211                return Err(FilterError::InvalidConfig {
212                    reason: format!("speed factor {factor} out of range [0.1, 100.0]"),
213                });
214            }
215            if let FilterStep::LoudnessNormalize {
216                target_lufs,
217                true_peak_db,
218                lra,
219            } = step
220            {
221                if *target_lufs >= 0.0 {
222                    return Err(FilterError::InvalidConfig {
223                        reason: format!(
224                            "loudness_normalize target_lufs {target_lufs} must be < 0.0"
225                        ),
226                    });
227                }
228                if *true_peak_db > 0.0 {
229                    return Err(FilterError::InvalidConfig {
230                        reason: format!(
231                            "loudness_normalize true_peak_db {true_peak_db} must be <= 0.0"
232                        ),
233                    });
234                }
235                if *lra <= 0.0 {
236                    return Err(FilterError::InvalidConfig {
237                        reason: format!("loudness_normalize lra {lra} must be > 0.0"),
238                    });
239                }
240            }
241            if let FilterStep::NormalizePeak { target_db } = step
242                && *target_db > 0.0
243            {
244                return Err(FilterError::InvalidConfig {
245                    reason: format!("normalize_peak target_db {target_db} must be <= 0.0"),
246                });
247            }
248            if let FilterStep::FreezeFrame { pts, duration } = step {
249                if *pts < 0.0 {
250                    return Err(FilterError::InvalidConfig {
251                        reason: format!("freeze_frame pts {pts} must be >= 0.0"),
252                    });
253                }
254                if *duration <= 0.0 {
255                    return Err(FilterError::InvalidConfig {
256                        reason: format!("freeze_frame duration {duration} must be > 0.0"),
257                    });
258                }
259            }
260            if let FilterStep::Crop { width, height, .. } = step
261                && (*width == 0 || *height == 0)
262            {
263                return Err(FilterError::InvalidConfig {
264                    reason: "crop width and height must be > 0".to_string(),
265                });
266            }
267            if let FilterStep::CropAnimated { width, height, .. } = step {
268                let w0 = width.value_at(Duration::ZERO);
269                let h0 = height.value_at(Duration::ZERO);
270                if w0 <= 0.0 || h0 <= 0.0 {
271                    return Err(FilterError::InvalidConfig {
272                        reason: "crop width and height must be > 0".to_string(),
273                    });
274                }
275            }
276            if let FilterStep::GBlurAnimated { sigma } = step {
277                let s0 = sigma.value_at(Duration::ZERO);
278                if s0 < 0.0 {
279                    return Err(FilterError::InvalidConfig {
280                        reason: format!("gblur sigma {s0} must be >= 0.0"),
281                    });
282                }
283            }
284            if let FilterStep::UnsharpAnimated {
285                luma_strength,
286                chroma_strength,
287            } = step
288            {
289                let l0 = luma_strength.value_at(Duration::ZERO);
290                let c0 = chroma_strength.value_at(Duration::ZERO);
291                if !(-1.5..=1.5).contains(&l0) {
292                    return Err(FilterError::InvalidConfig {
293                        reason: format!("unsharp luma_strength {l0} out of range [-1.5, 1.5]"),
294                    });
295                }
296                if !(-1.5..=1.5).contains(&c0) {
297                    return Err(FilterError::InvalidConfig {
298                        reason: format!("unsharp chroma_strength {c0} out of range [-1.5, 1.5]"),
299                    });
300                }
301            }
302            if let FilterStep::EqAnimated {
303                brightness,
304                contrast,
305                saturation,
306                gamma,
307                temperature,
308                tint,
309            } = step
310            {
311                let b = brightness.value_at(Duration::ZERO);
312                if !(-1.0..=1.0).contains(&b) {
313                    return Err(FilterError::InvalidConfig {
314                        reason: format!("eq brightness {b} out of range [-1.0, 1.0]"),
315                    });
316                }
317                let c = contrast.value_at(Duration::ZERO);
318                if !(0.0..=3.0).contains(&c) {
319                    return Err(FilterError::InvalidConfig {
320                        reason: format!("eq contrast {c} out of range [0.0, 3.0]"),
321                    });
322                }
323                let s = saturation.value_at(Duration::ZERO);
324                if !(0.0..=3.0).contains(&s) {
325                    return Err(FilterError::InvalidConfig {
326                        reason: format!("eq saturation {s} out of range [0.0, 3.0]"),
327                    });
328                }
329                let g = gamma.value_at(Duration::ZERO);
330                if !(0.1..=10.0).contains(&g) {
331                    return Err(FilterError::InvalidConfig {
332                        reason: format!("eq gamma {g} out of range [0.1, 10.0]"),
333                    });
334                }
335                let temp = temperature.value_at(Duration::ZERO);
336                if !(-1.0..=1.0).contains(&temp) {
337                    return Err(FilterError::InvalidConfig {
338                        reason: format!("eq temperature {temp} out of range [-1.0, 1.0]"),
339                    });
340                }
341                let ti = tint.value_at(Duration::ZERO);
342                if !(-1.0..=1.0).contains(&ti) {
343                    return Err(FilterError::InvalidConfig {
344                        reason: format!("eq tint {ti} out of range [-1.0, 1.0]"),
345                    });
346                }
347            }
348            if let FilterStep::ColorBalanceAnimated { lift, gamma, gain } = step {
349                for (label, av) in [("lift", lift), ("gamma", gamma), ("gain", gain)] {
350                    let (r, g, b) = av.value_at(Duration::ZERO);
351                    for (channel, v) in [("r", r), ("g", g), ("b", b)] {
352                        if !(-1.0..=1.0).contains(&v) {
353                            return Err(FilterError::InvalidConfig {
354                                reason: format!(
355                                    "color_correct {label}.{channel} {v} out of range [-1.0, 1.0]"
356                                ),
357                            });
358                        }
359                    }
360                }
361            }
362            if let FilterStep::FadeIn { duration, .. }
363            | FilterStep::FadeOut { duration, .. }
364            | FilterStep::FadeInWhite { duration, .. }
365            | FilterStep::FadeOutWhite { duration, .. } = step
366                && *duration <= 0.0
367            {
368                return Err(FilterError::InvalidConfig {
369                    reason: format!("fade duration {duration} must be > 0.0"),
370                });
371            }
372            if let FilterStep::AFadeIn { duration, .. } | FilterStep::AFadeOut { duration, .. } =
373                step
374                && *duration <= 0.0
375            {
376                return Err(FilterError::InvalidConfig {
377                    reason: format!("afade duration {duration} must be > 0.0"),
378                });
379            }
380            if let FilterStep::XFade { duration, .. } = step
381                && *duration <= 0.0
382            {
383                return Err(FilterError::InvalidConfig {
384                    reason: format!("xfade duration {duration} must be > 0.0"),
385                });
386            }
387            if let FilterStep::ANoiseGate {
388                attack_ms,
389                release_ms,
390                ..
391            } = step
392            {
393                if *attack_ms <= 0.0 {
394                    return Err(FilterError::InvalidConfig {
395                        reason: format!("agate attack_ms {attack_ms} must be > 0.0"),
396                    });
397                }
398                if *release_ms <= 0.0 {
399                    return Err(FilterError::InvalidConfig {
400                        reason: format!("agate release_ms {release_ms} must be > 0.0"),
401                    });
402                }
403            }
404            if let FilterStep::ACompressor {
405                ratio,
406                attack_ms,
407                release_ms,
408                ..
409            } = step
410            {
411                if *ratio < 1.0 {
412                    return Err(FilterError::InvalidConfig {
413                        reason: format!("compressor ratio {ratio} must be >= 1.0"),
414                    });
415                }
416                if *attack_ms <= 0.0 {
417                    return Err(FilterError::InvalidConfig {
418                        reason: format!("compressor attack_ms {attack_ms} must be > 0.0"),
419                    });
420                }
421                if *release_ms <= 0.0 {
422                    return Err(FilterError::InvalidConfig {
423                        reason: format!("compressor release_ms {release_ms} must be > 0.0"),
424                    });
425                }
426            }
427            if let FilterStep::ChannelMap { mapping } = step
428                && mapping.is_empty()
429            {
430                return Err(FilterError::InvalidConfig {
431                    reason: "channel_map mapping must not be empty".to_string(),
432                });
433            }
434            if let FilterStep::ConcatVideo { n } = step
435                && *n < 2
436            {
437                return Err(FilterError::InvalidConfig {
438                    reason: format!("concat_video n={n} must be >= 2"),
439                });
440            }
441            if let FilterStep::ConcatAudio { n } = step
442                && *n < 2
443            {
444                return Err(FilterError::InvalidConfig {
445                    reason: format!("concat_audio n={n} must be >= 2"),
446                });
447            }
448            if let FilterStep::DrawText { opts } = step {
449                if opts.text.is_empty() {
450                    return Err(FilterError::InvalidConfig {
451                        reason: "drawtext text must not be empty".to_string(),
452                    });
453                }
454                if !(0.0..=1.0).contains(&opts.opacity) {
455                    return Err(FilterError::InvalidConfig {
456                        reason: format!(
457                            "drawtext opacity {} out of range [0.0, 1.0]",
458                            opts.opacity
459                        ),
460                    });
461                }
462            }
463            if let FilterStep::Ticker {
464                text,
465                speed_px_per_sec,
466                ..
467            } = step
468            {
469                if text.is_empty() {
470                    return Err(FilterError::InvalidConfig {
471                        reason: "ticker text must not be empty".to_string(),
472                    });
473                }
474                if *speed_px_per_sec <= 0.0 {
475                    return Err(FilterError::InvalidConfig {
476                        reason: format!("ticker speed_px_per_sec {speed_px_per_sec} must be > 0.0"),
477                    });
478                }
479            }
480            if let FilterStep::Overlay { x, y } = step
481                && (*x < 0 || *y < 0)
482            {
483                return Err(FilterError::InvalidConfig {
484                    reason: format!(
485                        "overlay position ({x}, {y}) is off-screen; \
486                         ensure the watermark fits within the video dimensions"
487                    ),
488                });
489            }
490            if let FilterStep::Lut3d { path } = step {
491                let ext = Path::new(path)
492                    .extension()
493                    .and_then(|e| e.to_str())
494                    .unwrap_or("");
495                if !matches!(ext, "cube" | "3dl") {
496                    return Err(FilterError::InvalidConfig {
497                        reason: format!("unsupported LUT format: .{ext}; expected .cube or .3dl"),
498                    });
499                }
500                if !Path::new(path).exists() {
501                    return Err(FilterError::InvalidConfig {
502                        reason: format!("LUT file not found: {path}"),
503                    });
504                }
505            }
506            if let FilterStep::SubtitlesSrt { path, .. } = step {
507                let ext = Path::new(path)
508                    .extension()
509                    .and_then(|e| e.to_str())
510                    .unwrap_or("");
511                if ext != "srt" {
512                    return Err(FilterError::InvalidConfig {
513                        reason: format!("unsupported subtitle format: .{ext}; expected .srt"),
514                    });
515                }
516                if !Path::new(path).exists() {
517                    return Err(FilterError::InvalidConfig {
518                        reason: format!("subtitle file not found: {path}"),
519                    });
520                }
521            }
522            if let FilterStep::SubtitlesAss { path } = step {
523                let ext = Path::new(path)
524                    .extension()
525                    .and_then(|e| e.to_str())
526                    .unwrap_or("");
527                if !matches!(ext, "ass" | "ssa") {
528                    return Err(FilterError::InvalidConfig {
529                        reason: format!(
530                            "unsupported subtitle format: .{ext}; expected .ass or .ssa"
531                        ),
532                    });
533                }
534                if !Path::new(path).exists() {
535                    return Err(FilterError::InvalidConfig {
536                        reason: format!("subtitle file not found: {path}"),
537                    });
538                }
539            }
540            if let FilterStep::ChromaKey {
541                similarity, blend, ..
542            } = step
543            {
544                if !(0.0..=1.0).contains(similarity) {
545                    return Err(FilterError::InvalidConfig {
546                        reason: format!(
547                            "chromakey similarity {similarity} out of range [0.0, 1.0]"
548                        ),
549                    });
550                }
551                if !(0.0..=1.0).contains(blend) {
552                    return Err(FilterError::InvalidConfig {
553                        reason: format!("chromakey blend {blend} out of range [0.0, 1.0]"),
554                    });
555                }
556            }
557            if let FilterStep::ColorKey {
558                similarity, blend, ..
559            } = step
560            {
561                if !(0.0..=1.0).contains(similarity) {
562                    return Err(FilterError::InvalidConfig {
563                        reason: format!("colorkey similarity {similarity} out of range [0.0, 1.0]"),
564                    });
565                }
566                if !(0.0..=1.0).contains(blend) {
567                    return Err(FilterError::InvalidConfig {
568                        reason: format!("colorkey blend {blend} out of range [0.0, 1.0]"),
569                    });
570                }
571            }
572            if let FilterStep::SpillSuppress { strength, .. } = step
573                && !(0.0..=1.0).contains(strength)
574            {
575                return Err(FilterError::InvalidConfig {
576                    reason: format!("spill_suppress strength {strength} out of range [0.0, 1.0]"),
577                });
578            }
579            if let FilterStep::LumaKey {
580                threshold,
581                tolerance,
582                softness,
583                ..
584            } = step
585            {
586                if !(0.0..=1.0).contains(threshold) {
587                    return Err(FilterError::InvalidConfig {
588                        reason: format!("lumakey threshold {threshold} out of range [0.0, 1.0]"),
589                    });
590                }
591                if !(0.0..=1.0).contains(tolerance) {
592                    return Err(FilterError::InvalidConfig {
593                        reason: format!("lumakey tolerance {tolerance} out of range [0.0, 1.0]"),
594                    });
595                }
596                if !(0.0..=1.0).contains(softness) {
597                    return Err(FilterError::InvalidConfig {
598                        reason: format!("lumakey softness {softness} out of range [0.0, 1.0]"),
599                    });
600                }
601            }
602            if let FilterStep::FeatherMask { radius } = step
603                && *radius == 0
604            {
605                return Err(FilterError::InvalidConfig {
606                    reason: "feather_mask radius must be > 0".to_string(),
607                });
608            }
609            if let FilterStep::RectMask { width, height, .. } = step
610                && (*width == 0 || *height == 0)
611            {
612                return Err(FilterError::InvalidConfig {
613                    reason: "rect_mask width and height must be > 0".to_string(),
614                });
615            }
616            if let FilterStep::PolygonMatte { vertices, .. } = step {
617                if vertices.len() < 3 {
618                    return Err(FilterError::InvalidConfig {
619                        reason: format!(
620                            "polygon_matte requires at least 3 vertices, got {}",
621                            vertices.len()
622                        ),
623                    });
624                }
625                if vertices.len() > 16 {
626                    return Err(FilterError::InvalidConfig {
627                        reason: format!(
628                            "polygon_matte supports up to 16 vertices, got {}",
629                            vertices.len()
630                        ),
631                    });
632                }
633                for &(x, y) in vertices {
634                    if !(0.0..=1.0).contains(&x) || !(0.0..=1.0).contains(&y) {
635                        return Err(FilterError::InvalidConfig {
636                            reason: format!(
637                                "polygon_matte vertex ({x}, {y}) out of range [0.0, 1.0]"
638                            ),
639                        });
640                    }
641                }
642            }
643            if let FilterStep::OverlayImage { path, opacity, .. } = step {
644                let ext = Path::new(path)
645                    .extension()
646                    .and_then(|e| e.to_str())
647                    .unwrap_or("");
648                if ext != "png" {
649                    return Err(FilterError::InvalidConfig {
650                        reason: format!("unsupported image format: .{ext}; expected .png"),
651                    });
652                }
653                if !(0.0..=1.0).contains(opacity) {
654                    return Err(FilterError::InvalidConfig {
655                        reason: format!("overlay_image opacity {opacity} out of range [0.0, 1.0]"),
656                    });
657                }
658                if !Path::new(path).exists() {
659                    return Err(FilterError::InvalidConfig {
660                        reason: format!("overlay image not found: {path}"),
661                    });
662                }
663            }
664            if let FilterStep::Eq {
665                brightness,
666                contrast,
667                saturation,
668                temperature,
669                tint,
670            } = step
671            {
672                if !(-1.0..=1.0).contains(brightness) {
673                    return Err(FilterError::InvalidConfig {
674                        reason: format!("eq brightness {brightness} out of range [-1.0, 1.0]"),
675                    });
676                }
677                if !(0.0..=3.0).contains(contrast) {
678                    return Err(FilterError::InvalidConfig {
679                        reason: format!("eq contrast {contrast} out of range [0.0, 3.0]"),
680                    });
681                }
682                if !(0.0..=3.0).contains(saturation) {
683                    return Err(FilterError::InvalidConfig {
684                        reason: format!("eq saturation {saturation} out of range [0.0, 3.0]"),
685                    });
686                }
687                if !(-1.0..=1.0).contains(temperature) {
688                    return Err(FilterError::InvalidConfig {
689                        reason: format!("eq temperature {temperature} out of range [-1.0, 1.0]"),
690                    });
691                }
692                if !(-1.0..=1.0).contains(tint) {
693                    return Err(FilterError::InvalidConfig {
694                        reason: format!("eq tint {tint} out of range [-1.0, 1.0]"),
695                    });
696                }
697            }
698            if let FilterStep::Curves { master, r, g, b } = step {
699                for (channel, pts) in [
700                    ("master", master.as_slice()),
701                    ("r", r.as_slice()),
702                    ("g", g.as_slice()),
703                    ("b", b.as_slice()),
704                ] {
705                    for &(x, y) in pts {
706                        if !(0.0..=1.0).contains(&x) || !(0.0..=1.0).contains(&y) {
707                            return Err(FilterError::InvalidConfig {
708                                reason: format!(
709                                    "curves {channel} control point ({x}, {y}) out of range [0.0, 1.0]"
710                                ),
711                            });
712                        }
713                    }
714                }
715            }
716            if let FilterStep::WhiteBalance {
717                temperature_k,
718                tint,
719            } = step
720            {
721                if !(1000..=40000).contains(temperature_k) {
722                    return Err(FilterError::InvalidConfig {
723                        reason: format!(
724                            "white_balance temperature_k {temperature_k} out of range [1000, 40000]"
725                        ),
726                    });
727                }
728                if !(-1.0..=1.0).contains(tint) {
729                    return Err(FilterError::InvalidConfig {
730                        reason: format!("white_balance tint {tint} out of range [-1.0, 1.0]"),
731                    });
732                }
733            }
734            if let FilterStep::Hue { degrees } = step
735                && !(-360.0..=360.0).contains(degrees)
736            {
737                return Err(FilterError::InvalidConfig {
738                    reason: format!("hue degrees {degrees} out of range [-360.0, 360.0]"),
739                });
740            }
741            if let FilterStep::Gamma { r, g, b } = step {
742                for (channel, val) in [("r", r), ("g", g), ("b", b)] {
743                    if !(0.1..=10.0).contains(val) {
744                        return Err(FilterError::InvalidConfig {
745                            reason: format!("gamma {channel} {val} out of range [0.1, 10.0]"),
746                        });
747                    }
748                }
749            }
750            if let FilterStep::ThreeWayCC { gamma, .. } = step {
751                for (channel, val) in [("r", gamma.r), ("g", gamma.g), ("b", gamma.b)] {
752                    if val <= 0.0 {
753                        return Err(FilterError::InvalidConfig {
754                            reason: format!("three_way_cc gamma.{channel} {val} must be > 0.0"),
755                        });
756                    }
757                }
758            }
759            if let FilterStep::ThreeWayCCAnimated {
760                gamma: [gr, gg, gb],
761                ..
762            } = step
763            {
764                for (channel, av) in [("r", gr), ("g", gg), ("b", gb)] {
765                    let val = av.value_at(Duration::ZERO);
766                    if val <= 0.0 {
767                        return Err(FilterError::InvalidConfig {
768                            reason: format!("three_way_cc gamma.{channel} {val} must be > 0.0"),
769                        });
770                    }
771                }
772            }
773            if let FilterStep::Vignette { angle, .. } = step
774                && !((0.0)..=std::f32::consts::FRAC_PI_2).contains(angle)
775            {
776                return Err(FilterError::InvalidConfig {
777                    reason: format!("vignette angle {angle} out of range [0.0, π/2]"),
778                });
779            }
780            if let FilterStep::VignetteAnimated { amount, .. } = step {
781                let a0 = amount.value_at(Duration::ZERO);
782                if !(0.0..=1.0).contains(&a0) {
783                    return Err(FilterError::InvalidConfig {
784                        reason: format!("vignette amount {a0} out of range [0.0, 1.0]"),
785                    });
786                }
787            }
788            if let FilterStep::Pad { width, height, .. } = step
789                && (*width == 0 || *height == 0)
790            {
791                return Err(FilterError::InvalidConfig {
792                    reason: "pad width and height must be > 0".to_string(),
793                });
794            }
795            if let FilterStep::FitToAspect { width, height, .. } = step
796                && (*width == 0 || *height == 0)
797            {
798                return Err(FilterError::InvalidConfig {
799                    reason: "fit_to_aspect width and height must be > 0".to_string(),
800                });
801            }
802            if let FilterStep::FillToAspect { width, height } = step
803                && (*width == 0 || *height == 0)
804            {
805                return Err(FilterError::InvalidConfig {
806                    reason: "fill_to_aspect width and height must be > 0".to_string(),
807                });
808            }
809            if let FilterStep::GBlur { sigma } = step
810                && *sigma < 0.0
811            {
812                return Err(FilterError::InvalidConfig {
813                    reason: format!("gblur sigma {sigma} must be >= 0.0"),
814                });
815            }
816            if let FilterStep::Unsharp {
817                luma_strength,
818                chroma_strength,
819            } = step
820            {
821                if !(-1.5..=1.5).contains(luma_strength) {
822                    return Err(FilterError::InvalidConfig {
823                        reason: format!(
824                            "unsharp luma_strength {luma_strength} out of range [-1.5, 1.5]"
825                        ),
826                    });
827                }
828                if !(-1.5..=1.5).contains(chroma_strength) {
829                    return Err(FilterError::InvalidConfig {
830                        reason: format!(
831                            "unsharp chroma_strength {chroma_strength} out of range [-1.5, 1.5]"
832                        ),
833                    });
834                }
835            }
836            if let FilterStep::Hqdn3d {
837                luma_spatial,
838                chroma_spatial,
839                luma_tmp,
840                chroma_tmp,
841            } = step
842            {
843                for (name, val) in [
844                    ("luma_spatial", luma_spatial),
845                    ("chroma_spatial", chroma_spatial),
846                    ("luma_tmp", luma_tmp),
847                    ("chroma_tmp", chroma_tmp),
848                ] {
849                    if *val < 0.0 {
850                        return Err(FilterError::InvalidConfig {
851                            reason: format!("hqdn3d {name} {val} must be >= 0.0"),
852                        });
853                    }
854                }
855            }
856            if let FilterStep::Nlmeans { strength } = step
857                && (*strength < 1.0 || *strength > 30.0)
858            {
859                return Err(FilterError::InvalidConfig {
860                    reason: format!("nlmeans strength {strength} out of range [1.0, 30.0]"),
861                });
862            }
863        }
864
865        // A declared rate reaches libavfilter as the buffersrc's `frame_rate`, so a
866        // token it cannot use has to be caught here — filter args are only validated at
867        // push time (see `build`'s lazy-graph note), long after the caller set this.
868        //
869        // The lower bound is not cosmetic: the emitted rational is `round(fps * 1000)`
870        // over 1000, so anything under 0.0005 rounds the numerator to zero and emits
871        // `frame_rate=0/1` — exactly the unusable rate an *undeclared* buffersrc
872        // reports, and the one this check exists to keep out.
873        if let Some(fps) = self.input_frame_rate
874            && (!fps.is_finite() || fps < MIN_INPUT_FRAME_RATE)
875        {
876            return Err(FilterError::InvalidConfig {
877                reason: format!(
878                    "input_frame_rate {fps} must be finite and >= {MIN_INPUT_FRAME_RATE} \
879                     (a smaller rate rounds to the unusable 0/1)"
880                ),
881            });
882        }
883        // `xfade` requires a constant frame rate on its inputs and refuses to configure
884        // against the `0/1` an undeclared buffersrc reports. Without this the failure
885        // surfaces only on the first push, as an opaque FFmpeg "Invalid argument".
886        if self.input_frame_rate.is_none()
887            && self
888                .steps
889                .iter()
890                .any(|s| matches!(s, FilterStep::XFade { .. }))
891        {
892            return Err(FilterError::InvalidConfig {
893                reason: "xfade requires a constant frame rate: call input_frame_rate() \
894                         with the rate of the pushed frames"
895                    .to_string(),
896            });
897        }
898
899        // Pure and registry-independent, so it runs first: `validate_filter_steps`
900        // returns `Ok` early when the registry is empty and would mask it.
901        crate::filter_inner::validate_composite_ops(&self.steps)?;
902        crate::filter_inner::validate_filter_steps(&self.steps)?;
903        crate::filter_inner::validate_parse_descs(&self.steps)?;
904        let output_resolution = self.steps.iter().rev().find_map(|s| {
905            if let FilterStep::Scale { width, height, .. } = s {
906                Some((*width, *height))
907            } else {
908                None
909            }
910        });
911        Ok(FilterGraph {
912            inner: FilterGraphInner::new(self.steps, self.hw, self.input_frame_rate),
913            output_resolution,
914            pending_animations: self.animations,
915        })
916    }
917}
918
919#[cfg(test)]
920mod tests {
921    use super::*;
922    use ff_format::AlphaMode;
923
924    #[test]
925    fn builder_empty_steps_should_return_error() {
926        let result = FilterGraph::builder().build();
927        assert!(
928            matches!(result, Err(FilterError::BuildFailed)),
929            "expected BuildFailed, got {result:?}"
930        );
931    }
932
933    #[test]
934    fn builder_steps_should_accumulate_in_order() {
935        let result = FilterGraph::builder()
936            .trim(0.0, 5.0)
937            .scale(1280, 720, ScaleAlgorithm::Fast)
938            .volume(-3.0)
939            .build();
940        assert!(
941            result.is_ok(),
942            "builder with multiple valid steps must succeed, got {result:?}"
943        );
944    }
945
946    #[test]
947    fn builder_with_valid_steps_should_succeed() {
948        let result = FilterGraph::builder()
949            .scale(1280, 720, ScaleAlgorithm::Fast)
950            .build();
951        assert!(
952            result.is_ok(),
953            "builder with a known filter step must succeed, got {result:?}"
954        );
955    }
956
957    #[test]
958    fn output_resolution_should_be_none_when_no_scale() {
959        let fg = FilterGraph::builder().trim(0.0, 5.0).build().unwrap();
960        assert_eq!(fg.output_resolution(), None);
961    }
962
963    #[test]
964    fn output_resolution_should_be_last_scale_dimensions() {
965        let fg = FilterGraph::builder()
966            .scale(1280, 720, ScaleAlgorithm::Fast)
967            .build()
968            .unwrap();
969        assert_eq!(fg.output_resolution(), Some((1280, 720)));
970    }
971
972    #[test]
973    fn output_resolution_should_use_last_scale_when_multiple_present() {
974        let fg = FilterGraph::builder()
975            .scale(1920, 1080, ScaleAlgorithm::Fast)
976            .scale(1280, 720, ScaleAlgorithm::Bicubic)
977            .build()
978            .unwrap();
979        assert_eq!(fg.output_resolution(), Some((1280, 720)));
980    }
981
982    #[test]
983    fn rgb_neutral_constant_should_have_all_channels_one() {
984        assert_eq!(Rgb::NEUTRAL.r, 1.0);
985        assert_eq!(Rgb::NEUTRAL.g, 1.0);
986        assert_eq!(Rgb::NEUTRAL.b, 1.0);
987    }
988
989    // blend()
990
991    #[test]
992    fn blend_normal_full_opacity_should_use_overlay_filter() {
993        // build() must succeed; filter_name() == "overlay" is validated inside
994        // validate_filter_steps at build time.
995        let top = FilterGraphBuilder::new().trim(0.0, 5.0);
996        let result = FilterGraph::builder()
997            .trim(0.0, 5.0)
998            .blend(top, BlendMode::Normal, 1.0, AlphaMode::Straight)
999            .build();
1000        assert!(
1001            result.is_ok(),
1002            "blend(Normal, opacity=1.0) must build successfully, got {result:?}"
1003        );
1004    }
1005
1006    #[test]
1007    fn blend_normal_half_opacity_should_apply_colorchannelmixer() {
1008        // build() must succeed; the colorchannelmixer step is added at graph
1009        // construction time (push_video) — tested end-to-end in integration tests.
1010        let top = FilterGraphBuilder::new().trim(0.0, 5.0);
1011        let result = FilterGraph::builder()
1012            .trim(0.0, 5.0)
1013            .blend(top, BlendMode::Normal, 0.5, AlphaMode::Straight)
1014            .build();
1015        assert!(
1016            result.is_ok(),
1017            "blend(Normal, opacity=0.5) must build successfully, got {result:?}"
1018        );
1019    }
1020
1021    #[test]
1022    fn blend_opacity_above_one_should_be_clamped_to_one() {
1023        // Clamping happens in blend(); out-of-range opacity must not cause build() to fail.
1024        let top = FilterGraphBuilder::new().trim(0.0, 5.0);
1025        let result = FilterGraph::builder()
1026            .trim(0.0, 5.0)
1027            .blend(top, BlendMode::Normal, 2.5, AlphaMode::Straight)
1028            .build();
1029        assert!(
1030            result.is_ok(),
1031            "blend with opacity=2.5 must clamp to 1.0 and build successfully, got {result:?}"
1032        );
1033    }
1034
1035    #[test]
1036    fn colorkey_out_of_range_similarity_should_return_invalid_config() {
1037        let result = FilterGraph::builder()
1038            .trim(0.0, 5.0)
1039            .colorkey("green", 1.5, 0.0)
1040            .build();
1041        assert!(
1042            matches!(result, Err(FilterError::InvalidConfig { .. })),
1043            "colorkey similarity > 1.0 must return InvalidConfig, got {result:?}"
1044        );
1045    }
1046
1047    #[test]
1048    fn colorkey_out_of_range_blend_should_return_invalid_config() {
1049        let result = FilterGraph::builder()
1050            .trim(0.0, 5.0)
1051            .colorkey("green", 0.3, -0.1)
1052            .build();
1053        assert!(
1054            matches!(result, Err(FilterError::InvalidConfig { .. })),
1055            "colorkey blend < 0.0 must return InvalidConfig, got {result:?}"
1056        );
1057    }
1058
1059    #[test]
1060    fn lumakey_out_of_range_threshold_should_return_invalid_config() {
1061        let result = FilterGraph::builder()
1062            .trim(0.0, 5.0)
1063            .lumakey(1.5, 0.1, 0.0, false)
1064            .build();
1065        assert!(
1066            matches!(result, Err(FilterError::InvalidConfig { .. })),
1067            "lumakey threshold > 1.0 must return InvalidConfig, got {result:?}"
1068        );
1069    }
1070
1071    #[test]
1072    fn lumakey_out_of_range_tolerance_should_return_invalid_config() {
1073        let result = FilterGraph::builder()
1074            .trim(0.0, 5.0)
1075            .lumakey(0.9, -0.1, 0.0, false)
1076            .build();
1077        assert!(
1078            matches!(result, Err(FilterError::InvalidConfig { .. })),
1079            "lumakey tolerance < 0.0 must return InvalidConfig, got {result:?}"
1080        );
1081    }
1082
1083    #[test]
1084    fn lumakey_out_of_range_softness_should_return_invalid_config() {
1085        let result = FilterGraph::builder()
1086            .trim(0.0, 5.0)
1087            .lumakey(0.9, 0.1, 1.5, false)
1088            .build();
1089        assert!(
1090            matches!(result, Err(FilterError::InvalidConfig { .. })),
1091            "lumakey softness > 1.0 must return InvalidConfig, got {result:?}"
1092        );
1093    }
1094
1095    #[test]
1096    fn spill_suppress_out_of_range_strength_should_return_invalid_config() {
1097        let result = FilterGraph::builder()
1098            .trim(0.0, 5.0)
1099            .spill_suppress("green", 1.5)
1100            .build();
1101        assert!(
1102            matches!(result, Err(FilterError::InvalidConfig { .. })),
1103            "spill_suppress strength > 1.0 must return InvalidConfig, got {result:?}"
1104        );
1105    }
1106
1107    #[test]
1108    fn spill_suppress_negative_strength_should_return_invalid_config() {
1109        let result = FilterGraph::builder()
1110            .trim(0.0, 5.0)
1111            .spill_suppress("green", -0.1)
1112            .build();
1113        assert!(
1114            matches!(result, Err(FilterError::InvalidConfig { .. })),
1115            "spill_suppress strength < 0.0 must return InvalidConfig, got {result:?}"
1116        );
1117    }
1118
1119    #[test]
1120    fn feather_mask_zero_radius_should_return_invalid_config() {
1121        let result = FilterGraph::builder()
1122            .trim(0.0, 5.0)
1123            .feather_mask(0)
1124            .build();
1125        assert!(
1126            matches!(result, Err(FilterError::InvalidConfig { .. })),
1127            "feather_mask radius=0 must return InvalidConfig, got {result:?}"
1128        );
1129    }
1130
1131    #[test]
1132    fn rect_mask_zero_width_should_return_invalid_config() {
1133        let result = FilterGraph::builder()
1134            .trim(0.0, 5.0)
1135            .rect_mask(0, 0, 0, 32, false)
1136            .build();
1137        assert!(
1138            matches!(result, Err(FilterError::InvalidConfig { .. })),
1139            "rect_mask width=0 must return InvalidConfig, got {result:?}"
1140        );
1141    }
1142
1143    #[test]
1144    fn rect_mask_zero_height_should_return_invalid_config() {
1145        let result = FilterGraph::builder()
1146            .trim(0.0, 5.0)
1147            .rect_mask(0, 0, 32, 0, false)
1148            .build();
1149        assert!(
1150            matches!(result, Err(FilterError::InvalidConfig { .. })),
1151            "rect_mask height=0 must return InvalidConfig, got {result:?}"
1152        );
1153    }
1154
1155    #[test]
1156    fn polygon_matte_fewer_than_3_vertices_should_return_invalid_config() {
1157        let result = FilterGraph::builder()
1158            .trim(0.0, 5.0)
1159            .polygon_matte(vec![(0.0, 0.0), (1.0, 0.0)], false)
1160            .build();
1161        assert!(
1162            matches!(result, Err(FilterError::InvalidConfig { .. })),
1163            "polygon_matte with < 3 vertices must return InvalidConfig, got {result:?}"
1164        );
1165    }
1166
1167    #[test]
1168    fn polygon_matte_more_than_16_vertices_should_return_invalid_config() {
1169        let verts = (0..17)
1170            .map(|i| {
1171                let angle = i as f32 * 2.0 * std::f32::consts::PI / 17.0;
1172                (0.5 + 0.4 * angle.cos(), 0.5 + 0.4 * angle.sin())
1173            })
1174            .collect();
1175        let result = FilterGraph::builder()
1176            .trim(0.0, 5.0)
1177            .polygon_matte(verts, false)
1178            .build();
1179        assert!(
1180            matches!(result, Err(FilterError::InvalidConfig { .. })),
1181            "polygon_matte with > 16 vertices must return InvalidConfig, got {result:?}"
1182        );
1183    }
1184
1185    #[test]
1186    fn polygon_matte_out_of_range_vertex_should_return_invalid_config() {
1187        let result = FilterGraph::builder()
1188            .trim(0.0, 5.0)
1189            .polygon_matte(vec![(0.0, 0.0), (1.5, 0.0), (0.0, 1.0)], false)
1190            .build();
1191        assert!(
1192            matches!(result, Err(FilterError::InvalidConfig { .. })),
1193            "polygon_matte with vertex x > 1.0 must return InvalidConfig, got {result:?}"
1194        );
1195    }
1196
1197    #[test]
1198    fn polygon_matte_geq_uses_valid_constants_and_no_bare_star_minus() {
1199        // Regression for two `geq` eval failures:
1200        // 1. `geq` has no `iw`/`ih` constants (those are scale/overlay's) — it uses
1201        //    `W`/`H`; using `iw`/`ih` fails with "Undefined constant".
1202        // 2. A diagonal edge has negative `dx`/`dy`; they must be parenthesised so
1203        //    the expression contains no bare `*-`/`/-`.
1204        let steps = FilterGraph::builder()
1205            .polygon_matte(vec![(0.2, 0.1), (0.9, 0.5), (0.3, 0.9)], false)
1206            .steps()
1207            .to_vec();
1208        let args = steps[0].args();
1209        assert!(
1210            !args.contains("iw") && !args.contains("ih"),
1211            "geq must use W/H, not iw/ih: {args}"
1212        );
1213        assert!(
1214            args.contains("*W") && args.contains("*H"),
1215            "geq should use W/H: {args}"
1216        );
1217        assert!(
1218            !args.contains("*-") && !args.contains("/-"),
1219            "geq expression must not contain a bare '*-'/'/-': {args}"
1220        );
1221    }
1222
1223    #[test]
1224    fn chromakey_out_of_range_similarity_should_return_invalid_config() {
1225        let result = FilterGraph::builder()
1226            .trim(0.0, 5.0)
1227            .chromakey("green", 1.5, 0.0)
1228            .build();
1229        assert!(
1230            matches!(result, Err(FilterError::InvalidConfig { .. })),
1231            "chromakey similarity > 1.0 must return InvalidConfig, got {result:?}"
1232        );
1233    }
1234
1235    #[test]
1236    fn chromakey_out_of_range_blend_should_return_invalid_config() {
1237        let result = FilterGraph::builder()
1238            .trim(0.0, 5.0)
1239            .chromakey("green", 0.3, -0.1)
1240            .build();
1241        assert!(
1242            matches!(result, Err(FilterError::InvalidConfig { .. })),
1243            "chromakey blend < 0.0 must return InvalidConfig, got {result:?}"
1244        );
1245    }
1246
1247    #[test]
1248    fn parse_desc_should_append_a_step_carrying_the_description_verbatim() {
1249        // Deterministic, so it runs on the FFmpeg-less CI legs too: the escape
1250        // hatch is a passthrough, and any normalisation of the string here would
1251        // change what FFmpeg is asked to parse. Appending (not replacing) is what
1252        // lets a description sit between typed steps.
1253        let desc = "split[a][b];[a]hue=s=0[c];[b][c]overlay";
1254        let builder = FilterGraph::builder()
1255            .scale(1280, 720, ScaleAlgorithm::Fast)
1256            .parse_desc(desc);
1257
1258        let steps = builder.steps();
1259        assert_eq!(steps.len(), 2, "parse_desc must append, not replace");
1260        assert!(
1261            matches!(&steps[0], FilterStep::Scale { .. }),
1262            "the typed step must keep its position, got {:?}",
1263            steps[0]
1264        );
1265        match &steps[1] {
1266            FilterStep::ParseDesc { desc: recorded } => assert_eq!(
1267                recorded, desc,
1268                "the description must be carried through unchanged"
1269            ),
1270            other => panic!("expected a ParseDesc step, got {other:?}"),
1271        }
1272    }
1273}