use super::*;
use mediadecode::decoder::VideoStreamDecoder;
use std::num::NonZeroU32;
struct SyntheticClip {
parameters: ffmpeg_next::codec::Parameters,
packets: Vec<Packet>,
}
fn encode_synthetic_clip(width: u32, height: u32, frames: usize, gop: u32) -> SyntheticClip {
use ffmpeg_next as ff;
ff::init().expect("ffmpeg init");
let codec = ff::codec::encoder::find(ff::codec::Id::MPEG4).expect("mpeg4 encoder present");
let ctx = ff::codec::context::Context::new_with_codec(codec);
let mut enc = ctx.encoder().video().expect("video encoder context");
enc.set_width(width);
enc.set_height(height);
enc.set_format(ff::format::Pixel::YUV420P);
enc.set_time_base(ff::Rational::new(1, 25));
enc.set_gop(gop);
enc.set_max_b_frames(0);
enc.set_bit_rate(500_000);
let mut opened = enc.open_as(codec).expect("open encoder");
let parameters = ff::codec::Parameters::from(&opened);
let mut packets: Vec<Packet> = Vec::new();
let drain = |opened: &mut ff::codec::encoder::Video, out: &mut Vec<Packet>| {
loop {
let mut pkt = Packet::empty();
match opened.receive_packet(&mut pkt) {
Ok(()) => out.push(pkt),
Err(_) => break,
}
}
};
let mut frame = ff::frame::Video::new(ff::format::Pixel::YUV420P, width, height);
for i in 0..frames as i64 {
let ystride = frame.stride(0);
{
let data = frame.data_mut(0);
for y in 0..height as usize {
for x in 0..width as usize {
data[y * ystride + x] = ((x + y + i as usize * 4) & 0xff) as u8;
}
}
}
let cstride = frame.stride(1);
for p in 1..3usize {
let data = frame.data_mut(p);
for y in 0..(height as usize / 2) {
for x in 0..(width as usize / 2) {
data[y * cstride + x] = (128 + ((x as i64 - i) & 0x3f)) as u8;
}
}
}
frame.set_pts(Some(i));
opened.send_frame(&frame).expect("send_frame");
drain(&mut opened, &mut packets);
}
opened.send_eof().expect("encoder send_eof");
drain(&mut opened, &mut packets);
assert!(
packets.len() >= 8,
"synthetic clip needs enough packets ({} too few)",
packets.len()
);
assert!(packets[0].is_key(), "first packet must be a keyframe");
SyntheticClip {
parameters,
packets,
}
}
#[derive(Clone, Copy)]
enum FailShape {
PostCommit,
ProbeEra,
}
struct FakeHw {
width: u32,
height: u32,
doom_from_send: usize,
fail_at_send: usize,
shape: FailShape,
sends: usize,
queued: VecDeque<i64>,
history: Vec<Packet>,
}
impl FakeHw {
fn inert() -> Self {
Self {
width: 0,
height: 0,
doom_from_send: usize::MAX,
fail_at_send: usize::MAX,
shape: FailShape::PostCommit,
sends: 0,
queued: VecDeque::new(),
history: Vec::new(),
}
}
fn failing(
width: u32,
height: u32,
doom_from_send: usize,
fail_at_send: usize,
shape: FailShape,
) -> Self {
Self {
width,
height,
doom_from_send,
fail_at_send,
shape,
sends: 0,
queued: VecDeque::new(),
history: Vec::new(),
}
}
fn never_failing(width: u32, height: u32) -> Self {
Self::failing(width, height, usize::MAX, usize::MAX, FailShape::PostCommit)
}
}
impl HwInner for FakeHw {
fn send_packet(&mut self, packet: &Packet) -> Result<(), Error> {
let idx = self.sends;
self.sends += 1;
if idx == self.fail_at_send {
return match self.shape {
FailShape::PostCommit => Err(Error::AllBackendsFailed(
crate::error::AllBackendsFailed::new_post_commit(Vec::new()),
)),
FailShape::ProbeEra => Err(Error::AllBackendsFailed(
crate::error::AllBackendsFailed::new(Vec::new(), std::mem::take(&mut self.history)),
)),
};
}
if let Ok(cloned) = crate::decoder::try_clone_packet(packet) {
self.history.push(cloned);
}
if idx < self.doom_from_send {
self.queued.push_back(packet.pts().unwrap_or(0));
}
Ok(())
}
fn receive_frame(&mut self, frame: &mut Frame) -> Result<(), Error> {
match self.queued.pop_front() {
Some(pts) => {
let mut av =
frame::Video::new(ffmpeg_next::format::Pixel::YUV420P, self.width, self.height);
av.set_pts(Some(pts));
*frame.as_inner_mut() = av;
Ok(())
}
None => Err(Error::Ffmpeg(ffmpeg_next::Error::Other {
errno: ffmpeg_next::error::EAGAIN,
})),
}
}
fn send_eof(&mut self) -> Result<(), Error> {
Ok(())
}
fn flush(&mut self) -> Result<(), Error> {
self.queued.clear();
Ok(())
}
fn as_video_decoder(&self) -> Option<&VideoDecoder> {
None
}
}
struct FakeHwEofFails {
width: u32,
height: u32,
queued: VecDeque<i64>,
}
impl FakeHwEofFails {
fn new(width: u32, height: u32) -> Self {
Self {
width,
height,
queued: VecDeque::new(),
}
}
}
impl HwInner for FakeHwEofFails {
fn send_packet(&mut self, packet: &Packet) -> Result<(), Error> {
self.queued.push_back(packet.pts().unwrap_or(0));
Ok(())
}
fn receive_frame(&mut self, frame: &mut Frame) -> Result<(), Error> {
match self.queued.pop_front() {
Some(pts) => {
let mut av =
frame::Video::new(ffmpeg_next::format::Pixel::YUV420P, self.width, self.height);
av.set_pts(Some(pts));
*frame.as_inner_mut() = av;
Ok(())
}
None => Err(Error::Ffmpeg(ffmpeg_next::Error::Other {
errno: ffmpeg_next::error::EAGAIN,
})),
}
}
fn send_eof(&mut self) -> Result<(), Error> {
Err(Error::AllBackendsFailed(
crate::error::AllBackendsFailed::new_post_commit(Vec::new()),
))
}
fn flush(&mut self) -> Result<(), Error> {
self.queued.clear();
Ok(())
}
fn as_video_decoder(&self) -> Option<&VideoDecoder> {
None
}
}
fn drive(dec: &mut FfmpegVideoStreamDecoder, clip: &SyntheticClip) -> Vec<i64> {
let mut out: Vec<i64> = Vec::new();
let mut dst = crate::empty_video_frame();
let mut drain_frames = |dec: &mut FfmpegVideoStreamDecoder, out: &mut Vec<i64>| {
loop {
match dec.receive_frame(&mut dst) {
Ok(()) => out.push(dst.pts().map(|t| t.pts()).unwrap_or(i64::MIN)),
Err(VideoDecodeError::Decode(Error::Ffmpeg(ffmpeg_next::Error::Other { errno })))
if errno == ffmpeg_next::error::EAGAIN =>
{
break;
}
Err(VideoDecodeError::Decode(Error::Ffmpeg(ffmpeg_next::Error::Eof))) => break,
Err(e) => panic!("receive_frame: {e:?}"),
}
}
};
for av_pkt in &clip.packets {
let vpkt = boundary::video_packet_from_ffmpeg(av_pkt).expect("packet has a buffer");
dec.send_packet(&vpkt).expect("send_packet");
drain_frames(dec, &mut out);
}
dec.send_eof().expect("send_eof");
drain_frames(dec, &mut out);
out
}
fn nth_keyframe(clip: &SyntheticClip, n: usize) -> usize {
clip
.packets
.iter()
.enumerate()
.filter(|(_, p)| p.is_key())
.nth(n - 1)
.map(|(i, _)| i)
.unwrap_or_else(|| panic!("clip must have at least {n} keyframes (multi-GOP)"))
}
#[test]
fn post_commit_failure_degrades_and_resyncs_at_next_keyframe() {
let (w, h) = (128u32, 96u32);
let clip = encode_synthetic_clip(w, h, 24, 6);
let second_key = nth_keyframe(&clip, 2);
let third_key = nth_keyframe(&clip, 3);
let fail_at = second_key + 2;
assert!(
fail_at < third_key && !clip.packets[fail_at].is_key(),
"fail target must be a mid-GOP P-frame before the next keyframe"
);
let tb = Timebase::new(1, NonZeroU32::new(25).expect("nonzero"));
let mut dec = FfmpegVideoStreamDecoder::from_hw_inner_for_test(
Box::new(FakeHw::failing(
w,
h,
fail_at,
fail_at,
FailShape::PostCommit,
)),
clip.parameters.clone(),
tb,
)
.expect("build test decoder");
assert!(dec.is_hardware(), "must start on the HW seam");
let mut pts_out: Vec<i64> = Vec::new();
let mut dst = crate::empty_video_frame();
let mut drain = |dec: &mut FfmpegVideoStreamDecoder, out: &mut Vec<i64>| loop {
match dec.receive_frame(&mut dst) {
Ok(()) => out.push(dst.pts().map(|t| t.pts()).unwrap_or(i64::MIN)),
Err(VideoDecodeError::Decode(Error::Ffmpeg(ffmpeg_next::Error::Other { errno })))
if errno == ffmpeg_next::error::EAGAIN =>
{
break;
}
Err(VideoDecodeError::Decode(Error::Ffmpeg(ffmpeg_next::Error::Eof))) => break,
Err(e) => panic!("receive_frame: {e:?}"),
}
};
for av_pkt in clip.packets.iter().take(third_key) {
let vpkt = boundary::video_packet_from_ffmpeg(av_pkt).expect("packet has a buffer");
dec.send_packet(&vpkt).expect("send_packet");
drain(&mut dec, &mut pts_out);
}
assert!(
dec.is_software(),
"post-commit HW failure must trigger the SW fallback"
);
assert!(
dec.degraded_resync_pending_for_test(),
"no keyframe fed across the gap yet — the resync guard must still be pending \
(a concealed P-frame must not clear it)"
);
assert!(
!dec.degraded_keyframe_seen_for_test(),
"no keyframe has crossed the gap, so the keyframe-seen anchor must be unset"
);
for av_pkt in clip.packets.iter().skip(third_key) {
let vpkt = boundary::video_packet_from_ffmpeg(av_pkt).expect("packet has a buffer");
dec.send_packet(&vpkt).expect("send_packet");
drain(&mut dec, &mut pts_out);
}
dec.send_eof().expect("send_eof");
drain(&mut dec, &mut pts_out);
assert!(
!dec.degraded_resync_pending_for_test(),
"the keyframe-anchored resync must have cleared the guard before EOF"
);
assert!(
!pts_out.contains(&i64::MIN),
"no delivered frame may have a missing PTS: {pts_out:?}"
);
let third_key_pts = clip.packets[third_key].pts().expect("keyframe has pts");
let total = clip.packets.len() as i64;
let unique: std::collections::HashSet<i64> = pts_out.iter().copied().collect();
assert_eq!(
unique.len(),
pts_out.len(),
"no duplicate PTS — the degrade path must not re-emit a frame: {pts_out:?}"
);
for &pts in &pts_out {
assert!(
(0..total).contains(&pts),
"delivered PTS {pts} is outside the source range 0..{total}: {pts_out:?}"
);
}
for pts in 0..fail_at as i64 {
assert!(
unique.contains(&pts),
"HW delivered PTS {pts} before failing; it must be present: {pts_out:?}"
);
}
for pts in third_key_pts..total {
assert!(
unique.contains(&pts),
"SW must resync at the next keyframe and decode the remainder; PTS {pts} \
(>= resync keyframe {third_key_pts}) is missing — no resync: {pts_out:?}"
);
}
for pts in 0..total {
if !unique.contains(&pts) {
assert!(
(fail_at as i64..third_key_pts).contains(&pts),
"PTS {pts} was dropped but lies OUTSIDE the accepted [fail, keyframe) \
gap [{fail_at}, {third_key_pts}); only the bounded gap may be lost: \
{pts_out:?}"
);
}
}
assert!(
(third_key_pts - fail_at as i64) <= 6,
"the accepted gap must be bounded by ~one GOP; was {}",
third_key_pts - fail_at as i64
);
}
#[test]
fn fake_hw_without_failure_stays_on_hardware() {
let (w, h) = (128u32, 96u32);
let clip = encode_synthetic_clip(w, h, 12, 6);
let tb = Timebase::new(1, NonZeroU32::new(25).expect("nonzero"));
let mut dec = FfmpegVideoStreamDecoder::from_hw_inner_for_test(
Box::new(FakeHw::never_failing(w, h)),
clip.parameters.clone(),
tb,
)
.expect("build test decoder");
let pts_out = drive(&mut dec, &clip);
assert!(dec.is_hardware(), "no failure => stays on the HW seam");
assert_eq!(
pts_out.len(),
clip.packets.len(),
"HW path must deliver one frame per packet"
);
}
#[test]
fn probe_era_failure_replays_history_losslessly() {
let (w, h) = (128u32, 96u32);
let clip = encode_synthetic_clip(w, h, 16, 6);
let fail_at = 5;
assert!(fail_at < clip.packets.len(), "fail target in range");
let tb = Timebase::new(1, NonZeroU32::new(25).expect("nonzero"));
let mut dec = FfmpegVideoStreamDecoder::from_hw_inner_for_test(
Box::new(FakeHw::failing(w, h, 0, fail_at, FailShape::ProbeEra)),
clip.parameters.clone(),
tb,
)
.expect("build test decoder");
assert!(dec.is_hardware(), "must start on the HW seam");
let pts_out = drive(&mut dec, &clip);
assert!(
dec.is_software(),
"probe-era HW failure must trigger the SW fallback"
);
assert!(
!pts_out.contains(&i64::MIN),
"no delivered frame may have a missing PTS: {pts_out:?}"
);
let mut sorted = pts_out.clone();
sorted.sort_unstable();
let expected: Vec<i64> = (0..clip.packets.len() as i64).collect();
assert_eq!(
sorted, expected,
"a probe-era fallback must lose no frames — every source PTS delivered \
exactly once: {pts_out:?}"
);
}
fn unopenable_sw_decoder(hw: Box<dyn HwInner>) -> FfmpegVideoStreamDecoder {
ffmpeg_next::init().expect("ffmpeg init");
let params = ffmpeg_next::codec::Parameters::new();
let tb = Timebase::new(1, NonZeroU32::new(25).expect("nonzero"));
FfmpegVideoStreamDecoder::from_hw_inner_for_test(hw, params, tb).expect("build test decoder")
}
#[test]
fn post_commit_sw_open_failure_stays_on_hw_transactionally() {
let (w, h) = (64u32, 64u32);
let mut dec = unopenable_sw_decoder(Box::new(FakeHw::failing(w, h, 0, 0, FailShape::PostCommit)));
assert!(dec.is_hardware(), "must start on the HW seam");
let mut raw = Packet::new(16);
raw.set_pts(Some(0));
let vpkt = boundary::video_packet_from_ffmpeg(&raw).expect("packet has a buffer");
let err = dec
.send_packet(&vpkt)
.expect_err("SW-open failure must surface an error");
match err {
VideoDecodeError::Decode(Error::FallbackFailed(_)) => {}
other => panic!("expected FallbackFailed on SW-open failure, got {other:?}"),
}
assert!(
dec.is_hardware(),
"a failed fallback (SW could not open) must leave the decoder on its prior \
HW state — transactional rollback, not a half-committed SW"
);
}
fn corrupt_packet_payload(pkt: &mut Packet) {
if let Some(d) = pkt.data_mut() {
for b in d.iter_mut() {
*b = 0;
}
}
}
#[test]
fn sw_replay_drain_surfaces_non_transient_decode_error() {
let (w, h) = (128u32, 96u32);
let mut clip = encode_synthetic_clip(w, h, 12, 100);
let p1 = clip
.packets
.iter()
.position(|p| !p.is_key())
.expect("clip has P-frames");
assert!(
p1 + 2 < clip.packets.len(),
"need packets after the corrupt one"
);
corrupt_packet_payload(&mut clip.packets[p1]);
let fail_at = p1 + 3;
let tb = Timebase::new(1, NonZeroU32::new(25).expect("nonzero"));
let mut dec = FfmpegVideoStreamDecoder::from_hw_inner_for_test(
Box::new(FakeHw::failing(w, h, 0, fail_at, FailShape::ProbeEra)),
clip.parameters.clone(),
tb,
)
.expect("build test decoder");
let mut dst = crate::empty_video_frame();
let mut err = None;
for av_pkt in &clip.packets {
let vpkt = boundary::video_packet_from_ffmpeg(av_pkt).expect("packet has a buffer");
if let Err(e) = dec.send_packet(&vpkt) {
err = Some(e);
break;
}
loop {
match dec.receive_frame(&mut dst) {
Ok(()) => {}
Err(VideoDecodeError::Decode(Error::Ffmpeg(ffmpeg_next::Error::Other { errno })))
if errno == ffmpeg_next::error::EAGAIN =>
{
break;
}
Err(VideoDecodeError::Decode(Error::Ffmpeg(ffmpeg_next::Error::Eof))) => break,
Err(e) => {
err = Some(e);
break;
}
}
}
if err.is_some() {
break;
}
}
let err = err.expect("the corrupt replayed packet must surface an error, not be swallowed");
match err {
VideoDecodeError::Decode(Error::FallbackFailed(f)) => {
assert!(
!f.unconsumed_packets().is_empty(),
"FallbackFailed must carry the replay packets for recovery"
);
assert!(
matches!(f.source(), Error::Ffmpeg(ffmpeg_next::Error::InvalidData)),
"the surfaced error must be the SW InvalidData decode failure; got {:?}",
f.source()
);
}
other => panic!("expected FallbackFailed surfacing InvalidData, got {other:?}"),
}
assert!(
dec.is_hardware(),
"a failed fallback must leave the decoder on its prior (HW) state, not \
commit SW over swallowed corruption"
);
}
#[test]
fn sw_replay_deferred_error_surfaces_fallback_failed_at_commit() {
let (w, h) = (128u32, 96u32);
let mut clip = encode_synthetic_clip(w, h, 12, 100);
let fail_at = 5;
assert!(
fail_at >= 2 && fail_at < clip.packets.len(),
"need a multi-packet history with room for a corrupt tail"
);
let corrupt_idx = fail_at - 1;
assert!(
!clip.packets[corrupt_idx].is_key(),
"the corrupt last-history packet must be a P-frame (a corrupt keyframe \
could fail SW's send_packet instead of receive_frame)"
);
corrupt_packet_payload(&mut clip.packets[corrupt_idx]);
let tb = Timebase::new(1, NonZeroU32::new(25).expect("nonzero"));
let mut dec = FfmpegVideoStreamDecoder::from_hw_inner_for_test(
Box::new(FakeHw::failing(w, h, 0, fail_at, FailShape::ProbeEra)),
clip.parameters.clone(),
tb,
)
.expect("build test decoder");
let mut surfaced = None;
let mut dst = crate::empty_video_frame();
for av_pkt in clip.packets.iter().take(fail_at + 1) {
let vpkt = boundary::video_packet_from_ffmpeg(av_pkt).expect("packet has a buffer");
match dec.send_packet(&vpkt) {
Ok(()) => {
loop {
match dec.receive_frame(&mut dst) {
Ok(()) => {}
Err(VideoDecodeError::Decode(Error::Ffmpeg(ffmpeg_next::Error::Other { errno })))
if errno == ffmpeg_next::error::EAGAIN =>
{
break;
}
Err(VideoDecodeError::Decode(Error::Ffmpeg(ffmpeg_next::Error::Eof))) => break,
Err(e) => {
surfaced = Some(e);
break;
}
}
}
}
Err(e) => {
surfaced = Some(e);
break;
}
}
if surfaced.is_some() {
break;
}
}
let err = surfaced.expect(
"the deferred InvalidData must surface at the fallback commit boundary, not be \
committed over",
);
match err {
VideoDecodeError::Decode(Error::FallbackFailed(f)) => {
assert!(
!f.unconsumed_packets().is_empty(),
"FallbackFailed must retain the rescued replay packets for recovery"
);
assert!(
matches!(f.source(), Error::Ffmpeg(ffmpeg_next::Error::InvalidData)),
"the surfaced error must be the deferred SW InvalidData; got {:?}",
f.source()
);
}
other => panic!("expected FallbackFailed surfacing the deferred InvalidData, got {other:?}"),
}
assert!(
dec.is_hardware(),
"a deferred-error fallback caught at the commit boundary must leave the \
decoder on its prior HW state — nothing committed"
);
}
#[test]
fn failed_eof_fallback_restores_eof_sent_and_stays_on_hw() {
let (w, h) = (64u32, 64u32);
let mut dec = unopenable_sw_decoder(Box::new(FakeHwEofFails::new(w, h)));
assert!(dec.is_hardware(), "must start on the HW seam");
assert!(
!dec.eof_sent_for_test(),
"precondition: eof_sent starts false"
);
let err = dec
.send_eof()
.expect_err("a failed EOF fallback must surface an error");
match err {
VideoDecodeError::Decode(Error::FallbackFailed(_)) => {}
other => panic!("expected FallbackFailed on the failed EOF fallback, got {other:?}"),
}
assert!(
dec.is_hardware(),
"a failed EOF fallback (SW could not open) must leave the decoder on its \
prior HW state — transactional rollback"
);
assert!(
!dec.eof_sent_for_test(),
"eof_sent must be RESTORED to its prior value (false) after a failed EOF \
fallback — a stale true would inject EOF into a later SW fallback"
);
let err2 = dec.send_eof().expect_err(
"the still-HW decoder must re-attempt (and re-fail) EOF, not no-op off stale state",
);
assert!(
matches!(err2, VideoDecodeError::Decode(Error::FallbackFailed(_))),
"second send_eof must again drive the fallback (proving no stale-EOF short-circuit)"
);
assert!(
!dec.eof_sent_for_test(),
"still rolled back after the retry"
);
}
#[test]
fn post_commit_fallback_never_resyncing_escalates_at_eof() {
let (w, h) = (128u32, 96u32);
let clip = encode_synthetic_clip(w, h, 12, 6);
let tb = Timebase::new(1, NonZeroU32::new(25).expect("nonzero"));
let mut dec = FfmpegVideoStreamDecoder::from_hw_inner_for_test(
Box::new(FakeHwEofFails::new(w, h)),
clip.parameters.clone(),
tb,
)
.expect("build test decoder");
let mut dst = crate::empty_video_frame();
let mut delivered = 0usize;
let mut escalation = None;
let mut drain = |dec: &mut FfmpegVideoStreamDecoder,
delivered: &mut usize,
escalation: &mut Option<VideoDecodeError>| {
loop {
match dec.receive_frame(&mut dst) {
Ok(()) => *delivered += 1,
Err(VideoDecodeError::Decode(Error::Ffmpeg(ffmpeg_next::Error::Other { errno })))
if errno == ffmpeg_next::error::EAGAIN =>
{
break;
}
Err(VideoDecodeError::Decode(Error::Ffmpeg(ffmpeg_next::Error::Eof))) => break,
Err(e @ VideoDecodeError::PostCommitNeverResynced { .. }) => {
*escalation = Some(e);
break;
}
Err(e) => panic!("unexpected error draining frames: {e:?}"),
}
}
};
for av_pkt in &clip.packets {
let vpkt = boundary::video_packet_from_ffmpeg(av_pkt).expect("packet has a buffer");
dec.send_packet(&vpkt).expect("send_packet");
drain(&mut dec, &mut delivered, &mut escalation);
assert!(
escalation.is_none(),
"no escalation while still on the HW path"
);
}
assert!(dec.is_hardware(), "still HW until the EOF-time failure");
assert_eq!(
delivered,
clip.packets.len(),
"HW must deliver the whole stream before the EOF-time failure"
);
dec
.send_eof()
.expect("send_eof drives the fallback but itself succeeds");
assert!(
dec.is_software(),
"the EOF-time failure fell back to software"
);
assert!(
dec.degraded_resync_pending_for_test(),
"post-commit fallback at EOF must enter degraded-resync mode (SW opened cold)"
);
drain(&mut dec, &mut delivered, &mut escalation);
let esc = escalation.expect(
"a post-commit fallback whose SW decoder reaches EOF without resyncing must \
ESCALATE, not silently swallow the tail as a clean end-of-stream",
);
let VideoDecodeError::PostCommitNeverResynced { packets_lost } = esc else {
panic!("expected PostCommitNeverResynced, got {esc:?}");
};
assert_eq!(
packets_lost, 0,
"no packets crossed to SW on the EOF-entry path; the lost tail was HW-side"
);
assert!(
dec.is_software(),
"the decoder did fall back to software (it just never resynced)"
);
assert!(
!dec.degraded_resync_pending_for_test(),
"the degraded-resync flag must be cleared after the escalation fires"
);
let mut after = crate::empty_video_frame();
match dec.receive_frame(&mut after) {
Err(VideoDecodeError::Decode(Error::Ffmpeg(ffmpeg_next::Error::Eof))) => {}
other => panic!("a poll after the escalation must be plain EOF, got {other:?}"),
}
}
#[test]
fn post_commit_gap_counter_tallies_then_clears_on_resync() {
let (w, h) = (128u32, 96u32);
let clip = encode_synthetic_clip(w, h, 24, 6);
let second_key = nth_keyframe(&clip, 2);
let third_key = nth_keyframe(&clip, 3);
let fail_at = second_key + 2;
assert!(
fail_at < third_key && !clip.packets[fail_at].is_key(),
"fail target must be a mid-GOP P-frame before the next keyframe"
);
let tb = Timebase::new(1, NonZeroU32::new(25).expect("nonzero"));
let mut dec = FfmpegVideoStreamDecoder::from_hw_inner_for_test(
Box::new(FakeHw::failing(
w,
h,
fail_at,
fail_at,
FailShape::PostCommit,
)),
clip.parameters.clone(),
tb,
)
.expect("build test decoder");
let mut dst = crate::empty_video_frame();
for av_pkt in clip.packets.iter().take(fail_at + 1) {
let vpkt = boundary::video_packet_from_ffmpeg(av_pkt).expect("packet has a buffer");
dec.send_packet(&vpkt).expect("send_packet");
}
assert!(
dec.is_software(),
"the mid-GOP failure fell back to software"
);
assert!(
dec.degraded_resync_pending_for_test(),
"the gap is still open (no resync frame drained yet)"
);
assert_eq!(
dec.degraded_packets_since_fallback_for_test(),
1,
"the forwarded current packet must be tallied as crossing the gap"
);
let mut try_poll = |dec: &mut FfmpegVideoStreamDecoder| -> bool {
match dec.receive_frame(&mut dst) {
Ok(()) => true,
Err(VideoDecodeError::Decode(Error::Ffmpeg(ffmpeg_next::Error::Other { errno })))
if errno == ffmpeg_next::error::EAGAIN =>
{
false
}
Err(VideoDecodeError::Decode(Error::Ffmpeg(ffmpeg_next::Error::Eof))) => false,
Err(e) => panic!("unexpected drain error: {e:?}"),
}
};
while try_poll(&mut dec) {}
assert!(
dec.degraded_resync_pending_for_test(),
"a concealed frame from the forwarded P-frame must NOT clear the guard — no \
keyframe has crossed the gap yet"
);
assert!(
!dec.degraded_keyframe_seen_for_test(),
"no keyframe fed yet, so the keyframe-seen anchor must be unset"
);
for av_pkt in clip.packets[(fail_at + 1)..third_key].iter() {
let vpkt = boundary::video_packet_from_ffmpeg(av_pkt).expect("packet has a buffer");
dec.send_packet(&vpkt).expect("send_packet");
while try_poll(&mut dec) {}
assert!(
dec.degraded_resync_pending_for_test() && !dec.degraded_keyframe_seen_for_test(),
"concealed P-frame frames before the keyframe must not clear the guard or \
set the keyframe anchor"
);
}
assert!(third_key < clip.packets.len(), "clip has a third keyframe");
let key_vpkt =
boundary::video_packet_from_ffmpeg(&clip.packets[third_key]).expect("packet has a buffer");
dec.send_packet(&key_vpkt).expect("send_packet");
assert!(
dec.degraded_keyframe_seen_for_test(),
"feeding the keyframe across the gap must record it as the resync anchor"
);
let mut resynced = !dec.degraded_resync_pending_for_test();
while !resynced && try_poll(&mut dec) {
resynced = !dec.degraded_resync_pending_for_test();
}
for av_pkt in clip.packets[(third_key + 1)..].iter() {
if resynced {
break;
}
let vpkt = boundary::video_packet_from_ffmpeg(av_pkt).expect("packet has a buffer");
dec.send_packet(&vpkt).expect("send_packet");
while !resynced && try_poll(&mut dec) {
resynced = !dec.degraded_resync_pending_for_test();
}
}
assert!(
resynced,
"SW must resync once the keyframe is fed and produce a frame after it"
);
assert!(
!dec.degraded_resync_pending_for_test(),
"the keyframe-anchored resync must clear the pending flag"
);
assert_eq!(
dec.degraded_packets_since_fallback_for_test(),
0,
"resync must reset the gap counter"
);
}
#[test]
fn post_commit_concealed_p_frame_does_not_clear_resync_escalates_at_eof() {
let (w, h) = (128u32, 96u32);
let clip = encode_synthetic_clip(w, h, 24, 6);
let second_key = nth_keyframe(&clip, 2);
let third_key = nth_keyframe(&clip, 3);
let fail_at = second_key + 2;
assert!(
fail_at < third_key && !clip.packets[fail_at].is_key(),
"fail target must be a mid-GOP P-frame before the next keyframe"
);
let tb = Timebase::new(1, NonZeroU32::new(25).expect("nonzero"));
let mut dec = FfmpegVideoStreamDecoder::from_hw_inner_for_test(
Box::new(FakeHw::failing(
w,
h,
fail_at,
fail_at,
FailShape::PostCommit,
)),
clip.parameters.clone(),
tb,
)
.expect("build test decoder");
let mut dst = crate::empty_video_frame();
let mut concealed_frames = 0usize;
let mut escalation: Option<VideoDecodeError> = None;
let mut drain = |dec: &mut FfmpegVideoStreamDecoder,
concealed: &mut usize,
escalation: &mut Option<VideoDecodeError>| loop {
match dec.receive_frame(&mut dst) {
Ok(()) => *concealed += 1,
Err(VideoDecodeError::Decode(Error::Ffmpeg(ffmpeg_next::Error::Other { errno })))
if errno == ffmpeg_next::error::EAGAIN =>
{
break;
}
Err(VideoDecodeError::Decode(Error::Ffmpeg(ffmpeg_next::Error::Eof))) => break,
Err(e @ VideoDecodeError::PostCommitNeverResynced { .. }) => {
*escalation = Some(e);
break;
}
Err(e) => panic!("unexpected drain error: {e:?}"),
}
};
for av_pkt in clip.packets.iter().take(third_key) {
let vpkt = boundary::video_packet_from_ffmpeg(av_pkt).expect("packet has a buffer");
dec.send_packet(&vpkt).expect("send_packet");
drain(&mut dec, &mut concealed_frames, &mut escalation);
assert!(escalation.is_none(), "no escalation before EOF");
if dec.is_software() {
assert!(
dec.degraded_resync_pending_for_test(),
"a concealed P-frame must not clear the keyframe-gated resync guard"
);
assert!(
!dec.degraded_keyframe_seen_for_test(),
"no keyframe was fed, so the keyframe-seen anchor must stay unset"
);
}
}
assert!(
dec.is_software(),
"the post-commit failure fell back to software"
);
assert!(
concealed_frames > 0,
"the cold mpeg4 SW decoder must have concealed at least one frame from the \
lone P-frames (otherwise this test does not exercise the 'frame delivered \
but no keyframe' path)"
);
assert!(
dec.degraded_resync_pending_for_test(),
"after feeding only P-frames the guard must still be pending — the concealed \
frames did NOT count as a resync"
);
dec.send_eof().expect("send_eof on the SW path");
drain(&mut dec, &mut concealed_frames, &mut escalation);
let esc = escalation.expect(
"concealed P-frames must NOT have cleared the guard, so reaching EOF without a \
keyframe must ESCALATE with PostCommitNeverResynced",
);
let VideoDecodeError::PostCommitNeverResynced { packets_lost } = esc else {
panic!("expected PostCommitNeverResynced, got {esc:?}");
};
assert!(
packets_lost >= 1,
"every forwarded gap packet (current P-frame + the GOP-2 tail) must be \
tallied as lost; got {packets_lost}"
);
assert!(
!dec.degraded_resync_pending_for_test(),
"the guard is cleared after the escalation fires"
);
}
#[test]
fn post_commit_retains_no_replay_frames() {
let (w, h) = (128u32, 96u32);
let clip = encode_synthetic_clip(w, h, 24, 6);
let second_key = nth_keyframe(&clip, 2);
let third_key = nth_keyframe(&clip, 3);
let fail_at = second_key + 2;
assert!(
fail_at < third_key && !clip.packets[fail_at].is_key(),
"fail target must be a mid-GOP P-frame before the next keyframe"
);
let tb = Timebase::new(1, NonZeroU32::new(25).expect("nonzero"));
let mut dec = FfmpegVideoStreamDecoder::from_hw_inner_for_test(
Box::new(FakeHw::failing(
w,
h,
fail_at,
fail_at,
FailShape::PostCommit,
)),
clip.parameters.clone(),
tb,
)
.expect("build test decoder");
assert!(
dec.sw_replay_frames_is_empty_for_test(),
"no replay frames before any fallback"
);
for av_pkt in clip.packets.iter().take(fail_at + 1) {
let vpkt = boundary::video_packet_from_ffmpeg(av_pkt).expect("packet has a buffer");
dec.send_packet(&vpkt).expect("send_packet");
assert!(
dec.sw_replay_frames_is_empty_for_test(),
"the post-commit path must retain ZERO replay frames — nothing is drained \
into the replay queue, so there is no deferred conversion (finding 1)"
);
}
assert!(
dec.is_software(),
"the mid-GOP failure fell back to software"
);
assert!(
dec.degraded_resync_pending_for_test(),
"post-commit fallback entered degraded mode (sanity)"
);
let mut dst = crate::empty_video_frame();
for av_pkt in clip.packets.iter().skip(fail_at + 1) {
let vpkt = boundary::video_packet_from_ffmpeg(av_pkt).expect("packet has a buffer");
dec.send_packet(&vpkt).expect("send_packet");
loop {
match dec.receive_frame(&mut dst) {
Ok(()) => {}
Err(VideoDecodeError::Decode(Error::Ffmpeg(ffmpeg_next::Error::Other { errno })))
if errno == ffmpeg_next::error::EAGAIN =>
{
break;
}
Err(VideoDecodeError::Decode(Error::Ffmpeg(ffmpeg_next::Error::Eof))) => break,
Err(e) => panic!("unexpected drain error: {e:?}"),
}
}
assert!(
dec.sw_replay_frames_is_empty_for_test(),
"the post-commit path never populates the replay queue"
);
}
}
#[test]
fn inert_seam_builds_on_hardware() {
ffmpeg_next::init().expect("ffmpeg init");
let params = ffmpeg_next::codec::Parameters::new();
let tb = Timebase::new(1, NonZeroU32::new(25).expect("nonzero"));
let dec = FfmpegVideoStreamDecoder::from_hw_inner_for_test(Box::new(FakeHw::inert()), params, tb)
.expect("build test decoder");
assert!(dec.is_hardware(), "inert seam starts on the HW path");
assert!(!dec.is_software());
}
#[test]
#[ignore = "requires a Sony FX3 H.264 High 4:2:2 10-bit fixture (user-provided); \
set MEDIADECODE_FX3_SAMPLE to its path"]
fn fx3_high_422_10bit_falls_back_to_software_and_decodes_whole_stream() {
use ffmpeg_next::{format, media};
let Some(path) = std::env::var_os("MEDIADECODE_FX3_SAMPLE") else {
eprintln!(
"skipping: set MEDIADECODE_FX3_SAMPLE to the Sony FX3 H.264 422-10bit fixture path to run \
this experiment"
);
return;
};
ffmpeg_next::init().expect("ffmpeg init");
let mut input = format::input(&path).expect("open FX3 input");
let stream = input
.streams()
.best(media::Type::Video)
.expect("video stream");
let stream_index = stream.index();
let (expected_w, expected_h) = unsafe {
let p = stream.parameters();
((*p.as_ptr()).width as u32, (*p.as_ptr()).height as u32)
};
let tb = Timebase::new(1, NonZeroU32::new(24).expect("nonzero"));
let mut dec = match FfmpegVideoStreamDecoder::open(stream.parameters(), tb) {
Ok(d) => d,
Err(Error::AllBackendsFailed(p)) => {
eprintln!(
"skipping: no hardware backend available at open ({} attempts) — the post-commit \
degrade path needs a HW backend that COMMITS then fails at runtime",
p.attempts().len()
);
return;
}
Err(e) => panic!("open FX3 decoder: {e:?}"),
};
let mut obs = Fx3Observation::new(dec.is_hardware());
eprintln!(
"FX3 experiment: {expected_w}x{expected_h}; started_on_hw={} (is_software={})",
obs.started_on_hw,
dec.is_software()
);
let mut dst = crate::empty_video_frame();
'feed: for (s, packet) in input.packets() {
if s.index() != stream_index {
continue;
}
let is_key = packet.is_key();
let pkt_pts = packet.pts();
let Some(vpkt) = boundary::video_packet_from_ffmpeg(&packet) else {
continue; };
let mut attempts = 0u32;
loop {
match dec.send_packet(&vpkt) {
Ok(()) => break,
Err(VideoDecodeError::Decode(Error::Ffmpeg(ffmpeg_next::Error::Other { errno })))
if errno == ffmpeg_next::error::EAGAIN =>
{
if let Err(err) = obs.drain(&mut dec, &mut dst) {
obs.abort = Some(format!("during send #{} EAGAIN-drain: {err}", obs.send_idx));
break 'feed;
}
attempts += 1;
assert!(
attempts <= 64,
"send_packet stuck on EAGAIN at send #{}",
obs.send_idx
);
}
Err(e) => {
obs.abort = Some(format!(
"send_packet #{} (key={is_key}, pts={pkt_pts:?}) errored: {e:?}",
obs.send_idx
));
break 'feed;
}
}
}
if let Err(err) = obs.drain(&mut dec, &mut dst) {
obs.abort = Some(format!(
"after send #{} (key={is_key}): {err}",
obs.send_idx
));
break 'feed;
}
obs.send_idx += 1;
}
if obs.abort.is_none() {
match dec.send_eof() {
Ok(()) => {
if let Err(err) = obs.drain(&mut dec, &mut dst) {
obs.abort = Some(format!("during post-EOF drain: {err}"));
}
}
Err(VideoDecodeError::PostCommitNeverResynced { packets_lost }) => {
obs.escalated_never_resynced = Some(packets_lost);
}
Err(e) => obs.abort = Some(format!("send_eof errored: {e:?}")),
}
}
let ended_on_sw = dec.is_software();
let unique: std::collections::HashSet<i64> = obs.pts_out.iter().copied().collect();
eprintln!("FX3 experiment RESULT:");
eprintln!(" started_on_hw = {}", obs.started_on_hw);
eprintln!(
" transitioned_to_sw = {} (at send #{:?})",
obs.transitioned_to_sw, obs.transition_send_idx
);
eprintln!(" ended_on_sw = {ended_on_sw}");
eprintln!(
" frames_delivered = {} (unique pts = {})",
obs.pts_out.len(),
unique.len()
);
eprintln!(" delivered_pts = {:?}", obs.pts_out);
eprintln!(
" resync_pending@end = {}",
dec.degraded_resync_pending_for_test()
);
eprintln!(
" never_resynced_esc = {:?}",
obs.escalated_never_resynced
);
eprintln!(" abort = {:?}", obs.abort);
assert!(
obs.started_on_hw,
"expected to start on the VideoToolbox HW path; if it opened straight to SW the post-commit \
path was never exercised on this run"
);
assert!(
obs.transitioned_to_sw && ended_on_sw,
"expected a transparent mid-stream HW->SW fallback on the real FX3 clip (VideoToolbox cannot \
decode H.264 High 4:2:2 10-bit at runtime); observed transition={}, ended_on_sw={ended_on_sw}, \
abort={:?}",
obs.transitioned_to_sw,
obs.abort
);
assert!(
obs.abort.is_none(),
"the degrade-and-continue path aborted before EOF on the real H.264 codec: {:?} — this would \
be Codex R7's finding reproducing on a real (non-lenient) codec",
obs.abort
);
assert!(
obs.escalated_never_resynced.is_none(),
"the cold SW decoder never resynced at a keyframe before EOF (PostCommitNeverResynced, {:?} \
packets lost) — the whole tail was dropped; Codex R7's finding 2 (HW swallowed the keyframe / \
cold SW never saw it) reproduces on real H.264",
obs.escalated_never_resynced
);
assert!(
!dec.degraded_resync_pending_for_test(),
"a post-commit resync was still pending at EOF — SW never proved a keyframe-anchored resync"
);
assert!(
!obs.pts_out.is_empty(),
"no frames were delivered at all — neither HW prefix nor SW remainder"
);
assert!(
!obs.pts_out.contains(&i64::MIN),
"every delivered frame must carry a real PTS: {:?}",
obs.pts_out
);
assert_eq!(
unique.len(),
obs.pts_out.len(),
"the degrade path must not re-emit a frame (no duplicate PTS): {:?}",
obs.pts_out
);
}
struct Fx3Observation {
started_on_hw: bool,
transitioned_to_sw: bool,
transition_send_idx: Option<usize>,
send_idx: usize,
pts_out: Vec<i64>,
abort: Option<String>,
escalated_never_resynced: Option<u64>,
}
impl Fx3Observation {
fn new(started_on_hw: bool) -> Self {
Self {
started_on_hw,
transitioned_to_sw: false,
transition_send_idx: None,
send_idx: 0,
pts_out: Vec::new(),
abort: None,
escalated_never_resynced: None,
}
}
fn note_transition(&mut self, dec: &FfmpegVideoStreamDecoder, frame_pending: bool) {
if !self.transitioned_to_sw && dec.is_software() {
self.transitioned_to_sw = true;
self.transition_send_idx = Some(self.send_idx);
let detail = if frame_pending {
format!("frames delivered so far: {}", self.pts_out.len())
} else {
"no frame yet — cold SW awaiting resync keyframe".to_string()
};
eprintln!(
" -> HW->SW transition observed at/after send #{} ({detail})",
self.send_idx
);
}
}
fn drain(
&mut self,
dec: &mut FfmpegVideoStreamDecoder,
dst: &mut VideoFrame<mediadecode::PixelFormat, VideoFrameExtra, FfmpegBuffer>,
) -> Result<(), String> {
loop {
match dec.receive_frame(dst) {
Ok(()) => {
self.note_transition(dec, true);
let pts = VideoFrame::pts(dst).map(|t| t.pts()).unwrap_or(i64::MIN);
self.pts_out.push(pts);
}
Err(VideoDecodeError::Decode(Error::Ffmpeg(ffmpeg_next::Error::Other { errno })))
if errno == ffmpeg_next::error::EAGAIN =>
{
self.note_transition(dec, false);
break;
}
Err(VideoDecodeError::Decode(Error::Ffmpeg(ffmpeg_next::Error::Eof))) => break,
Err(VideoDecodeError::PostCommitNeverResynced { packets_lost }) => {
self.escalated_never_resynced = Some(packets_lost);
eprintln!(
" -> PostCommitNeverResynced at EOF: {packets_lost} packets fed to SW produced no \
frame (no keyframe crossed the gap)"
);
break;
}
Err(e) => return Err(format!("{e:?}")),
}
}
Ok(())
}
}