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