Skip to main content

ez_ffmpeg/core/analysis/
detector.rs

1//! Detector definitions and their FFmpeg `filter_desc` string generation.
2//!
3//! Lavfi detectors (`Black`, `Scene`, audio) map to one FFmpeg filter each.
4//! [`VideoDetector::Crop`] is native Rust and is not rendered into the graph.
5//! `to_filter` is a pure function (unit-tested); the runner assembles lavfi
6//! strings into a filter graph. Lavfi detectors are passthrough — they attach
7//! `lavfi.*` metadata to frames without dropping any.
8
9use crate::error::{Error, Result};
10
11/// A video-domain detector.
12#[derive(Debug, Clone, PartialEq)]
13pub enum VideoDetector {
14    /// `blackdetect`: reports black regions.
15    ///
16    /// - `min_duration_s`: minimum black duration to report (seconds).
17    /// - `pixel_th`: per-pixel blackness threshold, 0.0..1.0.
18    /// - `picture_th`: fraction of the picture that must be black, 0.0..1.0.
19    Black {
20        min_duration_s: f64,
21        pixel_th: f64,
22        picture_th: f64,
23    },
24    /// `scdet`: reports scene changes.
25    ///
26    /// `threshold_pct` is a **percentage in `0.0..=100.0`** (e.g. `10.0` means
27    /// 10%, not `0.10`). Runs with `sc_pass=0` so frames/metadata pass through
28    /// untouched.
29    Scene { threshold_pct: f64 },
30    /// Native luma crop / letterbox detection (not FFmpeg `cropdetect`).
31    ///
32    /// Runs on decoded **progressive** CPU Y planes inside
33    /// [`MetadataEventFilter`](crate::core::analysis::filter::MetadataEventFilter).
34    /// An interlaced, hardware, or otherwise
35    /// unreadable frame fails the job as [`Error::AnalysisFrame`] (not
36    /// [`Error::InvalidRecipeArg`]). Event and
37    /// report fields are unchanged, but coordinates are **not** guaranteed to
38    /// match FFmpeg bit-for-bit.
39    ///
40    /// - `limit`: 8-bit-equivalent luma threshold (`0..=255` is scaled by bit
41    ///   depth; `0` disables black classification and yields the full frame;
42    ///   values `> 255` are treated as a raw code). 10-bit `24` becomes 96.
43    /// - `round`: width/height are expanded **outward** to a multiple of this
44    ///   (never cutting detected content). `0`/`1` skip extra multiples.
45    /// - `reset`: every N evaluated frames, drop temporal evidence but keep the
46    ///   current stable rectangle (`0` = never). This is a finite window, not
47    ///   a permanent historical maximum.
48    ///
49    /// The first two real frames are skipped (`skip=2`) and three further
50    /// high-confidence candidates are required before the first event. Use
51    /// [`crate::analysis::CropDetectionOptions`] to change skip or the
52    /// threshold type.
53    Crop { limit: u32, round: u32, reset: u32 },
54}
55
56/// An audio-domain detector.
57#[derive(Debug, Clone, PartialEq)]
58pub enum AudioDetector {
59    /// `silencedetect`: reports silent regions.
60    ///
61    /// - `noise_db`: noise floor in **dB** (rendered with the required `dB`
62    ///   suffix, e.g. `-30dB`).
63    /// - `min_duration_s`: minimum silence duration to report (seconds).
64    /// - `mono`: when `true`, detect per channel (adds `mono=1`); the parser
65    ///   then sees `.N` (1-based) channel suffixes on the metadata keys.
66    Silence {
67        noise_db: f64,
68        min_duration_s: f64,
69        mono: bool,
70    },
71    /// `ebur128 metadata=1`: EBU R128 loudness measurement.
72    ///
73    /// `true_peak` adds `peak=true` so per-channel true-peak keys are emitted.
74    Ebur128 { true_peak: bool },
75}
76
77impl VideoDetector {
78    /// The bare FFmpeg filter name, for capability checks.
79    ///
80    /// [`VideoDetector::Crop`] is native Rust and has no lavfi name.
81    pub(crate) fn filter_name(&self) -> Option<&'static str> {
82        match self {
83            VideoDetector::Black { .. } => Some("blackdetect"),
84            VideoDetector::Scene { .. } => Some("scdet"),
85            VideoDetector::Crop { .. } => None,
86        }
87    }
88
89    /// Renders this detector as an FFmpeg filter string.
90    ///
91    /// [`VideoDetector::Crop`] returns `None` — it is not inserted into the
92    /// lavfi graph.
93    pub(crate) fn to_filter(&self) -> Option<String> {
94        match *self {
95            VideoDetector::Black {
96                min_duration_s,
97                pixel_th,
98                picture_th,
99            } => Some(format!(
100                "blackdetect=d={min_duration_s}:pix_th={pixel_th}:pic_th={picture_th}"
101            )),
102            VideoDetector::Scene { threshold_pct } => {
103                Some(format!("scdet=threshold={threshold_pct}:sc_pass=0"))
104            }
105            VideoDetector::Crop { .. } => None,
106        }
107    }
108
109    pub(crate) fn is_native_crop(&self) -> bool {
110        matches!(self, VideoDetector::Crop { .. })
111    }
112
113    /// Rejects values outside each detector's documented range up front, so
114    /// they surface as a clean [`Error::InvalidRecipeArg`] instead of an opaque
115    /// FFmpeg graph-parse failure.
116    pub(crate) fn validate(&self) -> Result<()> {
117        let in_range = |v: f64, lo: f64, hi: f64, what: &str| -> Result<()> {
118            if v.is_finite() && v >= lo && v <= hi {
119                Ok(())
120            } else {
121                Err(Error::InvalidRecipeArg(format!(
122                    "{what} must be in {lo}..={hi}, got {v}"
123                )))
124            }
125        };
126        match *self {
127            VideoDetector::Black {
128                min_duration_s,
129                pixel_th,
130                picture_th,
131            } => {
132                if !min_duration_s.is_finite() || min_duration_s < 0.0 {
133                    return Err(Error::InvalidRecipeArg(format!(
134                        "blackdetect min_duration_s must be finite and >= 0, got {min_duration_s}"
135                    )));
136                }
137                in_range(pixel_th, 0.0, 1.0, "blackdetect pixel_th")?;
138                in_range(picture_th, 0.0, 1.0, "blackdetect picture_th")?;
139            }
140            VideoDetector::Scene { threshold_pct } => {
141                in_range(threshold_pct, 0.0, 100.0, "scene threshold_pct")?;
142            }
143            VideoDetector::Crop {
144                limit,
145                round,
146                reset,
147            } => {
148                for (v, what) in [
149                    (limit, "crop limit"),
150                    (round, "crop round"),
151                    (reset, "crop reset"),
152                ] {
153                    if v > i32::MAX as u32 {
154                        return Err(Error::InvalidRecipeArg(format!(
155                            "{what} must be <= {}, got {v}",
156                            i32::MAX
157                        )));
158                    }
159                }
160            }
161        }
162        Ok(())
163    }
164}
165
166impl AudioDetector {
167    /// The bare FFmpeg filter name, for capability checks.
168    pub(crate) fn filter_name(&self) -> &'static str {
169        match self {
170            AudioDetector::Silence { .. } => "silencedetect",
171            AudioDetector::Ebur128 { .. } => "ebur128",
172        }
173    }
174
175    /// Renders this detector as an FFmpeg filter string.
176    pub(crate) fn to_filter(&self) -> String {
177        match *self {
178            AudioDetector::Silence {
179                noise_db,
180                min_duration_s,
181                mono,
182            } => {
183                let mut s = format!("silencedetect=noise={noise_db}dB:d={min_duration_s}");
184                if mono {
185                    s.push_str(":mono=1");
186                }
187                s
188            }
189            AudioDetector::Ebur128 { true_peak } => {
190                if true_peak {
191                    "ebur128=metadata=1:peak=true".to_string()
192                } else {
193                    "ebur128=metadata=1".to_string()
194                }
195            }
196        }
197    }
198
199    /// Rejects non-finite (`NaN`/`inf`) values up front.
200    pub(crate) fn validate(&self) -> Result<()> {
201        if let AudioDetector::Silence {
202            noise_db,
203            min_duration_s,
204            ..
205        } = *self
206        {
207            if !noise_db.is_finite() {
208                return Err(Error::InvalidRecipeArg(format!(
209                    "silencedetect noise_db must be finite, got {noise_db}"
210                )));
211            }
212            if !min_duration_s.is_finite() || min_duration_s < 0.0 {
213                return Err(Error::InvalidRecipeArg(format!(
214                    "silencedetect min_duration_s must be finite and >= 0, got {min_duration_s}"
215                )));
216            }
217        }
218        Ok(())
219    }
220}
221
222#[cfg(test)]
223mod tests {
224    use super::*;
225
226    #[test]
227    fn black_filter_string() {
228        let d = VideoDetector::Black {
229            min_duration_s: 0.1,
230            pixel_th: 0.1,
231            picture_th: 0.98,
232        };
233        assert_eq!(
234            d.to_filter().unwrap(),
235            "blackdetect=d=0.1:pix_th=0.1:pic_th=0.98"
236        );
237    }
238
239    #[test]
240    fn scene_filter_uses_sc_pass_zero() {
241        let d = VideoDetector::Scene {
242            threshold_pct: 10.0,
243        };
244        assert_eq!(d.to_filter().unwrap(), "scdet=threshold=10:sc_pass=0");
245    }
246
247    #[test]
248    fn crop_is_native_not_lavfi() {
249        let d = VideoDetector::Crop {
250            limit: 24,
251            round: 16,
252            reset: 0,
253        };
254        assert!(d.to_filter().is_none());
255        assert!(d.filter_name().is_none());
256        assert!(d.is_native_crop());
257    }
258
259    #[test]
260    fn silence_filter_requires_db_suffix() {
261        let d = AudioDetector::Silence {
262            noise_db: -30.0,
263            min_duration_s: 0.5,
264            mono: false,
265        };
266        assert_eq!(d.to_filter(), "silencedetect=noise=-30dB:d=0.5");
267    }
268
269    #[test]
270    fn silence_mono_adds_flag() {
271        let d = AudioDetector::Silence {
272            noise_db: -30.0,
273            min_duration_s: 0.5,
274            mono: true,
275        };
276        assert_eq!(d.to_filter(), "silencedetect=noise=-30dB:d=0.5:mono=1");
277    }
278
279    #[test]
280    fn ebur128_peak_toggle() {
281        assert_eq!(
282            AudioDetector::Ebur128 { true_peak: false }.to_filter(),
283            "ebur128=metadata=1"
284        );
285        assert_eq!(
286            AudioDetector::Ebur128 { true_peak: true }.to_filter(),
287            "ebur128=metadata=1:peak=true"
288        );
289    }
290
291    #[test]
292    fn validate_rejects_non_finite() {
293        assert!(VideoDetector::Black {
294            min_duration_s: f64::NAN,
295            pixel_th: 0.1,
296            picture_th: 0.98,
297        }
298        .validate()
299        .is_err());
300        assert!(VideoDetector::Scene {
301            threshold_pct: f64::INFINITY,
302        }
303        .validate()
304        .is_err());
305        assert!(AudioDetector::Silence {
306            noise_db: f64::NAN,
307            min_duration_s: 0.5,
308            mono: false,
309        }
310        .validate()
311        .is_err());
312        // Out-of-documented-range values are rejected too.
313        assert!(VideoDetector::Scene {
314            threshold_pct: 101.0
315        }
316        .validate()
317        .is_err());
318        assert!(VideoDetector::Black {
319            min_duration_s: -1.0,
320            pixel_th: 0.1,
321            picture_th: 0.98,
322        }
323        .validate()
324        .is_err());
325        assert!(VideoDetector::Black {
326            min_duration_s: 0.1,
327            pixel_th: 1.5,
328            picture_th: 0.98,
329        }
330        .validate()
331        .is_err());
332        assert!(VideoDetector::Black {
333            min_duration_s: 0.1,
334            pixel_th: 0.1,
335            picture_th: 0.98,
336        }
337        .validate()
338        .is_ok());
339    }
340}