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