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;