deepshrink_ffmpeg/
probe.rs1use std::collections::HashMap;
8use std::path::Path;
9use std::process::Command;
10
11use serde::Deserialize;
12
13use crate::FfmpegError;
14
15#[derive(Debug, Clone, Deserialize)]
17pub struct Ffprobe {
18 #[serde(default)]
19 pub streams: Vec<Stream>,
20 #[serde(default)]
21 pub format: Format,
22}
23
24#[derive(Debug, Clone, Default, Deserialize)]
26pub struct Format {
27 pub duration: Option<String>,
29 pub size: Option<String>,
31 pub bit_rate: Option<String>,
33 #[serde(default)]
35 pub tags: HashMap<String, String>,
36}
37
38#[derive(Debug, Clone, Deserialize)]
40pub struct Stream {
41 pub codec_type: Option<String>,
43 pub codec_name: Option<String>,
44 pub width: Option<u32>,
45 pub height: Option<u32>,
46 pub channels: Option<u32>,
47 pub r_frame_rate: Option<String>,
49 #[serde(default)]
51 pub bit_rate: Option<String>,
52 #[serde(default)]
54 pub color_transfer: Option<String>,
55}
56
57impl Ffprobe {
58 pub fn duration_sec(&self) -> Option<f64> {
60 self.format.duration.as_deref().and_then(parse_f64)
61 }
62
63 pub fn size_bytes(&self) -> Option<u64> {
65 self.format
66 .size
67 .as_deref()
68 .and_then(|s| s.trim().parse().ok())
69 }
70
71 pub fn video_stream(&self) -> Option<&Stream> {
73 self.streams
74 .iter()
75 .find(|s| s.codec_type.as_deref() == Some("video"))
76 }
77
78 pub fn audio_stream(&self) -> Option<&Stream> {
80 self.streams
81 .iter()
82 .find(|s| s.codec_type.as_deref() == Some("audio"))
83 }
84
85 pub fn format_tag(&self, key: &str) -> Option<&str> {
87 self.format
88 .tags
89 .iter()
90 .find(|(k, _)| k.eq_ignore_ascii_case(key))
91 .map(|(_, v)| v.as_str())
92 .filter(|v| !v.trim().is_empty())
93 }
94
95 pub fn audio_bitrate_bps(&self) -> Option<u64> {
97 self.audio_stream()
98 .and_then(|s| s.bit_rate.as_deref())
99 .and_then(|b| b.trim().parse().ok())
100 .filter(|&b| b > 0)
101 }
102
103 pub fn fps(&self) -> Option<f64> {
105 self.video_stream()
106 .and_then(|s| s.r_frame_rate.as_deref())
107 .and_then(parse_ratio)
108 }
109}
110
111fn parse_ratio(s: &str) -> Option<f64> {
113 let (num, den) = s.split_once('/')?;
114 let num: f64 = num.trim().parse().ok()?;
115 let den: f64 = den.trim().parse().ok()?;
116 if den == 0.0 {
117 return None;
118 }
119 Some(num / den)
120}
121
122fn parse_f64(s: &str) -> Option<f64> {
123 let v: f64 = s.trim().parse().ok()?;
124 if v.is_finite() {
125 Some(v)
126 } else {
127 None
128 }
129}
130
131pub fn probe(ffprobe: &Path, input: &Path) -> Result<Ffprobe, FfmpegError> {
133 let output = Command::new(ffprobe)
134 .args([
135 "-v",
136 "error",
137 "-show_format",
138 "-show_streams",
139 "-of",
140 "json",
141 ])
142 .arg(input)
143 .output()
144 .map_err(|source| FfmpegError::Spawn {
145 tool: "ffprobe",
146 source,
147 })?;
148
149 if !output.status.success() {
150 return Err(FfmpegError::CommandFailed {
151 tool: "ffprobe",
152 status: output.status.to_string(),
153 stderr: String::from_utf8_lossy(&output.stderr).trim().to_string(),
154 });
155 }
156
157 serde_json::from_slice(&output.stdout).map_err(|e| FfmpegError::Parse(e.to_string()))
158}
159
160#[cfg(test)]
161mod tests {
162 use super::*;
163
164 const SAMPLE: &str = r#"{
165 "streams": [
166 {"codec_type":"video","codec_name":"h264","width":1920,"height":1080,"r_frame_rate":"30000/1001"},
167 {"codec_type":"audio","codec_name":"aac","channels":2,"r_frame_rate":"0/0"}
168 ],
169 "format": {"duration":"134.20","size":"327553024","bit_rate":"19500000"}
170 }"#;
171
172 #[test]
173 fn parses_sample() {
174 let p: Ffprobe = serde_json::from_str(SAMPLE).unwrap();
175 assert_eq!(p.duration_sec(), Some(134.20));
176 assert_eq!(p.size_bytes(), Some(327_553_024));
177 let v = p.video_stream().unwrap();
178 assert_eq!(v.width, Some(1920));
179 assert_eq!(v.height, Some(1080));
180 assert_eq!(p.audio_stream().unwrap().channels, Some(2));
181 assert!((p.fps().unwrap() - 29.97).abs() < 0.01);
182 }
183
184 #[test]
185 fn tolerates_missing_fields() {
186 let p: Ffprobe = serde_json::from_str(r#"{"format":{}}"#).unwrap();
187 assert_eq!(p.duration_sec(), None);
188 assert!(p.video_stream().is_none());
189 assert!(p.audio_stream().is_none());
190 }
191
192 #[test]
193 fn ratio_guards_zero_denominator() {
194 assert_eq!(parse_ratio("0/0"), None);
195 assert_eq!(parse_ratio("30/1"), Some(30.0));
196 }
197}