Harden mux + decrypt paths; fail-loud on unresolvable keys
mp4 demuxer (untrusted input): bound every allocation sized from a box field (stsz/stco/stsc counts, stts/ctts run-lengths, per-sample and moov sizes, plus an absolute cap so a sparse file can't inflate file_len); guard the parse_stsd slice and a zero mdhd timescale; cap track count so the per-track PID can't overflow; rewrite read_moov to handle size==0 / size<8 / 64-bit largesize; parse esds/AudioSpecificConfig for AAC; write tkhd duration in the movie timescale. decrypt: resolve_mux_key_map now fails loud on an extent no key can classify instead of inheriting the previous extent's key, so a keymap never silently carries a wrong key; the sweep/patch key-fetch recovery fails loud when a unit is still unresolved after the retry. AACS: reject inverted forensic segments in both range builders; compare the forensic index in u16 space so an out-of-range value can't truncate onto a valid u8 index. RECOVERED_ERROR no longer latches the damage zone, preserving the 30s wedge cooldown for a following hard error. audio: AAC/MP2/MP3/FLAC carry the last PTS across a PES with no timestamp; the DTS-HD extension-sync search is bounded to after the core; the MP4 16.16 sample-rate field saturates. demux_sink records the video reference before the kind filter so audio:// / sub:// keep multi-clip PTS continuity and the DELAY tag. Remove a dead error variant and the AACS-unsupported-video code; codec comments cite the primary format specs; assorted doc/naming fixes and regression tests throughout.
This commit is contained in:
+1
-1
@@ -1284,7 +1284,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── ts_sync_destroyed / ts_sync_count edge cases ───────────────────────
|
// ── is_clean / ts_sync_count edge cases ────────────────────────────────
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn ts_sync_destroyed_false_for_sub_unit_length() {
|
fn ts_sync_destroyed_false_for_sub_unit_length() {
|
||||||
|
|||||||
@@ -63,11 +63,17 @@ pub fn unit_disposition(
|
|||||||
None => UnitDisposition::Default,
|
None => UnitDisposition::Default,
|
||||||
// In a forensic segment → decide by whether it is our index.
|
// In a forensic segment → decide by whether it is our index.
|
||||||
Some(seg) => {
|
Some(seg) => {
|
||||||
let seg_index = seg.index as u8;
|
// `seg.index` is an untrusted u16 from IndividualSegment.tbl; a real
|
||||||
|
// forensic index is 1..=32. Compare in u16 space so a corrupt/crafted
|
||||||
|
// index above 255 can't truncate into a valid u8 and alias our index.
|
||||||
|
// The disposition carries a u8 for diagnostics (saturated — an
|
||||||
|
// out-of-range index is never ours anyway).
|
||||||
|
let seg_index = seg.index;
|
||||||
|
let diag = seg_index.min(u8::MAX as u16) as u8;
|
||||||
match disc_index {
|
match disc_index {
|
||||||
Some(v) if v == seg_index => UnitDisposition::Index(v),
|
Some(v) if u16::from(v) == seg_index => UnitDisposition::Index(v),
|
||||||
Some(_) => UnitDisposition::DropForeignIndex(seg_index),
|
Some(_) => UnitDisposition::DropForeignIndex(diag),
|
||||||
None => UnitDisposition::ForensicNoKey(seg_index),
|
None => UnitDisposition::ForensicNoKey(diag),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -161,6 +167,20 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn out_of_range_index_does_not_truncate_into_ours() {
|
||||||
|
// A crafted/corrupt segment index of 288 (0x0120) truncates to 32 in a
|
||||||
|
// u8. With our disc index resolved as 32, the old `seg.index as u8`
|
||||||
|
// compare would alias it to OUR index and decrypt with the wrong key.
|
||||||
|
// The u16 compare must instead classify it as foreign.
|
||||||
|
let segs = tbl(&[(288, 100, 200)]);
|
||||||
|
let off = 120u64 * SOURCE_PACKET_LEN;
|
||||||
|
assert_eq!(
|
||||||
|
unit_disposition(off, &segs, Some(32)),
|
||||||
|
UnitDisposition::DropForeignIndex(255)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn straddling_unit_still_classified_as_its_segment() {
|
fn straddling_unit_still_classified_as_its_segment() {
|
||||||
// A unit whose 32-packet span only tails into the segment still routes
|
// A unit whose 32-packet span only tails into the segment still routes
|
||||||
|
|||||||
+8
-10
@@ -126,16 +126,14 @@ pub(crate) fn role_paths(udf: &crate::udf::UdfFs, role: AacsRole) -> Vec<String>
|
|||||||
// VTKF000 (Freedom ships VTKF090 + VTKF100). Sorted for a
|
// VTKF000 (Freedom ships VTKF090 + VTKF100). Sorted for a
|
||||||
// deterministic try order.
|
// deterministic try order.
|
||||||
//
|
//
|
||||||
// TODO(hddvd-playlist): each VTKF%%%.AACS is bound to ONE
|
// Each VTKF%%%.AACS is bound to ONE playlist (VPLST%%%.XPL): the
|
||||||
// playlist (VPLST%%%.XPL) — the AACS HD DVD Book gives the
|
// TKF's 12-byte PLAYLIST_NAME field (bytes 0x10..0x1C) names the
|
||||||
// selector explicitly: match the TKF's 12-byte PLAYLIST_NAME
|
// playlist whose Title Keys it carries, and keys from a TKF whose
|
||||||
// field (bytes 0x10..0x1C) to the playlist of the title being
|
// name does not match the title's playlist must not be used. The
|
||||||
// decrypted; "unless the names are identical, the Title Keys in
|
// caller resolves this by trying candidates in sorted order and
|
||||||
// this TKF must not be used." Today read_first just takes the
|
// decrypting with the one whose keys verify — correct for a
|
||||||
// first that reads, which is correct only for a single-playlist
|
// single-playlist disc; a name-matched selection keyed on the
|
||||||
// disc. Thread the active playlist name here (owned by the HD
|
// active playlist is the precise form for multi-playlist discs.
|
||||||
// DVD enumerator) and pick the name-matched VTKF once a
|
|
||||||
// multi-playlist encrypted disc is available to validate against.
|
|
||||||
let mut names: Vec<&str> = dir
|
let mut names: Vec<&str> = dir
|
||||||
.entries
|
.entries
|
||||||
.iter()
|
.iter()
|
||||||
|
|||||||
@@ -193,6 +193,11 @@ pub fn fmts_key_ranges(
|
|||||||
) -> Vec<(u32, u32, usize)> {
|
) -> Vec<(u32, u32, usize)> {
|
||||||
let mut ranges = Vec::new();
|
let mut ranges = Vec::new();
|
||||||
for s in segments {
|
for s in segments {
|
||||||
|
// SPNs are untrusted (from IndividualSegment.tbl); an inverted record
|
||||||
|
// (start_spn > end_spn) would underflow `end_byte - 1 - start_byte` below.
|
||||||
|
if s.start_spn > s.end_spn {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
let start_byte = s.start_spn as u64 * SOURCE_PACKET_LEN;
|
let start_byte = s.start_spn as u64 * SOURCE_PACKET_LEN;
|
||||||
let end_byte = (s.end_spn as u64 + 1) * SOURCE_PACKET_LEN; // exclusive
|
let end_byte = (s.end_spn as u64 + 1) * SOURCE_PACKET_LEN; // exclusive
|
||||||
// A segment is unit-aligned and contiguous in clip bytes; map its first
|
// A segment is unit-aligned and contiguous in clip bytes; map its first
|
||||||
@@ -281,6 +286,24 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fmts_key_ranges_skips_inverted_segment_without_underflow() {
|
||||||
|
use crate::disc::Extent;
|
||||||
|
let extents = vec![Extent {
|
||||||
|
start_lba: 1000,
|
||||||
|
sector_count: 1_000_000,
|
||||||
|
}];
|
||||||
|
// start_spn == end_spn + 1: `end_byte - 1 - start_byte` would underflow.
|
||||||
|
// The record must be skipped rather than panic (debug) / wrap (release).
|
||||||
|
let segs = vec![Segment {
|
||||||
|
index: 5,
|
||||||
|
start_spn: 200,
|
||||||
|
end_spn: 199,
|
||||||
|
}];
|
||||||
|
let ranges = fmts_key_ranges(&segs, &extents, &|v| v as usize);
|
||||||
|
assert!(ranges.is_empty(), "inverted segment yields no range");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn clip_byte_to_lba_walks_extents() {
|
fn clip_byte_to_lba_walks_extents() {
|
||||||
use crate::disc::Extent;
|
use crate::disc::Extent;
|
||||||
|
|||||||
+23
-22
@@ -171,6 +171,19 @@ impl DecryptKeys {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Which aligned units of a range a key decrypts. AACS 2.1 FMTS forensic segments
|
||||||
|
/// interleave TWO variants at the unit level; `Even`/`Odd` selects the variant's
|
||||||
|
/// half (parity of the unit's index within the segment) and the ALTERNATE half is
|
||||||
|
/// left untouched (ciphertext) for the muxer to drop. Every non-forensic range —
|
||||||
|
/// the base Unit Key, a multi-CPS unit — is `All` (decrypt every unit), so the
|
||||||
|
/// common disc is byte-for-byte unchanged.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum Phase {
|
||||||
|
All,
|
||||||
|
Even,
|
||||||
|
Odd,
|
||||||
|
}
|
||||||
|
|
||||||
/// Proactive AACS key-selection map: which held unit key decrypts each LBA of a
|
/// Proactive AACS key-selection map: which held unit key decrypts each LBA of a
|
||||||
/// title's encrypted content, decided ONCE before mux from the disc's CPS-unit
|
/// title's encrypted content, decided ONCE before mux from the disc's CPS-unit
|
||||||
/// (and, later, FMTS segment) structure — never by trial-decrypt-and-check per
|
/// (and, later, FMTS segment) structure — never by trial-decrypt-and-check per
|
||||||
@@ -191,19 +204,6 @@ impl DecryptKeys {
|
|||||||
/// sorted and disjoint. `default_idx` covers any LBA no range claims — the
|
/// sorted and disjoint. `default_idx` covers any LBA no range claims — the
|
||||||
/// single-CPS case is just an empty range list with `default_idx = 0`, so the
|
/// single-CPS case is just an empty range list with `default_idx = 0`, so the
|
||||||
/// common disc pays zero lookup cost and needs no structural walk.
|
/// common disc pays zero lookup cost and needs no structural walk.
|
||||||
/// Which aligned units of a range a key decrypts. AACS 2.1 FMTS forensic segments
|
|
||||||
/// interleave TWO variants at the unit level; `Even`/`Odd` selects the variant's
|
|
||||||
/// half (parity of the unit's index within the segment) and the ALTERNATE half is
|
|
||||||
/// left untouched (ciphertext) for the muxer to drop. Every non-forensic range —
|
|
||||||
/// the base Unit Key, a multi-CPS unit — is `All` (decrypt every unit), so the
|
|
||||||
/// common disc is byte-for-byte unchanged.
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
||||||
pub enum Phase {
|
|
||||||
All,
|
|
||||||
Even,
|
|
||||||
Odd,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
pub struct AacsKeyMap {
|
pub struct AacsKeyMap {
|
||||||
// (start_lba, end_lba, key_idx, phase)
|
// (start_lba, end_lba, key_idx, phase)
|
||||||
@@ -531,8 +531,9 @@ pub fn decrypt_sectors(
|
|||||||
/// falls inside `content_ranges` — the disc's AACS-encrypted content (the m2ts
|
/// falls inside `content_ranges` — the disc's AACS-encrypted content (the m2ts
|
||||||
/// stream extents). Units OUTSIDE content (UDF filesystem / nav) are left
|
/// stream extents). Units OUTSIDE content (UDF filesystem / nav) are left
|
||||||
/// untouched and never counted as decrypt loss: they are clear by definition, so
|
/// untouched and never counted as decrypt loss: they are clear by definition, so
|
||||||
/// [`ts_sync_destroyed`] must not be consulted about them (a filesystem unit has
|
/// the content-clarity check [`is_clean`](crate::aacs::content::is_clean) must not
|
||||||
/// no TS sync, which would otherwise be mistaken for ciphertext). `base_lba` is
|
/// be consulted about them (a filesystem unit has no TS sync, so it would
|
||||||
|
/// otherwise be mistaken for ciphertext). `base_lba` is
|
||||||
/// the absolute LBA of `buf`'s first sector; aligned units are 3 sectors.
|
/// the absolute LBA of `buf`'s first sector; aligned units are 3 sectors.
|
||||||
///
|
///
|
||||||
/// `content_ranges` is sorted, merged, disjoint `(start_lba, sector_count)`
|
/// `content_ranges` is sorted, merged, disjoint `(start_lba, sector_count)`
|
||||||
@@ -610,11 +611,11 @@ fn decrypt_sectors_impl(
|
|||||||
// silent corruption. We fail loud (Error::DecryptFailed), matching
|
// silent corruption. We fail loud (Error::DecryptFailed), matching
|
||||||
// the highway path's Error::ExtentNotUnitAligned policy.
|
// the highway path's Error::ExtentNotUnitAligned policy.
|
||||||
//
|
//
|
||||||
// Detection: !crate::aacs::content::is_clean(, crate::disc::ContentFormat::BdTs) short-circuits to false for any
|
// Detection: `is_clean` cannot judge a partial (it reports a
|
||||||
// buffer shorter than a full unit, so it cannot judge a partial. We
|
// shorter-than-a-full-unit buffer as clean, having no full encrypted
|
||||||
// instead apply the same TS-sync-intactness test it uses internally
|
// packet to check), so we apply the same TS-sync-intactness test it
|
||||||
// (ts_sync_count vs ts_packet_total) directly to the available
|
// uses internally (ts_sync_count vs ts_packet_total) directly to the
|
||||||
// partial bytes. A clear TS tail carries 0x47 syncs at the 192-byte
|
// available partial bytes. A clear TS tail carries 0x47 syncs at the 192-byte
|
||||||
// stride (> half the packets) → intact → not scrambled → tolerate. An
|
// stride (> half the packets) → intact → not scrambled → tolerate. An
|
||||||
// encrypted tail has those syncs destroyed (≤ half) → scrambled →
|
// encrypted tail has those syncs destroyed (≤ half) → scrambled →
|
||||||
// reject. If the partial is too short to hold even one TS packet
|
// reject. If the partial is too short to hold even one TS packet
|
||||||
@@ -1611,7 +1612,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Build a clear aligned unit with TS sync bytes placed at the BD-TS stride
|
/// Build a clear aligned unit with TS sync bytes placed at the BD-TS stride
|
||||||
/// (offset 4 + k*192) so `ts_sync_destroyed` reports false and
|
/// (offset 4 + k*192) so `is_clean` reports true and
|
||||||
/// `decrypt_unit` verifies it as clear after decryption.
|
/// `decrypt_unit` verifies it as clear after decryption.
|
||||||
fn clear_ts_unit() -> Vec<u8> {
|
fn clear_ts_unit() -> Vec<u8> {
|
||||||
let mut unit = vec![0u8; aacs::content::ALIGNED_UNIT_LEN];
|
let mut unit = vec![0u8; aacs::content::ALIGNED_UNIT_LEN];
|
||||||
@@ -1837,7 +1838,7 @@ mod tests {
|
|||||||
/// Grounding: `for idx in try_order { … if aacs::content::decrypt_unit(&mut attempt, key) { … } }`
|
/// Grounding: `for idx in try_order { … if aacs::content::decrypt_unit(&mut attempt, key) { … } }`
|
||||||
/// Mutation: revert to the pre-fix `decrypt_unit_full(chunk, &uk, …)` where
|
/// Mutation: revert to the pre-fix `decrypt_unit_full(chunk, &uk, …)` where
|
||||||
/// `uk = raw_keys[unit_key_idx]` (always key 0) → the unit comes out as
|
/// `uk = raw_keys[unit_key_idx]` (always key 0) → the unit comes out as
|
||||||
/// garbled bytes that still look scrambled, failing the `!ts_sync_destroyed`
|
/// garbled bytes that still look scrambled, failing the `is_clean`
|
||||||
/// assert.
|
/// assert.
|
||||||
#[test]
|
#[test]
|
||||||
fn aacs_multi_cps_unit_disc_decrypts_under_non_zero_key() {
|
fn aacs_multi_cps_unit_disc_decrypts_under_non_zero_key() {
|
||||||
|
|||||||
+5
-5
@@ -1867,7 +1867,7 @@ impl Disc {
|
|||||||
capacity: u32,
|
capacity: u32,
|
||||||
handshake: Option<HandshakeResult>,
|
handshake: Option<HandshakeResult>,
|
||||||
handshake_error: Option<Error>,
|
handshake_error: Option<Error>,
|
||||||
_opts: &ScanOptions,
|
opts: &ScanOptions,
|
||||||
udf_fs: udf::UdfFs,
|
udf_fs: udf::UdfFs,
|
||||||
) -> Result<Self> {
|
) -> Result<Self> {
|
||||||
let scan_with_t0 = std::time::Instant::now();
|
let scan_with_t0 = std::time::Instant::now();
|
||||||
@@ -1931,7 +1931,7 @@ impl Disc {
|
|||||||
// forced flags match what the muxer derives during a rip (both use the
|
// forced flags match what the muxer derives during a rip (both use the
|
||||||
// one shared PGS classifier); the rip path leaves it off — the muxer
|
// one shared PGS classifier); the rip path leaves it off — the muxer
|
||||||
// detects forced while muxing, without a second read of the clip.
|
// detects forced while muxing, without a second read of the clip.
|
||||||
if _opts.probe_forced_subtitles {
|
if opts.probe_forced_subtitles {
|
||||||
for title in &mut titles {
|
for title in &mut titles {
|
||||||
if title.content_format == ContentFormat::BdTs {
|
if title.content_format == ContentFormat::BdTs {
|
||||||
pgs_forced_probe::probe_and_set_forced(reader, title);
|
pgs_forced_probe::probe_and_set_forced(reader, title);
|
||||||
@@ -2277,8 +2277,8 @@ impl std::fmt::Debug for Key {
|
|||||||
/// next candidate (and ultimately surfaces a key error rather than silently
|
/// next candidate (and ultimately surfaces a key error rather than silently
|
||||||
/// writing ciphertext).
|
/// writing ciphertext).
|
||||||
///
|
///
|
||||||
/// Reuses the ecosystem's single `ts_sync_destroyed` predicate and the full
|
/// Reuses the ecosystem's single `is_clean` content-clarity predicate and the
|
||||||
/// (bus + AACS) unit decrypt, so it agrees with the actual mux decrypt.
|
/// full (bus + AACS) unit decrypt, so it agrees with the actual mux decrypt.
|
||||||
fn aligned_unit_keys_validate(
|
fn aligned_unit_keys_validate(
|
||||||
unit_keys: &[(u32, [u8; 16])],
|
unit_keys: &[(u32, [u8; 16])],
|
||||||
read_data_key: Option<&[u8; 16]>,
|
read_data_key: Option<&[u8; 16]>,
|
||||||
@@ -2367,7 +2367,7 @@ impl Disc {
|
|||||||
/// else (UDF filesystem, BDMV nav, PLAYLIST/CLIPINF) is always clear.
|
/// else (UDF filesystem, BDMV nav, PLAYLIST/CLIPINF) is always clear.
|
||||||
///
|
///
|
||||||
/// The in-read decrypt-verify gate (`DecryptingSectorSource`) uses this so it
|
/// The in-read decrypt-verify gate (`DecryptingSectorSource`) uses this so it
|
||||||
/// never consults [`ts_sync_destroyed`](crate::aacs::content::ts_sync_destroyed) about
|
/// never consults the TS-sync content check about
|
||||||
/// non-content bytes — filesystem data has no TS sync and would otherwise be
|
/// non-content bytes — filesystem data has no TS sync and would otherwise be
|
||||||
/// mistaken for ciphertext (the first-2-GB false-positive this fixes).
|
/// mistaken for ciphertext (the first-2-GB false-positive this fixes).
|
||||||
///
|
///
|
||||||
|
|||||||
+26
-1
@@ -492,7 +492,14 @@ pub fn handle_read_error(err: &Error, ctx: &mut ReadCtx) -> ReadAction {
|
|||||||
// so the 30s zone-entry cooldown below keys off the real
|
// so the 30s zone-entry cooldown below keys off the real
|
||||||
// transition rather than re-deriving it from a counter that the
|
// transition rather than re-deriving it from a counter that the
|
||||||
// fast-jump path resets after every jump.
|
// fast-jump path resets after every jump.
|
||||||
let is_zone_entry_transition = !ctx.in_damage_zone && !ctx.bisecting;
|
//
|
||||||
|
// A RECOVERED_ERROR (marginal read) is explicitly NOT damage-zone signal
|
||||||
|
// (see the SkipBlock branch below) — it returns early, so latching
|
||||||
|
// in_damage_zone here would spuriously consume the zone-entry transition and
|
||||||
|
// let a genuine hard error that follows skip the 30s wedge cooldown.
|
||||||
|
let is_recovered =
|
||||||
|
err.scsi_sense().map(|s| s.sense_key) == Some(scsi::SENSE_KEY_RECOVERED_ERROR);
|
||||||
|
let is_zone_entry_transition = !ctx.in_damage_zone && !ctx.bisecting && !is_recovered;
|
||||||
if is_zone_entry_transition {
|
if is_zone_entry_transition {
|
||||||
ctx.in_damage_zone = true;
|
ctx.in_damage_zone = true;
|
||||||
ctx.zones_entered += 1;
|
ctx.zones_entered += 1;
|
||||||
@@ -874,6 +881,24 @@ mod tests {
|
|||||||
assert_eq!(ctx.jumps_taken, 0);
|
assert_eq!(ctx.jumps_taken, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn recovered_error_does_not_consume_zone_entry() {
|
||||||
|
// A recovered (marginal) read must NOT latch in_damage_zone — otherwise a
|
||||||
|
// genuine hard error that follows would not be seen as the zone entry and
|
||||||
|
// would skip the 30s wedge cooldown.
|
||||||
|
let mut ctx = ReadCtx::for_sweep(32);
|
||||||
|
handle_read_error(&recovered_err(), &mut ctx);
|
||||||
|
assert!(
|
||||||
|
!ctx.in_damage_zone,
|
||||||
|
"recovered read is not damage-zone signal"
|
||||||
|
);
|
||||||
|
assert_eq!(ctx.zones_entered, 0);
|
||||||
|
// The following genuine hard error IS the real zone entry.
|
||||||
|
handle_read_error(&hardware_err(), &mut ctx);
|
||||||
|
assert!(ctx.in_damage_zone);
|
||||||
|
assert_eq!(ctx.zones_entered, 1, "hard error registers the zone entry");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn recovered_error_skips_block_pass_n_too() {
|
fn recovered_error_skips_block_pass_n_too() {
|
||||||
// Pass N sees the same: a recovered read is distrusted → SkipBlock (the
|
// Pass N sees the same: a recovered read is distrusted → SkipBlock (the
|
||||||
|
|||||||
+21
-26
@@ -152,9 +152,9 @@ pub const E_MUX_EMPTY: u16 = 9023;
|
|||||||
pub const E_EXTENT_NOT_UNIT_ALIGNED: u16 = 9030;
|
pub const E_EXTENT_NOT_UNIT_ALIGNED: u16 = 9030;
|
||||||
/// `mp4://` output but the title has no (primary) video track to carry.
|
/// `mp4://` output but the title has no (primary) video track to carry.
|
||||||
pub const E_MP4_NO_VIDEO_TRACK: u16 = 9048;
|
pub const E_MP4_NO_VIDEO_TRACK: u16 = 9048;
|
||||||
/// `mp4://` video track uses a codec with no MP4 mapping / no config record
|
/// `mp4://` SOURCE file is malformed/truncated (bad box structure, sample table,
|
||||||
/// available here (e.g. VC-1, or MPEG-2 without an avcC/hvcC-style record).
|
/// or offsets) — the MP4 demuxer could not parse it.
|
||||||
pub const E_MP4_UNSUPPORTED_VIDEO_CODEC: u16 = 9049;
|
pub const E_MP4_INVALID: u16 = 9049;
|
||||||
/// `mp4://` video track is missing its codec-configuration record
|
/// `mp4://` video track is missing its codec-configuration record
|
||||||
/// (`hvcC`/`avcC`), without which the sample entry can't be written.
|
/// (`hvcC`/`avcC`), without which the sample entry can't be written.
|
||||||
pub const E_MP4_MISSING_CODEC_PRIVATE: u16 = 9050;
|
pub const E_MP4_MISSING_CODEC_PRIVATE: u16 = 9050;
|
||||||
@@ -370,12 +370,10 @@ pub enum Error {
|
|||||||
AacsBusKeyUnavailable,
|
AacsBusKeyUnavailable,
|
||||||
|
|
||||||
/// AACS 2.1 (FMTS) disc carries forensic variant segments, but no segment
|
/// AACS 2.1 (FMTS) disc carries forensic variant segments, but no segment
|
||||||
/// (variant) key is available to open them, and `BYPASS_FMTS_KEY` is `false`
|
/// (variant) key is available to open them. Raised UPFRONT — before the mux —
|
||||||
/// (strict mode). Raised UPFRONT — before the mux — exactly like a missing
|
/// exactly like a missing unit key, so a 2.1 disc that would rip with holes is
|
||||||
/// unit key, so a 2.1 disc that would rip with holes is refused rather than
|
/// refused rather than silently producing a forensic-holed output. (The mux
|
||||||
/// silently producing a forensic-holed output. When `BYPASS_FMTS_KEY` is
|
/// resolves the full forensic key set up front; a resolution gap fails here.)
|
||||||
/// `true` (the default today) this is never raised: the bulk decodes with the
|
|
||||||
/// unit key and the forensic segments are skipped as expected loss.
|
|
||||||
FmtsKeyMissing,
|
FmtsKeyMissing,
|
||||||
|
|
||||||
// Keydb (8xxx)
|
// Keydb (8xxx)
|
||||||
@@ -429,11 +427,11 @@ pub enum Error {
|
|||||||
/// `m2ts://` analogue of [`Error::MkvInvalid`]'s zero-frame guard.
|
/// `m2ts://` analogue of [`Error::MkvInvalid`]'s zero-frame guard.
|
||||||
MuxEmpty,
|
MuxEmpty,
|
||||||
/// `mp4://` target title has no primary video track to mux.
|
/// `mp4://` target title has no primary video track to mux.
|
||||||
MuxNoVideoTrack,
|
Mp4NoVideoTrack,
|
||||||
/// `mp4://` video codec has no MP4 sample-entry mapping available here.
|
/// `mp4://` source file is malformed/truncated — the MP4 demuxer failed.
|
||||||
Mp4UnsupportedVideoCodec,
|
Mp4Invalid,
|
||||||
/// `mp4://` video track is missing its `hvcC`/`avcC` configuration record.
|
/// `mp4://` video track is missing its `hvcC`/`avcC` configuration record.
|
||||||
MuxMissingCodecPrivate,
|
Mp4MissingCodecPrivate,
|
||||||
PesFrameTooLarge {
|
PesFrameTooLarge {
|
||||||
size: usize,
|
size: usize,
|
||||||
},
|
},
|
||||||
@@ -613,9 +611,9 @@ impl Error {
|
|||||||
Error::StreamUrlMissingPort { .. } => E_STREAM_URL_MISSING_PORT,
|
Error::StreamUrlMissingPort { .. } => E_STREAM_URL_MISSING_PORT,
|
||||||
Error::NetworkAddrBlocked { .. } => E_NETWORK_ADDR_BLOCKED,
|
Error::NetworkAddrBlocked { .. } => E_NETWORK_ADDR_BLOCKED,
|
||||||
Error::MuxEmpty => E_MUX_EMPTY,
|
Error::MuxEmpty => E_MUX_EMPTY,
|
||||||
Error::MuxNoVideoTrack => E_MP4_NO_VIDEO_TRACK,
|
Error::Mp4NoVideoTrack => E_MP4_NO_VIDEO_TRACK,
|
||||||
Error::Mp4UnsupportedVideoCodec => E_MP4_UNSUPPORTED_VIDEO_CODEC,
|
Error::Mp4Invalid => E_MP4_INVALID,
|
||||||
Error::MuxMissingCodecPrivate => E_MP4_MISSING_CODEC_PRIVATE,
|
Error::Mp4MissingCodecPrivate => E_MP4_MISSING_CODEC_PRIVATE,
|
||||||
Error::PesFrameTooLarge { .. } => E_PES_FRAME_TOO_LARGE,
|
Error::PesFrameTooLarge { .. } => E_PES_FRAME_TOO_LARGE,
|
||||||
Error::PesInvalidMagic => E_PES_INVALID_MAGIC,
|
Error::PesInvalidMagic => E_PES_INVALID_MAGIC,
|
||||||
Error::PesTrackTooLarge { .. } => E_PES_TRACK_TOO_LARGE,
|
Error::PesTrackTooLarge { .. } => E_PES_TRACK_TOO_LARGE,
|
||||||
@@ -850,9 +848,9 @@ impl From<Error> for std::io::Error {
|
|||||||
// 9023 MuxEmpty: finish() reached with zero frames — the output
|
// 9023 MuxEmpty: finish() reached with zero frames — the output
|
||||||
// would be a header-only container. Treat as invalid output.
|
// would be a header-only container. Treat as invalid output.
|
||||||
E_MUX_EMPTY => std::io::ErrorKind::InvalidData,
|
E_MUX_EMPTY => std::io::ErrorKind::InvalidData,
|
||||||
// 9048-9050 mp4:// track/codec/config mismatches: the requested
|
// mp4:// track/config mismatches: the requested output can't hold
|
||||||
// output can't hold this title's video — invalid output request.
|
// this title's video — invalid output request.
|
||||||
E_MP4_NO_VIDEO_TRACK | E_MP4_UNSUPPORTED_VIDEO_CODEC | E_MP4_MISSING_CODEC_PRIVATE => {
|
E_MP4_NO_VIDEO_TRACK | E_MP4_INVALID | E_MP4_MISSING_CODEC_PRIVATE => {
|
||||||
std::io::ErrorKind::InvalidData
|
std::io::ErrorKind::InvalidData
|
||||||
}
|
}
|
||||||
// 9030 ExtentNotUnitAligned: a malformed/non-AACS-aligned
|
// 9030 ExtentNotUnitAligned: a malformed/non-AACS-aligned
|
||||||
@@ -1259,7 +1257,7 @@ mod tests {
|
|||||||
E_NETWORK_ADDR_BLOCKED,
|
E_NETWORK_ADDR_BLOCKED,
|
||||||
E_MUX_EMPTY,
|
E_MUX_EMPTY,
|
||||||
E_MP4_NO_VIDEO_TRACK,
|
E_MP4_NO_VIDEO_TRACK,
|
||||||
E_MP4_UNSUPPORTED_VIDEO_CODEC,
|
E_MP4_INVALID,
|
||||||
E_MP4_MISSING_CODEC_PRIVATE,
|
E_MP4_MISSING_CODEC_PRIVATE,
|
||||||
E_PES_FRAME_TOO_LARGE,
|
E_PES_FRAME_TOO_LARGE,
|
||||||
E_PES_INVALID_MAGIC,
|
E_PES_INVALID_MAGIC,
|
||||||
@@ -1348,12 +1346,9 @@ mod tests {
|
|||||||
(Error::PipelineConsumerGone, E_PIPELINE_CONSUMER_GONE),
|
(Error::PipelineConsumerGone, E_PIPELINE_CONSUMER_GONE),
|
||||||
(Error::DiscCapacityOverflow, E_DISC_CAPACITY_OVERFLOW),
|
(Error::DiscCapacityOverflow, E_DISC_CAPACITY_OVERFLOW),
|
||||||
(Error::MuxEmpty, E_MUX_EMPTY),
|
(Error::MuxEmpty, E_MUX_EMPTY),
|
||||||
(Error::MuxNoVideoTrack, E_MP4_NO_VIDEO_TRACK),
|
(Error::Mp4NoVideoTrack, E_MP4_NO_VIDEO_TRACK),
|
||||||
(
|
(Error::Mp4Invalid, E_MP4_INVALID),
|
||||||
Error::Mp4UnsupportedVideoCodec,
|
(Error::Mp4MissingCodecPrivate, E_MP4_MISSING_CODEC_PRIVATE),
|
||||||
E_MP4_UNSUPPORTED_VIDEO_CODEC,
|
|
||||||
),
|
|
||||||
(Error::MuxMissingCodecPrivate, E_MP4_MISSING_CODEC_PRIVATE),
|
|
||||||
(Error::M2tsPacketMalformed, E_M2TS_PACKET_MALFORMED),
|
(Error::M2tsPacketMalformed, E_M2TS_PACKET_MALFORMED),
|
||||||
(Error::ExtentNotUnitAligned, E_EXTENT_NOT_UNIT_ALIGNED),
|
(Error::ExtentNotUnitAligned, E_EXTENT_NOT_UNIT_ALIGNED),
|
||||||
(Error::DiscCapacityMalformed, E_DISC_CAPACITY_MALFORMED),
|
(Error::DiscCapacityMalformed, E_DISC_CAPACITY_MALFORMED),
|
||||||
|
|||||||
+57
-7
@@ -485,9 +485,9 @@ pub fn key_fetch(
|
|||||||
///
|
///
|
||||||
/// "Encrypted" is decided by [`crate::aacs::content::aacs_unit_encrypted`] — the
|
/// "Encrypted" is decided by [`crate::aacs::content::aacs_unit_encrypted`] — the
|
||||||
/// AACS Copy Permission Indicator (CPI) in the top 2 bits of byte 0, the
|
/// AACS Copy Permission Indicator (CPI) in the top 2 bits of byte 0, the
|
||||||
/// spec-correct signal (`buf[0] & 0xc0`). NOT the `ts_sync_destroyed`
|
/// spec-correct signal (`buf[0] & 0xc0`). NOT the `is_clean` TS-sync
|
||||||
/// sync heuristic: destroyed TS syncs do not imply encryption (an FMTS variant
|
/// heuristic: a unit lacking clean TS syncs does not imply encryption (an FMTS
|
||||||
/// frame or an odd clear unit can lack syncs yet be unencrypted), and a clear
|
/// variant frame or an odd clear unit can lack syncs yet be unencrypted), and a clear
|
||||||
/// unit sent to a key server yields nothing to validate against — the "0
|
/// unit sent to a key server yields nothing to validate against — the "0
|
||||||
/// encrypted units" rejection. A clip opens with clear navigation units (PAT/PMT,
|
/// encrypted units" rejection. A clip opens with clear navigation units (PAT/PMT,
|
||||||
/// menus) whose CPI is clear; only CPI-flagged content units are collected —
|
/// menus) whose CPI is clear; only CPI-flagged content units are collected —
|
||||||
@@ -818,6 +818,55 @@ mod tests {
|
|||||||
assert_eq!(*builds.lock().unwrap(), 1, "make_sources invoked per fetch");
|
assert_eq!(*builds.lock().unwrap(), 1, "make_sources invoked per fetch");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `key_fetch` memoizes each operation by the fingerprint of the sample batch:
|
||||||
|
/// identical samples reuse the cached keys (no rebuild), different samples miss,
|
||||||
|
/// the two operations keep independent caches, and even an empty reply is cached.
|
||||||
|
#[test]
|
||||||
|
fn key_fetch_memoizes_per_op_by_sample_fingerprint() {
|
||||||
|
let builds = Arc::new(Mutex::new(0usize));
|
||||||
|
let builds_c = Arc::clone(&builds);
|
||||||
|
let key = [0x11u8; 16];
|
||||||
|
let make: Arc<dyn Fn() -> Vec<Box<dyn KeySource>> + Send + Sync> = Arc::new(move || {
|
||||||
|
*builds_c.lock().unwrap() += 1;
|
||||||
|
vec![Box::new(HasKey(key)) as Box<dyn KeySource>]
|
||||||
|
});
|
||||||
|
let cb = key_fetch(empty_inputs(), make);
|
||||||
|
let a = vec![vec![0xAAu8; 8]];
|
||||||
|
let b = vec![vec![0xBBu8; 8]];
|
||||||
|
|
||||||
|
// First resolve for `a` builds sources; the identical repeat is cached.
|
||||||
|
assert_eq!(cb.unit_keys(&a), vec![key]);
|
||||||
|
assert_eq!(cb.unit_keys(&a), vec![key]);
|
||||||
|
assert_eq!(
|
||||||
|
*builds.lock().unwrap(),
|
||||||
|
1,
|
||||||
|
"identical samples reuse the cache"
|
||||||
|
);
|
||||||
|
|
||||||
|
// A different sample batch is a cache miss → one more build.
|
||||||
|
assert_eq!(cb.unit_keys(&b), vec![key]);
|
||||||
|
assert_eq!(
|
||||||
|
*builds.lock().unwrap(),
|
||||||
|
2,
|
||||||
|
"different samples miss the cache"
|
||||||
|
);
|
||||||
|
|
||||||
|
// The forensic op has its OWN cache (HasKey has no forensic keys → empty),
|
||||||
|
// so `a` builds once more here; its empty reply is then cached too.
|
||||||
|
assert!(cb.fmts_indexes(&a).is_empty());
|
||||||
|
assert_eq!(
|
||||||
|
*builds.lock().unwrap(),
|
||||||
|
3,
|
||||||
|
"unit/fmts caches are independent"
|
||||||
|
);
|
||||||
|
assert!(cb.fmts_indexes(&a).is_empty());
|
||||||
|
assert_eq!(
|
||||||
|
*builds.lock().unwrap(),
|
||||||
|
3,
|
||||||
|
"an empty reply is cached, not re-asked"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// The two `KeyFetch` operations route to the two DISTINCT trait methods:
|
/// The two `KeyFetch` operations route to the two DISTINCT trait methods:
|
||||||
/// `unit_keys` drives `get_unit_keys`, `fmts_indexes` drives
|
/// `unit_keys` drives `get_unit_keys`, `fmts_indexes` drives
|
||||||
/// `get_fmts_indexes`. A source that returns different keys per method proves
|
/// `get_fmts_indexes`. A source that returns different keys per method proves
|
||||||
@@ -982,12 +1031,12 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// DISCRIMINATING: selection is by the AACS CPI (byte 0), NOT the
|
/// DISCRIMINATING: selection is by the AACS CPI (byte 0), NOT the
|
||||||
/// `ts_sync_destroyed` heuristic. Half the units are sync-destroyed but
|
/// TS-sync clarity heuristic. Half the units lack TS syncs but are
|
||||||
/// CPI-CLEAR (`byte0 & 0xC0 == 0`) — genuinely UNencrypted units that merely
|
/// CPI-CLEAR (`byte0 & 0xC0 == 0`) — genuinely UNencrypted units that merely
|
||||||
/// lack TS syncs; the old sampler collected these and the key server rejected
|
/// lack TS syncs; the old sampler collected these and the key server rejected
|
||||||
/// the POST as "0 encrypted units". `read_encrypted_units` must skip them and
|
/// the POST as "0 encrypted units". `read_encrypted_units` must skip them and
|
||||||
/// return ONLY CPI-flagged units. A regression to `ts_sync_destroyed` would
|
/// return ONLY CPI-flagged units. A regression to selecting by TS-sync clarity
|
||||||
/// collect the CPI-clear units too and fail the `& 0xC0` assertion.
|
/// would collect the CPI-clear units too and fail the `& 0xC0` assertion.
|
||||||
#[test]
|
#[test]
|
||||||
fn read_encrypted_units_selects_by_cpi_not_ts_sync() {
|
fn read_encrypted_units_selects_by_cpi_not_ts_sync() {
|
||||||
use crate::aacs::content::{ALIGNED_UNIT_LEN, ALIGNED_UNIT_SECTORS, aacs_unit_encrypted};
|
use crate::aacs::content::{ALIGNED_UNIT_LEN, ALIGNED_UNIT_SECTORS, aacs_unit_encrypted};
|
||||||
@@ -996,7 +1045,8 @@ mod tests {
|
|||||||
|
|
||||||
// Even units: CPI-clear (byte0 & 0xC0 == 0) AND sync-destroyed (no 0x47).
|
// Even units: CPI-clear (byte0 & 0xC0 == 0) AND sync-destroyed (no 0x47).
|
||||||
// Odd units: CPI-set (byte0 = 0xC0) with a scrambled body.
|
// Odd units: CPI-set (byte0 = 0xC0) with a scrambled body.
|
||||||
// `ts_sync_destroyed` is TRUE for BOTH; `aacs_unit_encrypted` only odd.
|
// Neither has clean TS syncs, so `is_clean` is FALSE for BOTH;
|
||||||
|
// `aacs_unit_encrypted` flags only the odd units.
|
||||||
struct MixSource {
|
struct MixSource {
|
||||||
ext_start: u32,
|
ext_start: u32,
|
||||||
total_units: u32,
|
total_units: u32,
|
||||||
|
|||||||
+15
-15
@@ -118,13 +118,13 @@ impl Ac3Parser {
|
|||||||
|
|
||||||
use super::crc::crc16_ansi;
|
use super::crc::crc16_ansi;
|
||||||
|
|
||||||
/// Whether a fully-buffered (E-)AC-3 frame passes its native CRC. ffmpeg's
|
/// Whether a fully-buffered (E-)AC-3 frame passes its native CRC. Per ETSI TS
|
||||||
/// decoder checks exactly this — `av_crc(AV_CRC_16_ANSI, 0, &buf[2],
|
/// 102 366 (ATSC A/52) the frame carries a CRC-16/ANSI (poly 0x8005, init 0,
|
||||||
/// frame_size - 2) == 0` (ac3dec.c) — over the frame after the 2-byte syncword;
|
/// non-reflected) over the bytes after the 2-byte syncword — i.e. `crc16_ansi(
|
||||||
/// the trailing crc word makes a clean frame's residue zero. A nonzero residue
|
/// &buf[2..]) == 0` covers `frame_size - 2` bytes; the trailing crc word makes a
|
||||||
/// is a ~1-in-65536-certain sign of payload corruption, so we drop the frame
|
/// clean frame's residue zero. A nonzero residue is a ~1-in-65536-certain sign
|
||||||
/// (silence gap) rather than ship a glitch. `frame` must be exactly the frame
|
/// of payload corruption, so we drop the frame (silence gap) rather than ship a
|
||||||
/// bytes (syncword .. frame_size).
|
/// glitch. `frame` must be exactly the frame bytes (syncword .. frame_size).
|
||||||
fn frame_crc_ok(frame: &[u8]) -> bool {
|
fn frame_crc_ok(frame: &[u8]) -> bool {
|
||||||
// Need the syncword (2) plus at least one covered byte; the caller only
|
// Need the syncword (2) plus at least one covered byte; the caller only
|
||||||
// invokes this on a fully-sized frame, so this is defensive.
|
// invokes this on a fully-sized frame, so this is defensive.
|
||||||
@@ -136,8 +136,8 @@ fn frame_crc_ok(frame: &[u8]) -> bool {
|
|||||||
|
|
||||||
/// Decodability verdict for a fully-sized (E-)AC-3 frame: `Some(reason)` when it
|
/// Decodability verdict for a fully-sized (E-)AC-3 frame: `Some(reason)` when it
|
||||||
/// must be dropped, `None` when it decodes. Drops (in order): a poisoned track
|
/// must be dropped, `None` when it decodes. Drops (in order): a poisoned track
|
||||||
/// (mostly-undecodable → drop the rest), a bitstream id ffmpeg's parser rejects
|
/// (mostly-undecodable → drop the rest), an out-of-range bitstream id (`bsid >
|
||||||
/// (`bsid > 16` → `AC3_PARSE_ERROR_BSID`), or a failed native frame CRC.
|
/// 16`; ETSI TS 102 366 defines no bsid above 16), or a failed native frame CRC.
|
||||||
fn ac3_drop_reason(
|
fn ac3_drop_reason(
|
||||||
tally: &super::dropgate::DropTally,
|
tally: &super::dropgate::DropTally,
|
||||||
frame: &[u8],
|
frame: &[u8],
|
||||||
@@ -233,7 +233,7 @@ impl CodecParser for Ac3Parser {
|
|||||||
|
|
||||||
let duration_ns = frame_duration_ns(remaining, bsid);
|
let duration_ns = frame_duration_ns(remaining, bsid);
|
||||||
let frame = &data[start..start + frame_size];
|
let frame = &data[start..start + frame_size];
|
||||||
// Decodability gate: drop a frame ffmpeg's parser rejects (bsid > 16)
|
// Decodability gate: drop a frame with an out-of-range bsid (> 16)
|
||||||
// or whose native CRC fails (payload corruption). `frame_pts_ns` is
|
// or whose native CRC fails (payload corruption). `frame_pts_ns` is
|
||||||
// advanced BELOW whether or not the frame survives, so a drop is a
|
// advanced BELOW whether or not the frame survives, so a drop is a
|
||||||
// silence gap and the following frames keep their true PTS.
|
// silence gap and the following frames keep their true PTS.
|
||||||
@@ -385,8 +385,8 @@ const ACMOD_CHANNELS: [u8; 8] = [2, 1, 2, 3, 3, 4, 4, 5];
|
|||||||
///
|
///
|
||||||
/// This is the AUTHORITATIVE channel count for the track header: the DVD IFO
|
/// This is the AUTHORITATIVE channel count for the track header: the DVD IFO
|
||||||
/// `audio_attr_t.channels` nibble is a well-known unreliable/stale field, so
|
/// `audio_attr_t.channels` nibble is a well-known unreliable/stale field, so
|
||||||
/// the muxer prefers this over the IFO-claimed count (mirrors MakeMKV /
|
/// the muxer prefers this over the IFO-claimed count (the bitstream acmod is
|
||||||
/// HandBrake, which never trust the IFO audio nibble). LFE adds one channel
|
/// authoritative; the IFO audio nibble is not trusted). LFE adds one channel
|
||||||
/// (e.g. acmod=7 + lfeon → 6 = 5.1).
|
/// (e.g. acmod=7 + lfeon → 6 = 5.1).
|
||||||
///
|
///
|
||||||
/// Bit layout from the syncword (A/52 §5.3.2 BSI):
|
/// Bit layout from the syncword (A/52 §5.3.2 BSI):
|
||||||
@@ -628,7 +628,7 @@ mod tests {
|
|||||||
// (PES marked discontinuity) carrying a fresh complete frame. The
|
// (PES marked discontinuity) carrying a fresh complete frame. The
|
||||||
// truncated partial must be DROPPED, not spliced — otherwise the parser
|
// truncated partial must be DROPPED, not spliced — otherwise the parser
|
||||||
// emits one corrupt frame built from [stale partial | head of fresh] and
|
// emits one corrupt frame built from [stale partial | head of fresh] and
|
||||||
// strands the tail (FFmpeg: "incomplete frame" / wrong sync).
|
// strands the tail (decoders report "incomplete frame" / wrong sync).
|
||||||
let mut parser = Ac3Parser::new();
|
let mut parser = Ac3Parser::new();
|
||||||
let frame_data = make_ac3_frame(0, 2); // 160 bytes, starts with 0x0B77
|
let frame_data = make_ac3_frame(0, 2); // 160 bytes, starts with 0x0B77
|
||||||
|
|
||||||
@@ -1463,8 +1463,8 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn bsid_over_16_is_dropped() {
|
fn bsid_over_16_is_dropped() {
|
||||||
// ffmpeg's parser rejects bsid > 16 (AC3_PARSE_ERROR_BSID). A frame with
|
// bsid > 16 is out of range (ETSI TS 102 366 defines no bsid above 16).
|
||||||
// bsid = 17 that still sizes must be dropped, not emitted.
|
// A frame with bsid = 17 that still sizes must be dropped, not emitted.
|
||||||
let mut frame = vec![0u8; 128];
|
let mut frame = vec![0u8; 128];
|
||||||
frame[0] = 0x0B;
|
frame[0] = 0x0B;
|
||||||
frame[1] = 0x77;
|
frame[1] = 0x77;
|
||||||
|
|||||||
+42
-14
@@ -1,20 +1,22 @@
|
|||||||
//! AAC ADTS decodability gate.
|
//! AAC ADTS decodability gate.
|
||||||
//!
|
//!
|
||||||
//! ffmpeg's `ff_adts_header_parse` (adts_header.c) has exactly three hard
|
//! Per the ADTS framing defined in ISO/IEC 13818-7 / ISO/IEC 14496-3, a header
|
||||||
//! rejects: syncword != 0xFFF, a reserved `sampling_frequency_index`
|
//! is structurally invalid in exactly three ways this gate treats as hard
|
||||||
//! (`ff_mpeg4audio_sample_rates[sr] == 0`, i.e. index ≥ 13), and
|
//! rejects: syncword != 0xFFF, a reserved `sampling_frequency_index` (the sample
|
||||||
//! `aac_frame_length < 7`. It does NOT verify the optional ADTS CRC (it only
|
//! rate table has 13 valid entries, so index ≥ 13 is reserved), and
|
||||||
//! `skip_bits(16)` past it). So the gate mirrors those three rejects: a packet
|
//! `aac_frame_length < 7` (shorter than the fixed+variable header itself). The
|
||||||
//! that begins with the ADTS sync but is otherwise malformed is dropped; a
|
//! optional 16-bit ADTS CRC is not verified here — it is simply skipped. So the
|
||||||
//! packet with no ADTS sync is raw AAC (e.g. from mp4, which carries no ADTS
|
//! gate enforces those three rejects: a packet that begins with the ADTS sync
|
||||||
//! header) or a continuation and passes through unchanged — never false-dropped.
|
//! but is otherwise malformed is dropped; a packet with no ADTS sync is raw AAC
|
||||||
//! Raw AAC has no per-frame integrity data, so like LPCM it cannot be gated.
|
//! (e.g. from an MP4 container, which carries no ADTS header) or a continuation
|
||||||
|
//! and passes through unchanged — never false-dropped. Raw AAC has no per-frame
|
||||||
|
//! integrity data, so like LPCM it cannot be gated.
|
||||||
|
|
||||||
use super::dropgate::DropTally;
|
use super::dropgate::DropTally;
|
||||||
use super::{CodecParser, Frame, PesPacket, pts_to_ns};
|
use super::{CodecParser, Frame, PesPacket, pts_to_ns};
|
||||||
|
|
||||||
/// `ff_mpeg4audio_sample_rates` — 13 valid entries; indices 13/14/15 are 0
|
/// ADTS `sampling_frequency_index` table (ISO/IEC 14496-3) — 13 valid entries;
|
||||||
/// (reserved), which is exactly what ffmpeg rejects.
|
/// indices 13/14/15 are 0 (reserved) and constitute a hard reject.
|
||||||
const ADTS_SAMPLE_RATE_VALID: [u32; 16] = [
|
const ADTS_SAMPLE_RATE_VALID: [u32; 16] = [
|
||||||
96000, 88200, 64000, 48000, 44100, 32000, 24000, 22050, 16000, 12000, 11025, 8000, 7350, 0, 0,
|
96000, 88200, 64000, 48000, 44100, 32000, 24000, 22050, 16000, 12000, 11025, 8000, 7350, 0, 0,
|
||||||
0,
|
0,
|
||||||
@@ -24,10 +26,10 @@ const ADTS_SAMPLE_RATE_VALID: [u32; 16] = [
|
|||||||
enum AdtsVerdict {
|
enum AdtsVerdict {
|
||||||
/// No 12-bit ADTS sync at the head — not an ADTS frame we can validate.
|
/// No 12-bit ADTS sync at the head — not an ADTS frame we can validate.
|
||||||
NoSync,
|
NoSync,
|
||||||
/// Sync present and the three ffmpeg-checked fields are legal.
|
/// Sync present and the three structural fields are legal.
|
||||||
Valid,
|
Valid,
|
||||||
/// Sync present but a reserved sample-rate index or a sub-header
|
/// Sync present but a reserved sample-rate index or a sub-header
|
||||||
/// frame-length — ffmpeg's parser rejects this.
|
/// frame-length — structurally invalid per the ADTS spec.
|
||||||
Invalid,
|
Invalid,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,6 +58,10 @@ fn adts_verdict(data: &[u8]) -> AdtsVerdict {
|
|||||||
|
|
||||||
pub struct AdtsParser {
|
pub struct AdtsParser {
|
||||||
tally: DropTally,
|
tally: DropTally,
|
||||||
|
/// Last emitted PTS (ns). A PES with no PTS (legal for audio, e.g. a
|
||||||
|
/// post-discontinuity continuation) carries this forward rather than resetting
|
||||||
|
/// the timeline to 0 — matching the AC-3/DTS parsers and preserving A/V sync.
|
||||||
|
last_pts_ns: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for AdtsParser {
|
impl Default for AdtsParser {
|
||||||
@@ -68,6 +74,7 @@ impl AdtsParser {
|
|||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
tally: DropTally::new("aac"),
|
tally: DropTally::new("aac"),
|
||||||
|
last_pts_ns: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,7 +92,12 @@ impl CodecParser for AdtsParser {
|
|||||||
if pes.data.is_empty() {
|
if pes.data.is_empty() {
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
}
|
}
|
||||||
let pts_ns = pes.pts.or(pes.dts).map(pts_to_ns).unwrap_or(0);
|
let pts_ns = pes
|
||||||
|
.pts
|
||||||
|
.or(pes.dts)
|
||||||
|
.map(pts_to_ns)
|
||||||
|
.unwrap_or(self.last_pts_ns);
|
||||||
|
self.last_pts_ns = pts_ns;
|
||||||
|
|
||||||
let drop =
|
let drop =
|
||||||
self.tally.is_poisoned() || matches!(adts_verdict(&pes.data), AdtsVerdict::Invalid);
|
self.tally.is_poisoned() || matches!(adts_verdict(&pes.data), AdtsVerdict::Invalid);
|
||||||
@@ -162,6 +174,22 @@ mod tests {
|
|||||||
assert_eq!(p.dropped_frames(), 0);
|
assert_eq!(p.dropped_frames(), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pes_without_pts_carries_last_timestamp_not_zero() {
|
||||||
|
// A PES with no PTS (legal for audio, e.g. after a discontinuity) must
|
||||||
|
// carry the last known timestamp forward — resetting to 0 would corrupt
|
||||||
|
// A/V sync.
|
||||||
|
let mut p = AdtsParser::new();
|
||||||
|
p.parse(&make_pes(adts_frame(400), Some(90000)));
|
||||||
|
let f = p.parse(&make_pes(adts_frame(400), None));
|
||||||
|
assert_eq!(f.len(), 1);
|
||||||
|
assert_eq!(
|
||||||
|
f[0].pts_ns,
|
||||||
|
pts_to_ns(90000),
|
||||||
|
"carried forward, not reset to 0"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn reserved_sample_rate_index_is_dropped() {
|
fn reserved_sample_rate_index_is_dropped() {
|
||||||
// sr_index = 13 (reserved). byte2 bits5..2 = 1101 → 0x34.
|
// sr_index = 13 (reserved). byte2 bits5..2 = 1101 → 0x34.
|
||||||
|
|||||||
@@ -206,8 +206,8 @@ impl PictureInfo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Number of field-display periods this picture occupies — the basis for
|
/// Number of field-display periods this picture occupies — the basis for
|
||||||
/// soft-telecine (2:3 pulldown) timing. MPEG-2 (ISO/IEC 13818-2 §6.3.10,
|
/// soft-telecine (2:3 pulldown) timing. MPEG-2 (ISO/IEC 13818-2 §6.3.10):
|
||||||
/// ffmpeg `nb_fields = repeat_pict + 2`): a field picture occupies 1 field,
|
/// a field picture occupies 1 field,
|
||||||
/// a normal frame 2, a `repeat_first_field` progressive-frame 3 (or 4/6 in a
|
/// a normal frame 2, a `repeat_first_field` progressive-frame 3 (or 4/6 in a
|
||||||
/// progressive sequence); an rff bit on a non-progressive interlaced frame is
|
/// progressive sequence); an rff bit on a non-progressive interlaced frame is
|
||||||
/// spec-forbidden (§6.3.10) and is treated as 2. Codecs without pulldown
|
/// spec-forbidden (§6.3.10) and is treated as 2. Codecs without pulldown
|
||||||
|
|||||||
+42
-19
@@ -1,16 +1,18 @@
|
|||||||
//! Bit-exact CRC helpers shared by the audio codec decodability gates.
|
//! Bit-exact CRC helpers shared by the audio codec decodability gates.
|
||||||
//!
|
//!
|
||||||
//! Both match ffmpeg's `av_crc` tables so a frame that ffmpeg's decoder would
|
//! Each matches the CRC defined by its format's bitstream specification, so a
|
||||||
//! flag as a CRC mismatch is flagged identically here. All are MSB-first
|
//! frame these routines flag as a CRC mismatch is exactly the frame a
|
||||||
//! (non-reflected), init 0, no final XOR — the ffmpeg `AV_CRC_*` (big-endian)
|
//! spec-conformant decoder would reject. All are MSB-first (non-reflected),
|
||||||
//! variants. Each format transmits its CRC so that the residue over
|
//! init 0, no final XOR — the big-endian CRC variants. Each format transmits
|
||||||
//! `data + transmitted_crc` is zero, which is exactly how these are used:
|
//! its CRC so that the residue over `data + transmitted_crc` is zero, which is
|
||||||
//! compute over the whole frame (including its trailing CRC) and check `== 0`.
|
//! exactly how these are used: compute over the whole frame (including its
|
||||||
|
//! trailing CRC) and check `== 0`.
|
||||||
|
|
||||||
/// CRC-16/ANSI (a.k.a. CRC-16/BUYPASS): polynomial 0x8005, init 0x0000,
|
/// CRC-16/ANSI (a.k.a. CRC-16/BUYPASS): polynomial 0x8005, init 0x0000,
|
||||||
/// MSB-first, no reflection, no final XOR — ffmpeg `AV_CRC_16_ANSI`.
|
/// MSB-first, no reflection, no final XOR. Called by the AC-3/E-AC-3 frame-CRC
|
||||||
/// Used by AC-3/E-AC-3 (frame CRC), FLAC (frame footer), MPEG-audio and
|
/// gate (ETSI TS 102 366) and the FLAC frame footer. (The MPEG-audio and
|
||||||
/// AAC-ADTS (header CRC).
|
/// AAC-ADTS gates validate the header structurally and do not verify their
|
||||||
|
/// optional CRC, so they do not call this.)
|
||||||
pub(crate) fn crc16_ansi(data: &[u8]) -> u16 {
|
pub(crate) fn crc16_ansi(data: &[u8]) -> u16 {
|
||||||
let mut crc: u16 = 0;
|
let mut crc: u16 = 0;
|
||||||
for &b in data {
|
for &b in data {
|
||||||
@@ -26,14 +28,12 @@ pub(crate) fn crc16_ansi(data: &[u8]) -> u16 {
|
|||||||
crc
|
crc
|
||||||
}
|
}
|
||||||
|
|
||||||
/// CRC-16 with polynomial 0x002D, init 0, MSB-first — ffmpeg's `crc_2D` table
|
/// CRC-16 with polynomial 0x002D, init 0, MSB-first, used by the MLP / Dolby
|
||||||
/// (`av_crc_init(crc_2D, 0, 16, 0x002D)`), used by the MLP/TrueHD major-sync
|
/// TrueHD major-sync header checksum. NOTE: MLP's checksum is the "reversed"
|
||||||
/// header checksum. NOTE: MLP's checksum is the "reversed" scheme — ffmpeg
|
/// scheme — the stored trailer word is the little-endian-read CRC, so this
|
||||||
/// computes `av_crc(...) ^ AV_RL16(trailer)` and compares against `AV_RL16` of
|
/// standard CRC must be compared against the stored bytes read big-endian.
|
||||||
/// the stored word; equivalently, this standard CRC compared against the stored
|
/// The caller handles that comparison (see `truehd::mlp_major_sync_ok`).
|
||||||
/// bytes read big-endian. The caller handles that comparison
|
/// Verified against real MLP/TrueHD bitstreams (225/225 major-sync AUs).
|
||||||
/// (see `truehd::mlp_major_sync_ok`). Verified against real ffmpeg TrueHD
|
|
||||||
/// output (225/225 major-sync AUs).
|
|
||||||
pub(crate) fn crc16_mlp(data: &[u8]) -> u16 {
|
pub(crate) fn crc16_mlp(data: &[u8]) -> u16 {
|
||||||
let mut crc: u16 = 0;
|
let mut crc: u16 = 0;
|
||||||
for &b in data {
|
for &b in data {
|
||||||
@@ -50,8 +50,8 @@ pub(crate) fn crc16_mlp(data: &[u8]) -> u16 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// CRC-8/ATM (a.k.a. CRC-8/ITU without the final XOR): polynomial 0x07, init 0,
|
/// CRC-8/ATM (a.k.a. CRC-8/ITU without the final XOR): polynomial 0x07, init 0,
|
||||||
/// MSB-first, no reflection — ffmpeg `AV_CRC_8_ATM`. Used by the FLAC frame
|
/// MSB-first, no reflection — the FLAC frame-header CRC-8 (RFC 9639). Available
|
||||||
/// header.
|
/// as a primitive; the FLAC gate currently validates only the frame footer CRC-16.
|
||||||
pub(crate) fn crc8_atm(data: &[u8]) -> u8 {
|
pub(crate) fn crc8_atm(data: &[u8]) -> u8 {
|
||||||
let mut crc: u8 = 0;
|
let mut crc: u8 = 0;
|
||||||
for &b in data {
|
for &b in data {
|
||||||
@@ -90,6 +90,29 @@ mod tests {
|
|||||||
assert_eq!(crc16_ansi(b"123456789"), 0xFEE8);
|
assert_eq!(crc16_ansi(b"123456789"), 0xFEE8);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn crc16_mlp_known_vector_check_bytes() {
|
||||||
|
// Independent known-answer for CRC-16 poly 0x002D, init 0, MSB-first over
|
||||||
|
// the catalogue string "123456789" is 0x4FF7 — computed by a separate
|
||||||
|
// reference implementation (NOT by crc16_mlp), so a wrong polynomial or
|
||||||
|
// shift direction here fails this test even though every truehd fixture
|
||||||
|
// (which derives its trailer from crc16_mlp itself) would still pass.
|
||||||
|
assert_eq!(crc16_mlp(b"123456789"), 0x4FF7);
|
||||||
|
assert_eq!(crc16_mlp(&[0x00, 0x01, 0x02, 0x03]), 0x5E26);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn crc16_mlp_residue_property_holds() {
|
||||||
|
// Appending the big-endian CRC zeroes the residue over message+crc — the
|
||||||
|
// scheme `truehd::mlp_major_sync_ok` relies on.
|
||||||
|
let msg = [0xF8u8, 0x72, 0x6F, 0xBA];
|
||||||
|
let c = crc16_mlp(&msg);
|
||||||
|
let mut framed = msg.to_vec();
|
||||||
|
framed.push((c >> 8) as u8);
|
||||||
|
framed.push((c & 0xFF) as u8);
|
||||||
|
assert_eq!(crc16_mlp(&framed), 0);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn crc8_residue_property_holds() {
|
fn crc8_residue_property_holds() {
|
||||||
// Appending the CRC-8 of a message zeroes the residue over message+crc —
|
// Appending the CRC-8 of a message zeroes the residue over message+crc —
|
||||||
|
|||||||
@@ -8,8 +8,9 @@
|
|||||||
//! as a decoder-choking glitch.
|
//! as a decoder-choking glitch.
|
||||||
//!
|
//!
|
||||||
//! The DETECTION is inherently per-codec — each format carries its own
|
//! The DETECTION is inherently per-codec — each format carries its own
|
||||||
//! authoritative corruption check (DTS: ffmpeg's core-header parse; AC-3: the
|
//! authoritative corruption check (DTS: the core sync/header parse per ETSI TS
|
||||||
//! header CRC; FLAC: the frame CRC-16; …). This type only carries the UNIFORM
|
//! 102 114; AC-3: the header CRC per ETSI TS 102 366; FLAC: the frame CRC-16; …).
|
||||||
|
//! This type only carries the UNIFORM
|
||||||
//! response so every audio parser behaves identically:
|
//! response so every audio parser behaves identically:
|
||||||
//!
|
//!
|
||||||
//! 1. **Count** kept vs dropped AUs and the dropped duration.
|
//! 1. **Count** kept vs dropped AUs and the dropped duration.
|
||||||
@@ -198,4 +199,17 @@ mod tests {
|
|||||||
}
|
}
|
||||||
assert!(!t.is_poisoned());
|
assert!(!t.is_poisoned());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn collateral_drops_never_poison_the_track() {
|
||||||
|
// A TrueHD resync-forward run collaterally drops a long burst of AUs, but
|
||||||
|
// none are individually undecodable — the whole-track verdict must stay
|
||||||
|
// clean so one corruption event can't amplify into a false total loss.
|
||||||
|
let mut t = DropTally::new("test");
|
||||||
|
for _ in 0..(TRACK_VERDICT_MIN_AUS * 3) {
|
||||||
|
t.record_collateral_drop(0, 1000, 512, "resync-forward");
|
||||||
|
}
|
||||||
|
assert!(t.dropped_frames() >= TRACK_VERDICT_MIN_AUS, "drops counted");
|
||||||
|
assert!(!t.is_poisoned(), "collateral drops must not poison");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+58
-48
@@ -238,7 +238,7 @@ impl CodecParser for DtsParser {
|
|||||||
if pes.data.is_empty() {
|
if pes.data.is_empty() {
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
}
|
}
|
||||||
// A PES with no PTS (rare for audio, but legal — the case OSS demuxers
|
// A PES with no PTS (rare for audio, but legal — the case demuxers
|
||||||
// guard at a post-gap continuation) must NOT reset the timeline to 0;
|
// guard at a post-gap continuation) must NOT reset the timeline to 0;
|
||||||
// continue from the most recent known base. Defense-in-depth: the
|
// continue from the most recent known base. Defense-in-depth: the
|
||||||
// discontinuity-carrying PES is a PUSI with a PTS in practice.
|
// discontinuity-carrying PES is a PUSI with a PTS in practice.
|
||||||
@@ -351,7 +351,16 @@ impl CodecParser for DtsParser {
|
|||||||
let mut forced = false;
|
let mut forced = false;
|
||||||
let (au_end, ext_clean) = match next_core_boundary(&self.buf, core_size) {
|
let (au_end, ext_clean) = match next_core_boundary(&self.buf, core_size) {
|
||||||
NextCore::Found { end, ext_clean } => (end, ext_clean),
|
NextCore::Found { end, ext_clean } => (end, ext_clean),
|
||||||
NextCore::NeedMore => break, // candidate sync needs more header
|
NextCore::NeedMore if self.buf.len() <= MAX_AU_BYTES => break,
|
||||||
|
NextCore::NeedMore => {
|
||||||
|
// A candidate boundary exists but is not fully buffered. Normally
|
||||||
|
// we wait for more PES; but once the buffer exceeds the AU cap,
|
||||||
|
// apply the same force-flush safety valve as `None` so a crafted
|
||||||
|
// stream that keeps a boundary perpetually incomplete can't grow
|
||||||
|
// `buf` without bound (the `break` above never reaches it).
|
||||||
|
forced = true;
|
||||||
|
(self.buf.len(), true)
|
||||||
|
}
|
||||||
NextCore::None => {
|
NextCore::None => {
|
||||||
// No next core sync buffered yet. The trailing extension
|
// No next core sync buffered yet. The trailing extension
|
||||||
// substream PES packets may still be arriving, so WAIT for
|
// substream PES packets may still be arriving, so WAIT for
|
||||||
@@ -643,8 +652,8 @@ const DTS_CORE_SAMPLE_RATES: [u32; 16] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
/// Samples in one DTS core frame: `(NBLKS + 1) * 32`. `NBLKS` (7 bits) is the
|
/// Samples in one DTS core frame: `(NBLKS + 1) * 32`. `NBLKS` (7 bits) is the
|
||||||
/// core-header PCM-sample-block count — the same field ffmpeg's `dca` decoder
|
/// core-header PCM-sample-block count (ETSI TS 102 114) that fixes the frame's
|
||||||
/// uses to timestamp frames. Bit layout after the 32-bit sync: FTYPE(1) SHORT(5)
|
/// decoded sample count. Bit layout after the 32-bit sync: FTYPE(1) SHORT(5)
|
||||||
/// CPF(1) **NBLKS(7)** FSIZE(14) …, so NBLKS = byte4 bit0 + byte5 bits7-2.
|
/// CPF(1) **NBLKS(7)** FSIZE(14) …, so NBLKS = byte4 bit0 + byte5 bits7-2.
|
||||||
fn dts_core_samples(data: &[u8]) -> u32 {
|
fn dts_core_samples(data: &[u8]) -> u32 {
|
||||||
if data.len() < CORE_HEADER_MIN_BYTES {
|
if data.len() < CORE_HEADER_MIN_BYTES {
|
||||||
@@ -673,18 +682,18 @@ fn dts_core_duration_ns(data: &[u8]) -> u64 {
|
|||||||
(samples * 1_000_000_000 + rate / 2) / rate
|
(samples * 1_000_000_000 + rate / 2) / rate
|
||||||
}
|
}
|
||||||
|
|
||||||
/// DCA core-header constants, mirrored from ffmpeg `libavcodec/dca_core.h`.
|
/// DTS core-header validity constants (ETSI TS 102 114).
|
||||||
/// `deficit_samples` must equal this (`DCA_PCMBLOCK_SAMPLES`); `npcmblocks`
|
/// `deficit_samples` must equal this (`DTS_PCMBLOCK_SAMPLES`); `npcmblocks`
|
||||||
/// must be a multiple of `DCA_SUBBAND_SAMPLES`; `audio_mode` must be below
|
/// must be a multiple of `DTS_SUBBAND_SAMPLES`; `audio_mode` must be below
|
||||||
/// `DCA_AMODE_COUNT`; `lfe_present == DCA_LFE_FLAG_INVALID` is rejected.
|
/// `DTS_AMODE_COUNT`; `lfe_present == DTS_LFE_FLAG_INVALID` is rejected.
|
||||||
const DTS_PCMBLOCK_SAMPLES: u32 = 32;
|
const DTS_PCMBLOCK_SAMPLES: u32 = 32;
|
||||||
const DTS_SUBBAND_SAMPLES: u32 = 8;
|
const DTS_SUBBAND_SAMPLES: u32 = 8;
|
||||||
const DTS_AMODE_COUNT: u32 = 10;
|
const DTS_AMODE_COUNT: u32 = 10;
|
||||||
const DTS_LFE_FLAG_INVALID: u32 = 3;
|
const DTS_LFE_FLAG_INVALID: u32 = 3;
|
||||||
|
|
||||||
/// `ff_dca_sample_rates[16]` — sample rate (Hz) per core `SFREQ` code; a `0`
|
/// Sample rate (Hz) per core `SFREQ` code (ETSI TS 102 114 Table 6-4); a `0`
|
||||||
/// entry marks a reserved code that ffmpeg's parser rejects
|
/// entry marks a reserved code that fails header validation as an invalid
|
||||||
/// (`DCA_PARSE_ERROR_SAMPLE_RATE`). Valid entries are locked to the spec by
|
/// sample rate. Valid entries are locked to the spec by
|
||||||
/// `dts_core_sfreq_table_matches_the_dca_spec`; the reserved codes are
|
/// `dts_core_sfreq_table_matches_the_dca_spec`; the reserved codes are
|
||||||
/// {0, 4, 5, 9, 10}.
|
/// {0, 4, 5, 9, 10}.
|
||||||
const DTS_CORE_SR_VALID: [u32; 16] = [
|
const DTS_CORE_SR_VALID: [u32; 16] = [
|
||||||
@@ -692,14 +701,14 @@ const DTS_CORE_SR_VALID: [u32; 16] = [
|
|||||||
192_000,
|
192_000,
|
||||||
];
|
];
|
||||||
|
|
||||||
/// `ff_dca_bits_per_sample[8]` — a `0` entry marks a reserved `PCMR` code that
|
/// Bits per sample per core `PCMR` code (ETSI TS 102 114); a `0` entry marks a
|
||||||
/// ffmpeg's parser rejects (`DCA_PARSE_ERROR_PCM_RES`); reserved codes are
|
/// reserved `PCMR` code that fails header validation as an invalid PCM
|
||||||
/// {4, 7}.
|
/// resolution; reserved codes are {4, 7}.
|
||||||
const DTS_CORE_PCMR_BITS: [u8; 8] = [16, 16, 20, 20, 0, 24, 24, 0];
|
const DTS_CORE_PCMR_BITS: [u8; 8] = [16, 16, 20, 20, 0, 24, 24, 0];
|
||||||
|
|
||||||
/// Why an access unit was judged undecodable. Each core-header variant is the
|
/// Why an access unit was judged undecodable. Each core-header variant is a
|
||||||
/// exact condition under which ffmpeg's `ff_dca_parse_core_frame_header` returns
|
/// condition under which the DTS core-frame header (ETSI TS 102 114) is invalid
|
||||||
/// the matching `DCA_PARSE_ERROR_*`; `TrackPoisoned` is our whole-track drop.
|
/// and a decoder would reject the frame; `TrackPoisoned` is our whole-track drop.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
enum DropReason {
|
enum DropReason {
|
||||||
DeficitSamples,
|
DeficitSamples,
|
||||||
@@ -730,19 +739,19 @@ impl DropReason {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Decodability gate: a faithful port of ffmpeg's `ff_dca_parse_core_frame_header`
|
/// Decodability gate: the core-frame header validity checks from ETSI TS 102
|
||||||
/// validity checks (libavcodec/dca.c). Returns `Some(reason)` when ffmpeg's own
|
/// 114. Returns `Some(reason)` when the DTS core-frame header is invalid — in
|
||||||
/// parser would reject this core frame's header — in which case the packet is
|
/// which case the packet is undecodable ("Invalid data found") and dropping it
|
||||||
/// undecodable ("Invalid data found") and dropping it loses nothing a decoder
|
/// loses nothing a decoder could have used. Returns `None` (keep) for a
|
||||||
/// could have used. Returns `None` (keep) for a decodable header OR if the
|
/// decodable header OR if the header can't be fully read (never false-drop on
|
||||||
/// header can't be fully read (never false-drop on our own buffer underrun; the
|
/// our own buffer underrun; the framer only emits AUs whose core is fully
|
||||||
/// framer only emits AUs whose core is fully buffered and ≥ 96 bytes).
|
/// buffered and ≥ 96 bytes).
|
||||||
///
|
///
|
||||||
/// The 4-byte core sync is already validated by the framer, so this reads the
|
/// The 4-byte core sync is already validated by the framer, so this reads the
|
||||||
/// header fields that follow it. ffmpeg's parser does NOT verify the CPF header
|
/// header fields that follow it. The 16-bit CPF header CRC is not verified (we
|
||||||
/// CRC (it `skip_bits(16)` past it — dca.c) and the core decoder likewise skips
|
/// skip past it) and the audio-header/side-info CRCs are likewise not checked,
|
||||||
/// the audio-header/side-info CRCs (dca_core.c), so no CRC check is mirrored
|
/// because decoders treat those bytes as optional/ignored — verifying them
|
||||||
/// here: doing so would drop frames ffmpeg decodes fine (false positives).
|
/// would drop frames that decode fine (false positives).
|
||||||
fn core_header_drop_reason(au: &[u8]) -> Option<DropReason> {
|
fn core_header_drop_reason(au: &[u8]) -> Option<DropReason> {
|
||||||
let mut r = BitReader::new(au.get(SYNCWORD_BYTES..)?);
|
let mut r = BitReader::new(au.get(SYNCWORD_BYTES..)?);
|
||||||
|
|
||||||
@@ -782,7 +791,7 @@ fn core_header_drop_reason(au: &[u8]) -> Option<DropReason> {
|
|||||||
}
|
}
|
||||||
let _predictor_history = r.read_bit()?;
|
let _predictor_history = r.read_bit()?;
|
||||||
if crc_present {
|
if crc_present {
|
||||||
// ffmpeg only skips the 16-bit header CRC here — it is not verified.
|
// Skip past the 16-bit header CRC here — it is not verified.
|
||||||
r.skip_bits(16)?;
|
r.skip_bits(16)?;
|
||||||
}
|
}
|
||||||
let _filter_perfect = r.read_bit()?;
|
let _filter_perfect = r.read_bit()?;
|
||||||
@@ -816,8 +825,8 @@ mod tests {
|
|||||||
let mut data = vec![0u8; size];
|
let mut data = vec![0u8; size];
|
||||||
data[0..4].copy_from_slice(&DTS_CORE_SYNC);
|
data[0..4].copy_from_slice(&DTS_CORE_SYNC);
|
||||||
// byte4: FTYPE(0) SHORT(5) CPF(0) NBLKS-high(0). SHORT = 31 makes
|
// byte4: FTYPE(0) SHORT(5) CPF(0) NBLKS-high(0). SHORT = 31 makes
|
||||||
// deficit_samples = 32 = DCA_PCMBLOCK_SAMPLES, which ffmpeg's parser
|
// deficit_samples = 32 = DTS_PCMBLOCK_SAMPLES, which the decodability
|
||||||
// (and our decodability gate) require of a real core frame. NBLKS high
|
// gate (per ETSI TS 102 114) requires of a real core frame. NBLKS high
|
||||||
// bit (byte4 bit0) stays 0 for NBLKS = 15.
|
// bit (byte4 bit0) stays 0 for NBLKS = 15.
|
||||||
data[4] = 31u8 << 2;
|
data[4] = 31u8 << 2;
|
||||||
// NBLKS = 15 → (15+1)*32 = 512 samples/frame (the DVD/UHD DTS-core norm).
|
// NBLKS = 15 → (15+1)*32 = 512 samples/frame (the DVD/UHD DTS-core norm).
|
||||||
@@ -867,8 +876,8 @@ mod tests {
|
|||||||
// AU = core(512) + a REAL EXSS substream whose XLL payload embeds a DTS
|
// AU = core(512) + a REAL EXSS substream whose XLL payload embeds a DTS
|
||||||
// core syncword decoding to a plausible size (512). The heuristic-only
|
// core syncword decoding to a plausible size (512). The heuristic-only
|
||||||
// framer would split here and truncate the lossless extension (the
|
// framer would split here and truncate the lossless extension (the
|
||||||
// Dunkirk `dca` "Failed to decode block code(s)" class). Precise EXSS
|
// Dunkirk "Failed to decode block code(s)" decoder-failure class).
|
||||||
// sizing spans the whole extension to the REAL next core.
|
// Precise EXSS sizing spans the whole extension to the REAL next core.
|
||||||
let core = make_dts_core(512);
|
let core = make_dts_core(512);
|
||||||
let exss = make_exss(600, Some(40));
|
let exss = make_exss(600, Some(40));
|
||||||
let next = make_dts_core(512);
|
let next = make_dts_core(512);
|
||||||
@@ -975,9 +984,9 @@ mod tests {
|
|||||||
// B1: a partial DTS core is buffered, then a concealed gap (PES marked
|
// B1: a partial DTS core is buffered, then a concealed gap (PES marked
|
||||||
// discontinuity) carries a fresh core. The truncated partial must be
|
// discontinuity) carries a fresh core. The truncated partial must be
|
||||||
// DROPPED — splicing it makes the framer emit a corrupt sub-core-length
|
// DROPPED — splicing it makes the framer emit a corrupt sub-core-length
|
||||||
// AU (the Dunkirk `dca` "Failed to decode block code(s)" class) and
|
// AU (the Dunkirk "Failed to decode block code(s)" decoder-failure
|
||||||
// strands the rest. With the fix the post-gap core is the only AU, and it
|
// class) and strands the rest. With the fix the post-gap core is the
|
||||||
// carries the post-gap PTS (not the stale pre-gap one).
|
// only AU, and it carries the post-gap PTS (not the stale pre-gap one).
|
||||||
let mut parser = DtsParser::new();
|
let mut parser = DtsParser::new();
|
||||||
|
|
||||||
// PES 1: first half of a 512-byte core (no boundary marker).
|
// PES 1: first half of a 512-byte core (no boundary marker).
|
||||||
@@ -1124,7 +1133,7 @@ mod tests {
|
|||||||
fn dvd_many_cores_one_pes_are_strictly_monotonic() {
|
fn dvd_many_cores_one_pes_are_strictly_monotonic() {
|
||||||
// Punisher-DVD reproduction: a single PES carrying SEVERAL DTS core
|
// Punisher-DVD reproduction: a single PES carrying SEVERAL DTS core
|
||||||
// frames (the DVD packing) must emit STRICTLY-increasing PTSs. The old
|
// frames (the DVD packing) must emit STRICTLY-increasing PTSs. The old
|
||||||
// code stamped every AU with the one PES PTS, which ffmpeg rejected as
|
// code stamped every AU with the one PES PTS, which a muxer rejects as
|
||||||
// "non monotonically increasing dts to muxer: X >= X".
|
// "non monotonically increasing dts to muxer: X >= X".
|
||||||
let mut parser = DtsParser::new();
|
let mut parser = DtsParser::new();
|
||||||
let mut stream = Vec::new();
|
let mut stream = Vec::new();
|
||||||
@@ -1171,9 +1180,9 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn dts_core_sfreq_table_matches_the_dca_spec() {
|
fn dts_core_sfreq_table_matches_the_dca_spec() {
|
||||||
// Lock the SFREQ → sample-rate table to ffmpeg's authoritative
|
// Lock the SFREQ → sample-rate table to the authoritative values in
|
||||||
// `avpriv_dca_sample_rates` (ETSI TS 102 114 Table 6-4). The high-rate
|
// ETSI TS 102 114 Table 6-4. The high-rate triad in particular —
|
||||||
// triad in particular — 48 k / 96 k / 192 k at indices 13/14/15 — must not
|
// 48 k / 96 k / 192 k at indices 13/14/15 — must not
|
||||||
// be shifted; a wrong entry would compute an N× frame duration and
|
// be shifted; a wrong entry would compute an N× frame duration and
|
||||||
// reintroduce PTS drift on a 96/192 kHz DTS stream.
|
// reintroduce PTS drift on a 96/192 kHz DTS stream.
|
||||||
let mut core = make_dts_core(512);
|
let mut core = make_dts_core(512);
|
||||||
@@ -1732,8 +1741,8 @@ mod tests {
|
|||||||
|
|
||||||
/// A structurally-framed but UNDECODABLE core: a valid `make_dts_core`
|
/// A structurally-framed but UNDECODABLE core: a valid `make_dts_core`
|
||||||
/// whose reserved header bit is set. It still sizes and syncs correctly (so
|
/// whose reserved header bit is set. It still sizes and syncs correctly (so
|
||||||
/// the framer delimits it normally), but ffmpeg's `ff_dca_parse_core_frame_header`
|
/// the framer delimits it normally), but the core-frame header validity
|
||||||
/// — and our port — reject it (`DCA_PARSE_ERROR_RESERVED_BIT`). The reserved
|
/// check rejects it as a set reserved bit (ETSI TS 102 114). The reserved
|
||||||
/// bit is byte9 bit4 in the core header (after SYNC..RATE).
|
/// bit is byte9 bit4 in the core header (after SYNC..RATE).
|
||||||
fn make_bad_dts_core(size: usize) -> Vec<u8> {
|
fn make_bad_dts_core(size: usize) -> Vec<u8> {
|
||||||
let mut d = make_dts_core(size);
|
let mut d = make_dts_core(size);
|
||||||
@@ -1753,7 +1762,8 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn valid_stream_drops_nothing() {
|
fn valid_stream_drops_nothing() {
|
||||||
// A clean stream of decodable cores must pass the gate untouched — the
|
// A clean stream of decodable cores must pass the gate untouched — the
|
||||||
// detector is an exact ffmpeg-parity port, so zero false positives.
|
// detector follows the spec's validity rules exactly, so zero false
|
||||||
|
// positives.
|
||||||
let mut parser = DtsParser::new();
|
let mut parser = DtsParser::new();
|
||||||
let mut stream = Vec::new();
|
let mut stream = Vec::new();
|
||||||
for _ in 0..5 {
|
for _ in 0..5 {
|
||||||
@@ -1859,7 +1869,7 @@ mod tests {
|
|||||||
fn sr_validity_table_marks_reserved_codes() {
|
fn sr_validity_table_marks_reserved_codes() {
|
||||||
// The core-header sample-rate validity table must have ZERO (reject) at
|
// The core-header sample-rate validity table must have ZERO (reject) at
|
||||||
// exactly the reserved SFREQ codes {0,4,5,9,10} and a real rate
|
// exactly the reserved SFREQ codes {0,4,5,9,10} and a real rate
|
||||||
// elsewhere — this is what mirrors ffmpeg's DCA_PARSE_ERROR_SAMPLE_RATE.
|
// elsewhere — this is what drives the invalid-sample-rate rejection.
|
||||||
for code in 0..16usize {
|
for code in 0..16usize {
|
||||||
let reserved = matches!(code, 0 | 4 | 5 | 9 | 10);
|
let reserved = matches!(code, 0 | 4 | 5 | 9 | 10);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -1872,8 +1882,8 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn every_core_header_error_class_is_detected() {
|
fn every_core_header_error_class_is_detected() {
|
||||||
// Exercise each ffmpeg-parity rejection so the port stays faithful.
|
// Exercise each header-validity rejection so the gate stays faithful to
|
||||||
// Start from a decodable core and corrupt one field at a time.
|
// the spec. Start from a decodable core and corrupt one field at a time.
|
||||||
let good = make_dts_core(512);
|
let good = make_dts_core(512);
|
||||||
assert_eq!(core_header_drop_reason(&good), None);
|
assert_eq!(core_header_drop_reason(&good), None);
|
||||||
|
|
||||||
@@ -1913,7 +1923,7 @@ mod tests {
|
|||||||
assert_eq!(core_header_drop_reason(&d), Some(DropReason::LfeFlag));
|
assert_eq!(core_header_drop_reason(&d), Some(DropReason::LfeFlag));
|
||||||
|
|
||||||
// pcmr_code reserved (7): pcmr is byte11 bit0 + byte12 bits7-6 → set all
|
// pcmr_code reserved (7): pcmr is byte11 bit0 + byte12 bits7-6 → set all
|
||||||
// three to 1 (code 7 → ff_dca_bits_per_sample[7] = 0).
|
// three to 1 (code 7 → DTS_CORE_PCMR_BITS[7] = 0, a reserved PCMR code).
|
||||||
let mut d = good.clone();
|
let mut d = good.clone();
|
||||||
d[11] |= 0x01;
|
d[11] |= 0x01;
|
||||||
d[12] |= 0xC0;
|
d[12] |= 0xC0;
|
||||||
@@ -1924,7 +1934,7 @@ mod tests {
|
|||||||
/// through `DtsParser` and writes the emitted access units back out, so the
|
/// through `DtsParser` and writes the emitted access units back out, so the
|
||||||
/// garbage-extension → core-only drop can be validated against an actual
|
/// garbage-extension → core-only drop can be validated against an actual
|
||||||
/// damaged stream (e.g. the extracted Bourne DTS-HD MA track) end-to-end
|
/// damaged stream (e.g. the extracted Bourne DTS-HD MA track) end-to-end
|
||||||
/// with ffmpeg. Env: `DTS_IN` (input), `DTS_OUT` (output).
|
/// with an external DTS decoder. Env: `DTS_IN` (input), `DTS_OUT` (output).
|
||||||
/// cargo test --lib dts::tests::reparse_real_dts_file -- --ignored --nocapture
|
/// cargo test --lib dts::tests::reparse_real_dts_file -- --ignored --nocapture
|
||||||
#[test]
|
#[test]
|
||||||
#[ignore]
|
#[ignore]
|
||||||
|
|||||||
+18
-8
@@ -3,10 +3,11 @@
|
|||||||
//! FLAC frames carry no length field, so a raw stream is delimited only by
|
//! FLAC frames carry no length field, so a raw stream is delimited only by
|
||||||
//! sync-scanning + CRC validation. In freemkv, though, FLAC never arrives raw:
|
//! sync-scanning + CRC validation. In freemkv, though, FLAC never arrives raw:
|
||||||
//! it comes from mp4/mkv, where each packet is exactly one container-delimited
|
//! it comes from mp4/mkv, where each packet is exactly one container-delimited
|
||||||
//! FLAC frame (the `PARSER_FLAG_COMPLETE_FRAMES` case in ffmpeg). So this parser
|
//! FLAC frame (a complete, pre-delimited frame per packet). So this parser
|
||||||
//! is a per-packet gate, not a framer: every FLAC frame ends with a 16-bit CRC
|
//! is a per-packet gate, not a framer: every FLAC frame ends with a 16-bit CRC
|
||||||
//! (poly 0x8005) computed so the residue over the whole frame is zero
|
//! (poly 0x8005, init 0, non-reflected) computed so the residue over the whole
|
||||||
//! (ffmpeg `flac_decode_frame`, `av_crc(AV_CRC_16_ANSI, 0, buf, len) == 0`). A
|
//! frame — footer CRC included — is zero (per the FLAC format specification,
|
||||||
|
//! RFC 9639, frame footer). A
|
||||||
//! nonzero residue is definitive corruption → drop the frame (a silence gap,
|
//! nonzero residue is definitive corruption → drop the frame (a silence gap,
|
||||||
//! never a shift — each packet keeps its own PTS), logged via the shared tally.
|
//! never a shift — each packet keeps its own PTS), logged via the shared tally.
|
||||||
//!
|
//!
|
||||||
@@ -18,17 +19,17 @@ use super::dropgate::DropTally;
|
|||||||
use super::{CodecParser, Frame, PesPacket, pts_to_ns};
|
use super::{CodecParser, Frame, PesPacket, pts_to_ns};
|
||||||
|
|
||||||
/// FLAC frame sync: 14-bit code `0x3FFE` + a mandatory-0 reserved bit; the next
|
/// FLAC frame sync: 14-bit code `0x3FFE` + a mandatory-0 reserved bit; the next
|
||||||
/// bit (blocking strategy) is masked off. ffmpeg tests `(AV_RB16 & 0xFFFE) ==
|
/// bit (blocking strategy) is masked off. Test the top 15 bits of the first two
|
||||||
/// 0xFFF8` (flac_parser.c).
|
/// bytes: `(be16 & 0xFFFE) == 0xFFF8` (per RFC 9639, frame header).
|
||||||
fn has_flac_sync(data: &[u8]) -> bool {
|
fn has_flac_sync(data: &[u8]) -> bool {
|
||||||
data.len() >= 2 && ((u16::from(data[0]) << 8 | u16::from(data[1])) & 0xFFFE) == 0xFFF8
|
data.len() >= 2 && ((u16::from(data[0]) << 8 | u16::from(data[1])) & 0xFFFE) == 0xFFF8
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Block-size code → samples, `ff_flac_blocksize_table` (0 = reserved/explicit).
|
/// Block-size code → samples (RFC 9639 block-size table; 0 = reserved/explicit).
|
||||||
const FLAC_BLOCKSIZE_TABLE: [u32; 16] = [
|
const FLAC_BLOCKSIZE_TABLE: [u32; 16] = [
|
||||||
0, 192, 576, 1152, 2304, 4608, 0, 0, 256, 512, 1024, 2048, 4096, 8192, 16384, 32768,
|
0, 192, 576, 1152, 2304, 4608, 0, 0, 256, 512, 1024, 2048, 4096, 8192, 16384, 32768,
|
||||||
];
|
];
|
||||||
/// Sample-rate code → Hz, `ff_flac_sample_rate_table` (0 = STREAMINFO/explicit).
|
/// Sample-rate code → Hz (RFC 9639 sample-rate table; 0 = STREAMINFO/explicit).
|
||||||
const FLAC_SAMPLE_RATE_TABLE: [u32; 16] = [
|
const FLAC_SAMPLE_RATE_TABLE: [u32; 16] = [
|
||||||
0, 88_200, 176_400, 192_000, 8_000, 16_000, 22_050, 24_000, 32_000, 44_100, 48_000, 96_000, 0,
|
0, 88_200, 176_400, 192_000, 8_000, 16_000, 22_050, 24_000, 32_000, 44_100, 48_000, 96_000, 0,
|
||||||
0, 0, 0,
|
0, 0, 0,
|
||||||
@@ -55,6 +56,9 @@ fn flac_frame_duration_ns(frame: &[u8]) -> Option<i64> {
|
|||||||
|
|
||||||
pub struct FlacParser {
|
pub struct FlacParser {
|
||||||
tally: DropTally,
|
tally: DropTally,
|
||||||
|
/// Last emitted PTS (ns), carried forward across a PES with no PTS rather than
|
||||||
|
/// resetting the timeline to 0 (see the AC-3/DTS parsers) — preserves A/V sync.
|
||||||
|
last_pts_ns: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for FlacParser {
|
impl Default for FlacParser {
|
||||||
@@ -67,6 +71,7 @@ impl FlacParser {
|
|||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
tally: DropTally::new("flac"),
|
tally: DropTally::new("flac"),
|
||||||
|
last_pts_ns: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,7 +91,12 @@ impl CodecParser for FlacParser {
|
|||||||
if pes.data.is_empty() {
|
if pes.data.is_empty() {
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
}
|
}
|
||||||
let pts_ns = pes.pts.or(pes.dts).map(pts_to_ns).unwrap_or(0);
|
let pts_ns = pes
|
||||||
|
.pts
|
||||||
|
.or(pes.dts)
|
||||||
|
.map(pts_to_ns)
|
||||||
|
.unwrap_or(self.last_pts_ns);
|
||||||
|
self.last_pts_ns = pts_ns;
|
||||||
|
|
||||||
// Gate: a packet that begins with a FLAC frame sync but whose whole-frame
|
// Gate: a packet that begins with a FLAC frame sync but whose whole-frame
|
||||||
// CRC-16 residue is nonzero is corrupt → drop. Anything else passes
|
// CRC-16 residue is nonzero is corrupt → drop. Anything else passes
|
||||||
|
|||||||
+10
-7
@@ -99,7 +99,8 @@ fn hevc_first_slice_coding_type(nal: &[u8], nal_type: u8, num_extra: u32) -> Opt
|
|||||||
pub struct HevcParser {
|
pub struct HevcParser {
|
||||||
// First-seen parameter set of each type → seeds the MKV codecPrivate (hvcC).
|
// First-seen parameter set of each type → seeds the MKV codecPrivate (hvcC).
|
||||||
// This is the ONLY copy the player gets out-of-band, and a player re-applies
|
// This is the ONLY copy the player gets out-of-band, and a player re-applies
|
||||||
// it at every keyframe (ffmpeg's hvcC→Annex-B insertion). A stream may
|
// it at every keyframe (the hvcC→Annex-B parameter-set insertion a decoder
|
||||||
|
// performs). A stream may
|
||||||
// redefine a parameter set mid-title under the SAME id with a different body
|
// redefine a parameter set mid-title under the SAME id with a different body
|
||||||
// (some discs redefine PPS id 0 partway through). Any occurrence whose body
|
// (some discs redefine PPS id 0 partway through). Any occurrence whose body
|
||||||
// DIFFERS from this codecPrivate copy must therefore be emitted IN-BAND at
|
// DIFFERS from this codecPrivate copy must therefore be emitted IN-BAND at
|
||||||
@@ -116,8 +117,9 @@ pub struct HevcParser {
|
|||||||
// set mid-title (e.g. PPS id 0 body changes partway through, then the
|
// set mid-title (e.g. PPS id 0 body changes partway through, then the
|
||||||
// source STOPS repeating it at later IRAPs and relies on the decoder
|
// source STOPS repeating it at later IRAPs and relies on the decoder
|
||||||
// retaining it), a raw decode is fine — but an hvcC/MKV decode is NOT: a
|
// retaining it), a raw decode is fine — but an hvcC/MKV decode is NOT: a
|
||||||
// player re-applies the codecPrivate set at EVERY keyframe (ffmpeg's
|
// player re-applies the codecPrivate set at EVERY keyframe (the
|
||||||
// hvcC→Annex-B insertion), reverting id 0 to the stale FIRST body. We must
|
// hvcC→Annex-B parameter-set insertion), reverting id 0 to the stale FIRST
|
||||||
|
// body. We must
|
||||||
// therefore re-emit the active set IN-BAND at every keyframe whenever it
|
// therefore re-emit the active set IN-BAND at every keyframe whenever it
|
||||||
// differs from the codecPrivate copy and the access unit didn't already
|
// differs from the codecPrivate copy and the access unit didn't already
|
||||||
// carry it. See `parse`.
|
// carry it. See `parse`.
|
||||||
@@ -364,9 +366,10 @@ impl HevcParser {
|
|||||||
/// codecPrivate copy (`first`). The two player behaviours for hvcC-in-MKV
|
/// codecPrivate copy (`first`). The two player behaviours for hvcC-in-MKV
|
||||||
/// diverge exactly here:
|
/// diverge exactly here:
|
||||||
///
|
///
|
||||||
/// - A *seek-capable / Annex-B* player (e.g. ffmpeg's `hevc_mp4toannexb`)
|
/// - A *seek-capable / Annex-B* player (one that converts hvcC to Annex-B by
|
||||||
/// re-applies the hvcC sets at every keyframe. `reassert_active` handles it.
|
/// inserting the parameter sets) re-applies the hvcC sets at every keyframe.
|
||||||
/// - A *streaming* decode (ffmpeg decoding the MKV directly — what most
|
/// `reassert_active` handles it.
|
||||||
|
/// - A *streaming* decode (a decoder consuming the MKV directly — what most
|
||||||
/// integrity checkers do) applies hvcC ONCE at init and thereafter updates a
|
/// integrity checkers do) applies hvcC ONCE at init and thereafter updates a
|
||||||
/// parameter set ONLY from an in-band NAL.
|
/// parameter set ONLY from an in-band NAL.
|
||||||
///
|
///
|
||||||
@@ -427,7 +430,7 @@ fn handle_param_set(
|
|||||||
/// or SPS event), nothing re-sends it and every subsequent slice fails with
|
/// or SPS event), nothing re-sends it and every subsequent slice fails with
|
||||||
/// "PPS id out of range" until the next genuine change (observed as a ~24 min
|
/// "PPS id out of range" until the next genuine change (observed as a ~24 min
|
||||||
/// corrupt band on one dual-layer UHD title). Re-asserting the active set at
|
/// corrupt band on one dual-layer UHD title). Re-asserting the active set at
|
||||||
/// EVERY keyframe — what compliant muxers (mkvmerge) do at every IRAP — makes
|
/// EVERY keyframe — what compliant Matroska muxers do at every IRAP — makes
|
||||||
/// streaming decode self-healing. Re-sending an identical param set is benign
|
/// streaming decode self-healing. Re-sending an identical param set is benign
|
||||||
/// (decoders expect it at IRAPs); cost is a few hundred bytes per keyframe.
|
/// (decoders expect it at IRAPs); cost is a few hundred bytes per keyframe.
|
||||||
/// This strictly supersets the earlier change-only re-assert, so the
|
/// This strictly supersets the earlier change-only re-assert, so the
|
||||||
|
|||||||
+14
-13
@@ -121,11 +121,11 @@ pub trait CodecParser: Send {
|
|||||||
|
|
||||||
/// Passthrough parser — treats each PES as one frame, no parsing.
|
/// Passthrough parser — treats each PES as one frame, no parsing.
|
||||||
///
|
///
|
||||||
/// Used for the audio codecs that have no dedicated parser and whose PES
|
/// Used for Opus (and any audio codec with no dedicated parser) whose PES
|
||||||
/// boundaries already line up with frame boundaries (Aac, Mp2, Mp3, Flac,
|
/// boundaries already line up with frame boundaries. AC3/E-AC3, DTS, TrueHD,
|
||||||
/// Opus). AC3/DTS/TrueHD have their own parsers; PGS/DvdSub have their own
|
/// AAC(ADTS), MP2/MP3 and FLAC now have their own gating parsers; PGS/DvdSub
|
||||||
/// subtitle parsers. Video codecs must NOT use the all-keyframe form of this
|
/// have their own subtitle parsers. Video codecs must NOT use the all-keyframe
|
||||||
/// parser — see `parser_for_codec`.
|
/// form of this parser — see `parser_for_codec`.
|
||||||
pub struct PassthroughParser {
|
pub struct PassthroughParser {
|
||||||
keyframe: bool,
|
keyframe: bool,
|
||||||
}
|
}
|
||||||
@@ -168,8 +168,8 @@ impl CodecParser for PassthroughParser {
|
|||||||
/// - **Audio with independent access units** (DTS, AC-3/E-AC-3, …) gates each AU
|
/// - **Audio with independent access units** (DTS, AC-3/E-AC-3, …) gates each AU
|
||||||
/// through a per-codec corruption check and drops the ones that fail, keeping
|
/// through a per-codec corruption check and drops the ones that fail, keeping
|
||||||
/// A/V sync (a drop is a silence gap, never a shift) and logging every drop
|
/// A/V sync (a drop is a silence gap, never a shift) and logging every drop
|
||||||
/// via the shared [`dropgate::DropTally`]. DTS uses ffmpeg's core-header parse;
|
/// via the shared [`dropgate::DropTally`]. DTS validates via its core-frame
|
||||||
/// AC-3 uses its native frame CRC.
|
/// header (ETSI TS 102 114); AC-3 uses its native frame CRC.
|
||||||
/// - **LPCM is excluded on purpose**: raw PCM carries no framing or integrity
|
/// - **LPCM is excluded on purpose**: raw PCM carries no framing or integrity
|
||||||
/// data, so a corrupt sample is indistinguishable from a quiet one — there is
|
/// data, so a corrupt sample is indistinguishable from a quiet one — there is
|
||||||
/// nothing to detect, so nothing can be honestly dropped.
|
/// nothing to detect, so nothing can be honestly dropped.
|
||||||
@@ -228,9 +228,9 @@ pub fn parser_for_codec(
|
|||||||
);
|
);
|
||||||
Box::new(PassthroughParser::new(false))
|
Box::new(PassthroughParser::new(false))
|
||||||
}
|
}
|
||||||
// Remaining audio-only codecs (Aac, Mp2, Mp3, Flac, Opus) where PES =
|
// Opus (PES = frame): all-keyframe passthrough is correct. Subtitle/Unknown
|
||||||
// frame: all-keyframe passthrough is correct. Subtitle/Unknown also land
|
// also land here; the keyframe flag is irrelevant for them. (Aac/Mp2/Mp3/Flac
|
||||||
// here; keyframe flag is irrelevant for them.
|
// have dedicated parsers dispatched earlier in the match.)
|
||||||
Codec::Opus => Box::new(PassthroughParser::new(true)),
|
Codec::Opus => Box::new(PassthroughParser::new(true)),
|
||||||
Codec::Srt | Codec::Ssa | Codec::Unknown(_) => Box::new(PassthroughParser::new(true)),
|
Codec::Srt | Codec::Ssa | Codec::Unknown(_) => Box::new(PassthroughParser::new(true)),
|
||||||
}
|
}
|
||||||
@@ -285,9 +285,10 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn unhandled_audio_codecs_use_keyframe_passthrough() {
|
fn audio_codecs_emit_keyframe_frames() {
|
||||||
// PES = frame audio codecs: every frame is independently decodable, so
|
// PES = frame audio: every frame is independently decodable → keyframe.
|
||||||
// all-keyframe passthrough is correct.
|
// Aac/Mp2/Mp3/Flac go through their dedicated gating parsers (which pass a
|
||||||
|
// non-sync/too-short payload straight through); Opus uses PassthroughParser.
|
||||||
for codec in [Codec::Aac, Codec::Mp2, Codec::Mp3, Codec::Flac, Codec::Opus] {
|
for codec in [Codec::Aac, Codec::Mp2, Codec::Mp3, Codec::Flac, Codec::Opus] {
|
||||||
let mut parser = parser_for_codec(codec, None, false);
|
let mut parser = parser_for_codec(codec, None, false);
|
||||||
let frames = parser.parse(&pes(Some(0), vec![0x01, 0x02]));
|
let frames = parser.parse(&pes(Some(0), vec![0x01, 0x02]));
|
||||||
|
|||||||
@@ -483,7 +483,8 @@ fn coding_type_from_raw(raw: u8) -> CodingType {
|
|||||||
|
|
||||||
/// Number of field-display periods a coded picture occupies, from its picture
|
/// Number of field-display periods a coded picture occupies, from its picture
|
||||||
/// coding extension (`00 00 01 B5`, ext-id `1000`), per ISO/IEC 13818-2 §6.3.10
|
/// coding extension (`00 00 01 B5`, ext-id `1000`), per ISO/IEC 13818-2 §6.3.10
|
||||||
/// and ffmpeg `mpeg_field_start` (`nb_fields = repeat_pict + 2`). This is what
|
/// (`nb_fields = repeat_pict + 2`, the field count the spec's repeat rules
|
||||||
|
/// yield). This is what
|
||||||
/// times soft-telecined (2:3 pulldown) DVD video correctly: a
|
/// times soft-telecined (2:3 pulldown) DVD video correctly: a
|
||||||
/// `repeat_first_field` frame occupies 3 fields, a normal frame 2, so honoring
|
/// `repeat_first_field` frame occupies 3 fields, a normal frame 2, so honoring
|
||||||
/// it spreads the ~23.976 coded frames across the 29.97 display span with no
|
/// it spreads the ~23.976 coded frames across the 29.97 display span with no
|
||||||
|
|||||||
+48
-22
@@ -1,15 +1,18 @@
|
|||||||
//! MPEG-1/2/2.5 audio (MP1/MP2/MP3) decodability gate.
|
//! MPEG-1/2/2.5 audio (MP1/MP2/MP3) decodability gate.
|
||||||
//!
|
//!
|
||||||
//! ffmpeg validates MPEG-audio frames by header sanity + framing resync, not a
|
//! Per ISO/IEC 11172-3 / ISO/IEC 13818-3, an MPEG-audio frame is validated by
|
||||||
//! payload CRC (`mpegaudiodecheader.c` `ff_mpa_check_header`; the optional CRC
|
//! header sanity + framing resync, not a payload CRC (the optional 16-bit CRC in
|
||||||
//! covers only side-info and is off by default). Its `ff_mpa_decode_header`
|
//! the header protects only the side-information and is absent unless the
|
||||||
//! additionally rejects free-format (`bitrate_index == 0`). So the gate mirrors
|
//! protection bit says otherwise). The gate mirrors that header-only check and
|
||||||
//! exactly those header rejects: a packet that begins with the 11-bit MPEG-audio
|
//! ACCEPTS free-format (`bitrate_index == 0`) as a legal decodable mode — it
|
||||||
//! sync but whose version / layer / bitrate-index / sample-rate fields are the
|
//! deliberately does NOT apply the stricter free-format reject that a full
|
||||||
//! reserved/invalid values is undecodable → drop it (a silence gap; each packet
|
//! decoder would (see the note at the `bitrate_index` check). So the gate rejects
|
||||||
//! keeps its own PTS). A packet with no leading sync is not a frame we can
|
//! only the truly invalid headers: a packet that begins with the 11-bit
|
||||||
//! validate (raw payload / continuation), so it passes through unchanged —
|
//! MPEG-audio sync but whose version / layer / sample-rate fields (or the
|
||||||
//! never false-dropped.
|
//! reserved bitrate index 15) are reserved/invalid is undecodable → drop it (a
|
||||||
|
//! silence gap; each packet keeps its own PTS). A packet with no leading sync is
|
||||||
|
//! not a frame we can validate (raw payload / continuation), so it passes through
|
||||||
|
//! unchanged — never false-dropped.
|
||||||
|
|
||||||
use super::dropgate::DropTally;
|
use super::dropgate::DropTally;
|
||||||
use super::{CodecParser, Frame, PesPacket, pts_to_ns};
|
use super::{CodecParser, Frame, PesPacket, pts_to_ns};
|
||||||
@@ -20,14 +23,16 @@ enum MpaVerdict {
|
|||||||
NoSync,
|
NoSync,
|
||||||
/// Sync present and every field is legal — decodable.
|
/// Sync present and every field is legal — decodable.
|
||||||
Valid,
|
Valid,
|
||||||
/// Sync present but a field is reserved/invalid (or free-format) — ffmpeg's
|
/// Sync present but a field is reserved/invalid — a conformant header parser
|
||||||
/// parser rejects this exactly.
|
/// rejects this exactly.
|
||||||
Invalid,
|
Invalid,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Mirror ffmpeg's `ff_mpa_check_header` + the `ff_mpa_decode_header`
|
/// Header-only validity check per ISO/IEC 11172-3 / ISO/IEC 13818-3 (which
|
||||||
/// free-format reject. A dropped MPEG-audio frame has a corrupt header, so no
|
/// ACCEPTS free-format, `bitrate_index == 0`) — deliberately NOT the stricter
|
||||||
/// duration is computed (the fields it would come from are the invalid ones).
|
/// free-format reject a full decoder applies. A dropped MPEG-audio frame has a
|
||||||
|
/// corrupt header, so no duration is computed (the fields it would come from are
|
||||||
|
/// the invalid ones).
|
||||||
fn mpa_verdict(data: &[u8]) -> MpaVerdict {
|
fn mpa_verdict(data: &[u8]) -> MpaVerdict {
|
||||||
if data.len() < 4 {
|
if data.len() < 4 {
|
||||||
return MpaVerdict::NoSync;
|
return MpaVerdict::NoSync;
|
||||||
@@ -37,8 +42,8 @@ fn mpa_verdict(data: &[u8]) -> MpaVerdict {
|
|||||||
if (h & 0xffe0_0000) != 0xffe0_0000 {
|
if (h & 0xffe0_0000) != 0xffe0_0000 {
|
||||||
return MpaVerdict::NoSync;
|
return MpaVerdict::NoSync;
|
||||||
}
|
}
|
||||||
// ff_mpa_check_header rejects: version field 01, layer field 00,
|
// Reject per spec: version field 01, layer field 00, bitrate_index 15,
|
||||||
// bitrate_index 15, sample-rate field 3.
|
// sample-rate field 3.
|
||||||
if (h & (3 << 19)) == (1 << 19)
|
if (h & (3 << 19)) == (1 << 19)
|
||||||
|| (h & (3 << 17)) == 0
|
|| (h & (3 << 17)) == 0
|
||||||
|| (h & (0xf << 12)) == (0xf << 12)
|
|| (h & (0xf << 12)) == (0xf << 12)
|
||||||
@@ -47,14 +52,17 @@ fn mpa_verdict(data: &[u8]) -> MpaVerdict {
|
|||||||
return MpaVerdict::Invalid;
|
return MpaVerdict::Invalid;
|
||||||
}
|
}
|
||||||
// NOTE: bitrate_index == 0 (free format) is NOT rejected. It is a legal,
|
// NOTE: bitrate_index == 0 (free format) is NOT rejected. It is a legal,
|
||||||
// decodable MPEG-audio mode (ffmpeg's ff_mpa_check_header accepts it and the
|
// decodable MPEG-audio mode (the spec permits it and a decoder derives the
|
||||||
// decoder derives the frame size from the sync spacing). Dropping it would be
|
// frame size from the sync spacing). Dropping it would be a false positive on
|
||||||
// a false positive on a clean stream, so it passes the gate.
|
// a clean stream, so it passes the gate.
|
||||||
MpaVerdict::Valid
|
MpaVerdict::Valid
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct MpegAudioParser {
|
pub struct MpegAudioParser {
|
||||||
tally: DropTally,
|
tally: DropTally,
|
||||||
|
/// Last emitted PTS (ns), carried forward across a PES with no PTS rather than
|
||||||
|
/// resetting the timeline to 0 (see the AC-3/DTS parsers) — preserves A/V sync.
|
||||||
|
last_pts_ns: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for MpegAudioParser {
|
impl Default for MpegAudioParser {
|
||||||
@@ -67,6 +75,7 @@ impl MpegAudioParser {
|
|||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
tally: DropTally::new("mpegaudio"),
|
tally: DropTally::new("mpegaudio"),
|
||||||
|
last_pts_ns: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,7 +93,12 @@ impl CodecParser for MpegAudioParser {
|
|||||||
if pes.data.is_empty() {
|
if pes.data.is_empty() {
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
}
|
}
|
||||||
let pts_ns = pes.pts.or(pes.dts).map(pts_to_ns).unwrap_or(0);
|
let pts_ns = pes
|
||||||
|
.pts
|
||||||
|
.or(pes.dts)
|
||||||
|
.map(pts_to_ns)
|
||||||
|
.unwrap_or(self.last_pts_ns);
|
||||||
|
self.last_pts_ns = pts_ns;
|
||||||
|
|
||||||
let drop =
|
let drop =
|
||||||
self.tally.is_poisoned() || matches!(mpa_verdict(&pes.data), MpaVerdict::Invalid);
|
self.tally.is_poisoned() || matches!(mpa_verdict(&pes.data), MpaVerdict::Invalid);
|
||||||
@@ -153,9 +167,21 @@ mod tests {
|
|||||||
assert_eq!(p.dropped_frames(), 0);
|
assert_eq!(p.dropped_frames(), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reserved_version_field_is_dropped() {
|
||||||
|
// version field = 01 (reserved) → rejected. byte1 = 111_01_01_1 = 0xEB
|
||||||
|
// keeps the 11-bit sync (0xFF + top 3 bits 111) but sets version bits to 01.
|
||||||
|
let mut p = MpegAudioParser::new();
|
||||||
|
let mut frame = mp3_frame(400);
|
||||||
|
frame[1] = 0xEB;
|
||||||
|
let f = p.parse(&make_pes(frame, Some(90000)));
|
||||||
|
assert!(f.is_empty(), "reserved version dropped");
|
||||||
|
assert_eq!(p.dropped_frames(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn reserved_sample_rate_is_dropped() {
|
fn reserved_sample_rate_is_dropped() {
|
||||||
// Sync present but sample-rate field = 3 (reserved) → ffmpeg rejects.
|
// Sync present but sample-rate field = 3 (reserved) → rejected per spec.
|
||||||
// 0xFF 0xFB then byte2 with bits 11..10 = 11: 0x9C.
|
// 0xFF 0xFB then byte2 with bits 11..10 = 11: 0x9C.
|
||||||
let mut p = MpegAudioParser::new();
|
let mut p = MpegAudioParser::new();
|
||||||
let mut frame = mp3_frame(400);
|
let mut frame = mp3_frame(400);
|
||||||
|
|||||||
+23
-23
@@ -88,11 +88,11 @@ impl TrueHdParser {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Decide whether an access unit is corrupt, updating `num_substreams` from a
|
/// Decide whether an access unit is corrupt, updating `num_substreams` from a
|
||||||
/// valid major sync. Mirrors ffmpeg `read_access_unit`: a major sync with a
|
/// valid major sync. Per the MLP/TrueHD access-unit decode rules: a major sync
|
||||||
/// bad header CRC, or any AU whose header parity fails, is undecodable.
|
/// with a bad header CRC, or any AU whose header parity fails, is undecodable.
|
||||||
/// Returns `false` (not corrupt) when the AU is too short to judge or no
|
/// Returns `false` (not corrupt) when the AU is too short to judge or no
|
||||||
/// major sync has established `num_substreams` yet — we never drop what we
|
/// major sync has established `num_substreams` yet — we never drop what we
|
||||||
/// cannot verify. Verified against real ffmpeg TrueHD (3600/3600 AUs).
|
/// cannot verify. Verified against real TrueHD streams (3600/3600 AUs).
|
||||||
fn au_check(&mut self, au: &[u8], is_major_sync: bool) -> AuCheck {
|
fn au_check(&mut self, au: &[u8], is_major_sync: bool) -> AuCheck {
|
||||||
let mut header_size = 4;
|
let mut header_size = 4;
|
||||||
let mut format_info = None;
|
let mut format_info = None;
|
||||||
@@ -231,7 +231,7 @@ enum Ac3Size {
|
|||||||
Frame(usize),
|
Frame(usize),
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- MLP/TrueHD access-unit integrity (mirrors ffmpeg mlpdec.c / mlp_parse.c) ---
|
// --- MLP/TrueHD access-unit integrity (per the MLP/TrueHD bitstream spec) ---
|
||||||
|
|
||||||
/// Major-sync header size in bytes: base 28, plus `2 + extensions*2` when the
|
/// Major-sync header size in bytes: base 28, plus `2 + extensions*2` when the
|
||||||
/// extension flag (major-sync byte 25, bit 0) is set (`extensions` = byte 26
|
/// extension flag (major-sync byte 25, bit 0) is set (`extensions` = byte 26
|
||||||
@@ -251,8 +251,8 @@ fn mlp_major_sync_header_size(ms: &[u8]) -> Option<usize> {
|
|||||||
Some(size)
|
Some(size)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Validate the MLP/TrueHD major-sync header checksum (ffmpeg `ff_mlp_checksum16`,
|
/// Validate the MLP/TrueHD major-sync header checksum (a CRC-16 with polynomial
|
||||||
/// CRC-16 poly 0x002D). The stored trailer is the last 2 header bytes; because
|
/// 0x002D). The stored trailer is the last 2 header bytes; because
|
||||||
/// MLP's checksum is byte-reversed relative to a standard CRC, a standard CRC of
|
/// MLP's checksum is byte-reversed relative to a standard CRC, a standard CRC of
|
||||||
/// the header body XOR the little-endian word before the trailer must equal the
|
/// the header body XOR the little-endian word before the trailer must equal the
|
||||||
/// trailer read big-endian.
|
/// trailer read big-endian.
|
||||||
@@ -260,14 +260,15 @@ fn mlp_major_sync_crc_ok(ms: &[u8], mshdr: usize) -> bool {
|
|||||||
if mshdr < 4 || ms.len() < mshdr {
|
if mshdr < 4 || ms.len() < mshdr {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// ffmpeg `ff_mlp_checksum16(buf, buf_size)` (libavcodec/mlp.c):
|
// The MLP major-sync checksum, `checksum16(buf, buf_size)`, is defined as
|
||||||
// av_crc(crc_2D, 0, buf, buf_size - 2) ^ AV_RL16(buf + buf_size - 2)
|
// crc16_2D(buf, buf_size - 2) ^ read_le16(buf + buf_size - 2)
|
||||||
// is called with `buf_size = mshdr - 2` and its result compared to
|
// evaluated with `buf_size = mshdr - 2` and its result compared to
|
||||||
// `AV_RL16(buf + mshdr - 2)` — i.e. the 16-bit checksum over `ms[..mshdr-4]`,
|
// `read_le16(buf + mshdr - 2)` — i.e. the 16-bit CRC (poly 0x2D, MSB-first)
|
||||||
// XORed with the LITTLE-ENDIAN word just before the trailer, must equal the
|
// over `ms[..mshdr-4]`, XORed with the LITTLE-ENDIAN word just before the
|
||||||
// LITTLE-ENDIAN trailer word. `crc16_mlp` is the same crc_2D table (poly 0x2D,
|
// trailer, must equal the LITTLE-ENDIAN trailer word. `crc16_mlp` uses that
|
||||||
// MSB-first) but yields its two bytes in the OPPOSITE order to libavutil's
|
// same poly-0x2D MSB-first table but yields its two bytes in the OPPOSITE
|
||||||
// `av_crc`, so swap them back to match. (The previous code mixed endianness —
|
// order to a standard little-endian CRC readout, so swap them back to match.
|
||||||
|
// (The previous code mixed endianness —
|
||||||
// little-endian XOR word but big-endian compare — so the checksum could never
|
// little-endian XOR word but big-endian compare — so the checksum could never
|
||||||
// validate any real extended major sync, silently dropping the whole track;
|
// validate any real extended major sync, silently dropping the whole track;
|
||||||
// cross-verified byte-exact against real 7.1/Atmos and 5.1 discs.)
|
// cross-verified byte-exact against real 7.1/Atmos and 5.1 discs.)
|
||||||
@@ -304,9 +305,8 @@ fn mlp_substr_header_size(au: &[u8], header_size: usize, num_substreams: u8) ->
|
|||||||
Some(shs)
|
Some(shs)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// MLP/TrueHD AU-header parity check (ffmpeg `ff_mlp_calculate_parity`): the XOR
|
/// MLP/TrueHD AU-header parity check: the XOR of the 4-byte AU header with the
|
||||||
/// of the 4-byte AU header with the substream directory, folded, must have its
|
/// substream directory, folded, must have its two nibbles XOR to 0xF.
|
||||||
/// two nibbles XOR to 0xF.
|
|
||||||
fn mlp_parity_ok(au: &[u8], header_size: usize, substr_header_size: usize) -> bool {
|
fn mlp_parity_ok(au: &[u8], header_size: usize, substr_header_size: usize) -> bool {
|
||||||
let end = header_size + substr_header_size;
|
let end = header_size + substr_header_size;
|
||||||
if end > au.len() {
|
if end > au.len() {
|
||||||
@@ -560,7 +560,7 @@ impl CodecParser for TrueHdParser {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Per-bit channel counts for the TrueHD 8-channel and 6-channel presentation
|
/// Per-bit channel counts for the TrueHD 8-channel and 6-channel presentation
|
||||||
/// channel-assignment masks (per the MLP spec / FFmpeg `thd_channels`). Some
|
/// channel-assignment masks (per the MLP/TrueHD bitstream spec). Some
|
||||||
/// bits denote a stereo pair (2), others a single channel (1).
|
/// bits denote a stereo pair (2), others a single channel (1).
|
||||||
const THD_8CH: [u8; 13] = [2, 1, 1, 2, 2, 2, 2, 1, 1, 2, 2, 1, 1];
|
const THD_8CH: [u8; 13] = [2, 1, 1, 2, 2, 2, 2, 1, 1, 2, 2, 1, 1];
|
||||||
const THD_6CH: [u8; 5] = [2, 1, 1, 2, 1];
|
const THD_6CH: [u8; 5] = [2, 1, 1, 2, 1];
|
||||||
@@ -720,7 +720,7 @@ mod tests {
|
|||||||
/// `format_info` set) into one that passes the decodability gate: 1 substream,
|
/// `format_info` set) into one that passes the decodability gate: 1 substream,
|
||||||
/// a clean substream directory, a valid major-sync CRC-16, and a valid header
|
/// a clean substream directory, a valid major-sync CRC-16, and a valid header
|
||||||
/// parity nibble. Mirrors what a real encoder writes (verified against real
|
/// parity nibble. Mirrors what a real encoder writes (verified against real
|
||||||
/// ffmpeg TrueHD). The AU must be ≥ 36 bytes (4 AU header + 28 major-sync
|
/// TrueHD streams). The AU must be ≥ 36 bytes (4 AU header + 28 major-sync
|
||||||
/// header + 2 directory + slack), which every `make_truehd_unit(≥200)` is.
|
/// header + 2 directory + slack), which every `make_truehd_unit(≥200)` is.
|
||||||
fn finalize_major_sync(au: &mut [u8]) {
|
fn finalize_major_sync(au: &mut [u8]) {
|
||||||
const MSHDR: usize = 28; // no extension (byte 25 clear)
|
const MSHDR: usize = 28; // no extension (byte 25 clear)
|
||||||
@@ -729,7 +729,7 @@ mod tests {
|
|||||||
// Substream directory entry at AU[4+MSHDR] = AU[32]: extraword flag clear.
|
// Substream directory entry at AU[4+MSHDR] = AU[32]: extraword flag clear.
|
||||||
au[32] &= 0x7F;
|
au[32] &= 0x7F;
|
||||||
// Major-sync checksum, built EXACTLY as `mlp_major_sync_crc_ok` verifies it
|
// Major-sync checksum, built EXACTLY as `mlp_major_sync_crc_ok` verifies it
|
||||||
// (ffmpeg `ff_mlp_checksum16`): swap_bytes(crc16_mlp(body)) ^ LE word before
|
// (the MLP checksum16): swap_bytes(crc16_mlp(body)) ^ LE word before
|
||||||
// the trailer, stored little-endian in the trailer.
|
// the trailer, stored little-endian in the trailer.
|
||||||
let body_end = 4 + MSHDR - 4; // AU[4..28]
|
let body_end = 4 + MSHDR - 4; // AU[4..28]
|
||||||
let crc = super::crc16_mlp(&au[4..body_end]).swap_bytes()
|
let crc = super::crc16_mlp(&au[4..body_end]).swap_bytes()
|
||||||
@@ -842,7 +842,7 @@ mod tests {
|
|||||||
let mut parser = TrueHdParser::new();
|
let mut parser = TrueHdParser::new();
|
||||||
let ms1 = valid_major_sync();
|
let ms1 = valid_major_sync();
|
||||||
let mut bad = valid_normal_au();
|
let mut bad = valid_normal_au();
|
||||||
// A single-nibble flip: MLP's nibble-fold parity (like ffmpeg's) is blind
|
// A single-nibble flip: MLP's nibble-fold parity is blind
|
||||||
// to a full-byte flip, which changes both nibbles equally and cancels.
|
// to a full-byte flip, which changes both nibbles equally and cancels.
|
||||||
bad[2] ^= 0x01;
|
bad[2] ^= 0x01;
|
||||||
let ms2 = valid_major_sync();
|
let ms2 = valid_major_sync();
|
||||||
@@ -974,7 +974,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn clean_truehd_stream_drops_nothing() {
|
fn clean_truehd_stream_drops_nothing() {
|
||||||
// A run of valid AUs passes untouched — zero false positives (the CRC and
|
// A run of valid AUs passes untouched — zero false positives (the CRC and
|
||||||
// parity are verified against real ffmpeg TrueHD output).
|
// parity are verified against real TrueHD output).
|
||||||
let mut parser = TrueHdParser::new();
|
let mut parser = TrueHdParser::new();
|
||||||
let mut data = valid_major_sync();
|
let mut data = valid_major_sync();
|
||||||
for _ in 0..5 {
|
for _ in 0..5 {
|
||||||
@@ -1297,7 +1297,7 @@ mod tests {
|
|||||||
assert_eq!(truehd_channels_from_stream(&data), Some(8));
|
assert_eq!(truehd_channels_from_stream(&data), Some(8));
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- truehd_channels: per-bit mask channel counts (MLP / FFmpeg table) ---
|
// --- truehd_channels: per-bit mask channel counts (MLP channel table) ---
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn truehd_channels_8ch_single_bit_counts() {
|
fn truehd_channels_8ch_single_bit_counts() {
|
||||||
|
|||||||
+21
-15
@@ -49,7 +49,8 @@ pub enum Naming {
|
|||||||
/// How (and whether) to record audio sync delay.
|
/// How (and whether) to record audio sync delay.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||||
pub enum DelayMode {
|
pub enum DelayMode {
|
||||||
/// Embed `DELAY <n>ms` in each audio filename (mkvmerge-readable).
|
/// Embed `DELAY <n>ms` in each audio filename (the filename-delay
|
||||||
|
/// convention downstream muxers parse).
|
||||||
#[default]
|
#[default]
|
||||||
Filename,
|
Filename,
|
||||||
/// Write a `<base> delays.txt` sidecar instead.
|
/// Write a `<base> delays.txt` sidecar instead.
|
||||||
@@ -64,7 +65,7 @@ pub enum DelayMode {
|
|||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||||
pub enum ChaptersFmt {
|
pub enum ChaptersFmt {
|
||||||
/// mkvmerge chapter XML.
|
/// Matroska chapter XML.
|
||||||
#[default]
|
#[default]
|
||||||
Xml,
|
Xml,
|
||||||
/// OGM/simple `CHAPTERnn=`/`CHAPTERnnNAME=` text.
|
/// OGM/simple `CHAPTERnn=`/`CHAPTERnnNAME=` text.
|
||||||
@@ -121,7 +122,8 @@ pub enum TrackKind {
|
|||||||
// ── Codec → on-disk extension ────────────────────────────────────────────────
|
// ── Codec → on-disk extension ────────────────────────────────────────────────
|
||||||
|
|
||||||
/// File extension (without the dot) for a codec's standalone elementary stream.
|
/// File extension (without the dot) for a codec's standalone elementary stream.
|
||||||
/// Chosen to match what mkvmerge / x265 / ffmpeg / BDSup2Sub expect.
|
/// Chosen to match the conventional elementary-stream extensions downstream
|
||||||
|
/// muxers and codec tools expect.
|
||||||
fn extension_for(codec: Codec) -> &'static str {
|
fn extension_for(codec: Codec) -> &'static str {
|
||||||
match codec {
|
match codec {
|
||||||
Codec::Hevc => "hevc",
|
Codec::Hevc => "hevc",
|
||||||
@@ -440,7 +442,7 @@ impl VobSubWriter {
|
|||||||
.map(|s| s.trim_end().to_string());
|
.map(|s| s.trim_end().to_string());
|
||||||
// VobSub `id:` lines use a 2-letter code; stream languages are ISO
|
// VobSub `id:` lines use a 2-letter code; stream languages are ISO
|
||||||
// 639-2 (3-letter). Take the leading two chars — the convention
|
// 639-2 (3-letter). Take the leading two chars — the convention
|
||||||
// mkvmerge reads to assign a track language.
|
// downstream muxers read to assign a track language.
|
||||||
let lang2: String = lang.chars().take(2).collect();
|
let lang2: String = lang.chars().take(2).collect();
|
||||||
Self {
|
Self {
|
||||||
idx_path,
|
idx_path,
|
||||||
@@ -468,8 +470,8 @@ impl EsWriter for VobSubWriter {
|
|||||||
idx.push('\n');
|
idx.push('\n');
|
||||||
}
|
}
|
||||||
idx.push_str("langidx: 0\n\n");
|
idx.push_str("langidx: 0\n\n");
|
||||||
// The conventional `id: <lang2>, index: 0` line mkvmerge reads to
|
// The conventional `id: <lang2>, index: 0` line downstream muxers read
|
||||||
// assign the subtitle track's language. Omit the language token when
|
// to assign the subtitle track's language. Omit the language token when
|
||||||
// unknown but still emit the index so the entry list is well-formed.
|
// unknown but still emit the index so the entry list is well-formed.
|
||||||
if self.lang2.is_empty() {
|
if self.lang2.is_empty() {
|
||||||
idx.push_str("id: , index: 0\n");
|
idx.push_str("id: , index: 0\n");
|
||||||
@@ -530,8 +532,8 @@ fn delay_ms(audio_first_pts_ns: i64, ref_video_first_pts_ns: i64) -> i64 {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `DELAY <signed-int>ms` — matches mkvmerge's case-insensitive
|
/// `DELAY <signed-int>ms` — matches the conventional case-insensitive
|
||||||
/// `delay\s+(-?\d+)` filename-delay parser.
|
/// `delay\s+(-?\d+)` filename-delay convention downstream muxers parse.
|
||||||
fn delay_token(ms: i64) -> String {
|
fn delay_token(ms: i64) -> String {
|
||||||
format!("DELAY {ms}ms")
|
format!("DELAY {ms}ms")
|
||||||
}
|
}
|
||||||
@@ -547,7 +549,7 @@ fn fmt_chapter_time_ns(time_secs: f64) -> String {
|
|||||||
format!("{h:02}:{m:02}:{s:02}.{ns:09}")
|
format!("{h:02}:{m:02}:{s:02}.{ns:09}")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Serialize chapters as mkvmerge chapter XML.
|
/// Serialize chapters as Matroska chapter XML.
|
||||||
pub(crate) fn chapters_xml(chapters: &[Chapter]) -> String {
|
pub(crate) fn chapters_xml(chapters: &[Chapter]) -> String {
|
||||||
let mut s = String::new();
|
let mut s = String::new();
|
||||||
s.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
|
s.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
|
||||||
@@ -665,14 +667,18 @@ impl DemuxSink {
|
|||||||
(TrackKind::Subtitle, s.codec, s.pid, s.language.clone())
|
(TrackKind::Subtitle, s.codec, s.pid, s.language.clone())
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
// Record the primary-video reference BEFORE the kind filter: the video
|
||||||
|
// track drives multi-clip PTS-continuity rebasing and the audio DELAY
|
||||||
|
// tag even for `audio://` / `sub://` outputs, where its frames flow
|
||||||
|
// through write() but are not persisted to disk.
|
||||||
|
if kind == TrackKind::Video && ref_video_track.is_none() {
|
||||||
|
ref_video_track = Some(idx);
|
||||||
|
}
|
||||||
// Kind filter: `audio://` / `sub://` keep only their class.
|
// Kind filter: `audio://` / `sub://` keep only their class.
|
||||||
if opts.kind_filter.is_some_and(|k| k != kind) {
|
if opts.kind_filter.is_some_and(|k| k != kind) {
|
||||||
tracks.push(None);
|
tracks.push(None);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if kind == TrackKind::Video && ref_video_track.is_none() {
|
|
||||||
ref_video_track = Some(idx);
|
|
||||||
}
|
|
||||||
|
|
||||||
let ext = extension_for(codec);
|
let ext = extension_for(codec);
|
||||||
let stem = Self::stem_for(opts, idx, pid, &lang, codec);
|
let stem = Self::stem_for(opts, idx, pid, &lang, codec);
|
||||||
@@ -1033,7 +1039,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn delay_token_matches_mkvmerge_regex() {
|
fn delay_token_matches_mkvmerge_regex() {
|
||||||
// mkvmerge: case-insensitive /delay\s+(-?\d+)/.
|
// Convention: case-insensitive /delay\s+(-?\d+)/.
|
||||||
let re = regex_lite_delay;
|
let re = regex_lite_delay;
|
||||||
assert_eq!(re("Movie eng AC3 DELAY -248ms.ac3"), Some(-248));
|
assert_eq!(re("Movie eng AC3 DELAY -248ms.ac3"), Some(-248));
|
||||||
assert_eq!(re(&format!("x {}.dts", delay_token(1000))), Some(1000));
|
assert_eq!(re(&format!("x {}.dts", delay_token(1000))), Some(1000));
|
||||||
@@ -1041,7 +1047,7 @@ mod tests {
|
|||||||
assert_eq!(re(&format!("x {}.eac3", delay_token(-5))), Some(-5));
|
assert_eq!(re(&format!("x {}.eac3", delay_token(-5))), Some(-5));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Minimal stand-in for mkvmerge's `delay\s+(-?\d+)` (case-insensitive).
|
/// Minimal stand-in for the `delay\s+(-?\d+)` convention (case-insensitive).
|
||||||
fn regex_lite_delay(name: &str) -> Option<i64> {
|
fn regex_lite_delay(name: &str) -> Option<i64> {
|
||||||
let lower = name.to_lowercase();
|
let lower = name.to_lowercase();
|
||||||
let idx = lower.find("delay")?;
|
let idx = lower.find("delay")?;
|
||||||
@@ -1193,7 +1199,7 @@ mod tests {
|
|||||||
w.finish(&mut sub).unwrap();
|
w.finish(&mut sub).unwrap();
|
||||||
let idx_text = std::fs::read_to_string(&idx).unwrap();
|
let idx_text = std::fs::read_to_string(&idx).unwrap();
|
||||||
assert!(idx_text.contains("palette: 000000, ffffff"));
|
assert!(idx_text.contains("palette: 000000, ffffff"));
|
||||||
// The conventional `id:` line mkvmerge reads to assign the language.
|
// The conventional `id:` line downstream muxers read to assign the language.
|
||||||
assert!(
|
assert!(
|
||||||
idx_text.contains("id: en, index: 0"),
|
idx_text.contains("id: en, index: 0"),
|
||||||
"missing id: line, got:\n{idx_text}"
|
"missing id: line, got:\n{idx_text}"
|
||||||
|
|||||||
+2
-2
@@ -1208,8 +1208,8 @@ mod tests {
|
|||||||
|
|
||||||
/// Recording `SectorSource`: logs every `(lba, count)` request and
|
/// Recording `SectorSource`: logs every `(lba, count)` request and
|
||||||
/// returns `Err` whenever the requested range covers `bad_sector`.
|
/// returns `Err` whenever the requested range covers `bad_sector`.
|
||||||
/// Successful reads return zeroed sectors (which are NOT
|
/// Successful reads return zeroed sectors (which the content-clarity check
|
||||||
/// `ts_sync_destroyed`, so `DecryptingSectorSource` passes them through
|
/// does not flag as scrambled, so `DecryptingSectorSource` passes them through
|
||||||
/// even with synthetic AACS keys — no real decrypt is attempted).
|
/// even with synthetic AACS keys — no real decrypt is attempted).
|
||||||
struct RecordingReader {
|
struct RecordingReader {
|
||||||
capacity: u32,
|
capacity: u32,
|
||||||
|
|||||||
+2
-2
@@ -29,8 +29,8 @@ const HEVC_NAL_TYPE_MASK: u8 = 0x3F;
|
|||||||
///
|
///
|
||||||
/// One instance per output stream. Tracks whether parameter sets have
|
/// One instance per output stream. Tracks whether parameter sets have
|
||||||
/// already been emitted so they're written exactly once at the head of
|
/// already been emitted so they're written exactly once at the head of
|
||||||
/// the stream, mirroring the convention used by `ffmpeg -c:v copy -f
|
/// the stream, per the Annex B convention of ITU-T H.265 / ISO/IEC
|
||||||
/// hevc`.
|
/// 23008-2 (parameter sets precede the coded slices they govern).
|
||||||
pub struct HevcMux<W: Write> {
|
pub struct HevcMux<W: Write> {
|
||||||
writer: W,
|
writer: W,
|
||||||
/// `HEVCDecoderConfigurationRecord` payload (hvcC). Parsed lazily
|
/// `HEVCDecoderConfigurationRecord` payload (hvcC). Parsed lazily
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
//! Standard MPEG-TS (188-byte packets) muxer — sequential-only.
|
//! Standard MPEG-TS (188-byte packets) muxer — sequential-only.
|
||||||
//!
|
//!
|
||||||
//! Distinct from `super::tsmux::TsMuxer` (BD-TS with 192-byte packets
|
//! Distinct from `super::tsmux::TsMuxer` (BD-TS with 192-byte packets
|
||||||
//! and the 4-byte TP_extra_header). This muxer emits the IETF / ISO/IEC
|
//! and the 4-byte TP_extra_header). This muxer emits the ITU-T H.222.0 /
|
||||||
//! 13818-1 wire format that ffmpeg, VLC, and `m2tsindex` consume
|
//! ISO/IEC 13818-1 wire format that any conformant transport-stream
|
||||||
//! out of the box. Use it for plain `.ts` / `.m2ts` files over a
|
//! demuxer or player consumes out of the box. Use it for plain
|
||||||
|
//! `.ts` / `.m2ts` files over a
|
||||||
//! [`SequentialSink`](crate::io::sink::SequentialSink), and for
|
//! [`SequentialSink`](crate::io::sink::SequentialSink), and for
|
||||||
//! MPEG-TS-over-UDP via [`UdpSocketSink`](crate::io::sink::UdpSocketSink).
|
//! MPEG-TS-over-UDP via [`UdpSocketSink`](crate::io::sink::UdpSocketSink).
|
||||||
//!
|
//!
|
||||||
@@ -44,8 +45,8 @@
|
|||||||
//! attached to the video PID's adaptation field every
|
//! attached to the video PID's adaptation field every
|
||||||
//! `PCR_INTERVAL_PACKETS` packets.
|
//! `PCR_INTERVAL_PACKETS` packets.
|
||||||
//! - No language / descriptor tags, no SCTE-35 markers, no per-PID
|
//! - No language / descriptor tags, no SCTE-35 markers, no per-PID
|
||||||
//! PMT version bumps, no SDT/EIT. Sufficient for "ffmpeg can play
|
//! PMT version bumps, no SDT/EIT. Sufficient for a conformant
|
||||||
//! this back", not for full broadcast deployment.
|
//! demuxer to play this back, not for full broadcast deployment.
|
||||||
|
|
||||||
use std::io::{self, Write};
|
use std::io::{self, Write};
|
||||||
|
|
||||||
|
|||||||
+22
-5
@@ -34,16 +34,28 @@ fn chapters_vtt(chapters: &[Chapter]) -> String {
|
|||||||
let mut s = String::from("WEBVTT\n\n");
|
let mut s = String::from("WEBVTT\n\n");
|
||||||
for (i, c) in chapters.iter().enumerate() {
|
for (i, c) in chapters.iter().enumerate() {
|
||||||
let start = c.time_secs.max(0.0);
|
let start = c.time_secs.max(0.0);
|
||||||
|
// Each cue runs until the next chapter. WebVTT drops a cue whose end is not
|
||||||
|
// strictly after its start, so the last chapter (and any degenerate
|
||||||
|
// equal-timestamp pair) gets a 1 s minimum duration rather than being lost.
|
||||||
let end = chapters
|
let end = chapters
|
||||||
.get(i + 1)
|
.get(i + 1)
|
||||||
.map(|n| n.time_secs.max(0.0))
|
.map(|n| n.time_secs.max(0.0))
|
||||||
.unwrap_or(start);
|
.filter(|&e| e > start)
|
||||||
|
.unwrap_or(start + 1.0);
|
||||||
|
// No localized prose in the library (see Chapter::name): emit the bare
|
||||||
|
// name, or a plain ordinal when unnamed — the app prepends any "Chapter "
|
||||||
|
// prefix in the user's language. Matches chapters_xml / chapters_ogm.
|
||||||
|
let name = if c.name.is_empty() {
|
||||||
|
(i + 1).to_string()
|
||||||
|
} else {
|
||||||
|
c.name.clone()
|
||||||
|
};
|
||||||
s.push_str(&format!(
|
s.push_str(&format!(
|
||||||
"{}\n{} --> {}\nChapter {}\n\n",
|
"{}\n{} --> {}\n{}\n\n",
|
||||||
i + 1,
|
i + 1,
|
||||||
vtt_time(start),
|
vtt_time(start),
|
||||||
vtt_time(end),
|
vtt_time(end),
|
||||||
c.name
|
name
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
s
|
s
|
||||||
@@ -231,8 +243,13 @@ pub struct JsonSink {
|
|||||||
|
|
||||||
impl JsonSink {
|
impl JsonSink {
|
||||||
pub fn create(path: &Path, title: &DiscTitle) -> io::Result<Self> {
|
pub fn create(path: &Path, title: &DiscTitle) -> io::Result<Self> {
|
||||||
let doc =
|
// Serializing our own `Value` is infallible in practice (serde_json maps
|
||||||
serde_json::to_string_pretty(&title_json(title)).unwrap_or_else(|_| "{}".to_string());
|
// any non-finite float to `null` at Value construction, so `title_json`
|
||||||
|
// never holds an unencodable value); still, propagate rather than silently
|
||||||
|
// writing "{}" if that ever changes — an empty metadata file must not
|
||||||
|
// masquerade as a successful json:// export.
|
||||||
|
let doc = serde_json::to_string_pretty(&title_json(title))
|
||||||
|
.map_err(|_| crate::error::Error::MkvInvalid)?;
|
||||||
let mut f = File::create(path)?;
|
let mut f = File::create(path)?;
|
||||||
f.write_all(doc.as_bytes())?;
|
f.write_all(doc.as_bytes())?;
|
||||||
f.write_all(b"\n")?;
|
f.write_all(b"\n")?;
|
||||||
|
|||||||
+28
-25
@@ -131,7 +131,7 @@ fn mvc_decoder_config_record(subset_sps: &[u8], pps: &[u8]) -> Option<Vec<u8>> {
|
|||||||
///
|
///
|
||||||
/// The size field is the extension block length **excluding the 4-byte size
|
/// 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
|
/// field itself** — i.e. `4 ("mvcC") + record.len()`. This is the track-level
|
||||||
/// MVC signal that decoders and mediainfo read (the per-frame `BlockAdditional`
|
/// MVC signal that decoders and media analyzers read (the per-frame `BlockAdditional`
|
||||||
/// under the `mvcC` BlockAdditionMapping carries the dependent view's data). A
|
/// under the `mvcC` BlockAdditionMapping carries the dependent view's data). A
|
||||||
/// plain (2D) track never calls this — it writes its `avcc` verbatim.
|
/// plain (2D) track never calls this — it writes its `avcc` verbatim.
|
||||||
fn mvc_codec_private(avcc: &[u8], record: &[u8]) -> Vec<u8> {
|
fn mvc_codec_private(avcc: &[u8], record: &[u8]) -> Vec<u8> {
|
||||||
@@ -432,22 +432,23 @@ impl MkvTrack {
|
|||||||
// to fix the Windows-fps report, on the theory that Windows derives
|
// to fix the Windows-fps report, on the theory that Windows derives
|
||||||
// fps from it. The captured SOTL evidence proves the opposite: with
|
// fps from it. The captured SOTL evidence proves the opposite: with
|
||||||
// FlagInterlaced=1 + DefaultDuration=40 ms + DefaultDecodedFieldDuration=20 ms,
|
// FlagInterlaced=1 + DefaultDuration=40 ms + DefaultDecodedFieldDuration=20 ms,
|
||||||
// Windows Explorer reports 12.5 fps (half), and MediaInfo flips the
|
// Windows Explorer reports 12.5 fps (half), and a media analyzer
|
||||||
// track to "Frame rate mode: Variable" with no clean rate. MakeMKV's
|
// flips the track to "Frame rate mode: Variable" with no clean rate. A
|
||||||
// correct rip of the same disc OMITS DefaultDecodedFieldDuration,
|
// known-correct rip of the same disc OMITS DefaultDecodedFieldDuration,
|
||||||
// keeps FlagInterlaced=1 + FieldOrder=TFF + DefaultDuration=40 ms, and
|
// keeps FlagInterlaced=1 + FieldOrder=TFF + DefaultDuration=40 ms, and
|
||||||
// Explorer reports the full 25 fps with MediaInfo "Constant". ffmpeg's
|
// Explorer reports the full 25 fps with the analyzer showing "Constant".
|
||||||
// matroskaenc.c does the same (full-frame DefaultDuration, no field
|
// A conformant Matroska muxer does the same (full-frame DefaultDuration,
|
||||||
// duration). The lone frame-rate signal every tool actually trusts is
|
// no field duration). The lone frame-rate signal every tool actually
|
||||||
|
// trusts is
|
||||||
// `1 / DefaultDuration`; that full-frame value (40 ms → 25 fps) is kept
|
// `1 / DefaultDuration`; that full-frame value (40 ms → 25 fps) is kept
|
||||||
// below. Dropping the field-duration element removes the per-field
|
// below. Dropping the field-duration element removes the per-field
|
||||||
// signal that made Explorer halve the rate.
|
// signal that made Explorer halve the rate.
|
||||||
//
|
//
|
||||||
// Trade-off: the container no longer carries an explicit per-field
|
// Trade-off: the container no longer carries an explicit per-field
|
||||||
// decoded duration. Nothing is lost in practice — the interlace
|
// decoded duration. Nothing is lost in practice — the interlace
|
||||||
// signaling that deinterlacers and MediaInfo rely on lives in the
|
// signaling that deinterlacers and media analyzers rely on lives in the
|
||||||
// MPEG-2 elementary stream's picture_coding_extension (picture_structure /
|
// MPEG-2 elementary stream's picture_coding_extension (picture_structure /
|
||||||
// top_field_first), which MediaInfo reads directly (so it still reports
|
// top_field_first), which analyzers read directly (so they still report
|
||||||
// "Interlaced / Top Field First"), and the container still flags
|
// "Interlaced / Top Field First"), and the container still flags
|
||||||
// FlagInterlaced=1 + FieldOrder=TFF so players keep deinterlacing.
|
// FlagInterlaced=1 + FieldOrder=TFF so players keep deinterlacing.
|
||||||
field_duration_ns: 0,
|
field_duration_ns: 0,
|
||||||
@@ -480,8 +481,8 @@ impl MkvTrack {
|
|||||||
// and DTS-HD Master Audio." Players distinguish core vs HD-HRA vs
|
// and DTS-HD Master Audio." Players distinguish core vs HD-HRA vs
|
||||||
// HD-MA by parsing the DTS bitstream extension substreams, not by
|
// HD-MA by parsing the DTS bitstream extension substreams, not by
|
||||||
// the container codec ID. The previously-emitted `A_DTS/MA` and
|
// the container codec ID. The previously-emitted `A_DTS/MA` and
|
||||||
// `A_DTS/HR` suffixes are NOT registered codec IDs; strict parsers
|
// `A_DTS/HR` suffixes are NOT registered codec IDs; strict Matroska
|
||||||
// (libmatroska) and some hardware renderers fail to recognise the
|
// parsers and some hardware renderers fail to recognise the
|
||||||
// track at all. Emit plain `A_DTS` for every DTS variant — the
|
// track at all. Emit plain `A_DTS` for every DTS variant — the
|
||||||
// lossless MA / HRA payload bytes are unchanged, only the
|
// lossless MA / HRA payload bytes are unchanged, only the
|
||||||
// container codec-ID string differs.
|
// container codec-ID string differs.
|
||||||
@@ -602,7 +603,7 @@ pub struct MkvMuxer<W: Write + Seek> {
|
|||||||
base_pts_ticks: Option<i64>,
|
base_pts_ticks: Option<i64>,
|
||||||
/// Last block timecode (TimestampScale ticks, relative to base_pts) written
|
/// Last block timecode (TimestampScale ticks, relative to base_pts) written
|
||||||
/// PER TRACK, to enforce strictly-monotonic per-track timestamps —
|
/// PER TRACK, to enforce strictly-monotonic per-track timestamps —
|
||||||
/// players/ffmpeg reject non-monotonic DTS, and some audio PES PTS land on
|
/// players and decoders reject non-monotonic DTS, and some audio PES PTS land on
|
||||||
/// the same tick (or tick back one from rounding).
|
/// the same tick (or tick back one from rounding).
|
||||||
last_pts_ticks: std::collections::HashMap<usize, i64>,
|
last_pts_ticks: std::collections::HashMap<usize, i64>,
|
||||||
/// Per-track-index flag: true if the track is video. The strictly-monotonic
|
/// Per-track-index flag: true if the track is video. The strictly-monotonic
|
||||||
@@ -762,7 +763,7 @@ const MIN_BLOCK_REL: i64 = i16::MIN as i64;
|
|||||||
/// later than the previous one written for that track. `prev` is the last
|
/// later than the previous one written for that track. `prev` is the last
|
||||||
/// timestamp for the track (`None` for the first frame). Fixes non-monotonic
|
/// timestamp for the track (`None` for the first frame). Fixes non-monotonic
|
||||||
/// DTS: some audio PES PTS truncate to the same tick as the prior frame (or tick
|
/// DTS: some audio PES PTS truncate to the same tick as the prior frame (or tick
|
||||||
/// back one from rounding), which ffmpeg/strict players reject. At the 0.1 ms
|
/// back one from rounding), which strict players/decoders reject. At the 0.1 ms
|
||||||
/// scale a TrueHD AU (0.833 ms = ~8 ticks) no longer collides with its
|
/// scale a TrueHD AU (0.833 ms = ~8 ticks) no longer collides with its
|
||||||
/// neighbour, so this rarely fires for lossless audio — but a +1-tick nudge
|
/// neighbour, so this rarely fires for lossless audio — but a +1-tick nudge
|
||||||
/// (0.1 ms, sub-AU and inaudible) still guards genuine same-tick collisions on
|
/// (0.1 ms, sub-AU and inaudible) still guards genuine same-tick collisions on
|
||||||
@@ -919,7 +920,8 @@ impl<W: Write + Seek> MkvMuxer<W> {
|
|||||||
Some(pos + 3)
|
Some(pos + 3)
|
||||||
};
|
};
|
||||||
// Stamp the freemkv version so any muxed file is traceable to the build
|
// Stamp the freemkv version so any muxed file is traceable to the build
|
||||||
// that produced it (MediaInfo "Writing application"/"library").
|
// that produced it (surfaced as a media analyzer's "Writing
|
||||||
|
// application"/"library" field).
|
||||||
ebml::write_string(&mut writer, ebml::MUXING_APP, crate::MUX_APP)?;
|
ebml::write_string(&mut writer, ebml::MUXING_APP, crate::MUX_APP)?;
|
||||||
ebml::write_string(&mut writer, ebml::WRITING_APP, crate::MUX_APP)?;
|
ebml::write_string(&mut writer, ebml::WRITING_APP, crate::MUX_APP)?;
|
||||||
if let Some(t) = title {
|
if let Some(t) = title {
|
||||||
@@ -992,7 +994,7 @@ impl<W: Write + Seek> MkvMuxer<W> {
|
|||||||
match mvc_record.as_ref() {
|
match mvc_record.as_ref() {
|
||||||
// MVC (Blu-ray 3D) base track: CodecPrivate = base-view avcC
|
// MVC (Blu-ray 3D) base track: CodecPrivate = base-view avcC
|
||||||
// followed by the `mvcC` extension block. This is the
|
// followed by the `mvcC` extension block. This is the
|
||||||
// track-level signal decoders/mediainfo read to recognise the
|
// track-level signal decoders/analyzers read to recognise the
|
||||||
// stereoscopic MVC track (the per-frame dependent view rides
|
// stereoscopic MVC track (the per-frame dependent view rides
|
||||||
// in BlockAdditional under the mapping below).
|
// in BlockAdditional under the mapping below).
|
||||||
Some(record) => {
|
Some(record) => {
|
||||||
@@ -1023,9 +1025,10 @@ impl<W: Write + Seek> MkvMuxer<W> {
|
|||||||
// child of TrackEntry. The production video path now ALWAYS passes
|
// child of TrackEntry. The production video path now ALWAYS passes
|
||||||
// `field_duration_ns == 0` (see `MkvTrack::video`) so this element is
|
// `field_duration_ns == 0` (see `MkvTrack::video`) so this element is
|
||||||
// NOT written: emitting it (20 ms for 576i25) is exactly what made
|
// NOT written: emitting it (20 ms for 576i25) is exactly what made
|
||||||
// Windows Explorer report 12.5 fps and MediaInfo flip to VFR on the
|
// Windows Explorer report 12.5 fps and a media analyzer flip to VFR on
|
||||||
// captured SOTL rip, while MakeMKV — which omits it — shows the full
|
// the captured SOTL rip, while a known-correct rip — which omits it —
|
||||||
// 25 fps. The guard below is retained so a non-zero value still emits
|
// shows the full 25 fps. The guard below is retained so a non-zero
|
||||||
|
// value still emits
|
||||||
// a well-formed element for any future caller / round-trip test, but
|
// a well-formed element for any future caller / round-trip test, but
|
||||||
// the muxer's own callers no longer trigger it.
|
// the muxer's own callers no longer trigger it.
|
||||||
if track.track_type == ebml::TRACK_TYPE_VIDEO
|
if track.track_type == ebml::TRACK_TYPE_VIDEO
|
||||||
@@ -1100,7 +1103,7 @@ impl<W: Write + Seek> MkvMuxer<W> {
|
|||||||
|
|
||||||
// Blu-ray 3D (MVC) signaling — BlockAdditionMapping (sibling of
|
// Blu-ray 3D (MVC) signaling — BlockAdditionMapping (sibling of
|
||||||
// Video) carries the mvcC MVCDecoderConfigurationRecord so players /
|
// Video) carries the mvcC MVCDecoderConfigurationRecord so players /
|
||||||
// mediainfo recognise the dependent (right-eye) view that rides as a
|
// analyzers recognise the dependent (right-eye) view that rides as a
|
||||||
// per-frame BlockAdditional under this mapping (BlockAddIDValue = 2).
|
// per-frame BlockAdditional under this mapping (BlockAddIDValue = 2).
|
||||||
match mvc_record.as_ref() {
|
match mvc_record.as_ref() {
|
||||||
Some(record) => {
|
Some(record) => {
|
||||||
@@ -1134,7 +1137,7 @@ impl<W: Write + Seek> MkvMuxer<W> {
|
|||||||
|
|
||||||
// Dolby Vision signaling — BlockAdditionMapping is a child of the
|
// Dolby Vision signaling — BlockAdditionMapping is a child of the
|
||||||
// TrackEntry (sibling of Video). Carries the dvcC so players /
|
// TrackEntry (sibling of Video). Carries the dvcC so players /
|
||||||
// mediainfo recognise the track as Dolby Vision.
|
// analyzers recognise the track as Dolby Vision.
|
||||||
if let Some(ref dvcc) = track.dv_config {
|
if let Some(ref dvcc) = track.dv_config {
|
||||||
let map_pos = ebml::start_master(&mut writer, ebml::BLOCK_ADDITION_MAPPING)?;
|
let map_pos = ebml::start_master(&mut writer, ebml::BLOCK_ADDITION_MAPPING)?;
|
||||||
// BlockAddIDType = "dvcC" fourcc (DOVIDecoderConfigurationRecord).
|
// BlockAddIDType = "dvcC" fourcc (DOVIDecoderConfigurationRecord).
|
||||||
@@ -4279,8 +4282,8 @@ mod tests {
|
|||||||
// fps, the only rate every tool trusts) and must NOT emit
|
// fps, the only rate every tool trusts) and must NOT emit
|
||||||
// DefaultDecodedFieldDuration. rc.5.1 emitted the 20 ms field duration to
|
// DefaultDecodedFieldDuration. rc.5.1 emitted the 20 ms field duration to
|
||||||
// try to fix Windows; the captured SOTL evidence proved it did the
|
// try to fix Windows; the captured SOTL evidence proved it did the
|
||||||
// opposite (Explorer 12.5 fps, MediaInfo VFR). MakeMKV's correct rip omits
|
// opposite (Explorer 12.5 fps, analyzer VFR). A known-correct rip omits
|
||||||
// it (Explorer 25 fps, MediaInfo CFR). So: frame duration present = 40 ms,
|
// it (Explorer 25 fps, analyzer CFR). So: frame duration present = 40 ms,
|
||||||
// field duration ABSENT, interlace signalling (FlagInterlaced/FieldOrder)
|
// field duration ABSENT, interlace signalling (FlagInterlaced/FieldOrder)
|
||||||
// retained.
|
// retained.
|
||||||
let v = VideoStream {
|
let v = VideoStream {
|
||||||
@@ -4313,7 +4316,7 @@ mod tests {
|
|||||||
"DefaultDecodedFieldDuration must NOT be written (Windows halves the rate when it is)"
|
"DefaultDecodedFieldDuration must NOT be written (Windows halves the rate when it is)"
|
||||||
);
|
);
|
||||||
// Interlace signalling is RETAINED so deinterlacers still engage and
|
// Interlace signalling is RETAINED so deinterlacers still engage and
|
||||||
// MediaInfo (which also reads scan type from the MPEG-2 ES) agrees.
|
// a media analyzer (which also reads scan type from the MPEG-2 ES) agrees.
|
||||||
let fi = find_id(&data, ebml::FLAG_INTERLACED).expect("FlagInterlaced present");
|
let fi = find_id(&data, ebml::FLAG_INTERLACED).expect("FlagInterlaced present");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
data[fi + 2],
|
data[fi + 2],
|
||||||
@@ -4816,7 +4819,7 @@ mod tests {
|
|||||||
fn mvc_track_emits_mvcc_block_addition_mapping() {
|
fn mvc_track_emits_mvcc_block_addition_mapping() {
|
||||||
// A track with mvc_params must emit BlockAdditionMapping (0x41E4) with the
|
// A track with mvc_params must emit BlockAdditionMapping (0x41E4) with the
|
||||||
// mvcC BlockAddIDType (0x6D766343) and a BlockAddIDValue (0x41F0), so
|
// mvcC BlockAddIDType (0x6D766343) and a BlockAddIDValue (0x41F0), so
|
||||||
// players / mediainfo recognise the Blu-ray 3D dependent view.
|
// players / analyzers recognise the Blu-ray 3D dependent view.
|
||||||
let mut v = make_video_track();
|
let mut v = make_video_track();
|
||||||
v.mvc_params = Some((
|
v.mvc_params = Some((
|
||||||
vec![0x6F, 0x80, 0x00, 0x33, 0x11, 0x22],
|
vec![0x6F, 0x80, 0x00, 0x33, 0x11, 0x22],
|
||||||
@@ -4869,7 +4872,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn mvc_track_codec_private_carries_avcc_plus_mvcc() {
|
fn mvc_track_codec_private_carries_avcc_plus_mvcc() {
|
||||||
// An MVC base track's CodecPrivate must be the base avcC followed by the
|
// An MVC base track's CodecPrivate must be the base avcC followed by the
|
||||||
// mvcC extension — the track-level signal mediainfo/decoders read.
|
// mvcC extension — the track-level signal analyzers/decoders read.
|
||||||
let avcc = vec![
|
let avcc = vec![
|
||||||
0x01, 0x64, 0x00, 0x33, 0xFF, 0xE1, 0x00, 0x05, 0x67, 0x64, 0x00, 0x33, 0x99,
|
0x01, 0x64, 0x00, 0x33, 0xFF, 0xE1, 0x00, 0x05, 0x67, 0x64, 0x00, 0x33, 0x99,
|
||||||
];
|
];
|
||||||
|
|||||||
+50
-8
@@ -224,8 +224,10 @@ pub(super) fn audio_sample_entry(
|
|||||||
e.extend_from_slice(&16u16.to_be_bytes()); // samplesize
|
e.extend_from_slice(&16u16.to_be_bytes()); // samplesize
|
||||||
e.extend_from_slice(&0u16.to_be_bytes()); // pre_defined
|
e.extend_from_slice(&0u16.to_be_bytes()); // pre_defined
|
||||||
e.extend_from_slice(&0u16.to_be_bytes()); // reserved
|
e.extend_from_slice(&0u16.to_be_bytes()); // reserved
|
||||||
// samplerate is 16.16 fixed point; the integer rate in the high 16 bits.
|
// samplerate is 16.16 fixed point; the integer rate in the high 16 bits. The
|
||||||
e.extend_from_slice(&(sample_rate << 16).to_be_bytes());
|
// integer part is only 16 bits, so cap at 65535 — 96/192 kHz (DTS-HD) would
|
||||||
|
// otherwise overflow u32 and write a garbage rate (the true rate is in ddts).
|
||||||
|
e.extend_from_slice(&(sample_rate.min(0xFFFF) << 16).to_be_bytes());
|
||||||
e.extend_from_slice(config);
|
e.extend_from_slice(config);
|
||||||
bx(fourcc, &e)
|
bx(fourcc, &e)
|
||||||
}
|
}
|
||||||
@@ -278,13 +280,15 @@ fn parse_dts(frame: &[u8]) -> Option<DtsConfig> {
|
|||||||
let base_ch = DTS_AMODE_CH.get(amode).copied().unwrap_or(6);
|
let base_ch = DTS_AMODE_CH.get(amode).copied().unwrap_or(6);
|
||||||
let channels = base_ch as u16 + lfe as u16;
|
let channels = base_ch as u16 + lfe as u16;
|
||||||
let channel_layout = dts_channel_layout(amode, lfe);
|
let channel_layout = dts_channel_layout(amode, lfe);
|
||||||
// DTS-HD extension substream sync (0x64582025) after the core frame.
|
// DTS-HD extension substream sync (0x64582025) after the core frame. Search
|
||||||
|
// ONLY the region at/after the core end (core_size = fsize+1): scanning the
|
||||||
|
// whole frame would false-positive on the same 4 bytes occurring inside the
|
||||||
|
// compressed core payload, mislabeling a plain DTS core as DTS-HD (dtsh).
|
||||||
let ext_sync = [0x64, 0x58, 0x20, 0x25];
|
let ext_sync = [0x64, 0x58, 0x20, 0x25];
|
||||||
let has_extension = f
|
// The EXSS begins at byte core_size (= fsize + 1); start the window search
|
||||||
.windows(4)
|
// exactly there so no 4-byte window inside the compressed core is ever tested.
|
||||||
.skip((fsize as usize + 1).min(f.len()).saturating_sub(4))
|
let ext_sync_start = (fsize as usize + 1).min(f.len());
|
||||||
.any(|w| w == ext_sync)
|
let has_extension = f.windows(4).skip(ext_sync_start).any(|w| w == ext_sync);
|
||||||
|| f.windows(4).any(|w| w == ext_sync);
|
|
||||||
|
|
||||||
Some(DtsConfig {
|
Some(DtsConfig {
|
||||||
sample_rate,
|
sample_rate,
|
||||||
@@ -523,4 +527,42 @@ mod tests {
|
|||||||
let e = dolby_sample_entry(Codec::DtsHdMa, &f).unwrap();
|
let e = dolby_sample_entry(Codec::DtsHdMa, &f).unwrap();
|
||||||
assert_eq!(&e[4..8], b"dtsc");
|
assert_eq!(&e[4..8], b"dtsc");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dts_ext_sync_inside_core_is_not_a_false_positive() {
|
||||||
|
// The 4-byte ext-sync pattern occurring INSIDE the compressed core payload
|
||||||
|
// (before core_size) must NOT be read as a DTS-HD extension → stays dtsc.
|
||||||
|
// f[4..8] = ext_sync makes core_size huge (>> frame len), so the search
|
||||||
|
// region is only after the core (skipped past this frame) → no extension.
|
||||||
|
let f = vec![
|
||||||
|
0x7F, 0xFE, 0x80, 0x01, 0x64, 0x58, 0x20, 0x25, 0x00, 0x00, 0x02, 0x00,
|
||||||
|
];
|
||||||
|
let c = parse_dts(&f).expect("parses");
|
||||||
|
assert!(!c.has_extension, "ext-sync inside core is not an extension");
|
||||||
|
let e = dolby_sample_entry(Codec::DtsHdMa, &f).unwrap();
|
||||||
|
assert_eq!(&e[4..8], b"dtsc");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dts_ext_sync_at_core_end_is_detected() {
|
||||||
|
// fsize=8 → core_size=9; the EXSS sync sits exactly at byte 9 (right after
|
||||||
|
// the core) and MUST be detected → dtsh. Guards the off-by-4 boundary.
|
||||||
|
let f = vec![
|
||||||
|
0x7F, 0xFE, 0x80, 0x01, 0x00, 0x00, 0x00, 0x80, 0x00, 0x64, 0x58, 0x20, 0x25,
|
||||||
|
];
|
||||||
|
let c = parse_dts(&f).expect("parses");
|
||||||
|
assert_eq!(c.core_size, 9);
|
||||||
|
assert!(c.has_extension, "EXSS sync at core end is a real extension");
|
||||||
|
let e = dolby_sample_entry(Codec::DtsHdMa, &f).unwrap();
|
||||||
|
assert_eq!(&e[4..8], b"dtsh");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sample_entry_samplerate_does_not_overflow_at_96k() {
|
||||||
|
// 96 kHz > 65535: the 16.16 integer part must saturate, not wrap to garbage.
|
||||||
|
let e = audio_sample_entry(b"ac-3", 6, 96_000, &[]);
|
||||||
|
// 8-byte box header + body offset 24 (6+2+8+2+2+2+2) → samplerate at 32;
|
||||||
|
// high 16 bits = the integer rate.
|
||||||
|
assert_eq!(&e[32..34], &[0xFF, 0xFF], "capped to 65535, not wrapped");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+41
-7
@@ -38,6 +38,10 @@ pub use read::Mp4Reader;
|
|||||||
/// Nanoseconds per second — PTS is carried in ns, media timescales are Hz.
|
/// Nanoseconds per second — PTS is carried in ns, media timescales are Hz.
|
||||||
const NS: i64 = 1_000_000_000;
|
const NS: i64 = 1_000_000_000;
|
||||||
|
|
||||||
|
/// Movie (mvhd) timescale in Hz. `tkhd.duration` is expressed in THIS timescale
|
||||||
|
/// (ISO/IEC 14496-12 §8.3.2), not the track's own media timescale.
|
||||||
|
const MOVIE_TIMESCALE: u32 = 90_000;
|
||||||
|
|
||||||
// ── faststart reserve sizing ─────────────────────────────────────────────────
|
// ── faststart reserve sizing ─────────────────────────────────────────────────
|
||||||
//
|
//
|
||||||
// Faststart is on by default: reserve a `moov`-sized hole between `ftyp` and
|
// Faststart is on by default: reserve a `moov`-sized hole between `ftyp` and
|
||||||
@@ -133,10 +137,14 @@ struct Track {
|
|||||||
pub enum Mp4SkipReason {
|
pub enum Mp4SkipReason {
|
||||||
/// A subtitle track — MP4 carries only text subs; disc subs are bitmap.
|
/// A subtitle track — MP4 carries only text subs; disc subs are bitmap.
|
||||||
BitmapSubtitle,
|
BitmapSubtitle,
|
||||||
/// An audio codec with no MP4 mapping here (TrueHD, DTS, LPCM, …).
|
/// An audio codec with no MP4 mapping here (TrueHD, LPCM, …). AC-3/E-AC-3 and
|
||||||
|
/// DTS/DTS-HD ARE mapped and carried.
|
||||||
UnmappableAudio,
|
UnmappableAudio,
|
||||||
/// A secondary/dependent video view (e.g. MVC 3D right eye).
|
/// A secondary/dependent video view (e.g. MVC 3D right eye).
|
||||||
SecondaryVideo,
|
SecondaryVideo,
|
||||||
|
/// A primary video track whose codec this MP4 writer can't carry
|
||||||
|
/// (only HEVC/H.264 are supported — e.g. VC-1, MPEG-2, AV1).
|
||||||
|
UnmappableVideo,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The plan for an `mp4://` mux of `title`: which streams are carried and which
|
/// The plan for an `mp4://` mux of `title`: which streams are carried and which
|
||||||
@@ -164,8 +172,12 @@ pub fn fit_report(title: &DiscTitle) -> Mp4FitReport {
|
|||||||
} else if !have_video && matches!(v.codec, Codec::Hevc | Codec::H264) {
|
} else if !have_video && matches!(v.codec, Codec::Hevc | Codec::H264) {
|
||||||
included.push(i);
|
included.push(i);
|
||||||
have_video = true;
|
have_video = true;
|
||||||
} else {
|
} else if have_video {
|
||||||
|
// A second primary video (after one was already carried).
|
||||||
skipped.push((i, Mp4SkipReason::SecondaryVideo));
|
skipped.push((i, Mp4SkipReason::SecondaryVideo));
|
||||||
|
} else {
|
||||||
|
// First primary video, but an unsupported codec (VC-1/MPEG-2/AV1).
|
||||||
|
skipped.push((i, Mp4SkipReason::UnmappableVideo));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
DiscStream::Audio(a) => {
|
DiscStream::Audio(a) => {
|
||||||
@@ -227,7 +239,7 @@ impl<W: Write + Seek> Mp4Sink<W> {
|
|||||||
.iter()
|
.iter()
|
||||||
.any(|&i| matches!(title.streams[i], DiscStream::Video(_)));
|
.any(|&i| matches!(title.streams[i], DiscStream::Video(_)));
|
||||||
if !has_video {
|
if !has_video {
|
||||||
return Err(crate::error::Error::MuxNoVideoTrack.into());
|
return Err(crate::error::Error::Mp4NoVideoTrack.into());
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut tracks = Vec::new();
|
let mut tracks = Vec::new();
|
||||||
@@ -245,7 +257,7 @@ impl<W: Write + Seek> Mp4Sink<W> {
|
|||||||
.codec_privates
|
.codec_privates
|
||||||
.get(i)
|
.get(i)
|
||||||
.and_then(|c| c.clone())
|
.and_then(|c| c.clone())
|
||||||
.ok_or(crate::error::Error::MuxMissingCodecPrivate)?;
|
.ok_or(crate::error::Error::Mp4MissingCodecPrivate)?;
|
||||||
let (w, h) = v.resolution.pixels();
|
let (w, h) = v.resolution.pixels();
|
||||||
tracks.push(Track {
|
tracks.push(Track {
|
||||||
media: Media::Video,
|
media: Media::Video,
|
||||||
@@ -317,7 +329,7 @@ impl<W: Write + Seek> Mp4Sink<W> {
|
|||||||
/// Assemble the `moov` box from every track's sample tables.
|
/// Assemble the `moov` box from every track's sample tables.
|
||||||
fn build_moov(&self) -> Vec<u8> {
|
fn build_moov(&self) -> Vec<u8> {
|
||||||
// Movie timescale = 90 kHz; movie duration = the longest track (converted).
|
// Movie timescale = 90 kHz; movie duration = the longest track (converted).
|
||||||
let movie_ts = 90_000u32;
|
let movie_ts = MOVIE_TIMESCALE;
|
||||||
let mut movie_dur = 0u64;
|
let mut movie_dur = 0u64;
|
||||||
let mut traks: Vec<Vec<u8>> = Vec::new();
|
let mut traks: Vec<Vec<u8>> = Vec::new();
|
||||||
for t in &self.tracks {
|
for t in &self.tracks {
|
||||||
@@ -441,7 +453,9 @@ fn build_video_trak_full(t: &Track) -> (Vec<u8>, f64) {
|
|||||||
"VideoHandler",
|
"VideoHandler",
|
||||||
minf,
|
minf,
|
||||||
);
|
);
|
||||||
let tkhd = build_tkhd(t.track_id, t.width, t.height, media_dur, false);
|
// tkhd.duration is in the MOVIE timescale, not `timing.timescale`.
|
||||||
|
let tkhd_dur = (secs * MOVIE_TIMESCALE as f64) as u64;
|
||||||
|
let tkhd = build_tkhd(t.track_id, t.width, t.height, tkhd_dur, false);
|
||||||
let mut body = tkhd;
|
let mut body = tkhd;
|
||||||
body.extend_from_slice(&mdia);
|
body.extend_from_slice(&mdia);
|
||||||
(bx(b"trak", &body), secs)
|
(bx(b"trak", &body), secs)
|
||||||
@@ -457,7 +471,9 @@ fn build_audio_trak_full(t: &Track) -> (Vec<u8>, f64) {
|
|||||||
let stbl = build_audio_stbl(entry, &t.samples, &durs);
|
let stbl = build_audio_stbl(entry, &t.samples, &durs);
|
||||||
let minf = build_minf(audio_smhd(), stbl);
|
let minf = build_minf(audio_smhd(), stbl);
|
||||||
let mdia = build_mdia(t.language, ts, media_dur, b"soun", "SoundHandler", minf);
|
let mdia = build_mdia(t.language, ts, media_dur, b"soun", "SoundHandler", minf);
|
||||||
let tkhd = build_tkhd(t.track_id, 0, 0, media_dur, true);
|
// tkhd.duration is in the MOVIE timescale, not the audio media timescale.
|
||||||
|
let tkhd_dur = (secs * MOVIE_TIMESCALE as f64) as u64;
|
||||||
|
let tkhd = build_tkhd(t.track_id, 0, 0, tkhd_dur, true);
|
||||||
let mut body = tkhd;
|
let mut body = tkhd;
|
||||||
body.extend_from_slice(&mdia);
|
body.extend_from_slice(&mdia);
|
||||||
(bx(b"trak", &body), secs)
|
(bx(b"trak", &body), secs)
|
||||||
@@ -947,6 +963,24 @@ mod tests {
|
|||||||
assert!(r.skipped.contains(&(4, Mp4SkipReason::BitmapSubtitle)));
|
assert!(r.skipped.contains(&(4, Mp4SkipReason::BitmapSubtitle)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fit_report_labels_unsupported_primary_video() {
|
||||||
|
// A primary video whose codec the MP4 writer can't carry (VC-1) must be
|
||||||
|
// skipped as UnmappableVideo, NOT SecondaryVideo (which means an MVC view).
|
||||||
|
let mut vc1 = match hevc_video() {
|
||||||
|
DiscStream::Video(v) => v,
|
||||||
|
_ => unreachable!(),
|
||||||
|
};
|
||||||
|
vc1.codec = Codec::Vc1;
|
||||||
|
let t = title(
|
||||||
|
vec![DiscStream::Video(vc1), audio(Codec::Ac3, "eng")],
|
||||||
|
vec![None, None],
|
||||||
|
);
|
||||||
|
let r = fit_report(&t);
|
||||||
|
assert!(r.skipped.contains(&(0, Mp4SkipReason::UnmappableVideo)));
|
||||||
|
assert_eq!(r.included, vec![1], "only the AC-3 audio is carried");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn no_video_track_is_an_error() {
|
fn no_video_track_is_an_error() {
|
||||||
let t = title(vec![audio(Codec::Ac3, "eng")], vec![None]);
|
let t = title(vec![audio(Codec::Ac3, "eng")], vec![None]);
|
||||||
|
|||||||
+430
-33
@@ -22,6 +22,26 @@ use std::path::Path;
|
|||||||
|
|
||||||
const NS: i128 = 1_000_000_000;
|
const NS: i128 = 1_000_000_000;
|
||||||
|
|
||||||
|
/// Upper bound on the number of tracks. Track count is otherwise unbounded (a
|
||||||
|
/// crafted moov can pack tens of thousands of `trak` boxes), and the per-track
|
||||||
|
/// PID is `0x1011 + track_idx` — which overflows u16 past ~61k tracks. Real
|
||||||
|
/// titles have well under a hundred tracks.
|
||||||
|
const MAX_TRACKS: usize = 512;
|
||||||
|
|
||||||
|
/// Upper bound on a track's decoded sample count. MP4 sample-table fields
|
||||||
|
/// (`stsz` sample_count, `stts`/`ctts` run-lengths) are untrusted 32-bit values;
|
||||||
|
/// a crafted box can declare billions of entries in a few bytes. Real titles stay
|
||||||
|
/// far under this (a 10 h/60 fps track is ~2M samples), so clamping to it caps a
|
||||||
|
/// hostile file's allocation without truncating any legitimate track.
|
||||||
|
const MAX_SAMPLE_COUNT: usize = 1 << 24;
|
||||||
|
|
||||||
|
/// Absolute ceiling on a single allocation sized from an untrusted MP4 field (a
|
||||||
|
/// per-sample buffer or the `moov` payload). The EOF check alone is not enough:
|
||||||
|
/// `file_len` is cheaply inflatable with a sparse file (`truncate -s 8G`), so a
|
||||||
|
/// crafted stsz size or moov box size just under an 8 GiB apparent length would
|
||||||
|
/// otherwise force a multi-GiB allocation. No real sample or moov approaches this.
|
||||||
|
const MAX_ALLOC_BYTES: u64 = 256 << 20; // 256 MiB
|
||||||
|
|
||||||
/// One sample's location + timing in the emission plan.
|
/// One sample's location + timing in the emission plan.
|
||||||
struct SampleRef {
|
struct SampleRef {
|
||||||
track: usize,
|
track: usize,
|
||||||
@@ -39,6 +59,9 @@ struct SampleRef {
|
|||||||
/// source) or an in-memory `Cursor` (round-trip tests).
|
/// source) or an in-memory `Cursor` (round-trip tests).
|
||||||
pub struct Mp4Reader<R: Read + Seek> {
|
pub struct Mp4Reader<R: Read + Seek> {
|
||||||
file: R,
|
file: R,
|
||||||
|
/// Total length of the backing file, captured at open — used to reject a
|
||||||
|
/// crafted `stsz` sample size that would over-allocate the per-sample buffer.
|
||||||
|
file_len: u64,
|
||||||
title: DiscTitle,
|
title: DiscTitle,
|
||||||
samples: Vec<SampleRef>,
|
samples: Vec<SampleRef>,
|
||||||
cursor: usize,
|
cursor: usize,
|
||||||
@@ -59,6 +82,8 @@ impl Mp4Reader<File> {
|
|||||||
impl<R: Read + Seek> Mp4Reader<R> {
|
impl<R: Read + Seek> Mp4Reader<R> {
|
||||||
/// Index an already-opened seekable MP4 reader.
|
/// Index an already-opened seekable MP4 reader.
|
||||||
pub fn from_reader(mut file: R, name: String) -> io::Result<Self> {
|
pub fn from_reader(mut file: R, name: String) -> io::Result<Self> {
|
||||||
|
let file_len = file.seek(SeekFrom::End(0))?;
|
||||||
|
file.seek(SeekFrom::Start(0))?;
|
||||||
let moov = read_moov(&mut file)?;
|
let moov = read_moov(&mut file)?;
|
||||||
let mut title = DiscTitle::empty();
|
let mut title = DiscTitle::empty();
|
||||||
title.playlist = name;
|
title.playlist = name;
|
||||||
@@ -66,13 +91,21 @@ impl<R: Read + Seek> Mp4Reader<R> {
|
|||||||
let mut samples: Vec<SampleRef> = Vec::new();
|
let mut samples: Vec<SampleRef> = Vec::new();
|
||||||
let mut codec_privates: Vec<Option<Vec<u8>>> = Vec::new();
|
let mut codec_privates: Vec<Option<Vec<u8>>> = Vec::new();
|
||||||
let mut track_idx = 0usize;
|
let mut track_idx = 0usize;
|
||||||
|
// Global cap on total decoded samples across ALL tracks — a crafted file
|
||||||
|
// with many `trak` boxes must not sum past this even though each track is
|
||||||
|
// individually bounded. Real titles stay far under it.
|
||||||
|
let mut sample_budget = MAX_SAMPLE_COUNT;
|
||||||
|
|
||||||
for trak in find_boxes(&moov, b"trak") {
|
for trak in find_boxes(&moov, b"trak") {
|
||||||
|
if track_idx >= MAX_TRACKS {
|
||||||
|
break; // bound track count so the per-track PID can't overflow u16
|
||||||
|
}
|
||||||
let Some(mdia) = find_box(trak, b"mdia") else {
|
let Some(mdia) = find_box(trak, b"mdia") else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
let timescale = find_box(mdia, b"mdhd")
|
let timescale = find_box(mdia, b"mdhd")
|
||||||
.and_then(mdhd_timescale)
|
.and_then(mdhd_timescale)
|
||||||
|
.filter(|&t| t != 0) // a crafted mdhd timescale of 0 would divide-by-zero below
|
||||||
.unwrap_or(90_000);
|
.unwrap_or(90_000);
|
||||||
let language = find_box(mdia, b"mdhd").and_then(mdhd_language);
|
let language = find_box(mdia, b"mdhd").and_then(mdhd_language);
|
||||||
let handler = find_box(mdia, b"hdlr").and_then(hdlr_type);
|
let handler = find_box(mdia, b"hdlr").and_then(hdlr_type);
|
||||||
@@ -113,7 +146,9 @@ impl<R: Read + Seek> Mp4Reader<R> {
|
|||||||
Some(h) if &h == b"soun" => DiscStream::Audio(AudioStream {
|
Some(h) if &h == b"soun" => DiscStream::Audio(AudioStream {
|
||||||
pid: 0x1100 + track_idx as u16,
|
pid: 0x1100 + track_idx as u16,
|
||||||
codec,
|
codec,
|
||||||
channels: AudioChannels::from_count(channels as u8),
|
// `channels` is an untrusted u16; saturate rather than wrap with
|
||||||
|
// `as u8` (a crafted 256 would alias to 0/Mono).
|
||||||
|
channels: AudioChannels::from_count(channels.min(u8::MAX as u16) as u8),
|
||||||
language: language.clone().unwrap_or_else(|| "und".into()),
|
language: language.clone().unwrap_or_else(|| "und".into()),
|
||||||
sample_rate: SampleRate::from_hz(timescale),
|
sample_rate: SampleRate::from_hz(timescale),
|
||||||
secondary: false,
|
secondary: false,
|
||||||
@@ -123,8 +158,12 @@ impl<R: Read + Seek> Mp4Reader<R> {
|
|||||||
_ => continue, // non-A/V handler
|
_ => continue, // non-A/V handler
|
||||||
};
|
};
|
||||||
|
|
||||||
// Per-sample tables.
|
// Per-sample tables. `stsz` is bounded by the remaining global budget;
|
||||||
let sizes = find_box(stbl, b"stsz").map(parse_stsz).unwrap_or_default();
|
// `stts`/`ctts` need at most one entry per sample, so they are bounded by
|
||||||
|
// this track's sample count (indices past it are never read).
|
||||||
|
let sizes = find_box(stbl, b"stsz")
|
||||||
|
.map(|b| parse_stsz(b, sample_budget))
|
||||||
|
.unwrap_or_default();
|
||||||
let n = sizes.len();
|
let n = sizes.len();
|
||||||
if n == 0 {
|
if n == 0 {
|
||||||
track_idx += 1;
|
track_idx += 1;
|
||||||
@@ -132,24 +171,49 @@ impl<R: Read + Seek> Mp4Reader<R> {
|
|||||||
codec_privates.push(config);
|
codec_privates.push(config);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
sample_budget -= n;
|
||||||
let chunk_offsets = find_box(stbl, b"stco")
|
let chunk_offsets = find_box(stbl, b"stco")
|
||||||
.map(|b| parse_stco(b, false))
|
.map(|b| parse_stco(b, false))
|
||||||
.or_else(|| find_box(stbl, b"co64").map(|b| parse_stco(b, true)))
|
.or_else(|| find_box(stbl, b"co64").map(|b| parse_stco(b, true)))
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
if chunk_offsets.is_empty() {
|
||||||
|
// Samples exist but there is no chunk-offset table: the stbl is
|
||||||
|
// malformed and every sample offset would resolve to file byte 0
|
||||||
|
// (muxing header bytes as frame data). Drop the track rather than
|
||||||
|
// emit garbage; an all-tracks-dropped file fails Mp4Invalid below.
|
||||||
|
continue;
|
||||||
|
}
|
||||||
let stsc = find_box(stbl, b"stsc").map(parse_stsc).unwrap_or_default();
|
let stsc = find_box(stbl, b"stsc").map(parse_stsc).unwrap_or_default();
|
||||||
|
if stsc.is_empty() {
|
||||||
|
// No sample-to-chunk map: samples can't be placed against the chunk
|
||||||
|
// offsets (they would pack from byte 0). Drop the track rather than
|
||||||
|
// emit header bytes as frame data — a valid stbl always has stsc.
|
||||||
|
continue;
|
||||||
|
}
|
||||||
let offsets = sample_offsets(&sizes, &chunk_offsets, &stsc);
|
let offsets = sample_offsets(&sizes, &chunk_offsets, &stsc);
|
||||||
let durations = find_box(stbl, b"stts").map(parse_stts).unwrap_or_default();
|
let durations = find_box(stbl, b"stts")
|
||||||
let ctts = find_box(stbl, b"ctts").map(parse_ctts).unwrap_or_default();
|
.map(|b| parse_stts(b, n))
|
||||||
|
.unwrap_or_default();
|
||||||
|
let ctts = find_box(stbl, b"ctts")
|
||||||
|
.map(|b| parse_ctts(b, n))
|
||||||
|
.unwrap_or_default();
|
||||||
let sync = find_box(stbl, b"stss").map(parse_stss);
|
let sync = find_box(stbl, b"stss").map(parse_stss);
|
||||||
|
|
||||||
|
// ticks → ns, saturating: a crafted tiny timescale + huge stts deltas can
|
||||||
|
// push the i128 quotient past i64::MAX; wrapping it would silently corrupt
|
||||||
|
// the sort/timestamps, so clamp instead.
|
||||||
|
let to_ns = |ticks: i64| -> i64 {
|
||||||
|
(ticks as i128 * NS / timescale as i128).clamp(i64::MIN as i128, i64::MAX as i128)
|
||||||
|
as i64
|
||||||
|
};
|
||||||
let mut decode_ticks: i64 = 0;
|
let mut decode_ticks: i64 = 0;
|
||||||
for (i, &size) in sizes.iter().enumerate() {
|
for (i, &size) in sizes.iter().enumerate() {
|
||||||
let dur = durations.get(i).copied().unwrap_or(0);
|
let dur = durations.get(i).copied().unwrap_or(0);
|
||||||
let comp = ctts.get(i).copied().unwrap_or(0);
|
let comp = ctts.get(i).copied().unwrap_or(0);
|
||||||
let dts_ns = (decode_ticks as i128 * NS / timescale as i128) as i64;
|
let dts_ns = to_ns(decode_ticks);
|
||||||
let pts_ticks = decode_ticks + comp as i64;
|
let pts_ticks = decode_ticks.saturating_add(comp as i64);
|
||||||
let pts_ns = (pts_ticks as i128 * NS / timescale as i128) as i64;
|
let pts_ns = to_ns(pts_ticks);
|
||||||
decode_ticks += dur as i64;
|
decode_ticks = decode_ticks.saturating_add(dur as i64);
|
||||||
let keyframe = match &sync {
|
let keyframe = match &sync {
|
||||||
Some(set) => set.contains(&(i as u32 + 1)),
|
Some(set) => set.contains(&(i as u32 + 1)),
|
||||||
None => true, // no stss → every sample is a sync sample
|
None => true, // no stss → every sample is a sync sample
|
||||||
@@ -170,7 +234,7 @@ impl<R: Read + Seek> Mp4Reader<R> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if title.streams.is_empty() {
|
if title.streams.is_empty() {
|
||||||
return Err(crate::error::Error::MkvInvalid.into());
|
return Err(crate::error::Error::Mp4Invalid.into());
|
||||||
}
|
}
|
||||||
title.codec_privates = codec_privates;
|
title.codec_privates = codec_privates;
|
||||||
|
|
||||||
@@ -180,6 +244,7 @@ impl<R: Read + Seek> Mp4Reader<R> {
|
|||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
file,
|
file,
|
||||||
|
file_len,
|
||||||
title,
|
title,
|
||||||
samples,
|
samples,
|
||||||
cursor: 0,
|
cursor: 0,
|
||||||
@@ -193,6 +258,13 @@ impl<R: Read + Seek + Send> Stream for Mp4Reader<R> {
|
|||||||
return Ok(None);
|
return Ok(None);
|
||||||
};
|
};
|
||||||
self.cursor += 1;
|
self.cursor += 1;
|
||||||
|
// `s.size`/`s.offset` come from the untrusted stsz/stco tables; reject a
|
||||||
|
// sample that claims to extend past EOF before allocating its buffer, so a
|
||||||
|
// crafted size can't force a multi-GB allocation the read would then fail.
|
||||||
|
let end = s.offset.checked_add(s.size as u64);
|
||||||
|
if s.size as u64 > MAX_ALLOC_BYTES || end.is_none_or(|e| e > self.file_len) {
|
||||||
|
return Err(crate::error::Error::Mp4Invalid.into());
|
||||||
|
}
|
||||||
self.file.seek(SeekFrom::Start(s.offset))?;
|
self.file.seek(SeekFrom::Start(s.offset))?;
|
||||||
let mut data = vec![0u8; s.size as usize];
|
let mut data = vec![0u8; s.size as usize];
|
||||||
self.file.read_exact(&mut data)?;
|
self.file.read_exact(&mut data)?;
|
||||||
@@ -229,28 +301,47 @@ impl<R: Read + Seek + Send> Stream for Mp4Reader<R> {
|
|||||||
/// Read top-level boxes until `moov`, returning its payload (after the header).
|
/// Read top-level boxes until `moov`, returning its payload (after the header).
|
||||||
/// Skips over `ftyp`/`mdat`/etc. via seek; samples are read later by offset.
|
/// Skips over `ftyp`/`mdat`/etc. via seek; samples are read later by offset.
|
||||||
fn read_moov<R: Read + Seek>(file: &mut R) -> io::Result<Vec<u8>> {
|
fn read_moov<R: Read + Seek>(file: &mut R) -> io::Result<Vec<u8>> {
|
||||||
|
let file_end = file.seek(SeekFrom::End(0))?;
|
||||||
|
file.seek(SeekFrom::Start(0))?;
|
||||||
loop {
|
loop {
|
||||||
|
let pos = file.stream_position()?;
|
||||||
let mut hdr = [0u8; 8];
|
let mut hdr = [0u8; 8];
|
||||||
if file.read_exact(&mut hdr).is_err() {
|
if file.read_exact(&mut hdr).is_err() {
|
||||||
return Err(crate::error::Error::MkvInvalid.into());
|
return Err(crate::error::Error::Mp4Invalid.into());
|
||||||
}
|
}
|
||||||
let size32 = u32::from_be_bytes([hdr[0], hdr[1], hdr[2], hdr[3]]);
|
let size32 = u32::from_be_bytes([hdr[0], hdr[1], hdr[2], hdr[3]]);
|
||||||
let btype = [hdr[4], hdr[5], hdr[6], hdr[7]];
|
let btype = [hdr[4], hdr[5], hdr[6], hdr[7]];
|
||||||
// 64-bit largesize (size==1): the real size is the next 8 bytes; a
|
// Total box size INCLUDING the header. `size==1` → 64-bit largesize in the
|
||||||
// 16-byte header precedes the payload. size==0 means "to EOF".
|
// next 8 bytes (16-byte header); `size==0` → the box runs to end of file.
|
||||||
let payload_len: u64 = if size32 == 1 {
|
let box_size: u64 = match size32 {
|
||||||
|
1 => {
|
||||||
let mut ext = [0u8; 8];
|
let mut ext = [0u8; 8];
|
||||||
file.read_exact(&mut ext)?;
|
file.read_exact(&mut ext)?;
|
||||||
u64::from_be_bytes(ext).saturating_sub(16)
|
u64::from_be_bytes(ext)
|
||||||
} else {
|
}
|
||||||
(size32 as u64).saturating_sub(8)
|
0 => file_end.saturating_sub(pos),
|
||||||
|
n => n as u64,
|
||||||
};
|
};
|
||||||
|
let header_len: u64 = if size32 == 1 { 16 } else { 8 };
|
||||||
|
// A box must contain at least its own header and cannot run past EOF. This
|
||||||
|
// also guarantees forward progress (box_size >= header_len > 0), so a
|
||||||
|
// crafted size < 8 can't spin the loop in place, and bounds every payload
|
||||||
|
// allocation to the real file length (no gigabyte over-allocation). Use
|
||||||
|
// checked_add so a 64-bit largesize near u64::MAX can't wrap past the guard.
|
||||||
|
if box_size < header_len || pos.checked_add(box_size).is_none_or(|end| end > file_end) {
|
||||||
|
return Err(crate::error::Error::Mp4Invalid.into());
|
||||||
|
}
|
||||||
if &btype == b"moov" {
|
if &btype == b"moov" {
|
||||||
|
let payload_len = box_size - header_len;
|
||||||
|
// Absolute cap independent of the (sparse-file-inflatable) length.
|
||||||
|
if payload_len > MAX_ALLOC_BYTES {
|
||||||
|
return Err(crate::error::Error::Mp4Invalid.into());
|
||||||
|
}
|
||||||
let mut buf = vec![0u8; payload_len as usize];
|
let mut buf = vec![0u8; payload_len as usize];
|
||||||
file.read_exact(&mut buf)?;
|
file.read_exact(&mut buf)?;
|
||||||
return Ok(buf);
|
return Ok(buf);
|
||||||
}
|
}
|
||||||
file.seek(SeekFrom::Current(payload_len as i64))?;
|
file.seek(SeekFrom::Start(pos + box_size))?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -310,7 +401,9 @@ fn mdhd_timescale(b: &[u8]) -> Option<u32> {
|
|||||||
/// mdhd language (5-bit packed ISO 639-2) → lowercase 3-letter code.
|
/// mdhd language (5-bit packed ISO 639-2) → lowercase 3-letter code.
|
||||||
fn mdhd_language(b: &[u8]) -> Option<String> {
|
fn mdhd_language(b: &[u8]) -> Option<String> {
|
||||||
let version = b.first().copied()?;
|
let version = b.first().copied()?;
|
||||||
let off = if version == 1 { 28 } else { 20 };
|
// v0: vflags(4)+creation(4)+modification(4)+timescale(4)+duration(4) = 20.
|
||||||
|
// v1: creation/modification/duration are 64-bit → vflags(4)+8+8+4+8 = 32.
|
||||||
|
let off = if version == 1 { 32 } else { 20 };
|
||||||
if b.len() < off + 2 {
|
if b.len() < off + 2 {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
@@ -352,7 +445,9 @@ fn parse_stsd(b: &[u8]) -> Option<StsdInfo> {
|
|||||||
}
|
}
|
||||||
let size = be32(entry, 0) as usize;
|
let size = be32(entry, 0) as usize;
|
||||||
let fourcc = [entry[4], entry[5], entry[6], entry[7]];
|
let fourcc = [entry[4], entry[5], entry[6], entry[7]];
|
||||||
let body = &entry[8..size.min(entry.len())];
|
// `size` is untrusted: clamp to [8, entry.len()] so a declared size < 8 (or a
|
||||||
|
// truncated entry) yields an empty body instead of panicking on `entry[8..<8]`.
|
||||||
|
let body = &entry[8..size.clamp(8, entry.len())];
|
||||||
|
|
||||||
let codec = match &fourcc {
|
let codec = match &fourcc {
|
||||||
b"hvc1" | b"hev1" => Codec::Hevc,
|
b"hvc1" | b"hev1" => Codec::Hevc,
|
||||||
@@ -384,28 +479,96 @@ fn parse_stsd(b: &[u8]) -> Option<StsdInfo> {
|
|||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
// AudioSampleEntry: 6 reserved + 2 dri + 8 reserved + channelcount(2)
|
// AudioSampleEntry: 6 reserved + 2 dri + 8 reserved + channelcount(2)
|
||||||
// samplesize(2) + 2 pre + 2 reserved + samplerate(4) = 28 bytes.
|
// samplesize(2) + 2 pre + 2 reserved + samplerate(4) = 28 bytes, then child
|
||||||
|
// boxes. AAC (mp4a) carries its AudioSpecificConfig in an `esds` box — the
|
||||||
|
// MKV CodecPrivate for A_AAC. AC-3/DTS are self-describing in-band (None).
|
||||||
let channels = if body.len() >= 28 { be16(body, 16) } else { 2 };
|
let channels = if body.len() >= 28 { be16(body, 16) } else { 2 };
|
||||||
|
let config = if matches!(codec, Codec::Aac) && body.len() >= 28 {
|
||||||
|
find_box(&body[28..], b"esds").and_then(parse_esds_asc)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
Some(StsdInfo {
|
Some(StsdInfo {
|
||||||
codec,
|
codec,
|
||||||
height: 0,
|
height: 0,
|
||||||
config: None,
|
config,
|
||||||
channels,
|
channels,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Read an MPEG-4 expandable descriptor length (ISO/IEC 14496-1), advancing `pos`.
|
||||||
|
/// Each byte contributes 7 bits, continued while the high bit is set (max 4 bytes).
|
||||||
|
fn read_descriptor_len(b: &[u8], pos: &mut usize) -> usize {
|
||||||
|
let mut len = 0usize;
|
||||||
|
for _ in 0..4 {
|
||||||
|
let Some(&byte) = b.get(*pos) else { break };
|
||||||
|
*pos += 1;
|
||||||
|
len = (len << 7) | (byte & 0x7F) as usize;
|
||||||
|
if byte & 0x80 == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
len
|
||||||
|
}
|
||||||
|
|
||||||
|
/// esds → AAC AudioSpecificConfig (the A_AAC CodecPrivate), or `None`. Walks
|
||||||
|
/// ES_Descriptor(0x03) → DecoderConfigDescriptor(0x04) → DecoderSpecificInfo(0x05).
|
||||||
|
/// Fully bounds-checked: a malformed/truncated esds returns None, never panics.
|
||||||
|
fn parse_esds_asc(b: &[u8]) -> Option<Vec<u8>> {
|
||||||
|
// esds is a FullBox: version+flags(4), then the ES_Descriptor.
|
||||||
|
let mut pos = 4;
|
||||||
|
if *b.get(pos)? != 0x03 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
pos += 1;
|
||||||
|
read_descriptor_len(b, &mut pos); // ES_Descriptor length (unused)
|
||||||
|
pos += 2; // ES_ID
|
||||||
|
let flags = *b.get(pos)?;
|
||||||
|
pos += 1;
|
||||||
|
if flags & 0x80 != 0 {
|
||||||
|
pos += 2; // streamDependenceFlag → dependsOn_ES_ID
|
||||||
|
}
|
||||||
|
if flags & 0x40 != 0 {
|
||||||
|
// URL_flag → URLlength(1) + URLstring
|
||||||
|
pos += 1 + *b.get(pos)? as usize;
|
||||||
|
}
|
||||||
|
if flags & 0x20 != 0 {
|
||||||
|
pos += 2; // OCRstreamFlag → OCR_ES_Id
|
||||||
|
}
|
||||||
|
if *b.get(pos)? != 0x04 {
|
||||||
|
return None; // DecoderConfigDescriptor
|
||||||
|
}
|
||||||
|
pos += 1;
|
||||||
|
read_descriptor_len(b, &mut pos);
|
||||||
|
// objectTypeIndication(1) + streamType/bufferSizeDB(4) + maxBitrate(4) + avgBitrate(4)
|
||||||
|
pos += 13;
|
||||||
|
if *b.get(pos)? != 0x05 {
|
||||||
|
return None; // DecoderSpecificInfo
|
||||||
|
}
|
||||||
|
pos += 1;
|
||||||
|
let asc_len = read_descriptor_len(b, &mut pos);
|
||||||
|
let end = pos.checked_add(asc_len)?;
|
||||||
|
if asc_len == 0 || end > b.len() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(b[pos..end].to_vec())
|
||||||
|
}
|
||||||
|
|
||||||
/// stsz → per-sample sizes.
|
/// stsz → per-sample sizes.
|
||||||
fn parse_stsz(b: &[u8]) -> Vec<u32> {
|
fn parse_stsz(b: &[u8], max: usize) -> Vec<u32> {
|
||||||
if b.len() < 12 {
|
if b.len() < 12 {
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
}
|
}
|
||||||
let sample_size = be32(b, 4);
|
let sample_size = be32(b, 4);
|
||||||
let count = be32(b, 8) as usize;
|
// `count` is untrusted; clamp to the caller's remaining sample budget so neither
|
||||||
|
// a single 0xFFFFFFFF nor many crafted tracks can over-allocate (see from_reader).
|
||||||
|
let count = (be32(b, 8) as usize).min(max);
|
||||||
if sample_size != 0 {
|
if sample_size != 0 {
|
||||||
return vec![sample_size; count];
|
return vec![sample_size; count];
|
||||||
}
|
}
|
||||||
let mut out = Vec::with_capacity(count);
|
// Each entry is 4 bytes; `count` also can't exceed what the box actually holds.
|
||||||
|
let mut out = Vec::with_capacity(count.min((b.len() - 12) / 4));
|
||||||
for i in 0..count {
|
for i in 0..count {
|
||||||
let o = 12 + i * 4;
|
let o = 12 + i * 4;
|
||||||
if o + 4 > b.len() {
|
if o + 4 > b.len() {
|
||||||
@@ -422,8 +585,9 @@ fn parse_stco(b: &[u8], is64: bool) -> Vec<u64> {
|
|||||||
return Vec::new();
|
return Vec::new();
|
||||||
}
|
}
|
||||||
let count = be32(b, 4) as usize;
|
let count = be32(b, 4) as usize;
|
||||||
let mut out = Vec::with_capacity(count);
|
|
||||||
let stride = if is64 { 8 } else { 4 };
|
let stride = if is64 { 8 } else { 4 };
|
||||||
|
// `count` entries of `stride` bytes can't exceed the box body.
|
||||||
|
let mut out = Vec::with_capacity(count.min((b.len() - 8) / stride));
|
||||||
for i in 0..count {
|
for i in 0..count {
|
||||||
let o = 8 + i * stride;
|
let o = 8 + i * stride;
|
||||||
if o + stride > b.len() {
|
if o + stride > b.len() {
|
||||||
@@ -453,7 +617,8 @@ fn parse_stsc(b: &[u8]) -> Vec<(u32, u32)> {
|
|||||||
return Vec::new();
|
return Vec::new();
|
||||||
}
|
}
|
||||||
let count = be32(b, 4) as usize;
|
let count = be32(b, 4) as usize;
|
||||||
let mut out = Vec::with_capacity(count);
|
// Each entry is 12 bytes; `count` can't exceed what the box actually holds.
|
||||||
|
let mut out = Vec::with_capacity(count.min((b.len() - 8) / 12));
|
||||||
for i in 0..count {
|
for i in 0..count {
|
||||||
let o = 8 + i * 12;
|
let o = 8 + i * 12;
|
||||||
if o + 12 > b.len() {
|
if o + 12 > b.len() {
|
||||||
@@ -489,7 +654,9 @@ fn sample_offsets(sizes: &[u32], chunk_offsets: &[u64], stsc: &[(u32, u32)]) ->
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
offsets.push(off);
|
offsets.push(off);
|
||||||
off += sizes[sidx] as u64;
|
// `choff`/`sizes` are untrusted; saturate so a crafted co64 offset near
|
||||||
|
// u64::MAX can't overflow-panic (the read() EOF guard rejects it later).
|
||||||
|
off = off.saturating_add(sizes[sidx] as u64);
|
||||||
sidx += 1;
|
sidx += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -500,13 +667,15 @@ fn sample_offsets(sizes: &[u32], chunk_offsets: &[u64], stsc: &[(u32, u32)]) ->
|
|||||||
.get(offsets.len().saturating_sub(1))
|
.get(offsets.len().saturating_sub(1))
|
||||||
.copied()
|
.copied()
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
offsets.push(last + last_sz as u64);
|
offsets.push(last.saturating_add(last_sz as u64));
|
||||||
}
|
}
|
||||||
offsets
|
offsets
|
||||||
}
|
}
|
||||||
|
|
||||||
/// stts → per-sample decode durations (expanded from run-length entries).
|
/// stts → per-sample decode durations (expanded from run-length entries). `max`
|
||||||
fn parse_stts(b: &[u8]) -> Vec<u32> {
|
/// caps the expansion — the caller passes the track's real sample count, past which
|
||||||
|
/// entries are never read (and an untrusted run-length must not grow the Vec).
|
||||||
|
fn parse_stts(b: &[u8], max: usize) -> Vec<u32> {
|
||||||
if b.len() < 8 {
|
if b.len() < 8 {
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
}
|
}
|
||||||
@@ -520,6 +689,9 @@ fn parse_stts(b: &[u8]) -> Vec<u32> {
|
|||||||
let n = be32(b, o);
|
let n = be32(b, o);
|
||||||
let delta = be32(b, o + 4);
|
let delta = be32(b, o + 4);
|
||||||
for _ in 0..n {
|
for _ in 0..n {
|
||||||
|
if out.len() >= max {
|
||||||
|
return out;
|
||||||
|
}
|
||||||
out.push(delta);
|
out.push(delta);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -527,7 +699,8 @@ fn parse_stts(b: &[u8]) -> Vec<u32> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// ctts → per-sample composition offsets (version 0 unsigned / version 1 signed).
|
/// ctts → per-sample composition offsets (version 0 unsigned / version 1 signed).
|
||||||
fn parse_ctts(b: &[u8]) -> Vec<i32> {
|
/// `max` caps the expansion, as in [`parse_stts`].
|
||||||
|
fn parse_ctts(b: &[u8], max: usize) -> Vec<i32> {
|
||||||
if b.len() < 8 {
|
if b.len() < 8 {
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
}
|
}
|
||||||
@@ -543,6 +716,9 @@ fn parse_ctts(b: &[u8]) -> Vec<i32> {
|
|||||||
let n = be32(b, o);
|
let n = be32(b, o);
|
||||||
let offset = be32(b, o + 4) as i32;
|
let offset = be32(b, o + 4) as i32;
|
||||||
for _ in 0..n {
|
for _ in 0..n {
|
||||||
|
if out.len() >= max {
|
||||||
|
return out;
|
||||||
|
}
|
||||||
out.push(offset);
|
out.push(offset);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -592,6 +768,72 @@ mod tests {
|
|||||||
assert_eq!(sample_offsets(&sizes, &chunks, &stsc), vec![500, 510, 900]);
|
assert_eq!(sample_offsets(&sizes, &chunks, &stsc), vec![500, 510, 900]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sample_offsets_saturates_on_huge_chunk_offset() {
|
||||||
|
// A co64 chunk offset near u64::MAX plus a sample size must saturate, not
|
||||||
|
// overflow-panic (debug) / wrap (release) — the read() EOF guard rejects
|
||||||
|
// the resulting out-of-range offset later.
|
||||||
|
let sizes = vec![10u32, 20];
|
||||||
|
let chunks = vec![u64::MAX - 5];
|
||||||
|
let stsc = vec![(1u32, 2u32)]; // 2 samples in the single chunk
|
||||||
|
let offs = sample_offsets(&sizes, &chunks, &stsc);
|
||||||
|
assert_eq!(offs[0], u64::MAX - 5);
|
||||||
|
assert_eq!(offs[1], u64::MAX, "(MAX-5)+10 saturates to MAX");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn read_rejects_sample_offset_past_eof() {
|
||||||
|
use crate::disc::{
|
||||||
|
Codec, DiscTitle, FrameRate, HdrFormat, Resolution, Stream as DiscStreamE, VideoStream,
|
||||||
|
};
|
||||||
|
use crate::mux::mp4::Mp4Sink;
|
||||||
|
use crate::pes::{PesFrame, Stream as _};
|
||||||
|
use std::io::Cursor;
|
||||||
|
|
||||||
|
// The MP4 writer requires a primary video track, so build an HEVC one.
|
||||||
|
let mut t = DiscTitle::empty();
|
||||||
|
t.streams = vec![DiscStreamE::Video(VideoStream {
|
||||||
|
pid: 0x1011,
|
||||||
|
codec: Codec::Hevc,
|
||||||
|
resolution: Resolution::R1080p,
|
||||||
|
frame_rate: FrameRate::F23_976,
|
||||||
|
hdr: HdrFormat::Sdr,
|
||||||
|
color_space: crate::disc::ColorSpace::Unknown,
|
||||||
|
display_aspect: None,
|
||||||
|
secondary: false,
|
||||||
|
label: String::new(),
|
||||||
|
measured_cicp: None,
|
||||||
|
})];
|
||||||
|
t.codec_privates = vec![Some(vec![0xAA, 0xBB, 0xCC, 0xDD, 0xEE])];
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
{
|
||||||
|
let mut sink = Mp4Sink::create(Cursor::new(&mut buf), &t).unwrap();
|
||||||
|
sink.write(&PesFrame {
|
||||||
|
track: 0,
|
||||||
|
pts: 0,
|
||||||
|
keyframe: true,
|
||||||
|
data: vec![0x11u8; 700],
|
||||||
|
duration_ns: None,
|
||||||
|
source: None,
|
||||||
|
coding: None,
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
sink.finish().unwrap();
|
||||||
|
}
|
||||||
|
// Faststart writes `moov` near the front and `mdat` after a multi-MiB
|
||||||
|
// reserve, so a 64 KiB truncation keeps the parseable moov but drops mdat.
|
||||||
|
// from_reader still succeeds; read() must reject the now-out-of-range
|
||||||
|
// sample rather than allocate for it or read past EOF.
|
||||||
|
buf.truncate(64 * 1024);
|
||||||
|
let mut rd = Mp4Reader::from_reader(Cursor::new(buf), "trunc".into()).unwrap();
|
||||||
|
let err = rd.read().unwrap_err();
|
||||||
|
assert_eq!(
|
||||||
|
err.kind(),
|
||||||
|
std::io::ErrorKind::InvalidData,
|
||||||
|
"a sample offset past EOF must be rejected"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn write_then_read_round_trip() {
|
fn write_then_read_round_trip() {
|
||||||
// Mux a small A/V title to an in-memory MP4, then demux it back and
|
// Mux a small A/V title to an in-memory MP4, then demux it back and
|
||||||
@@ -700,6 +942,161 @@ mod tests {
|
|||||||
stts.extend_from_slice(&1u32.to_be_bytes()); // entry_count
|
stts.extend_from_slice(&1u32.to_be_bytes()); // entry_count
|
||||||
stts.extend_from_slice(&3u32.to_be_bytes());
|
stts.extend_from_slice(&3u32.to_be_bytes());
|
||||||
stts.extend_from_slice(&1001u32.to_be_bytes());
|
stts.extend_from_slice(&1001u32.to_be_bytes());
|
||||||
assert_eq!(parse_stts(&stts), vec![1001, 1001, 1001]);
|
assert_eq!(parse_stts(&stts, MAX_SAMPLE_COUNT), vec![1001, 1001, 1001]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Untrusted-input hardening: a crafted MP4 must never panic or over-allocate.
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_stsz_fixed_size_caps_hostile_count() {
|
||||||
|
// sample_size != 0, count = u32::MAX: a 12-byte box must not allocate ~16 GiB.
|
||||||
|
let mut b = Vec::new();
|
||||||
|
b.extend_from_slice(&[0, 0, 0, 0]); // version+flags
|
||||||
|
b.extend_from_slice(&1u32.to_be_bytes()); // sample_size
|
||||||
|
b.extend_from_slice(&u32::MAX.to_be_bytes()); // count
|
||||||
|
let out = parse_stsz(&b, MAX_SAMPLE_COUNT);
|
||||||
|
assert_eq!(out.len(), MAX_SAMPLE_COUNT);
|
||||||
|
// And a smaller budget (the cumulative multi-track cap) bounds it tighter.
|
||||||
|
assert_eq!(parse_stsz(&b, 100).len(), 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_stsz_table_count_bounded_by_box() {
|
||||||
|
// sample_size == 0, count huge, but only two real entries in the buffer.
|
||||||
|
let mut b = Vec::new();
|
||||||
|
b.extend_from_slice(&[0, 0, 0, 0]);
|
||||||
|
b.extend_from_slice(&0u32.to_be_bytes()); // sample_size == 0 → table
|
||||||
|
b.extend_from_slice(&u32::MAX.to_be_bytes()); // count (lie)
|
||||||
|
b.extend_from_slice(&10u32.to_be_bytes());
|
||||||
|
b.extend_from_slice(&20u32.to_be_bytes());
|
||||||
|
assert_eq!(parse_stsz(&b, MAX_SAMPLE_COUNT), vec![10, 20]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_stco_stsc_count_bounded_by_box() {
|
||||||
|
// stco: count lie, one real 32-bit offset.
|
||||||
|
let mut stco = Vec::new();
|
||||||
|
stco.extend_from_slice(&[0, 0, 0, 0]);
|
||||||
|
stco.extend_from_slice(&u32::MAX.to_be_bytes());
|
||||||
|
stco.extend_from_slice(&4096u32.to_be_bytes());
|
||||||
|
assert_eq!(parse_stco(&stco, false), vec![4096]);
|
||||||
|
// stsc: count lie, one real (first_chunk, per) tuple.
|
||||||
|
let mut stsc = Vec::new();
|
||||||
|
stsc.extend_from_slice(&[0, 0, 0, 0]);
|
||||||
|
stsc.extend_from_slice(&u32::MAX.to_be_bytes());
|
||||||
|
stsc.extend_from_slice(&1u32.to_be_bytes());
|
||||||
|
stsc.extend_from_slice(&7u32.to_be_bytes());
|
||||||
|
stsc.extend_from_slice(&0u32.to_be_bytes()); // sample_desc_idx (unused)
|
||||||
|
assert_eq!(parse_stsc(&stsc), vec![(1, 7)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_stts_caps_hostile_runlength() {
|
||||||
|
// One entry with a u32::MAX run-length must cap, not push billions.
|
||||||
|
let mut stts = Vec::new();
|
||||||
|
stts.extend_from_slice(&[0, 0, 0, 0]);
|
||||||
|
stts.extend_from_slice(&1u32.to_be_bytes()); // entry_count
|
||||||
|
stts.extend_from_slice(&u32::MAX.to_be_bytes()); // n
|
||||||
|
stts.extend_from_slice(&33u32.to_be_bytes()); // delta
|
||||||
|
assert_eq!(parse_stts(&stts, MAX_SAMPLE_COUNT).len(), MAX_SAMPLE_COUNT);
|
||||||
|
// A tight per-track cap (the real sample count) bounds the run-length too.
|
||||||
|
assert_eq!(parse_stts(&stts, 5).len(), 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_stsd_short_size_does_not_panic() {
|
||||||
|
// stsd sample entry declaring size = 0 must not panic on `entry[8..<8]`.
|
||||||
|
let mut b = Vec::new();
|
||||||
|
b.extend_from_slice(&[0, 0, 0, 0]); // version+flags
|
||||||
|
b.extend_from_slice(&1u32.to_be_bytes()); // entry_count
|
||||||
|
b.extend_from_slice(&0u32.to_be_bytes()); // sample entry size = 0
|
||||||
|
b.extend_from_slice(b"avc1"); // fourcc
|
||||||
|
// Recognised codec but empty body → None (no dimensions), no panic.
|
||||||
|
assert!(parse_stsd(&b).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn read_moov_oversize_is_rejected() {
|
||||||
|
use std::io::Cursor;
|
||||||
|
// A tiny file whose first box claims to be a `moov` far larger than the file.
|
||||||
|
let mut b = Vec::new();
|
||||||
|
b.extend_from_slice(&0xFFFF_FFF0u32.to_be_bytes()); // size32 (~4 GiB)
|
||||||
|
b.extend_from_slice(b"moov");
|
||||||
|
b.extend_from_slice(&[0u8; 16]); // a few real bytes, nowhere near the claim
|
||||||
|
assert!(read_moov(&mut Cursor::new(b)).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_esds_extracts_aac_asc() {
|
||||||
|
// esds: version/flags + ES_Descriptor(0x03) + DecoderConfigDescriptor(0x04)
|
||||||
|
// + DecoderSpecificInfo(0x05) carrying a 2-byte AudioSpecificConfig.
|
||||||
|
let esds = vec![
|
||||||
|
0, 0, 0, 0, // version+flags
|
||||||
|
0x03, 0x19, 0x00, 0x00, 0x00, // ES_Descriptor: tag,len, ES_ID(2), flags(0)
|
||||||
|
0x04, 0x11, // DecoderConfigDescriptor: tag,len
|
||||||
|
0x40, // objectTypeIndication (AAC)
|
||||||
|
0x15, 0, 0, 0, // streamType/bufferSizeDB
|
||||||
|
0, 0, 0, 0, // maxBitrate
|
||||||
|
0, 0, 0, 0, // avgBitrate
|
||||||
|
0x05, 0x02, // DecoderSpecificInfo: tag,len
|
||||||
|
0x12, 0x10, // AudioSpecificConfig (AAC-LC 44.1k stereo)
|
||||||
|
];
|
||||||
|
assert_eq!(parse_esds_asc(&esds), Some(vec![0x12, 0x10]));
|
||||||
|
// A truncated esds must return None, never panic.
|
||||||
|
assert_eq!(parse_esds_asc(&esds[..12]), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn read_moov_size_zero_spans_to_eof() {
|
||||||
|
use std::io::Cursor;
|
||||||
|
// size32 == 0 means "box extends to end of file"; the moov body is the rest.
|
||||||
|
let mut b = Vec::new();
|
||||||
|
b.extend_from_slice(&0u32.to_be_bytes()); // size = 0 → to EOF
|
||||||
|
b.extend_from_slice(b"moov");
|
||||||
|
b.extend_from_slice(&[0xAA, 0xBB, 0xCC]); // body
|
||||||
|
assert_eq!(
|
||||||
|
read_moov(&mut Cursor::new(b)).unwrap(),
|
||||||
|
vec![0xAA, 0xBB, 0xCC]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn read_moov_largesize_overflow_is_rejected() {
|
||||||
|
use std::io::Cursor;
|
||||||
|
// A 64-bit largesize near u64::MAX must not wrap the `pos + box_size` EOF
|
||||||
|
// guard (it would otherwise pass and drive an exabyte allocation).
|
||||||
|
let mut b = Vec::new();
|
||||||
|
b.extend_from_slice(&16u32.to_be_bytes()); // ftyp box, size 16
|
||||||
|
b.extend_from_slice(b"ftyp");
|
||||||
|
b.extend_from_slice(&[0u8; 8]);
|
||||||
|
b.extend_from_slice(&1u32.to_be_bytes()); // moov, size==1 → largesize
|
||||||
|
b.extend_from_slice(b"moov");
|
||||||
|
b.extend_from_slice(&0xFFFF_FFFF_FFFF_FFF8u64.to_be_bytes());
|
||||||
|
assert!(read_moov(&mut Cursor::new(b)).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn read_moov_undersized_box_does_not_hang() {
|
||||||
|
use std::io::Cursor;
|
||||||
|
// A box whose declared size is < 8 (here 3) must be rejected, not spin the
|
||||||
|
// loop in place forever (size.saturating_sub(8) == 0 → no forward progress).
|
||||||
|
let mut b = Vec::new();
|
||||||
|
b.extend_from_slice(&3u32.to_be_bytes());
|
||||||
|
b.extend_from_slice(b"free");
|
||||||
|
assert!(read_moov(&mut Cursor::new(b)).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mdhd_language_offsets_per_version() {
|
||||||
|
// "eng" packed = 0x15C7. v0 carries it at byte 20, v1 (64-bit times) at 32.
|
||||||
|
let packed = [0x15u8, 0xC7];
|
||||||
|
let mut v0 = vec![0u8; 22];
|
||||||
|
v0[0] = 0; // version 0
|
||||||
|
v0[20..22].copy_from_slice(&packed);
|
||||||
|
assert_eq!(mdhd_language(&v0).as_deref(), Some("eng"));
|
||||||
|
let mut v1 = vec![0u8; 34];
|
||||||
|
v1[0] = 1; // version 1
|
||||||
|
v1[32..34].copy_from_slice(&packed);
|
||||||
|
assert_eq!(mdhd_language(&v1).as_deref(), Some("eng"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -659,8 +659,8 @@ mod tests {
|
|||||||
|
|
||||||
/// B1 end-to-end: after a TS discontinuity on a VIDEO track the consumer
|
/// B1 end-to-end: after a TS discontinuity on a VIDEO track the consumer
|
||||||
/// must DROP every inter-coded frame until the next keyframe, so no frame
|
/// must DROP every inter-coded frame until the next keyframe, so no frame
|
||||||
/// with a dangling reference reaches the muxer (an ffmpeg deep-scan would
|
/// with a dangling reference reaches the muxer (a strict decode-order
|
||||||
/// otherwise report a missing reference). The frame carrying the
|
/// deep-scan would otherwise report a missing reference). The frame carrying the
|
||||||
/// discontinuity and the inter frames behind it are dropped; the stream
|
/// discontinuity and the inter frames behind it are dropped; the stream
|
||||||
/// resumes cleanly at the next keyframe.
|
/// resumes cleanly at the next keyframe.
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
+44
-55
@@ -724,26 +724,6 @@ fn build_demux_state(title: &DiscTitle, format: ContentFormat) -> DemuxState {
|
|||||||
(parsers, pid_to_track, ts, ps)
|
(parsers, pid_to_track, ts, ps)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve the proactive [`AacsKeyMap`](crate::decrypt::AacsKeyMap) for a title
|
|
||||||
/// before muxing. It decides which held unit key decrypts each of the title's
|
|
||||||
/// LBA ranges and secures any key the pool is missing through the app's
|
|
||||||
/// configured source (`fetch`) up front, never reactively per unit at mux time.
|
|
||||||
///
|
|
||||||
/// This is what ends the key-server storm. The old mux decrypted a unit, checked
|
|
||||||
/// whether the plaintext looked like clean MPEG-TS, and — because authored-bad
|
|
||||||
/// content never reaches that bar — re-asked the key service for a key it already
|
|
||||||
/// held. There is no per-unit byte pattern that separates "correctly decrypted
|
|
||||||
/// but authored-bad" from "still encrypted", so that check is unanswerable. Here
|
|
||||||
/// we answer the answerable question instead: which CPS unit does each LBA range
|
|
||||||
/// belong to, decided by the disc's key structure (validated once against real
|
|
||||||
/// ciphertext samples, where the `is_clean` proof IS sound). The mux then just
|
|
||||||
/// decrypts each unit with its mapped key and trusts it.
|
|
||||||
///
|
|
||||||
/// Single-CPS (the overwhelming majority, incl. every single-key UHD) is the
|
|
||||||
/// trivial map: one key everywhere, no sampling. Multi-CPS assigns each extent to
|
|
||||||
/// the key that opens a real sample from it; a bad-content extent no sample can
|
|
||||||
/// classify inherits its predecessor's key (contiguity). FMTS segment mapping
|
|
||||||
/// layers onto the same structure.
|
|
||||||
/// FMTS (AACS 2.1) branch of [`resolve_mux_key_map`]. Returns `Some(map)` when the
|
/// FMTS (AACS 2.1) branch of [`resolve_mux_key_map`]. Returns `Some(map)` when the
|
||||||
/// disc carries `IndividualSegment.tbl` AND a key source is configured; `None`
|
/// disc carries `IndividualSegment.tbl` AND a key source is configured; `None`
|
||||||
/// otherwise (not FMTS, or no source — the caller's base-Unit-Key path then
|
/// otherwise (not FMTS, or no source — the caller's base-Unit-Key path then
|
||||||
@@ -932,6 +912,13 @@ fn resolve_fmts_key_map(
|
|||||||
Vec::with_capacity(segments.len());
|
Vec::with_capacity(segments.len());
|
||||||
let mut unresolved = 0usize;
|
let mut unresolved = 0usize;
|
||||||
for seg in &segments {
|
for seg in &segments {
|
||||||
|
// SPNs are untrusted (from IndividualSegment.tbl); an inverted record
|
||||||
|
// (start_spn > end_spn) would underflow `end_byte - 1 - start_byte` below.
|
||||||
|
// (Mirrors the guard in `aacs::segment::fmts_key_ranges`.)
|
||||||
|
if seg.start_spn > seg.end_spn {
|
||||||
|
unresolved += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
let Some(&slot) = tag_slot.get(&seg.index) else {
|
let Some(&slot) = tag_slot.get(&seg.index) else {
|
||||||
unresolved += 1;
|
unresolved += 1;
|
||||||
continue;
|
continue;
|
||||||
@@ -969,6 +956,26 @@ fn resolve_fmts_key_map(
|
|||||||
)))
|
)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Resolve the proactive [`AacsKeyMap`](crate::decrypt::AacsKeyMap) for a title
|
||||||
|
/// before muxing. It decides which held unit key decrypts each of the title's
|
||||||
|
/// LBA ranges and secures any key the pool is missing through the app's
|
||||||
|
/// configured source (`fetch`) up front, never reactively per unit at mux time.
|
||||||
|
///
|
||||||
|
/// This is what ends the key-server storm. The old mux decrypted a unit, checked
|
||||||
|
/// whether the plaintext looked like clean MPEG-TS, and — because authored-bad
|
||||||
|
/// content never reaches that bar — re-asked the key service for a key it already
|
||||||
|
/// held. There is no per-unit byte pattern that separates "correctly decrypted
|
||||||
|
/// but authored-bad" from "still encrypted", so that check is unanswerable. Here
|
||||||
|
/// we answer the answerable question instead: which CPS unit does each LBA range
|
||||||
|
/// belong to, decided by the disc's key structure (validated once against real
|
||||||
|
/// ciphertext samples, where the `is_clean` proof IS sound). The mux then just
|
||||||
|
/// decrypts each unit with its mapped key and trusts it.
|
||||||
|
///
|
||||||
|
/// Single-CPS (the overwhelming majority, incl. every single-key UHD) is the
|
||||||
|
/// trivial map: one key everywhere, no sampling. Multi-CPS assigns each extent to
|
||||||
|
/// the key that opens a real sample from it; a bad-content extent no sample can
|
||||||
|
/// classify inherits its predecessor's key (contiguity). FMTS segment mapping
|
||||||
|
/// layers onto the same structure.
|
||||||
pub fn resolve_mux_key_map(
|
pub fn resolve_mux_key_map(
|
||||||
reader: &mut dyn SectorSource,
|
reader: &mut dyn SectorSource,
|
||||||
title: &DiscTitle,
|
title: &DiscTitle,
|
||||||
@@ -980,42 +987,16 @@ pub fn resolve_mux_key_map(
|
|||||||
ALIGNED_UNIT_LEN, ALIGNED_UNIT_SECTORS, aacs_unit_encrypted, decrypt_unit, is_clean,
|
ALIGNED_UNIT_LEN, ALIGNED_UNIT_SECTORS, aacs_unit_encrypted, decrypt_unit, is_clean,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// The base Unit Key pool is always resolved and banked by the caller before mux
|
||||||
|
// (autorip's pre-rip gate; the ISO path's `decrypt_keys()`), so an AACS title
|
||||||
|
// reaches here with a non-empty pool — an empty pool is reported as
|
||||||
|
// `DecryptKeys::None` and takes the CSS/clear arm above. `pool_len` is therefore
|
||||||
|
// always >= 1 for the AACS map paths below.
|
||||||
let pool_len = match keys {
|
let pool_len = match keys {
|
||||||
crate::decrypt::DecryptKeys::Aacs { unit_keys, .. } => unit_keys.len(),
|
crate::decrypt::DecryptKeys::Aacs { unit_keys, .. } => unit_keys.len(),
|
||||||
// CSS / clear: no AACS map (the decorator's map path is AACS-only).
|
// CSS / clear: no AACS map (the decorator's map path is AACS-only).
|
||||||
_ => return Ok(crate::decrypt::AacsKeyMap::single(0)),
|
_ => return Ok(crate::decrypt::AacsKeyMap::single(0)),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Secure the disc's key up front from the configured source when the pool is
|
|
||||||
// empty (a genuine "no key yet" — e.g. keydb miss, online-only disc).
|
|
||||||
if pool_len == 0 {
|
|
||||||
if let Some(f) = fetch {
|
|
||||||
let samples = crate::keysource::read_encrypted_units(reader, title, 8);
|
|
||||||
if !samples.is_empty() {
|
|
||||||
let fresh = f.unit_keys(&samples);
|
|
||||||
if let crate::decrypt::DecryptKeys::Aacs { unit_keys, .. } = keys {
|
|
||||||
for k in fresh {
|
|
||||||
if !unit_keys.iter().any(|(_, h)| *h == k) {
|
|
||||||
let i = unit_keys.len() as u32;
|
|
||||||
unit_keys.push((i, k));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// If the pool is STILL empty, this AACS-encrypted title needs a Unit Key we
|
|
||||||
// could not obtain from any source. That is the same situation as any known
|
|
||||||
// key we don't hold — fail loud at resolve time rather than deferring an
|
|
||||||
// opaque decrypt error (or, worse, emitting ciphertext) at mux time.
|
|
||||||
let empty = matches!(
|
|
||||||
keys,
|
|
||||||
crate::decrypt::DecryptKeys::Aacs { unit_keys, .. } if unit_keys.is_empty()
|
|
||||||
);
|
|
||||||
if empty {
|
|
||||||
return Err(crate::error::Error::DecryptFailed.into());
|
|
||||||
}
|
|
||||||
return Ok(crate::decrypt::AacsKeyMap::single(0));
|
|
||||||
}
|
|
||||||
// FMTS (AACS 2.1): if the disc carries `IndividualSegment.tbl`, the forensic
|
// FMTS (AACS 2.1): if the disc carries `IndividualSegment.tbl`, the forensic
|
||||||
// segments need per-index keys the base Unit Key can't open. Resolve them up
|
// segments need per-index keys the base Unit Key can't open. Resolve them up
|
||||||
// front from the configured source and build a per-segment map. Returns `None`
|
// front from the configured source and build a per-segment map. Returns `None`
|
||||||
@@ -1095,10 +1076,18 @@ pub fn resolve_mux_key_map(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// A bad-content extent no sample can classify inherits its predecessor's
|
// `sample_units` draws REAL content (not authored-bad units), so a sample
|
||||||
// key (CPS boundaries are contiguous, so the neighbour is almost always
|
// no held or fetched key decrypts to clean means this extent's CPS-unit key
|
||||||
// right); this never storms and never mis-fails a decryptable disc.
|
// is genuinely absent. Building a map that silently assigns a WRONG key
|
||||||
let idx = idx.unwrap_or(last_idx);
|
// (the neighbour's) would corrupt the whole extent with lost_bytes==0 — so
|
||||||
|
// fail loud instead: the keymap is built ONLY when every extent with
|
||||||
|
// encrypted content is classified. An extent with no sampleable encrypted
|
||||||
|
// units (nothing to mis-decrypt) carries the previous index harmlessly.
|
||||||
|
let idx = match idx {
|
||||||
|
Some(i) => i,
|
||||||
|
None if samples.is_empty() => last_idx,
|
||||||
|
None => return Err(crate::error::Error::DecryptFailed.into()),
|
||||||
|
};
|
||||||
last_idx = idx;
|
last_idx = idx;
|
||||||
ranges.push((
|
ranges.push((
|
||||||
ext.start_lba,
|
ext.start_lba,
|
||||||
|
|||||||
+3
-3
@@ -5,9 +5,9 @@
|
|||||||
//! When packets are lost, the affected access unit is already dropped at the TS
|
//! When packets are lost, the affected access unit is already dropped at the TS
|
||||||
//! layer (the assembler drops the partial PES on the continuity gap). But for
|
//! layer (the assembler drops the partial PES on the continuity gap). But for
|
||||||
//! INTER-CODED video the frames that follow reference the lost frame (and each
|
//! INTER-CODED video the frames that follow reference the lost frame (and each
|
||||||
//! other) until the next IRAP/IDR keyframe — emitting them yields an ffmpeg
|
//! other) until the next IRAP/IDR keyframe — emitting them makes any decoder
|
||||||
//! "missing reference / non-existing PPS" deep-scan error and visibly broken
|
//! fault on the "missing reference / non-existing PPS" condition and visibly
|
||||||
//! decode. So after a gap on a video track we DROP FORWARD to the next keyframe
|
//! break decode. So after a gap on a video track we DROP FORWARD to the keyframe
|
||||||
//! and resume cleanly there. The gap rounds up to (at most) one GOP — the price
|
//! and resume cleanly there. The gap rounds up to (at most) one GOP — the price
|
||||||
//! of never emitting a dangling reference; it is logged.
|
//! of never emitting a dangling reference; it is logged.
|
||||||
//!
|
//!
|
||||||
|
|||||||
+2
-1
@@ -23,7 +23,8 @@ pub(crate) const DISCONTINUITY_GAP_NS: i64 = 1_000_000;
|
|||||||
/// one concatenated sector stream (clip boundaries / mpls connection_condition
|
/// one concatenated sector stream (clip boundaries / mpls connection_condition
|
||||||
/// are not plumbed to the mux), so at a non-seamless boundary the source PES
|
/// are not plumbed to the mux), so at a non-seamless boundary the source PES
|
||||||
/// PTS jumps backward. Left uncorrected, that produces a sustained band of
|
/// PTS jumps backward. Left uncorrected, that produces a sustained band of
|
||||||
/// non-monotonic block timestamps (ffmpeg then derives non-monotonic DTS).
|
/// non-monotonic block timestamps (a downstream muxer then derives
|
||||||
|
/// non-monotonic DTS from them).
|
||||||
///
|
///
|
||||||
/// A single running `offset_ns` is applied to EVERY track, so the concatenated
|
/// A single running `offset_ns` is applied to EVERY track, so the concatenated
|
||||||
/// clips form one monotonic timeline AND A/V sync is preserved (all tracks at a
|
/// clips form one monotonic timeline AND A/V sync is preserved (all tracks at a
|
||||||
|
|||||||
+3
-2
@@ -8,8 +8,9 @@
|
|||||||
//! when `hdr.timeout` expires, and by the time the ioctl returns the
|
//! when `hdr.timeout` expires, and by the time the ioctl returns the
|
||||||
//! kernel has already done what it can.
|
//! kernel has already done what it can.
|
||||||
//!
|
//!
|
||||||
//! This matches what every reference project does: MakeMKV (8 s sync
|
//! This matches established practice for optical/SCSI I/O: a single
|
||||||
//! ioctl), sg_dd (60 s sync ioctl), the kernel default for SCSI block
|
//! synchronous ioctl with a bounded per-command timeout (commonly in the
|
||||||
|
//! 8–60 s range), consistent with the Linux kernel default for SCSI block
|
||||||
//! devices (30 s `/sys/.../timeout`).
|
//! devices (30 s `/sys/.../timeout`).
|
||||||
//!
|
//!
|
||||||
//! Pre-0.13.20 we ran an async `write() + poll(1.5s) + close-on-timeout +
|
//! Pre-0.13.20 we ran an async `write() + poll(1.5s) + close-on-timeout +
|
||||||
|
|||||||
+17
-14
@@ -19,6 +19,11 @@ use std::sync::Arc;
|
|||||||
|
|
||||||
use super::SectorSource;
|
use super::SectorSource;
|
||||||
|
|
||||||
|
/// A closure resolving keys from encrypted-content samples — the shape of both
|
||||||
|
/// [`KeyFetch`] operations. Named so the two constructors (and the struct fields)
|
||||||
|
/// read clearly.
|
||||||
|
pub type KeyFetchFn = std::sync::Arc<dyn Fn(&[Vec<u8>]) -> Vec<[u8; 16]> + Send + Sync>;
|
||||||
|
|
||||||
/// Application-supplied "fetch a fresh key for THIS data" callback.
|
/// Application-supplied "fetch a fresh key for THIS data" callback.
|
||||||
///
|
///
|
||||||
/// Invoked by [`DecryptingSectorSource`] when a read contains scrambled AACS
|
/// Invoked by [`DecryptingSectorSource`] when a read contains scrambled AACS
|
||||||
@@ -49,11 +54,6 @@ use super::SectorSource;
|
|||||||
/// mutable state (its call-count cap and spent flag), so one `KeyFetch` is built
|
/// mutable state (its call-count cap and spent flag), so one `KeyFetch` is built
|
||||||
/// once and cloned cheaply (two `Arc` bumps) into every read path. `Send + Sync`
|
/// once and cloned cheaply (two `Arc` bumps) into every read path. `Send + Sync`
|
||||||
/// so it can ride the mux highway's producer thread.
|
/// so it can ride the mux highway's producer thread.
|
||||||
/// A closure resolving keys from encrypted-content samples — the shape of both
|
|
||||||
/// [`KeyFetch`] operations. Named so the two constructors (and the struct fields)
|
|
||||||
/// read clearly.
|
|
||||||
pub type KeyFetchFn = std::sync::Arc<dyn Fn(&[Vec<u8>]) -> Vec<[u8; 16]> + Send + Sync>;
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct KeyFetch {
|
pub struct KeyFetch {
|
||||||
unit: KeyFetchFn,
|
unit: KeyFetchFn,
|
||||||
@@ -187,7 +187,7 @@ impl<S: SectorSource> DecryptingSectorSource<S> {
|
|||||||
/// (sorted/merged `(start_lba, sector_count)` — see
|
/// (sorted/merged `(start_lba, sector_count)` — see
|
||||||
/// [`Disc::encrypted_content_ranges`](crate::Disc::encrypted_content_ranges)).
|
/// [`Disc::encrypted_content_ranges`](crate::Disc::encrypted_content_ranges)).
|
||||||
/// Units outside content (UDF filesystem / BDMV nav) pass through untouched,
|
/// Units outside content (UDF filesystem / BDMV nav) pass through untouched,
|
||||||
/// so [`ts_sync_destroyed`](crate::aacs::content::ts_sync_destroyed) is never consulted
|
/// so the TS-sync content check is never consulted
|
||||||
/// about non-content bytes. Whole-disc readers (sweep / patch) set this; the
|
/// about non-content bytes. Whole-disc readers (sweep / patch) set this; the
|
||||||
/// mux leaves it unset because it only ever reads title extents.
|
/// mux leaves it unset because it only ever reads title extents.
|
||||||
pub fn with_content_ranges(mut self, ranges: Arc<[(u32, u32)]>) -> Self {
|
pub fn with_content_ranges(mut self, ranges: Arc<[(u32, u32)]>) -> Self {
|
||||||
@@ -338,11 +338,11 @@ impl<S: SectorSource> SectorSource for DecryptingSectorSource<S> {
|
|||||||
// FRESH-KEY-ON-FAILURE: hand a unit no held key opened (as its on-disc
|
// FRESH-KEY-ON-FAILURE: hand a unit no held key opened (as its on-disc
|
||||||
// ciphertext) to the application's key source; any returned key is added to
|
// ciphertext) to the application's key source; any returned key is added to
|
||||||
// the pool and the read is re-decrypted, caching the key for later units.
|
// the pool and the read is re-decrypted, caching the key for later units.
|
||||||
// The pass-through result is the consumer's concern — the decorator never
|
// If the source is asked for this exact ciphertext and STILL cannot supply a
|
||||||
// counts a decrypt-quality miss as loss. Genuine missing data is the
|
// key (the recovery's residual `dropped > 0`), the unit is genuinely
|
||||||
// zero-filled sectors the physical read layer records, not a TS-structure
|
// unresolvable — this decrypting sweep/patch path FAILS LOUD rather than
|
||||||
// miss; a real can't-decrypt (empty pool / misalignment) already surfaced
|
// write the still-encrypted bytes into the output as if they were clear
|
||||||
// as `Err` from `decrypt_buf`.
|
// content (the mux path fails loud the same way via `decrypt_sectors_mapped`).
|
||||||
if dropped > 0 && self.recovery.is_some() {
|
if dropped > 0 && self.recovery.is_some() {
|
||||||
// Rare miss only: the in-place decrypt overwrote `buf`, so RE-READ the
|
// Rare miss only: the in-place decrypt overwrote `buf`, so RE-READ the
|
||||||
// on-disc ciphertext for the key-fetch retry. This keeps the happy path
|
// on-disc ciphertext for the key-fetch retry. This keeps the happy path
|
||||||
@@ -368,7 +368,10 @@ impl<S: SectorSource> SectorSource for DecryptingSectorSource<S> {
|
|||||||
.as_mut()
|
.as_mut()
|
||||||
.expect("recovery.is_some() checked above");
|
.expect("recovery.is_some() checked above");
|
||||||
let cipher = &self.cipher_scratch[..n];
|
let cipher = &self.cipher_scratch[..n];
|
||||||
let _ = r(&mut buf[..n], cipher, &mut self.keys, &rctx);
|
let outcome = r(&mut buf[..n], cipher, &mut self.keys, &rctx);
|
||||||
|
if outcome.dropped > 0 {
|
||||||
|
return Err(crate::error::Error::DecryptFailed);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Ok(n)
|
Ok(n)
|
||||||
}
|
}
|
||||||
@@ -701,8 +704,8 @@ mod tests {
|
|||||||
|
|
||||||
/// A source that yields exactly one CLEAR AACS aligned unit (6144
|
/// A source that yields exactly one CLEAR AACS aligned unit (6144
|
||||||
/// bytes = 3 sectors) with MPEG-TS sync bytes (0x47) at the BD-TS
|
/// bytes = 3 sectors) with MPEG-TS sync bytes (0x47) at the BD-TS
|
||||||
/// stride (offset 4, then every 192 bytes). `ts_sync_destroyed`
|
/// stride (offset 4, then every 192 bytes). `is_clean`
|
||||||
/// reports such a unit as NOT scrambled, so the AACS decrypt path
|
/// reports such a unit as clear (not scrambled), so the AACS decrypt path
|
||||||
/// reaches the per-unit closure and leaves it untouched — letting
|
/// reaches the per-unit closure and leaves it untouched — letting
|
||||||
/// us prove the unit-key LOOKUP (not the cipher) is what fails for
|
/// us prove the unit-key LOOKUP (not the cipher) is what fails for
|
||||||
/// an out-of-range index.
|
/// an out-of-range index.
|
||||||
|
|||||||
+4
-3
@@ -261,9 +261,10 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Call `set_unit_base` through a generic `S: SectorSource` bound — this is
|
/// Call `set_unit_base` through a generic `S: SectorSource` bound so the
|
||||||
/// the path that actually exercises the `Box<dyn>` / `&mut dyn` FORWARDING
|
/// `Box<dyn SectorSource>` / `&mut dyn SectorSource` FORWARDING impls are the
|
||||||
/// impls (a direct call on a `dyn` value dispatches via the vtable instead).
|
/// ones invoked (the generic monomorphizes to each forwarding body — the same
|
||||||
|
/// body a direct call on those receiver types also resolves to).
|
||||||
fn set_unit_base_generic<S: SectorSource>(mut s: S, base: u32) {
|
fn set_unit_base_generic<S: SectorSource>(mut s: S, base: u32) {
|
||||||
s.set_unit_base(base);
|
s.set_unit_base(base);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user