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