Skip to main content

ff_filter/graph/builder/
mod.rs

1//! [`FilterGraphBuilder`] — consuming builder for filter graphs.
2
3use std::path::Path;
4
5pub(super) use super::FilterGraph;
6pub(super) use super::filter_step::FilterStep;
7pub(super) use super::types::{
8    DrawTextOptions, EqBand, HwAccel, Rgb, ScaleAlgorithm, ToneMap, XfadeTransition, YadifMode,
9};
10pub(super) use crate::blend::BlendMode;
11pub(super) use crate::error::FilterError;
12use crate::filter_inner::FilterGraphInner;
13
14mod audio;
15mod video;
16
17// ── FilterGraphBuilder ────────────────────────────────────────────────────────
18
19/// Builder for constructing a [`FilterGraph`].
20///
21/// Create one with [`FilterGraph::builder()`], chain the desired filter
22/// methods, then call [`build`](Self::build) to obtain the graph.
23///
24/// # Examples
25///
26/// ```ignore
27/// use ff_filter::{FilterGraph, ToneMap};
28///
29/// let graph = FilterGraph::builder()
30///     .scale(1280, 720)
31///     .tone_map(ToneMap::Hable)
32///     .build()?;
33/// ```
34#[derive(Debug, Default, Clone)]
35pub struct FilterGraphBuilder {
36    pub(super) steps: Vec<FilterStep>,
37    pub(super) hw: Option<HwAccel>,
38}
39
40impl FilterGraphBuilder {
41    /// Creates an empty builder.
42    #[must_use]
43    pub fn new() -> Self {
44        Self::default()
45    }
46
47    /// Returns the accumulated filter steps.
48    ///
49    /// Used by `filter_inner` to build sub-graphs (e.g. the top layer of a
50    /// [`FilterStep::Blend`] compound step).
51    pub(crate) fn steps(&self) -> &[FilterStep] {
52        &self.steps
53    }
54
55    /// Enable hardware-accelerated filtering.
56    ///
57    /// When set, `hwupload` and `hwdownload` filters are inserted around the
58    /// filter chain automatically.
59    #[must_use]
60    pub fn hardware(mut self, hw: HwAccel) -> Self {
61        self.hw = Some(hw);
62        self
63    }
64
65    // ── Build ─────────────────────────────────────────────────────────────────
66
67    /// Build the [`FilterGraph`].
68    ///
69    /// # Errors
70    ///
71    /// Returns [`FilterError::BuildFailed`] if `steps` is empty (there is
72    /// nothing to filter). The actual `FFmpeg` graph is constructed lazily on the
73    /// first [`push_video`](FilterGraph::push_video) or
74    /// [`push_audio`](FilterGraph::push_audio) call.
75    pub fn build(self) -> Result<FilterGraph, FilterError> {
76        if self.steps.is_empty() {
77            return Err(FilterError::BuildFailed);
78        }
79
80        // Validate overlay coordinates: negative x or y places the overlay
81        // entirely off-screen, which is almost always a misconfiguration
82        // (e.g. a watermark larger than the video). Catch it early with a
83        // descriptive error rather than silently producing invisible output.
84        for step in &self.steps {
85            if let FilterStep::ParametricEq { bands } = step
86                && bands.is_empty()
87            {
88                return Err(FilterError::InvalidConfig {
89                    reason: "equalizer bands must not be empty".to_string(),
90                });
91            }
92            if let FilterStep::Speed { factor } = step
93                && !(0.1..=100.0).contains(factor)
94            {
95                return Err(FilterError::InvalidConfig {
96                    reason: format!("speed factor {factor} out of range [0.1, 100.0]"),
97                });
98            }
99            if let FilterStep::LoudnessNormalize {
100                target_lufs,
101                true_peak_db,
102                lra,
103            } = step
104            {
105                if *target_lufs >= 0.0 {
106                    return Err(FilterError::InvalidConfig {
107                        reason: format!(
108                            "loudness_normalize target_lufs {target_lufs} must be < 0.0"
109                        ),
110                    });
111                }
112                if *true_peak_db > 0.0 {
113                    return Err(FilterError::InvalidConfig {
114                        reason: format!(
115                            "loudness_normalize true_peak_db {true_peak_db} must be <= 0.0"
116                        ),
117                    });
118                }
119                if *lra <= 0.0 {
120                    return Err(FilterError::InvalidConfig {
121                        reason: format!("loudness_normalize lra {lra} must be > 0.0"),
122                    });
123                }
124            }
125            if let FilterStep::NormalizePeak { target_db } = step
126                && *target_db > 0.0
127            {
128                return Err(FilterError::InvalidConfig {
129                    reason: format!("normalize_peak target_db {target_db} must be <= 0.0"),
130                });
131            }
132            if let FilterStep::FreezeFrame { pts, duration } = step {
133                if *pts < 0.0 {
134                    return Err(FilterError::InvalidConfig {
135                        reason: format!("freeze_frame pts {pts} must be >= 0.0"),
136                    });
137                }
138                if *duration <= 0.0 {
139                    return Err(FilterError::InvalidConfig {
140                        reason: format!("freeze_frame duration {duration} must be > 0.0"),
141                    });
142                }
143            }
144            if let FilterStep::Crop { width, height, .. } = step
145                && (*width == 0 || *height == 0)
146            {
147                return Err(FilterError::InvalidConfig {
148                    reason: "crop width and height must be > 0".to_string(),
149                });
150            }
151            if let FilterStep::FadeIn { duration, .. }
152            | FilterStep::FadeOut { duration, .. }
153            | FilterStep::FadeInWhite { duration, .. }
154            | FilterStep::FadeOutWhite { duration, .. } = step
155                && *duration <= 0.0
156            {
157                return Err(FilterError::InvalidConfig {
158                    reason: format!("fade duration {duration} must be > 0.0"),
159                });
160            }
161            if let FilterStep::AFadeIn { duration, .. } | FilterStep::AFadeOut { duration, .. } =
162                step
163                && *duration <= 0.0
164            {
165                return Err(FilterError::InvalidConfig {
166                    reason: format!("afade duration {duration} must be > 0.0"),
167                });
168            }
169            if let FilterStep::XFade { duration, .. } = step
170                && *duration <= 0.0
171            {
172                return Err(FilterError::InvalidConfig {
173                    reason: format!("xfade duration {duration} must be > 0.0"),
174                });
175            }
176            if let FilterStep::JoinWithDissolve {
177                dissolve_dur,
178                clip_a_end,
179                ..
180            } = step
181            {
182                if *dissolve_dur <= 0.0 {
183                    return Err(FilterError::InvalidConfig {
184                        reason: format!(
185                            "join_with_dissolve dissolve_dur={dissolve_dur} must be > 0.0"
186                        ),
187                    });
188                }
189                if *clip_a_end <= 0.0 {
190                    return Err(FilterError::InvalidConfig {
191                        reason: format!("join_with_dissolve clip_a_end={clip_a_end} must be > 0.0"),
192                    });
193                }
194            }
195            if let FilterStep::ANoiseGate {
196                attack_ms,
197                release_ms,
198                ..
199            } = step
200            {
201                if *attack_ms <= 0.0 {
202                    return Err(FilterError::InvalidConfig {
203                        reason: format!("agate attack_ms {attack_ms} must be > 0.0"),
204                    });
205                }
206                if *release_ms <= 0.0 {
207                    return Err(FilterError::InvalidConfig {
208                        reason: format!("agate release_ms {release_ms} must be > 0.0"),
209                    });
210                }
211            }
212            if let FilterStep::ACompressor {
213                ratio,
214                attack_ms,
215                release_ms,
216                ..
217            } = step
218            {
219                if *ratio < 1.0 {
220                    return Err(FilterError::InvalidConfig {
221                        reason: format!("compressor ratio {ratio} must be >= 1.0"),
222                    });
223                }
224                if *attack_ms <= 0.0 {
225                    return Err(FilterError::InvalidConfig {
226                        reason: format!("compressor attack_ms {attack_ms} must be > 0.0"),
227                    });
228                }
229                if *release_ms <= 0.0 {
230                    return Err(FilterError::InvalidConfig {
231                        reason: format!("compressor release_ms {release_ms} must be > 0.0"),
232                    });
233                }
234            }
235            if let FilterStep::ChannelMap { mapping } = step
236                && mapping.is_empty()
237            {
238                return Err(FilterError::InvalidConfig {
239                    reason: "channel_map mapping must not be empty".to_string(),
240                });
241            }
242            if let FilterStep::ConcatVideo { n } = step
243                && *n < 2
244            {
245                return Err(FilterError::InvalidConfig {
246                    reason: format!("concat_video n={n} must be >= 2"),
247                });
248            }
249            if let FilterStep::ConcatAudio { n } = step
250                && *n < 2
251            {
252                return Err(FilterError::InvalidConfig {
253                    reason: format!("concat_audio n={n} must be >= 2"),
254                });
255            }
256            if let FilterStep::DrawText { opts } = step {
257                if opts.text.is_empty() {
258                    return Err(FilterError::InvalidConfig {
259                        reason: "drawtext text must not be empty".to_string(),
260                    });
261                }
262                if !(0.0..=1.0).contains(&opts.opacity) {
263                    return Err(FilterError::InvalidConfig {
264                        reason: format!(
265                            "drawtext opacity {} out of range [0.0, 1.0]",
266                            opts.opacity
267                        ),
268                    });
269                }
270            }
271            if let FilterStep::Ticker {
272                text,
273                speed_px_per_sec,
274                ..
275            } = step
276            {
277                if text.is_empty() {
278                    return Err(FilterError::InvalidConfig {
279                        reason: "ticker text must not be empty".to_string(),
280                    });
281                }
282                if *speed_px_per_sec <= 0.0 {
283                    return Err(FilterError::InvalidConfig {
284                        reason: format!("ticker speed_px_per_sec {speed_px_per_sec} must be > 0.0"),
285                    });
286                }
287            }
288            if let FilterStep::Overlay { x, y } = step
289                && (*x < 0 || *y < 0)
290            {
291                return Err(FilterError::InvalidConfig {
292                    reason: format!(
293                        "overlay position ({x}, {y}) is off-screen; \
294                         ensure the watermark fits within the video dimensions"
295                    ),
296                });
297            }
298            if let FilterStep::Lut3d { path } = step {
299                let ext = Path::new(path)
300                    .extension()
301                    .and_then(|e| e.to_str())
302                    .unwrap_or("");
303                if !matches!(ext, "cube" | "3dl") {
304                    return Err(FilterError::InvalidConfig {
305                        reason: format!("unsupported LUT format: .{ext}; expected .cube or .3dl"),
306                    });
307                }
308                if !Path::new(path).exists() {
309                    return Err(FilterError::InvalidConfig {
310                        reason: format!("LUT file not found: {path}"),
311                    });
312                }
313            }
314            if let FilterStep::SubtitlesSrt { path } = step {
315                let ext = Path::new(path)
316                    .extension()
317                    .and_then(|e| e.to_str())
318                    .unwrap_or("");
319                if ext != "srt" {
320                    return Err(FilterError::InvalidConfig {
321                        reason: format!("unsupported subtitle format: .{ext}; expected .srt"),
322                    });
323                }
324                if !Path::new(path).exists() {
325                    return Err(FilterError::InvalidConfig {
326                        reason: format!("subtitle file not found: {path}"),
327                    });
328                }
329            }
330            if let FilterStep::SubtitlesAss { path } = step {
331                let ext = Path::new(path)
332                    .extension()
333                    .and_then(|e| e.to_str())
334                    .unwrap_or("");
335                if !matches!(ext, "ass" | "ssa") {
336                    return Err(FilterError::InvalidConfig {
337                        reason: format!(
338                            "unsupported subtitle format: .{ext}; expected .ass or .ssa"
339                        ),
340                    });
341                }
342                if !Path::new(path).exists() {
343                    return Err(FilterError::InvalidConfig {
344                        reason: format!("subtitle file not found: {path}"),
345                    });
346                }
347            }
348            if let FilterStep::ChromaKey {
349                similarity, blend, ..
350            } = step
351            {
352                if !(0.0..=1.0).contains(similarity) {
353                    return Err(FilterError::InvalidConfig {
354                        reason: format!(
355                            "chromakey similarity {similarity} out of range [0.0, 1.0]"
356                        ),
357                    });
358                }
359                if !(0.0..=1.0).contains(blend) {
360                    return Err(FilterError::InvalidConfig {
361                        reason: format!("chromakey blend {blend} out of range [0.0, 1.0]"),
362                    });
363                }
364            }
365            if let FilterStep::ColorKey {
366                similarity, blend, ..
367            } = step
368            {
369                if !(0.0..=1.0).contains(similarity) {
370                    return Err(FilterError::InvalidConfig {
371                        reason: format!("colorkey similarity {similarity} out of range [0.0, 1.0]"),
372                    });
373                }
374                if !(0.0..=1.0).contains(blend) {
375                    return Err(FilterError::InvalidConfig {
376                        reason: format!("colorkey blend {blend} out of range [0.0, 1.0]"),
377                    });
378                }
379            }
380            if let FilterStep::SpillSuppress { strength, .. } = step
381                && !(0.0..=1.0).contains(strength)
382            {
383                return Err(FilterError::InvalidConfig {
384                    reason: format!("spill_suppress strength {strength} out of range [0.0, 1.0]"),
385                });
386            }
387            if let FilterStep::LumaKey {
388                threshold,
389                tolerance,
390                softness,
391                ..
392            } = step
393            {
394                if !(0.0..=1.0).contains(threshold) {
395                    return Err(FilterError::InvalidConfig {
396                        reason: format!("lumakey threshold {threshold} out of range [0.0, 1.0]"),
397                    });
398                }
399                if !(0.0..=1.0).contains(tolerance) {
400                    return Err(FilterError::InvalidConfig {
401                        reason: format!("lumakey tolerance {tolerance} out of range [0.0, 1.0]"),
402                    });
403                }
404                if !(0.0..=1.0).contains(softness) {
405                    return Err(FilterError::InvalidConfig {
406                        reason: format!("lumakey softness {softness} out of range [0.0, 1.0]"),
407                    });
408                }
409            }
410            if let FilterStep::FeatherMask { radius } = step
411                && *radius == 0
412            {
413                return Err(FilterError::InvalidConfig {
414                    reason: "feather_mask radius must be > 0".to_string(),
415                });
416            }
417            if let FilterStep::RectMask { width, height, .. } = step
418                && (*width == 0 || *height == 0)
419            {
420                return Err(FilterError::InvalidConfig {
421                    reason: "rect_mask width and height must be > 0".to_string(),
422                });
423            }
424            if let FilterStep::PolygonMatte { vertices, .. } = step {
425                if vertices.len() < 3 {
426                    return Err(FilterError::InvalidConfig {
427                        reason: format!(
428                            "polygon_matte requires at least 3 vertices, got {}",
429                            vertices.len()
430                        ),
431                    });
432                }
433                if vertices.len() > 16 {
434                    return Err(FilterError::InvalidConfig {
435                        reason: format!(
436                            "polygon_matte supports up to 16 vertices, got {}",
437                            vertices.len()
438                        ),
439                    });
440                }
441                for &(x, y) in vertices {
442                    if !(0.0..=1.0).contains(&x) || !(0.0..=1.0).contains(&y) {
443                        return Err(FilterError::InvalidConfig {
444                            reason: format!(
445                                "polygon_matte vertex ({x}, {y}) out of range [0.0, 1.0]"
446                            ),
447                        });
448                    }
449                }
450            }
451            if let FilterStep::OverlayImage { path, opacity, .. } = step {
452                let ext = Path::new(path)
453                    .extension()
454                    .and_then(|e| e.to_str())
455                    .unwrap_or("");
456                if ext != "png" {
457                    return Err(FilterError::InvalidConfig {
458                        reason: format!("unsupported image format: .{ext}; expected .png"),
459                    });
460                }
461                if !(0.0..=1.0).contains(opacity) {
462                    return Err(FilterError::InvalidConfig {
463                        reason: format!("overlay_image opacity {opacity} out of range [0.0, 1.0]"),
464                    });
465                }
466                if !Path::new(path).exists() {
467                    return Err(FilterError::InvalidConfig {
468                        reason: format!("overlay image not found: {path}"),
469                    });
470                }
471            }
472            if let FilterStep::Eq {
473                brightness,
474                contrast,
475                saturation,
476            } = step
477            {
478                if !(-1.0..=1.0).contains(brightness) {
479                    return Err(FilterError::InvalidConfig {
480                        reason: format!("eq brightness {brightness} out of range [-1.0, 1.0]"),
481                    });
482                }
483                if !(0.0..=3.0).contains(contrast) {
484                    return Err(FilterError::InvalidConfig {
485                        reason: format!("eq contrast {contrast} out of range [0.0, 3.0]"),
486                    });
487                }
488                if !(0.0..=3.0).contains(saturation) {
489                    return Err(FilterError::InvalidConfig {
490                        reason: format!("eq saturation {saturation} out of range [0.0, 3.0]"),
491                    });
492                }
493            }
494            if let FilterStep::Curves { master, r, g, b } = step {
495                for (channel, pts) in [
496                    ("master", master.as_slice()),
497                    ("r", r.as_slice()),
498                    ("g", g.as_slice()),
499                    ("b", b.as_slice()),
500                ] {
501                    for &(x, y) in pts {
502                        if !(0.0..=1.0).contains(&x) || !(0.0..=1.0).contains(&y) {
503                            return Err(FilterError::InvalidConfig {
504                                reason: format!(
505                                    "curves {channel} control point ({x}, {y}) out of range [0.0, 1.0]"
506                                ),
507                            });
508                        }
509                    }
510                }
511            }
512            if let FilterStep::WhiteBalance {
513                temperature_k,
514                tint,
515            } = step
516            {
517                if !(1000..=40000).contains(temperature_k) {
518                    return Err(FilterError::InvalidConfig {
519                        reason: format!(
520                            "white_balance temperature_k {temperature_k} out of range [1000, 40000]"
521                        ),
522                    });
523                }
524                if !(-1.0..=1.0).contains(tint) {
525                    return Err(FilterError::InvalidConfig {
526                        reason: format!("white_balance tint {tint} out of range [-1.0, 1.0]"),
527                    });
528                }
529            }
530            if let FilterStep::Hue { degrees } = step
531                && !(-360.0..=360.0).contains(degrees)
532            {
533                return Err(FilterError::InvalidConfig {
534                    reason: format!("hue degrees {degrees} out of range [-360.0, 360.0]"),
535                });
536            }
537            if let FilterStep::Gamma { r, g, b } = step {
538                for (channel, val) in [("r", r), ("g", g), ("b", b)] {
539                    if !(0.1..=10.0).contains(val) {
540                        return Err(FilterError::InvalidConfig {
541                            reason: format!("gamma {channel} {val} out of range [0.1, 10.0]"),
542                        });
543                    }
544                }
545            }
546            if let FilterStep::ThreeWayCC { gamma, .. } = step {
547                for (channel, val) in [("r", gamma.r), ("g", gamma.g), ("b", gamma.b)] {
548                    if val <= 0.0 {
549                        return Err(FilterError::InvalidConfig {
550                            reason: format!("three_way_cc gamma.{channel} {val} must be > 0.0"),
551                        });
552                    }
553                }
554            }
555            if let FilterStep::Vignette { angle, .. } = step
556                && !((0.0)..=std::f32::consts::FRAC_PI_2).contains(angle)
557            {
558                return Err(FilterError::InvalidConfig {
559                    reason: format!("vignette angle {angle} out of range [0.0, π/2]"),
560                });
561            }
562            if let FilterStep::Pad { width, height, .. } = step
563                && (*width == 0 || *height == 0)
564            {
565                return Err(FilterError::InvalidConfig {
566                    reason: "pad width and height must be > 0".to_string(),
567                });
568            }
569            if let FilterStep::FitToAspect { width, height, .. } = step
570                && (*width == 0 || *height == 0)
571            {
572                return Err(FilterError::InvalidConfig {
573                    reason: "fit_to_aspect width and height must be > 0".to_string(),
574                });
575            }
576            if let FilterStep::GBlur { sigma } = step
577                && *sigma < 0.0
578            {
579                return Err(FilterError::InvalidConfig {
580                    reason: format!("gblur sigma {sigma} must be >= 0.0"),
581                });
582            }
583            if let FilterStep::Unsharp {
584                luma_strength,
585                chroma_strength,
586            } = step
587            {
588                if !(-1.5..=1.5).contains(luma_strength) {
589                    return Err(FilterError::InvalidConfig {
590                        reason: format!(
591                            "unsharp luma_strength {luma_strength} out of range [-1.5, 1.5]"
592                        ),
593                    });
594                }
595                if !(-1.5..=1.5).contains(chroma_strength) {
596                    return Err(FilterError::InvalidConfig {
597                        reason: format!(
598                            "unsharp chroma_strength {chroma_strength} out of range [-1.5, 1.5]"
599                        ),
600                    });
601                }
602            }
603            if let FilterStep::Hqdn3d {
604                luma_spatial,
605                chroma_spatial,
606                luma_tmp,
607                chroma_tmp,
608            } = step
609            {
610                for (name, val) in [
611                    ("luma_spatial", luma_spatial),
612                    ("chroma_spatial", chroma_spatial),
613                    ("luma_tmp", luma_tmp),
614                    ("chroma_tmp", chroma_tmp),
615                ] {
616                    if *val < 0.0 {
617                        return Err(FilterError::InvalidConfig {
618                            reason: format!("hqdn3d {name} {val} must be >= 0.0"),
619                        });
620                    }
621                }
622            }
623            if let FilterStep::Nlmeans { strength } = step
624                && (*strength < 1.0 || *strength > 30.0)
625            {
626                return Err(FilterError::InvalidConfig {
627                    reason: format!("nlmeans strength {strength} out of range [1.0, 30.0]"),
628                });
629            }
630        }
631
632        crate::filter_inner::validate_filter_steps(&self.steps)?;
633        let output_resolution = self.steps.iter().rev().find_map(|s| {
634            if let FilterStep::Scale { width, height, .. } = s {
635                Some((*width, *height))
636            } else {
637                None
638            }
639        });
640        Ok(FilterGraph {
641            inner: FilterGraphInner::new(self.steps, self.hw),
642            output_resolution,
643        })
644    }
645}
646
647#[cfg(test)]
648mod tests {
649    use super::*;
650
651    #[test]
652    fn builder_empty_steps_should_return_error() {
653        let result = FilterGraph::builder().build();
654        assert!(
655            matches!(result, Err(FilterError::BuildFailed)),
656            "expected BuildFailed, got {result:?}"
657        );
658    }
659
660    #[test]
661    fn builder_steps_should_accumulate_in_order() {
662        let result = FilterGraph::builder()
663            .trim(0.0, 5.0)
664            .scale(1280, 720, ScaleAlgorithm::Fast)
665            .volume(-3.0)
666            .build();
667        assert!(
668            result.is_ok(),
669            "builder with multiple valid steps must succeed, got {result:?}"
670        );
671    }
672
673    #[test]
674    fn builder_with_valid_steps_should_succeed() {
675        let result = FilterGraph::builder()
676            .scale(1280, 720, ScaleAlgorithm::Fast)
677            .build();
678        assert!(
679            result.is_ok(),
680            "builder with a known filter step must succeed, got {result:?}"
681        );
682    }
683
684    #[test]
685    fn output_resolution_should_be_none_when_no_scale() {
686        let fg = FilterGraph::builder().trim(0.0, 5.0).build().unwrap();
687        assert_eq!(fg.output_resolution(), None);
688    }
689
690    #[test]
691    fn output_resolution_should_be_last_scale_dimensions() {
692        let fg = FilterGraph::builder()
693            .scale(1280, 720, ScaleAlgorithm::Fast)
694            .build()
695            .unwrap();
696        assert_eq!(fg.output_resolution(), Some((1280, 720)));
697    }
698
699    #[test]
700    fn output_resolution_should_use_last_scale_when_multiple_present() {
701        let fg = FilterGraph::builder()
702            .scale(1920, 1080, ScaleAlgorithm::Fast)
703            .scale(1280, 720, ScaleAlgorithm::Bicubic)
704            .build()
705            .unwrap();
706        assert_eq!(fg.output_resolution(), Some((1280, 720)));
707    }
708
709    #[test]
710    fn rgb_neutral_constant_should_have_all_channels_one() {
711        assert_eq!(Rgb::NEUTRAL.r, 1.0);
712        assert_eq!(Rgb::NEUTRAL.g, 1.0);
713        assert_eq!(Rgb::NEUTRAL.b, 1.0);
714    }
715
716    // ── blend() ───────────────────────────────────────────────────────────
717
718    #[test]
719    fn blend_normal_full_opacity_should_use_overlay_filter() {
720        // build() must succeed; filter_name() == "overlay" is validated inside
721        // validate_filter_steps at build time.
722        let top = FilterGraphBuilder::new().trim(0.0, 5.0);
723        let result = FilterGraph::builder()
724            .trim(0.0, 5.0)
725            .blend(top, BlendMode::Normal, 1.0)
726            .build();
727        assert!(
728            result.is_ok(),
729            "blend(Normal, opacity=1.0) must build successfully, got {result:?}"
730        );
731    }
732
733    #[test]
734    fn blend_normal_half_opacity_should_apply_colorchannelmixer() {
735        // build() must succeed; the colorchannelmixer step is added at graph
736        // construction time (push_video) — tested end-to-end in integration tests.
737        let top = FilterGraphBuilder::new().trim(0.0, 5.0);
738        let result = FilterGraph::builder()
739            .trim(0.0, 5.0)
740            .blend(top, BlendMode::Normal, 0.5)
741            .build();
742        assert!(
743            result.is_ok(),
744            "blend(Normal, opacity=0.5) must build successfully, got {result:?}"
745        );
746    }
747
748    #[test]
749    fn blend_opacity_above_one_should_be_clamped_to_one() {
750        // Clamping happens in blend(); out-of-range opacity must not cause build() to fail.
751        let top = FilterGraphBuilder::new().trim(0.0, 5.0);
752        let result = FilterGraph::builder()
753            .trim(0.0, 5.0)
754            .blend(top, BlendMode::Normal, 2.5)
755            .build();
756        assert!(
757            result.is_ok(),
758            "blend with opacity=2.5 must clamp to 1.0 and build successfully, got {result:?}"
759        );
760    }
761
762    #[test]
763    fn colorkey_out_of_range_similarity_should_return_invalid_config() {
764        let result = FilterGraph::builder()
765            .trim(0.0, 5.0)
766            .colorkey("green", 1.5, 0.0)
767            .build();
768        assert!(
769            matches!(result, Err(FilterError::InvalidConfig { .. })),
770            "colorkey similarity > 1.0 must return InvalidConfig, got {result:?}"
771        );
772    }
773
774    #[test]
775    fn colorkey_out_of_range_blend_should_return_invalid_config() {
776        let result = FilterGraph::builder()
777            .trim(0.0, 5.0)
778            .colorkey("green", 0.3, -0.1)
779            .build();
780        assert!(
781            matches!(result, Err(FilterError::InvalidConfig { .. })),
782            "colorkey blend < 0.0 must return InvalidConfig, got {result:?}"
783        );
784    }
785
786    #[test]
787    fn lumakey_out_of_range_threshold_should_return_invalid_config() {
788        let result = FilterGraph::builder()
789            .trim(0.0, 5.0)
790            .lumakey(1.5, 0.1, 0.0, false)
791            .build();
792        assert!(
793            matches!(result, Err(FilterError::InvalidConfig { .. })),
794            "lumakey threshold > 1.0 must return InvalidConfig, got {result:?}"
795        );
796    }
797
798    #[test]
799    fn lumakey_out_of_range_tolerance_should_return_invalid_config() {
800        let result = FilterGraph::builder()
801            .trim(0.0, 5.0)
802            .lumakey(0.9, -0.1, 0.0, false)
803            .build();
804        assert!(
805            matches!(result, Err(FilterError::InvalidConfig { .. })),
806            "lumakey tolerance < 0.0 must return InvalidConfig, got {result:?}"
807        );
808    }
809
810    #[test]
811    fn lumakey_out_of_range_softness_should_return_invalid_config() {
812        let result = FilterGraph::builder()
813            .trim(0.0, 5.0)
814            .lumakey(0.9, 0.1, 1.5, false)
815            .build();
816        assert!(
817            matches!(result, Err(FilterError::InvalidConfig { .. })),
818            "lumakey softness > 1.0 must return InvalidConfig, got {result:?}"
819        );
820    }
821
822    #[test]
823    fn spill_suppress_out_of_range_strength_should_return_invalid_config() {
824        let result = FilterGraph::builder()
825            .trim(0.0, 5.0)
826            .spill_suppress("green", 1.5)
827            .build();
828        assert!(
829            matches!(result, Err(FilterError::InvalidConfig { .. })),
830            "spill_suppress strength > 1.0 must return InvalidConfig, got {result:?}"
831        );
832    }
833
834    #[test]
835    fn spill_suppress_negative_strength_should_return_invalid_config() {
836        let result = FilterGraph::builder()
837            .trim(0.0, 5.0)
838            .spill_suppress("green", -0.1)
839            .build();
840        assert!(
841            matches!(result, Err(FilterError::InvalidConfig { .. })),
842            "spill_suppress strength < 0.0 must return InvalidConfig, got {result:?}"
843        );
844    }
845
846    #[test]
847    fn feather_mask_zero_radius_should_return_invalid_config() {
848        let result = FilterGraph::builder()
849            .trim(0.0, 5.0)
850            .feather_mask(0)
851            .build();
852        assert!(
853            matches!(result, Err(FilterError::InvalidConfig { .. })),
854            "feather_mask radius=0 must return InvalidConfig, got {result:?}"
855        );
856    }
857
858    #[test]
859    fn rect_mask_zero_width_should_return_invalid_config() {
860        let result = FilterGraph::builder()
861            .trim(0.0, 5.0)
862            .rect_mask(0, 0, 0, 32, false)
863            .build();
864        assert!(
865            matches!(result, Err(FilterError::InvalidConfig { .. })),
866            "rect_mask width=0 must return InvalidConfig, got {result:?}"
867        );
868    }
869
870    #[test]
871    fn rect_mask_zero_height_should_return_invalid_config() {
872        let result = FilterGraph::builder()
873            .trim(0.0, 5.0)
874            .rect_mask(0, 0, 32, 0, false)
875            .build();
876        assert!(
877            matches!(result, Err(FilterError::InvalidConfig { .. })),
878            "rect_mask height=0 must return InvalidConfig, got {result:?}"
879        );
880    }
881
882    #[test]
883    fn polygon_matte_fewer_than_3_vertices_should_return_invalid_config() {
884        let result = FilterGraph::builder()
885            .trim(0.0, 5.0)
886            .polygon_matte(vec![(0.0, 0.0), (1.0, 0.0)], false)
887            .build();
888        assert!(
889            matches!(result, Err(FilterError::InvalidConfig { .. })),
890            "polygon_matte with < 3 vertices must return InvalidConfig, got {result:?}"
891        );
892    }
893
894    #[test]
895    fn polygon_matte_more_than_16_vertices_should_return_invalid_config() {
896        let verts = (0..17)
897            .map(|i| {
898                let angle = i as f32 * 2.0 * std::f32::consts::PI / 17.0;
899                (0.5 + 0.4 * angle.cos(), 0.5 + 0.4 * angle.sin())
900            })
901            .collect();
902        let result = FilterGraph::builder()
903            .trim(0.0, 5.0)
904            .polygon_matte(verts, false)
905            .build();
906        assert!(
907            matches!(result, Err(FilterError::InvalidConfig { .. })),
908            "polygon_matte with > 16 vertices must return InvalidConfig, got {result:?}"
909        );
910    }
911
912    #[test]
913    fn polygon_matte_out_of_range_vertex_should_return_invalid_config() {
914        let result = FilterGraph::builder()
915            .trim(0.0, 5.0)
916            .polygon_matte(vec![(0.0, 0.0), (1.5, 0.0), (0.0, 1.0)], false)
917            .build();
918        assert!(
919            matches!(result, Err(FilterError::InvalidConfig { .. })),
920            "polygon_matte with vertex x > 1.0 must return InvalidConfig, got {result:?}"
921        );
922    }
923
924    #[test]
925    fn chromakey_out_of_range_similarity_should_return_invalid_config() {
926        let result = FilterGraph::builder()
927            .trim(0.0, 5.0)
928            .chromakey("green", 1.5, 0.0)
929            .build();
930        assert!(
931            matches!(result, Err(FilterError::InvalidConfig { .. })),
932            "chromakey similarity > 1.0 must return InvalidConfig, got {result:?}"
933        );
934    }
935
936    #[test]
937    fn chromakey_out_of_range_blend_should_return_invalid_config() {
938        let result = FilterGraph::builder()
939            .trim(0.0, 5.0)
940            .chromakey("green", 0.3, -0.1)
941            .build();
942        assert!(
943            matches!(result, Err(FilterError::InvalidConfig { .. })),
944            "chromakey blend < 0.0 must return InvalidConfig, got {result:?}"
945        );
946    }
947}