Merge branch 'mux-mp4' into dev
This commit is contained in:
@@ -19,3 +19,7 @@ CLAUDE.md
|
|||||||
# absolute paths from the machine that ran it — which must never reach a public
|
# absolute paths from the machine that ran it — which must never reach a public
|
||||||
# repo. Ignored rather than relocated so a run from any working copy is safe.
|
# repo. Ignored rather than relocated so a run from any working copy is safe.
|
||||||
.nightly/
|
.nightly/
|
||||||
|
|
||||||
|
# cargo-mutants working output: large, machine-specific, never committed
|
||||||
|
mutants.out/
|
||||||
|
mutants.out.old/
|
||||||
|
|||||||
+558
-1
@@ -70,6 +70,16 @@ fn round_up_grain(x: u64) -> u64 {
|
|||||||
x.div_ceil(RESERVE_GRAIN).saturating_mul(RESERVE_GRAIN)
|
x.div_ceil(RESERVE_GRAIN).saturating_mul(RESERVE_GRAIN)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether `gap` bytes of leftover reserved-hole slack (after `moov` is
|
||||||
|
/// written into it) can be closed: an exact fill (`0`) needs no `free` box at
|
||||||
|
/// all, and `8+` bytes is enough to hold one (an ISO-BMFF box header is 8
|
||||||
|
/// bytes: 4-byte size + 4-byte type). A gap of 1–7 bytes cannot be expressed as
|
||||||
|
/// any box, so [`Mp4Sink::finish`] must fall back to moov-at-end instead of
|
||||||
|
/// writing a `free` box that lies about its own size.
|
||||||
|
fn faststart_fits(gap: u64) -> bool {
|
||||||
|
gap == 0 || gap >= 8
|
||||||
|
}
|
||||||
|
|
||||||
/// Estimate the faststart hole: `round_up_4MB(bytes_per_sample × est_samples)`
|
/// Estimate the faststart hole: `round_up_4MB(bytes_per_sample × est_samples)`
|
||||||
/// plus a 4 MiB buffer, floored at 8 MiB. `est_samples` comes from the title
|
/// plus a 4 MiB buffer, floored at 8 MiB. `est_samples` comes from the title
|
||||||
/// duration × each included track's frame rate.
|
/// duration × each included track's frame rate.
|
||||||
@@ -543,7 +553,7 @@ impl<W: Write + Seek + Send> Stream for Mp4Sink<W> {
|
|||||||
// room for a valid (≥8-byte) `free` box in the slack. Otherwise fall back
|
// room for a valid (≥8-byte) `free` box in the slack. Otherwise fall back
|
||||||
// to moov-at-end (rare — the +4 MiB buffer makes this near-impossible).
|
// to moov-at-end (rare — the +4 MiB buffer makes this near-impossible).
|
||||||
match gap {
|
match gap {
|
||||||
Some(g) if g == 0 || g >= 8 => {
|
Some(g) if faststart_fits(g) => {
|
||||||
self.writer.seek(SeekFrom::Start(self.hole_start))?;
|
self.writer.seek(SeekFrom::Start(self.hole_start))?;
|
||||||
self.writer.write_all(&moov)?;
|
self.writer.write_all(&moov)?;
|
||||||
if g >= 8 {
|
if g >= 8 {
|
||||||
@@ -1274,6 +1284,26 @@ mod tests {
|
|||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Direct child lookup by box type, one level, returning its payload (bytes
|
||||||
|
/// after the 8-byte size+type header). A minimal box walker duplicated here
|
||||||
|
/// for tests — the reader's own box-walking (`find_box`) lives in `read.rs`
|
||||||
|
/// and is private to that module.
|
||||||
|
fn find_child<'a>(buf: &'a [u8], want: &[u8; 4]) -> Option<&'a [u8]> {
|
||||||
|
let mut pos = 0;
|
||||||
|
while pos + 8 <= buf.len() {
|
||||||
|
let size =
|
||||||
|
u32::from_be_bytes([buf[pos], buf[pos + 1], buf[pos + 2], buf[pos + 3]]) as usize;
|
||||||
|
if size < 8 || pos + size > buf.len() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if &buf[pos + 4..pos + 8] == want {
|
||||||
|
return Some(&buf[pos + 8..pos + size]);
|
||||||
|
}
|
||||||
|
pos += size;
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
/// An audio track whose frames never yield a parseable sample entry must be
|
/// An audio track whose frames never yield a parseable sample entry must be
|
||||||
/// dropped from moov, NOT written as an stsd declaring entry_count=1 around an
|
/// dropped from moov, NOT written as an stsd declaring entry_count=1 around an
|
||||||
/// empty entry — that is a structurally invalid mp4 returned as success.
|
/// empty entry — that is a structurally invalid mp4 returned as success.
|
||||||
@@ -1701,6 +1731,356 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── pack_language ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Every bit-twiddle in `pack_language`'s valid-input path (both shift
|
||||||
|
/// amounts, both `-0x60` subtractions on the first two letters, and the OR
|
||||||
|
/// that assembles them) pinned against hand-computed packed values, using
|
||||||
|
/// letters other than 'a' so a `-`↔`/` flip on the per-letter offset is
|
||||||
|
/// visible: `'a' - 0x60 == 1 == 'a' / 0x60`, so a fixture starting with 'a'
|
||||||
|
/// cannot tell subtraction from division apart on that letter.
|
||||||
|
#[test]
|
||||||
|
fn pack_language_packs_three_lowercase_letters_into_15_bits() {
|
||||||
|
// "bcd": b=2, c=3, d=4 → (2<<10)|(3<<5)|4 = 2048+96+4 = 0x0864.
|
||||||
|
assert_eq!(pack_language("bcd"), [0x08, 0x64]);
|
||||||
|
// ISO 639-2 "eng", cross-checked against read.rs's `mdhd_language`
|
||||||
|
// fixture, which decodes the same packed constant back to "eng".
|
||||||
|
assert_eq!(pack_language("eng"), [0x15, 0xC7]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every rejection path falls back to the fixed "und" encoding: wrong
|
||||||
|
/// length (both shorter and longer than 3) and not-all-lowercase (upper
|
||||||
|
/// case, a digit, a non-ASCII byte).
|
||||||
|
#[test]
|
||||||
|
fn pack_language_falls_back_to_und_for_anything_not_three_lowercase_letters() {
|
||||||
|
const UND: [u8; 2] = [0x55, 0xC4];
|
||||||
|
assert_eq!(pack_language("en"), UND, "too short");
|
||||||
|
assert_eq!(pack_language("engl"), UND, "too long");
|
||||||
|
assert_eq!(pack_language("ENG"), UND, "not lowercase");
|
||||||
|
assert_eq!(pack_language("e1g"), UND, "not all letters");
|
||||||
|
assert_eq!(pack_language(""), UND, "empty");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── faststart reserve sizing constants and guards ──────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reserve_floor_is_8_mebibytes() {
|
||||||
|
// A shifted-the-wrong-way `8 << 20` silently becomes 0, collapsing the
|
||||||
|
// floor. Every other reserve test builds on this constant, so pin it
|
||||||
|
// directly rather than only through their derived numbers.
|
||||||
|
assert_eq!(RESERVE_FLOOR, 8 * 1024 * 1024);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `estimate_reserve`'s per-stream fps comes from `v.frame_rate.as_fraction()`
|
||||||
|
/// only when BOTH `n > 0 && d > 0`; otherwise it falls back to a flat 24.0.
|
||||||
|
/// `FrameRate::Unknown` is the only variant with `n == 0` (it reports
|
||||||
|
/// `(0, 1)`), so it is the one real input that takes the fallback branch —
|
||||||
|
/// every other variant has `n, d > 0` and must use its OWN fps, not 24.0.
|
||||||
|
///
|
||||||
|
/// A film-rate title (23.976 fps) is the fixture that can tell "used its own
|
||||||
|
/// fps" apart from "used the 24.0 fallback": at 24 fps flat those two numbers
|
||||||
|
/// coincide, so a guard broken by an operator flip (`&&`→`||`, `>`→`==`/`<`/
|
||||||
|
/// `>=`) would be invisible on it. Duration 76_500 s is chosen so the two
|
||||||
|
/// answers land in different 4 MiB reserve grains, not just differ before
|
||||||
|
/// rounding.
|
||||||
|
#[test]
|
||||||
|
fn estimate_reserve_uses_the_streams_own_fps_not_the_24fps_fallback() {
|
||||||
|
let mut t = title(vec![hevc_video()], vec![]);
|
||||||
|
t.duration_secs = 76_500.0;
|
||||||
|
let r = estimate_reserve(&t, &[0]);
|
||||||
|
assert_eq!(
|
||||||
|
r, 33_554_432,
|
||||||
|
"23.976 fps must be used verbatim, not rounded up to a flat 24.0"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The complementary case: `FrameRate::Unknown` (n=0) is the one real input
|
||||||
|
/// meant to take the fallback branch. If the guard's `n > 0` is weakened so
|
||||||
|
/// zero passes it (`==0`, `>=0`) or the `&&` becomes `||`, the code computes
|
||||||
|
/// `0 / 1 = 0.0` fps instead of falling back to 24.0 — collapsing the
|
||||||
|
/// estimate to near-zero samples instead of a reasonable guess.
|
||||||
|
#[test]
|
||||||
|
fn estimate_reserve_unknown_frame_rate_falls_back_to_24fps_not_zero() {
|
||||||
|
let mut vc1 = match hevc_video() {
|
||||||
|
DiscStream::Video(v) => v,
|
||||||
|
_ => unreachable!(),
|
||||||
|
};
|
||||||
|
vc1.frame_rate = FrameRate::Unknown;
|
||||||
|
let mut t = title(vec![DiscStream::Video(vc1)], vec![]);
|
||||||
|
t.duration_secs = 76_500.0;
|
||||||
|
let r = estimate_reserve(&t, &[0]);
|
||||||
|
// Same duration, taking the 24.0 fallback: lands in a DIFFERENT grain
|
||||||
|
// than the 23.976 fps case above (37_748_736 vs 33_554_432), and far
|
||||||
|
// above the floor+buffer a 0-fps collapse would produce (12_582_912).
|
||||||
|
assert_eq!(
|
||||||
|
r, 37_748_736,
|
||||||
|
"an unknown frame rate must estimate as if it were 24 fps, not 0"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `estimate_reserve` models a DTS/DTS-HD-MA/DTS-HD-HR audio unit as 512
|
||||||
|
/// samples (a DTS core AU is `(nblks+1)*32`), a THIRD of the 1536-sample
|
||||||
|
/// (E-)AC-3 default the `_` arm uses for everything else. Deleting the DTS
|
||||||
|
/// match arm silently reverts every DTS track to the AC-3 model, which
|
||||||
|
/// under-reserves a DTS-heavy title's sample table 3x and can push a real
|
||||||
|
/// mux onto the moov-at-end fallback.
|
||||||
|
#[test]
|
||||||
|
fn estimate_reserve_models_dts_at_512_samples_per_frame_not_1536() {
|
||||||
|
let t_dts = {
|
||||||
|
let mut t = title(vec![audio(Codec::Dts, "eng")], vec![]);
|
||||||
|
t.duration_secs = 100_000.0;
|
||||||
|
t
|
||||||
|
};
|
||||||
|
let r_dts = estimate_reserve(&t_dts, &[0]);
|
||||||
|
assert_eq!(
|
||||||
|
r_dts, 155_189_248,
|
||||||
|
"a DTS track must be modelled at 512 samples/frame"
|
||||||
|
);
|
||||||
|
|
||||||
|
let t_ac3 = {
|
||||||
|
let mut t = title(vec![audio(Codec::Ac3, "eng")], vec![]);
|
||||||
|
t.duration_secs = 100_000.0;
|
||||||
|
t
|
||||||
|
};
|
||||||
|
let r_ac3 = estimate_reserve(&t_ac3, &[0]);
|
||||||
|
assert_eq!(
|
||||||
|
r_ac3, 54_525_952,
|
||||||
|
"an AC-3 track (the `_` arm) is modelled at 1536 samples/frame"
|
||||||
|
);
|
||||||
|
assert_ne!(
|
||||||
|
r_dts, r_ac3,
|
||||||
|
"DTS and AC-3 must not be modelled identically — one is a third of the other"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── track timing arithmetic — every operator with a concrete number ───────
|
||||||
|
//
|
||||||
|
// Nothing above this point pins a NUMBER out of the PTS→ticks / duration /
|
||||||
|
// tkhd_dur / ctts chain: every existing test asserts a box exists or that
|
||||||
|
// gross shape (trak count, mdat size) is right, never that `stts`, `ctts` or
|
||||||
|
// `tkhd.duration` hold a specific value. Every arithmetic operator in that
|
||||||
|
// chain (`*`↔`/`↔`+`↔`-`, even `/`↔`%`) could be flipped with the whole
|
||||||
|
// suite green — a title would mux "successfully" with silently corrupted
|
||||||
|
// A/V sync or a wrong total duration. These pin exact values, chosen so a
|
||||||
|
// flipped operator cannot coincidentally reproduce the right answer (e.g. a
|
||||||
|
// non-zero `min_pts` so `pts - min_pts` and a mutated `pts + min_pts` differ,
|
||||||
|
// a `sample_dur` other than 1 so `*` and `/` by it differ).
|
||||||
|
|
||||||
|
/// `VideoTiming::derive`'s composition ticks are `(pts - min_pts) * ts / NS`.
|
||||||
|
/// `min_pts` is deliberately non-zero: with min_pts == 0 a mutated `+` gives
|
||||||
|
/// the same answer as `-` and the mutant survives for free.
|
||||||
|
#[test]
|
||||||
|
fn video_timing_derive_subtracts_the_minimum_pts_not_adds_it() {
|
||||||
|
let offset = 5_000_000_000i64; // 5 s, so min_pts != 0
|
||||||
|
let d = 40_000_000i64; // 40 ms/frame — exact 25 fps
|
||||||
|
// Decode order carries a classic I-P-B-B reorder (P presents last).
|
||||||
|
let samples = vec![
|
||||||
|
Sample {
|
||||||
|
offset: 0,
|
||||||
|
size: 1,
|
||||||
|
pts_ns: offset,
|
||||||
|
keyframe: true,
|
||||||
|
},
|
||||||
|
Sample {
|
||||||
|
offset: 0,
|
||||||
|
size: 1,
|
||||||
|
pts_ns: offset + 3 * d,
|
||||||
|
keyframe: false,
|
||||||
|
},
|
||||||
|
Sample {
|
||||||
|
offset: 0,
|
||||||
|
size: 1,
|
||||||
|
pts_ns: offset + d,
|
||||||
|
keyframe: false,
|
||||||
|
},
|
||||||
|
Sample {
|
||||||
|
offset: 0,
|
||||||
|
size: 1,
|
||||||
|
pts_ns: offset + 2 * d,
|
||||||
|
keyframe: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
let timing = VideoTiming::derive(&samples);
|
||||||
|
assert_eq!(timing.timescale, 25, "exact 25 fps snaps to timescale 25");
|
||||||
|
assert_eq!(timing.sample_dur, 1);
|
||||||
|
assert_eq!(
|
||||||
|
timing.cts,
|
||||||
|
vec![0, 3, 1, 2],
|
||||||
|
"cts must be (pts - min_pts) * ts / NS, in decode order"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `VideoTiming::total_duration` is `sample_count * sample_dur`. `sample_dur`
|
||||||
|
/// is deliberately not 1 — `*` and `/` by 1 are the same function, so a
|
||||||
|
/// mutated `/` would survive a `sample_dur == 1` fixture for free.
|
||||||
|
#[test]
|
||||||
|
fn video_timing_total_duration_multiplies_count_by_sample_duration() {
|
||||||
|
let timing = VideoTiming {
|
||||||
|
timescale: 24_000,
|
||||||
|
sample_dur: 1001,
|
||||||
|
cts: vec![0, 1001, 2002, 3003],
|
||||||
|
};
|
||||||
|
assert_eq!(timing.total_duration(), 4 * 1001);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `VideoTiming::ctts` is `cts[i] - i * sample_dur`. `sample_dur` is again
|
||||||
|
/// not 1, so `*` and `/` disagree.
|
||||||
|
#[test]
|
||||||
|
fn video_timing_ctts_subtracts_index_times_sample_duration() {
|
||||||
|
let timing = VideoTiming {
|
||||||
|
timescale: 25,
|
||||||
|
sample_dur: 2,
|
||||||
|
cts: vec![10, 5, 20],
|
||||||
|
};
|
||||||
|
// [10 - 0*2, 5 - 1*2, 20 - 2*2] = [10, 3, 16].
|
||||||
|
assert_eq!(timing.ctts(), vec![10, 3, 16]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `build_video_trak_full` and `build_audio_trak_full` both compute
|
||||||
|
/// `tkhd.duration = secs * MOVIE_TIMESCALE` — a SEPARATE multiplication from
|
||||||
|
/// the media-timescale duration in `mdhd`, because `tkhd.duration` lives in
|
||||||
|
/// the movie's 90 kHz clock (ISO/IEC 14496-12 §8.3.2). Read it straight out
|
||||||
|
/// of the emitted `tkhd` bytes (offset 28 in a version-1 tkhd: version+flags
|
||||||
|
/// (4) + creation(8) + modification(8) + track_id(4) + reserved(4) =28),
|
||||||
|
/// so the assertion is on what the file says, not a second copy of the
|
||||||
|
/// formula.
|
||||||
|
#[test]
|
||||||
|
fn tkhd_duration_is_seconds_times_movie_timescale_for_both_media_types() {
|
||||||
|
fn tkhd_duration(trak: &[u8]) -> u64 {
|
||||||
|
let tkhd = find_child(&trak[8..], b"tkhd").expect("tkhd");
|
||||||
|
u64::from_be_bytes(tkhd[28..36].try_into().unwrap())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Video: 4 samples at exact 25 fps → total_duration 4 ticks @ ts 25 →
|
||||||
|
// secs = 0.16 → tkhd_dur = 0.16 * 90_000 = 14_400 exactly.
|
||||||
|
let video = Track {
|
||||||
|
media: Media::Video,
|
||||||
|
track_id: 1,
|
||||||
|
stream_idx: 0,
|
||||||
|
codec: Codec::Hevc,
|
||||||
|
codec_private: vec![0xAA, 0xBB],
|
||||||
|
width: 1920,
|
||||||
|
height: 1080,
|
||||||
|
colr: None,
|
||||||
|
language: [0x55, 0xC4],
|
||||||
|
audio_entry: None,
|
||||||
|
audio_timescale: 0,
|
||||||
|
samples: (0..4)
|
||||||
|
.map(|i| Sample {
|
||||||
|
offset: 0,
|
||||||
|
size: 1,
|
||||||
|
pts_ns: i * 40_000_000,
|
||||||
|
keyframe: i == 0,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
};
|
||||||
|
let (vtrak, vsecs) = build_trak(&video);
|
||||||
|
assert_eq!(vsecs, 0.16);
|
||||||
|
assert_eq!(
|
||||||
|
tkhd_duration(&vtrak),
|
||||||
|
14_400,
|
||||||
|
"video tkhd.duration must be secs * MOVIE_TIMESCALE"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Audio: 2 samples 32 ms apart at 48 kHz → per-sample duration 1536
|
||||||
|
// ticks (exact), media_dur = 2 * 1536 = 3072, secs = 3072/48000 = 0.064
|
||||||
|
// → tkhd_dur = 0.064 * 90_000 = 5_760 exactly.
|
||||||
|
let audio = Track {
|
||||||
|
media: Media::Audio,
|
||||||
|
track_id: 2,
|
||||||
|
stream_idx: 1,
|
||||||
|
codec: Codec::Ac3,
|
||||||
|
codec_private: Vec::new(),
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
colr: None,
|
||||||
|
language: [0x55, 0xC4],
|
||||||
|
audio_entry: Some(vec![0x0B, 0x77]),
|
||||||
|
audio_timescale: 48_000,
|
||||||
|
samples: vec![
|
||||||
|
Sample {
|
||||||
|
offset: 0,
|
||||||
|
size: 10,
|
||||||
|
pts_ns: 0,
|
||||||
|
keyframe: true,
|
||||||
|
},
|
||||||
|
Sample {
|
||||||
|
offset: 10,
|
||||||
|
size: 10,
|
||||||
|
pts_ns: 32_000_000,
|
||||||
|
keyframe: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
let (atrak, asecs) = build_trak(&audio);
|
||||||
|
assert_eq!(
|
||||||
|
asecs,
|
||||||
|
3072.0 / 48_000.0,
|
||||||
|
"audio secs must be media_dur / timescale, not any other combination"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
tkhd_duration(&atrak),
|
||||||
|
5_760,
|
||||||
|
"audio tkhd.duration must be secs * MOVIE_TIMESCALE"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `audio_sample_durations`'s per-sample ticks are `ns * ts / NS`, and the
|
||||||
|
/// inter-sample delta is `ticks(next) - ticks(prev)` (clamped at 0), with the
|
||||||
|
/// LAST duration repeated for the trailing sample. PTS deltas are chosen so
|
||||||
|
/// every tick value is an exact integer and the two windows differ (32 ms
|
||||||
|
/// then another 32 ms from a non-zero base), so a `-`↔`+` flip on the delta
|
||||||
|
/// is visible on the second window even though it is invisible on the first
|
||||||
|
/// (whose previous tick is 0).
|
||||||
|
#[test]
|
||||||
|
fn audio_sample_durations_computes_exact_tick_deltas_and_repeats_the_last() {
|
||||||
|
let samples = vec![
|
||||||
|
Sample {
|
||||||
|
offset: 0,
|
||||||
|
size: 1,
|
||||||
|
pts_ns: 0,
|
||||||
|
keyframe: true,
|
||||||
|
},
|
||||||
|
Sample {
|
||||||
|
offset: 0,
|
||||||
|
size: 1,
|
||||||
|
pts_ns: 32_000_000, // 1536 ticks @ 48 kHz, exact
|
||||||
|
keyframe: true,
|
||||||
|
},
|
||||||
|
Sample {
|
||||||
|
offset: 0,
|
||||||
|
size: 1,
|
||||||
|
pts_ns: 64_000_000, // 3072 ticks @ 48 kHz, exact
|
||||||
|
keyframe: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
let durs = audio_sample_durations(&samples, 48_000);
|
||||||
|
assert_eq!(
|
||||||
|
durs,
|
||||||
|
vec![1536, 1536, 1536],
|
||||||
|
"two 1536-tick deltas, and the trailing sample repeats the last"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The single-sample fallback (`durs.last()` is `None`) pushes
|
||||||
|
/// `timescale / 30`, guarded by `!samples.is_empty()`. A single sample
|
||||||
|
/// exercises both: `windows(2)` yields nothing, so the fallback branch is
|
||||||
|
/// the only source of a duration at all.
|
||||||
|
#[test]
|
||||||
|
fn audio_sample_durations_single_sample_uses_timescale_over_30_fallback() {
|
||||||
|
let samples = vec![Sample {
|
||||||
|
offset: 0,
|
||||||
|
size: 1,
|
||||||
|
pts_ns: 0,
|
||||||
|
keyframe: true,
|
||||||
|
}];
|
||||||
|
assert_eq!(
|
||||||
|
audio_sample_durations(&samples, 48_000),
|
||||||
|
vec![48_000 / 30],
|
||||||
|
"a lone sample gets one fallback duration, timescale/30"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn detect_rate_picks_the_nearest_std_rate_regardless_of_table_order() {
|
fn detect_rate_picks_the_nearest_std_rate_regardless_of_table_order() {
|
||||||
// Order-independence is the property that keeps this fixed: every entry
|
// Order-independence is the property that keeps this fixed: every entry
|
||||||
@@ -1724,4 +2104,181 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Fewer than 2 samples can't measure a delta at all: fixed 90 kHz/3003
|
||||||
|
/// fallback (not 90 kHz/anything else, and not a panic on an empty median).
|
||||||
|
/// EXACTLY 2 samples is the boundary itself, not just "some samples fewer
|
||||||
|
/// than 2" — a `<`→`==`/`<=` mutant on `samples.len() < 2` forces the
|
||||||
|
/// fallback for 2 samples too, even though they carry a perfectly good
|
||||||
|
/// 25 fps delta.
|
||||||
|
#[test]
|
||||||
|
fn detect_rate_needs_at_least_two_samples_not_more() {
|
||||||
|
assert_eq!(detect_rate(&[]), (90_000, 3_003));
|
||||||
|
assert_eq!(
|
||||||
|
detect_rate(&[Sample {
|
||||||
|
offset: 0,
|
||||||
|
size: 1,
|
||||||
|
pts_ns: 0,
|
||||||
|
keyframe: true
|
||||||
|
}]),
|
||||||
|
(90_000, 3_003)
|
||||||
|
);
|
||||||
|
let two = vec![
|
||||||
|
Sample {
|
||||||
|
offset: 0,
|
||||||
|
size: 1,
|
||||||
|
pts_ns: 0,
|
||||||
|
keyframe: true,
|
||||||
|
},
|
||||||
|
Sample {
|
||||||
|
offset: 0,
|
||||||
|
size: 1,
|
||||||
|
pts_ns: 40_000_000, // exact 25 fps
|
||||||
|
keyframe: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
assert_eq!(
|
||||||
|
detect_rate(&two),
|
||||||
|
(25, 1),
|
||||||
|
"exactly 2 samples is enough to measure a real rate, not the fallback"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Only POSITIVE deltas are considered (`filter(|&d| d > 0)`). A `>`→`>=`
|
||||||
|
/// mutant lets zero deltas (duplicate/out-of-order PTS) through, which
|
||||||
|
/// shifts which element `deltas[deltas.len() / 2]` lands on. Three
|
||||||
|
/// duplicate timestamps plus one real 25 fps delta make the shift land on
|
||||||
|
/// the zero itself: median becomes 0, `fps` becomes `NS / 0 = inf`, no
|
||||||
|
/// `STD_RATES` entry is within tolerance of infinity, and the fallback
|
||||||
|
/// path's `median` of 0 forces its duration floor of 1 — a completely
|
||||||
|
/// different, clearly-wrong answer from the correct (25, 1).
|
||||||
|
#[test]
|
||||||
|
fn detect_rate_filters_zero_deltas_not_just_negative_ones() {
|
||||||
|
let samples: Vec<Sample> = vec![0, 0, 0, 40_000_000]
|
||||||
|
.into_iter()
|
||||||
|
.map(|pts_ns| Sample {
|
||||||
|
offset: 0,
|
||||||
|
size: 1,
|
||||||
|
pts_ns,
|
||||||
|
keyframe: pts_ns == 0,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
assert_eq!(
|
||||||
|
detect_rate(&samples),
|
||||||
|
(25, 1),
|
||||||
|
"the three duplicate zero deltas must be filtered out entirely, \
|
||||||
|
leaving the one real 25 fps delta as the (only, hence median) value"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A rate with no nearby `STD_RATES` entry takes the fallback branch:
|
||||||
|
/// `timescale = 90_000`, `duration = (median * 90_000) / NS`. 5 fps
|
||||||
|
/// (200 ms/frame) is chosen so `median * 90_000` is an exact multiple of
|
||||||
|
/// `NS`, giving a clean expected duration that a `*`↔`+`/`/` flip on either
|
||||||
|
/// operator, or `/`↔`%`, cannot coincidentally reproduce.
|
||||||
|
#[test]
|
||||||
|
fn detect_rate_fallback_duration_is_median_times_90khz_over_ns() {
|
||||||
|
let samples: Vec<Sample> = (0..10)
|
||||||
|
.map(|i| Sample {
|
||||||
|
offset: 0,
|
||||||
|
size: 1,
|
||||||
|
pts_ns: i as i64 * 200_000_000, // 5 fps — far outside every STD_RATES window
|
||||||
|
keyframe: i == 0,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
assert_eq!(
|
||||||
|
detect_rate(&samples),
|
||||||
|
(90_000, 18_000),
|
||||||
|
"median 200_000_000 ns * 90_000 / 1e9 = 18_000 exactly"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── build_ftyp ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_ftyp_names_the_codec_specific_compatible_brand() {
|
||||||
|
let hevc = build_ftyp(Codec::Hevc);
|
||||||
|
assert!(
|
||||||
|
hevc.windows(4).any(|w| w == b"hvc1"),
|
||||||
|
"an HEVC title must declare the hvc1 compatible brand"
|
||||||
|
);
|
||||||
|
let h264 = build_ftyp(Codec::H264);
|
||||||
|
assert!(
|
||||||
|
h264.windows(4).any(|w| w == b"avc1"),
|
||||||
|
"an H.264 title must declare the avc1 compatible brand"
|
||||||
|
);
|
||||||
|
assert_ne!(
|
||||||
|
hevc, h264,
|
||||||
|
"the two codec brands must not collapse to the same ftyp"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── build_audio_stbl run-length coalescing ─────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_audio_stbl_coalesces_equal_adjacent_durations_only() {
|
||||||
|
let samples: Vec<Sample> = (0..5)
|
||||||
|
.map(|i| Sample {
|
||||||
|
offset: i as u64 * 10,
|
||||||
|
size: 10,
|
||||||
|
pts_ns: 0,
|
||||||
|
keyframe: true,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
// Two distinct adjacent runs: [5,5] then [7,7,7]. If the coalescing
|
||||||
|
// guard always matches (or always mismatches), or `==`/`!=` is
|
||||||
|
// inverted, the run count and/or the runs' (count, value) pairs come
|
||||||
|
// out wrong.
|
||||||
|
let durs = vec![5u32, 5, 7, 7, 7];
|
||||||
|
let stbl = build_audio_stbl(vec![0u8; 4], &samples, &durs);
|
||||||
|
let stts = find_child(&stbl[8..], b"stts").expect("stts");
|
||||||
|
let entry_count = u32::from_be_bytes(stts[4..8].try_into().unwrap());
|
||||||
|
assert_eq!(
|
||||||
|
entry_count, 2,
|
||||||
|
"5,5,7,7,7 must coalesce into exactly two runs"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
&stts[8..16],
|
||||||
|
&[0, 0, 0, 2, 0, 0, 0, 5][..],
|
||||||
|
"first run: count=2 value=5"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
&stts[16..24],
|
||||||
|
&[0, 0, 0, 3, 0, 0, 0, 7][..],
|
||||||
|
"second run: count=3 value=7"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── faststart_fits ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn faststart_fits_zero_or_at_least_eight_bytes_only() {
|
||||||
|
assert!(faststart_fits(0), "an exact fill needs no free box");
|
||||||
|
for g in 1..8 {
|
||||||
|
assert!(
|
||||||
|
!faststart_fits(g),
|
||||||
|
"{g} bytes cannot be expressed as any box (min header is 8)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert!(faststart_fits(8), "8 bytes is exactly one empty free box");
|
||||||
|
assert!(faststart_fits(1_000_000));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Stream::read is write-only ──────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mp4_sink_read_is_unsupported() {
|
||||||
|
let t = title(
|
||||||
|
vec![hevc_video(), audio(Codec::Ac3, "eng")],
|
||||||
|
vec![Some(vec![1, 2, 3]), None],
|
||||||
|
);
|
||||||
|
let mut s = Mp4Sink::create(std::io::Cursor::new(Vec::new()), &t).unwrap();
|
||||||
|
let err = s.read().unwrap_err();
|
||||||
|
assert!(
|
||||||
|
err.to_string()
|
||||||
|
.starts_with(&format!("E{}", crate::error::E_STREAM_WRITE_ONLY)),
|
||||||
|
"an Mp4Sink is write-only; read() must report that, not silently return Ok(None); \
|
||||||
|
got {err}"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user