Skip to main content

ff_analysis/analysis/
scene_detector.rs

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