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