Skip to main content

ez_ffmpeg/core/analysis/
detector.rs

1//! Detector definitions and their FFmpeg `filter_desc` string generation.
2//!
3//! Each variant maps to exactly one FFmpeg detector filter. [`to_filter`] is a
4//! pure function (unit-tested) that renders the filter string; the runner
5//! assembles those into a filter graph. All detectors are passthrough — they
6//! attach `lavfi.*` metadata to frames without dropping any.
7//!
8//! [`to_filter`]: VideoDetector::to_filter
9
10use crate::error::{Error, Result};
11
12/// A video-domain detector.
13#[derive(Debug, Clone, PartialEq)]
14pub enum VideoDetector {
15    /// `blackdetect`: reports black regions.
16    ///
17    /// - `min_duration_s`: minimum black duration to report (seconds).
18    /// - `pixel_th`: per-pixel blackness threshold, 0.0..1.0.
19    /// - `picture_th`: fraction of the picture that must be black, 0.0..1.0.
20    Black {
21        min_duration_s: f64,
22        pixel_th: f64,
23        picture_th: f64,
24    },
25    /// `scdet`: reports scene changes.
26    ///
27    /// `threshold_pct` is a **percentage in `0.0..=100.0`** (e.g. `10.0` means
28    /// 10%, not `0.10`). Runs with `sc_pass=0` so frames/metadata pass through
29    /// untouched.
30    Scene { threshold_pct: f64 },
31    /// `cropdetect`: suggests a crop rectangle.
32    ///
33    /// - `limit`: luminance threshold below which a pixel is "black".
34    /// - `round`: width/height are rounded to a multiple of this.
35    /// - `reset`: recompute the crop every N frames (0 = never reset).
36    Crop { limit: u32, round: u32, reset: u32 },
37}
38
39/// An audio-domain detector.
40#[derive(Debug, Clone, PartialEq)]
41pub enum AudioDetector {
42    /// `silencedetect`: reports silent regions.
43    ///
44    /// - `noise_db`: noise floor in **dB** (rendered with the required `dB`
45    ///   suffix, e.g. `-30dB`).
46    /// - `min_duration_s`: minimum silence duration to report (seconds).
47    /// - `mono`: when `true`, detect per channel (adds `mono=1`); the parser
48    ///   then sees `.N` (1-based) channel suffixes on the metadata keys.
49    Silence {
50        noise_db: f64,
51        min_duration_s: f64,
52        mono: bool,
53    },
54    /// `ebur128 metadata=1`: EBU R128 loudness measurement.
55    ///
56    /// `true_peak` adds `peak=true` so per-channel true-peak keys are emitted.
57    Ebur128 { true_peak: bool },
58}
59
60impl VideoDetector {
61    /// The bare FFmpeg filter name, for capability checks.
62    pub(crate) fn filter_name(&self) -> &'static str {
63        match self {
64            VideoDetector::Black { .. } => "blackdetect",
65            VideoDetector::Scene { .. } => "scdet",
66            VideoDetector::Crop { .. } => "cropdetect",
67        }
68    }
69
70    /// Renders this detector as an FFmpeg filter string.
71    pub(crate) fn to_filter(&self) -> String {
72        match *self {
73            VideoDetector::Black {
74                min_duration_s,
75                pixel_th,
76                picture_th,
77            } => format!("blackdetect=d={min_duration_s}:pix_th={pixel_th}:pic_th={picture_th}"),
78            VideoDetector::Scene { threshold_pct } => {
79                format!("scdet=threshold={threshold_pct}:sc_pass=0")
80            }
81            VideoDetector::Crop {
82                limit,
83                round,
84                reset,
85            } => format!("cropdetect=limit={limit}:round={round}:reset={reset}"),
86        }
87    }
88
89    /// Rejects values outside each detector's documented range up front, so
90    /// they surface as a clean [`Error::InvalidRecipeArg`] instead of an opaque
91    /// FFmpeg graph-parse failure.
92    pub(crate) fn validate(&self) -> Result<()> {
93        let in_range = |v: f64, lo: f64, hi: f64, what: &str| -> Result<()> {
94            if v.is_finite() && v >= lo && v <= hi {
95                Ok(())
96            } else {
97                Err(Error::InvalidRecipeArg(format!(
98                    "{what} must be in {lo}..={hi}, got {v}"
99                )))
100            }
101        };
102        match *self {
103            VideoDetector::Black {
104                min_duration_s,
105                pixel_th,
106                picture_th,
107            } => {
108                if !min_duration_s.is_finite() || min_duration_s < 0.0 {
109                    return Err(Error::InvalidRecipeArg(format!(
110                        "blackdetect min_duration_s must be finite and >= 0, got {min_duration_s}"
111                    )));
112                }
113                in_range(pixel_th, 0.0, 1.0, "blackdetect pixel_th")?;
114                in_range(picture_th, 0.0, 1.0, "blackdetect picture_th")?;
115            }
116            VideoDetector::Scene { threshold_pct } => {
117                in_range(threshold_pct, 0.0, 100.0, "scene threshold_pct")?;
118            }
119            VideoDetector::Crop {
120                limit,
121                round,
122                reset,
123            } => {
124                // cropdetect maps these onto FFmpeg int AVOptions; values above
125                // i32::MAX would overflow into a late graph error.
126                for (v, what) in [
127                    (limit, "cropdetect limit"),
128                    (round, "cropdetect round"),
129                    (reset, "cropdetect reset"),
130                ] {
131                    if v > i32::MAX as u32 {
132                        return Err(Error::InvalidRecipeArg(format!(
133                            "{what} must be <= {}, got {v}",
134                            i32::MAX
135                        )));
136                    }
137                }
138            }
139        }
140        Ok(())
141    }
142}
143
144impl AudioDetector {
145    /// The bare FFmpeg filter name, for capability checks.
146    pub(crate) fn filter_name(&self) -> &'static str {
147        match self {
148            AudioDetector::Silence { .. } => "silencedetect",
149            AudioDetector::Ebur128 { .. } => "ebur128",
150        }
151    }
152
153    /// Renders this detector as an FFmpeg filter string.
154    pub(crate) fn to_filter(&self) -> String {
155        match *self {
156            AudioDetector::Silence {
157                noise_db,
158                min_duration_s,
159                mono,
160            } => {
161                let mut s = format!("silencedetect=noise={noise_db}dB:d={min_duration_s}");
162                if mono {
163                    s.push_str(":mono=1");
164                }
165                s
166            }
167            AudioDetector::Ebur128 { true_peak } => {
168                if true_peak {
169                    "ebur128=metadata=1:peak=true".to_string()
170                } else {
171                    "ebur128=metadata=1".to_string()
172                }
173            }
174        }
175    }
176
177    /// Rejects non-finite (`NaN`/`inf`) values up front.
178    pub(crate) fn validate(&self) -> Result<()> {
179        if let AudioDetector::Silence {
180            noise_db,
181            min_duration_s,
182            ..
183        } = *self
184        {
185            if !noise_db.is_finite() {
186                return Err(Error::InvalidRecipeArg(format!(
187                    "silencedetect noise_db must be finite, got {noise_db}"
188                )));
189            }
190            if !min_duration_s.is_finite() || min_duration_s < 0.0 {
191                return Err(Error::InvalidRecipeArg(format!(
192                    "silencedetect min_duration_s must be finite and >= 0, got {min_duration_s}"
193                )));
194            }
195        }
196        Ok(())
197    }
198}
199
200#[cfg(test)]
201mod tests {
202    use super::*;
203
204    #[test]
205    fn black_filter_string() {
206        let d = VideoDetector::Black {
207            min_duration_s: 0.1,
208            pixel_th: 0.1,
209            picture_th: 0.98,
210        };
211        assert_eq!(d.to_filter(), "blackdetect=d=0.1:pix_th=0.1:pic_th=0.98");
212    }
213
214    #[test]
215    fn scene_filter_uses_sc_pass_zero() {
216        let d = VideoDetector::Scene { threshold_pct: 10.0 };
217        assert_eq!(d.to_filter(), "scdet=threshold=10:sc_pass=0");
218    }
219
220    #[test]
221    fn crop_filter_string() {
222        let d = VideoDetector::Crop {
223            limit: 24,
224            round: 16,
225            reset: 0,
226        };
227        assert_eq!(d.to_filter(), "cropdetect=limit=24:round=16:reset=0");
228    }
229
230    #[test]
231    fn silence_filter_requires_db_suffix() {
232        let d = AudioDetector::Silence {
233            noise_db: -30.0,
234            min_duration_s: 0.5,
235            mono: false,
236        };
237        assert_eq!(d.to_filter(), "silencedetect=noise=-30dB:d=0.5");
238    }
239
240    #[test]
241    fn silence_mono_adds_flag() {
242        let d = AudioDetector::Silence {
243            noise_db: -30.0,
244            min_duration_s: 0.5,
245            mono: true,
246        };
247        assert_eq!(d.to_filter(), "silencedetect=noise=-30dB:d=0.5:mono=1");
248    }
249
250    #[test]
251    fn ebur128_peak_toggle() {
252        assert_eq!(
253            AudioDetector::Ebur128 { true_peak: false }.to_filter(),
254            "ebur128=metadata=1"
255        );
256        assert_eq!(
257            AudioDetector::Ebur128 { true_peak: true }.to_filter(),
258            "ebur128=metadata=1:peak=true"
259        );
260    }
261
262    #[test]
263    fn validate_rejects_non_finite() {
264        assert!(VideoDetector::Black {
265            min_duration_s: f64::NAN,
266            pixel_th: 0.1,
267            picture_th: 0.98,
268        }
269        .validate()
270        .is_err());
271        assert!(VideoDetector::Scene {
272            threshold_pct: f64::INFINITY,
273        }
274        .validate()
275        .is_err());
276        assert!(AudioDetector::Silence {
277            noise_db: f64::NAN,
278            min_duration_s: 0.5,
279            mono: false,
280        }
281        .validate()
282        .is_err());
283        // Out-of-documented-range values are rejected too.
284        assert!(VideoDetector::Scene { threshold_pct: 101.0 }.validate().is_err());
285        assert!(VideoDetector::Black {
286            min_duration_s: -1.0,
287            pixel_th: 0.1,
288            picture_th: 0.98,
289        }
290        .validate()
291        .is_err());
292        assert!(VideoDetector::Black {
293            min_duration_s: 0.1,
294            pixel_th: 1.5,
295            picture_th: 0.98,
296        }
297        .validate()
298        .is_err());
299        assert!(VideoDetector::Black {
300            min_duration_s: 0.1,
301            pixel_th: 0.1,
302            picture_th: 0.98,
303        }
304        .validate()
305        .is_ok());
306    }
307}