Skip to main content

ff_filter/
graph.rs

1//! Filter graph public API: [`FilterGraph`] and [`FilterGraphBuilder`].
2
3use std::time::Duration;
4
5use ff_format::{AudioFrame, VideoFrame};
6
7use crate::error::FilterError;
8use crate::filter_inner::FilterGraphInner;
9
10// ── Supporting enums ──────────────────────────────────────────────────────────
11
12/// Tone-mapping algorithm for HDR-to-SDR conversion.
13///
14/// Used with [`FilterGraphBuilder::tone_map`].
15///
16/// # Choosing an algorithm
17///
18/// | Variant | Characteristic | When to use |
19/// |---------|---------------|-------------|
20/// | [`Hable`](Self::Hable) | Filmic, rich contrast | Film / cinematic content |
21/// | [`Reinhard`](Self::Reinhard) | Simple, fast, neutral | Fast previews, general video |
22/// | [`Mobius`](Self::Mobius) | Smooth highlights | Bright outdoor or HDR10 content |
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum ToneMap {
25    /// Hable (Uncharted 2) filmic tone mapping.
26    ///
27    /// Produces a warm, cinematic look with compressed shadows and highlights.
28    /// The most commonly used algorithm for film and narrative video content.
29    Hable,
30    /// Reinhard tone mapping.
31    ///
32    /// A simple, globally uniform operator. Fast and neutral; a safe default
33    /// when color-accurate reproduction matters more than filmic aesthetics.
34    Reinhard,
35    /// Mobius tone mapping.
36    ///
37    /// A smooth, shoulder-based curve that preserves mid-tones while gently
38    /// rolling off bright highlights. Well suited for outdoor and HDR10 content.
39    Mobius,
40}
41
42impl ToneMap {
43    /// Returns the libavfilter `tonemap` algorithm name for this variant.
44    #[must_use]
45    pub const fn as_str(&self) -> &'static str {
46        match self {
47            Self::Hable => "hable",
48            Self::Reinhard => "reinhard",
49            Self::Mobius => "mobius",
50        }
51    }
52}
53
54/// Hardware acceleration backend for filter graph operations.
55///
56/// When set on the builder, upload/download filters are inserted automatically
57/// around the filter chain. This is independent of `ff_decode::HardwareAccel`
58/// and is defined here to avoid a hard dependency on `ff-decode`.
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60pub enum HwAccel {
61    /// NVIDIA CUDA.
62    Cuda,
63    /// Apple `VideoToolbox`.
64    VideoToolbox,
65    /// VA-API (Video Acceleration API, Linux).
66    Vaapi,
67}
68
69// ── FilterStep ────────────────────────────────────────────────────────────────
70
71/// A single step in a filter chain, constructed by the builder methods.
72///
73/// This is an internal representation; users interact with it only via the
74/// [`FilterGraphBuilder`] API.
75#[derive(Debug, Clone)]
76pub(crate) enum FilterStep {
77    /// Trim: keep only frames in `[start, end)` seconds.
78    Trim { start: f64, end: f64 },
79    /// Scale to a new resolution.
80    Scale { width: u32, height: u32 },
81    /// Crop a rectangular region.
82    Crop {
83        x: u32,
84        y: u32,
85        width: u32,
86        height: u32,
87    },
88    /// Overlay a second stream at position `(x, y)`.
89    Overlay { x: i32, y: i32 },
90    /// Fade-in from black over `duration`.
91    FadeIn(Duration),
92    /// Fade-out to black over `duration`.
93    FadeOut(Duration),
94    /// Rotate clockwise by `degrees`.
95    Rotate(f64),
96    /// HDR-to-SDR tone mapping.
97    ToneMap(ToneMap),
98    /// Adjust audio volume (in dB; negative = quieter).
99    Volume(f64),
100    /// Mix `n` audio inputs together.
101    Amix(usize),
102    /// Parametric equalizer band.
103    Equalizer { band_hz: f64, gain_db: f64 },
104}
105
106impl FilterStep {
107    /// Returns the libavfilter filter name for this step.
108    pub(crate) fn filter_name(&self) -> &'static str {
109        match self {
110            Self::Trim { .. } => "trim",
111            Self::Scale { .. } => "scale",
112            Self::Crop { .. } => "crop",
113            Self::Overlay { .. } => "overlay",
114            Self::FadeIn(_) | Self::FadeOut(_) => "fade",
115            Self::Rotate(_) => "rotate",
116            Self::ToneMap(_) => "tonemap",
117            Self::Volume(_) => "volume",
118            Self::Amix(_) => "amix",
119            Self::Equalizer { .. } => "equalizer",
120        }
121    }
122
123    /// Returns the `args` string passed to `avfilter_graph_create_filter`.
124    pub(crate) fn args(&self) -> String {
125        match self {
126            Self::Trim { start, end } => format!("start={start}:end={end}"),
127            Self::Scale { width, height } => format!("w={width}:h={height}"),
128            Self::Crop {
129                x,
130                y,
131                width,
132                height,
133            } => {
134                format!("x={x}:y={y}:w={width}:h={height}")
135            }
136            Self::Overlay { x, y } => format!("x={x}:y={y}"),
137            Self::FadeIn(d) => format!("type=in:duration={}", d.as_secs_f64()),
138            Self::FadeOut(d) => format!("type=out:duration={}", d.as_secs_f64()),
139            Self::Rotate(degrees) => {
140                format!("angle={}", degrees.to_radians())
141            }
142            Self::ToneMap(algorithm) => format!("tonemap={}", algorithm.as_str()),
143            Self::Volume(db) => format!("volume={db}dB"),
144            Self::Amix(inputs) => format!("inputs={inputs}"),
145            Self::Equalizer { band_hz, gain_db } => {
146                format!("f={band_hz}:width_type=o:width=2:g={gain_db}")
147            }
148        }
149    }
150}
151
152// ── FilterGraphBuilder ────────────────────────────────────────────────────────
153
154/// Builder for constructing a [`FilterGraph`].
155///
156/// Create one with [`FilterGraph::builder()`], chain the desired filter
157/// methods, then call [`build`](Self::build) to obtain the graph.
158///
159/// # Examples
160///
161/// ```ignore
162/// use ff_filter::{FilterGraph, ToneMap};
163///
164/// let graph = FilterGraph::builder()
165///     .scale(1280, 720)
166///     .tone_map(ToneMap::Hable)
167///     .build()?;
168/// ```
169#[derive(Debug, Default)]
170pub struct FilterGraphBuilder {
171    steps: Vec<FilterStep>,
172    hw: Option<HwAccel>,
173}
174
175impl FilterGraphBuilder {
176    /// Creates an empty builder.
177    #[must_use]
178    pub fn new() -> Self {
179        Self::default()
180    }
181
182    // ── Video filters ─────────────────────────────────────────────────────────
183
184    /// Trim the stream to the half-open interval `[start, end)` in seconds.
185    #[must_use]
186    pub fn trim(mut self, start: f64, end: f64) -> Self {
187        self.steps.push(FilterStep::Trim { start, end });
188        self
189    }
190
191    /// Scale the video to `width × height` pixels.
192    #[must_use]
193    pub fn scale(mut self, width: u32, height: u32) -> Self {
194        self.steps.push(FilterStep::Scale { width, height });
195        self
196    }
197
198    /// Crop a rectangle starting at `(x, y)` with the given dimensions.
199    #[must_use]
200    pub fn crop(mut self, x: u32, y: u32, width: u32, height: u32) -> Self {
201        self.steps.push(FilterStep::Crop {
202            x,
203            y,
204            width,
205            height,
206        });
207        self
208    }
209
210    /// Overlay a second input stream at position `(x, y)`.
211    #[must_use]
212    pub fn overlay(mut self, x: i32, y: i32) -> Self {
213        self.steps.push(FilterStep::Overlay { x, y });
214        self
215    }
216
217    /// Fade in from black over the given `duration`.
218    #[must_use]
219    pub fn fade_in(mut self, duration: Duration) -> Self {
220        self.steps.push(FilterStep::FadeIn(duration));
221        self
222    }
223
224    /// Fade out to black over the given `duration`.
225    #[must_use]
226    pub fn fade_out(mut self, duration: Duration) -> Self {
227        self.steps.push(FilterStep::FadeOut(duration));
228        self
229    }
230
231    /// Rotate the video clockwise by `degrees`.
232    #[must_use]
233    pub fn rotate(mut self, degrees: f64) -> Self {
234        self.steps.push(FilterStep::Rotate(degrees));
235        self
236    }
237
238    /// Apply HDR-to-SDR tone mapping using the given `algorithm`.
239    #[must_use]
240    pub fn tone_map(mut self, algorithm: ToneMap) -> Self {
241        self.steps.push(FilterStep::ToneMap(algorithm));
242        self
243    }
244
245    // ── Audio filters ─────────────────────────────────────────────────────────
246
247    /// Adjust audio volume by `gain_db` decibels (negative = quieter).
248    #[must_use]
249    pub fn volume(mut self, gain_db: f64) -> Self {
250        self.steps.push(FilterStep::Volume(gain_db));
251        self
252    }
253
254    /// Mix `inputs` audio streams together.
255    #[must_use]
256    pub fn amix(mut self, inputs: usize) -> Self {
257        self.steps.push(FilterStep::Amix(inputs));
258        self
259    }
260
261    /// Apply a parametric equalizer band at `band_hz` Hz with `gain_db` dB.
262    #[must_use]
263    pub fn equalizer(mut self, band_hz: f64, gain_db: f64) -> Self {
264        self.steps.push(FilterStep::Equalizer { band_hz, gain_db });
265        self
266    }
267
268    // ── Hardware ──────────────────────────────────────────────────────────────
269
270    /// Enable hardware-accelerated filtering.
271    ///
272    /// When set, `hwupload` and `hwdownload` filters are inserted around the
273    /// filter chain automatically.
274    #[must_use]
275    pub fn hardware(mut self, hw: HwAccel) -> Self {
276        self.hw = Some(hw);
277        self
278    }
279
280    // ── Build ─────────────────────────────────────────────────────────────────
281
282    /// Build the [`FilterGraph`].
283    ///
284    /// # Errors
285    ///
286    /// Returns [`FilterError::BuildFailed`] if `steps` is empty (there is
287    /// nothing to filter). The actual `FFmpeg` graph is constructed lazily on the
288    /// first [`push_video`](FilterGraph::push_video) or
289    /// [`push_audio`](FilterGraph::push_audio) call.
290    pub fn build(self) -> Result<FilterGraph, FilterError> {
291        if self.steps.is_empty() {
292            return Err(FilterError::BuildFailed);
293        }
294
295        // Validate overlay coordinates: negative x or y places the overlay
296        // entirely off-screen, which is almost always a misconfiguration
297        // (e.g. a watermark larger than the video). Catch it early with a
298        // descriptive error rather than silently producing invisible output.
299        for step in &self.steps {
300            if let FilterStep::Overlay { x, y } = step
301                && (*x < 0 || *y < 0)
302            {
303                return Err(FilterError::InvalidConfig {
304                    reason: format!(
305                        "overlay position ({x}, {y}) is off-screen; \
306                         ensure the watermark fits within the video dimensions"
307                    ),
308                });
309            }
310        }
311
312        crate::filter_inner::validate_filter_steps(&self.steps)?;
313        let output_resolution = self.steps.iter().rev().find_map(|s| {
314            if let FilterStep::Scale { width, height } = s {
315                Some((*width, *height))
316            } else {
317                None
318            }
319        });
320        Ok(FilterGraph {
321            inner: FilterGraphInner::new(self.steps, self.hw),
322            output_resolution,
323        })
324    }
325}
326
327// ── FilterGraph ───────────────────────────────────────────────────────────────
328
329/// An `FFmpeg` libavfilter filter graph.
330///
331/// Constructed via [`FilterGraph::builder()`].  The underlying `AVFilterGraph` is
332/// initialised lazily on the first push call, deriving format information from
333/// the first frame.
334///
335/// # Examples
336///
337/// ```ignore
338/// use ff_filter::FilterGraph;
339///
340/// let mut graph = FilterGraph::builder()
341///     .scale(1280, 720)
342///     .build()?;
343///
344/// // Push decoded frames in …
345/// graph.push_video(0, &video_frame)?;
346///
347/// // … and pull filtered frames out.
348/// while let Some(frame) = graph.pull_video()? {
349///     // use frame
350/// }
351/// ```
352pub struct FilterGraph {
353    inner: FilterGraphInner,
354    output_resolution: Option<(u32, u32)>,
355}
356
357impl std::fmt::Debug for FilterGraph {
358    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
359        f.debug_struct("FilterGraph").finish_non_exhaustive()
360    }
361}
362
363impl FilterGraph {
364    /// Create a new builder.
365    #[must_use]
366    pub fn builder() -> FilterGraphBuilder {
367        FilterGraphBuilder::new()
368    }
369
370    /// Returns the output resolution produced by this graph's `scale` filter step,
371    /// if one was configured.
372    ///
373    /// When multiple `scale` steps are chained, the **last** one's dimensions are
374    /// returned. Returns `None` when no `scale` step was added.
375    #[must_use]
376    pub fn output_resolution(&self) -> Option<(u32, u32)> {
377        self.output_resolution
378    }
379
380    /// Push a video frame into input slot `slot`.
381    ///
382    /// On the first call the filter graph is initialised using this frame's
383    /// format, resolution, and time base.
384    ///
385    /// # Errors
386    ///
387    /// - [`FilterError::InvalidInput`] if `slot` is out of range.
388    /// - [`FilterError::BuildFailed`] if the graph cannot be initialised.
389    /// - [`FilterError::ProcessFailed`] if the `FFmpeg` push fails.
390    pub fn push_video(&mut self, slot: usize, frame: &VideoFrame) -> Result<(), FilterError> {
391        self.inner.push_video(slot, frame)
392    }
393
394    /// Pull the next filtered video frame, if one is available.
395    ///
396    /// Returns `None` when the internal `FFmpeg` buffer is empty (EAGAIN) or
397    /// at end-of-stream.
398    ///
399    /// # Errors
400    ///
401    /// Returns [`FilterError::ProcessFailed`] on an unexpected `FFmpeg` error.
402    pub fn pull_video(&mut self) -> Result<Option<VideoFrame>, FilterError> {
403        self.inner.pull_video()
404    }
405
406    /// Push an audio frame into input slot `slot`.
407    ///
408    /// On the first call the audio filter graph is initialised using this
409    /// frame's format, sample rate, and channel count.
410    ///
411    /// # Errors
412    ///
413    /// - [`FilterError::InvalidInput`] if `slot` is out of range.
414    /// - [`FilterError::BuildFailed`] if the graph cannot be initialised.
415    /// - [`FilterError::ProcessFailed`] if the `FFmpeg` push fails.
416    pub fn push_audio(&mut self, slot: usize, frame: &AudioFrame) -> Result<(), FilterError> {
417        self.inner.push_audio(slot, frame)
418    }
419
420    /// Pull the next filtered audio frame, if one is available.
421    ///
422    /// Returns `None` when the internal `FFmpeg` buffer is empty (EAGAIN) or
423    /// at end-of-stream.
424    ///
425    /// # Errors
426    ///
427    /// Returns [`FilterError::ProcessFailed`] on an unexpected `FFmpeg` error.
428    pub fn pull_audio(&mut self) -> Result<Option<AudioFrame>, FilterError> {
429        self.inner.pull_audio()
430    }
431}
432
433// ── Unit tests ────────────────────────────────────────────────────────────────
434
435#[cfg(test)]
436mod tests {
437    use super::*;
438
439    #[test]
440    fn filter_step_scale_should_produce_correct_args() {
441        let step = FilterStep::Scale {
442            width: 1280,
443            height: 720,
444        };
445        assert_eq!(step.filter_name(), "scale");
446        assert_eq!(step.args(), "w=1280:h=720");
447    }
448
449    #[test]
450    fn filter_step_trim_should_produce_correct_args() {
451        let step = FilterStep::Trim {
452            start: 10.0,
453            end: 30.0,
454        };
455        assert_eq!(step.filter_name(), "trim");
456        assert_eq!(step.args(), "start=10:end=30");
457    }
458
459    #[test]
460    fn filter_step_volume_should_produce_correct_args() {
461        let step = FilterStep::Volume(-6.0);
462        assert_eq!(step.filter_name(), "volume");
463        assert_eq!(step.args(), "volume=-6dB");
464    }
465
466    #[test]
467    fn tone_map_variants_should_have_correct_names() {
468        assert_eq!(ToneMap::Hable.as_str(), "hable");
469        assert_eq!(ToneMap::Reinhard.as_str(), "reinhard");
470        assert_eq!(ToneMap::Mobius.as_str(), "mobius");
471    }
472
473    #[test]
474    fn builder_empty_steps_should_return_error() {
475        let result = FilterGraph::builder().build();
476        assert!(
477            matches!(result, Err(FilterError::BuildFailed)),
478            "expected BuildFailed, got {result:?}"
479        );
480    }
481
482    #[test]
483    fn filter_step_overlay_should_produce_correct_args() {
484        let step = FilterStep::Overlay { x: 10, y: 20 };
485        assert_eq!(step.filter_name(), "overlay");
486        assert_eq!(step.args(), "x=10:y=20");
487    }
488
489    #[test]
490    fn filter_step_crop_should_produce_correct_args() {
491        let step = FilterStep::Crop {
492            x: 0,
493            y: 0,
494            width: 640,
495            height: 360,
496        };
497        assert_eq!(step.filter_name(), "crop");
498        assert_eq!(step.args(), "x=0:y=0:w=640:h=360");
499    }
500
501    #[test]
502    fn filter_step_fade_in_should_produce_correct_args() {
503        let step = FilterStep::FadeIn(Duration::from_secs(1));
504        assert_eq!(step.filter_name(), "fade");
505        assert_eq!(step.args(), "type=in:duration=1");
506    }
507
508    #[test]
509    fn filter_step_fade_out_should_produce_correct_args() {
510        let step = FilterStep::FadeOut(Duration::from_secs(2));
511        assert_eq!(step.filter_name(), "fade");
512        assert_eq!(step.args(), "type=out:duration=2");
513    }
514
515    #[test]
516    fn filter_step_rotate_should_produce_correct_args() {
517        let step = FilterStep::Rotate(90.0);
518        assert_eq!(step.filter_name(), "rotate");
519        assert_eq!(step.args(), format!("angle={}", 90_f64.to_radians()));
520    }
521
522    #[test]
523    fn filter_step_tone_map_should_produce_correct_args() {
524        let step = FilterStep::ToneMap(ToneMap::Hable);
525        assert_eq!(step.filter_name(), "tonemap");
526        assert_eq!(step.args(), "tonemap=hable");
527    }
528
529    #[test]
530    fn filter_step_amix_should_produce_correct_args() {
531        let step = FilterStep::Amix(3);
532        assert_eq!(step.filter_name(), "amix");
533        assert_eq!(step.args(), "inputs=3");
534    }
535
536    #[test]
537    fn filter_step_equalizer_should_produce_correct_args() {
538        let step = FilterStep::Equalizer {
539            band_hz: 1000.0,
540            gain_db: 3.0,
541        };
542        assert_eq!(step.filter_name(), "equalizer");
543        assert_eq!(step.args(), "f=1000:width_type=o:width=2:g=3");
544    }
545
546    #[test]
547    fn builder_steps_should_accumulate_in_order() {
548        let result = FilterGraph::builder()
549            .trim(0.0, 5.0)
550            .scale(1280, 720)
551            .volume(-3.0)
552            .build();
553        assert!(
554            result.is_ok(),
555            "builder with multiple valid steps must succeed, got {result:?}"
556        );
557    }
558
559    #[test]
560    fn builder_with_valid_steps_should_succeed() {
561        let result = FilterGraph::builder().scale(1280, 720).build();
562        assert!(
563            result.is_ok(),
564            "builder with a known filter step must succeed, got {result:?}"
565        );
566    }
567
568    #[test]
569    fn output_resolution_should_return_scale_dimensions() {
570        let fg = FilterGraph::builder().scale(1280, 720).build().unwrap();
571        assert_eq!(fg.output_resolution(), Some((1280, 720)));
572    }
573
574    #[test]
575    fn output_resolution_should_return_last_scale_when_chained() {
576        let fg = FilterGraph::builder()
577            .scale(1920, 1080)
578            .scale(1280, 720)
579            .build()
580            .unwrap();
581        assert_eq!(fg.output_resolution(), Some((1280, 720)));
582    }
583
584    #[test]
585    fn output_resolution_should_return_none_when_no_scale() {
586        let fg = FilterGraph::builder().trim(0.0, 5.0).build().unwrap();
587        assert_eq!(fg.output_resolution(), None);
588    }
589}