au assembly: a PES opened inside a picture times the picture commencing in it

A PES PTS belongs to the first access unit commencing in that PES
(ISO/IEC 13818-1 2.4.3.7). The assembler gave every mark in an AU's byte
range to that AU, so in a program stream that packs pictures back to back
(ffmpeg's MPEG-PS) each picture took its successor's PTS. MPEG-2 GOPs whose
first displayed picture is a leading B then re-locked their origin a frame
off, read as a join, and dropped that open GOP's leading Bs: the synthetic
A1 fixture lost 3 of 180 frames.

A mark opened inside an AU that runs past it now times the next AU; one
running into the first AU after pre-sync bytes times that AU. Source
provenance is unchanged. DVD and HD DVD titles, which open a PES at each
timed picture, are byte-identical.
This commit is contained in:
Matthew Jackson
2026-10-02 15:27:13 -07:00
parent 04f28447bc
commit a8b6a2c1e0
3 changed files with 90 additions and 6 deletions
+1
View File
@@ -98,6 +98,7 @@
### Fixed
- A program-stream PES that opens inside one picture times the next picture commencing in it (ISO/IEC 13818-1 §2.4.3.7), not the one it opens in. An MPEG-PS packing pictures back to back (ffmpeg's) gave each picture its successor's PTS, so MPEG-2 GOPs re-locked a frame off and an open GOP's leading B-pictures were dropped as after a join. DVD and HD DVD titles, which open a PES at each timed picture, are unchanged.
- `m2ts://` output of a multi-clip title rides the clip-join timeline like the MKV, MP4 and MPG sinks: the PTS no longer restarts at each join, and reordered video keeps its DTS after the first join instead of falling back to PTS only.
- An image title whose extent ends off the 3-sector AACS unit grid (an HD DVD `.EVO`) no longer fails E9030 at that extent's end: the sub-unit tail is read, passed when clear and refused (E7013) when it is part of an encrypted unit.
- Every AACS format takes one encryption decision in `KeyRing::acquire`: content its own detector reads in the clear needs no key and asks no source, whatever key files the disc declares (an HD DVD copy in the clear declaring several keys, and an FMTS disc whose forensic segments are also clear, no longer ask for or refuse on keys); encrypted content is keyed by the format's rules and refused (E7022 / E7032) when no source has the key.
+50 -6
View File
@@ -129,10 +129,13 @@ enum Mode {
}
/// A timing/source mark taken at the absolute stream offset of a fragment that
/// carried it, so it survives `buf.drain(..)` and can be attributed to the AU
/// whose byte range contains it.
/// carried it, so it survives `buf.drain(..)`. Its source goes to the AU whose
/// byte range contains `off`; its PTS/DTS to the first AU that commences within
/// `[off, end)` (ISO/IEC 13818-1 §2.4.3.7), else to the AU containing `off`.
struct Mark {
off: u64,
/// Absolute offset one past the fragment's last byte.
end: u64,
pts: Option<i64>,
dts: Option<i64>,
source: Option<SourcePos>,
@@ -276,6 +279,7 @@ impl AuAssembler {
if pts.is_some() || dts.is_some() || source.is_some() {
self.marks.push_back(Mark {
off,
end: off + data.len() as u64,
pts,
dts,
source,
@@ -355,10 +359,15 @@ impl AuAssembler {
// PTS, so reading only the front mark would drop a field.
let (mut pts, mut dts, mut source) = (None, None, None);
while self.marks.front().is_some_and(|m| m.off < end_abs) {
let m = self.marks.front_mut().unwrap();
source = source.or(m.source.take());
// A fragment opened inside this AU that runs past it times the next AU.
if m.off > self.base && m.end > end_abs {
break;
}
let m = self.marks.pop_front().unwrap();
pts = pts.or(m.pts);
dts = dts.or(m.dts);
source = source.or(m.source);
}
// A backstop discard is a gap in its own right, independent of any
// upstream signal: bytes were thrown away, so this AU does not
@@ -489,10 +498,10 @@ impl AuAssembler {
None
}
// Retire every mark before `off`: the STREAM-START case, where bytes ahead of the first AU
// boundary predate sync and have no prior AU to be discontinuous from.
// Retire every mark ending at or before `off`: the STREAM-START case, where bytes ahead of
// the first AU boundary predate sync. A fragment running past `off` still times that AU.
fn drop_marks_before(&mut self, off: u64) {
while self.marks.front().is_some_and(|m| m.off < off) {
while self.marks.front().is_some_and(|m| m.end <= off) {
self.marks.pop_front();
}
while self.disc_marks.front().is_some_and(|&o| o < off) {
@@ -792,6 +801,41 @@ mod tests {
assert_eq!(tail[0].pts, Some(9376));
}
#[test]
fn a_pts_fragment_opened_inside_a_picture_times_the_picture_commencing_in_it() {
// Pictures packed back to back: each PES opens 5 bytes into the previous
// picture and its PTS is the next picture's. Read as the previous one's, every
// later picture took its successor's PTS.
let pics: Vec<Vec<u8>> = (0..3u8).map(|i| bdu(MP2_PICTURE, 0x10 + i, 20)).collect();
let stream = pics.concat();
let mut a = AuAssembler::mpeg2();
let mut out = a.push(&stream[..5], Some(100), None, None, false);
out.extend(a.push(&stream[5..29], Some(200), None, None, false));
out.extend(a.push(&stream[29..53], Some(300), None, None, false));
out.extend(a.push(&stream[53..], None, None, None, false));
out.extend(a.flush());
let pts: Vec<_> = out.iter().map(|au| au.pts).collect();
assert_eq!(pts, [Some(100), Some(200), Some(300)]);
assert_eq!(
out.iter().map(|au| au.data.clone()).collect::<Vec<_>>(),
pics
);
}
#[test]
fn a_pre_sync_fragment_times_the_first_access_unit_commencing_in_it() {
let mut a = AuAssembler::mpeg2();
let mut frag = vec![0xFF; 6];
frag.extend(bdu(MP2_PICTURE, 0x11, 10));
let mut out = a.push(&frag, Some(700), None, None, false);
out.extend(a.push(&bdu(MP2_PICTURE, 0x22, 10), Some(800), None, None, false));
out.extend(a.flush());
assert_eq!(
out.iter().map(|au| au.pts).collect::<Vec<_>>(),
[Some(700), Some(800)]
);
}
#[test]
fn mpeg2_slice_codes_are_not_au_boundaries() {
// Slice start codes (0x01..=0xAF) inside a picture must not split the AU.
+39
View File
@@ -1027,6 +1027,45 @@ mod tests {
assert_eq!(pts_ms(&f)[6..], [10_000, 10_120, 10_040, 10_080]);
}
#[test]
fn pes_opened_mid_picture_is_not_a_join() {
// A muxer packing pictures back to back (ffmpeg's MPEG-PS) opens each PES
// inside the previous picture, timed for the picture commencing in it. Read as
// the previous picture's PTS, the open GOP's origin moved a frame: a false join.
let mut pics = vec![
pic_pes(seq_gop(true, false), 1, 0, None),
pic_pes(Vec::new(), 2, 3, None),
pic_pes(Vec::new(), 3, 1, None),
pic_pes(Vec::new(), 3, 2, None),
];
pics.extend(gop_ibbp(gop_flags(false, false), None));
// Display slot of each picture, in decode order.
let slots: Vec<i64> = [0, 3, 1, 2, 6, 4, 5, 9, 7, 8]
.iter()
.map(|&s| 90_000 + s * 3_600)
.collect();
let starts: Vec<usize> = pics
.iter()
.scan(0, |at, p| Some(std::mem::replace(at, *at + p.data.len())))
.collect();
let es: Vec<u8> = pics.into_iter().flat_map(|p| p.data).collect();
// PES j opens 5 bytes into picture j - 1, so picture j commences inside it.
let mut cuts: Vec<usize> = std::iter::once(0)
.chain(starts.iter().map(|s| s + 5))
.collect();
cuts.push(es.len());
let pes = cuts
.windows(2)
.enumerate()
.map(|(j, w)| make_pes(es[w[0]..w[1]].to_vec(), slots.get(j).copied()));
let f = run(pes);
assert_eq!(
pts_ms(&f),
slots.iter().map(|s| s / 90).collect::<Vec<_>>(),
"every picture kept, each in its display slot"
);
}
#[test]
fn a_dropped_picture_passes_its_discontinuity_to_the_next_emitted_frame() {
let mut pes = gop_ibbp(seq_gop(false, false), Some(90_000));