mod support;
use mediadecode::{
Received,
decoder::ImageDecoder,
demuxer::{DemuxedPacket, Demuxer, TrackKind},
};
use mediadecode_ffmpeg::{
DecoderLimits, FfmpegBytes, FfmpegOwnedDemuxer as FfmpegDemuxer,
FfmpegOwnedImageDecoder as FfmpegImageDecoder, ImageDecodeError,
OwnedAttachmentPacket as AttachmentPacket, OwnedAudioFrame as AudioFrame,
OwnedAudioPacket as AudioPacket, OwnedDataPacket as DataPacket, OwnedImageFrame as ImageFrame,
OwnedSubtitleFrame as SubtitleFrame, OwnedSubtitlePacket as SubtitlePacket,
OwnedVideoFrame as VideoFrame, OwnedVideoPacket as VideoPacket,
extras::{ImageOrientation, SideDataEntry},
};
use support::Corpus;
fn assert_message<T: Send + Sync + Clone + 'static>() {}
#[test]
fn every_emitted_form_is_a_message() {
assert_message::<VideoFrame>();
assert_message::<AudioFrame>();
assert_message::<SubtitleFrame>();
assert_message::<ImageFrame>();
assert_message::<VideoPacket>();
assert_message::<AudioPacket>();
assert_message::<SubtitlePacket>();
assert_message::<DataPacket>();
assert_message::<AttachmentPacket>();
assert_message::<SideDataEntry>();
assert_message::<FfmpegBytes>();
}
#[test]
fn the_carrier_is_opaque_and_a_consumer_can_still_build_one() {
fn takes_the_carrier(_: &FfmpegBytes) {}
let packet = VideoPacket::new(
FfmpegBytes::try_copy_from_slice(&[1, 2, 3]).expect("a test payload"),
Default::default(),
);
takes_the_carrier(packet.data());
assert_eq!(packet.data().as_ref(), &[1, 2, 3]);
assert_eq!(packet.data().len(), 3);
assert!(!packet.data().is_empty());
fn reads_bytes<B: AsRef<[u8]>>(b: &B) -> usize {
b.as_ref().len()
}
assert_eq!(reads_bytes(packet.data()), 3);
assert!(
FfmpegBytes::empty().ptr_eq(&FfmpegBytes::try_copy_from_slice(&[]).expect("a test payload"))
);
}
#[test]
fn side_data_round_trips_through_the_carrier_and_clones_by_refcount() {
let payload = FfmpegBytes::try_copy_from_slice(&[7u8; 128]).expect("a test payload");
let entry = SideDataEntry::new(42, payload.clone());
assert_eq!(entry.kind(), 42);
assert_eq!(entry.data(), &[7u8; 128]);
assert!(
entry.data_ref().ptr_eq(&payload),
"the entry copied bytes it was handed a carrier for",
);
let cloned = entry.clone();
assert!(
cloned.data_ref().ptr_eq(entry.data_ref()),
"cloning a side-data entry copied its payload",
);
drop(entry);
drop(payload);
assert_eq!(cloned.data(), &[7u8; 128], "the clone kept the bytes alive");
}
#[test]
fn a_demuxed_packet_outlives_the_session_that_produced_it() {
let Some(corpus) = Corpus::new() else {
return;
};
let path = corpus.multi_track_mkv();
let mut demuxer = FfmpegDemuxer::open(&path).expect("open");
let mut kept: Option<FfmpegBytes> = None;
while let Some(packet) = demuxer.next_packet().expect("read") {
kept = Some(match packet {
DemuxedPacket::Video(p) => p.packet().data().clone(),
DemuxedPacket::Audio(p) => p.packet().data().clone(),
DemuxedPacket::Subtitle(p) => p.packet().data().clone(),
DemuxedPacket::Data(p) => p.packet().data().clone(),
DemuxedPacket::Attachment(p) => p.packet().data().clone(),
});
if kept.as_ref().is_some_and(|b| !b.is_empty()) {
break;
}
}
let kept = kept.expect("the file has packets");
let len = kept.len();
assert!(len > 0);
drop(demuxer);
assert_eq!(kept.len(), len);
let shared = kept.clone();
let handle = std::thread::spawn(move || shared.len());
assert_eq!(handle.join().expect("joined"), len);
assert_eq!(kept.len(), len);
}
#[test]
fn the_image_decoder_turns_cover_art_back_into_pixels() {
let Some(corpus) = Corpus::new() else {
return;
};
let path = corpus.cover_art_mp3();
let mut demuxer = FfmpegDemuxer::open(&path).expect("open");
let cover = demuxer
.tracks()
.iter()
.position(|t| t.kind() == TrackKind::Attachment)
.expect("the file has cover art");
let track = &demuxer.tracks()[cover];
let codec = track.params().codec();
assert_ne!(
format!("{codec:?}"),
format!("{:?}", mediadecode_ffmpeg::CodecId::from_raw(0)),
"the attachment track lost its codec identity",
);
let parameters = track.extra().clone_parameters().expect("parameters");
let mut payload = None;
while let Some(packet) = demuxer.next_packet().expect("read") {
if let DemuxedPacket::Attachment(attachment) = packet {
payload = Some(attachment.into_packet());
break;
}
}
let payload = payload.expect("the attachment track delivers its packet");
assert!(!payload.data().is_empty(), "cover art with no bytes");
let mut decoder =
FfmpegImageDecoder::open(parameters, DecoderLimits::default()).expect("open image decoder");
let image = decoder.decode(&payload).expect("decode the cover");
assert_eq!((image.width(), image.height()), (32, 32));
assert!(image.plane_count() >= 1, "a picture with no planes");
assert!(
image.planes().iter().any(|p| !p.data_ref().is_empty()),
"every plane came back empty",
);
let _: mediadecode::PixelFormat = image.pixel_format().clone();
let again = decoder.decode(&payload).expect("decode the cover twice");
assert_eq!(again.dimensions(), image.dimensions());
let plane: FfmpegBytes = image.planes()[0].data_ref().clone();
let bytes = plane.len();
drop(decoder);
drop(demuxer);
assert_eq!(plane.len(), bytes);
}
#[test]
fn an_attachment_with_no_bytes_is_refused_by_name() {
let Some(corpus) = Corpus::new() else {
return;
};
let path = corpus.cover_art_mp3();
let demuxer = FfmpegDemuxer::open(&path).expect("open");
let cover = demuxer
.tracks()
.iter()
.position(|t| t.kind() == TrackKind::Attachment)
.expect("the file has cover art");
let parameters = demuxer.tracks()[cover]
.extra()
.clone_parameters()
.expect("parameters");
let mut decoder =
FfmpegImageDecoder::open(parameters, DecoderLimits::default()).expect("open image decoder");
let empty = AttachmentPacket::new(FfmpegBytes::empty(), Default::default());
assert!(matches!(
decoder.decode(&empty),
Err(ImageDecodeError::EmptyPayload),
));
let garbage = AttachmentPacket::new(
FfmpegBytes::try_copy_from_slice(&[0xABu8; 64]).expect("a test payload"),
Default::default(),
);
let _ = decoder.decode(&garbage);
}
fn decode_still(path: &std::path::Path) -> ImageFrame {
use ffmpeg_next::codec::Parameters;
support::init_ffmpeg();
let input = ffmpeg_next::format::input(path).expect("open the still");
let stream = input
.streams()
.best(ffmpeg_next::media::Type::Video)
.expect("a picture stream");
let parameters: Parameters = stream.parameters();
let bytes = std::fs::read(path).expect("read the still");
drop(input);
let mut decoder =
FfmpegImageDecoder::open(parameters, DecoderLimits::default()).expect("open image decoder");
let packet = AttachmentPacket::new(
FfmpegBytes::try_copy_from_slice(&bytes).expect("a test payload"),
Default::default(),
);
decoder.decode(&packet).expect("decode the still")
}
#[test]
fn a_still_s_exif_orientation_reaches_the_image_extras() {
let Some(corpus) = Corpus::new() else {
return;
};
for tag in 1..=8u16 {
let image = decode_still(&corpus.exif_oriented_jpeg(tag));
let orientation = image
.extra()
.orientation()
.unwrap_or_else(|| panic!("tag {tag} produced no orientation"));
assert_eq!(
orientation.to_exif_code(),
Some(tag),
"tag {tag} came back as {orientation:?}",
);
assert_eq!(orientation.is_mirrored(), matches!(tag, 2 | 4 | 5 | 7));
assert!(
orientation.rotation().is_some(),
"tag {tag} is a quarter turn"
);
assert_eq!((image.width(), image.height()), (32, 24));
}
let sideways = decode_still(&corpus.exif_oriented_jpeg(6));
assert_eq!(
sideways.extra().orientation(),
Some(ImageOrientation::RightTop),
);
}
#[test]
fn a_still_with_no_orientation_tag_says_so_rather_than_guessing() {
let Some(corpus) = Corpus::new() else {
return;
};
let plain = decode_still(&corpus.exif_oriented_jpeg(1));
assert_eq!(
plain.extra().orientation().and_then(|o| o.to_exif_code()),
Some(1)
);
let out_of_range = decode_still(&corpus.exif_oriented_jpeg(9));
assert_eq!(
out_of_range.extra().orientation(),
None,
"an out-of-range tag was read as an orientation",
);
assert!(
!out_of_range
.extra()
.side_data()
.iter()
.any(|e| e.data().len() == ImageOrientation::DISPLAY_MATRIX_BYTES),
"an out-of-range tag produced a display matrix after all",
);
}
#[test]
fn a_packet_over_budget_is_refused_before_the_copy() {
use mediadecode_ffmpeg::{
PacketBufferError, PacketLimits, boundary::owned_video_packet_from_ffmpeg_in,
};
support::init_ffmpeg();
let body = vec![7u8; 4096];
let packet = ffmpeg_next::Packet::copy(&body);
let tb = mediadecode::Timebase::default();
let carried = owned_video_packet_from_ffmpeg_in(&packet, tb, PacketLimits::default())
.expect("4 KiB is nothing")
.expect("present");
assert_eq!(carried.data().len(), 4096);
let tight = PacketLimits::new().with_max_packet_bytes(4095);
match owned_video_packet_from_ffmpeg_in(&packet, tb, tight) {
Err(PacketBufferError::PacketTooLarge(p)) => {
assert_eq!(p.bytes(), 4096);
assert_eq!(p.limit(), 4095);
}
other => panic!("expected PacketTooLarge, got {other:?}"),
}
assert!(
owned_video_packet_from_ffmpeg_in(&packet, tb, PacketLimits::new().with_max_packet_bytes(4096))
.expect("exactly at the cap is not over it")
.is_some()
);
}
#[test]
fn the_packet_budget_fires_on_every_arm() {
use mediadecode_ffmpeg::{
PacketBufferError, PacketLimits,
boundary::{
owned_attachment_packet_from_ffmpeg, owned_audio_packet_from_ffmpeg_in,
owned_data_packet_from_ffmpeg_in, owned_subtitle_packet_from_ffmpeg_in,
owned_video_packet_from_ffmpeg_in,
},
};
support::init_ffmpeg();
let packet = ffmpeg_next::Packet::copy(&[1u8; 512]);
let tb = mediadecode::Timebase::default();
let tight = PacketLimits::new().with_max_packet_bytes(8);
let over = |r: Result<Option<bool>, PacketBufferError>| matches!(r, Err(PacketBufferError::PacketTooLarge(p)) if p.bytes() == 512 && p.limit() == 8);
assert!(over(
owned_video_packet_from_ffmpeg_in(&packet, tb, tight).map(|p| p.map(|_| true))
));
assert!(over(
owned_audio_packet_from_ffmpeg_in(&packet, tb, tight).map(|p| p.map(|_| true))
));
assert!(over(
owned_subtitle_packet_from_ffmpeg_in(&packet, tb, tight).map(|p| p.map(|_| true))
));
assert!(over(
owned_data_packet_from_ffmpeg_in(&packet, tb, tight).map(|p| p.map(|_| true))
));
assert!(over(
owned_attachment_packet_from_ffmpeg(&packet, tight).map(|p| p.map(|_| true))
));
}
#[test]
fn an_attachment_over_budget_refuses_the_open() {
use mediadecode_ffmpeg::{DemuxError, DemuxLimits};
let Some(corpus) = Corpus::new() else {
return;
};
let path = corpus.cover_art_mp3();
let baseline = FfmpegDemuxer::open(&path).expect("the default budget is generous");
drop(baseline);
let tight = DemuxLimits::new().with_max_attachment_bytes(16);
match FfmpegDemuxer::open_with(&path, tight) {
Err(DemuxError::AttachmentTooLarge(p)) => {
assert!(p.bytes() > 16, "the ask is reported: {}", p.bytes());
assert_eq!(p.limit(), 16);
}
Err(other) => panic!("expected AttachmentTooLarge, got {other:?}"),
Ok(_) => panic!("an over-budget attachment opened the session"),
}
}
#[test]
fn the_whole_file_attachment_budget_refuses_the_open_on_its_own() {
use mediadecode_ffmpeg::{DemuxError, DemuxLimits};
let Some(corpus) = Corpus::new() else {
return;
};
let path = corpus.cover_art_mp3();
let tight = DemuxLimits::new()
.with_max_attachment_bytes(usize::MAX) .with_max_total_attachment_bytes(16);
match FfmpegDemuxer::open_with(&path, tight) {
Err(DemuxError::AttachmentBudgetExhausted(p)) => {
assert!(
p.total() > 16,
"the running total is reported: {}",
p.total()
);
assert_eq!(p.limit(), 16);
}
Err(other) => panic!("expected AttachmentBudgetExhausted, got {other:?}"),
Ok(_) => panic!("an exhausted attachment budget opened the session"),
}
}
#[test]
fn a_synthesized_extradata_attachment_is_charged_the_same() {
use mediadecode_ffmpeg::{DemuxError, DemuxLimits};
let Some(corpus) = Corpus::new() else {
return;
};
let path = corpus.multi_track_mkv();
assert!(
FfmpegDemuxer::open(&path).is_ok(),
"the default budget opens a file with one small font",
);
let tight = DemuxLimits::new().with_max_attachment_bytes(4);
match FfmpegDemuxer::open_with(&path, tight) {
Err(DemuxError::AttachmentTooLarge(p)) => {
assert!(p.bytes() > 4);
assert_eq!(p.limit(), 4);
}
Err(other) => panic!("expected AttachmentTooLarge for the font, got {other:?}"),
Ok(_) => panic!("an over-budget font opened the session"),
}
}
#[test]
fn a_decoded_still_over_the_frame_ceilings_is_refused_before_the_planes() {
use mediadecode_ffmpeg::{FrameLimits, ImageDecodeError, convert, convert::ConvertError};
let Some(corpus) = Corpus::new() else {
return;
};
let path = corpus.exif_oriented_jpeg(1);
let by_pixels = decode_still_with(
&path,
DecoderLimits::new().with_frame(FrameLimits::new().with_max_pixels(700)),
);
match by_pixels {
Err(ImageDecodeError::Convert(ConvertError::TooManyPixels(p))) => {
assert_eq!(p.limit(), 700);
assert!(p.pixels() > 700);
}
Err(ImageDecodeError::Decode(_)) | Err(ImageDecodeError::NoImage) => {}
Err(other) => panic!("unexpected refusal: {other:?}"),
Ok(_) => panic!("a 768-pixel still passed a 700-pixel ceiling"),
}
let by_bytes = decode_still_with(
&path,
DecoderLimits::new().with_frame(FrameLimits::new().with_max_frame_bytes(8)),
);
match by_bytes {
Err(ImageDecodeError::Decode(_)) | Err(ImageDecodeError::NoImage) => {}
Err(ImageDecodeError::Convert(ConvertError::FrameTooLarge(_))) => {
panic!("the byte ceiling was only enforced after libavcodec allocated the frame")
}
Err(other) => panic!("unexpected refusal: {other:?}"),
Ok(_) => panic!("an 8-byte ceiling passed a real picture"),
}
let frame = picture_av_frame(64, 64);
match convert::image_frame_from(&frame, FrameLimits::new().with_max_frame_bytes(8)) {
Err(ConvertError::FrameTooLarge(p)) => {
assert_eq!(p.limit(), 8);
assert!(p.bytes() > 8, "the ask is reported: {}", p.bytes());
}
Err(other) => panic!("the conversion backstop must still refuse, got {other:?}"),
Ok(_) => panic!("the conversion backstop stopped enforcing max_frame_bytes"),
}
assert!(decode_still_with(&path, DecoderLimits::default()).is_ok());
}
fn decode_still_with(
path: &std::path::Path,
limits: mediadecode_ffmpeg::DecoderLimits,
) -> Result<ImageFrame, mediadecode_ffmpeg::ImageDecodeError> {
use ffmpeg_next::codec::Parameters;
support::init_ffmpeg();
let input = ffmpeg_next::format::input(path).expect("open the still");
let stream = input
.streams()
.best(ffmpeg_next::media::Type::Video)
.expect("a picture stream");
let parameters: Parameters = stream.parameters();
let bytes = std::fs::read(path).expect("read the still");
drop(input);
let mut decoder = FfmpegImageDecoder::open(parameters, limits)?;
let packet = AttachmentPacket::new(
FfmpegBytes::try_copy_from_slice(&bytes).expect("a test payload"),
Default::default(),
);
decoder.decode(&packet)
}
#[test]
fn the_frame_ceilings_ride_the_session() {
use mediadecode_ffmpeg::FrameLimits;
let Some(corpus) = Corpus::new() else {
return;
};
let path = corpus.exif_oriented_jpeg(1);
let limits = DecoderLimits::new()
.with_frame(
FrameLimits::new()
.with_max_pixels(1_234)
.with_max_frame_bytes(5_678_901),
)
.with_max_codec_parameter_bytes(9_012)
.with_max_image_input_bytes(3_456);
use ffmpeg_next::codec::Parameters;
support::init_ffmpeg();
let input = ffmpeg_next::format::input(&path).expect("open the still");
let parameters: Parameters = input
.streams()
.best(ffmpeg_next::media::Type::Video)
.expect("a picture stream")
.parameters();
drop(input);
let decoder = FfmpegImageDecoder::open(parameters, limits).expect("open");
assert_eq!(decoder.limits(), limits, "the session kept its ceilings");
assert_eq!(decoder.limits().frame().max_pixels(), 1_234);
assert_eq!(decoder.limits().frame().max_frame_bytes(), 5_678_901);
assert_eq!(decoder.limits().max_codec_parameter_bytes(), 9_012);
assert_eq!(decoder.limits().max_image_input_bytes(), 3_456);
}
#[test]
fn an_over_budget_extradata_attachment_refuses_before_any_payload_allocation() {
use mediadecode_ffmpeg::{DemuxError, DemuxLimits};
let Some(corpus) = Corpus::new() else {
return;
};
let path = corpus.multi_track_mkv();
{
let mut demuxer = FfmpegDemuxer::open(&path).expect("the default budget is generous");
let mut saw_font = false;
while let Some(packet) = demuxer.next_packet().expect("read") {
if let DemuxedPacket::Attachment(attachment) = packet {
assert!(!attachment.packet().data().is_empty(), "the font has bytes");
saw_font = true;
}
}
assert!(saw_font, "the fixture attaches a font");
}
let tight = DemuxLimits::new().with_max_attachment_bytes(4);
match FfmpegDemuxer::open_with(&path, tight) {
Err(DemuxError::AttachmentTooLarge(p)) => {
assert!(p.bytes() > 4, "the ask is reported: {}", p.bytes());
assert_eq!(p.limit(), 4);
}
Err(other) => panic!("expected AttachmentTooLarge, got {other:?}"),
Ok(_) => panic!("an over-budget extradata attachment opened the session"),
}
let aggregate = DemuxLimits::new()
.with_max_attachment_bytes(usize::MAX)
.with_max_total_attachment_bytes(4);
assert!(
matches!(
FfmpegDemuxer::open_with(&path, aggregate),
Err(DemuxError::AttachmentBudgetExhausted(_)),
),
"the whole-file budget must see the extradata road too",
);
}
#[test]
fn an_accepted_extradata_attachment_retains_one_copy_not_two() {
use mediadecode::demuxer::TrackKind;
let Some(corpus) = Corpus::new() else {
return;
};
let path = corpus.multi_track_mkv();
let demuxer = FfmpegDemuxer::open(&path).expect("open");
let font = demuxer
.tracks()
.iter()
.find(|t| {
t.kind() == TrackKind::Attachment && t.mime_type().is_some_and(|m| m.contains("truetype"))
})
.expect("the fixture attaches a font");
let parameters = font
.extra()
.clone_parameters()
.expect("the checked handoff");
let retained = unsafe { (*parameters.as_ptr()).extradata_size };
assert_eq!(
retained, 0,
"the parameter copy still holds a duplicate of the font payload",
);
let video = demuxer
.tracks()
.iter()
.find(|t| t.kind() == TrackKind::Video)
.expect("the fixture has video");
let video_parameters = video.extra().clone_parameters().expect("handoff");
let video_extradata = unsafe { (*video_parameters.as_ptr()).extradata_size };
assert!(
video_extradata > 0,
"H.264 parameter sets were stripped along with the font's payload",
);
}
#[cfg(feature = "resample")]
#[test]
fn an_amplifying_conversion_is_refused_before_the_output_frame_exists() {
use ffmpeg_next::{
ChannelLayout,
format::{Sample, sample::Type},
};
use mediadecode::resampler::AudioResampler;
use mediadecode_ffmpeg::{
FfmpegOwnedResampler as FfmpegResampler, FrameLimits, ResampleError, ResampleSpec,
};
support::init_ffmpeg();
let packed =
|rate: u32| ResampleSpec::new(rate, Sample::F32(Type::Packed), ChannelLayout::STEREO);
let tight = FrameLimits::new().with_max_frame_bytes(64 * 1024);
let mut resampler =
FfmpegResampler::new(packed(1), packed(48_000), tight).expect("the specs themselves are fine");
match resampler.send_frame(&stereo_f32_frame(1, 1_000)) {
Err(ResampleError::OutputTooLarge(p)) => {
assert_eq!(p.limit(), 64 * 1024);
assert!(
p.bytes() > 300_000_000,
"an 8 KB input asked for {} bytes",
p.bytes(),
);
}
other => panic!("expected OutputTooLarge, got {other:?}"),
}
let mut defaulted =
FfmpegResampler::new(packed(1), packed(48_000), FrameLimits::default()).expect("open");
assert!(
matches!(
defaulted.send_frame(&stereo_f32_frame(1, 2_000)),
Err(ResampleError::OutputTooLarge(_)),
),
"the default ceiling let a 16 KB input ask for ~768 MB",
);
let mut down = FfmpegResampler::new(packed(192_000), packed(8_000), tight).expect("open");
support::accepted(
down.send_frame(&stereo_f32_frame(192_000, 1_024)),
"a downsample produces less, and must pass the same ceiling",
);
let mut up =
FfmpegResampler::new(packed(8_000), packed(48_000), FrameLimits::default()).expect("open");
support::accepted(
up.send_frame(&stereo_f32_frame(8_000, 8_000)),
"a 6x upsample of one second is ordinary work",
);
let mut converted = mediadecode_ffmpeg::empty_owned_audio_frame();
assert_eq!(
up.receive_frame(&mut converted)
.expect("the conversion produced a frame"),
Received::Frame,
);
assert_eq!(converted.sample_rate(), 48_000);
assert!(converted.nb_samples() > 0);
}
#[cfg(feature = "resample")]
fn stereo_f32_frame(rate: u32, samples: u32) -> AudioFrame {
use mediadecode::frame::Plane;
let bytes = samples as usize * 2 * 4;
let plane = FfmpegBytes::try_copy_from_slice(&vec![0u8; bytes]).expect("a test payload");
let planes = std::array::from_fn(|index| {
Plane::new(
if index == 0 {
plane.clone()
} else {
FfmpegBytes::empty()
},
0,
)
});
AudioFrame::new(
rate,
samples,
2,
mediadecode_ffmpeg::SampleFormat::FLT,
mediadecode_ffmpeg::channel_layout_description_from_ffmpeg(&ffmpeg_next::ChannelLayout::STEREO)
.expect("a well-formed layout describes"),
planes,
1,
Default::default(),
)
}
#[test]
fn every_stream_s_codec_parameters_are_charged_at_open() {
use mediadecode_ffmpeg::{DemuxError, DemuxLimits};
let Some(corpus) = Corpus::new() else {
return;
};
let path = corpus.multi_track_mkv();
assert!(
FfmpegDemuxer::open(&path).is_ok(),
"the default parameter budget opens an ordinary file",
);
let tight = DemuxLimits::new().with_max_codec_parameter_bytes(4);
match FfmpegDemuxer::open_with(&path, tight) {
Err(DemuxError::ParametersTooLarge(p)) => {
assert!(p.bytes() > 4, "the ask is reported: {}", p.bytes());
assert_eq!(p.limit(), 4);
}
Err(other) => panic!("expected ParametersTooLarge, got {other:?}"),
Ok(_) => panic!("over-budget codec parameters opened the session"),
}
}
#[test]
fn the_parameter_budget_tiers_are_independent() {
use mediadecode_ffmpeg::{DemuxError, DemuxLimits};
let Some(corpus) = Corpus::new() else {
return;
};
let path = corpus.multi_track_mkv();
let aggregate = DemuxLimits::new()
.with_max_codec_parameter_bytes(usize::MAX)
.with_max_total_codec_parameter_bytes(8);
match FfmpegDemuxer::open_with(&path, aggregate) {
Err(DemuxError::ParametersBudgetExhausted(p)) => {
assert!(
p.total() > 8,
"the running total is reported: {}",
p.total()
);
assert_eq!(p.limit(), 8);
}
Err(other) => panic!("expected ParametersBudgetExhausted, got {other:?}"),
Ok(_) => panic!("an exhausted parameter budget opened the session"),
}
let mixed = DemuxLimits::new()
.with_max_attachment_bytes(usize::MAX)
.with_max_total_attachment_bytes(usize::MAX)
.with_max_codec_parameter_bytes(4);
assert!(
matches!(
FfmpegDemuxer::open_with(&path, mixed),
Err(DemuxError::ParametersTooLarge(_)),
),
"the attachment budgets must not cover for the parameter ones",
);
}
#[test]
fn a_stripped_attachment_payload_is_charged_once_not_twice() {
use mediadecode::demuxer::TrackKind;
use mediadecode_ffmpeg::DemuxLimits;
let Some(corpus) = Corpus::new() else {
return;
};
let path = corpus.cover_art_mp3();
let zero_parameters = DemuxLimits::new()
.with_max_codec_parameter_bytes(0)
.with_max_total_codec_parameter_bytes(0);
let demuxer = FfmpegDemuxer::open_with(&path, zero_parameters)
.expect("cover art is a carrier, not a parameter heap");
let cover = demuxer
.tracks()
.iter()
.find(|t| t.kind() == TrackKind::Attachment)
.expect("the file has cover art");
assert_eq!(
cover.extra().parameter_bytes(),
0,
"the cover-art row retains no parameter heap",
);
}
#[test]
fn oversized_parameters_are_refused_at_every_decoder_entry_point() {
use ffmpeg_next::codec::Parameters;
use mediadecode_ffmpeg::{
Backend, DecoderLimits, Error, FfmpegOwnedImageDecoder as FfmpegImageDecoder, VideoDecoder,
};
support::init_ffmpeg();
let build = || {
let mut out = Parameters::new();
unsafe {
let par = out.as_mut_ptr();
(*par).codec_id = ffmpeg_next::ffi::AVCodecID::AV_CODEC_ID_MJPEG;
(*par).codec_type = ffmpeg_next::ffi::AVMediaType::AVMEDIA_TYPE_VIDEO;
(*par).width = 32;
(*par).height = 24;
let array = ffmpeg_next::ffi::av_mallocz(core::mem::size_of::<
ffmpeg_next::ffi::AVPacketSideData,
>()) as *mut ffmpeg_next::ffi::AVPacketSideData;
assert!(!array.is_null());
let payload = ffmpeg_next::ffi::av_mallocz(8 * 1024 * 1024) as *mut u8;
assert!(!payload.is_null());
(*array).data = payload;
(*array).size = 8 * 1024 * 1024;
(*array).type_ = ffmpeg_next::ffi::AVPacketSideDataType::AV_PKT_DATA_ICC_PROFILE;
(*par).coded_side_data = array;
(*par).nb_coded_side_data = 1;
}
out
};
let tight = DecoderLimits::new().with_max_codec_parameter_bytes(64 * 1024);
match FfmpegImageDecoder::open(build(), tight) {
Err(mediadecode_ffmpeg::ImageDecodeError::Decode(Error::ParametersTooLarge(p))) => {
assert_eq!(p.limit(), 64 * 1024);
assert!(p.bytes() > 64 * 1024);
}
Err(other) => panic!("image open let oversized parameters through: {other:?}"),
Ok(_) => panic!("the image decoder opened over oversized parameters"),
}
match VideoDecoder::open_with_limits(build(), Backend::VideoToolbox, tight) {
Err(Error::ParametersTooLarge(p)) => assert_eq!(p.limit(), 64 * 1024),
Err(other) => panic!("expected ParametersTooLarge, got {other:?}"),
Ok(_) => panic!("the HW decoder opened over oversized parameters"),
}
assert!(FfmpegImageDecoder::open(build(), DecoderLimits::default()).is_ok());
}
#[test]
fn an_oversized_image_input_is_refused_before_the_packet_copy() {
use mediadecode_ffmpeg::{DecoderLimits, ImageDecodeError};
let Some(corpus) = Corpus::new() else {
return;
};
let path = corpus.exif_oriented_jpeg(1);
let bytes = std::fs::read(&path).expect("read the still");
use ffmpeg_next::codec::Parameters;
support::init_ffmpeg();
let input = ffmpeg_next::format::input(&path).expect("open");
let parameters: Parameters = input
.streams()
.best(ffmpeg_next::media::Type::Video)
.expect("a picture stream")
.parameters();
drop(input);
let tight = DecoderLimits::new().with_max_image_input_bytes(8);
let mut decoder = FfmpegImageDecoder::open(parameters, tight).expect("open");
let packet = AttachmentPacket::new(
FfmpegBytes::try_copy_from_slice(&bytes).expect("a test payload"),
Default::default(),
);
match decoder.decode(&packet) {
Err(ImageDecodeError::InputTooLarge(p)) => {
assert_eq!(p.limit(), 8);
assert_eq!(p.bytes(), bytes.len());
}
Err(other) => panic!("expected InputTooLarge, got {other:?}"),
Ok(_) => panic!("an 8-byte input ceiling passed a real JPEG"),
}
assert!(mediadecode_ffmpeg::DEFAULT_MAX_IMAGE_INPUT_BYTES >= bytes.len());
}
#[test]
fn an_attachment_between_the_two_ceilings_opens() {
use mediadecode_ffmpeg::DemuxLimits;
let Some(corpus) = Corpus::new() else {
return;
};
let path = corpus.multi_track_mkv();
let interval = DemuxLimits::new()
.with_max_codec_parameter_bytes(1_024)
.with_max_total_codec_parameter_bytes(8 * 1_024)
.with_max_attachment_bytes(1024 * 1024);
let mut demuxer = FfmpegDemuxer::open_with(&path, interval)
.expect("a font over the parameter ceiling and under the attachment one must open");
let mut saw_font = false;
while let Some(packet) = demuxer.next_packet().expect("read") {
if let DemuxedPacket::Attachment(attachment) = packet {
assert!(!attachment.packet().data().is_empty());
saw_font = true;
}
}
assert!(saw_font, "the attachment was admitted but never delivered");
}
#[test]
fn indexed_and_one_bit_stills_decode_as_what_ffmpeg_produced() {
let Some(corpus) = Corpus::new() else {
return;
};
let indexed = decode_still(&corpus.indexed_png());
assert_eq!((indexed.width(), indexed.height()), (32, 24));
assert_eq!(
indexed.pixel_format(),
&mediadecode::PixelFormat::Pal8,
"delivered as decoded, not converted",
);
assert_eq!(indexed.plane_count(), 2, "indices and palette");
assert_eq!(indexed.planes()[0].data_ref().len(), 32 * 24);
assert_eq!(
indexed.planes()[1].data_ref().len(),
1024,
"AVPALETTE_SIZE — a format bound, not a budget seat",
);
let mono = decode_still(&corpus.monochrome_png());
assert_eq!((mono.width(), mono.height()), (32, 24));
assert_eq!(mono.pixel_format(), &mediadecode::PixelFormat::Monoblack);
assert_eq!(mono.plane_count(), 1);
assert_eq!(mono.planes()[0].stride(), 4, "ceil(width / 8)");
assert_eq!(mono.planes()[0].data_ref().len(), 4 * 24);
assert!(!mediadecode_ffmpeg::convert::is_video_deliverable(
&mediadecode::PixelFormat::Pal8
));
assert!(!mediadecode_ffmpeg::convert::is_video_deliverable(
&mediadecode::PixelFormat::Monoblack
));
}
#[test]
fn an_audio_plane_exports_valid_samples_not_alignment_padding() {
use mediadecode::decoder::AudioStreamDecoder;
use mediadecode_ffmpeg::{
DecoderLimits, FfmpegOwnedAudioStreamDecoder as FfmpegAudioStreamDecoder,
};
let Some(corpus) = Corpus::new() else {
return;
};
let path = corpus.sine_wav("valid-bytes.wav", 44_100, 2, 440, 0.25);
support::init_ffmpeg();
let mut input = ffmpeg_next::format::input(&path).expect("open");
let stream = input
.streams()
.best(ffmpeg_next::media::Type::Audio)
.expect("an audio track");
let index = stream.index();
let time_base = mediadecode::Timebase::new(
stream.time_base().numerator(),
std::num::NonZeroI32::new(stream.time_base().denominator().max(1)).expect("nonzero"),
);
let parameters = stream.parameters();
let mut decoder = FfmpegAudioStreamDecoder::open(parameters, time_base, DecoderLimits::default())
.expect("open decoder");
let mut checked = 0usize;
let mut packet = ffmpeg_next::Packet::empty();
while packet.read(&mut input).is_ok() {
if packet.stream() != index {
continue;
}
let Ok(Some(portable)) =
mediadecode_ffmpeg::audio_packet_from_ffmpeg(&packet, mediadecode::Timebase::SECONDS)
else {
continue;
};
if decoder.send_packet(&portable).is_err() {
continue;
}
let mut frame = mediadecode_ffmpeg::empty_owned_audio_frame();
while matches!(
decoder.receive_frame(&mut frame).expect("receive_frame"),
Received::Frame
) {
let samples = frame.nb_samples() as usize;
if samples == 0 {
continue;
}
let channels = frame.channel_count() as usize;
let valid = if frame.plane_count() as usize == 1 {
samples * 2 * channels
} else {
samples * 2
};
for plane in frame.planes() {
assert_eq!(
plane.data_ref().len(),
valid,
"a plane exported {} bytes for {samples} samples across {channels} channels — \
alignment padding is riding along",
plane.data_ref().len(),
);
assert_eq!(plane.stride() as usize, valid);
}
checked += 1;
}
}
assert!(checked > 0, "the fixture decoded no audio frames");
}
#[test]
fn the_send_leg_judges_the_packet_budget_on_all_three_families() {
use mediadecode_ffmpeg::{
OwnedAudioPacket as AudioPacket, OwnedSubtitlePacket as SubtitlePacket,
OwnedVideoPacket as VideoPacket, PacketBuildError, PacketLimits,
boundary::{
ffmpeg_packet_from_owned_audio_packet, ffmpeg_packet_from_owned_subtitle_packet,
ffmpeg_packet_from_owned_video_packet,
},
};
support::init_ffmpeg();
let body = vec![3u8; 2_048];
let bytes = FfmpegBytes::try_copy_from_slice(&body).expect("a test payload");
let at_cap = PacketLimits::new().with_max_packet_bytes(2_048);
let under = PacketLimits::new().with_max_packet_bytes(2_047);
let video = VideoPacket::new(bytes.clone(), Default::default());
let audio = AudioPacket::new(bytes.clone(), Default::default());
let subtitle = SubtitlePacket::new(bytes.clone(), Default::default());
assert!(ffmpeg_packet_from_owned_video_packet(&video, at_cap).is_ok());
assert!(ffmpeg_packet_from_owned_audio_packet(&audio, at_cap).is_ok());
assert!(ffmpeg_packet_from_owned_subtitle_packet(&subtitle, at_cap).is_ok());
for (family, result) in [
(
"video",
ffmpeg_packet_from_owned_video_packet(&video, under).map(|_| ()),
),
(
"audio",
ffmpeg_packet_from_owned_audio_packet(&audio, under).map(|_| ()),
),
(
"subtitle",
ffmpeg_packet_from_owned_subtitle_packet(&subtitle, under).map(|_| ()),
),
] {
match result {
Err(PacketBuildError::SendPayloadTooLarge(p)) => {
assert_eq!(p.bytes(), 2_048, "{family}");
assert_eq!(p.limit(), 2_047, "{family}");
}
other => panic!("{family}: expected SendPayloadTooLarge, got {other:?}"),
}
}
}
#[test]
fn the_subtitle_session_keeps_its_limits() {
use mediadecode_ffmpeg::{
DecoderLimits, FfmpegOwnedSubtitleStreamDecoder as FfmpegSubtitleStreamDecoder,
};
let Some(corpus) = Corpus::new() else {
return;
};
let path = corpus.multi_track_mkv();
support::init_ffmpeg();
let input = ffmpeg_next::format::input(&path).expect("open");
let stream = input
.streams()
.best(ffmpeg_next::media::Type::Subtitle)
.expect("a subtitle track");
let parameters = stream.parameters();
drop(input);
let limits = DecoderLimits::new().with_max_packet_bytes(4_321);
let decoder =
FfmpegSubtitleStreamDecoder::open(parameters, mediadecode::Timebase::default(), limits)
.expect("open");
assert_eq!(
decoder.limits().max_packet_bytes(),
4_321,
"the subtitle session discarded its limits at open",
);
}
#[test]
fn the_active_parameter_ceiling_binds_both_ways() {
use ffmpeg_next::codec::Parameters;
use mediadecode_ffmpeg::{
DEFAULT_MAX_CODEC_PARAMETER_BYTES, DecoderLimits, Error,
FfmpegOwnedImageDecoder as FfmpegImageDecoder,
};
support::init_ffmpeg();
let build = |profile: usize| {
let mut out = Parameters::new();
unsafe {
let par = out.as_mut_ptr();
(*par).codec_id = ffmpeg_next::ffi::AVCodecID::AV_CODEC_ID_MJPEG;
(*par).codec_type = ffmpeg_next::ffi::AVMediaType::AVMEDIA_TYPE_VIDEO;
(*par).width = 32;
(*par).height = 24;
let array = ffmpeg_next::ffi::av_mallocz(core::mem::size_of::<
ffmpeg_next::ffi::AVPacketSideData,
>()) as *mut ffmpeg_next::ffi::AVPacketSideData;
assert!(!array.is_null());
let payload = ffmpeg_next::ffi::av_mallocz(profile) as *mut u8;
assert!(!payload.is_null());
(*array).data = payload;
(*array).size = profile;
(*array).type_ = ffmpeg_next::ffi::AVPacketSideDataType::AV_PKT_DATA_ICC_PROFILE;
(*par).coded_side_data = array;
(*par).nb_coded_side_data = 1;
}
out
};
const BIG: usize = 20 * 1024 * 1024;
const _: () = assert!(BIG > DEFAULT_MAX_CODEC_PARAMETER_BYTES);
let raised = DecoderLimits::new().with_max_codec_parameter_bytes(32 * 1024 * 1024);
assert!(
FfmpegImageDecoder::open(build(BIG), raised).is_ok(),
"a raised ceiling must actually admit",
);
assert!(
FfmpegImageDecoder::open(build(BIG), DecoderLimits::default()).is_err(),
"the default must still refuse it",
);
let lowered = DecoderLimits::new().with_max_codec_parameter_bytes(1_024);
match FfmpegImageDecoder::open(build(64 * 1024), lowered) {
Err(mediadecode_ffmpeg::ImageDecodeError::Decode(Error::ParametersTooLarge(p))) => {
assert_eq!(p.limit(), 1_024);
}
Err(other) => panic!("a lowered ceiling did not bind: {other:?}"),
Ok(_) => panic!("a lowered ceiling admitted 64 KiB of parameters"),
}
}
#[test]
fn the_attachment_cap_is_exact_and_agrees_across_both_roads() {
use mediadecode_ffmpeg::DemuxLimits;
let Some(corpus) = Corpus::new() else {
return;
};
let path = corpus.multi_track_mkv();
let mut demuxer = FfmpegDemuxer::open(&path).expect("open");
let mut payload = 0usize;
while let Some(packet) = demuxer.next_packet().expect("read") {
if let DemuxedPacket::Attachment(attachment) = packet {
payload = attachment.packet().data().len();
}
}
assert!(payload > 0, "the fixture attaches a font");
drop(demuxer);
let at_cap = DemuxLimits::new()
.with_max_attachment_bytes(payload)
.with_max_total_attachment_bytes(payload);
assert!(
FfmpegDemuxer::open_with(&path, at_cap).is_ok(),
"a {payload}-byte attachment was refused by a {payload}-byte ceiling",
);
let under = DemuxLimits::new().with_max_attachment_bytes(payload - 1);
assert!(
FfmpegDemuxer::open_with(&path, under).is_err(),
"the ceiling is not exact",
);
let aggregate_at_cap = DemuxLimits::new()
.with_max_attachment_bytes(usize::MAX)
.with_max_total_attachment_bytes(payload);
assert!(FfmpegDemuxer::open_with(&path, aggregate_at_cap).is_ok());
let aggregate_under = DemuxLimits::new()
.with_max_attachment_bytes(usize::MAX)
.with_max_total_attachment_bytes(payload - 1);
assert!(FfmpegDemuxer::open_with(&path, aggregate_under).is_err());
}
fn empty_audio_av_frame(
format: ffmpeg_next::ffi::AVSampleFormat,
channels: i32,
) -> ffmpeg_next::frame::Audio {
let frame = ffmpeg_next::frame::Audio::empty();
unsafe {
let p = frame.as_ptr() as *mut ffmpeg_next::ffi::AVFrame;
(*p).format = format as i32;
(*p).nb_samples = 0;
(*p).sample_rate = 48_000;
ffmpeg_next::ffi::av_channel_layout_default(core::ptr::addr_of_mut!((*p).ch_layout), channels);
}
frame
}
#[test]
fn a_packed_zero_sample_frame_converts_to_empty_planes() {
use mediadecode_ffmpeg::{FrameLimits, convert};
support::init_ffmpeg();
let frame = empty_audio_av_frame(ffmpeg_next::ffi::AVSampleFormat::AV_SAMPLE_FMT_S16, 2);
let out = convert::audio_frame_from(
&frame,
mediadecode::Timebase::default(),
FrameLimits::default(),
)
.expect("a header frame is a frame, not a layout error");
assert_eq!(out.nb_samples(), 0);
assert_eq!(out.channel_count(), 2);
assert_eq!(out.plane_count(), 1, "packed declares one plane");
for plane in out.planes() {
assert!(
plane.data_ref().is_empty(),
"a zero-sample plane carries no bytes"
);
assert_eq!(plane.stride(), 0);
}
}
#[test]
fn a_planar_zero_sample_frame_converts_to_empty_planes() {
use mediadecode_ffmpeg::{FrameLimits, convert};
support::init_ffmpeg();
let frame = empty_audio_av_frame(ffmpeg_next::ffi::AVSampleFormat::AV_SAMPLE_FMT_S16P, 6);
let out = convert::audio_frame_from(
&frame,
mediadecode::Timebase::default(),
FrameLimits::default(),
)
.expect("a header frame is a frame, not a layout error");
assert_eq!(out.nb_samples(), 0);
assert_eq!(out.channel_count(), 6);
assert_eq!(
out.plane_count(),
6,
"planar declares one plane per channel — the shape a consumer expects",
);
for plane in out.planes() {
assert!(plane.data_ref().is_empty());
assert_eq!(plane.stride(), 0);
}
let first = out.planes()[0].data_ref();
assert!(
out.planes().iter().all(|p| p.data_ref().ptr_eq(first)),
"the empty planes should share one allocation",
);
}
fn raw_audio_av_frame(
format_raw: i32,
nb_samples: i32,
channels: i32,
linesize0: i32,
) -> ffmpeg_next::frame::Audio {
let frame = ffmpeg_next::frame::Audio::empty();
unsafe {
let p = frame.as_ptr() as *mut ffmpeg_next::ffi::AVFrame;
(*p).format = format_raw;
(*p).nb_samples = nb_samples;
(*p).sample_rate = 48_000;
(*p).linesize[0] = linesize0;
ffmpeg_next::ffi::av_channel_layout_default(core::ptr::addr_of_mut!((*p).ch_layout), channels);
}
frame
}
#[test]
fn a_negative_sample_count_is_refused_by_name() {
use mediadecode_ffmpeg::{FrameLimits, convert, convert::ConvertError};
support::init_ffmpeg();
let frame = raw_audio_av_frame(
ffmpeg_next::ffi::AVSampleFormat::AV_SAMPLE_FMT_S16 as i32,
-1,
2,
0,
);
match convert::audio_frame_from(
&frame,
mediadecode::Timebase::default(),
FrameLimits::default(),
) {
Err(ConvertError::InvalidSampleCount(payload)) => {
assert_eq!(payload.count(), -1, "the refusal reports what it read");
}
Err(other) => panic!("a negative sample count must be refused by name, got {other:?}"),
Ok(_) => panic!("a negative sample count converted instead of being refused"),
}
}
#[test]
fn a_zero_sample_frame_with_no_sample_format_is_refused() {
use mediadecode_ffmpeg::{FrameLimits, convert, convert::ConvertError};
support::init_ffmpeg();
for format_raw in [
ffmpeg_next::ffi::AVSampleFormat::AV_SAMPLE_FMT_NONE as i32,
99_999, ] {
let frame = raw_audio_av_frame(format_raw, 0, 2, 0);
match convert::audio_frame_from(
&frame,
mediadecode::Timebase::default(),
FrameLimits::default(),
) {
Err(ConvertError::UnsupportedSampleFormat(payload)) => {
assert_eq!(payload.raw(), format_raw);
}
Err(other) => {
panic!("format {format_raw} must be refused even at zero samples, got {other:?}")
}
Ok(_) => panic!("format {format_raw} converted at zero samples instead of being refused"),
}
}
}
#[test]
fn a_packed_frame_above_255_channels_is_refused_before_any_copy() {
use mediadecode_ffmpeg::{FrameLimits, convert, convert::ConvertError};
support::init_ffmpeg();
let frame = raw_audio_av_frame(
ffmpeg_next::ffi::AVSampleFormat::AV_SAMPLE_FMT_S16 as i32,
1,
256,
512,
);
match convert::audio_frame_from(
&frame,
mediadecode::Timebase::default(),
FrameLimits::default(),
) {
Err(ConvertError::UnsupportedChannelCount(payload)) => {
assert_eq!(
payload.channels(),
256,
"the refusal reports the declared count, not a clipped one",
);
}
Err(other) => panic!("256 packed channels must be refused before geometry, got {other:?}"),
Ok(_) => panic!("256 packed channels converted — the count was clipped, not judged"),
}
}
#[test]
fn a_packed_frame_at_255_channels_still_converts() {
use mediadecode_ffmpeg::{FrameLimits, convert};
support::init_ffmpeg();
let mut frame = ffmpeg_next::frame::Audio::empty();
unsafe {
let p = frame.as_mut_ptr();
(*p).format = ffmpeg_next::ffi::AVSampleFormat::AV_SAMPLE_FMT_S16 as i32;
(*p).nb_samples = 1;
(*p).sample_rate = 48_000;
ffmpeg_next::ffi::av_channel_layout_default(core::ptr::addr_of_mut!((*p).ch_layout), 255);
let rc = ffmpeg_next::ffi::av_frame_get_buffer(p, 0);
assert_eq!(rc, 0, "255 packed channels is an allocation FFmpeg makes");
}
let out = convert::audio_frame_from(
&frame,
mediadecode::Timebase::default(),
FrameLimits::default(),
)
.expect("255 channels is inside the seat");
assert_eq!(out.channel_count(), 255);
assert_eq!(out.nb_samples(), 1);
assert_eq!(out.plane_count(), 1, "packed declares one plane");
assert_eq!(
out.planes()[0].data_ref().len(),
255 * 2,
"the byte product uses the declared count, not a clipped one",
);
}
fn audio_av_frame_with_layout(
format_raw: i32,
nb_samples: i32,
order_raw: i32,
nb_channels: i32,
) -> ffmpeg_next::frame::Audio {
let frame = ffmpeg_next::frame::Audio::empty();
unsafe {
let p = frame.as_ptr() as *mut ffmpeg_next::ffi::AVFrame;
(*p).format = format_raw;
(*p).nb_samples = nb_samples;
(*p).sample_rate = 48_000;
let layout = core::ptr::addr_of_mut!((*p).ch_layout);
core::ptr::write(
core::ptr::addr_of_mut!((*layout).order) as *mut i32,
order_raw,
);
(*layout).nb_channels = nb_channels;
}
frame
}
#[test]
fn a_negative_channel_count_is_refused_rather_than_read_as_zero() {
use mediadecode_ffmpeg::{FrameLimits, convert, convert::ConvertError};
support::init_ffmpeg();
let frame = audio_av_frame_with_layout(
ffmpeg_next::ffi::AVSampleFormat::AV_SAMPLE_FMT_S16 as i32,
0,
ffmpeg_next::ffi::AVChannelOrder::AV_CHANNEL_ORDER_UNSPEC as i32,
-1,
);
match convert::audio_frame_from(
&frame,
mediadecode::Timebase::default(),
FrameLimits::default(),
) {
Err(ConvertError::UnsupportedChannelCount(p)) => {
assert_eq!(
p.channels(),
-1,
"the refusal must report the signed count the frame declared",
);
}
Err(other) => panic!("expected UnsupportedChannelCount, got {other:?}"),
Ok(_) => panic!("a negative channel count was laundered into a frame"),
}
}
#[test]
fn an_oversized_custom_layout_is_refused_before_its_map_is_walked() {
use mediadecode_ffmpeg::{FrameLimits, convert, convert::ConvertError};
support::init_ffmpeg();
let frame = audio_av_frame_with_layout(
ffmpeg_next::ffi::AVSampleFormat::AV_SAMPLE_FMT_S16 as i32,
0,
ffmpeg_next::ffi::AVChannelOrder::AV_CHANNEL_ORDER_CUSTOM as i32,
i32::MAX,
);
match convert::audio_frame_from(
&frame,
mediadecode::Timebase::default(),
FrameLimits::default(),
) {
Err(ConvertError::UnsupportedChannelCount(p)) => assert_eq!(p.channels(), i32::MAX),
Err(other) => panic!("expected UnsupportedChannelCount, got {other:?}"),
Ok(_) => panic!("a custom layout of i32::MAX channels produced a frame"),
}
}
fn picture_av_frame(width: i32, height: i32) -> ffmpeg_next::frame::Video {
let frame = ffmpeg_next::frame::Video::empty();
unsafe {
let p = frame.as_ptr() as *mut ffmpeg_next::ffi::AVFrame;
(*p).format = ffmpeg_next::ffi::AVPixelFormat::AV_PIX_FMT_YUV420P as i32;
(*p).width = width;
(*p).height = height;
}
frame
}
#[test]
fn negative_picture_dimensions_are_refused_on_both_picture_roads() {
use mediadecode_ffmpeg::{FrameLimits, convert, convert::ConvertError};
support::init_ffmpeg();
for (w, h) in [(-1, 32), (32, -1), (-4, -4)] {
let frame = picture_av_frame(w, h);
match convert::video_frame_from(
&frame,
mediadecode::Timebase::default(),
FrameLimits::default(),
) {
Err(ConvertError::InvalidDimensions(p)) => {
assert_eq!((p.width(), p.height()), (w, h), "reported as declared");
}
Err(other) => panic!("video {w}x{h}: expected InvalidDimensions, got {other:?}"),
Ok(_) => panic!("video {w}x{h} was floored into a frame instead of refused"),
}
match convert::image_frame_from(&frame, FrameLimits::default()) {
Err(ConvertError::InvalidDimensions(p)) => {
assert_eq!((p.width(), p.height()), (w, h));
}
Err(other) => panic!("image {w}x{h}: expected InvalidDimensions, got {other:?}"),
Ok(_) => panic!("image {w}x{h} was floored into a frame instead of refused"),
}
}
}
#[test]
fn the_image_seam_honours_the_packet_flags_it_used_to_drop() {
use mediadecode::packet::PacketFlags;
use mediadecode_ffmpeg::CorruptSource;
let Some(corpus) = support::Corpus::new() else {
return;
};
support::init_ffmpeg();
let path = corpus.cover_art_mp3();
let mut demuxer = FfmpegDemuxer::open(&path).expect("open");
let cover = demuxer
.tracks()
.iter()
.position(|t| t.kind() == TrackKind::Attachment)
.expect("the file has cover art");
let parameters = demuxer.tracks()[cover]
.extra()
.clone_parameters()
.expect("parameters");
let mut payload = None;
while let Some(packet) = demuxer.next_packet().expect("read") {
if let DemuxedPacket::Attachment(attachment) = packet {
payload = Some(attachment.into_packet());
break;
}
}
let payload = payload.expect("the attachment track delivers its packet");
let mut decoder =
FfmpegImageDecoder::open(parameters, DecoderLimits::default()).expect("open image decoder");
decoder.decode(&payload).expect("the plain cover decodes");
let corrupt = payload
.clone()
.with_flags(PacketFlags::KEY | PacketFlags::CORRUPT);
match decoder.decode(&corrupt) {
Err(ImageDecodeError::Corrupt(p)) => {
assert_eq!(p.declared_by(), CorruptSource::Packet);
}
Err(other) => panic!("expected a named corruption refusal, got {other:?}"),
Ok(_) => panic!("a packet marked corrupt came back as a picture"),
}
let discard = payload
.clone()
.with_flags(PacketFlags::KEY | PacketFlags::DISCARD);
match decoder.decode(&discard) {
Err(ImageDecodeError::NoImage) => {}
Err(other) => panic!("expected the decoder to drop a DISCARD packet, got {other:?}"),
Ok(_) => panic!("DISCARD never reached libavcodec — the rebuilt packet lost its flags"),
}
let trusted = payload
.clone()
.with_flags(PacketFlags::from_bits_retain(0b0000_1000));
assert!(
matches!(
decoder.decode(&trusted),
Err(ImageDecodeError::TrustedPayload(_)),
),
"a TRUSTED attachment must not reach libavcodec",
);
decoder.decode(&payload).expect("the seam recovers");
}
fn allocated_picture(width: i32, height: i32, crop: [u64; 4]) -> ffmpeg_next::frame::Video {
let mut frame = ffmpeg_next::frame::Video::empty();
unsafe {
let p = frame.as_mut_ptr();
(*p).format = ffmpeg_next::ffi::AVPixelFormat::AV_PIX_FMT_YUV420P as i32;
(*p).width = width;
(*p).height = height;
let rc = ffmpeg_next::ffi::av_frame_get_buffer(p, 32);
assert_eq!(rc, 0, "av_frame_get_buffer");
(*p).crop_left = crop[0] as usize;
(*p).crop_top = crop[1] as usize;
(*p).crop_right = crop[2] as usize;
(*p).crop_bottom = crop[3] as usize;
}
frame
}
#[test]
fn a_crop_that_cannot_be_a_crop_withholds_the_rect_instead_of_inventing_one() {
use mediadecode_ffmpeg::{FrameLimits, convert};
support::init_ffmpeg();
let convert_it = |frame: &ffmpeg_next::frame::Video| {
convert::video_frame_from(
frame,
mediadecode::Timebase::default(),
FrameLimits::default(),
)
.expect("a well-formed picture converts")
};
let ok = allocated_picture(64, 64, [4, 8, 4, 8]);
let rect = convert_it(&ok)
.visible_rect()
.expect("a real crop is reported");
assert_eq!((rect.x(), rect.y()), (4, 8));
assert_eq!((rect.width(), rect.height()), (64 - 8, 64 - 16));
let overflow = allocated_picture(64, 64, [u64::MAX, 0, 2, 0]);
assert!(
convert_it(&overflow).visible_rect().is_none(),
"an overflowing crop must not produce a rect",
);
let at_extent = allocated_picture(64, 64, [32, 0, 32, 0]);
assert!(
convert_it(&at_extent).visible_rect().is_none(),
"a crop that leaves nothing is not a crop",
);
let just_inside = allocated_picture(64, 64, [31, 0, 32, 0]);
let rect = convert_it(&just_inside)
.visible_rect()
.expect("one column left is still a picture");
assert_eq!(rect.width(), 1);
}
#[test]
fn an_undersized_stride_is_refused_before_any_plane_is_copied() {
use mediadecode_ffmpeg::{FrameLimits, convert, convert::ConvertError};
support::init_ffmpeg();
let frame = allocated_picture(64, 64, [0; 4]);
unsafe {
(*frame.as_ptr().cast_mut()).linesize[0] = 8; }
match convert::video_frame_from(
&frame,
mediadecode::Timebase::default(),
FrameLimits::default(),
) {
Err(ConvertError::InvalidPlaneLayout(p)) => assert_eq!(p.plane(), 0),
Err(other) => panic!("expected InvalidPlaneLayout, got {other:?}"),
Ok(_) => panic!("a plane narrower than its format converted"),
}
}
#[test]
fn a_still_refuses_oversized_side_data_rather_than_dropping_the_orientation() {
use mediadecode_ffmpeg::{
DEFAULT_MAX_IMAGE_SIDE_DATA_BYTES, FrameLimits, convert, convert::ConvertError,
};
support::init_ffmpeg();
const ICC: ffmpeg_next::ffi::AVFrameSideDataType =
ffmpeg_next::ffi::AVFrameSideDataType::AV_FRAME_DATA_ICC_PROFILE;
const MATRIX: ffmpeg_next::ffi::AVFrameSideDataType =
ffmpeg_next::ffi::AVFrameSideDataType::AV_FRAME_DATA_DISPLAYMATRIX;
let with_side_data = |icc_bytes: usize| {
let frame = allocated_picture(16, 16, [0; 4]);
unsafe {
let icc = ffmpeg_next::ffi::av_frame_new_side_data(frame.as_ptr().cast_mut(), ICC, icc_bytes);
assert!(!icc.is_null(), "side-data allocation");
let m = ffmpeg_next::ffi::av_frame_new_side_data(frame.as_ptr().cast_mut(), MATRIX, 36);
assert!(!m.is_null(), "side-data allocation");
}
frame
};
let ok = with_side_data(4 * 1024 * 1024);
let image = convert::image_frame_from(&ok, FrameLimits::default()).expect("inside the seat");
let kinds: Vec<i32> = image.extra().side_data().iter().map(|e| e.kind()).collect();
assert!(kinds.contains(&(ICC as i32)), "the profile was dropped");
assert!(
kinds.contains(&(MATRIX as i32)),
"the orientation was pushed off the end by the profile — the exact loss",
);
let over = with_side_data(DEFAULT_MAX_IMAGE_SIDE_DATA_BYTES + 1);
match convert::image_frame_from(&over, FrameLimits::default()) {
Err(ConvertError::ImageSideDataTooLarge(p)) => {
assert_eq!(p.limit(), DEFAULT_MAX_IMAGE_SIDE_DATA_BYTES);
assert!(p.bytes() > p.limit());
}
Err(other) => panic!("expected ImageSideDataTooLarge, got {other:?}"),
Ok(_) => panic!("over-budget side data was silently truncated"),
}
assert!(matches!(
convert::image_frame_from(&ok, FrameLimits::new().with_max_image_side_data_bytes(1024)),
Err(ConvertError::ImageSideDataTooLarge(_)),
));
}
#[test]
fn a_still_judges_its_side_data_before_it_buys_a_single_plane() {
use mediadecode_ffmpeg::{
DEFAULT_MAX_IMAGE_SIDE_DATA_BYTES, FrameLimits, convert, convert::ConvertError,
};
support::init_ffmpeg();
let frame = allocated_picture(64, 64, [0; 4]);
const ICC: ffmpeg_next::ffi::AVFrameSideDataType =
ffmpeg_next::ffi::AVFrameSideDataType::AV_FRAME_DATA_ICC_PROFILE;
unsafe {
let p = frame.as_ptr().cast_mut();
let sd =
ffmpeg_next::ffi::av_frame_new_side_data(p, ICC, DEFAULT_MAX_IMAGE_SIDE_DATA_BYTES + 1);
assert!(!sd.is_null(), "side-data allocation");
(*p).data[0] = core::ptr::null_mut();
}
match convert::image_frame_from(&frame, FrameLimits::default()) {
Err(ConvertError::ImageSideDataTooLarge(p)) => {
assert_eq!(p.limit(), DEFAULT_MAX_IMAGE_SIDE_DATA_BYTES);
}
Err(ConvertError::InvalidPlaneLayout(_)) => {
panic!("the planes were walked before the side data was judged")
}
Err(other) => panic!("expected ImageSideDataTooLarge, got {other:?}"),
Ok(_) => panic!("over-budget side data was accepted"),
}
}
#[test]
fn a_degenerate_aspect_ratio_cannot_outrun_the_byte_ceiling() {
let Some(corpus) = Corpus::new() else {
return;
};
support::init_ffmpeg();
const CEILING: usize = 1024 * 1024;
for (w, h) in [(65_536u32, 1u32), (1u32, 65_536u32)] {
let path = corpus.gray_png(w, h);
match open_still_and_decode(&path, CEILING) {
Err(ImageDecodeError::Decode(_)) | Err(ImageDecodeError::NoImage) => {}
Err(other) => panic!("{w}x{h}: expected a pre-allocation refusal, got {other:?}"),
Ok(()) => panic!("{w}x{h} bought {}x more than its ceiling allowed", 32),
}
}
let square = corpus.gray_png(256, 256);
open_still_and_decode(&square, CEILING).expect("a square of the same pixel count fits");
let uhd = corpus.gray_png(7680, 4320);
open_still_and_decode(&uhd, mediadecode_ffmpeg::DEFAULT_MAX_FRAME_BYTES)
.expect("8K must still decode");
}
fn open_still_and_decode(path: &std::path::Path, bytes: usize) -> Result<(), ImageDecodeError> {
use mediadecode_ffmpeg::FrameLimits;
let mut input = ffmpeg_next::format::input(&path).expect("open the still");
let stream = input
.streams()
.best(ffmpeg_next::media::Type::Video)
.expect("a picture stream");
let index = stream.index();
let parameters = stream.parameters();
let mut payload = Vec::new();
loop {
let mut packet = ffmpeg_next::Packet::empty();
match packet.read(&mut input) {
Ok(()) if packet.stream() == index => {
payload = packet.data().unwrap_or(&[]).to_vec();
break;
}
Ok(()) => continue,
Err(ffmpeg_next::Error::Eof) => break,
Err(e) => panic!("read: {e}"),
}
}
drop(input);
let mut decoder = FfmpegImageDecoder::open(
parameters,
DecoderLimits::new()
.with_frame(FrameLimits::new().with_max_frame_bytes(bytes))
.with_max_image_input_bytes(usize::MAX),
)?;
let packet = AttachmentPacket::new(
FfmpegBytes::try_copy_from_slice(&payload).expect("a test payload"),
mediadecode_ffmpeg::extras::AttachmentPacketExtra::new(0),
);
decoder.decode(&packet).map(|_| ())
}
#[test]
fn audio_gets_a_pre_allocation_ceiling_too() {
use mediadecode::decoder::AudioStreamDecoder;
use mediadecode_ffmpeg::{
AudioDecodeError, FfmpegOwnedAudioStreamDecoder as FfmpegAudioStreamDecoder, FrameLimits,
};
let Some(corpus) = Corpus::new() else {
return;
};
support::init_ffmpeg();
let path = corpus.sine_wav("r11.wav", 48_000, 2, 440, 0.25);
let decode_with = |bytes: usize| {
let mut input = ffmpeg_next::format::input(&path).expect("open");
let stream = input
.streams()
.best(ffmpeg_next::media::Type::Audio)
.expect("an audio stream");
let index = stream.index();
let parameters = stream.parameters();
let time_base = mediadecode::Timebase::default();
let mut decoder = FfmpegAudioStreamDecoder::open(
parameters,
time_base,
DecoderLimits::new().with_frame(FrameLimits::new().with_max_frame_bytes(bytes)),
)
.expect("open the audio decoder");
let mut bodies: Vec<Vec<u8>> = Vec::new();
while bodies.len() < 4 {
let mut packet = ffmpeg_next::Packet::empty();
match packet.read(&mut input) {
Ok(()) if packet.stream() == index => {
bodies.push(packet.data().unwrap_or(&[]).to_vec());
}
Ok(()) => continue,
Err(ffmpeg_next::Error::Eof) => break,
Err(e) => panic!("read: {e}"),
}
}
drop(input);
let mut frame = mediadecode_ffmpeg::empty_owned_audio_frame();
for body in bodies {
let audio = AudioPacket::new(
FfmpegBytes::try_copy_from_slice(&body).expect("a test payload"),
mediadecode_ffmpeg::extras::AudioPacketExtra::new(0),
);
let _ = decoder.send_packet(&audio)?;
match decoder.receive_frame(&mut frame) {
Ok(Received::Frame) => return Ok(()),
Ok(Received::NeedsInput) => continue,
Ok(Received::Ended) => break,
Err(e) => return Err(e),
}
}
Err(
decoder
.receive_frame(&mut frame)
.expect_err("no frame and no error"),
)
};
match decode_with(4_080) {
Err(AudioDecodeError::Decode(_)) => {}
Err(AudioDecodeError::Convert(_)) => {
panic!("audio had no pre-allocation guard: the frame was allocated, then refused")
}
Err(other) => panic!("an unexpected refusal shape: {other:?}"),
Ok(()) => panic!("an oversized audio frame passed a 4080-byte ceiling"),
}
assert!(decode_with(mediadecode_ffmpeg::DEFAULT_MAX_FRAME_BYTES).is_ok());
}
fn decode_first_audio(
path: &std::path::Path,
bytes: usize,
) -> Result<AudioFrame, mediadecode_ffmpeg::AudioDecodeError> {
use mediadecode::decoder::AudioStreamDecoder;
use mediadecode_ffmpeg::{
FfmpegOwnedAudioStreamDecoder as FfmpegAudioStreamDecoder, FrameLimits,
};
let mut input = ffmpeg_next::format::input(&path).expect("open");
let stream = input
.streams()
.best(ffmpeg_next::media::Type::Audio)
.expect("an audio stream");
let index = stream.index();
let parameters = stream.parameters();
let mut decoder = FfmpegAudioStreamDecoder::open(
parameters,
mediadecode::Timebase::default(),
DecoderLimits::new().with_frame(FrameLimits::new().with_max_frame_bytes(bytes)),
)
.expect("open the audio decoder");
let mut frame = mediadecode_ffmpeg::empty_owned_audio_frame();
loop {
let mut packet = ffmpeg_next::Packet::empty();
match packet.read(&mut input) {
Ok(()) if packet.stream() == index => {
let body = packet.data().unwrap_or(&[]).to_vec();
let audio = AudioPacket::new(
FfmpegBytes::try_copy_from_slice(&body).expect("a test payload"),
mediadecode_ffmpeg::extras::AudioPacketExtra::new(0),
);
let _ = decoder.send_packet(&audio)?;
match decoder.receive_frame(&mut frame) {
Ok(Received::Frame) => return Ok(frame),
Ok(Received::NeedsInput | Received::Ended) => continue,
Err(e) => return Err(e),
}
}
Ok(()) => continue,
Err(ffmpeg_next::Error::Eof) => panic!("no audio frame in {}", path.display()),
Err(e) => panic!("read: {e}"),
}
}
}
#[test]
fn the_hardware_road_still_delivers_frames_through_its_new_seat() {
use mediadecode_ffmpeg::{Frame, VideoDecoder};
let Some(corpus) = Corpus::new() else {
return;
};
support::init_ffmpeg();
let path = corpus.multi_track_mkv();
let mut input = ffmpeg_next::format::input(&path).expect("open");
let stream = input
.streams()
.best(ffmpeg_next::media::Type::Video)
.expect("a video stream");
let index = stream.index();
let mut decoder = VideoDecoder::open(stream.parameters()).expect("open the video decoder");
let mut frame = Frame::empty().expect("allocate a frame slot");
let mut delivered = false;
'outer: loop {
let mut packet = ffmpeg_next::Packet::empty();
match packet.read(&mut input) {
Ok(()) if packet.stream() == index => {
if decoder.send_packet(&packet).is_err() {
continue;
}
if matches!(decoder.receive_frame(&mut frame), Ok(Received::Frame)) {
delivered = true;
break 'outer;
}
}
Ok(()) => continue,
Err(ffmpeg_next::Error::Eof) => break,
Err(e) => panic!("read: {e}"),
}
}
assert!(
delivered,
"the {:?} road stopped delivering frames",
decoder.backend(),
);
assert!(frame.width() > 0 && frame.height() > 0);
}
#[test]
fn a_cropped_stream_is_judged_on_what_it_allocates_not_what_it_displays() {
use mediadecode_ffmpeg::{FrameLimits, VideoDecoder};
let Some(corpus) = Corpus::new() else {
return;
};
support::init_ffmpeg();
let path = corpus.cropped_h264();
const CEILING: usize = 1024 * 1024;
let decode_under = |bytes: usize| -> Result<(), mediadecode_ffmpeg::Error> {
let mut input = ffmpeg_next::format::input(&path).expect("open");
let stream = input
.streams()
.best(ffmpeg_next::media::Type::Video)
.expect("a video stream");
let index = stream.index();
let mut decoder = VideoDecoder::open_with_frame_limits(
stream.parameters(),
DecoderLimits::new().with_frame(FrameLimits::new().with_max_frame_bytes(bytes)),
)
.expect("open the video decoder");
let mut frame = mediadecode_ffmpeg::Frame::empty().expect("frame slot");
loop {
let mut packet = ffmpeg_next::Packet::empty();
match packet.read(&mut input) {
Ok(()) if packet.stream() == index => {
let _ = decoder.send_packet(&packet)?;
match decoder.receive_frame(&mut frame) {
Ok(Received::Frame) => return Ok(()),
Ok(Received::NeedsInput | Received::Ended) => continue,
Err(e) => return Err(e),
}
}
Ok(()) => continue,
Err(e) => panic!("read: {e}"),
}
}
};
let refusal = decode_under(CEILING)
.expect_err("the cropped stream was judged on its display extent, not its allocation");
let mediadecode_ffmpeg::Error::AllBackendsFailed(failed) = &refusal else {
panic!("expected a backend failure, got {refusal:?}");
};
let named = failed
.attempts()
.iter()
.find_map(|(_, e)| match e.as_ref() {
mediadecode_ffmpeg::Error::HwSurfaceTooLarge(p) => Some(*p),
_ => None,
});
let named = named.unwrap_or_else(|| {
panic!(
"the refusal still wears libavcodec's misleading name: {:?}",
failed.attempts(),
)
});
assert!(
named.bytes() > 3_000_000 && named.bytes() < 8 * 1024 * 1024,
"a 1920x1088 NV12 pool should price around 3 MB, got {}",
named.bytes(),
);
assert_eq!(named.limit(), CEILING as i64);
decode_under(mediadecode_ffmpeg::DEFAULT_MAX_FRAME_BYTES)
.expect("an ordinary cropped stream must still decode");
}
#[test]
fn the_send_side_side_data_list_is_capped_in_both_directions() {
use mediadecode::packet::PacketFlags;
use mediadecode_ffmpeg::{PacketLimits, boundary, extras::VideoPacketExtra};
support::init_ffmpeg();
const NEW_EXTRADATA: i32 =
ffmpeg_next::ffi::AVPacketSideDataType::AV_PKT_DATA_NEW_EXTRADATA as i32;
let build = |entries: Vec<SideDataEntry>| {
let packet = VideoPacket::new(
FfmpegBytes::try_copy_from_slice(&[1u8, 2, 3, 4]).expect("a test payload"),
VideoPacketExtra::new(0).with_side_data(entries),
)
.with_flags(PacketFlags::KEY);
boundary::ffmpeg_packet_from_owned_video_packet(&packet, PacketLimits::default())
};
let entry = |bytes: usize| {
SideDataEntry::new(
NEW_EXTRADATA,
FfmpegBytes::try_copy_from_slice(&vec![7u8; bytes]).expect("a test payload"),
)
};
assert!(build(vec![entry(64), entry(128)]).is_ok());
let many: Vec<SideDataEntry> = (0..65).map(|_| entry(8)).collect();
match build(many) {
Err(mediadecode_ffmpeg::boundary::PacketBuildError::SendSideDataTooLarge(p)) => {
assert_eq!(p.what(), "entry-count");
assert_eq!(p.value(), 65);
assert_eq!(p.limit(), 64);
}
Err(other) => panic!("expected an entry-count refusal, got {other:?}"),
Ok(_) => panic!("65 side-data entries were allocated with nothing counting them"),
}
match build(vec![entry(200 * 1024), entry(200 * 1024)]) {
Err(mediadecode_ffmpeg::boundary::PacketBuildError::SendSideDataTooLarge(p)) => {
assert_eq!(p.what(), "byte");
assert_eq!(p.limit(), 256 * 1024);
assert!(p.value() > 256 * 1024);
}
Err(other) => panic!("expected a byte refusal, got {other:?}"),
Ok(_) => panic!("400 KiB of side data was allocated with nothing weighing it"),
}
}
#[test]
fn every_hardware_exit_names_the_coded_surface_refusal() {
use mediadecode_ffmpeg::{Backend, Error, FrameLimits, VideoDecoder};
let Some(corpus) = Corpus::new() else {
return;
};
support::init_ffmpeg();
let path = corpus.cropped_h264();
fn named(e: &Error) -> Option<mediadecode_ffmpeg::HwSurfaceTooLarge> {
match e {
Error::HwSurfaceTooLarge(p) => Some(*p),
Error::AllBackendsFailed(f) => f.attempts().iter().find_map(|(_, inner)| named(inner)),
_ => None,
}
}
const CEILING: usize = 1024 * 1024;
let limits = DecoderLimits::new().with_frame(FrameLimits::new().with_max_frame_bytes(CEILING));
let mut input = ffmpeg_next::format::input(&path).expect("open");
let stream = input
.streams()
.best(ffmpeg_next::media::Type::Video)
.expect("a video stream");
let index = stream.index();
let mut decoder =
VideoDecoder::open_with_limits(stream.parameters(), Backend::VideoToolbox, limits)
.expect("open the explicit-backend decoder");
let mut frame = mediadecode_ffmpeg::Frame::empty().expect("frame slot");
let mut refusal = None;
loop {
let mut packet = ffmpeg_next::Packet::empty();
match packet.read(&mut input) {
Ok(()) if packet.stream() == index => {
let outcome = decoder
.send_packet(&packet)
.and_then(|_| decoder.receive_frame(&mut frame));
match outcome {
Ok(Received::Frame) => panic!("a 2 Mpx coded surface passed a 1 Mpx ceiling"),
Ok(Received::NeedsInput | Received::Ended) => continue,
Err(e) => {
refusal = named(&e);
break;
}
}
}
Ok(()) => continue,
Err(_) => break,
}
}
let payload = refusal.expect("the explicit-backend road still loses the refusal");
assert!(
payload.bytes() > 3_000_000,
"the pool's own extent, priced: {}",
payload.bytes(),
);
assert_eq!(payload.limit(), CEILING as i64);
}
#[test]
fn a_cheap_format_is_not_charged_the_worst_formats_price() {
use mediadecode_ffmpeg::{DecoderLimits, FrameLimits, VideoDecoder};
let Some(corpus) = Corpus::new() else {
return;
};
support::init_ffmpeg();
let path = corpus.cropped_h264();
let decode_under = |bytes: usize| -> Result<(), mediadecode_ffmpeg::Error> {
let mut input = ffmpeg_next::format::input(&path).expect("open");
let stream = input
.streams()
.best(ffmpeg_next::media::Type::Video)
.expect("a video stream");
let index = stream.index();
let mut decoder = VideoDecoder::open_with_frame_limits(
stream.parameters(),
DecoderLimits::new().with_frame(FrameLimits::new().with_max_frame_bytes(bytes)),
)?;
let mut frame = mediadecode_ffmpeg::Frame::empty().expect("frame slot");
loop {
let mut packet = ffmpeg_next::Packet::empty();
match packet.read(&mut input) {
Ok(()) if packet.stream() == index => {
let _ = decoder.send_packet(&packet)?;
match decoder.receive_frame(&mut frame) {
Ok(Received::Frame) => return Ok(()),
Ok(Received::NeedsInput | Received::Ended) => continue,
Err(e) => return Err(e),
}
}
Ok(()) => continue,
Err(e) => panic!("read: {e}"),
}
}
};
decode_under(4 * 1024 * 1024).expect("a 3.1 MB frame must fit a 4 MiB budget");
assert!(decode_under(1024 * 1024).is_err());
let wav = corpus.sine_wav("r19-6ch.wav", 48_000, 6, 440, 0.5);
let frame = decode_first_audio(&wav, 64 * 1024).expect("6ch s16 must fit 64 KiB");
assert_eq!(frame.channel_count(), 6);
assert!(frame.nb_samples() > 0);
match decode_first_audio(&wav, 4096) {
Err(mediadecode_ffmpeg::AudioDecodeError::Decode(_)) => {}
Err(other) => panic!("expected a pre-allocation refusal, got {other:?}"),
Ok(_) => panic!("a 49 KiB frame passed a 4 KiB budget"),
}
}
#[test]
fn the_still_road_still_refuses_what_it_cannot_afford() {
let Some(corpus) = Corpus::new() else {
return;
};
support::init_ffmpeg();
let path = corpus.exif_oriented_jpeg(1);
assert!(
open_still_and_decode(&path, 512).is_err(),
"a 512-byte budget decoded a picture",
);
open_still_and_decode(&path, mediadecode_ffmpeg::DEFAULT_MAX_FRAME_BYTES)
.expect("the defaults must decode an ordinary still");
}
#[test]
fn a_byte_refused_frame_is_named_on_every_software_road() {
use mediadecode_ffmpeg::{
AudioDecodeError, DecoderLimits, Error, FrameBudgetExceeded, FrameLimits, FrameMedium,
ImageDecodeError, VideoDecoder,
};
let Some(corpus) = Corpus::new() else {
return;
};
support::init_ffmpeg();
fn named(e: &Error) -> Option<FrameBudgetExceeded> {
match e {
Error::FrameBudgetExceeded(p) => Some(*p),
Error::AllBackendsFailed(f) => f.attempts().iter().find_map(|(_, inner)| named(inner)),
_ => None,
}
}
let path = corpus.cropped_h264();
let mut input = ffmpeg_next::format::input(&path).expect("open");
let stream = input
.streams()
.best(ffmpeg_next::media::Type::Video)
.expect("a video stream");
let index = stream.index();
let mut decoder = VideoDecoder::open_with_frame_limits(
stream.parameters(),
DecoderLimits::new().with_frame(FrameLimits::new().with_max_frame_bytes(1024 * 1024)),
)
.expect("open");
let mut frame = mediadecode_ffmpeg::Frame::empty().expect("frame slot");
let mut video_named = None;
loop {
let mut packet = ffmpeg_next::Packet::empty();
match packet.read(&mut input) {
Ok(()) if packet.stream() == index => {
let outcome = decoder
.send_packet(&packet)
.and_then(|_| decoder.receive_frame(&mut frame));
match outcome {
Ok(Received::Frame) => panic!("a 3.1 MB frame passed a 1 MiB ceiling"),
Ok(Received::NeedsInput | Received::Ended) => continue,
Err(e) => {
video_named = named(&e);
break;
}
}
}
Ok(()) => continue,
Err(_) => break,
}
}
assert!(
video_named.is_some() || decoder.backend() == mediadecode_ffmpeg::Backend::VideoToolbox,
"the video road refused a frame with no name",
);
let wav = corpus.sine_wav("r19-named.wav", 48_000, 6, 440, 0.5);
match decode_first_audio(&wav, 4096) {
Err(AudioDecodeError::Decode(e)) => {
let payload = named(&e).expect("the audio road refused a frame with no name");
assert_eq!(payload.medium(), FrameMedium::Audio);
assert_eq!(payload.limit(), 4096);
assert!(payload.bytes() > payload.limit());
}
Err(other) => panic!("expected a named budget refusal, got {other:?}"),
Ok(_) => panic!("a 49 KiB frame passed a 4 KiB budget"),
}
let still = corpus.exif_oriented_jpeg(1);
match open_still_and_decode(&still, 512) {
Err(ImageDecodeError::Decode(e)) => {
let payload = named(&e).expect("the still road refused a frame with no name");
assert_eq!(payload.medium(), FrameMedium::Video);
assert_eq!(payload.limit(), 512);
}
other => panic!("expected a named budget refusal, got {other:?}"),
}
decode_first_audio(&wav, mediadecode_ffmpeg::DEFAULT_MAX_FRAME_BYTES)
.expect("a fresh decoder under the defaults must decode");
}
#[test]
fn the_software_video_road_names_its_budget_refusals() {
use mediadecode::decoder::VideoStreamDecoder;
use mediadecode_ffmpeg::{
DecoderLimits, Error, FfmpegOwnedVideoStreamDecoder as FfmpegVideoStreamDecoder,
FrameBudgetExceeded, FrameLimits, FrameMedium, VideoDecodeError,
};
let Some(corpus) = Corpus::new() else {
return;
};
support::init_ffmpeg();
fn named(e: &Error) -> Option<FrameBudgetExceeded> {
match e {
Error::FrameBudgetExceeded(p) => Some(*p),
Error::AllBackendsFailed(f) => f.attempts().iter().find_map(|(_, inner)| named(inner)),
_ => None,
}
}
let path = corpus.software_only_video();
let decode_under = |bytes: usize| -> Result<(), VideoDecodeError> {
let mut input = ffmpeg_next::format::input(&path).expect("open");
let stream = input
.streams()
.best(ffmpeg_next::media::Type::Video)
.expect("a video stream");
let index = stream.index();
let mut decoder = FfmpegVideoStreamDecoder::open(
stream.parameters(),
mediadecode::Timebase::default(),
DecoderLimits::new().with_frame(FrameLimits::new().with_max_frame_bytes(bytes)),
)
.map_err(VideoDecodeError::Decode)?;
let mut frame = mediadecode_ffmpeg::empty_owned_video_frame();
loop {
let mut packet = ffmpeg_next::Packet::empty();
match packet.read(&mut input) {
Ok(()) if packet.stream() == index => {
let body = packet.data().unwrap_or(&[]).to_vec();
let video = VideoPacket::new(
FfmpegBytes::try_copy_from_slice(&body).expect("a test payload"),
mediadecode_ffmpeg::extras::VideoPacketExtra::new(0),
);
let _ = decoder.send_packet(&video)?;
match decoder.receive_frame(&mut frame) {
Ok(Received::Frame) => return Ok(()),
Ok(Received::NeedsInput | Received::Ended) => continue,
Err(e) => return Err(e),
}
}
Ok(()) => continue,
Err(e) => panic!("read: {e}"),
}
}
};
let refusal = decode_under(64 * 1024).expect_err("a 1.4 MB frame passed a 64 KiB ceiling");
let VideoDecodeError::Decode(inner) = &refusal else {
panic!("expected a decode failure, got {refusal:?}");
};
let payload = named(inner).expect("the software video road refused a frame with no name");
assert_eq!(payload.medium(), FrameMedium::Video);
assert_eq!(payload.limit(), 64 * 1024);
assert!(payload.bytes() > payload.limit());
decode_under(mediadecode_ffmpeg::DEFAULT_MAX_FRAME_BYTES)
.expect("a fresh decoder under the defaults must decode");
}