use serde::{Deserialize, Serialize};
use std::time::Duration;
use tokio::process::Command;
use viser_ffmpeg::{ffmpeg_path, probe};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Shot {
pub index: i32,
pub start: Duration,
pub end: Duration,
pub duration: Duration,
pub score: f64, }
#[derive(Debug, Clone)]
pub struct DetectOpts {
pub threshold: f64,
pub min_duration: Duration,
}
impl Default for DetectOpts {
fn default() -> Self {
Self { threshold: 10.0, min_duration: Duration::from_millis(500) }
}
}
pub async fn detect(path: &str, opts: DetectOpts) -> anyhow::Result<Vec<Shot>> {
let threshold = if opts.threshold <= 0.0 { 10.0 } else { opts.threshold };
let min_duration =
if opts.min_duration.is_zero() { Duration::from_millis(500) } else { opts.min_duration };
let probe_result = probe(path).await?;
let total_duration = Duration::from_secs_f64(probe_result.format.duration);
let filter = format!("scdet=t={threshold:.1},metadata=mode=print:file=-");
let args = ["-i", path, "-vf", &filter, "-f", "null", "-"];
let output = Command::new(ffmpeg_path())
.args(args)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.output()
.await?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("ffmpeg scdet failed: {stderr}");
}
let stdout = String::from_utf8_lossy(&output.stdout);
let boundaries = parse_scdet_output(&stdout);
let shots = build_shots(&boundaries, total_duration, min_duration);
Ok(shots)
}
struct SceneChange {
pts: Duration,
score: f64,
}
fn parse_scdet_output(output: &str) -> Vec<SceneChange> {
let mut changes = Vec::new();
let mut current_pts = Duration::ZERO;
let mut current_score = 0.0;
let mut has_pts = false;
for line in output.lines() {
if line.starts_with("frame:") {
if let Some(pts_time) = extract_field(line, "pts_time:")
&& let Ok(seconds) = pts_time.parse::<f64>()
{
current_pts = Duration::from_secs_f64(seconds);
has_pts = true;
}
current_score = 0.0;
continue;
}
if let Some(score_str) = line.strip_prefix("lavfi.scd.score=")
&& let Ok(score) = score_str.parse::<f64>()
{
current_score = score;
continue;
}
if line.starts_with("lavfi.scd.time=") && has_pts {
changes.push(SceneChange { pts: current_pts, score: current_score });
}
}
changes.sort_by_key(|a| a.pts);
changes
}
fn extract_field<'a>(line: &'a str, key: &str) -> Option<&'a str> {
let idx = line.find(key)?;
let rest = &line[idx + key.len()..];
let rest = rest.trim_start();
let end = rest.find(|c: char| c.is_whitespace()).unwrap_or(rest.len());
Some(&rest[..end])
}
fn build_shots(
boundaries: &[SceneChange],
total_duration: Duration,
min_duration: Duration,
) -> Vec<Shot> {
if boundaries.is_empty() {
return vec![Shot {
index: 0,
start: Duration::ZERO,
end: total_duration,
duration: total_duration,
score: 0.0,
}];
}
let mut shots = Vec::new();
let mut prev_end = Duration::ZERO;
for sc in boundaries {
if sc.pts <= prev_end || sc.pts >= total_duration {
continue;
}
let s = Shot {
index: shots.len() as i32,
start: prev_end,
end: sc.pts,
duration: sc.pts.saturating_sub(prev_end),
score: sc.score,
};
if s.duration < min_duration && !shots.is_empty() {
let last: &mut Shot = shots.last_mut().unwrap();
last.end = sc.pts;
last.duration = sc.pts.saturating_sub(last.start);
} else {
shots.push(s);
}
prev_end = sc.pts;
}
if prev_end < total_duration {
let s = Shot {
index: shots.len() as i32,
start: prev_end,
end: total_duration,
duration: total_duration.saturating_sub(prev_end),
score: 0.0,
};
if s.duration < min_duration && !shots.is_empty() {
let last: &mut Shot = shots.last_mut().unwrap();
last.end = total_duration;
last.duration = total_duration.saturating_sub(last.start);
} else {
shots.push(s);
}
}
for (i, shot) in shots.iter_mut().enumerate() {
shot.index = i as i32;
}
shots
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_extract_field_basic() {
let line = "frame: 123 pts_time: 5.500 foo: bar";
assert_eq!(extract_field(line, "pts_time:"), Some("5.500"));
}
#[test]
fn test_extract_field_not_found() {
let line = "frame: 123 foo: bar";
assert_eq!(extract_field(line, "pts_time:"), None);
}
#[test]
fn test_extract_field_end_of_string() {
let line = "key: value";
assert_eq!(extract_field(line, "key:"), Some("value"));
}
#[test]
fn test_parse_scdet_output_empty() {
let result = parse_scdet_output("");
assert!(result.is_empty());
}
#[test]
fn test_parse_scdet_output_no_changes() {
let output = "\
frame: 1 pts_time:1.000
frame: 2 pts_time:2.000
";
let result = parse_scdet_output(output);
assert!(result.is_empty());
}
#[test]
fn test_parse_scdet_output_with_score() {
let output = "\
frame: 1 pts_time:1.000
lavfi.scd.score=15.5
lavfi.scd.time=1.000
frame: 2 pts_time:2.000
lavfi.scd.score=3.0
";
let result = parse_scdet_output(output);
assert_eq!(result.len(), 1);
assert!((result[0].score - 15.5).abs() < 1e-9);
assert_eq!(result[0].pts, Duration::from_secs(1));
}
#[test]
fn test_parse_scdet_output_multiple() {
let output = "\
frame: 1 pts_time:1.000
lavfi.scd.score=12.0
lavfi.scd.time=1.000
frame: 2 pts_time:2.000
lavfi.scd.score=2.0
frame: 3 pts_time:3.000
lavfi.scd.score=45.5
lavfi.scd.time=3.000
frame: 4 pts_time:4.000
lavfi.scd.score=1.0
";
let result = parse_scdet_output(output);
assert_eq!(result.len(), 2);
assert!((result[0].score - 12.0).abs() < 1e-9);
assert!((result[1].score - 45.5).abs() < 1e-9);
}
#[test]
fn test_parse_scdet_output_ignores_sub_threshold_frames() {
let output = "\
frame: 1 pts_time:1.000
lavfi.scd.score=4.9
frame: 2 pts_time:2.000
lavfi.scd.score=25.0
lavfi.scd.time=2.000
";
let result = parse_scdet_output(output);
assert_eq!(result.len(), 1);
assert!((result[0].score - 25.0).abs() < 1e-9);
assert_eq!(result[0].pts, Duration::from_secs(2));
}
#[test]
fn test_build_shots_no_boundaries() {
let shots = build_shots(&[], Duration::from_secs(10), Duration::from_millis(500));
assert_eq!(shots.len(), 1);
assert_eq!(shots[0].start, Duration::ZERO);
assert_eq!(shots[0].end, Duration::from_secs(10));
assert_eq!(shots[0].index, 0);
}
#[test]
fn test_build_shots_single_boundary() {
let boundaries = vec![super::SceneChange { pts: Duration::from_secs(4), score: 20.0 }];
let shots = build_shots(&boundaries, Duration::from_secs(10), Duration::from_millis(500));
assert_eq!(shots.len(), 2);
assert_eq!(shots[0].start, Duration::ZERO);
assert_eq!(shots[0].end, Duration::from_secs(4));
assert_eq!(shots[0].score, 20.0);
assert_eq!(shots[1].start, Duration::from_secs(4));
assert_eq!(shots[1].end, Duration::from_secs(10));
assert_eq!(shots[1].score, 0.0);
}
#[test]
fn test_build_shots_merges_short_shots() {
let boundaries = vec![super::SceneChange { pts: Duration::from_millis(200), score: 15.0 }];
let shots = build_shots(&boundaries, Duration::from_secs(10), Duration::from_millis(500));
assert_eq!(shots.len(), 2);
assert_eq!(shots[0].start, Duration::ZERO);
assert_eq!(shots[0].end, Duration::from_millis(200));
}
#[test]
fn test_build_shots_merges_short_middle_shot() {
let boundaries = vec![
super::SceneChange { pts: Duration::from_millis(200), score: 15.0 },
super::SceneChange { pts: Duration::from_millis(300), score: 20.0 },
];
let shots = build_shots(&boundaries, Duration::from_secs(10), Duration::from_millis(500));
assert_eq!(shots.len(), 2);
assert_eq!(shots[0].start, Duration::ZERO);
assert_eq!(shots[0].end, Duration::from_millis(300));
assert_eq!(shots[1].start, Duration::from_millis(300));
}
#[test]
fn test_build_shots_reindexes() {
let boundaries = vec![
super::SceneChange { pts: Duration::from_secs(2), score: 10.0 },
super::SceneChange { pts: Duration::from_secs(5), score: 20.0 },
];
let shots = build_shots(&boundaries, Duration::from_secs(10), Duration::from_millis(500));
for (i, s) in shots.iter().enumerate() {
assert_eq!(s.index as usize, i);
}
assert_eq!(shots.len(), 3);
}
#[test]
fn test_shot_serde_roundtrip() {
let shot = Shot {
index: 2,
start: Duration::from_secs(5),
end: Duration::from_secs(10),
duration: Duration::from_secs(5),
score: 42.5,
};
let json = serde_json::to_string(&shot).unwrap();
let back: Shot = serde_json::from_str(&json).unwrap();
assert_eq!(back.index, 2);
assert!((back.score - 42.5).abs() < 1e-9);
assert_eq!(back.start.as_secs(), 5);
}
#[test]
fn test_detect_opts_default() {
let opts = DetectOpts::default();
assert!((opts.threshold - 10.0).abs() < 1e-9);
assert_eq!(opts.min_duration, Duration::from_millis(500));
}
#[test]
fn test_build_shots_skips_same_pts() {
let boundaries = vec![
super::SceneChange { pts: Duration::from_secs(5), score: 10.0 },
super::SceneChange { pts: Duration::from_secs(5), score: 15.0 },
];
let shots = build_shots(&boundaries, Duration::from_secs(10), Duration::from_millis(500));
assert_eq!(shots.len(), 2); }
#[test]
fn test_build_shots_skips_zero_pts_boundary() {
let boundaries = vec![super::SceneChange { pts: Duration::ZERO, score: 10.0 }];
let shots = build_shots(&boundaries, Duration::from_secs(10), Duration::from_millis(500));
assert_eq!(shots.len(), 1);
assert_eq!(shots[0].start, Duration::ZERO);
assert_eq!(shots[0].end, Duration::from_secs(10));
}
#[test]
fn test_build_shots_skips_boundary_at_or_after_total_duration() {
let boundaries = vec![
super::SceneChange { pts: Duration::from_secs(5), score: 10.0 },
super::SceneChange { pts: Duration::from_secs(10), score: 20.0 },
super::SceneChange { pts: Duration::from_secs(15), score: 30.0 },
];
let shots = build_shots(&boundaries, Duration::from_secs(10), Duration::from_millis(500));
assert_eq!(shots.len(), 2);
assert_eq!(shots[0].end, Duration::from_secs(5));
assert_eq!(shots[1].start, Duration::from_secs(5));
assert_eq!(shots[1].end, Duration::from_secs(10));
}
}