mod support;
use std::{
fs::File,
io::{Read, Seek},
};
use triomphe::Arc;
use mediadecode::{
Received, Timebase, Timestamp,
demuxer::{DemuxedPacket, Demuxer, TrackKind},
packet::PacketFlags,
};
use mediadecode_ffmpeg::{DemuxError, DemuxLimits, FfmpegOwnedDemuxer as FfmpegDemuxer, TrackInfo};
use support::Corpus;
fn drain(demuxer: &mut FfmpegDemuxer) -> Vec<(usize, TrackKind, Option<Timestamp>)> {
let mut out = Vec::new();
while let Some(packet) = demuxer.next_packet().expect("pull") {
let pts = match &packet {
DemuxedPacket::Video(p) => p.packet().pts(),
DemuxedPacket::Audio(p) => p.packet().pts(),
DemuxedPacket::Subtitle(p) => p.packet().pts(),
DemuxedPacket::Data(p) => p.packet().pts(),
DemuxedPacket::Attachment(_) => None,
};
out.push((packet.track().get(), packet.kind(), pts));
}
out
}
#[test]
fn the_five_kinds_map_to_the_five_arms() {
let Some(corpus) = Corpus::new() else { return };
let multi = FfmpegDemuxer::open(&corpus.multi_track_mkv()).expect("open mkv");
let kinds: Vec<_> = multi.tracks().iter().map(|t| t.kind()).collect();
assert_eq!(
kinds,
vec![
TrackKind::Video,
TrackKind::Audio,
TrackKind::Subtitle,
TrackKind::Attachment,
],
"the Matroska file's four tracks",
);
let cover = FfmpegDemuxer::open(&corpus.cover_art_mp3()).expect("open mp3");
let kinds: Vec<_> = cover.tracks().iter().map(|t| t.kind()).collect();
assert_eq!(kinds, vec![TrackKind::Audio, TrackKind::Attachment]);
let mov = FfmpegDemuxer::open(&corpus.timecode_mov()).expect("open mov");
let kinds: Vec<_> = mov.tracks().iter().map(|t| t.kind()).collect();
assert_eq!(
kinds,
vec![TrackKind::Video, TrackKind::Audio, TrackKind::Data],
"-timecode adds a tmcd data track",
);
}
#[test]
fn a_track_row_carries_its_codec_parameters_and_attachment_identity() {
let Some(corpus) = Corpus::new() else { return };
let demuxer = FfmpegDemuxer::open(&corpus.multi_track_mkv()).expect("open mkv");
let video = &demuxer.tracks()[0];
match video.params() {
mediadecode::demuxer::TrackParams::Video(p) => {
assert_eq!((p.width(), p.height()), (160, 120));
}
other => panic!("expected video params, got {:?}", other.kind()),
}
let audio = &demuxer.tracks()[1];
match audio.params() {
mediadecode::demuxer::TrackParams::Audio(p) => {
assert_eq!(p.sample_rate(), 48_000);
assert_eq!(p.channel_count(), 2);
assert_eq!(p.channel_layout().channels(), 2);
}
other => panic!("expected audio params, got {:?}", other.kind()),
}
let font = &demuxer.tracks()[3];
assert_eq!(font.filename().map(|s| s.as_str()), Some("font.ttf"));
assert_eq!(
font.mime_type().map(|s| s.as_str()),
Some("application/x-truetype-font"),
);
assert_eq!(
audio.extra().ticket().codec_type(),
ffmpeg_next::ffi::AVMediaType::AVMEDIA_TYPE_AUDIO as i32,
);
assert_eq!(audio.extra().stream_index(), 1);
let expected_kinds: Vec<_> = demuxer.tracks().iter().map(|t| t.kind()).collect();
let held: Vec<Arc<TrackInfo>> = demuxer.tracks().to_vec();
assert_eq!(
held.iter().map(|t| t.kind()).collect::<Vec<_>>(),
expected_kinds,
"every row is reachable, in table order",
);
for (before, after) in held.iter().zip(demuxer.tracks()) {
assert!(
Arc::ptr_eq(before, after),
"a handle addresses the session's own row, not a copy",
);
}
}
#[test]
fn the_session_carries_the_container_libavformat_identified() {
let Some(corpus) = Corpus::new() else { return };
let mkv = FfmpegDemuxer::open(&corpus.multi_track_mkv()).expect("open mkv");
let format = mkv.format().expect("an opened session has a format");
assert_eq!(format.name(), "matroska,webm");
assert_eq!(format.long_name(), Some("Matroska / WebM"));
assert_eq!(format.names().collect::<Vec<_>>(), ["matroska", "webm"]);
let mov = FfmpegDemuxer::open(&corpus.timecode_mov()).expect("open mov");
let format = mov.format().expect("an opened session has a format");
assert_eq!(format.name(), "mov,mp4,m4a,3gp,3g2,mj2");
assert_eq!(
format.names().collect::<Vec<_>>(),
["mov", "mp4", "m4a", "3gp", "3g2", "mj2"],
"the ISOBMFF demuxer handles a family, and the door hands over the whole list rather \
than picking one of them",
);
}
#[test]
fn the_container_word_follows_the_bytes_and_not_the_path() {
let Some(corpus) = Corpus::new() else { return };
let honest = corpus.multi_track_mkv();
let misnamed = corpus.dir().join("actually-matroska.mp4");
std::fs::copy(&honest, &misnamed).expect("copy the same bytes under another name");
let demuxer = FfmpegDemuxer::open(&misnamed).expect("open the misnamed file");
assert_eq!(
demuxer.format().map(|f| f.name()),
Some("matroska,webm"),
"the extension said mp4 and the bytes said Matroska; the door reports the bytes",
);
}
#[test]
fn a_track_row_carries_the_language_the_container_declares() {
let Some(corpus) = Corpus::new() else { return };
let demuxer = FfmpegDemuxer::open(&corpus.language_tagged_mkv()).expect("open mkv");
let language = |index: usize| {
demuxer.tracks()[index]
.language()
.map(|tag| tag.as_str().to_owned())
};
assert_eq!(
language(0),
None,
"Matroska omits the element for an untagged track, so the row says nothing rather than \
guessing",
);
assert_eq!(language(1).as_deref(), Some("jpn"));
assert_eq!(
language(2).as_deref(),
Some("ger"),
"639-2/B is what the file wrote, so 639-2/B is what the row carries — the fold onto a \
canonical spelling belongs to whoever owns the language vocabulary",
);
}
#[test]
fn an_undetermined_declaration_is_not_a_missing_one() {
let Some(corpus) = Corpus::new() else { return };
let demuxer = FfmpegDemuxer::open(&corpus.language_tagged_mp4()).expect("open mp4");
assert_eq!(
demuxer.tracks()[0].language().map(|tag| tag.as_str()),
Some("und"),
"the MP4 declared its video track undetermined, which is not the same as declaring \
nothing",
);
assert_eq!(
demuxer.tracks()[1].language().map(|tag| tag.as_str()),
Some("ger"),
);
}
#[test]
fn a_session_carries_the_chapter_table_the_container_declares() {
let Some(corpus) = Corpus::new() else { return };
let demuxer = FfmpegDemuxer::open(&corpus.chaptered_mkv()).expect("open mkv");
let chapters = demuxer.chapters();
assert_eq!(chapters.len(), 2, "the sidecar wrote two chapters");
let ids: Vec<i64> = chapters.iter().map(|c| c.id()).collect();
assert_eq!(
ids,
vec![1, 2],
"the ids are Matroska's UIDs — offset off zero by the muxer because the format forbids \
that one — and not the rows' positions, which are 0 and 1",
);
for (index, chapter) in chapters.iter().enumerate() {
assert_eq!(
chapter.timebase(),
Timebase::NANOS,
"chapter {index}: Matroska counts chapter time in nanoseconds",
);
}
assert_eq!(chapters[0].start().pts(), 0);
assert_eq!(chapters[0].end().pts(), 1_000_000_000);
assert_eq!(chapters[1].start().pts(), 1_000_000_000);
assert_eq!(chapters[1].end().pts(), 2_500_000_000);
assert_eq!(chapters[0].end(), Timestamp::new(1, Timebase::SECONDS));
assert_eq!(chapters[1].start(), Timestamp::new(1_000, Timebase::MILLIS));
let titles: Vec<Option<&str>> = chapters
.iter()
.map(|c| c.title().map(|t| t.as_str()))
.collect();
assert_eq!(titles, vec![Some("Opening"), Some("Closing")]);
assert_eq!(demuxer.chapters().len(), 2);
}
#[test]
fn a_container_without_chapters_answers_an_empty_table() {
let Some(corpus) = Corpus::new() else { return };
let demuxer = FfmpegDemuxer::open(&corpus.multi_track_mkv()).expect("open mkv");
assert!(
demuxer.chapters().is_empty(),
"a Matroska with four tracks and no Chapters element declares none",
);
assert_eq!(
demuxer.tracks().len(),
4,
"and an empty chapter table says nothing about the track table",
);
}
#[test]
fn a_malformed_chapter_timebase_is_refused_by_name() {
support::init_ffmpeg();
let dir = tempfile::tempdir().expect("temp dir");
let sidecar = |name: &str, timebase: &str| {
let path = dir.path().join(name);
std::fs::write(
&path,
format!(";FFMETADATA1\n[CHAPTER]\nTIMEBASE={timebase}\nSTART=0\nEND=1000\ntitle=Bad\n\n"),
)
.expect("writing the sidecar");
path
};
for (name, timebase, expected) in [
("negative-num.ffmeta", "-1/1000", (-1, 1000)),
("negative-den.ffmeta", "1/-1000", (1, -1000)),
("zero-num.ffmeta", "0/1000", (0, 1000)),
] {
let opened = FfmpegDemuxer::open(&sidecar(name, timebase)).map(|d| d.chapters().len());
match opened {
Err(DemuxError::ChapterTimebaseInvalid(fault)) => {
assert_eq!(fault.index(), 0);
assert_eq!(
(fault.num(), fault.den()),
expected,
"{timebase}: the rational is reported as the container wrote it, not as a repair",
);
}
other => panic!("{timebase} must be refused by name, got {other:?}"),
}
}
let good = FfmpegDemuxer::open(&sidecar("good.ffmeta", "1/1000")).expect("open the sidecar");
assert_eq!(good.chapters().len(), 1);
assert_eq!(
good.chapters()[0].end(),
Timestamp::new(1, Timebase::SECONDS)
);
}
#[test]
fn a_chapter_table_over_the_ceiling_is_refused_before_it_is_allocated() {
let Some(corpus) = Corpus::new() else { return };
let path = corpus.chaptered_mkv();
let opened = FfmpegDemuxer::open_with(&path, DemuxLimits::new().with_max_chapters(1))
.map(|d| d.chapters().len());
match opened {
Err(DemuxError::TooManyChapters(fault)) => {
assert_eq!(fault.declared(), 2);
assert_eq!(fault.limit(), 1);
}
other => panic!("a two-chapter file under a ceiling of one must be refused, got {other:?}"),
}
assert_eq!(
FfmpegDemuxer::open(&path).expect("open").chapters().len(),
2,
"and the default ceiling admits the very same file",
);
}
#[test]
fn the_chapter_titles_are_charged_against_a_whole_file_budget() {
let Some(corpus) = Corpus::new() else { return };
let opened = FfmpegDemuxer::open_with(
&corpus.chaptered_mkv(),
DemuxLimits::new().with_max_total_chapter_title_bytes(1),
)
.map(|d| d.chapters().len());
match opened {
Err(DemuxError::ChapterTitleBudgetExhausted(fault)) => {
assert_eq!(
fault.index(),
0,
"the first title already crosses a one-byte budget",
);
assert_eq!(fault.bytes(), "Opening".len());
assert_eq!(fault.limit(), 1);
}
other => panic!("the title budget must be enforced, got {other:?}"),
}
}
#[test]
fn an_over_long_chapter_title_is_refused_rather_than_erased() {
support::init_ffmpeg();
let dir = tempfile::tempdir().expect("temp dir");
let sidecar = |name: &str, title_len: usize| {
let path = dir.path().join(name);
std::fs::write(
&path,
format!(
";FFMETADATA1\n[CHAPTER]\nTIMEBASE=1/1000\nSTART=0\nEND=1000\ntitle={}\n\n",
"x".repeat(title_len),
),
)
.expect("writing the sidecar");
path
};
let admitted = FfmpegDemuxer::open(&sidecar("at-the-cap.ffmeta", 65_535)).expect("open");
assert_eq!(
admitted.chapters()[0].title().map(|t| t.len()),
Some(65_535),
"a title one byte under the cap is carried, not dropped",
);
let opened =
FfmpegDemuxer::open(&sidecar("over-the-cap.ffmeta", 65_536)).map(|d| d.chapters().len());
match opened {
Err(DemuxError::ChapterTitleTooLong(fault)) => {
assert_eq!(fault.index(), 0);
assert_eq!(fault.limit(), 64 * 1024);
}
other => panic!("an over-long title must be refused by name, got {other:?}"),
}
}
#[test]
fn a_chapter_title_over_the_budget_is_refused_before_it_is_built() {
support::init_ffmpeg();
let dir = tempfile::tempdir().expect("temp dir");
let path = dir.path().join("zero-budget.ffmeta");
std::fs::write(
&path,
format!(
";FFMETADATA1\n[CHAPTER]\nTIMEBASE=1/1000\nSTART=0\nEND=1000\ntitle={}\n\n",
"x".repeat(60_000),
),
)
.expect("writing the sidecar");
let opened = FfmpegDemuxer::open_with(
&path,
DemuxLimits::new().with_max_total_chapter_title_bytes(0),
)
.map(|d| d.chapters().len());
match opened {
Err(DemuxError::ChapterTitleBudgetExhausted(fault)) => {
assert_eq!(fault.index(), 0);
assert_eq!(fault.limit(), 0);
assert_eq!(
fault.bytes(),
60_000,
"the charge is the measured size, which is what makes the refusal precede the copy",
);
}
other => panic!("a zero title budget must refuse, got {other:?}"),
}
}
#[test]
fn stream_metadata_is_charged_against_a_whole_file_budget() {
let Some(corpus) = Corpus::new() else { return };
let path = corpus.multi_track_mkv();
let opened = FfmpegDemuxer::open_with(
&path,
DemuxLimits::new().with_max_total_stream_metadata_bytes(1),
)
.map(|d| d.tracks().len());
match opened {
Err(DemuxError::TrackMetadataBudgetExhausted(fault)) => {
assert_eq!(fault.limit(), 1);
assert!(
fault.bytes() > 1,
"the refusal names the running total that crossed the line",
);
assert!(
!fault.key().is_empty(),
"and which of the three values it was reading",
);
}
other => panic!("a one-byte metadata budget must refuse, got {other:?}"),
}
let demuxer = FfmpegDemuxer::open(&path).expect("open");
assert_eq!(
demuxer.tracks()[3].filename().map(|s| s.as_str()),
Some("font.ttf"),
);
}
#[test]
fn a_track_row_names_its_codec() {
let Some(corpus) = Corpus::new() else { return };
let demuxer = FfmpegDemuxer::open(&corpus.multi_track_mkv()).expect("open mkv");
let named: Vec<_> = demuxer
.tracks()
.iter()
.take(3)
.map(|track| {
let codec = track.params().codec();
(codec.name().map(|n| n.as_str().to_owned()), codec.raw())
})
.collect();
assert_eq!(
named
.iter()
.map(|(name, _)| name.as_deref())
.collect::<Vec<_>>(),
[Some("h264"), Some("aac"), Some("subrip")],
);
for (name, raw) in &named {
assert!(
name.is_some() && *raw != mediadecode_ffmpeg::CodecId::NONE.raw(),
"a coded track carries both readings — the number to key on and the word to cross with",
);
}
}
#[test]
fn packets_arrive_in_interleaved_file_order() {
let Some(corpus) = Corpus::new() else { return };
let path = corpus.multi_track_mkv();
let expected = support::raw_packet_order(&path);
let mut demuxer = FfmpegDemuxer::open(&path).expect("open mkv");
let delivered = drain(&mut demuxer);
let (head, timed) = delivered.split_first().expect("at least one packet");
assert_eq!(head.1, TrackKind::Attachment);
let observed: Vec<(usize, Option<i64>)> = timed
.iter()
.map(|(track, _, pts)| (*track, pts.map(|t| t.pts())))
.collect();
assert_eq!(
observed, expected,
"the delivered order is the container's own order, packet for packet",
);
}
#[test]
fn reading_the_track_table_first_costs_no_packet() {
let Some(corpus) = Corpus::new() else { return };
let fixtures = [
(corpus.multi_track_mkv(), 1usize),
(corpus.timecode_mov(), 0),
];
for (path, synthesized) in fixtures {
let raw = support::raw_packet_order(&path).len();
assert!(raw > 0, "{}: the fixture holds packets", path.display());
let mut first = FfmpegDemuxer::open(&path).expect("open");
let rows: Vec<Arc<TrackInfo>> = first.tracks().to_vec();
assert!(
!rows.is_empty(),
"{}: the table is not empty",
path.display()
);
let after_reading = drain(&mut first);
let mut second = FfmpegDemuxer::open(&path).expect("open");
let before_reading = drain(&mut second);
assert_eq!(
after_reading.len(),
raw + synthesized,
"{}: reading the table first must not cost a packet",
path.display(),
);
assert_eq!(
after_reading,
before_reading,
"{}: the two orders deliver the same packets, in the same order",
path.display(),
);
assert_eq!(first.tracks().len(), rows.len());
for (before, after) in rows.iter().zip(first.tracks()) {
assert!(
Arc::ptr_eq(before, after),
"{}: the row a handle addresses is the session's own",
path.display(),
);
}
assert_eq!(
rows.iter().map(|t| t.kind()).collect::<Vec<_>>(),
second.tracks().iter().map(|t| t.kind()).collect::<Vec<_>>(),
"{}: the table reads the same after EOF as before the first pull",
path.display(),
);
}
}
#[test]
fn an_attachment_is_delivered_exactly_once_and_before_any_timed_packet() {
let Some(corpus) = Corpus::new() else { return };
let mut demuxer = FfmpegDemuxer::open(&corpus.multi_track_mkv()).expect("open mkv");
let first = demuxer.next_packet().expect("pull").expect("a packet");
match first {
DemuxedPacket::Attachment(p) => {
assert_eq!(p.track().get(), 3);
assert!(
p.packet().extra().synthesized(),
"a font's bytes never appear in the packet stream",
);
assert_eq!(p.packet().data().as_ref(), support::FONT_PAYLOAD);
}
other => panic!(
"the attachment must precede every timed packet, got {:?}",
other.kind()
),
}
let rest = drain(&mut demuxer);
assert_eq!(
rest
.iter()
.filter(|(_, k, _)| *k == TrackKind::Attachment)
.count(),
0,
"exactly one, and it was the first",
);
let mut demuxer = FfmpegDemuxer::open(&corpus.cover_art_mp3()).expect("open mp3");
let first = demuxer.next_packet().expect("pull").expect("a packet");
let cover_len = match first {
DemuxedPacket::Attachment(p) => {
assert_eq!(p.track().get(), 1);
assert!(
!p.packet().extra().synthesized(),
"cover art is hoisted from AVStream.attached_pic, not synthesized",
);
assert!(!p.packet().data().as_ref().is_empty());
p.packet().data().as_ref().len()
}
other => panic!("expected the cover first, got {:?}", other.kind()),
};
assert!(cover_len > 8, "a PNG payload, not a marker");
let rest = drain(&mut demuxer);
assert_eq!(
rest
.iter()
.filter(|(_, k, _)| *k == TrackKind::Attachment)
.count(),
0,
"the duplicate the MP3 demuxer emits is dropped",
);
assert!(
rest.iter().all(|(_, k, _)| *k == TrackKind::Audio),
"everything after the cover is audio",
);
}
#[test]
fn every_attachment_track_is_delivered_before_the_first_timed_packet() {
let Some(corpus) = Corpus::new() else { return };
for path in [
corpus.multi_track_mkv(),
corpus.cover_art_mp3(),
corpus.timecode_mov(),
] {
let mut demuxer = FfmpegDemuxer::open(&path).expect("open");
let expected = demuxer
.tracks()
.iter()
.filter(|t| t.kind() == TrackKind::Attachment)
.count();
let delivered = drain(&mut demuxer);
let attachments = delivered
.iter()
.take_while(|(_, kind, _)| *kind == TrackKind::Attachment)
.count();
assert_eq!(
attachments, expected,
"{path:?}: {expected} attachment tracks, {attachments} packets ahead of the timeline",
);
assert_eq!(
delivered
.iter()
.filter(|(_, kind, _)| *kind == TrackKind::Attachment)
.count(),
expected,
"{path:?}: and none of them arrived later",
);
}
}
#[test]
fn a_packets_side_data_arrives_with_it() {
let Some(corpus) = Corpus::new() else { return };
const SKIP_SAMPLES: i32 = 11;
let mut demuxer = FfmpegDemuxer::open(&corpus.cover_art_mp3()).expect("open mp3");
let mut seen = 0;
while let Some(packet) = demuxer.next_packet().expect("pull") {
if let DemuxedPacket::Audio(p) = packet {
seen += p
.packet()
.extra()
.side_data()
.iter()
.filter(|entry| entry.kind() == SKIP_SAMPLES)
.count();
}
}
assert!(
seen > 0,
"no packet arrived carrying the side data the container really holds",
);
}
fn decoded_samples(path: &std::path::Path, strip_side_data: bool) -> u64 {
use mediadecode::decoder::AudioStreamDecoder;
let mut demuxer = FfmpegDemuxer::open(path).expect("open");
let track = demuxer
.tracks()
.iter()
.position(|t| t.kind() == TrackKind::Audio)
.expect("an audio track");
let info = &demuxer.tracks()[track];
let mut decoder = mediadecode_ffmpeg::FfmpegOwnedAudioStreamDecoder::open(
info
.extra()
.clone_parameters()
.expect("the checked handoff"),
info.timebase(),
mediadecode_ffmpeg::DecoderLimits::default(),
)
.expect("open decoder");
let mut frame = mediadecode_ffmpeg::empty_owned_audio_frame();
let mut total = 0u64;
while let Some(packet) = demuxer.next_packet().expect("pull") {
let DemuxedPacket::Audio(p) = packet else {
continue;
};
let packet = p.into_packet();
let packet = if strip_side_data {
mediadecode_ffmpeg::OwnedAudioPacket::new(
packet.data().clone(),
mediadecode_ffmpeg::extras::AudioPacketExtra::new(packet.extra().stream_index()),
)
.with_pts(packet.pts())
.with_dts(packet.dts())
.with_duration(packet.duration())
.with_flags(packet.flags())
} else {
packet
};
support::accepted(decoder.send_packet(&packet), "send_packet");
while matches!(
decoder.receive_frame(&mut frame).expect("receive_frame"),
Received::Frame
) {
total += u64::from(frame.nb_samples());
}
}
support::accepted(decoder.send_eof(), "eof");
loop {
match decoder.receive_frame(&mut frame).expect("receive_frame") {
Received::Frame => total += u64::from(frame.nb_samples()),
Received::NeedsInput => panic!("a decoder at EOF asked for input"),
Received::Ended => break,
}
}
total
}
#[test]
fn side_data_survives_from_the_container_to_the_codec() {
let Some(corpus) = Corpus::new() else { return };
let path = corpus.cover_art_mp3();
let carried = decoded_samples(&path, false);
let stripped = decoded_samples(&path, true);
assert!(carried > 0 && stripped > 0, "both runs must decode");
assert!(
carried < stripped,
"the trim changed nothing: {carried} samples with side data, {stripped} without — \
the codec never saw it",
);
assert_eq!(
carried, 88_200,
"the trim is applied but lands wrong ({stripped} untrimmed)",
);
}
fn in_subprocess(test_name: &str, body: impl FnOnce()) {
const CHILD: &str = "MEDIADECODE_FFMPEG_DEMUX_FAULT_CHILD";
if std::env::var(CHILD).as_deref() == Ok(test_name) {
body();
return;
}
let exe = std::env::current_exe().expect("the test binary");
let output = std::process::Command::new(exe)
.args(["--exact", test_name, "--nocapture", "--test-threads=1"])
.env(CHILD, test_name)
.output()
.expect("spawning the child");
let stdout = String::from_utf8_lossy(&output.stdout);
assert_eq!(
output.status.code(),
Some(0),
"the child running `{test_name}` did not exit cleanly ({:?})\n{stdout}\n{}",
output.status,
String::from_utf8_lossy(&output.stderr),
);
assert!(
stdout.contains("1 passed"),
"the child ran no test — is `{test_name}` still the name?\n{stdout}",
);
}
#[test]
fn the_demux_to_decoder_handoff_survives_an_allocation_fault() {
let Some(corpus) = Corpus::new() else { return };
let path = corpus.cover_art_mp3();
in_subprocess(
"the_demux_to_decoder_handoff_survives_an_allocation_fault",
move || {
let demuxer = FfmpegDemuxer::open(&path).expect("open mp3");
let track = demuxer
.tracks()
.iter()
.find(|t| t.kind() == TrackKind::Audio)
.expect("an audio track");
unsafe { ffmpeg_next::ffi::av_max_alloc(1) };
let refused = track.extra().clone_parameters().map(|_| ());
unsafe { ffmpeg_next::ffi::av_max_alloc(i32::MAX as usize) };
assert!(
matches!(refused, Err(DemuxError::ParametersAlloc(_))),
"expected a named refusal, got {refused:?}",
);
let parameters = track.extra().clone_parameters().expect("uncapped");
mediadecode_ffmpeg::FfmpegOwnedAudioStreamDecoder::open(
parameters,
track.timebase(),
mediadecode_ffmpeg::DecoderLimits::default(),
)
.expect("the handoff opens a decoder");
},
);
}
#[test]
fn a_hoisted_cover_art_packet_keeps_its_flags() {
let Some(corpus) = Corpus::new() else { return };
let mut demuxer = FfmpegDemuxer::open(&corpus.cover_art_mp3()).expect("open mp3");
let first = demuxer.next_packet().expect("pull").expect("a packet");
let DemuxedPacket::Attachment(p) = first else {
panic!("the cover comes first");
};
assert!(
!p.packet().extra().synthesized(),
"this is the hoisted packet, not one this layer invented",
);
assert!(
p.packet().flags().contains(PacketFlags::KEY),
"the hoisted packet lost the flags it really carried: {:?}",
p.packet().flags(),
);
let mut demuxer = FfmpegDemuxer::open(&corpus.multi_track_mkv()).expect("open mkv");
let first = demuxer.next_packet().expect("pull").expect("a packet");
let DemuxedPacket::Attachment(p) = first else {
panic!("the font comes first");
};
assert!(p.packet().extra().synthesized());
assert_eq!(p.packet().flags(), PacketFlags::empty());
}
#[test]
fn the_data_arm_delivers_the_timecode_track() {
let Some(corpus) = Corpus::new() else { return };
let mut demuxer = FfmpegDemuxer::open(&corpus.timecode_mov()).expect("open mov");
let delivered = drain(&mut demuxer);
let data: Vec<_> = delivered
.iter()
.filter(|(_, kind, _)| *kind == TrackKind::Data)
.collect();
assert_eq!(data.len(), 1, "a tmcd track carries one sample");
assert_eq!(data[0].0, 2, "on the third track");
}
#[test]
fn timestamps_carry_their_own_track_timebase() {
let Some(corpus) = Corpus::new() else { return };
let mut demuxer = FfmpegDemuxer::open(&corpus.multi_track_mkv()).expect("open mkv");
let expected: Vec<Timebase> = demuxer.tracks().iter().map(|t| t.timebase()).collect();
let mut seen = [false; 4];
while let Some(packet) = demuxer.next_packet().expect("pull") {
let track = packet.track().get();
let pts = match &packet {
DemuxedPacket::Video(p) => p.packet().pts(),
DemuxedPacket::Audio(p) => p.packet().pts(),
DemuxedPacket::Subtitle(p) => p.packet().pts(),
DemuxedPacket::Data(p) => p.packet().pts(),
DemuxedPacket::Attachment(_) => continue,
};
if let Some(pts) = pts {
assert_eq!(
pts.timebase(),
expected[track],
"a timestamp whose timebase is a placeholder is not a timestamp",
);
seen[track] = true;
}
}
assert!(seen[0] && seen[1] && seen[2], "all three timed tracks seen");
}
#[test]
fn a_seek_lands_on_a_keyframe_at_or_before_the_target() {
let Some(corpus) = Corpus::new() else { return };
let mut demuxer = FfmpegDemuxer::open(&corpus.multi_track_mkv()).expect("open mkv");
let video_tb = demuxer.tracks()[0].timebase();
let target = Timestamp::new(1, Timebase::SECONDS);
demuxer.seek(target).expect("seek");
let mut first_video = None;
while let Some(packet) = demuxer.next_packet().expect("pull") {
if let DemuxedPacket::Video(p) = packet {
first_video = Some(p.into_packet());
break;
}
}
let packet = first_video.expect("a video packet after the seek");
let pts = packet.pts().expect("a timestamp");
assert!(
packet.flags().contains(PacketFlags::KEY),
"the landing point must be a keyframe, or the decoder has no reference",
);
assert!(
pts.pts() <= target.rescale_to(video_tb).pts(),
"landed at {} in {video_tb:?}, past the target",
pts.pts(),
);
}
#[test]
fn attachments_are_not_replayed_after_a_seek() {
let Some(corpus) = Corpus::new() else { return };
for path in [corpus.multi_track_mkv(), corpus.cover_art_mp3()] {
let mut demuxer = FfmpegDemuxer::open(&path).expect("open");
let first = demuxer.next_packet().expect("pull").expect("a packet");
assert_eq!(first.kind(), TrackKind::Attachment);
for secs in [1, 0, 1] {
demuxer
.seek(Timestamp::new(secs, Timebase::SECONDS))
.expect("seek");
let after = drain(&mut demuxer);
assert_eq!(
after
.iter()
.filter(|(_, k, _)| *k == TrackKind::Attachment)
.count(),
0,
"{path:?}: a seek moves the timeline, and attachments are not on it",
);
}
}
}
#[test]
fn an_attachment_owed_at_seek_time_is_still_owed_after_it() {
let Some(corpus) = Corpus::new() else { return };
let mut demuxer = FfmpegDemuxer::open(&corpus.multi_track_mkv()).expect("open mkv");
demuxer
.seek(Timestamp::new(1, Timebase::SECONDS))
.expect("seek");
let delivered = drain(&mut demuxer);
assert_eq!(
delivered
.iter()
.filter(|(_, k, _)| *k == TrackKind::Attachment)
.count(),
1,
"exactly one is still exactly one when the seek comes first",
);
assert_eq!(delivered[0].1, TrackKind::Attachment, "and still first");
}
#[test]
fn none_means_eof_and_keeps_meaning_it() {
let Some(corpus) = Corpus::new() else { return };
let mut demuxer = FfmpegDemuxer::open(&corpus.timecode_mov()).expect("open mov");
let count = drain(&mut demuxer).len();
assert!(count > 0);
assert!(demuxer.next_packet().expect("pull").is_none());
assert!(demuxer.next_packet().expect("pull").is_none());
demuxer
.seek(Timestamp::new(0, Timebase::SECONDS))
.expect("seek after EOF");
assert!(
!drain(&mut demuxer).is_empty(),
"an EOF latch left in place would make every later read fail",
);
}
struct PanicAfter {
file: File,
budget: u64,
served: u64,
}
impl PanicAfter {
fn new(path: &std::path::Path, budget: u64) -> Self {
Self {
file: File::open(path).expect("open file"),
budget,
served: 0,
}
}
}
impl Read for PanicAfter {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
assert!(
self.served < self.budget,
"the reader is out of patience at byte {}",
self.served,
);
let n = self.file.read(buf)?;
self.served += n as u64;
Ok(n)
}
}
impl Seek for PanicAfter {
fn seek(&mut self, pos: std::io::SeekFrom) -> std::io::Result<u64> {
self.file.seek(pos)
}
}
struct Counting {
file: File,
served: std::sync::Arc<std::sync::atomic::AtomicU64>,
}
impl Read for Counting {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
let n = self.file.read(buf)?;
self
.served
.fetch_add(n as u64, std::sync::atomic::Ordering::Relaxed);
Ok(n)
}
}
impl Seek for Counting {
fn seek(&mut self, pos: std::io::SeekFrom) -> std::io::Result<u64> {
self.file.seek(pos)
}
}
#[test]
fn a_panicking_reader_is_an_error_not_an_abort() {
let Some(corpus) = Corpus::new() else { return };
let path = corpus.multi_track_mkv();
let Err(err) = FfmpegDemuxer::open_reader(PanicAfter::new(&path, 0), Some("multi.mkv")) else {
panic!("a panicking reader cannot open a container");
};
match err {
DemuxError::ReaderPanic(ref p) => {
let message = p.message();
assert!(
message.contains("out of patience"),
"the panic's own words are carried: {message}",
);
}
other => panic!("expected ReaderPanic, got {other:?}"),
}
let served = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0));
let counting = Counting {
file: File::open(&path).expect("open file"),
served: std::sync::Arc::clone(&served),
};
let opened = FfmpegDemuxer::open_reader(counting, Some("multi.mkv")).expect("open");
let at_open = served.load(std::sync::atomic::Ordering::Relaxed);
drop(opened);
let mut demuxer =
FfmpegDemuxer::open_reader(PanicAfter::new(&path, at_open + 1), Some("multi.mkv"))
.expect("opening reads fewer bytes than the budget");
let mut failure = None;
loop {
match demuxer.next_packet() {
Ok(Some(_)) => continue,
Ok(None) => break,
Err(e) => {
failure = Some(e);
break;
}
}
}
let failure = failure.expect("the pull loop must fail, not end");
assert!(
matches!(failure, DemuxError::ReaderPanic(_)),
"a panic mid-demux is named, not mistaken for EOF: {failure:?}",
);
assert!(matches!(
demuxer.next_packet(),
Err(DemuxError::ReaderPanic(_))
));
assert!(matches!(
demuxer.seek(Timestamp::new(0, Timebase::SECONDS)),
Err(DemuxError::ReaderPanic(_))
));
}
struct PanicOnArmedSeek {
file: File,
armed: std::sync::Arc<std::sync::atomic::AtomicBool>,
}
impl Read for PanicOnArmedSeek {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
self.file.read(buf)
}
}
impl Seek for PanicOnArmedSeek {
fn seek(&mut self, pos: std::io::SeekFrom) -> std::io::Result<u64> {
assert!(
!self.armed.load(std::sync::atomic::Ordering::Relaxed),
"the reader gave up on seeking",
);
self.file.seek(pos)
}
}
#[test]
fn a_latched_panic_outranks_a_queued_attachment() {
let Some(corpus) = Corpus::new() else { return };
let path = corpus.multi_track_mkv();
let armed = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let reader = PanicOnArmedSeek {
file: File::open(&path).expect("open file"),
armed: std::sync::Arc::clone(&armed),
};
let mut demuxer = FfmpegDemuxer::open_reader(reader, Some("multi.mkv")).expect("open");
armed.store(true, std::sync::atomic::Ordering::Relaxed);
assert!(
matches!(
demuxer.seek(Timestamp::new(1, Timebase::SECONDS)),
Err(DemuxError::ReaderPanic(_))
),
"the seek must report the panic it caused",
);
match demuxer.next_packet() {
Err(DemuxError::ReaderPanic(_)) => {}
Err(other) => panic!("expected ReaderPanic, got {other:?}"),
Ok(Some(packet)) => panic!("a terminal session delivered a {:?} packet", packet.kind()),
Ok(None) => panic!("a terminal session answered EOF"),
}
}
#[test]
fn a_reader_opens_the_same_container_as_a_path() {
let Some(corpus) = Corpus::new() else { return };
let path = corpus.multi_track_mkv();
let from_path = FfmpegDemuxer::open(&path).expect("open by path");
let expected: Vec<_> = from_path.tracks().iter().map(|t| t.kind()).collect();
drop(from_path);
let file = File::open(&path).expect("open file");
let mut from_reader =
FfmpegDemuxer::open_reader(file, Some("multi.mkv")).expect("open by reader");
let observed: Vec<_> = from_reader.tracks().iter().map(|t| t.kind()).collect();
assert_eq!(observed, expected, "custom AVIO sees the same track table");
let delivered = drain(&mut from_reader);
assert!(delivered.len() > 100, "got {} packets", delivered.len());
assert_eq!(delivered[0].1, TrackKind::Attachment);
}
#[test]
fn the_probe_budget_refuses_an_open_before_libavformat_builds_the_container() {
use mediadecode_ffmpeg::{DemuxError, DemuxLimits};
let Some(corpus) = Corpus::new() else { return };
let path = corpus.cover_art_mp3();
let bytes = std::fs::read(&path).expect("read the fixture");
assert!(
bytes.len() > 4096,
"the fixture must exceed the tight budget"
);
let starved = DemuxLimits::new().with_max_probe_bytes(4096);
match FfmpegDemuxer::open_reader_with(std::io::Cursor::new(bytes.clone()), Some("x.mp3"), starved)
{
Err(DemuxError::ProbeBudgetExhausted(p)) => {
assert_eq!(p.budget(), 4096);
assert_eq!(
p.read(),
p.budget(),
"the refusal must report bytes libavformat really received",
);
}
Err(other) => panic!("expected ProbeBudgetExhausted, got {other:?}"),
Ok(_) => panic!("a 1 KiB probe budget opened a whole container"),
}
let mut demuxer = FfmpegDemuxer::open_reader_with(
std::io::Cursor::new(bytes),
Some("x.mp3"),
DemuxLimits::new(),
)
.expect("the defaults must open an ordinary file");
assert!(!demuxer.tracks().is_empty());
while demuxer
.next_packet()
.expect("read past the probe budget")
.is_some()
{}
}
#[test]
fn the_path_entrypoint_carries_the_probe_knobs_too() {
use mediadecode_ffmpeg::DemuxLimits;
let Some(corpus) = Corpus::new() else { return };
let path = corpus.cover_art_mp3();
assert!(
FfmpegDemuxer::open_with(&path, DemuxLimits::new().with_max_streams(0)).is_err(),
"the probe options did not reach the path entrypoint",
);
assert!(FfmpegDemuxer::open_with(&path, DemuxLimits::new()).is_ok());
}
#[test]
fn the_probe_meter_caps_the_read_rather_than_refusing_the_answer() {
use mediadecode_ffmpeg::{DemuxError, DemuxLimits};
let Some(corpus) = Corpus::new() else { return };
let bytes = std::fs::read(corpus.cover_art_mp3()).expect("read the fixture");
fn opens_under(bytes: &[u8], probe: u64) -> bool {
FfmpegDemuxer::open_reader_with(
std::io::Cursor::new(bytes.to_vec()),
Some("x.mp3"),
DemuxLimits::new().with_max_probe_bytes(probe),
)
.is_ok()
}
let (mut lo, mut hi) = (8_192u64, 1_048_576u64);
assert!(opens_under(&bytes, hi), "the fixture must open somewhere");
while lo + 1 < hi {
let mid = lo + (hi - lo) / 2;
if opens_under(&bytes, mid) {
hi = mid;
} else {
lo = mid;
}
}
let exact = hi;
assert!(
opens_under(&bytes, exact),
"a container finishing inside its allowance must open",
);
match FfmpegDemuxer::open_reader_with(
std::io::Cursor::new(bytes.clone()),
Some("x.mp3"),
DemuxLimits::new().with_max_probe_bytes(exact - 1),
) {
Err(DemuxError::ProbeBudgetExhausted(p)) => {
assert_eq!(p.budget(), exact - 1);
assert_eq!(p.read(), p.budget(), "the count must be what was delivered");
}
Err(other) => panic!("expected ProbeBudgetExhausted, got {other:?}"),
Ok(_) => panic!("the bisection is wrong: {exact} is not the boundary"),
}
}