Skip to main content

deepshrink_ffmpeg/
probe.rs

1//! Run `ffprobe` and parse its JSON output.
2//!
3//! We deserialize only the fields DeepShrink needs and expose typed accessors.
4//! Mapping into the core `MediaInfo` type happens in `deepshrink-core` so this
5//! crate stays free of a dependency on core.
6
7use std::collections::HashMap;
8use std::path::Path;
9use std::process::Command;
10
11use serde::Deserialize;
12
13use crate::FfmpegError;
14
15/// Top-level `ffprobe -show_format -show_streams -of json` output (subset).
16#[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/// The `format` object: container-level metadata.
25#[derive(Debug, Clone, Default, Deserialize)]
26pub struct Format {
27    /// Duration in seconds, as a string like "58.023000".
28    pub duration: Option<String>,
29    /// File size in bytes, as a string.
30    pub size: Option<String>,
31    /// Overall bit rate in bits/s, as a string.
32    pub bit_rate: Option<String>,
33    /// Container-level tags (`creation_time`, `com.apple.quicktime.*`, …).
34    #[serde(default)]
35    pub tags: HashMap<String, String>,
36}
37
38/// A single stream (video or audio).
39#[derive(Debug, Clone, Deserialize)]
40pub struct Stream {
41    /// "video", "audio", "subtitle", ...
42    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    /// Average frame rate as "num/den", e.g. "30000/1001".
48    pub r_frame_rate: Option<String>,
49    /// Stream bit rate in bits/s, as a string (absent for some containers).
50    #[serde(default)]
51    pub bit_rate: Option<String>,
52    /// Transfer characteristic, e.g. "bt709", "arib-std-b67" (HLG), "smpte2084" (PQ).
53    #[serde(default)]
54    pub color_transfer: Option<String>,
55    /// Side data — a "Display Matrix" entry carries the rotation a player
56    /// applies (phones store portrait video as a landscape frame + rotation).
57    #[serde(default)]
58    pub side_data_list: Vec<SideData>,
59    /// Stream tags (older files keep the rotation as `rotate`).
60    #[serde(default)]
61    pub tags: HashMap<String, String>,
62}
63
64/// One `side_data_list` entry; only the display rotation is read.
65#[derive(Debug, Clone, Default, Deserialize)]
66pub struct SideData {
67    #[serde(default)]
68    pub rotation: Option<f64>,
69}
70
71impl Stream {
72    /// The rotation players apply, normalised to 0 / 90 / 180 / 270 degrees.
73    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    /// Width × height as shown (a quarter turn swaps them).
84    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    /// Container duration in seconds, if reported.
94    pub fn duration_sec(&self) -> Option<f64> {
95        self.format.duration.as_deref().and_then(parse_f64)
96    }
97
98    /// Container size in bytes, if reported.
99    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    /// First video stream, if any.
107    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    /// First audio stream, if any.
114    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    /// A container tag by key (case-insensitive — muxers differ).
121    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    /// Bit rate of the first audio stream in bits/s, if reported.
131    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    /// Frame rate of the first video stream, if parseable.
139    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
146/// Parse "num/den" (e.g. "30000/1001") into a float, guarding against `/0`.
147fn 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
166/// Probe `input` with `ffprobe`, returning parsed metadata.
167pub 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}