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