mp4: stz2 sizes, u16-truncated entry rate, edit list covers lost-frame tail, saturating start delays; tests for sample order, duration, mpeg entries

This commit is contained in:
Matthew Jackson
2026-10-01 14:49:24 -07:00
parent 2a95f4a41a
commit 3cfb86f7c6
3 changed files with 240 additions and 9 deletions
+43 -2
View File
@@ -222,7 +222,7 @@ pub(super) fn dec3_box(c: &DolbyConfig) -> Vec<u8> {
bx(b"dec3", &[b[3], b[4], b[5], b[6], b[7]])
}
/// Build an audio sample entry (`ac-3` / `ec-3` / `dtsc` / `dtsh`) with the
/// Build an audio sample entry (`ac-3` / `ec-3` / `dtsc` / `dtsh` / `mp4a`) with the
/// given config box. `AudioSampleEntry` per ISO/IEC 14496-12 §12.2.3.
pub(super) fn audio_sample_entry(
fourcc: &[u8; 4],
@@ -246,6 +246,12 @@ pub(super) fn audio_sample_entry(
bx(fourcc, &e)
}
/// The integer sample rate `audio_sample_entry` wrote into `entry`, if it is long enough.
pub(super) fn entry_sample_rate(entry: &[u8]) -> Option<u32> {
let rate = entry.get(32..36)?;
Some(u32::from_be_bytes([rate[0], rate[1], rate[2], rate[3]]) >> 16)
}
// ── DTS (dtsc/dtsh + ddts) ───────────────────────────────────────────────────
/// DTS core `SFREQ` (4-bit) → sample rate (Hz); 0 marks an invalid code
@@ -485,7 +491,8 @@ pub(super) fn dolby_sample_entry(
}
/// (channels, rate) of an AudioSpecificConfig the `esds` can carry: a table rate and
/// channel configuration, no escaped object type, short enough for one-byte sizes.
/// channel configuration, no escaped object type, short enough for one-byte sizes
/// (the 100-byte cap keeps every enclosing descriptor body under 128).
pub(super) fn aac_config(asc: &[u8]) -> Option<(u16, u32)> {
const RATES: [u32; 12] = [
96000, 88200, 64000, 48000, 44100, 32000, 24000, 22050, 16000, 12000, 11025, 8000,
@@ -585,6 +592,40 @@ mod tests {
f
}
#[test]
fn aac_config_maps_channel_configurations() {
// AAC-LC, 44.1 kHz; the channel configuration sits in bits 6..3 of byte 1.
let cfg = |c: u8| aac_config(&[0x12, c << 3]);
assert_eq!(cfg(1), Some((1, 44_100)));
assert_eq!(cfg(6), Some((6, 44_100)));
assert_eq!(cfg(7), Some((8, 44_100)), "config 7 is 7.1");
assert_eq!(cfg(0), None, "PCE layout is not describable");
assert_eq!(cfg(8), None, "reserved");
}
#[test]
fn aac_config_refuses_escaped_object_types_and_oversized_configs() {
assert_eq!(aac_config(&[0xF8, 0x08]), None);
let mut asc = vec![0x12, 0x10];
asc.resize(100, 0);
assert!(aac_config(&asc).is_some());
asc.push(0);
assert_eq!(aac_config(&asc), None);
}
#[test]
fn mpeg_audio_rate_follows_the_version_bits() {
let rate = |h1: u8| {
mpeg_sample_entry(Codec::Mp3, &[0xFF, h1, 0x00, 0xC0], &[])
.and_then(|e| entry_sample_rate(&e))
};
assert_eq!(rate(0xFB), Some(44_100), "MPEG-1");
assert_eq!(rate(0xF3), Some(22_050), "MPEG-2");
assert_eq!(rate(0xE3), Some(11_025), "MPEG-2.5");
assert_eq!(rate(0xF1), None, "reserved layer");
assert_eq!(rate(0xEB), None, "reserved version");
}
#[test]
fn ac3_bsi_and_dac3() {
let c = parse_dolby(&ac3_frame_5_1()).expect("parsed");
+98 -5
View File
@@ -484,8 +484,7 @@ impl<W: Write + Seek + Send> PesSink for Mp4Sink<W> {
{
// A rate the title could not name (e.g. 22.05 kHz MP3) comes from the entry.
if self.tracks[slot].audio_timescale == 0 {
self.tracks[slot].audio_timescale =
u32::from_be_bytes([entry[32], entry[33], entry[34], entry[35]]) >> 16;
self.tracks[slot].audio_timescale = audio::entry_sample_rate(&entry).unwrap_or(0);
}
self.tracks[slot].audio_entry = Some(entry);
}
@@ -628,7 +627,6 @@ impl<W: Write + Seek + Send> PesSink for Mp4Sink<W> {
// ── per-track box assembly ───────────────────────────────────────────────────
/// Build a track's `trak` box and return `(bytes, duration_seconds)`.
/// Per-track start delay: how far each track's first sample lies after the earliest
/// sample of all tracks (the shared t=0).
fn start_delays_ns(tracks: &[Track]) -> Vec<i64> {
@@ -636,7 +634,7 @@ fn start_delays_ns(tracks: &[Track]) -> Vec<i64> {
let origin = tracks.iter().filter_map(first).min().unwrap_or(0);
tracks
.iter()
.map(|t| first(t).map_or(0, |f| f - origin))
.map(|t| first(t).map_or(0, |f| f.saturating_sub(origin)))
.collect()
}
@@ -660,6 +658,7 @@ fn build_edts(delay_ns: i64, media_secs: f64) -> Vec<u8> {
bx(b"edts", &fullbox(b"elst", 1, 0, &body))
}
/// Build a track's `trak` box and return `(bytes, duration_seconds)`.
fn build_trak_at(t: &Track, delay_ns: i64) -> (Vec<u8>, f64) {
match t.media {
Media::Video => build_video_trak_full(t, delay_ns),
@@ -670,7 +669,8 @@ fn build_trak_at(t: &Track, delay_ns: i64) -> (Vec<u8>, f64) {
fn build_video_trak_full(t: &Track, delay_ns: i64) -> (Vec<u8>, f64) {
let timing = VideoTiming::derive(&t.samples);
let media_dur = timing.total_duration();
let secs = media_dur as f64 / timing.timescale as f64;
// Presentation, not decode, extent: lost frames leave CTS beyond `media_dur`.
let secs = timing.presentation_end() as f64 / timing.timescale as f64;
let stsd = build_visual_stsd(t.codec, &t.codec_private, t.width, t.height, t.colr);
let stbl = build_video_stbl(stsd, &t.samples, &timing);
@@ -750,6 +750,12 @@ impl VideoTiming {
fn total_duration(&self) -> u64 {
self.cts.len() as u64 * self.sample_dur as u64
}
// Where the last frame to present ends; past `total_duration` when frames were lost.
fn presentation_end(&self) -> u64 {
let last = self.cts.iter().copied().max().unwrap_or(0).max(0) as u64;
self.total_duration()
.max(last.saturating_add(self.sample_dur as u64))
}
fn ctts(&self) -> Vec<i32> {
self.cts
.iter()
@@ -2129,6 +2135,93 @@ mod tests {
);
}
// Frames lost to a damaged span leave the last CTS past n*d; the edit list must still
// cover the whole presentation or players cut the tail.
#[test]
fn edit_list_covers_the_last_frame_when_frames_were_lost() {
let mut samples: Vec<Sample> = (0..4)
.map(|i| Sample {
offset: 0,
size: 1,
pts_ns: i * 40_000_000,
keyframe: i == 0,
})
.collect();
samples.push(Sample {
offset: 0,
size: 1,
pts_ns: 10_000_000_000,
keyframe: false,
});
let t = Track {
media: Media::Video,
track_id: 1,
stream_idx: 0,
codec: Codec::Hevc,
codec_private: vec![1],
width: 16,
height: 16,
colr: None,
language: [0x55, 0xC4],
audio_entry: None,
audio_timescale: 0,
samples,
};
let (trak, secs) = build_trak_at(&t, 40_000_000);
let edts = find_child(&trak[8..], b"edts").unwrap();
let e = find_child(edts, b"elst").unwrap();
let seg = u64::from_be_bytes(e[28..36].try_into().unwrap());
assert!(seg >= 10 * MOVIE_TIMESCALE as u64, "segment {seg}");
assert!(secs >= 10.04, "{secs}");
}
// AAC / MP3 entries come from the MPEG branch; a rate the title could not name is
// taken from the entry for the mdhd timescale.
#[test]
fn mp3_track_gets_an_entry_and_its_rate_from_the_first_frame() {
let mut mp3 = audio(Codec::Mp3, "eng");
if let DiscStream::Audio(a) = &mut mp3 {
a.sample_rate = SampleRate::Unknown;
}
let t = title(vec![hevc_video(), mp3], vec![Some(vec![1, 2, 3, 4]), None]);
let mut s = Mp4Sink::create(std::io::Cursor::new(Vec::new()), &t).unwrap();
s.write(&frame(0, 0, true, vec![0xAB; 800])).unwrap();
// MPEG-2 Layer III header, 22.05 kHz, mono.
let mp3_frame = vec![0xFF, 0xF3, 0x90, 0xC4, 0, 0, 0, 0];
s.write(&frame(1, 0, true, mp3_frame.clone())).unwrap();
s.write(&frame(1, 26_122_449, true, mp3_frame)).unwrap();
s.finish().unwrap();
assert!(s.undelivered_streams().is_empty());
assert_eq!(s.tracks[1].audio_timescale, 22_050);
assert!(s.tracks[1].audio_entry.is_some());
}
// Extreme first timestamps (a clamped reader) must not overflow the start delay.
#[test]
fn start_delays_saturate_on_extreme_timestamps() {
let mk = |id, pts_ns| Track {
media: Media::Audio,
track_id: id,
stream_idx: 0,
codec: Codec::Ac3,
codec_private: Vec::new(),
width: 0,
height: 0,
colr: None,
language: [0x55, 0xC4],
audio_entry: None,
audio_timescale: 48_000,
samples: vec![Sample {
offset: 0,
size: 1,
pts_ns,
keyframe: true,
}],
};
let delays = start_delays_ns(&[mk(1, i64::MAX), mk(2, i64::MIN)]);
assert_eq!(delays, vec![i64::MAX, 0]);
}
#[test]
fn video_before_its_first_keyframe_is_not_stored() {
let t = title(vec![hevc_video()], vec![Some(vec![1, 2, 3, 4])]);
+99 -2
View File
@@ -179,6 +179,7 @@ impl<R: Read + Seek> Mp4Reader<R> {
// this track's sample count (indices past it are never read).
let sizes = find_box(stbl, b"stsz")
.map(|b| parse_stsz(b, sample_budget))
.or_else(|| find_box(stbl, b"stz2").map(|b| parse_stz2(b, sample_budget)))
.unwrap_or_default();
let n = sizes.len();
if n == 0 {
@@ -820,7 +821,7 @@ fn audio_rate(entry: u32, timescale: u32, dts_hd: Option<bool>) -> u32 {
!known(entry)
&& timescale > 0xFFFF
&& known(timescale)
&& timescale.is_multiple_of(entry)
&& (timescale.is_multiple_of(entry) || entry == timescale & 0xFFFF)
}
};
if wider { timescale } else { entry }
@@ -1039,6 +1040,35 @@ fn parse_stsz(b: &[u8], max: usize) -> Vec<u32> {
out
}
/// stz2 (compact sample sizes, field size 4 / 8 / 16 bits) → per-sample sizes.
fn parse_stz2(b: &[u8], max: usize) -> Vec<u32> {
if b.len() < 12 {
return Vec::new();
}
let field_size = b[7] as usize;
if !matches!(field_size, 4 | 8 | 16) {
return Vec::new();
}
let count = (be32(b, 8) as usize).min(max);
let body = &b[12..];
// `count` entries of `field_size` bits can't exceed the box body.
let mut out = Vec::with_capacity(count.min(body.len() * 8 / field_size));
for i in 0..count {
let size = match field_size {
4 => body
.get(i / 2)
.map(|&v| u32::from(if i % 2 == 0 { v >> 4 } else { v & 0xF })),
8 => body.get(i).map(|&v| u32::from(v)),
_ => body
.get(i * 2..i * 2 + 2)
.map(|v| u32::from(u16::from_be_bytes([v[0], v[1]]))),
};
let Some(size) = size else { break };
out.push(size);
}
out
}
/// stco (32-bit) / co64 (64-bit) → chunk offsets.
fn parse_stco(b: &[u8], is64: bool) -> Vec<u64> {
// `< 8` vs `<= 8` is equivalent (same shape as `parse_stsd`, and stsc/stts/ctts/stss
@@ -3039,6 +3069,14 @@ mod tests {
p.extend_from_slice(&3u32.to_be_bytes()); // count = 3 (samples exist)
mp4_box(b"stsz", &p)
};
// Compact form of the same three 10-byte samples (omit == "stz2" swaps it in).
let stz2 = {
let mut p = vec![0u8; 4]; // version+flags
p.extend_from_slice(&[0, 0, 0, 8]); // reserved, field_size = 8
p.extend_from_slice(&3u32.to_be_bytes()); // count
p.extend_from_slice(&[10, 10, 10]);
mp4_box(b"stz2", &p)
};
let stco = {
let mut p = Vec::new();
p.extend_from_slice(&[0, 0, 0, 0]);
@@ -3068,9 +3106,12 @@ mod tests {
};
let mut stbl = Vec::new();
stbl.extend_from_slice(&stsd);
if omit != b"stsz" {
if omit != b"stsz" && omit != b"stz2" {
stbl.extend_from_slice(&stsz);
}
if omit == b"stz2" {
stbl.extend_from_slice(&stz2);
}
if omit != b"stco" {
stbl.extend_from_slice(&stco);
}
@@ -3088,6 +3129,62 @@ mod tests {
mp4_box(b"trak", &mp4_box(b"mdia", &mdia))
}
// Samples come out in global decode order, interleaved across tracks.
#[test]
fn samples_from_two_tracks_are_interleaved_by_decode_time() {
use std::io::Cursor;
let mut body = audio_trak_missing(b"____");
body.extend(audio_trak_missing(b"____"));
let rd =
Mp4Reader::from_reader(Cursor::new(mp4_box(b"moov", &body)), "two".into()).unwrap();
let order: Vec<usize> = rd.samples.iter().map(|s| s.track).collect();
assert_eq!(order, vec![0, 1, 0, 1, 0, 1]);
}
// The title duration is the longest track, whatever order the tracks come in.
#[test]
fn title_duration_is_the_longest_track() {
use std::io::Cursor;
let mut body = audio_trak(1_000); // 1 s
body.extend(audio_trak(48_000)); // ~21 ms
let rd =
Mp4Reader::from_reader(Cursor::new(mp4_box(b"moov", &body)), "dur".into()).unwrap();
assert!((rd.info().duration_secs - 1.0).abs() < 1e-9);
}
// A 16.16 samplerate whose integer part was truncated to u16 resolves to the mdhd rate.
#[test]
fn audio_rate_recovers_a_u16_truncated_entry_rate() {
assert_eq!(audio_rate(96_000 & 0xFFFF, 96_000, None), 96_000);
assert_eq!(audio_rate(48_000, 48_000, None), 48_000);
assert_eq!(audio_rate(44_100, 96_000, None), 44_100);
}
// A compact `stz2` size table carries the track's samples like `stsz` does.
#[test]
fn stz2_sizes_populate_the_track_samples() {
use std::io::Cursor;
let moov = mp4_box(b"moov", &audio_trak_missing(b"stz2"));
let rd = Mp4Reader::from_reader(Cursor::new(moov), "stz2".into()).unwrap();
assert_eq!(rd.samples.len(), 3);
assert_eq!(rd.samples[0].size, 10);
}
#[test]
fn parse_stz2_decodes_each_field_size() {
let hdr = |fs: u8, n: u32| {
let mut b = vec![0, 0, 0, 0, 0, 0, 0, fs];
b.extend_from_slice(&n.to_be_bytes());
b
};
let with = |fs, n, body: &[u8]| [hdr(fs, n), body.to_vec()].concat();
assert_eq!(parse_stz2(&with(4, 3, &[0x12, 0x30]), 99), vec![1, 2, 3]);
assert_eq!(parse_stz2(&with(8, 2, &[7, 9]), 99), vec![7, 9]);
assert_eq!(parse_stz2(&with(16, 1, &[1, 2]), 99), vec![0x0102]);
assert!(parse_stz2(&with(12, 1, &[1, 2]), 99).is_empty());
assert_eq!(parse_stz2(&with(8, 5, &[7, 9]), 99), vec![7, 9]);
}
// A track with samples (`stsz`) but no chunk-offset table (`stco`/`co64`) must be DROPPED,
// not indexed with offsets near file byte 0.
#[test]