metrale_model_layers/
video_decode_ffmpeg.rs1use anyhow::{Context, Result, bail, ensure};
19use image::RgbImage;
20use std::io::{Read, Write};
21use std::process::{Command, Stdio};
22use std::sync::{Arc, Mutex};
23
24#[derive(Debug, Clone)]
27pub struct FfmpegPolicy {
28 pub enabled: bool,
29 pub binary: String,
32 pub max_frames: usize,
33 pub max_output_bytes: usize,
34 pub timeout_secs: u64,
35}
36
37impl Default for FfmpegPolicy {
38 fn default() -> Self {
39 Self {
40 enabled: false,
41 binary: "ffmpeg".to_string(),
42 max_frames: 768,
43 max_output_bytes: 512 * 1024 * 1024,
46 timeout_secs: 120,
47 }
48 }
49}
50
51const PNG_MAGIC: [u8; 8] = [0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a];
54
55#[derive(Debug, Clone, PartialEq, Eq)]
57pub enum Availability {
58 Ready(String),
60 Missing(String),
62 Disabled,
64}
65
66pub fn probe(policy: &FfmpegPolicy) -> Availability {
70 if !policy.enabled {
71 return Availability::Disabled;
72 }
73 match Command::new(&policy.binary)
74 .arg("-version")
75 .stdin(Stdio::null())
76 .stdout(Stdio::piped())
77 .stderr(Stdio::null())
78 .output()
79 {
80 Ok(out) if out.status.success() => {
81 let first = String::from_utf8_lossy(&out.stdout)
82 .lines()
83 .next()
84 .unwrap_or("unknown version")
85 .trim()
86 .to_string();
87 Availability::Ready(first)
88 }
89 Ok(out) => {
90 Availability::Missing(format!("{:?} ran but exited {}", policy.binary, out.status))
91 }
92 Err(e) => Availability::Missing(format!("{:?} could not be run: {e}", policy.binary)),
93 }
94}
95
96fn spawn_failure(binary: &str, err: std::io::Error) -> anyhow::Error {
101 let hint = match err.kind() {
102 std::io::ErrorKind::NotFound => {
103 " — is ffmpeg installed and on PATH? (set --video-ffmpeg-path to point at it)"
104 }
105 std::io::ErrorKind::PermissionDenied => {
106 " — not executable by this user, or its filesystem is mounted noexec"
107 }
108 std::io::ErrorKind::ExecutableFileBusy => {
109 " — another process still holds it open for writing (ETXTBSY)"
110 }
111 _ => "",
112 };
113 anyhow::anyhow!("could not run {binary:?}: {err}{hint}")
114}
115
116pub fn decode_frames(
122 bytes: &[u8],
123 target_fps: f32,
124 policy: &FfmpegPolicy,
125) -> Result<Vec<RgbImage>> {
126 ensure!(
127 policy.enabled,
128 "this container needs ffmpeg to decode and subprocess decoding is disabled; \
129 pass --video-allow-ffmpeg to enable it, or send an animated GIF"
130 );
131 let fps = if target_fps.is_finite() && target_fps > 0.0 {
132 target_fps
133 } else {
134 2.0
135 };
136
137 let mut child = Command::new(&policy.binary)
138 .args([
139 "-v",
140 "error",
141 "-nostdin",
144 "-i",
145 "pipe:0",
146 "-vf",
147 &format!("fps={fps}"),
148 "-frames:v",
149 &policy.max_frames.to_string(),
150 "-f",
151 "image2pipe",
152 "-vcodec",
153 "png",
154 "pipe:1",
155 ])
156 .stdin(Stdio::piped())
157 .stdout(Stdio::piped())
158 .stderr(Stdio::piped())
159 .spawn()
160 .map_err(|err| spawn_failure(&policy.binary, err))?;
161
162 let mut stdin = child.stdin.take().context("no stdin pipe")?;
163 let mut stdout = child.stdout.take().context("no stdout pipe")?;
164 let mut stderr = child.stderr.take().context("no stderr pipe")?;
165
166 let input = bytes.to_vec();
170 let writer = std::thread::spawn(move || {
171 let _ = stdin.write_all(&input);
174 drop(stdin);
175 });
176
177 let child = Arc::new(Mutex::new(child));
178 let watchdog = {
179 let child = Arc::clone(&child);
180 let secs = policy.timeout_secs.max(1);
181 std::thread::spawn(move || {
182 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(secs);
183 loop {
184 std::thread::sleep(std::time::Duration::from_millis(100));
185 let mut guard = match child.lock() {
186 Ok(g) => g,
187 Err(_) => return false,
188 };
189 match guard.try_wait() {
190 Ok(Some(_)) => return false,
191 Ok(None) => {}
192 Err(_) => return false,
193 }
194 if std::time::Instant::now() >= deadline {
195 let _ = guard.kill();
196 return true;
197 }
198 }
199 })
200 };
201
202 let mut out = Vec::new();
205 let read_res = (&mut stdout)
206 .take(policy.max_output_bytes as u64 + 1)
207 .read_to_end(&mut out);
208
209 let over_cap = out.len() > policy.max_output_bytes;
213 if over_cap && let Ok(mut g) = child.lock() {
214 let _ = g.kill();
215 }
216
217 let mut err_text = String::new();
218 let _ = stderr.read_to_string(&mut err_text);
219 let _ = writer.join();
220
221 let status = {
222 let mut g = child
223 .lock()
224 .map_err(|_| anyhow::anyhow!("decoder lock poisoned"))?;
225 g.wait().context("waiting for the decoder")?
226 };
227 let timed_out = watchdog.join().unwrap_or(false);
228
229 read_res.context("reading decoded frames")?;
230 ensure!(
231 !timed_out,
232 "decoding exceeded {}s and was stopped",
233 policy.timeout_secs
234 );
235 ensure!(
236 !over_cap,
237 "decoded output exceeded the {}-byte cap",
238 policy.max_output_bytes
239 );
240 if !status.success() {
241 let why = err_text.lines().next_back().unwrap_or("no detail").trim();
242 bail!("decoder failed: {why}");
243 }
244
245 let frames = split_png_stream(&out)?;
246 ensure!(
247 !frames.is_empty(),
248 "the container decoded to zero frames (is there a video stream?)"
249 );
250 Ok(frames)
251}
252
253fn split_png_stream(buf: &[u8]) -> Result<Vec<RgbImage>> {
257 let mut starts = Vec::new();
258 let mut i = 0usize;
259 while i + PNG_MAGIC.len() <= buf.len() {
260 if buf[i..i + PNG_MAGIC.len()] == PNG_MAGIC {
261 starts.push(i);
262 i += PNG_MAGIC.len();
263 } else {
264 i += 1;
265 }
266 }
267 let mut frames = Vec::with_capacity(starts.len());
268 for (n, &s) in starts.iter().enumerate() {
269 let e = starts.get(n + 1).copied().unwrap_or(buf.len());
270 let img = image::load_from_memory_with_format(&buf[s..e], image::ImageFormat::Png)
271 .with_context(|| format!("frame {n} did not decode as PNG"))?;
272 frames.push(img.to_rgb8());
273 }
274 Ok(frames)
275}
276
277#[cfg(test)]
278#[path = "video_decode_ffmpeg_tests.rs"]
279mod tests;