Skip to main content

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(&parameters)?;
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(&parameters) {
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(&parameters) {
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(&parameters)?;
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(&parameters)?;
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}