1use 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 #[serde(default)]
58 pub side_data_list: Vec<SideData>,
59 #[serde(default)]
61 pub tags: HashMap<String, String>,
62}
63
64#[derive(Debug, Clone, Default, Deserialize)]
66pub struct SideData {
67 #[serde(default)]
68 pub rotation: Option<f64>,
69}
70
71impl Stream {
72 pub fn rotation(&self) -> u32 {
74 let deg = self
75 .side_data_list
76 .iter()
77 .find_map(|d| d.rotation)
78 .or_else(|| self.tags.get("rotate").and_then(|r| r.trim().parse().ok()))
79 .unwrap_or(0.0);
80 ((deg.round() as i64).rem_euclid(360) as u32 + 45) / 90 % 4 * 90
81 }
82
83 pub fn display_size(&self) -> (Option<u32>, Option<u32>) {
85 match self.rotation() {
86 90 | 270 => (self.height, self.width),
87 _ => (self.width, self.height),
88 }
89 }
90}
91
92impl Ffprobe {
93 pub fn duration_sec(&self) -> Option<f64> {
95 self.format.duration.as_deref().and_then(parse_f64)
96 }
97
98 pub fn size_bytes(&self) -> Option<u64> {
100 self.format
101 .size
102 .as_deref()
103 .and_then(|s| s.trim().parse().ok())
104 }
105
106 pub fn video_stream(&self) -> Option<&Stream> {
108 self.streams
109 .iter()
110 .find(|s| s.codec_type.as_deref() == Some("video"))
111 }
112
113 pub fn audio_stream(&self) -> Option<&Stream> {
115 self.streams
116 .iter()
117 .find(|s| s.codec_type.as_deref() == Some("audio"))
118 }
119
120 pub fn format_tag(&self, key: &str) -> Option<&str> {
122 self.format
123 .tags
124 .iter()
125 .find(|(k, _)| k.eq_ignore_ascii_case(key))
126 .map(|(_, v)| v.as_str())
127 .filter(|v| !v.trim().is_empty())
128 }
129
130 pub fn audio_bitrate_bps(&self) -> Option<u64> {
132 self.audio_stream()
133 .and_then(|s| s.bit_rate.as_deref())
134 .and_then(|b| b.trim().parse().ok())
135 .filter(|&b| b > 0)
136 }
137
138 pub fn fps(&self) -> Option<f64> {
140 self.video_stream()
141 .and_then(|s| s.r_frame_rate.as_deref())
142 .and_then(parse_ratio)
143 }
144}
145
146fn parse_ratio(s: &str) -> Option<f64> {
148 let (num, den) = s.split_once('/')?;
149 let num: f64 = num.trim().parse().ok()?;
150 let den: f64 = den.trim().parse().ok()?;
151 if den == 0.0 {
152 return None;
153 }
154 Some(num / den)
155}
156
157fn parse_f64(s: &str) -> Option<f64> {
158 let v: f64 = s.trim().parse().ok()?;
159 if v.is_finite() {
160 Some(v)
161 } else {
162 None
163 }
164}
165
166pub fn probe(ffprobe: &Path, input: &Path) -> Result<Ffprobe, FfmpegError> {
168 let output = Command::new(ffprobe)
169 .args([
170 "-v",
171 "error",
172 "-show_format",
173 "-show_streams",
174 "-of",
175 "json",
176 ])
177 .arg(input)
178 .output()
179 .map_err(|source| FfmpegError::Spawn {
180 tool: "ffprobe",
181 source,
182 })?;
183
184 if !output.status.success() {
185 return Err(FfmpegError::CommandFailed {
186 tool: "ffprobe",
187 status: output.status.to_string(),
188 stderr: String::from_utf8_lossy(&output.stderr).trim().to_string(),
189 });
190 }
191
192 serde_json::from_slice(&output.stdout).map_err(|e| FfmpegError::Parse(e.to_string()))
193}
194
195#[cfg(test)]
196mod tests {
197 use super::*;
198
199 const SAMPLE: &str = r#"{
200 "streams": [
201 {"codec_type":"video","codec_name":"h264","width":1920,"height":1080,"r_frame_rate":"30000/1001"},
202 {"codec_type":"audio","codec_name":"aac","channels":2,"r_frame_rate":"0/0"}
203 ],
204 "format": {"duration":"134.20","size":"327553024","bit_rate":"19500000"}
205 }"#;
206
207 #[test]
208 fn parses_sample() {
209 let p: Ffprobe = serde_json::from_str(SAMPLE).unwrap();
210 assert_eq!(p.duration_sec(), Some(134.20));
211 assert_eq!(p.size_bytes(), Some(327_553_024));
212 let v = p.video_stream().unwrap();
213 assert_eq!(v.width, Some(1920));
214 assert_eq!(v.height, Some(1080));
215 assert_eq!(p.audio_stream().unwrap().channels, Some(2));
216 assert!((p.fps().unwrap() - 29.97).abs() < 0.01);
217 }
218
219 #[test]
220 fn tolerates_missing_fields() {
221 let p: Ffprobe = serde_json::from_str(r#"{"format":{}}"#).unwrap();
222 assert_eq!(p.duration_sec(), None);
223 assert!(p.video_stream().is_none());
224 assert!(p.audio_stream().is_none());
225 }
226
227 #[test]
228 fn ratio_guards_zero_denominator() {
229 assert_eq!(parse_ratio("0/0"), None);
230 assert_eq!(parse_ratio("30/1"), Some(30.0));
231 }
232
233 #[test]
234 fn a_rotated_phone_video_reports_its_display_size() {
235 let json = r#"{"format": {"duration": "33.0"}, "streams": [
236 {"codec_type": "video", "width": 1024, "height": 576,
237 "side_data_list": [{"side_data_type": "Display Matrix", "rotation": -90}]},
238 {"codec_type": "video", "width": 1920, "height": 1080, "tags": {"rotate": "180"}},
239 {"codec_type": "video", "width": 1920, "height": 1080}
240 ]}"#;
241 let p: Ffprobe = serde_json::from_str(json).unwrap();
242 assert_eq!(p.streams[0].rotation(), 270);
243 assert_eq!(p.streams[0].display_size(), (Some(576), Some(1024)));
244 assert_eq!(p.streams[1].rotation(), 180);
245 assert_eq!(p.streams[1].display_size(), (Some(1920), Some(1080)));
246 assert_eq!(p.streams[2].display_size(), (Some(1920), Some(1080)));
247 }
248}