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::path::Path;
8use std::process::Command;
9
10use serde::Deserialize;
11
12use crate::FfmpegError;
13
14/// Top-level `ffprobe -show_format -show_streams -of json` output (subset).
15#[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/// The `format` object: container-level metadata.
24#[derive(Debug, Clone, Default, Deserialize)]
25pub struct Format {
26    /// Duration in seconds, as a string like "58.023000".
27    pub duration: Option<String>,
28    /// File size in bytes, as a string.
29    pub size: Option<String>,
30    /// Overall bit rate in bits/s, as a string.
31    pub bit_rate: Option<String>,
32}
33
34/// A single stream (video or audio).
35#[derive(Debug, Clone, Deserialize)]
36pub struct Stream {
37    /// "video", "audio", "subtitle", ...
38    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    /// Average frame rate as "num/den", e.g. "30000/1001".
44    pub r_frame_rate: Option<String>,
45}
46
47impl Ffprobe {
48    /// Container duration in seconds, if reported.
49    pub fn duration_sec(&self) -> Option<f64> {
50        self.format.duration.as_deref().and_then(parse_f64)
51    }
52
53    /// Container size in bytes, if reported.
54    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    /// First video stream, if any.
62    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    /// First audio stream, if any.
69    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    /// Frame rate of the first video stream, if parseable.
76    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
83/// Parse "num/den" (e.g. "30000/1001") into a float, guarding against `/0`.
84fn 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
103/// Probe `input` with `ffprobe`, returning parsed metadata.
104pub 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}