mirror of
https://github.com/freemkv/libfreemkv.git
synced 2026-10-07 18:13:10 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e99cb95bd4 | ||
|
|
1794f5c406 | ||
|
|
9379507c15 | ||
|
|
8457efb5da | ||
|
|
dbc135bee0 | ||
|
|
48fb9b2cf6 | ||
|
|
013f27eede | ||
|
|
9f0e10a524 |
+11
-1
@@ -1,6 +1,16 @@
|
||||
# Changelog
|
||||
|
||||
## [1.7.2] — 2026-09-18
|
||||
## [1.7.3] — 2026-09-19
|
||||
|
||||
### Fixed
|
||||
|
||||
- MPLS title enumeration: the STN (stream number) table of the first PlayItem is now located past the multi-angle *angle block* instead of at the fixed offset 32. A multi-angle first PlayItem (e.g. a seamless-branch UHD title) carries `number_of_angles` extra 10-byte angle entries before the STN table, so the old fixed offset read the wrong bytes and misparsed the primary title's streams — the root cause behind autorip/CLI picking the wrong (or no) main feature on some UHD discs. Validated against a 92-disc real-media corpus with zero regressions (issue #45).
|
||||
|
||||
### Added
|
||||
|
||||
- `disc::read_structure_files()` — captures a disc's **structure metadata** (`BDMV/index.bdmv`, `MovieObject.bdmv`, `PLAYLIST/*.mpls`, `CLIPINF/*.clpi`, `BDJO/*.bdjo`, `META/DL/*.xml`, DVD `VIDEO_TS/*.IFO`) over any `SectorSource` (drive, ISO, `dir://`). No audio/video essence and no AACS keys are read, so the result is safe to attach to a bug report (a few hundred KB). Powers `freemkv info … --share` so a reporter can reproduce a title-selection issue without shipping the full ISO.
|
||||
|
||||
## [1.7.2] — UNRELEASED
|
||||
|
||||
### Fixed
|
||||
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "libfreemkv"
|
||||
version = "1.7.2"
|
||||
version = "1.7.3"
|
||||
edition = "2024"
|
||||
rust-version = "1.88"
|
||||
license = "MIT"
|
||||
@@ -59,7 +59,7 @@ aes = { version = "0.9", optional = true }
|
||||
# this to `{ git = ".../freemkv-unlock", tag = "vX.Y.Z" }` in the tagged commit
|
||||
# (so the released tag resolves freemkv-unlock from git, not a sibling path) and
|
||||
# restores the path dep on the branch tip.
|
||||
freemkv-unlock = { git = "https://github.com/freemkv/freemkv-unlock", tag = "v1.7.2", optional = true }
|
||||
freemkv-unlock = { git = "https://github.com/freemkv/freemkv-unlock", tag = "v1.7.3", optional = true }
|
||||
zip = { version = "8", default-features = false, features = ["deflate"], optional = true }
|
||||
base64 = { version = "0.23", optional = true }
|
||||
# Read-only XML DOM parser (pure Rust, forbid(unsafe_code), entity-expansion
|
||||
|
||||
+34
-4
@@ -217,10 +217,40 @@ authoring an entire synthetic disc image. Running it against an HD-DVD/DVD
|
||||
|
||||
This catches both the wrapper decoy (`00245 ⊋ 00001`) and the oversize
|
||||
play-all — independent of capacity, which the `fits-disc` gate needs and
|
||||
which is `0`/unknown on drives that fail READ CAPACITY. The corpus
|
||||
title-selection gate is the backstop across the disc hoard for the known
|
||||
residual (a real feature with a separate "resume from the middle" branch
|
||||
playlist reusing most of its clips).
|
||||
which is `0`/unknown on drives that fail READ CAPACITY.
|
||||
|
||||
### Wrapper-shape gate (issue #45)
|
||||
|
||||
A proper-subset relation with `Q.size ≥ 0.5·P.size` is NOT by itself proof
|
||||
that `P` is a wrapper. A genuine **seamless-branch feature** `P` (body clip
|
||||
plus intro/credits/alternate-branch segments) is a *superset* of its
|
||||
single-clip body decoy `Q`: `Q` runs almost as long as `P` (e.g. 1h50 vs
|
||||
2h03, ~0.89 of the duration) yet is markedly smaller, because `P`'s branch
|
||||
segments add bytes without adding much played runtime. The old size-only
|
||||
gate mis-flagged such a `P` as a "play-all wrapper" and demoted the real
|
||||
2h03 feature below the shorter 1h50 body — a SAFETY bug (wrong title ripped).
|
||||
|
||||
So the composite flag additionally requires `P` to *have* a wrapper shape,
|
||||
via one of two capacity-independent, per-title (`duration_secs`, `size_bytes`)
|
||||
signals on the subset `Q`:
|
||||
|
||||
- **concat / play-all:** `Q.duration < 0.85·P.duration` — `Q` is a much
|
||||
shorter cut and `P` pads its runtime with other segments (the true
|
||||
play-all runs ~the SUM of its parts, so each part is well under half).
|
||||
- **bumper / outro wrapper:** `Q.size ≥ 0.90·P.size` — `Q` already holds
|
||||
almost all of `P`'s bytes and `P` merely prepends/appends a short
|
||||
non-feature lead/tail (the `00245 ⊋ 00001` decoy: `Q` is ~0.96 of both the
|
||||
duration and the size, so the size limb keeps it demoted).
|
||||
|
||||
Otherwise — `Q` runs almost as long as `P` (≥0.85) yet is well under 0.90 of
|
||||
its size — `P` is the fuller branch set and is kept. `0.85` sits above a real
|
||||
concat's per-part ratio (~0.5) and below a seamless body's (~0.89); `0.90`
|
||||
sits above a bumper wrapper's near-1.0 size share and below a branch body's.
|
||||
|
||||
**Known residual:** a wrapper that pads runtime only modestly AND withholds
|
||||
most of its bytes from the body clip is ambiguous by shape alone. The corpus
|
||||
title-selection gate is the backstop across the disc hoard, and
|
||||
higher-precedence nav/authoring signals override the composite key.
|
||||
|
||||
## aligned_unit_keys_validate
|
||||
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
// Corpus title-shape dump for issue #45 fallback tuning.
|
||||
//
|
||||
// Runs the REAL offline scan (Disc::scan_image → sort_titles_by_main_feature)
|
||||
// over a directory of decrypted ISOs and emits one NDJSON line per disc:
|
||||
// every title's duration/size/clip-shape plus the rank flags the selector
|
||||
// computed (nav/authoring/composite) and which title it picked (titles[0]).
|
||||
//
|
||||
// The point: measure, across many real discs, how cleanly any candidate
|
||||
// discriminator separates the true main feature from play-all wrappers and
|
||||
// seamless-branch decoys — so the #45 fallback is derived from data, not a
|
||||
// guessed 0.85/0.90 constant.
|
||||
//
|
||||
// Usage: corpus_titledump <iso_dir> [<iso_dir>...] > corpus.ndjson
|
||||
|
||||
use libfreemkv::disc::{Disc, ScanOptions};
|
||||
use libfreemkv::io::file_sector_source::FileSectorSource;
|
||||
use libfreemkv::sector::SectorSource;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
// scan_image wants the disc capacity in sectors; FileSectorSource knows it.
|
||||
|
||||
fn jstr(s: &str) -> String {
|
||||
let mut o = String::with_capacity(s.len() + 2);
|
||||
o.push('"');
|
||||
for c in s.chars() {
|
||||
match c {
|
||||
'"' => o.push_str("\\\""),
|
||||
'\\' => o.push_str("\\\\"),
|
||||
'\n' => o.push_str("\\n"),
|
||||
'\r' => o.push_str("\\r"),
|
||||
'\t' => o.push_str("\\t"),
|
||||
c if (c as u32) < 0x20 => o.push_str(&format!("\\u{:04x}", c as u32)),
|
||||
c => o.push(c),
|
||||
}
|
||||
}
|
||||
o.push('"');
|
||||
o
|
||||
}
|
||||
|
||||
fn dump_one(path: &Path) -> String {
|
||||
let name = path.file_name().and_then(|s| s.to_str()).unwrap_or("?");
|
||||
let mut reader = match FileSectorSource::open(path) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
return format!(
|
||||
"{{\"iso\":{},\"error\":\"open: {:?}\"}}",
|
||||
jstr(name),
|
||||
format!("{e}")
|
||||
);
|
||||
}
|
||||
};
|
||||
let cap = reader.capacity_sectors();
|
||||
let opts = ScanOptions::default();
|
||||
let disc = match Disc::scan_image(&mut reader, cap, &opts) {
|
||||
Ok(d) => d,
|
||||
Err(e) => {
|
||||
return format!(
|
||||
"{{\"iso\":{},\"error\":\"scan: {}\"}}",
|
||||
jstr(name),
|
||||
jstr(&format!("{e}"))
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Recompute the rank flags the selector used so we can see WHY it ordered
|
||||
// things the way it did. nav/authoring hint aren't available offline here
|
||||
// (scan_with already applied them into titles[0]); pass None/None so the
|
||||
// flags reflect the pure composite/video/size classification, which is the
|
||||
// fallback we are tuning.
|
||||
let ranks = Disc::rank_titles(&disc.titles, None, None);
|
||||
|
||||
// pick = titles[0] (post-sort main feature). Also compute the naive
|
||||
// "longest video title" as a ground-truth proxy for single-movie discs.
|
||||
let pick_pl = disc.titles.first().map(|t| t.playlist_id).unwrap_or(0);
|
||||
let longest_video_pl = disc
|
||||
.titles
|
||||
.iter()
|
||||
.filter(|t| t.has_probable_video())
|
||||
.max_by(|a, b| a.duration_secs.partial_cmp(&b.duration_secs).unwrap())
|
||||
.map(|t| t.playlist_id);
|
||||
|
||||
let mut titles_json = String::from("[");
|
||||
for (i, t) in disc.titles.iter().enumerate() {
|
||||
if i > 0 {
|
||||
titles_json.push(',');
|
||||
}
|
||||
let r = &ranks[i];
|
||||
// Sum of the FULL durations of this title's distinct clips (each clip
|
||||
// counted once at its clip-level duration). A play-all concat wrapper's
|
||||
// title duration ≈ this sum; a seamless-branch feature's is dominated by
|
||||
// one clip. This is the structural discriminator to evaluate.
|
||||
let mut seen = std::collections::BTreeMap::<&str, f64>::new();
|
||||
for c in &t.clips {
|
||||
seen.entry(c.clip_id.as_str()).or_insert(c.duration_secs);
|
||||
}
|
||||
let sum_distinct_clip_dur: f64 = seen.values().sum();
|
||||
let max_distinct_clip_dur: f64 = seen.values().cloned().fold(0.0, f64::max);
|
||||
let n_distinct_clips = seen.len();
|
||||
let clip_ids: String = {
|
||||
let mut s = String::from("[");
|
||||
for (k, id) in seen.keys().enumerate() {
|
||||
if k > 0 {
|
||||
s.push(',');
|
||||
}
|
||||
s.push_str(&jstr(id));
|
||||
}
|
||||
s.push(']');
|
||||
s
|
||||
};
|
||||
titles_json.push_str(&format!(
|
||||
"{{\"pl\":{},\"dur\":{:.1},\"size\":{},\"clips\":{},\"distinct_clips\":{},\
|
||||
\"sum_distinct_clip_dur\":{:.1},\"max_distinct_clip_dur\":{:.1},\
|
||||
\"has_video\":{},\"has_probable_video\":{},\
|
||||
\"nav\":{},\"authoring\":{},\"composite\":{},\"clip_ids\":{}}}",
|
||||
t.playlist_id,
|
||||
t.duration_secs,
|
||||
t.size_bytes,
|
||||
t.clips.len(),
|
||||
n_distinct_clips,
|
||||
sum_distinct_clip_dur,
|
||||
max_distinct_clip_dur,
|
||||
t.has_video(),
|
||||
t.has_probable_video(),
|
||||
r.nav,
|
||||
r.authoring,
|
||||
r.composite,
|
||||
clip_ids,
|
||||
));
|
||||
}
|
||||
titles_json.push(']');
|
||||
|
||||
format!(
|
||||
"{{\"iso\":{},\"format\":\"{:?}\",\"cap\":{},\"n_titles\":{},\
|
||||
\"pick_pl\":{},\"longest_video_pl\":{},\"pick_is_longest_video\":{},\"titles\":{}}}",
|
||||
jstr(name),
|
||||
disc.format,
|
||||
cap,
|
||||
disc.titles.len(),
|
||||
pick_pl,
|
||||
longest_video_pl
|
||||
.map(|p| p.to_string())
|
||||
.unwrap_or_else(|| "null".into()),
|
||||
longest_video_pl.map(|p| p == pick_pl).unwrap_or(false),
|
||||
titles_json,
|
||||
)
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let dirs: Vec<PathBuf> = std::env::args().skip(1).map(PathBuf::from).collect();
|
||||
if dirs.is_empty() {
|
||||
eprintln!("usage: corpus_titledump <iso_dir> [<iso_dir>...]");
|
||||
std::process::exit(2);
|
||||
}
|
||||
let mut isos: Vec<PathBuf> = Vec::new();
|
||||
for d in &dirs {
|
||||
if d.is_file() {
|
||||
isos.push(d.clone());
|
||||
continue;
|
||||
}
|
||||
if let Ok(rd) = std::fs::read_dir(d) {
|
||||
for e in rd.flatten() {
|
||||
let p = e.path();
|
||||
if p.extension()
|
||||
.and_then(|s| s.to_str())
|
||||
.map(|s| s.eq_ignore_ascii_case("iso"))
|
||||
.unwrap_or(false)
|
||||
{
|
||||
isos.push(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
isos.sort();
|
||||
eprintln!("scanning {} ISOs", isos.len());
|
||||
for (i, p) in isos.iter().enumerate() {
|
||||
eprintln!("[{}/{}] {}", i + 1, isos.len(), p.display());
|
||||
println!("{}", dump_one(p));
|
||||
}
|
||||
}
|
||||
+136
-5
@@ -1903,6 +1903,61 @@ impl Disc {
|
||||
Self::read_aacs_inputs_from_reader(&mut reader, &udf_fs)
|
||||
}
|
||||
|
||||
/// Read a disc's **structure metadata** for diagnostics — `(relative_path,
|
||||
/// bytes)` for each present index file that drives title enumeration and
|
||||
/// main-feature selection: `BDMV/index.bdmv`, `MovieObject.bdmv`,
|
||||
/// `PLAYLIST/*.mpls`, `CLIPINF/*.clpi`, `BDJO/*.bdjo`, `META/DL/*.xml`, DVD
|
||||
/// `VIDEO_TS/*.IFO`. No audio/video essence and no AACS keys are read, so the
|
||||
/// result is safe to share in a bug report (a few hundred KB); missing files
|
||||
/// are skipped and empty = no readable structure. Powers `freemkv info …
|
||||
/// --share` over any [`SectorSource`] (drive, ISO, `dir://`) — issue #45.
|
||||
pub fn read_structure_files(reader: &mut dyn SectorSource) -> Result<Vec<(String, Vec<u8>)>> {
|
||||
let udf_fs = udf::read_filesystem(reader)?;
|
||||
let mut out: Vec<(String, Vec<u8>)> = Vec::new();
|
||||
|
||||
// Read one named file into `out` under `rel`, skipping if absent.
|
||||
let mut grab = |udf_fs: &udf::UdfFs, reader: &mut dyn SectorSource, rel: &str| {
|
||||
if let Ok(bytes) = udf_fs.read_file(reader, &format!("/{rel}")) {
|
||||
out.push((rel.to_string(), bytes));
|
||||
}
|
||||
};
|
||||
|
||||
// Top-level BD nav files.
|
||||
grab(&udf_fs, reader, "BDMV/index.bdmv");
|
||||
grab(&udf_fs, reader, "BDMV/MovieObject.bdmv");
|
||||
|
||||
// List a directory's files by extension first (immutable borrow of
|
||||
// udf_fs), then read them (needs &mut reader) — so the directory borrow
|
||||
// is released before the reads. Sorted for deterministic bundles.
|
||||
let list = |udf_fs: &udf::UdfFs, dir: &str, ext: &str| -> Vec<String> {
|
||||
let mut names: Vec<String> = match udf_fs.find_dir(&format!("/{dir}")) {
|
||||
Some(d) => d
|
||||
.entries
|
||||
.iter()
|
||||
.filter(|e| !e.is_dir && e.name.to_ascii_lowercase().ends_with(ext))
|
||||
.map(|e| e.name.clone())
|
||||
.collect(),
|
||||
None => Vec::new(),
|
||||
};
|
||||
names.sort();
|
||||
names
|
||||
};
|
||||
|
||||
for (dir, ext) in [
|
||||
("BDMV/PLAYLIST", ".mpls"),
|
||||
("BDMV/CLIPINF", ".clpi"),
|
||||
("BDMV/BDJO", ".bdjo"),
|
||||
("BDMV/META/DL", ".xml"),
|
||||
("VIDEO_TS", ".ifo"),
|
||||
] {
|
||||
for name in list(&udf_fs, dir, ext) {
|
||||
grab(&udf_fs, reader, &format!("{dir}/{name}"));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Same as [`Disc::read_aacs_inputs`] but reads from a live drive. The
|
||||
/// out-of-band Unit Key path fetches the disc's key files from the drive,
|
||||
/// resolves a key from them however it likes, then applies it via
|
||||
@@ -2142,11 +2197,11 @@ impl Disc {
|
||||
/// Precompute the [`TitleRank`] for every title, in the same index
|
||||
/// order. Composite detection: title `P` is a composite when some OTHER
|
||||
/// title `Q` has a non-empty clip-id set that is a PROPER SUBSET of `P`'s,
|
||||
/// `Q` has plausible video, and `Q` accounts for at least half of `P`'s
|
||||
/// declared size — independent of capacity, which may be `0`/unknown.
|
||||
/// Known residual: a "resume from the middle" branch playlist could be
|
||||
/// mis-flagged; rare, and higher-precedence nav/authoring signals
|
||||
/// override it. See docs/disc-mod.md for detail.
|
||||
/// `Q` has plausible video, `Q` accounts for at least half of `P`'s
|
||||
/// declared size, AND `P` has a WRAPPER shape (issue #45): either `Q` is a
|
||||
/// much shorter cut or `Q` is nearly `P`'s size-equal. Capacity-independent
|
||||
/// (`capacity` may be `0`/unknown). See docs/disc-mod.md for the shape
|
||||
/// gate, its thresholds, and the known residual.
|
||||
pub fn rank_titles(
|
||||
titles: &[DiscTitle],
|
||||
hint: Option<&crate::labels::FeaturePlaylistHint>,
|
||||
@@ -2200,6 +2255,11 @@ impl Disc {
|
||||
&& qj.len() < pi.len()
|
||||
&& q.has_probable_video()
|
||||
&& q.size_bytes as f64 >= 0.5 * p.size_bytes as f64
|
||||
// Seamless-branch guard (issue #45): wrapper only
|
||||
// if Q is a much SHORTER cut (concat) or nearly P's
|
||||
// SIZE-equal (bumper). See docs/disc-mod.md.
|
||||
&& (q.duration_secs < 0.85 * p.duration_secs
|
||||
|| q.size_bytes as f64 >= 0.90 * p.size_bytes as f64)
|
||||
&& qj.is_subset(pi)
|
||||
}
|
||||
});
|
||||
@@ -4552,6 +4612,77 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Issue #45 (SAFETY): a genuine seamless-branch FEATURE (00800, 4 clips,
|
||||
/// 2h03) is a SUPERSET of its single-clip body decoy (00700, 1h50, ≥50 %
|
||||
/// size). The old size-only composite gate mis-demoted 00800 as a "play-all
|
||||
/// wrapper" and ripped the shorter decoy. The duration-shape guard keeps
|
||||
/// 00800: its body subset runs almost as long (1h50 vs 2h03, ~0.89) yet is
|
||||
/// markedly smaller (0.55) — the hallmark of seamless branching, not
|
||||
/// concatenation.
|
||||
#[test]
|
||||
fn seamless_branch_feature_not_demoted_issue_45() {
|
||||
// 00800: real feature = body + intro/credits/seamless branch segments.
|
||||
let feature = bd_title(
|
||||
"00800.mpls",
|
||||
800,
|
||||
7380.0, // 2h03
|
||||
60_000_000_000,
|
||||
&["00800", "00801", "00802", "00803"],
|
||||
);
|
||||
// 00700: single body clip, a PROPER subset, 1h50 (~89 % of 00800's
|
||||
// duration) and 55 % of its size — enough to trip the old size gate.
|
||||
let decoy = bd_title("00700.mpls", 700, 6600.0, 33_000_000_000, &["00800"]);
|
||||
let capacity = 88_000_000_000u64;
|
||||
|
||||
// The body decoy is a proper subset with ≥50 % size — the OLD gate
|
||||
// flagged 00800 composite on that alone. It must no longer be flagged.
|
||||
let ranks = Disc::rank_titles(&[feature.clone(), decoy.clone()], None, None);
|
||||
assert!(
|
||||
!ranks[0].composite,
|
||||
"the seamless-branch feature must not be flagged a wrapper composite"
|
||||
);
|
||||
assert!(!ranks[1].composite, "the shorter body subset is standalone");
|
||||
|
||||
let mut titles = vec![decoy, feature];
|
||||
Disc::sort_titles_by_main_feature(&mut titles, capacity, None, None);
|
||||
assert_eq!(
|
||||
titles[0].playlist_id, 800,
|
||||
"the full seamless-branch feature is selected, not the shorter body decoy"
|
||||
);
|
||||
}
|
||||
|
||||
/// The seamless-branch guard must NOT weaken the real play-all case: a
|
||||
/// concat wrapper (00099, four distinct parts, ~4h13 = their SUM) whose body
|
||||
/// subset is much SHORTER (00800, one part, ~2h02, <half the runtime) is
|
||||
/// still flagged composite and demoted below that standalone part.
|
||||
#[test]
|
||||
fn true_play_all_concat_wrapper_still_demoted() {
|
||||
let playall = bd_title(
|
||||
"00099.mpls",
|
||||
99,
|
||||
15_180.0, // ~4h13 — the SUM of its four parts
|
||||
92_400_000_000,
|
||||
&["00800", "00801", "00802", "00803"],
|
||||
);
|
||||
// The real feature = one part, ~2h02, less than half the wrapper's runtime.
|
||||
let feature = bd_title("00800.mpls", 800, 7320.0, 57_200_000_000, &["00800"]);
|
||||
let capacity = 100_000_000_000u64;
|
||||
|
||||
let ranks = Disc::rank_titles(&[playall.clone(), feature.clone()], None, None);
|
||||
assert!(
|
||||
ranks[0].composite,
|
||||
"a concat play-all whose subset is much shorter is still a composite"
|
||||
);
|
||||
assert!(!ranks[1].composite, "the standalone feature part is not");
|
||||
|
||||
let mut titles = vec![playall, feature];
|
||||
Disc::sort_titles_by_main_feature(&mut titles, capacity, None, None);
|
||||
assert_eq!(
|
||||
titles[0].playlist_id, 800,
|
||||
"the standalone feature wins; the concat play-all is demoted"
|
||||
);
|
||||
}
|
||||
|
||||
/// The disc's own authoring-designated feature wins even when it is NOT the
|
||||
/// longest/largest title — a size-independent signal a decoy cannot forge.
|
||||
#[test]
|
||||
|
||||
+101
-16
@@ -5,6 +5,21 @@
|
||||
//! stream info (video, audio, subtitle tracks).
|
||||
//!
|
||||
//! Format is documented in the BD-ROM PlayList (MPLS) specification.
|
||||
//!
|
||||
//! ## Multi-angle PlayItem STN offset (issue #45)
|
||||
//! The STN (stream-number) table sits at PlayItem offset 32 for a single-angle
|
||||
//! item, but a MULTI-ANGLE item (is_multi_angle = bit 4 of `item[10]`) inserts an
|
||||
//! angle block right after still_time at offset 32:
|
||||
//! number_of_angles(1) + flags(1) + (number_of_angles − 1) angle references,
|
||||
//! each clip_name(5) + clip_codec_id(4) + ref_to_STC_id(1) = 10 bytes
|
||||
//! (the first angle is the primary clip already at `item[0..5]`). So the real STN
|
||||
//! begins at `32 + 2 + (number_of_angles − 1) * 10`. A fixed offset of 32 lands
|
||||
//! INSIDE that block and misreads the stream counts as garbage (e.g. n_video =
|
||||
//! 51 on Spider-Man 3 UHD 00245/00246.mpls, number_of_angles = 2 ⇒ real offset
|
||||
//! 44), dropping every stream — the primary video included. That makes
|
||||
//! `DiscTitle::has_video()` return false, which drops a multi-angle/seamless-
|
||||
//! branch FEATURE from the disc's nav candidate set (see `Disc::scan_with`) and
|
||||
//! loses it in main-title selection. `parse` computes the offset accordingly.
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
|
||||
@@ -150,25 +165,31 @@ pub fn parse(data: &[u8]) -> Result<Playlist> {
|
||||
let in_time = u32::from_be_bytes([item[12], item[13], item[14], item[15]]);
|
||||
let out_time = u32::from_be_bytes([item[16], item[17], item[18], item[19]]);
|
||||
|
||||
// Parse STN table from the first play item. PlayItem layout after out_time:
|
||||
// UO_mask_table(8) + misc flags(1) + still_mode(1) + still_time(2), then
|
||||
// STN_table starting at STN_OFFSET.
|
||||
const STN_OFFSET: usize = 32;
|
||||
// `>=`, not `>`: the 16-byte STN header spans item[STN_OFFSET..STN_OFFSET+16], so
|
||||
// item.len() == STN_OFFSET + 16 already holds every header byte. Strict `>` demanded
|
||||
// STN table offset: base 32, shifted past the multi-angle angle block when
|
||||
// present (issue #45 — see the module `//!` header for the layout and why a
|
||||
// fixed 32 misparses). is_multi_angle is bit 4 of item[10].
|
||||
let is_multi_angle = (item[10] & 0x10) != 0;
|
||||
let stn_offset = if is_multi_angle && item.len() > 32 {
|
||||
let number_of_angles = item[32] as usize;
|
||||
32 + 2 + number_of_angles.saturating_sub(1) * 10
|
||||
} else {
|
||||
32
|
||||
};
|
||||
// `>=`, not `>`: the 16-byte STN header spans item[stn_offset..stn_offset+16], so
|
||||
// item.len() == stn_offset + 16 already holds every header byte. Strict `>` demanded
|
||||
// one extra byte, skipping a play item whose STN header ends exactly at the boundary (stream-less STN table).
|
||||
if item_idx == 0 && item.len() >= STN_OFFSET + 16 {
|
||||
if item_idx == 0 && item.len() >= stn_offset + 16 {
|
||||
// STN header: length(2) + reserved(2) + counts(8) + reserved(4) = 16 bytes
|
||||
let n_video = item[STN_OFFSET + 4] as usize;
|
||||
let n_audio = item[STN_OFFSET + 5] as usize;
|
||||
let n_pg = item[STN_OFFSET + 6] as usize;
|
||||
let n_ig = item[STN_OFFSET + 7] as usize;
|
||||
let n_sec_audio = item[STN_OFFSET + 8] as usize;
|
||||
let n_sec_video = item[STN_OFFSET + 9] as usize;
|
||||
let n_pip_pg = item[STN_OFFSET + 10] as usize;
|
||||
let n_dv = item[STN_OFFSET + 11] as usize;
|
||||
let n_video = item[stn_offset + 4] as usize;
|
||||
let n_audio = item[stn_offset + 5] as usize;
|
||||
let n_pg = item[stn_offset + 6] as usize;
|
||||
let n_ig = item[stn_offset + 7] as usize;
|
||||
let n_sec_audio = item[stn_offset + 8] as usize;
|
||||
let n_sec_video = item[stn_offset + 9] as usize;
|
||||
let n_pip_pg = item[stn_offset + 10] as usize;
|
||||
let n_dv = item[stn_offset + 11] as usize;
|
||||
|
||||
let mut spos = STN_OFFSET + 16;
|
||||
let mut spos = stn_offset + 16;
|
||||
|
||||
// Primary video
|
||||
for _ in 0..n_video {
|
||||
@@ -753,6 +774,70 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// Regression for issue #45 (see module `//!` header): a multi-angle first
|
||||
// PlayItem puts its STN past the angle block (2 angles ⇒ offset 44); a fixed
|
||||
// offset of 32 misreads it and drops the video. Assert the video is found.
|
||||
#[test]
|
||||
fn multi_angle_first_play_item_stn_is_located_after_the_angle_block() {
|
||||
let in_time = 0u32;
|
||||
let out_time = 9_000_000u32;
|
||||
let number_of_angles = 2u8;
|
||||
|
||||
let mut item = Vec::new();
|
||||
item.extend_from_slice(b"00001"); // [0..5] clip_id (angle 1 = primary)
|
||||
item.extend_from_slice(b"M2TS"); // [5..9] codec_id
|
||||
item.push(0); // [9] reserved
|
||||
item.push(0x10); // [10] is_multi_angle (bit 4), connection_condition = 0
|
||||
item.push(0); // [11] stc_id
|
||||
item.extend_from_slice(&in_time.to_be_bytes()); // [12..16]
|
||||
item.extend_from_slice(&out_time.to_be_bytes()); // [16..20]
|
||||
item.extend_from_slice(&[0u8; 8]); // [20..28] UO mask
|
||||
item.push(0); // [28] misc flags
|
||||
item.push(0); // [29] still_mode
|
||||
item.extend_from_slice(&[0u8; 2]); // [30..32] still_time
|
||||
// Angle block: number_of_angles(1) + flags(1) + (angles-1) refs * 10 bytes.
|
||||
item.push(number_of_angles); // [32]
|
||||
item.push(0); // [33] is_different_audios / is_seamless_angle_change
|
||||
item.extend_from_slice(b"00002"); // angle 2 clip_name(5)
|
||||
item.extend_from_slice(b"M2TS"); // angle 2 codec_id(4)
|
||||
item.push(0); // angle 2 ref_to_STC_id(1) -> STN now begins at offset 44
|
||||
// STN table: length(2) + reserved(2) + counts(8) + reserved(4) + entries.
|
||||
let stn_start = item.len();
|
||||
item.extend_from_slice(&[0u8; 2]); // STN length placeholder
|
||||
item.extend_from_slice(&[0u8; 2]); // reserved
|
||||
item.extend_from_slice(&[1, 0, 0, 0, 0, 0, 0, 0]); // n_video=1, rest 0
|
||||
item.extend_from_slice(&[0u8; 4]); // reserved
|
||||
item.extend_from_slice(&build_stream_entry_video(0x1011, 0x24, 8, 1, Some(0x12)));
|
||||
let stn_len = (item.len() - stn_start - 2) as u16;
|
||||
item[stn_start..stn_start + 2].copy_from_slice(&stn_len.to_be_bytes());
|
||||
|
||||
// Wrap in the file + PlayList headers.
|
||||
let mut buf = Vec::new();
|
||||
buf.extend_from_slice(b"MPLS0200");
|
||||
buf.extend_from_slice(&40u32.to_be_bytes()); // playlist_start
|
||||
buf.extend_from_slice(&[0u8; 28]); // mark_start(0) + padding to 40
|
||||
let pl_start = buf.len();
|
||||
buf.extend_from_slice(&[0u8; 4]); // PlayList length placeholder
|
||||
buf.extend_from_slice(&[0u8; 2]); // reserved
|
||||
buf.extend_from_slice(&1u16.to_be_bytes()); // num_play_items
|
||||
buf.extend_from_slice(&[0u8; 2]); // num_sub_paths
|
||||
buf.extend_from_slice(&(item.len() as u16).to_be_bytes());
|
||||
buf.extend_from_slice(&item);
|
||||
let pl_len = (buf.len() - pl_start - 4) as u32;
|
||||
buf[pl_start..pl_start + 4].copy_from_slice(&pl_len.to_be_bytes());
|
||||
|
||||
let playlist = parse(&buf).expect("multi-angle MPLS must parse");
|
||||
assert_eq!(playlist.play_items.len(), 1);
|
||||
assert_eq!(
|
||||
playlist.streams.len(),
|
||||
1,
|
||||
"the primary video must be found past the angle block, not misread as garbage"
|
||||
);
|
||||
assert_eq!(playlist.streams[0].stream_type, 1, "it is a video stream");
|
||||
assert_eq!(playlist.streams[0].pid, 0x1011);
|
||||
assert_eq!(playlist.streams[0].coding_type, 0x24); // HEVC
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_invalid_magic() {
|
||||
let mut data = build_mpls(&[(b"00001", 1, 0, 9000000)], (0, 0, 0, 0, 0, 0, 0, 0), &[]);
|
||||
|
||||
Reference in New Issue
Block a user