hddvd: authoritative title composition from the Advanced-Content playlist

Parse ADV_OBJ/VPLST000.XPL (the real HD-DVD player playlist) with roxmltree
into one DiscTitle per <Title>: its PrimaryAudioVideoClip clips in order (EVO
via the .MAP sidecar), the titleDuration, the displayName, and the ChapterList.
A layer-break split (FEATURE_1+FEATURE_2, feature/feature_Divide) composes into
ONE title with the two parts as clips, each carrying its title-time in/out
points (45kHz ticks) for seamless-join splicing. Falls back to the clip-name
heuristic when no playlist is present.

Fixes unknown runtimes (real durations), poor names (FEATURE -> 'Main Movie'),
and gives authoritative composition + chapters. Validated on ANCHORMAN (97m),
SHAUN (99m), HARRY_POTTER (152m).
This commit is contained in:
Matthew Jackson
2026-07-10 10:28:18 -07:00
parent a7df91b92b
commit 24e2bc33cf
2 changed files with 450 additions and 18 deletions
+6
View File
@@ -36,6 +36,12 @@ rand = "0.8"
cmac = "0.7"
zip = { version = "2", default-features = false, features = ["deflate"] }
base64 = "0.22.1"
# Read-only XML DOM parser (pure Rust, forbid(unsafe_code), entity-expansion
# bounded). Parses the HD-DVD Advanced-Content playlist `ADV_OBJ/VPLST000.XPL`
# — untrusted disc bytes — into authoritative titles/clips/chapters. A real
# parser, not a hand-rolled scanner: the XPL is genuine XML (comments, varied
# attribute order, self-closing tags).
roxmltree = "0.20"
# Trace-level instrumentation for Disc::copy + SgIoTransport::execute. Permitted
# under CLAUDE.md ("Acceptable strings: debug/trace logging"). Consumers (autorip)
# wire a tracing subscriber and pipe events into the JSONL debug log.
+444 -18
View File
@@ -9,26 +9,28 @@
//! rule: a genuinely different format is a new enumerator, not an extension
//! bolted into the BD path.
//!
//! Title composition: the `HVA*.VTI` navigation file (the DVD-IFO analogue) names
//! every clip in authored order. Standard Content splits the main feature across
//! clips at the layer break (`FEATURE_1`/`FEATURE_2`, or `feature`/`feature_Divide`);
//! the scanner parses the VTI clip table and concatenates those parts into ONE
//! [`DiscTitle`] (so the largest-title pick is the whole movie, not just part 1),
//! emitting every other clip as its own title. Container is
//! [`ContentFormat::MpegPs`], so the existing PS mux path handles it. Per-clip
//! streams ARE enumerated: the clip head is demuxed through the PS demuxer and one
//! [`Stream`] is built per distinct elementary stream (video + DD+ audio
//! sub-streams), with the codec sniffed from the ES bytes.
//! Title composition — authoritative, from the Advanced-Content playlist. HD-DVD
//! ships a real player playlist at `ADV_OBJ/VPLST000.XPL` (DVD-Forum
//! `HDDVDVideo/Playlist` XML). The scanner parses it (with a real XML parser,
//! `roxmltree`) into one [`DiscTitle`] per `<Title>`: its `<PrimaryAudioVideoClip>`
//! clips in playback order (each an EVO, referenced via its `.MAP` sidecar), the
//! `titleDuration`, the `displayName`, and the `<ChapterList>`. A layer-break
//! split (`FEATURE_1` + `FEATURE_2`, or `feature`/`feature_Divide`) is composed
//! into ONE title with the two parts as clips, carrying each clip's title-time
//! in/out points (45 kHz ticks) so a seamless join can be spliced onto one
//! timeline. Container is [`ContentFormat::MpegPs`], so the existing PS mux path
//! handles it. Per-clip streams are enumerated by demuxing the clip head and
//! building one [`Stream`] per distinct elementary stream (video + DD+ audio),
//! codec sniffed from the ES bytes.
//!
//! What is NOT parsed yet — and is honestly stubbed, not faked:
//! * full VTI program-chain parsing (chapters, non-feature title grouping) —
//! the feature join uses the clip table + the `feature*` naming convention,
//! not the authoritative PGC,
//! * `.map` timemap → real durations,
//! * subtitles (8-bit RLC on `0xBD` sub `0x20..=0x3F`).
//! When no playlist is present (or it fails to parse), the scanner falls back to
//! the older clip-name heuristic: parse the `HVA*.VTI` clip table, join the
//! `feature*`-named clips into one title, and emit every other clip on its own.
//!
//! Extents and size ARE real (the ripper needs those to image a clip); durations
//! and chapters are left empty rather than guessed.
//! Not parsed yet: subtitles (8-bit RLC on `0xBD` sub `0x20..=0x3F`) and per-track
//! audio languages (the XPL carries `<Audio description=...>` but they are not yet
//! wired onto the streams). Extents and size are real (the ripper images the
//! clips).
use super::*;
use crate::mux::ps::{PsDemuxer, dvd_audio_pid};
@@ -316,6 +318,229 @@ fn collect_es(
}
}
// ─────────────────────── Advanced-Content playlist (XPL) ──────────────────
//
// HD-DVD Advanced Content ships an authoritative playlist at
// `ADV_OBJ/VPLST000.XPL` (DVD-Forum `HDDVDVideo/Playlist` XML). It is the real
// player playlist: a `<TitleSet>` of `<Title>`s, each naming its
// `<PrimaryAudioVideoClip>` clips (an EVO, via its `.MAP` sidecar) in playback
// order with title-time in/out points, a `titleDuration`, a `displayName`, and a
// `<ChapterList>`. Parsing it gives authoritative title composition — clips,
// duration, name, chapters — instead of the `feature*` clip-name heuristic, plus
// the per-clip title-time offsets needed to splice a layer-break split
// (`FEATURE_1` + `FEATURE_2`) onto one continuous timeline. Parsed with a real
// XML parser (`roxmltree`), not a hand-rolled scanner — the XPL is genuine XML.
/// One clip reference inside an XPL `<Title>`: the resolved `.evo` name (lower
/// case) and the clip's placement on the title timeline, in seconds.
struct XplClip {
evo: String,
begin_secs: f64,
end_secs: f64,
}
/// One `<Title>` from the XPL: number, display name, total duration, its clips
/// in playback order, and chapter start times (seconds).
struct XplTitle {
number: u16,
name: String,
duration_secs: f64,
clips: Vec<XplClip>,
chapters: Vec<f64>,
}
/// Parse an `HH:MM:SS:FF` (or `MM:SS:FF`) timecode at `tick_base` frames/sec into
/// seconds. `None` on a malformed field.
fn parse_timecode(s: &str, tick_base: u32) -> Option<f64> {
let n: Vec<u32> = s
.split(':')
.map(|p| p.trim().parse::<u32>())
.collect::<std::result::Result<Vec<u32>, _>>()
.ok()?;
let tb = tick_base.max(1) as f64;
let (h, m, sec, f) = match n.as_slice() {
[h, m, s, f] => (*h, *m, *s, *f),
[m, s, f] => (0, *m, *s, *f),
_ => return None,
};
Some(h as f64 * 3600.0 + m as f64 * 60.0 + sec as f64 + f as f64 / tb)
}
/// `tickBase="60fps"` → 60. Defaults to 60 when absent/unparseable.
fn parse_tick_base(s: &str) -> u32 {
let digits: String = s.chars().take_while(|c| c.is_ascii_digit()).collect();
digits.parse().unwrap_or(60)
}
/// `<PrimaryAudioVideoClip src="file:///.../FEATURE_1.MAP">` → `feature_1.evo`:
/// take the basename, drop the extension, normalise to a lower-case `.evo` name
/// (the playlist references the `.MAP` sidecar; the A/V is the same-stem `.EVO`).
fn evo_from_src(src: &str) -> Option<String> {
let base = src.rsplit(['/', '\\']).next().unwrap_or(src);
let stem = base.rsplit_once('.').map(|(s, _)| s).unwrap_or(base);
if stem.is_empty() {
return None;
}
Some(format!("{}.evo", stem.to_ascii_lowercase()))
}
/// Parse the Advanced-Content playlist into its titles. Elements are matched by
/// LOCAL name (the document is in the `HDDVDVideo/Playlist` default namespace).
/// Returns empty for a non-XML / non-playlist blob so the caller falls back to
/// the clip-name heuristic.
fn parse_xpl_titles(xpl: &[u8]) -> Vec<XplTitle> {
let text = String::from_utf8_lossy(xpl);
let Ok(doc) = roxmltree::Document::parse(&text) else {
return Vec::new();
};
let local = |n: &roxmltree::Node, name: &str| n.tag_name().name() == name;
// tickBase lives on <TitleSet> (default 60fps).
let tick_base = doc
.descendants()
.find(|n| local(n, "TitleSet"))
.and_then(|n| n.attribute("tickBase"))
.map(parse_tick_base)
.unwrap_or(60);
let mut titles = Vec::new();
for tnode in doc.descendants().filter(|n| local(n, "Title")) {
let number = tnode
.attribute("titleNumber")
.and_then(|s| s.trim().parse::<u16>().ok())
.unwrap_or(0);
let name = tnode
.attribute("displayName")
.or_else(|| tnode.attribute("id"))
.unwrap_or("")
.to_string();
let duration_secs = tnode
.attribute("titleDuration")
.and_then(|s| parse_timecode(s, tick_base))
.unwrap_or(0.0);
let mut clips = Vec::new();
for c in tnode
.descendants()
.filter(|n| local(n, "PrimaryAudioVideoClip"))
{
let Some(evo) = c.attribute("src").and_then(evo_from_src) else {
continue;
};
let begin_secs = c
.attribute("titleTimeBegin")
.and_then(|s| parse_timecode(s, tick_base))
.unwrap_or(0.0);
let end_secs = c
.attribute("titleTimeEnd")
.and_then(|s| parse_timecode(s, tick_base))
.unwrap_or(begin_secs);
clips.push(XplClip {
evo,
begin_secs,
end_secs,
});
}
if clips.is_empty() {
continue;
}
let chapters = tnode
.descendants()
.filter(|n| local(n, "Chapter"))
.filter_map(|ch| {
ch.attribute("titleTimeBegin")
.and_then(|s| parse_timecode(s, tick_base))
})
.collect();
titles.push(XplTitle {
number,
name,
duration_secs,
clips,
chapters,
});
}
titles
}
/// Read the Advanced-Content playlist `ADV_OBJ/VPLST*.XPL`, if present.
fn read_adv_obj_xpl(reader: &mut dyn SectorSource, udf_fs: &udf::UdfFs) -> Option<Vec<u8>> {
let dir = udf_fs.find_dir("/ADV_OBJ")?;
let name = dir.entries.iter().find_map(|e| {
let lower = e.name.to_ascii_lowercase();
(!e.is_dir && lower.starts_with("vplst") && lower.ends_with(".xpl")).then(|| e.name.clone())
})?;
udf_fs.read_file(reader, &format!("/ADV_OBJ/{name}")).ok()
}
/// Compose [`DiscTitle`]s from parsed XPL titles: resolve each title's clips to
/// physical extents, concatenate them in playback order, carry the title-time
/// in/out points onto each [`Clip`] (45 kHz ticks — the offset that splices a
/// layer-break split onto one timeline), and attach the duration, name, and
/// chapters. A title whose clips resolve to no on-disc extents is skipped.
fn compose_xpl_titles(
reader: &mut dyn SectorSource,
xpl_titles: &[XplTitle],
clip_extents: &BTreeMap<String, (String, u64, Vec<Extent>)>,
) -> Vec<DiscTitle> {
let mut titles = Vec::new();
for t in xpl_titles {
let mut extents = Vec::new();
let mut size_bytes = 0u64;
let mut parts = Vec::new();
for c in &t.clips {
let Some((orig, size, exts)) = clip_extents.get(&c.evo) else {
continue;
};
extents.extend_from_slice(exts);
size_bytes += *size;
parts.push(Clip {
clip_id: orig
.rsplit_once('.')
.map(|(b, _)| b)
.unwrap_or(orig)
.to_string(),
in_time: (c.begin_secs * 45000.0).clamp(0.0, u32::MAX as f64) as u32,
out_time: (c.end_secs * 45000.0).clamp(0.0, u32::MAX as f64) as u32,
duration_secs: (c.end_secs - c.begin_secs).max(0.0),
source_packets: 0,
});
}
if parts.is_empty() {
continue;
}
let streams = probe_evo_streams(reader, &extents);
let chapters = t
.chapters
.iter()
.enumerate()
.map(|(i, &ts)| Chapter {
time_secs: ts.max(0.0),
name: super::chapter_name(i),
})
.collect();
titles.push(DiscTitle {
playlist: if t.name.is_empty() {
format!("Title {}", t.number)
} else {
t.name.clone()
},
playlist_id: t.number,
duration_secs: t.duration_secs,
size_bytes,
clips: parts,
streams,
chapters,
extents,
content_format: ContentFormat::MpegPs,
codec_privates: Vec::new(),
});
}
titles
}
impl Disc {
/// Scan HD-DVD titles from the `HVDVD_TS/` `.evo` clips.
///
@@ -375,6 +600,18 @@ impl Disc {
}
}
// Authoritative composition from the Advanced-Content playlist
// (`ADV_OBJ/VPLST*.XPL`): real per-title clip lists, durations, names,
// chapters, and title-time offsets. This is the primary path; the
// clip-name heuristic below is the fallback when the playlist is absent
// or unparseable (or resolves to no on-disc clips).
if let Some(xpl) = read_adv_obj_xpl(reader, udf_fs) {
let composed = compose_xpl_titles(reader, &parse_xpl_titles(&xpl), &clip_extents);
if !composed.is_empty() {
return composed;
}
}
// Feature clips, in authored order, that actually resolved to extents.
let feature: Vec<String> = order
.iter()
@@ -966,4 +1203,193 @@ mod tests {
"VC-1 routes to the extended-stream-id PID 0xFD55"
);
}
// ─────────────────────── Advanced-Content playlist (XPL) ──────────────
/// A minimal but faithful VPLST000.XPL: the DVD-Forum default namespace, an
/// XML comment, a `<TitleSet tickBase="60fps">`, a MainMovie title whose main
/// feature is a two-clip layer-break split (FEATURE_1 + FEATURE_2, seamless)
/// with two chapters, and a separate deleted-scene title. Attribute order
/// varies (as it does across real discs).
const SYNTH_XPL: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
<Playlist majorVersion="1" minorVersion="0" xmlns="http://www.dvdforum.org/2005/HDDVDVideo/Playlist">
<!-- Authored with TOSHIBA AdvMain -->
<TitleSet timeBase="60fps" tickBase="60fps" defaultLanguage="en">
<Title titleNumber="2" titleDuration="01:37:20:00" id="MainMovie" displayName="Main Movie">
<PrimaryAudioVideoClip titleTimeBegin="00:00:00:00" titleTimeEnd="00:48:29:50" src="file:///dvddisc/HVDVD_TS/FEATURE_1.MAP" dataSource="Disc">
<Video track="1" mediaAttr="2"/>
<Audio track="1" streamNumber="1" description="English DD+"/>
</PrimaryAudioVideoClip>
<PrimaryAudioVideoClip titleTimeBegin="00:48:29:50" clipTimeBegin="00:00:00:00" titleTimeEnd="01:37:20:00" src="file:///dvddisc/HVDVD_TS/FEATURE_2.MAP" seamless="true">
<Video track="1" mediaAttr="2"/>
</PrimaryAudioVideoClip>
<ChapterList>
<Chapter displayName="Chapter 1" titleTimeBegin="00:00:00:00" />
<Chapter displayName="Chapter 2" titleTimeBegin="00:03:40:30" />
</ChapterList>
</Title>
<Title titleNumber="7" titleDuration="00:00:44:29" id="Deleted5" displayName="Deleted Scenes - Veronica Past">
<PrimaryAudioVideoClip titleTimeBegin="00:00:00:00" titleTimeEnd="00:00:44:29" src="file:///dvddisc/HVDVD_TS/DEL5_VERONICAPAST.MAP" />
</Title>
</TitleSet>
</Playlist>"#;
#[test]
fn parse_timecode_hhmmssff_at_60fps() {
// 01:37:20:00 = 5840 s exactly.
assert!((parse_timecode("01:37:20:00", 60).unwrap() - 5840.0).abs() < 1e-6);
// 00:48:29:50 = 48m29s + 50/60 frames.
let want = 48.0 * 60.0 + 29.0 + 50.0 / 60.0;
assert!((parse_timecode("00:48:29:50", 60).unwrap() - want).abs() < 1e-6);
// MM:SS:FF short form (hours omitted).
assert!((parse_timecode("02:05:15", 60).unwrap() - (125.0 + 15.0 / 60.0)).abs() < 1e-6);
assert_eq!(parse_timecode("garbage", 60), None);
assert_eq!(parse_timecode("", 60), None);
}
#[test]
fn evo_from_src_maps_map_sidecar_to_evo() {
assert_eq!(
evo_from_src("file:///dvddisc/HVDVD_TS/FEATURE_1.MAP").as_deref(),
Some("feature_1.evo")
);
// Already an EVO, or lowercase feature — normalise to lower `.evo`.
assert_eq!(evo_from_src("feature.EVO").as_deref(), Some("feature.evo"));
assert_eq!(
evo_from_src("file:///x/feature_Divide.MAP").as_deref(),
Some("feature_divide.evo")
);
// Empty stem → None (defensive against a malformed src).
assert_eq!(evo_from_src("file:///x/").as_deref(), None);
}
#[test]
fn parse_xpl_titles_reads_titles_clips_chapters_durations() {
let titles = parse_xpl_titles(SYNTH_XPL.as_bytes());
assert_eq!(titles.len(), 2, "MainMovie + one deleted-scene title");
let mm = &titles[0];
assert_eq!(mm.number, 2);
assert_eq!(mm.name, "Main Movie");
assert!(
(mm.duration_secs - 5840.0).abs() < 1e-6,
"97:20 from titleDuration"
);
// The layer-break split is ONE title with TWO clips, contiguous timeline.
assert_eq!(mm.clips.len(), 2);
assert_eq!(mm.clips[0].evo, "feature_1.evo");
assert_eq!(mm.clips[1].evo, "feature_2.evo");
assert!(
(mm.clips[0].end_secs - mm.clips[1].begin_secs).abs() < 1e-6,
"FEATURE_2 begins exactly where FEATURE_1 ends (seamless join)"
);
assert!(
mm.clips[1].begin_secs > 0.0,
"second clip carries a title-time offset"
);
assert_eq!(mm.chapters.len(), 2);
assert!((mm.chapters[1] - (3.0 * 60.0 + 40.0 + 30.0 / 60.0)).abs() < 1e-6);
let del = &titles[1];
assert_eq!(del.name, "Deleted Scenes - Veronica Past");
assert_eq!(del.clips.len(), 1);
assert_eq!(del.clips[0].evo, "del5_veronicapast.evo");
}
#[test]
fn parse_xpl_titles_returns_empty_on_non_xml() {
assert!(parse_xpl_titles(b"not xml at all").is_empty());
assert!(parse_xpl_titles(&[0xFF, 0x00, 0x01, 0x02]).is_empty());
assert!(parse_xpl_titles(b"<Playlist></Playlist>").is_empty());
}
/// Build a UDF with `HVDVD_TS/` `.evo` clips plus an `ADV_OBJ/VPLST000.XPL`
/// carrying `xpl`, so `scan_hddvd_titles` takes the playlist path.
fn make_hddvd_fs_xpl(
disc: &mut MemDisc,
evos: &[(&str, u32, u32)],
xpl: &[u8],
) -> crate::udf::UdfFs {
let mut hv_files = Vec::new();
let mut icb = 100u32;
for (name, sectors, data_lba) in evos {
hv_files.push(file(name, icb, *data_lba, sectors * 2048, true));
icb += 1;
}
let root = DirSpec {
name: String::new(),
icb_lba: 10,
dir_data_lba: 11,
files: Vec::new(),
subdirs: vec![
DirSpec {
name: "HVDVD_TS".to_string(),
icb_lba: 20,
dir_data_lba: 21,
files: hv_files,
subdirs: vec![],
},
DirSpec {
name: "ADV_OBJ".to_string(),
icb_lba: 30,
dir_data_lba: 31,
files: vec![file_with("VPLST000.XPL", 40, 4000, xpl.to_vec(), true)],
subdirs: vec![],
},
],
};
build_udf_skeleton(disc, 10);
lay_dir(disc, &root);
crate::udf::read_filesystem(disc).expect("fs")
}
/// The authoritative path: when a VPLST000.XPL is present, titles come from
/// the playlist — the layer-break split (FEATURE_1 + FEATURE_2) is composed
/// into ONE title with the real duration, name, chapters, and per-clip
/// title-time offsets — not the clip-name heuristic.
#[test]
fn scan_hddvd_composes_titles_from_xpl_playlist() {
let mut disc = MemDisc::new();
let udf = make_hddvd_fs_xpl(
&mut disc,
&[
("FEATURE_1.EVO", 2000, 5000),
("FEATURE_2.EVO", 1800, 9000),
("DEL5_VERONICAPAST.EVO", 100, 12000),
],
SYNTH_XPL.as_bytes(),
);
let titles = Disc::scan_hddvd_titles(&mut disc, &udf);
assert_eq!(
titles.len(),
2,
"the two playlist titles that resolve to clips"
);
let mm = titles
.iter()
.find(|t| t.playlist == "Main Movie")
.expect("MainMovie composed from the playlist");
assert_eq!(mm.playlist_id, 2);
assert!(
(mm.duration_secs - 5840.0).abs() < 1.0,
"97:20 duration from titleDuration, not 0/unknown"
);
assert_eq!(
mm.clips.len(),
2,
"layer-break split kept as ONE title, two clips"
);
// FEATURE_2's clip carries the 48:29 offset (45 kHz ticks) — the datum
// that splices it onto FEATURE_1's timeline instead of restarting at 0.
assert!(
mm.clips[1].in_time > 100_000_000,
"FEATURE_2 offset onto the title timeline (48:29 * 45000), got {}",
mm.clips[1].in_time
);
assert_eq!(mm.chapters.len(), 2);
assert_eq!(mm.chapters[0].name, "1", "bare ordinal chapter name");
// Both feature halves are in ONE title's extents.
assert!(!mm.extents.is_empty());
}
}