1use serde::{Deserialize, Serialize};
8use std::time::Duration;
9use tokio::process::Command;
10use viser_ffmpeg::{ffmpeg_path, probe};
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct Shot {
15 pub index: i32,
17 pub start: Duration,
19 pub end: Duration,
21 pub duration: Duration,
23 pub score: f64, }
26
27#[derive(Debug, Clone)]
29pub struct DetectOpts {
30 pub threshold: f64,
32 pub min_duration: Duration,
34}
35
36impl Default for DetectOpts {
37 fn default() -> Self {
38 Self { threshold: 10.0, min_duration: Duration::from_millis(500) }
39 }
40}
41
42pub async fn detect(path: &str, opts: DetectOpts) -> anyhow::Result<Vec<Shot>> {
44 let threshold = if opts.threshold <= 0.0 { 10.0 } else { opts.threshold };
45 let min_duration =
46 if opts.min_duration.is_zero() { Duration::from_millis(500) } else { opts.min_duration };
47
48 let probe_result = probe(path).await?;
49 let total_duration = Duration::from_secs_f64(probe_result.format.duration);
50
51 let filter = format!("scdet=t={threshold:.1},metadata=mode=print:file=-");
52 let args = ["-i", path, "-vf", &filter, "-f", "null", "-"];
53
54 let output = Command::new(ffmpeg_path())
55 .args(args)
56 .stdout(std::process::Stdio::piped())
57 .stderr(std::process::Stdio::piped())
58 .output()
59 .await?;
60
61 if !output.status.success() {
62 let stderr = String::from_utf8_lossy(&output.stderr);
63 anyhow::bail!("ffmpeg scdet failed: {stderr}");
64 }
65
66 let stdout = String::from_utf8_lossy(&output.stdout);
67 let boundaries = parse_scdet_output(&stdout);
68 let shots = build_shots(&boundaries, total_duration, min_duration);
69
70 Ok(shots)
71}
72
73struct SceneChange {
74 pts: Duration,
75 score: f64,
76}
77
78fn parse_scdet_output(output: &str) -> Vec<SceneChange> {
79 let mut changes = Vec::new();
80 let mut current_pts = Duration::ZERO;
81 let mut current_score = 0.0;
82 let mut has_pts = false;
83
84 for line in output.lines() {
85 if line.starts_with("frame:") {
86 if let Some(pts_time) = extract_field(line, "pts_time:")
87 && let Ok(seconds) = pts_time.parse::<f64>()
88 {
89 current_pts = Duration::from_secs_f64(seconds);
90 has_pts = true;
91 }
92 current_score = 0.0;
93 continue;
94 }
95
96 if let Some(score_str) = line.strip_prefix("lavfi.scd.score=")
101 && let Ok(score) = score_str.parse::<f64>()
102 {
103 current_score = score;
104 continue;
105 }
106
107 if line.starts_with("lavfi.scd.time=") && has_pts {
108 changes.push(SceneChange { pts: current_pts, score: current_score });
109 }
110 }
111
112 changes.sort_by_key(|a| a.pts);
113 changes
114}
115
116fn extract_field<'a>(line: &'a str, key: &str) -> Option<&'a str> {
117 let idx = line.find(key)?;
118 let rest = &line[idx + key.len()..];
119 let rest = rest.trim_start();
120 let end = rest.find(|c: char| c.is_whitespace()).unwrap_or(rest.len());
121 Some(&rest[..end])
122}
123
124fn build_shots(
125 boundaries: &[SceneChange],
126 total_duration: Duration,
127 min_duration: Duration,
128) -> Vec<Shot> {
129 if boundaries.is_empty() {
130 return vec![Shot {
131 index: 0,
132 start: Duration::ZERO,
133 end: total_duration,
134 duration: total_duration,
135 score: 0.0,
136 }];
137 }
138
139 let mut shots = Vec::new();
140 let mut prev_end = Duration::ZERO;
141
142 for sc in boundaries {
143 if sc.pts <= prev_end || sc.pts >= total_duration {
144 continue;
145 }
146
147 let s = Shot {
148 index: shots.len() as i32,
149 start: prev_end,
150 end: sc.pts,
151 duration: sc.pts.saturating_sub(prev_end),
152 score: sc.score,
153 };
154
155 if s.duration < min_duration && !shots.is_empty() {
156 let last: &mut Shot = shots.last_mut().unwrap();
157 last.end = sc.pts;
158 last.duration = sc.pts.saturating_sub(last.start);
159 } else {
160 shots.push(s);
161 }
162
163 prev_end = sc.pts;
164 }
165
166 if prev_end < total_duration {
168 let s = Shot {
169 index: shots.len() as i32,
170 start: prev_end,
171 end: total_duration,
172 duration: total_duration.saturating_sub(prev_end),
173 score: 0.0,
174 };
175 if s.duration < min_duration && !shots.is_empty() {
176 let last: &mut Shot = shots.last_mut().unwrap();
177 last.end = total_duration;
178 last.duration = total_duration.saturating_sub(last.start);
179 } else {
180 shots.push(s);
181 }
182 }
183
184 for (i, shot) in shots.iter_mut().enumerate() {
186 shot.index = i as i32;
187 }
188
189 shots
190}
191
192#[cfg(test)]
193mod tests {
194 use super::*;
195
196 #[test]
197 fn test_extract_field_basic() {
198 let line = "frame: 123 pts_time: 5.500 foo: bar";
199 assert_eq!(extract_field(line, "pts_time:"), Some("5.500"));
200 }
201
202 #[test]
203 fn test_extract_field_not_found() {
204 let line = "frame: 123 foo: bar";
205 assert_eq!(extract_field(line, "pts_time:"), None);
206 }
207
208 #[test]
209 fn test_extract_field_end_of_string() {
210 let line = "key: value";
211 assert_eq!(extract_field(line, "key:"), Some("value"));
212 }
213
214 #[test]
215 fn test_parse_scdet_output_empty() {
216 let result = parse_scdet_output("");
217 assert!(result.is_empty());
218 }
219
220 #[test]
221 fn test_parse_scdet_output_no_changes() {
222 let output = "\
223frame: 1 pts_time:1.000
224frame: 2 pts_time:2.000
225";
226 let result = parse_scdet_output(output);
227 assert!(result.is_empty());
228 }
229
230 #[test]
231 fn test_parse_scdet_output_with_score() {
232 let output = "\
235frame: 1 pts_time:1.000
236lavfi.scd.score=15.5
237lavfi.scd.time=1.000
238frame: 2 pts_time:2.000
239lavfi.scd.score=3.0
240";
241 let result = parse_scdet_output(output);
242 assert_eq!(result.len(), 1);
243 assert!((result[0].score - 15.5).abs() < 1e-9);
244 assert_eq!(result[0].pts, Duration::from_secs(1));
245 }
246
247 #[test]
248 fn test_parse_scdet_output_multiple() {
249 let output = "\
250frame: 1 pts_time:1.000
251lavfi.scd.score=12.0
252lavfi.scd.time=1.000
253frame: 2 pts_time:2.000
254lavfi.scd.score=2.0
255frame: 3 pts_time:3.000
256lavfi.scd.score=45.5
257lavfi.scd.time=3.000
258frame: 4 pts_time:4.000
259lavfi.scd.score=1.0
260";
261 let result = parse_scdet_output(output);
262 assert_eq!(result.len(), 2);
263 assert!((result[0].score - 12.0).abs() < 1e-9);
264 assert!((result[1].score - 45.5).abs() < 1e-9);
265 }
266
267 #[test]
268 fn test_parse_scdet_output_ignores_sub_threshold_frames() {
269 let output = "\
271frame: 1 pts_time:1.000
272lavfi.scd.score=4.9
273frame: 2 pts_time:2.000
274lavfi.scd.score=25.0
275lavfi.scd.time=2.000
276";
277 let result = parse_scdet_output(output);
278 assert_eq!(result.len(), 1);
279 assert!((result[0].score - 25.0).abs() < 1e-9);
280 assert_eq!(result[0].pts, Duration::from_secs(2));
281 }
282
283 #[test]
284 fn test_build_shots_no_boundaries() {
285 let shots = build_shots(&[], Duration::from_secs(10), Duration::from_millis(500));
286 assert_eq!(shots.len(), 1);
287 assert_eq!(shots[0].start, Duration::ZERO);
288 assert_eq!(shots[0].end, Duration::from_secs(10));
289 assert_eq!(shots[0].index, 0);
290 }
291
292 #[test]
293 fn test_build_shots_single_boundary() {
294 let boundaries = vec![super::SceneChange { pts: Duration::from_secs(4), score: 20.0 }];
295 let shots = build_shots(&boundaries, Duration::from_secs(10), Duration::from_millis(500));
296 assert_eq!(shots.len(), 2);
297 assert_eq!(shots[0].start, Duration::ZERO);
298 assert_eq!(shots[0].end, Duration::from_secs(4));
299 assert_eq!(shots[0].score, 20.0);
300 assert_eq!(shots[1].start, Duration::from_secs(4));
301 assert_eq!(shots[1].end, Duration::from_secs(10));
302 assert_eq!(shots[1].score, 0.0);
303 }
304
305 #[test]
306 fn test_build_shots_merges_short_shots() {
307 let boundaries = vec![super::SceneChange { pts: Duration::from_millis(200), score: 15.0 }];
308 let shots = build_shots(&boundaries, Duration::from_secs(10), Duration::from_millis(500));
309 assert_eq!(shots.len(), 2);
311 assert_eq!(shots[0].start, Duration::ZERO);
312 assert_eq!(shots[0].end, Duration::from_millis(200));
313 }
314
315 #[test]
316 fn test_build_shots_merges_short_middle_shot() {
317 let boundaries = vec![
321 super::SceneChange { pts: Duration::from_millis(200), score: 15.0 },
322 super::SceneChange { pts: Duration::from_millis(300), score: 20.0 },
323 ];
324 let shots = build_shots(&boundaries, Duration::from_secs(10), Duration::from_millis(500));
325 assert_eq!(shots.len(), 2);
329 assert_eq!(shots[0].start, Duration::ZERO);
330 assert_eq!(shots[0].end, Duration::from_millis(300));
331 assert_eq!(shots[1].start, Duration::from_millis(300));
332 }
333
334 #[test]
335 fn test_build_shots_reindexes() {
336 let boundaries = vec![
337 super::SceneChange { pts: Duration::from_secs(2), score: 10.0 },
338 super::SceneChange { pts: Duration::from_secs(5), score: 20.0 },
339 ];
340 let shots = build_shots(&boundaries, Duration::from_secs(10), Duration::from_millis(500));
341 for (i, s) in shots.iter().enumerate() {
342 assert_eq!(s.index as usize, i);
343 }
344 assert_eq!(shots.len(), 3);
345 }
346
347 #[test]
348 fn test_shot_serde_roundtrip() {
349 let shot = Shot {
350 index: 2,
351 start: Duration::from_secs(5),
352 end: Duration::from_secs(10),
353 duration: Duration::from_secs(5),
354 score: 42.5,
355 };
356 let json = serde_json::to_string(&shot).unwrap();
357 let back: Shot = serde_json::from_str(&json).unwrap();
358 assert_eq!(back.index, 2);
359 assert!((back.score - 42.5).abs() < 1e-9);
360 assert_eq!(back.start.as_secs(), 5);
361 }
362
363 #[test]
364 fn test_detect_opts_default() {
365 let opts = DetectOpts::default();
366 assert!((opts.threshold - 10.0).abs() < 1e-9);
367 assert_eq!(opts.min_duration, Duration::from_millis(500));
368 }
369
370 #[test]
371 fn test_build_shots_skips_same_pts() {
372 let boundaries = vec![
373 super::SceneChange { pts: Duration::from_secs(5), score: 10.0 },
374 super::SceneChange { pts: Duration::from_secs(5), score: 15.0 },
375 ];
376 let shots = build_shots(&boundaries, Duration::from_secs(10), Duration::from_millis(500));
377 assert_eq!(shots.len(), 2); }
379
380 #[test]
381 fn test_build_shots_skips_zero_pts_boundary() {
382 let boundaries = vec![super::SceneChange { pts: Duration::ZERO, score: 10.0 }];
383 let shots = build_shots(&boundaries, Duration::from_secs(10), Duration::from_millis(500));
384 assert_eq!(shots.len(), 1);
385 assert_eq!(shots[0].start, Duration::ZERO);
386 assert_eq!(shots[0].end, Duration::from_secs(10));
387 }
388
389 #[test]
390 fn test_build_shots_skips_boundary_at_or_after_total_duration() {
391 let boundaries = vec![
392 super::SceneChange { pts: Duration::from_secs(5), score: 10.0 },
393 super::SceneChange { pts: Duration::from_secs(10), score: 20.0 },
394 super::SceneChange { pts: Duration::from_secs(15), score: 30.0 },
395 ];
396 let shots = build_shots(&boundaries, Duration::from_secs(10), Duration::from_millis(500));
397 assert_eq!(shots.len(), 2);
398 assert_eq!(shots[0].end, Duration::from_secs(5));
399 assert_eq!(shots[1].start, Duration::from_secs(5));
400 assert_eq!(shots[1].end, Duration::from_secs(10));
401 }
402}