mirror of
https://github.com/freemkv/libfreemkv.git
synced 2026-10-07 18:13:10 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eaa7ddc683 | ||
|
|
723c7edf45 | ||
|
|
d9881c57c0 | ||
|
|
61b4486ae9 | ||
|
|
72d39544ac | ||
|
|
1e394eaeb8 | ||
|
|
4d1aa6bbf7 | ||
|
|
9ce03b4d1d | ||
|
|
f099a4a31a | ||
|
|
e606b5e3a2 | ||
|
|
de809189ec | ||
|
|
253b0924e3 | ||
|
|
98a7892c34 | ||
|
|
b82d21756a | ||
|
|
02c584a3d9 |
@@ -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
@@ -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
@@ -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();
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user