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