Skip to main content

viser_shot/
lib.rs

1//! Shot/scene boundary detection for the `viser` video-encoding-optimizer workspace.
2//!
3//! Wraps FFmpeg's `scdet` filter to find scene changes in a video, merges shots
4//! shorter than a configurable minimum, and returns each shot's start/end
5//! timestamps and change score. See `detect` for the entry point.
6
7use serde::{Deserialize, Serialize};
8use std::time::Duration;
9use tokio::process::Command;
10use viser_ffmpeg::{ffmpeg_path, probe};
11
12/// A detected shot with start and end timestamps.
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct Shot {
15    /// Zero-based shot index within the video.
16    pub index: i32,
17    /// Start timestamp of the shot.
18    pub start: Duration,
19    /// End timestamp of the shot.
20    pub end: Duration,
21    /// Length of the shot (`end - start`).
22    pub duration: Duration,
23    /// Scene-change score at this shot's starting boundary (0-100); `0` for the first shot.
24    pub score: f64, // scene change score at boundary (0-100)
25}
26
27/// Parameters controlling `detect`.
28#[derive(Debug, Clone)]
29pub struct DetectOpts {
30    /// Threshold for scene change detection (0-100). Lower = more sensitive.
31    pub threshold: f64,
32    /// Minimum shot duration. Shots shorter than this are merged.
33    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
42/// Finds shot boundaries using FFmpeg's scdet filter.
43pub 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        // scdet prints `lavfi.scd.score` for *every* frame; the threshold only
97        // governs whether scdet flags an actual cut, which it signals by also
98        // emitting `lavfi.scd.time`. Key off that line so we mark a boundary
99        // only where scdet itself detected a scene change.
100        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    // Final shot from last boundary to end
167    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    // Re-index
185    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        // A score line alone is NOT a scene change; scdet emits `lavfi.scd.time`
233        // only on frames it actually flags as cuts.
234        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        // Every frame has a score, but only the one with `lavfi.scd.time` counts.
270        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        // First shot can't be merged (nothing to merge into), so we get 2 shots
310        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        // Boundary at 200ms creates a short first shot (unmergeable).
318        // Boundary at 300ms would create a 100ms shot between 200ms-300ms,
319        // which gets merged into the previous shot.
320        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        // Shot1 [0, 200ms) = 200ms (first, can't merge)
326        // Shot2 [200ms, 300ms) = 100ms (short, merges into shot1) -> shot1 becomes [0, 300ms)
327        // Final [300ms, 10s) = 9.7s
328        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); // second boundary at same PTS is skipped
378    }
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}