use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use container_probe::{Probe, probe_with_budget};
use media_plane::trunk::{SampleCursorItem, Trunk, TrunkConfig};
use multimux::source::file_reader::{
DemuxerKind, FileReader, FileReaderConfig, FileReaderError, select_demuxer,
};
use transmux::{DemuxEvent, StreamingTsDemux};
fn fixture(rel: &str) -> PathBuf {
PathBuf::from(format!("{}/../{}", env!("CARGO_MANIFEST_DIR"), rel))
}
fn trunk() -> Arc<Trunk> {
fn nz(n: usize) -> std::num::NonZeroUsize {
std::num::NonZeroUsize::new(n).unwrap()
}
Trunk::new(TrunkConfig::new(
nz(4096),
nz(1024),
nz(128),
nz(1024),
nz(1024),
))
}
fn reader(
path: PathBuf,
loop_file: bool,
pace: bool,
max_loops: Option<u32>,
trunk: Arc<Trunk>,
) -> FileReader {
FileReader::new(
FileReaderConfig::new(path, loop_file, trunk)
.with_pace(pace)
.with_max_retries(0)
.with_retry_interval(Duration::from_millis(1))
.with_max_loops(max_loops),
)
}
fn probe_fixture(rel: &str) -> (container_probe::Format, container_probe::Detail) {
let bytes = std::fs::read(fixture(rel)).expect("fixture must exist");
match probe_with_budget(&bytes, bytes.len()) {
Probe::Identified { format, detail, .. } => (format, detail),
other => panic!("fixture {rel} must probe cleanly, got {other:?}"),
}
}
#[test]
fn ts_fixture_selects_streaming_ts_demuxer() {
let (format, detail) = probe_fixture("fixtures/ts/h264_aac.ts");
assert_eq!(
select_demuxer(format, detail).unwrap(),
DemuxerKind::StreamingTs,
"expected StreamingTsDemux for MpegTs"
);
}
#[test]
fn progressive_mp4_selects_progressive_demuxer() {
let (format, detail) = probe_fixture("fixtures/mp4/h264_high.mp4");
assert_eq!(
select_demuxer(format, detail).unwrap(),
DemuxerKind::Progressive,
"expected ProgressiveDemux for progressive ISOBMFF"
);
}
#[test]
fn fragmented_mp4_selects_fmp4_demuxer() {
let (format, detail) = probe_fixture("fixtures/mp4/cmaf/av_frag.mp4");
assert_eq!(
select_demuxer(format, detail).unwrap(),
DemuxerKind::Fmp4,
"expected Fmp4Demux for fragmented ISOBMFF"
);
}
#[test]
fn mkv_selects_webm_demuxer() {
let (format, detail) = probe_fixture("fixtures/mkv/h264_aac.mkv");
assert_eq!(
select_demuxer(format, detail).unwrap(),
DemuxerKind::Webm,
"expected WebmDemux for Matroska"
);
}
#[test]
fn ps_fixture_selects_ps_demuxer() {
let (format, detail) = probe_fixture("fixtures/ps/h264_ac3.ps");
assert_eq!(
select_demuxer(format, detail).unwrap(),
DemuxerKind::Ps,
"expected PsDemux for MPEG-PS"
);
}
#[test]
fn flv_fixture_selects_streaming_flv_demuxer() {
let (format, detail) = probe_fixture("fixtures/flv/av.flv");
assert_eq!(
select_demuxer(format, detail).unwrap(),
DemuxerKind::StreamingFlv,
"expected StreamingFlvDemux for FLV"
);
}
#[test]
fn unsupported_fixtures_yield_distinct_format_errors() {
let (format, detail) = probe_fixture("fixtures/mxf/op1a_mpeg2_pcm.mxf");
match select_demuxer(format, detail) {
Err(FileReaderError::UnsupportedFormat { format: f }) => {
assert_eq!(
f, "Mxf",
"Mxf must be named by the unsupported-format error"
)
}
other => panic!("Mxf must be rejected as UnsupportedFormat, got {other:?}"),
}
let (format, detail) = probe_fixture("fixtures/container-probe/pcm_s16le.wav");
match select_demuxer(format, detail) {
Err(FileReaderError::UnsupportedFormat { format: f }) => {
assert_eq!(
f, "Wav",
"Wav must be named by the unsupported-format error"
)
}
other => panic!("Wav must be rejected as UnsupportedFormat, got {other:?}"),
}
let (format, detail) = probe_fixture("fixtures/container-probe/aac.adts");
match select_demuxer(format, detail) {
Err(FileReaderError::UnsupportedFormat { format: f }) => {
assert_eq!(
f, "AdtsAac",
"AdtsAac must be named by the unsupported-format error"
)
}
other => panic!("ADTS AAC must be rejected as UnsupportedFormat, got {other:?}"),
}
}
fn source_track_dts(rel: &str, track_id: u32) -> Vec<i64> {
let bytes = std::fs::read(fixture(rel)).unwrap();
let mut demux = StreamingTsDemux::new();
demux.feed(&bytes);
let mut dts = Vec::new();
while let Some(ev) = demux.poll_event() {
let DemuxEvent::Sample {
track_id: id,
sample,
..
} = ev
else {
continue;
};
if id == track_id
&& let Some(d) = sample.dts
{
dts.push(d);
}
}
dts
}
fn drain_samples(trunk: &Arc<Trunk>) -> Vec<(u32, i64)> {
let mut cursor = trunk.subscribe_from_backlog();
let mut out = Vec::new();
loop {
match cursor.poll() {
Some(SampleCursorItem::Timed { track_id, sample }) => {
out.push((track_id, sample.dts.expect("timed sample must carry a dts")));
}
Some(SampleCursorItem::Sparse { .. })
| Some(SampleCursorItem::Lagged { .. })
| Some(SampleCursorItem::Degraded { .. }) => {
panic!("reader must not drop timed samples nor use the sparse ring")
}
None => break,
Some(_) => panic!("unexpected sample-cursor item from the file reader"),
}
}
out
}
#[tokio::test]
async fn timeline_dts_is_monotonic_and_matches_source_decode_order() {
let trunk = trunk();
let t = trunk.clone();
let handle = tokio::spawn(async move {
reader(fixture("fixtures/ts/h264_aac.ts"), false, false, None, t)
.run()
.await
});
handle.await.unwrap().expect("reader must finish cleanly");
let drained = drain_samples(&trunk);
assert!(
drained.len() > 1,
"fixture must yield samples, got {}",
drained.len()
);
let tracks = trunk.tracks();
assert!(tracks.len() >= 2, "TS fixture must demux video + audio");
let mut by_track: std::collections::BTreeMap<u32, Vec<i64>> = Default::default();
for (track_id, dts) in &drained {
by_track.entry(*track_id).or_default().push(*dts);
}
for (track_id, seq) in &by_track {
for pair in seq.windows(2) {
assert!(
pair[1] > pair[0],
"track {track_id} dts must be strictly monotonic: {} then {}",
pair[0],
pair[1]
);
}
let source = source_track_dts("fixtures/ts/h264_aac.ts", *track_id);
assert_eq!(
&source, seq,
"track {track_id} decode order must match the source's own DTS sequence"
);
}
assert_eq!(
drained.len(),
source_sample_count("fixtures/ts/h264_aac.ts"),
"reader must write every source sample"
);
}
#[tokio::test]
async fn loop_preserves_dts_monotonicity_across_boundary() {
let trunk = trunk();
let t = trunk.clone();
let handle = tokio::spawn(async move {
reader(fixture("fixtures/ts/h264_aac.ts"), true, false, Some(2), t)
.run()
.await
});
handle
.await
.unwrap()
.expect("bounded loop must finish cleanly");
let drained = drain_samples(&trunk);
let mut by_track: std::collections::BTreeMap<u32, Vec<i64>> = Default::default();
for (track_id, dts) in &drained {
by_track.entry(*track_id).or_default().push(*dts);
}
for (track_id, seq) in &by_track {
let source = source_track_dts("fixtures/ts/h264_aac.ts", *track_id);
let per_loop = source.len();
assert!(
seq.len() == 2 * per_loop,
"track {track_id}: a 2-loop run must write 2× the per-loop sample count (got {} vs 2×{per_loop})",
seq.len()
);
for pair in seq.windows(2) {
assert!(
pair[1] > pair[0],
"loop DTS must stay strictly monotonic for track {track_id}: {} then {}",
pair[0],
pair[1]
);
}
let boundary_delta = seq[per_loop] - seq[per_loop - 1];
assert!(
boundary_delta > 0,
"track {track_id}: loop boundary DTS delta must be positive, got {boundary_delta}"
);
}
}
fn source_sample_count(rel: &str) -> usize {
let bytes = std::fs::read(fixture(rel)).unwrap();
let mut demux = StreamingTsDemux::new();
demux.feed(&bytes);
let mut n = 0usize;
while let Some(ev) = demux.poll_event() {
if let DemuxEvent::Sample { .. } = ev {
n += 1;
}
}
n
}
#[tokio::test]
async fn pacing_does_not_dump_the_whole_file_instantly() {
let total = source_sample_count("fixtures/ts/h264_aac.ts");
assert!(total > 0);
let trunk = trunk();
let t = trunk.clone();
let handle = tokio::spawn(async move {
reader(fixture("fixtures/ts/h264_aac.ts"), false, true, None, t)
.run()
.await
});
tokio::time::sleep(Duration::from_millis(150)).await;
let seen = drain_samples(&trunk).len();
assert!(
seen < total,
"after a short wait the reader must have written a prefix ({seen}) not the whole file ({total})"
);
handle.abort();
let _ = handle.await;
}
#[tokio::test]
async fn nonexistent_path_yields_read_error() {
let trunk = trunk();
let result = reader(
fixture("fixtures/does-not-exist.ts"),
false,
false,
None,
trunk,
)
.run()
.await;
match result {
Err(FileReaderError::Read { path, .. }) => {
assert!(
path.ends_with("does-not-exist.ts"),
"read error must name the missing path, got {path:?}"
)
}
other => panic!("nonexistent path must be a Read error, got {other:?}"),
}
}
#[tokio::test]
async fn empty_file_is_reported_as_too_short_to_identify() {
let dir = std::env::temp_dir().join("multimux-file-reader-empty");
std::fs::create_dir_all(&dir).unwrap();
let empty = dir.join("empty.bin");
std::fs::write(&empty, b"").unwrap();
let trunk = trunk();
let result = reader(empty.clone(), false, false, None, trunk).run().await;
match result {
Err(FileReaderError::FileTooShortToIdentify {
need_at_least,
file_bytes,
}) => {
assert_eq!(file_bytes, 0, "the fixture is a 0-byte file");
assert!(need_at_least > 0, "the probe must ask for a real minimum");
}
other => panic!("empty file must be FileTooShortToIdentify, got {other:?}"),
}
}
#[tokio::test]
async fn random_bytes_yield_a_probe_error_never_panic() {
let dir = std::env::temp_dir().join("multimux-file-reader-random");
std::fs::create_dir_all(&dir).unwrap();
let random = dir.join("random.bin");
let mut seed: u64 = 0x9e37_79b9_7f4a_7c15;
let mut bytes = Vec::with_capacity(64 * 1024);
for _ in 0..bytes.capacity() {
seed ^= seed << 13;
seed ^= seed >> 7;
seed ^= seed << 17;
bytes.push((seed & 0xff) as u8);
}
std::fs::write(&random, &bytes).unwrap();
let trunk = trunk();
let result = reader(random, false, false, None, trunk).run().await;
assert!(
result.is_err(),
"random bytes must fail, and must never panic"
);
}
fn fixture_content_duration_secs(rel: &str) -> f64 {
let bytes = std::fs::read(fixture(rel)).unwrap();
let mut demux = StreamingTsDemux::new();
demux.feed(&bytes);
let mut spans: std::collections::HashMap<u32, (i64, i64, u32)> =
std::collections::HashMap::new(); while let Some(ev) = demux.poll_event() {
match ev {
DemuxEvent::TrackAdded(spec) | DemuxEvent::TrackUpdated(spec) => {
spans
.entry(spec.track_id)
.or_insert((i64::MAX, i64::MIN, spec.timescale))
.2 = spec.timescale;
}
DemuxEvent::Sample {
track_id, sample, ..
} => {
if let Some(pts) = sample.pts
&& let Some(span) = spans.get_mut(&track_id)
{
span.0 = span.0.min(pts);
span.1 = span.1.max(pts);
}
}
_ => {}
}
}
spans
.values()
.map(|&(first, last, ts)| {
if last <= first {
0.0
} else {
(last - first) as f64 / ts.max(1) as f64
}
})
.fold(0.0f64, f64::max)
}
#[tokio::test]
async fn paced_loop_second_pass_waits_for_content_duration() {
let content = fixture_content_duration_secs("fixtures/ts/h264_aac.ts");
assert!(
content > 0.0,
"fixture must carry a positive content duration"
);
let trunk = trunk();
let t = trunk.clone();
let started = std::time::Instant::now();
let handle = tokio::spawn(async move {
reader(fixture("fixtures/ts/h264_aac.ts"), true, true, Some(2), t)
.run()
.await
});
handle
.await
.unwrap()
.expect("a bounded paced loop must finish cleanly");
let elapsed = started.elapsed();
let two_contents = Duration::from_secs_f64(2.0 * content);
assert!(
elapsed >= two_contents,
"a paced 2-pass loop must take ~2× content duration ({content:.3} s each pass → {:.3} s), not {elapsed:?} (baseline did not advance)",
two_contents.as_secs_f64()
);
}
#[tokio::test]
async fn second_reader_over_same_trunk_fails_with_writer_unavailable() {
let trunk = trunk();
let _writer = trunk.writer().expect("first writer must be claimable");
let result = reader(
fixture("fixtures/ts/h264_aac.ts"),
false,
false,
None,
trunk,
)
.run()
.await;
match result {
Err(FileReaderError::WriterUnavailable) => {}
other => {
panic!("a second reader over a claimed trunk must be WriterUnavailable, got {other:?}")
}
}
}
#[tokio::test]
async fn oversized_file_yields_file_too_large() {
let dir = std::env::temp_dir().join("multimux-file-reader-oversize");
std::fs::create_dir_all(&dir).unwrap();
let big = dir.join("big.bin");
std::fs::write(&big, vec![0u8; 1024]).unwrap();
let trunk = trunk();
let config = FileReaderConfig::new(big.clone(), false, trunk)
.with_pace(false)
.with_max_retries(0)
.with_retry_interval(Duration::from_millis(1))
.with_max_file_bytes(512);
let result = FileReader::new(config).run().await;
match result {
Err(FileReaderError::FileTooLarge { size, max, .. }) => {
assert_eq!(size, 1024, "must report the file's actual size");
assert_eq!(max, 512, "must report the configured cap");
}
other => panic!("over-cap file must be FileTooLarge, got {other:?}"),
}
}
#[tokio::test]
async fn directory_path_yields_not_a_regular_file() {
let dir = std::env::temp_dir(); let trunk = trunk();
let result = reader(dir.clone(), false, false, None, trunk).run().await;
match result {
Err(FileReaderError::NotARegularFile { kind, .. }) => {
assert_eq!(
kind, "directory",
"a directory must be labelled 'directory'"
);
}
other => panic!("a directory must be NotARegularFile, got {other:?}"),
}
}
#[tokio::test]
async fn b_frame_fixture_publishes_decode_order_dts() {
let trunk = trunk();
let t = trunk.clone();
let handle = tokio::spawn(async move {
reader(
fixture("fixtures/mp4/progressive/av_prog.mp4"),
false,
false,
None,
t,
)
.run()
.await
});
handle
.await
.unwrap()
.expect("progressive mp4 fixture must play cleanly");
let drained = drain_samples(&trunk);
assert!(
drained.len() > 1,
"B-frame fixture must yield samples, got {}",
drained.len()
);
let mut by_track: std::collections::BTreeMap<u32, Vec<i64>> = Default::default();
for (track_id, dts) in &drained {
by_track.entry(*track_id).or_default().push(*dts);
}
assert!(
by_track.len() >= 2,
"fixture must carry video + audio tracks, got {:?}",
by_track.keys().collect::<Vec<_>>()
);
for (track_id, seq) in &by_track {
for pair in seq.windows(2) {
assert!(
pair[1] >= pair[0],
"track {track_id} published DTS must be non-decreasing (decode order): {} then {}",
pair[0],
pair[1]
);
}
}
}