Skip to main content

ff_decode/analysis/
black_frame_detector.rs

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