Skip to main content

mediadecode_ffmpeg/video/
mod.rs

1//! `mediadecode::VideoStreamDecoder` impl with HW + SW fallback.
2//!
3//! [`FfmpegVideoStreamDecoder`] starts on the hardware path: an inner
4//! [`crate::VideoDecoder`] that auto-probes VideoToolbox / VAAPI /
5//! NVDEC / D3D11VA. When every HW backend fails — at `open` time
6//! (no backend opens) or mid-stream ([`crate::Error::AllBackendsFailed`]
7//! from `send_packet` / `receive_frame` / `send_eof`) — we transparently
8//! fall back to a **software** `ffmpeg::decoder::Video` opened from the
9//! same `Parameters`.
10//!
11//! Two HW-exhaustion shapes feed the same fallback, distinguished by an
12//! **explicit origin** the `AllBackendsFailed` carries
13//! ([`crate::error::FallbackOrigin`]) — *not* by whether its rescued
14//! `unconsumed_packets` is empty (both shapes can be empty: a probe-era
15//! failure on the first packet has no prior history, exactly like every
16//! post-commit failure):
17//!
18//! * **Probe-era** (pre-first-frame, [`crate::error::FallbackOrigin::Probe`]):
19//!   the inner decoder buffered every packet it consumed and surfaces them in
20//!   `unconsumed_packets`. We **replay exactly those** through the SW decoder
21//!   (lossless — no frame was delivered yet), then route the still-unconsumed
22//!   current packet (the one the inner decoder failed on / refused) to SW
23//!   ourselves. This is the original pre-runtime-fallback behaviour and is
24//!   unchanged.
25//! * **Post-commit** (after the first frame, the inner probe is gone,
26//!   [`crate::error::FallbackOrigin::PostCommit`]): a runtime HW-decode failure
27//!   — e.g. VideoToolbox choking on H.264 High 4:2:2 10-bit — is reclassified
28//!   to `AllBackendsFailed` by the inner decoder with an **empty**
29//!   `unconsumed_packets` (the probe buffer no longer exists). Here we
30//!   **degrade and continue** rather than reconstruct: open the SW decoder with
31//!   an empty replay set and let it **resync at the next keyframe**. Fed forward
32//!   packets from the failure point, the SW decoder naturally produces nothing
33//!   until that keyframe, then decodes normally from there. The bounded span
34//!   from the failure point to the next keyframe is dropped — an accepted,
35//!   **loudly logged** gap (a single `tracing::warn!`), not a silent one. The
36//!   indexing pipeline this serves prefers a small logged gap over the
37//!   error-prone mid-stream-reconstruction state machine a lossless replay
38//!   would require (see findit-studio/mediadecode#12). The *bounded*-ness is
39//!   **enforced, not assumed**: a post-commit fallback enters a degraded-resync
40//!   mode that holds until a **keyframe-anchored** resync — the SW decoder
41//!   delivering a frame *after* a keyframe was fed to it across the gap. (Gating
42//!   on a keyframe, not on *any* frame, matters because a lenient codec will
43//!   decode a lone P-frame from the dropped span into a concealed frame; that
44//!   must not count as a resync, or the one-GOP bound isn't truly enforced.) If
45//!   EOF is reached while the mode is still pending — no keyframe ever arrived
46//!   across the gap and the whole tail was lost — `receive_frame` escalates with
47//!   a distinct [`VideoDecodeError::PostCommitNeverResynced`] (and a
48//!   `tracing::error!`) rather than surfacing a clean end-of-stream that would
49//!   swallow the tail silently. So the gap is either bounded-and-logged (a real
50//!   keyframe resync happened) or reported-at-EOF (it never did) — never
51//!   silent-and-unbounded.
52//!
53//!   The post-commit path retains and reconstructs **zero** frames: it opens SW
54//!   cold, forwards only the failure arm's current packet (or EOF), and lets SW
55//!   resync naturally. It never populates the replay-frame queue, so the
56//!   replay/conversion machinery the probe-era path uses cannot touch it.
57//!
58//! The probe-era replay happens before the new packet (or the next
59//! `receive_frame` poll) is processed, so a probe-era HW exhaustion on a
60//! non-seekable input loses no compressed data. The post-commit path
61//! intentionally accepts the next-keyframe gap.
62//!
63//! After the transition the decoder stays on SW for the rest of its
64//! life — there's no probe-back-to-HW logic; once we've decided the
65//! stream isn't HW-decodable, that decision is sticky.
66//!
67//! Frames produced by either path are converted via
68//! [`crate::convert::av_frame_to_video_frame`] so the consumer sees
69//! the same `mediadecode::VideoFrame<PixelFormat, VideoFrameExtra,
70//! FfmpegBytes>` shape regardless of which backend produced it.
71
72use std::collections::VecDeque;
73
74/// Maximum number of frames the SW fallback replay path will buffer
75/// while draining the new SW decoder during packet/EOF replay.
76/// Replaying many compressed packets through SW can produce hundreds
77/// of decoded frames before the fallback commits; with no cap the
78/// resident memory grows unbounded (e.g. 4K frames at ~12 MB each ×
79/// 100s of frames). 64 frames is enough room to absorb every
80/// realistic codec's reorder/lookahead window without becoming a
81/// resource sink.
82const SW_REPLAY_FRAME_CAP: usize = 64;
83
84use derive_more::{IsVariant, TryUnwrap, Unwrap};
85use ffmpeg_next::{Packet, codec::Parameters, frame};
86use mediadecode::{
87  Received, Sent, Timebase, decoder::VideoStreamDecoder, frame::VideoFrame, packet::VideoPacket,
88};
89
90use crate::{
91  DecoderLimits, Error, Ffmpeg, Frame, VideoDecoder, boundary,
92  convert::{self, ConvertError},
93  decoder::{build_codec_context, try_clone_parameters},
94  error::FallbackFailed,
95  extras::{VideoFrameExtra, VideoPacketExtra},
96  frame::alloc_av_video_frame,
97};
98
99/// `mediadecode::VideoStreamDecoder` impl with transparent HW → SW
100/// fallback.
101pub struct CarrierVideoStreamDecoder<C: crate::FfmpegCarrier> {
102  state: DecodeState,
103  /// Codec parameters retained so we can open a software
104  /// `ffmpeg::decoder::Video` if the HW probe exhausts.
105  parameters: Parameters,
106  /// HW-side scratch frame (filled by [`VideoDecoder::receive_frame`]).
107  hw_scratch: Frame,
108  /// SW-side scratch frame (filled by `ffmpeg::decoder::Video::receive_frame`).
109  sw_scratch: frame::Video,
110  /// Frames produced while draining the SW decoder during fallback
111  /// replay (see [`Self::fall_back_to_sw`]). The trait's
112  /// `receive_frame` delivers from this queue before pulling new
113  /// frames from the SW decoder. Empty in steady-state operation.
114  sw_replay_frames: VecDeque<frame::Video>,
115  /// Resource ceilings for the frames this decoder exports, and for the
116  /// `AVCodecContext`s it opens — HW candidates, the SW fallback, and
117  /// any decoder a later probe advance builds all get the same number.
118  limits: DecoderLimits,
119  /// `true` once `send_eof` has been called on the active decoder.
120  /// Used to propagate EOF to the SW decoder when fallback fires
121  /// during the drain phase — without this, codecs that hold tail
122  /// frames at EOF would hang waiting for an EOF they already saw on
123  /// the HW path.
124  eof_sent: bool,
125  /// `true` between a **post-commit** fallback firing and a *keyframe-anchored*
126  /// resync (the SW decoder delivering a frame **after** a keyframe was fed to
127  /// it across the gap). A post-commit fallback opens SW cold and drops the
128  /// bounded span up to the next keyframe; the promise is that the span is
129  /// *bounded* — SW resyncs at that keyframe. This flag makes the promise
130  /// enforced rather than assumed: while it is set we have no proof SW ever
131  /// recovered from a real keyframe. It is cleared only when SW delivers a frame
132  /// *and* [`Self::degraded_keyframe_seen`] is set (a lone concealed P-frame a
133  /// lenient codec emits from the gap does **not** clear it); if EOF is reached
134  /// while it is still set the loss is escalated (a distinct loud error) rather
135  /// than silently swallowing the whole tail. Probe-era fallbacks never set it —
136  /// they replay losslessly and produce frames immediately.
137  degraded_resync_pending: bool,
138  /// `true` once a **keyframe** packet has been successfully fed to the SW
139  /// decoder while [`Self::degraded_resync_pending`] is set — i.e. a real resync
140  /// anchor crossed the gap. The pending flag clears only on a delivered SW
141  /// frame *after* this is set, so a concealed non-keyframe frame (a lenient
142  /// codec decoding a lone P-frame from the dropped span) cannot masquerade as a
143  /// resync and prematurely clear the guard. Set alongside `enter`/cleared with
144  /// the pending flag.
145  degraded_keyframe_seen: bool,
146  /// Packets fed to the SW decoder since the post-commit fallback fired while
147  /// [`Self::degraded_resync_pending`] is set — i.e. across the unresolved
148  /// resync gap. Reported in the escalation message so the lost span is
149  /// quantified ("N packets, no keyframe found"). Reset whenever the flag
150  /// clears or on `flush`.
151  degraded_packets_since_fallback: u64,
152  /// Source-stream time base, used to label produced frames.
153  time_base: Timebase,
154  /// The lane this decoder captures into. A marker: the carrier
155  /// appears in the frames it produces, not in its own state.
156  /// `true` when the scratch frame holds a decoded frame whose
157  /// conversion has **not committed** — see
158  /// [`CarrierAudioStreamDecoder::scratch_pending`](crate::audio::CarrierAudioStreamDecoder)
159  /// for the reasoning, which is the same on both roads.
160  ///
161  /// **This decoder has two scratches and can change which one is
162  /// current, so the seat is enforced rather than merely recorded.**
163  /// While it is set, `send_packet` and `send_eof` answer
164  /// [`Sent::MustDrain`]: both are the roads that commit a
165  /// hardware-to-software fallback, and a fallback under a parked frame
166  /// would leave the retry reading the *other* scratch — delivering a
167  /// stale frame, or refusing permanently and stranding a decoded one.
168  /// Refusing makes the retry's state the state that parked it **by
169  /// construction**, which is a stronger guarantee than remembering
170  /// which road produced it.
171  ///
172  /// **The discipline is unchanged; only its spelling moved.** It was
173  /// `VideoDecodeError::FramePending`, and the escape was already
174  /// documented as "call `receive_frame`, or `flush` to abandon it" —
175  /// which is to say it was back pressure wearing an error's clothes.
176  /// Now it says so, and a caller can act on it without inspecting a
177  /// backend-specific error type. The subtitle decoder keeps the same
178  /// seat one road over, spelled the same way.
179  scratch_pending: bool,
180  _carrier: core::marker::PhantomData<C>,
181}
182
183/// Hardware-decode seam behind [`DecodeState::Hw`]. In production this is
184/// the real [`VideoDecoder`]; tests substitute a fake to drive the
185/// post-commit fallback path without a live GPU. Mirrors the subset of
186/// `VideoDecoder`'s surface the wrapper drives on the HW path.
187pub(crate) trait HwInner: Send {
188  /// See [`VideoDecoder::send_packet`].
189  fn send_packet(&mut self, packet: &Packet) -> Result<Sent, Error>;
190  /// See [`VideoDecoder::receive_frame`].
191  fn receive_frame(&mut self, frame: &mut Frame) -> Result<Received, Error>;
192  /// See [`VideoDecoder::send_eof`].
193  fn send_eof(&mut self) -> Result<Sent, Error>;
194  /// See [`VideoDecoder::flush`]. Returns `Result` for a uniform seam even
195  /// though the inherent method is infallible.
196  fn flush(&mut self) -> Result<(), Error>;
197  /// Downcast to the concrete [`VideoDecoder`] when this seam is the real
198  /// HW decoder, so [`FfmpegVideoStreamDecoder::hardware_inner`] can keep
199  /// exposing it. Returns `None` for a test fake.
200  fn as_video_decoder(&self) -> Option<&VideoDecoder>;
201
202  /// Whether a packet submitted **now** would be recorded for replay.
203  ///
204  /// The probe keeps a rescue history so that a decoder which exhausts
205  /// every backend can hand the caller everything FFmpeg consumed since
206  /// open. It records by `av_packet_ref`, and
207  /// [`AllBackendsFailed::into_unconsumed_packets`] hands those
208  /// recordings out as owned, **mutable** `Packet`s — which is why the
209  /// view lane must not share its carrier's storage into a submission
210  /// that could be recorded. See
211  /// [`CarrierVideoStreamDecoder::send_packet_impl`].
212  fn records_submissions(&self) -> bool;
213}
214
215impl HwInner for VideoDecoder {
216  #[inline]
217  fn records_submissions(&self) -> bool {
218    self.is_probing()
219  }
220
221  #[inline]
222  fn send_packet(&mut self, packet: &Packet) -> Result<Sent, Error> {
223    VideoDecoder::send_packet(self, packet)
224  }
225  #[inline]
226  fn receive_frame(&mut self, frame: &mut Frame) -> Result<Received, Error> {
227    VideoDecoder::receive_frame(self, frame)
228  }
229  #[inline]
230  fn send_eof(&mut self) -> Result<Sent, Error> {
231    VideoDecoder::send_eof(self)
232  }
233  #[inline]
234  fn flush(&mut self) -> Result<(), Error> {
235    VideoDecoder::flush(self);
236    Ok(())
237  }
238  #[inline]
239  fn as_video_decoder(&self) -> Option<&VideoDecoder> {
240    Some(self)
241  }
242}
243
244/// Internal: which backend is currently driving the decode.
245enum DecodeState {
246  /// Hardware-backed decoder (auto-probe). May transition to `Sw` on
247  /// `AllBackendsFailed`. Boxed behind [`HwInner`] so tests can inject a
248  /// fake HW decoder.
249  Hw(Box<dyn HwInner>),
250  /// Software decoder. Terminal state.
251  Sw(SwDecoder),
252}
253
254/// A software decoder and the callback state its codec context points
255/// at.
256///
257/// The state carries the allocator judge's byte budget and the
258/// `get_format` declination; it has to outlive the `AVCodecContext`
259/// that references it, which is why it is a field here rather than a
260/// value dropped at the end of `open_sw_decoder`.
261///
262/// `Deref` so that every call site keeps talking to the decoder and
263/// only the construction changed — this pairing is a lifetime fact, not
264/// a new abstraction.
265pub(crate) struct SwDecoder {
266  decoder: ffmpeg_next::decoder::Video,
267  /// Declared **after** the decoder: fields drop in declaration order,
268  /// so the codec context is freed before the state it points at.
269  _callback_state: Box<crate::ffi::CallbackState>,
270}
271
272impl SwDecoder {
273  /// The callback state this decoder's codec context points at.
274  ///
275  /// Handed out as a raw pointer so an error closure can consult it
276  /// while the decoder itself is mutably borrowed — every software send
277  /// / receive / EOF failure on this road goes through
278  /// [`crate::decoder::software_exit`] with it, so a frame the
279  /// allocator judge refused surfaces named instead of as the `EINVAL`
280  /// libavcodec also uses for corrupt input.
281  ///
282  /// `Deref` alone was not enough: it exposes the decoder and hides the
283  /// state, so every call site kept wrapping raw and the budget refusal
284  /// had no way out on the whole software road — including the replay
285  /// and cold-fallback helpers, which drop the state when they finish.
286  pub(crate) fn state(&self) -> *const crate::ffi::CallbackState {
287    &*self._callback_state
288  }
289}
290
291impl core::ops::Deref for SwDecoder {
292  type Target = ffmpeg_next::decoder::Video;
293  fn deref(&self) -> &Self::Target {
294    &self.decoder
295  }
296}
297
298impl core::ops::DerefMut for SwDecoder {
299  fn deref_mut(&mut self) -> &mut Self::Target {
300    &mut self.decoder
301  }
302}
303
304/// What the cold SW decoder is fed on a **post-commit** degrade transition,
305/// named by the failure arm so the three shapes stay mutually exclusive (a
306/// current packet and EOF are never forwarded together). The post-commit path
307/// retains no replay frames, so this is the *only* thing handed to the new SW
308/// decoder at fallback time. See [`FfmpegVideoStreamDecoder::degrade_to_sw`].
309enum PostCommitInput<'a> {
310  /// `send_packet` arm: forward this current packet — the one the HW decoder
311  /// refused (so it was never in any replay set). If it is a keyframe it is the
312  /// resync anchor.
313  Packet(&'a Packet),
314  /// `receive_frame` arm: a frame-time failure has no current packet to forward.
315  FrameTime,
316  /// `send_eof` arm: EOF was pending on the HW path; re-forward it to the cold
317  /// SW so tail-delaying codecs don't hang.
318  Eof,
319}
320
321impl<C: crate::FfmpegCarrier + crate::CarrierOps> CarrierVideoStreamDecoder<C> {
322  /// Opens a decoder for the given codec parameters with the default
323  /// HW backend probe order. If the HW probe can't open any backend,
324  /// falls back to a software `ffmpeg::decoder::Video` immediately —
325  /// `open` only returns `Err` when both paths fail.
326  ///
327  /// Subsequent mid-stream `AllBackendsFailed` from the HW path
328  /// triggers the same SW fallback (with rescued packets replayed).
329  ///
330  /// `limits` bounds what one decoded frame may cost. It is taken here
331  /// rather than through a builder because half of it —
332  /// [`DecoderLimits::max_pixels`] — is written into every
333  /// `AVCodecContext` this decoder opens, and a context's ceiling
334  /// cannot be moved after `avcodec_open2`. That includes the contexts
335  /// opened later, by a mid-stream fallback or a probe advance: the
336  /// limits are retained for exactly that reason.
337  pub(crate) fn open_impl(
338    parameters: Parameters,
339    time_base: Timebase,
340    limits: DecoderLimits,
341  ) -> Result<Self, Error> {
342    // ffmpeg-next's `Parameters` carries an optional `owner: Rc<dyn Any>`
343    // (when constructed from `stream.parameters()` it points back at
344    // the demuxer's `AVStream`). Upstream marks the type `Send`
345    // anyway, which is unsound the moment a non-`None` owner is in
346    // play — moving such a value across threads moves the `Rc`. We
347    // sidestep this by always storing a deep-cloned `Parameters`
348    // (`avcodec_parameters_copy` produces an owner-free copy), so
349    // the `FfmpegVideoStreamDecoder`'s `Send` reachability never
350    // depends on the caller's owner discipline.
351    //
352    // Use `try_clone_parameters` instead of `Parameters::clone` —
353    // ffmpeg-next's `clone` calls `Parameters::new()` which can
354    // return a `Parameters` whose inner pointer is null on OOM
355    // (`avcodec_parameters_alloc` returns null without indication);
356    // the subsequent `avcodec_parameters_copy` against that null
357    // destination is C UB. Our checked helper surfaces the OOM as
358    // an error instead.
359    let owned_parameters = try_clone_parameters(&parameters, limits.max_codec_parameter_bytes())?;
360    let hw_scratch = Frame::empty()?;
361    let sw_scratch = alloc_av_video_frame()?;
362    let state = match VideoDecoder::open_with_frame_limits(
363      try_clone_parameters(&owned_parameters, limits.max_codec_parameter_bytes())?,
364      limits,
365    ) {
366      Ok(hw) => DecodeState::Hw(Box::new(hw)),
367      Err(Error::AllBackendsFailed(_)) => {
368        // Open-time HW exhaustion: no rescued packets (open didn't
369        // see any). Just open SW directly from our owned copy.
370        let sw = open_sw_decoder(&owned_parameters, limits)?;
371        DecodeState::Sw(sw)
372      }
373      Err(other) => return Err(other),
374    };
375    Ok(Self {
376      state,
377      parameters: owned_parameters,
378      hw_scratch,
379      sw_scratch,
380      sw_replay_frames: VecDeque::new(),
381      eof_sent: false,
382      degraded_resync_pending: false,
383      degraded_keyframe_seen: false,
384      degraded_packets_since_fallback: 0,
385      time_base,
386      limits,
387      scratch_pending: false,
388      _carrier: core::marker::PhantomData,
389    })
390  }
391
392  /// Returns `true` when this decoder has fallen back to the software
393  /// path. `false` while still on the HW probe (the initial state).
394  #[cfg_attr(not(tarpaulin), inline(always))]
395  pub(crate) const fn is_software_impl(&self) -> bool {
396    matches!(self.state, DecodeState::Sw(_))
397  }
398
399  /// Returns `true` while the HW probe is still active.
400  #[cfg_attr(not(tarpaulin), inline(always))]
401  pub(crate) const fn is_hardware_impl(&self) -> bool {
402    matches!(self.state, DecodeState::Hw(_))
403  }
404
405  /// Borrow the inner [`VideoDecoder`] when this decoder is still on the
406  /// real HW path. Returns `None` after the SW fallback has fired (or, in
407  /// tests, when the HW seam is a fake rather than a real decoder).
408  #[cfg_attr(not(tarpaulin), inline(always))]
409  pub(crate) fn hardware_inner_impl(&self) -> Option<&VideoDecoder> {
410    match &self.state {
411      DecodeState::Hw(hw) => hw.as_video_decoder(),
412      DecodeState::Sw(_) => None,
413    }
414  }
415
416  /// Returns the time base associated with the source stream.
417  #[cfg_attr(not(tarpaulin), inline(always))]
418  pub(crate) const fn time_base_impl(&self) -> Timebase {
419    self.time_base
420  }
421
422  /// Internal: **probe-era** transition from HW to SW. Replays the rescued
423  /// packets (the inner decoder's buffered history, already accepted by the HW
424  /// probe but not yet decoded) through the new SW decoder so the stream resumes
425  /// seamlessly. No frame was delivered on the HW path yet, so replaying the
426  /// history is lossless.
427  ///
428  /// Only the probe-era branches drive this. The **post-commit** path does
429  /// *not* — it retains and reconstructs zero frames, opening SW cold via
430  /// [`Self::degrade_to_sw`] and resyncing at the next keyframe instead of
431  /// replaying. (That is why this method's replay/drain machinery — and the
432  /// finding that the in-transaction drain doesn't cover later frame
433  /// *conversion* — cannot affect the post-commit path: it never produces a
434  /// post-commit replay frame to convert.)
435  ///
436  /// **Transactional**: drained replay frames accumulate in a local
437  /// queue; we only commit them to `self.sw_replay_frames` and switch
438  /// `self.state` to `Sw` after the replay (and EOF re-forwarding, if
439  /// needed) succeed. On failure, the SW decoder, the local frame
440  /// queue, and (where reachable) any consumed packets are dropped —
441  /// `self` is left in its prior state.
442  ///
443  /// **EOF-aware**: when EOF was already accepted on the HW path
444  /// (`self.eof_sent`), the new SW decoder also receives `send_eof()`
445  /// after replay. Without this, codecs that delay tail frames hang
446  /// forever in the drain phase.
447  ///
448  /// **EAGAIN-aware**: if SW's `send_packet` returns EAGAIN during
449  /// replay, drain produced frames into the local queue and retry.
450  ///
451  /// `eof_pending` is passed as a **local** argument rather than read from
452  /// `self.eof_sent`: callers must not mutate `self.eof_sent` before this
453  /// transaction commits (see [`VideoStreamDecoder::send_eof`]), so the
454  /// in-transaction SW EOF re-forward keys off the local flag and `self`'s
455  /// EOF state is updated only after a clean commit.
456  fn fall_back_to_sw(
457    &mut self,
458    unconsumed_packets: std::vec::Vec<ffmpeg_next::Packet>,
459    eof_pending: bool,
460  ) -> Result<(), Error> {
461    tracing::info!(
462      packets_replayed = unconsumed_packets.len(),
463      eof_pending,
464      "mediadecode-ffmpeg: HW probe exhausted, falling back to software decode",
465    );
466    // Wrap the internal worker so any failure path returns the
467    // rescued packets to the caller via `Error::FallbackFailed`.
468    // Without this, non-seekable streams (live feeds, pipes) would
469    // lose every compressed byte the HW path had consumed when a
470    // fallback transition fails partway.
471    match self.fall_back_to_sw_inner(&unconsumed_packets, eof_pending) {
472      Ok(()) => Ok(()),
473      Err(source) => Err(Error::FallbackFailed(FallbackFailed::new(
474        Box::new(source),
475        unconsumed_packets,
476      ))),
477    }
478  }
479
480  /// Worker for [`Self::fall_back_to_sw`]. Returns the rescued packets
481  /// untouched on the borrowed slice; the wrapper takes ownership of
482  /// them and surfaces them in `FallbackFailed` if this returns Err.
483  fn fall_back_to_sw_inner(
484    &mut self,
485    unconsumed_packets: &[ffmpeg_next::Packet],
486    eof_pending: bool,
487  ) -> Result<(), Error> {
488    let mut sw = open_sw_decoder(&self.parameters, self.limits)?;
489    // Bound before the decoder is mutably borrowed, so the error
490    // closures below can still consult it.
491    let sw_state = sw.state();
492    let mut local_replay: VecDeque<frame::Video> = VecDeque::new();
493    // Helper: drain SW into the local replay queue, capped at
494    // `SW_REPLAY_FRAME_CAP`.
495    //
496    // Error discipline: stop the drain **only** on the transient
497    // backpressure signals EAGAIN / EOF (the decoder has no more output for
498    // now). Every other `ffmpeg_next::Error` — e.g. `InvalidData` from a
499    // corrupt replayed packet — is a real decode failure and is propagated,
500    // so a non-recoverable error surfaces as `FallbackFailed` (carrying the
501    // replay packets) instead of being silently swallowed and the fallback
502    // committed over corruption.
503    fn drain_into(
504      sw: &mut ffmpeg_next::decoder::Video,
505      state: *const crate::ffi::CallbackState,
506      local_replay: &mut VecDeque<frame::Video>,
507    ) -> std::result::Result<(), Error> {
508      loop {
509        let mut tmp = alloc_av_video_frame()?;
510        match sw.receive_frame(&mut tmp) {
511          Ok(()) => {
512            if local_replay.len() >= SW_REPLAY_FRAME_CAP {
513              tracing::error!(
514                cap = SW_REPLAY_FRAME_CAP,
515                "mediadecode-ffmpeg: SW fallback replay produced more frames than the \
516                 replay cap allows; aborting fallback (no frames dropped — they're \
517                 still in the SW decoder's internal queue and will be released when \
518                 it drops)",
519              );
520              return Err(Error::Ffmpeg(ffmpeg_next::Error::Other {
521                errno: libc::ENOMEM,
522              }));
523            }
524            local_replay.push_back(tmp);
525          }
526          // EAGAIN / EOF: no more output for now — stop draining, success.
527          Err(ffmpeg_next::Error::Other { errno }) if errno == ffmpeg_next::error::EAGAIN => {
528            break;
529          }
530          Err(ffmpeg_next::Error::Eof) => break,
531          // Any other error is a genuine decode failure on a replayed
532          // packet — surface it so it is not masked as a clean fallback.
533          Err(other) => return Err(crate::decoder::software_exit(state, other)),
534        }
535      }
536      Ok(())
537    }
538
539    for pkt in unconsumed_packets {
540      let mut attempts: u32 = 0;
541      loop {
542        match sw.send_packet(pkt) {
543          Ok(()) => break,
544          Err(ffmpeg_next::Error::Other { errno }) if errno == ffmpeg_next::error::EAGAIN => {
545            drain_into(&mut sw, sw_state, &mut local_replay)?;
546            attempts += 1;
547            if attempts > 16 {
548              return Err(Error::Ffmpeg(ffmpeg_next::Error::Other {
549                errno: ffmpeg_next::error::EAGAIN,
550              }));
551            }
552          }
553          Err(other) => return Err(crate::decoder::software_exit(sw_state, other)),
554        }
555      }
556    }
557    // Re-forward EOF if the HW path already saw it. SW EOF can also
558    // return EAGAIN until prior output is drained — mirror the
559    // packet-replay loop.
560    if eof_pending {
561      let mut attempts: u32 = 0;
562      loop {
563        match sw.send_eof() {
564          Ok(()) => break,
565          Err(ffmpeg_next::Error::Other { errno }) if errno == ffmpeg_next::error::EAGAIN => {
566            drain_into(&mut sw, sw_state, &mut local_replay)?;
567            attempts += 1;
568            if attempts > 16 {
569              return Err(Error::Ffmpeg(ffmpeg_next::Error::Other {
570                errno: ffmpeg_next::error::EAGAIN,
571              }));
572            }
573          }
574          Err(other) => return Err(crate::decoder::software_exit(sw_state, other)),
575        }
576      }
577    }
578    // Final drain BEFORE commit — the transactional commit boundary. The
579    // EAGAIN-triggered drains above only fire when SW exerts backpressure mid
580    // replay; a SW decoder that ACCEPTS every replayed packet (and the EOF)
581    // without one then surfaces a non-transient error — `InvalidData` from a
582    // corrupt replayed packet, or any other decode failure — only on the *next*
583    // `receive_frame`. Without this drain that error would land after the
584    // commit (frames appended, `state` flipped to `Sw`, rescued packets
585    // dropped) and reach the caller as a plain decode failure, not
586    // `FallbackFailed` — breaking probe-era recovery on non-seekable input.
587    // Draining to EAGAIN/EOF here forces any such error to surface now, so it is
588    // wrapped as `FallbackFailed` (retaining the rescued packets) and the
589    // decoder stays on HW — nothing is committed. (Only the probe-era path
590    // reaches this; the post-commit path degrades via `degrade_to_sw` and never
591    // replays, so it has no drained frames to commit or convert.)
592    drain_into(&mut sw, sw_state, &mut local_replay)?;
593    // Commit: only after replay, any EOF forwarding, AND the final drain
594    // succeeded do we move the new SW decoder and queue into `self`.
595    self.sw_replay_frames.append(&mut local_replay);
596    self.state = DecodeState::Sw(sw);
597    Ok(())
598  }
599
600  /// **Post-commit** degrade-and-continue transition: open the SW decoder
601  /// **cold** and forward only the failure-arm's input, retaining and
602  /// reconstructing **zero** frames. This is the whole post-commit path: open
603  /// SW, forward the current packet (or EOF), degrade-track — nothing is drained
604  /// into `sw_replay_frames`, so there is no replayed frame to convert later and
605  /// no terminal-drain transaction to reason about. SW naturally produces no
606  /// frame until the next keyframe arrives across the gap, then decodes normally;
607  /// the failure-point→next-keyframe span is the accepted, logged drop.
608  ///
609  /// **Transactional (SW-open only)**: `self.state` flips to `Sw` *only after*
610  /// `open_sw_decoder` and the input forward succeed. On any failure the new SW
611  /// decoder is dropped and the decoder is left on its prior HW state, the error
612  /// surfaced as [`Error::FallbackFailed`] (with an empty rescue set — a
613  /// post-commit failure never carries unconsumed packets). With no replay-frame
614  /// retention there is nothing else to roll back.
615  ///
616  /// On a clean commit it enters degraded-resync mode (see
617  /// [`Self::enter_degraded_resync`]); if the forwarded current packet is itself
618  /// a keyframe, the resync anchor is recorded immediately
619  /// ([`Self::note_degraded_keyframe`]).
620  ///
621  /// # `eof_pending`
622  ///
623  /// Whether the session's end-of-stream has already been **committed**,
624  /// and so must be re-forwarded into the cold decoder. Carried as a
625  /// local argument for the same two reasons the probe-era road carries
626  /// it (see [`Self::fall_back_to_sw`]): it is read from `eof_sent`
627  /// before anything is mutated, so a fallback that fails leaves no
628  /// half-truth behind — and one question deserves one mechanism on
629  /// both fallback roads.
630  ///
631  /// It is **not** expressed by selecting [`PostCommitInput::Eof`],
632  /// even though that arm forwards the same call. That enum is named by
633  /// the *failure arm* — which road raised the exhaustion — and the
634  /// `warn!` each site emits says so; borrowing the EOF arm for a
635  /// frame-time failure would make it lie about where the failure came
636  /// from.
637  fn degrade_to_sw(&mut self, input: PostCommitInput<'_>, eof_pending: bool) -> Result<(), Error> {
638    match self.degrade_to_sw_inner(input, eof_pending) {
639      Ok(()) => Ok(()),
640      // **A budget refusal is not a fallback failure.** It travels
641      // unwrapped, and the spelling was chosen rather than inherited:
642      //
643      // * `FallbackFailed` means the fallback *machinery* could not
644      //   complete, and its contract is to hand back the unconsumed
645      //   packets so a caller can re-drive them. On this road that set
646      //   is empty by construction — the probe buffer is gone and no
647      //   replay frames are retained — so the envelope carries no
648      //   recovery affordance at all, only a label.
649      // * And the label is the wrong one. Re-driving is the natural
650      //   response to a fallback failure, and re-driving a budget
651      //   refusal under the same limits refuses identically. Naming it
652      //   a fallback failure invites an action that cannot succeed,
653      //   while `FrameBudgetExceeded` names the one that can: raise
654      //   the ceiling, or accept the refusal.
655      //
656      // So it keeps the same spelling here as on every other road. One
657      // fact, one name.
658      Err(budget @ Error::FrameBudgetExceeded(_)) => Err(budget),
659      // Everything else really is the machinery failing, and keeps the
660      // envelope — empty rescue set and all, which is what a
661      // post-commit failure has to hand back.
662      Err(source) => Err(Error::FallbackFailed(FallbackFailed::new(
663        Box::new(source),
664        std::vec::Vec::new(),
665      ))),
666    }
667  }
668
669  /// Worker for [`Self::degrade_to_sw`]. Opens SW cold, forwards the arm's input,
670  /// and on success commits + enters degraded-resync mode. Returns `Err` (and
671  /// commits nothing) if SW cannot open or the forward fails.
672  fn degrade_to_sw_inner(
673    &mut self,
674    input: PostCommitInput<'_>,
675    eof_pending: bool,
676  ) -> Result<(), Error> {
677    // The invariant [`PostCommitInput`] documents, stated where it can
678    // be checked: a current packet and an end-of-stream are never
679    // forwarded together. The send road cannot violate it — its own
680    // gate refuses every packet once `eof_sent` is committed — so this
681    // records the coupling rather than defending against it.
682    debug_assert!(
683      !(matches!(input, PostCommitInput::Packet(_)) && eof_pending),
684      "a current packet and a committed EOF must never be forwarded together",
685    );
686    let mut sw = open_sw_decoder(&self.parameters, self.limits)?;
687    // Captured before the decoder is borrowed for the forward, and
688    // before it can be dropped on the error road: this temporary
689    // decoder owns the callback state, so a `judge_buffer` refusal
690    // recorded during either forward below dies with it unless the
691    // reason is collected here. That was the last software road still
692    // wrapping libavcodec's `EINVAL` raw.
693    let state = sw.state();
694    let mut forwarded_keyframe = false;
695    let mut forwarded_packet = false;
696    match input {
697      PostCommitInput::Packet(pkt) => {
698        // The HW decoder REFUSED this packet, so it was never decoded; forward
699        // it to the cold SW. A failure here surfaces (it is not silently
700        // dropped) and rolls back to HW.
701        sw.send_packet(pkt)
702          .map_err(|e| crate::decoder::software_exit(state, e))?;
703        forwarded_keyframe = pkt.is_key();
704        forwarded_packet = true;
705      }
706      // Neither of these forwards a packet; the end-of-stream below is
707      // the only thing they can hand the cold decoder.
708      PostCommitInput::FrameTime | PostCommitInput::Eof => {}
709    }
710    // **The end of the stream is re-forwarded here, on every arm that
711    // has one, and that is the fix rather than an extra.**
712    //
713    // The cold decoder knows nothing: it was opened a moment ago, from
714    // codec parameters alone. If the session had already been told the
715    // stream ended and this new decoder is not, it answers `EAGAIN` to
716    // every drain — which reaches the caller as
717    // [`Received::NeedsInput`], an instruction to send another packet.
718    // On a session whose end is committed there is no legal way to obey
719    // that: both send gates refuse. The caller loops, or quietly
720    // accepts a truncated tail, until `flush`.
721    //
722    // It used to be reachable only through the `Eof` failure arm, so
723    // the frame-time road — a post-commit exhaustion raised *while
724    // draining*, after EOF was accepted — opened cold and stayed cold.
725    // A cold decoder has no buffered output, so this cannot answer
726    // `EAGAIN` itself.
727    if eof_pending {
728      sw.send_eof()
729        .map_err(|e| crate::decoder::software_exit(state, e))?;
730    }
731    // Commit: only after a clean open + forward.
732    self.state = DecodeState::Sw(sw);
733    self.enter_degraded_resync();
734    if forwarded_keyframe {
735      // The refused current packet was itself the resync anchor.
736      self.note_degraded_keyframe(true);
737    }
738    if forwarded_packet {
739      self.count_degraded_packet();
740    }
741    Ok(())
742  }
743
744  /// Enter post-commit degraded mode after a post-commit fallback commits: the
745  /// SW decoder opened cold and the span up to the next keyframe is being
746  /// dropped. We hold this mode until SW proves a *keyframe-anchored* resync
747  /// (a delivered frame after a keyframe was fed — see
748  /// [`Self::note_degraded_keyframe`] / [`Self::resync_on_frame`]) and the EOF
749  /// escalation in [`VideoStreamDecoder::receive_frame`]. Called only on the
750  /// post-commit path, only after a clean commit. Resets the keyframe-seen anchor
751  /// and the gap counter.
752  #[inline]
753  fn enter_degraded_resync(&mut self) {
754    self.degraded_resync_pending = true;
755    self.degraded_keyframe_seen = false;
756    self.degraded_packets_since_fallback = 0;
757  }
758
759  /// Record that a packet fed to the SW decoder across an unresolved post-commit
760  /// gap was a **keyframe** — the resync anchor. Only a frame delivered *after*
761  /// this clears the pending flag, so a lenient codec's concealed P-frame can't
762  /// masquerade as a resync. A no-op outside degraded mode, or for a
763  /// non-keyframe.
764  #[inline]
765  fn note_degraded_keyframe(&mut self, is_key: bool) {
766    if self.degraded_resync_pending && is_key {
767      self.degraded_keyframe_seen = true;
768    }
769  }
770
771  /// Count one packet fed to the SW decoder while a post-commit resync is still
772  /// unproven, so the EOF escalation can quantify the lost tail. A no-op once
773  /// SW has resynced (the flag is clear).
774  #[inline]
775  fn count_degraded_packet(&mut self) {
776    if self.degraded_resync_pending {
777      self.degraded_packets_since_fallback = self.degraded_packets_since_fallback.saturating_add(1);
778    }
779  }
780
781  /// A SW frame was delivered. Clear post-commit degraded mode **only if** a
782  /// keyframe was fed across the gap ([`Self::degraded_keyframe_seen`]) — that is
783  /// a real keyframe-anchored resync, so the dropped span is now the promised
784  /// *bounded* gap. A frame delivered with no keyframe yet (a concealed P-frame
785  /// from the dropped span) leaves the guard set, so the one-GOP bound stays
786  /// enforced and the EOF escalation still fires if no keyframe ever arrives.
787  /// Idempotent; a no-op outside degraded mode (steady state, probe-era replay).
788  #[inline]
789  fn resync_on_frame(&mut self) {
790    if self.degraded_resync_pending && self.degraded_keyframe_seen {
791      self.clear_degraded_resync();
792    }
793  }
794
795  /// Unconditionally reset post-commit degraded-mode state. Used where the gap
796  /// is moot regardless of resync proof: a `flush` (seek/reset re-anchors the
797  /// stream) and the cleanup after an EOF escalation has already fired (so a
798  /// follow-up poll sees plain EOF, not a repeated escalation). The
799  /// frame-delivery path uses the keyframe-gated [`Self::resync_on_frame`]
800  /// instead.
801  #[inline]
802  fn clear_degraded_resync(&mut self) {
803    self.degraded_resync_pending = false;
804    self.degraded_keyframe_seen = false;
805    self.degraded_packets_since_fallback = 0;
806  }
807
808  /// The one place a delivered frame is committed.
809  ///
810  /// Every road that hands a frame to the caller passes through here —
811  /// the hardware scratch, the software scratch, both replay-queue
812  /// entries, and the retry of a parked frame — so the bookkeeping a
813  /// delivery owes cannot be attached to some of them and forgotten on
814  /// others. It was: a parked software frame delivered on the retry
815  /// road skipped [`Self::resync_on_frame`], so the last recovered
816  /// frame of a degraded stream could leave the resync guard standing
817  /// and turn a clean EOF into a false
818  /// [`PostCommitNeverResynced`].
819  fn commit_delivery(
820    &mut self,
821    frame: VideoFrame<mediadecode::PixelFormat, VideoFrameExtra, C::Buffer>,
822    dst: &mut VideoFrame<mediadecode::PixelFormat, VideoFrameExtra, C::Buffer>,
823  ) {
824    // The seat is free once a carrier exists for what it held.
825    self.scratch_pending = false;
826    // A delivered frame is what clears a keyframe-anchored resync. A
827    // no-op on every road that never entered degraded mode, which is
828    // why it can be unconditional here.
829    self.resync_on_frame();
830    *dst = frame;
831  }
832
833  /// Where this session is. See
834  /// [`SessionPhase`](crate::decoder::SessionPhase).
835  ///
836  /// The wrapper never sees a probe — that lives inside the hardware
837  /// seam, which derives its own — so only the committed pair is
838  /// reachable from here.
839  const fn phase(&self) -> crate::decoder::SessionPhase {
840    if self.eof_sent {
841      crate::decoder::SessionPhase::Draining
842    } else {
843      crate::decoder::SessionPhase::Streaming
844    }
845  }
846
847  /// Reads a drain answer against the session's own committed end.
848  ///
849  /// Routes a settled end through the post-commit gap check.
850  ///
851  /// **The `NeedsInput`-past-the-end reading moved out of here.** It
852  /// used to be this method's own comparison against `eof_sent` — one
853  /// more road deriving the session's phase for itself, which is the
854  /// habit [`SessionPhase`](crate::decoder::SessionPhase) ended. The
855  /// classifier makes that reading now, for every road at once, and
856  /// what is left here is the part that is genuinely this wrapper's:
857  /// an end is not clean if a post-commit gap never closed.
858  fn settle(&mut self, status: Received) -> Result<Received, VideoDecodeError> {
859    match status {
860      Received::Ended => self.ended(),
861      other => Ok(other),
862    }
863  }
864
865  /// The end of the stream, read against a post-commit gap that never
866  /// closed.
867  ///
868  /// One place, because there are now two spellings that reach it — the
869  /// substrate's `AVERROR_EOF` and a settled [`Received::NeedsInput`]
870  /// past a committed end — and a lost tail must escalate on both. The
871  /// flag is cleared as it fires so a caller draining to the end sees
872  /// the escalation once and the plain end afterwards.
873  fn ended(&mut self) -> Result<Received, VideoDecodeError> {
874    if !self.degraded_resync_pending {
875      return Ok(Received::Ended);
876    }
877    let packets_lost = self.degraded_packets_since_fallback;
878    tracing::error!(
879      packets_lost,
880      "mediadecode-ffmpeg: post-commit HW->SW fallback never resynced before EOF — \
881       {packets_lost} packets fed to the software decoder produced no frame (no \
882       keyframe found across the gap); the stream tail from the fallback point was \
883       lost",
884    );
885    self.clear_degraded_resync();
886    Err(VideoDecodeError::PostCommitNeverResynced(
887      PostCommitNeverResynced::new(packets_lost),
888    ))
889  }
890
891  /// Internal: convert the active scratch frame into a
892  /// `mediadecode::VideoFrame` and write into `dst`.
893  fn deliver_frame(
894    &mut self,
895    dst: &mut VideoFrame<mediadecode::PixelFormat, VideoFrameExtra, C::Buffer>,
896  ) -> Result<Received, VideoDecodeError> {
897    let av_frame = match &mut self.state {
898      DecodeState::Hw(_) => unsafe { self.hw_scratch.as_inner_mut().as_ptr() },
899      DecodeState::Sw(_) => unsafe { self.sw_scratch.as_ptr() },
900    };
901    // SAFETY: the scratch frame is live — either just filled by the
902    // inner decoder's `receive_frame`, or left holding a frame whose
903    // conversion did not commit. Convert takes what it needs out of it,
904    // so the scratch can be reused once this has committed.
905    let converted = unsafe {
906      convert::av_frame_to_video_frame_as::<C>(av_frame, self.time_base, self.limits.frame())
907    };
908    match converted {
909      Ok(new_frame) => {
910        self.commit_delivery(new_frame, dst);
911        Ok(Received::Frame)
912      }
913      Err(e) => {
914        // Park only what another attempt could survive.
915        self.scratch_pending = e.parks_in_decode();
916        Err(VideoDecodeError::Convert(e))
917      }
918    }
919  }
920}
921
922#[cfg(test)]
923impl<C: crate::FfmpegCarrier + crate::CarrierOps> CarrierVideoStreamDecoder<C> {
924  /// Build a decoder around an injected HW seam, bypassing the real probe.
925  /// Lets tests drive the post-commit fallback path with a [`HwInner`] fake
926  /// instead of a live GPU. The SW fallback still opens the **real**
927  /// `ffmpeg::decoder::Video` from `parameters`, so a fallback in these tests
928  /// genuinely decodes.
929  pub(crate) fn from_hw_inner_for_test(
930    hw: Box<dyn HwInner>,
931    parameters: Parameters,
932    time_base: Timebase,
933  ) -> Result<Self, Error> {
934    let limits = DecoderLimits::default();
935    let owned_parameters = try_clone_parameters(&parameters, limits.max_codec_parameter_bytes())?;
936    Ok(Self {
937      state: DecodeState::Hw(hw),
938      parameters: owned_parameters,
939      hw_scratch: Frame::empty()?,
940      sw_scratch: alloc_av_video_frame()?,
941      sw_replay_frames: VecDeque::new(),
942      eof_sent: false,
943      degraded_resync_pending: false,
944      degraded_keyframe_seen: false,
945      degraded_packets_since_fallback: 0,
946      time_base,
947      limits,
948      scratch_pending: false,
949      _carrier: core::marker::PhantomData,
950    })
951  }
952
953  /// Whether `send_eof` has been committed on the active decoder. Lets the
954  /// rollback tests assert that a failed EOF fallback restores (never
955  /// half-mutates) `eof_sent`.
956  pub(crate) const fn eof_sent_for_test(&self) -> bool {
957    self.eof_sent
958  }
959
960  /// Whether a post-commit fallback is awaiting a keyframe-anchored resync.
961  /// Lets the escalation tests observe the degraded-resync state machine.
962  pub(crate) const fn degraded_resync_pending_for_test(&self) -> bool {
963    self.degraded_resync_pending
964  }
965
966  /// Whether a keyframe has been fed to the SW decoder across the unresolved
967  /// post-commit gap (the resync anchor). Lets the keyframe-gating test confirm
968  /// a concealed P-frame does not set it (so the resync clear stays blocked).
969  pub(crate) const fn degraded_keyframe_seen_for_test(&self) -> bool {
970    self.degraded_keyframe_seen
971  }
972
973  /// Whether the post-commit path retained any replay frames — must always be
974  /// empty for a post-commit fallback (it retains zero). Lets the finding-1
975  /// dissolution test assert no replay frame was ever queued.
976  pub(crate) fn sw_replay_frames_is_empty_for_test(&self) -> bool {
977    self.sw_replay_frames.is_empty()
978  }
979
980  /// Packets fed to SW across an unresolved post-commit resync gap. Lets the
981  /// counter test confirm packets crossing the gap from the `send_packet` arm
982  /// are tallied (and cleared on resync).
983  pub(crate) const fn degraded_packets_since_fallback_for_test(&self) -> u64 {
984    self.degraded_packets_since_fallback
985  }
986}
987
988impl<C: crate::FfmpegCarrier + crate::CarrierOps> CarrierVideoStreamDecoder<C> {
989  /// The fault a submission after end-of-stream earns on this face.
990  ///
991  /// **Censused from the empty-seat road rather than invented.** With
992  /// the seat free, a post-EOF `send_packet` or a repeated `send_eof`
993  /// reaches libavcodec, which answers `AVERROR_EOF`, and all four
994  /// roads through this wrapper — hardware and software, packet and
995  /// EOF — surface it as exactly this value. The gates below short
996  /// out to the same one so a parked seat cannot change *which* answer
997  /// a caller gets, only how quickly. `the_post_eof_fault_is_the_one_the_substrate_gives`
998  /// pins the two against each other.
999  ///
1000  /// Deliberately **not** a new `VideoDecodeError` arm. The subtitle
1001  /// seam had to mint `AfterEof` because `avcodec_decode_subtitle2` has
1002  /// no state machine to refuse for it; this face already has an answer
1003  /// for the condition, and a second spelling for one fault on one
1004  /// surface is the disease this release is curing.
1005  fn after_eof() -> VideoDecodeError {
1006    VideoDecodeError::Decode(Error::Ffmpeg(ffmpeg_next::Error::Eof))
1007  }
1008
1009  pub(crate) fn send_packet_impl(
1010    &mut self,
1011    packet: &VideoPacket<VideoPacketExtra, C::Buffer>,
1012  ) -> Result<Sent, VideoDecodeError> {
1013    // **The end of the stream outranks the parked seat, and the order
1014    // is the whole point.**
1015    //
1016    // `Sent::MustDrain` is a promise: drain the output and this same
1017    // offer becomes acceptable. Past end-of-stream that promise is
1018    // false — draining empties the seat and the retry still faults,
1019    // until `flush`. Checking the seat first made the wrapper answer
1020    // `MustDrain` for a submission nothing could ever accept, which is
1021    // the same fault-under-back-pressure inversion the subtitle seam
1022    // carried: a caller obeying the contract loops, drains, re-offers,
1023    // and is refused anyway.
1024    //
1025    // It is reachable: `send_eof` is accepted and sets `eof_sent`, a
1026    // delayed tail frame comes out of the decoder, its carrier
1027    // allocation fails parkably, and the seat is taken on a session
1028    // that is already over.
1029    if !self.phase().accepts_input() {
1030      return Err(Self::after_eof());
1031    }
1032    // **Nothing is sent while a frame is parked.** Both send roads can
1033    // commit a hardware-to-software fallback, and a fallback under a
1034    // parked frame would leave the retry reading the other scratch. See
1035    // [`Self::scratch_pending`]. Nothing was consumed, so this is back
1036    // pressure and the packet is still the caller's to re-offer — which
1037    // is true precisely because the stream is not over, checked above.
1038    if self.scratch_pending {
1039      return Ok(Sent::MustDrain);
1040    }
1041    let phase = self.phase();
1042    // Scoped submission: the rebuilt `AVPacket` never leaves this call,
1043    // which is what lets the view lane share its buffer with libavcodec
1044    // rather than copy into it. See `boundary::with_ffmpeg_video_packet`.
1045    let limits = self.limits.packet_limits();
1046    // **The route depends on what this decoder does with what it is
1047    // sent.** While the hardware probe is open it `av_packet_ref`s
1048    // every accepted packet into a rescue history, and
1049    // `AllBackendsFailed::into_unconsumed_packets` hands those out as
1050    // owned, mutable `Packet`s — so a shared body would escape this
1051    // call as a live mutable alias of a carrier the caller may still be
1052    // reading. Inside that window the body is copied; once the probe
1053    // has committed, nothing is recorded and the send is zero-copy
1054    // again. The software road never records.
1055    let route = match &self.state {
1056      DecodeState::Hw(hw) if hw.records_submissions() => crate::carrier::BodyRoute::Copy,
1057      _ => crate::carrier::BodyRoute::Submission,
1058    };
1059    boundary::with_ffmpeg_video_packet::<C, _>(packet, limits, route, |av_pkt| {
1060      match &mut self.state {
1061        DecodeState::Hw(hw) => match hw.send_packet(av_pkt) {
1062          // The seam already classified libavcodec's back pressure, so
1063          // both states travel on unchanged.
1064          Ok(status) => Ok(status),
1065          Err(Error::AllBackendsFailed(p)) => {
1066            // Route on the EXPLICIT origin, never on whether `rescued` is empty (a
1067            // probe-era first-packet cap trip is *also* empty).
1068            if p.origin().is_post_commit() {
1069              // Post-commit: DEGRADE AND CONTINUE. No lossless mid-stream
1070              // reconstruction — the SW decoder opens cold, retains zero replay
1071              // frames, and resyncs at the next keyframe. The current packet (the
1072              // one HW REFUSED) is forwarded to that cold SW: if it is the resync
1073              // keyframe SW decodes from it, otherwise SW drops it until a keyframe
1074              // arrives. The bounded span from here to that keyframe is dropped — a
1075              // loudly logged gap (see the `warn!`), not a silent one.
1076              tracing::warn!(
1077                backend = ?p.attempts().last().map(|(b, _)| *b),
1078                pts = ?av_pkt.pts(),
1079                "mediadecode-ffmpeg: HW decode failed post-commit; falling back to \
1080                 software, resyncing at next keyframe — a bounded span of frames \
1081                 may be dropped at this boundary",
1082              );
1083              // Transactional SW-open + current-packet forward; degrade-tracking
1084              // (incl. keyframe-anchor recording) happens inside on a clean commit.
1085              // A failure surfaces `FallbackFailed` and stays on HW.
1086              // A clean degrade forwarded this very packet into the
1087              // cold software decoder, so it was consumed.
1088              // `false`: this road is unreachable once the end is
1089              // committed — `send_packet_impl`'s first gate refuses
1090              // every packet past `eof_sent` — so there is no EOF to
1091              // re-forward, and forwarding one alongside a packet is
1092              // the pairing [`PostCommitInput`] forbids.
1093              return self
1094                .degrade_to_sw(PostCommitInput::Packet(av_pkt), false)
1095                .map(|()| Sent::Accepted)
1096                .map_err(VideoDecodeError::Decode);
1097            }
1098            // Probe-era: replay the inner decoder's buffered history (lossless —
1099            // no frame was delivered yet), then forward the still-unconsumed
1100            // current packet to SW.
1101            let rescued = p.into_unconsumed_packets();
1102            // `eof_pending` is the committed EOF state — never pre-mutated here.
1103            let eof_pending = self.eof_sent;
1104            self
1105              .fall_back_to_sw(rescued, eof_pending)
1106              .map_err(VideoDecodeError::Decode)?;
1107            // Forward the new (still-unconsumed) current packet to the
1108            // freshly-opened SW decoder — the HW decoder REFUSED it, so it was not
1109            // in the replay set. A failure here surfaces (it is not silently
1110            // dropped), and back pressure from the fresh decoder is reported as
1111            // such rather than mistaken for one: the fallback committed either
1112            // way, and the caller re-offers the packet.
1113            if let DecodeState::Sw(sw) = &mut self.state {
1114              let st = sw.state();
1115              if let Err(e) = sw.send_packet(av_pkt) {
1116                return crate::decoder::software_send(st, e, phase)
1117                  .map_err(VideoDecodeError::Decode);
1118              }
1119            }
1120            Ok(Sent::Accepted)
1121          }
1122          Err(other) => Err(VideoDecodeError::Decode(other)),
1123        },
1124        DecodeState::Sw(sw) => {
1125          let st = sw.state();
1126          if let Err(e) = sw.send_packet(av_pkt) {
1127            // Funnel, then gate. **Nothing below runs on back pressure**,
1128            // which is the point of returning here rather than falling
1129            // through: a packet libavcodec did not take must not be
1130            // counted across the resync gap or recorded as a keyframe
1131            // anchor, or a caller's honest re-offer would double-count
1132            // it.
1133            return crate::decoder::software_send(st, e, phase).map_err(VideoDecodeError::Decode);
1134          }
1135          // A keyframe fed across an unresolved post-commit gap is the resync
1136          // anchor; record it so the next delivered frame can clear the guard.
1137          self.note_degraded_keyframe(av_pkt.is_key());
1138          // Count packets crossing an unresolved post-commit resync gap so the
1139          // escalation at EOF can report how much tail was lost.
1140          self.count_degraded_packet();
1141          Ok(Sent::Accepted)
1142        }
1143      }
1144    })
1145    .map_err(|e| VideoDecodeError::Decode(Error::PacketBuild(e)))?
1146  }
1147
1148  pub(crate) fn receive_frame_impl(
1149    &mut self,
1150    dst: &mut VideoFrame<mediadecode::PixelFormat, VideoFrameExtra, C::Buffer>,
1151  ) -> Result<Received, VideoDecodeError> {
1152    // Deliver any frames produced during SW fallback replay before
1153    // pulling new ones from the SW decoder. This is the queue
1154    // populated by `fall_back_to_sw` when SW returned EAGAIN during
1155    // packet replay — a **probe-era** path only (the post-commit path retains
1156    // no replay frames), so `resync_on_frame` here is a no-op (probe-era never
1157    // enters degraded mode).
1158    // **Peeked, not popped.** A replayed frame is the rescue history's
1159    // only copy: popping it before the conversion committed lost it to
1160    // any allocation failure, which is the one thing this queue exists
1161    // to prevent. It leaves the queue when a carrier exists for it.
1162    if let Some(replayed) = self.sw_replay_frames.front() {
1163      // SAFETY: `replayed` is a live AVFrame owned by this queue;
1164      // convert takes what it needs out of it.
1165      let converted = unsafe {
1166        convert::av_frame_to_video_frame_as::<C>(
1167          replayed.as_ptr(),
1168          self.time_base,
1169          self.limits.frame(),
1170        )
1171      };
1172      let new_frame = match converted {
1173        Ok(new_frame) => new_frame,
1174        Err(e) if e.parks_in_decode() => return Err(VideoDecodeError::Convert(e)),
1175        // A frame nothing can carry is dropped rather than re-offered
1176        // forever — the same rule the scratch seat follows.
1177        Err(e) => {
1178          self.sw_replay_frames.pop_front();
1179          return Err(VideoDecodeError::Convert(e));
1180        }
1181      };
1182      self.sw_replay_frames.pop_front();
1183      self.commit_delivery(new_frame, dst);
1184      return Ok(Received::Frame);
1185    }
1186    // A frame whose conversion did not commit is converted again before
1187    // the decoder is asked for another — see [`Self::scratch_pending`].
1188    // The scratch still holds it, and `deliver_frame` reads whichever
1189    // scratch the current state uses.
1190    if self.scratch_pending {
1191      return self.deliver_frame(dst);
1192    }
1193    let phase = self.phase();
1194    loop {
1195      match &mut self.state {
1196        DecodeState::Hw(hw) => match hw.receive_frame(&mut self.hw_scratch) {
1197          Ok(Received::Frame) => {
1198            // The frame is out of the decoder's queue from here; the
1199            // seat is what keeps it if the conversion cannot commit.
1200            self.scratch_pending = true;
1201            return self.deliver_frame(dst);
1202          }
1203          // The hardware seam already classified the two flow signals.
1204          // They still pass the session's own end: see [`Self::settle`].
1205          Ok(status) => return self.settle(status),
1206          Err(Error::AllBackendsFailed(p)) => {
1207            // HW exhausted at frame-time. There is no current packet here.
1208            // Route on the explicit origin.
1209            if p.origin().is_post_commit() {
1210              // Post-commit: DEGRADE AND CONTINUE — open SW cold (no current
1211              // packet to forward, no replay frames retained) and resync at the
1212              // next keyframe, dropping the bounded span up to it. Loud single
1213              // `warn!` marks that accepted gap. A clean commit enters degraded
1214              // mode; a SW-open failure surfaces `FallbackFailed` and stays HW.
1215              tracing::warn!(
1216                backend = ?p.attempts().last().map(|(b, _)| *b),
1217                "mediadecode-ffmpeg: HW decode failed post-commit at frame-time; \
1218                 falling back to software, resyncing at next keyframe — a bounded \
1219                 span of frames may be dropped at this boundary",
1220              );
1221              // **The committed end travels with the fallback.** Read
1222              // before anything mutates, exactly as the probe-era road
1223              // below reads it. Without it the cold decoder answers
1224              // `EAGAIN` forever on a session no send can feed.
1225              let eof_pending = self.eof_sent;
1226              self
1227                .degrade_to_sw(PostCommitInput::FrameTime, eof_pending)
1228                .map_err(VideoDecodeError::Decode)?;
1229              // Nothing to deliver yet — fall through to the loop; the next
1230              // iteration takes the Sw arm and pulls from the cold SW decoder.
1231              continue;
1232            }
1233            // Probe-era: replay the buffered history (lossless).
1234            let rescued = p.into_unconsumed_packets();
1235            // `eof_pending` is the committed EOF state — never pre-mutated here.
1236            let eof_pending = self.eof_sent;
1237            self
1238              .fall_back_to_sw(rescued, eof_pending)
1239              .map_err(VideoDecodeError::Decode)?;
1240            // If the replay produced any drained frames, return one
1241            // immediately — preserves stream order vs. whatever the
1242            // SW decoder will produce next.
1243            // **Peeked, not popped** — the second delivery path onto
1244            // this queue, and it owes the same discipline as the first
1245            // (see the head of `receive_frame_impl`). The replay queue
1246            // is the rescue history's only copy of these frames, so a
1247            // conversion that cannot commit must leave the head where
1248            // it is rather than advance past it.
1249            if let Some(replayed) = self.sw_replay_frames.front() {
1250              // SAFETY: `replayed` is a live AVFrame owned by this
1251              // queue; convert takes what it needs out of it.
1252              let converted = unsafe {
1253                convert::av_frame_to_video_frame_as::<C>(
1254                  replayed.as_ptr(),
1255                  self.time_base,
1256                  self.limits.frame(),
1257                )
1258              };
1259              let new_frame = match converted {
1260                Ok(new_frame) => new_frame,
1261                Err(e) if e.parks_in_decode() => return Err(VideoDecodeError::Convert(e)),
1262                // A frame nothing can carry is dropped rather than
1263                // re-offered forever.
1264                Err(e) => {
1265                  self.sw_replay_frames.pop_front();
1266                  return Err(VideoDecodeError::Convert(e));
1267                }
1268              };
1269              self.sw_replay_frames.pop_front();
1270              self.commit_delivery(new_frame, dst);
1271              return Ok(Received::Frame);
1272            }
1273            // Fall through to the loop; next iteration takes the Sw arm.
1274          }
1275          Err(other) => return Err(VideoDecodeError::Decode(other)),
1276        },
1277        DecodeState::Sw(sw) => {
1278          // Convert inline (rather than via `deliver_frame`, which borrows all
1279          // of `self`) so only the disjoint fields `sw_scratch` / `time_base`
1280          // are touched alongside the `self.state` borrow `sw` holds.
1281          let st = sw.state();
1282          match sw.receive_frame(&mut self.sw_scratch) {
1283            Ok(()) => {
1284              // The frame is out of the decoder's queue from here; the
1285              // seat is what keeps it if the conversion cannot commit.
1286              self.scratch_pending = true;
1287              // SAFETY: the scratch frame is live (just filled by
1288              // `receive_frame`); convert takes what it needs out of
1289              // it, so the scratch can be reused once this commits.
1290              let converted = unsafe {
1291                convert::av_frame_to_video_frame_as::<C>(
1292                  self.sw_scratch.as_ptr(),
1293                  self.time_base,
1294                  self.limits.frame(),
1295                )
1296              };
1297              let new_frame = match converted {
1298                Ok(new_frame) => new_frame,
1299                Err(e) => {
1300                  self.scratch_pending = e.parks_in_decode();
1301                  return Err(VideoDecodeError::Convert(e));
1302                }
1303              };
1304              // SW produced a frame. The commit point clears degraded mode only
1305              // if a keyframe was fed across the gap — a real keyframe-anchored
1306              // resync, so the dropped span is the promised bounded gap. A
1307              // concealed P-frame (no keyframe yet) does not clear it (see
1308              // `resync_on_frame`).
1309              self.commit_delivery(new_frame, dst);
1310              return Ok(Received::Frame);
1311            }
1312            // Funnel first — so a recorded budget refusal is named
1313            // rather than laundered — read as a status second (`EAGAIN`
1314            // is `NeedsInput`, `Eof` is `Ended`, and the errno stops
1315            // inside this crate either way), and settled against the
1316            // session's own end third.
1317            //
1318            // That last step is where a post-commit resync that never
1319            // closed becomes [`VideoDecodeError::PostCommitNeverResynced`]
1320            // instead of a clean end that would swallow the tail — and
1321            // it now catches the end however the codec spelled it. See
1322            // [`Self::settle`] and [`Self::ended`].
1323            Err(e) => {
1324              let status =
1325                crate::decoder::software_receive(st, e, phase).map_err(VideoDecodeError::Decode)?;
1326              return self.settle(status);
1327            }
1328          }
1329        }
1330      }
1331    }
1332  }
1333
1334  pub(crate) fn send_eof_impl(&mut self) -> Result<Sent, VideoDecodeError> {
1335    // The same two gates in the same order, for the same reason: a
1336    // repeated end-of-stream past a committed one is refused however
1337    // much is drained, so answering back pressure would be a promise
1338    // this face cannot keep. See [`Self::after_eof`].
1339    if !self.phase().accepts_input() {
1340      return Err(Self::after_eof());
1341    }
1342    // As `send_packet`: EOF can commit a fallback too, and the escalation
1343    // it may raise reads the resync standing a parked frame has not yet
1344    // had the chance to clear. Nothing was recorded, so drain and signal
1345    // again.
1346    if self.scratch_pending {
1347      return Ok(Sent::MustDrain);
1348    }
1349    let phase = self.phase();
1350    let outcome = match &mut self.state {
1351      DecodeState::Hw(hw) => match hw.send_eof() {
1352        // The seam classified libavcodec's back pressure already.
1353        Ok(status) => Ok(status),
1354        Err(Error::AllBackendsFailed(p)) => {
1355          // EOF is pending for this transaction, so the SW decoder must also
1356          // receive `send_eof` (codecs that delay tail frames hang otherwise).
1357          // We pass that intent locally rather than pre-setting `self.eof_sent`:
1358          // a fallback that fails returns `FallbackFailed` and stays on HW, and a
1359          // half-mutated `self.eof_sent = true` would then make a *later*
1360          // fallback inject an EOF into SW even though this `send_eof` errored.
1361          // `self.eof_sent` is committed only after the whole operation succeeds
1362          // (the `outcome` check below), keeping the fallback all-or-nothing.
1363          if p.origin().is_post_commit() {
1364            // Post-commit: DEGRADE AND CONTINUE — open SW cold, re-forward EOF
1365            // (no current packet, no replay frames). The cold SW produces no
1366            // frame from EOF alone, so the drain-to-EOF in `receive_frame`
1367            // escalates (`PostCommitNeverResynced`) unless a later keyframe-fed
1368            // poll resyncs first. A clean commit enters degraded mode; a SW-open
1369            // failure surfaces `FallbackFailed` and stays HW.
1370            tracing::warn!(
1371              backend = ?p.attempts().last().map(|(b, _)| *b),
1372              "mediadecode-ffmpeg: HW decode failed post-commit at EOF; falling \
1373               back to software — a bounded span of tail frames may be dropped",
1374            );
1375            // Both fallback roads forward the EOF inside their own
1376            // transaction, so a clean commit means it was recorded.
1377            // `true`: this *is* the end being sent. `eof_sent` is not
1378            // committed until the whole operation succeeds, so the
1379            // intent is passed locally rather than read back.
1380            self
1381              .degrade_to_sw(PostCommitInput::Eof, true)
1382              .map(|()| Sent::Accepted)
1383              .map_err(VideoDecodeError::Decode)
1384          } else {
1385            // Probe-era: replay the buffered history (lossless), re-forwarding
1386            // EOF inside the transaction.
1387            let rescued = p.into_unconsumed_packets();
1388            self
1389              .fall_back_to_sw(rescued, true)
1390              .map(|()| Sent::Accepted)
1391              .map_err(VideoDecodeError::Decode)
1392          }
1393        }
1394        Err(other) => Err(VideoDecodeError::Decode(other)),
1395      },
1396      DecodeState::Sw(sw) => {
1397        let st = sw.state();
1398        match sw.send_eof() {
1399          Ok(()) => Ok(Sent::Accepted),
1400          Err(e) => crate::decoder::software_send(st, e, phase).map_err(VideoDecodeError::Decode),
1401        }
1402      }
1403    };
1404    // Commit EOF state only when the EOF was actually **taken** — a failed
1405    // fallback left `self.eof_sent` untouched (restored-by-construction: we
1406    // never mutated it), so HW stays EOF-not-yet-sent and a retry behaves
1407    // correctly.
1408    //
1409    // **`is_ok()` is not the test any more, and that is not a stylistic
1410    // change.** `Ok(Sent::MustDrain)` means the decoder did not take the
1411    // end-of-stream; recording `eof_sent` there would make a later fallback
1412    // inject an EOF into the software decoder for a signal that was never
1413    // accepted — the exact half-mutation the local `eof_pending` argument
1414    // exists to prevent on the failure road.
1415    if matches!(outcome, Ok(Sent::Accepted)) {
1416      self.eof_sent = true;
1417    }
1418    outcome
1419  }
1420
1421  pub(crate) fn flush_impl(&mut self) -> Result<(), VideoDecodeError> {
1422    // Drop any frames buffered during SW fallback replay before
1423    // flushing the inner decoder — otherwise a seek/reset would
1424    // surface stale pre-flush frames on the next `receive_frame`.
1425    self.sw_replay_frames.clear();
1426    // And a parked frame belongs to the position being abandoned.
1427    self.scratch_pending = false;
1428    // Flush ends the drain phase; the decoder accepts new packets
1429    // after this, so reset EOF tracking.
1430    self.eof_sent = false;
1431    // A flush (seek/reset) re-anchors the stream — any in-flight post-commit
1432    // resync tracking from before the flush is moot. Clear it so the next EOF
1433    // doesn't escalate over a now-irrelevant pre-flush gap.
1434    self.clear_degraded_resync();
1435    match &mut self.state {
1436      // The HW seam's `flush` returns `Result` for a uniform trait; the
1437      // real `VideoDecoder::flush` is infallible (always `Ok`).
1438      DecodeState::Hw(hw) => hw.flush().map_err(VideoDecodeError::Decode)?,
1439      DecodeState::Sw(sw) => sw.flush(),
1440    }
1441    Ok(())
1442  }
1443}
1444
1445macro_rules! video_lane_face {
1446  ($($lane:ty),+ $(,)?) => { $(
1447    impl CarrierVideoStreamDecoder<$lane> {
1448      /// Opens a video decoder for `parameters`, probing hardware
1449      /// backends in order and falling back to software.
1450      pub fn open(
1451        parameters: Parameters,
1452        time_base: Timebase,
1453        limits: DecoderLimits,
1454      ) -> Result<Self, Error> {
1455        Self::open_impl(parameters, time_base, limits)
1456      }
1457
1458      /// Whether this decoder is currently running on software.
1459      pub const fn is_software(&self) -> bool {
1460        self.is_software_impl()
1461      }
1462
1463      /// Whether this decoder is currently running on hardware.
1464      pub const fn is_hardware(&self) -> bool {
1465        self.is_hardware_impl()
1466      }
1467
1468      /// The hardware wrapper, when one is in use.
1469      pub fn hardware_inner(&self) -> Option<&VideoDecoder> {
1470        self.hardware_inner_impl()
1471      }
1472
1473      /// The stream timebase every produced timestamp is stamped with.
1474      pub const fn time_base(&self) -> Timebase {
1475        self.time_base_impl()
1476      }
1477    }
1478
1479    impl VideoStreamDecoder for CarrierVideoStreamDecoder<$lane> {
1480      type Adapter = Ffmpeg;
1481      type Buffer = <$lane as crate::FfmpegCarrier>::Buffer;
1482      type Error = VideoDecodeError;
1483
1484      fn send_packet(
1485        &mut self,
1486        packet: &VideoPacket<VideoPacketExtra, Self::Buffer>,
1487      ) -> Result<Sent, Self::Error> {
1488        self.send_packet_impl(packet)
1489      }
1490
1491      fn receive_frame(
1492        &mut self,
1493        dst: &mut VideoFrame<mediadecode::PixelFormat, VideoFrameExtra, Self::Buffer>,
1494      ) -> Result<Received, Self::Error> {
1495        self.receive_frame_impl(dst)
1496      }
1497
1498      fn send_eof(&mut self) -> Result<Sent, Self::Error> {
1499        self.send_eof_impl()
1500      }
1501
1502      fn flush(&mut self) -> Result<(), Self::Error> {
1503        self.flush_impl()
1504      }
1505    }
1506  )+ };
1507}
1508
1509video_lane_face!(crate::View, crate::Owned);
1510
1511fn open_sw_decoder(parameters: &Parameters, limits: DecoderLimits) -> Result<SwDecoder, Error> {
1512  // Use the checked codec-context builder — ffmpeg-next's
1513  // `Context::from_parameters` calls `Context::new()` which doesn't
1514  // null-check `avcodec_alloc_context3`'s return value before
1515  // running `avcodec_parameters_to_context` against it. Under
1516  // memory pressure that's C-level UB; `build_codec_context`
1517  // surfaces the OOM as an error instead.
1518  let (ctx, callback_state) = build_codec_context(parameters, limits)?;
1519  // Opened without forming a bindgen enum from FFmpeg memory: the codec
1520  // is resolved off a raw `codec_id`, and the medium is proved off a raw
1521  // `codec_type`. See `crate::decoder::ensure_codec_type`.
1522  let codec = crate::decoder::find_decoder(parameters)?;
1523  let opened = ctx.decoder().open_as(codec).map_err(Error::Ffmpeg)?;
1524  crate::decoder::ensure_video_codec_type(&opened)?;
1525  Ok(SwDecoder {
1526    decoder: ffmpeg_next::decoder::Video(opened),
1527    _callback_state: callback_state,
1528  })
1529}
1530
1531/// Payload for [`VideoDecodeError::PostCommitNeverResynced`].
1532///
1533/// A **post-commit** HW->SW fallback degraded the stream (dropping the
1534/// bounded span up to the next keyframe) but the software decoder
1535/// reached EOF without ever producing a frame — it never resynced, so
1536/// the entire tail from the failure point was lost. The "bounded,
1537/// logged gap" the post-commit path promises did not materialise (no
1538/// keyframe arrived before EOF), so the loss is surfaced loudly here
1539/// instead of being silently swallowed as a clean end-of-stream.
1540#[derive(thiserror::Error, Debug)]
1541#[error(
1542  "post-commit HW->SW fallback never resynced before EOF: {packets_lost} packets fed to the \
1543   software decoder produced no frame (no keyframe found across the gap) — the stream tail \
1544   from the fallback point was lost"
1545)]
1546pub struct PostCommitNeverResynced {
1547  packets_lost: u64,
1548}
1549
1550impl PostCommitNeverResynced {
1551  /// Constructs a `PostCommitNeverResynced` payload.
1552  #[inline]
1553  pub const fn new(packets_lost: u64) -> Self {
1554    Self { packets_lost }
1555  }
1556  /// Packets fed to the software decoder across the unresolved resync
1557  /// gap.
1558  #[inline]
1559  pub const fn packets_lost(&self) -> u64 {
1560    self.packets_lost
1561  }
1562}
1563
1564/// Error type for [`FfmpegVideoStreamDecoder`] — **faults and the
1565/// send-side refusal**.
1566///
1567/// Every arm here is something that went wrong or something the push
1568/// face declined. The drain's *needs input* and *ended* are
1569/// [`Received`] states out of `receive_frame`; they used to arrive as
1570/// `Decode(Ffmpeg(Other { errno: EAGAIN }))` and `Decode(Ffmpeg(Eof))`,
1571/// which is to say they had no name at this tier at all.
1572/// [`Self::PostCommitNeverResynced`] is the deliberate exception on the
1573/// end-of-stream road: it is not "the stream ended", it is "the stream
1574/// ended and the tail was lost", which is a fault.
1575///
1576/// **Open fault taxonomy, so it is `#[non_exhaustive]`.** New ways to
1577/// fail are discovered — a backend, a ceiling, a corruption a codec
1578/// learns to report — and a consumer that meets one it has never heard
1579/// of should take its generic-fault path. That is exactly what the
1580/// wildcard arm this attribute forces is for. The two status
1581/// vocabularies opposite it,
1582/// [`Sent`](mediadecode::Sent) and [`Received`](mediadecode::Received),
1583/// are exhaustive for the mirror-image reason: their arms are the
1584/// substrate's fixed state set, and there the wildcard would be dead
1585/// weight hiding a state a consumer forgot.
1586#[derive(thiserror::Error, Debug, IsVariant, Unwrap, TryUnwrap)]
1587#[unwrap(ref, ref_mut)]
1588#[try_unwrap(ref, ref_mut)]
1589#[non_exhaustive]
1590pub enum VideoDecodeError {
1591  /// The wrapped decoder (HW or SW) reported an error.
1592  #[error(transparent)]
1593  Decode(#[from] Error),
1594  /// Frame conversion from FFmpeg's native types to mediadecode's
1595  /// types failed.
1596  #[error(transparent)]
1597  Convert(#[from] ConvertError),
1598  /// A **post-commit** HW->SW fallback degraded the stream but the
1599  /// software decoder reached EOF without ever producing a frame.
1600  #[error(transparent)]
1601  PostCommitNeverResynced(#[from] PostCommitNeverResynced),
1602}
1603
1604#[cfg(test)]
1605mod tests;