deepshrink_ffmpeg/
probe.rs1use std::path::Path;
8use std::process::Command;
9
10use serde::Deserialize;
11
12use crate::FfmpegError;
13
14#[derive(Debug, Clone, Deserialize)]
16pub struct Ffprobe {
17 #[serde(default)]
18 pub streams: Vec<Stream>,
19 #[serde(default)]
20 pub format: Format,
21}
22
23#[derive(Debug, Clone, Default, Deserialize)]
25pub struct Format {
26 pub duration: Option<String>,
28 pub size: Option<String>,
30 pub bit_rate: Option<String>,
32}
33
34#[derive(Debug, Clone, Deserialize)]
36pub struct Stream {
37 pub codec_type: Option<String>,
39 pub codec_name: Option<String>,
40 pub width: Option<u32>,
41 pub height: Option<u32>,
42 pub channels: Option<u32>,
43 pub r_frame_rate: Option<String>,
45}
46
47impl Ffprobe {
48 pub fn duration_sec(&self) -> Option<f64> {
50 self.format.duration.as_deref().and_then(parse_f64)
51 }
52
53 pub fn size_bytes(&self) -> Option<u64> {
55 self.format
56 .size
57 .as_deref()
58 .and_then(|s| s.trim().parse().ok())
59 }
60
61 pub fn video_stream(&self) -> Option<&Stream> {
63 self.streams
64 .iter()
65 .find(|s| s.codec_type.as_deref() == Some("video"))
66 }
67
68 pub fn audio_stream(&self) -> Option<&Stream> {
70 self.streams
71 .iter()
72 .find(|s| s.codec_type.as_deref() == Some("audio"))
73 }
74
75 pub fn fps(&self) -> Option<f64> {
77 self.video_stream()
78 .and_then(|s| s.r_frame_rate.as_deref())
79 .and_then(parse_ratio)
80 }
81}
82
83fn parse_ratio(s: &str) -> Option<f64> {
85 let (num, den) = s.split_once('/')?;
86 let num: f64 = num.trim().parse().ok()?;
87 let den: f64 = den.trim().parse().ok()?;
88 if den == 0.0 {
89 return None;
90 }
91 Some(num / den)
92}
93
94fn parse_f64(s: &str) -> Option<f64> {
95 let v: f64 = s.trim().parse().ok()?;
96 if v.is_finite() {
97 Some(v)
98 } else {
99 None
100 }
101}
102
103pub fn probe(ffprobe: &Path, input: &Path) -> Result<Ffprobe, FfmpegError> {
105 let output = Command::new(ffprobe)
106 .args([
107 "-v",
108 "error",
109 "-show_format",
110 "-show_streams",
111 "-of",
112 "json",
113 ])
114 .arg(input)
115 .output()
116 .map_err(|source| FfmpegError::Spawn {
117 tool: "ffprobe",
118 source,
119 })?;
120
121 if !output.status.success() {
122 return Err(FfmpegError::CommandFailed {
123 tool: "ffprobe",
124 status: output.status.to_string(),
125 stderr: String::from_utf8_lossy(&output.stderr).trim().to_string(),
126 });
127 }
128
129 serde_json::from_slice(&output.stdout).map_err(|e| FfmpegError::Parse(e.to_string()))
130}
131
132#[cfg(test)]
133mod tests {
134 use super::*;
135
136 const SAMPLE: &str = r#"{
137 "streams": [
138 {"codec_type":"video","codec_name":"h264","width":1920,"height":1080,"r_frame_rate":"30000/1001"},
139 {"codec_type":"audio","codec_name":"aac","channels":2,"r_frame_rate":"0/0"}
140 ],
141 "format": {"duration":"134.20","size":"327553024","bit_rate":"19500000"}
142 }"#;
143
144 #[test]
145 fn parses_sample() {
146 let p: Ffprobe = serde_json::from_str(SAMPLE).unwrap();
147 assert_eq!(p.duration_sec(), Some(134.20));
148 assert_eq!(p.size_bytes(), Some(327_553_024));
149 let v = p.video_stream().unwrap();
150 assert_eq!(v.width, Some(1920));
151 assert_eq!(v.height, Some(1080));
152 assert_eq!(p.audio_stream().unwrap().channels, Some(2));
153 assert!((p.fps().unwrap() - 29.97).abs() < 0.01);
154 }
155
156 #[test]
157 fn tolerates_missing_fields() {
158 let p: Ffprobe = serde_json::from_str(r#"{"format":{}}"#).unwrap();
159 assert_eq!(p.duration_sec(), None);
160 assert!(p.video_stream().is_none());
161 assert!(p.audio_stream().is_none());
162 }
163
164 #[test]
165 fn ratio_guards_zero_denominator() {
166 assert_eq!(parse_ratio("0/0"), None);
167 assert_eq!(parse_ratio("30/1"), Some(30.0));
168 }
169}