1use std::io::{ErrorKind, Read};
32use std::path::{Path, PathBuf};
33use std::process::{Child, Command, Stdio};
34
35use crate::{VideoFrame, VideoStream};
36
37pub struct GstreamerPipeStream {
39 child: Child,
40 width: u32,
41 height: u32,
42 frame_rate: f32,
43 frame_count: Option<u64>,
44 next_index: u64,
45 frame_buffer: Vec<u8>,
47}
48
49#[derive(Debug, thiserror::Error)]
51pub enum Error {
52 #[error(
57 "failed to spawn `{cmd}`: {source} (is `GStreamer` installed and on PATH? \
58 current PATH={path})"
59 )]
60 Spawn {
61 cmd: &'static str,
63 #[source]
65 source: std::io::Error,
66 path: String,
68 },
69 #[error("gst-discoverer output unparseable: {0}")]
71 DiscoverParse(String),
72 #[error("gst-discoverer failed for `{path}`: {stderr}")]
74 DiscoverFailed {
75 path: PathBuf,
77 stderr: String,
79 },
80 #[error("pipe read error: {0}")]
82 Io(#[from] std::io::Error),
83}
84
85pub type Result<T> = std::result::Result<T, Error>;
87
88#[derive(Debug, Clone)]
90pub struct VideoMetadata {
91 pub width: u32,
93 pub height: u32,
95 pub frame_rate: f32,
97 pub frame_count: Option<u64>,
101}
102
103#[must_use]
120pub fn gstreamer_available() -> bool {
121 Command::new("gst-launch-1.0")
122 .arg("--version")
123 .output()
124 .is_ok_and(|out| out.status.success())
125 && Command::new("gst-discoverer-1.0")
126 .arg("--version")
127 .output()
128 .is_ok_and(|out| out.status.success())
129}
130
131impl GstreamerPipeStream {
132 pub fn probe(path: &Path) -> Result<VideoMetadata> {
135 let uri = file_uri(path);
136 let output = Command::new("gst-discoverer-1.0")
137 .args(["-v", &uri])
138 .stdout(Stdio::piped())
139 .stderr(Stdio::piped())
140 .output()
141 .map_err(|source| Error::Spawn {
142 cmd: "gst-discoverer-1.0",
143 source,
144 path: std::env::var("PATH").unwrap_or_else(|_| "<unset>".into()),
145 })?;
146
147 if !output.status.success() {
148 return Err(Error::DiscoverFailed {
149 path: path.to_path_buf(),
150 stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
151 });
152 }
153 parse_discoverer(&String::from_utf8_lossy(&output.stdout))
154 }
155
156 pub fn open(path: &Path) -> Result<Self> {
159 let meta = Self::probe(path)?;
160 let frame_size = (meta.width as usize) * (meta.height as usize) * 4;
161
162 let location = path
166 .to_str()
167 .ok_or_else(|| Error::DiscoverParse(format!("non-UTF-8 path: {}", path.display())))?;
168 let pipeline = format!(
169 "filesrc location={location} ! decodebin ! videoconvert \
170 ! video/x-raw,format=BGRA ! fdsink fd=1 sync=false"
171 );
172
173 let child = Command::new("gst-launch-1.0")
174 .args(["-q", "--no-position"])
175 .args(pipeline.split_whitespace())
176 .stdout(Stdio::piped())
177 .stderr(Stdio::piped())
178 .spawn()
179 .map_err(|source| Error::Spawn {
180 cmd: "gst-launch-1.0",
181 source,
182 path: std::env::var("PATH").unwrap_or_else(|_| "<unset>".into()),
183 })?;
184
185 Ok(Self {
186 child,
187 width: meta.width,
188 height: meta.height,
189 frame_rate: meta.frame_rate,
190 frame_count: meta.frame_count,
191 next_index: 0,
192 frame_buffer: vec![0u8; frame_size],
193 })
194 }
195}
196
197impl VideoStream for GstreamerPipeStream {
198 fn width(&self) -> u32 {
199 self.width
200 }
201
202 fn height(&self) -> u32 {
203 self.height
204 }
205
206 fn frame_rate(&self) -> f32 {
207 self.frame_rate
208 }
209
210 fn frame_count_hint(&self) -> Option<u64> {
211 self.frame_count
212 }
213
214 fn next_frame(&mut self) -> Option<VideoFrame> {
215 let stdout = self.child.stdout.as_mut()?;
216 if let Err(err) = stdout.read_exact(&mut self.frame_buffer) {
217 if err.kind() != ErrorKind::UnexpectedEof {
218 tracing::warn!(?err, "gstreamer pipe read error");
219 }
220 return None;
221 }
222 let pts = f64::from(u32::try_from(self.next_index).unwrap_or(u32::MAX))
223 / f64::from(self.frame_rate);
224 let frame = VideoFrame {
225 width: self.width,
226 height: self.height,
227 bgra: self.frame_buffer.clone(),
228 pts_seconds: pts,
229 frame_index: self.next_index,
230 };
231 self.next_index += 1;
232 Some(frame)
233 }
234}
235
236impl Drop for GstreamerPipeStream {
237 fn drop(&mut self) {
238 let _ = self.child.kill();
239 let _ = self.child.wait();
240 }
241}
242
243fn file_uri(path: &Path) -> String {
244 if let Ok(canonical) = path.canonicalize() {
245 format!("file://{}", canonical.display())
246 } else {
247 format!("file://{}", path.display())
248 }
249}
250
251fn parse_discoverer(text: &str) -> Result<VideoMetadata> {
258 let mut width: Option<u32> = None;
259 let mut height: Option<u32> = None;
260 let mut frame_rate: Option<f32> = None;
261 let mut duration_seconds: Option<f64> = None;
262 let mut in_video = false;
263
264 for raw in text.lines() {
265 let line = raw.trim();
266
267 if let Some(rest) = line.strip_prefix("Duration:") {
269 duration_seconds = parse_clock(rest.trim());
270 }
271
272 if line.starts_with("video:") || line.starts_with("video #") {
275 in_video = true;
276 continue;
277 }
278 if in_video && (line.starts_with("audio:") || line.starts_with("subtitle:")) {
279 in_video = false;
280 }
281 if !in_video {
282 continue;
283 }
284
285 if let Some(rest) = line.strip_prefix("Width:") {
286 width = rest.trim().parse().ok();
287 } else if let Some(rest) = line.strip_prefix("Height:") {
288 height = rest.trim().parse().ok();
289 } else if let Some(rest) = line.strip_prefix("Frame rate:") {
290 frame_rate = parse_rational(rest.trim());
291 }
292 }
293
294 let width = width.ok_or_else(|| Error::DiscoverParse("missing Width".into()))?;
295 let height = height.ok_or_else(|| Error::DiscoverParse("missing Height".into()))?;
296 let frame_rate = frame_rate.ok_or_else(|| Error::DiscoverParse("missing Frame rate".into()))?;
297 #[allow(
298 clippy::cast_possible_truncation,
299 clippy::cast_sign_loss,
300 reason = "frame counts in practice fit in u64; rounding is acceptable for a hint"
301 )]
302 let frame_count = duration_seconds.map(|d| (d * f64::from(frame_rate)).round() as u64);
303
304 Ok(VideoMetadata {
305 width,
306 height,
307 frame_rate,
308 frame_count,
309 })
310}
311
312fn parse_rational(text: &str) -> Option<f32> {
314 let (num_s, den_s) = text.split_once('/')?;
315 let num: f32 = num_s.trim().parse().ok()?;
316 let den: f32 = den_s.trim().parse().ok()?;
317 if den == 0.0 {
318 return None;
319 }
320 Some(num / den)
321}
322
323fn parse_clock(text: &str) -> Option<f64> {
325 let mut parts = text.split(':');
326 let h: f64 = parts.next()?.trim().parse().ok()?;
327 let m: f64 = parts.next()?.trim().parse().ok()?;
328 let s: f64 = parts.next()?.trim().parse().ok()?;
329 Some(h * 3600.0 + m * 60.0 + s)
330}
331
332#[cfg(test)]
333mod tests {
334 use super::*;
335
336 #[test]
337 fn parses_typical_discoverer_output() {
338 let raw = "\
340Properties:
341 Duration: 0:00:00.266666666
342 Seekable: yes
343 container: Quicktime
344 video: H.264 (High Profile)
345 Width: 480
346 Height: 270
347 Frame rate: 30/1
348 Pixel aspect ratio: 1/1
349 audio: MPEG-4 AAC
350 Channels: 2
351";
352 let m = parse_discoverer(raw).expect("parse");
353 assert_eq!(m.width, 480);
354 assert_eq!(m.height, 270);
355 assert!((m.frame_rate - 30.0).abs() < 1e-6);
356 assert_eq!(m.frame_count, Some(8));
358 }
359
360 #[test]
361 fn parses_ntsc_rational_frame_rate() {
362 let raw = "video: H.264\n Width: 640\n Height: 480\n Frame rate: 30000/1001\n";
363 let m = parse_discoverer(raw).expect("parse");
364 assert!((m.frame_rate - 29.97).abs() < 0.01, "got {}", m.frame_rate);
365 }
366
367 #[test]
368 fn missing_dimensions_is_error() {
369 let raw = "video: foo\n Frame rate: 30/1\n";
370 assert!(parse_discoverer(raw).is_err());
371 }
372
373 #[test]
374 fn audio_block_does_not_pollute_video_metadata() {
375 let raw = "\
379audio: AAC
380 Width: 999
381 Height: 999
382video: H.264
383 Width: 320
384 Height: 240
385 Frame rate: 24/1
386";
387 let m = parse_discoverer(raw).expect("parse");
388 assert_eq!(m.width, 320);
389 assert_eq!(m.height, 240);
390 }
391
392 #[test]
393 fn rational_with_zero_denominator_is_none() {
394 assert!(parse_rational("30/0").is_none());
395 }
396
397 #[test]
398 fn clock_parses_hms() {
399 let v = parse_clock("0:00:00.266666666").unwrap();
400 assert!((v - 0.2667).abs() < 1e-3, "{v}");
401 let v2 = parse_clock("1:02:03.5").unwrap();
402 assert!((v2 - 3723.5).abs() < 1e-6);
403 }
404}