Compare commits

...
15 Commits
Author SHA1 Message Date
Matthew Jackson eaa7ddc683 v1.4.1: bump version (freemkv-unlock git-pinned for the tag) 2026-07-14 14:45:30 -07:00
Matthew Jackson 723c7edf45 Fix mux concealing decryptable video over a single defective packet
AACS content decryption rejected a whole 6144-byte aligned unit unless
EVERY content packet was conformant MPEG-TS. One authored-bad packet (a
pressing/encoding defect or an AACS 2.1 forensic-variant frame) made the
mux conceal the entire unit as NULL TS — destroying up to 31/32 good
packets and tallying them as loss, surfacing as false "corruption" on
otherwise-clean discs (observed across two UHD titles).

decrypt_unit now asks only "did a key OPEN this unit?" — a padding-aware
>=75% supermajority of content packets restoring their 0x47 sync, a gate
no wrong key can reach (uniform-AES noise floor) yet one that tolerates a
minority of authored-bad packets. Opened units pass through VERBATIM; a
non-conforming packet is left for the demuxer to drop on sync-loss and
resync past. TS-sync conformance is a muxer concern, never a decryption
verdict. The post-read verify/sweep gate now shares the same primitive so
it can never disagree with the mux decrypt.

Also unify the MVC (Blu-ray 3D) track signals: the mvcC CodecPrivate
extension, the BlockAdditionMapping, and each per-frame BlockAdditional
all derive from one MVCDecoderConfigurationRecord built once per track, so
a malformed dependent-view parameter set can no longer orphan a BlockAddID.
2026-07-14 14:43:06 -07:00
Matthew Jackson d9881c57c0 restore freemkv-unlock path dep for local dev (post-v1.4.0) 2026-07-13 19:28:55 -07:00
Matthew Jackson 61b4486ae9 v1.4.0: bump version (freemkv-unlock git-pinned for the tag) 2026-07-13 19:28:52 -07:00
Matthew Jackson 72d39544ac Changelog: 1.4.0 (Blu-ray 3D / MVC) 2026-07-13 12:47:03 -07:00
Matthew Jackson 1e394eaeb8 3D MVC mux: audit round 2 (converged)
Second audit round converged (severity collapsed 6 HIGH -> 1; the one
HIGH was a bounded 32-element scan, not a defect; the sole spec MEDIUM
was the same false-positive re-raised — 0xBF matches ISO/IEC 14496-15
§7.6.2 verbatim). One genuine robustness fix plus coverage:

- extract_mvc_params: skip a zero-length NAL instead of abandoning the
  scan, so a stray length prefix before the subset SPS/PPS no longer
  silently drops 3D signalling. Test proves params after a zero-length
  NAL are still found.
- Tests: parser_for_mvc_dependent routes H.264 to a passthrough parser;
  passthrough with an IDR does not re-assert param sets (the keyframe &&
  !mvc branch).
- Document the per-playlist (not per-clip) is_3d latching as a known
  limitation (real main-feature playlists are uniformly 3D).
2026-07-13 11:39:07 -07:00
Matthew Jackson 4d1aa6bbf7 Harden 3D MVC mux: robustness + tests (audit round 1)
Triage of a 10-lens code audit of the 3D branch. Fixes for real defects;
rejected three spec false-positives that matched the ISO/IEC 14496-15
§7.6.2 record verbatim.

Robustness / correctness:
- Never panic when a title's only video is the MVC dependent view: the
  base is now the first NON-dependent video, so a dependent-only title
  sets up no merge (muxed as an ordinary track) instead of hitting an
  `expect` on the skipped track slot.
- Drop a per-frame BlockAdditional (BlockAddID=2) when the track declared
  no mvcC mapping (dependent params not captured before the header) — a
  plain block keeps the file conforming instead of an orphaned add.
- A non-keyframe MVC base frame always carries a ReferenceBlock (fall back
  to a 0 offset in the pre-first-keyframe corner) so it is never mistaken
  for a seek point.
- Reference the last keyframe on the PRIMARY video track only, so a
  secondary video track's keyframe can't become a cross-track reference.
- dep_by_pts overflow: bound BEFORE inserting so the just-arrived
  dependent survives the drift-clear; count a displaced duplicate-PTS
  dependent as an orphan instead of losing it silently.

API / docs:
- Fold write_frame_with_additional into write_frame(..., Option<&[u8]>)
  per the "no foo_with_X" convention.
- Fix mvc_params doc (StereoMode is intentionally not emitted); remove a
  stale PAT/PMT comment describing an approach that was never taken.

Tests: MVCDecoderConfigurationRecord over-length guards; write_int minimal
two's-complement widths; BlockGroup/BlockAdditions/BlockAdditional +
ReferenceBlock emission; additional dropped without a mapping; h264 MVC
passthrough keeps param sets in-band; extract_mvc_params no-panic on
truncated/empty input; pairing window + dep-overflow edges; no-panic on a
dependent-only title.
2026-07-13 11:24:23 -07:00
Matthew Jackson 9ce03b4d1d Mux Blu-ray 3D (MVC) as a single MVC video track
Fold the MVC dependent (right-eye) view into the base H.264 track as a
per-frame BlockAdditional under an mvcC BlockAdditionMapping, so a 3D
title produces one MVC video track instead of two independent H.264
tracks.

- h264: MVC-passthrough parser mode keeps the dependent view's subset
  SPS/PPS in-band, so each emitted frame is a self-contained dependent
  access unit for a BlockAdditional
- resolve: route the dependent stream through the passthrough parser
- mkvstream: detect the dependent view, pair it to the base frame by
  PTS (bounded FIFO), attach it as a BlockAdditional (BlockAddID=2),
  and skip building its own track; build the mvcC
  MVCDecoderConfigurationRecord from the captured subset SPS/PPS and set
  it on the base track at activation
- mkv: emit the mvcC BlockAdditionMapping and BlockGroup/BlockAdditions,
  with a ReferenceBlock on non-keyframe base frames
- ebml: add BlockAdditions/BlockMore/BlockAdditional/BlockAddID/
  BlockAddIDValue/ReferenceBlock elements and a signed-int writer

Verified against a Blu-ray 3D ISO: ffprobe shows a single MVC track,
the mvcC mapping is present, ~144k BlockAdditionals carry the dependent
view (8.7 GB), and the base view decodes cleanly with no regression.
MVCDecoderConfigurationRecord follows ISO/IEC 14496-15 7.6.2; StereoMode
is intentionally omitted (no enum value describes MVC-in-BlockAdditional;
the mvcC mapping is the primary 3D signal per RFC 9559).
2026-07-13 11:00:04 -07:00
Matthew Jackson f099a4a31a feat(3d): read the SSIF and enumerate the MVC dependent view
Blu-ray 3D as a mux-path variant:
- Detect 3D (a clip has STREAM/SSIF/<clip>.ssif — note the SSIF/ subdir, which
  the old .ssif fallback path got wrong) and use the SSIF extents for the mux:
  one transport stream carrying both the base (left) and MVC dependent (right)
  eyes on distinct PIDs, so muxing it captures the full 3D.
- Add the dependent-view video stream. The base STN table lists only the left
  eye; the on-disc PAT/PMT are AACS-encrypted (unreadable pre-key) and the base
  STN omits the dependent view (it lives in the MPLS STN_table_SS), so use the
  BD-3D PID convention: dependent = base video PID + 1 (0x1011 -> 0x1012),
  coding 0x20 -> H.264. The demux routes its packets from the SSIF.

info now reports two video streams for a 3D title; the mux writes both eyes.
2026-07-12 23:41:47 -07:00
Matthew Jackson e606b5e3a2 feat(3d): enumerate MVC dependent view (stream_type 0x20 -> H.264 video)
from_coding_type maps 0x20 (MVC dependent view) to Codec::H264, so the existing
PAT/PMT scan surfaces the SSIF right-eye substream as a second video stream on
its own PID instead of dropping it as Unknown. No new parser: the dependent eye
rides the same demux path as any other TS video stream. Removes the throwaway
3D-structure probes (findings captured in prior commit messages).
2026-07-12 22:16:13 -07:00
Matthew Jackson de809189ec feat(3d): add MVC dependent-view coding type (0x20)
First step of Blu-ray 3D as a mux-path format variant: name stream_type 0x20
(H.264/MVC dependent view, the SSIF right-eye substream). Recognition in the
scan/demux + dependent-stream enumeration follow.
2026-07-12 22:11:01 -07:00
Matthew Jackson 253b0924e3 wip(3d): prove SSIF de-interleave = ssif−base, dependent decrypts on same UK
Empirical (300, clip 00042): base .m2ts (1567 extents, 25.85 GB) is entirely
inside the SSIF LBA span; dependent = SSIF − base = 1567 ranges, 9.59 GB
(exact vs 35.43−25.85). 200/200 sampled dependent-view units decrypt under the
base UK. => de-interleave needs no SSIF parsing (complement of the base extents
we already resolve), and the dependent eye needs no extra key.
2026-07-12 20:40:58 -07:00
Matthew Jackson 98a7892c34 wip(3d): read-only 3D-structure probe (SSIF/m2ts + MPLS ExtensionData)
Dev tool for the 3D build: uses the library UDF reader (7z cannot parse these
ISOs) to dump base .m2ts vs SSIF sizes and the MPLS ExtensionData entries
(STN_table_SS + SS sub-path). Not product code.
2026-07-12 20:38:37 -07:00
Matthew Jackson b82d21756a fix(udf): follow allocation-descriptor continuation past the AED header
read_icb_extents parsed a continuation block's allocation descriptors from
offset 0, but a continuation block begins with a 24-byte Allocation Extent
Descriptor (ECMA-167 4/14.5): 16-byte descriptor tag + prev-loc(4) +
length_of_allocation_descriptors(4); the real ADs start at offset 24. Reading
from 0 mis-parsed the AED tag as a descriptor -> one garbage extent, then an
unknown-type break, silently truncating any file whose extents spill into a
continuation block.

Few-extent files store their ADs inline and never hit this path, so it stayed
hidden; a heavily fragmented file (~1600 fragments) truncated to 113 extents +
a bogus non-unit-aligned fragment that then aborted the mux
(ExtentNotUnitAligned). Skip the 24-byte AED header, read l_ad bytes of
descriptors from offset 24. Adds a regression test with a non-zero AED tag.
2026-07-12 20:37:55 -07:00
Matthew Jackson 02c584a3d9 restore freemkv-unlock path dep for local dev (post-v1.3.2) 2026-07-10 14:26:11 -07:00
14 changed files with 1780 additions and 149 deletions
+49
View File
@@ -1,5 +1,54 @@
# Changelog
## [1.4.1] — 2026-07-14
### Fixed
- **Mux no longer discards good video over a single defective packet.** AACS
content decryption judged a 6144-byte aligned unit "undecryptable" unless
**every** content packet was conformant MPEG-TS. A single authored-bad packet
— a pressing/encoding defect, or an AACS 2.1 forensic-variant frame — made the
mux conceal the **whole** unit as NULL TS, destroying up to 31 of 32 good
packets and tallying them as loss. On discs carrying such packets this
surfaced as false "corruption" over large runs of otherwise-perfect video
(observed across two UHD titles: ~466 MB concealed, every unit decryptable).
The decrypt path now asks only *"did a key OPEN this unit?"* — a padding-aware
**≥75% supermajority** of content packets restoring their `0x47` sync, a gate
no wrong key can reach (uniform-AES noise floor ≈ 256⁻ⁿ) yet one that tolerates
a minority of authored-bad packets. Opened units pass through **verbatim**; a
non-conforming packet is left for the demuxer to drop on sync-loss and resync
past — TS-sync conformance is a muxer concern, never a decryption verdict. The
read/decrypt path no longer rewrites content bytes. The post-read verify/sweep
gate now shares the exact same primitive (`decrypt_unit` for TS), so verify can
never disagree with the mux decrypt and never false-marks a defect unit as a
bad read.
- **MVC (Blu-ray 3D) track signals unified and hardened.** The `mvcC`
`CodecPrivate` extension, the `BlockAdditionMapping`, and each frame's
`BlockAdditional` now all derive from a single `MVCDecoderConfigurationRecord`
built once per track, so they can no longer diverge. A track is flagged 3D
only when that record actually builds — a malformed dependent-view parameter
set no longer emits a mapping with no matching record (previously the flag was
taken from `mvc_params.is_some()`, which could orphan a `BlockAddID`). The base
track's `CodecPrivate` now carries the `mvcC` extension block
(`avcC ‖ u32be(size) ‖ "mvcC" ‖ record`, Matroska-spec size = block − 4) so
players and mediainfo detect MVC at the track level.
## [1.4.0] — 2026-07-13
### Added
- **Blu-ray 3D (MVC) support.** A 3D disc now rips to an MKV that preserves
**both eyes** as a single MVC video track — the AVC base (left) view in each
Block, and the MVC dependent (right-eye) view as a per-frame `BlockAdditional`
under an `mvcC` `BlockAdditionMapping` (`MVCDecoderConfigurationRecord` per
ISO/IEC 14496-15 §7.6.2), paired to the base by PTS. Remux only — no
transcode, no side-by-side conversion. The Blu-ray scan reads the interleaved
`STREAM/SSIF/<clip>.ssif`, enumerates the dependent view (stream_type `0x20`)
by the BD-3D PID convention, and parses it in a parameter-set-passthrough mode
so every dependent frame is a self-contained access unit. Verified on
*300: Rise of an Empire*: one MVC track, ~8.7 GB dependent payload carried in
per-frame BlockAdditionals, base view byte-identical to the 2D rip.
## [1.3.2] — 2026-07-10
### Added
+2 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "libfreemkv"
version = "1.3.2"
version = "1.4.1"
edition = "2024"
rust-version = "1.86"
license = "MIT"
@@ -28,7 +28,7 @@ cbc = "0.1"
# Interim path dep for local cross-repo dev; the release script re-pins this to
# `{ git = ".../freemkv-unlock", tag = "vX.Y.Z" }` before tagging libfreemkv (so
# the released tag resolves freemkv-unlock from git, not a sibling path).
freemkv-unlock = { git = "https://github.com/freemkv/freemkv-unlock", tag = "v1.3.2" }
freemkv-unlock = { git = "https://github.com/freemkv/freemkv-unlock", tag = "v1.4.1" }
num-bigint = "0.4"
num-traits = "0.2"
num-integer = "0.1"
+249 -41
View File
@@ -113,51 +113,68 @@ pub fn aacs_unit_needs_decrypt(unit: &[u8]) -> bool {
aacs_unit_encrypted(unit) && ts_sync_destroyed(unit)
}
/// PADDING-AWARE "is this aligned unit STILL genuine ciphertext?" — the conceal-
/// path twin of [`decrypt_unit`]'s acceptance criterion, run on the POST-decrypt
/// bytes.
/// The one canonical "did a key OPEN this unit?" signal — a padding-aware,
/// defect-TOLERANT structural check on the POST-decrypt bytes.
///
/// [`aacs_unit_needs_decrypt`] cannot answer this: it composes CPI with the
/// MAJORITY-VOTE [`ts_sync_destroyed`] (`ts_sync_count <= total/2`). A
/// successfully padding-aware-decrypted content-fragment TAIL unit (e.g. 11 of 32
/// packets are real content, the other 21 source-zero padding) carries only 11 TS
/// syncs after decrypt, so the majority vote calls it "destroyed" and
/// `aacs_unit_needs_decrypt` returns true — even though the unit decrypted
/// PERFECTLY. Concealing on that predicate overwrites the good decrypted tail with
/// NULL-TS, silently discarding correct video (the bug this fixes; the 1.2.0
/// fragment-tail fix in [`decrypt_unit`] must not be undone by the conceal loop).
/// ARCHITECTURE (why this is the only TS-sync test the read/decrypt path may
/// use): TS-sync presence is a *muxer* concern, not a decryption verdict. The
/// read/decrypt path is not allowed to reject or conceal a unit just because a
/// packet isn't conformant MPEG-TS — a non-conforming packet inside an otherwise
/// perfectly-decrypted unit is an authoring/pressing defect (or an AACS 2.1
/// forensic-variant frame), which the demuxer drops on sync-loss and resyncs
/// past. The ONLY thing the decrypt path legitimately needs from TS structure is
/// KEY SELECTION: "did this candidate key turn ciphertext back into MPEG-TS at
/// all?" — used to pick among held keys (multi-CPS-unit discs) and to detect a
/// genuinely-missing key. That is a coarse, all-or-nothing question, and this is
/// its answer.
///
/// The correct, padding-aware notion of "still ciphertext", checkable on the
/// post-decrypt bytes, uses the SAME discriminator [`decrypt_unit`] uses:
/// * A genuinely-FAILED unit was restored to on-disc ciphertext by
/// `decrypt_unit_try_keys`/`decrypt_buf` → its packets are scrambled: a
/// non-padding (non-zero payload) packet LACKS the `0x47` sync at offset 4.
/// * A SUCCESSFULLY-decrypted unit (full OR padding-tail) → every non-zero
/// (content) packet carries `0x47`; padding packets are all-zero.
/// Verdict: the key opened the unit iff a SUPERMAJORITY (>= 75%) of the
/// non-padding content packets carry their `0x47` TS sync. Rationale:
/// * WRONG key → AES output is uniform random → each content packet carries
/// `0x47` at offset 4 with probability 1/256 → ~0 synced. Reaching 75% by
/// chance is cryptographically impossible (e.g. 17-of-22 ≈ 256^-17). So a
/// wrong key can NEVER pass — no silent-corruption hole.
/// * RIGHT key → every real content packet decrypts to clear TS. A handful of
/// authored-bad packets (pressing defects / variant frames) legitimately
/// lack `0x47`, but they are a small minority and MUST NOT reject the unit —
/// the decrypted bytes (defects and all) pass through verbatim; the muxer
/// handles the bad packets. This is the whole point: correctness of a
/// *packet* is not a decryption verdict.
///
/// So this is true iff `aacs_unit_encrypted` AND at least one 192-byte packet
/// whose 188-byte payload (`[off+4..off+192]`) is NOT all-zero is missing its
/// `0x47` sync at `off+4`. A full content unit (no zero-payload packets) reduces
/// to the strict all-32 check, so the common cases are unchanged: a fully-clear /
/// fully-decrypted unit is never flagged; a fully-ciphertext unit always is.
pub fn aacs_unit_still_ciphertext(unit: &[u8]) -> bool {
if !aacs_unit_encrypted(unit) {
return false;
}
/// Padding-aware: a 192-byte packet whose 188-byte payload is all-zero is source
/// padding (or a NULL packet) and is excluded from the count, so a legitimate
/// content-fragment TAIL (a few real packets + source-zero padding) is judged on
/// its real packets only. A full content unit reduces to "nearly all 32 synced".
pub fn unit_content_decrypted(unit: &[u8]) -> bool {
const PKT: usize = BD_SOURCE_PACKET_BYTES; // 192
let limit = ALIGNED_UNIT_LEN.min(unit.len());
let mut content = 0usize;
let mut synced = 0usize;
let mut off = 0;
while off + PKT <= limit {
// A packet whose 188-byte payload is all-zero is padding (source zeros) —
// excluded from the verdict, exactly as `decrypt_unit` excludes it. Any
// other (content) packet that lacks its TS sync is un-restored ciphertext.
// All-zero payload → padding / NULL packet → excluded from the verdict.
let payload = &unit[off + 4..off + PKT];
if !payload.iter().all(|&b| b == 0) && unit[off + 4] != TS_SYNC {
return true;
if !payload.iter().all(|&b| b == 0) {
content += 1;
if unit[off + 4] == TS_SYNC {
synced += 1;
}
}
off += PKT;
}
false
// No content packets (all padding) → trivially "opened". Otherwise require a
// >=75% supermajority of content packets restored — the wrong-key-proof gate.
content == 0 || synced * 4 >= content * 3
}
/// "Is this aligned unit STILL genuine ciphertext (no held key opened it)?" — the
/// conceal-path twin of [`unit_content_decrypted`], run on the POST-decrypt
/// bytes. True iff the unit is flagged encrypted (CPI set) AND no key opened it
/// (below the supermajority-sync gate). A unit the right key opened — even one
/// carrying a few defective packets — is NOT ciphertext and is never concealed;
/// its bytes belong to the muxer.
pub fn aacs_unit_still_ciphertext(unit: &[u8]) -> bool {
aacs_unit_encrypted(unit) && !unit_content_decrypted(unit)
}
/// Overwrite an aligned unit (6144 bytes) IN PLACE with valid NULL MPEG-TS
@@ -349,20 +366,25 @@ pub fn decrypt_unit(unit: &mut [u8], unit_key: &[u8; 16]) -> bool {
// Final 6128 bytes of the aligned unit under the Block Key; first 16 = clear seed. [BD] §3.10.1.
aes_cbc_decrypt(&decrypt_key, &mut unit[16..ALIGNED_UNIT_LEN]);
// Verify content packets; zero out padding packets (their decrypted bytes are
// garbage from AES-decrypting zeros, but the source was zero so a clean zero
// fill is lossless and gives the demux a tidy gap instead of garbage).
// Restore source-zero padding packets to zero (their decrypted bytes are
// garbage from AES-decrypting zeros, but the source WAS zero, so writing the
// true source back is faithful — not concealment — and gives the demux a tidy
// gap instead of AES noise). Content packets are left EXACTLY as decrypted:
// the read/decrypt path never rewrites content, so an authored-bad packet
// (no `0x47`) passes through verbatim for the muxer to drop.
for (p, &is_pad) in pad.iter().enumerate().take(npkt) {
let off = p * PKT;
if is_pad {
let off = p * PKT;
for b in unit[off..off + PKT].iter_mut() {
*b = 0;
}
} else if unit[off + 4] != TS_SYNC {
return false; // a real content packet failed → genuinely undecryptable
}
}
true
// KEY-SELECTION verdict only: did this key OPEN the unit (restore the TS
// structure of a supermajority of content packets)? A wrong key can't; the
// right key can even when a few content packets are authored-bad. This is NOT
// a per-packet conformance gate — that belongs to the muxer.
unit_content_decrypted(unit)
}
/// Decrypt an AACS aligned unit in place, accepting the key only when `accept`
@@ -882,6 +904,192 @@ mod tests {
);
}
// ── Defect-tolerant "did a key OPEN this unit?" verdict ─────────────────
//
// The Bourne-UHD bug: a commercial disc carries the odd authored-bad TS
// packet (a pressing/encoding defect, or an AACS 2.1 forensic-variant frame)
// — one non-conforming packet inside an otherwise perfectly-decrypted 6144
// unit. The OLD strict per-packet acceptance rejected the WHOLE unit over
// that single packet, so the mux concealed ~all of it as NULL and 21/22 good
// video packets were destroyed and tallied as loss ("false corruption").
// The read/decrypt path must instead ACCEPT the unit (the key opened it) and
// pass the defective packet through VERBATIM for the muxer to drop. TS-sync
// conformance is a muxer concern, never a decryption verdict.
/// Build a unit that decrypts to 32 content packets, with `defect_pkts`
/// marked authored-bad (a non-`0x47` at the sync position + non-zero payload
/// so they count as genuine content, not padding), encrypted under `key`.
fn unit_with_defects(key: &[u8; 16], defect_pkts: &[usize]) -> Vec<u8> {
let mut unit = clear_unit();
for &p in defect_pkts {
let off = p * BD_SOURCE_PACKET_BYTES;
unit[off + 4] = 0x80; // NOT a TS sync
unit[off + 5] = 0xAB; // non-zero payload => real content, not padding
}
aacs_encrypt_unit(&mut unit, key);
unit
}
/// A POST-DECRYPT-looking unit: `content` non-padding packets, `synced` of
/// them carrying `0x47`; the remaining packets are source-zero padding. CPI
/// (encrypted flag) set iff `cpi`. Feeds the key-independent predicates.
fn decrypted_shape(content: usize, synced: usize, cpi: bool) -> Vec<u8> {
let mut u = vec![0u8; ALIGNED_UNIT_LEN];
for p in 0..content {
let off = p * BD_SOURCE_PACKET_BYTES;
u[off + 5] = 0xAB; // non-zero payload => counted as content
u[off + 4] = if p < synced { TS_SYNC } else { 0x80 };
}
if cpi {
u[0] |= 0xC0;
}
u
}
#[test]
fn single_authored_bad_packet_still_decrypts_and_passes_verbatim() {
// 1 defective content packet in an otherwise-perfect unit (the real case).
let key = [0x5Au8; 16];
let mut unit = unit_with_defects(&key, &[17]);
assert!(
decrypt_unit(&mut unit, &key),
"31/32 content packets synced -> the key OPENED the unit"
);
let off = 17 * BD_SOURCE_PACKET_BYTES;
assert_eq!(
unit[off + 4],
0x80,
"defect packet's bytes pass through VERBATIM (no null-fill, no zeroing)"
);
assert_eq!(unit[off + 5], 0xAB, "defect payload untouched");
assert!(
!aacs_unit_still_ciphertext(&unit),
"an opened unit is NOT ciphertext -> the mux never conceals it"
);
for p in 0..32 {
if p == 17 {
continue;
}
assert_eq!(
unit[p * BD_SOURCE_PACKET_BYTES + 4],
TS_SYNC,
"every other packet restored its sync (pkt {p})"
);
}
}
#[test]
fn several_defect_packets_within_tolerance_still_decrypt() {
// Up to 25% authored-bad content packets are tolerated (opened + passed
// through); the muxer drops them.
let key = [0x33u8; 16];
let mut unit = unit_with_defects(&key, &[3, 9, 17, 24, 30]); // 5/32 ≈ 16%
assert!(
decrypt_unit(&mut unit, &key),
"27/32 synced (>=75%) -> opened"
);
}
#[test]
fn wrong_key_never_opens_a_unit() {
// A wrong key restores ~0 syncs -> far below the supermajority gate.
let key = [0x5Au8; 16];
let mut unit = clear_unit();
aacs_encrypt_unit(&mut unit, &key);
assert!(
!decrypt_unit(&mut unit, &[0x22u8; 16]),
"wrong key cannot open the unit"
);
}
#[test]
fn threshold_accepts_at_75pct_and_rejects_just_below() {
// 24/32 = exactly 75% opens; 23/32 does not.
assert!(unit_content_decrypted(&decrypted_shape(32, 24, false)));
assert!(!unit_content_decrypted(&decrypted_shape(32, 23, false)));
}
#[test]
fn still_ciphertext_tracks_the_open_verdict() {
// Fully clean -> opened, not ciphertext.
assert!(!aacs_unit_still_ciphertext(&decrypted_shape(32, 32, true)));
// One defect -> still opened, still not ciphertext.
assert!(!aacs_unit_still_ciphertext(&decrypted_shape(32, 31, true)));
// Wrong-key noise floor -> not opened -> ciphertext (concealable).
assert!(aacs_unit_still_ciphertext(&decrypted_shape(32, 3, true)));
// CPI-clear bytes are never "ciphertext" regardless of sync count.
assert!(!aacs_unit_still_ciphertext(&decrypted_shape(32, 0, false)));
}
#[test]
fn all_padding_unit_is_trivially_opened() {
// CPI set but every packet is source-zero padding: nothing to decrypt.
assert!(unit_content_decrypted(&decrypted_shape(0, 0, true)));
assert!(!aacs_unit_still_ciphertext(&decrypted_shape(0, 0, true)));
}
#[test]
fn defect_count_boundary_via_real_decrypt() {
// Pin the 75% gate on the REAL decrypt path (not just the predicate):
// 8/32 defects => 24 synced = exactly 75% => opens; 9/32 => 23 synced =>
// does NOT open. Packets 1.. avoid the clear-seed packet 0.
let key = [0x77u8; 16];
let eight: Vec<usize> = (1..9).collect();
let mut u_ok = unit_with_defects(&key, &eight);
assert!(
decrypt_unit(&mut u_ok, &key),
"8 defects (24/32 = 75%) -> opened"
);
let nine: Vec<usize> = (1..10).collect();
let mut u_no = unit_with_defects(&key, &nine);
assert!(
!decrypt_unit(&mut u_no, &key),
"9 defects (23/32 < 75%) -> NOT opened (too corrupt / wrong key)"
);
}
#[test]
fn small_content_units_keep_the_wrong_key_floor() {
// Tiny content units are where a coincidental wrong-key sync matters most.
// The gate stays strict enough that a single fluke can't "open" them.
assert!(unit_content_decrypted(&decrypted_shape(2, 2, false))); // 2/2 -> open
assert!(!unit_content_decrypted(&decrypted_shape(2, 1, false))); // 1/2 -> not
assert!(unit_content_decrypted(&decrypted_shape(4, 3, false))); // 3/4=75% -> open
assert!(!unit_content_decrypted(&decrypted_shape(4, 2, false))); // 2/4 -> not
}
#[test]
fn fragment_tail_padding_plus_one_defect_still_decrypts() {
// Both tolerances at once: source-zero padding tail EXCLUDED, and the one
// authored-bad packet in the real prefix TOLERATED. 20 real packets
// (pkt 5 defective) => 19/20 synced => opened; padding emitted as zeros.
let key = [0x5Au8; 16];
let mut unit = clear_unit();
let off = 5 * BD_SOURCE_PACKET_BYTES;
unit[off + 4] = 0x80; // defect inside the real content
unit[off + 5] = 0xAB;
for b in unit[20 * BD_SOURCE_PACKET_BYTES..].iter_mut() {
*b = 0; // source-zero padding tail (packets 20..32)
}
aacs_encrypt_unit(&mut unit, &key);
assert!(
decrypt_unit(&mut unit, &key),
"19/20 real packets synced + zero pad -> opened"
);
assert_eq!(
unit[off + 4],
0x80,
"the defect packet passes through verbatim"
);
for p in 20..32 {
let o = p * BD_SOURCE_PACKET_BYTES;
assert!(
unit[o..o + BD_SOURCE_PACKET_BYTES].iter().all(|&b| b == 0),
"padding pkt {p} emitted as clean zeros"
);
}
}
#[test]
fn wrong_key_full_unit_is_not_decryptable() {
let mut unit = clear_unit();
+4
View File
@@ -66,6 +66,10 @@ pub mod coding_type {
pub const MPEG2_VIDEO: u8 = 0x02;
/// H.264 / AVC video (ISO/IEC 13818-1 Table 2-34).
pub const H264: u8 = 0x1B;
/// H.264 / MVC dependent view (Blu-ray 3D right-eye substream). Carried in
/// the SSIF interleaved stream under its own PID; the base view is [`H264`].
/// ISO/IEC 13818-1 stream_type 0x20 (MVC video sub-bitstream).
pub const H264_MVC: u8 = 0x20;
/// HEVC / H.265 video (ISO/IEC 13818-1 Table 2-34, 2015 amendment).
pub const HEVC: u8 = 0x24;
/// SMPTE VC-1 video (BD-ROM convention, ISO user-private range).
+59 -5
View File
@@ -72,6 +72,11 @@ impl Disc {
// Parse each clip for size, duration, and sector extents
let mut extents = Vec::new();
let mut total_size: u64 = 0;
// Set when any clip resolves to a STREAM/SSIF/<clip>.ssif — a Blu-ray 3D
// interleaved stream carrying both the base (left) and MVC dependent
// (right) views. Drives reading the SSIF for both eyes and adding the
// dependent-view stream below.
let mut is_3d = false;
let mut clips = Vec::with_capacity(parsed.play_items.len());
// BD playlists legally reference the same .m2ts clip_id from
// multiple PlayItems (multi-angle, seamless splits, looped
@@ -110,10 +115,23 @@ impl Disc {
// use `.ssif` (see [`CLIP_STREAM_EXTS`]). A normal `.m2ts`
// clip is unchanged — the fallback only runs when `.m2ts`
// is absent, which is exactly when `file_extents` errors.
let file_exts = CLIP_STREAM_EXTS.iter().find_map(|ext| {
let path = format!("/BDMV/STREAM/{}.{}", play_item.clip_id, ext);
udf_fs.file_extents(reader, &path).ok()
});
// 3D discs interleave the left (base) and right (MVC
// dependent) views in STREAM/SSIF/<clip>.ssif — note the
// SSIF/ subdir. Prefer it when present: the SSIF is one
// transport stream carrying BOTH eyes on distinct PIDs,
// so muxing it captures the full 3D. 2D clips fall back to
// the base .m2ts / .fmts as before.
let ssif = format!("/BDMV/STREAM/SSIF/{}.ssif", play_item.clip_id);
let file_exts = match udf_fs.file_extents(reader, &ssif) {
Ok(exts) => {
is_3d = true;
Some(exts)
}
Err(_) => CLIP_STREAM_EXTS.iter().find_map(|ext| {
let path = format!("/BDMV/STREAM/{}.{}", play_item.clip_id, ext);
udf_fs.file_extents(reader, &path).ok()
}),
};
if let Some(file_exts) = file_exts {
for (lba, sectors) in file_exts {
if sectors > 0 && lba > 0 {
@@ -138,7 +156,7 @@ impl Disc {
}
// Build streams from STN table
let streams: Vec<Stream> = parsed
let mut streams: Vec<Stream> = parsed
.streams
.iter()
.filter_map(|s| {
@@ -232,6 +250,42 @@ impl Disc {
})
.collect();
// 3D: add the MVC dependent (right-eye) video stream. The base STN table
// lists only the left-eye video; the dependent view is a second video
// PID (stream_type 0x20) carried in the SSIF. The on-disc PAT/PMT are
// AACS-encrypted (unreadable pre-key) and the base STN omits the
// dependent view (it lives in the MPLS STN_table_SS), so we use the
// BD-3D PID convention: dependent = base-view video PID + 1
// (e.g. 0x1011 -> 0x1012). Reading the SSIF (above) provides its packets.
//
// Limitation: `is_3d` latches per PLAYLIST, not per clip. A playlist that
// mixed a 3D clip (has an SSIF) with a 2D clip (no SSIF) would tag the
// whole title 3D; the 2D clip's frames then mux as plain Blocks (no
// dependent PID → no BlockAdditional) under a track that still advertises
// the mvcC mapping. That output is valid (per-frame BlockAdditional is
// optional) but over-claims 3D for those frames. Real 3D main-feature
// playlists are single-clip or uniformly 3D, so this is not exercised;
// per-clip 3D would need per-clip stream sets (a larger change).
if is_3d {
if let Some(base) = streams.iter().find_map(|s| match s {
Stream::Video(v) => Some(v.clone()),
_ => None,
}) {
let dep_pid = base.pid.wrapping_add(1);
let have_dep = streams
.iter()
.any(|s| matches!(s, Stream::Video(v) if v.pid == dep_pid));
if !have_dep {
streams.push(Stream::Video(VideoStream {
pid: dep_pid,
secondary: true,
label: crate::disc::MVC_DEPENDENT_LABEL.to_string(),
..base
}));
}
}
}
// Convert marks to chapters. mark_type == 1 is an entry-mark
// (chapter); type 2 is a link point and type 0 is reserved, so
// neither is a chapter.
+21 -1
View File
@@ -216,6 +216,22 @@ pub struct VideoStream {
pub measured_cicp: Option<MeasuredCicp>,
}
/// Label marking a video stream as the Blu-ray 3D **MVC dependent (right-eye)
/// view** — the paired substream of the AVC base view. Set by the BD scan
/// (`bluray.rs`) and recognised by the mux path (`resolve.rs` builds its parser
/// in param-set-preserving mode; `mkvstream.rs` folds it into the base track as
/// per-frame `BlockAdditional`). Single source of truth for the contract.
pub const MVC_DEPENDENT_LABEL: &str = "MVC dependent view (3D right eye)";
impl VideoStream {
/// Whether this video stream is the MVC dependent (right-eye) view — the
/// 3D substream that the muxer merges into the base track as a per-frame
/// `BlockAdditional` rather than emitting as an independent track.
pub fn is_mvc_dependent(&self) -> bool {
self.label == MVC_DEPENDENT_LABEL
}
}
/// Measured CICP colour signalling read directly from a video elementary stream
/// (ITU-T H.273). Preferred over the coarse [`ColorSpace`] enum when present.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -811,7 +827,11 @@ impl Codec {
use crate::consts::coding_type as c;
match ct {
c::HEVC => Codec::Hevc,
c::H264 => Codec::H264,
// 0x1B base-view AVC and 0x20 MVC dependent-view (Blu-ray 3D right
// eye) are both H.264. Mapping 0x20 to video is what makes the PMT
// scan enumerate the dependent view as a second H.264 stream (its
// own PID in the SSIF) instead of dropping it — the basis of 3D.
c::H264 | c::H264_MVC => Codec::H264,
c::VC1 => Codec::Vc1,
c::MPEG2_VIDEO => Codec::Mpeg2,
c::TRUEHD => Codec::TrueHd,
+74 -11
View File
@@ -6,8 +6,11 @@
//! decrypt-verify are meaningful is each clip's FILE-anchored 6144-byte unit
//! grid (clips can start off the 6144 grid and fragment across UDF extents). So
//! this gate BUFFERS the disc-absolute read stream and re-ALIGNS it into
//! clip-file units, then applies the standards-correct
//! [`crate::aacs::content::unit_is_clean_ts`] gate (all-32 TS syncs).
//! clip-file units, then decrypts each with the SAME primitive the mux/rip path
//! uses ([`crate::aacs::content::decrypt_unit`] for TS) so verify and the real
//! decrypt can never disagree. That verdict is "did a key OPEN this unit?" —
//! defect-TOLERANT: an opened unit carrying a few authored-bad packets (pressing
//! defects / forensic-variant frames) is NOT bad; only a unit no key opens is.
//!
//! FAIL-SAFE CONTRACT (this sits in the middle of every read, so it must never
//! break a good read): the gate can ONLY downgrade a unit it is *confident* is
@@ -62,6 +65,15 @@ pub enum ContainerKind {
Ps,
}
/// PS-container per-unit decrypt for the verify gate — the analogue of
/// [`aacs::content::decrypt_unit`] (TS) for HD-DVD program-stream content. Wraps
/// [`aacs::content::decrypt_unit_checked`] with the pack-start structural check.
/// A free `fn` (not a closure) so it has the same `fn(&mut [u8], &[u8;16]) -> bool`
/// type as `decrypt_unit`, letting [`Verifier::decrypt_for`] return either.
fn decrypt_unit_ps(unit: &mut [u8], key: &[u8; 16]) -> bool {
aacs::content::decrypt_unit_checked(unit, key, aacs::content::unit_is_clean_ps)
}
/// A clip's on-disc layout: declared file size, its absolute disc extents in
/// FILE order, and its stream container. `extents` is `(disc_lba, byte_len)`;
/// the verifier reuses exactly the same `(abs_lba, byte_len)` extents the
@@ -185,7 +197,9 @@ impl UnitVerifier {
})
}
/// The post-decrypt structural check for a clip's container.
/// The post-decrypt structural check for a clip's container — used ONLY on the
/// CPI-clear branch (a plaintext unit that is structurally clean is
/// decryptable-as-is; if not, we return Unknown, never bad).
fn accept_for(&self, clip: u32) -> fn(&[u8]) -> bool {
match self.containers[clip as usize] {
ContainerKind::Ts => aacs::content::unit_is_clean_ts,
@@ -193,6 +207,21 @@ impl UnitVerifier {
}
}
/// The per-unit DECRYPT for a clip's container — the key-selection primitive
/// verify uses to prove a candidate key opens an ENCRYPTED unit. It MUST be
/// the exact same primitive the mux/rip decrypt path uses, so verify and the
/// real decrypt never disagree (a unit the mux will happily decrypt must not
/// be marked bad here). For TS that is [`aacs::content::decrypt_unit`], whose
/// verdict is defect-TOLERANT (a supermajority of content packets restored) —
/// an authored-bad packet or forensic-variant frame no longer false-flags the
/// whole unit as undecryptable.
fn decrypt_for(&self, clip: u32) -> fn(&mut [u8], &[u8; 16]) -> bool {
match self.containers[clip as usize] {
ContainerKind::Ts => aacs::content::decrypt_unit,
ContainerKind::Ps => decrypt_unit_ps,
}
}
/// Feed a just-read, just-`Finished` disc byte range (`bytes` starts at disc
/// sector `disc_lba`). Routes each backing sector into its clip-file unit;
/// every unit that becomes fully assembled is verified immediately. Returns
@@ -215,7 +244,8 @@ impl UnitVerifier {
self.fill(clip, unit, slot, lba, &bytes[off..off + sector]);
if let Some((raw, lbas)) = self.take_if_complete(clip, unit) {
let accept = self.accept_for(clip);
match self.decryptability(&raw, accept) {
let decrypt = self.decrypt_for(clip);
match self.decryptability(&raw, accept, decrypt) {
Decryptability::Undecryptable => push_ranges(&mut bad, &lbas),
Decryptability::Decryptable | Decryptability::Unknown => {}
}
@@ -309,6 +339,7 @@ impl UnitVerifier {
&mut self,
raw: &[u8; ALIGNED_UNIT_LEN],
accept: fn(&[u8]) -> bool,
decrypt: fn(&mut [u8], &[u8; 16]) -> bool,
) -> Decryptability {
// CPI clear -> the unit is plaintext by spec (no key needed). If it is
// structurally clean, it is decryptable-as-is. If it ISN'T, we
@@ -327,7 +358,7 @@ impl UnitVerifier {
};
}
// Encrypted: any held key that decrypts to a structurally-clean unit.
if self.try_keys(raw, accept) {
if self.try_keys(raw, decrypt) {
return Decryptability::Decryptable;
}
// No held key works. Ask the application's key source ONCE for this
@@ -350,7 +381,7 @@ impl UnitVerifier {
self.fetch_spent = true; // service has nothing new; stop asking
return Decryptability::Unknown;
}
if self.try_keys(raw, accept) {
if self.try_keys(raw, decrypt) {
return Decryptability::Decryptable;
}
return Decryptability::Undecryptable; // service's keys don't open it -> bad ciphertext
@@ -359,12 +390,18 @@ impl UnitVerifier {
Decryptability::Unknown
}
/// True if any currently-held key decrypts `raw` to a structurally-clean unit
/// under the container's `accept` check.
fn try_keys(&self, raw: &[u8; ALIGNED_UNIT_LEN], accept: fn(&[u8]) -> bool) -> bool {
/// True if any currently-held key OPENS `raw` — i.e. the container's per-unit
/// `decrypt` (the same primitive the mux uses) reports the key restored the
/// unit's TS structure. Defect-tolerant for TS: an opened unit carrying a few
/// authored-bad packets still counts as decryptable (never marked bad).
fn try_keys(
&self,
raw: &[u8; ALIGNED_UNIT_LEN],
decrypt: fn(&mut [u8], &[u8; 16]) -> bool,
) -> bool {
for k in &self.keys {
let mut scratch = *raw;
if aacs::content::decrypt_unit_checked(&mut scratch, k, accept) {
if decrypt(&mut scratch, k) {
return true;
}
}
@@ -439,9 +476,10 @@ impl UnitVerifier {
}
}
let accept = self.accept_for(clip);
let decrypt = self.decrypt_for(clip);
if readable
&& matches!(
self.decryptability(&raw, accept),
self.decryptability(&raw, accept, decrypt),
Decryptability::Undecryptable
)
{
@@ -898,6 +936,31 @@ mod tests {
);
}
#[test]
fn reverify_iso_defect_packet_unit_is_not_flagged_bad() {
// A unit with ONE authored-bad content packet (a pressing defect). The
// held key OPENS it (31/32 syncs restored), so verify must AGREE with the
// mux decrypt and NOT mark it bad — the bad packet is the muxer's problem,
// not a read/decrypt failure. (Old strict all-32 gate flagged it bad.)
let key = [0x5a; 16];
let mut u = clear_unit();
let off = 17 * 192; // corrupt packet 17's sync in the plaintext
u[off + 4] = 0x80;
u[off + 5] = 0xAB;
encrypt_unit(&mut u, &key);
let mut iso = MockIso {
sectors: Default::default(),
err_lba: None,
};
place_unit(&mut iso, [100, 101, 102], &u);
let mut v = UnitVerifier::new(&one_clip(100), &aacs_keys(&[key]), None).unwrap();
let bad = v.reverify_iso(&mut iso, &[(100 * 2048, 3 * 2048)], &|_| true);
assert!(
bad.is_empty(),
"defect-packet unit is opened by the held key -> not flagged bad"
);
}
#[test]
fn reverify_iso_fragmented_unit_reads_distant_sectors() {
let key = [0x5a; 16];
+114 -3
View File
@@ -56,6 +56,13 @@ pub struct H264Parser {
/// (HD-DVD EVO) path where the source stamps a PTS once per GOP. `None` on
/// the BD/UHD transport path, which carries a per-frame PTS.
reorder: Option<super::reorder::SparsePtsReorder>,
/// MVC dependent-view (Blu-ray 3D right-eye) passthrough mode. When set, the
/// parser does NOT strip SPS/PPS (nor re-assert at keyframes): every NAL —
/// subset SPS (type 15), prefix (14), coded-slice-extension (20), PPS (8) —
/// is length-prefixed in-band, so each emitted frame is a self-contained
/// dependent access unit suitable for a Matroska `BlockAdditional`. The base
/// view's avcC/param-set stripping is unchanged (separate parser instance).
mvc_passthrough: bool,
}
impl Default for H264Parser {
@@ -73,6 +80,7 @@ impl H264Parser {
cur_sps: None,
cur_pps: None,
reorder: None,
mvc_passthrough: false,
}
}
@@ -85,6 +93,15 @@ impl H264Parser {
self
}
/// Enable MVC dependent-view passthrough (see the `mvc_passthrough` field):
/// keep every parameter set in-band so each frame is a self-contained
/// dependent access unit for a Matroska `BlockAdditional`. Used only for the
/// Blu-ray 3D dependent (right-eye) stream.
pub(crate) fn with_mvc_passthrough(mut self, enabled: bool) -> Self {
self.mvc_passthrough = enabled;
self
}
/// Route a finished frame through the PTS reorderer when enabled, else emit
/// it directly (unchanged transport-stream behaviour).
fn finish(&mut self, explicit: Option<i64>, frame: Frame) -> Vec<Frame> {
@@ -192,17 +209,23 @@ impl CodecParser for H264Parser {
// frame in the mux hot path (mirrors the HEVC parser).
let mut frame_data = Vec::with_capacity(pes.data.len() + 64);
// MVC dependent-view passthrough: keep ALL param sets in-band (the frame
// is a self-contained BlockAdditional access unit), never strip/re-assert.
let mvc = self.mvc_passthrough;
for nal in NalIterator::new(&pes.data) {
let nal_type = nal[0] & 0x1F;
match nal_type {
// Param sets: seed avcC, strip if unchanged vs the active set,
// emit in-band on any change (incl. reverting to the avcC copy).
NAL_SPS => {
// In MVC passthrough these fall through to the default arm so the
// subset SPS / PPS stay in-band (self-contained dependent AU).
NAL_SPS if !mvc => {
emitted_sps |=
handle_param_set(&mut self.sps, &mut self.cur_sps, nal, &mut frame_data)
}
NAL_PPS => {
NAL_PPS if !mvc => {
emitted_pps |=
handle_param_set(&mut self.pps, &mut self.cur_pps, nal, &mut frame_data)
}
@@ -250,7 +273,7 @@ impl CodecParser for H264Parser {
// ahead of the slices (even when unchanged vs codecPrivate) so a decoder
// that dropped the set at a reset recovers, and a stale avcC re-apply
// can't revert it. Skipped per-type only when this AU already carried it.
if keyframe {
if keyframe && !mvc {
let mut prefix = Vec::new();
reassert_active(&mut prefix, &self.cur_sps, emitted_sps);
reassert_active(&mut prefix, &self.cur_pps, emitted_pps);
@@ -651,6 +674,94 @@ mod tests {
v
}
#[test]
fn mvc_passthrough_keeps_param_sets_inband() {
// A dependent-view access unit: subset SPS (NAL 15) + PPS (NAL 8) +
// coded-slice-extension (NAL 20). No IDR (type 5), so keyframe stays
// false and there is no keyframe re-assertion.
let au = || {
let mut d = Vec::new();
d.extend_from_slice(&h264_nal(0x6F, &[0x80, 0x00, 0x33, 0xAA])); // subset SPS (15)
d.extend_from_slice(&h264_nal(0x68, &[0xCE, 0x01])); // PPS (8)
d.extend_from_slice(&h264_nal(0x74, &[0x11, 0x22])); // slice-ext (20)
d
};
let nal_types =
|f: &Frame| -> Vec<u8> { h264_nals_in(&f.data).iter().map(|n| n[0] & 0x1F).collect() };
// Normal parser strips the PPS from a non-keyframe AU (it is captured for
// the avcC and, without an IDR, never re-asserted in-band).
let mut normal = H264Parser::new();
let f = normal.parse(&make_pes(au(), Some(90000)));
assert_eq!(f.len(), 1);
assert!(
!nal_types(&f[0]).contains(&8),
"normal parser strips PPS from a non-keyframe AU: {:?}",
nal_types(&f[0])
);
// Passthrough keeps EVERY parameter set in-band, so each dependent frame
// is a self-contained access unit for a BlockAdditional.
let mut pt = H264Parser::new().with_mvc_passthrough(true);
let f = pt.parse(&make_pes(au(), Some(90000)));
assert_eq!(f.len(), 1);
let types = nal_types(&f[0]);
assert!(types.contains(&15), "subset SPS kept in-band: {types:?}");
assert!(
types.contains(&8),
"PPS kept in-band under passthrough: {types:?}"
);
assert!(types.contains(&20), "slice kept: {types:?}");
}
#[test]
fn parser_for_mvc_dependent_h264_is_passthrough() {
// The dependent-view stream must get a passthrough parser: a PPS in a
// non-keyframe AU is kept in-band, not stripped like the base parser.
let mut p = crate::mux::codec::parser_for_mvc_dependent(crate::disc::Codec::H264, false);
let mut d = Vec::new();
d.extend_from_slice(&h264_nal(0x68, &[0xCE, 0x01])); // PPS (8)
d.extend_from_slice(&h264_nal(0x74, &[0x11, 0x22])); // slice-ext (20)
let f = p.parse(&make_pes(d, Some(90000)));
assert_eq!(f.len(), 1);
let types: Vec<u8> = h264_nals_in(&f[0].data)
.iter()
.map(|n| n[0] & 0x1F)
.collect();
assert!(
types.contains(&8),
"dependent parser keeps PPS in-band (passthrough): {types:?}"
);
}
#[test]
fn mvc_passthrough_with_idr_does_not_reassert_param_sets() {
// With an IDR present (keyframe=true), passthrough must NOT re-assert the
// param sets (the `keyframe && !mvc` guard), so SPS/PPS appear exactly
// once — a duplicate would corrupt the dependent BlockAdditional.
let mut p = H264Parser::new().with_mvc_passthrough(true);
let mut d = Vec::new();
d.extend_from_slice(&h264_nal(0x67, &[0x42, 0x00, 0x1E, 0x01])); // SPS (7)
d.extend_from_slice(&h264_nal(0x68, &[0xCE, 0x01])); // PPS (8)
d.extend_from_slice(&h264_nal(0x65, &[0x88, 0x00])); // IDR slice (5)
let f = p.parse(&make_pes(d, Some(90000)));
assert_eq!(f.len(), 1);
let types: Vec<u8> = h264_nals_in(&f[0].data)
.iter()
.map(|n| n[0] & 0x1F)
.collect();
assert_eq!(
types.iter().filter(|&&t| t == 7).count(),
1,
"exactly one SPS, no keyframe re-assert under passthrough: {types:?}"
);
assert_eq!(
types.iter().filter(|&&t| t == 8).count(),
1,
"exactly one PPS, no keyframe re-assert under passthrough: {types:?}"
);
}
#[test]
fn h264_populates_measured_coding_type_and_source() {
use super::super::coding::CodingType;
+16
View File
@@ -207,6 +207,22 @@ pub fn parser_for_codec(
}
}
/// Build the codec parser for a Blu-ray 3D **MVC dependent (right-eye)** video
/// stream. Same codec space as the base view (H.264), but in param-set
/// passthrough mode so each emitted frame is a self-contained dependent access
/// unit for a Matroska `BlockAdditional`. Non-H.264 (unexpected) falls back to
/// the ordinary parser.
pub fn parser_for_mvc_dependent(codec: Codec, is_dvd_ps: bool) -> Box<dyn CodecParser> {
match codec {
Codec::H264 => Box::new(
h264::H264Parser::new()
.with_ps_reorder(is_dvd_ps)
.with_mvc_passthrough(true),
),
_ => parser_for_codec(codec, None, is_dvd_ps),
}
}
#[cfg(test)]
mod tests {
use super::*;
+69
View File
@@ -94,6 +94,31 @@ pub fn write_uint(w: &mut impl Write, id: u32, val: u64) -> io::Result<()> {
}
}
/// Write a complete EBML signed-integer element (two's-complement, big-endian,
/// minimal width). Used for `ReferenceBlock` (0xFB), whose value is a signed
/// tick offset relative to the current block's timestamp.
pub fn write_int(w: &mut impl Write, id: u32, val: i64) -> io::Result<()> {
write_id(w, id)?;
// Minimal two's-complement width: shrink while the top byte is pure sign
// extension of the next byte's MSB.
let be = val.to_be_bytes();
let mut start = 0usize;
while start < 7 {
let sign_ext = if be[start + 1] & 0x80 != 0 {
0xFF
} else {
0x00
};
if be[start] != sign_ext {
break;
}
start += 1;
}
let bytes = &be[start..];
write_size(w, bytes.len() as u64)?;
w.write_all(bytes)
}
/// Write a complete EBML float element (8-byte double).
pub fn write_float(w: &mut impl Write, id: u32, val: f64) -> io::Result<()> {
write_id(w, id)?;
@@ -481,6 +506,25 @@ pub const LUMINANCE_MIN: u32 = 0x55DA;
pub const BLOCK_ADDITION_MAPPING: u32 = 0x41E4;
pub const BLOCK_ADD_ID_TYPE: u32 = 0x41E7;
pub const BLOCK_ADD_ID_EXTRA_DATA: u32 = 0x41ED;
/// BlockAddIDValue (RFC 9559) — the value a per-frame `BlockAddID` references
/// to select this BlockAdditionMapping. Values ≥ 2 (1 is the default plain
/// BlockAdditional). Used by the MVC (`mvcC`) mapping for Blu-ray 3D.
pub const BLOCK_ADD_ID_VALUE: u32 = 0x41F0;
// Block additions carried inside a BlockGroup — per-frame side data. For
// Blu-ray 3D (MVC) the dependent (right-eye) view NAL units for an access unit
// ride here as a BlockAdditional under the track's `mvcC` mapping (RFC 9559
// §5.1.4.1.4; Matroska Codec Specifications §4.1.5).
pub const BLOCK_ADDITIONS: u32 = 0x75A1;
pub const BLOCK_MORE: u32 = 0xA6;
pub const BLOCK_ADDITIONAL: u32 = 0xA5;
pub const BLOCK_ADD_ID: u32 = 0xEE;
/// ReferenceBlock (RFC 9559 element 0xFB, child of BlockGroup) — signed
/// timestamp (in TimestampScale ticks) of a block this one references, relative
/// to this block's own timestamp. Its PRESENCE marks the Block as non-keyframe
/// (a keyframe Block in a BlockGroup carries none). Written for non-keyframe
/// video frames that must live in a BlockGroup to carry an MVC BlockAdditional.
pub const REFERENCE_BLOCK: u32 = 0xFB;
// Audio
pub const AUDIO: u32 = 0xE1;
@@ -960,6 +1004,31 @@ mod tests {
assert_eq!(buf, [0x42, 0x86, 0x81, 0x00]);
}
#[test]
fn write_int_minimal_two_complement_width() {
// ReferenceBlock (0xFB) signed offsets, minimal two's-complement width.
let enc = |v: i64| {
let mut b = Vec::new();
write_int(&mut b, REFERENCE_BLOCK, v).unwrap();
b
};
assert_eq!(enc(0), [0xFB, 0x81, 0x00], "0 -> 1 byte 0x00");
assert_eq!(enc(-1), [0xFB, 0x81, 0xFF], "-1 -> 1 byte 0xFF");
assert_eq!(enc(127), [0xFB, 0x81, 0x7F], "127 -> 1 byte");
assert_eq!(
enc(128),
[0xFB, 0x82, 0x00, 0x80],
"128 needs 2 bytes (0x80 alone is -128)"
);
assert_eq!(enc(-128), [0xFB, 0x81, 0x80], "-128 -> 1 byte 0x80");
assert_eq!(enc(-129), [0xFB, 0x82, 0xFF, 0x7F], "-129 needs 2 bytes");
// i64::MIN is the widest: 8 bytes, size 0x88.
let mn = enc(i64::MIN);
assert_eq!(mn[0], 0xFB);
assert_eq!(mn[1], 0x88);
assert_eq!(&mn[2..], &i64::MIN.to_be_bytes());
}
// ============================================================
// write_float — EBML floats here are always 8-byte IEEE-754 doubles,
// big-endian (Matroska SamplingFrequency/Duration). size byte = 0x88.
+505 -28
View File
@@ -68,6 +68,82 @@ const COLOUR_RANGE_LIMITED: u8 = 1;
/// Vision configuration record (RFC 9559 + Dolby Vision-in-Matroska spec).
const BLOCK_ADD_ID_TYPE_DVCC: u64 = 0x6476_6343;
/// BlockAddIDType "mvcC" — the MVCDecoderConfigurationRecord fourcc, big-endian
/// ASCII 'm''v''c''C'. Matroska BlockAdditionMapping/BlockAddIDType for a
/// Blu-ray 3D MVC configuration (RFC 9559 + Matroska Codec Specifications
/// §4.1.5; equals ISO/IEC 14496-15 MVCConfigurationBox('mvcC')). The per-frame
/// dependent (right-eye) view rides as a BlockAdditional under this mapping.
const BLOCK_ADD_ID_TYPE_MVCC: u64 = 0x6D76_6343;
/// BlockAddIDValue for the MVC mapping — the value each per-frame `BlockAddID`
/// references (RFC 9559 requires ≥ 2; 1 is the default plain BlockAdditional).
const BLOCK_ADD_ID_VALUE_MVC: u64 = 2;
/// Build an `MVCDecoderConfigurationRecord` (ISO/IEC 14496-15:2013 §7.6.2) from
/// the dependent view's subset-SPS (NAL type 15) and PPS NAL units. This is the
/// `BlockAddIDExtraData` for the `mvcC` BlockAdditionMapping (and, in the ISO
/// container, the `MVCConfigurationBox('mvcC')` payload).
///
/// Layout mirrors the AVCDecoderConfigurationRecord except byte[4] repurposes
/// the AVC record's `bit(6) reserved` as
/// `complete_representation(1) | explicit_au_track(1) | reserved '1111'(4)`.
/// `profile`/`compat`/`level` describe the WHOLE MVC stream and come from the
/// subset SPS (bytes 1..=3). `length_size_minus_one` MUST match the base avcC
/// (freemkv always emits 4-byte length prefixes → 3). SPSs precede subset SPSs
/// in the array; here the array carries the dependent view's parameter sets.
///
/// Returns `None` if either param set is absent, too short, or exceeds the
/// 16-bit length field (a param set > 65535 bytes is non-conforming and would
/// mis-frame the record).
fn mvc_decoder_config_record(subset_sps: &[u8], pps: &[u8]) -> Option<Vec<u8>> {
if subset_sps.len() < 4 || subset_sps.len() > 0xFFFF || pps.is_empty() || pps.len() > 0xFFFF {
return None;
}
let mut record = vec![
1, // configurationVersion
subset_sps[1], // AVCProfileIndication (whole MVC stream, from subset SPS)
subset_sps[2], // profile_compatibility
subset_sps[3], // AVCLevelIndication
// complete_representation(1)=1 | explicit_au_track(1)=0 |
// reserved '1111'(4) | lengthSizeMinusOne(2)=3(11) → 1_0_1111_11 = 0xBF.
0xBF,
// reserved '0'(1) | numOfSequenceParameterSets(7)=1 → 0_0000001 = 0x01.
// NB: distinct from the AVC record's byte[5] (reserved(3)|numSPS(5)).
0x01,
(subset_sps.len() >> 8) as u8,
subset_sps.len() as u8,
];
record.extend_from_slice(subset_sps);
record.push(1); // numOfPictureParameterSets
record.push((pps.len() >> 8) as u8);
record.push(pps.len() as u8);
record.extend_from_slice(pps);
Some(record)
}
/// Build the `CodecPrivate` for an MVC (Blu-ray 3D) base track: the base view's
/// `AVCDecoderConfigurationRecord` (`avcc`) followed by an `mvcC` extension
/// block, per the Matroska Codec Specifications §4.3.9:
///
/// ```text
/// avcC ‖ u32be(extension_block_size − 4) ‖ "mvcC" ‖ MVCDecoderConfigurationRecord
/// ```
///
/// The size field is the extension block length **excluding the 4-byte size
/// field itself** — i.e. `4 ("mvcC") + record.len()`. This is the track-level
/// MVC signal that decoders and mediainfo read (the per-frame `BlockAdditional`
/// under the `mvcC` BlockAdditionMapping carries the dependent view's data). A
/// plain (2D) track never calls this — it writes its `avcc` verbatim.
fn mvc_codec_private(avcc: &[u8], record: &[u8]) -> Vec<u8> {
let ext_size = (4 + record.len()) as u32; // "mvcC" (4) + record; = block size − 4
let mut out = Vec::with_capacity(avcc.len() + 8 + record.len());
out.extend_from_slice(avcc);
out.extend_from_slice(&ext_size.to_be_bytes());
out.extend_from_slice(b"mvcC");
out.extend_from_slice(record);
out
}
/// Resolve a video stream's CICP colour code points — `(matrix, transfer,
/// primaries, range)`, ITU-T H.273 — using a single precedence so EVERY sink
/// (the MKV muxer here AND the FVI sidecar in `videomap.rs`) agrees and can
@@ -176,6 +252,16 @@ pub struct MkvTrack {
/// stream is parsed. When `Some`, the serializer emits MasteringMetadata +
/// MaxCLL/MaxFALL inside Colour; when `None` they are omitted entirely.
pub hdr10: Option<crate::mux::codec::Hdr10Metadata>,
/// Blu-ray 3D (MVC): the dependent (right-eye) view's `(subset_sps, pps)`
/// NAL units, from which the serializer builds the `mvcC`
/// MVCDecoderConfigurationRecord (ISO/IEC 14496-15 §7.6.2) for the track's
/// BlockAdditionMapping. `None` for non-3D tracks. Set at muxer activation
/// from the dependent stream's parameter sets — the same deferred path
/// `hdr10`/FieldOrder use — never at construction. When `Some`, the per-frame
/// dependent view rides as a `BlockAdditional`. (No `StereoMode` is written:
/// RFC 9559 assigns no StereoMode value to MVC-in-BlockAdditional; the mvcC
/// mapping is the 3D signal.)
pub mvc_params: Option<(Vec<u8>, Vec<u8>)>,
}
/// Build a DOVIDecoderConfigurationRecord (dvcC) — 24 bytes — for the Matroska
@@ -380,6 +466,7 @@ impl MkvTrack {
// coded picture's PictureInfo before the header is written (the same
// deferred path FieldOrder uses). `None` here → omitted unless seen.
hdr10: None,
mvc_params: None,
}
}
@@ -448,6 +535,7 @@ impl MkvTrack {
bit_depth: 0,
dv_config: None,
hdr10: None,
mvc_params: None,
}
}
@@ -485,6 +573,7 @@ impl MkvTrack {
bit_depth: 0,
dv_config: None,
hdr10: None,
mvc_params: None,
}
}
}
@@ -530,6 +619,12 @@ pub struct MkvMuxer<W: Write + Seek> {
/// the primary video. `None` when the title has no video track (no track
/// drives epochs).
primary_video_track: Option<usize>,
/// Per-track: whether the track declared an `mvcC` BlockAdditionMapping (its
/// `mvc_params` was set at activation). A `BlockAdditional` with BlockAddID=2
/// is only conforming when the track carries the matching mapping, so
/// `write_frame`'s additional is dropped for a track without it (e.g. the
/// dependent view's parameter sets were never captured before activation).
track_has_mvc_mapping: Vec<bool>,
/// Cross-clip timeline-continuity corrector (clip-boundary PTS rebasing).
continuity: TimelineContinuity,
cues: Vec<CuePoint>,
@@ -565,6 +660,11 @@ pub struct MkvMuxer<W: Write + Seek> {
/// Highest block timestamp (TimestampScale ticks) written across all tracks —
/// the muxed runtime, used to back-patch the DURATION placeholder.
max_block_ticks: i64,
/// Timestamp (TimestampScale ticks) of the last video keyframe written on the
/// primary video track. A non-keyframe MVC base frame lives in a BlockGroup
/// (to carry its dependent-view BlockAdditional) and needs a `ReferenceBlock`
/// so players don't mistake it for a keyframe; it references this keyframe.
last_video_keyframe_ticks: Option<i64>,
/// Per-AC-3-audio-track channel-correction state. The DVD IFO audio nibble
/// is unreliable, so the channel count written in the track header is
/// corrected from the AC-3 bitstream `acmod` of the first frame on the
@@ -805,9 +905,23 @@ impl<W: Write + Seek> MkvMuxer<W> {
let mut track_uids: Vec<u64> = Vec::with_capacity(tracks.len());
let mut ac3_channel_fixups: std::collections::HashMap<usize, Ac3ChannelFixup> =
std::collections::HashMap::new();
// Per track: whether it emitted a conforming `mvcC` BlockAdditionMapping.
// Filled below from the SAME built record that drives the CodecPrivate
// mvcC extension, so the three MVC signals never diverge.
let mut track_has_mvc_mapping: Vec<bool> = Vec::with_capacity(tracks.len());
for (i, track) in tracks.iter().enumerate() {
let track_uid = (i + 1) as u64 | 0x100_0000;
track_uids.push(track_uid);
// Build the MVC (Blu-ray 3D) MVCDecoderConfigurationRecord ONCE per
// track from the dependent view's subset-SPS/PPS. `None` for every
// non-3D track (and if the params are malformed) — the single source
// of truth for the CodecPrivate mvcC extension, the
// BlockAdditionMapping, and whether BlockAdditionals are conforming.
let mvc_record = track
.mvc_params
.as_ref()
.and_then(|(sps, pps)| mvc_decoder_config_record(sps, pps));
track_has_mvc_mapping.push(mvc_record.is_some());
let entry_pos = ebml::start_master(&mut writer, ebml::TRACK_ENTRY)?;
ebml::write_uint(&mut writer, ebml::TRACK_NUMBER, (i + 1) as u64)?;
ebml::write_uint(&mut writer, ebml::TRACK_UID, track_uid)?;
@@ -827,7 +941,20 @@ impl<W: Write + Seek> MkvMuxer<W> {
}
if let Some(ref cp) = track.codec_private {
ebml::write_binary(&mut writer, ebml::CODEC_PRIVATE, cp)?;
match mvc_record.as_ref() {
// MVC (Blu-ray 3D) base track: CodecPrivate = base-view avcC
// followed by the `mvcC` extension block. This is the
// track-level signal decoders/mediainfo read to recognise the
// stereoscopic MVC track (the per-frame dependent view rides
// in BlockAdditional under the mapping below).
Some(record) => {
let cp_mvc = mvc_codec_private(cp, record);
ebml::write_binary(&mut writer, ebml::CODEC_PRIVATE, &cp_mvc)?;
}
// Non-MVC (2D/UHD/audio/…): write the codec_private verbatim —
// the unchanged path, byte-identical to a 2D mux.
None => ebml::write_binary(&mut writer, ebml::CODEC_PRIVATE, cp)?,
}
}
// Pre-0.13 a deferred codecPrivate path existed for video tracks
// (placeholder reserve + later seek-back fill via
@@ -923,6 +1050,40 @@ impl<W: Write + Seek> MkvMuxer<W> {
ebml::end_master(&mut writer, vid_pos)?;
}
// Blu-ray 3D (MVC) signaling — BlockAdditionMapping (sibling of
// Video) carries the mvcC MVCDecoderConfigurationRecord so players /
// mediainfo recognise the dependent (right-eye) view that rides as a
// per-frame BlockAdditional under this mapping (BlockAddIDValue = 2).
match mvc_record.as_ref() {
Some(record) => {
let map_pos = ebml::start_master(&mut writer, ebml::BLOCK_ADDITION_MAPPING)?;
ebml::write_uint(
&mut writer,
ebml::BLOCK_ADD_ID_VALUE,
BLOCK_ADD_ID_VALUE_MVC,
)?;
ebml::write_uint(&mut writer, ebml::BLOCK_ADD_ID_TYPE, BLOCK_ADD_ID_TYPE_MVCC)?;
ebml::write_binary(&mut writer, ebml::BLOCK_ADD_ID_EXTRA_DATA, record)?;
ebml::end_master(&mut writer, map_pos)?;
}
// `mvc_params` present but the record failed to build (malformed
// parameter sets): no mapping, and `track_has_mvc_mapping` above
// is already `false`, so BlockAdditionals are dropped — the file
// stays conforming rather than carrying an orphaned BlockAddID.
None if track.mvc_params.is_some() => {
let (s, p) = track.mvc_params.as_ref().unwrap();
tracing::warn!(
target: "mux",
"MVC track: could not build MVCDecoderConfigurationRecord from the \
dependent view's parameter sets (subset_sps={} B, pps={} B); \
emitting no mvcC mapping — the 3D pairing will not be signalled.",
s.len(),
p.len(),
);
}
None => {}
}
// Dolby Vision signaling — BlockAdditionMapping is a child of the
// TrackEntry (sibling of Video). Carries the dvcC so players /
// mediainfo recognise the track as Dolby Vision.
@@ -1013,6 +1174,7 @@ impl<W: Write + Seek> MkvMuxer<W> {
primary_video_track: tracks
.iter()
.position(|t| t.track_type == ebml::TRACK_TYPE_VIDEO),
track_has_mvc_mapping,
continuity: TimelineContinuity::new(),
cues: Vec::new(),
frame_count: 0,
@@ -1027,6 +1189,7 @@ impl<W: Write + Seek> MkvMuxer<W> {
duration_secs,
duration_patch_pos,
max_block_ticks: 0,
last_video_keyframe_ticks: None,
ac3_channel_fixups,
opening_capture: None,
})
@@ -1058,6 +1221,13 @@ impl<W: Write + Seek> MkvMuxer<W> {
/// (`None`) leaves the written value untouched: an interlaced track keeps
/// its guess rather than being cleared via a multi-element change. The byte
/// width is fixed (FieldOrder is 0..=14), so the in-place rewrite is valid.
///
/// `block_additional`, when `Some`, is attached to the frame as a Matroska
/// `BlockAdditional` (BlockAddID=2) — Blu-ray 3D (MVC): the base view is the
/// Block and the dependent (right-eye) access unit rides as the
/// BlockAdditional under the track's `mvcC` mapping. Such a frame is always a
/// `BlockGroup` (never a SimpleBlock), with a `ReferenceBlock` when it is not
/// a keyframe. `None` for every non-3D frame.
pub fn write_frame(
&mut self,
track_idx: usize,
@@ -1065,6 +1235,7 @@ impl<W: Write + Seek> MkvMuxer<W> {
keyframe: bool,
data: &[u8],
duration_ns: Option<u64>,
block_additional: Option<&[u8]>,
) -> io::Result<()> {
// --log-level 3: capture the first ~100 coded frames per track to the
// side file BEFORE any timeline mangling, with the codec parser's own
@@ -1226,15 +1397,72 @@ impl<W: Write + Seek> MkvMuxer<W> {
self.max_block_ticks = self.max_block_ticks.max(block_end_ticks);
let relative_ts = (pts_ticks - self.cluster_ts_ticks) as i16;
match duration_ns {
Some(dur_ns) => {
// BlockDuration is in TimestampScale ticks, floored at 1.
let duration_ticks = (dur_ns as i64 / TIMESTAMP_SCALE_NS).max(1) as u64;
self.write_block_group(track_idx + 1, relative_ts, keyframe, data, duration_ticks)?;
let duration_ticks =
duration_ns.map(|dur_ns| (dur_ns as i64 / TIMESTAMP_SCALE_NS).max(1) as u64);
// A BlockAdditional with BlockAddID=2 is only conforming when the track
// declared the matching mvcC BlockAdditionMapping. If it did not (the
// dependent view's parameter sets were never captured before the header
// was written), drop the additional and emit a plain block rather than a
// non-conforming file with an orphaned BlockAddID.
let block_additional = match block_additional {
Some(a)
if self
.track_has_mvc_mapping
.get(track_idx)
.copied()
.unwrap_or(false) =>
{
Some(a)
}
None => {
self.write_simple_block(track_idx + 1, relative_ts, keyframe, data)?;
Some(_) => None,
None => None,
};
match block_additional {
// MVC: base view Block + dependent-view BlockAdditional, always a
// BlockGroup. Non-keyframe base frames get a ReferenceBlock to the
// last keyframe (a keyframe carries none), so a player never treats a
// P/B frame as a seek point.
Some(additional) => {
let reference = if keyframe {
None
} else {
// Offset (ticks) of the referenced keyframe relative to this
// block. A non-keyframe MUST carry a ReferenceBlock or a reader
// treats it as a seek point; fall back to 0 (self-relative) in
// the pre-first-keyframe corner (unreachable in practice — such
// frames are dropped before a cluster opens) so the marker is
// never absent.
Some(
self.last_video_keyframe_ticks
.map(|kf| kf - pts_ticks)
.unwrap_or(0),
)
};
self.write_block_group_mvc(
track_idx + 1,
relative_ts,
data,
additional,
reference,
duration_ticks,
)?;
}
None => match duration_ticks {
// BlockDuration present (PGS subtitles) → BlockGroup.
Some(dt) => {
self.write_block_group(track_idx + 1, relative_ts, keyframe, data, dt)?;
}
None => {
self.write_simple_block(track_idx + 1, relative_ts, keyframe, data)?;
}
},
}
// Remember the last PRIMARY-video keyframe's tick so a later non-keyframe
// MVC base frame references a keyframe on its OWN track (see the
// block_additional path above). Gating to the primary video track avoids a
// secondary video track's keyframe becoming a cross-track reference target.
if keyframe && Some(track_idx) == self.primary_video_track {
self.last_video_keyframe_ticks = Some(pts_ticks);
}
self.frame_count += 1;
@@ -1519,6 +1747,52 @@ impl<W: Write + Seek> MkvMuxer<W> {
ebml::end_master(&mut self.writer, bg_pos)?;
Ok(())
}
/// Write a BlockGroup carrying the base view Block plus the MVC dependent
/// (right-eye) access unit as a `BlockAdditional` (BlockAddID=2), per the
/// track's `mvcC` BlockAdditionMapping. A non-keyframe frame gets a
/// `ReferenceBlock` (`reference` = referenced keyframe offset in ticks) so it
/// is not mistaken for a seek point; `BlockDuration` is written when known.
fn write_block_group_mvc(
&mut self,
track_num: usize,
relative_ts: i16,
data: &[u8],
additional: &[u8],
reference: Option<i64>,
duration_ticks: Option<u64>,
) -> io::Result<()> {
let (tv, tv_len) = track_vint(track_num);
let track_vint = &tv[..tv_len];
// The 0x80 Keyframe flag is SimpleBlock-only; inside a BlockGroup Block
// it is reserved and MUST be 0 — keyframe-ness is signalled by the
// presence/absence of ReferenceBlock.
let flags: u8 = 0x00;
let block_size = track_vint.len() + 2 + 1 + data.len();
let bg_pos = ebml::start_master(&mut self.writer, ebml::BLOCK_GROUP)?;
ebml::write_id(&mut self.writer, ebml::BLOCK)?;
ebml::write_size(&mut self.writer, block_size as u64)?;
self.writer.write_all(track_vint)?;
self.writer.write_all(&relative_ts.to_be_bytes())?;
self.writer.write_all(&[flags])?;
self.writer.write_all(data)?;
if let Some(dt) = duration_ticks {
ebml::write_uint(&mut self.writer, ebml::BLOCK_DURATION, dt)?;
}
if let Some(ref_off) = reference {
ebml::write_int(&mut self.writer, ebml::REFERENCE_BLOCK, ref_off)?;
}
// BlockAdditions → BlockMore { BlockAddID=2, BlockAdditional=dependent AU }.
let adds_pos = ebml::start_master(&mut self.writer, ebml::BLOCK_ADDITIONS)?;
let more_pos = ebml::start_master(&mut self.writer, ebml::BLOCK_MORE)?;
ebml::write_uint(&mut self.writer, ebml::BLOCK_ADD_ID, BLOCK_ADD_ID_VALUE_MVC)?;
ebml::write_binary(&mut self.writer, ebml::BLOCK_ADDITIONAL, additional)?;
ebml::end_master(&mut self.writer, more_pos)?;
ebml::end_master(&mut self.writer, adds_pos)?;
ebml::end_master(&mut self.writer, bg_pos)?;
Ok(())
}
}
// ============================================================
@@ -1702,6 +1976,7 @@ mod tests {
bit_depth: 0,
dv_config: None,
hdr10: None,
mvc_params: None,
}
}
@@ -1731,6 +2006,7 @@ mod tests {
bit_depth: 0,
dv_config: None,
hdr10: None,
mvc_params: None,
}
}
@@ -1814,7 +2090,7 @@ mod tests {
let tracks = [make_video_track()];
let mut muxer = MkvMuxer::new(buf, &tracks, None, 60.0, &[]).unwrap();
muxer
.write_frame(0, 0, true, &[0xDE, 0xAD, 0xBE, 0xEF], None)
.write_frame(0, 0, true, &[0xDE, 0xAD, 0xBE, 0xEF], None, None)
.unwrap();
let data = muxer.writer.into_inner();
assert!(
@@ -1834,7 +2110,7 @@ mod tests {
let tracks = [make_video_track()];
let mut muxer = MkvMuxer::new(writer, &tracks, Some("Cue Test"), 60.0, &[]).unwrap();
muxer
.write_frame(0, 0, true, &[0x01, 0x02, 0x03], None)
.write_frame(0, 0, true, &[0x01, 0x02, 0x03], None, None)
.unwrap();
muxer.finish().unwrap();
@@ -1860,7 +2136,7 @@ mod tests {
let tracks = [make_video_track()];
let mut muxer = MkvMuxer::new(writer, &tracks, Some("NoCue"), 60.0, &[]).unwrap();
muxer
.write_frame(0, 0, true, &[0x01, 0x02, 0x03], None)
.write_frame(0, 0, true, &[0x01, 0x02, 0x03], None, None)
.unwrap();
// Force the zero-cue branch: drop every cue before finalizing.
let cues_entry_pos = muxer.cues_seek_entry_pos.expect("CUES seek entry recorded");
@@ -2111,16 +2387,16 @@ mod tests {
let mut muxer = MkvMuxer::new(buf, &tracks, Some("Multi"), 120.0, &[]).unwrap();
// Write frames to both tracks
muxer
.write_frame(0, 0, true, &[0x00, 0x00, 0x01], None)
.write_frame(0, 0, true, &[0x00, 0x00, 0x01], None, None)
.unwrap();
muxer
.write_frame(1, 0, false, &[0x0B, 0x77, 0x00], None)
.write_frame(1, 0, false, &[0x0B, 0x77, 0x00], None, None)
.unwrap();
muxer
.write_frame(0, 40_000_000, false, &[0x00, 0x00, 0x01], None)
.write_frame(0, 40_000_000, false, &[0x00, 0x00, 0x01], None, None)
.unwrap();
muxer
.write_frame(1, 32_000_000, false, &[0x0B, 0x77, 0x01], None)
.write_frame(1, 32_000_000, false, &[0x0B, 0x77, 0x01], None, None)
.unwrap();
// Should not panic
let data = muxer.writer.into_inner();
@@ -2135,11 +2411,11 @@ mod tests {
// Record position before first frame
let pos_before_kf = muxer.writer.position();
muxer.write_frame(0, 0, true, &[0xAA], None).unwrap();
muxer.write_frame(0, 0, true, &[0xAA], None, None).unwrap();
let pos_after_kf = muxer.writer.position();
muxer
.write_frame(0, 1_000_000, false, &[0xBB], None)
.write_frame(0, 1_000_000, false, &[0xBB], None, None)
.unwrap();
let pos_after_nkf = muxer.writer.position();
@@ -2385,7 +2661,7 @@ mod tests {
let writer = SharedWriter(shared.clone());
let mut muxer = MkvMuxer::new(writer, tracks, None, 0.0, chapters).unwrap();
for (t, pts, kf, data) in frames {
muxer.write_frame(*t, *pts, *kf, data, None).unwrap();
muxer.write_frame(*t, *pts, *kf, data, None, None).unwrap();
}
let frame_count = muxer.frame_count;
muxer.finish().unwrap();
@@ -2747,7 +3023,7 @@ mod tests {
let mut muxer = MkvMuxer::new(writer, &tracks, None, 0.0, &[]).unwrap();
for f in &frames {
muxer
.write_frame(0, f.pts_ns, f.keyframe, &f.data, f.duration_ns)
.write_frame(0, f.pts_ns, f.keyframe, &f.data, f.duration_ns, None)
.unwrap();
}
muxer.finish().unwrap();
@@ -3136,12 +3412,14 @@ mod tests {
let writer = SharedWriter(shared.clone());
let mut muxer = MkvMuxer::new(writer, &tracks, None, 0.0, &[]).unwrap();
// Audio frames (track 1) and non-keyframe video — no track-0 keyframe.
muxer.write_frame(1, 0, true, &[0xAA; 8], None).unwrap();
muxer
.write_frame(0, 10_000_000, false, &[0xBB; 8], None)
.write_frame(1, 0, true, &[0xAA; 8], None, None)
.unwrap();
muxer
.write_frame(1, 20_000_000, true, &[0xCC; 8], None)
.write_frame(0, 10_000_000, false, &[0xBB; 8], None, None)
.unwrap();
muxer
.write_frame(1, 20_000_000, true, &[0xCC; 8], None, None)
.unwrap();
let err = muxer.finish().unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::InvalidData);
@@ -3216,7 +3494,7 @@ mod tests {
let writer = SharedWriter(shared.clone());
let mut muxer = MkvMuxer::new(writer, &tracks, None, 0.0, &[]).unwrap();
for (t, pts, kf, data) in &frames_in_order {
muxer.write_frame(*t, *pts, *kf, data, None).unwrap();
muxer.write_frame(*t, *pts, *kf, data, None, None).unwrap();
}
muxer.finish().unwrap();
let data = shared.lock().unwrap().clone().into_inner();
@@ -3286,7 +3564,7 @@ mod tests {
let writer = SharedWriter(shared.clone());
let mut muxer = MkvMuxer::new(writer, tracks, None, 0.0, &[]).unwrap();
for (t, pts, kf, data, dur) in frames {
muxer.write_frame(*t, *pts, *kf, data, *dur).unwrap();
muxer.write_frame(*t, *pts, *kf, data, *dur, None).unwrap();
}
muxer.finish().unwrap();
shared.lock().unwrap().clone().into_inner()
@@ -4135,10 +4413,10 @@ mod tests {
let mut muxer = MkvMuxer::new(writer, &tracks, None, 10.0, &[]).unwrap();
// Video keyframe 1000 bytes; audio frame 500 bytes.
muxer
.write_frame(0, 0, true, &vec![0xABu8; 1000], None)
.write_frame(0, 0, true, &vec![0xABu8; 1000], None, None)
.unwrap();
muxer
.write_frame(1, 0, false, &vec![0xCDu8; 500], None)
.write_frame(1, 0, false, &vec![0xCDu8; 500], None, None)
.unwrap();
muxer.finish().unwrap();
let data = shared.lock().unwrap().clone().into_inner();
@@ -4188,8 +4466,10 @@ mod tests {
// lfeon(0) = 0b0100_0000 = 0x40. acmod_channels only needs >= 8 bytes.
let ac3 = vec![0x0B, 0x77, 0x00, 0x00, 0x00, 8 << 3, 0x40, 0x00];
// Open a cluster with a video keyframe first (cluster invariant).
muxer.write_frame(0, 0, true, &[0x01, 0x02], None).unwrap();
muxer.write_frame(1, 0, false, &ac3, None).unwrap();
muxer
.write_frame(0, 0, true, &[0x01, 0x02], None, None)
.unwrap();
muxer.write_frame(1, 0, false, &ac3, None, None).unwrap();
muxer.finish().unwrap();
let data = shared.lock().unwrap().clone().into_inner();
@@ -4234,6 +4514,203 @@ mod tests {
assert!(find_id(&data, ebml::BLOCK_ADDITION_MAPPING).is_none());
}
#[test]
fn mvc_decoder_config_record_matches_iso_14496_15_layout() {
// subset SPS (NAL type 15): [nal_hdr, profile, compat, level, ...].
let subset_sps = vec![0x6F, 0x80, 0x00, 0x33, 0x11, 0x22];
let pps = vec![0x68, 0xEE, 0x3C];
let rec = mvc_decoder_config_record(&subset_sps, &pps).expect("record builds");
// ISO/IEC 14496-15:2013 §7.6.2 byte layout, verbatim:
let expected: Vec<u8> = [
vec![
1, // configurationVersion
0x80, // AVCProfileIndication = subset_sps[1]
0x00, // profile_compatibility = subset_sps[2]
0x33, // AVCLevelIndication = subset_sps[3]
0xBF, // complete_rep(1) explicit_au(0) reserved'1111' lengthSizeMinusOne=3
0x01, // reserved'0'(1) numOfSequenceParameterSets(7)=1
0x00, 0x06, // sequenceParameterSetLength = 6
],
subset_sps.clone(),
vec![
1, // numOfPictureParameterSets
0x00, 0x03, // pictureParameterSetLength = 3
],
pps.clone(),
]
.concat();
assert_eq!(rec, expected, "MVCDecoderConfigurationRecord byte layout");
// Guards: too-short SPS and empty PPS both refuse (no corrupt record).
assert!(mvc_decoder_config_record(&[0x6F, 0x80, 0x00], &pps).is_none());
assert!(mvc_decoder_config_record(&subset_sps, &[]).is_none());
// Over-length param sets (> 65535) would mis-frame the 16-bit length
// field; both must refuse rather than emit a truncated record.
let huge = vec![0u8; 0x1_0000];
assert!(mvc_decoder_config_record(&huge, &pps).is_none());
assert!(mvc_decoder_config_record(&subset_sps, &huge).is_none());
}
#[test]
fn mvc_frame_emits_blockgroup_additional_and_reference() {
// A track declaring an mvcC mapping: a keyframe base frame carrying a
// dependent AU emits BlockGroup > BlockAdditions > BlockMore
// {BlockAddID=2, BlockAdditional=dep}; a following non-keyframe adds a
// ReferenceBlock so it is not mistaken for a seek point.
let mut v = make_video_track();
v.mvc_params = Some((
vec![0x6F, 0x80, 0x00, 0x33, 0x11, 0x22],
vec![0x68, 0xEE, 0x3C],
));
let mut muxer = MkvMuxer::new(Cursor::new(Vec::new()), &[v], None, 0.0, &[]).unwrap();
let dep_kf = [0xDEu8, 0xAD, 0xBE, 0xEF];
let dep_p = [0xCAu8, 0xFE];
muxer
.write_frame(0, 0, true, &[0x65, 0x01, 0x02], None, Some(&dep_kf))
.unwrap();
muxer
.write_frame(0, 40_000_000, false, &[0x41, 0x03], None, Some(&dep_p))
.unwrap();
let data = muxer.writer.into_inner();
assert!(
find_id(&data, ebml::BLOCK_ADDITIONS).is_some(),
"BlockAdditions (0x75A1) present"
);
// BlockAddID = 2 (uint): element 0xEE, size 0x81, value 0x02.
assert!(
data.windows(3).any(|w| w == [0xEE, 0x81, 0x02]),
"BlockAddID must be 2"
);
assert!(
data.windows(4).any(|w| w == dep_kf),
"keyframe dependent AU present as BlockAdditional"
);
assert!(
data.windows(2).any(|w| w == dep_p),
"non-keyframe dependent AU present"
);
assert!(
find_id(&data, ebml::REFERENCE_BLOCK).is_some(),
"non-keyframe MVC base frame must carry a ReferenceBlock"
);
}
#[test]
fn additional_dropped_when_track_has_no_mvc_mapping() {
// If a track did NOT declare an mvcC mapping (mvc_params None), a stray
// block_additional must be dropped (no orphaned BlockAddID=2 / no
// BlockAdditions) so the file stays conforming.
let mut muxer = MkvMuxer::new(
Cursor::new(Vec::new()),
&[make_video_track()],
None,
0.0,
&[],
)
.unwrap();
muxer
.write_frame(0, 0, true, &[0x65, 0x01], None, Some(&[0xDE, 0xAD]))
.unwrap();
let data = muxer.writer.into_inner();
assert!(
find_id(&data, ebml::BLOCK_ADDITIONS).is_none(),
"no BlockAdditions without a declared mvcC mapping"
);
}
#[test]
fn mvc_track_emits_mvcc_block_addition_mapping() {
// A track with mvc_params must emit BlockAdditionMapping (0x41E4) with the
// mvcC BlockAddIDType (0x6D766343) and a BlockAddIDValue (0x41F0), so
// players / mediainfo recognise the Blu-ray 3D dependent view.
let mut v = make_video_track();
v.mvc_params = Some((
vec![0x6F, 0x80, 0x00, 0x33, 0x11, 0x22],
vec![0x68, 0xEE, 0x3C],
));
let muxer = MkvMuxer::new(Cursor::new(Vec::new()), &[v], None, 0.0, &[]).unwrap();
let data = muxer.writer.into_inner();
assert!(
find_id(&data, ebml::BLOCK_ADDITION_MAPPING).is_some(),
"MVC track must emit BlockAdditionMapping"
);
assert!(
find_id(&data, ebml::BLOCK_ADD_ID_VALUE).is_some(),
"MVC mapping must carry BlockAddIDValue"
);
assert!(
data.windows(4).any(|w| w == [0x6D, 0x76, 0x63, 0x43]),
"mvcC fourcc must be present as the BlockAddIDType value"
);
// Without mvc_params, no mapping.
let muxer = MkvMuxer::new(
Cursor::new(Vec::new()),
&[make_video_track()],
None,
0.0,
&[],
)
.unwrap();
let data = muxer.writer.into_inner();
assert!(find_id(&data, ebml::BLOCK_ADDITION_MAPPING).is_none());
}
#[test]
fn mvc_codec_private_appends_mvcc_extension_block() {
// avcC ‖ u32be(4 + record.len()) ‖ "mvcC" ‖ record (Matroska Codec Spec §4.3.9).
let avcc = vec![0x01, 0x64, 0x00, 0x33, 0xFF, 0xE1, 0xAA];
let record = vec![0x01, 0x80, 0x00, 0x33, 0xBF, 0x01, 0xCC]; // 7 bytes
let out = mvc_codec_private(&avcc, &record);
assert_eq!(&out[..avcc.len()], &avcc[..], "avcC preserved verbatim");
// size field = 4 ("mvcC") + 7 (record) = 11 = extension block size minus 4.
assert_eq!(&out[avcc.len()..avcc.len() + 4], &11u32.to_be_bytes());
assert_eq!(&out[avcc.len() + 4..avcc.len() + 8], b"mvcC");
assert_eq!(
&out[avcc.len() + 8..],
&record[..],
"record after the mvcC fourcc"
);
}
#[test]
fn mvc_track_codec_private_carries_avcc_plus_mvcc() {
// An MVC base track's CodecPrivate must be the base avcC followed by the
// mvcC extension — the track-level signal mediainfo/decoders read.
let avcc = vec![
0x01, 0x64, 0x00, 0x33, 0xFF, 0xE1, 0x00, 0x05, 0x67, 0x64, 0x00, 0x33, 0x99,
];
let subset_sps = vec![0x6F, 0x80, 0x00, 0x33, 0x11, 0x22];
let pps = vec![0x68, 0xEE, 0x3C];
let mut v = make_video_track();
v.codec_private = Some(avcc.clone());
v.mvc_params = Some((subset_sps.clone(), pps.clone()));
let data = MkvMuxer::new(Cursor::new(Vec::new()), &[v], None, 0.0, &[])
.unwrap()
.writer
.into_inner();
let record = mvc_decoder_config_record(&subset_sps, &pps).unwrap();
let expected = mvc_codec_private(&avcc, &record);
assert!(
data.windows(expected.len())
.any(|w| w == expected.as_slice()),
"emitted CodecPrivate must be avcC + mvcC extension"
);
// A non-MVC (2D) track writes its avcC VERBATIM — no mvcC appended.
let mut v2 = make_video_track();
v2.codec_private = Some(avcc.clone());
let d2 = MkvMuxer::new(Cursor::new(Vec::new()), &[v2], None, 0.0, &[])
.unwrap()
.writer
.into_inner();
assert!(
!d2.windows(4).any(|w| w == b"mvcC"),
"2D track CodecPrivate must not carry an mvcC extension"
);
}
// ---- CodecPrivate emission (avcC / hvcC / VC-1 / MPEG-2) -------------
//
// `MkvTrack::video` always builds with `codec_private: None`; the PES mux
+549 -52
View File
@@ -120,14 +120,206 @@ struct PendingMux {
video_track: Option<usize>,
/// `--log-level 3` opening-capture side-file path (if any).
opening_capture_path: Option<std::path::PathBuf>,
/// Frames received before activation, replayed in order once built.
buffered: Vec<crate::pes::PesFrame>,
/// Frames received before activation, replayed in order once built. Each
/// carries an optional MVC dependent-view `BlockAdditional` (present only
/// for a 3D base-view frame that was already paired before activation).
buffered: Vec<(crate::pes::PesFrame, Option<Vec<u8>>)>,
}
/// Matroska container stream.
pub struct MkvStream {
disc_title: DiscTitle,
mode: Mode,
/// Blu-ray 3D (MVC) merge state — present iff the title carries an MVC
/// dependent (right-eye) view. Folds the dependent stream's frames into the
/// base video track as per-frame `BlockAdditional`, paired by PTS, so the
/// output is a single MVC track instead of two independent H.264 tracks.
mvc: Option<MvcMerge>,
}
/// Largest number of base frames held awaiting their PTS-matching dependent AU
/// before the oldest is flushed unpaired (a plain Block). The SSIF interleaves
/// base and dependent access units per unit, so a base's dependent normally
/// arrives within one or two frames; this window only bounds memory/latency for
/// a stream where the pairing drifts.
const MVC_PAIR_WINDOW: usize = 32;
/// A base-view frame (track already remapped to the muxer's base track index)
/// awaiting — or already carrying — its dependent-view `BlockAdditional`.
struct PendingBase {
frame: crate::pes::PesFrame,
additional: Option<Vec<u8>>,
}
/// State for folding the MVC dependent (right-eye) view into the base track.
struct MvcMerge {
/// `title.streams` index of the base (left-eye) video stream.
base_stream_idx: usize,
/// `title.streams` index of the dependent (right-eye) video stream.
dep_stream_idx: usize,
/// Muxer track index of the base view — where the dependent AU is attached
/// as a `BlockAdditional` and where `mvc_params` (the `mvcC` mapping) lives.
base_track_idx: usize,
/// `title.streams` index → muxer track index. The dependent maps to `None`
/// (it becomes a BlockAdditional, not a track); every other stream shifts
/// down by one if it followed the dependent in stream order.
stream_to_track: Vec<Option<usize>>,
/// Base frames (decode order) awaiting their dependent or a window flush.
pending_base: std::collections::VecDeque<PendingBase>,
/// Dependent AU data keyed by PTS, waiting for the matching base.
dep_by_pts: std::collections::HashMap<i64, Vec<u8>>,
/// `(subset_sps, pps)` from the first dependent AU — builds the `mvcC`
/// MVCDecoderConfigurationRecord for the base track's BlockAdditionMapping.
captured_params: Option<(Vec<u8>, Vec<u8>)>,
/// Count of dependent AUs dropped with no matching base (diagnostic).
orphan_deps: u64,
}
impl MvcMerge {
/// Ingest one incoming frame; returns `(frame, additional)` pairs ready to
/// hand to the muxer, in emit order. Base frames buffer briefly to pair with
/// their dependent by PTS; the dependent stream produces no frames of its own
/// (it becomes `BlockAdditional`); all other streams pass straight through
/// with their track index remapped.
fn ingest(
&mut self,
frame: &crate::pes::PesFrame,
) -> Vec<(crate::pes::PesFrame, Option<Vec<u8>>)> {
let mut out = Vec::new();
if frame.track == self.dep_stream_idx {
if self.captured_params.is_none() {
self.captured_params = extract_mvc_params(&frame.data);
}
// Attach to a waiting base of the same PTS, else stash by PTS.
if let Some(pb) = self
.pending_base
.iter_mut()
.find(|pb| pb.frame.pts == frame.pts && pb.additional.is_none())
{
pb.additional = Some(frame.data.clone());
} else {
// Bound the orphan map BEFORE inserting: if dependents pile up
// unpaired (pairing badly drifted), drop the drifted buffer so it
// stays bounded — but keep THIS just-arrived dependent, whose base
// frame commonly arrives next. Clearing after the insert would
// discard it and overcount orphans by one.
if self.dep_by_pts.len() >= MVC_PAIR_WINDOW * 4 {
self.orphan_deps += self.dep_by_pts.len() as u64;
self.dep_by_pts.clear();
}
// A duplicate-PTS dependent (e.g. a stale repeat after a stream
// discontinuity) displaces the prior one — count it as an orphan
// rather than losing it silently.
if self
.dep_by_pts
.insert(frame.pts, frame.data.clone())
.is_some()
{
self.orphan_deps += 1;
}
}
} else if frame.track == self.base_stream_idx {
let additional = self.dep_by_pts.remove(&frame.pts);
let mut remapped = frame.clone();
remapped.track = self.base_track_idx;
self.pending_base.push_back(PendingBase {
frame: remapped,
additional,
});
} else {
// Audio / subtitle / other video: remap the track index and forward.
let mut remapped = frame.clone();
if let Some(Some(t)) = self.stream_to_track.get(frame.track) {
remapped.track = *t;
out.push((remapped, None));
}
}
self.drain_ready(&mut out);
out
}
/// Emit base frames from the FIFO front once each has its dependent attached,
/// or flush the oldest unpaired base as a plain Block when the window is full.
fn drain_ready(&mut self, out: &mut Vec<(crate::pes::PesFrame, Option<Vec<u8>>)>) {
loop {
let front_ready = self
.pending_base
.front()
.map(|pb| pb.additional.is_some())
.unwrap_or(false);
if front_ready || self.pending_base.len() > MVC_PAIR_WINDOW {
if let Some(pb) = self.pending_base.pop_front() {
out.push((pb.frame, pb.additional));
continue;
}
}
break;
}
}
/// Flush every remaining buffered base frame (unpaired → plain Block) at EOF.
fn flush(&mut self) -> Vec<(crate::pes::PesFrame, Option<Vec<u8>>)> {
let mut out = Vec::new();
for pb in self.pending_base.drain(..) {
out.push((pb.frame, pb.additional));
}
self.orphan_deps += self.dep_by_pts.len() as u64;
self.dep_by_pts.clear();
out
}
}
/// Hand a frame to the muxer, attaching the MVC dependent view as a
/// `BlockAdditional` when `additional` is `Some` (a 3D base frame), else a
/// plain block.
fn emit_to_muxer(
m: &mut MkvMuxer<Box<dyn WriteSeek + Send>>,
frame: &crate::pes::PesFrame,
additional: Option<&[u8]>,
) -> io::Result<()> {
m.write_frame(
frame.track,
frame.pts,
frame.keyframe,
&frame.data,
frame.duration_ns,
additional,
)
}
/// Scan a length-prefixed (4-byte big-endian) H.264 NAL stream for the first
/// subset SPS (NAL type 15) and first PPS (NAL type 8) — the two parameter sets
/// that populate the `mvcC` MVCDecoderConfigurationRecord. Returns
/// `Some((subset_sps, pps))` only when BOTH are found; `None` otherwise (the
/// serializer then emits no mvcC mapping and logs it).
fn extract_mvc_params(data: &[u8]) -> Option<(Vec<u8>, Vec<u8>)> {
let mut subset_sps: Option<Vec<u8>> = None;
let mut pps: Option<Vec<u8>> = None;
let mut i = 0usize;
while i + 4 <= data.len() {
let len = u32::from_be_bytes([data[i], data[i + 1], data[i + 2], data[i + 3]]) as usize;
i += 4;
// A zero-length NAL (a stray length prefix) is skipped, not fatal — the
// subset SPS / PPS may still follow. A length that runs past the buffer
// end IS unrecoverable (the NAL can't be read), so stop there.
if len == 0 {
continue;
}
if i + len > data.len() {
break;
}
let nal = &data[i..i + len];
i += len;
match nal[0] & 0x1F {
15 if subset_sps.is_none() => subset_sps = Some(nal.to_vec()),
8 if pps.is_none() => pps = Some(nal.to_vec()),
_ => {}
}
if subset_sps.is_some() && pps.is_some() {
break;
}
}
Some((subset_sps?, pps?))
}
impl MkvStream {
@@ -146,10 +338,38 @@ impl MkvStream {
title: &DiscTitle,
output_path: Option<&std::path::Path>,
) -> io::Result<Self> {
// Blu-ray 3D (MVC): a dependent (right-eye) view stream is NOT emitted as
// its own track — it is folded into the base track as per-frame
// BlockAdditional. Detect it so we skip building a track for it and set up
// the merge. `base_stream_idx` is the first video stream.
let dep_stream_idx = title
.streams
.iter()
.position(|s| matches!(s, crate::disc::Stream::Video(v) if v.is_mvc_dependent()));
// The base is the first NON-dependent video. Excluding the dependent here
// means a (malformed / hand-built) title whose only video IS the dependent
// yields `base_stream_idx == None` → no merge (the dependent is muxed as an
// ordinary track) instead of `base == dep` and a panic on the skipped slot.
let base_stream_idx = title
.streams
.iter()
.position(|s| matches!(s, crate::disc::Stream::Video(v) if !v.is_mvc_dependent()));
// The merge is only active when BOTH a dependent and a distinct base exist;
// only then is the dependent's track skipped/folded.
let mvc_active = dep_stream_idx.is_some() && base_stream_idx.is_some();
let skip_stream_idx = if mvc_active { dep_stream_idx } else { None };
let mut tracks = Vec::new();
let mut has_default_video = false;
let mut has_default_audio = false;
// `title.streams` index → muxer track index (`None` = the dependent view,
// which has no track). Streams after the dependent shift down by one.
let mut stream_to_track: Vec<Option<usize>> = Vec::with_capacity(title.streams.len());
for (idx, s) in title.streams.iter().enumerate() {
if Some(idx) == skip_stream_idx {
stream_to_track.push(None);
continue;
}
let mut track = match s {
crate::disc::Stream::Video(v) => MkvTrack::video(v),
crate::disc::Stream::Audio(a) => MkvTrack::audio(a),
@@ -166,9 +386,31 @@ impl MkvStream {
if let Some(cp) = title.codec_privates.get(idx).and_then(|c| c.as_ref()) {
track.codec_private = Some(cp.clone());
}
stream_to_track.push(Some(tracks.len()));
tracks.push(track);
}
// Assemble the MVC merge only when active — i.e. a dependent AND a
// distinct base video both exist (established above). `base_stream_idx`
// then always has a built track, so its remap is `Some` (no panic path).
let mvc = match (mvc_active, dep_stream_idx, base_stream_idx) {
(true, Some(dep_stream_idx), Some(base_stream_idx)) => stream_to_track
.get(base_stream_idx)
.copied()
.flatten()
.map(|base_track_idx| MvcMerge {
base_stream_idx,
dep_stream_idx,
base_track_idx,
stream_to_track,
pending_base: std::collections::VecDeque::new(),
dep_by_pts: std::collections::HashMap::new(),
captured_params: None,
orphan_deps: 0,
}),
_ => None,
};
// Defer muxer construction (and the TrackEntry dump) until the first
// coded picture arrives, so the primary video track's FieldOrder is set
// from the parser's MEASURED value before the header is written — never
@@ -177,6 +419,7 @@ impl MkvStream {
Ok(Self {
disc_title: title.clone(),
mvc,
mode: Mode::Write(WriteMode::Pending(Box::new(PendingMux {
writer,
tracks,
@@ -209,6 +452,24 @@ impl MkvStream {
if let Some(vt) = pending.video_track {
apply_coding_to_track(&mut pending.tracks[vt], coding, video_picture_seen);
}
// Blu-ray 3D: set the base video track's `mvc_params` from the dependent
// view's captured subset-SPS/PPS BEFORE the header is written, so the
// TrackEntry carries the `mvcC` BlockAdditionMapping. Captured from the
// first dependent AU (which arrives right after the first base AU in the
// SSIF), so it is available by the time the first base frame activates.
if let Some(mvc) = &self.mvc {
if let Some(params) = &mvc.captured_params {
if let Some(t) = pending.tracks.get_mut(mvc.base_track_idx) {
t.mvc_params = Some(params.clone());
}
} else {
tracing::warn!(
target: "mux",
"MVC: no dependent-view subset-SPS/PPS captured before activation; \
the base track will carry no mvcC mapping (3D not signalled)."
);
}
}
// --log-level 3: dump the FINAL TrackEntry metadata (field order set).
for (i, track) in pending.tracks.iter().enumerate() {
crate::diag::dump_mkv_track((i + 1) as u64, track);
@@ -223,18 +484,72 @@ impl MkvStream {
if let Some(path) = &pending.opening_capture_path {
muxer.set_opening_capture(crate::diag::OpeningCapture::new(path, pending.tracks.len()));
}
for f in pending.buffered.drain(..) {
muxer.write_frame(f.track, f.pts, f.keyframe, &f.data, f.duration_ns)?;
for (f, additional) in pending.buffered.drain(..) {
muxer.write_frame(
f.track,
f.pts,
f.keyframe,
&f.data,
f.duration_ns,
additional.as_deref(),
)?;
}
self.mode = Mode::Write(WriteMode::Active(Box::new(muxer)));
Ok(())
}
/// Emit one frame (track index already muxer-relative) with an optional MVC
/// dependent-view `BlockAdditional`, honouring the deferred-activation
/// machinery: the first video frame triggers muxer construction (its coding
/// sets FieldOrder); earlier frames buffer. `additional` is `None` for every
/// non-3D frame and for the 3D base frames that had no paired dependent.
fn emit(&mut self, frame: &crate::pes::PesFrame, additional: Option<&[u8]>) -> io::Result<()> {
match &mut self.mode {
Mode::Read(_) => return Err(crate::error::Error::StreamReadOnly.into()),
Mode::Write(WriteMode::Active(m)) => {
return emit_to_muxer(m, frame, additional);
}
Mode::Write(WriteMode::Building) => return Ok(()),
Mode::Write(WriteMode::Pending(_)) => {}
}
// Pending: the first video frame (or the safety cap) triggers muxer
// construction; that frame's coding sets the field order. Other frames
// buffer until then.
let (activate_now, use_coding) = match &self.mode {
Mode::Write(WriteMode::Pending(p)) => {
let is_video = match p.video_track {
Some(vt) => frame.track == vt,
// No video track: nothing to wait for — build on frame one.
None => true,
};
(is_video || p.buffered.len() >= MAX_PENDING_FRAMES, is_video)
}
_ => unreachable!("guarded above"),
};
if activate_now {
// Pass the trigger frame's coding only when it IS the video frame; a
// cap-triggered build never saw the video frame, so nothing measured
// is passed (apply_coding_to_track then logs + leaves UNDETERMINED).
self.activate(if use_coding { frame.coding } else { None }, use_coding)?;
if let Mode::Write(WriteMode::Active(m)) = &mut self.mode {
return emit_to_muxer(m, frame, additional);
}
Ok(())
} else {
if let Mode::Write(WriteMode::Pending(p)) = &mut self.mode {
p.buffered
.push((frame.clone(), additional.map(|a| a.to_vec())));
}
Ok(())
}
}
/// Open an MKV file for reading → PES frames.
pub fn open(mut reader: impl Read + Send + 'static) -> io::Result<Self> {
let (disc_title, codec_privates, ts_scale_ns) = parse_mkv_header(&mut reader)?;
Ok(Self {
disc_title,
mvc: None,
mode: Mode::Read(ReadState {
reader: Box::new(reader),
cluster_ts_ticks: 0,
@@ -425,59 +740,40 @@ impl crate::pes::Stream for MkvStream {
}
fn write(&mut self, frame: &crate::pes::PesFrame) -> io::Result<()> {
// Fast paths.
match &mut self.mode {
Mode::Read(_) => return Err(crate::error::Error::StreamReadOnly.into()),
Mode::Write(WriteMode::Active(m)) => {
return m.write_frame(
frame.track,
frame.pts,
frame.keyframe,
&frame.data,
frame.duration_ns,
);
}
Mode::Write(WriteMode::Building) => return Ok(()),
Mode::Write(WriteMode::Pending(_)) => {}
if matches!(self.mode, Mode::Read(_)) {
return Err(crate::error::Error::StreamReadOnly.into());
}
// Pending: the first video frame (or the safety cap) triggers muxer
// construction; that frame's coding sets the field order. Other frames
// buffer until then.
let (activate_now, use_coding) = match &self.mode {
Mode::Write(WriteMode::Pending(p)) => {
let is_video = match p.video_track {
Some(vt) => frame.track == vt,
// No video track: nothing to wait for — build on frame one.
None => true,
};
(is_video || p.buffered.len() >= MAX_PENDING_FRAMES, is_video)
}
_ => unreachable!("guarded above"),
};
if activate_now {
// Pass the trigger frame's coding only when it IS the video frame; a
// cap-triggered build never saw the video frame, so nothing measured
// is passed (apply_coding_to_track then logs + leaves UNDETERMINED).
self.activate(if use_coding { frame.coding } else { None }, use_coding)?;
if let Mode::Write(WriteMode::Active(m)) = &mut self.mode {
return m.write_frame(
frame.track,
frame.pts,
frame.keyframe,
&frame.data,
frame.duration_ns,
);
}
Ok(())
} else {
if let Mode::Write(WriteMode::Pending(p)) = &mut self.mode {
p.buffered.push(frame.clone());
}
Ok(())
// Non-3D fast path: emit the frame directly, no clone, no buffering.
if self.mvc.is_none() {
return self.emit(frame, None);
}
// Blu-ray 3D: run the frame through the MVC merge, which remaps track
// indices, folds the dependent view into the base as BlockAdditional
// (paired by PTS), and yields 0+ frames ready to emit. `ingest` returns
// owned pairs so the `self.mvc` borrow is released before `emit`.
let emits = self.mvc.as_mut().unwrap().ingest(frame);
for (f, additional) in emits {
self.emit(&f, additional.as_deref())?;
}
Ok(())
}
fn finish(&mut self) -> io::Result<()> {
// Blu-ray 3D: flush any base frames still awaiting a dependent (emitted
// unpaired as plain Blocks) before finalizing.
if let Some(mvc) = self.mvc.as_mut() {
let tail = mvc.flush();
let orphans = mvc.orphan_deps;
if orphans > 0 {
tracing::debug!(
target: "mux",
"MVC: {orphans} dependent-view access units had no matching base frame (dropped)"
);
}
for (f, additional) in tail {
self.emit(&f, additional.as_deref())?;
}
}
// A title that produced no frames (or only buffered ones) is still
// finalized into a valid MKV: activate now with no measured coding.
if matches!(self.mode, Mode::Write(WriteMode::Pending(_))) {
@@ -916,6 +1212,207 @@ mod tests {
use crate::pes::Stream as _;
use std::io::Cursor;
/// Length-prefix (4-byte big-endian) each NAL, as the H.264 parser emits.
fn lp(nals: &[&[u8]]) -> Vec<u8> {
let mut v = Vec::new();
for n in nals {
v.extend_from_slice(&(n.len() as u32).to_be_bytes());
v.extend_from_slice(n);
}
v
}
fn mvc_frame(track: usize, pts: i64, keyframe: bool, data: Vec<u8>) -> crate::pes::PesFrame {
crate::pes::PesFrame {
track,
pts,
keyframe,
data,
duration_ns: None,
source: None,
coding: None,
}
}
// A subset SPS (NAL type 15), a PPS (type 8), and a coded-slice-extension
// (type 20) — the shape of a dependent-view access unit.
const SUBSET_SPS: [u8; 5] = [0x6F, 0x80, 0x00, 0x33, 0xAA]; // 0x6F & 0x1F = 15
const DEP_PPS: [u8; 3] = [0x68, 0xEE, 0x3C]; // 0x68 & 0x1F = 8
const DEP_SLICE: [u8; 3] = [0x74, 0x11, 0x22]; // 0x74 & 0x1F = 20
#[test]
fn extract_mvc_params_finds_subset_sps_and_pps() {
let data = lp(&[&SUBSET_SPS, &DEP_PPS, &DEP_SLICE]);
let (s, p) = extract_mvc_params(&data).expect("both param sets present");
assert_eq!(s, SUBSET_SPS, "subset SPS (NAL 15) captured verbatim");
assert_eq!(p, DEP_PPS, "PPS (NAL 8) captured verbatim");
// Missing PPS → None (the serializer then emits no mvcC mapping).
assert!(extract_mvc_params(&lp(&[&SUBSET_SPS, &DEP_SLICE])).is_none());
// Missing subset SPS → None.
assert!(extract_mvc_params(&lp(&[&DEP_PPS, &DEP_SLICE])).is_none());
}
fn empty_merge() -> MvcMerge {
MvcMerge {
base_stream_idx: 0,
dep_stream_idx: 2,
base_track_idx: 0,
stream_to_track: vec![Some(0), Some(1), None],
pending_base: std::collections::VecDeque::new(),
dep_by_pts: std::collections::HashMap::new(),
captured_params: None,
orphan_deps: 0,
}
}
#[test]
fn mvc_merge_pairs_base_and_dependent_by_pts() {
let mut m = empty_merge();
let dep = lp(&[&SUBSET_SPS, &DEP_PPS, &DEP_SLICE]);
// Base arrives first (SSIF order): buffered, nothing emitted yet.
let e = m.ingest(&mvc_frame(0, 100, true, lp(&[&[0x65, 1, 2]])));
assert!(e.is_empty(), "base held until its dependent arrives");
// Dependent arrives → base is emitted, remapped to the base track, with
// the dependent AU as its BlockAdditional; params are captured.
let e = m.ingest(&mvc_frame(2, 100, false, dep.clone()));
assert_eq!(e.len(), 1, "the paired base frame is emitted");
assert_eq!(e[0].0.track, 0, "remapped to the base muxer track");
assert_eq!(
e[0].1.as_deref(),
Some(dep.as_slice()),
"dependent attached"
);
assert!(m.captured_params.is_some(), "mvcC params captured");
// Audio passes straight through (remapped, no additional).
let e = m.ingest(&mvc_frame(1, 100, true, vec![0xAA]));
assert_eq!(e.len(), 1);
assert_eq!(e[0].0.track, 1);
assert!(e[0].1.is_none());
// Dependent-before-base (reordered) also pairs.
let dep2 = lp(&[&DEP_SLICE]);
assert!(m.ingest(&mvc_frame(2, 200, false, dep2.clone())).is_empty());
let e = m.ingest(&mvc_frame(0, 200, false, lp(&[&[0x61, 3, 4]])));
assert_eq!(e.len(), 1);
assert_eq!(e[0].1.as_deref(), Some(dep2.as_slice()));
}
#[test]
fn mvc_merge_flushes_unpaired_base_at_eof() {
let mut m = empty_merge();
// Base with no dependent ever → held, then flushed unpaired at EOF.
assert!(
m.ingest(&mvc_frame(0, 10, true, vec![0, 0, 0, 1]))
.is_empty()
);
let tail = m.flush();
assert_eq!(tail.len(), 1, "unpaired base still emitted");
assert!(tail[0].1.is_none(), "no BlockAdditional when unpaired");
}
#[test]
fn extract_mvc_params_no_panic_on_truncated_or_empty() {
// Empty, sub-header, zero-length NAL, and a length prefix claiming more
// than is present must all return None without panicking (untrusted AU).
assert!(extract_mvc_params(&[]).is_none());
assert!(extract_mvc_params(&[0, 0, 0]).is_none());
assert!(
extract_mvc_params(&[0, 0, 0, 0]).is_none(),
"lone zero-length NAL yields no params"
);
assert!(
extract_mvc_params(&[0, 0, 0, 10, 0x6F]).is_none(),
"length prefix past end breaks, no slice panic"
);
// A zero-length NAL is SKIPPED, not fatal: valid param sets that follow
// are still found (a stray length prefix must not abandon the whole AU).
let mut d = vec![0, 0, 0, 0];
d.extend_from_slice(&lp(&[&SUBSET_SPS, &DEP_PPS]));
let (s, p) = extract_mvc_params(&d).expect("params found past the zero-length NAL");
assert_eq!(s, SUBSET_SPS);
assert_eq!(p, DEP_PPS);
}
#[test]
fn mvc_merge_flushes_oldest_base_once_past_window() {
let mut m = empty_merge();
// Push more unpaired base frames than the window; the excess flush as
// plain (unpaired) blocks in FIFO order once len exceeds MVC_PAIR_WINDOW.
let n = MVC_PAIR_WINDOW + 8;
let mut emitted = 0usize;
for pts in 0..n {
emitted += m
.ingest(&mvc_frame(0, pts as i64, false, vec![0, 0, 0, 1]))
.len();
}
assert_eq!(emitted, 8, "the {n} bases beyond the window flush unpaired");
assert_eq!(m.pending_base.len(), MVC_PAIR_WINDOW, "window still held");
assert!(m.flush().iter().all(|(_, add)| add.is_none()));
}
#[test]
fn mvc_merge_dep_overflow_drops_old_keeps_newest() {
let mut m = empty_merge();
// Fill dep_by_pts to the bound with unpaired dependents (unique PTS).
for pts in 0..(MVC_PAIR_WINDOW * 4) {
assert!(
m.ingest(&mvc_frame(2, pts as i64, false, lp(&[&DEP_SLICE])))
.is_empty()
);
}
assert_eq!(m.dep_by_pts.len(), MVC_PAIR_WINDOW * 4);
// One more overflows: the drifted buffer is cleared BUT the newest survives
// so its (soon-to-arrive) base can still pair.
let dep_new = lp(&[&DEP_SLICE]);
m.ingest(&mvc_frame(2, 9_999, false, dep_new.clone()));
assert_eq!(m.dep_by_pts.len(), 1, "old cleared, newest kept");
assert!(m.dep_by_pts.contains_key(&9_999));
assert_eq!(
m.orphan_deps,
(MVC_PAIR_WINDOW * 4) as u64,
"old buffer counted once"
);
// The surviving dependent pairs with its base.
let e = m.ingest(&mvc_frame(0, 9_999, false, vec![0x61, 1]));
assert_eq!(e.len(), 1);
assert_eq!(e[0].1.as_deref(), Some(dep_new.as_slice()));
}
#[test]
fn create_does_not_panic_when_only_video_is_mvc_dependent() {
// A (malformed / hand-built) title whose single video IS the dependent
// must NOT panic: base_stream_idx is None, so no merge is set up and the
// dependent is muxed as an ordinary track.
use crate::disc::{
Codec, ColorSpace, DiscTitle, FrameRate, HdrFormat, Resolution, Stream, VideoStream,
};
let dep = VideoStream {
pid: 0x1012,
codec: Codec::H264,
resolution: Resolution::R1080p,
frame_rate: FrameRate::F24,
hdr: HdrFormat::Sdr,
color_space: ColorSpace::Bt709,
display_aspect: None,
secondary: true,
label: crate::disc::MVC_DEPENDENT_LABEL.to_string(),
measured_cicp: None,
};
let title = DiscTitle {
streams: vec![Stream::Video(dep)],
..DiscTitle::empty()
};
let s = MkvStream::create(Box::new(Cursor::new(Vec::new())), &title)
.expect("create must succeed, not panic");
assert!(
s.mvc.is_none(),
"no merge when there is no distinct base view"
);
}
#[test]
fn apply_coding_to_track_sets_measured_field_order_never_guesses() {
use crate::disc::{Codec, ColorSpace, FrameRate, HdrFormat, Resolution, VideoStream};
+11 -1
View File
@@ -575,7 +575,17 @@ fn build_demux_state(title: &DiscTitle, format: ContentFormat) -> DemuxState {
pids.push(pid);
pid_to_track.push((pid, idx));
let is_dvd_ps = matches!(format, ContentFormat::MpegPs);
parsers.push((pid, super::codec::parser_for_codec(codec, None, is_dvd_ps)));
// The Blu-ray 3D MVC dependent (right-eye) view uses a param-set-
// passthrough H.264 parser so each frame is a self-contained dependent
// access unit for a BlockAdditional; every other stream uses the
// ordinary parser for its codec.
let parser = match s {
crate::disc::Stream::Video(v) if v.is_mvc_dependent() => {
super::codec::parser_for_mvc_dependent(codec, is_dvd_ps)
}
_ => super::codec::parser_for_codec(codec, None, is_dvd_ps),
};
parsers.push((pid, parser));
}
let (ts, ps) = match format {
ContentFormat::MpegPs => (None, Some(super::ps::PsDemuxer::new())),
+58 -5
View File
@@ -593,10 +593,27 @@ impl UdfFs {
match next_block {
Some(cont_lba) => {
read_sector(reader, self.meta_to_abs(cont_lba)?, &mut block)?;
// A continuation block is a list of Short ADs from byte 0,
// spanning the whole sector.
ad_start = 0;
ad_bytes = block.len();
// A continuation block does NOT begin with allocation
// descriptors — it begins with a 24-byte Allocation Extent
// Descriptor (ECMA-167 4/14.5, tag id 258): the 16-byte
// descriptor tag, then prev_allocation_extent_location
// (Uint32 @16) and length_of_allocation_descriptors
// (Uint32 @20). The real ADs (same Short/Long/Extended type
// as the file) start at offset 24, and their total byte
// length is that @20 field.
//
// Reading from offset 0 parses the AED's own tag header as
// allocation descriptors → one garbage extent, then an
// unknown extent_type break — silently truncating every
// file whose ADs spill into a continuation block (a heavily
// fragmented file, e.g. a Blu-ray 3D interleaved base-view
// .m2ts with ~1600 fragments). A normal few-extent .m2ts
// fits inline and never reaches here, which is why this
// stayed hidden.
let aed_l_ad =
u32::from_le_bytes([block[20], block[21], block[22], block[23]]) as usize;
ad_start = 24;
ad_bytes = aed_l_ad.min(block.len().saturating_sub(24));
}
None => break,
}
@@ -1535,8 +1552,15 @@ mod tests {
/// A continuation block: a bare list of short ADs from byte 0.
fn build_cont_block(ads: &[(u32, u32, u32)]) -> [u8; 2048] {
// A continuation block is an Allocation Extent Descriptor (ECMA-167
// 4/14.5): a 16-byte descriptor tag, then prev_allocation_extent_location
// (Uint32 @16) and length_of_allocation_descriptors (Uint32 @20). The
// actual allocation descriptors begin at offset 24. The parser skips the
// 24-byte header and reads `l_ad` bytes of ADs from there.
let mut s = [0u8; 2048];
let mut off = 0usize;
let l_ad = (ads.len() * 8) as u32;
s[20..24].copy_from_slice(&l_ad.to_le_bytes());
let mut off = 24usize;
for &(etype, dlen, dlba) in ads {
let raw_len = (etype << 30) | (dlen & 0x3FFF_FFFF);
s[off..off + 4].copy_from_slice(&raw_len.to_le_bytes());
@@ -1590,6 +1614,35 @@ mod tests {
assert_eq!(extents, vec![(10, 4096), (20, 2048)]);
}
#[test]
fn icb_extents_continuation_skips_aed_header_not_read_as_extent() {
// Regression (Blu-ray 3D): a continuation block begins with a 24-byte
// Allocation Extent Descriptor whose 16-byte descriptor tag holds
// NON-zero bytes (tag id 258, CRC, location, ...). Reading allocation
// descriptors from offset 0 parses that tag header as an AD → a garbage
// extent, then an unknown extent_type break → every fragment past the
// first continuation is lost. On a 3D disc that truncated the 25.8 GB
// interleaved base-view feature to ~1.8 GB (113 of ~1600 fragments) and
// then choked the mux on the bogus, non-unit-aligned extent. The parser
// MUST skip the 24-byte AED header and read the real ADs from offset 24.
let icb = build_efe(6144, &[(0, 4096, 10), (3, 2048, 50)]);
let mut cont = build_cont_block(&[(0, 2048, 20)]);
// Stamp a realistic AED descriptor tag (id 258) into the header so a
// regression that reads from offset 0 mis-parses it as a bogus extent
// instead of finding the real (20, 2048) at offset 24.
cont[0..2].copy_from_slice(&258u16.to_le_bytes());
let mut reader = MapReader::new();
reader.put(5, icb);
reader.put(50, cont);
let fs = fs_with(0, 0, file_entry("3D", 5, 6144));
let extents = fs.read_icb_extents(&mut reader, 5).expect("extents");
assert_eq!(
extents,
vec![(10, 4096), (20, 2048)],
"continuation ADs must be read past the 24-byte AED header, not from offset 0"
);
}
#[test]
fn icb_extents_long_ad_returns_all_extents_not_just_first() {
// Regression: BD-ROM large .m2ts files use Long ADs (16-byte