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}