use std::collections::HashSet;
use crate::cpl::{CompositionPlaylist, McaTagSymbol};
use crate::diagnostics::{Category, Location, Severity, ValidationIssue};
use crate::validation::{App2E2021, ConstraintsValidator};
use crate::validation::iab_codes::{self as iab_codes, IabCode};
pub struct AppIabPlugin2019;
impl ConstraintsValidator for AppIabPlugin2019 {
fn spec_id(&self) -> &str {
"ST 2067-201:2019 (IAB Level 0 Plug-in)"
}
fn validate_cpl(&self, cpl: &CompositionPlaylist) -> Vec<ValidationIssue> {
let mut issues = Vec::new();
App2E2021.validate_all(cpl, true, &mut issues);
validate_iab_descriptors(cpl, iab_codes::St2067_201_2019::for_code, true, &mut issues);
validate_iab_sequences(cpl, iab_codes::St2067_201_2019::for_code, &mut issues);
issues
}
}
pub struct AppIabPlugin2021;
impl ConstraintsValidator for AppIabPlugin2021 {
fn spec_id(&self) -> &str {
"ST 2067-201:2021 (IAB Level 0 Plug-in)"
}
fn validate_cpl(&self, cpl: &CompositionPlaylist) -> Vec<ValidationIssue> {
let mut issues = Vec::new();
App2E2021.validate_all(cpl, true, &mut issues);
validate_iab_descriptors(
cpl,
iab_codes::St2067_201_2021::for_code,
false,
&mut issues,
);
validate_iab_sequences(cpl, iab_codes::St2067_201_2021::for_code, &mut issues);
issues
}
}
pub struct AppIabPlugin2026;
impl ConstraintsValidator for AppIabPlugin2026 {
fn spec_id(&self) -> &str {
"ST 2067-201:2026 (IAB Level 0 Plug-in)"
}
fn validate_cpl(&self, cpl: &CompositionPlaylist) -> Vec<ValidationIssue> {
let mut issues = AppIabPlugin2021.validate_cpl(cpl);
validate_iab_channel_sub_descriptor_recommendation(cpl, &mut issues);
issues
}
}
fn validate_iab_channel_sub_descriptor_recommendation(
cpl: &CompositionPlaylist,
issues: &mut Vec<ValidationIssue>,
) {
use crate::diagnostics::codes::ValidationCode;
use crate::validation::iab_codes::St2067_201_2026Delta;
let edl = match &cpl.essence_descriptor_list {
Some(edl) => edl,
None => return,
};
for ed in &edl.essence_descriptors {
let iab = match &ed.iab_essence_descriptor {
Some(iab) => iab,
None => continue,
};
let n = iab
.sub_descriptors
.as_ref()
.map(|sd| sd.iab_channel_sub_descriptors.len())
.unwrap_or(0);
if n == 0 {
let code = St2067_201_2026Delta::IabChannelSubDescriptorRecommended;
let ed_loc = Location::new()
.with_cpl(cpl.id)
.with_path(format!("EssenceDescriptor/{}", ed.id));
issues.push(
ValidationIssue::new(
code.default_severity(),
code.category(),
code.code(),
format!(
"IABEssenceDescriptor {}: no <IABChannelSubDescriptor> entries — \
ST 2067-201:2026 Annex E §E.2 recommends one per channel of each BedDefinition.",
ed.id,
),
)
.with_location(ed_loc),
);
}
}
}
pub const URI_2019: &str = "http://www.smpte-ra.org/ns/2067-201/2019";
pub const URI_2019_SCHEMAS: &str = "http://www.smpte-ra.org/schemas/2067-201/2019";
fn ul_matches(provided: &str, canonical: &str) -> bool {
fn parse_ul(s: &str) -> Option<[u8; 16]> {
let hex = s.strip_prefix("urn:smpte:ul:")?;
let groups: Vec<&str> = hex.split('.').collect();
if groups.len() != 4 {
return None;
}
let mut bytes = [0u8; 16];
let mut idx = 0;
for group in &groups {
if group.len() != 8 {
return None;
}
for i in (0..8).step_by(2) {
bytes[idx] = u8::from_str_radix(&group[i..i + 2], 16).ok()?;
idx += 1;
}
}
bytes[7] = 0; Some(bytes)
}
match (parse_ul(provided), parse_ul(canonical)) {
(Some(a), Some(b)) => a == b,
_ => false,
}
}
fn validate_iab_descriptors(
cpl: &CompositionPlaylist,
code: fn(IabCode) -> &'static str,
check_channel_count: bool,
issues: &mut Vec<ValidationIssue>,
) {
const IAB_CONTAINER_FORMAT: &str = "urn:smpte:ul:060e2b34.0401010d.0d010301.021d0101";
const IAB_SOUND_COMPRESSION: &str = "urn:smpte:ul:060e2b34.04010105.0e090604.00000000";
const IAB_MCA_LABEL_DICT_ID: &str = "urn:smpte:ul:060e2b34.0401010d.03020221.00000000";
let edl = match &cpl.essence_descriptor_list {
Some(edl) => edl,
None => return,
};
for ed in &edl.essence_descriptors {
let iab = match &ed.iab_essence_descriptor {
Some(iab) => iab,
None => continue,
};
let ed_loc = Location::new()
.with_cpl(cpl.id)
.with_path(format!("EssenceDescriptor/{}", ed.id));
if iab.codec.is_some() {
issues.push(
ValidationIssue::new(
Severity::Error,
Category::Audio,
code(IabCode::CodecForbidden),
format!(
"IABEssenceDescriptor {}: Codec item shall not be present \
(ST 2067-201 §5.9)",
ed.id,
),
)
.with_location(ed_loc.clone()),
);
}
if iab.electrospatial_formulation.is_some() {
issues.push(
ValidationIssue::new(
Severity::Error,
Category::Audio,
code(IabCode::ElectrospatialFormulationForbidden),
format!(
"IABEssenceDescriptor {}: ElectrospatialFormulation shall not be \
present (ST 2067-201 §5.9)",
ed.id,
),
)
.with_location(ed_loc.clone()),
);
}
match iab.quantization_bits {
None => {
issues.push(
ValidationIssue::new(
Severity::Warning,
Category::Audio,
code(IabCode::QuantizationBitsMissing),
format!(
"IABEssenceDescriptor {}: QuantizationBits is missing; \
ST 2067-201 §5.9 requires 24",
ed.id,
),
)
.with_location(ed_loc.clone()),
);
}
Some(qb) if qb != 24 => {
issues.push(
ValidationIssue::new(
Severity::Error,
Category::Audio,
code(IabCode::QuantizationBitsInvalid),
format!(
"IABEssenceDescriptor {}: QuantizationBits {} shall be 24 \
(ST 2067-201 §5.9)",
ed.id, qb,
),
)
.with_location(ed_loc.clone()),
);
}
_ => {}
}
match &iab.container_format {
None => {
issues.push(
ValidationIssue::new(
Severity::Warning,
Category::Audio,
code(IabCode::ContainerFormatMissing),
format!(
"IABEssenceDescriptor {}: ContainerFormat is missing; \
should be {IAB_CONTAINER_FORMAT}",
ed.id,
),
)
.with_location(ed_loc.clone()),
);
}
Some(cf) if !ul_matches(cf, IAB_CONTAINER_FORMAT) => {
issues.push(
ValidationIssue::new(
Severity::Error,
Category::Audio,
code(IabCode::EssenceContainerInvalid),
format!(
"IABEssenceDescriptor {}: ContainerFormat `{cf}` is not the \
required IAB container UL {IAB_CONTAINER_FORMAT} \
(ST 2067-201 §5.3)",
ed.id,
),
)
.with_location(ed_loc.clone()),
);
}
_ => {}
}
match &iab.audio_sample_rate {
None => {
issues.push(
ValidationIssue::new(
Severity::Warning,
Category::Audio,
code(IabCode::AudioSamplingRateMissing),
format!(
"IABEssenceDescriptor {}: AudioSampleRate is missing; \
ST 2067-201 §5.9 requires 48000/1",
ed.id,
),
)
.with_location(ed_loc.clone()),
);
}
Some(rate) if rate.numerator != 48000 || rate.denominator != 1 => {
issues.push(
ValidationIssue::new(
Severity::Error,
Category::Audio,
code(IabCode::AudioSamplingRateInvalid),
format!(
"IABEssenceDescriptor {}: AudioSampleRate {}/{} is not 48000/1 \
(ST 2067-201 §5.9)",
ed.id, rate.numerator, rate.denominator,
),
)
.with_location(ed_loc.clone()),
);
}
_ => {}
}
match &iab.sound_compression {
None => {
issues.push(
ValidationIssue::new(
Severity::Warning,
Category::Audio,
code(IabCode::SoundCompressionMissing),
format!(
"IABEssenceDescriptor {}: SoundCompression is missing; \
should be {IAB_SOUND_COMPRESSION}",
ed.id,
),
)
.with_location(ed_loc.clone()),
);
}
Some(sc) if !ul_matches(sc, IAB_SOUND_COMPRESSION) => {
issues.push(
ValidationIssue::new(
Severity::Error,
Category::Audio,
code(IabCode::SoundCompressionInvalid),
format!(
"IABEssenceDescriptor {}: SoundCompression `{sc}` is not the \
required IAB UL {IAB_SOUND_COMPRESSION} (ST 2067-201 §5.9)",
ed.id,
),
)
.with_location(ed_loc.clone()),
);
}
_ => {}
}
if check_channel_count {
match iab.channel_count {
Some(cc) if cc != 0 => {
issues.push(
ValidationIssue::new(
Severity::Error,
Category::Audio,
code(IabCode::ChannelCountNotZero),
format!(
"IABEssenceDescriptor {}: ChannelCount {cc} shall be the \
distinguished value 0 (ST 2067-201 §5.9, ST 377-1 Annex F.5)",
ed.id,
),
)
.with_location(ed_loc.clone()),
);
}
_ => {}
}
}
let soundfield = iab
.sub_descriptors
.as_ref()
.and_then(|sd| sd.iab_soundfield_label_sub_descriptor.as_ref());
if soundfield.is_none() {
issues.push(
ValidationIssue::new(
Severity::Error,
Category::Audio,
code(IabCode::SubDescriptorMissing),
format!(
"IABEssenceDescriptor {}: IABSoundfieldLabelSubDescriptor shall be \
present (ST 2067-201 §5.9)",
ed.id,
),
)
.with_location(ed_loc.clone()),
);
continue; }
let sf = soundfield.unwrap();
let sub_loc = Location::new().with_cpl(cpl.id).with_path(format!(
"EssenceDescriptor/{}/IABSoundfieldLabelSubDescriptor",
ed.id
));
match &sf.mca_tag_symbol {
None => {
issues.push(
ValidationIssue::new(
Severity::Error,
Category::Audio,
code(IabCode::MCATagSymbolMissing),
format!(
"IABEssenceDescriptor {}: IABSoundfieldLabelSubDescriptor \
MCATagSymbol shall be \"IAB\" (ST 2067-201 §5.9)",
ed.id,
),
)
.with_location(sub_loc.clone()),
);
}
Some(sym) if *sym != McaTagSymbol::Iab => {
issues.push(
ValidationIssue::new(
Severity::Error,
Category::Audio,
code(IabCode::MCATagSymbolInvalid),
format!(
"IABEssenceDescriptor {}: IABSoundfieldLabelSubDescriptor \
MCATagSymbol shall be \"IAB\", got \"{sym:?}\" \
(ST 2067-201 §5.9)",
ed.id,
),
)
.with_location(sub_loc.clone()),
);
}
_ => {}
}
match sf.mca_tag_name.as_deref() {
None => {
issues.push(
ValidationIssue::new(
Severity::Error,
Category::Audio,
code(IabCode::MCATagNameMissing),
format!(
"IABEssenceDescriptor {}: IABSoundfieldLabelSubDescriptor \
MCATagName shall be \"IAB\" (ST 2067-201 §5.9)",
ed.id,
),
)
.with_location(sub_loc.clone()),
);
}
Some(name) if name != "IAB" => {
issues.push(
ValidationIssue::new(
Severity::Error,
Category::Audio,
code(IabCode::MCATagNameInvalid),
format!(
"IABEssenceDescriptor {}: IABSoundfieldLabelSubDescriptor \
MCATagName shall be \"IAB\", got \"{name}\" \
(ST 2067-201 §5.9)",
ed.id,
),
)
.with_location(sub_loc.clone()),
);
}
_ => {}
}
match sf.mca_label_dictionary_id.as_deref() {
None => {
issues.push(
ValidationIssue::new(
Severity::Error,
Category::Audio,
code(IabCode::MCALabelDictionaryIDMissing),
format!(
"IABEssenceDescriptor {}: IABSoundfieldLabelSubDescriptor \
MCALabelDictionaryID is missing; shall be {IAB_MCA_LABEL_DICT_ID} \
(ST 2067-201 §5.9)",
ed.id,
),
)
.with_location(sub_loc.clone()),
);
}
Some(id) if !ul_matches(id, IAB_MCA_LABEL_DICT_ID) => {
issues.push(
ValidationIssue::new(
Severity::Error,
Category::Audio,
code(IabCode::MCALabelDictionaryIDInvalid),
format!(
"IABEssenceDescriptor {}: IABSoundfieldLabelSubDescriptor \
MCALabelDictionaryID `{id}` shall be {IAB_MCA_LABEL_DICT_ID} \
(ST 2067-201 §5.9)",
ed.id,
),
)
.with_location(sub_loc.clone()),
);
}
_ => {}
}
}
}
fn validate_iab_sequences(
cpl: &CompositionPlaylist,
code: fn(IabCode) -> &'static str,
issues: &mut Vec<ValidationIssue>,
) {
let iab_ed_ids: HashSet<String> = cpl
.essence_descriptor_list
.as_ref()
.map(|edl| {
edl.essence_descriptors
.iter()
.filter(|ed| ed.iab_essence_descriptor.is_some())
.map(|ed| ed.id.to_string())
.collect()
})
.unwrap_or_default();
for segment in &cpl.segment_list.segments {
let sl = &segment.sequence_list;
if sl.iab_sequences.is_empty() {
continue;
}
let seg_loc = Location::new()
.with_cpl(cpl.id)
.with_path(format!("Segment/{}", segment.id));
if sl.main_audio_sequences.is_empty() {
issues.push(
ValidationIssue::new(
Severity::Error,
Category::Audio,
code(IabCode::MainAudioMissing),
format!(
"Segment {}: IABSequence present but no MainAudioSequence found; \
ST 2067-201 §6.2 requires a MainAudioSequence alongside IABSequence",
segment.id,
),
)
.with_location(seg_loc.clone()),
);
}
for iab_seq in &sl.iab_sequences {
let seq_loc = Location::new()
.with_cpl(cpl.id)
.with_path(format!("Segment/{}/IABSequence/{}", segment.id, iab_seq.id));
if iab_seq.resource_list.resources.is_empty() {
issues.push(
ValidationIssue::new(
Severity::Error,
Category::Audio,
code(IabCode::IABSequenceNoResources),
format!(
"IABSequence {}: shall contain at least one Resource \
(ST 2067-201 §6.2)",
iab_seq.id,
),
)
.with_location(seq_loc.clone()),
);
}
for resource in &iab_seq.resource_list.resources {
if let Some(ref se) = resource.source_encoding {
let se_str = se.to_string();
if !iab_ed_ids.contains(&se_str) {
issues.push(
ValidationIssue::new(
Severity::Error,
Category::Audio,
code(IabCode::IABSequenceSourceEncodingInvalid),
format!(
"IABSequence {}: Resource {} SourceEncoding {se_str} \
does not reference an IABEssenceDescriptor \
(ST 2067-201 §6.2)",
iab_seq.id, resource.id,
),
)
.with_location(seq_loc.clone()),
);
}
}
}
}
}
}
#[cfg(test)]
mod namespace_tests {
use super::*;
#[test]
fn iab_namespace_uris_match_pdf_evidence() {
assert_eq!(URI_2019, "http://www.smpte-ra.org/ns/2067-201/2019");
assert_eq!(
URI_2019_SCHEMAS,
"http://www.smpte-ra.org/schemas/2067-201/2019"
);
}
}
#[cfg(test)]
mod plugin_2026_tests {
use super::*;
use crate::cpl::parse_cpl;
use crate::diagnostics::Severity;
fn cpl_xml_with_iab_subdescriptors(subdescriptors_body: &str) -> String {
format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<CompositionPlaylist xmlns="http://www.smpte-ra.org/schemas/2067-3/2016">
<Id>urn:uuid:00000000-0000-0000-0000-000000000cc1</Id>
<IssueDate>2026-06-13T12:00:00Z</IssueDate>
<ContentTitle>T</ContentTitle>
<EditRate>24 1</EditRate>
<EssenceDescriptorList>
<EssenceDescriptor>
<Id>urn:uuid:00000000-0000-0000-0000-000000000aaa</Id>
<IABEssenceDescriptor>
<SubDescriptors>
{subdescriptors_body}
</SubDescriptors>
</IABEssenceDescriptor>
</EssenceDescriptor>
</EssenceDescriptorList>
<SegmentList/>
</CompositionPlaylist>"#,
)
}
#[test]
fn plugin_2026_warns_when_iab_channel_subdescriptors_absent() {
let xml = cpl_xml_with_iab_subdescriptors("<IABSoundfieldLabelSubDescriptor/>");
let cpl = parse_cpl(&xml).expect("CPL should parse");
let issues = AppIabPlugin2026.validate_cpl(&cpl);
let hit = issues
.iter()
.find(|i| i.code == "ST2067-201:2026:Annex-E/IabChannelSubDescriptorRecommended");
assert!(hit.is_some(), "expected Annex E warning, got: {issues:#?}");
assert_eq!(hit.unwrap().severity, Severity::Warning);
}
#[test]
fn plugin_2026_silent_when_iab_channel_subdescriptors_present() {
let xml = cpl_xml_with_iab_subdescriptors(
"<IABSoundfieldLabelSubDescriptor/>\
<IABChannelSubDescriptor><IABBedMetaID>1</IABBedMetaID><IABChannelID>1</IABChannelID></IABChannelSubDescriptor>",
);
let cpl = parse_cpl(&xml).expect("CPL should parse");
let issues = AppIabPlugin2026.validate_cpl(&cpl);
assert!(
!issues
.iter()
.any(|i| i.code.contains("IabChannelSubDescriptorRecommended")),
"should NOT fire Annex E warning when ≥1 entry present, got: {issues:#?}"
);
}
#[test]
fn plugin_2021_silent_on_annex_e_recommendation() {
let xml = cpl_xml_with_iab_subdescriptors("<IABSoundfieldLabelSubDescriptor/>");
let cpl = parse_cpl(&xml).expect("CPL should parse");
let issues = AppIabPlugin2021.validate_cpl(&cpl);
assert!(
!issues
.iter()
.any(|i| i.code.contains("IabChannelSubDescriptorRecommended")),
"AppIabPlugin2021 must not emit the 2026-only Annex E code"
);
}
}