Skip to main content

resopt/
media.rs

1//! Audio and video inspection through the optional `ffprobe` tool.
2//!
3//! resopt does not transcode audio or video: every useful reduction is lossy
4//! and depends on how the app plays the file. It reports codec, duration and
5//! bitrate so oversized media is easy to spot, and says so when ffprobe is
6//! missing instead of pretending the files were analyzed.
7use serde::{Deserialize, Serialize};
8use std::{
9    path::Path,
10    process::{Command, Stdio},
11    sync::OnceLock,
12};
13
14#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
15pub struct MediaInfo {
16    pub container: Option<String>,
17    pub duration_seconds: Option<f64>,
18    pub bit_rate: Option<u64>,
19    /// `codec_type/codec_name` per stream, e.g. `video/h264`.
20    pub streams: Vec<String>,
21    pub width: Option<u32>,
22    pub height: Option<u32>,
23}
24
25pub(crate) fn ffprobe_available() -> bool {
26    static FOUND: OnceLock<bool> = OnceLock::new();
27    *FOUND.get_or_init(|| {
28        Command::new("ffprobe")
29            .arg("-version")
30            .stdin(Stdio::null())
31            .stdout(Stdio::null())
32            .stderr(Stdio::null())
33            .status()
34            .is_ok_and(|status| status.success())
35    })
36}
37
38/// Parse `ffprobe -of json -show_format -show_streams` output.
39pub(crate) fn parse(json: &[u8]) -> Option<MediaInfo> {
40    let value: serde_json::Value = serde_json::from_slice(json).ok()?;
41    let format = value.get("format")?;
42    let number = |v: &serde_json::Value| v.as_str().and_then(|s| s.parse::<f64>().ok());
43    let streams = value.get("streams")?.as_array()?;
44    let video = streams.iter().find(|s| s["codec_type"] == "video");
45    Some(MediaInfo {
46        container: format["format_name"].as_str().map(str::to_string),
47        duration_seconds: number(&format["duration"]).filter(|d| d.is_finite() && *d >= 0.0),
48        bit_rate: number(&format["bit_rate"]).map(|b| b as u64),
49        streams: streams
50            .iter()
51            .map(|s| {
52                format!(
53                    "{}/{}",
54                    s["codec_type"].as_str().unwrap_or("unknown"),
55                    s["codec_name"].as_str().unwrap_or("unknown")
56                )
57            })
58            .collect(),
59        width: video.and_then(|s| s["width"].as_u64()).map(|v| v as u32),
60        height: video.and_then(|s| s["height"].as_u64()).map(|v| v as u32),
61    })
62}
63
64pub(crate) fn probe(path: &Path) -> Option<MediaInfo> {
65    if !ffprobe_available() {
66        return None;
67    }
68    let output = Command::new("ffprobe")
69        .args([
70            "-v",
71            "error",
72            "-of",
73            "json",
74            "-show_format",
75            "-show_streams",
76        ])
77        .arg(path)
78        .stdin(Stdio::null())
79        .stderr(Stdio::null())
80        .output()
81        .ok()?;
82    output
83        .status
84        .success()
85        .then(|| parse(&output.stdout))
86        .flatten()
87}
88
89#[cfg(test)]
90mod tests {
91    use super::*;
92
93    #[test]
94    fn parses_ffprobe_json_and_rejects_garbage() {
95        let info = parse(br#"{"streams":[{"codec_type":"video","codec_name":"h264","width":720,"height":1280},{"codec_type":"audio","codec_name":"aac"}],"format":{"format_name":"mov,mp4","duration":"3.500000","bit_rate":"1250000"}}"#).unwrap();
96        assert_eq!(info.streams, ["video/h264", "audio/aac"]);
97        assert_eq!((info.width, info.height), (Some(720), Some(1280)));
98        assert_eq!(info.duration_seconds, Some(3.5));
99        assert_eq!(info.bit_rate, Some(1_250_000));
100        assert_eq!(parse(b"not json"), None);
101        assert_eq!(parse(br#"{"format":{}}"#), None);
102        let sparse = parse(br#"{"streams":[],"format":{"duration":"nan"}}"#).unwrap();
103        assert_eq!(sparse.duration_seconds, None);
104    }
105}