use std::path::Path;
use std::process::Command;
use serde::Deserialize;
use crate::FfmpegError;
#[derive(Debug, Clone, Deserialize)]
pub struct Ffprobe {
#[serde(default)]
pub streams: Vec<Stream>,
#[serde(default)]
pub format: Format,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct Format {
pub duration: Option<String>,
pub size: Option<String>,
pub bit_rate: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct Stream {
pub codec_type: Option<String>,
pub codec_name: Option<String>,
pub width: Option<u32>,
pub height: Option<u32>,
pub channels: Option<u32>,
pub r_frame_rate: Option<String>,
}
impl Ffprobe {
pub fn duration_sec(&self) -> Option<f64> {
self.format.duration.as_deref().and_then(parse_f64)
}
pub fn size_bytes(&self) -> Option<u64> {
self.format
.size
.as_deref()
.and_then(|s| s.trim().parse().ok())
}
pub fn video_stream(&self) -> Option<&Stream> {
self.streams
.iter()
.find(|s| s.codec_type.as_deref() == Some("video"))
}
pub fn audio_stream(&self) -> Option<&Stream> {
self.streams
.iter()
.find(|s| s.codec_type.as_deref() == Some("audio"))
}
pub fn fps(&self) -> Option<f64> {
self.video_stream()
.and_then(|s| s.r_frame_rate.as_deref())
.and_then(parse_ratio)
}
}
fn parse_ratio(s: &str) -> Option<f64> {
let (num, den) = s.split_once('/')?;
let num: f64 = num.trim().parse().ok()?;
let den: f64 = den.trim().parse().ok()?;
if den == 0.0 {
return None;
}
Some(num / den)
}
fn parse_f64(s: &str) -> Option<f64> {
let v: f64 = s.trim().parse().ok()?;
if v.is_finite() {
Some(v)
} else {
None
}
}
pub fn probe(ffprobe: &Path, input: &Path) -> Result<Ffprobe, FfmpegError> {
let output = Command::new(ffprobe)
.args([
"-v",
"error",
"-show_format",
"-show_streams",
"-of",
"json",
])
.arg(input)
.output()
.map_err(|source| FfmpegError::Spawn {
tool: "ffprobe",
source,
})?;
if !output.status.success() {
return Err(FfmpegError::CommandFailed {
tool: "ffprobe",
status: output.status.to_string(),
stderr: String::from_utf8_lossy(&output.stderr).trim().to_string(),
});
}
serde_json::from_slice(&output.stdout).map_err(|e| FfmpegError::Parse(e.to_string()))
}
#[cfg(test)]
mod tests {
use super::*;
const SAMPLE: &str = r#"{
"streams": [
{"codec_type":"video","codec_name":"h264","width":1920,"height":1080,"r_frame_rate":"30000/1001"},
{"codec_type":"audio","codec_name":"aac","channels":2,"r_frame_rate":"0/0"}
],
"format": {"duration":"134.20","size":"327553024","bit_rate":"19500000"}
}"#;
#[test]
fn parses_sample() {
let p: Ffprobe = serde_json::from_str(SAMPLE).unwrap();
assert_eq!(p.duration_sec(), Some(134.20));
assert_eq!(p.size_bytes(), Some(327_553_024));
let v = p.video_stream().unwrap();
assert_eq!(v.width, Some(1920));
assert_eq!(v.height, Some(1080));
assert_eq!(p.audio_stream().unwrap().channels, Some(2));
assert!((p.fps().unwrap() - 29.97).abs() < 0.01);
}
#[test]
fn tolerates_missing_fields() {
let p: Ffprobe = serde_json::from_str(r#"{"format":{}}"#).unwrap();
assert_eq!(p.duration_sec(), None);
assert!(p.video_stream().is_none());
assert!(p.audio_stream().is_none());
}
#[test]
fn ratio_guards_zero_denominator() {
assert_eq!(parse_ratio("0/0"), None);
assert_eq!(parse_ratio("30/1"), Some(30.0));
}
}