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::error::FilterError;
11use crate::filter_inner::FilterGraphInner;
12
13mod audio;
14mod video;
15
16// ── FilterGraphBuilder ────────────────────────────────────────────────────────
17
18/// Builder for constructing a [`FilterGraph`].
19///
20/// Create one with [`FilterGraph::builder()`], chain the desired filter
21/// methods, then call [`build`](Self::build) to obtain the graph.
22///
23/// # Examples
24///
25/// ```ignore
26/// use ff_filter::{FilterGraph, ToneMap};
27///
28/// let graph = FilterGraph::builder()
29///     .scale(1280, 720)
30///     .tone_map(ToneMap::Hable)
31///     .build()?;
32/// ```
33#[derive(Debug, Default)]
34pub struct FilterGraphBuilder {
35    pub(super) steps: Vec<FilterStep>,
36    pub(super) hw: Option<HwAccel>,
37}
38
39impl FilterGraphBuilder {
40    /// Creates an empty builder.
41    #[must_use]
42    pub fn new() -> Self {
43        Self::default()
44    }
45
46    /// Enable hardware-accelerated filtering.
47    ///
48    /// When set, `hwupload` and `hwdownload` filters are inserted around the
49    /// filter chain automatically.
50    #[must_use]
51    pub fn hardware(mut self, hw: HwAccel) -> Self {
52        self.hw = Some(hw);
53        self
54    }
55
56    // ── Build ─────────────────────────────────────────────────────────────────
57
58    /// Build the [`FilterGraph`].
59    ///
60    /// # Errors
61    ///
62    /// Returns [`FilterError::BuildFailed`] if `steps` is empty (there is
63    /// nothing to filter). The actual `FFmpeg` graph is constructed lazily on the
64    /// first [`push_video`](FilterGraph::push_video) or
65    /// [`push_audio`](FilterGraph::push_audio) call.
66    pub fn build(self) -> Result<FilterGraph, FilterError> {
67        if self.steps.is_empty() {
68            return Err(FilterError::BuildFailed);
69        }
70
71        // Validate overlay coordinates: negative x or y places the overlay
72        // entirely off-screen, which is almost always a misconfiguration
73        // (e.g. a watermark larger than the video). Catch it early with a
74        // descriptive error rather than silently producing invisible output.
75        for step in &self.steps {
76            if let FilterStep::ParametricEq { bands } = step
77                && bands.is_empty()
78            {
79                return Err(FilterError::InvalidConfig {
80                    reason: "equalizer bands must not be empty".to_string(),
81                });
82            }
83            if let FilterStep::Speed { factor } = step
84                && !(0.1..=100.0).contains(factor)
85            {
86                return Err(FilterError::InvalidConfig {
87                    reason: format!("speed factor {factor} out of range [0.1, 100.0]"),
88                });
89            }
90            if let FilterStep::LoudnessNormalize {
91                target_lufs,
92                true_peak_db,
93                lra,
94            } = step
95            {
96                if *target_lufs >= 0.0 {
97                    return Err(FilterError::InvalidConfig {
98                        reason: format!(
99                            "loudness_normalize target_lufs {target_lufs} must be < 0.0"
100                        ),
101                    });
102                }
103                if *true_peak_db > 0.0 {
104                    return Err(FilterError::InvalidConfig {
105                        reason: format!(
106                            "loudness_normalize true_peak_db {true_peak_db} must be <= 0.0"
107                        ),
108                    });
109                }
110                if *lra <= 0.0 {
111                    return Err(FilterError::InvalidConfig {
112                        reason: format!("loudness_normalize lra {lra} must be > 0.0"),
113                    });
114                }
115            }
116            if let FilterStep::NormalizePeak { target_db } = step
117                && *target_db > 0.0
118            {
119                return Err(FilterError::InvalidConfig {
120                    reason: format!("normalize_peak target_db {target_db} must be <= 0.0"),
121                });
122            }
123            if let FilterStep::FreezeFrame { pts, duration } = step {
124                if *pts < 0.0 {
125                    return Err(FilterError::InvalidConfig {
126                        reason: format!("freeze_frame pts {pts} must be >= 0.0"),
127                    });
128                }
129                if *duration <= 0.0 {
130                    return Err(FilterError::InvalidConfig {
131                        reason: format!("freeze_frame duration {duration} must be > 0.0"),
132                    });
133                }
134            }
135            if let FilterStep::Crop { width, height, .. } = step
136                && (*width == 0 || *height == 0)
137            {
138                return Err(FilterError::InvalidConfig {
139                    reason: "crop width and height must be > 0".to_string(),
140                });
141            }
142            if let FilterStep::FadeIn { duration, .. }
143            | FilterStep::FadeOut { duration, .. }
144            | FilterStep::FadeInWhite { duration, .. }
145            | FilterStep::FadeOutWhite { duration, .. } = step
146                && *duration <= 0.0
147            {
148                return Err(FilterError::InvalidConfig {
149                    reason: format!("fade duration {duration} must be > 0.0"),
150                });
151            }
152            if let FilterStep::AFadeIn { duration, .. } | FilterStep::AFadeOut { duration, .. } =
153                step
154                && *duration <= 0.0
155            {
156                return Err(FilterError::InvalidConfig {
157                    reason: format!("afade duration {duration} must be > 0.0"),
158                });
159            }
160            if let FilterStep::XFade { duration, .. } = step
161                && *duration <= 0.0
162            {
163                return Err(FilterError::InvalidConfig {
164                    reason: format!("xfade duration {duration} must be > 0.0"),
165                });
166            }
167            if let FilterStep::JoinWithDissolve {
168                dissolve_dur,
169                clip_a_end,
170                ..
171            } = step
172            {
173                if *dissolve_dur <= 0.0 {
174                    return Err(FilterError::InvalidConfig {
175                        reason: format!(
176                            "join_with_dissolve dissolve_dur={dissolve_dur} must be > 0.0"
177                        ),
178                    });
179                }
180                if *clip_a_end <= 0.0 {
181                    return Err(FilterError::InvalidConfig {
182                        reason: format!("join_with_dissolve clip_a_end={clip_a_end} must be > 0.0"),
183                    });
184                }
185            }
186            if let FilterStep::ANoiseGate {
187                attack_ms,
188                release_ms,
189                ..
190            } = step
191            {
192                if *attack_ms <= 0.0 {
193                    return Err(FilterError::InvalidConfig {
194                        reason: format!("agate attack_ms {attack_ms} must be > 0.0"),
195                    });
196                }
197                if *release_ms <= 0.0 {
198                    return Err(FilterError::InvalidConfig {
199                        reason: format!("agate release_ms {release_ms} must be > 0.0"),
200                    });
201                }
202            }
203            if let FilterStep::ACompressor {
204                ratio,
205                attack_ms,
206                release_ms,
207                ..
208            } = step
209            {
210                if *ratio < 1.0 {
211                    return Err(FilterError::InvalidConfig {
212                        reason: format!("compressor ratio {ratio} must be >= 1.0"),
213                    });
214                }
215                if *attack_ms <= 0.0 {
216                    return Err(FilterError::InvalidConfig {
217                        reason: format!("compressor attack_ms {attack_ms} must be > 0.0"),
218                    });
219                }
220                if *release_ms <= 0.0 {
221                    return Err(FilterError::InvalidConfig {
222                        reason: format!("compressor release_ms {release_ms} must be > 0.0"),
223                    });
224                }
225            }
226            if let FilterStep::ChannelMap { mapping } = step
227                && mapping.is_empty()
228            {
229                return Err(FilterError::InvalidConfig {
230                    reason: "channel_map mapping must not be empty".to_string(),
231                });
232            }
233            if let FilterStep::ConcatVideo { n } = step
234                && *n < 2
235            {
236                return Err(FilterError::InvalidConfig {
237                    reason: format!("concat_video n={n} must be >= 2"),
238                });
239            }
240            if let FilterStep::ConcatAudio { n } = step
241                && *n < 2
242            {
243                return Err(FilterError::InvalidConfig {
244                    reason: format!("concat_audio n={n} must be >= 2"),
245                });
246            }
247            if let FilterStep::DrawText { opts } = step {
248                if opts.text.is_empty() {
249                    return Err(FilterError::InvalidConfig {
250                        reason: "drawtext text must not be empty".to_string(),
251                    });
252                }
253                if !(0.0..=1.0).contains(&opts.opacity) {
254                    return Err(FilterError::InvalidConfig {
255                        reason: format!(
256                            "drawtext opacity {} out of range [0.0, 1.0]",
257                            opts.opacity
258                        ),
259                    });
260                }
261            }
262            if let FilterStep::Ticker {
263                text,
264                speed_px_per_sec,
265                ..
266            } = step
267            {
268                if text.is_empty() {
269                    return Err(FilterError::InvalidConfig {
270                        reason: "ticker text must not be empty".to_string(),
271                    });
272                }
273                if *speed_px_per_sec <= 0.0 {
274                    return Err(FilterError::InvalidConfig {
275                        reason: format!("ticker speed_px_per_sec {speed_px_per_sec} must be > 0.0"),
276                    });
277                }
278            }
279            if let FilterStep::Overlay { x, y } = step
280                && (*x < 0 || *y < 0)
281            {
282                return Err(FilterError::InvalidConfig {
283                    reason: format!(
284                        "overlay position ({x}, {y}) is off-screen; \
285                         ensure the watermark fits within the video dimensions"
286                    ),
287                });
288            }
289            if let FilterStep::Lut3d { path } = step {
290                let ext = Path::new(path)
291                    .extension()
292                    .and_then(|e| e.to_str())
293                    .unwrap_or("");
294                if !matches!(ext, "cube" | "3dl") {
295                    return Err(FilterError::InvalidConfig {
296                        reason: format!("unsupported LUT format: .{ext}; expected .cube or .3dl"),
297                    });
298                }
299                if !Path::new(path).exists() {
300                    return Err(FilterError::InvalidConfig {
301                        reason: format!("LUT file not found: {path}"),
302                    });
303                }
304            }
305            if let FilterStep::SubtitlesSrt { path } = step {
306                let ext = Path::new(path)
307                    .extension()
308                    .and_then(|e| e.to_str())
309                    .unwrap_or("");
310                if ext != "srt" {
311                    return Err(FilterError::InvalidConfig {
312                        reason: format!("unsupported subtitle format: .{ext}; expected .srt"),
313                    });
314                }
315                if !Path::new(path).exists() {
316                    return Err(FilterError::InvalidConfig {
317                        reason: format!("subtitle file not found: {path}"),
318                    });
319                }
320            }
321            if let FilterStep::SubtitlesAss { path } = step {
322                let ext = Path::new(path)
323                    .extension()
324                    .and_then(|e| e.to_str())
325                    .unwrap_or("");
326                if !matches!(ext, "ass" | "ssa") {
327                    return Err(FilterError::InvalidConfig {
328                        reason: format!(
329                            "unsupported subtitle format: .{ext}; expected .ass or .ssa"
330                        ),
331                    });
332                }
333                if !Path::new(path).exists() {
334                    return Err(FilterError::InvalidConfig {
335                        reason: format!("subtitle file not found: {path}"),
336                    });
337                }
338            }
339            if let FilterStep::OverlayImage { path, opacity, .. } = step {
340                let ext = Path::new(path)
341                    .extension()
342                    .and_then(|e| e.to_str())
343                    .unwrap_or("");
344                if ext != "png" {
345                    return Err(FilterError::InvalidConfig {
346                        reason: format!("unsupported image format: .{ext}; expected .png"),
347                    });
348                }
349                if !(0.0..=1.0).contains(opacity) {
350                    return Err(FilterError::InvalidConfig {
351                        reason: format!("overlay_image opacity {opacity} out of range [0.0, 1.0]"),
352                    });
353                }
354                if !Path::new(path).exists() {
355                    return Err(FilterError::InvalidConfig {
356                        reason: format!("overlay image not found: {path}"),
357                    });
358                }
359            }
360            if let FilterStep::Eq {
361                brightness,
362                contrast,
363                saturation,
364            } = step
365            {
366                if !(-1.0..=1.0).contains(brightness) {
367                    return Err(FilterError::InvalidConfig {
368                        reason: format!("eq brightness {brightness} out of range [-1.0, 1.0]"),
369                    });
370                }
371                if !(0.0..=3.0).contains(contrast) {
372                    return Err(FilterError::InvalidConfig {
373                        reason: format!("eq contrast {contrast} out of range [0.0, 3.0]"),
374                    });
375                }
376                if !(0.0..=3.0).contains(saturation) {
377                    return Err(FilterError::InvalidConfig {
378                        reason: format!("eq saturation {saturation} out of range [0.0, 3.0]"),
379                    });
380                }
381            }
382            if let FilterStep::Curves { master, r, g, b } = step {
383                for (channel, pts) in [
384                    ("master", master.as_slice()),
385                    ("r", r.as_slice()),
386                    ("g", g.as_slice()),
387                    ("b", b.as_slice()),
388                ] {
389                    for &(x, y) in pts {
390                        if !(0.0..=1.0).contains(&x) || !(0.0..=1.0).contains(&y) {
391                            return Err(FilterError::InvalidConfig {
392                                reason: format!(
393                                    "curves {channel} control point ({x}, {y}) out of range [0.0, 1.0]"
394                                ),
395                            });
396                        }
397                    }
398                }
399            }
400            if let FilterStep::WhiteBalance {
401                temperature_k,
402                tint,
403            } = step
404            {
405                if !(1000..=40000).contains(temperature_k) {
406                    return Err(FilterError::InvalidConfig {
407                        reason: format!(
408                            "white_balance temperature_k {temperature_k} out of range [1000, 40000]"
409                        ),
410                    });
411                }
412                if !(-1.0..=1.0).contains(tint) {
413                    return Err(FilterError::InvalidConfig {
414                        reason: format!("white_balance tint {tint} out of range [-1.0, 1.0]"),
415                    });
416                }
417            }
418            if let FilterStep::Hue { degrees } = step
419                && !(-360.0..=360.0).contains(degrees)
420            {
421                return Err(FilterError::InvalidConfig {
422                    reason: format!("hue degrees {degrees} out of range [-360.0, 360.0]"),
423                });
424            }
425            if let FilterStep::Gamma { r, g, b } = step {
426                for (channel, val) in [("r", r), ("g", g), ("b", b)] {
427                    if !(0.1..=10.0).contains(val) {
428                        return Err(FilterError::InvalidConfig {
429                            reason: format!("gamma {channel} {val} out of range [0.1, 10.0]"),
430                        });
431                    }
432                }
433            }
434            if let FilterStep::ThreeWayCC { gamma, .. } = step {
435                for (channel, val) in [("r", gamma.r), ("g", gamma.g), ("b", gamma.b)] {
436                    if val <= 0.0 {
437                        return Err(FilterError::InvalidConfig {
438                            reason: format!("three_way_cc gamma.{channel} {val} must be > 0.0"),
439                        });
440                    }
441                }
442            }
443            if let FilterStep::Vignette { angle, .. } = step
444                && !((0.0)..=std::f32::consts::FRAC_PI_2).contains(angle)
445            {
446                return Err(FilterError::InvalidConfig {
447                    reason: format!("vignette angle {angle} out of range [0.0, π/2]"),
448                });
449            }
450            if let FilterStep::Pad { width, height, .. } = step
451                && (*width == 0 || *height == 0)
452            {
453                return Err(FilterError::InvalidConfig {
454                    reason: "pad width and height must be > 0".to_string(),
455                });
456            }
457            if let FilterStep::FitToAspect { width, height, .. } = step
458                && (*width == 0 || *height == 0)
459            {
460                return Err(FilterError::InvalidConfig {
461                    reason: "fit_to_aspect width and height must be > 0".to_string(),
462                });
463            }
464            if let FilterStep::GBlur { sigma } = step
465                && *sigma < 0.0
466            {
467                return Err(FilterError::InvalidConfig {
468                    reason: format!("gblur sigma {sigma} must be >= 0.0"),
469                });
470            }
471            if let FilterStep::Unsharp {
472                luma_strength,
473                chroma_strength,
474            } = step
475            {
476                if !(-1.5..=1.5).contains(luma_strength) {
477                    return Err(FilterError::InvalidConfig {
478                        reason: format!(
479                            "unsharp luma_strength {luma_strength} out of range [-1.5, 1.5]"
480                        ),
481                    });
482                }
483                if !(-1.5..=1.5).contains(chroma_strength) {
484                    return Err(FilterError::InvalidConfig {
485                        reason: format!(
486                            "unsharp chroma_strength {chroma_strength} out of range [-1.5, 1.5]"
487                        ),
488                    });
489                }
490            }
491            if let FilterStep::Hqdn3d {
492                luma_spatial,
493                chroma_spatial,
494                luma_tmp,
495                chroma_tmp,
496            } = step
497            {
498                for (name, val) in [
499                    ("luma_spatial", luma_spatial),
500                    ("chroma_spatial", chroma_spatial),
501                    ("luma_tmp", luma_tmp),
502                    ("chroma_tmp", chroma_tmp),
503                ] {
504                    if *val < 0.0 {
505                        return Err(FilterError::InvalidConfig {
506                            reason: format!("hqdn3d {name} {val} must be >= 0.0"),
507                        });
508                    }
509                }
510            }
511            if let FilterStep::Nlmeans { strength } = step
512                && (*strength < 1.0 || *strength > 30.0)
513            {
514                return Err(FilterError::InvalidConfig {
515                    reason: format!("nlmeans strength {strength} out of range [1.0, 30.0]"),
516                });
517            }
518        }
519
520        crate::filter_inner::validate_filter_steps(&self.steps)?;
521        let output_resolution = self.steps.iter().rev().find_map(|s| {
522            if let FilterStep::Scale { width, height, .. } = s {
523                Some((*width, *height))
524            } else {
525                None
526            }
527        });
528        Ok(FilterGraph {
529            inner: FilterGraphInner::new(self.steps, self.hw),
530            output_resolution,
531        })
532    }
533}
534
535#[cfg(test)]
536mod tests {
537    use super::*;
538
539    #[test]
540    fn builder_empty_steps_should_return_error() {
541        let result = FilterGraph::builder().build();
542        assert!(
543            matches!(result, Err(FilterError::BuildFailed)),
544            "expected BuildFailed, got {result:?}"
545        );
546    }
547
548    #[test]
549    fn builder_steps_should_accumulate_in_order() {
550        let result = FilterGraph::builder()
551            .trim(0.0, 5.0)
552            .scale(1280, 720, ScaleAlgorithm::Fast)
553            .volume(-3.0)
554            .build();
555        assert!(
556            result.is_ok(),
557            "builder with multiple valid steps must succeed, got {result:?}"
558        );
559    }
560
561    #[test]
562    fn builder_with_valid_steps_should_succeed() {
563        let result = FilterGraph::builder()
564            .scale(1280, 720, ScaleAlgorithm::Fast)
565            .build();
566        assert!(
567            result.is_ok(),
568            "builder with a known filter step must succeed, got {result:?}"
569        );
570    }
571
572    #[test]
573    fn output_resolution_should_be_none_when_no_scale() {
574        let fg = FilterGraph::builder().trim(0.0, 5.0).build().unwrap();
575        assert_eq!(fg.output_resolution(), None);
576    }
577
578    #[test]
579    fn output_resolution_should_be_last_scale_dimensions() {
580        let fg = FilterGraph::builder()
581            .scale(1280, 720, ScaleAlgorithm::Fast)
582            .build()
583            .unwrap();
584        assert_eq!(fg.output_resolution(), Some((1280, 720)));
585    }
586
587    #[test]
588    fn output_resolution_should_use_last_scale_when_multiple_present() {
589        let fg = FilterGraph::builder()
590            .scale(1920, 1080, ScaleAlgorithm::Fast)
591            .scale(1280, 720, ScaleAlgorithm::Bicubic)
592            .build()
593            .unwrap();
594        assert_eq!(fg.output_resolution(), Some((1280, 720)));
595    }
596
597    #[test]
598    fn rgb_neutral_constant_should_have_all_channels_one() {
599        assert_eq!(Rgb::NEUTRAL.r, 1.0);
600        assert_eq!(Rgb::NEUTRAL.g, 1.0);
601        assert_eq!(Rgb::NEUTRAL.b, 1.0);
602    }
603}