Skip to main content

ff_decode/analysis/
scene_detector.rs

1//! Scene-change detection.
2
3#![allow(unsafe_code)]
4
5use std::path::{Path, PathBuf};
6use std::time::Duration;
7
8use crate::DecodeError;
9
10/// Detects scene changes in a video file and returns their timestamps.
11///
12/// Uses `FFmpeg`'s `select=gt(scene\,threshold)` filter to identify frames
13/// where the scene changes.  The `threshold` controls detection sensitivity:
14/// lower values detect more cuts (including subtle ones); higher values detect
15/// only hard cuts.
16///
17/// # Examples
18///
19/// ```ignore
20/// use ff_decode::SceneDetector;
21///
22/// let cuts = SceneDetector::new("video.mp4")
23///     .threshold(0.3)
24///     .run()?;
25///
26/// for ts in &cuts {
27///     println!("Scene change at {:?}", ts);
28/// }
29/// ```
30pub struct SceneDetector {
31    input: PathBuf,
32    threshold: f64,
33}
34
35impl SceneDetector {
36    /// Creates a new detector for the given video file.
37    ///
38    /// The default detection threshold is `0.4`.  Call
39    /// [`threshold`](Self::threshold) to override it.
40    pub fn new(input: impl AsRef<Path>) -> Self {
41        Self {
42            input: input.as_ref().to_path_buf(),
43            threshold: 0.4,
44        }
45    }
46
47    /// Sets the scene-change detection threshold.
48    ///
49    /// Must be in the range `[0.0, 1.0]`.  Lower values make the detector more
50    /// sensitive (more cuts reported); higher values require a larger visual
51    /// difference.  Passing a value outside this range causes
52    /// [`run`](Self::run) to return [`DecodeError::AnalysisFailed`].
53    ///
54    /// Default: `0.4`.
55    #[must_use]
56    pub fn threshold(self, t: f64) -> Self {
57        Self {
58            threshold: t,
59            ..self
60        }
61    }
62
63    /// Runs scene-change detection and returns one [`Duration`] per detected cut.
64    ///
65    /// Timestamps are sorted in ascending order and represent the PTS of the
66    /// first frame of each new scene.
67    ///
68    /// # Errors
69    ///
70    /// - [`DecodeError::AnalysisFailed`] — threshold outside `[0.0, 1.0]`,
71    ///   input file not found, or an internal filter-graph error.
72    pub fn run(self) -> Result<Vec<Duration>, DecodeError> {
73        if !(0.0..=1.0).contains(&self.threshold) {
74            return Err(DecodeError::AnalysisFailed {
75                reason: format!("threshold must be in [0.0, 1.0], got {}", self.threshold),
76            });
77        }
78        if !self.input.exists() {
79            return Err(DecodeError::AnalysisFailed {
80                reason: format!("file not found: {}", self.input.display()),
81            });
82        }
83        // SAFETY: detect_scenes_unsafe manages all raw pointer lifetimes according
84        // to the avfilter ownership rules documented in analysis_inner. The path is
85        // valid for the duration of the call.
86        unsafe { super::analysis_inner::detect_scenes_unsafe(&self.input, self.threshold) }
87    }
88}
89
90#[cfg(test)]
91mod tests {
92    use super::*;
93
94    #[test]
95    fn scene_detector_invalid_threshold_below_zero_should_return_analysis_failed() {
96        let result = SceneDetector::new("irrelevant.mp4").threshold(-0.1).run();
97        assert!(
98            matches!(result, Err(DecodeError::AnalysisFailed { .. })),
99            "expected AnalysisFailed for threshold=-0.1, got {result:?}"
100        );
101    }
102
103    #[test]
104    fn scene_detector_invalid_threshold_above_one_should_return_analysis_failed() {
105        let result = SceneDetector::new("irrelevant.mp4").threshold(1.1).run();
106        assert!(
107            matches!(result, Err(DecodeError::AnalysisFailed { .. })),
108            "expected AnalysisFailed for threshold=1.1, got {result:?}"
109        );
110    }
111
112    #[test]
113    fn scene_detector_missing_file_should_return_analysis_failed() {
114        let result = SceneDetector::new("does_not_exist_99999.mp4").run();
115        assert!(
116            matches!(result, Err(DecodeError::AnalysisFailed { .. })),
117            "expected AnalysisFailed for missing file, got {result:?}"
118        );
119    }
120
121    #[test]
122    fn scene_detector_boundary_thresholds_should_be_valid() {
123        // 0.0 and 1.0 are valid thresholds (boundary-inclusive check).
124        // They return errors only for missing file, not for bad threshold.
125        let r0 = SceneDetector::new("irrelevant.mp4").threshold(0.0).run();
126        let r1 = SceneDetector::new("irrelevant.mp4").threshold(1.0).run();
127        // Both should fail with AnalysisFailed (file not found), NOT threshold error.
128        assert!(
129            matches!(r0, Err(DecodeError::AnalysisFailed { .. })),
130            "expected AnalysisFailed (file), got {r0:?}"
131        );
132        assert!(
133            matches!(r1, Err(DecodeError::AnalysisFailed { .. })),
134            "expected AnalysisFailed (file), got {r1:?}"
135        );
136    }
137}