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}
56
57impl Ffprobe {
58    /// Container duration in seconds, if reported.
59    pub fn duration_sec(&self) -> Option<f64> {
60        self.format.duration.as_deref().and_then(parse_f64)
61    }
62
63    /// Container size in bytes, if reported.
64    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    /// First video stream, if any.
72    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    /// First audio stream, if any.
79    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    /// A container tag by key (case-insensitive — muxers differ).
86    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    /// Bit rate of the first audio stream in bits/s, if reported.
96    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    /// Frame rate of the first video stream, if parseable.
104    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
111/// Parse "num/den" (e.g. "30000/1001") into a float, guarding against `/0`.
112fn 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
131/// Probe `input` with `ffprobe`, returning parsed metadata.
132pub 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}