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, pub size: i64, pub bit_rate: i64, 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, 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, pub bit_rate: i64, pub nb_frames: i32,
42 pub r_frame_rate: String, pub avg_frame_rate: String, pub sample_rate: i32, pub channels: i32, pub channel_layout: String, 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 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
91pub 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#[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}