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:
@@ -36,6 +36,12 @@ rand = "0.8"
|
|||||||
cmac = "0.7"
|
cmac = "0.7"
|
||||||
zip = { version = "2", default-features = false, features = ["deflate"] }
|
zip = { version = "2", default-features = false, features = ["deflate"] }
|
||||||
base64 = "0.22.1"
|
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
|
# Trace-level instrumentation for Disc::copy + SgIoTransport::execute. Permitted
|
||||||
# under CLAUDE.md ("Acceptable strings: debug/trace logging"). Consumers (autorip)
|
# under CLAUDE.md ("Acceptable strings: debug/trace logging"). Consumers (autorip)
|
||||||
# wire a tracing subscriber and pipe events into the JSONL debug log.
|
# wire a tracing subscriber and pipe events into the JSONL debug log.
|
||||||
|
|||||||
+444
-18
@@ -9,26 +9,28 @@
|
|||||||
//! rule: a genuinely different format is a new enumerator, not an extension
|
//! rule: a genuinely different format is a new enumerator, not an extension
|
||||||
//! bolted into the BD path.
|
//! bolted into the BD path.
|
||||||
//!
|
//!
|
||||||
//! Title composition: the `HVA*.VTI` navigation file (the DVD-IFO analogue) names
|
//! Title composition — authoritative, from the Advanced-Content playlist. HD-DVD
|
||||||
//! every clip in authored order. Standard Content splits the main feature across
|
//! ships a real player playlist at `ADV_OBJ/VPLST000.XPL` (DVD-Forum
|
||||||
//! clips at the layer break (`FEATURE_1`/`FEATURE_2`, or `feature`/`feature_Divide`);
|
//! `HDDVDVideo/Playlist` XML). The scanner parses it (with a real XML parser,
|
||||||
//! the scanner parses the VTI clip table and concatenates those parts into ONE
|
//! `roxmltree`) into one [`DiscTitle`] per `<Title>`: its `<PrimaryAudioVideoClip>`
|
||||||
//! [`DiscTitle`] (so the largest-title pick is the whole movie, not just part 1),
|
//! clips in playback order (each an EVO, referenced via its `.MAP` sidecar), the
|
||||||
//! emitting every other clip as its own title. Container is
|
//! `titleDuration`, the `displayName`, and the `<ChapterList>`. A layer-break
|
||||||
//! [`ContentFormat::MpegPs`], so the existing PS mux path handles it. Per-clip
|
//! split (`FEATURE_1` + `FEATURE_2`, or `feature`/`feature_Divide`) is composed
|
||||||
//! streams ARE enumerated: the clip head is demuxed through the PS demuxer and one
|
//! into ONE title with the two parts as clips, carrying each clip's title-time
|
||||||
//! [`Stream`] is built per distinct elementary stream (video + DD+ audio
|
//! in/out points (45 kHz ticks) so a seamless join can be spliced onto one
|
||||||
//! sub-streams), with the codec sniffed from the ES bytes.
|
//! 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:
|
//! When no playlist is present (or it fails to parse), the scanner falls back to
|
||||||
//! * full VTI program-chain parsing (chapters, non-feature title grouping) —
|
//! the older clip-name heuristic: parse the `HVA*.VTI` clip table, join the
|
||||||
//! the feature join uses the clip table + the `feature*` naming convention,
|
//! `feature*`-named clips into one title, and emit every other clip on its own.
|
||||||
//! not the authoritative PGC,
|
|
||||||
//! * `.map` timemap → real durations,
|
|
||||||
//! * subtitles (8-bit RLC on `0xBD` sub `0x20..=0x3F`).
|
|
||||||
//!
|
//!
|
||||||
//! Extents and size ARE real (the ripper needs those to image a clip); durations
|
//! Not parsed yet: subtitles (8-bit RLC on `0xBD` sub `0x20..=0x3F`) and per-track
|
||||||
//! and chapters are left empty rather than guessed.
|
//! 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 super::*;
|
||||||
use crate::mux::ps::{PsDemuxer, dvd_audio_pid};
|
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 {
|
impl Disc {
|
||||||
/// Scan HD-DVD titles from the `HVDVD_TS/` `.evo` clips.
|
/// 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.
|
// Feature clips, in authored order, that actually resolved to extents.
|
||||||
let feature: Vec<String> = order
|
let feature: Vec<String> = order
|
||||||
.iter()
|
.iter()
|
||||||
@@ -966,4 +1203,193 @@ mod tests {
|
|||||||
"VC-1 routes to the extended-stream-id PID 0xFD55"
|
"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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user