Fix fifteen defects across perf, resource, panics and key hygiene

All 21 findings held up under verification; 15 fixed here, 6 deferred to files
another agent held this round, 0 rejected.

**A defect in my own round-2 probe fix.** CHUNK_SECTORS was 1024, and
1024 % 3 == 1 — verified — so every chunk after the first was misaligned against
the 6144-byte AACS aligned unit and would be REJECTED by
DecryptingSectorSource's alignment gate. On an encrypted disc the forced-subtitle
probe I added last round would have read almost nothing past its first chunk.
Now 1023 sectors (341 aligned units) with a const assertion that fails the build
if it stops dividing, plus set_unit_base per extent so the source's gate is
anchored where the extent actually starts.

**The same probe skipped sectors on a short read**, advancing by the REQUESTED
count rather than the bytes actually returned, so a partial read silently left a
gap in the middle of the evidence. It now advances by n/SECTOR_BYTES and clamps n
to the buffer.

**Its cache key omitted the PGS PID set**, so a playlist declaring an extra
subtitle PID got another playlist's verdict for a track that had never been
probed. And the key was the whole extent list, so partial clip sharing missed
entirely. Both fixed by keying (start_lba, sector_count, pid) — and per-extent
keying was shown SOUND rather than assumed: ForcedTracker is two monotone
booleans, so per-extent evidence composes by field-wise OR, order- and
grouping-independently. Making that honest required per-extent demux state, so an
extent's evidence comes only from its own bytes, and memoising only extents whose
read reached a designed stop.

**A reachable panic in the timeline.** mkvstream::parse_block accepts a
TimestampScale up to i64::MAX, so a video frame can set high_ns = i64::MAX and the
next passive frame panicked adding the backstep. In release it wrapped negative
instead, firing the straggler clamp for essentially every passive frame — audio
and subtitles rewritten onto the wrong point of the output timeline. All four
sites saturate.

**A public constructor divided by zero**: PrefetchedSectorSource::new_with_events
with unit_align == 0. Now InvalidInput, matching its batch_sectors sibling.

**Two Debug impls printed key material.** DiscInputs (volume_id, mkb, unit_key_ro,
samples) and UnitKeyFile both derived Debug. Nothing logs them today — fixed as
prevention, because the next tracing::debug! someone adds is the leak. A doc claim
that DiscInputs "contains no secrets" was false and is corrected.

**An env-var multiply could overflow** in file_sector_source; now bounded at 64 GiB
like its writeback sibling, with the parse split out so the bound is testable
without touching process env.

**The mp4 demuxer allowed one sample per file byte** — ~64x RAM amplification.
Now file_len/16, since only vide/soun tracks are indexed and the shortest legal
AC-3 frame is 128 bytes.

**Two pipeline concurrency defects**: a consumer apply() error was invisible to the
producer, and abandon/finalise had a TOCTOU where a caller could report an
unfinalised output. Both fixed with compare-exchange state rather than a bool.

**Two per-frame copies removed**, both MEASURED rather than reasoned: the AU
assembler now hands its allocation to the frame (same pointer, unchanged capacity,
proven by asserting the pointer) and tsmux reuses one Annex-B buffer across
frames. Both keep capacity deliberately — a naive split_off would have cost more
than it saved.

**A comment pointed at the wrong file** for a mirrored constant; the mirror is now
compiler-enforced with a const assertion converting 90 kHz ticks to ns, so drift
fails the build.

Deferred to another agent's files, all confirmed: detect_rate's fractional-twin
snap, the mp4 reserve's u32 truncation, round_up_grain's overflow, the quadratic
base-key gap fill, and MkvStream's frame cap counting frames rather than bytes.

Every fix verified red by reverting it. Also noted for later:
DecodeSampleSet still derives Debug over multi-MB of on-disc ciphertext.
This commit is contained in:
Matthew Jackson
2026-07-29 20:47:31 -07:00
parent 3efa6211f3
commit a32373ff40
11 changed files with 1303 additions and 168 deletions
+83 -2
View File
@@ -321,8 +321,7 @@ impl AuAssembler {
self.disc_marks.pop_front();
}
let data = self.buf[..end].to_vec();
self.buf.drain(..end);
let data = self.take_front(end);
self.base += end as u64;
self.reset_scan();
out.push(AssembledAu {
@@ -336,6 +335,41 @@ impl AuAssembler {
out
}
/// Detach `buf[..end]` as the emitted AU's own `Vec` and leave `buf` holding
/// the tail.
///
/// The AU's bytes are HANDED OVER — `buf`'s allocation becomes the returned
/// `Vec` and a fresh buffer (pre-sized to the same capacity, so the next AU
/// accumulates without re-growing) takes its place holding only the short
/// tail. `buf[..end].to_vec()` + `drain(..end)` instead copied every AU out
/// in full: on a UHD HEVC title that is a whole-frame memcpy (hundreds of KB)
/// per coded picture, ~200k times, for bytes that are about to be discarded
/// from `buf` anyway.
///
/// The allocation COUNT is unchanged (one per AU either way — the frame `Vec`
/// before, the replacement buffer now), so the only difference is the copy
/// that no longer happens. Nothing depends on `buf` keeping its identity: the
/// only state tied to `buf[0]`'s position is `base`/`scan_pos`/`opener_pos`,
/// which the caller updates immediately after.
///
/// Falls back to a copy when the buffer's capacity is far larger than the AU
/// (a small AU after a multi-MB one): handing over would otherwise attach an
/// oversized idle allocation to a small frame for as long as the frame queues
/// downstream, trading a copy for resident memory.
fn take_front(&mut self, end: usize) -> Vec<u8> {
let cap = self.buf.capacity();
if cap > end.saturating_mul(2) {
let data = self.buf[..end].to_vec();
self.buf.drain(..end);
return data;
}
let mut tail = Vec::with_capacity(cap.max(self.buf.len() - end));
tail.extend_from_slice(&self.buf[end..]);
let mut data = std::mem::replace(&mut self.buf, tail);
data.truncate(end);
data
}
/// Reset the incremental boundary-scan cursor. Called whenever `buf[0]` moves
/// (an AU drained, or leading bytes discarded) so the next scan starts fresh
/// from the new AU opener.
@@ -841,4 +875,51 @@ mod tests {
"over-cap AU is force-flushed, not buffered forever"
);
}
/// MEASURED: a drained AU must be HANDED the accumulation buffer's
/// allocation, not copied out of it. The emitted `Vec`'s data pointer is the
/// buffer's own pointer — which is only true if no full-frame copy happened.
/// (`buf[..end].to_vec()` allocates fresh, so the pointers differ.) One
/// whole-AU memcpy per coded picture is ~200k memcpys of a few hundred KB
/// each on a UHD feature.
#[test]
fn drained_au_takes_over_the_buffer_allocation_without_copying() {
let mut a = AuAssembler::for_codec(Codec::H264);
// An AU large enough that the buffer's capacity is not >2x its size (the
// small-AU copy path exists so a small frame cannot carry an oversized
// idle allocation downstream).
let au1 = au(0x11, 400 * 1024);
let au2 = au(0x22, 400 * 1024);
let mut stream = au1.clone();
stream.extend_from_slice(&au2);
// Push everything except the final byte of AU2's delimiter, so no AU has
// been emitted yet but the buffer holds the whole of AU1.
a.push(&stream[..au1.len() + 3], Some(1), None, None, false);
let before = a.buf.as_ptr();
let cap_before = a.buf.capacity();
let out = a.push(
&stream[au1.len() + 3..au1.len() + 4],
None,
None,
None,
false,
);
assert_eq!(out.len(), 1);
assert_eq!(
out[0].data, au1,
"handover must preserve the AU bytes exactly"
);
assert_eq!(
out[0].data.as_ptr(),
before,
"the emitted AU must own the buffer's allocation (no whole-frame copy)"
);
assert_eq!(
a.buf.capacity(),
cap_before,
"the replacement buffer keeps the capacity, so the next AU does not re-grow"
);
assert_eq!(a.buf.len(), 4, "the buffer holds only AU2's delimiter tail");
}
}
+12 -3
View File
@@ -201,12 +201,21 @@ struct ContentLightLevel {
// window (HEVC reorder depth tops out ~16 frames, <1 s at 24 fps). 3 s = 270000
// ticks sits well above any legitimate reorder dip and far below any real clip's
// duration, so it never false-triggers within a clip. This MIRRORS the mux-side
// `DISCONTINUITY_BACKSTEP_NS` (3 s) in `mux/mkv.rs`, which independently rebases
// the timeline at the same boundaries; here it drives the CRA→BLA rewrite that
// kills the dangling-RASL "Could not find ref with POC N" decode errors a
// `DISCONTINUITY_BACKSTEP_NS` (3 s) in `mux/timeline.rs`, which independently
// rebases the timeline at the same boundaries; here it drives the CRA→BLA rewrite
// that kills the dangling-RASL "Could not find ref with POC N" decode errors a
// concatenated multi-clip title otherwise produces.
const BACKSTEP_TICKS: i64 = 270_000;
// The mirror above is enforced, not just described: 90 kHz ticks → ns is
// × (1_000_000_000 / 90_000) = × 100_000 / 9, so 270_000 ticks must be exactly
// `DISCONTINUITY_BACKSTEP_NS`. Changing either constant without the other fails
// the build here, which is the drift the comment exists to prevent.
const _: () = assert!(
BACKSTEP_TICKS * 100_000 / 9 == crate::mux::timeline::DISCONTINUITY_BACKSTEP_NS,
"HEVC BACKSTEP_TICKS must mirror mux::timeline::DISCONTINUITY_BACKSTEP_NS"
);
// The 33-bit 90 kHz PES PTS counter wraps at 2^33 ticks (~26.5 h). When the raw
// PTS steps backward by approximately a full period — i.e. it landed just past
// the wrap — it is a counter wraparound, NOT a clip reset: unwrap it (add 2^33)
+70 -15
View File
@@ -35,6 +35,17 @@ const MAX_TRACKS: usize = 512;
/// hostile file's allocation without truncating any legitimate track.
const MAX_SAMPLE_COUNT: usize = 1 << 24;
/// Smallest number of FILE bytes one indexed sample is assumed to occupy — the
/// divisor that turns `file_len` into a sample-count budget (see `from_reader`).
///
/// Only `vide` and `soun` tracks are indexed here, and no real coded video or
/// audio access unit is anywhere near this small: the shortest legal AC-3 frame is
/// 128 bytes, an AAC frame is hundreds, and a video sample carries at least a NAL
/// header plus slice data. A 2-hour title runs to thousands of file bytes per
/// sample, so this cannot truncate a genuine track — it only stops a crafted
/// sample table from claiming more samples than the file could possibly hold.
const MIN_FILE_BYTES_PER_SAMPLE: u64 = 16;
/// Absolute ceiling on a single allocation sized from an untrusted MP4 field (a
/// per-sample buffer or the `moov` payload). The EOF check alone is not enough:
/// `file_len` is cheaply inflatable with a sparse file (`truncate -s 8G`), so a
@@ -98,13 +109,16 @@ impl<R: Read + Seek> Mp4Reader<R> {
// crafted file with a fixed-size `stsz` claiming count=0xFFFFFFFF can't
// inflate the `sizes`/`Vec<SampleRef>` allocations past the file's own size
// (a genuine large title has file_len ≫ sample count, so it is unaffected).
// NOTE on the bound this actually gives: each indexed sample costs about
// 52 bytes of RAM (SampleRef 40 + u32 size 4 + u64 offset 8, plus 4 each
// for the expanded stts/ctts), so the ceiling is ~52x file_len, not 1x —
// capped by MAX_SAMPLE_COUNT. That is still a real bound (a 1 MiB crafted
// file cannot reach the 16M-sample ceiling), just not the "past the file's
// own size" the previous comment implied.
let mut sample_budget = MAX_SAMPLE_COUNT.min(file_len.min(usize::MAX as u64) as usize);
// The bound is in file BYTES PER SAMPLE, not in samples: each indexed
// sample costs ~60 bytes of RAM (SampleRef 40 + u32 size 4 + u64 offset 8,
// plus 4 each for the expanded stts/ctts), so a budget of one sample per
// file byte still let a 16 MiB crafted file (a fixed-size `stsz` declaring
// 16M one-byte samples, a one-entry stsc/stco/stts) force a ~1 GiB eager
// allocation and a 16M-element sort before a single frame was read — a 64x
// amplification. Dividing by MIN_FILE_BYTES_PER_SAMPLE caps the
// amplification at ~4x instead.
let mut sample_budget = MAX_SAMPLE_COUNT
.min((file_len / MIN_FILE_BYTES_PER_SAMPLE).min(usize::MAX as u64) as usize);
// Bound the scan at MAX_TRACKS *matches* so a crafted moov packed with tiny
// (8-byte) trak headers can't force the scan to materialize a Vec far
@@ -1341,10 +1355,10 @@ mod tests {
/// NOT the 16M `MAX_SAMPLE_COUNT` ceiling. Mutation check: revert the budget
/// to a bare `MAX_SAMPLE_COUNT` and this file yields ~16M samples, failing the
/// `<= file_len` (and `< MAX_SAMPLE_COUNT`) assertions below.
#[test]
fn stsz_sample_count_bounded_by_file_len() {
use std::io::Cursor;
// A minimal audio trak, but with a fixed-size stsz lying about its count.
/// A minimal-but-complete audio `trak` whose fixed-size `stsz` LIES about its
/// sample count (`u32::MAX`), with an stsc/stts wide enough to place whatever
/// count survives the budget. The shape the sample-table budget bounds.
fn audio_trak_hostile_count() -> Vec<u8> {
let mdhd = {
let mut p = vec![0u8; 24];
p[12..16].copy_from_slice(&48_000u32.to_be_bytes()); // timescale
@@ -1410,6 +1424,13 @@ mod tests {
mdia.extend_from_slice(&hdlr);
mdia.extend_from_slice(&minf);
let trak = mp4_box(b"trak", &mp4_box(b"mdia", &mdia));
trak
}
#[test]
fn stsz_sample_count_bounded_by_file_len() {
use std::io::Cursor;
let trak = audio_trak_hostile_count();
let moov = mp4_box(b"moov", &trak);
let file_len = moov.len() as u64;
@@ -1418,11 +1439,16 @@ mod tests {
"fixture stays a few hundred bytes ({file_len})"
);
let rd = Mp4Reader::from_reader(Cursor::new(moov), "hostile".into()).unwrap();
// The index must be bounded by the file's byte length, NOT the 16M ceiling.
// The index must be bounded by the file's byte length in FILE BYTES PER
// SAMPLE, not one sample per byte: each indexed sample costs ~60 bytes of
// RAM, so a one-sample-per-byte budget still let a 16 MiB crafted file
// force a ~1 GiB allocation (64x amplification) before a frame was read.
assert!(
(rd.samples.len() as u64) <= file_len,
"sample count {} must be bounded by file_len {file_len}, not the count lie",
rd.samples.len()
(rd.samples.len() as u64) <= file_len / MIN_FILE_BYTES_PER_SAMPLE,
"sample count {} must be bounded by file_len/{MIN_FILE_BYTES_PER_SAMPLE} \
({}), not by the count lie",
rd.samples.len(),
file_len / MIN_FILE_BYTES_PER_SAMPLE
);
assert!(
rd.samples.len() < MAX_SAMPLE_COUNT,
@@ -1430,6 +1456,35 @@ mod tests {
);
}
/// The RAM amplification the byte-per-sample budget actually bounds: a small
/// crafted file whose `stsz` claims `u32::MAX` samples must not force an eager
/// multi-hundred-MB index. At ~60 bytes of RAM per indexed sample, a budget of
/// one sample per file byte gave ~60x the input size; the assertion below pins
/// the amplification factor rather than a raw count, so it fails if the budget
/// ever goes back to counting samples per byte.
#[test]
fn sample_index_ram_is_bounded_by_a_multiple_of_the_file() {
use std::io::Cursor;
// ~64 KiB of `free` padding so file_len is large enough for the ratio to
// be meaningful, with the same lying stsz as above.
let mut traks = Vec::new();
traks.extend_from_slice(&audio_trak_hostile_count());
let mut file = mp4_box(b"moov", &traks);
file.extend_from_slice(&mp4_box(b"free", &vec![0u8; 64 * 1024]));
let file_len = file.len() as u64;
let rd = Mp4Reader::from_reader(Cursor::new(file), "amp".into()).unwrap();
// ~60 bytes of RAM per sample; assert the index cannot exceed ~4x the file.
const RAM_PER_SAMPLE: u64 = 60;
let ram = rd.samples.len() as u64 * RAM_PER_SAMPLE;
assert!(
ram <= file_len * 4,
"sample index RAM {ram} B from a {file_len} B file is more than 4x \
amplification ({} samples)",
rd.samples.len()
);
}
/// Build an audio `trak` identical to `audio_trak(48_000)` but with the
/// named stbl child box omitted. Used to reach the untrusted-input guards
/// that drop a track whose `stsz` says samples exist yet whose `stco`/`co64`
+57 -4
View File
@@ -130,10 +130,22 @@ impl TimelineContinuity {
// within a backstep below the frontier keeps the remap to genuine
// tail stragglers; a long audio-only tail, a sparse subtitle, or an
// EL frame that simply runs ahead is left on the current offset.
//
// Every comparison below saturates. `high` is derived from an
// untrusted container timestamp (an `mkv://` source's
// CLUSTER_TIMESTAMP × TimestampScale is clamped only against
// `i64::MAX`, so a hostile file can put the frontier AT `i64::MAX`),
// and `raw_pts_ns` can be negative (a SimpleBlock's signed relative
// timestamp). Plain `high + BACKSTEP` / `high - BACKSTEP` would then
// overflow: a panic out of the public `Stream::write` path in an
// overflow-checked build, and in release a wrap to the opposite sign
// that fires the straggler clamp on essentially every passive frame.
if let Some(high) = self.high_ns {
if mapped > high + DISCONTINUITY_BACKSTEP_NS {
if mapped > high.saturating_add(DISCONTINUITY_BACKSTEP_NS) {
let prev_mapped = raw_pts_ns.saturating_add(self.prev_offset_ns);
if prev_mapped <= high && prev_mapped >= high - DISCONTINUITY_BACKSTEP_NS {
if prev_mapped <= high
&& prev_mapped >= high.saturating_sub(DISCONTINUITY_BACKSTEP_NS)
{
return prev_mapped;
}
}
@@ -147,12 +159,17 @@ impl TimelineContinuity {
return adj;
};
let adj = raw_pts_ns.saturating_add(self.offset_ns);
if adj < high - DISCONTINUITY_BACKSTEP_NS {
if adj < high.saturating_sub(DISCONTINUITY_BACKSTEP_NS) {
// Clip-boundary reset (real multi-clip seam): continue just after the
// frontier. Save the previous offset so a lagging non-video tail
// frame can be recognised and remapped to the seam (see above).
self.prev_offset_ns = self.offset_ns;
let bump = (high - adj).saturating_add(DISCONTINUITY_GAP_NS);
// `high - adj` is a backward step, so positive — but both ends are
// untrusted (`high` up to i64::MAX, `adj` down to i64::MIN), so
// saturate rather than panic in a checked build.
let bump = high
.saturating_sub(adj)
.saturating_add(DISCONTINUITY_GAP_NS);
self.offset_ns = self.offset_ns.saturating_add(bump);
let adj2 = raw_pts_ns.saturating_add(self.offset_ns);
self.high_ns = Some(high.max(adj2));
@@ -464,4 +481,40 @@ mod tests {
"frame must stay in the new epoch (> frontier), got {out}"
);
}
/// A saturated frontier must not panic the muxer. An `mkv://` source's
/// tick→ns multiply saturates at `i64::MAX` (mkvstream's `parse_block`), so a
/// hostile TimestampScale/CLUSTER_TIMESTAMP puts `high_ns` AT `i64::MAX`.
/// Every subsequent PASSIVE frame then evaluated `high + BACKSTEP`, which
/// panicked ("attempt to add with overflow") out of the public
/// `Stream::write` path in any overflow-checked build.
#[test]
fn saturated_frontier_does_not_overflow_on_passive_frame() {
let mut tc = TimelineContinuity::new();
// Video establishes the frontier at the saturation point.
assert_eq!(adj_video(&mut tc, i64::MAX), i64::MAX);
assert_eq!(tc.high_ns, Some(i64::MAX));
// Passive frame: `high + BACKSTEP` overflowed here.
let out = adj_other(&mut tc, 0);
assert_eq!(out, 0, "a passive frame keeps its own mapping");
// And a passive frame AT the frontier: `high - BACKSTEP` is the other
// unchecked side of the straggler discriminator.
assert_eq!(adj_other(&mut tc, i64::MAX), i64::MAX);
}
/// The epoch-decision side of the same arithmetic: `adj < high - BACKSTEP`
/// and the `high - adj` bump both took untrusted ends. A frontier at
/// `i64::MIN`-adjacent values (a negative SimpleBlock-relative timestamp) and
/// a `i64::MAX` frontier are both reachable from container data.
#[test]
fn extreme_video_pts_does_not_overflow_the_epoch_bump() {
let mut tc = TimelineContinuity::new();
assert_eq!(adj_video(&mut tc, i64::MAX), i64::MAX);
// Hard backward jump to the negative extreme: `high - adj` overflowed.
let out = adj_video(&mut tc, i64::MIN);
// Saturated bump (`i64::MAX`) applied to `i64::MIN` → -1, and the
// frontier never regresses.
assert_eq!(out, -1);
assert_eq!(tc.high_ns, Some(i64::MAX));
}
}
+88 -16
View File
@@ -70,6 +70,15 @@ pub struct TsMuxer<W: Write> {
/// so a header-only `m2ts://` output can't be reported as success —
/// mirroring `MkvMuxer.frame_count`.
frame_count: u64,
/// Reusable Annex-B conversion buffer for NAL video, kept across frames so
/// the conversion does not allocate (and free) a whole-frame buffer per
/// coded picture — the same reason `MkvMuxer` keeps its `block_group_buf`.
/// A UHD frame is ~310 KB, i.e. an mmap + first-touch page faults + munmap
/// per frame, ~200k times per feature. Cleared (never shrunk) per use, so it
/// settles at the largest frame's size. Taken out of `self` while in use, so
/// the borrow of the converted bytes does not conflict with the `&mut self`
/// the writer needs.
annex_b: Vec<u8>,
}
impl<W: Write> TsMuxer<W> {
@@ -84,6 +93,7 @@ impl<W: Write> TsMuxer<W> {
video_codec: vec![Codec::Hevc; n],
base_pts_ns: None,
frame_count: 0,
annex_b: Vec::new(),
}
}
@@ -180,11 +190,20 @@ impl<W: Write> TsMuxer<W> {
// start-code ES, never length-prefixed) the bytes pass through
// unchanged, so borrow `data` directly rather than copying it; only
// NAL video needs an owned Annex-B conversion buffer.
let es_data: std::borrow::Cow<'_, [u8]> = if is_video && self.is_nal_video(track) {
// Size the buffer once for the whole frame. `Vec::new()` re-grew from
// zero capacity on every frame, reallocating repeatedly inside a single
// ~310 KB conversion. The slack covers any prepended parameter sets.
let mut annex_b = Vec::with_capacity(data.len() + 1024);
// Take the reusable conversion buffer out of `self` for the duration of
// this frame: that frees the `&mut self` the writer needs below while the
// converted bytes are still borrowed, and keeps the allocation across
// frames instead of making (and dropping) a whole-frame one per picture.
// It is put back at the end of the function, so a mid-frame error path
// costs only the buffer's capacity, never correctness.
let mut annex_b = std::mem::take(&mut self.annex_b);
let convert = is_video && self.is_nal_video(track);
if convert {
annex_b.clear();
// Size once for the whole frame; the slack covers any prepended
// parameter sets. `reserve` is a no-op once the buffer has settled at
// the largest frame's size.
annex_b.reserve(data.len() + 1024);
if keyframe && !self.params_written[track] {
if let Some(ref cp) = self.codec_privates[track] {
// avcC and hvcC are DIFFERENT box layouts; parsing one with
@@ -221,13 +240,12 @@ impl<W: Write> TsMuxer<W> {
// allocations and two full-frame copies. At ~200k frames averaging
// ~310 KB of ES on a UHD, that is ~124 GB of pointless memcpy.
append_length_prefixed_as_annex_b(&mut annex_b, data);
std::borrow::Cow::Owned(annex_b)
} else {
if is_video {
self.params_written[track] = true;
}
std::borrow::Cow::Borrowed(data)
};
} else if is_video {
self.params_written[track] = true;
}
// Borrowed from a LOCAL (the taken-out buffer), never from `self`, so the
// `&mut self` writes below are free of it.
let es_data: &[u8] = if convert { &annex_b } else { data };
let pts_90k = if pts_ns >= 0 {
(pts_ns as u64).saturating_mul(9) / 100_000
@@ -240,15 +258,32 @@ impl<W: Write> TsMuxer<W> {
// access units into multiple PES packets. Each emitted PES carries
// the same PTS and starts on its own PUSI packet (only the keyframe
// RAI rides the first packet of the first PES).
if is_video || es_data.len() <= MAX_BD_PES_PAYLOAD {
self.write_pes_chain(track, pid, pts_90k, is_video, keyframe, &es_data)?;
//
// The write result is held rather than `?`-propagated so the conversion
// buffer goes back into `self` on every path.
let res = if is_video || es_data.len() <= MAX_BD_PES_PAYLOAD {
self.write_pes_chain(track, pid, pts_90k, is_video, keyframe, es_data)
} else {
let mut first_pes = true;
let mut res = Ok(());
for chunk in es_data.chunks(MAX_BD_PES_PAYLOAD) {
self.write_pes_chain(track, pid, pts_90k, is_video, keyframe && first_pes, chunk)?;
res = self.write_pes_chain(
track,
pid,
pts_90k,
is_video,
keyframe && first_pes,
chunk,
);
if res.is_err() {
break;
}
first_pes = false;
}
}
res
};
self.annex_b = annex_b;
res?;
// A frame that survived the pre-keyframe drop guard above and reached
// the writer counts as emitted. `finish()` checks this so a zero-frame
// mux fails loudly instead of producing a header-only "success".
@@ -1133,4 +1168,41 @@ mod tests {
assert_eq!(got.len(), big.len(), "no bytes lost in the PES split");
assert_eq!(got, big, "split audio reassembles byte-for-byte");
}
/// MEASURED: the Annex-B conversion buffer must be REUSED across video
/// frames, not allocated per frame. Both the allocation's address and its
/// capacity are unchanged after the second and third same-sized frames — if
/// the conversion allocated a fresh `Vec` per frame (the old
/// `Vec::with_capacity(data.len() + 1024)`), the buffer left on the muxer
/// would be empty with zero capacity, and each frame would pay an
/// allocate/first-touch/free cycle over the whole ~310 KB frame.
#[test]
fn annex_b_conversion_buffer_is_reused_across_frames() {
let mut sink: Vec<u8> = Vec::new();
let mut mux = TsMuxer::new(&mut sink, &[VIDEO_PID]);
let idr = fake_hevc_nal(19, 300_000);
mux.write_frame(0, 0, true, &idr).unwrap();
let cap = mux.annex_b.capacity();
let ptr = mux.annex_b.as_ptr();
assert!(
cap >= idr.len(),
"buffer survives the frame with the frame's capacity, got {cap}"
);
for i in 1..4 {
let p = fake_hevc_nal(1, 300_000);
mux.write_frame(0, i * 41_000_000, false, &p).unwrap();
assert_eq!(
mux.annex_b.as_ptr(),
ptr,
"frame {i}: conversion buffer must be the same allocation"
);
assert_eq!(
mux.annex_b.capacity(),
cap,
"frame {i}: no re-grow once the buffer has settled"
);
}
mux.finish().unwrap();
}
}