mirror of
https://github.com/freemkv/libfreemkv.git
synced 2026-10-07 18:13:10 +00:00
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:
+43
-2
@@ -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
@@ -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
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user