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}