spark_model/
video_decode_ffmpeg.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! Frame extraction for real-world containers, via ffmpeg.
4//!
5//! # Why a subprocess and not a linked decoder
6//!
7//! The alternative evaluated was `openh264` plus a pure-Rust MP4 demuxer. It
8//! builds quickly (~9 s on aarch64) and needs no runtime dependency, but it
9//! covers **H.264 only** — no H.265, VP9 or AV1, which is a large share of
10//! what people actually send — and Cisco's royalty-free patent grant covers
11//! the binaries *Cisco* distributes, not a source build redistributed by a
12//! third party. That is a licensing question for the project to answer
13//! deliberately, not one to settle by adding a dependency.
14//!
15//! ffmpeg as a subprocess needs no build or link dependency, decodes
16//! everything, and can be swapped for an in-process decoder later without
17//! changing this module's signature. The cost is a runtime binary and a
18//! process spawn per request, so it is OPT-IN and the absence of the binary
19//! is reported by name rather than as a decode failure.
20//!
21//! # What is bounded
22//!
23//! Everything the caller controls, because the input is an untrusted byte
24//! blob from an HTTP request:
25//!
26//! - **No shell.** Arguments are passed as argv. Nothing is interpolated into
27//!   a command string, so no input can become a flag or a second command.
28//! - **No temp file.** The container goes in over stdin, so there is no path
29//!   to traverse, collide on, or leave behind.
30//! - **`-nostdin`, and stdin is the pipe** — ffmpeg cannot reach for a
31//!   terminal or block waiting on one.
32//! - **Frame count** capped with `-frames:v`, so a long clip cannot decode
33//!   forever.
34//! - **Output size** capped while reading, and the child is killed the moment
35//!   the cap is passed.
36//! - **Wall clock** capped by a watchdog that kills the child; a decoder that
37//!   hangs must not hold a request thread indefinitely.
38//! - **Protocol whitelist** is moot because the input is a pipe, but
39//!   `-f image2pipe` output and a `pipe:0` input mean ffmpeg is never asked
40//!   to open a URL — the SSRF path that `remote_image` guards for stills
41//!   simply does not exist here.
42
43use anyhow::{Context, Result, bail, ensure};
44use image::RgbImage;
45use std::io::{Read, Write};
46use std::process::{Command, Stdio};
47use std::sync::{Arc, Mutex};
48
49/// Operator policy for subprocess decoding.
50#[derive(Debug, Clone)]
51pub struct FfmpegPolicy {
52    pub enabled: bool,
53    /// Binary to run. A name is resolved on PATH; an absolute path is used
54    /// as given, so a deployment can pin a known build.
55    pub binary: String,
56    pub max_frames: usize,
57    pub max_output_bytes: usize,
58    pub timeout_secs: u64,
59}
60
61impl Default for FfmpegPolicy {
62    fn default() -> Self {
63        Self {
64            enabled: false,
65            binary: "ffmpeg".to_string(),
66            max_frames: 768,
67            // 768 frames of 1280x720 PNG is comfortably under this; it exists
68            // to bound a pathological stream, not to size a normal one.
69            max_output_bytes: 512 * 1024 * 1024,
70            timeout_secs: 120,
71        }
72    }
73}
74
75/// The 8-byte PNG signature. Frames arrive concatenated on one stream, so
76/// this is how they are separated.
77const PNG_MAGIC: [u8; 8] = [0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a];
78
79/// What a startup probe found.
80#[derive(Debug, Clone, PartialEq, Eq)]
81pub enum Availability {
82    /// Usable, with the version string it reported.
83    Ready(String),
84    /// Configured but not runnable, with why.
85    Missing(String),
86    /// Not asked for.
87    Disabled,
88}
89
90/// Check at BOOT whether the configured decoder can actually run.
91///
92/// Worth doing eagerly rather than discovering it on the first video request:
93/// a deployment that enabled video decoding and does not have the binary is
94/// misconfigured, and the operator should learn that while reading the
95/// startup log — not from a user's failed request an hour later. The check is
96/// one `-version` invocation, so it costs nothing at boot.
97pub fn probe(policy: &FfmpegPolicy) -> Availability {
98    if !policy.enabled {
99        return Availability::Disabled;
100    }
101    match Command::new(&policy.binary)
102        .arg("-version")
103        .stdin(Stdio::null())
104        .stdout(Stdio::piped())
105        .stderr(Stdio::null())
106        .output()
107    {
108        Ok(out) if out.status.success() => {
109            let first = String::from_utf8_lossy(&out.stdout)
110                .lines()
111                .next()
112                .unwrap_or("unknown version")
113                .trim()
114                .to_string();
115            Availability::Ready(first)
116        }
117        Ok(out) => {
118            Availability::Missing(format!("{:?} ran but exited {}", policy.binary, out.status))
119        }
120        Err(e) => Availability::Missing(format!("{:?} could not be run: {e}", policy.binary)),
121    }
122}
123
124/// Why the decoder could not be started, without guessing.
125///
126/// The message this replaced said "is ffmpeg installed and on PATH?" for EVERY
127/// spawn error. That is a diagnosis, and only one of the errno values it
128/// covered had ever been checked. On 2026-09-06 a CI job failed here against a
129/// binary the test had just written and chmod'd 0755 in `/tmp` — installed,
130/// present, executable — and the operator was told to install ffmpeg. The real
131/// errno never reached the log at all.
132///
133/// So: keep the hint where it is actually implied (`NotFound`), and otherwise
134/// say what the OS said. `PermissionDenied` means the mode or a `noexec` mount;
135/// `ExecutableFileBusy` (ETXTBSY) means something still holds a write handle to
136/// it, which on a busy multi-job runner is a race, not a misconfiguration.
137fn spawn_failure(binary: &str, err: std::io::Error) -> anyhow::Error {
138    let hint = match err.kind() {
139        std::io::ErrorKind::NotFound => {
140            " — is ffmpeg installed and on PATH? (set --video-ffmpeg-path to point at it)"
141        }
142        std::io::ErrorKind::PermissionDenied => {
143            " — not executable by this user, or its filesystem is mounted noexec"
144        }
145        std::io::ErrorKind::ExecutableFileBusy => {
146            " — another process still holds it open for writing (ETXTBSY)"
147        }
148        _ => "",
149    };
150    anyhow::anyhow!("could not run {binary:?}: {err}{hint}")
151}
152
153/// Decode `bytes` to RGB frames sampled at `target_fps`.
154///
155/// ffmpeg performs the temporal sampling itself (`-vf fps=`), which is both
156/// faster and more accurate than decoding everything and discarding most of
157/// it — and it means the caller does not have to know the source frame rate.
158/// The returned frames are therefore ALREADY at `target_fps`.
159pub fn decode_frames(
160    bytes: &[u8],
161    target_fps: f32,
162    policy: &FfmpegPolicy,
163) -> Result<Vec<RgbImage>> {
164    ensure!(
165        policy.enabled,
166        "this container needs ffmpeg to decode and subprocess decoding is disabled; \
167         pass --video-allow-ffmpeg to enable it, or send an animated GIF"
168    );
169    let fps = if target_fps.is_finite() && target_fps > 0.0 {
170        target_fps
171    } else {
172        2.0
173    };
174
175    let mut child = Command::new(&policy.binary)
176        .args([
177            "-v",
178            "error",
179            // Never touch a terminal: without this ffmpeg can block on a
180            // prompt when it thinks stdin is interactive.
181            "-nostdin",
182            "-i",
183            "pipe:0",
184            "-vf",
185            &format!("fps={fps}"),
186            "-frames:v",
187            &policy.max_frames.to_string(),
188            "-f",
189            "image2pipe",
190            "-vcodec",
191            "png",
192            "pipe:1",
193        ])
194        .stdin(Stdio::piped())
195        .stdout(Stdio::piped())
196        .stderr(Stdio::piped())
197        .spawn()
198        .map_err(|err| spawn_failure(&policy.binary, err))?;
199
200    let mut stdin = child.stdin.take().context("no stdin pipe")?;
201    let mut stdout = child.stdout.take().context("no stdout pipe")?;
202    let mut stderr = child.stderr.take().context("no stderr pipe")?;
203
204    // Feed the container on a thread. It MUST be concurrent with reading:
205    // ffmpeg writes output while still consuming input, so a write-then-read
206    // sequence deadlocks as soon as the output pipe buffer fills.
207    let input = bytes.to_vec();
208    let writer = std::thread::spawn(move || {
209        // A broken pipe here is normal — ffmpeg stops reading once it has the
210        // frames it was asked for — so the error is deliberately dropped.
211        let _ = stdin.write_all(&input);
212        drop(stdin);
213    });
214
215    let child = Arc::new(Mutex::new(child));
216    let watchdog = {
217        let child = Arc::clone(&child);
218        let secs = policy.timeout_secs.max(1);
219        std::thread::spawn(move || {
220            let deadline = std::time::Instant::now() + std::time::Duration::from_secs(secs);
221            loop {
222                std::thread::sleep(std::time::Duration::from_millis(100));
223                let mut guard = match child.lock() {
224                    Ok(g) => g,
225                    Err(_) => return false,
226                };
227                match guard.try_wait() {
228                    Ok(Some(_)) => return false, // exited on its own
229                    Ok(None) => {}
230                    Err(_) => return false,
231                }
232                if std::time::Instant::now() >= deadline {
233                    let _ = guard.kill();
234                    return true; // we killed it
235                }
236            }
237        })
238    };
239
240    // Read with a hard cap. `take` bounds it without trusting the child.
241    let mut out = Vec::new();
242    let read_res = (&mut stdout)
243        .take(policy.max_output_bytes as u64 + 1)
244        .read_to_end(&mut out);
245
246    // KILL FIRST, then drain stderr. Order matters and getting it wrong
247    // deadlocks: once the cap is hit we stop reading stdout, so the child
248    // blocks writing into a full pipe and never closes stderr — and a
249    // `read_to_string` on stderr then waits for an EOF that only the watchdog
250    // will ever cause. Killing here turns a 120-second hang into an immediate
251    // error. (Draining stdout instead would defeat the cap, which is the
252    // thing being enforced.)
253    let over_cap = out.len() > policy.max_output_bytes;
254    if over_cap && let Ok(mut g) = child.lock() {
255        let _ = g.kill();
256    }
257
258    let mut err_text = String::new();
259    let _ = stderr.read_to_string(&mut err_text);
260    let _ = writer.join();
261
262    let status = {
263        let mut g = child
264            .lock()
265            .map_err(|_| anyhow::anyhow!("decoder lock poisoned"))?;
266        g.wait().context("waiting for the decoder")?
267    };
268    let timed_out = watchdog.join().unwrap_or(false);
269
270    read_res.context("reading decoded frames")?;
271    ensure!(
272        !timed_out,
273        "decoding exceeded {}s and was stopped",
274        policy.timeout_secs
275    );
276    ensure!(
277        !over_cap,
278        "decoded output exceeded the {}-byte cap",
279        policy.max_output_bytes
280    );
281    if !status.success() {
282        let why = err_text.lines().next_back().unwrap_or("no detail").trim();
283        bail!("decoder failed: {why}");
284    }
285
286    let frames = split_png_stream(&out)?;
287    ensure!(
288        !frames.is_empty(),
289        "the container decoded to zero frames (is there a video stream?)"
290    );
291    Ok(frames)
292}
293
294/// Split a concatenated PNG stream into images.
295///
296/// Splitting on the signature rather than parsing IEND chunks: a PNG's
297/// payload can legitimately contain the IEND byte pattern, whereas the
298/// 8-byte signature only appears at a file start in a well-formed stream
299/// produced by `image2pipe`.
300fn split_png_stream(buf: &[u8]) -> Result<Vec<RgbImage>> {
301    let mut starts = Vec::new();
302    let mut i = 0usize;
303    while i + PNG_MAGIC.len() <= buf.len() {
304        if buf[i..i + PNG_MAGIC.len()] == PNG_MAGIC {
305            starts.push(i);
306            i += PNG_MAGIC.len();
307        } else {
308            i += 1;
309        }
310    }
311    let mut frames = Vec::with_capacity(starts.len());
312    for (n, &s) in starts.iter().enumerate() {
313        let e = starts.get(n + 1).copied().unwrap_or(buf.len());
314        let img = image::load_from_memory_with_format(&buf[s..e], image::ImageFormat::Png)
315            .with_context(|| format!("frame {n} did not decode as PNG"))?;
316        frames.push(img.to_rgb8());
317    }
318    Ok(frames)
319}
320
321#[cfg(test)]
322#[path = "video_decode_ffmpeg_tests.rs"]
323mod tests;