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(¶meters).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(¶meters).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;