Skip to main content

viser_ffmpeg/
probe.rs

1use serde::{Deserialize, Serialize};
2use tokio::process::Command;
3
4use crate::ffprobe_path;
5
6#[derive(Debug, Clone, Serialize, Deserialize)]
7pub struct ProbeResult {
8    pub format: FormatInfo,
9    pub streams: Vec<StreamInfo>,
10}
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct FormatInfo {
14    pub filename: String,
15    pub format_name: String,
16    pub format_long_name: String,
17    pub duration: f64, // seconds
18    pub size: i64,     // bytes
19    pub bit_rate: i64, // bits/sec
20    pub probe_score: i32,
21}
22
23#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct StreamInfo {
25    pub index: i32,
26    pub codec_name: String,
27    pub codec_long_name: String,
28    pub codec_type: String, // "video", "audio", "subtitle"
29    pub profile: String,
30    pub width: i32,
31    pub height: i32,
32    pub pix_fmt: String,
33    pub level: i32,
34    pub field_order: String,
35    pub color_range: String,
36    pub color_space: String,
37    pub color_transfer: String,
38    pub color_primaries: String,
39    pub duration: f64, // seconds
40    pub bit_rate: i64, // bits/sec
41    pub nb_frames: i32,
42    pub r_frame_rate: String,   // e.g. "25/1"
43    pub avg_frame_rate: String, // e.g. "25/1"
44    pub sample_rate: i32,       // audio
45    pub channels: i32,          // audio
46    pub channel_layout: String, // audio
47    pub bits_per_raw_sample: i32,
48}
49
50impl StreamInfo {
51    pub fn validate(&self) -> anyhow::Result<()> {
52        if self.codec_type != "video" {
53            anyhow::bail!("not a video stream (type={})", self.codec_type);
54        }
55        if self.width <= 0 || self.height <= 0 {
56            anyhow::bail!("invalid dimensions: {}x{}", self.width, self.height);
57        }
58        Ok(())
59    }
60
61    /// Frame rate parsed from r_frame_rate (e.g. "50/1" -> 50.0).
62    pub fn fps(&self) -> f64 {
63        parse_rational(&self.r_frame_rate)
64    }
65
66    pub fn resolution_str(&self) -> String {
67        if self.width == 0 || self.height == 0 {
68            String::new()
69        } else {
70            format!("{}x{}", self.width, self.height)
71        }
72    }
73}
74
75impl FormatInfo {
76    pub fn duration_secs(&self) -> std::time::Duration {
77        std::time::Duration::from_secs_f64(self.duration)
78    }
79}
80
81impl ProbeResult {
82    pub fn video_stream(&self) -> Option<&StreamInfo> {
83        self.streams.iter().find(|s| s.codec_type == "video")
84    }
85
86    pub fn audio_stream(&self) -> Option<&StreamInfo> {
87        self.streams.iter().find(|s| s.codec_type == "audio")
88    }
89}
90
91/// Runs ffprobe on the given file and returns parsed results.
92pub async fn probe(path: &str) -> anyhow::Result<ProbeResult> {
93    let args = ["-v", "error", "-print_format", "json", "-show_format", "-show_streams", path];
94
95    let output = Command::new(ffprobe_path())
96        .args(args)
97        .stderr(std::process::Stdio::piped())
98        .output()
99        .await?;
100
101    if !output.status.success() {
102        let stderr = String::from_utf8_lossy(&output.stderr);
103        anyhow::bail!("ffprobe failed for {path}: {stderr}");
104    }
105
106    let raw: ProbeJsonRaw = serde_json::from_slice(&output.stdout)
107        .map_err(|e| anyhow::anyhow!("failed to parse ffprobe output: {e}"))?;
108
109    Ok(convert_probe(raw))
110}
111
112// Raw ffprobe JSON — numbers come as strings
113#[derive(Deserialize)]
114struct ProbeJsonRaw {
115    format: ProbeFormatRaw,
116    streams: Vec<ProbeStreamRaw>,
117}
118
119#[derive(Deserialize)]
120struct ProbeFormatRaw {
121    #[serde(default)]
122    filename: String,
123    #[serde(default)]
124    format_name: String,
125    #[serde(default)]
126    format_long_name: String,
127    #[serde(default)]
128    duration: String,
129    #[serde(default)]
130    size: String,
131    #[serde(default)]
132    bit_rate: String,
133    #[serde(default)]
134    probe_score: i32,
135}
136
137#[derive(Deserialize)]
138struct ProbeStreamRaw {
139    #[serde(default)]
140    index: i32,
141    #[serde(default)]
142    codec_name: String,
143    #[serde(default)]
144    codec_long_name: String,
145    #[serde(default)]
146    codec_type: String,
147    #[serde(default)]
148    profile: String,
149    #[serde(default)]
150    width: i32,
151    #[serde(default)]
152    height: i32,
153    #[serde(default)]
154    pix_fmt: String,
155    #[serde(default)]
156    level: i32,
157    #[serde(default)]
158    field_order: String,
159    #[serde(default)]
160    color_range: String,
161    #[serde(default)]
162    color_space: String,
163    #[serde(default)]
164    color_transfer: String,
165    #[serde(default)]
166    color_primaries: String,
167    #[serde(default)]
168    duration: String,
169    #[serde(default)]
170    bit_rate: String,
171    #[serde(default)]
172    nb_frames: String,
173    #[serde(default)]
174    r_frame_rate: String,
175    #[serde(default)]
176    avg_frame_rate: String,
177    #[serde(default)]
178    sample_rate: String,
179    #[serde(default)]
180    channels: i32,
181    #[serde(default)]
182    channel_layout: String,
183    #[serde(default)]
184    bits_per_raw_sample: String,
185}
186
187fn convert_probe(raw: ProbeJsonRaw) -> ProbeResult {
188    let format = FormatInfo {
189        filename: raw.format.filename,
190        format_name: raw.format.format_name,
191        format_long_name: raw.format.format_long_name,
192        duration: raw.format.duration.parse().unwrap_or(0.0),
193        size: raw.format.size.parse().unwrap_or(0),
194        bit_rate: raw.format.bit_rate.parse().unwrap_or(0),
195        probe_score: raw.format.probe_score,
196    };
197
198    let streams = raw
199        .streams
200        .into_iter()
201        .map(|s| StreamInfo {
202            index: s.index,
203            codec_name: s.codec_name,
204            codec_long_name: s.codec_long_name,
205            codec_type: s.codec_type,
206            profile: s.profile,
207            width: s.width,
208            height: s.height,
209            pix_fmt: s.pix_fmt,
210            level: s.level,
211            field_order: s.field_order,
212            color_range: s.color_range,
213            color_space: s.color_space,
214            color_transfer: s.color_transfer,
215            color_primaries: s.color_primaries,
216            duration: s.duration.parse().unwrap_or(0.0),
217            bit_rate: s.bit_rate.parse().unwrap_or(0),
218            nb_frames: s.nb_frames.parse().unwrap_or(0),
219            r_frame_rate: s.r_frame_rate,
220            avg_frame_rate: s.avg_frame_rate,
221            sample_rate: s.sample_rate.parse().unwrap_or(0),
222            channels: s.channels,
223            channel_layout: s.channel_layout,
224            bits_per_raw_sample: s.bits_per_raw_sample.parse().unwrap_or(0),
225        })
226        .collect();
227
228    ProbeResult { format, streams }
229}
230
231fn parse_rational(s: &str) -> f64 {
232    if let Some((num_s, den_s)) = s.split_once('/') {
233        let num: f64 = num_s.parse().unwrap_or(0.0);
234        let den: f64 = den_s.parse().unwrap_or(0.0);
235        if den != 0.0 { num / den } else { 0.0 }
236    } else {
237        s.parse().unwrap_or(0.0)
238    }
239}