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,
88  decoder::{ScaledOutputCapability, VideoStreamDecoder},
89  frame::VideoFrame,
90  packet::VideoPacket,
91};
92
93use crate::{
94  Backend, DecoderLimits, Error, Ffmpeg, Frame, VideoDecoder, boundary,
95  convert::{self, ConvertError},
96  decoder::{build_codec_context, try_clone_parameters},
97  error::FallbackFailed,
98  extras::{VideoFrameExtra, VideoPacketExtra},
99  frame::alloc_av_video_frame,
100};
101
102/// Which decode path a video session takes — the choice
103/// [`CarrierVideoStreamDecoder::open_as`] is given.
104///
105/// # The arms differ in what they PERMIT, not only in where they start
106///
107/// [`Auto`](Self::Auto) is a preference: it starts on hardware and is
108/// free to end on software, at open or mid-stream. The other two are
109/// **pins**, and a pin that a mid-stream failure could quietly undo
110/// would not be one — so a session opened on either of them stays on
111/// the path it was opened on for its whole life, and a hardware failure
112/// that `Auto` would degrade through is reported instead.
113///
114/// That is the difference the two consumers of this door need. A
115/// determinism comparison decodes *one stream* both ways and compares
116/// the pixels; a run that silently swapped paths halfway would compare
117/// nothing and say it had. An operator turning hardware off for a lane
118/// over a driver that produces wrong pixels needs it to stay off.
119///
120/// # Observability is unchanged
121///
122/// [`is_hardware`](CarrierVideoStreamDecoder::is_hardware) and
123/// [`is_software`](CarrierVideoStreamDecoder::is_software) read where a
124/// session **is**, which stays a live reading — under
125/// [`Auto`](Self::Auto) it can still change once, and under the pins it
126/// answers what was pinned because nothing can move it.
127///
128/// This type deliberately grows **no** `is_*` predicates of its own,
129/// where most vocabularies in this crate do. They would spell the
130/// decoder's two questions a second time with a different meaning —
131/// `path.is_software()` is *what was asked for* and
132/// `decoder.is_software()` is *where it ended up*, and under
133/// [`Auto`](Self::Auto) those genuinely differ. A caller that needs to
134/// branch on the choice it made already holds the value and can
135/// `match` it.
136#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
137pub enum DecodePath {
138  /// Probe the platform's hardware backends in order and fall back to
139  /// software — at open, and again on a mid-stream hardware failure.
140  ///
141  /// What [`CarrierVideoStreamDecoder::open`] has always done, and what
142  /// it still does.
143  Auto,
144  /// **This hardware backend, or nothing.** No other backend is probed
145  /// and software is never opened.
146  ///
147  /// A backend that cannot be opened for the stream fails the
148  /// [`open_as`](CarrierVideoStreamDecoder::open_as) call. A backend
149  /// that opens and then fails to decode surfaces
150  /// [`Error::AllBackendsFailed`] from the send or receive road that
151  /// met it, carrying that backend and what it said — the same error
152  /// [`Auto`](Self::Auto) treats as its cue to degrade, reported here
153  /// because degrading is what this arm declines.
154  Hardware(Backend),
155  /// **Software, with no probe at all.**
156  ///
157  /// Opens `libavcodec`'s own decoder for the stream directly. There is
158  /// no hardware in this session to fail, so there is nothing for it to
159  /// fall back from — the terminal state [`Auto`](Self::Auto) reaches
160  /// by degrading, entered on purpose.
161  Software,
162}
163
164/// `mediadecode::VideoStreamDecoder` impl with transparent HW → SW
165/// fallback.
166pub struct CarrierVideoStreamDecoder<C: crate::FfmpegCarrier> {
167  state: DecodeState,
168  /// The path this session was opened on — see [`DecodePath`].
169  ///
170  /// Read for exactly one question, [`Self::may_open_software`]: whether
171  /// a hardware exhaustion is this session's cue to degrade or its cue
172  /// to report. Kept as the whole choice rather than reduced to that
173  /// bit so a session can say what it *is*, not only what it allows.
174  path: DecodePath,
175  /// Codec parameters retained so we can open a software
176  /// `ffmpeg::decoder::Video` if the HW probe exhausts.
177  parameters: Parameters,
178  /// HW-side scratch frame (filled by [`VideoDecoder::receive_frame`]).
179  hw_scratch: Frame,
180  /// SW-side scratch frame (filled by `ffmpeg::decoder::Video::receive_frame`).
181  sw_scratch: frame::Video,
182  /// Frames produced while draining the SW decoder during fallback
183  /// replay (see [`Self::fall_back_to_sw`]). The trait's
184  /// `receive_frame` delivers from this queue before pulling new
185  /// frames from the SW decoder. Empty in steady-state operation.
186  sw_replay_frames: VecDeque<frame::Video>,
187  /// Resource ceilings for the frames this decoder exports, and for the
188  /// `AVCodecContext`s it opens — HW candidates, the SW fallback, and
189  /// any decoder a later probe advance builds all get the same number.
190  limits: DecoderLimits,
191  /// `true` once `send_eof` has been called on the active decoder.
192  /// Used to propagate EOF to the SW decoder when fallback fires
193  /// during the drain phase — without this, codecs that hold tail
194  /// frames at EOF would hang waiting for an EOF they already saw on
195  /// the HW path.
196  eof_sent: bool,
197  /// `true` between a **post-commit** fallback firing and a *keyframe-anchored*
198  /// resync (the SW decoder delivering a frame **after** a keyframe was fed to
199  /// it across the gap). A post-commit fallback opens SW cold and drops the
200  /// bounded span up to the next keyframe; the promise is that the span is
201  /// *bounded* — SW resyncs at that keyframe. This flag makes the promise
202  /// enforced rather than assumed: while it is set we have no proof SW ever
203  /// recovered from a real keyframe. It is cleared only when SW delivers a frame
204  /// *and* [`Self::degraded_keyframe_seen`] is set (a lone concealed P-frame a
205  /// lenient codec emits from the gap does **not** clear it); if EOF is reached
206  /// while it is still set the loss is escalated (a distinct loud error) rather
207  /// than silently swallowing the whole tail. Probe-era fallbacks never set it —
208  /// they replay losslessly and produce frames immediately.
209  degraded_resync_pending: bool,
210  /// `true` once a **keyframe** packet has been successfully fed to the SW
211  /// decoder while [`Self::degraded_resync_pending`] is set — i.e. a real resync
212  /// anchor crossed the gap. The pending flag clears only on a delivered SW
213  /// frame *after* this is set, so a concealed non-keyframe frame (a lenient
214  /// codec decoding a lone P-frame from the dropped span) cannot masquerade as a
215  /// resync and prematurely clear the guard. Set alongside `enter`/cleared with
216  /// the pending flag.
217  degraded_keyframe_seen: bool,
218  /// Packets fed to the SW decoder since the post-commit fallback fired while
219  /// [`Self::degraded_resync_pending`] is set — i.e. across the unresolved
220  /// resync gap. Reported in the escalation message so the lost span is
221  /// quantified ("N packets, no keyframe found"). Reset whenever the flag
222  /// clears or on `flush`.
223  degraded_packets_since_fallback: u64,
224  /// Source-stream time base, used to label produced frames.
225  time_base: Timebase,
226  /// The lane this decoder captures into. A marker: the carrier
227  /// appears in the frames it produces, not in its own state.
228  /// `true` when the scratch frame holds a decoded frame whose
229  /// conversion has **not committed** — see
230  /// [`CarrierAudioStreamDecoder::scratch_pending`](crate::audio::CarrierAudioStreamDecoder)
231  /// for the reasoning, which is the same on both roads.
232  ///
233  /// **This decoder has two scratches and can change which one is
234  /// current, so the seat is enforced rather than merely recorded.**
235  /// While it is set, `send_packet` and `send_eof` answer
236  /// [`Sent::MustDrain`]: both are the roads that commit a
237  /// hardware-to-software fallback, and a fallback under a parked frame
238  /// would leave the retry reading the *other* scratch — delivering a
239  /// stale frame, or refusing permanently and stranding a decoded one.
240  /// Refusing makes the retry's state the state that parked it **by
241  /// construction**, which is a stronger guarantee than remembering
242  /// which road produced it.
243  ///
244  /// **The discipline is unchanged; only its spelling moved.** It was
245  /// `VideoDecodeError::FramePending`, and the escape was already
246  /// documented as "call `receive_frame`, or `flush` to abandon it" —
247  /// which is to say it was back pressure wearing an error's clothes.
248  /// Now it says so, and a caller can act on it without inspecting a
249  /// backend-specific error type. The subtitle decoder keeps the same
250  /// seat one road over, spelled the same way.
251  scratch_pending: bool,
252  _carrier: core::marker::PhantomData<C>,
253}
254
255/// Hardware-decode seam behind [`DecodeState::Hw`]. In production this is
256/// the real [`VideoDecoder`]; tests substitute a fake to drive the
257/// post-commit fallback path without a live GPU. Mirrors the subset of
258/// `VideoDecoder`'s surface the wrapper drives on the HW path.
259pub(crate) trait HwInner: Send {
260  /// See [`VideoDecoder::send_packet`].
261  fn send_packet(&mut self, packet: &Packet) -> Result<Sent, Error>;
262  /// See [`VideoDecoder::receive_frame`].
263  fn receive_frame(&mut self, frame: &mut Frame) -> Result<Received, Error>;
264  /// See [`VideoDecoder::send_eof`].
265  fn send_eof(&mut self) -> Result<Sent, Error>;
266  /// See [`VideoDecoder::flush`]. Returns `Result` for a uniform seam even
267  /// though the inherent method is infallible.
268  fn flush(&mut self) -> Result<(), Error>;
269  /// Downcast to the concrete [`VideoDecoder`] when this seam is the real
270  /// HW decoder, so [`FfmpegVideoStreamDecoder::hardware_inner`] can keep
271  /// exposing it. Returns `None` for a test fake.
272  fn as_video_decoder(&self) -> Option<&VideoDecoder>;
273
274  /// Whether a packet submitted **now** would be recorded for replay.
275  ///
276  /// The probe keeps a rescue history so that a decoder which exhausts
277  /// every backend can hand the caller everything FFmpeg consumed since
278  /// open. It records by `av_packet_ref`, and
279  /// [`AllBackendsFailed::into_unconsumed_packets`] hands those
280  /// recordings out as owned, **mutable** `Packet`s — which is why the
281  /// view lane must not share its carrier's storage into a submission
282  /// that could be recorded. See
283  /// [`CarrierVideoStreamDecoder::send_packet_impl`].
284  fn records_submissions(&self) -> bool;
285
286  /// See [`VideoDecoder::scaled_output_capability`].
287  ///
288  /// Defaulted to the refusal so a test fake — which has no
289  /// VideoToolbox road behind it, and therefore no stage — answers
290  /// honestly without having to say so.
291  fn scaled_output_capability(&self) -> ScaledOutputCapability {
292    ScaledOutputCapability::Unsupported
293  }
294
295  /// See [`VideoDecoder::request_scaled_output`]. Defaulted to the
296  /// refusal, for the same reason as above.
297  fn request_scaled_output(&mut self, size: (u32, u32)) -> ScaledOutputCapability {
298    let _ = size;
299    ScaledOutputCapability::Unsupported
300  }
301
302  /// See [`VideoDecoder::cancel_scaled_output`]. Defaulted to nothing,
303  /// because a seat that never accepts a request has none to withdraw.
304  fn cancel_scaled_output(&mut self) {}
305}
306
307impl HwInner for VideoDecoder {
308  #[inline]
309  fn records_submissions(&self) -> bool {
310    self.is_probing()
311  }
312
313  #[inline]
314  fn send_packet(&mut self, packet: &Packet) -> Result<Sent, Error> {
315    VideoDecoder::send_packet(self, packet)
316  }
317  #[inline]
318  fn receive_frame(&mut self, frame: &mut Frame) -> Result<Received, Error> {
319    VideoDecoder::receive_frame(self, frame)
320  }
321  #[inline]
322  fn send_eof(&mut self) -> Result<Sent, Error> {
323    VideoDecoder::send_eof(self)
324  }
325  #[inline]
326  fn flush(&mut self) -> Result<(), Error> {
327    VideoDecoder::flush(self);
328    Ok(())
329  }
330  #[inline]
331  fn as_video_decoder(&self) -> Option<&VideoDecoder> {
332    Some(self)
333  }
334  #[inline]
335  fn scaled_output_capability(&self) -> ScaledOutputCapability {
336    VideoDecoder::scaled_output_capability(self)
337  }
338  #[inline]
339  fn request_scaled_output(&mut self, size: (u32, u32)) -> ScaledOutputCapability {
340    VideoDecoder::request_scaled_output(self, size)
341  }
342  #[inline]
343  fn cancel_scaled_output(&mut self) {
344    VideoDecoder::cancel_scaled_output(self);
345  }
346}
347
348/// Internal: which backend is currently driving the decode.
349enum DecodeState {
350  /// Hardware-backed decoder (auto-probe). May transition to `Sw` on
351  /// `AllBackendsFailed`. Boxed behind [`HwInner`] so tests can inject a
352  /// fake HW decoder.
353  Hw(Box<dyn HwInner>),
354  /// Software decoder. Terminal state.
355  Sw(SwDecoder),
356}
357
358/// A software decoder and the callback state its codec context points
359/// at.
360///
361/// The state carries the allocator judge's byte budget and the
362/// `get_format` declination; it has to outlive the `AVCodecContext`
363/// that references it, which is why it is a field here rather than a
364/// value dropped at the end of `open_sw_decoder`.
365///
366/// `Deref` so that every call site keeps talking to the decoder and
367/// only the construction changed — this pairing is a lifetime fact, not
368/// a new abstraction.
369pub(crate) struct SwDecoder {
370  decoder: ffmpeg_next::decoder::Video,
371  /// Declared **after** the decoder: fields drop in declaration order,
372  /// so the codec context is freed before the state it points at.
373  _callback_state: Box<crate::ffi::CallbackState>,
374}
375
376impl SwDecoder {
377  /// The callback state this decoder's codec context points at.
378  ///
379  /// Handed out as a raw pointer so an error closure can consult it
380  /// while the decoder itself is mutably borrowed — every software send
381  /// / receive / EOF failure on this road goes through
382  /// [`crate::decoder::software_exit`] with it, so a frame the
383  /// allocator judge refused surfaces named instead of as the `EINVAL`
384  /// libavcodec also uses for corrupt input.
385  ///
386  /// `Deref` alone was not enough: it exposes the decoder and hides the
387  /// state, so every call site kept wrapping raw and the budget refusal
388  /// had no way out on the whole software road — including the replay
389  /// and cold-fallback helpers, which drop the state when they finish.
390  pub(crate) fn state(&self) -> *const crate::ffi::CallbackState {
391    &*self._callback_state
392  }
393}
394
395impl core::ops::Deref for SwDecoder {
396  type Target = ffmpeg_next::decoder::Video;
397  fn deref(&self) -> &Self::Target {
398    &self.decoder
399  }
400}
401
402impl core::ops::DerefMut for SwDecoder {
403  fn deref_mut(&mut self) -> &mut Self::Target {
404    &mut self.decoder
405  }
406}
407
408/// What the cold SW decoder is fed on a **post-commit** degrade transition,
409/// named by the failure arm so the three shapes stay mutually exclusive (a
410/// current packet and EOF are never forwarded together). The post-commit path
411/// retains no replay frames, so this is the *only* thing handed to the new SW
412/// decoder at fallback time. See [`FfmpegVideoStreamDecoder::degrade_to_sw`].
413enum PostCommitInput<'a> {
414  /// `send_packet` arm: forward this current packet — the one the HW decoder
415  /// refused (so it was never in any replay set). If it is a keyframe it is the
416  /// resync anchor.
417  Packet(&'a Packet),
418  /// `receive_frame` arm: a frame-time failure has no current packet to forward.
419  FrameTime,
420  /// `send_eof` arm: EOF was pending on the HW path; re-forward it to the cold
421  /// SW so tail-delaying codecs don't hang.
422  Eof,
423}
424
425impl<C: crate::FfmpegCarrier + crate::CarrierOps> CarrierVideoStreamDecoder<C> {
426  /// Opens a decoder for the given codec parameters with the default
427  /// HW backend probe order. If the HW probe can't open any backend,
428  /// falls back to a software `ffmpeg::decoder::Video` immediately —
429  /// `open` only returns `Err` when both paths fail.
430  ///
431  /// Subsequent mid-stream `AllBackendsFailed` from the HW path
432  /// triggers the same SW fallback (with rescued packets replayed).
433  ///
434  /// `limits` bounds what one decoded frame may cost. It is taken here
435  /// rather than through a builder because half of it —
436  /// [`DecoderLimits::max_pixels`] — is written into every
437  /// `AVCodecContext` this decoder opens, and a context's ceiling
438  /// cannot be moved after `avcodec_open2`. That includes the contexts
439  /// opened later, by a mid-stream fallback or a probe advance: the
440  /// limits are retained for exactly that reason.
441  pub(crate) fn open_impl(
442    parameters: Parameters,
443    time_base: Timebase,
444    limits: DecoderLimits,
445  ) -> Result<Self, Error> {
446    Self::open_as_impl(parameters, time_base, limits, DecodePath::Auto)
447  }
448
449  /// [`Self::open_impl`], with the decode path chosen rather than
450  /// probed. `DecodePath::Auto` is the constructor above, verbatim.
451  pub(crate) fn open_as_impl(
452    parameters: Parameters,
453    time_base: Timebase,
454    limits: DecoderLimits,
455    path: DecodePath,
456  ) -> Result<Self, Error> {
457    // ffmpeg-next's `Parameters` carries an optional `owner: Rc<dyn Any>`
458    // (when constructed from `stream.parameters()` it points back at
459    // the demuxer's `AVStream`). Upstream marks the type `Send`
460    // anyway, which is unsound the moment a non-`None` owner is in
461    // play — moving such a value across threads moves the `Rc`. We
462    // sidestep this by always storing a deep-cloned `Parameters`
463    // (`avcodec_parameters_copy` produces an owner-free copy), so
464    // the `FfmpegVideoStreamDecoder`'s `Send` reachability never
465    // depends on the caller's owner discipline.
466    //
467    // Use `try_clone_parameters` instead of `Parameters::clone` —
468    // ffmpeg-next's `clone` calls `Parameters::new()` which can
469    // return a `Parameters` whose inner pointer is null on OOM
470    // (`avcodec_parameters_alloc` returns null without indication);
471    // the subsequent `avcodec_parameters_copy` against that null
472    // destination is C UB. Our checked helper surfaces the OOM as
473    // an error instead.
474    let owned_parameters = try_clone_parameters(&parameters, limits.max_codec_parameter_bytes())?;
475    let hw_scratch = Frame::empty()?;
476    let sw_scratch = alloc_av_video_frame()?;
477    let state = match path {
478      DecodePath::Auto => match VideoDecoder::open_with_frame_limits_timed(
479        try_clone_parameters(&owned_parameters, limits.max_codec_parameter_bytes())?,
480        limits,
481        time_base,
482      ) {
483        Ok(hw) => DecodeState::Hw(Box::new(hw)),
484        Err(Error::AllBackendsFailed(_)) => {
485          // Open-time HW exhaustion: no rescued packets (open didn't
486          // see any). Just open SW directly from our owned copy.
487          let sw = open_sw_decoder(&owned_parameters, limits, Some(time_base))?;
488          DecodeState::Sw(sw)
489        }
490        Err(other) => return Err(other),
491      },
492      // **The named backend, and no probe order at all.** Nothing is
493      // tried before it and nothing after it, which is what makes the
494      // arm a pin: an open that fails is the answer, where `Auto` would
495      // have read the same failure as a reason to look elsewhere.
496      DecodePath::Hardware(backend) => {
497        DecodeState::Hw(Box::new(VideoDecoder::open_with_limits_timed(
498          try_clone_parameters(&owned_parameters, limits.max_codec_parameter_bytes())?,
499          backend,
500          limits,
501          time_base,
502        )?))
503      }
504      // The software decoder, opened on purpose rather than reached by
505      // degrading. `DecodeState::Sw` is terminal, so this session has
506      // nothing to keep it on its path but the shape of the state
507      // machine itself.
508      DecodePath::Software => {
509        DecodeState::Sw(open_sw_decoder(&owned_parameters, limits, Some(time_base))?)
510      }
511    };
512    Ok(Self {
513      state,
514      path,
515      parameters: owned_parameters,
516      hw_scratch,
517      sw_scratch,
518      sw_replay_frames: VecDeque::new(),
519      eof_sent: false,
520      degraded_resync_pending: false,
521      degraded_keyframe_seen: false,
522      degraded_packets_since_fallback: 0,
523      time_base,
524      limits,
525      scratch_pending: false,
526      _carrier: core::marker::PhantomData,
527    })
528  }
529
530  /// Returns `true` when this decoder has fallen back to the software
531  /// path. `false` while still on the HW probe (the initial state).
532  #[cfg_attr(not(tarpaulin), inline(always))]
533  pub(crate) const fn is_software_impl(&self) -> bool {
534    matches!(self.state, DecodeState::Sw(_))
535  }
536
537  /// Returns `true` while the HW probe is still active.
538  #[cfg_attr(not(tarpaulin), inline(always))]
539  pub(crate) const fn is_hardware_impl(&self) -> bool {
540    matches!(self.state, DecodeState::Hw(_))
541  }
542
543  /// Whether this session can currently honor a
544  /// [`Self::request_scaled_output_impl`] request. See
545  /// [`ScaledOutputCapability`] for the determinism trade a caller
546  /// takes on by requesting one.
547  ///
548  /// **[`ScaledOutputCapability::Supported`] on exactly one road: a
549  /// live VideoToolbox session on an Apple target.** There, a
550  /// `VTPixelTransferSession` sits between the decoded hardware frame
551  /// and `av_hwframe_transfer_data` and resizes the `CVPixelBuffer` on
552  /// the GPU, so the fitted picture is what crosses to the CPU — see
553  /// [`crate::vtscale`] for the design, and
554  /// [mediadecode#55](https://github.com/findit-studio/mediadecode/issues/55)
555  /// for the ruling that chose it. Everything else answers
556  /// `Unsupported`, and each refusal has its own reason rather than a
557  /// shared shrug:
558  ///
559  /// - **A session that has degraded to software.** The stage is the
560  ///   hardware road's; this answer follows the session, so it flips to
561  ///   `Unsupported` the moment a fallback commits, and a caller that
562  ///   asks again learns it.
563  /// - **The other hardware backends.** [`Backend::Vaapi`],
564  ///   [`Backend::Cuda`] and [`Backend::D3d11va`] are wired in source
565  ///   (`Backend::av_hwdevice_type`, `probe_order`) but cannot be
566  ///   compiled, run or verified on a non-Linux, non-Windows host, and
567  ///   each has a native scaling seam of its own that this crate has
568  ///   not built: NVDEC/CUVID in-decode scaling
569  ///   ([#56](https://github.com/findit-studio/mediadecode/issues/56)),
570  ///   VAAPI VPP
571  ///   ([#57](https://github.com/findit-studio/mediadecode/issues/57)),
572  ///   the D3D11 Video Processor
573  ///   ([#58](https://github.com/findit-studio/mediadecode/issues/58)).
574  ///   Filed rather than fabricated.
575  /// - **Software.** See [`Self::request_scaled_output_impl`] for the
576  ///   software road's own, separate refusal.
577  ///
578  /// What the VideoToolbox road did **not** get is decode-time
579  /// scaling, and the distinction is worth keeping: inter prediction
580  /// needs full-resolution reference frames, so every road decodes full
581  /// size internally. What this seam saves is the GPU→CPU crossing and
582  /// the CPU frame at the end of it — roughly thirtyfold on a 4K stream
583  /// fitted to a 512-class box. A caller-owned `VTDecompressionSession`
584  /// would save the same crossing and no more, which is why it stays
585  /// #55's standing future enhancement rather than this release's work.
586  ///
587  /// A pure query: calling it requests nothing and changes nothing
588  /// about what [`Self::receive_frame`] delivers.
589  #[cfg_attr(not(tarpaulin), inline(always))]
590  pub(crate) fn scaled_output_capability_impl(&self) -> ScaledOutputCapability {
591    match &self.state {
592      DecodeState::Hw(hw) => hw.scaled_output_capability(),
593      DecodeState::Sw(_) => ScaledOutputCapability::Unsupported,
594    }
595  }
596
597  /// Requests that this session emit pictures at `size` from the next
598  /// frame on. See [`Self::scaled_output_capability_impl`] for which
599  /// road can honor it at all.
600  ///
601  /// # A parked frame refuses
602  ///
603  /// While [`Self::scratch_pending`] holds a decoded picture whose
604  /// conversion did not commit, this refuses. That frame is already
605  /// decided — the retry delivers it from the scratch without
606  /// consulting the stage — so accepting a new size would promise an
607  /// extent the very next frame cannot have. Drain it and ask again;
608  /// the same escape every other seat guarded by that flag offers.
609  ///
610  /// The refusal carries the same meaning as every other: the session
611  /// returns to full coded size, so any request already standing is
612  /// withdrawn. The parked picture keeps the extent it was decoded at.
613  ///
614  /// # When a mid-stream request takes effect
615  ///
616  /// On the **next** picture [`Self::receive_frame`] produces. The
617  /// stage is consulted per frame, on the way out of the hardware
618  /// decoder and before the GPU→CPU download, so a request never
619  /// reaches back to a picture already decoded and never waits longer
620  /// than the one being decoded now.
621  ///
622  /// # The two refusals this seat mints itself
623  ///
624  /// Neither is an error, and each **returns the session to full coded
625  /// size**, dropping any request already standing — what the trait
626  /// says this answer means, and the only reading a caller can act on
627  /// without risking a second resample of an already-fitted picture:
628  ///
629  /// - **A zero extent.** A zero-extent picture is not a smaller
630  ///   picture.
631  /// - **An upscale.** The stage exists to move fewer bytes across the
632  ///   GPU→CPU bus; enlarging moves more, and inventing detail the
633  ///   decoder did not produce is the caller's business rather than a
634  ///   decode session's. An *equal* size is not an upscale: it is
635  ///   accepted, and the stage simply has nothing to do.
636  ///
637  /// # The software road's refusal has its own, different shape
638  ///
639  /// Worth naming rather than folding into "no backend does this yet":
640  /// FFmpeg's software decoders have no *general* decode-time scaling
641  /// seam. The one option that comes close — `AVCodecContext.lowres`
642  /// (the CLI's `-lowres`) — falls short on three separate counts, any
643  /// one of which would disqualify it as this seam's software answer:
644  ///
645  /// 1. **Narrow codec coverage.** `lowres` is wired only into the
646  ///    legacy MPEG-family decoders (MPEG-1/2/4 part 2, H.263) that
647  ///    still carry the low-resolution IDCT machinery it depends on.
648  ///    HEVC, AV1 and VP9 — the codecs a modern HDR pipeline actually
649  ///    decodes — implement no `lowres` support at all.
650  /// 2. **The one codec that is wired is broken.** `lowres` on H.264
651  ///    (also nominally covered) has been non-functional for years —
652  ///    the decoder does not honor it correctly — so even the "old
653  ///    family" half of the promise does not hold across the board.
654  /// 3. **It is not a resize, it is reduced reconstruction.** Where it
655  ///    does work, `lowres` decodes at a coarser IDCT precision
656  ///    (`1<<lowres`), skipping reconstruction detail rather than
657  ///    decoding in full and scaling the result — later inter frames
658  ///    drift from a reference the decoder itself degraded, which is a
659  ///    different (and worse) contract than "the same picture, smaller".
660  ///
661  /// So the software road's answer is not "unimplemented" the way the
662  /// other hardware backends' is — it is "full-size decode, then the
663  /// fused conform walk downstream", by design, on every codec this
664  /// crate decodes in software.
665  #[cfg_attr(not(tarpaulin), inline(always))]
666  pub(crate) fn request_scaled_output_impl(&mut self, size: (u32, u32)) -> ScaledOutputCapability {
667    // **A parked frame is already decided, so it may not be
668    // re-promised.** [`Self::scratch_pending`] means a picture came out
669    // of the decoder and its conversion did not commit; the retry
670    // delivers *that* frame from the scratch without going back through
671    // the stage, so it will arrive at whatever extent it already has.
672    // Accepting a new size here would answer `Supported` and then hand
673    // the caller a frame the new request never touched — the exact
674    // silent mismatch [`Self::scaled_output_capability_impl`]'s promise
675    // exists to rule out. Refusing changes nothing, which is the
676    // contract for a refusal, and the caller's escape is the one this
677    // seat already documents everywhere else: drain the frame, then ask
678    // again.
679    if self.scratch_pending {
680      tracing::debug!(
681        requested_width = size.0,
682        requested_height = size.1,
683        "mediadecode-ffmpeg: scaled-output request refused while a decoded frame is parked; \
684         the session returns to full size — drain it and ask again"
685      );
686      // **A refusal means the same thing here as anywhere else.**
687      // Returning `Unsupported` while an earlier request stayed armed
688      // would leave the caller resampling pictures this session went on
689      // fitting — the very double-scale the word exists to prevent. So
690      // the standing request goes, and with it what was built for it.
691      // The parked picture keeps the extent it was decoded at, which is
692      // the same rule an *acceptance* has always carried.
693      if let DecodeState::Hw(hw) = &mut self.state {
694        hw.cancel_scaled_output();
695      }
696      return ScaledOutputCapability::Unsupported;
697    }
698    match &mut self.state {
699      DecodeState::Hw(hw) => hw.request_scaled_output(size),
700      DecodeState::Sw(_) => ScaledOutputCapability::Unsupported,
701    }
702  }
703
704  /// Borrow the inner [`VideoDecoder`] when this decoder is still on the
705  /// real HW path. Returns `None` after the SW fallback has fired (or, in
706  /// tests, when the HW seam is a fake rather than a real decoder).
707  #[cfg_attr(not(tarpaulin), inline(always))]
708  pub(crate) fn hardware_inner_impl(&self) -> Option<&VideoDecoder> {
709    match &self.state {
710      DecodeState::Hw(hw) => hw.as_video_decoder(),
711      DecodeState::Sw(_) => None,
712    }
713  }
714
715  /// Returns the time base associated with the source stream.
716  #[cfg_attr(not(tarpaulin), inline(always))]
717  pub(crate) const fn time_base_impl(&self) -> Timebase {
718    self.time_base
719  }
720
721  /// Whether this session may open a software decoder in answer to a
722  /// hardware exhaustion.
723  ///
724  /// **The one place the pin is enforced**, consulted by all three
725  /// roads that can meet [`Error::AllBackendsFailed`] — the two send
726  /// arms and the receive arm. It is one predicate rather than three
727  /// conditions because the pin is one promise: a session opened on
728  /// [`DecodePath::Hardware`] ends on hardware or ends in an error, and
729  /// a road that forgot to ask would break that promise silently,
730  /// which is the failure mode a caller cannot see.
731  ///
732  /// [`DecodePath::Software`] answers `true` and it costs nothing:
733  /// `DecodeState::Sw` is terminal, so no hardware exhaustion can
734  /// reach a road that asks. Answering for it by state rather than by
735  /// pin would make the predicate say something it does not mean.
736  #[cfg_attr(not(tarpaulin), inline(always))]
737  const fn may_open_software(&self) -> bool {
738    !matches!(self.path, DecodePath::Hardware(_))
739  }
740
741  /// Internal: **probe-era** transition from HW to SW. Replays the rescued
742  /// packets (the inner decoder's buffered history, already accepted by the HW
743  /// probe but not yet decoded) through the new SW decoder so the stream resumes
744  /// seamlessly. No frame was delivered on the HW path yet, so replaying the
745  /// history is lossless.
746  ///
747  /// Only the probe-era branches drive this. The **post-commit** path does
748  /// *not* — it retains and reconstructs zero frames, opening SW cold via
749  /// [`Self::degrade_to_sw`] and resyncing at the next keyframe instead of
750  /// replaying. (That is why this method's replay/drain machinery — and the
751  /// finding that the in-transaction drain doesn't cover later frame
752  /// *conversion* — cannot affect the post-commit path: it never produces a
753  /// post-commit replay frame to convert.)
754  ///
755  /// **Transactional**: drained replay frames accumulate in a local
756  /// queue; we only commit them to `self.sw_replay_frames` and switch
757  /// `self.state` to `Sw` after the replay (and EOF re-forwarding, if
758  /// needed) succeed. On failure, the SW decoder, the local frame
759  /// queue, and (where reachable) any consumed packets are dropped —
760  /// `self` is left in its prior state.
761  ///
762  /// **EOF-aware**: when EOF was already accepted on the HW path
763  /// (`self.eof_sent`), the new SW decoder also receives `send_eof()`
764  /// after replay. Without this, codecs that delay tail frames hang
765  /// forever in the drain phase.
766  ///
767  /// **EAGAIN-aware**: if SW's `send_packet` returns EAGAIN during
768  /// replay, drain produced frames into the local queue and retry.
769  ///
770  /// `eof_pending` is passed as a **local** argument rather than read from
771  /// `self.eof_sent`: callers must not mutate `self.eof_sent` before this
772  /// transaction commits (see [`VideoStreamDecoder::send_eof`]), so the
773  /// in-transaction SW EOF re-forward keys off the local flag and `self`'s
774  /// EOF state is updated only after a clean commit.
775  fn fall_back_to_sw(
776    &mut self,
777    unconsumed_packets: std::vec::Vec<ffmpeg_next::Packet>,
778    eof_pending: bool,
779  ) -> Result<(), Error> {
780    tracing::info!(
781      packets_replayed = unconsumed_packets.len(),
782      eof_pending,
783      "mediadecode-ffmpeg: HW probe exhausted, falling back to software decode",
784    );
785    // Wrap the internal worker so any failure path returns the
786    // rescued packets to the caller via `Error::FallbackFailed`.
787    // Without this, non-seekable streams (live feeds, pipes) would
788    // lose every compressed byte the HW path had consumed when a
789    // fallback transition fails partway.
790    match self.fall_back_to_sw_inner(&unconsumed_packets, eof_pending) {
791      Ok(()) => Ok(()),
792      Err(source) => Err(Error::FallbackFailed(FallbackFailed::new(
793        Box::new(source),
794        unconsumed_packets,
795      ))),
796    }
797  }
798
799  /// Worker for [`Self::fall_back_to_sw`]. Returns the rescued packets
800  /// untouched on the borrowed slice; the wrapper takes ownership of
801  /// them and surfaces them in `FallbackFailed` if this returns Err.
802  fn fall_back_to_sw_inner(
803    &mut self,
804    unconsumed_packets: &[ffmpeg_next::Packet],
805    eof_pending: bool,
806  ) -> Result<(), Error> {
807    let mut sw = open_sw_decoder(&self.parameters, self.limits, Some(self.time_base))?;
808    // Bound before the decoder is mutably borrowed, so the error
809    // closures below can still consult it.
810    let sw_state = sw.state();
811    let mut local_replay: VecDeque<frame::Video> = VecDeque::new();
812    // Helper: drain SW into the local replay queue, capped at
813    // `SW_REPLAY_FRAME_CAP`.
814    //
815    // Error discipline: stop the drain **only** on the transient
816    // backpressure signals EAGAIN / EOF (the decoder has no more output for
817    // now). Every other `ffmpeg_next::Error` — e.g. `InvalidData` from a
818    // corrupt replayed packet — is a real decode failure and is propagated,
819    // so a non-recoverable error surfaces as `FallbackFailed` (carrying the
820    // replay packets) instead of being silently swallowed and the fallback
821    // committed over corruption.
822    fn drain_into(
823      sw: &mut ffmpeg_next::decoder::Video,
824      state: *const crate::ffi::CallbackState,
825      local_replay: &mut VecDeque<frame::Video>,
826    ) -> std::result::Result<(), Error> {
827      loop {
828        let mut tmp = alloc_av_video_frame()?;
829        match sw.receive_frame(&mut tmp) {
830          Ok(()) => {
831            if local_replay.len() >= SW_REPLAY_FRAME_CAP {
832              tracing::error!(
833                cap = SW_REPLAY_FRAME_CAP,
834                "mediadecode-ffmpeg: SW fallback replay produced more frames than the \
835                 replay cap allows; aborting fallback (no frames dropped — they're \
836                 still in the SW decoder's internal queue and will be released when \
837                 it drops)",
838              );
839              return Err(Error::Ffmpeg(ffmpeg_next::Error::Other {
840                errno: libc::ENOMEM,
841              }));
842            }
843            local_replay.push_back(tmp);
844          }
845          // EAGAIN / EOF: no more output for now — stop draining, success.
846          Err(ffmpeg_next::Error::Other { errno }) if errno == ffmpeg_next::error::EAGAIN => {
847            break;
848          }
849          Err(ffmpeg_next::Error::Eof) => break,
850          // Any other error is a genuine decode failure on a replayed
851          // packet — surface it so it is not masked as a clean fallback.
852          Err(other) => return Err(crate::decoder::software_exit(state, other)),
853        }
854      }
855      Ok(())
856    }
857
858    for pkt in unconsumed_packets {
859      let mut attempts: u32 = 0;
860      loop {
861        match sw.send_packet(pkt) {
862          Ok(()) => break,
863          Err(ffmpeg_next::Error::Other { errno }) if errno == ffmpeg_next::error::EAGAIN => {
864            drain_into(&mut sw, sw_state, &mut local_replay)?;
865            attempts += 1;
866            if attempts > 16 {
867              return Err(Error::Ffmpeg(ffmpeg_next::Error::Other {
868                errno: ffmpeg_next::error::EAGAIN,
869              }));
870            }
871          }
872          Err(other) => return Err(crate::decoder::software_exit(sw_state, other)),
873        }
874      }
875    }
876    // Re-forward EOF if the HW path already saw it. SW EOF can also
877    // return EAGAIN until prior output is drained — mirror the
878    // packet-replay loop.
879    if eof_pending {
880      let mut attempts: u32 = 0;
881      loop {
882        match sw.send_eof() {
883          Ok(()) => break,
884          Err(ffmpeg_next::Error::Other { errno }) if errno == ffmpeg_next::error::EAGAIN => {
885            drain_into(&mut sw, sw_state, &mut local_replay)?;
886            attempts += 1;
887            if attempts > 16 {
888              return Err(Error::Ffmpeg(ffmpeg_next::Error::Other {
889                errno: ffmpeg_next::error::EAGAIN,
890              }));
891            }
892          }
893          Err(other) => return Err(crate::decoder::software_exit(sw_state, other)),
894        }
895      }
896    }
897    // Final drain BEFORE commit — the transactional commit boundary. The
898    // EAGAIN-triggered drains above only fire when SW exerts backpressure mid
899    // replay; a SW decoder that ACCEPTS every replayed packet (and the EOF)
900    // without one then surfaces a non-transient error — `InvalidData` from a
901    // corrupt replayed packet, or any other decode failure — only on the *next*
902    // `receive_frame`. Without this drain that error would land after the
903    // commit (frames appended, `state` flipped to `Sw`, rescued packets
904    // dropped) and reach the caller as a plain decode failure, not
905    // `FallbackFailed` — breaking probe-era recovery on non-seekable input.
906    // Draining to EAGAIN/EOF here forces any such error to surface now, so it is
907    // wrapped as `FallbackFailed` (retaining the rescued packets) and the
908    // decoder stays on HW — nothing is committed. (Only the probe-era path
909    // reaches this; the post-commit path degrades via `degrade_to_sw` and never
910    // replays, so it has no drained frames to commit or convert.)
911    drain_into(&mut sw, sw_state, &mut local_replay)?;
912    // Commit: only after replay, any EOF forwarding, AND the final drain
913    // succeeded do we move the new SW decoder and queue into `self`.
914    self.sw_replay_frames.append(&mut local_replay);
915    self.state = DecodeState::Sw(sw);
916    Ok(())
917  }
918
919  /// **Post-commit** degrade-and-continue transition: open the SW decoder
920  /// **cold** and forward only the failure-arm's input, retaining and
921  /// reconstructing **zero** frames. This is the whole post-commit path: open
922  /// SW, forward the current packet (or EOF), degrade-track — nothing is drained
923  /// into `sw_replay_frames`, so there is no replayed frame to convert later and
924  /// no terminal-drain transaction to reason about. SW naturally produces no
925  /// frame until the next keyframe arrives across the gap, then decodes normally;
926  /// the failure-point→next-keyframe span is the accepted, logged drop.
927  ///
928  /// **Transactional (SW-open only)**: `self.state` flips to `Sw` *only after*
929  /// `open_sw_decoder` and the input forward succeed. On any failure the new SW
930  /// decoder is dropped and the decoder is left on its prior HW state, the error
931  /// surfaced as [`Error::FallbackFailed`] (with an empty rescue set — a
932  /// post-commit failure never carries unconsumed packets). With no replay-frame
933  /// retention there is nothing else to roll back.
934  ///
935  /// On a clean commit it enters degraded-resync mode (see
936  /// [`Self::enter_degraded_resync`]); if the forwarded current packet is itself
937  /// a keyframe, the resync anchor is recorded immediately
938  /// ([`Self::note_degraded_keyframe`]).
939  ///
940  /// # `eof_pending`
941  ///
942  /// Whether the session's end-of-stream has already been **committed**,
943  /// and so must be re-forwarded into the cold decoder. Carried as a
944  /// local argument for the same two reasons the probe-era road carries
945  /// it (see [`Self::fall_back_to_sw`]): it is read from `eof_sent`
946  /// before anything is mutated, so a fallback that fails leaves no
947  /// half-truth behind — and one question deserves one mechanism on
948  /// both fallback roads.
949  ///
950  /// It is **not** expressed by selecting [`PostCommitInput::Eof`],
951  /// even though that arm forwards the same call. That enum is named by
952  /// the *failure arm* — which road raised the exhaustion — and the
953  /// `warn!` each site emits says so; borrowing the EOF arm for a
954  /// frame-time failure would make it lie about where the failure came
955  /// from.
956  fn degrade_to_sw(&mut self, input: PostCommitInput<'_>, eof_pending: bool) -> Result<(), Error> {
957    match self.degrade_to_sw_inner(input, eof_pending) {
958      Ok(()) => Ok(()),
959      // **A budget refusal is not a fallback failure.** It travels
960      // unwrapped, and the spelling was chosen rather than inherited:
961      //
962      // * `FallbackFailed` means the fallback *machinery* could not
963      //   complete, and its contract is to hand back the unconsumed
964      //   packets so a caller can re-drive them. On this road that set
965      //   is empty by construction — the probe buffer is gone and no
966      //   replay frames are retained — so the envelope carries no
967      //   recovery affordance at all, only a label.
968      // * And the label is the wrong one. Re-driving is the natural
969      //   response to a fallback failure, and re-driving a budget
970      //   refusal under the same limits refuses identically. Naming it
971      //   a fallback failure invites an action that cannot succeed,
972      //   while `FrameBudgetExceeded` names the one that can: raise
973      //   the ceiling, or accept the refusal.
974      //
975      // So it keeps the same spelling here as on every other road. One
976      // fact, one name.
977      Err(budget @ Error::FrameBudgetExceeded(_)) => Err(budget),
978      // Everything else really is the machinery failing, and keeps the
979      // envelope — empty rescue set and all, which is what a
980      // post-commit failure has to hand back.
981      Err(source) => Err(Error::FallbackFailed(FallbackFailed::new(
982        Box::new(source),
983        std::vec::Vec::new(),
984      ))),
985    }
986  }
987
988  /// Worker for [`Self::degrade_to_sw`]. Opens SW cold, forwards the arm's input,
989  /// and on success commits + enters degraded-resync mode. Returns `Err` (and
990  /// commits nothing) if SW cannot open or the forward fails.
991  fn degrade_to_sw_inner(
992    &mut self,
993    input: PostCommitInput<'_>,
994    eof_pending: bool,
995  ) -> Result<(), Error> {
996    // The invariant [`PostCommitInput`] documents, stated where it can
997    // be checked: a current packet and an end-of-stream are never
998    // forwarded together. The send road cannot violate it — its own
999    // gate refuses every packet once `eof_sent` is committed — so this
1000    // records the coupling rather than defending against it.
1001    debug_assert!(
1002      !(matches!(input, PostCommitInput::Packet(_)) && eof_pending),
1003      "a current packet and a committed EOF must never be forwarded together",
1004    );
1005    let mut sw = open_sw_decoder(&self.parameters, self.limits, Some(self.time_base))?;
1006    // Captured before the decoder is borrowed for the forward, and
1007    // before it can be dropped on the error road: this temporary
1008    // decoder owns the callback state, so a `judge_buffer` refusal
1009    // recorded during either forward below dies with it unless the
1010    // reason is collected here. That was the last software road still
1011    // wrapping libavcodec's `EINVAL` raw.
1012    let state = sw.state();
1013    let mut forwarded_keyframe = false;
1014    let mut forwarded_packet = false;
1015    match input {
1016      PostCommitInput::Packet(pkt) => {
1017        // The HW decoder REFUSED this packet, so it was never decoded; forward
1018        // it to the cold SW. A failure here surfaces (it is not silently
1019        // dropped) and rolls back to HW.
1020        sw.send_packet(pkt)
1021          .map_err(|e| crate::decoder::software_exit(state, e))?;
1022        forwarded_keyframe = pkt.is_key();
1023        forwarded_packet = true;
1024      }
1025      // Neither of these forwards a packet; the end-of-stream below is
1026      // the only thing they can hand the cold decoder.
1027      PostCommitInput::FrameTime | PostCommitInput::Eof => {}
1028    }
1029    // **The end of the stream is re-forwarded here, on every arm that
1030    // has one, and that is the fix rather than an extra.**
1031    //
1032    // The cold decoder knows nothing: it was opened a moment ago, from
1033    // codec parameters alone. If the session had already been told the
1034    // stream ended and this new decoder is not, it answers `EAGAIN` to
1035    // every drain — which reaches the caller as
1036    // [`Received::NeedsInput`], an instruction to send another packet.
1037    // On a session whose end is committed there is no legal way to obey
1038    // that: both send gates refuse. The caller loops, or quietly
1039    // accepts a truncated tail, until `flush`.
1040    //
1041    // It used to be reachable only through the `Eof` failure arm, so
1042    // the frame-time road — a post-commit exhaustion raised *while
1043    // draining*, after EOF was accepted — opened cold and stayed cold.
1044    // A cold decoder has no buffered output, so this cannot answer
1045    // `EAGAIN` itself.
1046    if eof_pending {
1047      sw.send_eof()
1048        .map_err(|e| crate::decoder::software_exit(state, e))?;
1049    }
1050    // Commit: only after a clean open + forward.
1051    self.state = DecodeState::Sw(sw);
1052    self.enter_degraded_resync();
1053    if forwarded_keyframe {
1054      // The refused current packet was itself the resync anchor.
1055      self.note_degraded_keyframe(true);
1056    }
1057    if forwarded_packet {
1058      self.count_degraded_packet();
1059    }
1060    Ok(())
1061  }
1062
1063  /// Enter post-commit degraded mode after a post-commit fallback commits: the
1064  /// SW decoder opened cold and the span up to the next keyframe is being
1065  /// dropped. We hold this mode until SW proves a *keyframe-anchored* resync
1066  /// (a delivered frame after a keyframe was fed — see
1067  /// [`Self::note_degraded_keyframe`] / [`Self::resync_on_frame`]) and the EOF
1068  /// escalation in [`VideoStreamDecoder::receive_frame`]. Called only on the
1069  /// post-commit path, only after a clean commit. Resets the keyframe-seen anchor
1070  /// and the gap counter.
1071  #[inline]
1072  fn enter_degraded_resync(&mut self) {
1073    self.degraded_resync_pending = true;
1074    self.degraded_keyframe_seen = false;
1075    self.degraded_packets_since_fallback = 0;
1076  }
1077
1078  /// Record that a packet fed to the SW decoder across an unresolved post-commit
1079  /// gap was a **keyframe** — the resync anchor. Only a frame delivered *after*
1080  /// this clears the pending flag, so a lenient codec's concealed P-frame can't
1081  /// masquerade as a resync. A no-op outside degraded mode, or for a
1082  /// non-keyframe.
1083  #[inline]
1084  fn note_degraded_keyframe(&mut self, is_key: bool) {
1085    if self.degraded_resync_pending && is_key {
1086      self.degraded_keyframe_seen = true;
1087    }
1088  }
1089
1090  /// Count one packet fed to the SW decoder while a post-commit resync is still
1091  /// unproven, so the EOF escalation can quantify the lost tail. A no-op once
1092  /// SW has resynced (the flag is clear).
1093  #[inline]
1094  fn count_degraded_packet(&mut self) {
1095    if self.degraded_resync_pending {
1096      self.degraded_packets_since_fallback = self.degraded_packets_since_fallback.saturating_add(1);
1097    }
1098  }
1099
1100  /// A SW frame was delivered. Clear post-commit degraded mode **only if** a
1101  /// keyframe was fed across the gap ([`Self::degraded_keyframe_seen`]) — that is
1102  /// a real keyframe-anchored resync, so the dropped span is now the promised
1103  /// *bounded* gap. A frame delivered with no keyframe yet (a concealed P-frame
1104  /// from the dropped span) leaves the guard set, so the one-GOP bound stays
1105  /// enforced and the EOF escalation still fires if no keyframe ever arrives.
1106  /// Idempotent; a no-op outside degraded mode (steady state, probe-era replay).
1107  #[inline]
1108  fn resync_on_frame(&mut self) {
1109    if self.degraded_resync_pending && self.degraded_keyframe_seen {
1110      self.clear_degraded_resync();
1111    }
1112  }
1113
1114  /// Unconditionally reset post-commit degraded-mode state. Used where the gap
1115  /// is moot regardless of resync proof: a `flush` (seek/reset re-anchors the
1116  /// stream) and the cleanup after an EOF escalation has already fired (so a
1117  /// follow-up poll sees plain EOF, not a repeated escalation). The
1118  /// frame-delivery path uses the keyframe-gated [`Self::resync_on_frame`]
1119  /// instead.
1120  #[inline]
1121  fn clear_degraded_resync(&mut self) {
1122    self.degraded_resync_pending = false;
1123    self.degraded_keyframe_seen = false;
1124    self.degraded_packets_since_fallback = 0;
1125  }
1126
1127  /// The one place a delivered frame is committed.
1128  ///
1129  /// Every road that hands a frame to the caller passes through here —
1130  /// the hardware scratch, the software scratch, both replay-queue
1131  /// entries, and the retry of a parked frame — so the bookkeeping a
1132  /// delivery owes cannot be attached to some of them and forgotten on
1133  /// others. It was: a parked software frame delivered on the retry
1134  /// road skipped [`Self::resync_on_frame`], so the last recovered
1135  /// frame of a degraded stream could leave the resync guard standing
1136  /// and turn a clean EOF into a false
1137  /// [`PostCommitNeverResynced`].
1138  fn commit_delivery(
1139    &mut self,
1140    frame: VideoFrame<mediadecode::PixelFormat, VideoFrameExtra, C::Buffer>,
1141    dst: &mut VideoFrame<mediadecode::PixelFormat, VideoFrameExtra, C::Buffer>,
1142  ) {
1143    // The seat is free once a carrier exists for what it held.
1144    self.scratch_pending = false;
1145    // A delivered frame is what clears a keyframe-anchored resync. A
1146    // no-op on every road that never entered degraded mode, which is
1147    // why it can be unconditional here.
1148    self.resync_on_frame();
1149    *dst = frame;
1150  }
1151
1152  /// Where this session is. See
1153  /// [`SessionPhase`](crate::decoder::SessionPhase).
1154  ///
1155  /// The wrapper never sees a probe — that lives inside the hardware
1156  /// seam, which derives its own — so only the committed pair is
1157  /// reachable from here.
1158  const fn phase(&self) -> crate::decoder::SessionPhase {
1159    if self.eof_sent {
1160      crate::decoder::SessionPhase::Draining
1161    } else {
1162      crate::decoder::SessionPhase::Streaming
1163    }
1164  }
1165
1166  /// Reads a drain answer against the session's own committed end.
1167  ///
1168  /// Routes a settled end through the post-commit gap check.
1169  ///
1170  /// **The `NeedsInput`-past-the-end reading moved out of here.** It
1171  /// used to be this method's own comparison against `eof_sent` — one
1172  /// more road deriving the session's phase for itself, which is the
1173  /// habit [`SessionPhase`](crate::decoder::SessionPhase) ended. The
1174  /// classifier makes that reading now, for every road at once, and
1175  /// what is left here is the part that is genuinely this wrapper's:
1176  /// an end is not clean if a post-commit gap never closed.
1177  fn settle(&mut self, status: Received) -> Result<Received, VideoDecodeError> {
1178    match status {
1179      Received::Ended => self.ended(),
1180      other => Ok(other),
1181    }
1182  }
1183
1184  /// The end of the stream, read against a post-commit gap that never
1185  /// closed.
1186  ///
1187  /// One place, because there are now two spellings that reach it — the
1188  /// substrate's `AVERROR_EOF` and a settled [`Received::NeedsInput`]
1189  /// past a committed end — and a lost tail must escalate on both. The
1190  /// flag is cleared as it fires so a caller draining to the end sees
1191  /// the escalation once and the plain end afterwards.
1192  fn ended(&mut self) -> Result<Received, VideoDecodeError> {
1193    if !self.degraded_resync_pending {
1194      return Ok(Received::Ended);
1195    }
1196    let packets_lost = self.degraded_packets_since_fallback;
1197    tracing::error!(
1198      packets_lost,
1199      "mediadecode-ffmpeg: post-commit HW->SW fallback never resynced before EOF — \
1200       {packets_lost} packets fed to the software decoder produced no frame (no \
1201       keyframe found across the gap); the stream tail from the fallback point was \
1202       lost",
1203    );
1204    self.clear_degraded_resync();
1205    Err(VideoDecodeError::PostCommitNeverResynced(
1206      PostCommitNeverResynced::new(packets_lost),
1207    ))
1208  }
1209
1210  /// Internal: convert the active scratch frame into a
1211  /// `mediadecode::VideoFrame` and write into `dst`.
1212  fn deliver_frame(
1213    &mut self,
1214    dst: &mut VideoFrame<mediadecode::PixelFormat, VideoFrameExtra, C::Buffer>,
1215  ) -> Result<Received, VideoDecodeError> {
1216    let av_frame = match &mut self.state {
1217      DecodeState::Hw(_) => unsafe { self.hw_scratch.as_inner_mut().as_ptr() },
1218      DecodeState::Sw(_) => unsafe { self.sw_scratch.as_ptr() },
1219    };
1220    // SAFETY: the scratch frame is live — either just filled by the
1221    // inner decoder's `receive_frame`, or left holding a frame whose
1222    // conversion did not commit. Convert takes what it needs out of it,
1223    // so the scratch can be reused once this has committed.
1224    let converted = unsafe {
1225      convert::av_frame_to_video_frame_as::<C>(av_frame, self.time_base, self.limits.frame())
1226    };
1227    match converted {
1228      Ok(new_frame) => {
1229        self.commit_delivery(new_frame, dst);
1230        Ok(Received::Frame)
1231      }
1232      Err(e) => {
1233        // Park only what another attempt could survive.
1234        self.scratch_pending = e.parks_in_decode();
1235        Err(VideoDecodeError::Convert(e))
1236      }
1237    }
1238  }
1239}
1240
1241#[cfg(test)]
1242impl<C: crate::FfmpegCarrier + crate::CarrierOps> CarrierVideoStreamDecoder<C> {
1243  /// Build a decoder around an injected HW seam, bypassing the real probe.
1244  /// Lets tests drive the post-commit fallback path with a [`HwInner`] fake
1245  /// instead of a live GPU. The SW fallback still opens the **real**
1246  /// `ffmpeg::decoder::Video` from `parameters`, so a fallback in these tests
1247  /// genuinely decodes.
1248  pub(crate) fn from_hw_inner_for_test(
1249    hw: Box<dyn HwInner>,
1250    parameters: Parameters,
1251    time_base: Timebase,
1252  ) -> Result<Self, Error> {
1253    Self::from_hw_inner_for_test_as(hw, parameters, time_base, DecodePath::Auto)
1254  }
1255
1256  /// [`Self::from_hw_inner_for_test`], with the session's
1257  /// [`DecodePath`] named.
1258  ///
1259  /// The seam a **pinned** session's mid-stream behaviour is driven
1260  /// through: a pin's promise is about what happens when the hardware
1261  /// fails after opening, and the only way to reach that on a machine
1262  /// whose GPU works is to inject a seam that fails on demand. See
1263  /// `a_hardware_pin_reports_a_mid_stream_exhaustion_instead_of_degrading`.
1264  pub(crate) fn from_hw_inner_for_test_as(
1265    hw: Box<dyn HwInner>,
1266    parameters: Parameters,
1267    time_base: Timebase,
1268    path: DecodePath,
1269  ) -> Result<Self, Error> {
1270    let limits = DecoderLimits::default();
1271    let owned_parameters = try_clone_parameters(&parameters, limits.max_codec_parameter_bytes())?;
1272    Ok(Self {
1273      state: DecodeState::Hw(hw),
1274      path,
1275      parameters: owned_parameters,
1276      hw_scratch: Frame::empty()?,
1277      sw_scratch: alloc_av_video_frame()?,
1278      sw_replay_frames: VecDeque::new(),
1279      eof_sent: false,
1280      degraded_resync_pending: false,
1281      degraded_keyframe_seen: false,
1282      degraded_packets_since_fallback: 0,
1283      time_base,
1284      limits,
1285      scratch_pending: false,
1286      _carrier: core::marker::PhantomData,
1287    })
1288  }
1289
1290  /// Whether `send_eof` has been committed on the active decoder. Lets the
1291  /// rollback tests assert that a failed EOF fallback restores (never
1292  /// half-mutates) `eof_sent`.
1293  pub(crate) const fn eof_sent_for_test(&self) -> bool {
1294    self.eof_sent
1295  }
1296
1297  /// Whether a post-commit fallback is awaiting a keyframe-anchored resync.
1298  /// Lets the escalation tests observe the degraded-resync state machine.
1299  pub(crate) const fn degraded_resync_pending_for_test(&self) -> bool {
1300    self.degraded_resync_pending
1301  }
1302
1303  /// Whether a keyframe has been fed to the SW decoder across the unresolved
1304  /// post-commit gap (the resync anchor). Lets the keyframe-gating test confirm
1305  /// a concealed P-frame does not set it (so the resync clear stays blocked).
1306  pub(crate) const fn degraded_keyframe_seen_for_test(&self) -> bool {
1307    self.degraded_keyframe_seen
1308  }
1309
1310  /// Whether the post-commit path retained any replay frames — must always be
1311  /// empty for a post-commit fallback (it retains zero). Lets the finding-1
1312  /// dissolution test assert no replay frame was ever queued.
1313  pub(crate) fn sw_replay_frames_is_empty_for_test(&self) -> bool {
1314    self.sw_replay_frames.is_empty()
1315  }
1316
1317  /// Packets fed to SW across an unresolved post-commit resync gap. Lets the
1318  /// counter test confirm packets crossing the gap from the `send_packet` arm
1319  /// are tallied (and cleared on resync).
1320  pub(crate) const fn degraded_packets_since_fallback_for_test(&self) -> u64 {
1321    self.degraded_packets_since_fallback
1322  }
1323}
1324
1325impl<C: crate::FfmpegCarrier + crate::CarrierOps> CarrierVideoStreamDecoder<C> {
1326  /// The fault a submission after end-of-stream earns on this face.
1327  ///
1328  /// **Censused from the empty-seat road rather than invented.** With
1329  /// the seat free, a post-EOF `send_packet` or a repeated `send_eof`
1330  /// reaches libavcodec, which answers `AVERROR_EOF`, and all four
1331  /// roads through this wrapper — hardware and software, packet and
1332  /// EOF — surface it as exactly this value. The gates below short
1333  /// out to the same one so a parked seat cannot change *which* answer
1334  /// a caller gets, only how quickly. `the_post_eof_fault_is_the_one_the_substrate_gives`
1335  /// pins the two against each other.
1336  ///
1337  /// Deliberately **not** a new `VideoDecodeError` arm. The subtitle
1338  /// seam had to mint `AfterEof` because `avcodec_decode_subtitle2` has
1339  /// no state machine to refuse for it; this face already has an answer
1340  /// for the condition, and a second spelling for one fault on one
1341  /// surface is the disease this release is curing.
1342  fn after_eof() -> VideoDecodeError {
1343    VideoDecodeError::Decode(Error::Ffmpeg(ffmpeg_next::Error::Eof))
1344  }
1345
1346  pub(crate) fn send_packet_impl(
1347    &mut self,
1348    packet: &VideoPacket<VideoPacketExtra, C::Buffer>,
1349  ) -> Result<Sent, VideoDecodeError> {
1350    // **The end of the stream outranks the parked seat, and the order
1351    // is the whole point.**
1352    //
1353    // `Sent::MustDrain` is a promise: drain the output and this same
1354    // offer becomes acceptable. Past end-of-stream that promise is
1355    // false — draining empties the seat and the retry still faults,
1356    // until `flush`. Checking the seat first made the wrapper answer
1357    // `MustDrain` for a submission nothing could ever accept, which is
1358    // the same fault-under-back-pressure inversion the subtitle seam
1359    // carried: a caller obeying the contract loops, drains, re-offers,
1360    // and is refused anyway.
1361    //
1362    // It is reachable: `send_eof` is accepted and sets `eof_sent`, a
1363    // delayed tail frame comes out of the decoder, its carrier
1364    // allocation fails parkably, and the seat is taken on a session
1365    // that is already over.
1366    if !self.phase().accepts_input() {
1367      return Err(Self::after_eof());
1368    }
1369    // **Nothing is sent while a frame is parked.** Both send roads can
1370    // commit a hardware-to-software fallback, and a fallback under a
1371    // parked frame would leave the retry reading the other scratch. See
1372    // [`Self::scratch_pending`]. Nothing was consumed, so this is back
1373    // pressure and the packet is still the caller's to re-offer — which
1374    // is true precisely because the stream is not over, checked above.
1375    if self.scratch_pending {
1376      return Ok(Sent::MustDrain);
1377    }
1378    let phase = self.phase();
1379    // Scoped submission: the rebuilt `AVPacket` never leaves this call,
1380    // which is what lets the view lane share its buffer with libavcodec
1381    // rather than copy into it. See `boundary::with_ffmpeg_video_packet`.
1382    let limits = self.limits.packet_limits();
1383    // **The route depends on what this decoder does with what it is
1384    // sent.** While the hardware probe is open it `av_packet_ref`s
1385    // every accepted packet into a rescue history, and
1386    // `AllBackendsFailed::into_unconsumed_packets` hands those out as
1387    // owned, mutable `Packet`s — so a shared body would escape this
1388    // call as a live mutable alias of a carrier the caller may still be
1389    // reading. Inside that window the body is copied; once the probe
1390    // has committed, nothing is recorded and the send is zero-copy
1391    // again. The software road never records.
1392    let route = match &self.state {
1393      DecodeState::Hw(hw) if hw.records_submissions() => crate::carrier::BodyRoute::Copy,
1394      _ => crate::carrier::BodyRoute::Submission,
1395    };
1396    boundary::with_ffmpeg_video_packet::<C, _>(packet, limits, route, |av_pkt| {
1397      match &mut self.state {
1398        DecodeState::Hw(hw) => match hw.send_packet(av_pkt) {
1399          // The seam already classified libavcodec's back pressure, so
1400          // both states travel on unchanged.
1401          Ok(status) => Ok(status),
1402          Err(Error::AllBackendsFailed(p)) => {
1403            // **A pinned hardware session reports rather than degrades.**
1404            // See [`Self::may_open_software`]: this is the exhaustion
1405            // `DecodePath::Auto` reads as its cue to open software, and
1406            // the pin's whole content is that it is not that cue here.
1407            // Reported with the payload intact, so the caller keeps the
1408            // backend, its error, and any rescued packets.
1409            if !self.may_open_software() {
1410              return Err(VideoDecodeError::Decode(Error::AllBackendsFailed(p)));
1411            }
1412            // Route on the EXPLICIT origin, never on whether `rescued` is empty (a
1413            // probe-era first-packet cap trip is *also* empty).
1414            if p.origin().is_post_commit() {
1415              // Post-commit: DEGRADE AND CONTINUE. No lossless mid-stream
1416              // reconstruction — the SW decoder opens cold, retains zero replay
1417              // frames, and resyncs at the next keyframe. The current packet (the
1418              // one HW REFUSED) is forwarded to that cold SW: if it is the resync
1419              // keyframe SW decodes from it, otherwise SW drops it until a keyframe
1420              // arrives. The bounded span from here to that keyframe is dropped — a
1421              // loudly logged gap (see the `warn!`), not a silent one.
1422              tracing::warn!(
1423                backend = ?p.attempts().last().map(|(b, _)| *b),
1424                pts = ?av_pkt.pts(),
1425                "mediadecode-ffmpeg: HW decode failed post-commit; falling back to \
1426                 software, resyncing at next keyframe — a bounded span of frames \
1427                 may be dropped at this boundary",
1428              );
1429              // Transactional SW-open + current-packet forward; degrade-tracking
1430              // (incl. keyframe-anchor recording) happens inside on a clean commit.
1431              // A failure surfaces `FallbackFailed` and stays on HW.
1432              // A clean degrade forwarded this very packet into the
1433              // cold software decoder, so it was consumed.
1434              // `false`: this road is unreachable once the end is
1435              // committed — `send_packet_impl`'s first gate refuses
1436              // every packet past `eof_sent` — so there is no EOF to
1437              // re-forward, and forwarding one alongside a packet is
1438              // the pairing [`PostCommitInput`] forbids.
1439              return self
1440                .degrade_to_sw(PostCommitInput::Packet(av_pkt), false)
1441                .map(|()| Sent::Accepted)
1442                .map_err(VideoDecodeError::Decode);
1443            }
1444            // Probe-era: replay the inner decoder's buffered history (lossless —
1445            // no frame was delivered yet), then forward the still-unconsumed
1446            // current packet to SW.
1447            let rescued = p.into_unconsumed_packets();
1448            // `eof_pending` is the committed EOF state — never pre-mutated here.
1449            let eof_pending = self.eof_sent;
1450            self
1451              .fall_back_to_sw(rescued, eof_pending)
1452              .map_err(VideoDecodeError::Decode)?;
1453            // Forward the new (still-unconsumed) current packet to the
1454            // freshly-opened SW decoder — the HW decoder REFUSED it, so it was not
1455            // in the replay set. A failure here surfaces (it is not silently
1456            // dropped), and back pressure from the fresh decoder is reported as
1457            // such rather than mistaken for one: the fallback committed either
1458            // way, and the caller re-offers the packet.
1459            if let DecodeState::Sw(sw) = &mut self.state {
1460              let st = sw.state();
1461              if let Err(e) = sw.send_packet(av_pkt) {
1462                return crate::decoder::software_send(st, e, phase)
1463                  .map_err(VideoDecodeError::Decode);
1464              }
1465            }
1466            Ok(Sent::Accepted)
1467          }
1468          Err(other) => Err(VideoDecodeError::Decode(other)),
1469        },
1470        DecodeState::Sw(sw) => {
1471          let st = sw.state();
1472          if let Err(e) = sw.send_packet(av_pkt) {
1473            // Funnel, then gate. **Nothing below runs on back pressure**,
1474            // which is the point of returning here rather than falling
1475            // through: a packet libavcodec did not take must not be
1476            // counted across the resync gap or recorded as a keyframe
1477            // anchor, or a caller's honest re-offer would double-count
1478            // it.
1479            return crate::decoder::software_send(st, e, phase).map_err(VideoDecodeError::Decode);
1480          }
1481          // A keyframe fed across an unresolved post-commit gap is the resync
1482          // anchor; record it so the next delivered frame can clear the guard.
1483          self.note_degraded_keyframe(av_pkt.is_key());
1484          // Count packets crossing an unresolved post-commit resync gap so the
1485          // escalation at EOF can report how much tail was lost.
1486          self.count_degraded_packet();
1487          Ok(Sent::Accepted)
1488        }
1489      }
1490    })
1491    .map_err(|e| VideoDecodeError::Decode(Error::PacketBuild(e)))?
1492  }
1493
1494  pub(crate) fn receive_frame_impl(
1495    &mut self,
1496    dst: &mut VideoFrame<mediadecode::PixelFormat, VideoFrameExtra, C::Buffer>,
1497  ) -> Result<Received, VideoDecodeError> {
1498    // Deliver any frames produced during SW fallback replay before
1499    // pulling new ones from the SW decoder. This is the queue
1500    // populated by `fall_back_to_sw` when SW returned EAGAIN during
1501    // packet replay — a **probe-era** path only (the post-commit path retains
1502    // no replay frames), so `resync_on_frame` here is a no-op (probe-era never
1503    // enters degraded mode).
1504    // **Peeked, not popped.** A replayed frame is the rescue history's
1505    // only copy: popping it before the conversion committed lost it to
1506    // any allocation failure, which is the one thing this queue exists
1507    // to prevent. It leaves the queue when a carrier exists for it.
1508    if let Some(replayed) = self.sw_replay_frames.front() {
1509      // SAFETY: `replayed` is a live AVFrame owned by this queue;
1510      // convert takes what it needs out of it.
1511      let converted = unsafe {
1512        convert::av_frame_to_video_frame_as::<C>(
1513          replayed.as_ptr(),
1514          self.time_base,
1515          self.limits.frame(),
1516        )
1517      };
1518      let new_frame = match converted {
1519        Ok(new_frame) => new_frame,
1520        Err(e) if e.parks_in_decode() => return Err(VideoDecodeError::Convert(e)),
1521        // A frame nothing can carry is dropped rather than re-offered
1522        // forever — the same rule the scratch seat follows.
1523        Err(e) => {
1524          self.sw_replay_frames.pop_front();
1525          return Err(VideoDecodeError::Convert(e));
1526        }
1527      };
1528      self.sw_replay_frames.pop_front();
1529      self.commit_delivery(new_frame, dst);
1530      return Ok(Received::Frame);
1531    }
1532    // A frame whose conversion did not commit is converted again before
1533    // the decoder is asked for another — see [`Self::scratch_pending`].
1534    // The scratch still holds it, and `deliver_frame` reads whichever
1535    // scratch the current state uses.
1536    if self.scratch_pending {
1537      return self.deliver_frame(dst);
1538    }
1539    let phase = self.phase();
1540    loop {
1541      match &mut self.state {
1542        DecodeState::Hw(hw) => match hw.receive_frame(&mut self.hw_scratch) {
1543          Ok(Received::Frame) => {
1544            // The frame is out of the decoder's queue from here; the
1545            // seat is what keeps it if the conversion cannot commit.
1546            self.scratch_pending = true;
1547            return self.deliver_frame(dst);
1548          }
1549          // The hardware seam already classified the two flow signals.
1550          // They still pass the session's own end: see [`Self::settle`].
1551          Ok(status) => return self.settle(status),
1552          Err(Error::AllBackendsFailed(p)) => {
1553            // The pin, on the receive road — see
1554            // [`Self::may_open_software`] and the identical gate on the
1555            // two send roads.
1556            if !self.may_open_software() {
1557              return Err(VideoDecodeError::Decode(Error::AllBackendsFailed(p)));
1558            }
1559            // HW exhausted at frame-time. There is no current packet here.
1560            // Route on the explicit origin.
1561            if p.origin().is_post_commit() {
1562              // Post-commit: DEGRADE AND CONTINUE — open SW cold (no current
1563              // packet to forward, no replay frames retained) and resync at the
1564              // next keyframe, dropping the bounded span up to it. Loud single
1565              // `warn!` marks that accepted gap. A clean commit enters degraded
1566              // mode; a SW-open failure surfaces `FallbackFailed` and stays HW.
1567              tracing::warn!(
1568                backend = ?p.attempts().last().map(|(b, _)| *b),
1569                "mediadecode-ffmpeg: HW decode failed post-commit at frame-time; \
1570                 falling back to software, resyncing at next keyframe — a bounded \
1571                 span of frames may be dropped at this boundary",
1572              );
1573              // **The committed end travels with the fallback.** Read
1574              // before anything mutates, exactly as the probe-era road
1575              // below reads it. Without it the cold decoder answers
1576              // `EAGAIN` forever on a session no send can feed.
1577              let eof_pending = self.eof_sent;
1578              self
1579                .degrade_to_sw(PostCommitInput::FrameTime, eof_pending)
1580                .map_err(VideoDecodeError::Decode)?;
1581              // Nothing to deliver yet — fall through to the loop; the next
1582              // iteration takes the Sw arm and pulls from the cold SW decoder.
1583              continue;
1584            }
1585            // Probe-era: replay the buffered history (lossless).
1586            let rescued = p.into_unconsumed_packets();
1587            // `eof_pending` is the committed EOF state — never pre-mutated here.
1588            let eof_pending = self.eof_sent;
1589            self
1590              .fall_back_to_sw(rescued, eof_pending)
1591              .map_err(VideoDecodeError::Decode)?;
1592            // If the replay produced any drained frames, return one
1593            // immediately — preserves stream order vs. whatever the
1594            // SW decoder will produce next.
1595            // **Peeked, not popped** — the second delivery path onto
1596            // this queue, and it owes the same discipline as the first
1597            // (see the head of `receive_frame_impl`). The replay queue
1598            // is the rescue history's only copy of these frames, so a
1599            // conversion that cannot commit must leave the head where
1600            // it is rather than advance past it.
1601            if let Some(replayed) = self.sw_replay_frames.front() {
1602              // SAFETY: `replayed` is a live AVFrame owned by this
1603              // queue; convert takes what it needs out of it.
1604              let converted = unsafe {
1605                convert::av_frame_to_video_frame_as::<C>(
1606                  replayed.as_ptr(),
1607                  self.time_base,
1608                  self.limits.frame(),
1609                )
1610              };
1611              let new_frame = match converted {
1612                Ok(new_frame) => new_frame,
1613                Err(e) if e.parks_in_decode() => return Err(VideoDecodeError::Convert(e)),
1614                // A frame nothing can carry is dropped rather than
1615                // re-offered forever.
1616                Err(e) => {
1617                  self.sw_replay_frames.pop_front();
1618                  return Err(VideoDecodeError::Convert(e));
1619                }
1620              };
1621              self.sw_replay_frames.pop_front();
1622              self.commit_delivery(new_frame, dst);
1623              return Ok(Received::Frame);
1624            }
1625            // Fall through to the loop; next iteration takes the Sw arm.
1626          }
1627          Err(other) => return Err(VideoDecodeError::Decode(other)),
1628        },
1629        DecodeState::Sw(sw) => {
1630          // Convert inline (rather than via `deliver_frame`, which borrows all
1631          // of `self`) so only the disjoint fields `sw_scratch` / `time_base`
1632          // are touched alongside the `self.state` borrow `sw` holds.
1633          let st = sw.state();
1634          match sw.receive_frame(&mut self.sw_scratch) {
1635            Ok(()) => {
1636              // The frame is out of the decoder's queue from here; the
1637              // seat is what keeps it if the conversion cannot commit.
1638              self.scratch_pending = true;
1639              // SAFETY: the scratch frame is live (just filled by
1640              // `receive_frame`); convert takes what it needs out of
1641              // it, so the scratch can be reused once this commits.
1642              let converted = unsafe {
1643                convert::av_frame_to_video_frame_as::<C>(
1644                  self.sw_scratch.as_ptr(),
1645                  self.time_base,
1646                  self.limits.frame(),
1647                )
1648              };
1649              let new_frame = match converted {
1650                Ok(new_frame) => new_frame,
1651                Err(e) => {
1652                  self.scratch_pending = e.parks_in_decode();
1653                  return Err(VideoDecodeError::Convert(e));
1654                }
1655              };
1656              // SW produced a frame. The commit point clears degraded mode only
1657              // if a keyframe was fed across the gap — a real keyframe-anchored
1658              // resync, so the dropped span is the promised bounded gap. A
1659              // concealed P-frame (no keyframe yet) does not clear it (see
1660              // `resync_on_frame`).
1661              self.commit_delivery(new_frame, dst);
1662              return Ok(Received::Frame);
1663            }
1664            // Funnel first — so a recorded budget refusal is named
1665            // rather than laundered — read as a status second (`EAGAIN`
1666            // is `NeedsInput`, `Eof` is `Ended`, and the errno stops
1667            // inside this crate either way), and settled against the
1668            // session's own end third.
1669            //
1670            // That last step is where a post-commit resync that never
1671            // closed becomes [`VideoDecodeError::PostCommitNeverResynced`]
1672            // instead of a clean end that would swallow the tail — and
1673            // it now catches the end however the codec spelled it. See
1674            // [`Self::settle`] and [`Self::ended`].
1675            Err(e) => {
1676              let status =
1677                crate::decoder::software_receive(st, e, phase).map_err(VideoDecodeError::Decode)?;
1678              return self.settle(status);
1679            }
1680          }
1681        }
1682      }
1683    }
1684  }
1685
1686  pub(crate) fn send_eof_impl(&mut self) -> Result<Sent, VideoDecodeError> {
1687    // The same two gates in the same order, for the same reason: a
1688    // repeated end-of-stream past a committed one is refused however
1689    // much is drained, so answering back pressure would be a promise
1690    // this face cannot keep. See [`Self::after_eof`].
1691    if !self.phase().accepts_input() {
1692      return Err(Self::after_eof());
1693    }
1694    // As `send_packet`: EOF can commit a fallback too, and the escalation
1695    // it may raise reads the resync standing a parked frame has not yet
1696    // had the chance to clear. Nothing was recorded, so drain and signal
1697    // again.
1698    if self.scratch_pending {
1699      return Ok(Sent::MustDrain);
1700    }
1701    let phase = self.phase();
1702    let outcome = match &mut self.state {
1703      DecodeState::Hw(hw) => match hw.send_eof() {
1704        // The seam classified libavcodec's back pressure already.
1705        Ok(status) => Ok(status),
1706        Err(Error::AllBackendsFailed(p)) => {
1707          // The pin, on the EOF road — see [`Self::may_open_software`].
1708          // Returned rather than folded into `outcome`: the commit below
1709          // fires only on `Ok(Sent::Accepted)`, so the two roads agree,
1710          // and leaving early keeps the fallback body at the nesting it
1711          // was written at.
1712          if !self.may_open_software() {
1713            return Err(VideoDecodeError::Decode(Error::AllBackendsFailed(p)));
1714          }
1715          // EOF is pending for this transaction, so the SW decoder must also
1716          // receive `send_eof` (codecs that delay tail frames hang otherwise).
1717          // We pass that intent locally rather than pre-setting `self.eof_sent`:
1718          // a fallback that fails returns `FallbackFailed` and stays on HW, and a
1719          // half-mutated `self.eof_sent = true` would then make a *later*
1720          // fallback inject an EOF into SW even though this `send_eof` errored.
1721          // `self.eof_sent` is committed only after the whole operation succeeds
1722          // (the `outcome` check below), keeping the fallback all-or-nothing.
1723          if p.origin().is_post_commit() {
1724            // Post-commit: DEGRADE AND CONTINUE — open SW cold, re-forward EOF
1725            // (no current packet, no replay frames). The cold SW produces no
1726            // frame from EOF alone, so the drain-to-EOF in `receive_frame`
1727            // escalates (`PostCommitNeverResynced`) unless a later keyframe-fed
1728            // poll resyncs first. A clean commit enters degraded mode; a SW-open
1729            // failure surfaces `FallbackFailed` and stays HW.
1730            tracing::warn!(
1731              backend = ?p.attempts().last().map(|(b, _)| *b),
1732              "mediadecode-ffmpeg: HW decode failed post-commit at EOF; falling \
1733               back to software — a bounded span of tail frames may be dropped",
1734            );
1735            // Both fallback roads forward the EOF inside their own
1736            // transaction, so a clean commit means it was recorded.
1737            // `true`: this *is* the end being sent. `eof_sent` is not
1738            // committed until the whole operation succeeds, so the
1739            // intent is passed locally rather than read back.
1740            self
1741              .degrade_to_sw(PostCommitInput::Eof, true)
1742              .map(|()| Sent::Accepted)
1743              .map_err(VideoDecodeError::Decode)
1744          } else {
1745            // Probe-era: replay the buffered history (lossless), re-forwarding
1746            // EOF inside the transaction.
1747            let rescued = p.into_unconsumed_packets();
1748            self
1749              .fall_back_to_sw(rescued, true)
1750              .map(|()| Sent::Accepted)
1751              .map_err(VideoDecodeError::Decode)
1752          }
1753        }
1754        Err(other) => Err(VideoDecodeError::Decode(other)),
1755      },
1756      DecodeState::Sw(sw) => {
1757        let st = sw.state();
1758        match sw.send_eof() {
1759          Ok(()) => Ok(Sent::Accepted),
1760          Err(e) => crate::decoder::software_send(st, e, phase).map_err(VideoDecodeError::Decode),
1761        }
1762      }
1763    };
1764    // Commit EOF state only when the EOF was actually **taken** — a failed
1765    // fallback left `self.eof_sent` untouched (restored-by-construction: we
1766    // never mutated it), so HW stays EOF-not-yet-sent and a retry behaves
1767    // correctly.
1768    //
1769    // **`is_ok()` is not the test any more, and that is not a stylistic
1770    // change.** `Ok(Sent::MustDrain)` means the decoder did not take the
1771    // end-of-stream; recording `eof_sent` there would make a later fallback
1772    // inject an EOF into the software decoder for a signal that was never
1773    // accepted — the exact half-mutation the local `eof_pending` argument
1774    // exists to prevent on the failure road.
1775    if matches!(outcome, Ok(Sent::Accepted)) {
1776      self.eof_sent = true;
1777    }
1778    outcome
1779  }
1780
1781  pub(crate) fn flush_impl(&mut self) -> Result<(), VideoDecodeError> {
1782    // Drop any frames buffered during SW fallback replay before
1783    // flushing the inner decoder — otherwise a seek/reset would
1784    // surface stale pre-flush frames on the next `receive_frame`.
1785    self.sw_replay_frames.clear();
1786    // And a parked frame belongs to the position being abandoned.
1787    self.scratch_pending = false;
1788    // Flush ends the drain phase; the decoder accepts new packets
1789    // after this, so reset EOF tracking.
1790    self.eof_sent = false;
1791    // A flush (seek/reset) re-anchors the stream — any in-flight post-commit
1792    // resync tracking from before the flush is moot. Clear it so the next EOF
1793    // doesn't escalate over a now-irrelevant pre-flush gap.
1794    self.clear_degraded_resync();
1795    match &mut self.state {
1796      // The HW seam's `flush` returns `Result` for a uniform trait; the
1797      // real `VideoDecoder::flush` is infallible (always `Ok`).
1798      DecodeState::Hw(hw) => hw.flush().map_err(VideoDecodeError::Decode)?,
1799      DecodeState::Sw(sw) => sw.flush(),
1800    }
1801    Ok(())
1802  }
1803}
1804
1805macro_rules! video_lane_face {
1806  ($($lane:ty),+ $(,)?) => { $(
1807    impl CarrierVideoStreamDecoder<$lane> {
1808      /// Opens a video decoder for `parameters`, probing hardware
1809      /// backends in order and falling back to software.
1810      ///
1811      /// [`open_as`](Self::open_as)`(.., DecodePath::Auto)`, which is
1812      /// what this has always done.
1813      pub fn open(
1814        parameters: Parameters,
1815        time_base: Timebase,
1816        limits: DecoderLimits,
1817      ) -> Result<Self, Error> {
1818        Self::open_impl(parameters, time_base, limits)
1819      }
1820
1821      /// Opens a video decoder on a **named decode path**.
1822      ///
1823      /// [`DecodePath::Auto`] is [`open`](Self::open) exactly; the
1824      /// other two arms pin the session to hardware or to software for
1825      /// its whole life. See [`DecodePath`] for what a pin promises and
1826      /// what it costs.
1827      ///
1828      /// Everything else about the session is unchanged — the same
1829      /// [`VideoStreamDecoder`] face, the same frames, the same
1830      /// [`is_hardware`](Self::is_hardware) / [`is_software`](Self::is_software)
1831      /// readings. The choice is *which decoder is behind them*, which
1832      /// is what a determinism comparison and a deployment policy each
1833      /// need and neither could reach.
1834      ///
1835      /// # Errors
1836      ///
1837      /// [`DecodePath::Hardware`] fails here when the named backend
1838      /// cannot be opened for the stream — where [`DecodePath::Auto`]
1839      /// would have gone on to software. [`DecodePath::Software`] fails
1840      /// only where libavcodec has no decoder for the stream, or the
1841      /// context cannot be built.
1842      ///
1843      /// # Examples
1844      ///
1845      /// ```no_run
1846      /// use mediadecode_ffmpeg::{DecodePath, DecoderLimits, FfmpegVideoStreamDecoder};
1847      /// # fn f(parameters: ffmpeg_next::codec::Parameters, time_base: mediadecode::Timebase)
1848      /// # -> Result<(), Box<dyn std::error::Error>> {
1849      /// // The same stream, decoded without a GPU anywhere in the story.
1850      /// let decoder = FfmpegVideoStreamDecoder::open_as(
1851      ///   parameters,
1852      ///   time_base,
1853      ///   DecoderLimits::default(),
1854      ///   DecodePath::Software,
1855      /// )?;
1856      /// assert!(decoder.is_software());
1857      /// # Ok(())
1858      /// # }
1859      /// ```
1860      pub fn open_as(
1861        parameters: Parameters,
1862        time_base: Timebase,
1863        limits: DecoderLimits,
1864        path: DecodePath,
1865      ) -> Result<Self, Error> {
1866        Self::open_as_impl(parameters, time_base, limits, path)
1867      }
1868
1869      /// Whether this decoder is currently running on software.
1870      pub const fn is_software(&self) -> bool {
1871        self.is_software_impl()
1872      }
1873
1874      /// Whether this decoder is currently running on hardware.
1875      pub const fn is_hardware(&self) -> bool {
1876        self.is_hardware_impl()
1877      }
1878
1879      /// Whether this session can currently emit pictures at a
1880      /// caller-requested output size. See
1881      /// [`ScaledOutputCapability`] and
1882      /// [`Self::request_scaled_output`].
1883      ///
1884      /// `Supported` on a live VideoToolbox session on an Apple
1885      /// target, `Unsupported` everywhere else — including on a
1886      /// session that has degraded to software, which is why this
1887      /// reads the session's live state rather than a fact recorded
1888      /// once at open.
1889      pub fn scaled_output_capability(&self) -> ScaledOutputCapability {
1890        self.scaled_output_capability_impl()
1891      }
1892
1893      /// Requests that this session emit pictures at `size` (width,
1894      /// height) from the next frame on, and reports whether the
1895      /// request was recorded. See
1896      /// [`VideoStreamDecoder::request_scaled_output`] for the full
1897      /// contract (never an error) and
1898      /// [`Self::scaled_output_capability`]'s documentation for which
1899      /// road can honor one, what a mid-stream request means, and the
1900      /// zero / upscale refusals this seat mints itself.
1901      pub fn request_scaled_output(&mut self, size: (u32, u32)) -> ScaledOutputCapability {
1902        self.request_scaled_output_impl(size)
1903      }
1904
1905      /// The hardware wrapper, when one is in use.
1906      pub fn hardware_inner(&self) -> Option<&VideoDecoder> {
1907        self.hardware_inner_impl()
1908      }
1909
1910      /// The stream timebase every produced timestamp is stamped with.
1911      pub const fn time_base(&self) -> Timebase {
1912        self.time_base_impl()
1913      }
1914    }
1915
1916    impl VideoStreamDecoder for CarrierVideoStreamDecoder<$lane> {
1917      type Adapter = Ffmpeg;
1918      type Buffer = <$lane as crate::FfmpegCarrier>::Buffer;
1919      type Error = VideoDecodeError;
1920
1921      fn send_packet(
1922        &mut self,
1923        packet: &VideoPacket<VideoPacketExtra, Self::Buffer>,
1924      ) -> Result<Sent, Self::Error> {
1925        self.send_packet_impl(packet)
1926      }
1927
1928      fn receive_frame(
1929        &mut self,
1930        dst: &mut VideoFrame<mediadecode::PixelFormat, VideoFrameExtra, Self::Buffer>,
1931      ) -> Result<Received, Self::Error> {
1932        self.receive_frame_impl(dst)
1933      }
1934
1935      fn send_eof(&mut self) -> Result<Sent, Self::Error> {
1936        self.send_eof_impl()
1937      }
1938
1939      fn flush(&mut self) -> Result<(), Self::Error> {
1940        self.flush_impl()
1941      }
1942
1943      fn scaled_output_capability(&self) -> ScaledOutputCapability {
1944        self.scaled_output_capability_impl()
1945      }
1946
1947      fn request_scaled_output(&mut self, size: (u32, u32)) -> ScaledOutputCapability {
1948        self.request_scaled_output_impl(size)
1949      }
1950    }
1951  )+ };
1952}
1953
1954video_lane_face!(crate::View, crate::Owned);
1955
1956fn open_sw_decoder(
1957  parameters: &Parameters,
1958  limits: DecoderLimits,
1959  pkt_timebase: Option<Timebase>,
1960) -> Result<SwDecoder, Error> {
1961  // Use the checked codec-context builder — ffmpeg-next's
1962  // `Context::from_parameters` calls `Context::new()` which doesn't
1963  // null-check `avcodec_alloc_context3`'s return value before
1964  // running `avcodec_parameters_to_context` against it. Under
1965  // memory pressure that's C-level UB; `build_codec_context`
1966  // surfaces the OOM as an error instead.
1967  let (ctx, callback_state) = build_codec_context(parameters, limits, pkt_timebase)?;
1968  // Opened without forming a bindgen enum from FFmpeg memory: the codec
1969  // is resolved off a raw `codec_id`, and the medium is proved off a raw
1970  // `codec_type`. See `crate::decoder::ensure_codec_type`.
1971  let codec = crate::decoder::find_decoder(parameters)?;
1972  let opened = ctx.decoder().open_as(codec).map_err(Error::Ffmpeg)?;
1973  crate::decoder::ensure_video_codec_type(&opened)?;
1974  Ok(SwDecoder {
1975    decoder: ffmpeg_next::decoder::Video(opened),
1976    _callback_state: callback_state,
1977  })
1978}
1979
1980/// Payload for [`VideoDecodeError::PostCommitNeverResynced`].
1981///
1982/// A **post-commit** HW->SW fallback degraded the stream (dropping the
1983/// bounded span up to the next keyframe) but the software decoder
1984/// reached EOF without ever producing a frame — it never resynced, so
1985/// the entire tail from the failure point was lost. The "bounded,
1986/// logged gap" the post-commit path promises did not materialise (no
1987/// keyframe arrived before EOF), so the loss is surfaced loudly here
1988/// instead of being silently swallowed as a clean end-of-stream.
1989#[derive(thiserror::Error, Debug)]
1990#[error(
1991  "post-commit HW->SW fallback never resynced before EOF: {packets_lost} packets fed to the \
1992   software decoder produced no frame (no keyframe found across the gap) — the stream tail \
1993   from the fallback point was lost"
1994)]
1995pub struct PostCommitNeverResynced {
1996  packets_lost: u64,
1997}
1998
1999impl PostCommitNeverResynced {
2000  /// Constructs a `PostCommitNeverResynced` payload.
2001  #[inline]
2002  pub const fn new(packets_lost: u64) -> Self {
2003    Self { packets_lost }
2004  }
2005  /// Packets fed to the software decoder across the unresolved resync
2006  /// gap.
2007  #[inline]
2008  pub const fn packets_lost(&self) -> u64 {
2009    self.packets_lost
2010  }
2011}
2012
2013/// Error type for [`FfmpegVideoStreamDecoder`] — **faults and the
2014/// send-side refusal**.
2015///
2016/// Every arm here is something that went wrong or something the push
2017/// face declined. The drain's *needs input* and *ended* are
2018/// [`Received`] states out of `receive_frame`; they used to arrive as
2019/// `Decode(Ffmpeg(Other { errno: EAGAIN }))` and `Decode(Ffmpeg(Eof))`,
2020/// which is to say they had no name at this tier at all.
2021/// [`Self::PostCommitNeverResynced`] is the deliberate exception on the
2022/// end-of-stream road: it is not "the stream ended", it is "the stream
2023/// ended and the tail was lost", which is a fault.
2024///
2025/// **Open fault taxonomy, so it is `#[non_exhaustive]`.** New ways to
2026/// fail are discovered — a backend, a ceiling, a corruption a codec
2027/// learns to report — and a consumer that meets one it has never heard
2028/// of should take its generic-fault path. That is exactly what the
2029/// wildcard arm this attribute forces is for. The two status
2030/// vocabularies opposite it,
2031/// [`Sent`](mediadecode::Sent) and [`Received`](mediadecode::Received),
2032/// are exhaustive for the mirror-image reason: their arms are the
2033/// substrate's fixed state set, and there the wildcard would be dead
2034/// weight hiding a state a consumer forgot.
2035#[derive(thiserror::Error, Debug, IsVariant, Unwrap, TryUnwrap)]
2036#[unwrap(ref, ref_mut)]
2037#[try_unwrap(ref, ref_mut)]
2038#[non_exhaustive]
2039pub enum VideoDecodeError {
2040  /// The wrapped decoder (HW or SW) reported an error.
2041  #[error(transparent)]
2042  Decode(#[from] Error),
2043  /// Frame conversion from FFmpeg's native types to mediadecode's
2044  /// types failed.
2045  #[error(transparent)]
2046  Convert(#[from] ConvertError),
2047  /// A **post-commit** HW->SW fallback degraded the stream but the
2048  /// software decoder reached EOF without ever producing a frame.
2049  #[error(transparent)]
2050  PostCommitNeverResynced(#[from] PostCommitNeverResynced),
2051}
2052
2053#[cfg(test)]
2054mod tests;