Skip to main content

ff_analysis/analysis/
black_frame_detector.rs

1//! Black-frame detection.
2
3use std::path::{Path, PathBuf};
4use std::time::Duration;
5
6use crate::AnalysisError;
7
8/// Detects black intervals in a video file and returns their start timestamps.
9///
10/// Uses `FFmpeg`'s `blackdetect` filter to identify frames or segments where
11/// the proportion of "black" pixels exceeds `threshold`.  One [`Duration`] is
12/// returned per detected black interval (the start of that interval).
13///
14/// # Examples
15///
16/// ```ignore
17/// use ff_analysis::BlackFrameDetector;
18///
19/// let black_starts = BlackFrameDetector::new("video.mp4")
20///     .threshold(0.1)
21///     .run()?;
22///
23/// for ts in &black_starts {
24///     println!("Black interval starts at {:?}", ts);
25/// }
26/// ```
27pub struct BlackFrameDetector {
28    input: PathBuf,
29    threshold: f64,
30}
31
32impl BlackFrameDetector {
33    /// Creates a new detector for the given video file.
34    ///
35    /// The default threshold is `0.1` (10% of pixels must be below the
36    /// blackness cutoff for a frame to count as black).
37    pub fn new(input: impl AsRef<Path>) -> Self {
38        Self {
39            input: input.as_ref().to_path_buf(),
40            threshold: 0.1,
41        }
42    }
43
44    /// Sets the luminance threshold for black-pixel detection.
45    ///
46    /// Must be in the range `[0.0, 1.0]`.  Higher values make the detector
47    /// more permissive (more frames qualify as black); lower values are
48    /// stricter.  Passing a value outside this range causes
49    /// [`run`](Self::run) to return [`AnalysisError::Failed`].
50    ///
51    /// Default: `0.1`.
52    #[must_use]
53    pub fn threshold(self, t: f64) -> Self {
54        Self {
55            threshold: t,
56            ..self
57        }
58    }
59
60    /// Runs black-frame detection and returns the start [`Duration`] of each
61    /// detected black interval.
62    ///
63    /// # Errors
64    ///
65    /// - [`AnalysisError::Failed`] — `threshold` outside `[0.0, 1.0]`,
66    ///   input file not found, or an internal filter-graph error.
67    pub fn run(self) -> Result<Vec<Duration>, AnalysisError> {
68        if !(0.0..=1.0).contains(&self.threshold) {
69            return Err(AnalysisError::Failed {
70                reason: format!("threshold must be in [0.0, 1.0], got {}", self.threshold),
71            });
72        }
73        if !self.input.exists() {
74            return Err(AnalysisError::Failed {
75                reason: format!("file not found: {}", self.input.display()),
76            });
77        }
78        super::analysis_inner::detect_black_frames(&self.input, self.threshold)
79    }
80}
81
82#[cfg(test)]
83mod tests {
84    use super::*;
85
86    #[test]
87    fn black_frame_detector_invalid_threshold_below_zero_should_return_analysis_failed() {
88        let result = BlackFrameDetector::new("irrelevant.mp4")
89            .threshold(-0.1)
90            .run();
91        assert!(
92            matches!(result, Err(AnalysisError::Failed { .. })),
93            "expected Failed for threshold=-0.1, got {result:?}"
94        );
95    }
96
97    #[test]
98    fn black_frame_detector_invalid_threshold_above_one_should_return_analysis_failed() {
99        let result = BlackFrameDetector::new("irrelevant.mp4")
100            .threshold(1.1)
101            .run();
102        assert!(
103            matches!(result, Err(AnalysisError::Failed { .. })),
104            "expected Failed for threshold=1.1, got {result:?}"
105        );
106    }
107
108    #[test]
109    fn black_frame_detector_missing_file_should_return_analysis_failed() {
110        let result = BlackFrameDetector::new("does_not_exist_99999.mp4").run();
111        assert!(
112            matches!(result, Err(AnalysisError::Failed { .. })),
113            "expected Failed for missing file, got {result:?}"
114        );
115    }
116
117    #[test]
118    fn black_frame_detector_boundary_thresholds_should_be_valid() {
119        // 0.0 and 1.0 are valid thresholds; errors come from missing file, not threshold.
120        let r0 = BlackFrameDetector::new("irrelevant.mp4")
121            .threshold(0.0)
122            .run();
123        let r1 = BlackFrameDetector::new("irrelevant.mp4")
124            .threshold(1.0)
125            .run();
126        assert!(
127            matches!(r0, Err(AnalysisError::Failed { .. })),
128            "expected Failed (file not found) for threshold=0.0, got {r0:?}"
129        );
130        assert!(
131            matches!(r1, Err(AnalysisError::Failed { .. })),
132            "expected Failed (file not found) for threshold=1.0, got {r1:?}"
133        );
134    }
135}