Number vendor label streams by STN slot, not by parsed entry
Sweep of every parser in src/labels/ for the numbering bug fixed in
pixelogic: a blob lists one entry per STN slot, but the parser advances
its per-kind counter only for entries it can use, so every entry it
skips shifts all later labels onto the wrong stream.
Three parsers were affected; the rest key each label off a number the
blob states outright and are immune.
* paramount — `aud` / `sub` are the STN-ordered stream lists, and
`forced_sub` / `*_com1_idx` index those same cells. A cell with an
empty language was skipped without consuming its slot, so every
label behind it bound one stream early while the vendor's own
positional indices still pointed at the raw cell. `stream_number`
is now the cell's 1-based position. The forced flag is the payload
here, so the shift lands `forced` on a full-dialogue track.
* mpls_universal — its counters must agree with the stream list
`disc::bluray` builds from the same STN entries, since that list is
what `apply_labels` counts against. They disagreed twice: a
`coding_type == 0` padding entry was counted here and dropped
there, and a PG coding_type in an audio STN slot (a layout
`mpls::parse_stream_entry` has a dedicated arm for) was counted as
audio here and built as a subtitle there. Both rules now live in
one `label_type_for`.
* deluxe — a binding construction whose Language `getstatic` did not
resolve was skipped outright. It is still an STN slot; it just has
nothing to label. It now advances the counter, with the list it
belongs to taken from its CodingType argument or, failing that,
from what its binding type's resolved siblings showed.
Two paramount tests asserted the renumbering as if it were the spec
(`empty_middle_slot_does_not_inflate_stream_number`,
`audio_stream_numbering_skips_empty_slots`) and are rewritten. Immunity
pins added for ctrm, dbp and criterion so the property cannot rot.
Also scrubs two commercial disc titles from the menu-graphic filename
examples in png_filenames and vocab.
This commit is contained in:
@@ -263,6 +263,41 @@ mod tests {
|
|||||||
assert_eq!(nums, vec![1, 2, 1]);
|
assert_eq!(nums, vec![1, 2, 1]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Immunity pin. `parse_stream_infos` emits one `StreamInfo` per
|
||||||
|
/// `*StreamInfos` element unconditionally — no filter, no `continue` — so
|
||||||
|
/// an element whose fields are missing or unrecognized still occupies its
|
||||||
|
/// position, and `assign_stream_numbers` still spends a number on it.
|
||||||
|
///
|
||||||
|
/// That is the property that keeps this parser out of the failure mode
|
||||||
|
/// where a skipped entry pulls every later label one stream forward. It
|
||||||
|
/// is load-bearing for the fallback path specifically: with no
|
||||||
|
/// `playbackconfig.xml` the numbers come purely from position in this
|
||||||
|
/// list, so dropping an element there would shift the rest.
|
||||||
|
///
|
||||||
|
/// Mutation: skip elements with an empty `ID`/`LangInfoID` → the two
|
||||||
|
/// real audio streams renumber to 1 and 2.
|
||||||
|
#[test]
|
||||||
|
fn unusable_stream_element_still_occupies_its_position() {
|
||||||
|
let sp = r#"
|
||||||
|
<AudioStreamInfos><ID>a0</ID><LangInfoID>ENG_US</LangInfoID></AudioStreamInfos>
|
||||||
|
<AudioStreamInfos></AudioStreamInfos>
|
||||||
|
<AudioStreamInfos><ID>a2</ID><LangInfoID>FRA</LangInfoID><Content>COMMENTARY</Content></AudioStreamInfos>
|
||||||
|
<SubtitleStreamInfos><ID>s0</ID><LangInfoID></LangInfoID><Qualifier>WAT</Qualifier></SubtitleStreamInfos>
|
||||||
|
<SubtitleStreamInfos><ID>s1</ID><LangInfoID>ENG</LangInfoID><Qualifier>SDH</Qualifier></SubtitleStreamInfos>
|
||||||
|
"#;
|
||||||
|
let infos = parse_stream_infos(sp);
|
||||||
|
assert_eq!(infos.len(), 5, "every element yields a StreamInfo");
|
||||||
|
let nums =
|
||||||
|
assign_stream_numbers(&infos, &HashMap::new()).expect("numbering space not exhausted");
|
||||||
|
assert_eq!(
|
||||||
|
nums,
|
||||||
|
vec![1, 2, 3, 1, 2],
|
||||||
|
"the blank element owns audio slot 2, so the commentary is slot 3"
|
||||||
|
);
|
||||||
|
assert_eq!(infos[2].purpose, LabelPurpose::Commentary);
|
||||||
|
assert_eq!(infos[4].qualifier, LabelQualifier::Sdh);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn fallback_does_not_collide_with_partial_map() {
|
fn fallback_does_not_collide_with_partial_map() {
|
||||||
// Map claims audio "a1" -> 1. The unmapped audio "a0" must NOT
|
// Map claims audio "a1" -> 1. The unmapped audio "a0" must NOT
|
||||||
|
|||||||
@@ -557,6 +557,72 @@ mod tests {
|
|||||||
assert!(labels.is_empty());
|
assert!(labels.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Immunity pin. `language_streams.txt` states each stream's number in
|
||||||
|
/// field 3, so a row the parser cannot use is simply dropped — it can
|
||||||
|
/// never renumber the rows behind it. This is the property that keeps
|
||||||
|
/// this parser out of the STN-slot-shifting failure mode that bites
|
||||||
|
/// parsers which count positionally: there, a skipped entry silently
|
||||||
|
/// pulls every later label one stream forward.
|
||||||
|
///
|
||||||
|
/// Mutation: replace `parts[2]` with a running per-type counter → the
|
||||||
|
/// three unusable rows here collapse the survivors onto 1/2 and 1.
|
||||||
|
#[test]
|
||||||
|
fn ls_stream_numbers_come_from_the_row_not_a_counter() {
|
||||||
|
let labels = parse_language_streams_text(
|
||||||
|
"id,audio_production,4,eng\n\
|
||||||
|
id,audio_bonus_extended,5,eng\n\
|
||||||
|
id,audio_production,0,fra\n\
|
||||||
|
id,audio_production,7,fra\n\
|
||||||
|
id,subtitle_production\n\
|
||||||
|
id,subtitle_narrative,9,deu\n",
|
||||||
|
);
|
||||||
|
let nums: Vec<(StreamLabelType, u16)> = labels
|
||||||
|
.iter()
|
||||||
|
.map(|l| (l.stream_type, l.stream_number))
|
||||||
|
.collect();
|
||||||
|
assert_eq!(
|
||||||
|
nums,
|
||||||
|
vec![
|
||||||
|
(StreamLabelType::Audio, 4),
|
||||||
|
(StreamLabelType::Audio, 7),
|
||||||
|
(StreamLabelType::Subtitle, 9),
|
||||||
|
],
|
||||||
|
"an unusable row drops out without shifting the numbering"
|
||||||
|
);
|
||||||
|
assert_eq!(labels[2].qualifier, LabelQualifier::Forced);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Immunity pin, `menu_base.prop` side: the number comes from the
|
||||||
|
/// entry's own `streamNumber` property, so a skipped entry (commented
|
||||||
|
/// out, `streamNumber=0`, neither audio nor subtitle) leaves the
|
||||||
|
/// surviving entries on their authored slots.
|
||||||
|
///
|
||||||
|
/// Mutation: number by iteration order → the survivors collapse to 1/2.
|
||||||
|
#[test]
|
||||||
|
fn menu_base_stream_numbers_come_from_the_entry_not_a_counter() {
|
||||||
|
let labels = parse_props(
|
||||||
|
"#audio_0.class=AudioButton\n\
|
||||||
|
#audio_0.streamNumber=1\n\
|
||||||
|
audio_1.class=AudioButton\n\
|
||||||
|
audio_1.streamNumber=0\n\
|
||||||
|
audio_2.class=AudioButton\n\
|
||||||
|
audio_2.streamNumber=6\n\
|
||||||
|
other_1.class=SomeOtherButton\n\
|
||||||
|
other_1.streamNumber=2\n\
|
||||||
|
subtitle_1.class=SubtitleButton\n\
|
||||||
|
subtitle_1.streamNumber=11\n",
|
||||||
|
);
|
||||||
|
let nums: Vec<(StreamLabelType, u16)> = labels
|
||||||
|
.iter()
|
||||||
|
.map(|l| (l.stream_type, l.stream_number))
|
||||||
|
.collect();
|
||||||
|
assert_eq!(
|
||||||
|
nums,
|
||||||
|
vec![(StreamLabelType::Audio, 6), (StreamLabelType::Subtitle, 11),],
|
||||||
|
"skipped entries must not renumber the ones that survive"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Spec: `eda` variant → `Descriptive` purpose.
|
/// Spec: `eda` variant → `Descriptive` purpose.
|
||||||
/// Mutation: miss the `eda` branch → purpose stays Normal.
|
/// Mutation: miss the `eda` branch → purpose stays Normal.
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -272,6 +272,44 @@ mod tests {
|
|||||||
assert_eq!(sub.qualifier, LabelQualifier::Sdh);
|
assert_eq!(sub.qualifier, LabelQualifier::Sdh);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Immunity pin. Every dbp label states its own slot in the `AudioN` /
|
||||||
|
/// `SubtitleN` token, so the numbering survives gaps and skipped entries
|
||||||
|
/// intact. Nothing here counts positionally, which is what keeps this
|
||||||
|
/// parser out of the failure mode where a skipped entry pulls every later
|
||||||
|
/// label one stream forward.
|
||||||
|
///
|
||||||
|
/// Mutation: number by iteration order → `Audio4` becomes 2 and
|
||||||
|
/// `Subtitle3` becomes 1, silently rebinding both to other streams.
|
||||||
|
#[test]
|
||||||
|
fn stream_numbers_come_from_the_token_not_iteration_order() {
|
||||||
|
let class_bytes = build_class(&[
|
||||||
|
"LTextField,Audio1,English Dolby Atmos,Fontstrip_Composite,296,763",
|
||||||
|
// Slots 2 and 3 have no menu TextField authored.
|
||||||
|
"LTextField,Audio4,French 5.1 Dolby Digital,Fontstrip_Composite,296,803",
|
||||||
|
// Not a stream: the disable-subtitles button.
|
||||||
|
"ATextField,Subtitle0,None,Fontstrip_Composite,1312,843",
|
||||||
|
// Unparseable slot token — dropped, and must shift nothing.
|
||||||
|
"HTextField,SubtitleX,German,Fontstrip_Composite,1312,883",
|
||||||
|
"HTextField,Subtitle3,English SDH,Fontstrip_Composite,1312,763",
|
||||||
|
]);
|
||||||
|
let mut archive = build_jar(&[("com/dbp/Menu.class", class_bytes)]);
|
||||||
|
|
||||||
|
let labels = scan_jar(&mut archive);
|
||||||
|
let nums: Vec<(StreamLabelType, u16)> = labels
|
||||||
|
.iter()
|
||||||
|
.map(|l| (l.stream_type, l.stream_number))
|
||||||
|
.collect();
|
||||||
|
assert_eq!(
|
||||||
|
nums,
|
||||||
|
vec![
|
||||||
|
(StreamLabelType::Audio, 1),
|
||||||
|
(StreamLabelType::Audio, 4),
|
||||||
|
(StreamLabelType::Subtitle, 3),
|
||||||
|
],
|
||||||
|
"unlabelled and unusable slots leave the authored numbers alone"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// A `CONSTANT_Utf8_info` carries a `u16` length (JVMS §4.4.7), so one
|
/// A `CONSTANT_Utf8_info` carries a `u16` length (JVMS §4.4.7), so one
|
||||||
/// crafted constant contributes up to 65535 bytes and the `u16` stream
|
/// crafted constant contributes up to 65535 bytes and the `u16` stream
|
||||||
/// keyspace admits 65536 slots per type — ~4 GiB of retained `String` per
|
/// keyspace admits 65536 slots per type — ~4 GiB of retained `String` per
|
||||||
|
|||||||
+169
-2
@@ -951,12 +951,16 @@ impl MasterEnumTable {
|
|||||||
/// on Deluxe don't carry a CodingType; their codec is implicit
|
/// on Deluxe don't carry a CodingType; their codec is implicit
|
||||||
/// PGS via the BD spec).
|
/// PGS via the BD spec).
|
||||||
/// - Construction has Language but no CodingType → subtitle stream.
|
/// - Construction has Language but no CodingType → subtitle stream.
|
||||||
/// - No Language → not a stream (skip).
|
/// - Neither, and its binding type never yielded a stream → not a
|
||||||
|
/// stream (skip). See [`slot_kind`] for why the binding type is
|
||||||
|
/// consulted rather than the language alone.
|
||||||
fn interpret_streams(constructions: &[Construction], master: &MasterEnumTable) -> Vec<StreamLabel> {
|
fn interpret_streams(constructions: &[Construction], master: &MasterEnumTable) -> Vec<StreamLabel> {
|
||||||
let mut audio_idx: u16 = 0;
|
let mut audio_idx: u16 = 0;
|
||||||
let mut sub_idx: u16 = 0;
|
let mut sub_idx: u16 = 0;
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
|
|
||||||
|
let slot_kinds = slot_kinds(constructions);
|
||||||
|
|
||||||
for c in constructions {
|
for c in constructions {
|
||||||
let mut lang_ord: Option<u16> = None;
|
let mut lang_ord: Option<u16> = None;
|
||||||
let mut purpose_ord: Option<u16> = None;
|
let mut purpose_ord: Option<u16> = None;
|
||||||
@@ -979,7 +983,26 @@ fn interpret_streams(constructions: &[Construction], master: &MasterEnumTable) -
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let Some(lang_ord) = lang_ord else { continue };
|
let Some(lang_ord) = lang_ord else {
|
||||||
|
// No language resolved. If the construction is still recognisably
|
||||||
|
// a stream binding it OCCUPIES its STN slot and must advance the
|
||||||
|
// counter — there is just nothing to label. Numbering only the
|
||||||
|
// slots that resolve renumbers the rest 1..N and lands every
|
||||||
|
// surviving label on the wrong stream.
|
||||||
|
//
|
||||||
|
// `saturating_add` is safe here where it would not be on the
|
||||||
|
// emitting path below: no label is produced, so parking the
|
||||||
|
// counter at `u16::MAX` binds nothing. The next slot that DOES
|
||||||
|
// resolve hits the `checked_add` guard and stops emission.
|
||||||
|
match slot_kind(c, coding_type.is_some(), &slot_kinds) {
|
||||||
|
Some(StreamLabelType::Audio) => audio_idx = audio_idx.saturating_add(1),
|
||||||
|
Some(StreamLabelType::Subtitle) => sub_idx = sub_idx.saturating_add(1),
|
||||||
|
// Not a stream binding (`new StringBuilder` and friends in the
|
||||||
|
// same `<clinit>`): no slot, no counter.
|
||||||
|
None => {}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
// Audio when a CodingType is present (audio binding type
|
// Audio when a CodingType is present (audio binding type
|
||||||
// always references org.bluray.ti.CodingType); subtitle
|
// always references org.bluray.ti.CodingType); subtitle
|
||||||
@@ -1057,6 +1080,69 @@ fn interpret_streams(constructions: &[Construction], master: &MasterEnumTable) -
|
|||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Which stream list each binding type enumerates, learned from the
|
||||||
|
/// constructions that DID resolve a language.
|
||||||
|
///
|
||||||
|
/// A `<clinit>` walk emits a [`Construction`] for every `new X; … ;
|
||||||
|
/// invokespecial X.<init>` it sees, so the list mixes real stream bindings
|
||||||
|
/// with whatever else the class initializer builds. `binding_type` is the
|
||||||
|
/// constructed class name, which is how the two are told apart: the stream
|
||||||
|
/// bindings all share one class (Deluxe splits audio and subtitle across two),
|
||||||
|
/// and that class is identifiable from the slots that resolved.
|
||||||
|
///
|
||||||
|
/// A binding type that resolved as both kinds is left out — with no consistent
|
||||||
|
/// answer, guessing a list to advance would be worse than not advancing.
|
||||||
|
fn slot_kinds(constructions: &[Construction]) -> HashMap<&str, Option<StreamLabelType>> {
|
||||||
|
let mut kinds: HashMap<&str, Option<StreamLabelType>> = HashMap::new();
|
||||||
|
for c in constructions {
|
||||||
|
let mut has_lang = false;
|
||||||
|
let mut has_coding = false;
|
||||||
|
for arg in &c.args {
|
||||||
|
match arg {
|
||||||
|
StackVal::EnumRef {
|
||||||
|
kind: "Language", ..
|
||||||
|
} => has_lang = true,
|
||||||
|
StackVal::CodingType(_) => has_coding = true,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !has_lang {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let kind = if has_coding {
|
||||||
|
StreamLabelType::Audio
|
||||||
|
} else {
|
||||||
|
StreamLabelType::Subtitle
|
||||||
|
};
|
||||||
|
kinds
|
||||||
|
.entry(c.binding_type.as_str())
|
||||||
|
.and_modify(|e| {
|
||||||
|
if *e != Some(kind) {
|
||||||
|
*e = None;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.or_insert(Some(kind));
|
||||||
|
}
|
||||||
|
kinds
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The stream list an unresolved construction occupies a slot in, or `None`
|
||||||
|
/// when it is not a stream binding.
|
||||||
|
///
|
||||||
|
/// A `org.bluray.ti.CodingType` argument is decisive on its own: nothing but
|
||||||
|
/// an audio stream binding is handed one. Otherwise fall back to what the
|
||||||
|
/// binding type's resolved siblings showed (see [`slot_kinds`]).
|
||||||
|
fn slot_kind(
|
||||||
|
c: &Construction,
|
||||||
|
has_coding_type: bool,
|
||||||
|
slot_kinds: &HashMap<&str, Option<StreamLabelType>>,
|
||||||
|
) -> Option<StreamLabelType> {
|
||||||
|
if has_coding_type {
|
||||||
|
return Some(StreamLabelType::Audio);
|
||||||
|
}
|
||||||
|
slot_kinds.get(c.binding_type.as_str()).copied().flatten()
|
||||||
|
}
|
||||||
|
|
||||||
/// Map a `org.bluray.ti.CodingType` field name (as observed in
|
/// Map a `org.bluray.ti.CodingType` field name (as observed in
|
||||||
/// getstatic operands on Deluxe binding classes) to a human-readable
|
/// getstatic operands on Deluxe binding classes) to a human-readable
|
||||||
/// codec hint string.
|
/// codec hint string.
|
||||||
@@ -2716,6 +2802,87 @@ mod tests {
|
|||||||
assert_eq!(out[0].language, "eng");
|
assert_eq!(out[0].language, "eng");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Each stream binding in a binding class's `<clinit>` is one STN slot,
|
||||||
|
/// in STN order — that is the whole basis for numbering them positionally
|
||||||
|
/// here. Whether the abstract interpreter managed to RESOLVE a slot's
|
||||||
|
/// language does not change how many slots the disc has: a `getstatic`
|
||||||
|
/// whose owning class was not fingerprinted as a master enum, or whose
|
||||||
|
/// field is missing from the resolved ordinal map, arrives as
|
||||||
|
/// `StackVal::Unknown`.
|
||||||
|
///
|
||||||
|
/// A slot that resolved nothing has no label to emit, but it must still
|
||||||
|
/// consume its number. Skipping it renumbers every slot behind it and
|
||||||
|
/// binds their labels — language, commentary, descriptive-audio — one
|
||||||
|
/// stream early.
|
||||||
|
#[test]
|
||||||
|
fn interpret_streams_unresolved_slot_still_consumes_its_number() {
|
||||||
|
let audio_slot = |lang: Option<u16>| Construction {
|
||||||
|
binding_type: "AudioSlot".into(),
|
||||||
|
args: vec![
|
||||||
|
match lang {
|
||||||
|
Some(ordinal) => StackVal::EnumRef {
|
||||||
|
kind: "Language",
|
||||||
|
ordinal,
|
||||||
|
},
|
||||||
|
// Language `getstatic` the decoder could not resolve.
|
||||||
|
None => StackVal::Unknown,
|
||||||
|
},
|
||||||
|
StackVal::CodingType("DOLBY_AC3_AUDIO".into()),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
let sub_slot = |lang: Option<u16>| Construction {
|
||||||
|
binding_type: "SubtitleSlot".into(),
|
||||||
|
args: vec![match lang {
|
||||||
|
Some(ordinal) => StackVal::EnumRef {
|
||||||
|
kind: "Language",
|
||||||
|
ordinal,
|
||||||
|
},
|
||||||
|
None => StackVal::Unknown,
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
|
||||||
|
let constructions = vec![
|
||||||
|
audio_slot(Some(0)), // audio STN 1 — English
|
||||||
|
audio_slot(None), // audio STN 2 — unresolved
|
||||||
|
audio_slot(Some(1)), // audio STN 3 — French
|
||||||
|
sub_slot(Some(0)), // PG STN 1 — English
|
||||||
|
sub_slot(None), // PG STN 2 — unresolved
|
||||||
|
sub_slot(Some(2)), // PG STN 3 — Spanish
|
||||||
|
// Not a stream binding at all: no language, no CodingType, and a
|
||||||
|
// binding type that never yielded a stream. Must not take a slot.
|
||||||
|
Construction {
|
||||||
|
binding_type: "java/lang/StringBuilder".into(),
|
||||||
|
args: Vec::new(),
|
||||||
|
},
|
||||||
|
sub_slot(Some(1)), // PG STN 4 — French
|
||||||
|
];
|
||||||
|
|
||||||
|
let out = interpret_streams(&constructions, &lang_enum_master());
|
||||||
|
|
||||||
|
let audio: Vec<_> = out
|
||||||
|
.iter()
|
||||||
|
.filter(|l| l.stream_type == StreamLabelType::Audio)
|
||||||
|
.map(|l| (l.language.as_str(), l.stream_number))
|
||||||
|
.collect();
|
||||||
|
assert_eq!(
|
||||||
|
audio,
|
||||||
|
vec![("eng", 1), ("fra", 3)],
|
||||||
|
"the unresolved audio slot owns STN 2"
|
||||||
|
);
|
||||||
|
|
||||||
|
let sub: Vec<_> = out
|
||||||
|
.iter()
|
||||||
|
.filter(|l| l.stream_type == StreamLabelType::Subtitle)
|
||||||
|
.map(|l| (l.language.as_str(), l.stream_number))
|
||||||
|
.collect();
|
||||||
|
assert_eq!(
|
||||||
|
sub,
|
||||||
|
vec![("eng", 1), ("spa", 3), ("fra", 4)],
|
||||||
|
"the unresolved PG slot owns STN 2; the non-stream construction \
|
||||||
|
owns nothing"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn interpret_streams_purpose_routed_through_deluxe_enum() {
|
fn interpret_streams_purpose_routed_through_deluxe_enum() {
|
||||||
let constructions = vec![Construction {
|
let constructions = vec![Construction {
|
||||||
|
|||||||
@@ -110,14 +110,8 @@ fn build_labels(playlists: &[crate::mpls::Playlist]) -> Vec<StreamLabel> {
|
|||||||
|
|
||||||
for playlist in playlists {
|
for playlist in playlists {
|
||||||
for entry in &playlist.streams {
|
for entry in &playlist.streams {
|
||||||
let label_type = match entry.stream_type {
|
let Some(label_type) = label_type_for(entry) else {
|
||||||
2 | 5 => StreamLabelType::Audio, // primary + secondary audio
|
continue;
|
||||||
3 => StreamLabelType::Subtitle, // PG subtitle
|
|
||||||
// 1 = primary video, 6 = secondary video, 7 = DV EL
|
|
||||||
// → no StreamLabelType variant for video, skip.
|
|
||||||
// 4 = IG (interactive graphics) — not a user-facing
|
|
||||||
// stream, skip.
|
|
||||||
_ => continue,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let language = normalize_language(&entry.language);
|
let language = normalize_language(&entry.language);
|
||||||
@@ -156,6 +150,37 @@ fn build_labels(playlists: &[crate::mpls::Playlist]) -> Vec<StreamLabel> {
|
|||||||
labels
|
labels
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Which per-type numbering list an STN entry belongs to, or `None` when it
|
||||||
|
/// is not a labellable stream at all.
|
||||||
|
///
|
||||||
|
/// This MUST agree with the stream list `disc::bluray` builds from the same
|
||||||
|
/// entries, because that list is what `labels::apply_labels` counts against
|
||||||
|
/// when it binds `stream_number`. The two counters run over the same STN
|
||||||
|
/// entries in the same order, so any entry one side keeps and the other drops
|
||||||
|
/// — or files under a different type — shifts every later label of that type
|
||||||
|
/// onto the wrong stream. Three rules, all mirroring `disc::bluray`:
|
||||||
|
///
|
||||||
|
/// * `coding_type == 0` is the STN table's empty/padding slot. Not a
|
||||||
|
/// stream on either side.
|
||||||
|
/// * a PG coding_type in an audio STN slot is a subtitle, not audio.
|
||||||
|
/// `mpls::parse_stream_entry` has a dedicated arm for this layout, so it
|
||||||
|
/// is an authored shape rather than a corruption.
|
||||||
|
/// * video (1 / 6 / 7 = primary, secondary, Dolby Vision EL) and IG (4)
|
||||||
|
/// have no `StreamLabelType`; they are numbered in their own STN lists
|
||||||
|
/// and never interleave with the audio or PG lists.
|
||||||
|
fn label_type_for(entry: &crate::mpls::StreamEntry) -> Option<StreamLabelType> {
|
||||||
|
use crate::consts::coding_type as c;
|
||||||
|
if entry.coding_type == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
match entry.stream_type {
|
||||||
|
2 | 5 if entry.coding_type == c::PG => Some(StreamLabelType::Subtitle),
|
||||||
|
2 | 5 => Some(StreamLabelType::Audio),
|
||||||
|
3 => Some(StreamLabelType::Subtitle),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn has_mpls_extension(name: &str) -> bool {
|
fn has_mpls_extension(name: &str) -> bool {
|
||||||
// Case-insensitive ".mpls" suffix. Some discs use uppercase,
|
// Case-insensitive ".mpls" suffix. Some discs use uppercase,
|
||||||
// some lowercase; UDF filenames preserve case but we don't.
|
// some lowercase; UDF filenames preserve case but we don't.
|
||||||
@@ -412,6 +437,72 @@ mod tests {
|
|||||||
assert_eq!(labels[2].language, "fra");
|
assert_eq!(labels[2].language, "fra");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `stream_number` is bound by `labels::apply_labels` against the title's
|
||||||
|
/// own stream list, which `disc::bluray` builds from these same STN
|
||||||
|
/// entries. That builder DROPS an entry whose `coding_type` is 0 — the
|
||||||
|
/// STN table's empty/padding slot — so it must not be counted here
|
||||||
|
/// either. Counting it advances the audio counter past a stream that
|
||||||
|
/// never materializes, and every label behind it binds one stream late.
|
||||||
|
#[test]
|
||||||
|
fn padding_stn_entry_does_not_consume_a_label_slot() {
|
||||||
|
let pl = playlist_with(vec![
|
||||||
|
audio_entry(0x1100, 0x83, 12, 1, "eng"),
|
||||||
|
// coding_type 0: STN padding. Not a stream.
|
||||||
|
audio_entry(0x1101, 0x00, 0, 0, ""),
|
||||||
|
audio_entry(0x1102, 0x81, 6, 1, "fra"),
|
||||||
|
]);
|
||||||
|
let labels = labels_from_playlists(&[pl]);
|
||||||
|
assert_eq!(labels.len(), 2, "the padding slot yields no label");
|
||||||
|
assert_eq!(labels[0].language, "eng");
|
||||||
|
assert_eq!(labels[0].stream_number, 1);
|
||||||
|
assert_eq!(labels[1].language, "fra");
|
||||||
|
assert_eq!(
|
||||||
|
labels[1].stream_number, 2,
|
||||||
|
"padding is absent from the title's stream list, so `fra` is \
|
||||||
|
audio stream 2"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A PG coding_type sitting in an audio STN slot is a real, documented
|
||||||
|
/// shape — `mpls::parse_stream_entry` has an explicit arm for it, and
|
||||||
|
/// `disc::bluray` builds it as a Subtitle stream, not an Audio one. This
|
||||||
|
/// module must classify it the same way, or the audio counter runs one
|
||||||
|
/// ahead and the subtitle counter one behind for every later stream.
|
||||||
|
#[test]
|
||||||
|
fn pg_coding_type_in_an_audio_slot_counts_as_a_subtitle() {
|
||||||
|
let mut misplaced = audio_entry(0x1200, 0x90, 0, 0, "spa");
|
||||||
|
misplaced.stream_type = 2;
|
||||||
|
let pl = playlist_with(vec![
|
||||||
|
audio_entry(0x1100, 0x83, 12, 1, "eng"),
|
||||||
|
misplaced,
|
||||||
|
audio_entry(0x1101, 0x81, 6, 1, "fra"),
|
||||||
|
pg_entry(0x1201, "deu"),
|
||||||
|
]);
|
||||||
|
let labels = labels_from_playlists(&[pl]);
|
||||||
|
|
||||||
|
let audio: Vec<_> = labels
|
||||||
|
.iter()
|
||||||
|
.filter(|l| l.stream_type == StreamLabelType::Audio)
|
||||||
|
.map(|l| (l.language.as_str(), l.stream_number))
|
||||||
|
.collect();
|
||||||
|
assert_eq!(
|
||||||
|
audio,
|
||||||
|
vec![("eng", 1), ("fra", 2)],
|
||||||
|
"the PG entry is not an audio stream and must not number one"
|
||||||
|
);
|
||||||
|
|
||||||
|
let sub: Vec<_> = labels
|
||||||
|
.iter()
|
||||||
|
.filter(|l| l.stream_type == StreamLabelType::Subtitle)
|
||||||
|
.map(|l| (l.language.as_str(), l.stream_number))
|
||||||
|
.collect();
|
||||||
|
assert_eq!(
|
||||||
|
sub,
|
||||||
|
vec![("spa", 1), ("deu", 2)],
|
||||||
|
"it is subtitle stream 1, ahead of the PG-slot entry"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn dedup_streams_across_playlists() {
|
fn dedup_streams_across_playlists() {
|
||||||
// Two playlists, same English TrueHD 7.1 PID 0x1100 in both.
|
// Two playlists, same English TrueHD 7.1 PID 0x1100 in both.
|
||||||
|
|||||||
+112
-38
@@ -59,13 +59,26 @@ fn labels_from_feature(feature: &str) -> Vec<StreamLabel> {
|
|||||||
.map(|s| s.split(',').filter_map(|i| i.trim().parse().ok()).collect())
|
.map(|s| s.split(',').filter_map(|i| i.trim().parse().ok()).collect())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
// stream_number must match apply_labels' monotonic 1-based
|
// The CSV *is* the STN list: one cell per stream, in stream order,
|
||||||
// per-type counter, which increments once per *real* stream — so
|
// and `aud_com1_idx` is a 0-based index into those same cells. So
|
||||||
// it counts only non-empty slots, not the raw CSV index. The
|
// `stream_number` is the cell's own 1-based position — NOT a counter
|
||||||
// commentary index comparison stays on the raw CSV index `i`,
|
// that only advances on cells carrying a language.
|
||||||
// since aud_com1_idx is positional against the original CSV.
|
//
|
||||||
let mut audio_num: u16 = 0;
|
// A cell with an empty language still occupies its STN slot; it just
|
||||||
|
// has nothing to label. Renumbering the surviving cells 1..N shifts
|
||||||
|
// every label behind an empty cell one slot forward, which is how a
|
||||||
|
// marker authored for one stream ends up written onto the stream in
|
||||||
|
// front of it (see the subtitle side, where the marker is `forced`).
|
||||||
|
//
|
||||||
|
// `u16::try_from` rather than `saturating_add`: past the 1-based u16
|
||||||
|
// numbering space every cell would collapse onto `u16::MAX`, binding
|
||||||
|
// several streams to one label. Stop emitting instead. Unreachable on
|
||||||
|
// real media — the BD STN_table admits at most 32 primary audio
|
||||||
|
// streams per playlist.
|
||||||
for (i, lang) in aud.split(',').enumerate() {
|
for (i, lang) in aud.split(',').enumerate() {
|
||||||
|
let Ok(stream_number) = u16::try_from(i + 1) else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
let lang = lang.trim();
|
let lang = lang.trim();
|
||||||
if lang.is_empty() {
|
if lang.is_empty() {
|
||||||
continue;
|
continue;
|
||||||
@@ -75,9 +88,8 @@ fn labels_from_feature(feature: &str) -> Vec<StreamLabel> {
|
|||||||
} else {
|
} else {
|
||||||
LabelPurpose::Normal
|
LabelPurpose::Normal
|
||||||
};
|
};
|
||||||
audio_num = audio_num.saturating_add(1);
|
|
||||||
labels.push(StreamLabel {
|
labels.push(StreamLabel {
|
||||||
stream_number: audio_num,
|
stream_number,
|
||||||
stream_type: StreamLabelType::Audio,
|
stream_type: StreamLabelType::Audio,
|
||||||
language: lang.to_string(),
|
language: lang.to_string(),
|
||||||
name: String::new(),
|
name: String::new(),
|
||||||
@@ -101,10 +113,15 @@ fn labels_from_feature(feature: &str) -> Vec<StreamLabel> {
|
|||||||
.map(|s| s.split(',').filter_map(|i| i.trim().parse().ok()).collect())
|
.map(|s| s.split(',').filter_map(|i| i.trim().parse().ok()).collect())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
// As with audio: count only non-empty slots for stream_number,
|
// As with audio: the cell position IS the STN slot. `forced_sub` and
|
||||||
// but keep com/forced lookups on the raw CSV index `i`.
|
// `sub_com1_idx` are indexed against those same cells, so an empty
|
||||||
let mut sub_num: u16 = 0;
|
// cell must not renumber the cells behind it — a forced marker
|
||||||
|
// authored for one PG slot would otherwise be written onto an
|
||||||
|
// earlier, full-dialogue subtitle track.
|
||||||
for (i, lang) in sub.split(',').enumerate() {
|
for (i, lang) in sub.split(',').enumerate() {
|
||||||
|
let Ok(stream_number) = u16::try_from(i + 1) else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
let lang = lang.trim();
|
let lang = lang.trim();
|
||||||
if lang.is_empty() {
|
if lang.is_empty() {
|
||||||
continue;
|
continue;
|
||||||
@@ -122,9 +139,8 @@ fn labels_from_feature(feature: &str) -> Vec<StreamLabel> {
|
|||||||
LabelQualifier::None
|
LabelQualifier::None
|
||||||
};
|
};
|
||||||
|
|
||||||
sub_num = sub_num.saturating_add(1);
|
|
||||||
labels.push(StreamLabel {
|
labels.push(StreamLabel {
|
||||||
stream_number: sub_num,
|
stream_number,
|
||||||
stream_type: StreamLabelType::Subtitle,
|
stream_type: StreamLabelType::Subtitle,
|
||||||
language: lang.to_string(),
|
language: lang.to_string(),
|
||||||
name: String::new(),
|
name: String::new(),
|
||||||
@@ -182,18 +198,26 @@ mod tests {
|
|||||||
/// single attacker-supplied file.
|
/// single attacker-supplied file.
|
||||||
///
|
///
|
||||||
/// Proof is by deadline rather than micro-benchmark. With the linear scan
|
/// Proof is by deadline rather than micro-benchmark. With the linear scan
|
||||||
/// this fixture (200 000 streams x 1 000 001 indices) measures 31 s in a
|
/// the original fixture (200 000 streams x 1 000 001 indices) measured
|
||||||
/// release build and far longer in debug; with a set it measures 0.03 s
|
/// 31 s in a release build and far longer in debug; with a set it measured
|
||||||
/// release / 0.56 s debug. A 10 s deadline sits ~18x above the slowest
|
/// 0.03 s release / 0.56 s debug. A 10 s deadline sits ~18x above the
|
||||||
/// passing measurement and ~3x below the fastest failing one, and makes a
|
/// slowest passing measurement and ~3x below the fastest failing one, and
|
||||||
/// regression fail fast instead of hanging CI.
|
/// makes a regression fail fast instead of hanging CI.
|
||||||
|
///
|
||||||
|
/// The CSV now stops at the end of the 1-based `u16` stream-numbering
|
||||||
|
/// space, so only the first 65 535 cells are scanned. `INDICES` is raised
|
||||||
|
/// to keep the linear-scan work product (`cells x indices`) at or above
|
||||||
|
/// the original fixture's, preserving that deadline margin.
|
||||||
///
|
///
|
||||||
/// Correctness is pinned on fixture-derived literals: indices 0, 2 and 4
|
/// Correctness is pinned on fixture-derived literals: indices 0, 2 and 4
|
||||||
/// are the commentary tracks, 1 and 3 are not.
|
/// are the commentary tracks, 1 and 3 are not.
|
||||||
#[test]
|
#[test]
|
||||||
fn commentary_index_lookup_is_not_quadratic() {
|
fn commentary_index_lookup_is_not_quadratic() {
|
||||||
|
/// Cells offered. Everything past `u16::MAX` is unnumberable and the
|
||||||
|
/// parser stops there, so the scanned prefix is 65 535 cells.
|
||||||
const STREAMS: usize = 200_000;
|
const STREAMS: usize = 200_000;
|
||||||
const INDICES: usize = 1_000_000;
|
const SCANNED: usize = u16::MAX as usize;
|
||||||
|
const INDICES: usize = 3_100_000;
|
||||||
let (tx, rx) = std::sync::mpsc::channel();
|
let (tx, rx) = std::sync::mpsc::channel();
|
||||||
let worker = std::thread::spawn(move || {
|
let worker = std::thread::spawn(move || {
|
||||||
let mut feature = String::from(r#"<playlist name="Feature" sub=""#);
|
let mut feature = String::from(r#"<playlist name="Feature" sub=""#);
|
||||||
@@ -210,7 +234,7 @@ mod tests {
|
|||||||
match rx.recv_timeout(std::time::Duration::from_secs(10)) {
|
match rx.recv_timeout(std::time::Duration::from_secs(10)) {
|
||||||
Ok(labels) => {
|
Ok(labels) => {
|
||||||
worker.join().expect("worker panicked");
|
worker.join().expect("worker panicked");
|
||||||
assert_eq!(labels.len(), STREAMS);
|
assert_eq!(labels.len(), SCANNED);
|
||||||
assert_eq!(labels[0].purpose, LabelPurpose::Commentary);
|
assert_eq!(labels[0].purpose, LabelPurpose::Commentary);
|
||||||
assert_eq!(labels[1].purpose, LabelPurpose::Normal);
|
assert_eq!(labels[1].purpose, LabelPurpose::Normal);
|
||||||
assert_eq!(labels[2].purpose, LabelPurpose::Commentary);
|
assert_eq!(labels[2].purpose, LabelPurpose::Commentary);
|
||||||
@@ -259,11 +283,56 @@ mod tests {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The `aud` / `sub` CSVs are the vendor's STN-ordered stream lists: one
|
||||||
|
/// slot per stream, and `aud_com1_idx` / `forced_sub` are indexed against
|
||||||
|
/// those same slot positions. A slot whose language cell is empty carries
|
||||||
|
/// nothing to label but still OCCUPIES its slot, so it must not renumber
|
||||||
|
/// the slots behind it.
|
||||||
|
///
|
||||||
|
/// Numbering only the slots that carry a language collapsed every later
|
||||||
|
/// label one position forward per empty cell, which is how a forced
|
||||||
|
/// marker authored for one STN slot lands on the full-subtitle track in
|
||||||
|
/// front of it.
|
||||||
#[test]
|
#[test]
|
||||||
fn empty_middle_slot_does_not_inflate_stream_number() {
|
fn empty_csv_slot_still_occupies_its_stn_slot() {
|
||||||
// aud="eng,,fra": the empty middle slot is skipped, and the
|
// Audio: slot 2 is empty; `fra` is STN slot 3 and is the commentary
|
||||||
// second real stream (fra) must be numbered 2, matching
|
// the vendor pointed at with the 0-based CSV index 2.
|
||||||
// apply_labels' monotonic counter — not 3 (its raw CSV index).
|
let feature = r#"<playlist name="Feature" aud="eng,,fra" aud_com1_idx="2" />"#;
|
||||||
|
let labels = labels_from_feature(feature);
|
||||||
|
let a = audio(&labels);
|
||||||
|
assert_eq!(a.len(), 2, "the empty slot carries no label");
|
||||||
|
assert_eq!(a[0].language, "eng");
|
||||||
|
assert_eq!(a[0].stream_number, 1);
|
||||||
|
assert_eq!(a[1].language, "fra");
|
||||||
|
assert_eq!(
|
||||||
|
a[1].stream_number, 3,
|
||||||
|
"an empty CSV cell occupies STN slot 2, so `fra` is slot 3"
|
||||||
|
);
|
||||||
|
assert_eq!(a[1].purpose, LabelPurpose::Commentary);
|
||||||
|
|
||||||
|
// Subtitles: same shape, and the consequence is a misplaced forced
|
||||||
|
// flag. `forced_sub` index 2 is the forced-narrative track; with the
|
||||||
|
// empty slot renumbered away it would be written onto STN slot 2.
|
||||||
|
let feature = r#"<playlist name="Feature" sub="eng,,fra" forced_sub="0,0,1" />"#;
|
||||||
|
let labels = labels_from_feature(feature);
|
||||||
|
let s = subs(&labels);
|
||||||
|
assert_eq!(s.len(), 2);
|
||||||
|
assert_eq!(s[0].language, "eng");
|
||||||
|
assert_eq!(s[0].stream_number, 1);
|
||||||
|
assert_eq!(s[0].qualifier, LabelQualifier::None);
|
||||||
|
assert_eq!(s[1].language, "fra");
|
||||||
|
assert_eq!(
|
||||||
|
s[1].stream_number, 3,
|
||||||
|
"the forced marker belongs to STN slot 3, not slot 2"
|
||||||
|
);
|
||||||
|
assert_eq!(s[1].qualifier, LabelQualifier::Forced);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_middle_slot_carries_no_label_but_keeps_its_slot() {
|
||||||
|
// aud="eng,,fra": the empty middle cell yields no label — there is
|
||||||
|
// nothing to label — but it still owns STN slot 2, so `fra` is slot
|
||||||
|
// 3. (This test previously asserted 2, pinning the renumbering bug.)
|
||||||
let feature = r#"<playlist name="Feature" aud="eng,,fra" />"#;
|
let feature = r#"<playlist name="Feature" aud="eng,,fra" />"#;
|
||||||
let labels = labels_from_feature(feature);
|
let labels = labels_from_feature(feature);
|
||||||
let a = audio(&labels);
|
let a = audio(&labels);
|
||||||
@@ -271,7 +340,7 @@ mod tests {
|
|||||||
assert_eq!(a[0].language, "eng");
|
assert_eq!(a[0].language, "eng");
|
||||||
assert_eq!(a[0].stream_number, 1);
|
assert_eq!(a[0].stream_number, 1);
|
||||||
assert_eq!(a[1].language, "fra");
|
assert_eq!(a[1].language, "fra");
|
||||||
assert_eq!(a[1].stream_number, 2);
|
assert_eq!(a[1].stream_number, 3);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -279,7 +348,7 @@ mod tests {
|
|||||||
// Whitespace around the index, and a multi-value list, must both
|
// Whitespace around the index, and a multi-value list, must both
|
||||||
// resolve. com index is positional against the raw CSV, so with
|
// resolve. com index is positional against the raw CSV, so with
|
||||||
// an empty slot at position 1, " 2 " marks the 'fra' track
|
// an empty slot at position 1, " 2 " marks the 'fra' track
|
||||||
// (CSV index 2) as commentary.
|
// (CSV index 2, STN slot 3) as commentary.
|
||||||
let feature = r#"<playlist aud="eng,,fra" aud_com1_idx=" 2 " />"#;
|
let feature = r#"<playlist aud="eng,,fra" aud_com1_idx=" 2 " />"#;
|
||||||
let labels = labels_from_feature(feature);
|
let labels = labels_from_feature(feature);
|
||||||
let a = audio(&labels);
|
let a = audio(&labels);
|
||||||
@@ -293,7 +362,7 @@ mod tests {
|
|||||||
fn forced_sub_aligns_with_raw_csv_index() {
|
fn forced_sub_aligns_with_raw_csv_index() {
|
||||||
// sub="eng,eng,zho,ces" forced_sub="0,0,0,1": the forced flag is
|
// sub="eng,eng,zho,ces" forced_sub="0,0,0,1": the forced flag is
|
||||||
// positional on the raw CSV, so 'ces' (index 3) is forced; its
|
// positional on the raw CSV, so 'ces' (index 3) is forced; its
|
||||||
// stream_number is its non-empty position (4 here, no gaps).
|
// stream_number is its 1-based cell position, 4.
|
||||||
let feature = r#"<playlist sub="eng,eng,zho,ces" forced_sub="0,0,0,1" />"#;
|
let feature = r#"<playlist sub="eng,eng,zho,ces" forced_sub="0,0,0,1" />"#;
|
||||||
let labels = labels_from_feature(feature);
|
let labels = labels_from_feature(feature);
|
||||||
let s = subs(&labels);
|
let s = subs(&labels);
|
||||||
@@ -339,10 +408,12 @@ mod tests {
|
|||||||
assert!(feature.contains(r#"name="MainMovie""#));
|
assert!(feature.contains(r#"name="MainMovie""#));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Spec: stream_number for audio is 1-based and increments only on non-empty slots.
|
/// Spec: stream_number for audio is the cell's own 1-based CSV position,
|
||||||
/// Mutation: increment for empty slots too → stream numbers inflate.
|
/// because the CSV is the STN list and empty cells are slots too.
|
||||||
|
/// Mutation: count only non-empty cells → every label behind an empty
|
||||||
|
/// cell shifts one slot forward.
|
||||||
#[test]
|
#[test]
|
||||||
fn audio_stream_numbering_skips_empty_slots() {
|
fn audio_stream_numbering_uses_raw_csv_slot_position() {
|
||||||
let feature = r#"<playlist name="Feature" aud="eng,,fra,,spa" />"#;
|
let feature = r#"<playlist name="Feature" aud="eng,,fra,,spa" />"#;
|
||||||
let labels = labels_from_feature(feature);
|
let labels = labels_from_feature(feature);
|
||||||
let a = audio(&labels);
|
let a = audio(&labels);
|
||||||
@@ -350,9 +421,9 @@ mod tests {
|
|||||||
assert_eq!(a[0].language, "eng");
|
assert_eq!(a[0].language, "eng");
|
||||||
assert_eq!(a[0].stream_number, 1);
|
assert_eq!(a[0].stream_number, 1);
|
||||||
assert_eq!(a[1].language, "fra");
|
assert_eq!(a[1].language, "fra");
|
||||||
assert_eq!(a[1].stream_number, 2);
|
assert_eq!(a[1].stream_number, 3);
|
||||||
assert_eq!(a[2].language, "spa");
|
assert_eq!(a[2].language, "spa");
|
||||||
assert_eq!(a[2].stream_number, 3);
|
assert_eq!(a[2].stream_number, 5);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Spec: forced subtitle at the last position with gaps in between.
|
/// Spec: forced subtitle at the last position with gaps in between.
|
||||||
@@ -361,7 +432,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn forced_sub_uses_raw_csv_index_with_gaps() {
|
fn forced_sub_uses_raw_csv_index_with_gaps() {
|
||||||
// sub="eng,,fra,,spa" forced_sub="0,0,0,0,1"
|
// sub="eng,,fra,,spa" forced_sub="0,0,0,0,1"
|
||||||
// raw CSV index 4 = "spa"; stream_number for spa = 3 (3rd non-empty).
|
// raw CSV index 4 = "spa", i.e. STN slot 5.
|
||||||
let feature = r#"<playlist name="Feature" sub="eng,,fra,,spa" forced_sub="0,0,0,0,1" />"#;
|
let feature = r#"<playlist name="Feature" sub="eng,,fra,,spa" forced_sub="0,0,0,0,1" />"#;
|
||||||
let labels = labels_from_feature(feature);
|
let labels = labels_from_feature(feature);
|
||||||
let s = subs(&labels);
|
let s = subs(&labels);
|
||||||
@@ -372,6 +443,7 @@ mod tests {
|
|||||||
assert_eq!(s[1].qualifier, LabelQualifier::None);
|
assert_eq!(s[1].qualifier, LabelQualifier::None);
|
||||||
assert_eq!(s[2].language, "spa");
|
assert_eq!(s[2].language, "spa");
|
||||||
assert_eq!(s[2].qualifier, LabelQualifier::Forced);
|
assert_eq!(s[2].qualifier, LabelQualifier::Forced);
|
||||||
|
assert_eq!(s[2].stream_number, 5);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Spec: aud_com1_idx is positional against the raw CSV.
|
/// Spec: aud_com1_idx is positional against the raw CSV.
|
||||||
@@ -380,13 +452,14 @@ mod tests {
|
|||||||
/// Mutation: use stream_number instead of raw CSV index → wrong stream is commentary.
|
/// Mutation: use stream_number instead of raw CSV index → wrong stream is commentary.
|
||||||
#[test]
|
#[test]
|
||||||
fn audio_commentary_index_raw_csv_position() {
|
fn audio_commentary_index_raw_csv_position() {
|
||||||
// aud="eng,,fra,spa" aud_com1_idx="2" → CSV index 2 = "fra".
|
// aud="eng,,fra,spa" aud_com1_idx="2" → CSV index 2 = "fra",
|
||||||
// "fra" is stream_number 2 (second non-empty slot, skipping the empty).
|
// which is STN slot 3.
|
||||||
let feature = r#"<playlist name="Feature" aud="eng,,fra,spa" aud_com1_idx="2" />"#;
|
let feature = r#"<playlist name="Feature" aud="eng,,fra,spa" aud_com1_idx="2" />"#;
|
||||||
let labels = labels_from_feature(feature);
|
let labels = labels_from_feature(feature);
|
||||||
let a = audio(&labels);
|
let a = audio(&labels);
|
||||||
assert_eq!(a.len(), 3);
|
assert_eq!(a.len(), 3);
|
||||||
assert_eq!(a[1].language, "fra");
|
assert_eq!(a[1].language, "fra");
|
||||||
|
assert_eq!(a[1].stream_number, 3);
|
||||||
assert_eq!(a[1].purpose, LabelPurpose::Commentary);
|
assert_eq!(a[1].purpose, LabelPurpose::Commentary);
|
||||||
assert_eq!(a[0].purpose, LabelPurpose::Normal);
|
assert_eq!(a[0].purpose, LabelPurpose::Normal);
|
||||||
assert_eq!(a[2].purpose, LabelPurpose::Normal);
|
assert_eq!(a[2].purpose, LabelPurpose::Normal);
|
||||||
@@ -429,10 +502,11 @@ mod tests {
|
|||||||
assert!(s.is_empty(), "no subtitle labels when sub is absent");
|
assert!(s.is_empty(), "no subtitle labels when sub is absent");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Spec: audio stream_number uses saturating_add on overflow (per u16 cap).
|
/// Spec: audio stream_number is the cell's 1-based position and never
|
||||||
/// Mutation: use wrapping_add → stream numbers wrap to 0, skipping apply.
|
/// wraps; past the u16 space the parser stops emitting.
|
||||||
|
/// Mutation: cast `i + 1` to u16 → stream numbers wrap to 0, skipping apply.
|
||||||
#[test]
|
#[test]
|
||||||
fn audio_stream_number_saturates_not_wraps() {
|
fn audio_stream_number_never_wraps() {
|
||||||
// 65535 audio tracks is impossible on a real disc but the parser must
|
// 65535 audio tracks is impossible on a real disc but the parser must
|
||||||
// not panic or produce 0. Build a comma-separated list of 65535 "eng"s.
|
// not panic or produce 0. Build a comma-separated list of 65535 "eng"s.
|
||||||
// We only run the number-assignment logic via labels_from_feature.
|
// We only run the number-assignment logic via labels_from_feature.
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
//! Menu-graphic filename language hints.
|
//! Menu-graphic filename language hints.
|
||||||
//!
|
//!
|
||||||
//! Some BD-J discs encode per-language menu artwork with the language in the
|
//! Some BD-J discs encode per-language menu artwork with the language in the
|
||||||
//! filename, e.g. `Dune_UHD01_Eng_Composite1.png`,
|
//! filename, e.g. `Feature_UHD01_Eng_Composite1.png`,
|
||||||
//! `VForVendetta_UHD01_FRE_Composite2.png`. The `_UHD01_{LANG}_Composite`
|
//! `AltFeature_UHD01_FRE_Composite2.png`. The `_UHD01_{LANG}_Composite`
|
||||||
//! marker is authored deliberately, so the set of `{LANG}` tokens is the set
|
//! marker is authored deliberately, so the set of `{LANG}` tokens is the set
|
||||||
//! of menu languages the disc ships.
|
//! of menu languages the disc ships.
|
||||||
//!
|
//!
|
||||||
@@ -91,10 +91,16 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn extracts_confirmed_samples() {
|
fn extracts_confirmed_samples() {
|
||||||
assert_eq!(filename_lang("Dune_UHD01_Eng_Composite1.png"), Some("eng"));
|
|
||||||
assert_eq!(filename_lang("Dune_UHD01_Ger_Composite2.png"), Some("deu"));
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
filename_lang("VForVendetta_UHD01_FRE_Composite2.png"),
|
filename_lang("Feature_UHD01_Eng_Composite1.png"),
|
||||||
|
Some("eng")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
filename_lang("Feature_UHD01_Ger_Composite2.png"),
|
||||||
|
Some("deu")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
filename_lang("AltFeature_UHD01_FRE_Composite2.png"),
|
||||||
Some("fra")
|
Some("fra")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -120,9 +126,9 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn dedups_and_numbers_distinct_languages() {
|
fn dedups_and_numbers_distinct_languages() {
|
||||||
let names = vec![
|
let names = vec![
|
||||||
"Dune_UHD01_Eng_Composite1.png".to_string(),
|
"Feature_UHD01_Eng_Composite1.png".to_string(),
|
||||||
"Dune_UHD01_Eng_Composite2.png".to_string(),
|
"Feature_UHD01_Eng_Composite2.png".to_string(),
|
||||||
"Dune_UHD01_Ger_Composite1.png".to_string(),
|
"Feature_UHD01_Ger_Composite1.png".to_string(),
|
||||||
"LoadingComposite1.png".to_string(),
|
"LoadingComposite1.png".to_string(),
|
||||||
];
|
];
|
||||||
let labels = labels_from_filenames(&names);
|
let labels = labels_from_filenames(&names);
|
||||||
|
|||||||
+1
-1
@@ -177,7 +177,7 @@ const BARE_LANGS: &[(&str, &str)] = &[
|
|||||||
];
|
];
|
||||||
|
|
||||||
/// Map a short menu-graphic language token (as embedded in authoring
|
/// Map a short menu-graphic language token (as embedded in authoring
|
||||||
/// filenames like `Dune_UHD01_Eng_Composite1.png`) to an ISO-639-2/T code.
|
/// filenames like `Feature_UHD01_Eng_Composite1.png`) to an ISO-639-2/T code.
|
||||||
///
|
///
|
||||||
/// These filename tokens are compact 2/3-letter abbreviations, NOT the full
|
/// These filename tokens are compact 2/3-letter abbreviations, NOT the full
|
||||||
/// language names [`lang`] handles, so they get their own certain table.
|
/// language names [`lang`] handles, so they get their own certain table.
|
||||||
|
|||||||
Reference in New Issue
Block a user