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//! FfmpegBuffer>` 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::{Timebase, decoder::VideoStreamDecoder, frame::VideoFrame, packet::VideoPacket};
87
88use crate::{
89  Error, Ffmpeg, FfmpegBuffer, Frame, VideoDecoder, boundary,
90  convert::{self, ConvertError},
91  decoder::{build_codec_context, try_clone_parameters},
92  error::FallbackFailed,
93  extras::{VideoFrameExtra, VideoPacketExtra},
94  frame::alloc_av_video_frame,
95};
96
97/// `mediadecode::VideoStreamDecoder` impl with transparent HW → SW
98/// fallback.
99pub struct FfmpegVideoStreamDecoder {
100  state: DecodeState,
101  /// Codec parameters retained so we can open a software
102  /// `ffmpeg::decoder::Video` if the HW probe exhausts.
103  parameters: Parameters,
104  /// HW-side scratch frame (filled by [`VideoDecoder::receive_frame`]).
105  hw_scratch: Frame,
106  /// SW-side scratch frame (filled by `ffmpeg::decoder::Video::receive_frame`).
107  sw_scratch: frame::Video,
108  /// Frames produced while draining the SW decoder during fallback
109  /// replay (see [`Self::fall_back_to_sw`]). The trait's
110  /// `receive_frame` delivers from this queue before pulling new
111  /// frames from the SW decoder. Empty in steady-state operation.
112  sw_replay_frames: VecDeque<frame::Video>,
113  /// `true` once `send_eof` has been called on the active decoder.
114  /// Used to propagate EOF to the SW decoder when fallback fires
115  /// during the drain phase — without this, codecs that hold tail
116  /// frames at EOF would hang waiting for an EOF they already saw on
117  /// the HW path.
118  eof_sent: bool,
119  /// `true` between a **post-commit** fallback firing and a *keyframe-anchored*
120  /// resync (the SW decoder delivering a frame **after** a keyframe was fed to
121  /// it across the gap). A post-commit fallback opens SW cold and drops the
122  /// bounded span up to the next keyframe; the promise is that the span is
123  /// *bounded* — SW resyncs at that keyframe. This flag makes the promise
124  /// enforced rather than assumed: while it is set we have no proof SW ever
125  /// recovered from a real keyframe. It is cleared only when SW delivers a frame
126  /// *and* [`Self::degraded_keyframe_seen`] is set (a lone concealed P-frame a
127  /// lenient codec emits from the gap does **not** clear it); if EOF is reached
128  /// while it is still set the loss is escalated (a distinct loud error) rather
129  /// than silently swallowing the whole tail. Probe-era fallbacks never set it —
130  /// they replay losslessly and produce frames immediately.
131  degraded_resync_pending: bool,
132  /// `true` once a **keyframe** packet has been successfully fed to the SW
133  /// decoder while [`Self::degraded_resync_pending`] is set — i.e. a real resync
134  /// anchor crossed the gap. The pending flag clears only on a delivered SW
135  /// frame *after* this is set, so a concealed non-keyframe frame (a lenient
136  /// codec decoding a lone P-frame from the dropped span) cannot masquerade as a
137  /// resync and prematurely clear the guard. Set alongside `enter`/cleared with
138  /// the pending flag.
139  degraded_keyframe_seen: bool,
140  /// Packets fed to the SW decoder since the post-commit fallback fired while
141  /// [`Self::degraded_resync_pending`] is set — i.e. across the unresolved
142  /// resync gap. Reported in the escalation message so the lost span is
143  /// quantified ("N packets, no keyframe found"). Reset whenever the flag
144  /// clears or on `flush`.
145  degraded_packets_since_fallback: u64,
146  /// Source-stream time base, used to label produced frames.
147  time_base: Timebase,
148}
149
150/// Hardware-decode seam behind [`DecodeState::Hw`]. In production this is
151/// the real [`VideoDecoder`]; tests substitute a fake to drive the
152/// post-commit fallback path without a live GPU. Mirrors the subset of
153/// `VideoDecoder`'s surface the wrapper drives on the HW path.
154pub(crate) trait HwInner: Send {
155  /// See [`VideoDecoder::send_packet`].
156  fn send_packet(&mut self, packet: &Packet) -> Result<(), Error>;
157  /// See [`VideoDecoder::receive_frame`].
158  fn receive_frame(&mut self, frame: &mut Frame) -> Result<(), Error>;
159  /// See [`VideoDecoder::send_eof`].
160  fn send_eof(&mut self) -> Result<(), Error>;
161  /// See [`VideoDecoder::flush`]. Returns `Result` for a uniform seam even
162  /// though the inherent method is infallible.
163  fn flush(&mut self) -> Result<(), Error>;
164  /// Downcast to the concrete [`VideoDecoder`] when this seam is the real
165  /// HW decoder, so [`FfmpegVideoStreamDecoder::hardware_inner`] can keep
166  /// exposing it. Returns `None` for a test fake.
167  fn as_video_decoder(&self) -> Option<&VideoDecoder>;
168}
169
170impl HwInner for VideoDecoder {
171  #[inline]
172  fn send_packet(&mut self, packet: &Packet) -> Result<(), Error> {
173    VideoDecoder::send_packet(self, packet)
174  }
175  #[inline]
176  fn receive_frame(&mut self, frame: &mut Frame) -> Result<(), Error> {
177    VideoDecoder::receive_frame(self, frame)
178  }
179  #[inline]
180  fn send_eof(&mut self) -> Result<(), Error> {
181    VideoDecoder::send_eof(self)
182  }
183  #[inline]
184  fn flush(&mut self) -> Result<(), Error> {
185    VideoDecoder::flush(self);
186    Ok(())
187  }
188  #[inline]
189  fn as_video_decoder(&self) -> Option<&VideoDecoder> {
190    Some(self)
191  }
192}
193
194/// Internal: which backend is currently driving the decode.
195enum DecodeState {
196  /// Hardware-backed decoder (auto-probe). May transition to `Sw` on
197  /// `AllBackendsFailed`. Boxed behind [`HwInner`] so tests can inject a
198  /// fake HW decoder.
199  Hw(Box<dyn HwInner>),
200  /// Software decoder. Terminal state.
201  Sw(ffmpeg_next::decoder::Video),
202}
203
204/// What the cold SW decoder is fed on a **post-commit** degrade transition,
205/// named by the failure arm so the three shapes stay mutually exclusive (a
206/// current packet and EOF are never forwarded together). The post-commit path
207/// retains no replay frames, so this is the *only* thing handed to the new SW
208/// decoder at fallback time. See [`FfmpegVideoStreamDecoder::degrade_to_sw`].
209enum PostCommitInput<'a> {
210  /// `send_packet` arm: forward this current packet — the one the HW decoder
211  /// refused (so it was never in any replay set). If it is a keyframe it is the
212  /// resync anchor.
213  Packet(&'a Packet),
214  /// `receive_frame` arm: a frame-time failure has no current packet to forward.
215  FrameTime,
216  /// `send_eof` arm: EOF was pending on the HW path; re-forward it to the cold
217  /// SW so tail-delaying codecs don't hang.
218  Eof,
219}
220
221impl FfmpegVideoStreamDecoder {
222  /// Opens a decoder for the given codec parameters with the default
223  /// HW backend probe order. If the HW probe can't open any backend,
224  /// falls back to a software `ffmpeg::decoder::Video` immediately —
225  /// `open` only returns `Err` when both paths fail.
226  ///
227  /// Subsequent mid-stream `AllBackendsFailed` from the HW path
228  /// triggers the same SW fallback (with rescued packets replayed).
229  pub fn open(parameters: Parameters, time_base: Timebase) -> Result<Self, Error> {
230    // ffmpeg-next's `Parameters` carries an optional `owner: Rc<dyn Any>`
231    // (when constructed from `stream.parameters()` it points back at
232    // the demuxer's `AVStream`). Upstream marks the type `Send`
233    // anyway, which is unsound the moment a non-`None` owner is in
234    // play — moving such a value across threads moves the `Rc`. We
235    // sidestep this by always storing a deep-cloned `Parameters`
236    // (`avcodec_parameters_copy` produces an owner-free copy), so
237    // the `FfmpegVideoStreamDecoder`'s `Send` reachability never
238    // depends on the caller's owner discipline.
239    //
240    // Use `try_clone_parameters` instead of `Parameters::clone` —
241    // ffmpeg-next's `clone` calls `Parameters::new()` which can
242    // return a `Parameters` whose inner pointer is null on OOM
243    // (`avcodec_parameters_alloc` returns null without indication);
244    // the subsequent `avcodec_parameters_copy` against that null
245    // destination is C UB. Our checked helper surfaces the OOM as
246    // an error instead.
247    let owned_parameters = try_clone_parameters(&parameters).map_err(Error::Ffmpeg)?;
248    let hw_scratch = Frame::empty()?;
249    let sw_scratch = alloc_av_video_frame()?;
250    let state =
251      match VideoDecoder::open(try_clone_parameters(&owned_parameters).map_err(Error::Ffmpeg)?) {
252        Ok(hw) => DecodeState::Hw(Box::new(hw)),
253        Err(Error::AllBackendsFailed(_)) => {
254          // Open-time HW exhaustion: no rescued packets (open didn't
255          // see any). Just open SW directly from our owned copy.
256          let sw = open_sw_decoder(&owned_parameters)?;
257          DecodeState::Sw(sw)
258        }
259        Err(other) => return Err(other),
260      };
261    Ok(Self {
262      state,
263      parameters: owned_parameters,
264      hw_scratch,
265      sw_scratch,
266      sw_replay_frames: VecDeque::new(),
267      eof_sent: false,
268      degraded_resync_pending: false,
269      degraded_keyframe_seen: false,
270      degraded_packets_since_fallback: 0,
271      time_base,
272    })
273  }
274
275  /// Returns `true` when this decoder has fallen back to the software
276  /// path. `false` while still on the HW probe (the initial state).
277  #[cfg_attr(not(tarpaulin), inline(always))]
278  pub const fn is_software(&self) -> bool {
279    matches!(self.state, DecodeState::Sw(_))
280  }
281
282  /// Returns `true` while the HW probe is still active.
283  #[cfg_attr(not(tarpaulin), inline(always))]
284  pub const fn is_hardware(&self) -> bool {
285    matches!(self.state, DecodeState::Hw(_))
286  }
287
288  /// Borrow the inner [`VideoDecoder`] when this decoder is still on the
289  /// real HW path. Returns `None` after the SW fallback has fired (or, in
290  /// tests, when the HW seam is a fake rather than a real decoder).
291  #[cfg_attr(not(tarpaulin), inline(always))]
292  pub fn hardware_inner(&self) -> Option<&VideoDecoder> {
293    match &self.state {
294      DecodeState::Hw(hw) => hw.as_video_decoder(),
295      DecodeState::Sw(_) => None,
296    }
297  }
298
299  /// Returns the time base associated with the source stream.
300  #[cfg_attr(not(tarpaulin), inline(always))]
301  pub const fn time_base(&self) -> Timebase {
302    self.time_base
303  }
304
305  /// Internal: **probe-era** transition from HW to SW. Replays the rescued
306  /// packets (the inner decoder's buffered history, already accepted by the HW
307  /// probe but not yet decoded) through the new SW decoder so the stream resumes
308  /// seamlessly. No frame was delivered on the HW path yet, so replaying the
309  /// history is lossless.
310  ///
311  /// Only the probe-era branches drive this. The **post-commit** path does
312  /// *not* — it retains and reconstructs zero frames, opening SW cold via
313  /// [`Self::degrade_to_sw`] and resyncing at the next keyframe instead of
314  /// replaying. (That is why this method's replay/drain machinery — and the
315  /// finding that the in-transaction drain doesn't cover later frame
316  /// *conversion* — cannot affect the post-commit path: it never produces a
317  /// post-commit replay frame to convert.)
318  ///
319  /// **Transactional**: drained replay frames accumulate in a local
320  /// queue; we only commit them to `self.sw_replay_frames` and switch
321  /// `self.state` to `Sw` after the replay (and EOF re-forwarding, if
322  /// needed) succeed. On failure, the SW decoder, the local frame
323  /// queue, and (where reachable) any consumed packets are dropped —
324  /// `self` is left in its prior state.
325  ///
326  /// **EOF-aware**: when EOF was already accepted on the HW path
327  /// (`self.eof_sent`), the new SW decoder also receives `send_eof()`
328  /// after replay. Without this, codecs that delay tail frames hang
329  /// forever in the drain phase.
330  ///
331  /// **EAGAIN-aware**: if SW's `send_packet` returns EAGAIN during
332  /// replay, drain produced frames into the local queue and retry.
333  ///
334  /// `eof_pending` is passed as a **local** argument rather than read from
335  /// `self.eof_sent`: callers must not mutate `self.eof_sent` before this
336  /// transaction commits (see [`VideoStreamDecoder::send_eof`]), so the
337  /// in-transaction SW EOF re-forward keys off the local flag and `self`'s
338  /// EOF state is updated only after a clean commit.
339  fn fall_back_to_sw(
340    &mut self,
341    unconsumed_packets: std::vec::Vec<ffmpeg_next::Packet>,
342    eof_pending: bool,
343  ) -> Result<(), Error> {
344    tracing::info!(
345      packets_replayed = unconsumed_packets.len(),
346      eof_pending,
347      "mediadecode-ffmpeg: HW probe exhausted, falling back to software decode",
348    );
349    // Wrap the internal worker so any failure path returns the
350    // rescued packets to the caller via `Error::FallbackFailed`.
351    // Without this, non-seekable streams (live feeds, pipes) would
352    // lose every compressed byte the HW path had consumed when a
353    // fallback transition fails partway.
354    match self.fall_back_to_sw_inner(&unconsumed_packets, eof_pending) {
355      Ok(()) => Ok(()),
356      Err(source) => Err(Error::FallbackFailed(FallbackFailed::new(
357        Box::new(source),
358        unconsumed_packets,
359      ))),
360    }
361  }
362
363  /// Worker for [`Self::fall_back_to_sw`]. Returns the rescued packets
364  /// untouched on the borrowed slice; the wrapper takes ownership of
365  /// them and surfaces them in `FallbackFailed` if this returns Err.
366  fn fall_back_to_sw_inner(
367    &mut self,
368    unconsumed_packets: &[ffmpeg_next::Packet],
369    eof_pending: bool,
370  ) -> Result<(), Error> {
371    let mut sw = open_sw_decoder(&self.parameters)?;
372    let mut local_replay: VecDeque<frame::Video> = VecDeque::new();
373    // Helper: drain SW into the local replay queue, capped at
374    // `SW_REPLAY_FRAME_CAP`.
375    //
376    // Error discipline: stop the drain **only** on the transient
377    // backpressure signals EAGAIN / EOF (the decoder has no more output for
378    // now). Every other `ffmpeg_next::Error` — e.g. `InvalidData` from a
379    // corrupt replayed packet — is a real decode failure and is propagated,
380    // so a non-recoverable error surfaces as `FallbackFailed` (carrying the
381    // replay packets) instead of being silently swallowed and the fallback
382    // committed over corruption.
383    fn drain_into(
384      sw: &mut ffmpeg_next::decoder::Video,
385      local_replay: &mut VecDeque<frame::Video>,
386    ) -> std::result::Result<(), Error> {
387      loop {
388        let mut tmp = alloc_av_video_frame()?;
389        match sw.receive_frame(&mut tmp) {
390          Ok(()) => {
391            if local_replay.len() >= SW_REPLAY_FRAME_CAP {
392              tracing::error!(
393                cap = SW_REPLAY_FRAME_CAP,
394                "mediadecode-ffmpeg: SW fallback replay produced more frames than the \
395                 replay cap allows; aborting fallback (no frames dropped — they're \
396                 still in the SW decoder's internal queue and will be released when \
397                 it drops)",
398              );
399              return Err(Error::Ffmpeg(ffmpeg_next::Error::Other {
400                errno: libc::ENOMEM,
401              }));
402            }
403            local_replay.push_back(tmp);
404          }
405          // EAGAIN / EOF: no more output for now — stop draining, success.
406          Err(ffmpeg_next::Error::Other { errno }) if errno == ffmpeg_next::error::EAGAIN => {
407            break;
408          }
409          Err(ffmpeg_next::Error::Eof) => break,
410          // Any other error is a genuine decode failure on a replayed
411          // packet — surface it so it is not masked as a clean fallback.
412          Err(other) => return Err(Error::Ffmpeg(other)),
413        }
414      }
415      Ok(())
416    }
417
418    for pkt in unconsumed_packets {
419      let mut attempts: u32 = 0;
420      loop {
421        match sw.send_packet(pkt) {
422          Ok(()) => break,
423          Err(ffmpeg_next::Error::Other { errno }) if errno == ffmpeg_next::error::EAGAIN => {
424            drain_into(&mut sw, &mut local_replay)?;
425            attempts += 1;
426            if attempts > 16 {
427              return Err(Error::Ffmpeg(ffmpeg_next::Error::Other {
428                errno: ffmpeg_next::error::EAGAIN,
429              }));
430            }
431          }
432          Err(other) => return Err(Error::Ffmpeg(other)),
433        }
434      }
435    }
436    // Re-forward EOF if the HW path already saw it. SW EOF can also
437    // return EAGAIN until prior output is drained — mirror the
438    // packet-replay loop.
439    if eof_pending {
440      let mut attempts: u32 = 0;
441      loop {
442        match sw.send_eof() {
443          Ok(()) => break,
444          Err(ffmpeg_next::Error::Other { errno }) if errno == ffmpeg_next::error::EAGAIN => {
445            drain_into(&mut sw, &mut local_replay)?;
446            attempts += 1;
447            if attempts > 16 {
448              return Err(Error::Ffmpeg(ffmpeg_next::Error::Other {
449                errno: ffmpeg_next::error::EAGAIN,
450              }));
451            }
452          }
453          Err(other) => return Err(Error::Ffmpeg(other)),
454        }
455      }
456    }
457    // Final drain BEFORE commit — the transactional commit boundary. The
458    // EAGAIN-triggered drains above only fire when SW exerts backpressure mid
459    // replay; a SW decoder that ACCEPTS every replayed packet (and the EOF)
460    // without one then surfaces a non-transient error — `InvalidData` from a
461    // corrupt replayed packet, or any other decode failure — only on the *next*
462    // `receive_frame`. Without this drain that error would land after the
463    // commit (frames appended, `state` flipped to `Sw`, rescued packets
464    // dropped) and reach the caller as a plain decode failure, not
465    // `FallbackFailed` — breaking probe-era recovery on non-seekable input.
466    // Draining to EAGAIN/EOF here forces any such error to surface now, so it is
467    // wrapped as `FallbackFailed` (retaining the rescued packets) and the
468    // decoder stays on HW — nothing is committed. (Only the probe-era path
469    // reaches this; the post-commit path degrades via `degrade_to_sw` and never
470    // replays, so it has no drained frames to commit or convert.)
471    drain_into(&mut sw, &mut local_replay)?;
472    // Commit: only after replay, any EOF forwarding, AND the final drain
473    // succeeded do we move the new SW decoder and queue into `self`.
474    self.sw_replay_frames.append(&mut local_replay);
475    self.state = DecodeState::Sw(sw);
476    Ok(())
477  }
478
479  /// **Post-commit** degrade-and-continue transition: open the SW decoder
480  /// **cold** and forward only the failure-arm's input, retaining and
481  /// reconstructing **zero** frames. This is the whole post-commit path: open
482  /// SW, forward the current packet (or EOF), degrade-track — nothing is drained
483  /// into `sw_replay_frames`, so there is no replayed frame to convert later and
484  /// no terminal-drain transaction to reason about. SW naturally produces no
485  /// frame until the next keyframe arrives across the gap, then decodes normally;
486  /// the failure-point→next-keyframe span is the accepted, logged drop.
487  ///
488  /// **Transactional (SW-open only)**: `self.state` flips to `Sw` *only after*
489  /// `open_sw_decoder` and the input forward succeed. On any failure the new SW
490  /// decoder is dropped and the decoder is left on its prior HW state, the error
491  /// surfaced as [`Error::FallbackFailed`] (with an empty rescue set — a
492  /// post-commit failure never carries unconsumed packets). With no replay-frame
493  /// retention there is nothing else to roll back.
494  ///
495  /// On a clean commit it enters degraded-resync mode (see
496  /// [`Self::enter_degraded_resync`]); if the forwarded current packet is itself
497  /// a keyframe, the resync anchor is recorded immediately
498  /// ([`Self::note_degraded_keyframe`]).
499  fn degrade_to_sw(&mut self, input: PostCommitInput<'_>) -> Result<(), Error> {
500    match self.degrade_to_sw_inner(input) {
501      Ok(()) => Ok(()),
502      // Post-commit rescue is always empty: the probe buffer is gone, and we
503      // retain no replay frames, so there are no packets to hand back.
504      Err(source) => Err(Error::FallbackFailed(FallbackFailed::new(
505        Box::new(source),
506        std::vec::Vec::new(),
507      ))),
508    }
509  }
510
511  /// Worker for [`Self::degrade_to_sw`]. Opens SW cold, forwards the arm's input,
512  /// and on success commits + enters degraded-resync mode. Returns `Err` (and
513  /// commits nothing) if SW cannot open or the forward fails.
514  fn degrade_to_sw_inner(&mut self, input: PostCommitInput<'_>) -> Result<(), Error> {
515    let mut sw = open_sw_decoder(&self.parameters)?;
516    let mut forwarded_keyframe = false;
517    let mut forwarded_packet = false;
518    match input {
519      PostCommitInput::Packet(pkt) => {
520        // The HW decoder REFUSED this packet, so it was never decoded; forward
521        // it to the cold SW. A failure here surfaces (it is not silently
522        // dropped) and rolls back to HW.
523        sw.send_packet(pkt).map_err(Error::Ffmpeg)?;
524        forwarded_keyframe = pkt.is_key();
525        forwarded_packet = true;
526      }
527      // Frame-time failure: there is no current packet to forward.
528      PostCommitInput::FrameTime => {}
529      PostCommitInput::Eof => {
530        // EOF was pending on the HW path; the cold SW must also see it so codecs
531        // that delay tail frames don't hang. A cold decoder (no packets sent)
532        // has no buffered output, so this cannot return EAGAIN.
533        sw.send_eof().map_err(Error::Ffmpeg)?;
534      }
535    }
536    // Commit: only after a clean open + forward.
537    self.state = DecodeState::Sw(sw);
538    self.enter_degraded_resync();
539    if forwarded_keyframe {
540      // The refused current packet was itself the resync anchor.
541      self.note_degraded_keyframe(true);
542    }
543    if forwarded_packet {
544      self.count_degraded_packet();
545    }
546    Ok(())
547  }
548
549  /// Enter post-commit degraded mode after a post-commit fallback commits: the
550  /// SW decoder opened cold and the span up to the next keyframe is being
551  /// dropped. We hold this mode until SW proves a *keyframe-anchored* resync
552  /// (a delivered frame after a keyframe was fed — see
553  /// [`Self::note_degraded_keyframe`] / [`Self::resync_on_frame`]) and the EOF
554  /// escalation in [`VideoStreamDecoder::receive_frame`]. Called only on the
555  /// post-commit path, only after a clean commit. Resets the keyframe-seen anchor
556  /// and the gap counter.
557  #[inline]
558  fn enter_degraded_resync(&mut self) {
559    self.degraded_resync_pending = true;
560    self.degraded_keyframe_seen = false;
561    self.degraded_packets_since_fallback = 0;
562  }
563
564  /// Record that a packet fed to the SW decoder across an unresolved post-commit
565  /// gap was a **keyframe** — the resync anchor. Only a frame delivered *after*
566  /// this clears the pending flag, so a lenient codec's concealed P-frame can't
567  /// masquerade as a resync. A no-op outside degraded mode, or for a
568  /// non-keyframe.
569  #[inline]
570  fn note_degraded_keyframe(&mut self, is_key: bool) {
571    if self.degraded_resync_pending && is_key {
572      self.degraded_keyframe_seen = true;
573    }
574  }
575
576  /// Count one packet fed to the SW decoder while a post-commit resync is still
577  /// unproven, so the EOF escalation can quantify the lost tail. A no-op once
578  /// SW has resynced (the flag is clear).
579  #[inline]
580  fn count_degraded_packet(&mut self) {
581    if self.degraded_resync_pending {
582      self.degraded_packets_since_fallback = self.degraded_packets_since_fallback.saturating_add(1);
583    }
584  }
585
586  /// A SW frame was delivered. Clear post-commit degraded mode **only if** a
587  /// keyframe was fed across the gap ([`Self::degraded_keyframe_seen`]) — that is
588  /// a real keyframe-anchored resync, so the dropped span is now the promised
589  /// *bounded* gap. A frame delivered with no keyframe yet (a concealed P-frame
590  /// from the dropped span) leaves the guard set, so the one-GOP bound stays
591  /// enforced and the EOF escalation still fires if no keyframe ever arrives.
592  /// Idempotent; a no-op outside degraded mode (steady state, probe-era replay).
593  #[inline]
594  fn resync_on_frame(&mut self) {
595    if self.degraded_resync_pending && self.degraded_keyframe_seen {
596      self.clear_degraded_resync();
597    }
598  }
599
600  /// Unconditionally reset post-commit degraded-mode state. Used where the gap
601  /// is moot regardless of resync proof: a `flush` (seek/reset re-anchors the
602  /// stream) and the cleanup after an EOF escalation has already fired (so a
603  /// follow-up poll sees plain EOF, not a repeated escalation). The
604  /// frame-delivery path uses the keyframe-gated [`Self::resync_on_frame`]
605  /// instead.
606  #[inline]
607  fn clear_degraded_resync(&mut self) {
608    self.degraded_resync_pending = false;
609    self.degraded_keyframe_seen = false;
610    self.degraded_packets_since_fallback = 0;
611  }
612
613  /// Internal: convert the active scratch frame into a
614  /// `mediadecode::VideoFrame` and write into `dst`.
615  fn deliver_frame(
616    &mut self,
617    dst: &mut VideoFrame<mediadecode::PixelFormat, VideoFrameExtra, FfmpegBuffer>,
618  ) -> Result<(), VideoDecodeError> {
619    let av_frame = match &mut self.state {
620      DecodeState::Hw(_) => unsafe { self.hw_scratch.as_inner_mut().as_ptr() },
621      DecodeState::Sw(_) => unsafe { self.sw_scratch.as_ptr() },
622    };
623    // SAFETY: the scratch frame is live (just filled by the inner
624    // decoder's `receive_frame`); convert bumps refcounts on each
625    // plane buffer it pulls into the produced VideoFrame so the
626    // scratch can be reused on the next call.
627    let new_frame = unsafe { convert::av_frame_to_video_frame(av_frame, self.time_base) }
628      .map_err(VideoDecodeError::Convert)?;
629    *dst = new_frame;
630    Ok(())
631  }
632}
633
634#[cfg(test)]
635impl FfmpegVideoStreamDecoder {
636  /// Build a decoder around an injected HW seam, bypassing the real probe.
637  /// Lets tests drive the post-commit fallback path with a [`HwInner`] fake
638  /// instead of a live GPU. The SW fallback still opens the **real**
639  /// `ffmpeg::decoder::Video` from `parameters`, so a fallback in these tests
640  /// genuinely decodes.
641  pub(crate) fn from_hw_inner_for_test(
642    hw: Box<dyn HwInner>,
643    parameters: Parameters,
644    time_base: Timebase,
645  ) -> Result<Self, Error> {
646    let owned_parameters = try_clone_parameters(&parameters).map_err(Error::Ffmpeg)?;
647    Ok(Self {
648      state: DecodeState::Hw(hw),
649      parameters: owned_parameters,
650      hw_scratch: Frame::empty()?,
651      sw_scratch: alloc_av_video_frame()?,
652      sw_replay_frames: VecDeque::new(),
653      eof_sent: false,
654      degraded_resync_pending: false,
655      degraded_keyframe_seen: false,
656      degraded_packets_since_fallback: 0,
657      time_base,
658    })
659  }
660
661  /// Whether `send_eof` has been committed on the active decoder. Lets the
662  /// rollback tests assert that a failed EOF fallback restores (never
663  /// half-mutates) `eof_sent`.
664  pub(crate) const fn eof_sent_for_test(&self) -> bool {
665    self.eof_sent
666  }
667
668  /// Whether a post-commit fallback is awaiting a keyframe-anchored resync.
669  /// Lets the escalation tests observe the degraded-resync state machine.
670  pub(crate) const fn degraded_resync_pending_for_test(&self) -> bool {
671    self.degraded_resync_pending
672  }
673
674  /// Whether a keyframe has been fed to the SW decoder across the unresolved
675  /// post-commit gap (the resync anchor). Lets the keyframe-gating test confirm
676  /// a concealed P-frame does not set it (so the resync clear stays blocked).
677  pub(crate) const fn degraded_keyframe_seen_for_test(&self) -> bool {
678    self.degraded_keyframe_seen
679  }
680
681  /// Whether the post-commit path retained any replay frames — must always be
682  /// empty for a post-commit fallback (it retains zero). Lets the finding-1
683  /// dissolution test assert no replay frame was ever queued.
684  pub(crate) fn sw_replay_frames_is_empty_for_test(&self) -> bool {
685    self.sw_replay_frames.is_empty()
686  }
687
688  /// Packets fed to SW across an unresolved post-commit resync gap. Lets the
689  /// counter test confirm packets crossing the gap from the `send_packet` arm
690  /// are tallied (and cleared on resync).
691  pub(crate) const fn degraded_packets_since_fallback_for_test(&self) -> u64 {
692    self.degraded_packets_since_fallback
693  }
694}
695
696impl VideoStreamDecoder for FfmpegVideoStreamDecoder {
697  type Adapter = Ffmpeg;
698  type Buffer = FfmpegBuffer;
699  type Error = VideoDecodeError;
700
701  fn send_packet(
702    &mut self,
703    packet: &VideoPacket<VideoPacketExtra, Self::Buffer>,
704  ) -> Result<(), Self::Error> {
705    let av_pkt = boundary::ffmpeg_packet_from_video_packet(packet)
706      .map_err(|e| VideoDecodeError::Decode(Error::PacketBuild(e)))?;
707    match &mut self.state {
708      DecodeState::Hw(hw) => match hw.send_packet(&av_pkt) {
709        Ok(()) => Ok(()),
710        Err(Error::AllBackendsFailed(p)) => {
711          // Route on the EXPLICIT origin, never on whether `rescued` is empty (a
712          // probe-era first-packet cap trip is *also* empty).
713          if p.origin().is_post_commit() {
714            // Post-commit: DEGRADE AND CONTINUE. No lossless mid-stream
715            // reconstruction — the SW decoder opens cold, retains zero replay
716            // frames, and resyncs at the next keyframe. The current packet (the
717            // one HW REFUSED) is forwarded to that cold SW: if it is the resync
718            // keyframe SW decodes from it, otherwise SW drops it until a keyframe
719            // arrives. The bounded span from here to that keyframe is dropped — a
720            // loudly logged gap (see the `warn!`), not a silent one.
721            tracing::warn!(
722              backend = ?p.attempts().last().map(|(b, _)| *b),
723              pts = ?av_pkt.pts(),
724              "mediadecode-ffmpeg: HW decode failed post-commit; falling back to \
725               software, resyncing at next keyframe — a bounded span of frames \
726               may be dropped at this boundary",
727            );
728            // Transactional SW-open + current-packet forward; degrade-tracking
729            // (incl. keyframe-anchor recording) happens inside on a clean commit.
730            // A failure surfaces `FallbackFailed` and stays on HW.
731            return self
732              .degrade_to_sw(PostCommitInput::Packet(&av_pkt))
733              .map_err(VideoDecodeError::Decode);
734          }
735          // Probe-era: replay the inner decoder's buffered history (lossless —
736          // no frame was delivered yet), then forward the still-unconsumed
737          // current packet to SW.
738          let rescued = p.into_unconsumed_packets();
739          // `eof_pending` is the committed EOF state — never pre-mutated here.
740          let eof_pending = self.eof_sent;
741          self
742            .fall_back_to_sw(rescued, eof_pending)
743            .map_err(VideoDecodeError::Decode)?;
744          // Forward the new (still-unconsumed) current packet to the
745          // freshly-opened SW decoder — the HW decoder REFUSED it, so it was not
746          // in the replay set. A failure here surfaces (it is not silently
747          // dropped).
748          if let DecodeState::Sw(sw) = &mut self.state {
749            sw.send_packet(&av_pkt)
750              .map_err(|e| VideoDecodeError::Decode(Error::Ffmpeg(e)))?;
751          }
752          Ok(())
753        }
754        Err(other) => Err(VideoDecodeError::Decode(other)),
755      },
756      DecodeState::Sw(sw) => {
757        sw.send_packet(&av_pkt)
758          .map_err(|e| VideoDecodeError::Decode(Error::Ffmpeg(e)))?;
759        // A keyframe fed across an unresolved post-commit gap is the resync
760        // anchor; record it so the next delivered frame can clear the guard.
761        self.note_degraded_keyframe(av_pkt.is_key());
762        // Count packets crossing an unresolved post-commit resync gap so the
763        // escalation at EOF can report how much tail was lost.
764        self.count_degraded_packet();
765        Ok(())
766      }
767    }
768  }
769
770  fn receive_frame(
771    &mut self,
772    dst: &mut VideoFrame<mediadecode::PixelFormat, VideoFrameExtra, Self::Buffer>,
773  ) -> Result<(), Self::Error> {
774    // Deliver any frames produced during SW fallback replay before
775    // pulling new ones from the SW decoder. This is the queue
776    // populated by `fall_back_to_sw` when SW returned EAGAIN during
777    // packet replay — a **probe-era** path only (the post-commit path retains
778    // no replay frames), so `resync_on_frame` here is a no-op (probe-era never
779    // enters degraded mode).
780    if let Some(replayed) = self.sw_replay_frames.pop_front() {
781      // SAFETY: `replayed` is a live AVFrame owned by us; convert
782      // bumps refcounts on each plane buffer.
783      let new_frame =
784        unsafe { convert::av_frame_to_video_frame(replayed.as_ptr(), self.time_base) }
785          .map_err(VideoDecodeError::Convert)?;
786      self.resync_on_frame();
787      *dst = new_frame;
788      return Ok(());
789    }
790    loop {
791      match &mut self.state {
792        DecodeState::Hw(hw) => match hw.receive_frame(&mut self.hw_scratch) {
793          Ok(()) => return self.deliver_frame(dst),
794          Err(Error::AllBackendsFailed(p)) => {
795            // HW exhausted at frame-time. There is no current packet here.
796            // Route on the explicit origin.
797            if p.origin().is_post_commit() {
798              // Post-commit: DEGRADE AND CONTINUE — open SW cold (no current
799              // packet to forward, no replay frames retained) and resync at the
800              // next keyframe, dropping the bounded span up to it. Loud single
801              // `warn!` marks that accepted gap. A clean commit enters degraded
802              // mode; a SW-open failure surfaces `FallbackFailed` and stays HW.
803              tracing::warn!(
804                backend = ?p.attempts().last().map(|(b, _)| *b),
805                "mediadecode-ffmpeg: HW decode failed post-commit at frame-time; \
806                 falling back to software, resyncing at next keyframe — a bounded \
807                 span of frames may be dropped at this boundary",
808              );
809              self
810                .degrade_to_sw(PostCommitInput::FrameTime)
811                .map_err(VideoDecodeError::Decode)?;
812              // Nothing to deliver yet — fall through to the loop; the next
813              // iteration takes the Sw arm and pulls from the cold SW decoder.
814              continue;
815            }
816            // Probe-era: replay the buffered history (lossless).
817            let rescued = p.into_unconsumed_packets();
818            // `eof_pending` is the committed EOF state — never pre-mutated here.
819            let eof_pending = self.eof_sent;
820            self
821              .fall_back_to_sw(rescued, eof_pending)
822              .map_err(VideoDecodeError::Decode)?;
823            // If the replay produced any drained frames, return one
824            // immediately — preserves stream order vs. whatever the
825            // SW decoder will produce next.
826            if let Some(replayed) = self.sw_replay_frames.pop_front() {
827              // SAFETY: `replayed` is a live AVFrame owned by us; convert bumps
828              // refcounts on each plane buffer.
829              let new_frame =
830                unsafe { convert::av_frame_to_video_frame(replayed.as_ptr(), self.time_base) }
831                  .map_err(VideoDecodeError::Convert)?;
832              self.resync_on_frame();
833              *dst = new_frame;
834              return Ok(());
835            }
836            // Fall through to the loop; next iteration takes the Sw arm.
837          }
838          Err(other) => return Err(VideoDecodeError::Decode(other)),
839        },
840        DecodeState::Sw(sw) => {
841          // Convert inline (rather than via `deliver_frame`, which borrows all
842          // of `self`) so only the disjoint fields `sw_scratch` / `time_base`
843          // are touched alongside the `self.state` borrow `sw` holds.
844          match sw.receive_frame(&mut self.sw_scratch) {
845            Ok(()) => {
846              // SAFETY: the scratch frame is live (just filled by
847              // `receive_frame`); convert bumps plane refcounts so the
848              // scratch can be reused on the next call.
849              let new_frame = unsafe {
850                convert::av_frame_to_video_frame(self.sw_scratch.as_ptr(), self.time_base)
851              }
852              .map_err(VideoDecodeError::Convert)?;
853              // SW produced a frame. Clear degraded mode only if a keyframe was
854              // fed across the gap — a real keyframe-anchored resync, so the
855              // dropped span is the promised bounded gap. A concealed P-frame
856              // (no keyframe yet) does not clear it (see `resync_on_frame`).
857              self.resync_on_frame();
858              *dst = new_frame;
859              return Ok(());
860            }
861            // EOF while a post-commit resync is still unproven: SW never emitted
862            // a frame between the fallback and end-of-stream, so no keyframe
863            // arrived across the gap and the ENTIRE tail was lost — not the
864            // bounded span the degrade-and-continue path promises. Escalate
865            // loudly with a distinct error instead of surfacing a clean `Eof`
866            // that would silently swallow the tail. (Resync clears the flag, so
867            // a normal degraded-then-recovered stream reaches EOF with the flag
868            // already clear and takes the plain `Eof` path below.)
869            Err(ffmpeg_next::Error::Eof) if self.degraded_resync_pending => {
870              let packets_lost = self.degraded_packets_since_fallback;
871              tracing::error!(
872                packets_lost,
873                "mediadecode-ffmpeg: post-commit HW->SW fallback never resynced before EOF — \
874                 {packets_lost} packets fed to the software decoder produced no frame (no \
875                 keyframe found across the gap); the stream tail from the fallback point was \
876                 lost",
877              );
878              // Clear so a subsequent `receive_frame` poll (callers often drain
879              // to EOF) sees plain EOF, not a repeated escalation.
880              self.clear_degraded_resync();
881              return Err(VideoDecodeError::PostCommitNeverResynced(
882                PostCommitNeverResynced::new(packets_lost),
883              ));
884            }
885            Err(e) => return Err(VideoDecodeError::Decode(Error::Ffmpeg(e))),
886          }
887        }
888      }
889    }
890  }
891
892  fn send_eof(&mut self) -> Result<(), Self::Error> {
893    let outcome = match &mut self.state {
894      DecodeState::Hw(hw) => match hw.send_eof() {
895        Ok(()) => Ok(()),
896        Err(Error::AllBackendsFailed(p)) => {
897          // EOF is pending for this transaction, so the SW decoder must also
898          // receive `send_eof` (codecs that delay tail frames hang otherwise).
899          // We pass that intent locally rather than pre-setting `self.eof_sent`:
900          // a fallback that fails returns `FallbackFailed` and stays on HW, and a
901          // half-mutated `self.eof_sent = true` would then make a *later*
902          // fallback inject an EOF into SW even though this `send_eof` errored.
903          // `self.eof_sent` is committed only after the whole operation succeeds
904          // (the `outcome` check below), keeping the fallback all-or-nothing.
905          if p.origin().is_post_commit() {
906            // Post-commit: DEGRADE AND CONTINUE — open SW cold, re-forward EOF
907            // (no current packet, no replay frames). The cold SW produces no
908            // frame from EOF alone, so the drain-to-EOF in `receive_frame`
909            // escalates (`PostCommitNeverResynced`) unless a later keyframe-fed
910            // poll resyncs first. A clean commit enters degraded mode; a SW-open
911            // failure surfaces `FallbackFailed` and stays HW.
912            tracing::warn!(
913              backend = ?p.attempts().last().map(|(b, _)| *b),
914              "mediadecode-ffmpeg: HW decode failed post-commit at EOF; falling \
915               back to software — a bounded span of tail frames may be dropped",
916            );
917            self
918              .degrade_to_sw(PostCommitInput::Eof)
919              .map_err(VideoDecodeError::Decode)
920          } else {
921            // Probe-era: replay the buffered history (lossless), re-forwarding
922            // EOF inside the transaction.
923            let rescued = p.into_unconsumed_packets();
924            self
925              .fall_back_to_sw(rescued, true)
926              .map_err(VideoDecodeError::Decode)
927          }
928        }
929        Err(other) => Err(VideoDecodeError::Decode(other)),
930      },
931      DecodeState::Sw(sw) => sw
932        .send_eof()
933        .map_err(|e| VideoDecodeError::Decode(Error::Ffmpeg(e))),
934    };
935    // Commit EOF state only on success — a failed fallback left `self.eof_sent`
936    // untouched (restored-by-construction: we never mutated it), so HW stays
937    // EOF-not-yet-sent and a retry behaves correctly.
938    if outcome.is_ok() {
939      self.eof_sent = true;
940    }
941    outcome
942  }
943
944  fn flush(&mut self) -> Result<(), Self::Error> {
945    // Drop any frames buffered during SW fallback replay before
946    // flushing the inner decoder — otherwise a seek/reset would
947    // surface stale pre-flush frames on the next `receive_frame`.
948    self.sw_replay_frames.clear();
949    // Flush ends the drain phase; the decoder accepts new packets
950    // after this, so reset EOF tracking.
951    self.eof_sent = false;
952    // A flush (seek/reset) re-anchors the stream — any in-flight post-commit
953    // resync tracking from before the flush is moot. Clear it so the next EOF
954    // doesn't escalate over a now-irrelevant pre-flush gap.
955    self.clear_degraded_resync();
956    match &mut self.state {
957      // The HW seam's `flush` returns `Result` for a uniform trait; the
958      // real `VideoDecoder::flush` is infallible (always `Ok`).
959      DecodeState::Hw(hw) => hw.flush().map_err(VideoDecodeError::Decode)?,
960      DecodeState::Sw(sw) => sw.flush(),
961    }
962    Ok(())
963  }
964}
965
966fn open_sw_decoder(parameters: &Parameters) -> Result<ffmpeg_next::decoder::Video, Error> {
967  // Use the checked codec-context builder — ffmpeg-next's
968  // `Context::from_parameters` calls `Context::new()` which doesn't
969  // null-check `avcodec_alloc_context3`'s return value before
970  // running `avcodec_parameters_to_context` against it. Under
971  // memory pressure that's C-level UB; `build_codec_context`
972  // surfaces the OOM as an error instead.
973  let ctx = build_codec_context(parameters)?;
974  ctx.decoder().video().map_err(Error::Ffmpeg)
975}
976
977/// Payload for [`VideoDecodeError::PostCommitNeverResynced`].
978///
979/// A **post-commit** HW->SW fallback degraded the stream (dropping the
980/// bounded span up to the next keyframe) but the software decoder
981/// reached EOF without ever producing a frame — it never resynced, so
982/// the entire tail from the failure point was lost. The "bounded,
983/// logged gap" the post-commit path promises did not materialise (no
984/// keyframe arrived before EOF), so the loss is surfaced loudly here
985/// instead of being silently swallowed as a clean end-of-stream.
986#[derive(thiserror::Error, Debug)]
987#[error(
988  "post-commit HW->SW fallback never resynced before EOF: {packets_lost} packets fed to the \
989   software decoder produced no frame (no keyframe found across the gap) — the stream tail \
990   from the fallback point was lost"
991)]
992pub struct PostCommitNeverResynced {
993  packets_lost: u64,
994}
995
996impl PostCommitNeverResynced {
997  /// Constructs a `PostCommitNeverResynced` payload.
998  #[inline]
999  pub const fn new(packets_lost: u64) -> Self {
1000    Self { packets_lost }
1001  }
1002  /// Packets fed to the software decoder across the unresolved resync
1003  /// gap.
1004  #[inline]
1005  pub const fn packets_lost(&self) -> u64 {
1006    self.packets_lost
1007  }
1008}
1009
1010/// Error type for [`FfmpegVideoStreamDecoder`].
1011#[derive(thiserror::Error, Debug, IsVariant, Unwrap, TryUnwrap)]
1012#[unwrap(ref, ref_mut)]
1013#[try_unwrap(ref, ref_mut)]
1014pub enum VideoDecodeError {
1015  /// The wrapped decoder (HW or SW) reported an error.
1016  #[error(transparent)]
1017  Decode(#[from] Error),
1018  /// Frame conversion from FFmpeg's native types to mediadecode's
1019  /// types failed.
1020  #[error(transparent)]
1021  Convert(#[from] ConvertError),
1022  /// A **post-commit** HW->SW fallback degraded the stream but the
1023  /// software decoder reached EOF without ever producing a frame.
1024  #[error(transparent)]
1025  PostCommitNeverResynced(#[from] PostCommitNeverResynced),
1026}
1027
1028#[cfg(test)]
1029mod tests;