Skip to main content

decode/
gstreamer_pipe.rs

1//! Real video decode via the `gst-launch-1.0` and `gst-discoverer-1.0` CLIs.
2//!
3//! Mirrors the spirit of [`crate::mock::MockVideoStream`] but reads bytes
4//! from a real `GStreamer` pipeline:
5//!
6//! ```text
7//! filesrc location=<path>
8//!   ! decodebin
9//!   ! videoconvert
10//!   ! video/x-raw,format=BGRA
11//!   ! fdsink fd=1
12//! ```
13//!
14//! # Trade-offs
15//!
16//! - **+** Zero compile-time integration with libgstreamer; works against
17//!   any system `GStreamer` the user installs (`brew install gstreamer`,
18//!   `apt install gstreamer1.0-tools`, …). No FFI surface.
19//! - **+** `GStreamer` is LGPL — friendlier than `FFmpeg`'s GPL-or-LGPL split,
20//!   no licensing entanglement for our binary either way.
21//! - **+** Hardware decode picks up automatically when `GStreamer`'s
22//!   `vah264dec` / `vtdec` / `nvh264dec` plugins are present.
23//! - **−** Per-process fork; for the player loop that's one fork for the
24//!   whole stream, not per-frame.
25//! - **−** Assumes `gst-launch-1.0` and `gst-discoverer-1.0` on `PATH`.
26//!   Reported as [`Error::Spawn`] rather than a panic.
27//!
28//! For a proper Rust-bound integration via `gstreamer-rs` see M-DEC.3+;
29//! the [`crate::VideoStream`] trait makes it a swap-in replacement.
30
31use std::io::{ErrorKind, Read};
32use std::path::{Path, PathBuf};
33use std::process::{Child, Command, Stdio};
34
35use crate::{VideoFrame, VideoStream};
36
37/// Streams BGRA frames from a video file by piping it through `gst-launch-1.0`.
38pub 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    /// Pre-sized buffer — `width * height * 4` bytes, reused per frame.
46    frame_buffer: Vec<u8>,
47}
48
49/// Failure modes for the gstreamer-pipe decoder.
50#[derive(Debug, thiserror::Error)]
51pub enum Error {
52    /// `gst-launch-1.0` or `gst-discoverer-1.0` could not be launched.
53    /// Almost always means the `GStreamer` CLI tools aren't on `PATH`.
54    /// The error message includes the current `PATH` value so CI logs
55    /// surface the exact lookup state for diagnosis.
56    #[error(
57        "failed to spawn `{cmd}`: {source} (is `GStreamer` installed and on PATH? \
58         current PATH={path})"
59    )]
60    Spawn {
61        /// The command we tried to spawn.
62        cmd: &'static str,
63        /// The OS-level reason the spawn failed.
64        #[source]
65        source: std::io::Error,
66        /// Snapshot of `PATH` at the moment of failure.
67        path: String,
68    },
69    /// `gst-discoverer-1.0` returned non-parseable output.
70    #[error("gst-discoverer output unparseable: {0}")]
71    DiscoverParse(String),
72    /// `gst-discoverer-1.0` returned an error exit code.
73    #[error("gst-discoverer failed for `{path}`: {stderr}")]
74    DiscoverFailed {
75        /// The video path we asked about.
76        path: PathBuf,
77        /// Captured stderr.
78        stderr: String,
79    },
80    /// I/O error while reading the pipe.
81    #[error("pipe read error: {0}")]
82    Io(#[from] std::io::Error),
83}
84
85/// Convenience alias.
86pub type Result<T> = std::result::Result<T, Error>;
87
88/// Per-stream metadata, as reported by `gst-discoverer-1.0`.
89#[derive(Debug, Clone)]
90pub struct VideoMetadata {
91    /// Frame width in pixels.
92    pub width: u32,
93    /// Frame height in pixels.
94    pub height: u32,
95    /// Reported frame rate (fps).
96    pub frame_rate: f32,
97    /// Total frame count if reported. Most container formats don't carry
98    /// this directly; we derive it from `duration × frame_rate` when both
99    /// are available.
100    pub frame_count: Option<u64>,
101}
102
103/// Whether both `gst-launch-1.0` and `gst-discoverer-1.0` are on `PATH`
104/// and runnable. Useful as a runtime guard for tests + production code
105/// that wants to gracefully degrade when `GStreamer` isn't installed.
106///
107/// Empirically: on GitHub Actions Ubuntu runners, the
108/// `gstreamer1.0-tools` apt package installs successfully BUT the
109/// resulting binaries are sometimes not findable from later cargo
110/// nextest test processes (root cause: TBD). Tests that depend on
111/// these binaries should call this and skip if it returns `false`,
112/// matching the pattern in `crates/decode/tests/gstreamer_integration.rs`.
113/// Note: prefer `media::gstreamer::is_available` (M-MEDIA.1) for new
114/// code — it returns a structured `media::gstreamer::GStreamerProbe`
115/// with `PATH` snapshot + per-binary version + per-plugin presence, which
116/// is much easier to debug in CI than a bare `bool`. This helper is kept
117/// for backwards compatibility with existing decode / preview / app
118/// integration tests; cutover happens lazily as those tests are touched.
119#[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    /// Probe `path` with `gst-discoverer-1.0` and return the metadata,
133    /// without starting a decode.
134    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    /// Open `path` for streaming decode. Spawns `gst-launch-1.0`
157    /// immediately; frames are pulled via [`VideoStream::next_frame`].
158    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        // Build the `GStreamer` pipeline. `decodebin` handles container demux
163        // and codec detection automatically; `videoconvert` ensures we end
164        // up at BGRA regardless of the source colour format.
165        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
251/// Parse `gst-discoverer-1.0 -v` output for the first video stream.
252///
253/// The tool prints a tree-shaped report; we only care about a few lines.
254/// Robust enough for typical MP4/MOV/MKV inputs; truly weird containers
255/// can be hand-probed and the metadata fed in via a future explicit
256/// constructor.
257fn 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        // Container-level duration applies to the whole file.
268        if let Some(rest) = line.strip_prefix("Duration:") {
269            duration_seconds = parse_clock(rest.trim());
270        }
271
272        // The discoverer prints `video: ...` headers per video stream;
273        // we lock onto the first and capture its width/height/fps.
274        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
312/// Parses `gst-discoverer-1.0` rationals like `30/1` or `30000/1001`.
313fn 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
323/// Parses `gst-discoverer-1.0` `Duration:` clocks like `0:00:00.266666666`.
324fn 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        // Trimmed reproduction of `gst-discoverer-1.0 -v` output.
339        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        // Duration × fps = 0.2667 × 30 = 8.0 → 8 frames.
357        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        // If we only saw audio dimensions, we should NOT surface them as
376        // video dims. (None of the audio lines we generate match Width/Height
377        // anyway, but the boundary detection matters.)
378        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}