use std::collections::HashSet;
use crate::diagnostics::{Category, Location, Severity, ValidationIssue};
use crate::cpl::codes::St2067_3Code;
use crate::cpl::{
CompositionPlaylist, ContentKind, CplNamespace, MarkerLabel, CONTENT_KIND_DEFAULT_SCOPE,
};
fn code_fn(ns: &CplNamespace) -> fn(St2067_3Code) -> &'static str {
use crate::cpl::codes::{St2067_3_2013, St2067_3_2016, St2067_3_2020};
match ns {
CplNamespace::Dci429_7 | CplNamespace::Smpte2067_3_2013 => St2067_3_2013::for_code,
CplNamespace::Smpte2067_3_2016 => St2067_3_2016::for_code,
CplNamespace::Smpte2067_3_2020 | CplNamespace::Unknown(_) => St2067_3_2020::for_code,
}
}
pub fn validate_cpl(cpl: &CompositionPlaylist) -> Vec<ValidationIssue> {
let mut issues = Vec::new();
let code = code_fn(&cpl.namespace);
validate_content_kind_vocabulary(cpl, code, &mut issues);
validate_source_encoding_refs(cpl, code, &mut issues);
validate_content_versions(cpl, code, &mut issues);
validate_content_version_uniqueness(cpl, code, &mut issues);
validate_locale_language_tags(cpl, code, &mut issues);
validate_track_id_uniqueness(cpl, code, &mut issues);
validate_sequence_duration_integer_edit_units(cpl, code, &mut issues);
validate_marker_offsets(cpl, code, &mut issues);
validate_marker_labels(cpl, code, &mut issues);
issues
}
fn validate_content_kind_vocabulary(
cpl: &CompositionPlaylist,
code: fn(St2067_3Code) -> &'static str,
issues: &mut Vec<ValidationIssue>,
) {
if cpl.content_kind.effective_scope() != CONTENT_KIND_DEFAULT_SCOPE {
return;
}
if let ContentKind::Other(ref s) = cpl.content_kind.kind {
issues.push(
ValidationIssue::new(
Severity::Warning,
Category::Metadata,
code(St2067_3Code::ContentKindUnknown),
format!(
"ContentKind '{s}' is not a recognized value under the SMPTE scope \
(expected: feature, trailer, test, promo, teaser, rating-bump, \
advertisement, episode, short, commercial, psa)",
),
)
.with_location(Location::new().with_cpl(cpl.id)),
);
}
}
fn validate_source_encoding_refs(
cpl: &CompositionPlaylist,
code: fn(St2067_3Code) -> &'static str,
issues: &mut Vec<ValidationIssue>,
) {
let cpl_id = cpl.id;
let loc = Location::new().with_cpl(cpl_id);
let ed_ids: HashSet<String> = cpl
.essence_descriptor_list
.as_ref()
.map(|edl| {
edl.essence_descriptors
.iter()
.map(|ed| ed.id.to_string())
.collect()
})
.unwrap_or_default();
let has_edl = cpl.essence_descriptor_list.is_some();
for (seg_idx, segment) in cpl.segment_list.segments.iter().enumerate() {
let sl = &segment.sequence_list;
let all_seqs = sl.all_sequences_typed();
for (seq, track_type) in all_seqs {
for (res_idx, resource) in seq.resource_list().resources.iter().enumerate() {
if let Some(ref se) = resource.source_encoding {
let se_str = se.to_string();
let res_loc = Location::new()
.with_cpl(cpl_id)
.with_segment(seg_idx)
.with_resource(res_idx);
if !has_edl {
issues.push(
ValidationIssue::new(
Severity::Error,
Category::Reference,
code(St2067_3Code::SourceEncodingNoEssenceDescriptorList),
format!(
"{track_type} resource {res_idx}: SourceEncoding '{se_str}' \
present but EssenceDescriptorList is absent",
),
)
.with_location(res_loc),
);
} else if !ed_ids.contains(&se_str) {
issues.push(
ValidationIssue::new(
Severity::Error,
Category::Reference,
code(St2067_3Code::SourceEncodingUnresolved),
format!(
"{track_type} resource {res_idx}: SourceEncoding '{se_str}' \
does not match any EssenceDescriptor Id",
),
)
.with_location(res_loc),
);
}
}
}
}
}
if let Some(ref edl) = cpl.essence_descriptor_list {
if edl.essence_descriptors.is_empty() {
issues.push(
ValidationIssue::new(
Severity::Error,
Category::Structure,
code(St2067_3Code::EssenceDescriptorListEmpty),
"EssenceDescriptorList is present but contains no EssenceDescriptors",
)
.with_location(loc),
);
}
}
}
fn validate_content_versions(
cpl: &CompositionPlaylist,
code: fn(St2067_3Code) -> &'static str,
issues: &mut Vec<ValidationIssue>,
) {
let loc = Location::new().with_cpl(cpl.id);
let cvl = match &cpl.content_version_list {
Some(cvl) => cvl,
None => return,
};
if cvl.content_versions.is_empty() {
issues.push(
ValidationIssue::new(
Severity::Error,
Category::Structure,
code(St2067_3Code::ContentVersionListEmpty),
"ContentVersionList shall contain at least one ContentVersion",
)
.with_location(loc.clone()),
);
return;
}
for (i, cv) in cvl.content_versions.iter().enumerate() {
if cv.id.trim().is_empty() {
issues.push(
ValidationIssue::new(
Severity::Error,
Category::Metadata,
code(St2067_3Code::ContentVersionIdInvalid),
format!("ContentVersion[{i}] has an empty Id (shall be a URI)"),
)
.with_location(loc.clone()),
);
}
if cv.label_text.is_none() {
issues.push(
ValidationIssue::new(
Severity::Warning,
Category::Metadata,
code(St2067_3Code::ContentVersionLabelTextMissing),
format!("ContentVersion[{i}] (Id: '{}') is missing LabelText", cv.id,),
)
.with_location(loc.clone()),
);
}
}
}
fn validate_locale_language_tags(
cpl: &CompositionPlaylist,
code: fn(St2067_3Code) -> &'static str,
issues: &mut Vec<ValidationIssue>,
) {
let loc = Location::new().with_cpl(cpl.id);
let ll = match &cpl.locale_list {
Some(ll) => ll,
None => return,
};
for (i, locale) in ll.locales.iter().enumerate() {
if let Some(ref lang_list) = locale.language_list {
for tag in &lang_list.languages {
let s = tag.as_str();
if s.is_empty() || !s.chars().next().unwrap_or(' ').is_ascii_alphabetic() {
issues.push(
ValidationIssue::new(
Severity::Warning,
Category::Metadata,
code(St2067_3Code::LocaleLanguageTagInvalid),
format!(
"Locale[{i}]: language tag '{s}' does not conform to RFC 5646 \
(must start with an ASCII letter)",
),
)
.with_location(loc.clone()),
);
}
}
}
}
}
fn validate_track_id_uniqueness(
cpl: &CompositionPlaylist,
code: fn(St2067_3Code) -> &'static str,
issues: &mut Vec<ValidationIssue>,
) {
let cpl_id = cpl.id;
for (seg_idx, segment) in cpl.segment_list.segments.iter().enumerate() {
let sl = &segment.sequence_list;
let mut seen: HashSet<String> = HashSet::new();
let mut all_seqs = sl.all_sequences_typed();
for s in &sl.marker_sequences {
all_seqs.push((s, "MarkerSequence"));
}
for (seq, track_type) in all_seqs {
let id = seq.track_id().to_string();
if !seen.insert(id.clone()) {
issues.push(
ValidationIssue::new(
Severity::Error,
Category::Structure,
code(St2067_3Code::TrackIdNotUnique),
format!(
"Duplicate TrackId '{id}' in segment {seg} ({track_type})",
seg = seg_idx + 1,
),
)
.with_location(Location::new().with_cpl(cpl_id).with_segment(seg_idx)),
);
}
}
}
}
fn validate_marker_offsets(
cpl: &CompositionPlaylist,
code: fn(St2067_3Code) -> &'static str,
issues: &mut Vec<ValidationIssue>,
) {
let cpl_id = cpl.id;
for (seg_idx, segment) in cpl.segment_list.segments.iter().enumerate() {
for seq in &segment.sequence_list.marker_sequences {
for (res_idx, resource) in seq.resource_list.resources.iter().enumerate() {
let effective_duration = resource
.source_duration
.unwrap_or(resource.intrinsic_duration - resource.entry_point.unwrap_or(0));
for marker in &resource.markers {
if marker.offset >= effective_duration {
issues.push(
ValidationIssue::new(
Severity::Error,
Category::Timing,
code(St2067_3Code::MarkerOffsetOutOfRange),
format!(
"Marker '{}' offset {} exceeds resource effective duration {}",
marker.label, marker.offset, effective_duration,
),
)
.with_location(
Location::new()
.with_cpl(cpl_id)
.with_segment(seg_idx)
.with_resource(res_idx),
),
);
}
}
}
}
}
}
const SMPTE_MARKER_SCOPE: &str = "http://www.smpte-ra.org/schemas/2067-3/2013#standard-markers";
const SMPTE_MARKER_SCOPE_2016: &str =
"http://www.smpte-ra.org/schemas/2067-3/2016#standard-markers";
fn validate_marker_labels(
cpl: &CompositionPlaylist,
code: fn(St2067_3Code) -> &'static str,
issues: &mut Vec<ValidationIssue>,
) {
let cpl_id = cpl.id;
for (seg_idx, segment) in cpl.segment_list.segments.iter().enumerate() {
for seq in &segment.sequence_list.marker_sequences {
for (res_idx, resource) in seq.resource_list.resources.iter().enumerate() {
for marker in &resource.markers {
let scope = marker.label.effective_scope();
if scope == SMPTE_MARKER_SCOPE {
if let MarkerLabel::Other(ref s) = marker.label.label {
issues.push(
ValidationIssue::new(
Severity::Warning,
Category::Metadata,
code(St2067_3Code::MarkerLabelUnknown),
format!(
"Marker label '{s}' is not a recognized SMPTE standard marker \
(expected: FFOC, LFOC, FFAC, LFAC, FFMC, LFMC, FFHC, LFHC)",
),
)
.with_location(
Location::new()
.with_cpl(cpl_id)
.with_segment(seg_idx)
.with_resource(res_idx),
),
);
}
} else if scope == SMPTE_MARKER_SCOPE_2016 {
if let MarkerLabel::Other(ref s) = marker.label.label {
issues.push(
ValidationIssue::new(
Severity::Warning,
Category::Metadata,
code(St2067_3Code::MarkerLabelUnknown),
format!(
"Marker label '{s}' is not a recognized SMPTE 2016 standard \
marker (expected: FFDC, LFDC)",
),
)
.with_location(
Location::new()
.with_cpl(cpl_id)
.with_segment(seg_idx)
.with_resource(res_idx),
),
);
}
}
}
}
}
}
}
fn validate_content_version_uniqueness(
cpl: &CompositionPlaylist,
code: fn(St2067_3Code) -> &'static str,
issues: &mut Vec<ValidationIssue>,
) {
let loc = Location::new().with_cpl(cpl.id);
let cvl = match &cpl.content_version_list {
Some(cvl) => cvl,
None => return,
};
let mut seen: HashSet<String> = HashSet::new();
for (i, cv) in cvl.content_versions.iter().enumerate() {
let id = cv.id.trim().to_string();
if id.is_empty() {
continue; }
if !seen.insert(id.clone()) {
issues.push(
ValidationIssue::new(
Severity::Error,
Category::Structure,
code(St2067_3Code::ContentVersionIdDuplicate),
format!(
"ContentVersion[{i}] Id '{id}' is duplicated; \
no two ContentVersion elements shall have identical Id values",
),
)
.with_location(loc.clone()),
);
}
}
}
#[allow(clippy::manual_checked_ops)]
fn validate_sequence_duration_integer_edit_units(
cpl: &CompositionPlaylist,
code: fn(St2067_3Code) -> &'static str,
issues: &mut Vec<ValidationIssue>,
) {
let cpl_er = match &cpl.edit_rate {
Some(er) => *er,
None => return,
};
let cpl_id = cpl.id;
for (seg_idx, segment) in cpl.segment_list.segments.iter().enumerate() {
let sl = &segment.sequence_list;
let all_seqs = sl.all_sequences_typed();
for (seq, track_type) in all_seqs {
let mut sum_num: u64 = 0;
let mut sum_den: u64 = 1;
let mut has_cross_rate = false;
for resource in &seq.resource_list().resources {
let res_er = resource.edit_rate.unwrap_or(cpl_er);
if res_er.numerator != cpl_er.numerator || res_er.denominator != cpl_er.denominator
{
has_cross_rate = true;
}
let src_dur = resource.source_duration.unwrap_or_else(|| {
resource
.intrinsic_duration
.saturating_sub(resource.entry_point.unwrap_or(0))
});
let contrib_num = src_dur
.saturating_mul(cpl_er.numerator as u64)
.saturating_mul(res_er.denominator as u64);
let contrib_den =
(res_er.numerator as u64).saturating_mul(cpl_er.denominator as u64);
let l = lcm_u64(sum_den, contrib_den);
sum_num = sum_num
.saturating_mul(l / sum_den)
.saturating_add(contrib_num.saturating_mul(l / contrib_den));
sum_den = l;
let g = gcd_u64(sum_num, sum_den);
if g > 0 {
sum_num /= g;
sum_den /= g;
}
}
if has_cross_rate && sum_den != 1 {
issues.push(
ValidationIssue::new(
Severity::Error,
Category::Timing,
code(St2067_3Code::SegmentDurationIntegerEditUnits),
format!(
"Segment {seg} {track_type} sequence duration is not an integer \
number of Composition Edit Units ({sum_num}/{sum_den})",
seg = seg_idx + 1,
),
)
.with_location(Location::new().with_cpl(cpl_id).with_segment(seg_idx)),
);
}
}
}
}
fn gcd_u64(mut a: u64, mut b: u64) -> u64 {
while b != 0 {
let t = b;
b = a % b;
a = t;
}
a
}
#[allow(clippy::manual_checked_ops)]
fn lcm_u64(a: u64, b: u64) -> u64 {
let g = gcd_u64(a, b);
if g == 0 {
0
} else {
a / g * b
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::assetmap::ImfUuid;
use crate::cpl::{
ContentKind, ContentKindElement, ContentVersion, ContentVersionList, EditRate,
LanguageString, MainImageSequence, Resource, ResourceList, Segment, SegmentList,
SequenceList,
};
fn dummy_uuid() -> ImfUuid {
ImfUuid::parse("urn:uuid:00000000-0000-0000-0000-000000000001").unwrap()
}
fn empty_sequence_list() -> SequenceList {
SequenceList {
marker_sequences: vec![],
main_image_sequences: vec![],
main_audio_sequences: vec![],
subtitles_sequences: vec![],
hearing_impaired_captions_sequences: vec![],
forced_narrative_sequences: vec![],
iab_sequences: vec![],
isxd_sequences: vec![],
}
}
fn minimal_cpl() -> CompositionPlaylist {
CompositionPlaylist {
id: dummy_uuid(),
namespace: CplNamespace::Smpte2067_3_2016,
annotation: None,
issue_date: String::new(),
issuer: None,
creator: None,
content_originator: None,
content_title: LanguageString::default(),
content_kind: ContentKindElement {
kind: ContentKind::Other("unknown".to_string()),
scope: None,
},
content_version_list: None,
essence_descriptor_list: None,
edit_rate: Some(EditRate::new(24, 1)),
total_running_time: None,
locale_list: None,
extension_properties: None,
composition_timecode: None,
has_signer: false,
has_signature: false,
segment_list: SegmentList { segments: vec![] },
}
}
fn make_resource(
intrinsic_duration: u64,
source_duration: Option<u64>,
edit_rate: Option<EditRate>,
) -> Resource {
Resource {
id: dummy_uuid(),
annotation: None,
edit_rate,
intrinsic_duration,
entry_point: None,
source_duration,
source_encoding: None,
track_file_id: None,
repeat_count: None,
key_id: None,
hash: None,
markers: vec![],
}
}
fn image_segment_with_resources(resources: Vec<Resource>) -> Segment {
let mut sl = empty_sequence_list();
sl.main_image_sequences = vec![MainImageSequence {
id: dummy_uuid(),
track_id: dummy_uuid(),
resource_list: ResourceList { resources },
}];
Segment {
id: dummy_uuid(),
sequence_list: sl,
}
}
#[test]
fn content_version_duplicate_id_emits_error() {
let mut cpl = minimal_cpl();
cpl.content_version_list = Some(ContentVersionList {
content_versions: vec![
ContentVersion {
id: "urn:uuid:aaa".to_string(),
label_text: None,
},
ContentVersion {
id: "urn:uuid:aaa".to_string(),
label_text: None,
},
],
});
let issues = validate_cpl(&cpl);
assert!(
issues
.iter()
.any(|i| i.code.contains("ContentVersionIdDuplicate")),
"expected ContentVersionIdDuplicate, got: {issues:?}",
);
}
#[test]
fn content_version_distinct_ids_ok() {
let mut cpl = minimal_cpl();
cpl.content_version_list = Some(ContentVersionList {
content_versions: vec![
ContentVersion {
id: "urn:uuid:aaa".to_string(),
label_text: None,
},
ContentVersion {
id: "urn:uuid:bbb".to_string(),
label_text: None,
},
],
});
let issues = validate_cpl(&cpl);
assert!(
!issues
.iter()
.any(|i| i.code.contains("ContentVersionIdDuplicate")),
"unexpected ContentVersionIdDuplicate",
);
}
#[test]
fn sequence_non_integer_edit_units_emits_error() {
let mut cpl = minimal_cpl();
cpl.segment_list = SegmentList {
segments: vec![image_segment_with_resources(vec![make_resource(
48000,
Some(47999),
Some(EditRate::new(48000, 1)),
)])],
};
let issues = validate_cpl(&cpl);
assert!(
issues
.iter()
.any(|i| i.code.contains("SegmentDurationIntegerEditUnits")),
"expected SegmentDurationIntegerEditUnits, got: {issues:?}",
);
}
#[test]
fn sequence_integer_edit_units_ok() {
let mut cpl = minimal_cpl();
cpl.segment_list = SegmentList {
segments: vec![image_segment_with_resources(vec![make_resource(
48000,
Some(48000),
Some(EditRate::new(48000, 1)),
)])],
};
let issues = validate_cpl(&cpl);
assert!(
!issues
.iter()
.any(|i| i.code.contains("SegmentDurationIntegerEditUnits")),
"unexpected SegmentDurationIntegerEditUnits",
);
}
#[test]
fn marker_label_unknown_2016_scope_emits_warning() {
let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
<CompositionPlaylist xmlns="http://www.smpte-ra.org/schemas/2067-3/2016"
xmlns:cc="http://www.smpte-ra.org/ns/2067-2/2020">
<Id>urn:uuid:00000000-0000-0000-0000-000000000001</Id>
<IssueDate>2024-01-01T00:00:00Z</IssueDate>
<ContentTitle>Test</ContentTitle>
<EditRate>24 1</EditRate>
<ContentKind>feature</ContentKind>
<SegmentList>
<Segment>
<Id>urn:uuid:00000000-0000-0000-0000-000000000002</Id>
<SequenceList>
<MarkerSequence>
<Id>urn:uuid:00000000-0000-0000-0000-000000000003</Id>
<TrackId>urn:uuid:00000000-0000-0000-0000-000000000004</TrackId>
<ResourceList>
<Resource>
<Id>urn:uuid:00000000-0000-0000-0000-000000000005</Id>
<IntrinsicDuration>100</IntrinsicDuration>
<Marker>
<Label scope="http://www.smpte-ra.org/schemas/2067-3/2016#standard-markers">UNKNOWN_LABEL</Label>
<Offset>0</Offset>
</Marker>
</Resource>
</ResourceList>
</MarkerSequence>
</SequenceList>
</Segment>
</SegmentList>
</CompositionPlaylist>"#;
let cpl = crate::cpl::parse_cpl(xml).expect("parse failed");
let issues = validate_cpl(&cpl);
assert!(
issues
.iter()
.any(|i| i.code.contains("MarkerLabelUnknown") && i.message.contains("FFDC, LFDC")),
"expected 2016 MarkerLabelUnknown, got: {issues:?}",
);
}
}