Skip to main content

mediadecode_ffmpeg/decoder/
mod.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 (committed) backend.
67///
68/// The committed backend can still fail at runtime — e.g. VideoToolbox can
69/// decode a clip's first frames and then hit content its kernel can't handle
70/// (H.264 High 4:2:2 10-bit), surfacing `AVERROR_EXTERNAL`. Post-commit a
71/// non-transient, non-EOF error from the committed backend is reclassified to
72/// [`Error::AllBackendsFailed`] (see the `is_hw_decode_failure` predicate), so
73/// the [`crate::FfmpegVideoStreamDecoder`] wrapper still recognises it as a
74/// HW-path exhaustion and falls back to software. The post-commit
75/// `unconsumed_packets` is empty (the probe buffer is gone); the wrapper's
76/// rolling since-last-keyframe buffer supplies the replay set.
77pub struct VideoDecoder {
78  /// Live FFmpeg state for the currently active backend.
79  state: DecoderState,
80  /// Reusable frame buffer used for hw-side decoding before transfer / move.
81  /// Internal use only — never handed to callers.
82  hw_frame: frame::Video,
83  /// Probe state: present until the first frame is received from the active
84  /// backend, then `None`. While `Some`, packets are buffered for replay and
85  /// non-transient errors / decoder failures advance to the next backend.
86  probe: Option<ProbeState>,
87  /// CPU-side frames produced by a candidate decoder during probe replay
88  /// (when its internal queue filled and we had to drain output before the
89  /// next `send_packet`). Already transferred from the candidate's
90  /// `AVHWFramesContext` to a CPU frame, so they remain valid after the
91  /// candidate state is committed. [`Self::receive_frame`] dequeues these
92  /// FIFO before reading from `state.inner`.
93  pending_frames: VecDeque<frame::Video>,
94  /// Per-decoder byte budget for [`Self::pending_frames`] during probe
95  /// replay. Defaults to [`DEFAULT_MAX_PROBE_PENDING_BYTES`]; override via
96  /// [`Self::with_max_probe_pending_bytes`].
97  max_probe_pending_bytes: usize,
98}
99
100/// Owned FFmpeg state for one open codec context. Has its own `Drop` so we
101/// can swap it out cleanly during a probe advance via `mem::replace`.
102struct DecoderState {
103  /// Wrapped FFmpeg decoder. `ManuallyDrop` so we can sequence its drop
104  /// before freeing the callback state.
105  inner: ManuallyDrop<ffmpeg_next::decoder::Video>,
106  /// Backend driving this state.
107  backend: Backend,
108  /// Owned reference produced by `av_hwdevice_ctx_create`.
109  hw_device_ref: *mut AVBufferRef,
110  /// Owned `Box<CallbackState>` raw pointer; `AVCodecContext::opaque`
111  /// aliases it.
112  callback_state: *mut CallbackState,
113}
114
115/// Maximum number of packets we are willing to buffer for probe replay
116/// before abandoning the fallback safety net. Set high enough to absorb
117/// long B-frame GOPs and codec setup latency, low enough to bound memory
118/// against malicious / pathological streams that never produce a first
119/// frame.
120const MAX_PROBE_PACKETS: usize = 256;
121
122/// Maximum total compressed-byte size of buffered probe packets. Each
123/// `Packet` clone holds a refcounted reference to the demuxer's bitstream
124/// data — even though the clone itself is shallow, the underlying buffers
125/// stay alive until we drop them. 64 MiB is generous for normal video and
126/// gives untrusted media a hard ceiling.
127const MAX_PROBE_PACKET_BYTES: usize = 64 * 1024 * 1024;
128
129/// Hard cap on the number of side-data entries we tolerate per buffered
130/// packet. `av_packet_ref` allocates an `AVPacketSideData` descriptor and
131/// an `AVBufferRef` per entry, so a packet stuffed with many tiny or
132/// zero-sized entries can consume significant memory in descriptor /
133/// allocator overhead even after [`packet_side_data_bytes`] charges
134/// [`SIDE_DATA_ENTRY_OVERHEAD`] bytes per entry. Refusing to clone such
135/// packets short-circuits the descriptor explosion path.
136///
137/// Sized for legitimate streams (typical video packets carry 0-5 side-
138/// data entries; SEI-heavy HEVC/AV1 maybe a dozen) while comfortably
139/// rejecting weaponised input.
140///
141/// Shared with the [`crate::FfmpegVideoStreamDecoder`] rolling GOP buffer,
142/// which charges the same side-data budget so its byte cap is a true upper
143/// bound on retained memory rather than counting bare payloads.
144pub(crate) const MAX_PROBE_PACKET_SIDE_DATA_ENTRIES: usize = 64;
145
146/// Conservative per-side-data-entry overhead estimate used by both
147/// [`packet_side_data_bytes`] and the budget accounting in
148/// [`VideoDecoder::send_packet`]. Counts the `AVPacketSideData`
149/// descriptor (24 bytes per the FFmpeg 8.x bindings), the `AVBufferRef`
150/// FFmpeg allocates per entry, and a margin for malloc bookkeeping
151/// (header bytes, alignment slack). Setting it on the high side keeps
152/// the byte cap a true upper bound on retained memory; under-charging
153/// would let many tiny entries slip past the cap.
154const SIDE_DATA_ENTRY_OVERHEAD: usize = 80;
155
156/// Conservative upper-bound bytes-per-pixel multiplier used to estimate
157/// the size of a CPU frame **before** `av_hwframe_transfer_data`
158/// allocates its pixel buffers. Covers every HW download format this
159/// crate produces (worst case is `P416LE` / `P412LE` at 6 bytes/pixel
160/// for 16-bit 4:4:4 semi-planar) plus a margin for FFmpeg's per-row
161/// stride alignment (typically 32-byte aligned, ~5% extra at HD widths
162/// and below).
163///
164/// Used by [`drain_into_pending`] as a pre-transfer guard: if the
165/// product `width * height * WORST_CASE_BYTES_PER_PIXEL` would already
166/// push `pending_bytes` past `max_probe_pending_bytes`, the candidate
167/// replay refuses the frame *before* allocating. Without this, FFmpeg
168/// would perform the full HW→CPU download (potentially ~100 MiB for
169/// 8K HDR) and we would only reject the frame after RSS had already
170/// spiked. The post-transfer accounting via [`cpu_frame_bytes`] stays in
171/// place as a backstop using the frame's actual stride/format.
172///
173/// Slightly over-charges true 4:2:0 NV12 / P010 frames (which dominate
174/// real workloads) — that's the right side to err on. Callers feeding
175/// 8K+ workloads through the probe path can tune
176/// [`VideoDecoder::with_max_probe_pending_bytes`] upward to compensate.
177const WORST_CASE_BYTES_PER_PIXEL: usize = 8;
178
179/// Maximum number of CPU frames we are willing to queue from a candidate
180/// during probe replay. Each frame is a fully-allocated CPU buffer
181/// (~3 MiB for 1080p NV12, ~24 MiB for 4K P010, ~96 MiB for 8K P010), so
182/// an unbounded queue would OOM on a candidate with a shallow internal
183/// queue against a deep replay history. This cap, together with
184/// [`DEFAULT_MAX_PROBE_PENDING_BYTES`], is enforced as a hard limit during
185/// replay: once either limit is reached, probe buffering fails for the
186/// candidate (returns `ENOMEM` from `drain_into_pending`) instead of
187/// queueing additional drained frames. The probe loop then advances to
188/// the next backend or returns `Error::AllBackendsFailed` if exhausted.
189const MAX_PROBE_PENDING_FRAMES: usize = 16;
190
191/// Default byte budget for probe-replay drained frames. 256 MiB is enough
192/// for 16 frames at 4K P010 (~24 MiB each = 384 MiB worst case under the
193/// count cap), and is the cap that fires first for very high-resolution
194/// content (8K P010: ~96 MiB per frame → only ~2 frames fit).
195///
196/// Override per-decoder with [`VideoDecoder::with_max_probe_pending_bytes`]
197/// when targeting 8K+ workloads or memory-constrained environments.
198///
199/// TODO: when frames significantly exceed typical sizes, consider
200/// memmap-backed pending buffers (write transferred frames to a temp file
201/// or shared-memory segment) so the resident set stays bounded even when
202/// the byte cap is raised. Out of scope for now.
203pub const DEFAULT_MAX_PROBE_PENDING_BYTES: usize = 256 * 1024 * 1024;
204
205/// State carried only during the probe window (before the first successful
206/// frame). Holds enough information to tear down the current decoder and
207/// retry with the next backend.
208struct ProbeState {
209  parameters: codec::Parameters,
210  codec: Codec,
211  /// Backends still to try, in order. Empty means "no more options after
212  /// the active one fails" — `advance_probe` then surfaces
213  /// [`Error::AllBackendsFailed`] so the contract is the same on
214  /// single-backend platforms (e.g. macOS) as on multi-backend ones.
215  remaining_backends: Vec<Backend>,
216  /// Packets sent so far, kept for replay through any candidate backend.
217  /// Preserved across failed candidates — only cleared when the probe
218  /// collapses on a successful first frame, or when the probe is
219  /// abandoned due to the size caps.
220  buffered_packets: Vec<Packet>,
221  /// Cumulative size (in compressed bytes) of `buffered_packets`. Tracked
222  /// incrementally so we don't have to re-sum on every send.
223  buffered_bytes: usize,
224  /// Whether `send_eof` has been called; replayed alongside packets.
225  eof_sent: bool,
226  /// Per-backend errors captured since the probe window opened. Pushed
227  /// whenever a backend's failure triggers `advance_probe` (the active
228  /// backend that just failed) or a candidate's build / replay rejects
229  /// it. Drained into [`Error::AllBackendsFailed`] when the probe
230  /// exhausts every option.
231  attempts: Vec<(Backend, Box<Error>)>,
232}
233
234// SAFETY: All raw pointers are exclusively owned by `DecoderState` and never
235// shared. `ffmpeg::decoder::Video` is itself `Send` (its `Context` carries an
236// `unsafe impl Send`). The decoder is not safe for concurrent use, hence not
237// `Sync`.
238unsafe impl Send for DecoderState {}
239unsafe impl Send for VideoDecoder {}
240
241impl Drop for DecoderState {
242  fn drop(&mut self) {
243    // Order matters:
244    //  1. Drop the codec context first. While it lives, FFmpeg may invoke
245    //     `get_format`, which dereferences `callback_state` via `opaque`.
246    //  2. Free the callback state heap allocation.
247    //  3. Release our hw device reference (FFmpeg released its own when
248    //     the codec context was freed in step 1).
249    unsafe {
250      ManuallyDrop::drop(&mut self.inner);
251      if !self.callback_state.is_null() {
252        drop(Box::from_raw(self.callback_state));
253        self.callback_state = ptr::null_mut();
254      }
255      if !self.hw_device_ref.is_null() {
256        av_buffer_unref(&mut self.hw_device_ref);
257      }
258    }
259  }
260}
261
262impl VideoDecoder {
263  /// Auto-probe hardware backends in the platform's default order.
264  ///
265  /// Each backend opens with a strict `get_format` callback. The first
266  /// backend whose `avcodec_open2` succeeds becomes active; if its first
267  /// frame is unusable (decode error, transfer failure, or a CPU-format
268  /// frame from a HW context) the decoder is torn down and the next backend
269  /// is tried — packets sent so far are replayed through the new decoder
270  /// transparently. The probe advance is transactional: the next backend
271  /// must build *and* accept the replayed history before any probe state is
272  /// consumed, so a misbehaving middle backend cannot strand the caller.
273  ///
274  /// [`Self::backend`] reflects whichever backend ultimately produced the
275  /// first frame.
276  ///
277  /// [`Error::AllBackendsFailed`] surfaces in two places, with the same
278  /// meaning ("no hardware backend can decode this stream — fall back to
279  /// software yourself"):
280  /// - From `open` itself, when no backend even opens.
281  /// - From [`Self::send_packet`] / [`Self::send_eof`] /
282  ///   [`Self::receive_frame`], when the initially-opened backend fails
283  ///   at decode time and every remaining backend in the probe order
284  ///   either also fails or doesn't exist. On single-backend platforms
285  ///   (e.g. macOS, where the order is `[VideoToolbox]`), this is the
286  ///   only place a HW-only failure surfaces.
287  ///
288  /// In both cases, `attempts` carries the per-backend error log. When
289  /// the runtime path fires, `unconsumed_packets` also contains the
290  /// packets the decoder consumed from the caller before the probe
291  /// exhausted (refcounted shallow clones); for non-seekable inputs
292  /// (live streams, pipes) the caller can replay these directly into
293  /// a software decoder of their choice without re-demuxing. From the
294  /// open-time path the vec is empty since no packets have been sent.
295  ///
296  /// On `Ok`, the returned decoder **always** has an active probe
297  /// rescue safety net. If a parameters clone fails under memory
298  /// pressure before the probe state can be set up, `open` returns
299  /// `Err(Error::Ffmpeg(Other { errno: ENOMEM }))` rather than handing
300  /// back a live decoder with no fallback contract. No packets have
301  /// been sent yet, so the caller can retry or fall back to software
302  /// with the original `parameters` directly.
303  pub fn open(parameters: codec::Parameters) -> Result<Self> {
304    let codec = find_decoder(&parameters)?;
305    let order = backend::probe_order();
306
307    let mut attempts: Vec<(Backend, Box<Error>)> = Vec::new();
308    for (i, &backend) in order.iter().enumerate() {
309      // Use the checked clone — ffmpeg-next's `Parameters::clone` does
310      // `avcodec_parameters_alloc` without a null check and ignores the
311      // return of `avcodec_parameters_copy`. Under OOM that path silently
312      // produces a Parameters with a null inner pointer.
313      let cloned_for_build = match try_clone_parameters(&parameters) {
314        Ok(p) => p,
315        Err(e) => {
316          tracing::warn!(?backend, error = %e, "hwdecode: parameters clone failed");
317          attempts.push((backend, Box::new(Error::Ffmpeg(e))));
318          continue;
319        }
320      };
321      match Self::build_state(cloned_for_build, codec, backend) {
322        Ok(state) => {
323          tracing::info!(?backend, "hwdecode: opened video decoder (probing)");
324          let remaining = order[(i + 1)..].to_vec();
325          // Deep-copy the caller's `parameters` before storing in ProbeState.
326          // `codec::Parameters` from `stream.parameters()` carries an Rc
327          // owner pointing at the demuxer; moving that Rc to a worker
328          // thread (when VideoDecoder is sent) would race with the demuxer's
329          // Rc on the original thread. The checked clone copies the bytes
330          // into a fresh allocation with `owner: None`, severing the link.
331          //
332          // We always create ProbeState — even when `remaining` is empty
333          // (single-backend platforms like macOS) — so that a first-frame
334          // failure on the only backend surfaces as
335          // `Error::AllBackendsFailed` from `receive_frame` /
336          // `send_packet` rather than as a raw FFmpeg error. That keeps
337          // the API contract the same regardless of how many HW backends
338          // the platform exposes.
339          //
340          // If the clone fails (ENOMEM), fail the **whole open call**
341          // rather than returning a live decoder with `probe: None`.
342          // Returning Ok here would let the caller send packets that the
343          // active backend consumes, and a subsequent backend failure
344          // would then surface as a raw FFmpeg error with no
345          // `unconsumed_packets` — silently breaking the rescue contract
346          // for non-seekable inputs (live streams, pipes). Dropping the
347          // already-built `state` here runs its FFmpeg cleanup, and the
348          // caller can retry / fall back to software with the original
349          // parameters in their hand (no packets were consumed yet).
350          // Seed the probe's attempt log with any backends that failed
351          // to open earlier in this loop (including
352          // `BackendUnsupportedByCodec` and parameters-clone errors).
353          // Without this, a runtime exhaustion on the active backend
354          // would surface an `AllBackendsFailed` containing only the
355          // active backend's runtime failure — losing the original
356          // open-time causes that, on multi-backend platforms (Linux,
357          // Windows), are usually the more diagnostic signal. E.g. a
358          // VAAPI-then-CUDA host where VAAPI fails to open and CUDA
359          // later fails at first-frame must report both failures in
360          // probe order, not just CUDA.
361          let probe = match try_clone_parameters(&parameters) {
362            Ok(probe_params) => ProbeState {
363              parameters: probe_params,
364              codec,
365              remaining_backends: remaining,
366              buffered_packets: Vec::new(),
367              buffered_bytes: 0,
368              eof_sent: false,
369              attempts: std::mem::take(&mut attempts),
370            },
371            Err(e) => {
372              tracing::warn!(
373                error = %e,
374                "hwdecode: parameters clone failed for probe state at open; \
375                 failing closed instead of returning a decoder without rescue"
376              );
377              return Err(Error::Ffmpeg(e));
378            }
379          };
380          return Ok(Self {
381            state,
382            hw_frame: alloc_av_frame().map_err(Error::Ffmpeg)?,
383            probe: Some(probe),
384            pending_frames: VecDeque::new(),
385            max_probe_pending_bytes: DEFAULT_MAX_PROBE_PENDING_BYTES,
386          });
387        }
388        Err(e) => {
389          tracing::warn!(?backend, error = %e, "hwdecode: backend open failed");
390          attempts.push((backend, Box::new(e)));
391        }
392      }
393    }
394    // No packets have been consumed at open time.
395    Err(Error::AllBackendsFailed(AllBackendsFailed::new(
396      attempts,
397      Vec::new(),
398    )))
399  }
400
401  /// Open the decoder with a specific backend. No probe, no fallback.
402  ///
403  /// If `backend` cannot actually decode this stream, the failure surfaces
404  /// from [`Self::receive_frame`] (the strict `get_format` callback returns
405  /// `AV_PIX_FMT_NONE`, the decoder errors out). The caller is responsible
406  /// for retrying with another hardware backend or falling back to a
407  /// software decoder of their choice (e.g. `ffmpeg::decoder::Video`).
408  pub fn open_with(parameters: codec::Parameters, backend: Backend) -> Result<Self> {
409    let codec = find_decoder(&parameters)?;
410    let state = Self::build_state(parameters, codec, backend)?;
411    Ok(Self {
412      state,
413      hw_frame: alloc_av_frame().map_err(Error::Ffmpeg)?,
414      probe: None,
415      pending_frames: VecDeque::new(),
416      max_probe_pending_bytes: DEFAULT_MAX_PROBE_PENDING_BYTES,
417    })
418  }
419
420  /// Override the byte budget for probe-replay queued frames. Defaults to
421  /// [`DEFAULT_MAX_PROBE_PENDING_BYTES`]. Use a higher value when targeting
422  /// 8K+ workloads where 16 frames at full size could exceed the default;
423  /// use a lower value in memory-constrained services to bound peak
424  /// allocation more tightly.
425  ///
426  /// Setting after the first frame has been delivered is harmless but has
427  /// no observable effect — the probe has already collapsed and the cap
428  /// only applies during replay drain.
429  ///
430  /// Returns `self` for builder-style chaining:
431  /// ```ignore
432  /// let decoder = VideoDecoder::open(params)?
433  ///     .with_max_probe_pending_bytes(1024 * 1024 * 1024); // 1 GiB
434  /// ```
435  #[must_use]
436  pub fn with_max_probe_pending_bytes(mut self, bytes: usize) -> Self {
437    self.max_probe_pending_bytes = bytes;
438    self
439  }
440
441  /// The backend currently producing frames. While the probe is still in
442  /// progress (no frame received yet) this returns the optimistically
443  /// selected backend; after the first frame, it is the backend that
444  /// actually produced it. Once stable, never changes again.
445  pub fn backend(&self) -> Backend {
446    self.state.backend
447  }
448
449  /// Decoder width in pixels.
450  pub fn width(&self) -> u32 {
451    self.state.inner.width()
452  }
453
454  /// Decoder height in pixels.
455  pub fn height(&self) -> u32 {
456    self.state.inner.height()
457  }
458
459  /// Codec context time base.
460  pub fn time_base(&self) -> Rational {
461    self.state.inner.time_base()
462  }
463
464  /// Frame rate from the codec context, if known.
465  pub fn frame_rate(&self) -> Option<Rational> {
466    self.state.inner.frame_rate()
467  }
468
469  /// Reclassify a post-commit runtime error from the committed HW backend
470  /// into [`Error::AllBackendsFailed`] so the [`crate::FfmpegVideoStreamDecoder`]
471  /// wrapper recognises it as a HW-path exhaustion and falls back to
472  /// software. The single attempt records the committed backend
473  /// (`self.state.backend` is the live backend post-commit) paired with the
474  /// underlying FFmpeg error. `unconsumed_packets` is empty: the probe
475  /// buffer is gone after commit, so the wrapper's rolling
476  /// since-last-keyframe buffer supplies the replay set.
477  fn post_commit_hw_failure(&self, e: ffmpeg_next::Error) -> Error {
478    // `new_post_commit` stamps `FallbackOrigin::PostCommit`: the wrapper
479    // routes its replay on that explicit signal, not on the (here-empty)
480    // `unconsumed_packets`, which a probe-era first-packet cap trip also
481    // leaves empty.
482    Error::AllBackendsFailed(AllBackendsFailed::new_post_commit(vec![(
483      self.state.backend,
484      Box::new(Error::Ffmpeg(e)),
485    )]))
486  }
487
488  /// Submit a packet to the decoder.
489  ///
490  /// On success — and only on success — the packet is buffered for potential
491  /// replay through a fallback backend while the probe is active. EAGAIN
492  /// (decoder needs `receive_frame` to drain output first) propagates as
493  /// normal backpressure; the caller drains then retries.
494  ///
495  /// While the probe is active, a non-transient error (e.g. the active HW
496  /// backend rejecting this stream's geometry on first packet) advances the
497  /// probe to the next candidate and retries the packet there. The caller
498  /// observes only the eventual success or, if the probe is exhausted, the
499  /// final error.
500  ///
501  /// **Atomic probe rescue.** While the probe is active, the rescue
502  /// invariant is that everything FFmpeg has consumed since open is
503  /// reflected in `buffered_packets` (so a future
504  /// [`Error::AllBackendsFailed`] can hand a complete replay history
505  /// back to the caller for software fallback on a non-seekable input).
506  /// If we cannot prove this packet is buffer-able — its side-data
507  /// entry count exceeds [`MAX_PROBE_PACKET_SIDE_DATA_ENTRIES`], its
508  /// bytes would push the probe past [`MAX_PROBE_PACKETS`] or
509  /// [`MAX_PROBE_PACKET_BYTES`], or [`av_packet_ref`] fails ENOMEM —
510  /// `send_packet` returns [`Error::AllBackendsFailed`] **without
511  /// invoking** `state.inner.send_packet` on this packet. The caller's
512  /// packet stays in their hand and `unconsumed_packets` carries the
513  /// pre-existing buffered history, so they can replay
514  /// `unconsumed_packets` plus the current packet through their
515  /// software decoder of choice. The post-probe path (after the first
516  /// frame, when `self.probe` is `None`) skips this pre-flight
517  /// entirely.
518  pub fn send_packet(&mut self, packet: &Packet) -> Result<()> {
519    loop {
520      // Pre-flight while probe is active: prove we can record this
521      // packet for replay BEFORE the active decoder consumes it.
522      // `staged_clone` carries the refcounted clone and the new
523      // `buffered_bytes` value through the send below; we only commit
524      // them to the probe state if FFmpeg accepts the packet.
525      let staged_clone: Option<(Packet, usize)> = if let Some(probe) = self.probe.as_ref() {
526        // Step 1: side-data entry count cap. Read just `side_data_elems`
527        // (no array walk yet) so a corrupt or weaponised value cannot
528        // drive an unbounded loop from the safe entry point.
529        let side_count = packet_side_data_count(packet);
530        if side_count > MAX_PROBE_PACKET_SIDE_DATA_ENTRIES {
531          let probe = self.probe.take().expect("probe present");
532          tracing::warn!(
533            side_data_entries = side_count,
534            max_side_data_entries = MAX_PROBE_PACKET_SIDE_DATA_ENTRIES,
535            trigger = "side_data_entry_cap",
536            "hwdecode: probe rescue exhausted before consuming packet; \
537             returning AllBackendsFailed without invoking decoder"
538          );
539          return Err(Error::AllBackendsFailed(AllBackendsFailed::new(
540            probe.attempts,
541            probe.buffered_packets,
542          )));
543        }
544        // Step 2: byte / packet count cap. `packet_side_data_bytes`
545        // clamps its walk to MAX_PROBE_PACKET_SIDE_DATA_ENTRIES as
546        // defense-in-depth even though the count check above already
547        // bounded the array length.
548        let pkt_size = packet.size().saturating_add(packet_side_data_bytes(
549          packet,
550          MAX_PROBE_PACKET_SIDE_DATA_ENTRIES,
551        ));
552        let new_count = probe.buffered_packets.len() + 1;
553        let new_bytes = probe.buffered_bytes.saturating_add(pkt_size);
554        if new_count > MAX_PROBE_PACKETS || new_bytes > MAX_PROBE_PACKET_BYTES {
555          let probe = self.probe.take().expect("probe present");
556          tracing::warn!(
557            packets = new_count,
558            bytes = new_bytes,
559            side_data_entries = side_count,
560            max_packets = MAX_PROBE_PACKETS,
561            max_bytes = MAX_PROBE_PACKET_BYTES,
562            trigger = "byte_or_packet_cap",
563            "hwdecode: probe rescue exhausted before consuming packet; \
564             returning AllBackendsFailed without invoking decoder"
565          );
566          return Err(Error::AllBackendsFailed(AllBackendsFailed::new(
567            probe.attempts,
568            probe.buffered_packets,
569          )));
570        }
571        // Step 3: pre-clone before consuming. `av_packet_ref` is a
572        // refcounted shallow clone (no payload deep-copy) but can still
573        // ENOMEM on heavy side-data; if it does we bail rather than
574        // consuming a packet we can't track.
575        match try_clone_packet(packet) {
576          Ok(c) => Some((c, new_bytes)),
577          Err(e) => {
578            let probe = self.probe.take().expect("probe present");
579            tracing::warn!(
580              error = %e,
581              "hwdecode: packet clone failed before consuming; \
582               returning AllBackendsFailed without invoking decoder"
583            );
584            return Err(Error::AllBackendsFailed(AllBackendsFailed::new(
585              probe.attempts,
586              probe.buffered_packets,
587            )));
588          }
589        }
590      } else {
591        None
592      };
593
594      match self.state.inner.send_packet(packet) {
595        Ok(()) => {
596          if let Some((cloned, new_bytes)) = staged_clone {
597            // Probe is still Some here: the only paths that take it are
598            // the bailouts above (which return) and `advance_probe`'s
599            // exhaustion (which would have propagated via `?`). Commit
600            // the clone now that FFmpeg has accepted the packet.
601            if let Some(probe) = self.probe.as_mut() {
602              probe.buffered_packets.push(cloned);
603              probe.buffered_bytes = new_bytes;
604            }
605          }
606          return Ok(());
607        }
608        Err(e) if is_transient(&e) => {
609          // EAGAIN / EOF backpressure — pass through unchanged. The
610          // staged clone drops; the caller will retry after draining
611          // and we'll re-clone at the top of the loop.
612          return Err(Error::Ffmpeg(e));
613        }
614        Err(e) => {
615          if self.probe.is_some() {
616            // advance_probe consumes the error into `attempts` and
617            // either installs a candidate (Ok — loop top re-clones for
618            // the new candidate) or surfaces AllBackendsFailed (Err —
619            // `?` propagates). Either way the staged clone we just
620            // built drops without entering history; the next iteration
621            // clones afresh against the new active state.
622            self.advance_probe(Error::Ffmpeg(e))?;
623            continue;
624          }
625          // Post-commit (probe gone): the committed HW backend just failed
626          // at runtime. A HW-only decoder's non-transient, non-EOF error
627          // means the backend can't decode this content — reclassify to
628          // AllBackendsFailed so the wrapper falls back to software.
629          if is_hw_decode_failure(&e) {
630            return Err(self.post_commit_hw_failure(e));
631          }
632          return Err(Error::Ffmpeg(e));
633        }
634      }
635    }
636  }
637
638  /// Signal end-of-stream to the decoder.
639  ///
640  /// Recorded for replay only if the underlying `send_eof` succeeds. While
641  /// the probe is active, non-transient errors trigger probe advance and
642  /// retry, matching `send_packet`'s behaviour.
643  pub fn send_eof(&mut self) -> Result<()> {
644    loop {
645      match self.state.inner.send_eof() {
646        Ok(()) => {
647          if let Some(probe) = self.probe.as_mut() {
648            probe.eof_sent = true;
649          }
650          return Ok(());
651        }
652        Err(e) if is_transient(&e) => return Err(Error::Ffmpeg(e)),
653        Err(e) => {
654          if self.probe.is_some() {
655            self.advance_probe(Error::Ffmpeg(e))?;
656            continue;
657          }
658          // Post-commit: committed HW backend failed draining at EOF.
659          // Reclassify a HW-decode failure so the wrapper falls back to SW
660          // (the SW decoder will re-receive the buffered GOP + EOF).
661          if is_hw_decode_failure(&e) {
662            return Err(self.post_commit_hw_failure(e));
663          }
664          return Err(Error::Ffmpeg(e));
665        }
666      }
667    }
668  }
669
670  /// Receive a CPU-side decoded frame.
671  ///
672  /// The frame is downloaded with `av_hwframe_transfer_data` and metadata
673  /// is copied via `av_frame_copy_props`. The caller's frame is always
674  /// unref'd first, so reuse across resolution changes or different
675  /// decoders is safe.
676  ///
677  /// While the probe window is open, *any* non-transient failure (decode
678  /// error, transfer error, copy_props error, or a CPU-format frame from a
679  /// HW-opened context) tears down the current decoder and advances to the
680  /// next hardware backend in probe order, replaying buffered packets
681  /// through it. Frames the candidate produced during replay (drained when
682  /// `send_packet` returned EAGAIN) are queued and delivered FIFO via this
683  /// method, so the caller never loses initial frames after a fallback.
684  ///
685  /// This crate is hardware-only: there is no software fallback inside the
686  /// decoder. When every backend in the probe order has been exhausted —
687  /// including the case of a single-backend platform whose only backend
688  /// failed — this returns [`Error::AllBackendsFailed`] with the per-
689  /// backend attempt log so the caller can branch into a software
690  /// decoder of their choice.
691  ///
692  /// Returns the same transient signals as `ffmpeg::decoder::Video`:
693  /// `Error::Ffmpeg(Other { errno: EAGAIN })` when no frame is ready and
694  /// more packets must be sent, and `Error::Ffmpeg(Eof)` once fully drained.
695  pub fn receive_frame(&mut self, frame: &mut Frame) -> Result<()> {
696    // Pre-drain frames queued during probe replay. They are already CPU-side
697    // (transferred at drain time, when the candidate's HW context was alive)
698    // so we just move them into the caller's slot.
699    if self.try_pop_pending(frame) {
700      return Ok(());
701    }
702
703    loop {
704      let res = self.state.inner.receive_frame(&mut self.hw_frame);
705      match res {
706        Err(e) => {
707          // EAGAIN is normal backpressure — pass through unconditionally.
708          if is_eagain(&e) {
709            return Err(Error::Ffmpeg(e));
710          }
711          // EOF (and every other non-transient error): if we are still
712          // probing, treat it as candidate failure — a backend that drains
713          // to EOF without ever producing a frame should not silently
714          // present as "stream over" to the caller. Advance and retry; if
715          // every backend has been exhausted, advance_probe surfaces
716          // AllBackendsFailed and `?` propagates it.
717          if self.probe.is_some() {
718            self.advance_probe(Error::Ffmpeg(e))?;
719            // Probe advance may have populated `pending_frames`; deliver
720            // one of those before reading more from the new candidate.
721            if self.try_pop_pending(frame) {
722              return Ok(());
723            }
724            continue;
725          }
726          // Probe collapsed already. A non-transient, non-EOF error from the
727          // committed HW backend is a runtime HW-decode failure — reclassify
728          // to AllBackendsFailed so the wrapper falls back to software.
729          // `is_hw_decode_failure` excludes EOF, so a genuine end-of-stream
730          // still propagates as `Error::Ffmpeg(Eof)` (never trapped in an
731          // infinite fallback-retry loop).
732          if is_hw_decode_failure(&e) {
733            return Err(self.post_commit_hw_failure(e));
734          }
735          // Surface the error (including EOF for a genuinely drained stream).
736          return Err(Error::Ffmpeg(e));
737        }
738        Ok(()) => {
739          // Always attempt the HW→CPU transfer. With strict `get_format`,
740          // libavcodec can only deliver frames in the wired-up HW format
741          // (or fail). If a misbehaving codec ever hands us a CPU-side
742          // frame anyway, `av_hwframe_transfer_data` returns AVERROR(EINVAL)
743          // (neither src nor dst has an AVHWFramesContext attached) and we
744          // route through the same error path below.
745          match unsafe { transfer_hw_frame(frame, &mut self.hw_frame) } {
746            Ok(()) => {
747              self.probe = None;
748              return Ok(());
749            }
750            Err(e) => {
751              if self.probe.is_some() {
752                self.advance_probe(Error::Ffmpeg(e))?;
753                unsafe { av_frame_unref(frame.as_inner_mut().as_mut_ptr()) };
754                if self.try_pop_pending(frame) {
755                  return Ok(());
756                }
757                continue;
758              }
759              // Post-commit transfer failure: an unsupported CPU output
760              // pix_fmt surfaces as AVERROR(EINVAL) and a context-loss as
761              // Bug/Bug2/Unknown — all HW-output problems, never input
762              // corruption. Reclassify so the wrapper falls back to software.
763              if is_hw_decode_failure(&e) {
764                return Err(self.post_commit_hw_failure(e));
765              }
766              return Err(Error::Ffmpeg(e));
767            }
768          }
769        }
770      }
771    }
772  }
773
774  /// Pop one queued frame (produced by a candidate decoder during probe
775  /// replay) into the caller's slot. Returns `true` when a frame was
776  /// delivered, `false` when the queue was empty.
777  fn try_pop_pending(&mut self, frame: &mut Frame) -> bool {
778    let Some(mut buffered) = self.pending_frames.pop_front() else {
779      return false;
780    };
781    // SAFETY: `buffered` is a CPU-side AVFrame we previously transferred
782    // and pushed into the queue; both pointers are valid.
783    unsafe {
784      av_frame_unref(frame.as_inner_mut().as_mut_ptr());
785      av_frame_move_ref(frame.as_inner_mut().as_mut_ptr(), buffered.as_mut_ptr());
786    }
787    // Probe semantics: delivering a frame collapses the probe.
788    self.probe = None;
789    true
790  }
791
792  /// Flush internal buffers (e.g. after a seek).
793  ///
794  /// Discards every frame buffered by the decoder, every frame queued during
795  /// probe replay (`pending_frames`), and the residual `hw_frame` scratch
796  /// buffer. Probe-time replay state (buffered packets, EOF marker) is also
797  /// cleared since post-seek packets do not align with the previously
798  /// captured history. After a flush, the next `receive_frame` waits for new
799  /// post-seek input.
800  pub fn flush(&mut self) {
801    self.state.inner.flush();
802    // SAFETY: hw_frame is a valid AVFrame we own; av_frame_unref is a no-op
803    // for an already-empty frame.
804    unsafe { av_frame_unref(self.hw_frame.as_mut_ptr()) };
805    self.pending_frames.clear();
806    if let Some(probe) = self.probe.as_mut() {
807      probe.buffered_packets.clear();
808      probe.buffered_bytes = 0;
809      probe.eof_sent = false;
810    }
811  }
812
813  /// Try the next backend in `remaining_backends`. Transactional: a
814  /// candidate must successfully build and accept the replayed history
815  /// before any probe state is consumed. Backends that fail to build or
816  /// reject the replay are recorded into `probe.attempts` and the loop
817  /// continues to the next one.
818  ///
819  /// `last_error` is the error that triggered this advance — i.e. the
820  /// failure of the currently active backend on `send_packet` /
821  /// `send_eof` / `receive_frame`. It is recorded against the active
822  /// backend before any candidate is tried so that a final
823  /// `AllBackendsFailed` carries the full attempt log including the
824  /// initially-opened backend's runtime failure.
825  ///
826  /// Returns:
827  /// - `Ok(())` when a candidate is installed and replay completed —
828  ///   caller should retry the operation.
829  /// - `Err(Error::AllBackendsFailed(p))` when every remaining
830  ///   backend has been exhausted (including the just-failed active one).
831  ///   `p.attempts()` carries the per-backend failure log.
832  ///   This is what the documented `open` contract promises, surfaced at
833  ///   runtime so the caller can branch into a software fallback. On a
834  ///   single-backend platform (e.g. macOS), this fires after the only
835  ///   backend's first-frame failure; on multi-backend platforms it
836  ///   fires after the last candidate's failure.
837  /// - `Err(_)` for other fatal conditions surfaced by probe machinery
838  ///   itself (e.g. `alloc_av_frame` ENOMEM during replay drain).
839  fn advance_probe(&mut self, last_error: Error) -> Result<()> {
840    // Record the failure that triggered this advance against the active
841    // backend. If the probe was somehow already gone (shouldn't happen —
842    // call sites guard with `self.probe.is_some()`), just propagate the
843    // error so behaviour matches the pre-fix code path.
844    let active_backend = self.state.backend;
845    match self.probe.as_mut() {
846      Some(probe) => probe.attempts.push((active_backend, Box::new(last_error))),
847      None => return Err(last_error),
848    }
849
850    // Drop frames previously queued from the backend we're now abandoning.
851    // They came from a candidate that just failed for cause and cannot be
852    // trusted alongside frames we may queue from the next candidate. (If
853    // this method is called repeatedly via chained probe advances, this
854    // also keeps `pending_frames` from accumulating frames from multiple
855    // rejected backends.)
856    self.pending_frames.clear();
857
858    loop {
859      // Snapshot inputs without mutating probe state. Use the checked
860      // clone helper rather than `Parameters::clone` (which masks ENOMEM).
861      let (next_backend, parameters, codec) = match self.probe.as_ref() {
862        Some(probe) if !probe.remaining_backends.is_empty() => {
863          let parameters = match try_clone_parameters(&probe.parameters) {
864            Ok(p) => p,
865            Err(e) => {
866              tracing::warn!(
867                error = %e,
868                "hwdecode: parameters clone failed during probe advance; popping backend and trying next"
869              );
870              let popped = self
871                .probe
872                .as_mut()
873                .expect("probe state present")
874                .remaining_backends
875                .remove(0);
876              self
877                .probe
878                .as_mut()
879                .expect("probe state present")
880                .attempts
881                .push((popped, Box::new(Error::Ffmpeg(e))));
882              continue;
883            }
884          };
885          (probe.remaining_backends[0], parameters, probe.codec)
886        }
887        // No more candidates — surface the accumulated attempt log as
888        // AllBackendsFailed so single- and multi-backend platforms have
889        // the same contract for "every HW backend failed."
890        //
891        // Hand the buffered packet history back to the caller along
892        // with the attempt log: those packets were consumed from the
893        // caller's demuxer (and refcounted-cloned into `buffered_packets`)
894        // before the probe exhausted, and for non-seekable inputs the
895        // caller cannot re-demux them. Returning them here lets a
896        // caller-side software fallback replay the same byte history
897        // through `ffmpeg::decoder::Video` without losing initial frames.
898        // Dropping `ProbeState` after the take frees the codec/params
899        // refs we no longer need; only `attempts` and `buffered_packets`
900        // are retained.
901        _ => {
902          let (attempts, unconsumed_packets) = self
903            .probe
904            .take()
905            .map(|p| (p.attempts, p.buffered_packets))
906            .unwrap_or_default();
907          return Err(Error::AllBackendsFailed(AllBackendsFailed::new(
908            attempts,
909            unconsumed_packets,
910          )));
911        }
912      };
913
914      let prev_backend = self.state.backend;
915      tracing::warn!(from = ?prev_backend, to = ?next_backend, "hwdecode: advancing probe");
916
917      // Build candidate. On failure, record into attempts and continue
918      // without touching the packet buffer.
919      let mut candidate_state = match Self::build_state(parameters, codec, next_backend) {
920        Ok(s) => s,
921        Err(e) => {
922          tracing::warn!(?next_backend, error = %e, "hwdecode: candidate build failed");
923          self
924            .probe
925            .as_mut()
926            .expect("probe state present")
927            .remaining_backends
928            .remove(0);
929          self
930            .probe
931            .as_mut()
932            .expect("probe state present")
933            .attempts
934            .push((next_backend, Box::new(e)));
935          continue;
936        }
937      };
938
939      // Replay buffered history through the candidate WITHOUT installing it.
940      // We borrow the buffer immutably; if replay fails the candidate's Drop
941      // releases the FFmpeg state and the buffer is preserved for the next
942      // attempt.
943      //
944      // EAGAIN handling: `avcodec_send_packet` may return EAGAIN when its
945      // internal queue is full and the user is expected to drain output
946      // first (B-frame buffering, candidate-specific queue depth, etc.).
947      // This is normal flow — we drain frames out of the candidate, transfer
948      // each one to a CPU frame, and stash them in `local_pending`. After
949      // commit they move to `self.pending_frames` and are delivered FIFO
950      // by `receive_frame`, so the caller never loses initial frames.
951      let mut local_pending: VecDeque<frame::Video> = VecDeque::new();
952      let mut local_pending_bytes: usize = 0;
953      let max_pending_bytes = self.max_probe_pending_bytes;
954      let replay_result: std::result::Result<(), ffmpeg_next::Error> = {
955        let probe = self.probe.as_ref().expect("probe state present");
956        let mut hw_buf = match alloc_av_frame() {
957          Ok(f) => f,
958          Err(e) => return Err(Error::Ffmpeg(e)),
959        };
960        let mut r: std::result::Result<(), ffmpeg_next::Error> = Ok(());
961
962        'replay: for pkt in &probe.buffered_packets {
963          loop {
964            match candidate_state.inner.send_packet(pkt) {
965              Ok(()) => break,
966              Err(e) if is_eagain(&e) => {
967                // Drain candidate output (transferring + queueing each frame)
968                // and retry the same packet.
969                if let Err(de) = drain_into_pending(
970                  &mut candidate_state.inner,
971                  &mut hw_buf,
972                  &mut local_pending,
973                  &mut local_pending_bytes,
974                  max_pending_bytes,
975                ) {
976                  r = Err(de);
977                  break 'replay;
978                }
979              }
980              Err(e) => {
981                r = Err(e);
982                break 'replay;
983              }
984            }
985          }
986        }
987        if r.is_ok() && probe.eof_sent {
988          // `avcodec_send_packet(NULL)` (which `send_eof` becomes) can
989          // return EAGAIN with the same drain-output-first semantics as
990          // a regular send_packet. Loop drain+retry instead of failing
991          // the candidate on backpressure.
992          loop {
993            match candidate_state.inner.send_eof() {
994              Ok(()) => break,
995              Err(e) if is_eagain(&e) => {
996                if let Err(de) = drain_into_pending(
997                  &mut candidate_state.inner,
998                  &mut hw_buf,
999                  &mut local_pending,
1000                  &mut local_pending_bytes,
1001                  max_pending_bytes,
1002                ) {
1003                  r = Err(de);
1004                  break;
1005                }
1006              }
1007              Err(e) => {
1008                r = Err(e);
1009                break;
1010              }
1011            }
1012          }
1013        }
1014        r
1015      };
1016
1017      if let Err(e) = replay_result {
1018        tracing::warn!(?next_backend, error = %e, "hwdecode: candidate replay failed");
1019        // Drop candidate explicitly so its FFI cleanup runs now. Discard any
1020        // frames we drained from this candidate — they're tied to a decoder
1021        // we're throwing away.
1022        drop(candidate_state);
1023        drop(local_pending);
1024        self
1025          .probe
1026          .as_mut()
1027          .expect("probe state present")
1028          .remaining_backends
1029          .remove(0);
1030        self
1031          .probe
1032          .as_mut()
1033          .expect("probe state present")
1034          .attempts
1035          .push((next_backend, Box::new(Error::Ffmpeg(e))));
1036        continue;
1037      }
1038
1039      // Commit: install the candidate, clear residual hw_frame, queue the
1040      // drained frames for the caller, and pop the now-active backend.
1041      self.state = candidate_state;
1042      unsafe { av_frame_unref(self.hw_frame.as_mut_ptr()) };
1043      self.pending_frames.append(&mut local_pending);
1044      self
1045        .probe
1046        .as_mut()
1047        .expect("probe state present")
1048        .remaining_backends
1049        .remove(0);
1050      return Ok(());
1051    }
1052  }
1053
1054  /// Build raw FFmpeg state for one hardware backend. Strict `get_format`
1055  /// (NONE on missing HW format); cross-backend fallback is the caller's job.
1056  fn build_state(
1057    parameters: codec::Parameters,
1058    codec: Codec,
1059    backend: Backend,
1060  ) -> Result<DecoderState> {
1061    // Use our checked allocator instead of Context::from_parameters, which
1062    // does not null-check avcodec_alloc_context3 and would feed a null
1063    // AVCodecContext into FFmpeg under OOM.
1064    let mut ctx = build_codec_context(&parameters)?;
1065    let av_type = backend.av_hwdevice_type();
1066
1067    // Verify the codec advertises this hwaccel **with the exact HW pix_fmt
1068    // we're about to wire up in `get_format`**. FFmpeg's HW config table
1069    // is keyed per (device_type, pix_fmt); a codec can advertise the same
1070    // device with several HW pix_fmts, so matching only on device_type
1071    // would let probing succeed for a backend whose pix_fmt the codec
1072    // never offers — the failure would then surface deep inside the
1073    // probe/decode loop. Matching the exact pix_fmt keeps the strict
1074    // `get_format` honest and gives `open_with` a clean rejection.
1075    let hw_pix_fmt = backend.hw_pixel_format();
1076    if !codec_supports_hwaccel(unsafe { codec.as_ptr() }, av_type, hw_pix_fmt as i32) {
1077      return Err(Error::BackendUnsupportedByCodec(backend));
1078    }
1079
1080    // Create the device context.
1081    let mut hw_device_ref: *mut AVBufferRef = ptr::null_mut();
1082    // SAFETY: `hw_device_ref` is a stack ptr we hand FFmpeg to fill.
1083    let ret = unsafe {
1084      av_hwdevice_ctx_create(&mut hw_device_ref, av_type, ptr::null(), ptr::null_mut(), 0)
1085    };
1086    if ret < 0 {
1087      return Err(Error::HwDeviceInitFailed(HwDeviceInitFailed::new(
1088        backend,
1089        ffmpeg_next::Error::from(ret),
1090      )));
1091    }
1092
1093    let callback_state = Box::into_raw(Box::new(CallbackState {
1094      wanted: hw_pix_fmt,
1095      wanted_int: hw_pix_fmt as i32,
1096    }));
1097    // RAII guard: from now until the end-of-function `into_owned()`, every
1098    // early return — `av_buffer_ref` failure, `open_as` failure, codec_type
1099    // mismatch, or any future error path added between here and the
1100    // `DecoderState` construction — frees `hw_device_ref` and
1101    // `callback_state` via the guard's Drop. Without it, each error site
1102    // had to remember to clean up these two FFI-owned resources by hand;
1103    // the codec_type-mismatch branch was missed and silently leaked one
1104    // device ref + one heap allocation per bad input.
1105    let guard = PartialBuildState {
1106      hw_device_ref,
1107      callback_state,
1108    };
1109
1110    // SAFETY: ctx is a freshly-constructed AVCodecContext we own;
1111    // av_buffer_ref bumps the refcount of the device buffer for FFmpeg's
1112    // use (we keep our own ref in `hw_device_ref` for cleanup).
1113    // av_buffer_ref returns NULL on allocation failure; we must check it
1114    // before assigning, otherwise the codec context would be opened with a
1115    // HW-flagged setup but no actual device reference.
1116    let device_ref_for_ctx = unsafe { av_buffer_ref(hw_device_ref) };
1117    if device_ref_for_ctx.is_null() {
1118      // guard's Drop frees hw_device_ref (the first ref) and callback_state.
1119      return Err(Error::Ffmpeg(ffmpeg_next::Error::Other {
1120        errno: libc::ENOMEM,
1121      }));
1122    }
1123    // SAFETY: device_ref_for_ctx is a valid AVBufferRef* from av_buffer_ref;
1124    // ctx is freshly built and owned by us. After this point ctx aliases
1125    // `callback_state` via `opaque` (FFmpeg never frees opaque, so
1126    // `callback_state` ownership stays with us / the guard) and aliases
1127    // `device_ref_for_ctx` (the second ref) via `hw_device_ctx` (FFmpeg
1128    // unrefs that on codec context drop, independent of the guard's first
1129    // ref).
1130    unsafe {
1131      let raw = ctx.as_mut_ptr();
1132      (*raw).hw_device_ctx = device_ref_for_ctx;
1133      (*raw).opaque = callback_state.cast();
1134      (*raw).get_format = Some(get_hw_format);
1135    }
1136
1137    // Open the decoder. On failure `ctx`/`opened` Drop releases the codec
1138    // context (and via that the second device ref); the guard releases the
1139    // first device ref and the callback state.
1140    //
1141    // We deliberately bypass `Opened::video()` because it calls
1142    // `Context::medium()`, which reads `AVCodecContext.codec_type` as the
1143    // bindgen `AVMediaType` enum — the same UB hazard we've been
1144    // systematically removing. Instead: validate `codec_type` as a raw
1145    // `c_int` ourselves, then construct the `decoder::Video` wrapper
1146    // directly via its public tuple field.
1147    let opened = ctx.decoder().open_as(codec).map_err(Error::Ffmpeg)?;
1148
1149    // Validate codec_type as a raw integer — never construct AVMediaType
1150    // from an unvalidated runtime value.
1151    // SAFETY: codec_type is bound as AVMediaType (`#[repr(i32)]`), same
1152    // size and alignment as i32; reading the bytes as i32 cannot be UB.
1153    let codec_type_int: i32 =
1154      unsafe { ptr::read(ptr::addr_of!((*opened.as_ptr()).codec_type) as *const i32) };
1155    let video_type_int: i32 = AVMediaType::AVMEDIA_TYPE_VIDEO as i32;
1156    if codec_type_int != video_type_int {
1157      // Not a video codec context — surface the same error
1158      // `Opened::video()` would have, without going through enum
1159      // construction. `opened`'s Drop releases the codec context; the
1160      // guard releases the first hw_device_ref and the callback state.
1161      return Err(Error::Ffmpeg(ffmpeg_next::Error::InvalidData));
1162    }
1163    // SAFETY of construction: `decoder::Video` is `pub struct Video(pub Opened)`.
1164    // We construct via the public field; this is the same wrapping
1165    // `Opened::video()` does on success, just without the enum read.
1166    let opened = ffmpeg_next::decoder::Video(opened);
1167
1168    // Disarm the guard and transfer ownership of both resources into the
1169    // returned DecoderState (whose own Drop handles their lifetime).
1170    let (hw_device_ref, callback_state) = guard.into_owned();
1171    Ok(DecoderState {
1172      inner: ManuallyDrop::new(opened),
1173      backend,
1174      hw_device_ref,
1175      callback_state,
1176    })
1177  }
1178}
1179
1180/// RAII guard for the partially-owned FFmpeg state that
1181/// [`VideoDecoder::build_state`] holds between the
1182/// `av_hwdevice_ctx_create` and `Box::into_raw(CallbackState)`
1183/// allocations and the final `DecoderState` construction.
1184///
1185/// If `build_state` returns `Err` for any reason in that window
1186/// (`av_buffer_ref` ENOMEM, `open_as` failure, codec_type mismatch, or
1187/// any future error path), this guard's `Drop` releases
1188/// `hw_device_ref` — the first ref returned by `av_hwdevice_ctx_create`,
1189/// distinct from the second ref FFmpeg unrefs when the codec context
1190/// drops — and the boxed `CallbackState`, which FFmpeg never touches
1191/// because `AVCodecContext::opaque` is purely user-owned.
1192///
1193/// Successful construction calls [`Self::into_owned`] to disarm the
1194/// guard and hand both pointers to the new `DecoderState`.
1195struct PartialBuildState {
1196  hw_device_ref: *mut AVBufferRef,
1197  callback_state: *mut CallbackState,
1198}
1199
1200impl PartialBuildState {
1201  /// Disarm the guard: return the owned pointers and replace the guard's
1202  /// fields with null so its Drop is a no-op.
1203  fn into_owned(mut self) -> (*mut AVBufferRef, *mut CallbackState) {
1204    let hw = std::mem::replace(&mut self.hw_device_ref, ptr::null_mut());
1205    let cb = std::mem::replace(&mut self.callback_state, ptr::null_mut());
1206    (hw, cb)
1207  }
1208}
1209
1210impl Drop for PartialBuildState {
1211  fn drop(&mut self) {
1212    // SAFETY: pointers are either freshly allocated by `build_state` (via
1213    // `av_hwdevice_ctx_create` and `Box::into_raw`) or null after
1214    // `into_owned`. Both `av_buffer_unref` and `Box::from_raw` need the
1215    // null check we apply here; both are otherwise sound on resources we
1216    // own.
1217    unsafe {
1218      if !self.hw_device_ref.is_null() {
1219        let mut hw = self.hw_device_ref;
1220        av_buffer_unref(&mut hw);
1221      }
1222      if !self.callback_state.is_null() {
1223        drop(Box::from_raw(self.callback_state));
1224      }
1225    }
1226  }
1227}
1228
1229/// Download a HW frame into a CPU [`Frame`]. Always unrefs the destination
1230/// first so reuse across resolution changes is safe.
1231///
1232/// Deliberately does **not** call `av_frame_copy_props`. That FFmpeg
1233/// helper deep-copies AVFrame side data (SEI, mastering display, ICC
1234/// profiles, dynamic HDR, etc.), the metadata dict, and bumps both
1235/// `opaque_ref` and `private_ref` on every receive — none of which
1236/// `Frame` exposes via its public accessors. On a crafted stream with
1237/// megabytes of per-frame metadata that would mean an unbounded
1238/// allocation per receive, with no caller-visible benefit. We instead
1239/// copy only the scalar fields the public API can read (today: `pts`);
1240/// pixel layout (`width`, `height`, `format`, `linesize`, `data`) is
1241/// already set by `av_hwframe_transfer_data`. If `Frame` ever grows
1242/// accessors for timing extras (`duration`, `time_base`, `pkt_dts`) or
1243/// color metadata, add those to `copy_frame_props_minimal` at the same
1244/// time.
1245unsafe fn transfer_hw_frame(
1246  dst: &mut Frame,
1247  src: &mut frame::Video,
1248) -> std::result::Result<(), ffmpeg_next::Error> {
1249  unsafe {
1250    av_frame_unref(dst.as_inner_mut().as_mut_ptr());
1251    let ret = av_hwframe_transfer_data(dst.as_inner_mut().as_mut_ptr(), src.as_ptr(), 0);
1252    if ret < 0 {
1253      return Err(ffmpeg_next::Error::from(ret));
1254    }
1255    // Validate the post-transfer CPU pix_fmt against the safe `Frame`
1256    // accessor's supported set. FFmpeg picks the destination format
1257    // when `dst.format == AV_PIX_FMT_NONE` on entry (which it always is
1258    // here — `av_frame_unref` clears it) by walking the result of
1259    // `av_hwframe_transfer_get_formats`. Driver/version ordering can
1260    // pick a layout outside our NV*/P0xx/P2xx/P4xx set; the call would
1261    // return success while the resulting frame is unreadable through
1262    // `Frame::row` / `Frame::as_ptr` (those return `None` for
1263    // unsupported formats). Surface the unsupported result as a
1264    // transfer failure so `receive_frame`'s probe-active path advances
1265    // to the next backend rather than collapsing on an unusable frame;
1266    // post-probe, the caller gets an `Err` they can branch into a
1267    // software fallback.
1268    let dst_raw_fmt: i32 = (*dst.as_inner_mut().as_ptr()).format;
1269    let dst_pix_fmt = crate::boundary::from_av_pixel_format(dst_raw_fmt);
1270    if !crate::frame::is_supported_cpu_pix_fmt(dst_pix_fmt) {
1271      tracing::warn!(
1272        pix_fmt = dst_raw_fmt,
1273        "hwdecode: hw->cpu transfer produced unsupported pix_fmt; \
1274         treating as backend failure"
1275      );
1276      av_frame_unref(dst.as_inner_mut().as_mut_ptr());
1277      return Err(ffmpeg_next::Error::Other {
1278        errno: libc::EINVAL,
1279      });
1280    }
1281    if let Err(e) = copy_frame_props_minimal(dst.as_inner_mut().as_mut_ptr(), src.as_ptr()) {
1282      // Failed to propagate metadata. Reset the destination so the
1283      // partial frame doesn't leak (its pixel buffers were attached
1284      // by `av_hwframe_transfer_data` above) and surface as a
1285      // backend failure — the probe path will advance to the next
1286      // candidate; post-probe, the caller branches into SW fallback.
1287      av_frame_unref(dst.as_inner_mut().as_mut_ptr());
1288      return Err(e);
1289    }
1290  }
1291  Ok(())
1292}
1293
1294/// Copies AVFrame metadata (timestamps, color metadata, crop rect,
1295/// flags, side data, etc.) from the source HW frame to the destination
1296/// CPU frame so the post-transfer frame surfaces the same metadata a
1297/// SW-decoded frame would.
1298///
1299/// Defers to FFmpeg's `av_frame_copy_props`, which handles the per-
1300/// `side_data[i]` allocation, dict copy, and refcounted buffer
1301/// replacements internally. The cost is bounded by what the source
1302/// frame attaches — typical HDR streams carry 1–3 side-data entries
1303/// (mastering display, content light level, dolby/HDR10+ dynamic
1304/// metadata) totalling a few hundred bytes, so per-frame allocation
1305/// overhead stays negligible relative to the pixel data already
1306/// transferred via `av_hwframe_transfer_data`.
1307///
1308/// # Safety
1309/// Both pointers must be valid `AVFrame` pointers we own. We do not
1310/// form `&AVFrame` — `av_frame_copy_props` operates on raw pointers
1311/// directly.
1312/// Sum the byte sizes of every entry in `(*frame).side_data[]`.
1313/// Used by the probe replay queue's byte-cap accounting so a
1314/// frame's deep-copied side-data is charged against
1315/// `max_probe_pending_bytes` along with its pixel buffers.
1316///
1317/// # Safety
1318/// `frame` must be a live `*const AVFrame`. Reads only `nb_side_data`,
1319/// the `side_data` pointer array, and each `AVFrameSideData.size` —
1320/// no `&AVFrame` reference is formed.
1321unsafe fn sum_side_data_bytes(frame: *const AVFrame) -> usize {
1322  // Clamp `nb_side_data` to the same entry cap the copy path
1323  // enforces. Without the clamp, a decoder-controlled or
1324  // version-skew `nb_side_data` value (the bindgen field is
1325  // `c_int`, signed) could drive this walk arbitrarily long
1326  // before the cap downstream kicks in. Negative values are
1327  // pinned to zero before casting.
1328  let raw = unsafe { (*frame).nb_side_data };
1329  let arr = unsafe { (*frame).side_data };
1330  if raw <= 0 || arr.is_null() {
1331    return 0;
1332  }
1333  let count = (raw as usize).min(HW_COPY_SIDE_DATA_MAX_ENTRIES);
1334  let mut total: usize = 0;
1335  for i in 0..count {
1336    // SAFETY: `arr` points to `nb_side_data` valid `*mut AVFrameSideData`
1337    // entries per FFmpeg's contract; `i < count` is in-bounds.
1338    let entry = unsafe { *arr.add(i) };
1339    if entry.is_null() {
1340      continue;
1341    }
1342    let sz = unsafe { (*entry).size };
1343    total = total.saturating_add(sz);
1344    if total >= HW_COPY_SIDE_DATA_MAX_TOTAL_BYTES {
1345      // Already at or above the byte cap — further entries can't
1346      // change the projected-vs-cap decision the caller makes.
1347      total = HW_COPY_SIDE_DATA_MAX_TOTAL_BYTES;
1348      break;
1349    }
1350  }
1351  total
1352}
1353
1354/// Hard cap on the number of `AVFrameSideData` entries we copy from
1355/// HW source frame to CPU destination frame on the HW transfer
1356/// path. Mirrors `convert::SIDE_DATA_MAX_ENTRIES`; the public
1357/// converter re-enforces the same cap so this is defense in depth.
1358const HW_COPY_SIDE_DATA_MAX_ENTRIES: usize = 64;
1359/// Hard cap on the total side-data byte budget per HW transfer.
1360/// Mirrors `convert::SIDE_DATA_MAX_TOTAL_BYTES`.
1361const HW_COPY_SIDE_DATA_MAX_TOTAL_BYTES: usize = 256 * 1024;
1362
1363/// Maps a raw `AV_FRAME_DATA_*` integer to the matching bindgen
1364/// `AVFrameSideDataType` enum value when (and only when) the integer
1365/// is a known discriminant in the linked FFmpeg's bindgen output.
1366/// Returns `None` for unknown / version-skew / corrupt values —
1367/// the caller drops those entries instead of `transmute`-ing an
1368/// arbitrary integer back into the enum (which would be immediate
1369/// UB if the discriminant isn't in the enum's set).
1370///
1371/// The whitelist covers the entries safe to preserve across HW
1372/// transfer:
1373/// - HDR10 / HDR10+ / Dolby Vision / Vivid / ambient HDR metadata
1374/// - SMPTE / GOP timecodes
1375/// - ICC color profile
1376/// - A53 closed captions
1377/// - Spherical / display matrix orientation
1378/// - Stereo3D layout
1379///
1380/// Other AV_FRAME_DATA_* constants exist (motion vectors, encoder
1381/// params, RPU buffers, …) but are either decoder-internal or
1382/// rarely useful through the public mediadecode API; dropping them
1383/// is the safe default.
1384fn whitelisted_side_data_kind(kind_raw: i32) -> Option<ffmpeg_next::ffi::AVFrameSideDataType> {
1385  use ffmpeg_next::ffi::AVFrameSideDataType;
1386  // Each match arm compares `kind_raw` against the i32 cast of a
1387  // known constant, then returns the constant itself — we never
1388  // construct the enum from arbitrary integer bytes.
1389  let kind = match kind_raw {
1390    x if x == AVFrameSideDataType::AV_FRAME_DATA_PANSCAN as i32 => {
1391      AVFrameSideDataType::AV_FRAME_DATA_PANSCAN
1392    }
1393    x if x == AVFrameSideDataType::AV_FRAME_DATA_A53_CC as i32 => {
1394      AVFrameSideDataType::AV_FRAME_DATA_A53_CC
1395    }
1396    x if x == AVFrameSideDataType::AV_FRAME_DATA_STEREO3D as i32 => {
1397      AVFrameSideDataType::AV_FRAME_DATA_STEREO3D
1398    }
1399    x if x == AVFrameSideDataType::AV_FRAME_DATA_DISPLAYMATRIX as i32 => {
1400      AVFrameSideDataType::AV_FRAME_DATA_DISPLAYMATRIX
1401    }
1402    x if x == AVFrameSideDataType::AV_FRAME_DATA_AFD as i32 => {
1403      AVFrameSideDataType::AV_FRAME_DATA_AFD
1404    }
1405    x if x == AVFrameSideDataType::AV_FRAME_DATA_MASTERING_DISPLAY_METADATA as i32 => {
1406      AVFrameSideDataType::AV_FRAME_DATA_MASTERING_DISPLAY_METADATA
1407    }
1408    x if x == AVFrameSideDataType::AV_FRAME_DATA_GOP_TIMECODE as i32 => {
1409      AVFrameSideDataType::AV_FRAME_DATA_GOP_TIMECODE
1410    }
1411    x if x == AVFrameSideDataType::AV_FRAME_DATA_SPHERICAL as i32 => {
1412      AVFrameSideDataType::AV_FRAME_DATA_SPHERICAL
1413    }
1414    x if x == AVFrameSideDataType::AV_FRAME_DATA_CONTENT_LIGHT_LEVEL as i32 => {
1415      AVFrameSideDataType::AV_FRAME_DATA_CONTENT_LIGHT_LEVEL
1416    }
1417    x if x == AVFrameSideDataType::AV_FRAME_DATA_ICC_PROFILE as i32 => {
1418      AVFrameSideDataType::AV_FRAME_DATA_ICC_PROFILE
1419    }
1420    x if x == AVFrameSideDataType::AV_FRAME_DATA_S12M_TIMECODE as i32 => {
1421      AVFrameSideDataType::AV_FRAME_DATA_S12M_TIMECODE
1422    }
1423    x if x == AVFrameSideDataType::AV_FRAME_DATA_DYNAMIC_HDR_PLUS as i32 => {
1424      AVFrameSideDataType::AV_FRAME_DATA_DYNAMIC_HDR_PLUS
1425    }
1426    x if x == AVFrameSideDataType::AV_FRAME_DATA_REGIONS_OF_INTEREST as i32 => {
1427      AVFrameSideDataType::AV_FRAME_DATA_REGIONS_OF_INTEREST
1428    }
1429    x if x == AVFrameSideDataType::AV_FRAME_DATA_SEI_UNREGISTERED as i32 => {
1430      AVFrameSideDataType::AV_FRAME_DATA_SEI_UNREGISTERED
1431    }
1432    x if x == AVFrameSideDataType::AV_FRAME_DATA_FILM_GRAIN_PARAMS as i32 => {
1433      AVFrameSideDataType::AV_FRAME_DATA_FILM_GRAIN_PARAMS
1434    }
1435    x if x == AVFrameSideDataType::AV_FRAME_DATA_DOVI_RPU_BUFFER as i32 => {
1436      AVFrameSideDataType::AV_FRAME_DATA_DOVI_RPU_BUFFER
1437    }
1438    x if x == AVFrameSideDataType::AV_FRAME_DATA_DOVI_METADATA as i32 => {
1439      AVFrameSideDataType::AV_FRAME_DATA_DOVI_METADATA
1440    }
1441    x if x == AVFrameSideDataType::AV_FRAME_DATA_DYNAMIC_HDR_VIVID as i32 => {
1442      AVFrameSideDataType::AV_FRAME_DATA_DYNAMIC_HDR_VIVID
1443    }
1444    x if x == AVFrameSideDataType::AV_FRAME_DATA_AMBIENT_VIEWING_ENVIRONMENT as i32 => {
1445      AVFrameSideDataType::AV_FRAME_DATA_AMBIENT_VIEWING_ENVIRONMENT
1446    }
1447    _ => return None,
1448  };
1449  Some(kind)
1450}
1451
1452unsafe fn copy_frame_props_minimal(
1453  dst: *mut AVFrame,
1454  src: *const AVFrame,
1455) -> std::result::Result<(), ffmpeg_next::Error> {
1456  // We deliberately do NOT use `av_frame_copy_props` here, despite
1457  // its convenience. Upstream `av_frame_copy_props` deep-copies
1458  // *every* `AVFrameSideData` entry, the metadata `AVDictionary`,
1459  // and refcounted `opaque_ref` / `private_ref` buffers — all from
1460  // attacker-controlled decoder output. A crafted stream with many
1461  // multi-MiB side-data entries could drive the per-frame
1462  // allocation cost arbitrarily high (one alloc per entry, with the
1463  // entry's bytes copied via `memcpy`). The downstream
1464  // `convert::collect_side_data` cap helps the *Rust* side but the
1465  // FFmpeg-side allocations have already happened.
1466  //
1467  // Instead we copy scalar fields manually (timestamps, color
1468  // metadata, picture type, flags) and copy side-data with a hard
1469  // cap matching the converter's. Metadata dict and opaque_ref /
1470  // private_ref are intentionally NOT copied — they're rarely
1471  // populated on decoded frames and represent unbounded surfaces.
1472  use core::ptr::{addr_of, addr_of_mut, read_unaligned, write_unaligned};
1473  use ffmpeg_next::ffi::av_frame_new_side_data;
1474  unsafe {
1475    // Scalar timestamps / flags / color / SAR / crop. None of
1476    // these allocate.
1477    (*dst).pts = (*src).pts;
1478    (*dst).pkt_dts = (*src).pkt_dts;
1479    (*dst).duration = (*src).duration;
1480    (*dst).best_effort_timestamp = (*src).best_effort_timestamp;
1481    (*dst).quality = (*src).quality;
1482    (*dst).repeat_pict = (*src).repeat_pict;
1483    (*dst).flags = (*src).flags;
1484    (*dst).sample_aspect_ratio = (*src).sample_aspect_ratio;
1485    (*dst).crop_left = (*src).crop_left;
1486    (*dst).crop_top = (*src).crop_top;
1487    (*dst).crop_right = (*src).crop_right;
1488    (*dst).crop_bottom = (*src).crop_bottom;
1489    (*dst).time_base = (*src).time_base;
1490
1491    // Enum-typed fields: bit-copy raw to avoid materializing an
1492    // invalid `AVColorPrimaries` etc. on either side. `read_unaligned`
1493    // / `write_unaligned` on `i32` projections sidestep the bindgen
1494    // enum's discriminant-validity invariant.
1495    let pict_type_raw = read_unaligned(addr_of!((*src).pict_type) as *const i32);
1496    write_unaligned(addr_of_mut!((*dst).pict_type) as *mut i32, pict_type_raw);
1497    let cp_raw = read_unaligned(addr_of!((*src).color_primaries) as *const i32);
1498    write_unaligned(addr_of_mut!((*dst).color_primaries) as *mut i32, cp_raw);
1499    let trc_raw = read_unaligned(addr_of!((*src).color_trc) as *const i32);
1500    write_unaligned(addr_of_mut!((*dst).color_trc) as *mut i32, trc_raw);
1501    let cs_raw = read_unaligned(addr_of!((*src).colorspace) as *const i32);
1502    write_unaligned(addr_of_mut!((*dst).colorspace) as *mut i32, cs_raw);
1503    let cr_raw = read_unaligned(addr_of!((*src).color_range) as *const i32);
1504    write_unaligned(addr_of_mut!((*dst).color_range) as *mut i32, cr_raw);
1505    let cl_raw = read_unaligned(addr_of!((*src).chroma_location) as *const i32);
1506    write_unaligned(addr_of_mut!((*dst).chroma_location) as *mut i32, cl_raw);
1507
1508    // Side-data: bounded copy. `av_frame_new_side_data(dst, type,
1509    // size)` allocates the entry and returns a pointer to write
1510    // the payload bytes into; a null return is the OOM signal.
1511    // Callers (`transfer_hw_frame`, `drain_into_pending`) hand us
1512    // freshly-unref'd `dst` frames, so any prior side-data has
1513    // already been freed by `av_frame_unref` — we don't need to
1514    // strip dst's existing side-data here.
1515    // Read `nb_side_data` as the bindgen `c_int` and clamp non-
1516    // positive values BEFORE casting to `usize`. A negative value
1517    // (corrupt / version-skew decoder output) cast directly to
1518    // `usize` becomes a huge positive count and would walk OOB
1519    // memory below; pinning to zero up front collapses that to a
1520    // no-op. Same signed-count guard `sum_side_data_bytes` applies.
1521    let nb_side_data_raw = (*src).nb_side_data;
1522    let src_arr = (*src).side_data;
1523    if nb_side_data_raw > 0 && !src_arr.is_null() {
1524      let count_raw = nb_side_data_raw as usize;
1525      let count = count_raw.min(HW_COPY_SIDE_DATA_MAX_ENTRIES);
1526      if count_raw > HW_COPY_SIDE_DATA_MAX_ENTRIES {
1527        tracing::warn!(
1528          cap = HW_COPY_SIDE_DATA_MAX_ENTRIES,
1529          requested = count_raw,
1530          "mediadecode-ffmpeg: HW->CPU transfer side-data entry cap reached; truncating",
1531        );
1532      }
1533      let mut total_bytes: usize = 0;
1534      for i in 0..count {
1535        let entry = *src_arr.add(i);
1536        if entry.is_null() {
1537          continue;
1538        }
1539        let kind_raw = read_unaligned(addr_of!((*entry).type_) as *const i32);
1540        let size = (*entry).size;
1541        let data_ptr = (*entry).data;
1542        if size == 0 || data_ptr.is_null() {
1543          continue;
1544        }
1545        // Whitelist gate: only proceed when `kind_raw` matches a
1546        // known `AV_FRAME_DATA_*` constant the linked FFmpeg's
1547        // bindgen output knows about. Without this gate, a
1548        // version-skew or hostile decoder could write a side-data
1549        // type integer outside our bindgen's discriminant set, and
1550        // constructing the `AVFrameSideDataType` enum value (so
1551        // we could pass it to `av_frame_new_side_data`) would be
1552        // immediate UB before the call. Unknown types are dropped
1553        // with a debug-level log — the public converter's
1554        // `collect_side_data` walks the destination raw and would
1555        // also surface them as bare integers in `SideDataEntry.kind`.
1556        let Some(kind_enum) = whitelisted_side_data_kind(kind_raw) else {
1557          tracing::debug!(
1558            kind_raw,
1559            "mediadecode-ffmpeg: unknown AV_FRAME_DATA type during HW->CPU transfer; dropping",
1560          );
1561          continue;
1562        };
1563        let projected = total_bytes.saturating_add(size);
1564        if projected > HW_COPY_SIDE_DATA_MAX_TOTAL_BYTES {
1565          tracing::warn!(
1566            cap = HW_COPY_SIDE_DATA_MAX_TOTAL_BYTES,
1567            projected,
1568            "mediadecode-ffmpeg: HW->CPU transfer side-data byte cap reached; dropping rest",
1569          );
1570          break;
1571        }
1572        let new_entry = av_frame_new_side_data(dst, kind_enum, size);
1573        if new_entry.is_null() {
1574          // OOM mid-loop: stop copying further entries but don't
1575          // fail the whole transfer — the frames we did copy stay
1576          // attached. The convert path's cap is the final guard.
1577          tracing::warn!("mediadecode-ffmpeg: av_frame_new_side_data OOM during HW->CPU transfer",);
1578          break;
1579        }
1580        // SAFETY: `(*new_entry).data` is allocated for `size` bytes
1581        // per av_frame_new_side_data's contract; `data_ptr` is
1582        // valid for `size` reads per AVFrameSideData's contract.
1583        core::ptr::copy_nonoverlapping(data_ptr, (*new_entry).data, size);
1584        total_bytes = projected;
1585      }
1586    }
1587  }
1588  Ok(())
1589}
1590
1591/// `EAGAIN` and `EOF` are normal flow signals from `avcodec_receive_frame`
1592/// and must not be treated as backend failures.
1593fn is_transient(e: &ffmpeg_next::Error) -> bool {
1594  is_eagain(e) || matches!(e, ffmpeg_next::Error::Eof)
1595}
1596
1597/// Post-commit, a HW-only decoder's non-transient, non-EOF error means the
1598/// committed HW backend can't decode this content → fall back to SW. VT's
1599/// "hardware accelerator failed" surfaces as AVERROR_EXTERNAL; some HW
1600/// backends report unsupported geometry as InvalidData; context loss as
1601/// Bug/Bug2/Unknown. Broad-by-design (decode-all-kinds); fixtures will let us
1602/// narrow if a real backend proves a code should NOT trigger fallback.
1603///
1604/// `EAGAIN`/`EOF` are deliberately excluded by the caller (each call site
1605/// guards on `is_transient` first): `EAGAIN` is backpressure and `EOF` is a
1606/// genuine end-of-stream that must propagate, never be trapped in an infinite
1607/// fallback-retry loop. `Other { errno: EINVAL }` from the HW→CPU transfer
1608/// path is also covered — an unsupported CPU output pix_fmt is a HW-output
1609/// problem, never input corruption.
1610fn is_hw_decode_failure(e: &ffmpeg_next::Error) -> bool {
1611  matches!(
1612    e,
1613    ffmpeg_next::Error::External
1614      | ffmpeg_next::Error::Bug
1615      | ffmpeg_next::Error::Bug2
1616      | ffmpeg_next::Error::Unknown
1617      | ffmpeg_next::Error::InvalidData
1618      | ffmpeg_next::Error::Other {
1619        errno: libc::EINVAL
1620      }
1621  )
1622}
1623
1624/// Reject a `codec::Parameters` whose inner `*mut AVCodecParameters` is
1625/// null. This guards the public trust boundary: ffmpeg-next can produce
1626/// such a `Parameters` under OOM (`Parameters::new()` does not check
1627/// `avcodec_parameters_alloc`), and a safe caller can legally hand one
1628/// in. Without this check, the very next `(*p.as_ptr()).field` read
1629/// would be a null deref.
1630fn ensure_parameters_non_null(parameters: &codec::Parameters) -> Result<()> {
1631  // SAFETY: as_ptr() returns the inner *const AVCodecParameters; we just
1632  // inspect the pointer value (no deref).
1633  if unsafe { parameters.as_ptr() }.is_null() {
1634    return Err(Error::Ffmpeg(ffmpeg_next::Error::Other {
1635      errno: libc::ENOMEM,
1636    }));
1637  }
1638  Ok(())
1639}
1640
1641/// Allocate a fresh `frame::Video`, checking that `av_frame_alloc` did not
1642/// return NULL. ffmpeg-next's `frame::Video::empty()` does not surface that
1643/// failure and the resulting null pointer would be UB on the next field
1644/// access; this wrapper catches it and surfaces it as `ENOMEM`.
1645fn alloc_av_frame() -> std::result::Result<frame::Video, ffmpeg_next::Error> {
1646  let inner = frame::Video::empty();
1647  // SAFETY: as_ptr() just exposes the inner pointer for inspection.
1648  if unsafe { inner.as_ptr() }.is_null() {
1649    return Err(ffmpeg_next::Error::Other {
1650      errno: libc::ENOMEM,
1651    });
1652  }
1653  Ok(inner)
1654}
1655
1656/// Build a fresh `Context` from `parameters`, checking the underlying
1657/// `avcodec_alloc_context3` for NULL before passing it to
1658/// `avcodec_parameters_to_context`. ffmpeg-next's `Context::from_parameters`
1659/// skips that check and would feed a null pointer into FFmpeg under OOM —
1660/// undefined behavior. This helper surfaces the failure as `ENOMEM` and
1661/// frees the context if `parameters_to_context` itself errors.
1662pub(crate) fn build_codec_context(parameters: &codec::Parameters) -> Result<Context> {
1663  ensure_parameters_non_null(parameters)?;
1664  // SAFETY: avcodec_alloc_context3(NULL) returns a fresh AVCodecContext
1665  // or NULL on allocation failure.
1666  let ctx_ptr = unsafe { avcodec_alloc_context3(ptr::null()) };
1667  if ctx_ptr.is_null() {
1668    return Err(Error::Ffmpeg(ffmpeg_next::Error::Other {
1669      errno: libc::ENOMEM,
1670    }));
1671  }
1672  // SAFETY: ctx_ptr is non-null and freshly allocated; parameters.as_ptr()
1673  // returns a valid AVCodecParameters pointer; the function copies bytes
1674  // out of parameters into the context.
1675  let ret = unsafe { avcodec_parameters_to_context(ctx_ptr, parameters.as_ptr()) };
1676  if ret < 0 {
1677    // SAFETY: ctx_ptr was allocated by us and never handed to anyone else.
1678    let mut p = ctx_ptr;
1679    unsafe { avcodec_free_context(&mut p) };
1680    return Err(Error::Ffmpeg(ffmpeg_next::Error::from(ret)));
1681  }
1682  // SAFETY: ctx_ptr is valid; passing `owner: None` means our wrapper owns
1683  // the allocation and `Context::drop` will run `avcodec_free_context`.
1684  Ok(unsafe { Context::wrap(ctx_ptr, None) })
1685}
1686
1687/// Checked deep-clone of `codec::Parameters`. ffmpeg-next's
1688/// `Parameters::clone` allocates via `avcodec_parameters_alloc` without
1689/// checking for NULL and runs `avcodec_parameters_copy` without checking
1690/// the return code. On `ENOMEM` the result is a `Parameters` with a null
1691/// inner pointer, which becomes UB when later passed to FFmpeg.
1692///
1693/// This helper performs both calls explicitly, frees a partial allocation
1694/// on failure, and surfaces the AVERROR. The returned `Parameters` has
1695/// `owner: None`, severing any Rc link to the caller's demuxer (the
1696/// reason we deep-clone in the first place — see Send safety in
1697/// `VideoDecoder::open`).
1698pub(crate) fn try_clone_parameters(
1699  src: &codec::Parameters,
1700) -> std::result::Result<codec::Parameters, ffmpeg_next::Error> {
1701  // Reject a null inner pointer at the boundary; a deref inside
1702  // avcodec_parameters_copy below would otherwise be UB.
1703  if unsafe { src.as_ptr() }.is_null() {
1704    return Err(ffmpeg_next::Error::Other {
1705      errno: libc::ENOMEM,
1706    });
1707  }
1708  // SAFETY: avcodec_parameters_alloc returns a fresh AVCodecParameters
1709  // pointer or NULL on allocation failure.
1710  let dst_ptr = unsafe { avcodec_parameters_alloc() };
1711  if dst_ptr.is_null() {
1712    return Err(ffmpeg_next::Error::Other {
1713      errno: libc::ENOMEM,
1714    });
1715  }
1716  // SAFETY: dst_ptr is non-null and freshly allocated; src.as_ptr() is
1717  // a valid AVCodecParameters pointer; the function copies bytes from
1718  // src into dst.
1719  let ret = unsafe { avcodec_parameters_copy(dst_ptr, src.as_ptr()) };
1720  if ret < 0 {
1721    // SAFETY: dst_ptr was allocated by us and never handed out.
1722    let mut p = dst_ptr;
1723    unsafe { avcodec_parameters_free(&mut p) };
1724    return Err(ffmpeg_next::Error::from(ret));
1725  }
1726  // SAFETY: dst_ptr is a valid AVCodecParameters; passing `owner: None`
1727  // means our wrapper owns the allocation and `Parameters::drop` will
1728  // call `avcodec_parameters_free`.
1729  Ok(unsafe { codec::Parameters::wrap(dst_ptr, None) })
1730}
1731
1732/// Checked counterpart to `Packet::clone()`. ffmpeg-next's `clone_from`
1733/// calls `av_packet_ref` and ignores the int return value; on `ENOMEM`
1734/// the destination is left empty while the caller assumes the clone
1735/// succeeded — corrupting any later replay history. This helper surfaces
1736/// the AVERROR. The result is a refcounted shallow clone — the payload
1737/// buffer is shared with `src` rather than deep-copied; the probe replay
1738/// only sends packets through `avcodec_send_packet`, which does not
1739/// require a writable buffer.
1740pub(crate) fn try_clone_packet(src: &Packet) -> std::result::Result<Packet, ffmpeg_next::Error> {
1741  let mut dst = Packet::empty();
1742  // SAFETY: dst is a freshly zero-initialized Packet (av_init_packet inside
1743  // Packet::empty); av_packet_ref initializes its data fields from src's
1744  // refcounted buffer or returns AVERROR(ENOMEM) on failure.
1745  let ret = unsafe { av_packet_ref(dst.as_mut_ptr(), src.as_ptr()) };
1746  if ret < 0 {
1747    return Err(ffmpeg_next::Error::from(ret));
1748  }
1749  Ok(dst)
1750}
1751
1752/// Sum of `AVPacket.side_data[i].size` across every entry, plus
1753/// `nb_entries * SIDE_DATA_ENTRY_OVERHEAD` (descriptor + AVBufferRef +
1754/// allocator bookkeeping per entry). `av_packet_ref` performs a deep
1755/// copy of side data via `av_packet_copy_props`, so each probe-buffered
1756/// clone retains every one of these bytes. Charging both keeps
1757/// `MAX_PROBE_PACKET_BYTES` a true upper bound — without the overhead,
1758/// many zero-size entries slip past the cap on pure descriptor cost.
1759///
1760/// Walks at most `max_entries` entries even when `side_data_elems`
1761/// reports a larger count. Defense-in-depth against a corrupt or hostile
1762/// packet whose `side_data_elems` lies about the actual array length:
1763/// the caller is expected to also reject any packet whose count exceeds
1764/// the cap (so the inflated clone is never created), but bounding the
1765/// walk here means a stale or weaponised value can never trigger an
1766/// unbounded raw-pointer scan from the safe API.
1767///
1768/// Reads only the `size` field of each `AVPacketSideData` entry — never
1769/// touches the bindgen `AVPacketSideDataType` enum, so no UB even if a
1770/// future FFmpeg adds a side-data type discriminant our build doesn't
1771/// know.
1772pub(crate) fn packet_side_data_bytes(packet: &Packet, max_entries: usize) -> usize {
1773  // SAFETY: AVPacket.side_data is `*mut AVPacketSideData` and
1774  // side_data_elems is `c_int`; both are raw struct fields safe to read.
1775  // Field projection (`.size`) does not reconstruct the enum-typed `type_`
1776  // field, so the bindgen-enum UB hazard does not apply here.
1777  unsafe {
1778    let raw = packet.as_ptr();
1779    let nel = (*raw).side_data_elems;
1780    let arr = (*raw).side_data;
1781    if arr.is_null() || nel <= 0 || max_entries == 0 {
1782      return 0;
1783    }
1784    let count = (nel as usize).min(max_entries);
1785    let mut total = count.saturating_mul(SIDE_DATA_ENTRY_OVERHEAD);
1786    for i in 0..count {
1787      let entry = arr.add(i);
1788      total = total.saturating_add((*entry).size);
1789    }
1790    total
1791  }
1792}
1793
1794/// Number of `AVPacketSideData` entries on `packet`. The probe buffer
1795/// uses this to enforce [`MAX_PROBE_PACKET_SIDE_DATA_ENTRIES`] before
1796/// cloning, so a packet whose entry count alone would dominate retained
1797/// memory is rejected up front.
1798pub(crate) fn packet_side_data_count(packet: &Packet) -> usize {
1799  // SAFETY: side_data_elems is `c_int`, safe to read; clamp negatives to 0.
1800  let nel = unsafe { (*packet.as_ptr()).side_data_elems };
1801  if nel <= 0 { 0 } else { nel as usize }
1802}
1803
1804/// Just `EAGAIN` (separate from EOF — the FFmpeg send/receive state machine
1805/// distinguishes "drain output and retry" from "stream over").
1806fn is_eagain(e: &ffmpeg_next::Error) -> bool {
1807  matches!(e, ffmpeg_next::Error::Other { errno } if *errno == ffmpeg_next::error::EAGAIN)
1808}
1809
1810/// Look up the decoder for `parameters` without going through the bindgen
1811/// `AVCodecID` Rust enum. Reads the codec_id field as raw `u32` via
1812/// `addr_of!` + `ptr::read` so a value not in our build's discriminant
1813/// set never invokes UB.
1814fn find_decoder(parameters: &codec::Parameters) -> Result<Codec> {
1815  ensure_parameters_non_null(parameters)?;
1816  // SAFETY: parameters' inner pointer is non-null (checked above);
1817  // addr_of! projects to the codec_id field; the *const u32 cast is sound
1818  // because AVCodecID is `#[repr(u32)]` (same size and alignment as u32).
1819  // Reading as u32 cannot be UB regardless of the value FFmpeg wrote.
1820  let raw_id: u32 =
1821    unsafe { ptr::read(ptr::addr_of!((*parameters.as_ptr()).codec_id) as *const u32) };
1822
1823  // Call C `avcodec_find_decoder` via our local `c_int`-typed shim — we
1824  // never construct an `AVCodecID` enum from `raw_id`. The C function
1825  // returns NULL for unknown ids, which we surface as `Error::NoCodec`.
1826  // SAFETY: avcodec_find_decoder is a pure FFmpeg lookup; passing any
1827  // c_int is sound (returns NULL for unknown).
1828  let codec_ptr = unsafe { c_shims::avcodec_find_decoder(raw_id as libc::c_int) };
1829  if codec_ptr.is_null() {
1830    return Err(Error::NoCodec(raw_id));
1831  }
1832  // SAFETY: codec_ptr is a non-null *const AVCodec into FFmpeg's static
1833  // codec table; it lives for the duration of the program.
1834  Ok(unsafe { Codec::wrap(codec_ptr) })
1835}
1836
1837/// Drain output frames from a candidate decoder during probe replay,
1838/// transferring each one from the candidate's HW context to a fresh CPU
1839/// frame and queueing it. Returns `Ok(())` once the candidate signals
1840/// EAGAIN/EOF. The transfer happens while the candidate is still alive
1841/// (its `AVHWFramesContext` is reachable); the resulting CPU frames remain
1842/// valid after the candidate is committed because they hold their own
1843/// buffer references with no dependency on the original device context.
1844fn drain_into_pending(
1845  decoder: &mut ffmpeg_next::decoder::Video,
1846  hw_buf: &mut frame::Video,
1847  pending: &mut VecDeque<frame::Video>,
1848  pending_bytes: &mut usize,
1849  max_bytes: usize,
1850) -> std::result::Result<(), ffmpeg_next::Error> {
1851  loop {
1852    match decoder.receive_frame(hw_buf) {
1853      Ok(()) => {
1854        // Pre-transfer cap check: if we are already at or over either cap,
1855        // the candidate is producing more than we can hold. Treat as an
1856        // explicit candidate failure so `advance_probe` can try the next
1857        // backend instead of committing a stream with silently-dropped
1858        // frames in the middle.
1859        //
1860        // TODO: at very large frame sizes (8K HDR P010, > ~96 MiB each)
1861        // even a single retained frame is significant. Future direction:
1862        // memmap-backed pending frames (write to a temp file or shared
1863        // memory segment) so the resident set stays bounded even when the
1864        // byte cap is raised. Out of scope for now.
1865        if pending.len() >= MAX_PROBE_PENDING_FRAMES || *pending_bytes >= max_bytes {
1866          tracing::warn!(
1867            frames = pending.len(),
1868            bytes = *pending_bytes,
1869            max_frames = MAX_PROBE_PENDING_FRAMES,
1870            max_bytes = max_bytes,
1871            "hwdecode: probe pending cap reached; failing candidate replay"
1872          );
1873          // SAFETY: hw_buf is owned and valid; unref of an empty frame is a no-op.
1874          unsafe { av_frame_unref(hw_buf.as_mut_ptr()) };
1875          return Err(ffmpeg_next::Error::Other {
1876            errno: libc::ENOMEM,
1877          });
1878        }
1879        // Pre-transfer size guard: `av_hwframe_transfer_data` will
1880        // allocate the CPU buffer based on `hw_buf`'s dimensions. If a
1881        // single frame's worst-case footprint already pushes past the
1882        // cap, refuse the candidate **before** allocating so RSS does
1883        // not spike on a frame we'd immediately drop. Uses a width *
1884        // height * `WORST_CASE_BYTES_PER_PIXEL` upper bound; the
1885        // post-transfer accounting via `cpu_frame_bytes` below stays in
1886        // place as a backstop using the actual stride/format.
1887        let estimated_bytes = match estimate_transfer_bytes(hw_buf) {
1888          Some(b) => b,
1889          None => {
1890            // SAFETY: AVFrame.width/height are c_int reads.
1891            let (w, h) = unsafe {
1892              let raw = hw_buf.as_ptr();
1893              ((*raw).width, (*raw).height)
1894            };
1895            tracing::warn!(
1896              width = w,
1897              height = h,
1898              "hwdecode: HW frame dimensions invalid for sizing; failing candidate replay"
1899            );
1900            unsafe { av_frame_unref(hw_buf.as_mut_ptr()) };
1901            return Err(ffmpeg_next::Error::Other {
1902              errno: libc::ENOMEM,
1903            });
1904          }
1905        };
1906        let estimated_total = pending_bytes.saturating_add(estimated_bytes);
1907        if estimated_total > max_bytes {
1908          // SAFETY: AVFrame.width/height are c_int reads.
1909          let (w, h) = unsafe {
1910            let raw = hw_buf.as_ptr();
1911            ((*raw).width, (*raw).height)
1912          };
1913          tracing::warn!(
1914            pending_bytes = *pending_bytes,
1915            estimated_bytes,
1916            width = w,
1917            height = h,
1918            max_bytes = max_bytes,
1919            "hwdecode: pre-transfer size estimate exceeds cap; \
1920             refusing candidate replay before allocating CPU frame"
1921          );
1922          unsafe { av_frame_unref(hw_buf.as_mut_ptr()) };
1923          return Err(ffmpeg_next::Error::Other {
1924            errno: libc::ENOMEM,
1925          });
1926        }
1927        let mut cpu = alloc_av_frame()?;
1928        // SAFETY: hw_buf is a freshly-decoded HW frame;
1929        // `av_hwframe_transfer_data` allocates pixel buffers on `cpu`.
1930        // We use `copy_frame_props_minimal` (only `pts`) instead of
1931        // `av_frame_copy_props` for the same reason as
1932        // `transfer_hw_frame`: the public `Frame` API does not expose
1933        // side data / metadata / opaque refs, so deep-copying them per
1934        // frame is pure cost and an unbounded allocation source on
1935        // attacker-controlled streams.
1936        unsafe {
1937          let r1 = av_hwframe_transfer_data(cpu.as_mut_ptr(), hw_buf.as_ptr(), 0);
1938          if r1 < 0 {
1939            return Err(ffmpeg_next::Error::from(r1));
1940          }
1941        }
1942        // Same post-transfer pix_fmt validation as `transfer_hw_frame`.
1943        // A driver that picks a CPU format outside our supported set
1944        // would queue an unusable frame here; later, when
1945        // `try_pop_pending` hands it to the caller, `Frame::row` /
1946        // `Frame::as_ptr` would return `None`. Refuse the candidate
1947        // before the queue grows so probing advances to the next
1948        // backend instead.
1949        let cpu_raw_fmt: i32 = unsafe { (*cpu.as_ptr()).format };
1950        let cpu_pix_fmt = crate::boundary::from_av_pixel_format(cpu_raw_fmt);
1951        if !crate::frame::is_supported_cpu_pix_fmt(cpu_pix_fmt) {
1952          tracing::warn!(
1953            pix_fmt = cpu_raw_fmt,
1954            "hwdecode: candidate produced unsupported CPU pix_fmt during \
1955             probe replay; failing candidate"
1956          );
1957          return Err(ffmpeg_next::Error::Other {
1958            errno: libc::EINVAL,
1959          });
1960        }
1961        let pixel_bytes = match cpu_frame_bytes(&cpu) {
1962          Some(b) => b,
1963          None => {
1964            // Unknown pix_fmt or vertically-flipped layout — we cannot
1965            // bound this frame's contribution against the byte cap, so up
1966            // to MAX_PROBE_PENDING_FRAMES of them could exhaust memory.
1967            // Fail the candidate so probing tries the next backend
1968            // rather than queueing untracked allocations.
1969            // SAFETY: AVFrame.format is c_int, safe to read.
1970            let pix_fmt: i32 = unsafe { (*cpu.as_ptr()).format };
1971            tracing::warn!(
1972              pix_fmt,
1973              "hwdecode: cannot size unknown CPU pix_fmt during replay; failing candidate"
1974            );
1975            // cpu drops here.
1976            return Err(ffmpeg_next::Error::Other {
1977              errno: libc::ENOMEM,
1978            });
1979          }
1980        };
1981        // Account for side-data bytes that `av_frame_copy_props`
1982        // will deep-copy from the source HW frame. HDR streams
1983        // typically carry mastering display + content light level
1984        // (~50 bytes) and dynamic HDR metadata (~few hundred bytes);
1985        // pathological side-data could otherwise quietly bypass the
1986        // pixel-data byte cap.
1987        // SAFETY: hw_buf is a valid AVFrame; we read scalar fields
1988        // and pointer arrays without forming a `&AVFrame`.
1989        let side_data_bytes = unsafe { sum_side_data_bytes(hw_buf.as_ptr()) };
1990        let new_total = pending_bytes
1991          .saturating_add(pixel_bytes)
1992          .saturating_add(side_data_bytes);
1993        if new_total > max_bytes {
1994          tracing::warn!(
1995            pending_bytes = *pending_bytes,
1996            pixel_bytes,
1997            side_data_bytes,
1998            max_bytes,
1999            "hwdecode: queueing this frame would exceed byte cap; \
2000             failing candidate replay"
2001          );
2002          // cpu drops here without ever paying a metadata deep copy.
2003          return Err(ffmpeg_next::Error::Other {
2004            errno: libc::ENOMEM,
2005          });
2006        }
2007        // Cap check passed — copy AVFrame metadata. SAFETY: cpu and
2008        // hw_buf are both valid AVFrames we own. On failure (OOM
2009        // during side-data alloc) we propagate so the probe candidate
2010        // is treated as failed rather than queueing a frame whose
2011        // metadata silently disappeared.
2012        unsafe { copy_frame_props_minimal(cpu.as_mut_ptr(), hw_buf.as_ptr()) }?;
2013        *pending_bytes = new_total;
2014        pending.push_back(cpu);
2015      }
2016      Err(e) if is_transient(&e) => return Ok(()),
2017      Err(e) => return Err(e),
2018    }
2019  }
2020}
2021
2022/// Allocated frame dimensions according to `hw_buf.hw_frames_ctx`.
2023///
2024/// Per FFmpeg's `libavutil/hwcontext.c::transfer_data_alloc`, the CPU
2025/// destination of `av_hwframe_transfer_data` is allocated using
2026/// `AVHWFramesContext.width / .height` (the *allocated* surface size of
2027/// the HW pool); only afterwards is `dst->width / dst->height` reset to
2028/// `src->width / src->height` (the *display* size). For cropped or
2029/// heavily aligned streams the allocated dims can be much larger than
2030/// the display dims (e.g. coded 8192×8192 surface with a 100×100
2031/// display crop), so any byte-cap accounting that uses display dims
2032/// undercounts by `allocated_height / display_height` and lets the
2033/// real allocation slip past the cap.
2034///
2035/// Returns `None` when no `hw_frames_ctx` is attached or the dimensions
2036/// are non-positive — the caller treats `None` as "cannot prove
2037/// allocation extent, fail the candidate."
2038fn hw_frames_ctx_dimensions(frame: &frame::Video) -> Option<(i32, i32)> {
2039  // SAFETY: AVFrame.hw_frames_ctx is `*mut AVBufferRef`. When non-null,
2040  // its `data` field points to an `AVHWFramesContext`. We read `.width`
2041  // and `.height` (both `c_int`) via field projection — neither field is
2042  // enum-typed, so no bindgen-enum UB hazard.
2043  unsafe {
2044    let raw = frame.as_ptr();
2045    let hw_ctx_ref = (*raw).hw_frames_ctx;
2046    if hw_ctx_ref.is_null() {
2047      return None;
2048    }
2049    let data = (*hw_ctx_ref).data;
2050    if data.is_null() {
2051      return None;
2052    }
2053    let frames_ctx = data as *const AVHWFramesContext;
2054    let w: i32 = ptr::read(ptr::addr_of!((*frames_ctx).width));
2055    let h: i32 = ptr::read(ptr::addr_of!((*frames_ctx).height));
2056    if w <= 0 || h <= 0 {
2057      return None;
2058    }
2059    Some((w, h))
2060  }
2061}
2062
2063/// Conservative upper-bound estimate of the bytes
2064/// `av_hwframe_transfer_data` will allocate when downloading `hw_buf` to
2065/// a CPU frame. Used by [`drain_into_pending`] as a pre-transfer guard
2066/// so a candidate replay can refuse a frame whose footprint would
2067/// exceed the byte budget *without* first paying the allocation.
2068///
2069/// Sizes from `hw_buf.hw_frames_ctx` (the allocated dims used by the
2070/// FFmpeg transfer path) rather than `AVFrame.width / .height` (display
2071/// dims). On a cropped stream the two can differ by orders of magnitude
2072/// and using display dims would let the real allocation slip past the
2073/// cap.
2074///
2075/// Returns `None` when `hw_frames_ctx` is missing or its width/height
2076/// are non-positive — caller treats as candidate failure since we
2077/// cannot prove the allocation extent. (A SW source frame on the probe
2078/// replay path is not expected; we don't fall back to display dims
2079/// because that's the exact attack the cap is meant to prevent.)
2080fn estimate_transfer_bytes(hw_buf: &frame::Video) -> Option<usize> {
2081  let (w, h) = hw_frames_ctx_dimensions(hw_buf)?;
2082  Some(
2083    (w as usize)
2084      .saturating_mul(h as usize)
2085      .saturating_mul(WORST_CASE_BYTES_PER_PIXEL),
2086  )
2087}
2088
2089/// Exact resident size of a CPU frame: sum of `AVFrame.buf[i].size`
2090/// across every populated buffer.
2091///
2092/// `AVBufferRef.size` is documented as "Size of data in bytes" — the
2093/// real allocated extent FFmpeg used. Reading it directly handles the
2094/// cropped/aligned case where `AVFrame.height` (display) is smaller
2095/// than the underlying allocation height (the `AVHWFramesContext`
2096/// surface size FFmpeg sized the buffer for); a `linesize *
2097/// plane_height_for(display_height)` formula would undercount in that
2098/// case.
2099///
2100/// Returns `None` only when `linesize[0]` is negative — FFmpeg's
2101/// vertically-flipped layout. The crate's safe row accessors
2102/// ([`crate::Frame::row`] / [`crate::Frame::rows`]) already reject
2103/// negative-stride frames, so queueing one during probe replay would
2104/// just delay the failure to the consumer; refusing here lets the
2105/// probe loop advance to the next backend instead.
2106fn cpu_frame_bytes(frame: &frame::Video) -> Option<usize> {
2107  // SAFETY: AVFrame.linesize is `[c_int; 8]`; AVFrame.buf is
2108  // `[*mut AVBufferRef; 8]`; AVBufferRef.size is `usize`. All are
2109  // primitive reads / pointer dereferences with no enum interpretation.
2110  unsafe {
2111    let raw = frame.as_ptr();
2112    let first_linesize = (*raw).linesize[0];
2113    // Vertically-flipped (negative linesize) is the only "unsizeable"
2114    // case we still surface as `None`; everything else can be exactly
2115    // measured from buf[i].size.
2116    if first_linesize < 0 {
2117      return None;
2118    }
2119    let mut total: usize = 0;
2120    for i in 0..(*raw).buf.len() {
2121      let buf = (*raw).buf[i];
2122      if buf.is_null() {
2123        continue;
2124      }
2125      total = total.saturating_add((*buf).size);
2126    }
2127    Some(total)
2128  }
2129}
2130
2131#[allow(dead_code)]
2132fn _assert_send() {
2133  fn check<T: Send>() {}
2134  check::<VideoDecoder>();
2135}
2136
2137#[cfg(test)]
2138mod tests;