mediadecode_ffmpeg/decoder.rs
1use std::{collections::VecDeque, mem::ManuallyDrop, ptr};
2
3use ffmpeg_next::{
4 Codec, Packet, Rational,
5 codec::{
6 self,
7 Context,
8 // Bring the `Mut` / `Ref` traits into scope so `Packet::as_ptr` /
9 // `Packet::as_mut_ptr` resolve. They are aliased to avoid shadowing
10 // any future `Mut`/`Ref` types we might add — `cargo clippy` would
11 // otherwise flag them as "unused" without the alias and the import
12 // can mistakenly look unused. Confirmed in use by all `packet.as_ptr()`
13 // / `packet.as_mut_ptr()` call sites in this module.
14 packet::{Mut as PacketMut, Ref as PacketRef},
15 },
16 ffi::{
17 AVBufferRef, AVCodec, AVFrame, AVHWFramesContext, AVMediaType, av_buffer_ref, av_buffer_unref,
18 av_frame_move_ref, av_frame_unref, av_hwdevice_ctx_create, av_hwframe_transfer_data,
19 av_packet_ref, avcodec_alloc_context3, avcodec_free_context, avcodec_parameters_alloc,
20 avcodec_parameters_copy, avcodec_parameters_free, avcodec_parameters_to_context,
21 },
22 frame,
23};
24
25/// Local FFI shim: `avcodec_find_decoder` declared with `c_int` instead of
26/// the bindgen `AVCodecID` enum. Constructing `AVCodecID` from a runtime
27/// integer that isn't in our build's discriminant set is UB; calling the
28/// C function with a raw int avoids that boundary entirely. Both Rust
29/// declarations resolve to the same C symbol at link time.
30mod c_shims {
31 use super::AVCodec;
32 use libc::c_int;
33 unsafe extern "C" {
34 pub fn avcodec_find_decoder(id: c_int) -> *const AVCodec;
35 }
36}
37
38use crate::{
39 backend::{self, Backend},
40 error::{AllBackendsFailed, Error, HwDeviceInitFailed, Result},
41 ffi::{CallbackState, codec_supports_hwaccel, get_hw_format},
42 frame::Frame,
43};
44
45/// Hardware-accelerated video decoder.
46///
47/// Hardware-only — there is no software fallback inside this crate. If
48/// every hardware backend in the platform's probe order fails to open,
49/// `open` returns [`Error::AllBackendsFailed`] and the caller is
50/// responsible for falling back to a software decoder of their choice
51/// (e.g. `ffmpeg::decoder::Video`).
52///
53/// Mirrors `ffmpeg::decoder::Video`'s `send_packet`/`receive_frame` interface.
54/// Decoded frames are returned through [`crate::Frame`], a CPU-side wrapper
55/// whose accessors avoid the `AVPixelFormat`-enum UB that an unvalidated read
56/// of FFmpeg's raw integer pixel formats can trigger.
57///
58/// `open` does a true probe: each backend opens with a strict `get_format`
59/// callback. On the first non-transient error from a backend the decoder is
60/// torn down and the next backend in probe order is tried, with all packets
61/// seen so far replayed through it. The advance is *transactional* — the
62/// candidate backend must successfully build and accept the replayed packets
63/// before any probe state is consumed, so a failing backend in the middle of
64/// the order does not strand the caller without history. Once the first frame
65/// is delivered the probe collapses and subsequent calls go straight to the
66/// active backend.
67pub struct VideoDecoder {
68 /// Live FFmpeg state for the currently active backend.
69 state: DecoderState,
70 /// Reusable frame buffer used for hw-side decoding before transfer / move.
71 /// Internal use only — never handed to callers.
72 hw_frame: frame::Video,
73 /// Probe state: present until the first frame is received from the active
74 /// backend, then `None`. While `Some`, packets are buffered for replay and
75 /// non-transient errors / decoder failures advance to the next backend.
76 probe: Option<ProbeState>,
77 /// CPU-side frames produced by a candidate decoder during probe replay
78 /// (when its internal queue filled and we had to drain output before the
79 /// next `send_packet`). Already transferred from the candidate's
80 /// `AVHWFramesContext` to a CPU frame, so they remain valid after the
81 /// candidate state is committed. [`Self::receive_frame`] dequeues these
82 /// FIFO before reading from `state.inner`.
83 pending_frames: VecDeque<frame::Video>,
84 /// Per-decoder byte budget for [`Self::pending_frames`] during probe
85 /// replay. Defaults to [`DEFAULT_MAX_PROBE_PENDING_BYTES`]; override via
86 /// [`Self::with_max_probe_pending_bytes`].
87 max_probe_pending_bytes: usize,
88}
89
90/// Owned FFmpeg state for one open codec context. Has its own `Drop` so we
91/// can swap it out cleanly during a probe advance via `mem::replace`.
92struct DecoderState {
93 /// Wrapped FFmpeg decoder. `ManuallyDrop` so we can sequence its drop
94 /// before freeing the callback state.
95 inner: ManuallyDrop<ffmpeg_next::decoder::Video>,
96 /// Backend driving this state.
97 backend: Backend,
98 /// Owned reference produced by `av_hwdevice_ctx_create`.
99 hw_device_ref: *mut AVBufferRef,
100 /// Owned `Box<CallbackState>` raw pointer; `AVCodecContext::opaque`
101 /// aliases it.
102 callback_state: *mut CallbackState,
103}
104
105/// Maximum number of packets we are willing to buffer for probe replay
106/// before abandoning the fallback safety net. Set high enough to absorb
107/// long B-frame GOPs and codec setup latency, low enough to bound memory
108/// against malicious / pathological streams that never produce a first
109/// frame.
110const MAX_PROBE_PACKETS: usize = 256;
111
112/// Maximum total compressed-byte size of buffered probe packets. Each
113/// `Packet` clone holds a refcounted reference to the demuxer's bitstream
114/// data — even though the clone itself is shallow, the underlying buffers
115/// stay alive until we drop them. 64 MiB is generous for normal video and
116/// gives untrusted media a hard ceiling.
117const MAX_PROBE_PACKET_BYTES: usize = 64 * 1024 * 1024;
118
119/// Hard cap on the number of side-data entries we tolerate per buffered
120/// packet. `av_packet_ref` allocates an `AVPacketSideData` descriptor and
121/// an `AVBufferRef` per entry, so a packet stuffed with many tiny or
122/// zero-sized entries can consume significant memory in descriptor /
123/// allocator overhead even after [`packet_side_data_bytes`] charges
124/// [`SIDE_DATA_ENTRY_OVERHEAD`] bytes per entry. Refusing to clone such
125/// packets short-circuits the descriptor explosion path.
126///
127/// Sized for legitimate streams (typical video packets carry 0-5 side-
128/// data entries; SEI-heavy HEVC/AV1 maybe a dozen) while comfortably
129/// rejecting weaponised input.
130const MAX_PROBE_PACKET_SIDE_DATA_ENTRIES: usize = 64;
131
132/// Conservative per-side-data-entry overhead estimate used by both
133/// [`packet_side_data_bytes`] and the budget accounting in
134/// [`VideoDecoder::send_packet`]. Counts the `AVPacketSideData`
135/// descriptor (24 bytes per the FFmpeg 8.x bindings), the `AVBufferRef`
136/// FFmpeg allocates per entry, and a margin for malloc bookkeeping
137/// (header bytes, alignment slack). Setting it on the high side keeps
138/// the byte cap a true upper bound on retained memory; under-charging
139/// would let many tiny entries slip past the cap.
140const SIDE_DATA_ENTRY_OVERHEAD: usize = 80;
141
142/// Conservative upper-bound bytes-per-pixel multiplier used to estimate
143/// the size of a CPU frame **before** `av_hwframe_transfer_data`
144/// allocates its pixel buffers. Covers every HW download format this
145/// crate produces (worst case is `P416LE` / `P412LE` at 6 bytes/pixel
146/// for 16-bit 4:4:4 semi-planar) plus a margin for FFmpeg's per-row
147/// stride alignment (typically 32-byte aligned, ~5% extra at HD widths
148/// and below).
149///
150/// Used by [`drain_into_pending`] as a pre-transfer guard: if the
151/// product `width * height * WORST_CASE_BYTES_PER_PIXEL` would already
152/// push `pending_bytes` past `max_probe_pending_bytes`, the candidate
153/// replay refuses the frame *before* allocating. Without this, FFmpeg
154/// would perform the full HW→CPU download (potentially ~100 MiB for
155/// 8K HDR) and we would only reject the frame after RSS had already
156/// spiked. The post-transfer accounting via [`cpu_frame_bytes`] stays in
157/// place as a backstop using the frame's actual stride/format.
158///
159/// Slightly over-charges true 4:2:0 NV12 / P010 frames (which dominate
160/// real workloads) — that's the right side to err on. Callers feeding
161/// 8K+ workloads through the probe path can tune
162/// [`VideoDecoder::with_max_probe_pending_bytes`] upward to compensate.
163const WORST_CASE_BYTES_PER_PIXEL: usize = 8;
164
165/// Maximum number of CPU frames we are willing to queue from a candidate
166/// during probe replay. Each frame is a fully-allocated CPU buffer
167/// (~3 MiB for 1080p NV12, ~24 MiB for 4K P010, ~96 MiB for 8K P010), so
168/// an unbounded queue would OOM on a candidate with a shallow internal
169/// queue against a deep replay history. This cap, together with
170/// [`DEFAULT_MAX_PROBE_PENDING_BYTES`], is enforced as a hard limit during
171/// replay: once either limit is reached, probe buffering fails for the
172/// candidate (returns `ENOMEM` from `drain_into_pending`) instead of
173/// queueing additional drained frames. The probe loop then advances to
174/// the next backend or returns `Error::AllBackendsFailed` if exhausted.
175const MAX_PROBE_PENDING_FRAMES: usize = 16;
176
177/// Default byte budget for probe-replay drained frames. 256 MiB is enough
178/// for 16 frames at 4K P010 (~24 MiB each = 384 MiB worst case under the
179/// count cap), and is the cap that fires first for very high-resolution
180/// content (8K P010: ~96 MiB per frame → only ~2 frames fit).
181///
182/// Override per-decoder with [`VideoDecoder::with_max_probe_pending_bytes`]
183/// when targeting 8K+ workloads or memory-constrained environments.
184///
185/// TODO: when frames significantly exceed typical sizes, consider
186/// memmap-backed pending buffers (write transferred frames to a temp file
187/// or shared-memory segment) so the resident set stays bounded even when
188/// the byte cap is raised. Out of scope for now.
189pub const DEFAULT_MAX_PROBE_PENDING_BYTES: usize = 256 * 1024 * 1024;
190
191/// State carried only during the probe window (before the first successful
192/// frame). Holds enough information to tear down the current decoder and
193/// retry with the next backend.
194struct ProbeState {
195 parameters: codec::Parameters,
196 codec: Codec,
197 /// Backends still to try, in order. Empty means "no more options after
198 /// the active one fails" — `advance_probe` then surfaces
199 /// [`Error::AllBackendsFailed`] so the contract is the same on
200 /// single-backend platforms (e.g. macOS) as on multi-backend ones.
201 remaining_backends: Vec<Backend>,
202 /// Packets sent so far, kept for replay through any candidate backend.
203 /// Preserved across failed candidates — only cleared when the probe
204 /// collapses on a successful first frame, or when the probe is
205 /// abandoned due to the size caps.
206 buffered_packets: Vec<Packet>,
207 /// Cumulative size (in compressed bytes) of `buffered_packets`. Tracked
208 /// incrementally so we don't have to re-sum on every send.
209 buffered_bytes: usize,
210 /// Whether `send_eof` has been called; replayed alongside packets.
211 eof_sent: bool,
212 /// Per-backend errors captured since the probe window opened. Pushed
213 /// whenever a backend's failure triggers `advance_probe` (the active
214 /// backend that just failed) or a candidate's build / replay rejects
215 /// it. Drained into [`Error::AllBackendsFailed`] when the probe
216 /// exhausts every option.
217 attempts: Vec<(Backend, Box<Error>)>,
218}
219
220// SAFETY: All raw pointers are exclusively owned by `DecoderState` and never
221// shared. `ffmpeg::decoder::Video` is itself `Send` (its `Context` carries an
222// `unsafe impl Send`). The decoder is not safe for concurrent use, hence not
223// `Sync`.
224unsafe impl Send for DecoderState {}
225unsafe impl Send for VideoDecoder {}
226
227impl Drop for DecoderState {
228 fn drop(&mut self) {
229 // Order matters:
230 // 1. Drop the codec context first. While it lives, FFmpeg may invoke
231 // `get_format`, which dereferences `callback_state` via `opaque`.
232 // 2. Free the callback state heap allocation.
233 // 3. Release our hw device reference (FFmpeg released its own when
234 // the codec context was freed in step 1).
235 unsafe {
236 ManuallyDrop::drop(&mut self.inner);
237 if !self.callback_state.is_null() {
238 drop(Box::from_raw(self.callback_state));
239 self.callback_state = ptr::null_mut();
240 }
241 if !self.hw_device_ref.is_null() {
242 av_buffer_unref(&mut self.hw_device_ref);
243 }
244 }
245 }
246}
247
248impl VideoDecoder {
249 /// Auto-probe hardware backends in the platform's default order.
250 ///
251 /// Each backend opens with a strict `get_format` callback. The first
252 /// backend whose `avcodec_open2` succeeds becomes active; if its first
253 /// frame is unusable (decode error, transfer failure, or a CPU-format
254 /// frame from a HW context) the decoder is torn down and the next backend
255 /// is tried — packets sent so far are replayed through the new decoder
256 /// transparently. The probe advance is transactional: the next backend
257 /// must build *and* accept the replayed history before any probe state is
258 /// consumed, so a misbehaving middle backend cannot strand the caller.
259 ///
260 /// [`Self::backend`] reflects whichever backend ultimately produced the
261 /// first frame.
262 ///
263 /// [`Error::AllBackendsFailed`] surfaces in two places, with the same
264 /// meaning ("no hardware backend can decode this stream — fall back to
265 /// software yourself"):
266 /// - From `open` itself, when no backend even opens.
267 /// - From [`Self::send_packet`] / [`Self::send_eof`] /
268 /// [`Self::receive_frame`], when the initially-opened backend fails
269 /// at decode time and every remaining backend in the probe order
270 /// either also fails or doesn't exist. On single-backend platforms
271 /// (e.g. macOS, where the order is `[VideoToolbox]`), this is the
272 /// only place a HW-only failure surfaces.
273 ///
274 /// In both cases, `attempts` carries the per-backend error log. When
275 /// the runtime path fires, `unconsumed_packets` also contains the
276 /// packets the decoder consumed from the caller before the probe
277 /// exhausted (refcounted shallow clones); for non-seekable inputs
278 /// (live streams, pipes) the caller can replay these directly into
279 /// a software decoder of their choice without re-demuxing. From the
280 /// open-time path the vec is empty since no packets have been sent.
281 ///
282 /// On `Ok`, the returned decoder **always** has an active probe
283 /// rescue safety net. If a parameters clone fails under memory
284 /// pressure before the probe state can be set up, `open` returns
285 /// `Err(Error::Ffmpeg(Other { errno: ENOMEM }))` rather than handing
286 /// back a live decoder with no fallback contract. No packets have
287 /// been sent yet, so the caller can retry or fall back to software
288 /// with the original `parameters` directly.
289 pub fn open(parameters: codec::Parameters) -> Result<Self> {
290 let codec = find_decoder(¶meters)?;
291 let order = backend::probe_order();
292
293 let mut attempts: Vec<(Backend, Box<Error>)> = Vec::new();
294 for (i, &backend) in order.iter().enumerate() {
295 // Use the checked clone — ffmpeg-next's `Parameters::clone` does
296 // `avcodec_parameters_alloc` without a null check and ignores the
297 // return of `avcodec_parameters_copy`. Under OOM that path silently
298 // produces a Parameters with a null inner pointer.
299 let cloned_for_build = match try_clone_parameters(¶meters) {
300 Ok(p) => p,
301 Err(e) => {
302 tracing::warn!(?backend, error = %e, "hwdecode: parameters clone failed");
303 attempts.push((backend, Box::new(Error::Ffmpeg(e))));
304 continue;
305 }
306 };
307 match Self::build_state(cloned_for_build, codec, backend) {
308 Ok(state) => {
309 tracing::info!(?backend, "hwdecode: opened video decoder (probing)");
310 let remaining = order[(i + 1)..].to_vec();
311 // Deep-copy the caller's `parameters` before storing in ProbeState.
312 // `codec::Parameters` from `stream.parameters()` carries an Rc
313 // owner pointing at the demuxer; moving that Rc to a worker
314 // thread (when VideoDecoder is sent) would race with the demuxer's
315 // Rc on the original thread. The checked clone copies the bytes
316 // into a fresh allocation with `owner: None`, severing the link.
317 //
318 // We always create ProbeState — even when `remaining` is empty
319 // (single-backend platforms like macOS) — so that a first-frame
320 // failure on the only backend surfaces as
321 // `Error::AllBackendsFailed` from `receive_frame` /
322 // `send_packet` rather than as a raw FFmpeg error. That keeps
323 // the API contract the same regardless of how many HW backends
324 // the platform exposes.
325 //
326 // If the clone fails (ENOMEM), fail the **whole open call**
327 // rather than returning a live decoder with `probe: None`.
328 // Returning Ok here would let the caller send packets that the
329 // active backend consumes, and a subsequent backend failure
330 // would then surface as a raw FFmpeg error with no
331 // `unconsumed_packets` — silently breaking the rescue contract
332 // for non-seekable inputs (live streams, pipes). Dropping the
333 // already-built `state` here runs its FFmpeg cleanup, and the
334 // caller can retry / fall back to software with the original
335 // parameters in their hand (no packets were consumed yet).
336 // Seed the probe's attempt log with any backends that failed
337 // to open earlier in this loop (including
338 // `BackendUnsupportedByCodec` and parameters-clone errors).
339 // Without this, a runtime exhaustion on the active backend
340 // would surface an `AllBackendsFailed` containing only the
341 // active backend's runtime failure — losing the original
342 // open-time causes that, on multi-backend platforms (Linux,
343 // Windows), are usually the more diagnostic signal. E.g. a
344 // VAAPI-then-CUDA host where VAAPI fails to open and CUDA
345 // later fails at first-frame must report both failures in
346 // probe order, not just CUDA.
347 let probe = match try_clone_parameters(¶meters) {
348 Ok(probe_params) => ProbeState {
349 parameters: probe_params,
350 codec,
351 remaining_backends: remaining,
352 buffered_packets: Vec::new(),
353 buffered_bytes: 0,
354 eof_sent: false,
355 attempts: std::mem::take(&mut attempts),
356 },
357 Err(e) => {
358 tracing::warn!(
359 error = %e,
360 "hwdecode: parameters clone failed for probe state at open; \
361 failing closed instead of returning a decoder without rescue"
362 );
363 return Err(Error::Ffmpeg(e));
364 }
365 };
366 return Ok(Self {
367 state,
368 hw_frame: alloc_av_frame().map_err(Error::Ffmpeg)?,
369 probe: Some(probe),
370 pending_frames: VecDeque::new(),
371 max_probe_pending_bytes: DEFAULT_MAX_PROBE_PENDING_BYTES,
372 });
373 }
374 Err(e) => {
375 tracing::warn!(?backend, error = %e, "hwdecode: backend open failed");
376 attempts.push((backend, Box::new(e)));
377 }
378 }
379 }
380 // No packets have been consumed at open time.
381 Err(Error::AllBackendsFailed(AllBackendsFailed::new(
382 attempts,
383 Vec::new(),
384 )))
385 }
386
387 /// Open the decoder with a specific backend. No probe, no fallback.
388 ///
389 /// If `backend` cannot actually decode this stream, the failure surfaces
390 /// from [`Self::receive_frame`] (the strict `get_format` callback returns
391 /// `AV_PIX_FMT_NONE`, the decoder errors out). The caller is responsible
392 /// for retrying with another hardware backend or falling back to a
393 /// software decoder of their choice (e.g. `ffmpeg::decoder::Video`).
394 pub fn open_with(parameters: codec::Parameters, backend: Backend) -> Result<Self> {
395 let codec = find_decoder(¶meters)?;
396 let state = Self::build_state(parameters, codec, backend)?;
397 Ok(Self {
398 state,
399 hw_frame: alloc_av_frame().map_err(Error::Ffmpeg)?,
400 probe: None,
401 pending_frames: VecDeque::new(),
402 max_probe_pending_bytes: DEFAULT_MAX_PROBE_PENDING_BYTES,
403 })
404 }
405
406 /// Override the byte budget for probe-replay queued frames. Defaults to
407 /// [`DEFAULT_MAX_PROBE_PENDING_BYTES`]. Use a higher value when targeting
408 /// 8K+ workloads where 16 frames at full size could exceed the default;
409 /// use a lower value in memory-constrained services to bound peak
410 /// allocation more tightly.
411 ///
412 /// Setting after the first frame has been delivered is harmless but has
413 /// no observable effect — the probe has already collapsed and the cap
414 /// only applies during replay drain.
415 ///
416 /// Returns `self` for builder-style chaining:
417 /// ```ignore
418 /// let decoder = VideoDecoder::open(params)?
419 /// .with_max_probe_pending_bytes(1024 * 1024 * 1024); // 1 GiB
420 /// ```
421 #[must_use]
422 pub fn with_max_probe_pending_bytes(mut self, bytes: usize) -> Self {
423 self.max_probe_pending_bytes = bytes;
424 self
425 }
426
427 /// The backend currently producing frames. While the probe is still in
428 /// progress (no frame received yet) this returns the optimistically
429 /// selected backend; after the first frame, it is the backend that
430 /// actually produced it. Once stable, never changes again.
431 pub fn backend(&self) -> Backend {
432 self.state.backend
433 }
434
435 /// Decoder width in pixels.
436 pub fn width(&self) -> u32 {
437 self.state.inner.width()
438 }
439
440 /// Decoder height in pixels.
441 pub fn height(&self) -> u32 {
442 self.state.inner.height()
443 }
444
445 /// Codec context time base.
446 pub fn time_base(&self) -> Rational {
447 self.state.inner.time_base()
448 }
449
450 /// Frame rate from the codec context, if known.
451 pub fn frame_rate(&self) -> Option<Rational> {
452 self.state.inner.frame_rate()
453 }
454
455 /// Submit a packet to the decoder.
456 ///
457 /// On success — and only on success — the packet is buffered for potential
458 /// replay through a fallback backend while the probe is active. EAGAIN
459 /// (decoder needs `receive_frame` to drain output first) propagates as
460 /// normal backpressure; the caller drains then retries.
461 ///
462 /// While the probe is active, a non-transient error (e.g. the active HW
463 /// backend rejecting this stream's geometry on first packet) advances the
464 /// probe to the next candidate and retries the packet there. The caller
465 /// observes only the eventual success or, if the probe is exhausted, the
466 /// final error.
467 ///
468 /// **Atomic probe rescue.** While the probe is active, the rescue
469 /// invariant is that everything FFmpeg has consumed since open is
470 /// reflected in `buffered_packets` (so a future
471 /// [`Error::AllBackendsFailed`] can hand a complete replay history
472 /// back to the caller for software fallback on a non-seekable input).
473 /// If we cannot prove this packet is buffer-able — its side-data
474 /// entry count exceeds [`MAX_PROBE_PACKET_SIDE_DATA_ENTRIES`], its
475 /// bytes would push the probe past [`MAX_PROBE_PACKETS`] or
476 /// [`MAX_PROBE_PACKET_BYTES`], or [`av_packet_ref`] fails ENOMEM —
477 /// `send_packet` returns [`Error::AllBackendsFailed`] **without
478 /// invoking** `state.inner.send_packet` on this packet. The caller's
479 /// packet stays in their hand and `unconsumed_packets` carries the
480 /// pre-existing buffered history, so they can replay
481 /// `unconsumed_packets` plus the current packet through their
482 /// software decoder of choice. The post-probe path (after the first
483 /// frame, when `self.probe` is `None`) skips this pre-flight
484 /// entirely.
485 pub fn send_packet(&mut self, packet: &Packet) -> Result<()> {
486 loop {
487 // Pre-flight while probe is active: prove we can record this
488 // packet for replay BEFORE the active decoder consumes it.
489 // `staged_clone` carries the refcounted clone and the new
490 // `buffered_bytes` value through the send below; we only commit
491 // them to the probe state if FFmpeg accepts the packet.
492 let staged_clone: Option<(Packet, usize)> = if let Some(probe) = self.probe.as_ref() {
493 // Step 1: side-data entry count cap. Read just `side_data_elems`
494 // (no array walk yet) so a corrupt or weaponised value cannot
495 // drive an unbounded loop from the safe entry point.
496 let side_count = packet_side_data_count(packet);
497 if side_count > MAX_PROBE_PACKET_SIDE_DATA_ENTRIES {
498 let probe = self.probe.take().expect("probe present");
499 tracing::warn!(
500 side_data_entries = side_count,
501 max_side_data_entries = MAX_PROBE_PACKET_SIDE_DATA_ENTRIES,
502 trigger = "side_data_entry_cap",
503 "hwdecode: probe rescue exhausted before consuming packet; \
504 returning AllBackendsFailed without invoking decoder"
505 );
506 return Err(Error::AllBackendsFailed(AllBackendsFailed::new(
507 probe.attempts,
508 probe.buffered_packets,
509 )));
510 }
511 // Step 2: byte / packet count cap. `packet_side_data_bytes`
512 // clamps its walk to MAX_PROBE_PACKET_SIDE_DATA_ENTRIES as
513 // defense-in-depth even though the count check above already
514 // bounded the array length.
515 let pkt_size = packet.size().saturating_add(packet_side_data_bytes(
516 packet,
517 MAX_PROBE_PACKET_SIDE_DATA_ENTRIES,
518 ));
519 let new_count = probe.buffered_packets.len() + 1;
520 let new_bytes = probe.buffered_bytes.saturating_add(pkt_size);
521 if new_count > MAX_PROBE_PACKETS || new_bytes > MAX_PROBE_PACKET_BYTES {
522 let probe = self.probe.take().expect("probe present");
523 tracing::warn!(
524 packets = new_count,
525 bytes = new_bytes,
526 side_data_entries = side_count,
527 max_packets = MAX_PROBE_PACKETS,
528 max_bytes = MAX_PROBE_PACKET_BYTES,
529 trigger = "byte_or_packet_cap",
530 "hwdecode: probe rescue exhausted before consuming packet; \
531 returning AllBackendsFailed without invoking decoder"
532 );
533 return Err(Error::AllBackendsFailed(AllBackendsFailed::new(
534 probe.attempts,
535 probe.buffered_packets,
536 )));
537 }
538 // Step 3: pre-clone before consuming. `av_packet_ref` is a
539 // refcounted shallow clone (no payload deep-copy) but can still
540 // ENOMEM on heavy side-data; if it does we bail rather than
541 // consuming a packet we can't track.
542 match try_clone_packet(packet) {
543 Ok(c) => Some((c, new_bytes)),
544 Err(e) => {
545 let probe = self.probe.take().expect("probe present");
546 tracing::warn!(
547 error = %e,
548 "hwdecode: packet clone failed before consuming; \
549 returning AllBackendsFailed without invoking decoder"
550 );
551 return Err(Error::AllBackendsFailed(AllBackendsFailed::new(
552 probe.attempts,
553 probe.buffered_packets,
554 )));
555 }
556 }
557 } else {
558 None
559 };
560
561 match self.state.inner.send_packet(packet) {
562 Ok(()) => {
563 if let Some((cloned, new_bytes)) = staged_clone {
564 // Probe is still Some here: the only paths that take it are
565 // the bailouts above (which return) and `advance_probe`'s
566 // exhaustion (which would have propagated via `?`). Commit
567 // the clone now that FFmpeg has accepted the packet.
568 if let Some(probe) = self.probe.as_mut() {
569 probe.buffered_packets.push(cloned);
570 probe.buffered_bytes = new_bytes;
571 }
572 }
573 return Ok(());
574 }
575 Err(e) if is_transient(&e) => {
576 // EAGAIN / EOF backpressure — pass through unchanged. The
577 // staged clone drops; the caller will retry after draining
578 // and we'll re-clone at the top of the loop.
579 return Err(Error::Ffmpeg(e));
580 }
581 Err(e) => {
582 if self.probe.is_some() {
583 // advance_probe consumes the error into `attempts` and
584 // either installs a candidate (Ok — loop top re-clones for
585 // the new candidate) or surfaces AllBackendsFailed (Err —
586 // `?` propagates). Either way the staged clone we just
587 // built drops without entering history; the next iteration
588 // clones afresh against the new active state.
589 self.advance_probe(Error::Ffmpeg(e))?;
590 continue;
591 }
592 return Err(Error::Ffmpeg(e));
593 }
594 }
595 }
596 }
597
598 /// Signal end-of-stream to the decoder.
599 ///
600 /// Recorded for replay only if the underlying `send_eof` succeeds. While
601 /// the probe is active, non-transient errors trigger probe advance and
602 /// retry, matching `send_packet`'s behaviour.
603 pub fn send_eof(&mut self) -> Result<()> {
604 loop {
605 match self.state.inner.send_eof() {
606 Ok(()) => {
607 if let Some(probe) = self.probe.as_mut() {
608 probe.eof_sent = true;
609 }
610 return Ok(());
611 }
612 Err(e) if is_transient(&e) => return Err(Error::Ffmpeg(e)),
613 Err(e) => {
614 if self.probe.is_some() {
615 self.advance_probe(Error::Ffmpeg(e))?;
616 continue;
617 }
618 return Err(Error::Ffmpeg(e));
619 }
620 }
621 }
622 }
623
624 /// Receive a CPU-side decoded frame.
625 ///
626 /// The frame is downloaded with `av_hwframe_transfer_data` and metadata
627 /// is copied via `av_frame_copy_props`. The caller's frame is always
628 /// unref'd first, so reuse across resolution changes or different
629 /// decoders is safe.
630 ///
631 /// While the probe window is open, *any* non-transient failure (decode
632 /// error, transfer error, copy_props error, or a CPU-format frame from a
633 /// HW-opened context) tears down the current decoder and advances to the
634 /// next hardware backend in probe order, replaying buffered packets
635 /// through it. Frames the candidate produced during replay (drained when
636 /// `send_packet` returned EAGAIN) are queued and delivered FIFO via this
637 /// method, so the caller never loses initial frames after a fallback.
638 ///
639 /// This crate is hardware-only: there is no software fallback inside the
640 /// decoder. When every backend in the probe order has been exhausted —
641 /// including the case of a single-backend platform whose only backend
642 /// failed — this returns [`Error::AllBackendsFailed`] with the per-
643 /// backend attempt log so the caller can branch into a software
644 /// decoder of their choice.
645 ///
646 /// Returns the same transient signals as `ffmpeg::decoder::Video`:
647 /// `Error::Ffmpeg(Other { errno: EAGAIN })` when no frame is ready and
648 /// more packets must be sent, and `Error::Ffmpeg(Eof)` once fully drained.
649 pub fn receive_frame(&mut self, frame: &mut Frame) -> Result<()> {
650 // Pre-drain frames queued during probe replay. They are already CPU-side
651 // (transferred at drain time, when the candidate's HW context was alive)
652 // so we just move them into the caller's slot.
653 if self.try_pop_pending(frame) {
654 return Ok(());
655 }
656
657 loop {
658 let res = self.state.inner.receive_frame(&mut self.hw_frame);
659 match res {
660 Err(e) => {
661 // EAGAIN is normal backpressure — pass through unconditionally.
662 if is_eagain(&e) {
663 return Err(Error::Ffmpeg(e));
664 }
665 // EOF (and every other non-transient error): if we are still
666 // probing, treat it as candidate failure — a backend that drains
667 // to EOF without ever producing a frame should not silently
668 // present as "stream over" to the caller. Advance and retry; if
669 // every backend has been exhausted, advance_probe surfaces
670 // AllBackendsFailed and `?` propagates it.
671 if self.probe.is_some() {
672 self.advance_probe(Error::Ffmpeg(e))?;
673 // Probe advance may have populated `pending_frames`; deliver
674 // one of those before reading more from the new candidate.
675 if self.try_pop_pending(frame) {
676 return Ok(());
677 }
678 continue;
679 }
680 // Probe collapsed already — surface the error (including EOF
681 // for a genuinely empty stream).
682 return Err(Error::Ffmpeg(e));
683 }
684 Ok(()) => {
685 // Always attempt the HW→CPU transfer. With strict `get_format`,
686 // libavcodec can only deliver frames in the wired-up HW format
687 // (or fail). If a misbehaving codec ever hands us a CPU-side
688 // frame anyway, `av_hwframe_transfer_data` returns AVERROR(EINVAL)
689 // (neither src nor dst has an AVHWFramesContext attached) and we
690 // route through the same error path below.
691 match unsafe { transfer_hw_frame(frame, &mut self.hw_frame) } {
692 Ok(()) => {
693 self.probe = None;
694 return Ok(());
695 }
696 Err(e) => {
697 if self.probe.is_some() {
698 self.advance_probe(Error::Ffmpeg(e))?;
699 unsafe { av_frame_unref(frame.as_inner_mut().as_mut_ptr()) };
700 if self.try_pop_pending(frame) {
701 return Ok(());
702 }
703 continue;
704 }
705 return Err(Error::Ffmpeg(e));
706 }
707 }
708 }
709 }
710 }
711 }
712
713 /// Pop one queued frame (produced by a candidate decoder during probe
714 /// replay) into the caller's slot. Returns `true` when a frame was
715 /// delivered, `false` when the queue was empty.
716 fn try_pop_pending(&mut self, frame: &mut Frame) -> bool {
717 let Some(mut buffered) = self.pending_frames.pop_front() else {
718 return false;
719 };
720 // SAFETY: `buffered` is a CPU-side AVFrame we previously transferred
721 // and pushed into the queue; both pointers are valid.
722 unsafe {
723 av_frame_unref(frame.as_inner_mut().as_mut_ptr());
724 av_frame_move_ref(frame.as_inner_mut().as_mut_ptr(), buffered.as_mut_ptr());
725 }
726 // Probe semantics: delivering a frame collapses the probe.
727 self.probe = None;
728 true
729 }
730
731 /// Flush internal buffers (e.g. after a seek).
732 ///
733 /// Discards every frame buffered by the decoder, every frame queued during
734 /// probe replay (`pending_frames`), and the residual `hw_frame` scratch
735 /// buffer. Probe-time replay state (buffered packets, EOF marker) is also
736 /// cleared since post-seek packets do not align with the previously
737 /// captured history. After a flush, the next `receive_frame` waits for new
738 /// post-seek input.
739 pub fn flush(&mut self) {
740 self.state.inner.flush();
741 // SAFETY: hw_frame is a valid AVFrame we own; av_frame_unref is a no-op
742 // for an already-empty frame.
743 unsafe { av_frame_unref(self.hw_frame.as_mut_ptr()) };
744 self.pending_frames.clear();
745 if let Some(probe) = self.probe.as_mut() {
746 probe.buffered_packets.clear();
747 probe.buffered_bytes = 0;
748 probe.eof_sent = false;
749 }
750 }
751
752 /// Try the next backend in `remaining_backends`. Transactional: a
753 /// candidate must successfully build and accept the replayed history
754 /// before any probe state is consumed. Backends that fail to build or
755 /// reject the replay are recorded into `probe.attempts` and the loop
756 /// continues to the next one.
757 ///
758 /// `last_error` is the error that triggered this advance — i.e. the
759 /// failure of the currently active backend on `send_packet` /
760 /// `send_eof` / `receive_frame`. It is recorded against the active
761 /// backend before any candidate is tried so that a final
762 /// `AllBackendsFailed` carries the full attempt log including the
763 /// initially-opened backend's runtime failure.
764 ///
765 /// Returns:
766 /// - `Ok(())` when a candidate is installed and replay completed —
767 /// caller should retry the operation.
768 /// - `Err(Error::AllBackendsFailed(p))` when every remaining
769 /// backend has been exhausted (including the just-failed active one).
770 /// `p.attempts()` carries the per-backend failure log.
771 /// This is what the documented `open` contract promises, surfaced at
772 /// runtime so the caller can branch into a software fallback. On a
773 /// single-backend platform (e.g. macOS), this fires after the only
774 /// backend's first-frame failure; on multi-backend platforms it
775 /// fires after the last candidate's failure.
776 /// - `Err(_)` for other fatal conditions surfaced by probe machinery
777 /// itself (e.g. `alloc_av_frame` ENOMEM during replay drain).
778 fn advance_probe(&mut self, last_error: Error) -> Result<()> {
779 // Record the failure that triggered this advance against the active
780 // backend. If the probe was somehow already gone (shouldn't happen —
781 // call sites guard with `self.probe.is_some()`), just propagate the
782 // error so behaviour matches the pre-fix code path.
783 let active_backend = self.state.backend;
784 match self.probe.as_mut() {
785 Some(probe) => probe.attempts.push((active_backend, Box::new(last_error))),
786 None => return Err(last_error),
787 }
788
789 // Drop frames previously queued from the backend we're now abandoning.
790 // They came from a candidate that just failed for cause and cannot be
791 // trusted alongside frames we may queue from the next candidate. (If
792 // this method is called repeatedly via chained probe advances, this
793 // also keeps `pending_frames` from accumulating frames from multiple
794 // rejected backends.)
795 self.pending_frames.clear();
796
797 loop {
798 // Snapshot inputs without mutating probe state. Use the checked
799 // clone helper rather than `Parameters::clone` (which masks ENOMEM).
800 let (next_backend, parameters, codec) = match self.probe.as_ref() {
801 Some(probe) if !probe.remaining_backends.is_empty() => {
802 let parameters = match try_clone_parameters(&probe.parameters) {
803 Ok(p) => p,
804 Err(e) => {
805 tracing::warn!(
806 error = %e,
807 "hwdecode: parameters clone failed during probe advance; popping backend and trying next"
808 );
809 let popped = self
810 .probe
811 .as_mut()
812 .expect("probe state present")
813 .remaining_backends
814 .remove(0);
815 self
816 .probe
817 .as_mut()
818 .expect("probe state present")
819 .attempts
820 .push((popped, Box::new(Error::Ffmpeg(e))));
821 continue;
822 }
823 };
824 (probe.remaining_backends[0], parameters, probe.codec)
825 }
826 // No more candidates — surface the accumulated attempt log as
827 // AllBackendsFailed so single- and multi-backend platforms have
828 // the same contract for "every HW backend failed."
829 //
830 // Hand the buffered packet history back to the caller along
831 // with the attempt log: those packets were consumed from the
832 // caller's demuxer (and refcounted-cloned into `buffered_packets`)
833 // before the probe exhausted, and for non-seekable inputs the
834 // caller cannot re-demux them. Returning them here lets a
835 // caller-side software fallback replay the same byte history
836 // through `ffmpeg::decoder::Video` without losing initial frames.
837 // Dropping `ProbeState` after the take frees the codec/params
838 // refs we no longer need; only `attempts` and `buffered_packets`
839 // are retained.
840 _ => {
841 let (attempts, unconsumed_packets) = self
842 .probe
843 .take()
844 .map(|p| (p.attempts, p.buffered_packets))
845 .unwrap_or_default();
846 return Err(Error::AllBackendsFailed(AllBackendsFailed::new(
847 attempts,
848 unconsumed_packets,
849 )));
850 }
851 };
852
853 let prev_backend = self.state.backend;
854 tracing::warn!(from = ?prev_backend, to = ?next_backend, "hwdecode: advancing probe");
855
856 // Build candidate. On failure, record into attempts and continue
857 // without touching the packet buffer.
858 let mut candidate_state = match Self::build_state(parameters, codec, next_backend) {
859 Ok(s) => s,
860 Err(e) => {
861 tracing::warn!(?next_backend, error = %e, "hwdecode: candidate build failed");
862 self
863 .probe
864 .as_mut()
865 .expect("probe state present")
866 .remaining_backends
867 .remove(0);
868 self
869 .probe
870 .as_mut()
871 .expect("probe state present")
872 .attempts
873 .push((next_backend, Box::new(e)));
874 continue;
875 }
876 };
877
878 // Replay buffered history through the candidate WITHOUT installing it.
879 // We borrow the buffer immutably; if replay fails the candidate's Drop
880 // releases the FFmpeg state and the buffer is preserved for the next
881 // attempt.
882 //
883 // EAGAIN handling: `avcodec_send_packet` may return EAGAIN when its
884 // internal queue is full and the user is expected to drain output
885 // first (B-frame buffering, candidate-specific queue depth, etc.).
886 // This is normal flow — we drain frames out of the candidate, transfer
887 // each one to a CPU frame, and stash them in `local_pending`. After
888 // commit they move to `self.pending_frames` and are delivered FIFO
889 // by `receive_frame`, so the caller never loses initial frames.
890 let mut local_pending: VecDeque<frame::Video> = VecDeque::new();
891 let mut local_pending_bytes: usize = 0;
892 let max_pending_bytes = self.max_probe_pending_bytes;
893 let replay_result: std::result::Result<(), ffmpeg_next::Error> = {
894 let probe = self.probe.as_ref().expect("probe state present");
895 let mut hw_buf = match alloc_av_frame() {
896 Ok(f) => f,
897 Err(e) => return Err(Error::Ffmpeg(e)),
898 };
899 let mut r: std::result::Result<(), ffmpeg_next::Error> = Ok(());
900
901 'replay: for pkt in &probe.buffered_packets {
902 loop {
903 match candidate_state.inner.send_packet(pkt) {
904 Ok(()) => break,
905 Err(e) if is_eagain(&e) => {
906 // Drain candidate output (transferring + queueing each frame)
907 // and retry the same packet.
908 if let Err(de) = drain_into_pending(
909 &mut candidate_state.inner,
910 &mut hw_buf,
911 &mut local_pending,
912 &mut local_pending_bytes,
913 max_pending_bytes,
914 ) {
915 r = Err(de);
916 break 'replay;
917 }
918 }
919 Err(e) => {
920 r = Err(e);
921 break 'replay;
922 }
923 }
924 }
925 }
926 if r.is_ok() && probe.eof_sent {
927 // `avcodec_send_packet(NULL)` (which `send_eof` becomes) can
928 // return EAGAIN with the same drain-output-first semantics as
929 // a regular send_packet. Loop drain+retry instead of failing
930 // the candidate on backpressure.
931 loop {
932 match candidate_state.inner.send_eof() {
933 Ok(()) => break,
934 Err(e) if is_eagain(&e) => {
935 if let Err(de) = drain_into_pending(
936 &mut candidate_state.inner,
937 &mut hw_buf,
938 &mut local_pending,
939 &mut local_pending_bytes,
940 max_pending_bytes,
941 ) {
942 r = Err(de);
943 break;
944 }
945 }
946 Err(e) => {
947 r = Err(e);
948 break;
949 }
950 }
951 }
952 }
953 r
954 };
955
956 if let Err(e) = replay_result {
957 tracing::warn!(?next_backend, error = %e, "hwdecode: candidate replay failed");
958 // Drop candidate explicitly so its FFI cleanup runs now. Discard any
959 // frames we drained from this candidate — they're tied to a decoder
960 // we're throwing away.
961 drop(candidate_state);
962 drop(local_pending);
963 self
964 .probe
965 .as_mut()
966 .expect("probe state present")
967 .remaining_backends
968 .remove(0);
969 self
970 .probe
971 .as_mut()
972 .expect("probe state present")
973 .attempts
974 .push((next_backend, Box::new(Error::Ffmpeg(e))));
975 continue;
976 }
977
978 // Commit: install the candidate, clear residual hw_frame, queue the
979 // drained frames for the caller, and pop the now-active backend.
980 self.state = candidate_state;
981 unsafe { av_frame_unref(self.hw_frame.as_mut_ptr()) };
982 self.pending_frames.append(&mut local_pending);
983 self
984 .probe
985 .as_mut()
986 .expect("probe state present")
987 .remaining_backends
988 .remove(0);
989 return Ok(());
990 }
991 }
992
993 /// Build raw FFmpeg state for one hardware backend. Strict `get_format`
994 /// (NONE on missing HW format); cross-backend fallback is the caller's job.
995 fn build_state(
996 parameters: codec::Parameters,
997 codec: Codec,
998 backend: Backend,
999 ) -> Result<DecoderState> {
1000 // Use our checked allocator instead of Context::from_parameters, which
1001 // does not null-check avcodec_alloc_context3 and would feed a null
1002 // AVCodecContext into FFmpeg under OOM.
1003 let mut ctx = build_codec_context(¶meters)?;
1004 let av_type = backend.av_hwdevice_type();
1005
1006 // Verify the codec advertises this hwaccel **with the exact HW pix_fmt
1007 // we're about to wire up in `get_format`**. FFmpeg's HW config table
1008 // is keyed per (device_type, pix_fmt); a codec can advertise the same
1009 // device with several HW pix_fmts, so matching only on device_type
1010 // would let probing succeed for a backend whose pix_fmt the codec
1011 // never offers — the failure would then surface deep inside the
1012 // probe/decode loop. Matching the exact pix_fmt keeps the strict
1013 // `get_format` honest and gives `open_with` a clean rejection.
1014 let hw_pix_fmt = backend.hw_pixel_format();
1015 if !codec_supports_hwaccel(unsafe { codec.as_ptr() }, av_type, hw_pix_fmt as i32) {
1016 return Err(Error::BackendUnsupportedByCodec(backend));
1017 }
1018
1019 // Create the device context.
1020 let mut hw_device_ref: *mut AVBufferRef = ptr::null_mut();
1021 // SAFETY: `hw_device_ref` is a stack ptr we hand FFmpeg to fill.
1022 let ret = unsafe {
1023 av_hwdevice_ctx_create(&mut hw_device_ref, av_type, ptr::null(), ptr::null_mut(), 0)
1024 };
1025 if ret < 0 {
1026 return Err(Error::HwDeviceInitFailed(HwDeviceInitFailed::new(
1027 backend,
1028 ffmpeg_next::Error::from(ret),
1029 )));
1030 }
1031
1032 let callback_state = Box::into_raw(Box::new(CallbackState {
1033 wanted: hw_pix_fmt,
1034 wanted_int: hw_pix_fmt as i32,
1035 }));
1036 // RAII guard: from now until the end-of-function `into_owned()`, every
1037 // early return — `av_buffer_ref` failure, `open_as` failure, codec_type
1038 // mismatch, or any future error path added between here and the
1039 // `DecoderState` construction — frees `hw_device_ref` and
1040 // `callback_state` via the guard's Drop. Without it, each error site
1041 // had to remember to clean up these two FFI-owned resources by hand;
1042 // the codec_type-mismatch branch was missed and silently leaked one
1043 // device ref + one heap allocation per bad input.
1044 let guard = PartialBuildState {
1045 hw_device_ref,
1046 callback_state,
1047 };
1048
1049 // SAFETY: ctx is a freshly-constructed AVCodecContext we own;
1050 // av_buffer_ref bumps the refcount of the device buffer for FFmpeg's
1051 // use (we keep our own ref in `hw_device_ref` for cleanup).
1052 // av_buffer_ref returns NULL on allocation failure; we must check it
1053 // before assigning, otherwise the codec context would be opened with a
1054 // HW-flagged setup but no actual device reference.
1055 let device_ref_for_ctx = unsafe { av_buffer_ref(hw_device_ref) };
1056 if device_ref_for_ctx.is_null() {
1057 // guard's Drop frees hw_device_ref (the first ref) and callback_state.
1058 return Err(Error::Ffmpeg(ffmpeg_next::Error::Other {
1059 errno: libc::ENOMEM,
1060 }));
1061 }
1062 // SAFETY: device_ref_for_ctx is a valid AVBufferRef* from av_buffer_ref;
1063 // ctx is freshly built and owned by us. After this point ctx aliases
1064 // `callback_state` via `opaque` (FFmpeg never frees opaque, so
1065 // `callback_state` ownership stays with us / the guard) and aliases
1066 // `device_ref_for_ctx` (the second ref) via `hw_device_ctx` (FFmpeg
1067 // unrefs that on codec context drop, independent of the guard's first
1068 // ref).
1069 unsafe {
1070 let raw = ctx.as_mut_ptr();
1071 (*raw).hw_device_ctx = device_ref_for_ctx;
1072 (*raw).opaque = callback_state.cast();
1073 (*raw).get_format = Some(get_hw_format);
1074 }
1075
1076 // Open the decoder. On failure `ctx`/`opened` Drop releases the codec
1077 // context (and via that the second device ref); the guard releases the
1078 // first device ref and the callback state.
1079 //
1080 // We deliberately bypass `Opened::video()` because it calls
1081 // `Context::medium()`, which reads `AVCodecContext.codec_type` as the
1082 // bindgen `AVMediaType` enum — the same UB hazard we've been
1083 // systematically removing. Instead: validate `codec_type` as a raw
1084 // `c_int` ourselves, then construct the `decoder::Video` wrapper
1085 // directly via its public tuple field.
1086 let opened = ctx.decoder().open_as(codec).map_err(Error::Ffmpeg)?;
1087
1088 // Validate codec_type as a raw integer — never construct AVMediaType
1089 // from an unvalidated runtime value.
1090 // SAFETY: codec_type is bound as AVMediaType (`#[repr(i32)]`), same
1091 // size and alignment as i32; reading the bytes as i32 cannot be UB.
1092 let codec_type_int: i32 =
1093 unsafe { ptr::read(ptr::addr_of!((*opened.as_ptr()).codec_type) as *const i32) };
1094 let video_type_int: i32 = AVMediaType::AVMEDIA_TYPE_VIDEO as i32;
1095 if codec_type_int != video_type_int {
1096 // Not a video codec context — surface the same error
1097 // `Opened::video()` would have, without going through enum
1098 // construction. `opened`'s Drop releases the codec context; the
1099 // guard releases the first hw_device_ref and the callback state.
1100 return Err(Error::Ffmpeg(ffmpeg_next::Error::InvalidData));
1101 }
1102 // SAFETY of construction: `decoder::Video` is `pub struct Video(pub Opened)`.
1103 // We construct via the public field; this is the same wrapping
1104 // `Opened::video()` does on success, just without the enum read.
1105 let opened = ffmpeg_next::decoder::Video(opened);
1106
1107 // Disarm the guard and transfer ownership of both resources into the
1108 // returned DecoderState (whose own Drop handles their lifetime).
1109 let (hw_device_ref, callback_state) = guard.into_owned();
1110 Ok(DecoderState {
1111 inner: ManuallyDrop::new(opened),
1112 backend,
1113 hw_device_ref,
1114 callback_state,
1115 })
1116 }
1117}
1118
1119/// RAII guard for the partially-owned FFmpeg state that
1120/// [`VideoDecoder::build_state`] holds between the
1121/// `av_hwdevice_ctx_create` and `Box::into_raw(CallbackState)`
1122/// allocations and the final `DecoderState` construction.
1123///
1124/// If `build_state` returns `Err` for any reason in that window
1125/// (`av_buffer_ref` ENOMEM, `open_as` failure, codec_type mismatch, or
1126/// any future error path), this guard's `Drop` releases
1127/// `hw_device_ref` — the first ref returned by `av_hwdevice_ctx_create`,
1128/// distinct from the second ref FFmpeg unrefs when the codec context
1129/// drops — and the boxed `CallbackState`, which FFmpeg never touches
1130/// because `AVCodecContext::opaque` is purely user-owned.
1131///
1132/// Successful construction calls [`Self::into_owned`] to disarm the
1133/// guard and hand both pointers to the new `DecoderState`.
1134struct PartialBuildState {
1135 hw_device_ref: *mut AVBufferRef,
1136 callback_state: *mut CallbackState,
1137}
1138
1139impl PartialBuildState {
1140 /// Disarm the guard: return the owned pointers and replace the guard's
1141 /// fields with null so its Drop is a no-op.
1142 fn into_owned(mut self) -> (*mut AVBufferRef, *mut CallbackState) {
1143 let hw = std::mem::replace(&mut self.hw_device_ref, ptr::null_mut());
1144 let cb = std::mem::replace(&mut self.callback_state, ptr::null_mut());
1145 (hw, cb)
1146 }
1147}
1148
1149impl Drop for PartialBuildState {
1150 fn drop(&mut self) {
1151 // SAFETY: pointers are either freshly allocated by `build_state` (via
1152 // `av_hwdevice_ctx_create` and `Box::into_raw`) or null after
1153 // `into_owned`. Both `av_buffer_unref` and `Box::from_raw` need the
1154 // null check we apply here; both are otherwise sound on resources we
1155 // own.
1156 unsafe {
1157 if !self.hw_device_ref.is_null() {
1158 let mut hw = self.hw_device_ref;
1159 av_buffer_unref(&mut hw);
1160 }
1161 if !self.callback_state.is_null() {
1162 drop(Box::from_raw(self.callback_state));
1163 }
1164 }
1165 }
1166}
1167
1168/// Download a HW frame into a CPU [`Frame`]. Always unrefs the destination
1169/// first so reuse across resolution changes is safe.
1170///
1171/// Deliberately does **not** call `av_frame_copy_props`. That FFmpeg
1172/// helper deep-copies AVFrame side data (SEI, mastering display, ICC
1173/// profiles, dynamic HDR, etc.), the metadata dict, and bumps both
1174/// `opaque_ref` and `private_ref` on every receive — none of which
1175/// `Frame` exposes via its public accessors. On a crafted stream with
1176/// megabytes of per-frame metadata that would mean an unbounded
1177/// allocation per receive, with no caller-visible benefit. We instead
1178/// copy only the scalar fields the public API can read (today: `pts`);
1179/// pixel layout (`width`, `height`, `format`, `linesize`, `data`) is
1180/// already set by `av_hwframe_transfer_data`. If `Frame` ever grows
1181/// accessors for timing extras (`duration`, `time_base`, `pkt_dts`) or
1182/// color metadata, add those to `copy_frame_props_minimal` at the same
1183/// time.
1184unsafe fn transfer_hw_frame(
1185 dst: &mut Frame,
1186 src: &mut frame::Video,
1187) -> std::result::Result<(), ffmpeg_next::Error> {
1188 unsafe {
1189 av_frame_unref(dst.as_inner_mut().as_mut_ptr());
1190 let ret = av_hwframe_transfer_data(dst.as_inner_mut().as_mut_ptr(), src.as_ptr(), 0);
1191 if ret < 0 {
1192 return Err(ffmpeg_next::Error::from(ret));
1193 }
1194 // Validate the post-transfer CPU pix_fmt against the safe `Frame`
1195 // accessor's supported set. FFmpeg picks the destination format
1196 // when `dst.format == AV_PIX_FMT_NONE` on entry (which it always is
1197 // here — `av_frame_unref` clears it) by walking the result of
1198 // `av_hwframe_transfer_get_formats`. Driver/version ordering can
1199 // pick a layout outside our NV*/P0xx/P2xx/P4xx set; the call would
1200 // return success while the resulting frame is unreadable through
1201 // `Frame::row` / `Frame::as_ptr` (those return `None` for
1202 // unsupported formats). Surface the unsupported result as a
1203 // transfer failure so `receive_frame`'s probe-active path advances
1204 // to the next backend rather than collapsing on an unusable frame;
1205 // post-probe, the caller gets an `Err` they can branch into a
1206 // software fallback.
1207 let dst_raw_fmt: i32 = (*dst.as_inner_mut().as_ptr()).format;
1208 let dst_pix_fmt = crate::boundary::from_av_pixel_format(dst_raw_fmt);
1209 if !crate::frame::is_supported_cpu_pix_fmt(dst_pix_fmt) {
1210 tracing::warn!(
1211 pix_fmt = dst_raw_fmt,
1212 "hwdecode: hw->cpu transfer produced unsupported pix_fmt; \
1213 treating as backend failure"
1214 );
1215 av_frame_unref(dst.as_inner_mut().as_mut_ptr());
1216 return Err(ffmpeg_next::Error::Other {
1217 errno: libc::EINVAL,
1218 });
1219 }
1220 if let Err(e) = copy_frame_props_minimal(dst.as_inner_mut().as_mut_ptr(), src.as_ptr()) {
1221 // Failed to propagate metadata. Reset the destination so the
1222 // partial frame doesn't leak (its pixel buffers were attached
1223 // by `av_hwframe_transfer_data` above) and surface as a
1224 // backend failure — the probe path will advance to the next
1225 // candidate; post-probe, the caller branches into SW fallback.
1226 av_frame_unref(dst.as_inner_mut().as_mut_ptr());
1227 return Err(e);
1228 }
1229 }
1230 Ok(())
1231}
1232
1233/// Copies AVFrame metadata (timestamps, color metadata, crop rect,
1234/// flags, side data, etc.) from the source HW frame to the destination
1235/// CPU frame so the post-transfer frame surfaces the same metadata a
1236/// SW-decoded frame would.
1237///
1238/// Defers to FFmpeg's `av_frame_copy_props`, which handles the per-
1239/// `side_data[i]` allocation, dict copy, and refcounted buffer
1240/// replacements internally. The cost is bounded by what the source
1241/// frame attaches — typical HDR streams carry 1–3 side-data entries
1242/// (mastering display, content light level, dolby/HDR10+ dynamic
1243/// metadata) totalling a few hundred bytes, so per-frame allocation
1244/// overhead stays negligible relative to the pixel data already
1245/// transferred via `av_hwframe_transfer_data`.
1246///
1247/// # Safety
1248/// Both pointers must be valid `AVFrame` pointers we own. We do not
1249/// form `&AVFrame` — `av_frame_copy_props` operates on raw pointers
1250/// directly.
1251/// Sum the byte sizes of every entry in `(*frame).side_data[]`.
1252/// Used by the probe replay queue's byte-cap accounting so a
1253/// frame's deep-copied side-data is charged against
1254/// `max_probe_pending_bytes` along with its pixel buffers.
1255///
1256/// # Safety
1257/// `frame` must be a live `*const AVFrame`. Reads only `nb_side_data`,
1258/// the `side_data` pointer array, and each `AVFrameSideData.size` —
1259/// no `&AVFrame` reference is formed.
1260unsafe fn sum_side_data_bytes(frame: *const AVFrame) -> usize {
1261 // Clamp `nb_side_data` to the same entry cap the copy path
1262 // enforces. Without the clamp, a decoder-controlled or
1263 // version-skew `nb_side_data` value (the bindgen field is
1264 // `c_int`, signed) could drive this walk arbitrarily long
1265 // before the cap downstream kicks in. Negative values are
1266 // pinned to zero before casting.
1267 let raw = unsafe { (*frame).nb_side_data };
1268 let arr = unsafe { (*frame).side_data };
1269 if raw <= 0 || arr.is_null() {
1270 return 0;
1271 }
1272 let count = (raw as usize).min(HW_COPY_SIDE_DATA_MAX_ENTRIES);
1273 let mut total: usize = 0;
1274 for i in 0..count {
1275 // SAFETY: `arr` points to `nb_side_data` valid `*mut AVFrameSideData`
1276 // entries per FFmpeg's contract; `i < count` is in-bounds.
1277 let entry = unsafe { *arr.add(i) };
1278 if entry.is_null() {
1279 continue;
1280 }
1281 let sz = unsafe { (*entry).size };
1282 total = total.saturating_add(sz);
1283 if total >= HW_COPY_SIDE_DATA_MAX_TOTAL_BYTES {
1284 // Already at or above the byte cap — further entries can't
1285 // change the projected-vs-cap decision the caller makes.
1286 total = HW_COPY_SIDE_DATA_MAX_TOTAL_BYTES;
1287 break;
1288 }
1289 }
1290 total
1291}
1292
1293/// Hard cap on the number of `AVFrameSideData` entries we copy from
1294/// HW source frame to CPU destination frame on the HW transfer
1295/// path. Mirrors `convert::SIDE_DATA_MAX_ENTRIES`; the public
1296/// converter re-enforces the same cap so this is defense in depth.
1297const HW_COPY_SIDE_DATA_MAX_ENTRIES: usize = 64;
1298/// Hard cap on the total side-data byte budget per HW transfer.
1299/// Mirrors `convert::SIDE_DATA_MAX_TOTAL_BYTES`.
1300const HW_COPY_SIDE_DATA_MAX_TOTAL_BYTES: usize = 256 * 1024;
1301
1302/// Maps a raw `AV_FRAME_DATA_*` integer to the matching bindgen
1303/// `AVFrameSideDataType` enum value when (and only when) the integer
1304/// is a known discriminant in the linked FFmpeg's bindgen output.
1305/// Returns `None` for unknown / version-skew / corrupt values —
1306/// the caller drops those entries instead of `transmute`-ing an
1307/// arbitrary integer back into the enum (which would be immediate
1308/// UB if the discriminant isn't in the enum's set).
1309///
1310/// The whitelist covers the entries safe to preserve across HW
1311/// transfer:
1312/// - HDR10 / HDR10+ / Dolby Vision / Vivid / ambient HDR metadata
1313/// - SMPTE / GOP timecodes
1314/// - ICC color profile
1315/// - A53 closed captions
1316/// - Spherical / display matrix orientation
1317/// - Stereo3D layout
1318///
1319/// Other AV_FRAME_DATA_* constants exist (motion vectors, encoder
1320/// params, RPU buffers, …) but are either decoder-internal or
1321/// rarely useful through the public mediadecode API; dropping them
1322/// is the safe default.
1323fn whitelisted_side_data_kind(kind_raw: i32) -> Option<ffmpeg_next::ffi::AVFrameSideDataType> {
1324 use ffmpeg_next::ffi::AVFrameSideDataType;
1325 // Each match arm compares `kind_raw` against the i32 cast of a
1326 // known constant, then returns the constant itself — we never
1327 // construct the enum from arbitrary integer bytes.
1328 let kind = match kind_raw {
1329 x if x == AVFrameSideDataType::AV_FRAME_DATA_PANSCAN as i32 => {
1330 AVFrameSideDataType::AV_FRAME_DATA_PANSCAN
1331 }
1332 x if x == AVFrameSideDataType::AV_FRAME_DATA_A53_CC as i32 => {
1333 AVFrameSideDataType::AV_FRAME_DATA_A53_CC
1334 }
1335 x if x == AVFrameSideDataType::AV_FRAME_DATA_STEREO3D as i32 => {
1336 AVFrameSideDataType::AV_FRAME_DATA_STEREO3D
1337 }
1338 x if x == AVFrameSideDataType::AV_FRAME_DATA_DISPLAYMATRIX as i32 => {
1339 AVFrameSideDataType::AV_FRAME_DATA_DISPLAYMATRIX
1340 }
1341 x if x == AVFrameSideDataType::AV_FRAME_DATA_AFD as i32 => {
1342 AVFrameSideDataType::AV_FRAME_DATA_AFD
1343 }
1344 x if x == AVFrameSideDataType::AV_FRAME_DATA_MASTERING_DISPLAY_METADATA as i32 => {
1345 AVFrameSideDataType::AV_FRAME_DATA_MASTERING_DISPLAY_METADATA
1346 }
1347 x if x == AVFrameSideDataType::AV_FRAME_DATA_GOP_TIMECODE as i32 => {
1348 AVFrameSideDataType::AV_FRAME_DATA_GOP_TIMECODE
1349 }
1350 x if x == AVFrameSideDataType::AV_FRAME_DATA_SPHERICAL as i32 => {
1351 AVFrameSideDataType::AV_FRAME_DATA_SPHERICAL
1352 }
1353 x if x == AVFrameSideDataType::AV_FRAME_DATA_CONTENT_LIGHT_LEVEL as i32 => {
1354 AVFrameSideDataType::AV_FRAME_DATA_CONTENT_LIGHT_LEVEL
1355 }
1356 x if x == AVFrameSideDataType::AV_FRAME_DATA_ICC_PROFILE as i32 => {
1357 AVFrameSideDataType::AV_FRAME_DATA_ICC_PROFILE
1358 }
1359 x if x == AVFrameSideDataType::AV_FRAME_DATA_S12M_TIMECODE as i32 => {
1360 AVFrameSideDataType::AV_FRAME_DATA_S12M_TIMECODE
1361 }
1362 x if x == AVFrameSideDataType::AV_FRAME_DATA_DYNAMIC_HDR_PLUS as i32 => {
1363 AVFrameSideDataType::AV_FRAME_DATA_DYNAMIC_HDR_PLUS
1364 }
1365 x if x == AVFrameSideDataType::AV_FRAME_DATA_REGIONS_OF_INTEREST as i32 => {
1366 AVFrameSideDataType::AV_FRAME_DATA_REGIONS_OF_INTEREST
1367 }
1368 x if x == AVFrameSideDataType::AV_FRAME_DATA_SEI_UNREGISTERED as i32 => {
1369 AVFrameSideDataType::AV_FRAME_DATA_SEI_UNREGISTERED
1370 }
1371 x if x == AVFrameSideDataType::AV_FRAME_DATA_FILM_GRAIN_PARAMS as i32 => {
1372 AVFrameSideDataType::AV_FRAME_DATA_FILM_GRAIN_PARAMS
1373 }
1374 x if x == AVFrameSideDataType::AV_FRAME_DATA_DOVI_RPU_BUFFER as i32 => {
1375 AVFrameSideDataType::AV_FRAME_DATA_DOVI_RPU_BUFFER
1376 }
1377 x if x == AVFrameSideDataType::AV_FRAME_DATA_DOVI_METADATA as i32 => {
1378 AVFrameSideDataType::AV_FRAME_DATA_DOVI_METADATA
1379 }
1380 x if x == AVFrameSideDataType::AV_FRAME_DATA_DYNAMIC_HDR_VIVID as i32 => {
1381 AVFrameSideDataType::AV_FRAME_DATA_DYNAMIC_HDR_VIVID
1382 }
1383 x if x == AVFrameSideDataType::AV_FRAME_DATA_AMBIENT_VIEWING_ENVIRONMENT as i32 => {
1384 AVFrameSideDataType::AV_FRAME_DATA_AMBIENT_VIEWING_ENVIRONMENT
1385 }
1386 _ => return None,
1387 };
1388 Some(kind)
1389}
1390
1391unsafe fn copy_frame_props_minimal(
1392 dst: *mut AVFrame,
1393 src: *const AVFrame,
1394) -> std::result::Result<(), ffmpeg_next::Error> {
1395 // We deliberately do NOT use `av_frame_copy_props` here, despite
1396 // its convenience. Upstream `av_frame_copy_props` deep-copies
1397 // *every* `AVFrameSideData` entry, the metadata `AVDictionary`,
1398 // and refcounted `opaque_ref` / `private_ref` buffers — all from
1399 // attacker-controlled decoder output. A crafted stream with many
1400 // multi-MiB side-data entries could drive the per-frame
1401 // allocation cost arbitrarily high (one alloc per entry, with the
1402 // entry's bytes copied via `memcpy`). The downstream
1403 // `convert::collect_side_data` cap helps the *Rust* side but the
1404 // FFmpeg-side allocations have already happened.
1405 //
1406 // Instead we copy scalar fields manually (timestamps, color
1407 // metadata, picture type, flags) and copy side-data with a hard
1408 // cap matching the converter's. Metadata dict and opaque_ref /
1409 // private_ref are intentionally NOT copied — they're rarely
1410 // populated on decoded frames and represent unbounded surfaces.
1411 use core::ptr::{addr_of, addr_of_mut, read_unaligned, write_unaligned};
1412 use ffmpeg_next::ffi::av_frame_new_side_data;
1413 unsafe {
1414 // Scalar timestamps / flags / color / SAR / crop. None of
1415 // these allocate.
1416 (*dst).pts = (*src).pts;
1417 (*dst).pkt_dts = (*src).pkt_dts;
1418 (*dst).duration = (*src).duration;
1419 (*dst).best_effort_timestamp = (*src).best_effort_timestamp;
1420 (*dst).quality = (*src).quality;
1421 (*dst).repeat_pict = (*src).repeat_pict;
1422 (*dst).flags = (*src).flags;
1423 (*dst).sample_aspect_ratio = (*src).sample_aspect_ratio;
1424 (*dst).crop_left = (*src).crop_left;
1425 (*dst).crop_top = (*src).crop_top;
1426 (*dst).crop_right = (*src).crop_right;
1427 (*dst).crop_bottom = (*src).crop_bottom;
1428 (*dst).time_base = (*src).time_base;
1429
1430 // Enum-typed fields: bit-copy raw to avoid materializing an
1431 // invalid `AVColorPrimaries` etc. on either side. `read_unaligned`
1432 // / `write_unaligned` on `i32` projections sidestep the bindgen
1433 // enum's discriminant-validity invariant.
1434 let pict_type_raw = read_unaligned(addr_of!((*src).pict_type) as *const i32);
1435 write_unaligned(addr_of_mut!((*dst).pict_type) as *mut i32, pict_type_raw);
1436 let cp_raw = read_unaligned(addr_of!((*src).color_primaries) as *const i32);
1437 write_unaligned(addr_of_mut!((*dst).color_primaries) as *mut i32, cp_raw);
1438 let trc_raw = read_unaligned(addr_of!((*src).color_trc) as *const i32);
1439 write_unaligned(addr_of_mut!((*dst).color_trc) as *mut i32, trc_raw);
1440 let cs_raw = read_unaligned(addr_of!((*src).colorspace) as *const i32);
1441 write_unaligned(addr_of_mut!((*dst).colorspace) as *mut i32, cs_raw);
1442 let cr_raw = read_unaligned(addr_of!((*src).color_range) as *const i32);
1443 write_unaligned(addr_of_mut!((*dst).color_range) as *mut i32, cr_raw);
1444 let cl_raw = read_unaligned(addr_of!((*src).chroma_location) as *const i32);
1445 write_unaligned(addr_of_mut!((*dst).chroma_location) as *mut i32, cl_raw);
1446
1447 // Side-data: bounded copy. `av_frame_new_side_data(dst, type,
1448 // size)` allocates the entry and returns a pointer to write
1449 // the payload bytes into; a null return is the OOM signal.
1450 // Callers (`transfer_hw_frame`, `drain_into_pending`) hand us
1451 // freshly-unref'd `dst` frames, so any prior side-data has
1452 // already been freed by `av_frame_unref` — we don't need to
1453 // strip dst's existing side-data here.
1454 // Read `nb_side_data` as the bindgen `c_int` and clamp non-
1455 // positive values BEFORE casting to `usize`. A negative value
1456 // (corrupt / version-skew decoder output) cast directly to
1457 // `usize` becomes a huge positive count and would walk OOB
1458 // memory below; pinning to zero up front collapses that to a
1459 // no-op. Same signed-count guard `sum_side_data_bytes` applies.
1460 let nb_side_data_raw = (*src).nb_side_data;
1461 let src_arr = (*src).side_data;
1462 if nb_side_data_raw > 0 && !src_arr.is_null() {
1463 let count_raw = nb_side_data_raw as usize;
1464 let count = count_raw.min(HW_COPY_SIDE_DATA_MAX_ENTRIES);
1465 if count_raw > HW_COPY_SIDE_DATA_MAX_ENTRIES {
1466 tracing::warn!(
1467 cap = HW_COPY_SIDE_DATA_MAX_ENTRIES,
1468 requested = count_raw,
1469 "mediadecode-ffmpeg: HW->CPU transfer side-data entry cap reached; truncating",
1470 );
1471 }
1472 let mut total_bytes: usize = 0;
1473 for i in 0..count {
1474 let entry = *src_arr.add(i);
1475 if entry.is_null() {
1476 continue;
1477 }
1478 let kind_raw = read_unaligned(addr_of!((*entry).type_) as *const i32);
1479 let size = (*entry).size;
1480 let data_ptr = (*entry).data;
1481 if size == 0 || data_ptr.is_null() {
1482 continue;
1483 }
1484 // Whitelist gate: only proceed when `kind_raw` matches a
1485 // known `AV_FRAME_DATA_*` constant the linked FFmpeg's
1486 // bindgen output knows about. Without this gate, a
1487 // version-skew or hostile decoder could write a side-data
1488 // type integer outside our bindgen's discriminant set, and
1489 // constructing the `AVFrameSideDataType` enum value (so
1490 // we could pass it to `av_frame_new_side_data`) would be
1491 // immediate UB before the call. Unknown types are dropped
1492 // with a debug-level log — the public converter's
1493 // `collect_side_data` walks the destination raw and would
1494 // also surface them as bare integers in `SideDataEntry.kind`.
1495 let Some(kind_enum) = whitelisted_side_data_kind(kind_raw) else {
1496 tracing::debug!(
1497 kind_raw,
1498 "mediadecode-ffmpeg: unknown AV_FRAME_DATA type during HW->CPU transfer; dropping",
1499 );
1500 continue;
1501 };
1502 let projected = total_bytes.saturating_add(size);
1503 if projected > HW_COPY_SIDE_DATA_MAX_TOTAL_BYTES {
1504 tracing::warn!(
1505 cap = HW_COPY_SIDE_DATA_MAX_TOTAL_BYTES,
1506 projected,
1507 "mediadecode-ffmpeg: HW->CPU transfer side-data byte cap reached; dropping rest",
1508 );
1509 break;
1510 }
1511 let new_entry = av_frame_new_side_data(dst, kind_enum, size);
1512 if new_entry.is_null() {
1513 // OOM mid-loop: stop copying further entries but don't
1514 // fail the whole transfer — the frames we did copy stay
1515 // attached. The convert path's cap is the final guard.
1516 tracing::warn!("mediadecode-ffmpeg: av_frame_new_side_data OOM during HW->CPU transfer",);
1517 break;
1518 }
1519 // SAFETY: `(*new_entry).data` is allocated for `size` bytes
1520 // per av_frame_new_side_data's contract; `data_ptr` is
1521 // valid for `size` reads per AVFrameSideData's contract.
1522 core::ptr::copy_nonoverlapping(data_ptr, (*new_entry).data, size);
1523 total_bytes = projected;
1524 }
1525 }
1526 }
1527 Ok(())
1528}
1529
1530/// `EAGAIN` and `EOF` are normal flow signals from `avcodec_receive_frame`
1531/// and must not be treated as backend failures.
1532fn is_transient(e: &ffmpeg_next::Error) -> bool {
1533 is_eagain(e) || matches!(e, ffmpeg_next::Error::Eof)
1534}
1535
1536/// Reject a `codec::Parameters` whose inner `*mut AVCodecParameters` is
1537/// null. This guards the public trust boundary: ffmpeg-next can produce
1538/// such a `Parameters` under OOM (`Parameters::new()` does not check
1539/// `avcodec_parameters_alloc`), and a safe caller can legally hand one
1540/// in. Without this check, the very next `(*p.as_ptr()).field` read
1541/// would be a null deref.
1542fn ensure_parameters_non_null(parameters: &codec::Parameters) -> Result<()> {
1543 // SAFETY: as_ptr() returns the inner *const AVCodecParameters; we just
1544 // inspect the pointer value (no deref).
1545 if unsafe { parameters.as_ptr() }.is_null() {
1546 return Err(Error::Ffmpeg(ffmpeg_next::Error::Other {
1547 errno: libc::ENOMEM,
1548 }));
1549 }
1550 Ok(())
1551}
1552
1553/// Allocate a fresh `frame::Video`, checking that `av_frame_alloc` did not
1554/// return NULL. ffmpeg-next's `frame::Video::empty()` does not surface that
1555/// failure and the resulting null pointer would be UB on the next field
1556/// access; this wrapper catches it and surfaces it as `ENOMEM`.
1557fn alloc_av_frame() -> std::result::Result<frame::Video, ffmpeg_next::Error> {
1558 let inner = frame::Video::empty();
1559 // SAFETY: as_ptr() just exposes the inner pointer for inspection.
1560 if unsafe { inner.as_ptr() }.is_null() {
1561 return Err(ffmpeg_next::Error::Other {
1562 errno: libc::ENOMEM,
1563 });
1564 }
1565 Ok(inner)
1566}
1567
1568/// Build a fresh `Context` from `parameters`, checking the underlying
1569/// `avcodec_alloc_context3` for NULL before passing it to
1570/// `avcodec_parameters_to_context`. ffmpeg-next's `Context::from_parameters`
1571/// skips that check and would feed a null pointer into FFmpeg under OOM —
1572/// undefined behavior. This helper surfaces the failure as `ENOMEM` and
1573/// frees the context if `parameters_to_context` itself errors.
1574pub(crate) fn build_codec_context(parameters: &codec::Parameters) -> Result<Context> {
1575 ensure_parameters_non_null(parameters)?;
1576 // SAFETY: avcodec_alloc_context3(NULL) returns a fresh AVCodecContext
1577 // or NULL on allocation failure.
1578 let ctx_ptr = unsafe { avcodec_alloc_context3(ptr::null()) };
1579 if ctx_ptr.is_null() {
1580 return Err(Error::Ffmpeg(ffmpeg_next::Error::Other {
1581 errno: libc::ENOMEM,
1582 }));
1583 }
1584 // SAFETY: ctx_ptr is non-null and freshly allocated; parameters.as_ptr()
1585 // returns a valid AVCodecParameters pointer; the function copies bytes
1586 // out of parameters into the context.
1587 let ret = unsafe { avcodec_parameters_to_context(ctx_ptr, parameters.as_ptr()) };
1588 if ret < 0 {
1589 // SAFETY: ctx_ptr was allocated by us and never handed to anyone else.
1590 let mut p = ctx_ptr;
1591 unsafe { avcodec_free_context(&mut p) };
1592 return Err(Error::Ffmpeg(ffmpeg_next::Error::from(ret)));
1593 }
1594 // SAFETY: ctx_ptr is valid; passing `owner: None` means our wrapper owns
1595 // the allocation and `Context::drop` will run `avcodec_free_context`.
1596 Ok(unsafe { Context::wrap(ctx_ptr, None) })
1597}
1598
1599/// Checked deep-clone of `codec::Parameters`. ffmpeg-next's
1600/// `Parameters::clone` allocates via `avcodec_parameters_alloc` without
1601/// checking for NULL and runs `avcodec_parameters_copy` without checking
1602/// the return code. On `ENOMEM` the result is a `Parameters` with a null
1603/// inner pointer, which becomes UB when later passed to FFmpeg.
1604///
1605/// This helper performs both calls explicitly, frees a partial allocation
1606/// on failure, and surfaces the AVERROR. The returned `Parameters` has
1607/// `owner: None`, severing any Rc link to the caller's demuxer (the
1608/// reason we deep-clone in the first place — see Send safety in
1609/// `VideoDecoder::open`).
1610pub(crate) fn try_clone_parameters(
1611 src: &codec::Parameters,
1612) -> std::result::Result<codec::Parameters, ffmpeg_next::Error> {
1613 // Reject a null inner pointer at the boundary; a deref inside
1614 // avcodec_parameters_copy below would otherwise be UB.
1615 if unsafe { src.as_ptr() }.is_null() {
1616 return Err(ffmpeg_next::Error::Other {
1617 errno: libc::ENOMEM,
1618 });
1619 }
1620 // SAFETY: avcodec_parameters_alloc returns a fresh AVCodecParameters
1621 // pointer or NULL on allocation failure.
1622 let dst_ptr = unsafe { avcodec_parameters_alloc() };
1623 if dst_ptr.is_null() {
1624 return Err(ffmpeg_next::Error::Other {
1625 errno: libc::ENOMEM,
1626 });
1627 }
1628 // SAFETY: dst_ptr is non-null and freshly allocated; src.as_ptr() is
1629 // a valid AVCodecParameters pointer; the function copies bytes from
1630 // src into dst.
1631 let ret = unsafe { avcodec_parameters_copy(dst_ptr, src.as_ptr()) };
1632 if ret < 0 {
1633 // SAFETY: dst_ptr was allocated by us and never handed out.
1634 let mut p = dst_ptr;
1635 unsafe { avcodec_parameters_free(&mut p) };
1636 return Err(ffmpeg_next::Error::from(ret));
1637 }
1638 // SAFETY: dst_ptr is a valid AVCodecParameters; passing `owner: None`
1639 // means our wrapper owns the allocation and `Parameters::drop` will
1640 // call `avcodec_parameters_free`.
1641 Ok(unsafe { codec::Parameters::wrap(dst_ptr, None) })
1642}
1643
1644/// Checked counterpart to `Packet::clone()`. ffmpeg-next's `clone_from`
1645/// calls `av_packet_ref` and ignores the int return value; on `ENOMEM`
1646/// the destination is left empty while the caller assumes the clone
1647/// succeeded — corrupting any later replay history. This helper surfaces
1648/// the AVERROR. The result is a refcounted shallow clone — the payload
1649/// buffer is shared with `src` rather than deep-copied; the probe replay
1650/// only sends packets through `avcodec_send_packet`, which does not
1651/// require a writable buffer.
1652fn try_clone_packet(src: &Packet) -> std::result::Result<Packet, ffmpeg_next::Error> {
1653 let mut dst = Packet::empty();
1654 // SAFETY: dst is a freshly zero-initialized Packet (av_init_packet inside
1655 // Packet::empty); av_packet_ref initializes its data fields from src's
1656 // refcounted buffer or returns AVERROR(ENOMEM) on failure.
1657 let ret = unsafe { av_packet_ref(dst.as_mut_ptr(), src.as_ptr()) };
1658 if ret < 0 {
1659 return Err(ffmpeg_next::Error::from(ret));
1660 }
1661 Ok(dst)
1662}
1663
1664/// Sum of `AVPacket.side_data[i].size` across every entry, plus
1665/// `nb_entries * SIDE_DATA_ENTRY_OVERHEAD` (descriptor + AVBufferRef +
1666/// allocator bookkeeping per entry). `av_packet_ref` performs a deep
1667/// copy of side data via `av_packet_copy_props`, so each probe-buffered
1668/// clone retains every one of these bytes. Charging both keeps
1669/// `MAX_PROBE_PACKET_BYTES` a true upper bound — without the overhead,
1670/// many zero-size entries slip past the cap on pure descriptor cost.
1671///
1672/// Walks at most `max_entries` entries even when `side_data_elems`
1673/// reports a larger count. Defense-in-depth against a corrupt or hostile
1674/// packet whose `side_data_elems` lies about the actual array length:
1675/// the caller is expected to also reject any packet whose count exceeds
1676/// the cap (so the inflated clone is never created), but bounding the
1677/// walk here means a stale or weaponised value can never trigger an
1678/// unbounded raw-pointer scan from the safe API.
1679///
1680/// Reads only the `size` field of each `AVPacketSideData` entry — never
1681/// touches the bindgen `AVPacketSideDataType` enum, so no UB even if a
1682/// future FFmpeg adds a side-data type discriminant our build doesn't
1683/// know.
1684fn packet_side_data_bytes(packet: &Packet, max_entries: usize) -> usize {
1685 // SAFETY: AVPacket.side_data is `*mut AVPacketSideData` and
1686 // side_data_elems is `c_int`; both are raw struct fields safe to read.
1687 // Field projection (`.size`) does not reconstruct the enum-typed `type_`
1688 // field, so the bindgen-enum UB hazard does not apply here.
1689 unsafe {
1690 let raw = packet.as_ptr();
1691 let nel = (*raw).side_data_elems;
1692 let arr = (*raw).side_data;
1693 if arr.is_null() || nel <= 0 || max_entries == 0 {
1694 return 0;
1695 }
1696 let count = (nel as usize).min(max_entries);
1697 let mut total = count.saturating_mul(SIDE_DATA_ENTRY_OVERHEAD);
1698 for i in 0..count {
1699 let entry = arr.add(i);
1700 total = total.saturating_add((*entry).size);
1701 }
1702 total
1703 }
1704}
1705
1706/// Number of `AVPacketSideData` entries on `packet`. The probe buffer
1707/// uses this to enforce [`MAX_PROBE_PACKET_SIDE_DATA_ENTRIES`] before
1708/// cloning, so a packet whose entry count alone would dominate retained
1709/// memory is rejected up front.
1710fn packet_side_data_count(packet: &Packet) -> usize {
1711 // SAFETY: side_data_elems is `c_int`, safe to read; clamp negatives to 0.
1712 let nel = unsafe { (*packet.as_ptr()).side_data_elems };
1713 if nel <= 0 { 0 } else { nel as usize }
1714}
1715
1716/// Just `EAGAIN` (separate from EOF — the FFmpeg send/receive state machine
1717/// distinguishes "drain output and retry" from "stream over").
1718fn is_eagain(e: &ffmpeg_next::Error) -> bool {
1719 matches!(e, ffmpeg_next::Error::Other { errno } if *errno == ffmpeg_next::error::EAGAIN)
1720}
1721
1722/// Look up the decoder for `parameters` without going through the bindgen
1723/// `AVCodecID` Rust enum. Reads the codec_id field as raw `u32` via
1724/// `addr_of!` + `ptr::read` so a value not in our build's discriminant
1725/// set never invokes UB.
1726fn find_decoder(parameters: &codec::Parameters) -> Result<Codec> {
1727 ensure_parameters_non_null(parameters)?;
1728 // SAFETY: parameters' inner pointer is non-null (checked above);
1729 // addr_of! projects to the codec_id field; the *const u32 cast is sound
1730 // because AVCodecID is `#[repr(u32)]` (same size and alignment as u32).
1731 // Reading as u32 cannot be UB regardless of the value FFmpeg wrote.
1732 let raw_id: u32 =
1733 unsafe { ptr::read(ptr::addr_of!((*parameters.as_ptr()).codec_id) as *const u32) };
1734
1735 // Call C `avcodec_find_decoder` via our local `c_int`-typed shim — we
1736 // never construct an `AVCodecID` enum from `raw_id`. The C function
1737 // returns NULL for unknown ids, which we surface as `Error::NoCodec`.
1738 // SAFETY: avcodec_find_decoder is a pure FFmpeg lookup; passing any
1739 // c_int is sound (returns NULL for unknown).
1740 let codec_ptr = unsafe { c_shims::avcodec_find_decoder(raw_id as libc::c_int) };
1741 if codec_ptr.is_null() {
1742 return Err(Error::NoCodec(raw_id));
1743 }
1744 // SAFETY: codec_ptr is a non-null *const AVCodec into FFmpeg's static
1745 // codec table; it lives for the duration of the program.
1746 Ok(unsafe { Codec::wrap(codec_ptr) })
1747}
1748
1749/// Drain output frames from a candidate decoder during probe replay,
1750/// transferring each one from the candidate's HW context to a fresh CPU
1751/// frame and queueing it. Returns `Ok(())` once the candidate signals
1752/// EAGAIN/EOF. The transfer happens while the candidate is still alive
1753/// (its `AVHWFramesContext` is reachable); the resulting CPU frames remain
1754/// valid after the candidate is committed because they hold their own
1755/// buffer references with no dependency on the original device context.
1756fn drain_into_pending(
1757 decoder: &mut ffmpeg_next::decoder::Video,
1758 hw_buf: &mut frame::Video,
1759 pending: &mut VecDeque<frame::Video>,
1760 pending_bytes: &mut usize,
1761 max_bytes: usize,
1762) -> std::result::Result<(), ffmpeg_next::Error> {
1763 loop {
1764 match decoder.receive_frame(hw_buf) {
1765 Ok(()) => {
1766 // Pre-transfer cap check: if we are already at or over either cap,
1767 // the candidate is producing more than we can hold. Treat as an
1768 // explicit candidate failure so `advance_probe` can try the next
1769 // backend instead of committing a stream with silently-dropped
1770 // frames in the middle.
1771 //
1772 // TODO: at very large frame sizes (8K HDR P010, > ~96 MiB each)
1773 // even a single retained frame is significant. Future direction:
1774 // memmap-backed pending frames (write to a temp file or shared
1775 // memory segment) so the resident set stays bounded even when the
1776 // byte cap is raised. Out of scope for now.
1777 if pending.len() >= MAX_PROBE_PENDING_FRAMES || *pending_bytes >= max_bytes {
1778 tracing::warn!(
1779 frames = pending.len(),
1780 bytes = *pending_bytes,
1781 max_frames = MAX_PROBE_PENDING_FRAMES,
1782 max_bytes = max_bytes,
1783 "hwdecode: probe pending cap reached; failing candidate replay"
1784 );
1785 // SAFETY: hw_buf is owned and valid; unref of an empty frame is a no-op.
1786 unsafe { av_frame_unref(hw_buf.as_mut_ptr()) };
1787 return Err(ffmpeg_next::Error::Other {
1788 errno: libc::ENOMEM,
1789 });
1790 }
1791 // Pre-transfer size guard: `av_hwframe_transfer_data` will
1792 // allocate the CPU buffer based on `hw_buf`'s dimensions. If a
1793 // single frame's worst-case footprint already pushes past the
1794 // cap, refuse the candidate **before** allocating so RSS does
1795 // not spike on a frame we'd immediately drop. Uses a width *
1796 // height * `WORST_CASE_BYTES_PER_PIXEL` upper bound; the
1797 // post-transfer accounting via `cpu_frame_bytes` below stays in
1798 // place as a backstop using the actual stride/format.
1799 let estimated_bytes = match estimate_transfer_bytes(hw_buf) {
1800 Some(b) => b,
1801 None => {
1802 // SAFETY: AVFrame.width/height are c_int reads.
1803 let (w, h) = unsafe {
1804 let raw = hw_buf.as_ptr();
1805 ((*raw).width, (*raw).height)
1806 };
1807 tracing::warn!(
1808 width = w,
1809 height = h,
1810 "hwdecode: HW frame dimensions invalid for sizing; failing candidate replay"
1811 );
1812 unsafe { av_frame_unref(hw_buf.as_mut_ptr()) };
1813 return Err(ffmpeg_next::Error::Other {
1814 errno: libc::ENOMEM,
1815 });
1816 }
1817 };
1818 let estimated_total = pending_bytes.saturating_add(estimated_bytes);
1819 if estimated_total > max_bytes {
1820 // SAFETY: AVFrame.width/height are c_int reads.
1821 let (w, h) = unsafe {
1822 let raw = hw_buf.as_ptr();
1823 ((*raw).width, (*raw).height)
1824 };
1825 tracing::warn!(
1826 pending_bytes = *pending_bytes,
1827 estimated_bytes,
1828 width = w,
1829 height = h,
1830 max_bytes = max_bytes,
1831 "hwdecode: pre-transfer size estimate exceeds cap; \
1832 refusing candidate replay before allocating CPU frame"
1833 );
1834 unsafe { av_frame_unref(hw_buf.as_mut_ptr()) };
1835 return Err(ffmpeg_next::Error::Other {
1836 errno: libc::ENOMEM,
1837 });
1838 }
1839 let mut cpu = alloc_av_frame()?;
1840 // SAFETY: hw_buf is a freshly-decoded HW frame;
1841 // `av_hwframe_transfer_data` allocates pixel buffers on `cpu`.
1842 // We use `copy_frame_props_minimal` (only `pts`) instead of
1843 // `av_frame_copy_props` for the same reason as
1844 // `transfer_hw_frame`: the public `Frame` API does not expose
1845 // side data / metadata / opaque refs, so deep-copying them per
1846 // frame is pure cost and an unbounded allocation source on
1847 // attacker-controlled streams.
1848 unsafe {
1849 let r1 = av_hwframe_transfer_data(cpu.as_mut_ptr(), hw_buf.as_ptr(), 0);
1850 if r1 < 0 {
1851 return Err(ffmpeg_next::Error::from(r1));
1852 }
1853 }
1854 // Same post-transfer pix_fmt validation as `transfer_hw_frame`.
1855 // A driver that picks a CPU format outside our supported set
1856 // would queue an unusable frame here; later, when
1857 // `try_pop_pending` hands it to the caller, `Frame::row` /
1858 // `Frame::as_ptr` would return `None`. Refuse the candidate
1859 // before the queue grows so probing advances to the next
1860 // backend instead.
1861 let cpu_raw_fmt: i32 = unsafe { (*cpu.as_ptr()).format };
1862 let cpu_pix_fmt = crate::boundary::from_av_pixel_format(cpu_raw_fmt);
1863 if !crate::frame::is_supported_cpu_pix_fmt(cpu_pix_fmt) {
1864 tracing::warn!(
1865 pix_fmt = cpu_raw_fmt,
1866 "hwdecode: candidate produced unsupported CPU pix_fmt during \
1867 probe replay; failing candidate"
1868 );
1869 return Err(ffmpeg_next::Error::Other {
1870 errno: libc::EINVAL,
1871 });
1872 }
1873 let pixel_bytes = match cpu_frame_bytes(&cpu) {
1874 Some(b) => b,
1875 None => {
1876 // Unknown pix_fmt or vertically-flipped layout — we cannot
1877 // bound this frame's contribution against the byte cap, so up
1878 // to MAX_PROBE_PENDING_FRAMES of them could exhaust memory.
1879 // Fail the candidate so probing tries the next backend
1880 // rather than queueing untracked allocations.
1881 // SAFETY: AVFrame.format is c_int, safe to read.
1882 let pix_fmt: i32 = unsafe { (*cpu.as_ptr()).format };
1883 tracing::warn!(
1884 pix_fmt,
1885 "hwdecode: cannot size unknown CPU pix_fmt during replay; failing candidate"
1886 );
1887 // cpu drops here.
1888 return Err(ffmpeg_next::Error::Other {
1889 errno: libc::ENOMEM,
1890 });
1891 }
1892 };
1893 // Account for side-data bytes that `av_frame_copy_props`
1894 // will deep-copy from the source HW frame. HDR streams
1895 // typically carry mastering display + content light level
1896 // (~50 bytes) and dynamic HDR metadata (~few hundred bytes);
1897 // pathological side-data could otherwise quietly bypass the
1898 // pixel-data byte cap.
1899 // SAFETY: hw_buf is a valid AVFrame; we read scalar fields
1900 // and pointer arrays without forming a `&AVFrame`.
1901 let side_data_bytes = unsafe { sum_side_data_bytes(hw_buf.as_ptr()) };
1902 let new_total = pending_bytes
1903 .saturating_add(pixel_bytes)
1904 .saturating_add(side_data_bytes);
1905 if new_total > max_bytes {
1906 tracing::warn!(
1907 pending_bytes = *pending_bytes,
1908 pixel_bytes,
1909 side_data_bytes,
1910 max_bytes,
1911 "hwdecode: queueing this frame would exceed byte cap; \
1912 failing candidate replay"
1913 );
1914 // cpu drops here without ever paying a metadata deep copy.
1915 return Err(ffmpeg_next::Error::Other {
1916 errno: libc::ENOMEM,
1917 });
1918 }
1919 // Cap check passed — copy AVFrame metadata. SAFETY: cpu and
1920 // hw_buf are both valid AVFrames we own. On failure (OOM
1921 // during side-data alloc) we propagate so the probe candidate
1922 // is treated as failed rather than queueing a frame whose
1923 // metadata silently disappeared.
1924 unsafe { copy_frame_props_minimal(cpu.as_mut_ptr(), hw_buf.as_ptr()) }?;
1925 *pending_bytes = new_total;
1926 pending.push_back(cpu);
1927 }
1928 Err(e) if is_transient(&e) => return Ok(()),
1929 Err(e) => return Err(e),
1930 }
1931 }
1932}
1933
1934/// Allocated frame dimensions according to `hw_buf.hw_frames_ctx`.
1935///
1936/// Per FFmpeg's `libavutil/hwcontext.c::transfer_data_alloc`, the CPU
1937/// destination of `av_hwframe_transfer_data` is allocated using
1938/// `AVHWFramesContext.width / .height` (the *allocated* surface size of
1939/// the HW pool); only afterwards is `dst->width / dst->height` reset to
1940/// `src->width / src->height` (the *display* size). For cropped or
1941/// heavily aligned streams the allocated dims can be much larger than
1942/// the display dims (e.g. coded 8192×8192 surface with a 100×100
1943/// display crop), so any byte-cap accounting that uses display dims
1944/// undercounts by `allocated_height / display_height` and lets the
1945/// real allocation slip past the cap.
1946///
1947/// Returns `None` when no `hw_frames_ctx` is attached or the dimensions
1948/// are non-positive — the caller treats `None` as "cannot prove
1949/// allocation extent, fail the candidate."
1950fn hw_frames_ctx_dimensions(frame: &frame::Video) -> Option<(i32, i32)> {
1951 // SAFETY: AVFrame.hw_frames_ctx is `*mut AVBufferRef`. When non-null,
1952 // its `data` field points to an `AVHWFramesContext`. We read `.width`
1953 // and `.height` (both `c_int`) via field projection — neither field is
1954 // enum-typed, so no bindgen-enum UB hazard.
1955 unsafe {
1956 let raw = frame.as_ptr();
1957 let hw_ctx_ref = (*raw).hw_frames_ctx;
1958 if hw_ctx_ref.is_null() {
1959 return None;
1960 }
1961 let data = (*hw_ctx_ref).data;
1962 if data.is_null() {
1963 return None;
1964 }
1965 let frames_ctx = data as *const AVHWFramesContext;
1966 let w: i32 = ptr::read(ptr::addr_of!((*frames_ctx).width));
1967 let h: i32 = ptr::read(ptr::addr_of!((*frames_ctx).height));
1968 if w <= 0 || h <= 0 {
1969 return None;
1970 }
1971 Some((w, h))
1972 }
1973}
1974
1975/// Conservative upper-bound estimate of the bytes
1976/// `av_hwframe_transfer_data` will allocate when downloading `hw_buf` to
1977/// a CPU frame. Used by [`drain_into_pending`] as a pre-transfer guard
1978/// so a candidate replay can refuse a frame whose footprint would
1979/// exceed the byte budget *without* first paying the allocation.
1980///
1981/// Sizes from `hw_buf.hw_frames_ctx` (the allocated dims used by the
1982/// FFmpeg transfer path) rather than `AVFrame.width / .height` (display
1983/// dims). On a cropped stream the two can differ by orders of magnitude
1984/// and using display dims would let the real allocation slip past the
1985/// cap.
1986///
1987/// Returns `None` when `hw_frames_ctx` is missing or its width/height
1988/// are non-positive — caller treats as candidate failure since we
1989/// cannot prove the allocation extent. (A SW source frame on the probe
1990/// replay path is not expected; we don't fall back to display dims
1991/// because that's the exact attack the cap is meant to prevent.)
1992fn estimate_transfer_bytes(hw_buf: &frame::Video) -> Option<usize> {
1993 let (w, h) = hw_frames_ctx_dimensions(hw_buf)?;
1994 Some(
1995 (w as usize)
1996 .saturating_mul(h as usize)
1997 .saturating_mul(WORST_CASE_BYTES_PER_PIXEL),
1998 )
1999}
2000
2001/// Exact resident size of a CPU frame: sum of `AVFrame.buf[i].size`
2002/// across every populated buffer.
2003///
2004/// `AVBufferRef.size` is documented as "Size of data in bytes" — the
2005/// real allocated extent FFmpeg used. Reading it directly handles the
2006/// cropped/aligned case where `AVFrame.height` (display) is smaller
2007/// than the underlying allocation height (the `AVHWFramesContext`
2008/// surface size FFmpeg sized the buffer for); a `linesize *
2009/// plane_height_for(display_height)` formula would undercount in that
2010/// case.
2011///
2012/// Returns `None` only when `linesize[0]` is negative — FFmpeg's
2013/// vertically-flipped layout. The crate's safe row accessors
2014/// ([`crate::Frame::row`] / [`crate::Frame::rows`]) already reject
2015/// negative-stride frames, so queueing one during probe replay would
2016/// just delay the failure to the consumer; refusing here lets the
2017/// probe loop advance to the next backend instead.
2018fn cpu_frame_bytes(frame: &frame::Video) -> Option<usize> {
2019 // SAFETY: AVFrame.linesize is `[c_int; 8]`; AVFrame.buf is
2020 // `[*mut AVBufferRef; 8]`; AVBufferRef.size is `usize`. All are
2021 // primitive reads / pointer dereferences with no enum interpretation.
2022 unsafe {
2023 let raw = frame.as_ptr();
2024 let first_linesize = (*raw).linesize[0];
2025 // Vertically-flipped (negative linesize) is the only "unsizeable"
2026 // case we still surface as `None`; everything else can be exactly
2027 // measured from buf[i].size.
2028 if first_linesize < 0 {
2029 return None;
2030 }
2031 let mut total: usize = 0;
2032 for i in 0..(*raw).buf.len() {
2033 let buf = (*raw).buf[i];
2034 if buf.is_null() {
2035 continue;
2036 }
2037 total = total.saturating_add((*buf).size);
2038 }
2039 Some(total)
2040 }
2041}
2042
2043#[allow(dead_code)]
2044fn _assert_send() {
2045 fn check<T: Send>() {}
2046 check::<VideoDecoder>();
2047}
2048
2049#[cfg(test)]
2050mod tests {
2051 use super::*;
2052
2053 #[test]
2054 fn no_codec_for_unknown_id() {
2055 let err = Error::NoCodec(0);
2056 assert!(format!("{err}").contains("no decoder"));
2057 }
2058
2059 #[test]
2060 fn videodecoder_is_send() {
2061 _assert_send();
2062 }
2063
2064 #[test]
2065 fn is_transient_recognises_eagain_and_eof() {
2066 let eagain = ffmpeg_next::Error::Other {
2067 errno: ffmpeg_next::error::EAGAIN,
2068 };
2069 assert!(is_transient(&eagain));
2070 assert!(is_transient(&ffmpeg_next::Error::Eof));
2071 let other = ffmpeg_next::Error::InvalidData;
2072 assert!(!is_transient(&other));
2073 }
2074
2075 /// Regression: a `codec::Parameters` with a null inner pointer must be
2076 /// rejected at the entrypoint, not deref'd. ffmpeg-next's
2077 /// `Parameters::new()` does not check `avcodec_parameters_alloc()`, so a
2078 /// safe caller can hand us such a value under OOM.
2079 #[test]
2080 fn open_rejects_null_parameters() {
2081 // SAFETY: Parameters::wrap accepts any pointer; we explicitly construct
2082 // one with null inner. avcodec_parameters_free is null-safe on Drop.
2083 let null_params = unsafe { codec::Parameters::wrap(std::ptr::null_mut(), None) };
2084 match VideoDecoder::open(null_params) {
2085 Ok(_) => panic!("open should fail on null parameters"),
2086 Err(Error::Ffmpeg(ffmpeg_next::Error::Other { errno })) => {
2087 assert_eq!(errno, libc::ENOMEM, "expected ENOMEM, got {errno}");
2088 }
2089 Err(other) => panic!("expected Ffmpeg(Other {{ ENOMEM }}), got {other:?}"),
2090 }
2091 }
2092
2093 #[test]
2094 fn open_with_rejects_null_parameters() {
2095 // SAFETY: see open_rejects_null_parameters.
2096 let null_params = unsafe { codec::Parameters::wrap(std::ptr::null_mut(), None) };
2097 match VideoDecoder::open_with(null_params, Backend::VideoToolbox) {
2098 Ok(_) => panic!("open_with should fail on null parameters"),
2099 Err(Error::Ffmpeg(ffmpeg_next::Error::Other { errno })) => {
2100 assert_eq!(errno, libc::ENOMEM, "expected ENOMEM, got {errno}");
2101 }
2102 Err(other) => panic!("expected Ffmpeg(Other {{ ENOMEM }}), got {other:?}"),
2103 }
2104 }
2105
2106 /// `try_clone_packet` calls `av_packet_ref`, which deep-copies side
2107 /// data via `av_packet_copy_props`. The probe budget therefore has to
2108 /// include side-data bytes — otherwise a stream with a 16-byte payload
2109 /// and a 1 MiB side-data attachment would only consume 16 bytes of the
2110 /// 64 MiB budget per packet, and 256 buffered clones would retain
2111 /// ~256 MiB of side data while logs claim a few KiB.
2112 #[test]
2113 fn packet_side_data_counts_against_probe_budget() {
2114 use ffmpeg_next::ffi::{AVPacketSideDataType, av_packet_new_side_data};
2115
2116 const PAYLOAD_SIZE: usize = 16;
2117 const SIDE_DATA_SIZE: usize = 1024 * 1024; // 1 MiB
2118
2119 let mut packet = Packet::new(PAYLOAD_SIZE);
2120 // SAFETY: packet is a freshly allocated AVPacket; av_packet_new_side_data
2121 // attaches a fresh `SIDE_DATA_SIZE`-byte buffer of the requested type
2122 // to it and returns a writable pointer (or NULL on OOM).
2123 let p = unsafe {
2124 av_packet_new_side_data(
2125 packet.as_mut_ptr(),
2126 AVPacketSideDataType::AV_PKT_DATA_NEW_EXTRADATA,
2127 SIDE_DATA_SIZE,
2128 )
2129 };
2130 assert!(!p.is_null(), "av_packet_new_side_data returned NULL");
2131
2132 assert_eq!(packet.size(), PAYLOAD_SIZE);
2133 let side = packet_side_data_bytes(&packet, MAX_PROBE_PACKET_SIDE_DATA_ENTRIES);
2134 assert!(
2135 side >= SIDE_DATA_SIZE,
2136 "side-data accounting must include the attached buffer; got {side}"
2137 );
2138 let total = packet.size().saturating_add(side);
2139 assert!(
2140 total >= PAYLOAD_SIZE + SIDE_DATA_SIZE,
2141 "probe budget must charge payload + side data; got {total}"
2142 );
2143 }
2144
2145 #[test]
2146 fn packet_side_data_is_zero_when_no_side_data() {
2147 let packet = Packet::new(64);
2148 assert_eq!(
2149 packet_side_data_bytes(&packet, MAX_PROBE_PACKET_SIDE_DATA_ENTRIES),
2150 0
2151 );
2152 assert_eq!(packet_side_data_count(&packet), 0);
2153 }
2154
2155 /// Packets with many tiny side-data entries must be charged the
2156 /// per-entry descriptor + ref overhead, even when each entry's payload
2157 /// `size` is zero. Without `SIDE_DATA_ENTRY_OVERHEAD`, a packet stuffed
2158 /// with N zero-byte entries would charge 0 bytes against the budget
2159 /// while `av_packet_ref` still allocates ~`N * 80` bytes of descriptor
2160 /// + AVBufferRef + allocator overhead per cloned copy.
2161 #[test]
2162 fn packet_side_data_bytes_charges_descriptor_overhead_for_zero_size_entries() {
2163 use ffmpeg_next::ffi::{AVPacketSideDataType, av_packet_new_side_data};
2164
2165 let mut packet = Packet::new(0);
2166 // Attach two zero-byte entries of distinct types so neither call
2167 // replaces the other.
2168 let p1 = unsafe {
2169 av_packet_new_side_data(
2170 packet.as_mut_ptr(),
2171 AVPacketSideDataType::AV_PKT_DATA_NEW_EXTRADATA,
2172 0,
2173 )
2174 };
2175 let p2 = unsafe {
2176 av_packet_new_side_data(
2177 packet.as_mut_ptr(),
2178 AVPacketSideDataType::AV_PKT_DATA_PALETTE,
2179 0,
2180 )
2181 };
2182 assert!(
2183 !p1.is_null() && !p2.is_null(),
2184 "av_packet_new_side_data NULL"
2185 );
2186
2187 assert_eq!(packet_side_data_count(&packet), 2);
2188 let bytes = packet_side_data_bytes(&packet, MAX_PROBE_PACKET_SIDE_DATA_ENTRIES);
2189 assert!(
2190 bytes >= 2 * SIDE_DATA_ENTRY_OVERHEAD,
2191 "must charge descriptor overhead per entry even at zero payload; got {bytes}"
2192 );
2193 }
2194
2195 /// `packet_side_data_bytes` must clamp its walk to `max_entries`
2196 /// regardless of `side_data_elems`. Defense-in-depth: the caller is
2197 /// expected to short-circuit packets whose count exceeds the cap, but
2198 /// if a corrupt or weaponised packet ever does reach the helper, the
2199 /// internal cap prevents an unbounded raw-pointer walk.
2200 ///
2201 /// This test attaches 5 entries of distinct types and asks the helper
2202 /// to walk only the first 2. Result must equal exactly `2 * overhead +
2203 /// (size_a + size_b)`, confirming entries 3-5 were not even read.
2204 #[test]
2205 fn packet_side_data_bytes_respects_max_entries_cap() {
2206 use ffmpeg_next::ffi::{AVPacketSideDataType, av_packet_new_side_data};
2207
2208 let mut packet = Packet::new(0);
2209 // Five distinct side-data types so each `av_packet_new_side_data`
2210 // call appends rather than replaces.
2211 let types_and_sizes: [(AVPacketSideDataType, usize); 5] = [
2212 (AVPacketSideDataType::AV_PKT_DATA_NEW_EXTRADATA, 100),
2213 (AVPacketSideDataType::AV_PKT_DATA_PALETTE, 200),
2214 (AVPacketSideDataType::AV_PKT_DATA_REPLAYGAIN, 300),
2215 (AVPacketSideDataType::AV_PKT_DATA_DISPLAYMATRIX, 400),
2216 (AVPacketSideDataType::AV_PKT_DATA_STEREO3D, 500),
2217 ];
2218 for (ty, size) in types_and_sizes {
2219 let p = unsafe { av_packet_new_side_data(packet.as_mut_ptr(), ty, size) };
2220 assert!(!p.is_null(), "av_packet_new_side_data returned NULL");
2221 }
2222 assert_eq!(packet_side_data_count(&packet), 5);
2223
2224 let walked_2 = packet_side_data_bytes(&packet, 2);
2225 let walked_5 = packet_side_data_bytes(&packet, 5);
2226
2227 assert_eq!(
2228 walked_2,
2229 2 * SIDE_DATA_ENTRY_OVERHEAD + 100 + 200,
2230 "max_entries=2 must walk exactly the first two entries"
2231 );
2232 assert_eq!(
2233 walked_5,
2234 5 * SIDE_DATA_ENTRY_OVERHEAD + 100 + 200 + 300 + 400 + 500,
2235 "max_entries=5 must walk all five entries"
2236 );
2237 // max_entries=0 short-circuits to 0.
2238 assert_eq!(packet_side_data_bytes(&packet, 0), 0);
2239 // max_entries larger than the actual count clamps to the actual count
2240 // (no out-of-bounds walk past `side_data_elems`).
2241 let walked_huge = packet_side_data_bytes(&packet, 1_000_000);
2242 assert_eq!(walked_huge, walked_5);
2243 }
2244
2245 /// `MAX_PROBE_PACKET_SIDE_DATA_ENTRIES` is the cliff above which a
2246 /// packet is rejected from the probe buffer regardless of byte total —
2247 /// pure descriptor inflation is its own attack vector. Sanity-check
2248 /// that `packet_side_data_count` reports the value the cap is checked
2249 /// against.
2250 #[test]
2251 fn packet_side_data_count_reports_attached_entries() {
2252 use ffmpeg_next::ffi::{AVPacketSideDataType, av_packet_new_side_data};
2253
2254 let mut packet = Packet::new(0);
2255 let _p1 = unsafe {
2256 av_packet_new_side_data(
2257 packet.as_mut_ptr(),
2258 AVPacketSideDataType::AV_PKT_DATA_NEW_EXTRADATA,
2259 4,
2260 )
2261 };
2262 let _p2 = unsafe {
2263 av_packet_new_side_data(
2264 packet.as_mut_ptr(),
2265 AVPacketSideDataType::AV_PKT_DATA_PALETTE,
2266 4,
2267 )
2268 };
2269 assert_eq!(packet_side_data_count(&packet), 2);
2270 }
2271
2272 /// `cpu_frame_bytes` must refuse to size a frame whose first plane has
2273 /// a negative `linesize`. Pre-fix, the loop break treated negative the
2274 /// same as zero (FFmpeg's "no more populated planes" sentinel), so a
2275 /// vertically-flipped frame returned `Some(0)` and `drain_into_pending`
2276 /// would queue it as a 0-byte allocation — letting up to
2277 /// `MAX_PROBE_PENDING_FRAMES` such frames bypass the configured byte
2278 /// budget entirely.
2279 #[test]
2280 fn cpu_frame_bytes_rejects_negative_first_plane_linesize() {
2281 let mut f = frame::Video::empty();
2282 // SAFETY: f is freshly allocated; we set `format` to NV12 and the
2283 // first plane's linesize negative (FFmpeg's vertical-flip convention).
2284 // No backing data buffer is allocated — cpu_frame_bytes must reject
2285 // before any pointer dereference.
2286 unsafe {
2287 let raw = f.as_mut_ptr();
2288 (*raw).format = ffmpeg_next::ffi::AVPixelFormat::AV_PIX_FMT_NV12 as i32;
2289 (*raw).width = 1920;
2290 (*raw).height = 1080;
2291 (*raw).linesize[0] = -1920;
2292 (*raw).linesize[1] = -1920;
2293 }
2294 assert!(
2295 cpu_frame_bytes(&f).is_none(),
2296 "negative linesize must be unsizeable, not Some(0)"
2297 );
2298 }
2299
2300 /// Build a synthetic `AVHWFramesContext`-backed `AVBufferRef` for
2301 /// tests. The buffer's data is a zeroed `AVHWFramesContext` with only
2302 /// `width` and `height` populated — enough for [`hw_frames_ctx_dimensions`]
2303 /// / [`estimate_transfer_bytes`] to read the allocated dims.
2304 ///
2305 /// Returned ref has refcount 1; transfer ownership into
2306 /// `AVFrame.hw_frames_ctx` and let `av_frame_unref` (called by
2307 /// `frame::Video::Drop`) free it via `av_buffer_default_free`.
2308 fn make_hw_frames_ctx_ref(w: i32, h: i32) -> *mut ffmpeg_next::ffi::AVBufferRef {
2309 use ffmpeg_next::ffi::av_buffer_alloc;
2310 use std::mem::size_of;
2311
2312 // SAFETY: `av_buffer_alloc(n)` returns a fresh `AVBufferRef` whose
2313 // `.data` points to `n` bytes of allocator-supplied storage. We
2314 // zero the AVHWFramesContext and write only `width` / `height`,
2315 // which is all the helpers we test read.
2316 unsafe {
2317 let buf = av_buffer_alloc(size_of::<AVHWFramesContext>());
2318 assert!(!buf.is_null(), "av_buffer_alloc returned NULL");
2319 let data = (*buf).data as *mut AVHWFramesContext;
2320 std::ptr::write_bytes(data, 0, 1);
2321 (*data).width = w;
2322 (*data).height = h;
2323 buf
2324 }
2325 }
2326
2327 /// Sanity-check the positive path with a real allocation: an
2328 /// `av_buffer_alloc`'d 4096-byte plane attached as `buf[0]` must
2329 /// surface as `Some(4096)`.
2330 #[test]
2331 fn cpu_frame_bytes_sums_buf_sizes() {
2332 use ffmpeg_next::ffi::av_buffer_alloc;
2333
2334 let mut f = frame::Video::empty();
2335 // SAFETY: av_buffer_alloc returns a fresh AVBufferRef. Attaching it
2336 // to AVFrame.buf[0] transfers ownership to the frame; av_frame_unref
2337 // on Drop releases it.
2338 let buf0 = unsafe { av_buffer_alloc(4096) };
2339 let buf1 = unsafe { av_buffer_alloc(2048) };
2340 assert!(!buf0.is_null() && !buf1.is_null());
2341 unsafe {
2342 let raw = f.as_mut_ptr();
2343 (*raw).buf[0] = buf0;
2344 (*raw).buf[1] = buf1;
2345 // Positive linesize so the negative-stride rejection doesn't fire.
2346 (*raw).linesize[0] = 256;
2347 }
2348 assert_eq!(cpu_frame_bytes(&f), Some(4096 + 2048));
2349 }
2350
2351 /// A frame with no populated `buf` entries — the empty-frame state
2352 /// `Frame::empty()` produces — must return `Some(0)`. (Pre-fix this
2353 /// case was sized via the linesize×plane_height table; the new
2354 /// `buf[i].size` accounting handles it without a special branch.)
2355 #[test]
2356 fn cpu_frame_bytes_zero_for_empty_frame() {
2357 let f = frame::Video::empty();
2358 assert_eq!(cpu_frame_bytes(&f), Some(0));
2359 }
2360
2361 /// `cpu_frame_bytes` must size against the underlying
2362 /// `AVBufferRef.size`, not `linesize × plane_height_for(AVFrame.height)`.
2363 /// On a cropped or heavily aligned stream the underlying buffer can
2364 /// be far larger than `AVFrame.height` (display) suggests — a
2365 /// height-based formula under-counts the allocation by
2366 /// `allocated_height / display_height` and lets the real
2367 /// allocation slip past `max_probe_pending_bytes`.
2368 ///
2369 /// Build a 256-byte buffer, attach it as `buf[0]`, but set
2370 /// `AVFrame.height` to 1 to simulate a cropped display. The
2371 /// `buf[i].size` accounting must report 256, not `linesize * 1`.
2372 #[test]
2373 fn cpu_frame_bytes_uses_buf_size_independent_of_display_height() {
2374 use ffmpeg_next::ffi::av_buffer_alloc;
2375
2376 let buf0 = unsafe { av_buffer_alloc(256) };
2377 assert!(!buf0.is_null());
2378
2379 let mut f = frame::Video::empty();
2380 unsafe {
2381 let raw = f.as_mut_ptr();
2382 (*raw).format = ffmpeg_next::ffi::AVPixelFormat::AV_PIX_FMT_NV12 as i32;
2383 // Display dims tiny — pre-fix would have used `height = 1` to
2384 // size the plane and reported `linesize * 1` ≪ 256.
2385 (*raw).width = 1;
2386 (*raw).height = 1;
2387 (*raw).linesize[0] = 32;
2388 (*raw).buf[0] = buf0;
2389 }
2390 assert_eq!(
2391 cpu_frame_bytes(&f),
2392 Some(256),
2393 "cropped/aligned frames must be sized by buf[i].size, not display dims"
2394 );
2395 }
2396
2397 /// `estimate_transfer_bytes` must read `hw_frames_ctx.width / .height`
2398 /// (allocated dims) — not `AVFrame.width / .height` (display dims).
2399 /// Verify with a synthetic frames context that disagrees with the
2400 /// frame's display dims by 80×.
2401 #[test]
2402 fn estimate_transfer_bytes_reads_alloc_dims_from_hw_frames_ctx() {
2403 let buf = make_hw_frames_ctx_ref(8192, 8192);
2404 let mut f = frame::Video::empty();
2405 unsafe {
2406 let raw = f.as_mut_ptr();
2407 // Display dims: 100×100 — pre-fix the estimate was 80 KB. After
2408 // the fix it must be 8192×8192×8 = 512 MiB.
2409 (*raw).width = 100;
2410 (*raw).height = 100;
2411 (*raw).hw_frames_ctx = buf;
2412 }
2413 assert_eq!(
2414 estimate_transfer_bytes(&f),
2415 Some(8192usize * 8192 * WORST_CASE_BYTES_PER_PIXEL),
2416 );
2417 }
2418
2419 /// A frame with no `hw_frames_ctx` cannot have its allocation extent
2420 /// proved — the helper returns `None` so the probe-replay caller
2421 /// fails the candidate rather than under-counting from display dims.
2422 /// (This is the exact attack the cap is meant to prevent.)
2423 #[test]
2424 fn estimate_transfer_bytes_returns_none_without_hw_frames_ctx() {
2425 let mut f = frame::Video::empty();
2426 unsafe {
2427 let raw = f.as_mut_ptr();
2428 (*raw).width = 1920;
2429 (*raw).height = 1080;
2430 // hw_frames_ctx stays null.
2431 }
2432 assert!(estimate_transfer_bytes(&f).is_none());
2433 }
2434
2435 /// Non-positive `hw_frames_ctx` dimensions also surface as `None` —
2436 /// a corrupt or malformed HW pool descriptor must not get a free
2437 /// pass.
2438 #[test]
2439 fn estimate_transfer_bytes_rejects_non_positive_alloc_dimensions() {
2440 let mut f = frame::Video::empty();
2441 let buf = make_hw_frames_ctx_ref(0, 1080);
2442 unsafe {
2443 (*f.as_mut_ptr()).hw_frames_ctx = buf;
2444 }
2445 assert!(estimate_transfer_bytes(&f).is_none());
2446 }
2447
2448 /// 8K HDR P010 has actual ~96 MiB resident size; the estimate should
2449 /// over-charge it (the right side to err on for a memory cap) while
2450 /// still fitting within the configurable
2451 /// [`DEFAULT_MAX_PROBE_PENDING_BYTES`] cap (256 MiB) for a single
2452 /// frame so a default-configured decoder is not forced to reject 8K
2453 /// streams outright.
2454 #[test]
2455 fn estimate_transfer_bytes_8k_fits_default_cap() {
2456 let buf = make_hw_frames_ctx_ref(7680, 4320);
2457 let mut f = frame::Video::empty();
2458 unsafe {
2459 (*f.as_mut_ptr()).hw_frames_ctx = buf;
2460 }
2461 let estimate = estimate_transfer_bytes(&f).expect("8K is sizable");
2462 assert!(
2463 estimate <= DEFAULT_MAX_PROBE_PENDING_BYTES,
2464 "8K estimate {estimate} must fit DEFAULT_MAX_PROBE_PENDING_BYTES \
2465 {DEFAULT_MAX_PROBE_PENDING_BYTES}; otherwise the default cap rejects \
2466 even a single 8K frame at probe time"
2467 );
2468 assert!(
2469 estimate > 96 * 1024 * 1024,
2470 "estimate must over-charge real 8K P010 to bound the worst case; got {estimate}"
2471 );
2472 }
2473
2474 /// `PartialBuildState`'s `Drop` must be a no-op when both pointers are
2475 /// null — the disarmed-by-`into_owned` post-state. A panic / double-free
2476 /// here would break the success path of every `build_state` call.
2477 #[test]
2478 fn partial_build_state_drop_is_no_op_on_null_pointers() {
2479 let _g = PartialBuildState {
2480 hw_device_ref: ptr::null_mut(),
2481 callback_state: ptr::null_mut(),
2482 };
2483 // Drops at end of scope. Test passes if it doesn't panic / crash.
2484 }
2485
2486 /// `into_owned` must return the original pointers and disarm the guard
2487 /// (so the guard's Drop becomes a no-op and the caller can safely
2488 /// transfer ownership to `DecoderState` without double-freeing).
2489 #[test]
2490 fn partial_build_state_into_owned_disarms_and_returns_originals() {
2491 use ffmpeg_next::ffi::{AVPixelFormat, av_buffer_alloc, av_buffer_unref};
2492
2493 // SAFETY: av_buffer_alloc returns a fresh AVBufferRef* with refcount
2494 // 1, or NULL on OOM. We free it ourselves below (after into_owned
2495 // disarms the guard).
2496 let hw_ptr = unsafe { av_buffer_alloc(64) };
2497 assert!(!hw_ptr.is_null(), "av_buffer_alloc(64) returned NULL");
2498 let cb_ptr = Box::into_raw(Box::new(CallbackState {
2499 wanted: AVPixelFormat::AV_PIX_FMT_NONE,
2500 wanted_int: AVPixelFormat::AV_PIX_FMT_NONE as i32,
2501 }));
2502
2503 let g = PartialBuildState {
2504 hw_device_ref: hw_ptr,
2505 callback_state: cb_ptr,
2506 };
2507 let (hw_back, cb_back) = g.into_owned();
2508 assert_eq!(
2509 hw_back, hw_ptr,
2510 "into_owned must return the original device ref"
2511 );
2512 assert_eq!(
2513 cb_back, cb_ptr,
2514 "into_owned must return the original callback box"
2515 );
2516
2517 // Guard is now disarmed (its Drop ran with null pointers as soon as
2518 // into_owned consumed it). We own the pointers and must free them.
2519 // SAFETY: hw_ptr and cb_ptr are still the freshly-allocated values.
2520 unsafe {
2521 let mut hw = hw_back;
2522 av_buffer_unref(&mut hw);
2523 drop(Box::from_raw(cb_back));
2524 }
2525 }
2526
2527 /// `send_packet` must NOT consume the packet through the active
2528 /// decoder if the probe rescue cannot record it. The wrong order is
2529 /// `state.inner.send_packet → cap check → abandon probe → return
2530 /// Ok` — by the time the probe is abandoned the packet is already
2531 /// in FFmpeg's state but missing from `buffered_packets`, so a
2532 /// later runtime exhaustion would surface `unconsumed_packets`
2533 /// without that packet and a non-seekable caller could not rebuild
2534 /// the input stream.
2535 ///
2536 /// Post-fix the pre-flight runs first: cap overflow returns
2537 /// `Err(AllBackendsFailed)` *before* `state.inner.send_packet` is
2538 /// called, the packet stays in the caller's hand, and the rescue
2539 /// history is the consistent record up to (but not including) it.
2540 ///
2541 /// `pending_frames` are still preserved across the bailout — they
2542 /// belong to the active backend (possibly a candidate `advance_probe`
2543 /// just committed) and the caller can drain them via `receive_frame`
2544 /// before switching to software.
2545 ///
2546 /// Live HW required: a real `VideoDecoder` is the only way to
2547 /// construct a valid `DecoderState` (its `Drop` invokes FFmpeg
2548 /// cleanup).
2549 #[test]
2550 #[ignore = "requires HWDECODE_SAMPLE_VIDEO and a working hardware backend"]
2551 fn cap_overflow_does_not_consume_packet_and_preserves_pending() {
2552 use ffmpeg_next::{format, media};
2553
2554 let path = std::env::var_os("HWDECODE_SAMPLE_VIDEO")
2555 .expect("HWDECODE_SAMPLE_VIDEO must be set for this test");
2556
2557 ffmpeg_next::init().expect("ffmpeg init");
2558 let mut input = format::input(&path).expect("open input");
2559 let stream_index = input
2560 .streams()
2561 .best(media::Type::Video)
2562 .expect("video stream")
2563 .index();
2564 let stream_params = input
2565 .streams()
2566 .best(media::Type::Video)
2567 .expect("video stream")
2568 .parameters();
2569
2570 let mut decoder = VideoDecoder::open(stream_params).expect("open decoder");
2571 assert!(
2572 decoder.probe.is_some(),
2573 "probe must be active immediately after open"
2574 );
2575
2576 // Inject sentinel frames as if `advance_probe` had drained them from
2577 // a freshly-committed candidate during this same send_packet call.
2578 decoder.pending_frames.push_back(frame::Video::empty());
2579 decoder.pending_frames.push_back(frame::Video::empty());
2580 let pending_before = decoder.pending_frames.len();
2581
2582 // Pre-stage one buffered packet so we can verify the rescue history
2583 // is returned unchanged (not silently extended with the triggering
2584 // packet, and not dropped). Sized to push the byte counter to its
2585 // ceiling so the very next send_packet trips the byte/packet cap.
2586 let pre_existing = Packet::new(8);
2587 decoder
2588 .probe
2589 .as_mut()
2590 .expect("probe present")
2591 .buffered_packets
2592 .push(pre_existing);
2593 decoder
2594 .probe
2595 .as_mut()
2596 .expect("probe present")
2597 .buffered_bytes = MAX_PROBE_PACKET_BYTES;
2598
2599 // Find the first video packet and feed it. The pre-flight must
2600 // surface AllBackendsFailed; `state.inner.send_packet` must NOT be
2601 // called on this packet.
2602 let mut hit_bailout = false;
2603 for (s, packet) in input.packets() {
2604 if s.index() != stream_index {
2605 continue;
2606 }
2607 match decoder.send_packet(&packet) {
2608 Err(Error::AllBackendsFailed(p)) => {
2609 let attempts = p.attempts();
2610 let unconsumed_packets = p.unconsumed_packets();
2611 assert_eq!(
2612 unconsumed_packets.len(),
2613 1,
2614 "rescue history must contain the pre-existing packet only — \
2615 the triggering packet must NOT have been consumed"
2616 );
2617 assert_eq!(
2618 unconsumed_packets[0].size(),
2619 8,
2620 "the pre-existing packet must come back unmodified"
2621 );
2622 assert!(
2623 attempts.is_empty(),
2624 "no backend failure occurred; attempts must be empty when \
2625 bailout fires from cap overflow alone"
2626 );
2627 hit_bailout = true;
2628 break;
2629 }
2630 Ok(()) => panic!("send_packet must bail out when probe is at the byte cap"),
2631 Err(other) => panic!("expected AllBackendsFailed bailout, got {other:?}"),
2632 }
2633 }
2634 assert!(
2635 hit_bailout,
2636 "expected at least one send_packet to trip the cap-overflow bailout"
2637 );
2638
2639 assert!(
2640 decoder.probe.is_none(),
2641 "probe must be abandoned after cap overflow"
2642 );
2643 assert_eq!(
2644 decoder.pending_frames.len(),
2645 pending_before,
2646 "pending_frames belong to the active backend; abandon must not drop them"
2647 );
2648 }
2649
2650 /// When `advance_probe` exhausts the probe (no more candidates and
2651 /// the active backend just failed), the `Err(AllBackendsFailed
2652 /// { unconsumed_packets, .. })` it returns must include the
2653 /// packets the decoder has already consumed from the caller's
2654 /// demuxer. For non-seekable inputs (live streams, pipes, network
2655 /// sources), losing those packets means the caller's software
2656 /// fallback cannot replay the initial bytes and silently drops
2657 /// the leading frames.
2658 ///
2659 /// Live HW required: we need a real `VideoDecoder` (its `Drop` runs
2660 /// FFmpeg cleanup) and `advance_probe` is private — only callable
2661 /// from the same module.
2662 #[test]
2663 #[ignore = "requires HWDECODE_SAMPLE_VIDEO and a working hardware backend"]
2664 fn all_backends_failed_returns_buffered_packets_to_caller() {
2665 use ffmpeg_next::{format, media};
2666
2667 let path = std::env::var_os("HWDECODE_SAMPLE_VIDEO")
2668 .expect("HWDECODE_SAMPLE_VIDEO must be set for this test");
2669
2670 ffmpeg_next::init().expect("ffmpeg init");
2671 let input = format::input(&path).expect("open input");
2672 let stream_params = input
2673 .streams()
2674 .best(media::Type::Video)
2675 .expect("video stream")
2676 .parameters();
2677
2678 let mut decoder = VideoDecoder::open(stream_params).expect("open decoder");
2679 assert!(
2680 decoder.probe.is_some(),
2681 "probe must be active immediately after open"
2682 );
2683
2684 // Stuff the probe history with two distinct packets and clear the
2685 // remaining_backends list so the next advance_probe call is forced
2686 // into the exhaustion branch.
2687 let p1 = Packet::new(16);
2688 let p2 = Packet::new(32);
2689 {
2690 let probe = decoder.probe.as_mut().expect("probe");
2691 probe.buffered_packets.push(p1);
2692 probe.buffered_packets.push(p2);
2693 probe.remaining_backends.clear();
2694 }
2695
2696 // Trigger advance_probe directly with a synthetic non-transient
2697 // error. The exhaustion branch must take ownership of the
2698 // buffered packets and surface them via `unconsumed_packets`.
2699 let result = decoder.advance_probe(Error::Ffmpeg(ffmpeg_next::Error::InvalidData));
2700 match result {
2701 Err(Error::AllBackendsFailed(p)) => {
2702 let attempts = p.attempts();
2703 let unconsumed_packets = p.unconsumed_packets();
2704 assert_eq!(
2705 unconsumed_packets.len(),
2706 2,
2707 "buffered probe packets must be returned to the caller for SW fallback"
2708 );
2709 assert_eq!(unconsumed_packets[0].size(), 16);
2710 assert_eq!(unconsumed_packets[1].size(), 32);
2711 // The synthetic InvalidData was recorded against the active
2712 // backend before the exhaustion check, so attempts is non-empty.
2713 assert!(
2714 !attempts.is_empty(),
2715 "the active backend's failure should be in attempts"
2716 );
2717 }
2718 other => panic!("expected AllBackendsFailed, got {other:?}"),
2719 }
2720 }
2721
2722 /// `ProbeState.attempts` must carry forward `open`'s accumulated
2723 /// failures from earlier backends in probe order. The wrong
2724 /// shape — initialising `ProbeState.attempts` to `Vec::new()` at
2725 /// the start of `open`'s "promote to runtime" step — drops
2726 /// earlier failures so a runtime exhaustion surfaces an
2727 /// `AllBackendsFailed` whose `attempts` log only mentions the
2728 /// active backend's failure (e.g. VAAPI's earlier open failure
2729 /// goes missing).
2730 ///
2731 /// `open` seeds `ProbeState.attempts` with the local `attempts`
2732 /// vec via `mem::take`, so a runtime exhaustion surfaces the
2733 /// full failure chain in probe order.
2734 ///
2735 /// Live HW required: opens a real decoder, manually injects a
2736 /// synthetic earlier-backend failure into `probe.attempts` (as if
2737 /// `open` had recorded one), then triggers exhaustion via
2738 /// `advance_probe`. The synthetic earlier failure must appear
2739 /// before the active backend's failure in the returned `attempts`.
2740 #[test]
2741 #[ignore = "requires HWDECODE_SAMPLE_VIDEO and a working hardware backend"]
2742 fn all_backends_failed_preserves_earlier_open_failures() {
2743 use ffmpeg_next::{format, media};
2744
2745 let path = std::env::var_os("HWDECODE_SAMPLE_VIDEO")
2746 .expect("HWDECODE_SAMPLE_VIDEO must be set for this test");
2747
2748 ffmpeg_next::init().expect("ffmpeg init");
2749 let input = format::input(&path).expect("open input");
2750 let stream_params = input
2751 .streams()
2752 .best(media::Type::Video)
2753 .expect("video stream")
2754 .parameters();
2755
2756 let mut decoder = VideoDecoder::open(stream_params).expect("open decoder");
2757 let active_backend = decoder.backend();
2758
2759 // Pick a Backend distinct from the active one to simulate a prior
2760 // open failure that `open`'s seeding would have captured. We use
2761 // `BackendUnsupportedByCodec` as the synthetic earlier error since
2762 // it doesn't depend on FFmpeg state.
2763 //
2764 // Choose any Backend that isn't the active one. On macOS the only
2765 // backend is VideoToolbox, so we use a non-Apple backend
2766 // (Vaapi/Cuda/D3d11va) — its "supported by codec" status is
2767 // irrelevant; we're injecting the synthetic failure directly.
2768 let earlier_backend = match active_backend {
2769 Backend::VideoToolbox => Backend::Vaapi,
2770 Backend::Vaapi => Backend::Cuda,
2771 Backend::Cuda => Backend::Vaapi,
2772 Backend::D3d11va => Backend::Cuda,
2773 };
2774 let synthetic_earlier = Error::BackendUnsupportedByCodec(earlier_backend);
2775
2776 // Seed attempts as `open` would have if backend 0 failed before
2777 // the active backend opened.
2778 {
2779 let probe = decoder.probe.as_mut().expect("probe present");
2780 probe
2781 .attempts
2782 .push((earlier_backend, Box::new(synthetic_earlier)));
2783 probe.remaining_backends.clear(); // force exhaustion on next advance.
2784 }
2785
2786 let result = decoder.advance_probe(Error::Ffmpeg(ffmpeg_next::Error::InvalidData));
2787 match result {
2788 Err(Error::AllBackendsFailed(p)) => {
2789 let attempts = p.attempts();
2790 assert_eq!(
2791 attempts.len(),
2792 2,
2793 "AllBackendsFailed must surface BOTH the seeded earlier failure \
2794 and the active backend's runtime failure"
2795 );
2796 assert_eq!(
2797 attempts[0].0, earlier_backend,
2798 "earlier open failure must come first in probe order"
2799 );
2800 assert!(
2801 matches!(*attempts[0].1, Error::BackendUnsupportedByCodec(_)),
2802 "earlier failure must preserve its original error variant"
2803 );
2804 assert_eq!(
2805 attempts[1].0, active_backend,
2806 "active backend's runtime failure must come second"
2807 );
2808 assert!(
2809 matches!(
2810 *attempts[1].1,
2811 Error::Ffmpeg(ffmpeg_next::Error::InvalidData)
2812 ),
2813 "active backend's failure must preserve the synthetic InvalidData"
2814 );
2815 }
2816 other => panic!("expected AllBackendsFailed, got {other:?}"),
2817 }
2818 }
2819}