Skip to main content

imferno_core/cpl/
mod.rs

1//! SMPTE ST 2067-3: Composition Playlist (CPL) parser
2//!
3//! This parser handles CPL files - the heart of IMF packages containing:
4//! - Composition metadata and timeline structure
5//! - Segment and sequence definitions
6//! - Resource references to MXF essence files
7//! - Edit rates and timing information
8//! - EssenceDescriptors (RGBADescriptor, WAVEPCMDescriptor, DCTimedTextDescriptor, IABEssenceDescriptor)
9
10pub mod types;
11pub use types::{
12    CodingEquations, ColorPrimaries, ContentKind, CplNamespace, EditRate, LanguageTag, MarkerLabel,
13    McaTagSymbol, Resolution, TransferCharacteristic, VideoCodec,
14};
15
16pub mod validate;
17pub use validate::validate_cpl as validate_cpl_constraints;
18
19pub mod codes;
20
21use crate::assetmap::{HashAlgorithm, ImfUuid};
22use base64::Engine;
23use quick_xml::events::Event;
24use serde::{Deserialize, Serialize};
25use std::collections::BTreeSet;
26use thiserror::Error;
27
28#[cfg(all(feature = "xmlsec", not(target_arch = "wasm32")))]
29use libxml::parser::Parser as XmlParser;
30#[cfg(all(feature = "xmlsec1", not(target_arch = "wasm32")))]
31use std::io::Write;
32#[cfg(all(feature = "xmlsec1", not(target_arch = "wasm32")))]
33use std::process::Command;
34#[cfg(all(feature = "xmlsec", not(target_arch = "wasm32")))]
35use xmlsec::{XmlSecKey, XmlSecKeyFormat, XmlSecSignatureContext};
36
37#[cfg(feature = "typescript")]
38use ts_rs::TS;
39
40#[cfg(feature = "wasm")]
41use tsify::Tsify;
42
43// =============================================================================
44// Error type
45// =============================================================================
46
47#[derive(Debug, Error)]
48pub enum CplParseError {
49    #[error("XML parse error: {0}")]
50    Xml(#[from] quick_xml::DeError),
51
52    #[error("strict unknown XML token(s): {0}")]
53    StrictUnknownXml(String),
54
55    #[error("strict schema violation: {0}")]
56    StrictSchema(String),
57
58    #[error("XMLDSIG verifier is required for selected signature mode")]
59    SignatureVerifierRequired,
60
61    #[error("XMLDSIG verification failed: {0}")]
62    SignatureVerificationFailed(String),
63}
64
65#[derive(Debug, Clone, Copy, PartialEq, Eq)]
66pub enum UnknownFieldMode {
67    Ignore,
68    Error,
69}
70
71#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72pub enum SchemaStrictMode {
73    Off,
74    Basic,
75}
76
77#[derive(Debug, Clone, Copy, PartialEq, Eq)]
78pub enum SignatureValidationMode {
79    Ignore,
80    RequirePresence,
81    VerifyIfPresent,
82    RequireValid,
83}
84
85pub trait XmlSignatureVerifier {
86    fn verify(&self, xml_content: &str) -> Result<(), String>;
87}
88
89/// Concrete XMLDSIG verifier backend.
90///
91/// This verifier validates XMLDSIG structure and verifies `<Reference>` digest values.
92/// For `URI=""` references it removes the first `<Signature>` element (enveloped
93/// signature transform) and computes the digest over a normalized XML form.
94///
95/// Notes:
96/// - This backend does not perform asymmetric key / certificate signature checks.
97/// - `URI="#..."` references are validated for algorithm and digest value shape only.
98#[derive(Debug, Default, Clone, Copy)]
99pub struct ReferenceDigestXmlDsigVerifier;
100
101impl XmlSignatureVerifier for ReferenceDigestXmlDsigVerifier {
102    fn verify(&self, xml_content: &str) -> Result<(), String> {
103        validate_signature_structure(xml_content)?;
104        validate_reference_digests(xml_content)
105    }
106}
107
108/// `xmlsec` crate-backed XMLDSIG verifier.
109///
110/// This backend uses the Rust `xmlsec` crate (libxml2/xmlsec bindings) and
111/// verifies signatures against an explicitly supplied verification key.
112#[cfg(all(feature = "xmlsec", not(target_arch = "wasm32")))]
113#[derive(Debug, Clone)]
114pub struct XmlSecCrateVerifier {
115    key_data: Vec<u8>,
116    key_format: XmlSecKeyFormat,
117    key_password: Option<String>,
118}
119
120#[cfg(all(feature = "xmlsec", not(target_arch = "wasm32")))]
121impl XmlSecCrateVerifier {
122    pub fn from_key_data(key_data: Vec<u8>, key_format: XmlSecKeyFormat) -> Self {
123        Self {
124            key_data,
125            key_format,
126            key_password: None,
127        }
128    }
129
130    pub fn from_pem(key_data: impl AsRef<[u8]>) -> Self {
131        Self::from_key_data(key_data.as_ref().to_vec(), XmlSecKeyFormat::Pem)
132    }
133
134    pub fn with_password(mut self, password: impl Into<String>) -> Self {
135        self.key_password = Some(password.into());
136        self
137    }
138}
139
140#[cfg(all(feature = "xmlsec", not(target_arch = "wasm32")))]
141impl XmlSignatureVerifier for XmlSecCrateVerifier {
142    fn verify(&self, xml_content: &str) -> Result<(), String> {
143        validate_signature_structure(xml_content)?;
144
145        let mut tmp = tempfile::NamedTempFile::new()
146            .map_err(|e| format!("failed to create temp xml file: {}", e))?;
147        tmp.write_all(xml_content.as_bytes())
148            .map_err(|e| format!("failed to write temp xml file: {}", e))?;
149        tmp.flush()
150            .map_err(|e| format!("failed to flush temp xml file: {}", e))?;
151
152        let doc = XmlParser::default()
153            .parse_file(tmp.path().to_string_lossy().as_ref())
154            .map_err(|e| format!("xml parse failed for xmlsec verifier: {}", e))?;
155
156        let key = XmlSecKey::from_memory(
157            &self.key_data,
158            self.key_format,
159            self.key_password.as_deref(),
160        )
161        .map_err(|e| format!("xmlsec key load failed: {}", e))?;
162
163        let mut ctx = XmlSecSignatureContext::new();
164        ctx.insert_key(key);
165
166        let valid = ctx
167            .verify_document(&doc)
168            .map_err(|e| format!("xmlsec verify failed: {}", e))?;
169
170        if valid {
171            Ok(())
172        } else {
173            Err("xmlsec signature verification returned invalid".to_string())
174        }
175    }
176}
177
178/// `xmlsec1` CLI-backed XMLDSIG verifier.
179///
180/// This backend delegates full XML signature verification to the system
181/// `xmlsec1` command line utility.
182///
183/// Enabled with the crate feature `xmlsec1` (non-WASM targets only).
184#[cfg(all(feature = "xmlsec1", not(target_arch = "wasm32")))]
185#[derive(Debug, Clone)]
186pub struct XmlSec1Verifier {
187    binary_path: String,
188    extra_args: Vec<String>,
189}
190
191#[cfg(all(feature = "xmlsec1", not(target_arch = "wasm32")))]
192impl Default for XmlSec1Verifier {
193    fn default() -> Self {
194        let binary_path =
195            std::env::var("IMF_XMLSEC1_BIN").unwrap_or_else(|_| "xmlsec1".to_string());
196        Self {
197            binary_path,
198            extra_args: Vec::new(),
199        }
200    }
201}
202
203#[cfg(all(feature = "xmlsec1", not(target_arch = "wasm32")))]
204impl XmlSec1Verifier {
205    pub fn new() -> Self {
206        Self::default()
207    }
208
209    pub fn with_binary_path(mut self, binary_path: impl Into<String>) -> Self {
210        self.binary_path = binary_path.into();
211        self
212    }
213
214    pub fn with_extra_args<I, S>(mut self, args: I) -> Self
215    where
216        I: IntoIterator<Item = S>,
217        S: Into<String>,
218    {
219        self.extra_args = args.into_iter().map(Into::into).collect();
220        self
221    }
222}
223
224#[cfg(all(feature = "xmlsec1", not(target_arch = "wasm32")))]
225impl XmlSignatureVerifier for XmlSec1Verifier {
226    fn verify(&self, xml_content: &str) -> Result<(), String> {
227        let mut tmp = tempfile::NamedTempFile::new()
228            .map_err(|e| format!("failed to create temp xml file: {}", e))?;
229        tmp.write_all(xml_content.as_bytes())
230            .map_err(|e| format!("failed to write temp xml file: {}", e))?;
231        tmp.flush()
232            .map_err(|e| format!("failed to flush temp xml file: {}", e))?;
233
234        let mut command = Command::new(&self.binary_path);
235        command.arg("--verify");
236        for arg in &self.extra_args {
237            command.arg(arg);
238        }
239        command.arg(tmp.path());
240
241        let output = command
242            .output()
243            .map_err(|e| format!("failed to execute '{} --verify': {}", self.binary_path, e))?;
244
245        if output.status.success() {
246            return Ok(());
247        }
248
249        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
250        let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
251        let message = if !stderr.is_empty() {
252            stderr
253        } else if !stdout.is_empty() {
254            stdout
255        } else {
256            format!("xmlsec1 exited with status {}", output.status)
257        };
258
259        Err(message)
260    }
261}
262
263pub struct CplParseOptions<'a> {
264    pub unknown_field_mode: UnknownFieldMode,
265    pub schema_strict_mode: SchemaStrictMode,
266    pub signature_validation_mode: SignatureValidationMode,
267    pub signature_verifier: Option<&'a dyn XmlSignatureVerifier>,
268}
269
270impl Default for CplParseOptions<'_> {
271    fn default() -> Self {
272        Self {
273            unknown_field_mode: UnknownFieldMode::Ignore,
274            schema_strict_mode: SchemaStrictMode::Off,
275            signature_validation_mode: SignatureValidationMode::Ignore,
276            signature_verifier: None,
277        }
278    }
279}
280
281/// Build strict production-oriented parse options.
282///
283/// This enables strict unknown-token and basic schema checks, and requires
284/// a valid XML signature using the provided verifier.
285pub fn strict_production_parse_options<'a>(
286    signature_verifier: &'a dyn XmlSignatureVerifier,
287) -> CplParseOptions<'a> {
288    CplParseOptions {
289        unknown_field_mode: UnknownFieldMode::Error,
290        schema_strict_mode: SchemaStrictMode::Basic,
291        signature_validation_mode: SignatureValidationMode::RequireValid,
292        signature_verifier: Some(signature_verifier),
293    }
294}
295
296/// Create the recommended signature verifier backend for the current build.
297///
298/// Preference order:
299/// 1. `xmlsec1` CLI backend when feature `xmlsec1` is enabled (non-WASM).
300/// 2. Fallback digest verifier (`ReferenceDigestXmlDsigVerifier`) otherwise.
301pub fn recommended_signature_verifier() -> Box<dyn XmlSignatureVerifier> {
302    #[cfg(all(feature = "xmlsec1", not(target_arch = "wasm32")))]
303    {
304        Box::new(XmlSec1Verifier::default())
305    }
306
307    #[cfg(not(all(feature = "xmlsec1", not(target_arch = "wasm32"))))]
308    {
309        Box::new(ReferenceDigestXmlDsigVerifier)
310    }
311}
312
313fn validate_signature_structure(xml_content: &str) -> Result<(), String> {
314    let signature_xml = extract_first_element(xml_content, "Signature")
315        .ok_or_else(|| "missing Signature element".to_string())?;
316
317    if extract_first_element(&signature_xml, "SignedInfo").is_none() {
318        return Err("missing SignedInfo element".to_string());
319    }
320
321    let signature_value_raw = extract_first_element_text(&signature_xml, "SignatureValue")
322        .ok_or_else(|| "missing SignatureValue element".to_string())?;
323    let signature_value = collapse_xml_text(&signature_value_raw);
324    if signature_value.is_empty() {
325        return Err("SignatureValue is empty".to_string());
326    }
327    let decoded = base64::engine::general_purpose::STANDARD
328        .decode(signature_value.as_bytes())
329        .map_err(|e| format!("invalid SignatureValue base64: {}", e))?;
330    if decoded.is_empty() {
331        return Err("SignatureValue decodes to zero bytes".to_string());
332    }
333
334    if let Some(signature_method_alg) = extract_signature_method_algorithm(&signature_xml) {
335        let is_supported = matches!(
336            signature_method_alg.as_str(),
337            "http://www.w3.org/2000/09/xmldsig#rsa-sha1"
338                | "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"
339                | "http://www.w3.org/2001/04/xmldsig-more#rsa-sha384"
340                | "http://www.w3.org/2001/04/xmldsig-more#rsa-sha512"
341        );
342        if !is_supported {
343            return Err(format!(
344                "unsupported SignatureMethod algorithm: {}",
345                signature_method_alg
346            ));
347        }
348    }
349
350    Ok(())
351}
352
353fn validate_reference_digests(xml_content: &str) -> Result<(), String> {
354    let signature_xml = extract_first_element(xml_content, "Signature")
355        .ok_or_else(|| "missing Signature element".to_string())?;
356
357    let references = extract_reference_entries(&signature_xml)?;
358    if references.is_empty() {
359        return Err("SignedInfo contains no Reference elements".to_string());
360    }
361
362    for reference in references {
363        let digest_algorithm = HashAlgorithm::from_uri(&reference.digest_method_algorithm)
364            .ok_or_else(|| {
365                format!(
366                    "unsupported DigestMethod algorithm: {}",
367                    reference.digest_method_algorithm
368                )
369            })?;
370
371        let expected_digest = base64::engine::general_purpose::STANDARD
372            .decode(reference.digest_value.as_bytes())
373            .map_err(|e| format!("invalid DigestValue base64: {}", e))?;
374
375        if expected_digest.len() != digest_algorithm.digest_len() {
376            return Err(format!(
377                "DigestValue length {} does not match {} digest length {}",
378                expected_digest.len(),
379                digest_algorithm,
380                digest_algorithm.digest_len()
381            ));
382        }
383
384        match reference.uri.as_deref().unwrap_or("") {
385            "" => {
386                let unsigned_xml = strip_first_signature_element(xml_content)
387                    .ok_or_else(|| "failed to remove Signature element for URI=\"\"".to_string())?;
388                let normalized = normalize_xml_for_digest(&unsigned_xml);
389                let actual_digest = compute_hash(digest_algorithm, normalized.as_bytes());
390                if actual_digest != expected_digest {
391                    return Err(format!(
392                        "DigestValue mismatch for Reference URI=\"\" (algorithm {})",
393                        digest_algorithm
394                    ));
395                }
396            }
397            uri if uri.starts_with('#') => {}
398            uri => {
399                return Err(format!(
400                    "unsupported Reference URI '{}'; only empty or fragment URIs are supported",
401                    uri
402                ));
403            }
404        }
405    }
406
407    Ok(())
408}
409
410fn compute_hash(algorithm: HashAlgorithm, bytes: &[u8]) -> Vec<u8> {
411    match algorithm {
412        HashAlgorithm::Sha1 => {
413            use sha1::Digest;
414            let mut hasher = sha1::Sha1::new();
415            hasher.update(bytes);
416            hasher.finalize().to_vec()
417        }
418        HashAlgorithm::Sha256 => {
419            use sha2::Digest;
420            let mut hasher = sha2::Sha256::new();
421            hasher.update(bytes);
422            hasher.finalize().to_vec()
423        }
424    }
425}
426
427#[derive(Debug, Clone)]
428struct SignatureReferenceEntry {
429    uri: Option<String>,
430    digest_method_algorithm: String,
431    digest_value: String,
432}
433
434fn extract_signature_method_algorithm(signature_xml: &str) -> Option<String> {
435    use std::sync::LazyLock;
436    static RE: LazyLock<regex::Regex> = LazyLock::new(|| {
437        regex::Regex::new(
438            r#"<(?:(?:\w+):)?SignatureMethod\b[^>]*\bAlgorithm\s*=\s*\"([^\"]+)\"[^>]*/?>"#,
439        )
440        .unwrap()
441    });
442    RE.captures(signature_xml)
443        .and_then(|c| c.get(1).map(|m| m.as_str().trim().to_string()))
444}
445
446fn extract_reference_entries(signature_xml: &str) -> Result<Vec<SignatureReferenceEntry>, String> {
447    use std::sync::LazyLock;
448    static REFERENCE_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
449        regex::Regex::new(r#"(?s)<(?:(?:\w+):)?Reference\b([^>]*)>(.*?)</(?:(?:\w+):)?Reference>"#)
450            .unwrap()
451    });
452    static URI_RE: LazyLock<regex::Regex> =
453        LazyLock::new(|| regex::Regex::new(r#"\bURI\s*=\s*\"([^\"]*)\""#).unwrap());
454    static DIGEST_METHOD_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
455        regex::Regex::new(
456            r#"<(?:(?:\w+):)?DigestMethod\b[^>]*\bAlgorithm\s*=\s*\"([^\"]+)\"[^>]*/?>"#,
457        )
458        .unwrap()
459    });
460    static DIGEST_VALUE_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
461        regex::Regex::new(
462            r#"(?s)<(?:(?:\w+):)?DigestValue\b[^>]*>(.*?)</(?:(?:\w+):)?DigestValue>"#,
463        )
464        .unwrap()
465    });
466
467    let mut out = Vec::new();
468    for captures in REFERENCE_RE.captures_iter(signature_xml) {
469        let attrs = captures
470            .get(1)
471            .map(|m| m.as_str())
472            .ok_or_else(|| "internal parse error while reading Reference attributes".to_string())?;
473        let inner = captures
474            .get(2)
475            .map(|m| m.as_str())
476            .ok_or_else(|| "internal parse error while reading Reference body".to_string())?;
477
478        let uri = URI_RE
479            .captures(attrs)
480            .and_then(|c| c.get(1).map(|m| m.as_str().to_string()));
481        let digest_method_algorithm = DIGEST_METHOD_RE
482            .captures(inner)
483            .and_then(|c| c.get(1).map(|m| m.as_str().trim().to_string()))
484            .ok_or_else(|| "Reference missing DigestMethod/@Algorithm".to_string())?;
485        let digest_value = DIGEST_VALUE_RE
486            .captures(inner)
487            .and_then(|c| c.get(1).map(|m| collapse_xml_text(m.as_str())))
488            .ok_or_else(|| "Reference missing DigestValue".to_string())?;
489
490        out.push(SignatureReferenceEntry {
491            uri,
492            digest_method_algorithm,
493            digest_value,
494        });
495    }
496
497    Ok(out)
498}
499
500fn strip_first_signature_element(xml: &str) -> Option<String> {
501    use std::sync::LazyLock;
502    static RE: LazyLock<regex::Regex> = LazyLock::new(|| {
503        regex::Regex::new(r#"(?s)<(?:(?:\w+):)?Signature\b[^>]*>.*?</(?:(?:\w+):)?Signature\s*>"#)
504            .unwrap()
505    });
506    let m = RE.find(xml)?;
507    let mut out = String::with_capacity(xml.len() - (m.end() - m.start()));
508    out.push_str(&xml[..m.start()]);
509    out.push_str(&xml[m.end()..]);
510    Some(out)
511}
512
513fn normalize_xml_for_digest(xml: &str) -> String {
514    use std::sync::LazyLock;
515    static DECL_RE: LazyLock<regex::Regex> =
516        LazyLock::new(|| regex::Regex::new(r#"(?s)^\s*<\?xml[^>]*\?>"#).unwrap());
517    static INTER_TAG_WS_RE: LazyLock<regex::Regex> =
518        LazyLock::new(|| regex::Regex::new(r#">\s+<"#).unwrap());
519
520    let no_decl = xml.strip_prefix("\u{FEFF}").unwrap_or(xml).trim();
521    let without_decl = DECL_RE.replace(no_decl, "").to_string();
522    INTER_TAG_WS_RE
523        .replace_all(without_decl.trim(), "><")
524        .to_string()
525}
526
527fn extract_first_element(xml: &str, local_name: &str) -> Option<String> {
528    let escaped = regex::escape(local_name);
529    let pattern = format!(
530        r#"(?s)<(?:(?:\w+):)?{name}\b[^>]*>.*?</(?:(?:\w+):)?{name}\s*>"#,
531        name = escaped
532    );
533    // Dynamic pattern from local_name — cannot use LazyLock
534    let re = regex::Regex::new(&pattern).expect("valid regex pattern");
535    re.find(xml).map(|m| m.as_str().to_string())
536}
537
538fn extract_first_element_text(xml: &str, local_name: &str) -> Option<String> {
539    let escaped = regex::escape(local_name);
540    let pattern = format!(
541        r#"(?s)<(?:(?:\w+):)?{name}\b[^>]*>(.*?)</(?:(?:\w+):)?{name}\s*>"#,
542        name = escaped
543    );
544    // Dynamic pattern from local_name — cannot use LazyLock
545    let re = regex::Regex::new(&pattern).expect("valid regex pattern");
546    re.captures(xml)
547        .and_then(|c| c.get(1).map(|m| m.as_str().to_string()))
548}
549
550fn collapse_xml_text(text: &str) -> String {
551    text.chars().filter(|c| !c.is_whitespace()).collect()
552}
553
554// =============================================================================
555// Serde deserialization helpers
556// =============================================================================
557
558mod de_helpers {
559    use crate::cpl::{
560        CodingEquations, ColorPrimaries, EditRate, LanguageTag, McaTagSymbol,
561        TransferCharacteristic, VideoCodec,
562    };
563    use serde::{Deserialize, Deserializer};
564
565    pub fn de_optional_edit_rate<'de, D: Deserializer<'de>>(
566        d: D,
567    ) -> Result<Option<EditRate>, D::Error> {
568        let s = String::deserialize(d)?;
569        let trimmed = s.trim();
570        if trimmed.is_empty() {
571            Ok(None)
572        } else {
573            // Support both space-separated ("60000 1001") and slash-separated ("60000/1001") formats
574            let normalized = trimmed.replace('/', " ");
575            EditRate::parse(&normalized)
576                .map(Some)
577                .map_err(serde::de::Error::custom)
578        }
579    }
580
581    /// Shared helper: deserialize an optional string, trim, and apply a converter if non-empty.
582    fn de_optional_ul_type<'de, D, T, F>(d: D, from_ul: F) -> Result<Option<T>, D::Error>
583    where
584        D: Deserializer<'de>,
585        F: FnOnce(&str) -> T,
586    {
587        let s = String::deserialize(d)?;
588        Ok(if s.trim().is_empty() {
589            None
590        } else {
591            Some(from_ul(s.trim()))
592        })
593    }
594
595    pub fn de_optional_color_primaries<'de, D: Deserializer<'de>>(
596        d: D,
597    ) -> Result<Option<ColorPrimaries>, D::Error> {
598        de_optional_ul_type(d, ColorPrimaries::from_ul)
599    }
600
601    pub fn de_optional_transfer_characteristic<'de, D: Deserializer<'de>>(
602        d: D,
603    ) -> Result<Option<TransferCharacteristic>, D::Error> {
604        de_optional_ul_type(d, TransferCharacteristic::from_ul)
605    }
606
607    pub fn de_optional_video_codec<'de, D: Deserializer<'de>>(
608        d: D,
609    ) -> Result<Option<VideoCodec>, D::Error> {
610        de_optional_ul_type(d, VideoCodec::from_ul)
611    }
612
613    pub fn de_optional_coding_equations<'de, D: Deserializer<'de>>(
614        d: D,
615    ) -> Result<Option<CodingEquations>, D::Error> {
616        de_optional_ul_type(d, CodingEquations::from_ul)
617    }
618
619    pub fn de_optional_mca_tag_symbol<'de, D: Deserializer<'de>>(
620        d: D,
621    ) -> Result<Option<McaTagSymbol>, D::Error> {
622        let s = String::deserialize(d)?;
623        Ok(if s.trim().is_empty() {
624            None
625        } else {
626            Some(McaTagSymbol::parse(s.trim()))
627        })
628    }
629
630    pub fn de_optional_language_tag<'de, D: Deserializer<'de>>(
631        d: D,
632    ) -> Result<Option<LanguageTag>, D::Error> {
633        let s = String::deserialize(d)?;
634        let trimmed = s.trim();
635        if trimmed.is_empty() {
636            Ok(None)
637        } else {
638            LanguageTag::parse(trimmed)
639                .map(Some)
640                .map_err(serde::de::Error::custom)
641        }
642    }
643
644    /// ColorSiting may be numeric (0 = CoSiting) or a label string ("CoSiting").
645    /// Maps known label strings to their MXF numeric values; unknown strings → None.
646    pub fn de_optional_color_siting<'de, D: Deserializer<'de>>(
647        d: D,
648    ) -> Result<Option<u32>, D::Error> {
649        let s = String::deserialize(d)?;
650        let s = s.trim();
651        if s.is_empty() {
652            return Ok(None);
653        }
654        if let Ok(n) = s.parse::<u32>() {
655            return Ok(Some(n));
656        }
657        let v = match s.to_lowercase().as_str() {
658            "cositing" => 0,
659            "horizcositing" => 1,
660            "threetap" => 2,
661            "quincunx" => 3,
662            "rec709" => 4,
663            "rec601" => 6,
664            _ => return Ok(None),
665        };
666        Ok(Some(v))
667    }
668
669    pub fn de_language_tag_list<'de, D: Deserializer<'de>>(
670        d: D,
671    ) -> Result<Vec<LanguageTag>, D::Error> {
672        let s = String::deserialize(d)?;
673        s.split(',')
674            .map(|part| part.trim())
675            .filter(|part| !part.is_empty())
676            .map(|part| LanguageTag::parse(part).map_err(serde::de::Error::custom))
677            .collect()
678    }
679}
680
681/// Default content kind when not specified
682fn default_content_kind() -> ContentKindElement {
683    ContentKindElement {
684        kind: ContentKind::Other("unknown".to_string()),
685        scope: None,
686    }
687}
688
689// =============================================================================
690// ContentKindElement — text + @scope attribute per CPL XSD ContentKindType
691// =============================================================================
692
693/// Default scope URI for ContentKind per CPL XSD (ST 2067-3).
694pub const CONTENT_KIND_DEFAULT_SCOPE: &str =
695    "http://www.smpte-ra.org/schemas/2067-3/2013#content-kind";
696
697/// ContentKind element with optional `scope` attribute, per CPL XSD `ContentKindType`.
698///
699/// ```xml
700/// <ContentKind scope="http://www.smpte-ra.org/schemas/2067-3/2013#content-kind">feature</ContentKind>
701/// ```
702///
703/// When `scope` is `None`, the XSD default applies: [`CONTENT_KIND_DEFAULT_SCOPE`].
704#[derive(Debug, Clone, PartialEq, Eq)]
705#[cfg_attr(feature = "typescript", derive(TS))]
706#[cfg_attr(feature = "typescript", ts(export, rename_all = "camelCase"))]
707#[cfg_attr(feature = "wasm", derive(Tsify))]
708#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))]
709pub struct ContentKindElement {
710    pub kind: ContentKind,
711    /// Scope URI. `None` means the XSD default applies.
712    pub scope: Option<String>,
713}
714
715impl ContentKindElement {
716    /// Returns the effective scope, falling back to the XSD default.
717    pub fn effective_scope(&self) -> &str {
718        self.scope.as_deref().unwrap_or(CONTENT_KIND_DEFAULT_SCOPE)
719    }
720}
721
722impl std::fmt::Display for ContentKindElement {
723    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
724        self.kind.fmt(f)
725    }
726}
727
728impl PartialEq<ContentKind> for ContentKindElement {
729    fn eq(&self, other: &ContentKind) -> bool {
730        self.kind == *other
731    }
732}
733
734impl From<ContentKind> for ContentKindElement {
735    fn from(kind: ContentKind) -> Self {
736        Self { kind, scope: None }
737    }
738}
739
740impl<'de> Deserialize<'de> for ContentKindElement {
741    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
742    where
743        D: serde::Deserializer<'de>,
744    {
745        use serde::de::{self, MapAccess, Visitor};
746        use std::fmt;
747
748        struct ContentKindElementVisitor;
749
750        impl<'de> Visitor<'de> for ContentKindElementVisitor {
751            type Value = ContentKindElement;
752
753            fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
754                formatter.write_str(
755                    "a string or an object with text content and optional @scope attribute",
756                )
757            }
758
759            fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
760            where
761                E: de::Error,
762            {
763                Ok(ContentKindElement {
764                    kind: ContentKind::parse(value),
765                    scope: None,
766                })
767            }
768
769            fn visit_map<M>(self, mut map: M) -> Result<Self::Value, M::Error>
770            where
771                M: MapAccess<'de>,
772            {
773                let mut text = None;
774                let mut scope = None;
775
776                while let Some(key) = map.next_key::<String>()? {
777                    match key.as_str() {
778                        "$text" | "#text" | "$value" => {
779                            if text.is_some() {
780                                return Err(de::Error::duplicate_field("text"));
781                            }
782                            text = Some(map.next_value::<String>()?);
783                        }
784                        "@scope" | "scope" => {
785                            if scope.is_some() {
786                                return Err(de::Error::duplicate_field("scope"));
787                            }
788                            let raw: String = map.next_value()?;
789                            let trimmed = raw.trim();
790                            if !trimmed.is_empty() {
791                                scope = Some(trimmed.to_string());
792                            }
793                        }
794                        _ => {
795                            let _ = map.next_value::<serde::de::IgnoredAny>()?;
796                        }
797                    }
798                }
799
800                let kind = ContentKind::parse(text.as_deref().unwrap_or(""));
801                Ok(ContentKindElement { kind, scope })
802            }
803        }
804
805        deserializer.deserialize_any(ContentKindElementVisitor)
806    }
807}
808
809impl Serialize for ContentKindElement {
810    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
811    where
812        S: serde::Serializer,
813    {
814        use serde::ser::SerializeStruct;
815
816        if self.scope.is_some() {
817            let mut state = serializer.serialize_struct("ContentKindElement", 2)?;
818            state.serialize_field("$text", &self.kind.to_string())?;
819            state.serialize_field("@scope", &self.scope)?;
820            state.end()
821        } else {
822            serializer.serialize_str(&self.kind.to_string())
823        }
824    }
825}
826
827#[cfg(feature = "jsonschema")]
828impl schemars::JsonSchema for ContentKindElement {
829    fn schema_name() -> String {
830        "ContentKindElement".to_owned()
831    }
832
833    fn json_schema(gen: &mut schemars::gen::SchemaGenerator) -> schemars::schema::Schema {
834        use schemars::schema::*;
835
836        let string_schema = gen.subschema_for::<String>();
837        let mut obj = SchemaObject {
838            instance_type: Some(InstanceType::Object.into()),
839            ..Default::default()
840        };
841        let obj_validation = obj.object();
842        obj_validation
843            .properties
844            .insert("$text".to_owned(), gen.subschema_for::<String>());
845        obj_validation
846            .properties
847            .insert("@scope".to_owned(), gen.subschema_for::<Option<String>>());
848        obj_validation.required.insert("$text".to_owned());
849
850        SchemaObject {
851            subschemas: Some(Box::new(SubschemaValidation {
852                any_of: Some(vec![string_schema, obj.into()]),
853                ..Default::default()
854            })),
855            ..Default::default()
856        }
857        .into()
858    }
859}
860
861// =============================================================================
862// MarkerLabelElement — text + @scope attribute per CPL XSD LabelType
863// =============================================================================
864
865/// Default scope URI for MarkerLabel per CPL XSD (ST 2067-3).
866pub const MARKER_LABEL_DEFAULT_SCOPE: &str =
867    "http://www.smpte-ra.org/schemas/2067-3/2013#standard-markers";
868
869/// Marker Label element with optional `scope` attribute, per CPL XSD `LabelType`.
870///
871/// ```xml
872/// <Label scope="http://www.smpte-ra.org/schemas/2067-3/2013#standard-markers">FFOC</Label>
873/// ```
874///
875/// When `scope` is `None`, the XSD default applies: [`MARKER_LABEL_DEFAULT_SCOPE`].
876#[derive(Debug, Clone, PartialEq, Eq)]
877#[cfg_attr(feature = "typescript", derive(TS))]
878#[cfg_attr(feature = "typescript", ts(export, rename_all = "camelCase"))]
879#[cfg_attr(feature = "wasm", derive(Tsify))]
880#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))]
881pub struct MarkerLabelElement {
882    pub label: MarkerLabel,
883    /// Scope URI. `None` means the XSD default applies.
884    pub scope: Option<String>,
885}
886
887impl MarkerLabelElement {
888    /// Returns the effective scope, falling back to the XSD default.
889    pub fn effective_scope(&self) -> &str {
890        self.scope.as_deref().unwrap_or(MARKER_LABEL_DEFAULT_SCOPE)
891    }
892}
893
894impl std::fmt::Display for MarkerLabelElement {
895    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
896        self.label.fmt(f)
897    }
898}
899
900impl PartialEq<MarkerLabel> for MarkerLabelElement {
901    fn eq(&self, other: &MarkerLabel) -> bool {
902        self.label == *other
903    }
904}
905
906impl From<MarkerLabel> for MarkerLabelElement {
907    fn from(label: MarkerLabel) -> Self {
908        Self { label, scope: None }
909    }
910}
911
912impl<'de> Deserialize<'de> for MarkerLabelElement {
913    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
914    where
915        D: serde::Deserializer<'de>,
916    {
917        use serde::de::{self, MapAccess, Visitor};
918        use std::fmt;
919
920        struct MarkerLabelElementVisitor;
921
922        impl<'de> Visitor<'de> for MarkerLabelElementVisitor {
923            type Value = MarkerLabelElement;
924
925            fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
926                formatter.write_str(
927                    "a string or an object with text content and optional @scope attribute",
928                )
929            }
930
931            fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
932            where
933                E: de::Error,
934            {
935                Ok(MarkerLabelElement {
936                    label: MarkerLabel::parse(value),
937                    scope: None,
938                })
939            }
940
941            fn visit_map<M>(self, mut map: M) -> Result<Self::Value, M::Error>
942            where
943                M: MapAccess<'de>,
944            {
945                let mut text = None;
946                let mut scope = None;
947
948                while let Some(key) = map.next_key::<String>()? {
949                    match key.as_str() {
950                        "$text" | "#text" | "$value" => {
951                            if text.is_some() {
952                                return Err(de::Error::duplicate_field("text"));
953                            }
954                            text = Some(map.next_value::<String>()?);
955                        }
956                        "@scope" | "scope" => {
957                            if scope.is_some() {
958                                return Err(de::Error::duplicate_field("scope"));
959                            }
960                            let raw: String = map.next_value()?;
961                            let trimmed = raw.trim();
962                            if !trimmed.is_empty() {
963                                scope = Some(trimmed.to_string());
964                            }
965                        }
966                        _ => {
967                            let _ = map.next_value::<serde::de::IgnoredAny>()?;
968                        }
969                    }
970                }
971
972                let label = MarkerLabel::parse(text.as_deref().unwrap_or(""));
973                Ok(MarkerLabelElement { label, scope })
974            }
975        }
976
977        deserializer.deserialize_any(MarkerLabelElementVisitor)
978    }
979}
980
981impl Serialize for MarkerLabelElement {
982    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
983    where
984        S: serde::Serializer,
985    {
986        use serde::ser::SerializeStruct;
987
988        if self.scope.is_some() {
989            let mut state = serializer.serialize_struct("MarkerLabelElement", 2)?;
990            state.serialize_field("$text", &self.label.to_string())?;
991            state.serialize_field("@scope", &self.scope)?;
992            state.end()
993        } else {
994            serializer.serialize_str(&self.label.to_string())
995        }
996    }
997}
998
999#[cfg(feature = "jsonschema")]
1000impl schemars::JsonSchema for MarkerLabelElement {
1001    fn schema_name() -> String {
1002        "MarkerLabelElement".to_owned()
1003    }
1004
1005    fn json_schema(gen: &mut schemars::gen::SchemaGenerator) -> schemars::schema::Schema {
1006        use schemars::schema::*;
1007
1008        let string_schema = gen.subschema_for::<String>();
1009        let mut obj = SchemaObject {
1010            instance_type: Some(InstanceType::Object.into()),
1011            ..Default::default()
1012        };
1013        let obj_validation = obj.object();
1014        obj_validation
1015            .properties
1016            .insert("$text".to_owned(), gen.subschema_for::<String>());
1017        obj_validation
1018            .properties
1019            .insert("@scope".to_owned(), gen.subschema_for::<Option<String>>());
1020        obj_validation.required.insert("$text".to_owned());
1021
1022        SchemaObject {
1023            subschemas: Some(Box::new(SubschemaValidation {
1024                any_of: Some(vec![string_schema, obj.into()]),
1025                ..Default::default()
1026            })),
1027            ..Default::default()
1028        }
1029        .into()
1030    }
1031}
1032
1033// =============================================================================
1034// XML namespace stripping
1035// =============================================================================
1036
1037/// Strip XML namespace prefixes so quick-xml/serde can match element names uniformly.
1038///
1039/// Converts `<r0:RGBADescriptor>` → `<RGBADescriptor>`, `</cc:MainImageSequence>` → `</MainImageSequence>`, etc.
1040/// Also strips `xmlns:prefix="..."` declarations (but preserves default `xmlns="..."`).
1041///
1042/// This is the same approach used by the TypeScript mapper (fast-xml-parser namespace stripping).
1043pub fn strip_xml_namespaces(xml: &str) -> String {
1044    use std::sync::LazyLock;
1045    // Strip namespace prefixes from element names: <ns:Element → <Element, </ns:Element → </Element
1046    static TAG_PREFIX_RE: LazyLock<regex::Regex> =
1047        LazyLock::new(|| regex::Regex::new(r"<(/?)(\w+):(\w)").unwrap());
1048    let result = TAG_PREFIX_RE.replace_all(xml, "<$1$3");
1049    // Strip xmlns:prefix="..." attribute declarations
1050    static XMLNS_PREFIX_RE: LazyLock<regex::Regex> =
1051        LazyLock::new(|| regex::Regex::new(r#"\s+xmlns:\w+="[^"]*""#).unwrap());
1052    XMLNS_PREFIX_RE.replace_all(&result, "").to_string()
1053}
1054
1055// =============================================================================
1056// LanguageString - String with optional language attribute
1057// =============================================================================
1058
1059/// String with optional language attribute
1060#[derive(Debug, Default, PartialEq, Clone)]
1061#[cfg_attr(feature = "typescript", derive(TS))]
1062#[cfg_attr(feature = "typescript", ts(export, rename_all = "camelCase"))]
1063#[cfg_attr(feature = "wasm", derive(Tsify))]
1064#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))]
1065pub struct LanguageString {
1066    pub text: String,
1067    pub language: Option<LanguageTag>, // RFC 5646 language tag
1068}
1069
1070impl std::fmt::Display for LanguageString {
1071    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1072        if let Some(lang) = &self.language {
1073            write!(f, "{} ({})", self.text, lang)
1074        } else {
1075            write!(f, "{}", self.text)
1076        }
1077    }
1078}
1079
1080// Helper to deserialize plain strings as LanguageString
1081impl<'de> Deserialize<'de> for LanguageString {
1082    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1083    where
1084        D: serde::Deserializer<'de>,
1085    {
1086        use serde::de::{self, MapAccess, Visitor};
1087        use std::fmt;
1088
1089        struct LanguageStringVisitor;
1090
1091        impl<'de> Visitor<'de> for LanguageStringVisitor {
1092            type Value = LanguageString;
1093
1094            fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
1095                formatter.write_str("a string or an object with text and optional language")
1096            }
1097
1098            fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
1099            where
1100                E: de::Error,
1101            {
1102                Ok(LanguageString {
1103                    text: value.to_string(),
1104                    language: None,
1105                })
1106            }
1107
1108            fn visit_map<M>(self, mut map: M) -> Result<Self::Value, M::Error>
1109            where
1110                M: MapAccess<'de>,
1111            {
1112                let mut text = None;
1113                let mut language = None;
1114
1115                while let Some(key) = map.next_key::<String>()? {
1116                    match key.as_str() {
1117                        "$text" | "#text" | "$value" => {
1118                            if text.is_some() {
1119                                return Err(de::Error::duplicate_field("text"));
1120                            }
1121                            text = Some(map.next_value()?);
1122                        }
1123                        "@language" | "language" => {
1124                            if language.is_some() {
1125                                return Err(de::Error::duplicate_field("language"));
1126                            }
1127                            let raw: String = map.next_value()?;
1128                            let trimmed = raw.trim();
1129                            if !trimmed.is_empty() {
1130                                language = Some(LanguageTag::new(trimmed));
1131                            }
1132                        }
1133                        _ => {
1134                            // Ignore unknown fields
1135                            let _ = map.next_value::<serde::de::IgnoredAny>()?;
1136                        }
1137                    }
1138                }
1139
1140                Ok(LanguageString {
1141                    text: text.unwrap_or_default(),
1142                    language,
1143                })
1144            }
1145        }
1146
1147        deserializer.deserialize_any(LanguageStringVisitor)
1148    }
1149}
1150
1151impl Serialize for LanguageString {
1152    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
1153    where
1154        S: serde::Serializer,
1155    {
1156        use serde::ser::SerializeStruct;
1157
1158        if self.language.is_some() {
1159            let mut state = serializer.serialize_struct("LanguageString", 2)?;
1160            state.serialize_field("$text", &self.text)?;
1161            state.serialize_field("@language", &self.language)?;
1162            state.end()
1163        } else {
1164            serializer.serialize_str(&self.text)
1165        }
1166    }
1167}
1168
1169#[cfg(feature = "jsonschema")]
1170impl schemars::JsonSchema for LanguageString {
1171    fn schema_name() -> String {
1172        "LanguageString".to_owned()
1173    }
1174
1175    fn json_schema(gen: &mut schemars::gen::SchemaGenerator) -> schemars::schema::Schema {
1176        use schemars::schema::*;
1177
1178        let string_schema = gen.subschema_for::<String>();
1179        let mut obj = SchemaObject {
1180            instance_type: Some(InstanceType::Object.into()),
1181            ..Default::default()
1182        };
1183        let obj_validation = obj.object();
1184        obj_validation
1185            .properties
1186            .insert("$text".to_owned(), gen.subschema_for::<String>());
1187        obj_validation.properties.insert(
1188            "@language".to_owned(),
1189            gen.subschema_for::<Option<String>>(),
1190        );
1191        obj_validation.required.insert("$text".to_owned());
1192
1193        SchemaObject {
1194            subschemas: Some(Box::new(SubschemaValidation {
1195                any_of: Some(vec![string_schema, obj.into()]),
1196                ..Default::default()
1197            })),
1198            ..Default::default()
1199        }
1200        .into()
1201    }
1202}
1203
1204// =============================================================================
1205// Locale types
1206// =============================================================================
1207
1208/// LocaleList - Content locale information
1209#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
1210#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
1211#[cfg_attr(feature = "typescript", derive(TS))]
1212#[cfg_attr(feature = "typescript", ts(export, rename_all = "camelCase"))]
1213#[cfg_attr(feature = "wasm", derive(Tsify))]
1214#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))]
1215pub struct LocaleList {
1216    #[cfg_attr(not(feature = "wasm"), serde(rename = "Locale", default))]
1217    #[cfg_attr(feature = "wasm", serde(rename = "locales", alias = "Locale", default))]
1218    pub locales: Vec<Locale>,
1219}
1220
1221#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
1222#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
1223#[cfg_attr(feature = "typescript", derive(TS))]
1224#[cfg_attr(feature = "typescript", ts(export, rename_all = "camelCase"))]
1225#[cfg_attr(feature = "wasm", derive(Tsify))]
1226#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))]
1227pub struct Locale {
1228    #[cfg_attr(not(feature = "wasm"), serde(rename = "LanguageList", default))]
1229    #[cfg_attr(
1230        feature = "wasm",
1231        serde(rename = "languageList", alias = "LanguageList", default)
1232    )]
1233    pub language_list: Option<LanguageList>,
1234
1235    #[cfg_attr(not(feature = "wasm"), serde(rename = "RegionList", default))]
1236    #[cfg_attr(
1237        feature = "wasm",
1238        serde(rename = "regionList", alias = "RegionList", default)
1239    )]
1240    pub region_list: Option<RegionList>,
1241
1242    #[cfg_attr(
1243        not(feature = "wasm"),
1244        serde(rename = "ContentMaturityRatingList", default)
1245    )]
1246    #[cfg_attr(
1247        feature = "wasm",
1248        serde(
1249            rename = "contentMaturityRatingList",
1250            alias = "ContentMaturityRatingList",
1251            default
1252        )
1253    )]
1254    pub content_maturity_rating_list: Option<ContentMaturityRatingList>,
1255}
1256
1257#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
1258#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
1259#[cfg_attr(feature = "typescript", derive(TS))]
1260#[cfg_attr(feature = "typescript", ts(export, rename_all = "camelCase"))]
1261#[cfg_attr(feature = "wasm", derive(Tsify))]
1262#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))]
1263pub struct ContentMaturityRatingList {
1264    #[cfg_attr(not(feature = "wasm"), serde(rename = "ContentMaturityRating"))]
1265    #[cfg_attr(
1266        feature = "wasm",
1267        serde(rename = "contentMaturityRatings", alias = "ContentMaturityRating")
1268    )]
1269    pub ratings: Vec<ContentMaturityRating>,
1270}
1271
1272/// A single content maturity rating entry per ST 2067-3 / ST 2067-21 §5.1.3.
1273#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
1274#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
1275#[cfg_attr(feature = "typescript", derive(TS))]
1276#[cfg_attr(feature = "typescript", ts(export, rename_all = "camelCase"))]
1277#[cfg_attr(feature = "wasm", derive(Tsify))]
1278#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))]
1279pub struct ContentMaturityRating {
1280    #[cfg_attr(not(feature = "wasm"), serde(rename = "Agency"))]
1281    #[cfg_attr(feature = "wasm", serde(rename = "agency", alias = "Agency"))]
1282    pub agency: String,
1283
1284    #[cfg_attr(not(feature = "wasm"), serde(rename = "Rating", default))]
1285    #[cfg_attr(feature = "wasm", serde(rename = "rating", alias = "Rating", default))]
1286    pub rating: Option<String>,
1287
1288    #[cfg_attr(not(feature = "wasm"), serde(rename = "Audience", default))]
1289    #[cfg_attr(
1290        feature = "wasm",
1291        serde(rename = "audience", alias = "Audience", default)
1292    )]
1293    pub audience: Option<AudienceElement>,
1294}
1295
1296/// The `<Audience>` element carries an optional `scope` attribute.
1297#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
1298#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
1299#[cfg_attr(feature = "typescript", derive(TS))]
1300#[cfg_attr(feature = "typescript", ts(export, rename_all = "camelCase"))]
1301#[cfg_attr(feature = "wasm", derive(Tsify))]
1302#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))]
1303pub struct AudienceElement {
1304    #[serde(rename = "@scope", default)]
1305    pub scope: Option<String>,
1306
1307    #[serde(rename = "$text", default)]
1308    pub text: Option<String>,
1309}
1310
1311#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
1312#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
1313#[cfg_attr(feature = "typescript", derive(TS))]
1314#[cfg_attr(feature = "typescript", ts(export, rename_all = "camelCase"))]
1315#[cfg_attr(feature = "wasm", derive(Tsify))]
1316#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))]
1317pub struct LanguageList {
1318    #[cfg_attr(not(feature = "wasm"), serde(rename = "Language"))]
1319    #[cfg_attr(feature = "wasm", serde(rename = "languages", alias = "Language"))]
1320    pub languages: Vec<LanguageTag>, // RFC 5646 language tags
1321}
1322
1323#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
1324#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
1325#[cfg_attr(feature = "typescript", derive(TS))]
1326#[cfg_attr(feature = "typescript", ts(export, rename_all = "camelCase"))]
1327#[cfg_attr(feature = "wasm", derive(Tsify))]
1328#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))]
1329pub struct RegionList {
1330    #[cfg_attr(not(feature = "wasm"), serde(rename = "Region"))]
1331    #[cfg_attr(feature = "wasm", serde(rename = "regions", alias = "Region"))]
1332    pub regions: Vec<String>, // ISO 3166-1 country codes
1333}
1334
1335// =============================================================================
1336// Extension Properties
1337// =============================================================================
1338
1339#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
1340#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
1341#[cfg_attr(feature = "typescript", derive(TS))]
1342#[cfg_attr(feature = "typescript", ts(export, rename_all = "camelCase"))]
1343#[cfg_attr(feature = "wasm", derive(Tsify))]
1344#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))]
1345pub struct ExtensionProperties {
1346    #[cfg_attr(
1347        not(feature = "wasm"),
1348        serde(rename = "ApplicationIdentification", default)
1349    )]
1350    #[cfg_attr(
1351        feature = "wasm",
1352        serde(
1353            rename = "applicationIdentification",
1354            alias = "ApplicationIdentification",
1355            default
1356        )
1357    )]
1358    pub application_identification: Option<String>,
1359
1360    #[cfg_attr(not(feature = "wasm"), serde(rename = "MaxCLL", default))]
1361    #[cfg_attr(feature = "wasm", serde(rename = "maxCLL", alias = "MaxCLL", default))]
1362    pub max_cll: Option<u32>,
1363
1364    #[cfg_attr(not(feature = "wasm"), serde(rename = "MaxFALL", default))]
1365    #[cfg_attr(
1366        feature = "wasm",
1367        serde(rename = "maxFALL", alias = "MaxFALL", default)
1368    )]
1369    pub max_fall: Option<u32>,
1370}
1371
1372// =============================================================================
1373// EssenceDescriptor types - proper deserialization of MXF metadata
1374// =============================================================================
1375
1376#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
1377#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
1378#[cfg_attr(feature = "typescript", derive(TS))]
1379#[cfg_attr(feature = "typescript", ts(export, rename_all = "camelCase"))]
1380#[cfg_attr(feature = "wasm", derive(Tsify))]
1381#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))]
1382pub struct EssenceDescriptorList {
1383    #[cfg_attr(not(feature = "wasm"), serde(rename = "EssenceDescriptor"))]
1384    #[cfg_attr(
1385        feature = "wasm",
1386        serde(rename = "essenceDescriptors", alias = "EssenceDescriptor")
1387    )]
1388    pub essence_descriptors: Vec<EssenceDescriptor>,
1389}
1390
1391#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
1392#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
1393#[cfg_attr(feature = "typescript", derive(TS))]
1394#[cfg_attr(feature = "typescript", ts(export, rename_all = "camelCase"))]
1395#[cfg_attr(feature = "wasm", derive(Tsify))]
1396#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))]
1397pub struct EssenceDescriptor {
1398    #[cfg_attr(not(feature = "wasm"), serde(rename = "Id"))]
1399    #[cfg_attr(feature = "wasm", serde(rename = "id", alias = "Id"))]
1400    pub id: ImfUuid,
1401
1402    #[cfg_attr(not(feature = "wasm"), serde(rename = "RGBADescriptor", default))]
1403    #[cfg_attr(
1404        feature = "wasm",
1405        serde(rename = "rgbaDescriptor", alias = "RGBADescriptor", default)
1406    )]
1407    pub rgba_descriptor: Option<RGBADescriptor>,
1408
1409    #[cfg_attr(not(feature = "wasm"), serde(rename = "CDCIDescriptor", default))]
1410    #[cfg_attr(
1411        feature = "wasm",
1412        serde(rename = "cdciDescriptor", alias = "CDCIDescriptor", default)
1413    )]
1414    pub cdci_descriptor: Option<CDCIDescriptor>,
1415
1416    #[cfg_attr(not(feature = "wasm"), serde(rename = "WAVEPCMDescriptor", default))]
1417    #[cfg_attr(
1418        feature = "wasm",
1419        serde(rename = "wavePCMDescriptor", alias = "WAVEPCMDescriptor", default)
1420    )]
1421    pub wave_pcm_descriptor: Option<WAVEPCMDescriptor>,
1422
1423    #[cfg_attr(
1424        not(feature = "wasm"),
1425        serde(rename = "DCTimedTextDescriptor", default)
1426    )]
1427    #[cfg_attr(
1428        feature = "wasm",
1429        serde(
1430            rename = "dcTimedTextDescriptor",
1431            alias = "DCTimedTextDescriptor",
1432            default
1433        )
1434    )]
1435    pub dc_timed_text_descriptor: Option<DCTimedTextDescriptor>,
1436
1437    #[cfg_attr(not(feature = "wasm"), serde(rename = "IABEssenceDescriptor", default))]
1438    #[cfg_attr(
1439        feature = "wasm",
1440        serde(
1441            rename = "iabEssenceDescriptor",
1442            alias = "IABEssenceDescriptor",
1443            default
1444        )
1445    )]
1446    pub iab_essence_descriptor: Option<IABEssenceDescriptor>,
1447
1448    #[cfg_attr(
1449        not(feature = "wasm"),
1450        serde(rename = "ISXDDataEssenceDescriptor", default)
1451    )]
1452    #[cfg_attr(
1453        feature = "wasm",
1454        serde(
1455            rename = "isxdDataEssenceDescriptor",
1456            alias = "ISXDDataEssenceDescriptor",
1457            default
1458        )
1459    )]
1460    pub isxd_data_essence_descriptor: Option<ISXDDataEssenceDescriptor>,
1461}
1462
1463/// RGBA video descriptor (JPEG 2000 RGB content)
1464#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
1465#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
1466#[cfg_attr(feature = "typescript", derive(TS))]
1467#[cfg_attr(feature = "typescript", ts(export, rename_all = "camelCase"))]
1468#[cfg_attr(feature = "wasm", derive(Tsify))]
1469#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))]
1470pub struct RGBADescriptor {
1471    #[serde(rename = "InstanceID", default)]
1472    pub instance_id: Option<String>,
1473
1474    #[serde(rename = "DisplayWidth", default)]
1475    pub display_width: Option<u32>,
1476
1477    #[serde(rename = "DisplayHeight", default)]
1478    pub display_height: Option<u32>,
1479
1480    #[serde(rename = "StoredWidth", default)]
1481    pub stored_width: Option<u32>,
1482
1483    #[serde(rename = "StoredHeight", default)]
1484    pub stored_height: Option<u32>,
1485
1486    #[serde(
1487        rename = "SampleRate",
1488        default,
1489        deserialize_with = "de_helpers::de_optional_edit_rate"
1490    )]
1491    pub sample_rate: Option<EditRate>,
1492
1493    #[serde(rename = "ImageAspectRatio", default)]
1494    pub image_aspect_ratio: Option<String>,
1495
1496    #[serde(
1497        rename = "ColorPrimaries",
1498        default,
1499        deserialize_with = "de_helpers::de_optional_color_primaries"
1500    )]
1501    pub color_primaries: Option<ColorPrimaries>,
1502
1503    #[serde(
1504        rename = "TransferCharacteristic",
1505        default,
1506        deserialize_with = "de_helpers::de_optional_transfer_characteristic"
1507    )]
1508    pub transfer_characteristic: Option<TransferCharacteristic>,
1509
1510    #[serde(
1511        rename = "CodingEquations",
1512        default,
1513        deserialize_with = "de_helpers::de_optional_coding_equations"
1514    )]
1515    pub coding_equations: Option<CodingEquations>,
1516
1517    #[serde(
1518        rename = "PictureCompression",
1519        default,
1520        deserialize_with = "de_helpers::de_optional_video_codec"
1521    )]
1522    pub picture_compression: Option<VideoCodec>,
1523
1524    /// Generic Picture Essence Descriptor: Frame Layout
1525    /// "FullFrame" (00h, progressive) or "SeparateFields" (01h, interlaced)
1526    #[serde(rename = "FrameLayout", default)]
1527    pub frame_layout: Option<String>,
1528
1529    /// Generic Picture Essence Descriptor: DisplayF2Offset
1530    #[serde(rename = "DisplayF2Offset", default)]
1531    pub display_f2_offset: Option<i32>,
1532
1533    /// RGBA Descriptor: Component Max Ref (Table 10/11)
1534    #[serde(rename = "ComponentMaxRef", default)]
1535    pub component_max_ref: Option<u32>,
1536
1537    /// RGBA Descriptor: Component Min Ref (Table 10/11)
1538    #[serde(rename = "ComponentMinRef", default)]
1539    pub component_min_ref: Option<u32>,
1540
1541    /// RGBA Descriptor: Scanning Direction (Table 10)
1542    /// Shall be "ScanningDirection_LeftToRightTopToBottom" (00h)
1543    #[serde(rename = "ScanningDirection", default)]
1544    pub scanning_direction: Option<String>,
1545
1546    /// Table 8: StoredF2Offset — shall not be present
1547    #[serde(rename = "StoredF2Offset", default)]
1548    pub stored_f2_offset: Option<i32>,
1549
1550    /// Table 8: SampledWidth — shall not be present or shall be equal to StoredWidth
1551    #[serde(rename = "SampledWidth", default)]
1552    pub sampled_width: Option<u32>,
1553
1554    /// Table 8: SampledHeight — shall not be present or shall be equal to StoredHeight
1555    #[serde(rename = "SampledHeight", default)]
1556    pub sampled_height: Option<u32>,
1557
1558    /// Table 8: SampledXOffset — shall not be present or shall be 0
1559    #[serde(rename = "SampledXOffset", default)]
1560    pub sampled_x_offset: Option<u32>,
1561
1562    /// Table 8: SampledYOffset — shall not be present or shall be 0
1563    #[serde(rename = "SampledYOffset", default)]
1564    pub sampled_y_offset: Option<u32>,
1565
1566    /// Table 8: AlphaTransparency — shall not be present
1567    #[serde(rename = "AlphaTransparency", default)]
1568    pub alpha_transparency: Option<String>,
1569
1570    /// Table 8: ImageAlignmentOffset — shall not be present
1571    #[serde(rename = "ImageAlignmentOffset", default)]
1572    pub image_alignment_offset: Option<u32>,
1573
1574    /// Table 8: ImageStartOffset — shall not be present
1575    #[serde(rename = "ImageStartOffset", default)]
1576    pub image_start_offset: Option<u32>,
1577
1578    /// Table 8: ImageEndOffset — shall not be present
1579    #[serde(rename = "ImageEndOffset", default)]
1580    pub image_end_offset: Option<u32>,
1581
1582    /// Table 8: FieldDominance — shall be present if interlaced, shall not be present if progressive
1583    #[serde(rename = "FieldDominance", default)]
1584    pub field_dominance: Option<u32>,
1585
1586    /// Table 10: AlphaMaxRef — shall not be present
1587    #[serde(rename = "AlphaMaxRef", default)]
1588    pub alpha_max_ref: Option<u32>,
1589
1590    /// Table 10: AlphaMinRef — shall not be present
1591    #[serde(rename = "AlphaMinRef", default)]
1592    pub alpha_min_ref: Option<u32>,
1593
1594    /// Table 10: Palette — shall not be present
1595    #[serde(rename = "Palette", default)]
1596    pub palette: Option<String>,
1597
1598    /// Table 10: PaletteLayout — shall not be present
1599    #[serde(rename = "PaletteLayout", default)]
1600    pub palette_layout: Option<String>,
1601
1602    #[serde(rename = "LinkedTrackID", default)]
1603    pub linked_track_id: Option<u32>,
1604
1605    #[serde(rename = "SubDescriptors", default)]
1606    pub sub_descriptors: Option<VideoSubDescriptors>,
1607}
1608
1609/// CDCI video descriptor (YCbCr content)
1610#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
1611#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
1612#[cfg_attr(feature = "typescript", derive(TS))]
1613#[cfg_attr(feature = "typescript", ts(export, rename_all = "camelCase"))]
1614#[cfg_attr(feature = "wasm", derive(Tsify))]
1615#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))]
1616pub struct CDCIDescriptor {
1617    #[serde(rename = "InstanceUID", alias = "InstanceID", default)]
1618    pub instance_id: Option<String>,
1619
1620    #[serde(rename = "StoredWidth", default)]
1621    pub stored_width: Option<u32>,
1622
1623    #[serde(rename = "StoredHeight", default)]
1624    pub stored_height: Option<u32>,
1625
1626    #[serde(rename = "DisplayWidth", default)]
1627    pub display_width: Option<u32>,
1628
1629    #[serde(rename = "DisplayHeight", default)]
1630    pub display_height: Option<u32>,
1631
1632    #[serde(rename = "ActiveWidth", default)]
1633    pub active_width: Option<u32>,
1634
1635    #[serde(rename = "ActiveHeight", default)]
1636    pub active_height: Option<u32>,
1637
1638    #[serde(
1639        rename = "SampleRate",
1640        default,
1641        deserialize_with = "de_helpers::de_optional_edit_rate"
1642    )]
1643    pub sample_rate: Option<EditRate>,
1644
1645    #[serde(rename = "ImageAspectRatio", default)]
1646    pub image_aspect_ratio: Option<String>,
1647
1648    #[serde(
1649        rename = "ColorPrimaries",
1650        default,
1651        deserialize_with = "de_helpers::de_optional_color_primaries"
1652    )]
1653    pub color_primaries: Option<ColorPrimaries>,
1654
1655    #[serde(
1656        rename = "TransferCharacteristic",
1657        default,
1658        deserialize_with = "de_helpers::de_optional_transfer_characteristic"
1659    )]
1660    pub transfer_characteristic: Option<TransferCharacteristic>,
1661
1662    #[serde(
1663        rename = "CodingEquations",
1664        default,
1665        deserialize_with = "de_helpers::de_optional_coding_equations"
1666    )]
1667    pub coding_equations: Option<CodingEquations>,
1668
1669    #[serde(
1670        rename = "PictureCompression",
1671        default,
1672        deserialize_with = "de_helpers::de_optional_video_codec"
1673    )]
1674    pub picture_compression: Option<VideoCodec>,
1675
1676    #[serde(rename = "ComponentDepth", default)]
1677    pub component_depth: Option<u32>,
1678
1679    /// Generic Picture Essence Descriptor: Frame Layout
1680    /// "FullFrame" (00h, progressive) or "SeparateFields" (01h, interlaced)
1681    #[serde(rename = "FrameLayout", default)]
1682    pub frame_layout: Option<String>,
1683
1684    /// Generic Picture Essence Descriptor: DisplayF2Offset
1685    #[serde(rename = "DisplayF2Offset", default)]
1686    pub display_f2_offset: Option<i32>,
1687
1688    /// CDCI Descriptor: Horizontal Subsampling (Table 12)
1689    /// 1 = 4:4:4, 2 = 4:2:2
1690    #[serde(rename = "HorizontalSubsampling", default)]
1691    pub horizontal_subsampling: Option<u32>,
1692
1693    /// CDCI Descriptor: Vertical Subsampling (Table 12)
1694    /// Shall be 1
1695    #[serde(rename = "VerticalSubsampling", default)]
1696    pub vertical_subsampling: Option<u32>,
1697
1698    /// CDCI Descriptor: Color Siting (Table 12)
1699    /// Shall be 0 (CoSiting) but some encoders write a label string (e.g. "CoSiting").
1700    #[serde(
1701        rename = "ColorSiting",
1702        default,
1703        deserialize_with = "de_helpers::de_optional_color_siting"
1704    )]
1705    pub color_siting: Option<u32>,
1706
1707    /// CDCI Descriptor: Black Ref Level (Table 13)
1708    #[serde(rename = "BlackRefLevel", default)]
1709    pub black_ref_level: Option<u32>,
1710
1711    /// CDCI Descriptor: White Ref Level (Table 13)
1712    #[serde(rename = "WhiteRefLevel", default)]
1713    pub white_ref_level: Option<u32>,
1714
1715    /// CDCI Descriptor: Color Range (Table 13)
1716    #[serde(rename = "ColorRange", default)]
1717    pub color_range: Option<u32>,
1718
1719    /// Table 8: StoredF2Offset — shall not be present
1720    #[serde(rename = "StoredF2Offset", default)]
1721    pub stored_f2_offset: Option<i32>,
1722
1723    /// Table 8: SampledWidth — shall not be present or shall be equal to StoredWidth
1724    #[serde(rename = "SampledWidth", default)]
1725    pub sampled_width: Option<u32>,
1726
1727    /// Table 8: SampledHeight — shall not be present or shall be equal to StoredHeight
1728    #[serde(rename = "SampledHeight", default)]
1729    pub sampled_height: Option<u32>,
1730
1731    /// Table 8: SampledXOffset — shall not be present or shall be 0
1732    #[serde(rename = "SampledXOffset", default)]
1733    pub sampled_x_offset: Option<u32>,
1734
1735    /// Table 8: SampledYOffset — shall not be present or shall be 0
1736    #[serde(rename = "SampledYOffset", default)]
1737    pub sampled_y_offset: Option<u32>,
1738
1739    /// Table 8: AlphaTransparency — shall not be present
1740    #[serde(rename = "AlphaTransparency", default)]
1741    pub alpha_transparency: Option<String>,
1742
1743    /// Table 8: ImageAlignmentOffset — shall not be present
1744    #[serde(rename = "ImageAlignmentOffset", default)]
1745    pub image_alignment_offset: Option<u32>,
1746
1747    /// Table 8: ImageStartOffset — shall not be present
1748    #[serde(rename = "ImageStartOffset", default)]
1749    pub image_start_offset: Option<u32>,
1750
1751    /// Table 8: ImageEndOffset — shall not be present
1752    #[serde(rename = "ImageEndOffset", default)]
1753    pub image_end_offset: Option<u32>,
1754
1755    /// Table 8: FieldDominance — shall be present if interlaced, shall not be present if progressive
1756    #[serde(rename = "FieldDominance", default)]
1757    pub field_dominance: Option<u32>,
1758
1759    /// Table 12: ReversedByteOrder — shall not be present
1760    #[serde(rename = "ReversedByteOrder", default)]
1761    pub reversed_byte_order: Option<String>,
1762
1763    /// Table 12: PaddingBits — shall not be present
1764    #[serde(rename = "PaddingBits", default)]
1765    pub padding_bits: Option<i32>,
1766
1767    /// Table 12: AlphaSampleDepth — shall not be present
1768    #[serde(rename = "AlphaSampleDepth", default)]
1769    pub alpha_sample_depth: Option<u32>,
1770
1771    #[serde(rename = "LinkedTrackID", default)]
1772    pub linked_track_id: Option<u32>,
1773
1774    #[serde(rename = "SubDescriptors", default)]
1775    pub sub_descriptors: Option<VideoSubDescriptors>,
1776}
1777
1778/// SubDescriptors for video (RGBA/CDCI) descriptors
1779#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
1780#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
1781#[cfg_attr(feature = "typescript", derive(TS))]
1782#[cfg_attr(feature = "typescript", ts(export, rename_all = "camelCase"))]
1783#[cfg_attr(feature = "wasm", derive(Tsify))]
1784#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))]
1785pub struct VideoSubDescriptors {
1786    /// Presence indicates Dolby Vision HDR
1787    #[serde(rename = "PHDRMetadataTrackSubDescriptor", default)]
1788    pub phdr_metadata_track_sub_descriptor: Option<PHDRMetadataTrackSubDescriptor>,
1789
1790    /// JPEG 2000 Picture Sub Descriptor — Table 14 constraints
1791    #[serde(rename = "JPEG2000SubDescriptor", default)]
1792    pub jpeg2000_sub_descriptor: Option<JPEG2000SubDescriptor>,
1793}
1794
1795/// JPEG 2000 Picture Sub Descriptor (ST 422 / ST 2067-21 Table 14)
1796#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
1797#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
1798#[cfg_attr(feature = "typescript", derive(TS))]
1799#[cfg_attr(feature = "typescript", ts(export, rename_all = "camelCase"))]
1800#[cfg_attr(feature = "wasm", derive(Tsify))]
1801#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))]
1802pub struct JPEG2000SubDescriptor {
1803    #[serde(rename = "InstanceID", default)]
1804    pub instance_id: Option<String>,
1805
1806    /// Decoder capabilities (Rsiz)
1807    #[serde(rename = "Rsiz", default)]
1808    pub rsiz: Option<u32>,
1809
1810    /// Image width (Xsiz)
1811    #[serde(rename = "Xsiz", default)]
1812    pub xsiz: Option<u32>,
1813
1814    /// Image height (Ysiz)
1815    #[serde(rename = "Ysiz", default)]
1816    pub ysiz: Option<u32>,
1817
1818    /// Image X offset (XOsiz)
1819    #[serde(rename = "XOsiz", default)]
1820    pub xo_siz: Option<u32>,
1821
1822    /// Image Y offset (YOsiz)
1823    #[serde(rename = "YOsiz", default)]
1824    pub yo_siz: Option<u32>,
1825
1826    /// Tile width (XTsiz)
1827    #[serde(rename = "XTsiz", default)]
1828    pub xt_siz: Option<u32>,
1829
1830    /// Tile height (YTsiz)
1831    #[serde(rename = "YTsiz", default)]
1832    pub yt_siz: Option<u32>,
1833
1834    /// Tile X offset (XTOsiz)
1835    #[serde(rename = "XTOsiz", default)]
1836    pub xto_siz: Option<u32>,
1837
1838    /// Tile Y offset (YTOsiz)
1839    #[serde(rename = "YTOsiz", default)]
1840    pub yto_siz: Option<u32>,
1841
1842    /// Number of components (Csiz)
1843    #[serde(rename = "Csiz", default)]
1844    pub csiz: Option<u32>,
1845
1846    /// Table 14: Coding Style — shall be present
1847    #[serde(rename = "CodingStyleDefault", default)]
1848    pub coding_style_default: Option<String>,
1849
1850    /// Quantization Default
1851    #[serde(rename = "QuantizationDefault", default)]
1852    pub quantization_default: Option<String>,
1853
1854    /// Table 14: J2CLayout — shall be present (§6.5.2)
1855    #[serde(rename = "J2CLayout", default)]
1856    pub j2c_layout: Option<J2CLayout>,
1857
1858    /// Table 14: J2KExtendedCapabilities — shall be present if ISO/IEC 15444-15 coding
1859    #[serde(rename = "J2KExtendedCapabilities", default)]
1860    pub j2k_extended_capabilities: Option<J2KExtendedCapabilities>,
1861
1862    /// Picture component sizing information
1863    #[serde(rename = "PictureComponentSizing", default)]
1864    pub picture_component_sizing: Option<PictureComponentSizing>,
1865}
1866
1867/// J2CLayout — pixel component layout for JPEG 2000 (§6.5.2)
1868#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
1869#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
1870#[cfg_attr(feature = "typescript", derive(TS))]
1871#[cfg_attr(feature = "typescript", ts(export, rename_all = "camelCase"))]
1872#[cfg_attr(feature = "wasm", derive(Tsify))]
1873#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))]
1874pub struct J2CLayout {
1875    #[serde(rename = "RGBAComponent", default)]
1876    pub components: Vec<RGBALayoutComponent>,
1877}
1878
1879/// RGBA component entry within J2CLayout or PixelLayout
1880#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
1881#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
1882#[cfg_attr(feature = "typescript", derive(TS))]
1883#[cfg_attr(feature = "typescript", ts(export, rename_all = "camelCase"))]
1884#[cfg_attr(feature = "wasm", derive(Tsify))]
1885#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))]
1886pub struct RGBALayoutComponent {
1887    /// Absent in intentionally malformed corpus files (e.g. RGBAError1).
1888    /// Default to empty string so the parser succeeds; the validator flags missing codes.
1889    #[serde(rename = "Code", default)]
1890    pub code: String,
1891
1892    #[serde(rename = "ComponentSize", default)]
1893    pub component_size: u32,
1894}
1895
1896/// J2K Extended Capabilities (ISO/IEC 15444-15)
1897#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
1898#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
1899#[cfg_attr(feature = "typescript", derive(TS))]
1900#[cfg_attr(feature = "typescript", ts(export, rename_all = "camelCase"))]
1901#[cfg_attr(feature = "wasm", derive(Tsify))]
1902#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))]
1903pub struct J2KExtendedCapabilities {
1904    /// Profile capabilities (Pcap)
1905    #[serde(rename = "Pcap", default)]
1906    pub pcap: Option<u64>,
1907}
1908
1909/// Picture component sizing information
1910#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
1911#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
1912#[cfg_attr(feature = "typescript", derive(TS))]
1913#[cfg_attr(feature = "typescript", ts(export, rename_all = "camelCase"))]
1914#[cfg_attr(feature = "wasm", derive(Tsify))]
1915#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))]
1916pub struct PictureComponentSizing {
1917    #[serde(rename = "J2KComponentSizing", default)]
1918    pub components: Vec<J2KComponentSizing>,
1919}
1920
1921/// Individual J2K component sizing
1922#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
1923#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
1924#[cfg_attr(feature = "typescript", derive(TS))]
1925#[cfg_attr(feature = "typescript", ts(export, rename_all = "camelCase"))]
1926#[cfg_attr(feature = "wasm", derive(Tsify))]
1927#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))]
1928pub struct J2KComponentSizing {
1929    /// Component bit depth minus 1
1930    #[serde(rename = "Ssiz", default)]
1931    pub ssiz: Option<u32>,
1932
1933    /// Horizontal separation of sample
1934    #[serde(rename = "XRSiz", default)]
1935    pub xr_siz: Option<u32>,
1936
1937    /// Vertical separation of sample
1938    #[serde(rename = "YRSiz", default)]
1939    pub yr_siz: Option<u32>,
1940}
1941
1942/// PHDR (Dolby Vision) metadata track sub-descriptor
1943#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
1944#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
1945#[cfg_attr(feature = "typescript", derive(TS))]
1946#[cfg_attr(feature = "typescript", ts(export, rename_all = "camelCase"))]
1947#[cfg_attr(feature = "wasm", derive(Tsify))]
1948#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))]
1949pub struct PHDRMetadataTrackSubDescriptor {
1950    #[serde(rename = "InstanceID", default)]
1951    pub instance_id: Option<String>,
1952
1953    #[serde(rename = "PHDRMetadataTrackSubDescriptor_DataDefinition", default)]
1954    pub data_definition: Option<String>,
1955
1956    #[serde(rename = "PHDRMetadataTrackSubDescriptor_SimplePayloadSID", default)]
1957    pub simple_payload_sid: Option<u32>,
1958
1959    #[serde(rename = "PHDRMetadataTrackSubDescriptor_SourceTrackID", default)]
1960    pub source_track_id: Option<u32>,
1961}
1962
1963/// WAVE PCM audio descriptor
1964#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
1965#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
1966#[cfg_attr(feature = "typescript", derive(TS))]
1967#[cfg_attr(feature = "typescript", ts(export, rename_all = "camelCase"))]
1968#[cfg_attr(feature = "wasm", derive(Tsify))]
1969#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))]
1970pub struct WAVEPCMDescriptor {
1971    #[serde(rename = "InstanceID", alias = "InstanceUID", default)]
1972    pub instance_id: Option<String>,
1973
1974    #[serde(
1975        rename = "SampleRate",
1976        default,
1977        deserialize_with = "de_helpers::de_optional_edit_rate"
1978    )]
1979    pub sample_rate: Option<EditRate>,
1980
1981    #[serde(
1982        rename = "AudioSampleRate",
1983        default,
1984        deserialize_with = "de_helpers::de_optional_edit_rate"
1985    )]
1986    pub audio_sample_rate: Option<EditRate>,
1987
1988    #[serde(rename = "ChannelCount", default)]
1989    pub channel_count: Option<u32>,
1990
1991    #[serde(rename = "QuantizationBits", default)]
1992    pub quantization_bits: Option<u32>,
1993
1994    #[serde(rename = "LinkedTrackID", default)]
1995    pub linked_track_id: Option<u32>,
1996
1997    #[serde(rename = "SubDescriptors", default)]
1998    pub sub_descriptors: Option<AudioSubDescriptors>,
1999}
2000
2001/// SubDescriptors for audio (WAVEPCMDescriptor)
2002#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
2003#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
2004#[cfg_attr(feature = "typescript", derive(TS))]
2005#[cfg_attr(feature = "typescript", ts(export, rename_all = "camelCase"))]
2006#[cfg_attr(feature = "wasm", derive(Tsify))]
2007#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))]
2008pub struct AudioSubDescriptors {
2009    #[serde(rename = "SoundfieldGroupLabelSubDescriptor", default)]
2010    pub soundfield_group_label_sub_descriptor: Option<SoundfieldGroupLabelSubDescriptor>,
2011}
2012
2013/// Soundfield group label sub-descriptor — contains language and audio content kind
2014#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
2015#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
2016#[cfg_attr(feature = "typescript", derive(TS))]
2017#[cfg_attr(feature = "typescript", ts(export, rename_all = "camelCase"))]
2018#[cfg_attr(feature = "wasm", derive(Tsify))]
2019#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))]
2020pub struct SoundfieldGroupLabelSubDescriptor {
2021    #[serde(
2022        rename = "MCATagSymbol",
2023        default,
2024        deserialize_with = "de_helpers::de_optional_mca_tag_symbol"
2025    )]
2026    pub mca_tag_symbol: Option<McaTagSymbol>,
2027
2028    #[serde(rename = "MCATagName", default)]
2029    pub mca_tag_name: Option<String>,
2030
2031    #[serde(rename = "MCAAudioContentKind", default)]
2032    pub mca_audio_content_kind: Option<String>,
2033
2034    /// RFC 5646 language tag — field name varies between vendors
2035    #[serde(
2036        rename = "RFC5646SpokenLanguage",
2037        alias = "RFC5646AudioLanguageCode",
2038        default,
2039        deserialize_with = "de_helpers::de_optional_language_tag"
2040    )]
2041    pub rfc5646_spoken_language: Option<LanguageTag>,
2042}
2043
2044/// DC Timed Text descriptor (subtitles/captions)
2045#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
2046#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
2047#[cfg_attr(feature = "typescript", derive(TS))]
2048#[cfg_attr(feature = "typescript", ts(export, rename_all = "camelCase"))]
2049#[cfg_attr(feature = "wasm", derive(Tsify))]
2050#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))]
2051pub struct DCTimedTextDescriptor {
2052    #[serde(rename = "InstanceID", alias = "InstanceUID", default)]
2053    pub instance_id: Option<String>,
2054
2055    #[serde(rename = "LinkedTrackID", default)]
2056    pub linked_track_id: Option<u32>,
2057
2058    #[serde(
2059        rename = "SampleRate",
2060        default,
2061        deserialize_with = "de_helpers::de_optional_edit_rate"
2062    )]
2063    pub sample_rate: Option<EditRate>,
2064
2065    /// Comma-separated RFC 5646 language tags for this timed text track
2066    #[serde(
2067        rename = "RFC5646LanguageTagList",
2068        default,
2069        deserialize_with = "de_helpers::de_language_tag_list"
2070    )]
2071    pub rfc5646_language_tag_list: Vec<LanguageTag>,
2072
2073    #[serde(rename = "NamespaceURI", default)]
2074    pub namespace_uri: Option<String>,
2075}
2076
2077/// IAB (Immersive Audio Bitstream) essence descriptor — Dolby Atmos
2078#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
2079#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
2080#[cfg_attr(feature = "typescript", derive(TS))]
2081#[cfg_attr(feature = "typescript", ts(export, rename_all = "camelCase"))]
2082#[cfg_attr(feature = "wasm", derive(Tsify))]
2083#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))]
2084pub struct IABEssenceDescriptor {
2085    #[serde(rename = "InstanceID", alias = "InstanceUID", default)]
2086    pub instance_id: Option<String>,
2087
2088    #[serde(rename = "LinkedTrackID", default)]
2089    pub linked_track_id: Option<u32>,
2090
2091    #[serde(
2092        rename = "SampleRate",
2093        default,
2094        deserialize_with = "de_helpers::de_optional_edit_rate"
2095    )]
2096    pub sample_rate: Option<EditRate>,
2097
2098    #[serde(
2099        rename = "AudioSampleRate",
2100        default,
2101        deserialize_with = "de_helpers::de_optional_edit_rate"
2102    )]
2103    pub audio_sample_rate: Option<EditRate>,
2104
2105    #[serde(rename = "ChannelCount", default)]
2106    pub channel_count: Option<u32>,
2107
2108    /// ST 2067-201 §5.9: QuantizationBits shall be 24.
2109    #[serde(rename = "QuantizationBits", default)]
2110    pub quantization_bits: Option<u32>,
2111
2112    /// ST 2067-201 §5.3: ContainerFormat shall be the IAB essence container UL.
2113    #[serde(rename = "ContainerFormat", default)]
2114    pub container_format: Option<String>,
2115
2116    #[serde(rename = "SoundCompression", default)]
2117    pub sound_compression: Option<String>,
2118
2119    /// ST 2067-201 §5.9: Codec item shall NOT be present.
2120    #[serde(rename = "Codec", default)]
2121    pub codec: Option<String>,
2122
2123    /// ST 2067-201 §5.9: ElectrospatialFormulation shall NOT be present.
2124    #[serde(rename = "ElectrospatialFormulation", default)]
2125    pub electrospatial_formulation: Option<u32>,
2126
2127    #[serde(rename = "SubDescriptors", default)]
2128    pub sub_descriptors: Option<IABSubDescriptors>,
2129}
2130
2131/// SubDescriptors for IAB essence
2132#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
2133#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
2134#[cfg_attr(feature = "typescript", derive(TS))]
2135#[cfg_attr(feature = "typescript", ts(export, rename_all = "camelCase"))]
2136#[cfg_attr(feature = "wasm", derive(Tsify))]
2137#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))]
2138pub struct IABSubDescriptors {
2139    #[serde(rename = "IABSoundfieldLabelSubDescriptor", default)]
2140    pub iab_soundfield_label_sub_descriptor: Option<IABSoundfieldLabelSubDescriptor>,
2141
2142    /// ST 2067-201:2026 Annex E — IAB Channel SubDescriptor entries.
2143    /// Optional in 2021 (and earlier — silently dropped), recommended
2144    /// in 2026 ("should contain one instance for each channel of each
2145    /// BedDefinition"). Captured as a raw count via a bag struct so
2146    /// downstream code can probe presence without the parser needing
2147    /// to model every field defined in Annex E Table E.1.
2148    #[serde(rename = "IABChannelSubDescriptor", default)]
2149    pub iab_channel_sub_descriptors: Vec<IABChannelSubDescriptor>,
2150}
2151
2152/// Presence-only stub for ST 2067-201:2026 Annex E `IABChannelSubDescriptor`.
2153///
2154/// The 2026 spec defines the full item set in Table E.1
2155/// (`IABBedMetaID`, `IABChannelID`, `IABAudioDescription`,
2156/// `IABAudioDescriptionText`); imferno's CPL parser only needs to count
2157/// occurrences to fire the `IabChannelSubDescriptorRecommended` warning,
2158/// so the inner shape is intentionally permissive — any nested content
2159/// deserialises into the catch-all map without affecting presence.
2160#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
2161#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, Default)]
2162#[cfg_attr(feature = "typescript", derive(TS))]
2163#[cfg_attr(feature = "typescript", ts(export, rename_all = "camelCase"))]
2164#[cfg_attr(feature = "wasm", derive(Tsify))]
2165#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))]
2166pub struct IABChannelSubDescriptor {
2167    /// Annex E §E.2 — IAB Bed MetaID of the associated BedDefinition.
2168    #[serde(rename = "IABBedMetaID", default)]
2169    pub bed_meta_id: Option<u32>,
2170    /// Annex E §E.2 — Channel ID within the bed.
2171    #[serde(rename = "IABChannelID", default)]
2172    pub channel_id: Option<u32>,
2173}
2174
2175/// IAB soundfield label sub-descriptor — contains language for Atmos tracks
2176#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
2177#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
2178#[cfg_attr(feature = "typescript", derive(TS))]
2179#[cfg_attr(feature = "typescript", ts(export, rename_all = "camelCase"))]
2180#[cfg_attr(feature = "wasm", derive(Tsify))]
2181#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))]
2182pub struct IABSoundfieldLabelSubDescriptor {
2183    #[serde(rename = "InstanceID", alias = "InstanceUID", default)]
2184    pub instance_id: Option<String>,
2185
2186    #[serde(
2187        rename = "MCATagSymbol",
2188        default,
2189        deserialize_with = "de_helpers::de_optional_mca_tag_symbol"
2190    )]
2191    pub mca_tag_symbol: Option<McaTagSymbol>,
2192
2193    #[serde(rename = "MCATagName", default)]
2194    pub mca_tag_name: Option<String>,
2195
2196    /// ST 2067-201 §5.9: MCALabelDictionaryID shall be `urn:smpte:ul:060e2b34.0401010d.03020221.00000000`.
2197    #[serde(rename = "MCALabelDictionaryID", default)]
2198    pub mca_label_dictionary_id: Option<String>,
2199
2200    #[serde(
2201        rename = "RFC5646SpokenLanguage",
2202        alias = "RFC5646AudioLanguageCode",
2203        default,
2204        deserialize_with = "de_helpers::de_optional_language_tag"
2205    )]
2206    pub rfc5646_spoken_language: Option<LanguageTag>,
2207}
2208
2209/// SubDescriptors for ISXD essence descriptor
2210#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
2211#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, Default)]
2212#[cfg_attr(feature = "typescript", derive(TS))]
2213#[cfg_attr(feature = "typescript", ts(export, rename_all = "camelCase"))]
2214#[cfg_attr(feature = "wasm", derive(Tsify))]
2215#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))]
2216pub struct IsxdSubDescriptors {
2217    /// ST 2067-202: ContainerConstraintsSubDescriptor shall be present.
2218    #[serde(rename = "ContainerConstraintsSubDescriptor", default)]
2219    pub container_constraints_sub_descriptor: Option<ContainerConstraintsSubDescriptor>,
2220}
2221
2222/// ContainerConstraintsSubDescriptor — presence required by ST 2067-202 §5
2223#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
2224#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
2225#[cfg_attr(feature = "typescript", derive(TS))]
2226#[cfg_attr(feature = "typescript", ts(export, rename_all = "camelCase"))]
2227#[cfg_attr(feature = "wasm", derive(Tsify))]
2228#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))]
2229pub struct ContainerConstraintsSubDescriptor {
2230    #[serde(rename = "InstanceID", alias = "InstanceUID", default)]
2231    pub instance_id: Option<String>,
2232}
2233
2234/// ISXD (Immersive Sound XML Data) essence descriptor — Dolby Atmos sidecar format
2235#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
2236#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
2237#[cfg_attr(feature = "typescript", derive(TS))]
2238#[cfg_attr(feature = "typescript", ts(export, rename_all = "camelCase"))]
2239#[cfg_attr(feature = "wasm", derive(Tsify))]
2240#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))]
2241pub struct ISXDDataEssenceDescriptor {
2242    #[serde(rename = "InstanceID", alias = "InstanceUID", default)]
2243    pub instance_id: Option<String>,
2244
2245    #[serde(rename = "LinkedTrackID", default)]
2246    pub linked_track_id: Option<u32>,
2247
2248    #[serde(
2249        rename = "SampleRate",
2250        default,
2251        deserialize_with = "de_helpers::de_optional_edit_rate"
2252    )]
2253    pub sample_rate: Option<EditRate>,
2254
2255    #[serde(rename = "DataEssenceCoding", default)]
2256    pub data_essence_coding: Option<String>,
2257
2258    #[serde(rename = "NamespaceURI", default)]
2259    pub namespace_uri: Option<String>,
2260
2261    #[serde(rename = "SubDescriptors", default)]
2262    pub sub_descriptors: Option<IsxdSubDescriptors>,
2263}
2264
2265// =============================================================================
2266// Root CPL structure
2267// =============================================================================
2268
2269/// Root CPL structure — defines a complete IMF composition.
2270///
2271/// # Spec-required vs `Option<T>` policy (FIX-7 audit)
2272///
2273/// The parser is intentionally lenient: every spec-required element that
2274/// isn't strictly necessary to **construct** a valid `CompositionPlaylist`
2275/// is exposed as `Option<T>` (or via a `default = "…"` serde attribute).
2276/// Missing-required-field violations are surfaced as catalogue diagnostics
2277/// by the validator (`validate_cpl`) rather than as parse errors, so a
2278/// caller can still inspect the parsed structure of a non-conformant CPL.
2279///
2280/// Field-by-field map against ST 2067-3 §6 / §7 (2013 / 2016 — the 2020
2281/// edition reuses the 2016 schema verbatim):
2282///
2283/// | Field                     | Type             | Spec status                               |
2284/// |---------------------------|------------------|-------------------------------------------|
2285/// | `id`                      | `ImfUuid`        | required §6.1 — parse error if missing    |
2286/// | `annotation`              | `Option<…>`      | optional §6.2                             |
2287/// | `issue_date`              | `String`         | required §6.3 — parse error if missing    |
2288/// | `issuer`                  | `Option<…>`      | optional §6.4                             |
2289/// | `creator`                 | `Option<…>`      | optional §6.5                             |
2290/// | `content_originator`      | `Option<…>`      | optional §6.6                             |
2291/// | `content_title`           | `LanguageString` | required §6.7 — parse error if missing    |
2292/// | `content_kind`            | concrete (default) | required §6.8 — `default_content_kind`  |
2293/// | `content_version_list`    | `Option<…>`      | optional §6.10                            |
2294/// | `essence_descriptor_list` | `Option<…>`      | **required** per ST 2067-2 §6.1.5 —       |
2295/// |                           |                  | parser-lenient; absence is reported by    |
2296/// |                           |                  | `validate_cpl` as Error                   |
2297/// | `edit_rate`               | `Option<…>`      | required §6.13 — parser-lenient; absence  |
2298/// |                           |                  | reported by `validate_cpl`                |
2299/// | `total_running_time`      | `Option<String>` | optional §6.14                            |
2300/// | `locale_list`             | `Option<…>`      | optional §6.15                            |
2301/// | `extension_properties`    | `Option<…>`      | optional §6.16                            |
2302/// | `composition_timecode`    | `Option<…>`      | optional §6.9                             |
2303/// | `segment_list`            | `SegmentList`    | required §6.17 — parse error if missing   |
2304/// | `has_signer`/`has_signature` | `bool`        | reflect presence in raw XML (§8 signatures unparsed) |
2305/// | `source_xml`              | `Option<String>` | retained when parsed from XML; absent for JSON-deserialised |
2306///
2307/// Five fields are spec-required but stored as `Option<T>` (with `default`
2308/// on the serde side) to support the parser-lenient model:
2309/// `content_kind` (defaults via `default_content_kind`), `content_version_list`,
2310/// `essence_descriptor_list`, `edit_rate`, `locale_list`. The validator
2311/// surfaces missing-required findings against the ST 2067-3 prose.
2312#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
2313#[derive(Debug, Serialize, Deserialize, PartialEq)]
2314#[cfg_attr(feature = "typescript", derive(TS))]
2315#[cfg_attr(feature = "typescript", ts(export, rename_all = "camelCase"))]
2316#[cfg_attr(feature = "wasm", derive(Tsify))]
2317#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))]
2318pub struct CompositionPlaylist {
2319    /// The SMPTE spec version detected from the root xmlns of the CPL XML.
2320    /// Set after deserialization by `parse_cpl()`.
2321    #[serde(skip)]
2322    pub namespace: CplNamespace,
2323
2324    #[cfg_attr(not(feature = "wasm"), serde(rename = "Id"))]
2325    #[cfg_attr(feature = "wasm", serde(rename = "id", alias = "Id"))]
2326    #[cfg_attr(feature = "typescript", ts(rename = "id"))]
2327    pub id: ImfUuid,
2328
2329    #[cfg_attr(not(feature = "wasm"), serde(rename = "Annotation", default))]
2330    #[cfg_attr(
2331        feature = "wasm",
2332        serde(rename = "annotation", alias = "Annotation", default)
2333    )]
2334    #[cfg_attr(feature = "typescript", ts(rename = "annotation"))]
2335    pub annotation: Option<LanguageString>,
2336
2337    #[cfg_attr(not(feature = "wasm"), serde(rename = "IssueDate"))]
2338    #[cfg_attr(feature = "wasm", serde(rename = "issueDate", alias = "IssueDate"))]
2339    #[cfg_attr(feature = "typescript", ts(rename = "issueDate"))]
2340    pub issue_date: String, // ISO 8601 datetime
2341
2342    #[cfg_attr(not(feature = "wasm"), serde(rename = "Issuer", default))]
2343    #[cfg_attr(feature = "wasm", serde(rename = "issuer", alias = "Issuer", default))]
2344    #[cfg_attr(feature = "typescript", ts(rename = "issuer"))]
2345    pub issuer: Option<LanguageString>,
2346
2347    #[cfg_attr(not(feature = "wasm"), serde(rename = "Creator", default))]
2348    #[cfg_attr(
2349        feature = "wasm",
2350        serde(rename = "creator", alias = "Creator", default)
2351    )]
2352    #[cfg_attr(feature = "typescript", ts(rename = "creator"))]
2353    pub creator: Option<LanguageString>,
2354
2355    #[cfg_attr(not(feature = "wasm"), serde(rename = "ContentOriginator", default))]
2356    #[cfg_attr(
2357        feature = "wasm",
2358        serde(rename = "contentOriginator", alias = "ContentOriginator", default)
2359    )]
2360    #[cfg_attr(feature = "typescript", ts(rename = "contentOriginator"))]
2361    pub content_originator: Option<LanguageString>,
2362
2363    #[cfg_attr(not(feature = "wasm"), serde(rename = "ContentTitle"))]
2364    #[cfg_attr(
2365        feature = "wasm",
2366        serde(rename = "contentTitle", alias = "ContentTitle")
2367    )]
2368    #[cfg_attr(feature = "typescript", ts(rename = "contentTitle"))]
2369    pub content_title: LanguageString,
2370
2371    #[cfg_attr(
2372        not(feature = "wasm"),
2373        serde(rename = "ContentKind", default = "default_content_kind")
2374    )]
2375    #[cfg_attr(
2376        feature = "wasm",
2377        serde(
2378            rename = "contentKind",
2379            alias = "ContentKind",
2380            default = "default_content_kind"
2381        )
2382    )]
2383    #[cfg_attr(feature = "typescript", ts(rename = "contentKind"))]
2384    pub content_kind: ContentKindElement,
2385
2386    #[cfg_attr(not(feature = "wasm"), serde(rename = "ContentVersionList", default))]
2387    #[cfg_attr(
2388        feature = "wasm",
2389        serde(rename = "contentVersionList", alias = "ContentVersionList", default)
2390    )]
2391    #[cfg_attr(feature = "typescript", ts(rename = "contentVersionList"))]
2392    pub content_version_list: Option<ContentVersionList>,
2393
2394    #[cfg_attr(
2395        not(feature = "wasm"),
2396        serde(rename = "EssenceDescriptorList", default)
2397    )]
2398    #[cfg_attr(
2399        feature = "wasm",
2400        serde(
2401            rename = "essenceDescriptorList",
2402            alias = "EssenceDescriptorList",
2403            default
2404        )
2405    )]
2406    #[cfg_attr(feature = "typescript", ts(rename = "essenceDescriptorList"))]
2407    pub essence_descriptor_list: Option<EssenceDescriptorList>,
2408
2409    #[cfg_attr(
2410        not(feature = "wasm"),
2411        serde(
2412            rename = "EditRate",
2413            default,
2414            deserialize_with = "de_helpers::de_optional_edit_rate"
2415        )
2416    )]
2417    #[cfg_attr(
2418        feature = "wasm",
2419        serde(
2420            rename = "editRate",
2421            alias = "EditRate",
2422            default,
2423            deserialize_with = "de_helpers::de_optional_edit_rate"
2424        )
2425    )]
2426    #[cfg_attr(feature = "typescript", ts(rename = "editRate"))]
2427    pub edit_rate: Option<EditRate>,
2428
2429    #[cfg_attr(not(feature = "wasm"), serde(rename = "TotalRunningTime", default))]
2430    #[cfg_attr(
2431        feature = "wasm",
2432        serde(rename = "totalRunningTime", alias = "TotalRunningTime", default)
2433    )]
2434    #[cfg_attr(feature = "typescript", ts(rename = "totalRunningTime"))]
2435    pub total_running_time: Option<String>,
2436
2437    #[cfg_attr(not(feature = "wasm"), serde(rename = "LocaleList", default))]
2438    #[cfg_attr(
2439        feature = "wasm",
2440        serde(rename = "localeList", alias = "LocaleList", default)
2441    )]
2442    #[cfg_attr(feature = "typescript", ts(rename = "localeList"))]
2443    pub locale_list: Option<LocaleList>,
2444
2445    #[cfg_attr(not(feature = "wasm"), serde(rename = "ExtensionProperties", default))]
2446    #[cfg_attr(
2447        feature = "wasm",
2448        serde(rename = "extensionProperties", alias = "ExtensionProperties", default)
2449    )]
2450    #[cfg_attr(feature = "typescript", ts(rename = "extensionProperties"))]
2451    pub extension_properties: Option<ExtensionProperties>,
2452
2453    #[cfg_attr(not(feature = "wasm"), serde(rename = "CompositionTimecode", default))]
2454    #[cfg_attr(
2455        feature = "wasm",
2456        serde(rename = "compositionTimecode", alias = "CompositionTimecode", default)
2457    )]
2458    #[cfg_attr(feature = "typescript", ts(rename = "compositionTimecode"))]
2459    pub composition_timecode: Option<CompositionTimecode>,
2460
2461    /// Whether the original CPL XML contained a `<Signer>` element.
2462    /// Set by `parse_cpl()` from raw XML before namespace stripping.
2463    #[serde(skip)]
2464    pub has_signer: bool,
2465
2466    /// Whether the original CPL XML contained a `<Signature>` element.
2467    /// Set by `parse_cpl()` from raw XML before namespace stripping.
2468    #[serde(skip)]
2469    pub has_signature: bool,
2470
2471    /// The raw CPL XML as parsed, retained so callers running through
2472    /// `validate_cpl(&cpl)` can transparently invoke the runtime-XSD
2473    /// validator (which needs the unparsed source). Set by `parse_cpl()`.
2474    /// `None` when the struct was built via JSON deserialization or
2475    /// manual construction.
2476    #[serde(skip)]
2477    pub source_xml: Option<String>,
2478
2479    #[cfg_attr(not(feature = "wasm"), serde(rename = "SegmentList"))]
2480    #[cfg_attr(feature = "wasm", serde(rename = "segmentList", alias = "SegmentList"))]
2481    #[cfg_attr(feature = "typescript", ts(rename = "segmentList"))]
2482    pub segment_list: SegmentList,
2483}
2484
2485// =============================================================================
2486// CompositionTimecode
2487// =============================================================================
2488
2489#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
2490#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
2491#[cfg_attr(not(feature = "wasm"), serde(rename_all = "PascalCase"))]
2492#[cfg_attr(feature = "wasm", serde(rename_all = "camelCase"))]
2493#[cfg_attr(feature = "typescript", derive(TS))]
2494#[cfg_attr(feature = "typescript", ts(export, rename_all = "camelCase"))]
2495#[cfg_attr(feature = "wasm", derive(Tsify))]
2496#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))]
2497pub struct CompositionTimecode {
2498    #[cfg_attr(not(feature = "wasm"), serde(rename = "TimecodeDropFrame"))]
2499    #[cfg_attr(
2500        feature = "wasm",
2501        serde(rename = "timecodeDropFrame", alias = "TimecodeDropFrame")
2502    )]
2503    pub timecode_drop_frame: Option<bool>,
2504
2505    #[cfg_attr(not(feature = "wasm"), serde(rename = "TimecodeRate"))]
2506    #[cfg_attr(
2507        feature = "wasm",
2508        serde(rename = "timecodeRate", alias = "TimecodeRate")
2509    )]
2510    pub timecode_rate: Option<u32>,
2511
2512    #[cfg_attr(not(feature = "wasm"), serde(rename = "TimecodeStartAddress"))]
2513    #[cfg_attr(
2514        feature = "wasm",
2515        serde(rename = "timecodeStartAddress", alias = "TimecodeStartAddress")
2516    )]
2517    pub timecode_start_address: Option<String>,
2518}
2519
2520// =============================================================================
2521// Content Version types
2522// =============================================================================
2523
2524#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
2525#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
2526#[cfg_attr(feature = "typescript", derive(TS))]
2527#[cfg_attr(feature = "typescript", ts(export, rename_all = "camelCase"))]
2528#[cfg_attr(feature = "wasm", derive(Tsify))]
2529#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))]
2530pub struct ContentVersionList {
2531    #[cfg_attr(not(feature = "wasm"), serde(rename = "ContentVersion"))]
2532    #[cfg_attr(
2533        feature = "wasm",
2534        serde(rename = "contentVersions", alias = "ContentVersion")
2535    )]
2536    pub content_versions: Vec<ContentVersion>,
2537}
2538
2539#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
2540#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
2541#[cfg_attr(feature = "typescript", derive(TS))]
2542#[cfg_attr(feature = "typescript", ts(export, rename_all = "camelCase"))]
2543#[cfg_attr(feature = "wasm", derive(Tsify))]
2544#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))]
2545pub struct ContentVersion {
2546    #[cfg_attr(not(feature = "wasm"), serde(rename = "Id"))]
2547    #[cfg_attr(feature = "wasm", serde(rename = "id", alias = "Id"))]
2548    pub id: String,
2549
2550    #[cfg_attr(not(feature = "wasm"), serde(rename = "LabelText", default))]
2551    #[cfg_attr(
2552        feature = "wasm",
2553        serde(rename = "labelText", alias = "LabelText", default)
2554    )]
2555    pub label_text: Option<LanguageString>,
2556}
2557
2558// =============================================================================
2559// Segment and Sequence types
2560// =============================================================================
2561
2562#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
2563#[derive(Debug, Serialize, Deserialize, PartialEq)]
2564#[cfg_attr(feature = "typescript", derive(TS))]
2565#[cfg_attr(feature = "typescript", ts(export, rename_all = "camelCase"))]
2566#[cfg_attr(feature = "wasm", derive(Tsify))]
2567#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))]
2568pub struct SegmentList {
2569    #[cfg_attr(not(feature = "wasm"), serde(rename = "Segment", default))]
2570    #[cfg_attr(
2571        feature = "wasm",
2572        serde(rename = "segments", alias = "Segment", default)
2573    )]
2574    pub segments: Vec<Segment>,
2575}
2576
2577#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
2578#[derive(Debug, Serialize, Deserialize, PartialEq)]
2579#[cfg_attr(feature = "typescript", derive(TS))]
2580#[cfg_attr(feature = "typescript", ts(export, rename_all = "camelCase"))]
2581#[cfg_attr(feature = "wasm", derive(Tsify))]
2582#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))]
2583pub struct Segment {
2584    #[cfg_attr(not(feature = "wasm"), serde(rename = "Id"))]
2585    #[cfg_attr(feature = "wasm", serde(rename = "id", alias = "Id"))]
2586    pub id: ImfUuid,
2587
2588    #[cfg_attr(not(feature = "wasm"), serde(rename = "SequenceList"))]
2589    #[cfg_attr(
2590        feature = "wasm",
2591        serde(rename = "sequenceList", alias = "SequenceList")
2592    )]
2593    pub sequence_list: SequenceList,
2594}
2595
2596#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
2597#[derive(Debug, Serialize, Deserialize, PartialEq)]
2598#[cfg_attr(feature = "typescript", derive(TS))]
2599#[cfg_attr(feature = "typescript", ts(export, rename_all = "camelCase"))]
2600#[cfg_attr(feature = "wasm", derive(Tsify))]
2601#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))]
2602pub struct SequenceList {
2603    #[cfg_attr(not(feature = "wasm"), serde(rename = "MarkerSequence", default))]
2604    #[cfg_attr(
2605        feature = "wasm",
2606        serde(rename = "markerSequences", alias = "MarkerSequence", default)
2607    )]
2608    pub marker_sequences: Vec<MarkerSequence>,
2609
2610    #[cfg_attr(not(feature = "wasm"), serde(rename = "MainImageSequence", default))]
2611    #[cfg_attr(
2612        feature = "wasm",
2613        serde(rename = "mainImageSequences", alias = "MainImageSequence", default)
2614    )]
2615    pub main_image_sequences: Vec<MainImageSequence>,
2616
2617    #[cfg_attr(not(feature = "wasm"), serde(rename = "MainAudioSequence", default))]
2618    #[cfg_attr(
2619        feature = "wasm",
2620        serde(rename = "mainAudioSequences", alias = "MainAudioSequence", default)
2621    )]
2622    pub main_audio_sequences: Vec<MainAudioSequence>,
2623
2624    #[cfg_attr(
2625        not(feature = "wasm"),
2626        serde(rename = "SubtitlesSequence", alias = "MainSubtitleSequence", default)
2627    )]
2628    #[cfg_attr(
2629        feature = "wasm",
2630        serde(
2631            rename = "subtitlesSequences",
2632            alias = "SubtitlesSequence",
2633            alias = "MainSubtitleSequence",
2634            default
2635        )
2636    )]
2637    pub subtitles_sequences: Vec<SubtitlesSequence>,
2638
2639    #[cfg_attr(
2640        not(feature = "wasm"),
2641        serde(rename = "HearingImpairedCaptionsSequence", default)
2642    )]
2643    #[cfg_attr(
2644        feature = "wasm",
2645        serde(
2646            rename = "hearingImpairedCaptionsSequences",
2647            alias = "HearingImpairedCaptionsSequence",
2648            default
2649        )
2650    )]
2651    pub hearing_impaired_captions_sequences: Vec<HearingImpairedCaptionsSequence>,
2652
2653    #[cfg_attr(
2654        not(feature = "wasm"),
2655        serde(rename = "ForcedNarrativeSequence", default)
2656    )]
2657    #[cfg_attr(
2658        feature = "wasm",
2659        serde(
2660            rename = "forcedNarrativeSequences",
2661            alias = "ForcedNarrativeSequence",
2662            default
2663        )
2664    )]
2665    pub forced_narrative_sequences: Vec<ForcedNarrativeSequence>,
2666
2667    #[cfg_attr(not(feature = "wasm"), serde(rename = "IABSequence", default))]
2668    #[cfg_attr(
2669        feature = "wasm",
2670        serde(rename = "iabSequences", alias = "IABSequence", default)
2671    )]
2672    pub iab_sequences: Vec<IABSequence>,
2673
2674    #[cfg_attr(not(feature = "wasm"), serde(rename = "ISXDSequence", default))]
2675    #[cfg_attr(
2676        feature = "wasm",
2677        serde(rename = "isxdSequences", alias = "ISXDSequence", default)
2678    )]
2679    pub isxd_sequences: Vec<ISXDSequence>,
2680}
2681
2682impl SequenceList {
2683    /// Return all non-marker sequences as trait objects.
2684    pub fn all_sequences(&self) -> Vec<&dyn SequenceAccess> {
2685        let mut v: Vec<&dyn SequenceAccess> = Vec::new();
2686        for s in &self.main_image_sequences {
2687            v.push(s);
2688        }
2689        for s in &self.main_audio_sequences {
2690            v.push(s);
2691        }
2692        for s in &self.subtitles_sequences {
2693            v.push(s);
2694        }
2695        for s in &self.hearing_impaired_captions_sequences {
2696            v.push(s);
2697        }
2698        for s in &self.forced_narrative_sequences {
2699            v.push(s);
2700        }
2701        for s in &self.iab_sequences {
2702            v.push(s);
2703        }
2704        for s in &self.isxd_sequences {
2705            v.push(s);
2706        }
2707        v
2708    }
2709
2710    /// Return all non-marker sequences paired with their type name.
2711    pub fn all_sequences_typed(&self) -> Vec<(&dyn SequenceAccess, &'static str)> {
2712        let mut v: Vec<(&dyn SequenceAccess, &'static str)> = Vec::new();
2713        for s in &self.main_image_sequences {
2714            v.push((s, "MainImage"));
2715        }
2716        for s in &self.main_audio_sequences {
2717            v.push((s, "MainAudio"));
2718        }
2719        for s in &self.subtitles_sequences {
2720            v.push((s, "Subtitles"));
2721        }
2722        for s in &self.hearing_impaired_captions_sequences {
2723            v.push((s, "HearingImpairedCaptions"));
2724        }
2725        for s in &self.forced_narrative_sequences {
2726            v.push((s, "ForcedNarrative"));
2727        }
2728        for s in &self.iab_sequences {
2729            v.push((s, "IAB"));
2730        }
2731        for s in &self.isxd_sequences {
2732            v.push((s, "ISXD"));
2733        }
2734        v
2735    }
2736}
2737
2738// All sequence types share the same structure: Id, TrackId, ResourceList
2739/// Trait for accessing common sequence fields
2740pub trait SequenceAccess {
2741    fn id(&self) -> &ImfUuid;
2742    fn track_id(&self) -> &ImfUuid;
2743    fn resource_list(&self) -> &ResourceList;
2744}
2745
2746macro_rules! define_sequence_type {
2747    ($name:ident) => {
2748        #[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
2749        #[derive(Debug, Serialize, Deserialize, PartialEq)]
2750        #[cfg_attr(feature = "typescript", derive(TS))]
2751        #[cfg_attr(feature = "typescript", ts(export, rename_all = "camelCase"))]
2752        #[cfg_attr(feature = "wasm", derive(Tsify))]
2753        #[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))]
2754        pub struct $name {
2755            #[cfg_attr(not(feature = "wasm"), serde(rename = "Id"))]
2756            #[cfg_attr(feature = "wasm", serde(rename = "id", alias = "Id"))]
2757            pub id: ImfUuid,
2758
2759            #[cfg_attr(not(feature = "wasm"), serde(rename = "TrackId"))]
2760            #[cfg_attr(feature = "wasm", serde(rename = "trackId", alias = "TrackId"))]
2761            pub track_id: ImfUuid,
2762
2763            #[cfg_attr(not(feature = "wasm"), serde(rename = "ResourceList"))]
2764            #[cfg_attr(
2765                feature = "wasm",
2766                serde(rename = "resourceList", alias = "ResourceList")
2767            )]
2768            pub resource_list: ResourceList,
2769        }
2770
2771        impl SequenceAccess for $name {
2772            fn id(&self) -> &ImfUuid {
2773                &self.id
2774            }
2775            fn track_id(&self) -> &ImfUuid {
2776                &self.track_id
2777            }
2778            fn resource_list(&self) -> &ResourceList {
2779                &self.resource_list
2780            }
2781        }
2782    };
2783}
2784
2785define_sequence_type!(MarkerSequence);
2786define_sequence_type!(MainImageSequence);
2787define_sequence_type!(MainAudioSequence);
2788define_sequence_type!(SubtitlesSequence);
2789define_sequence_type!(HearingImpairedCaptionsSequence);
2790define_sequence_type!(ForcedNarrativeSequence);
2791define_sequence_type!(IABSequence);
2792define_sequence_type!(ISXDSequence);
2793
2794// =============================================================================
2795// Resource types
2796// =============================================================================
2797
2798#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
2799#[derive(Debug, Serialize, Deserialize, PartialEq)]
2800#[cfg_attr(feature = "typescript", derive(TS))]
2801#[cfg_attr(feature = "typescript", ts(export, rename_all = "camelCase"))]
2802#[cfg_attr(feature = "wasm", derive(Tsify))]
2803#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))]
2804pub struct ResourceList {
2805    #[cfg_attr(not(feature = "wasm"), serde(rename = "Resource", default))]
2806    #[cfg_attr(
2807        feature = "wasm",
2808        serde(rename = "resources", alias = "Resource", default)
2809    )]
2810    pub resources: Vec<Resource>,
2811}
2812
2813#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
2814#[derive(Debug, Serialize, Deserialize, PartialEq)]
2815#[cfg_attr(feature = "typescript", derive(TS))]
2816#[cfg_attr(feature = "typescript", ts(export, rename_all = "camelCase"))]
2817#[cfg_attr(feature = "wasm", derive(Tsify))]
2818#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))]
2819pub struct Resource {
2820    #[cfg_attr(not(feature = "wasm"), serde(rename = "Id"))]
2821    #[cfg_attr(feature = "wasm", serde(rename = "id", alias = "Id"))]
2822    pub id: ImfUuid,
2823
2824    #[cfg_attr(not(feature = "wasm"), serde(rename = "Annotation", default))]
2825    #[cfg_attr(
2826        feature = "wasm",
2827        serde(rename = "annotation", alias = "Annotation", default)
2828    )]
2829    pub annotation: Option<LanguageString>,
2830
2831    #[cfg_attr(
2832        not(feature = "wasm"),
2833        serde(
2834            rename = "EditRate",
2835            default,
2836            deserialize_with = "de_helpers::de_optional_edit_rate"
2837        )
2838    )]
2839    #[cfg_attr(
2840        feature = "wasm",
2841        serde(
2842            rename = "editRate",
2843            alias = "EditRate",
2844            default,
2845            deserialize_with = "de_helpers::de_optional_edit_rate"
2846        )
2847    )]
2848    pub edit_rate: Option<EditRate>,
2849
2850    #[cfg_attr(not(feature = "wasm"), serde(rename = "IntrinsicDuration"))]
2851    #[cfg_attr(
2852        feature = "wasm",
2853        serde(rename = "intrinsicDuration", alias = "IntrinsicDuration")
2854    )]
2855    pub intrinsic_duration: u64,
2856
2857    #[cfg_attr(not(feature = "wasm"), serde(rename = "EntryPoint", default))]
2858    #[cfg_attr(
2859        feature = "wasm",
2860        serde(rename = "entryPoint", alias = "EntryPoint", default)
2861    )]
2862    pub entry_point: Option<u64>,
2863
2864    #[cfg_attr(not(feature = "wasm"), serde(rename = "SourceDuration", default))]
2865    #[cfg_attr(
2866        feature = "wasm",
2867        serde(rename = "sourceDuration", alias = "SourceDuration", default)
2868    )]
2869    pub source_duration: Option<u64>,
2870
2871    #[cfg_attr(not(feature = "wasm"), serde(rename = "SourceEncoding", default))]
2872    #[cfg_attr(
2873        feature = "wasm",
2874        serde(rename = "sourceEncoding", alias = "SourceEncoding", default)
2875    )]
2876    pub source_encoding: Option<ImfUuid>, // UUID reference to EssenceDescriptor
2877
2878    #[cfg_attr(not(feature = "wasm"), serde(rename = "TrackFileId", default))]
2879    #[cfg_attr(
2880        feature = "wasm",
2881        serde(rename = "trackFileId", alias = "TrackFileId", default)
2882    )]
2883    pub track_file_id: Option<ImfUuid>, // UUID reference to MXF file in AssetMap
2884
2885    #[cfg_attr(not(feature = "wasm"), serde(rename = "RepeatCount", default))]
2886    #[cfg_attr(
2887        feature = "wasm",
2888        serde(rename = "repeatCount", alias = "RepeatCount", default)
2889    )]
2890    pub repeat_count: Option<u64>,
2891
2892    #[cfg_attr(not(feature = "wasm"), serde(rename = "KeyId", default))]
2893    #[cfg_attr(feature = "wasm", serde(rename = "keyId", alias = "KeyId", default))]
2894    pub key_id: Option<ImfUuid>, // UUID reference to encryption key
2895
2896    #[cfg_attr(not(feature = "wasm"), serde(rename = "Hash", default))]
2897    #[cfg_attr(feature = "wasm", serde(rename = "hash", alias = "Hash", default))]
2898    pub hash: Option<String>,
2899
2900    #[cfg_attr(not(feature = "wasm"), serde(rename = "Marker", default))]
2901    #[cfg_attr(feature = "wasm", serde(rename = "markers", alias = "Marker", default))]
2902    pub markers: Vec<MarkerInfo>,
2903}
2904
2905#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
2906#[derive(Debug, Serialize, Deserialize, PartialEq)]
2907#[cfg_attr(feature = "typescript", derive(TS))]
2908#[cfg_attr(feature = "typescript", ts(export, rename_all = "camelCase"))]
2909#[cfg_attr(feature = "wasm", derive(Tsify))]
2910#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))]
2911pub struct MarkerInfo {
2912    #[cfg_attr(not(feature = "wasm"), serde(rename = "Annotation", default))]
2913    #[cfg_attr(
2914        feature = "wasm",
2915        serde(rename = "annotation", alias = "Annotation", default)
2916    )]
2917    pub annotation: Option<String>,
2918
2919    #[cfg_attr(not(feature = "wasm"), serde(rename = "Label"))]
2920    #[cfg_attr(feature = "wasm", serde(rename = "label", alias = "Label"))]
2921    pub label: MarkerLabelElement,
2922
2923    #[cfg_attr(not(feature = "wasm"), serde(rename = "Offset"))]
2924    #[cfg_attr(feature = "wasm", serde(rename = "offset", alias = "Offset"))]
2925    pub offset: u64,
2926}
2927
2928// =============================================================================
2929// Track information (legacy, kept for backward compatibility)
2930// =============================================================================
2931
2932/// Track information with codec details (legacy — use EssenceDescriptor parsing instead)
2933#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
2934#[derive(Debug, Clone, Serialize, Deserialize)]
2935#[cfg_attr(feature = "typescript", derive(TS))]
2936#[cfg_attr(feature = "typescript", ts(export, rename_all = "camelCase"))]
2937#[cfg_attr(feature = "wasm", derive(Tsify))]
2938#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))]
2939pub struct TrackInfo {
2940    pub track_id: String,
2941    pub track_type: String, // "video", "audio", "subtitle"
2942    pub codec: String,
2943    pub language: Option<String>,
2944    pub channels: Option<String>,
2945    pub format_details: Option<String>,
2946    pub resolution: Option<String>,
2947    pub framerate: Option<String>,
2948    pub bit_depth: Option<String>,
2949    pub subtitle_type: Option<String>,
2950}
2951
2952// =============================================================================
2953// Parser functions
2954// =============================================================================
2955
2956/// Parse CPL XML content with namespace stripping.
2957///
2958/// Detects the SMPTE spec version from the root `xmlns` attribute and stores it
2959/// on the returned `CompositionPlaylist.namespace` field. This enables downstream
2960/// code to apply version-specific validation rules.
2961pub fn parse_cpl(xml_content: &str) -> Result<CompositionPlaylist, CplParseError> {
2962    parse_cpl_with_options(xml_content, &CplParseOptions::default())
2963}
2964
2965/// Parse CPL XML content with configurable hardening options.
2966pub fn parse_cpl_with_options(
2967    xml_content: &str,
2968    options: &CplParseOptions<'_>,
2969) -> Result<CompositionPlaylist, CplParseError> {
2970    // Detect namespace before stripping (stripping preserves default xmlns but
2971    // removes prefixed xmlns:xxx declarations). A document with no detectable
2972    // root xmlns falls into `Unknown(String::new())` so downstream validators
2973    // see "namespace unknown" instead of silently defaulting to the 2013
2974    // ruleset (the first enum variant).
2975    let namespace = crate::assetmap::detect_root_namespace(xml_content)
2976        .map(|uri| CplNamespace::from_uri(&uri))
2977        .unwrap_or_else(|| CplNamespace::Unknown(String::new()));
2978
2979    // Detect Signer/Signature presence from raw XML before stripping
2980    let has_signer = xml_content.contains("<Signer") || xml_content.contains(":Signer");
2981    let has_signature = xml_content.contains("<Signature") || xml_content.contains(":Signature");
2982
2983    match options.signature_validation_mode {
2984        SignatureValidationMode::Ignore => {}
2985        SignatureValidationMode::RequirePresence => {
2986            if !has_signature {
2987                return Err(CplParseError::StrictSchema(
2988                    "Signature element is required by selected signature mode".to_string(),
2989                ));
2990            }
2991        }
2992        SignatureValidationMode::VerifyIfPresent => {
2993            if has_signature {
2994                let verifier = options
2995                    .signature_verifier
2996                    .ok_or(CplParseError::SignatureVerifierRequired)?;
2997                verifier
2998                    .verify(xml_content)
2999                    .map_err(CplParseError::SignatureVerificationFailed)?;
3000            }
3001        }
3002        SignatureValidationMode::RequireValid => {
3003            if !has_signature {
3004                return Err(CplParseError::StrictSchema(
3005                    "Signature element is required by selected signature mode".to_string(),
3006                ));
3007            }
3008            let verifier = options
3009                .signature_verifier
3010                .ok_or(CplParseError::SignatureVerifierRequired)?;
3011            verifier
3012                .verify(xml_content)
3013                .map_err(CplParseError::SignatureVerificationFailed)?;
3014        }
3015    }
3016
3017    let stripped = strip_xml_namespaces(xml_content);
3018
3019    if options.unknown_field_mode == UnknownFieldMode::Error {
3020        let unknown = collect_unknown_xml_tokens(&stripped).map_err(|e| {
3021            CplParseError::StrictUnknownXml(format!("unknown token scan failed: {}", e))
3022        })?;
3023        if !unknown.is_empty() {
3024            let list = unknown.into_iter().collect::<Vec<_>>().join(", ");
3025            return Err(CplParseError::StrictUnknownXml(list));
3026        }
3027    }
3028
3029    let mut cpl: CompositionPlaylist = quick_xml::de::from_str(&stripped)?;
3030
3031    if options.schema_strict_mode == SchemaStrictMode::Basic {
3032        validate_basic_schema_constraints(&cpl)?;
3033    }
3034
3035    cpl.namespace = namespace;
3036    cpl.has_signer = has_signer;
3037    cpl.has_signature = has_signature;
3038    cpl.source_xml = Some(xml_content.to_string());
3039    Ok(cpl)
3040}
3041
3042fn validate_basic_schema_constraints(cpl: &CompositionPlaylist) -> Result<(), CplParseError> {
3043    if cpl.segment_list.segments.is_empty() {
3044        return Err(CplParseError::StrictSchema(
3045            "SegmentList must contain at least one Segment".to_string(),
3046        ));
3047    }
3048
3049    for (segment_index, segment) in cpl.segment_list.segments.iter().enumerate() {
3050        let sequence_count = segment.sequence_list.marker_sequences.len()
3051            + segment.sequence_list.main_image_sequences.len()
3052            + segment.sequence_list.main_audio_sequences.len()
3053            + segment.sequence_list.subtitles_sequences.len()
3054            + segment
3055                .sequence_list
3056                .hearing_impaired_captions_sequences
3057                .len()
3058            + segment.sequence_list.forced_narrative_sequences.len()
3059            + segment.sequence_list.iab_sequences.len()
3060            + segment.sequence_list.isxd_sequences.len();
3061
3062        if sequence_count == 0 {
3063            return Err(CplParseError::StrictSchema(format!(
3064                "Segment[{}] must contain at least one sequence",
3065                segment_index
3066            )));
3067        }
3068    }
3069
3070    Ok(())
3071}
3072
3073fn collect_unknown_xml_tokens(xml: &str) -> Result<BTreeSet<String>, String> {
3074    let mut reader = quick_xml::Reader::from_str(xml);
3075    reader.trim_text(true);
3076
3077    let allowed_elements: BTreeSet<&'static str> = [
3078        "CompositionPlaylist",
3079        "Id",
3080        "Annotation",
3081        "IssueDate",
3082        "Issuer",
3083        "Creator",
3084        "ContentOriginator",
3085        "ContentTitle",
3086        "ContentKind",
3087        "ContentVersionList",
3088        "ContentVersion",
3089        "LabelText",
3090        "EssenceDescriptorList",
3091        "EssenceDescriptor",
3092        "EditRate",
3093        "TotalRunningTime",
3094        "LocaleList",
3095        "Locale",
3096        "LanguageList",
3097        "Language",
3098        "RegionList",
3099        "Region",
3100        "ContentMaturityRatingList",
3101        "ContentMaturityRating",
3102        "Agency",
3103        "Rating",
3104        "Audience",
3105        "ExtensionProperties",
3106        "ApplicationIdentification",
3107        "MaxCLL",
3108        "MaxFALL",
3109        "CompositionTimecode",
3110        "TimecodeDropFrame",
3111        "TimecodeRate",
3112        "TimecodeStartAddress",
3113        "SegmentList",
3114        "Segment",
3115        "SequenceList",
3116        "MarkerSequence",
3117        "MainImageSequence",
3118        "MainAudioSequence",
3119        "SubtitlesSequence",
3120        "MainSubtitleSequence",
3121        "HearingImpairedCaptionsSequence",
3122        "ForcedNarrativeSequence",
3123        "IABSequence",
3124        "ISXDSequence",
3125        "TrackId",
3126        "ResourceList",
3127        "Resource",
3128        "IntrinsicDuration",
3129        "EntryPoint",
3130        "SourceDuration",
3131        "SourceEncoding",
3132        "TrackFileId",
3133        "RepeatCount",
3134        "KeyId",
3135        "Hash",
3136        "Marker",
3137        "Label",
3138        "Offset",
3139        "RGBADescriptor",
3140        "CDCIDescriptor",
3141        "WAVEPCMDescriptor",
3142        "DCTimedTextDescriptor",
3143        "IABEssenceDescriptor",
3144        "ISXDDataEssenceDescriptor",
3145        "InstanceID",
3146        "InstanceUID",
3147        "DisplayWidth",
3148        "DisplayHeight",
3149        "StoredWidth",
3150        "StoredHeight",
3151        "SampleRate",
3152        "ImageAspectRatio",
3153        "ColorPrimaries",
3154        "TransferCharacteristic",
3155        "CodingEquations",
3156        "PictureCompression",
3157        "FrameLayout",
3158        "DisplayF2Offset",
3159        "ComponentMaxRef",
3160        "ComponentMinRef",
3161        "ScanningDirection",
3162        "StoredF2Offset",
3163        "SampledWidth",
3164        "SampledHeight",
3165        "SampledXOffset",
3166        "SampledYOffset",
3167        "AlphaTransparency",
3168        "ImageAlignmentOffset",
3169        "ImageStartOffset",
3170        "ImageEndOffset",
3171        "FieldDominance",
3172        "AlphaMaxRef",
3173        "AlphaMinRef",
3174        "Palette",
3175        "PaletteLayout",
3176        "LinkedTrackID",
3177        "SubDescriptors",
3178        "ActiveWidth",
3179        "ActiveHeight",
3180        "ComponentDepth",
3181        "HorizontalSubsampling",
3182        "VerticalSubsampling",
3183        "ColorSiting",
3184        "BlackRefLevel",
3185        "WhiteRefLevel",
3186        "ColorRange",
3187        "ReversedByteOrder",
3188        "PaddingBits",
3189        "AlphaSampleDepth",
3190        "PHDRMetadataTrackSubDescriptor",
3191        "JPEG2000SubDescriptor",
3192        "Rsiz",
3193        "Xsiz",
3194        "Ysiz",
3195        "XOsiz",
3196        "YOsiz",
3197        "XTsiz",
3198        "YTsiz",
3199        "XTOsiz",
3200        "YTOsiz",
3201        "Csiz",
3202        "CodingStyleDefault",
3203        "QuantizationDefault",
3204        "J2CLayout",
3205        "J2KExtendedCapabilities",
3206        "PictureComponentSizing",
3207        "RGBAComponent",
3208        "Code",
3209        "ComponentSize",
3210        "Pcap",
3211        "J2KComponentSizing",
3212        "Ssiz",
3213        "XRSiz",
3214        "YRSiz",
3215        "PHDRMetadataTrackSubDescriptor_DataDefinition",
3216        "PHDRMetadataTrackSubDescriptor_SimplePayloadSID",
3217        "PHDRMetadataTrackSubDescriptor_SourceTrackID",
3218        "AudioSampleRate",
3219        "ChannelCount",
3220        "QuantizationBits",
3221        "SoundfieldGroupLabelSubDescriptor",
3222        "MCATagSymbol",
3223        "MCATagName",
3224        "MCAAudioContentKind",
3225        "RFC5646SpokenLanguage",
3226        "RFC5646AudioLanguageCode",
3227        "RFC5646LanguageTagList",
3228        "NamespaceURI",
3229        "SoundCompression",
3230        "IABSoundfieldLabelSubDescriptor",
3231        "ContainerFormat",
3232        "Codec",
3233        "ElectrospatialFormulation",
3234        "MCALabelDictionaryID",
3235        "EssenceLength",
3236        "Locked",
3237        "MCALinkID",
3238        "MCAChannelID",
3239        "AudioChannelLabelSubDescriptor",
3240        "MCATitle",
3241        "MCATitleVersion",
3242        "MCAAudioElementKind",
3243        "SoundfieldGroupLinkID",
3244        "DataEssenceCoding",
3245        "ContainerConstraintsSubDescriptor",
3246        "Signer",
3247        "Signature",
3248    ]
3249    .into_iter()
3250    .collect();
3251
3252    let allowed_attributes: BTreeSet<&'static str> =
3253        ["xmlns", "scope", "language"].into_iter().collect();
3254
3255    let mut unknown = BTreeSet::new();
3256
3257    loop {
3258        match reader.read_event() {
3259            Ok(Event::Start(e)) | Ok(Event::Empty(e)) => {
3260                let name = std::str::from_utf8(e.name().as_ref())
3261                    .map_err(|e| e.to_string())?
3262                    .to_string();
3263                if !allowed_elements.contains(name.as_str()) {
3264                    unknown.insert(format!("element:{}", name));
3265                }
3266                for attr in e.attributes() {
3267                    let attr = attr.map_err(|e| e.to_string())?;
3268                    let key = std::str::from_utf8(attr.key.as_ref())
3269                        .map_err(|e| e.to_string())?
3270                        .to_string();
3271                    if !(allowed_attributes.contains(key.as_str()) || key.starts_with("xmlns:")) {
3272                        unknown.insert(format!("attribute:{}@{}", key, name));
3273                    }
3274                }
3275            }
3276            Ok(Event::Eof) => break,
3277            Ok(_) => {}
3278            Err(e) => return Err(e.to_string()),
3279        }
3280    }
3281
3282    Ok(unknown)
3283}
3284
3285/// Extract all languages found in a CPL
3286pub fn extract_cpl_languages(cpl: &CompositionPlaylist) -> Vec<LanguageTag> {
3287    let mut languages: Vec<LanguageTag> = Vec::new();
3288
3289    let add_lang = |languages: &mut Vec<LanguageTag>, lang_opt: &Option<LanguageTag>| {
3290        if let Some(lang) = lang_opt {
3291            if !lang.as_str().is_empty() && !languages.contains(lang) {
3292                languages.push(lang.clone());
3293            }
3294        }
3295    };
3296
3297    let add_lang_string = |languages: &mut Vec<LanguageTag>,
3298                           lang_string: &Option<LanguageString>| {
3299        if let Some(ls) = lang_string {
3300            add_lang(languages, &ls.language);
3301        }
3302    };
3303
3304    let add_required_lang_string =
3305        |languages: &mut Vec<LanguageTag>, lang_string: &LanguageString| {
3306            add_lang(languages, &lang_string.language);
3307        };
3308
3309    // Extract from main CPL fields
3310    add_lang_string(&mut languages, &cpl.annotation);
3311    add_lang_string(&mut languages, &cpl.issuer);
3312    add_lang_string(&mut languages, &cpl.creator);
3313    add_lang_string(&mut languages, &cpl.content_originator);
3314    add_required_lang_string(&mut languages, &cpl.content_title);
3315
3316    // Extract from content versions
3317    if let Some(content_version_list) = &cpl.content_version_list {
3318        for version in &content_version_list.content_versions {
3319            add_lang_string(&mut languages, &version.label_text);
3320        }
3321    }
3322
3323    // Extract from LocaleList
3324    if let Some(locale_list) = &cpl.locale_list {
3325        for locale in &locale_list.locales {
3326            if let Some(language_list) = &locale.language_list {
3327                for lang in &language_list.languages {
3328                    if !lang.as_str().is_empty() && !languages.contains(lang) {
3329                        languages.push(lang.clone());
3330                    }
3331                }
3332            }
3333        }
3334    }
3335
3336    // Extract from EssenceDescriptors
3337    if let Some(edl) = &cpl.essence_descriptor_list {
3338        for ed in &edl.essence_descriptors {
3339            // Audio language from WAVEPCMDescriptor
3340            if let Some(wave) = &ed.wave_pcm_descriptor {
3341                if let Some(subs) = &wave.sub_descriptors {
3342                    if let Some(sf) = &subs.soundfield_group_label_sub_descriptor {
3343                        add_lang(&mut languages, &sf.rfc5646_spoken_language);
3344                    }
3345                }
3346            }
3347            // Audio language from IABEssenceDescriptor
3348            if let Some(iab) = &ed.iab_essence_descriptor {
3349                if let Some(subs) = &iab.sub_descriptors {
3350                    if let Some(sf) = &subs.iab_soundfield_label_sub_descriptor {
3351                        add_lang(&mut languages, &sf.rfc5646_spoken_language);
3352                    }
3353                }
3354            }
3355            // Timed text language from DCTimedTextDescriptor
3356            if let Some(tt) = &ed.dc_timed_text_descriptor {
3357                for lang in &tt.rfc5646_language_tag_list {
3358                    if !lang.as_str().is_empty() && !languages.contains(lang) {
3359                        languages.push(lang.clone());
3360                    }
3361                }
3362            }
3363        }
3364    }
3365
3366    languages.sort_by(|a, b| a.as_str().cmp(b.as_str()));
3367    languages.dedup();
3368    languages
3369}
3370
3371/// Extract track-level codec information from CPL XML, returning an error on parse failure.
3372pub fn try_extract_cpl_track_codecs_from_xml(
3373    xml_content: &str,
3374) -> Result<Vec<TrackInfo>, CplParseError> {
3375    let cpl = parse_cpl(xml_content)?;
3376    Ok(extract_tracks_from_cpl(&cpl, xml_content))
3377}
3378
3379/// Extract track-level codec information from CPL XML content.
3380///
3381/// Returns an empty `Vec` if the CPL fails to parse. Prefer
3382/// [`try_extract_cpl_track_codecs_from_xml`] to distinguish parse failure from empty tracks.
3383pub fn extract_cpl_track_codecs_from_xml(xml_content: &str) -> Vec<TrackInfo> {
3384    try_extract_cpl_track_codecs_from_xml(xml_content).unwrap_or_default()
3385}
3386
3387/// Extract track info from a properly parsed CPL (replaces regex-based extraction)
3388fn extract_tracks_from_cpl(cpl: &CompositionPlaylist, _raw_xml: &str) -> Vec<TrackInfo> {
3389    let mut tracks = Vec::new();
3390
3391    // Build essence descriptor lookup by ID
3392    let descriptors: std::collections::HashMap<ImfUuid, &EssenceDescriptor> =
3393        if let Some(edl) = &cpl.essence_descriptor_list {
3394            edl.essence_descriptors
3395                .iter()
3396                .map(|ed| (ed.id, ed))
3397                .collect()
3398        } else {
3399            std::collections::HashMap::new()
3400        };
3401
3402    for segment in &cpl.segment_list.segments {
3403        let seq_list = &segment.sequence_list;
3404
3405        // Video tracks from MainImageSequence
3406        for seq in &seq_list.main_image_sequences {
3407            for resource in &seq.resource_list.resources {
3408                if let Some(source_encoding) = &resource.source_encoding {
3409                    if let Some(ed) = descriptors.get(source_encoding) {
3410                        let (codec, resolution, bit_depth) = extract_video_info_from_descriptor(ed);
3411                        let framerate = resource
3412                            .edit_rate
3413                            .as_ref()
3414                            .or(cpl.edit_rate.as_ref())
3415                            .map(format_framerate);
3416                        tracks.push(TrackInfo {
3417                            track_id: seq.track_id.to_string(),
3418                            track_type: "video".to_string(),
3419                            codec,
3420                            language: None,
3421                            channels: None,
3422                            format_details: None,
3423                            resolution,
3424                            framerate,
3425                            bit_depth,
3426                            subtitle_type: None,
3427                        });
3428                    }
3429                }
3430            }
3431        }
3432
3433        // Audio tracks from MainAudioSequence
3434        for seq in &seq_list.main_audio_sequences {
3435            for resource in &seq.resource_list.resources {
3436                if let Some(source_encoding) = &resource.source_encoding {
3437                    if let Some(ed) = descriptors.get(source_encoding) {
3438                        let (codec, channels, format_details, language) =
3439                            extract_audio_info_from_descriptor(ed);
3440                        tracks.push(TrackInfo {
3441                            track_id: seq.track_id.to_string(),
3442                            track_type: "audio".to_string(),
3443                            codec,
3444                            language,
3445                            channels,
3446                            format_details,
3447                            resolution: None,
3448                            framerate: None,
3449                            bit_depth: None,
3450                            subtitle_type: None,
3451                        });
3452                    }
3453                }
3454            }
3455        }
3456
3457        // IAB (Atmos) tracks
3458        for seq in &seq_list.iab_sequences {
3459            for resource in &seq.resource_list.resources {
3460                if let Some(source_encoding) = &resource.source_encoding {
3461                    if let Some(ed) = descriptors.get(source_encoding) {
3462                        let language = ed
3463                            .iab_essence_descriptor
3464                            .as_ref()
3465                            .and_then(|iab| iab.sub_descriptors.as_ref())
3466                            .and_then(|sd| sd.iab_soundfield_label_sub_descriptor.as_ref())
3467                            .and_then(|sf| sf.rfc5646_spoken_language.as_ref())
3468                            .map(|lt| lt.as_str().to_string());
3469                        tracks.push(TrackInfo {
3470                            track_id: seq.track_id.to_string(),
3471                            track_type: "audio".to_string(),
3472                            codec: "IAB (Dolby Atmos)".to_string(),
3473                            language,
3474                            channels: Some("Object-based".to_string()),
3475                            format_details: Some("Immersive Audio".to_string()),
3476                            resolution: None,
3477                            framerate: None,
3478                            bit_depth: None,
3479                            subtitle_type: None,
3480                        });
3481                    }
3482                }
3483            }
3484        }
3485
3486        // Subtitle tracks
3487        let subtitle_sequences: Vec<(&str, &[SubtitlesSequence])> = vec![
3488            // We need to handle each type separately due to different types
3489        ];
3490        let _ = subtitle_sequences; // suppress warning
3491
3492        for seq in &seq_list.subtitles_sequences {
3493            if let Some(track) =
3494                extract_timed_text_track(seq.track_id, "standard", &seq.resource_list, &descriptors)
3495            {
3496                tracks.push(track);
3497            }
3498        }
3499        for seq in &seq_list.hearing_impaired_captions_sequences {
3500            if let Some(track) =
3501                extract_timed_text_track(seq.track_id, "hi", &seq.resource_list, &descriptors)
3502            {
3503                tracks.push(track);
3504            }
3505        }
3506        for seq in &seq_list.forced_narrative_sequences {
3507            if let Some(track) =
3508                extract_timed_text_track(seq.track_id, "forced", &seq.resource_list, &descriptors)
3509            {
3510                tracks.push(track);
3511            }
3512        }
3513    }
3514
3515    tracks
3516}
3517
3518fn extract_video_info_from_descriptor(
3519    ed: &EssenceDescriptor,
3520) -> (String, Option<String>, Option<String>) {
3521    if let Some(rgba) = &ed.rgba_descriptor {
3522        let width = rgba.display_width.or(rgba.stored_width);
3523        let height = rgba.display_height.or(rgba.stored_height);
3524        let resolution = match (width, height) {
3525            (Some(w), Some(h)) => Some(format!("{}x{}", w, h)),
3526            _ => None,
3527        };
3528        let codec = rgba
3529            .picture_compression
3530            .as_ref()
3531            .map(|c| c.to_string())
3532            .unwrap_or_else(|| "JPEG 2000".to_string());
3533        return (codec, resolution, None);
3534    }
3535    if let Some(cdci) = &ed.cdci_descriptor {
3536        let width = cdci
3537            .active_width
3538            .or(cdci.display_width)
3539            .or(cdci.stored_width);
3540        let height = cdci
3541            .active_height
3542            .or(cdci.display_height)
3543            .or(cdci.stored_height);
3544        let resolution = match (width, height) {
3545            (Some(w), Some(h)) => Some(format!("{}x{}", w, h)),
3546            _ => None,
3547        };
3548        let codec = cdci
3549            .picture_compression
3550            .as_ref()
3551            .map(|c| c.to_string())
3552            .unwrap_or_else(|| "JPEG 2000".to_string());
3553        let bit_depth = cdci.component_depth.map(|d| format!("{}-bit", d));
3554        return (codec, resolution, bit_depth);
3555    }
3556    ("Unknown".to_string(), None, None)
3557}
3558
3559fn extract_audio_info_from_descriptor(
3560    ed: &EssenceDescriptor,
3561) -> (String, Option<String>, Option<String>, Option<String>) {
3562    if let Some(wave) = &ed.wave_pcm_descriptor {
3563        let codec = wave
3564            .quantization_bits
3565            .map(|b| format!("PCM {}-bit", b))
3566            .unwrap_or_else(|| "PCM".to_string());
3567        let (channels, format_details) = match wave.channel_count {
3568            Some(1) => (Some("1.0".to_string()), Some("Mono".to_string())),
3569            Some(2) => (Some("2.0".to_string()), Some("Stereo".to_string())),
3570            Some(6) => (Some("5.1".to_string()), Some("Surround".to_string())),
3571            Some(8) => (Some("7.1".to_string()), Some("Surround".to_string())),
3572            Some(n) => (Some(format!("{}.0", n)), Some(format!("{} Channel", n))),
3573            None => (None, None),
3574        };
3575        let language = wave
3576            .sub_descriptors
3577            .as_ref()
3578            .and_then(|sd| sd.soundfield_group_label_sub_descriptor.as_ref())
3579            .and_then(|sf| sf.rfc5646_spoken_language.as_ref())
3580            .map(|lt| lt.as_str().to_string());
3581        return (codec, channels, format_details, language);
3582    }
3583    ("Unknown".to_string(), None, None, None)
3584}
3585
3586fn extract_timed_text_track(
3587    track_id: ImfUuid,
3588    subtitle_type: &str,
3589    resource_list: &ResourceList,
3590    descriptors: &std::collections::HashMap<ImfUuid, &EssenceDescriptor>,
3591) -> Option<TrackInfo> {
3592    for resource in &resource_list.resources {
3593        if let Some(source_encoding) = &resource.source_encoding {
3594            if let Some(ed) = descriptors.get(source_encoding) {
3595                let language = ed
3596                    .dc_timed_text_descriptor
3597                    .as_ref()
3598                    .map(|tt| {
3599                        tt.rfc5646_language_tag_list
3600                            .iter()
3601                            .map(|lt| lt.as_str())
3602                            .filter(|s| !s.is_empty())
3603                            .collect::<Vec<_>>()
3604                            .join(",")
3605                    })
3606                    .filter(|s| !s.is_empty());
3607                return Some(TrackInfo {
3608                    track_id: track_id.to_string(),
3609                    track_type: "subtitle".to_string(),
3610                    codec: "IMSC1 (Timed Text)".to_string(),
3611                    language,
3612                    channels: None,
3613                    format_details: None,
3614                    resolution: None,
3615                    framerate: None,
3616                    bit_depth: None,
3617                    subtitle_type: Some(subtitle_type.to_string()),
3618                });
3619            }
3620        }
3621    }
3622    None
3623}
3624
3625pub fn format_framerate(edit_rate: &EditRate) -> String {
3626    let fps = edit_rate.as_f64();
3627    if (fps - 23.976).abs() < 0.01 {
3628        "23.976".to_string()
3629    } else if (fps - 29.97).abs() < 0.01 {
3630        "29.97".to_string()
3631    } else if (fps - 59.94).abs() < 0.01 {
3632        "59.94".to_string()
3633    } else if fps == fps.round() {
3634        format!("{}", fps as u32)
3635    } else {
3636        format!("{:.3}", fps)
3637    }
3638}
3639
3640// =============================================================================
3641// Tests
3642// =============================================================================
3643
3644#[cfg(test)]
3645mod tests {
3646    use super::*;
3647    use crate::assetmap::ImfUuid;
3648
3649    fn make_seq_list_with_all_types() -> SequenceList {
3650        let uuid = || ImfUuid::parse("urn:uuid:00000000-0000-0000-0000-000000000001").unwrap();
3651        let rl = || ResourceList { resources: vec![] };
3652        SequenceList {
3653            marker_sequences: vec![MarkerSequence {
3654                id: uuid(),
3655                track_id: uuid(),
3656                resource_list: rl(),
3657            }],
3658            main_image_sequences: vec![MainImageSequence {
3659                id: uuid(),
3660                track_id: uuid(),
3661                resource_list: rl(),
3662            }],
3663            main_audio_sequences: vec![MainAudioSequence {
3664                id: uuid(),
3665                track_id: uuid(),
3666                resource_list: rl(),
3667            }],
3668            subtitles_sequences: vec![SubtitlesSequence {
3669                id: uuid(),
3670                track_id: uuid(),
3671                resource_list: rl(),
3672            }],
3673            hearing_impaired_captions_sequences: vec![HearingImpairedCaptionsSequence {
3674                id: uuid(),
3675                track_id: uuid(),
3676                resource_list: rl(),
3677            }],
3678            forced_narrative_sequences: vec![ForcedNarrativeSequence {
3679                id: uuid(),
3680                track_id: uuid(),
3681                resource_list: rl(),
3682            }],
3683            iab_sequences: vec![IABSequence {
3684                id: uuid(),
3685                track_id: uuid(),
3686                resource_list: rl(),
3687            }],
3688            isxd_sequences: vec![ISXDSequence {
3689                id: uuid(),
3690                track_id: uuid(),
3691                resource_list: rl(),
3692            }],
3693        }
3694    }
3695
3696    #[test]
3697    fn all_sequences_excludes_markers() {
3698        let sl = make_seq_list_with_all_types();
3699        // 7 non-marker sequence types, 1 of each
3700        assert_eq!(sl.all_sequences().len(), 7);
3701    }
3702
3703    #[test]
3704    fn all_sequences_typed_returns_type_names() {
3705        let sl = make_seq_list_with_all_types();
3706        let typed = sl.all_sequences_typed();
3707        assert_eq!(typed.len(), 7);
3708        let names: Vec<&str> = typed.iter().map(|(_, n)| *n).collect();
3709        assert!(names.contains(&"MainImage"));
3710        assert!(names.contains(&"MainAudio"));
3711        assert!(names.contains(&"Subtitles"));
3712        assert!(names.contains(&"HearingImpairedCaptions"));
3713        assert!(names.contains(&"ForcedNarrative"));
3714        assert!(names.contains(&"IAB"));
3715        assert!(names.contains(&"ISXD"));
3716    }
3717
3718    #[test]
3719    fn all_sequences_empty_list() {
3720        let sl = SequenceList {
3721            marker_sequences: vec![],
3722            main_image_sequences: vec![],
3723            main_audio_sequences: vec![],
3724            subtitles_sequences: vec![],
3725            hearing_impaired_captions_sequences: vec![],
3726            forced_narrative_sequences: vec![],
3727            iab_sequences: vec![],
3728            isxd_sequences: vec![],
3729        };
3730        assert!(sl.all_sequences().is_empty());
3731        assert!(sl.all_sequences_typed().is_empty());
3732    }
3733
3734    #[test]
3735    fn try_extract_cpl_track_codecs_invalid_xml() {
3736        let result = try_extract_cpl_track_codecs_from_xml("<not-a-cpl/>");
3737        assert!(result.is_err());
3738    }
3739
3740    struct AcceptAllSignatureVerifier;
3741    impl XmlSignatureVerifier for AcceptAllSignatureVerifier {
3742        fn verify(&self, _xml_content: &str) -> Result<(), String> {
3743            Ok(())
3744        }
3745    }
3746
3747    struct RejectingSignatureVerifier;
3748    impl XmlSignatureVerifier for RejectingSignatureVerifier {
3749        fn verify(&self, _xml_content: &str) -> Result<(), String> {
3750            Err("bad signature".to_string())
3751        }
3752    }
3753
3754    #[test]
3755    fn strict_production_options_enable_all_strict_checks() {
3756        let verifier = ReferenceDigestXmlDsigVerifier;
3757        let options = strict_production_parse_options(&verifier);
3758        assert_eq!(options.unknown_field_mode, UnknownFieldMode::Error);
3759        assert_eq!(options.schema_strict_mode, SchemaStrictMode::Basic);
3760        assert_eq!(
3761            options.signature_validation_mode,
3762            SignatureValidationMode::RequireValid
3763        );
3764        assert!(options.signature_verifier.is_some());
3765    }
3766
3767    #[test]
3768    fn recommended_signature_verifier_rejects_unsigned_xml() {
3769        let xml = minimal_cpl_with_ns("http://www.smpte-ra.org/schemas/2067-3/2013");
3770        let verifier = recommended_signature_verifier();
3771        assert!(verifier.verify(&xml).is_err());
3772    }
3773
3774    #[test]
3775    fn test_strip_xml_namespaces() {
3776        let input = r#"<r0:RGBADescriptor xmlns:r0="http://example.com"><r1:DisplayWidth>3840</r1:DisplayWidth></r0:RGBADescriptor>"#;
3777        let result = strip_xml_namespaces(input);
3778        assert!(result.contains("<RGBADescriptor"));
3779        assert!(result.contains("<DisplayWidth>3840</DisplayWidth>"));
3780        assert!(result.contains("</RGBADescriptor>"));
3781        assert!(!result.contains("xmlns:r0"));
3782    }
3783
3784    #[test]
3785    fn test_strip_preserves_content_with_colons() {
3786        let input = r#"<PictureCompression>urn:smpte:ul:060e2b34</PictureCompression>"#;
3787        let result = strip_xml_namespaces(input);
3788        assert_eq!(result, input); // No namespace prefixes to strip
3789    }
3790
3791    #[test]
3792    fn test_parse_simple_cpl() {
3793        let xml = r#"<?xml version="1.0" encoding="UTF-8" ?>
3794<CompositionPlaylist xmlns="http://www.smpte-ra.org/schemas/2067-3/2013">
3795<Id>urn:uuid:0eb3d1b9-b77b-4d3f-bbe5-7c69b15dca85</Id>
3796<Annotation>Test CPL</Annotation>
3797<IssueDate>2016-10-06T08:35:02-00:00</IssueDate>
3798<ContentTitle>Test Content</ContentTitle>
3799<ContentKind>Test</ContentKind>
3800<SegmentList>
3801<Segment>
3802<Id>urn:uuid:00000000-0000-0000-0000-000000000001</Id>
3803<SequenceList>
3804</SequenceList>
3805</Segment>
3806</SegmentList>
3807</CompositionPlaylist>"#;
3808
3809        let result = parse_cpl(xml);
3810        match result {
3811            Ok(cpl) => {
3812                assert_eq!(
3813                    cpl.id,
3814                    ImfUuid::parse("urn:uuid:0eb3d1b9-b77b-4d3f-bbe5-7c69b15dca85").unwrap()
3815                );
3816                assert_eq!(cpl.content_title.text, "Test Content");
3817                assert_eq!(cpl.content_kind, ContentKind::Test);
3818                assert!(!cpl.segment_list.segments.is_empty());
3819            }
3820            Err(e) => panic!("Failed to parse CPL: {:?}", e),
3821        }
3822    }
3823
3824    #[test]
3825    fn test_content_kind_scope_attribute() {
3826        // Verify scope attribute is captured from ContentKind XML element
3827        let xml = r#"<?xml version="1.0" encoding="UTF-8" ?>
3828<CompositionPlaylist xmlns="http://www.smpte-ra.org/schemas/2067-3/2013">
3829<Id>urn:uuid:0eb3d1b9-b77b-4d3f-bbe5-7c69b15dca85</Id>
3830<IssueDate>2024-01-01T00:00:00Z</IssueDate>
3831<ContentTitle>Test</ContentTitle>
3832<ContentKind scope="http://www.smpte-ra.org/schemas/2067-3/2013#content-kind">feature</ContentKind>
3833<SegmentList>
3834<Segment>
3835<Id>urn:uuid:00000000-0000-0000-0000-000000000001</Id>
3836<SequenceList>
3837</SequenceList>
3838</Segment>
3839</SegmentList>
3840</CompositionPlaylist>"#;
3841
3842        let cpl = parse_cpl(xml).expect("Failed to parse CPL with ContentKind scope");
3843        assert_eq!(cpl.content_kind.kind, ContentKind::Feature);
3844        assert_eq!(
3845            cpl.content_kind.scope.as_deref(),
3846            Some("http://www.smpte-ra.org/schemas/2067-3/2013#content-kind")
3847        );
3848        assert_eq!(
3849            cpl.content_kind.effective_scope(),
3850            "http://www.smpte-ra.org/schemas/2067-3/2013#content-kind"
3851        );
3852    }
3853
3854    #[test]
3855    fn test_content_kind_custom_scope() {
3856        // Verify custom (non-default) scope is preserved
3857        let xml = r#"<?xml version="1.0" encoding="UTF-8" ?>
3858<CompositionPlaylist xmlns="http://www.smpte-ra.org/schemas/2067-3/2013">
3859<Id>urn:uuid:0eb3d1b9-b77b-4d3f-bbe5-7c69b15dca85</Id>
3860<IssueDate>2024-01-01T00:00:00Z</IssueDate>
3861<ContentTitle>Test</ContentTitle>
3862<ContentKind scope="http://example.com/custom-kinds">my-custom-kind</ContentKind>
3863<SegmentList>
3864<Segment>
3865<Id>urn:uuid:00000000-0000-0000-0000-000000000001</Id>
3866<SequenceList>
3867</SequenceList>
3868</Segment>
3869</SegmentList>
3870</CompositionPlaylist>"#;
3871
3872        let cpl = parse_cpl(xml).expect("Failed to parse CPL with custom scope");
3873        assert_eq!(
3874            cpl.content_kind.kind,
3875            ContentKind::Other("my-custom-kind".to_string())
3876        );
3877        assert_eq!(
3878            cpl.content_kind.scope.as_deref(),
3879            Some("http://example.com/custom-kinds")
3880        );
3881    }
3882
3883    #[test]
3884    fn test_content_kind_no_scope_uses_default() {
3885        // When no scope attribute is present, effective_scope() returns the XSD default
3886        let xml = r#"<?xml version="1.0" encoding="UTF-8" ?>
3887<CompositionPlaylist xmlns="http://www.smpte-ra.org/schemas/2067-3/2013">
3888<Id>urn:uuid:0eb3d1b9-b77b-4d3f-bbe5-7c69b15dca85</Id>
3889<IssueDate>2024-01-01T00:00:00Z</IssueDate>
3890<ContentTitle>Test</ContentTitle>
3891<ContentKind>Test</ContentKind>
3892<SegmentList>
3893<Segment>
3894<Id>urn:uuid:00000000-0000-0000-0000-000000000001</Id>
3895<SequenceList>
3896</SequenceList>
3897</Segment>
3898</SegmentList>
3899</CompositionPlaylist>"#;
3900
3901        let cpl = parse_cpl(xml).expect("Failed to parse CPL without scope");
3902        assert_eq!(cpl.content_kind.kind, ContentKind::Test);
3903        assert!(cpl.content_kind.scope.is_none());
3904        assert_eq!(
3905            cpl.content_kind.effective_scope(),
3906            CONTENT_KIND_DEFAULT_SCOPE
3907        );
3908    }
3909
3910    #[test]
3911    fn test_malformed_xml_handling() {
3912        let malformed_xml = r#"<?xml version="1.0" encoding="UTF-8" ?>
3913<CompositionPlaylist xmlns="http://www.smpte-ra.org/schemas/2067-3/2013">
3914<Id>urn:uuid:test</Id>
3915<ContentTitle>Broken XML"#;
3916
3917        let result: Result<CompositionPlaylist, CplParseError> = parse_cpl(malformed_xml);
3918        assert!(result.is_err(), "Should fail with malformed XML");
3919    }
3920
3921    // ── Namespace compatibility ──────────────────────────────────────────────
3922
3923    /// Helper: build a minimal CPL XML with the given xmlns.
3924    fn minimal_cpl_with_ns(ns: &str) -> String {
3925        format!(
3926            r#"<?xml version="1.0" encoding="UTF-8" ?>
3927<CompositionPlaylist xmlns="{ns}">
3928<Id>urn:uuid:0eb3d1b9-b77b-4d3f-bbe5-7c69b15dca85</Id>
3929<IssueDate>2024-01-01T00:00:00Z</IssueDate>
3930<ContentTitle>NS Test</ContentTitle>
3931<ContentKind>Test</ContentKind>
3932<SegmentList>
3933<Segment>
3934<Id>urn:uuid:00000000-0000-0000-0000-000000000001</Id>
3935<SequenceList></SequenceList>
3936</Segment>
3937</SegmentList>
3938</CompositionPlaylist>"#
3939        )
3940    }
3941
3942    /// SMPTE ST 2067-3:2013 namespace (original).
3943    #[test]
3944    fn cpl_parses_with_2067_3_2013_namespace() {
3945        let xml = minimal_cpl_with_ns("http://www.smpte-ra.org/schemas/2067-3/2013");
3946        let cpl = parse_cpl(&xml).expect("2013 namespace should parse");
3947        assert_eq!(cpl.content_title.text, "NS Test");
3948        assert_eq!(cpl.namespace, CplNamespace::Smpte2067_3_2013);
3949        assert_eq!(cpl.namespace.spec_id(), "ST 2067-3:2013");
3950        assert_eq!(cpl.namespace.year(), Some(2013));
3951    }
3952
3953    /// SMPTE ST 2067-3:2016 namespace.
3954    #[test]
3955    fn cpl_parses_with_2067_3_2016_namespace() {
3956        let xml = minimal_cpl_with_ns("http://www.smpte-ra.org/schemas/2067-3/2016");
3957        let cpl = parse_cpl(&xml).expect("2016 namespace should parse");
3958        assert_eq!(cpl.content_title.text, "NS Test");
3959        assert_eq!(cpl.namespace, CplNamespace::Smpte2067_3_2016);
3960        assert_eq!(cpl.namespace.year(), Some(2016));
3961    }
3962
3963    /// `http://www.smpte-ra.org/ns/2067-3/2020` is not a registered namespace —
3964    /// ST 2067-3:2020 reuses the 2016 namespace per the canonical XSD. Documents
3965    /// declaring the fake URI parse but resolve to `Unknown`.
3966    #[test]
3967    fn cpl_parses_with_fake_2020_namespace_as_unknown() {
3968        let xml = minimal_cpl_with_ns("http://www.smpte-ra.org/ns/2067-3/2020");
3969        let cpl = parse_cpl(&xml).expect("CPL should still parse, namespace just unknown");
3970        assert_eq!(cpl.content_title.text, "NS Test");
3971        assert!(matches!(cpl.namespace, CplNamespace::Unknown(_)));
3972        assert_eq!(cpl.namespace.year(), None);
3973    }
3974
3975    /// FIX-3 regression: a CPL with no detectable root xmlns lands in
3976    /// `Unknown(String::new())` rather than silently defaulting to the 2013
3977    /// ruleset (the first enum variant).
3978    #[test]
3979    fn cpl_without_root_xmlns_lands_in_unknown_not_2013() {
3980        // No xmlns attribute on the root element at all.
3981        let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
3982<CompositionPlaylist>
3983    <Id>urn:uuid:0eb3d1b9-b77b-4d3f-bbe5-7c69b15dca85</Id>
3984    <IssueDate>2024-01-01T00:00:00Z</IssueDate>
3985    <ContentTitle>NS Test</ContentTitle>
3986    <EditRate>24 1</EditRate>
3987    <SegmentList></SegmentList>
3988</CompositionPlaylist>"#;
3989        let cpl = parse_cpl(xml).expect("CPL should still parse without xmlns");
3990        assert!(
3991            matches!(cpl.namespace, CplNamespace::Unknown(ref s) if s.is_empty()),
3992            "expected Unknown(\"\") for missing xmlns, got {:?}",
3993            cpl.namespace
3994        );
3995    }
3996
3997    /// DCI CPL namespace compatibility (pre-IMF era, ST 429 series).
3998    #[test]
3999    fn cpl_parses_with_dci_429_7_namespace() {
4000        let xml = minimal_cpl_with_ns("http://www.smpte-ra.org/schemas/429-7/2006/CPL");
4001        let cpl = parse_cpl(&xml).expect("DCI 429-7 namespace should parse");
4002        assert_eq!(cpl.content_title.text, "NS Test");
4003        assert_eq!(cpl.namespace, CplNamespace::Dci429_7);
4004        assert_eq!(cpl.namespace.year(), Some(2006));
4005    }
4006
4007    /// Real test corpus: MERIDIAN CPL should detect 2013 namespace.
4008    #[test]
4009    fn cpl_meridian_detects_2013_namespace() {
4010        let xml = include_str!("../../../../test-data/MERIDIAN_Netflix_Photon_161006/CPL_0eb3d1b9-b77b-4d3f-bbe5-7c69b15dca85.xml");
4011        let cpl = parse_cpl(xml).expect("MERIDIAN CPL should parse");
4012        assert_eq!(cpl.namespace, CplNamespace::Smpte2067_3_2013);
4013    }
4014
4015    #[test]
4016    fn strict_unknown_mode_rejects_unknown_elements() {
4017        let xml = r#"<?xml version="1.0" encoding="UTF-8" ?>
4018<CompositionPlaylist xmlns="http://www.smpte-ra.org/schemas/2067-3/2013">
4019<Id>urn:uuid:0eb3d1b9-b77b-4d3f-bbe5-7c69b15dca85</Id>
4020<IssueDate>2024-01-01T00:00:00Z</IssueDate>
4021<ContentTitle>Strict Unknown</ContentTitle>
4022<ContentKind>Test</ContentKind>
4023<UnknownElement>oops</UnknownElement>
4024<SegmentList><Segment><Id>urn:uuid:00000000-0000-0000-0000-000000000001</Id><SequenceList></SequenceList></Segment></SegmentList>
4025</CompositionPlaylist>"#;
4026
4027        let options = CplParseOptions {
4028            unknown_field_mode: UnknownFieldMode::Error,
4029            ..Default::default()
4030        };
4031        let result = parse_cpl_with_options(xml, &options);
4032        assert!(matches!(result, Err(CplParseError::StrictUnknownXml(_))));
4033    }
4034
4035    #[test]
4036    fn strict_schema_mode_rejects_empty_sequence_list_per_segment() {
4037        let xml = r#"<?xml version="1.0" encoding="UTF-8" ?>
4038<CompositionPlaylist xmlns="http://www.smpte-ra.org/schemas/2067-3/2013">
4039<Id>urn:uuid:0eb3d1b9-b77b-4d3f-bbe5-7c69b15dca85</Id>
4040<IssueDate>2024-01-01T00:00:00Z</IssueDate>
4041<ContentTitle>Strict Schema</ContentTitle>
4042<ContentKind>Test</ContentKind>
4043<SegmentList><Segment><Id>urn:uuid:00000000-0000-0000-0000-000000000001</Id><SequenceList></SequenceList></Segment></SegmentList>
4044</CompositionPlaylist>"#;
4045
4046        let options = CplParseOptions {
4047            schema_strict_mode: SchemaStrictMode::Basic,
4048            ..Default::default()
4049        };
4050        let result = parse_cpl_with_options(xml, &options);
4051        assert!(matches!(result, Err(CplParseError::StrictSchema(_))));
4052    }
4053
4054    #[test]
4055    fn signature_mode_require_presence_rejects_unsigned_cpl() {
4056        let xml = minimal_cpl_with_ns("http://www.smpte-ra.org/schemas/2067-3/2013");
4057        let options = CplParseOptions {
4058            signature_validation_mode: SignatureValidationMode::RequirePresence,
4059            ..Default::default()
4060        };
4061        let result = parse_cpl_with_options(&xml, &options);
4062        assert!(matches!(result, Err(CplParseError::StrictSchema(_))));
4063    }
4064
4065    #[test]
4066    fn signature_mode_require_valid_needs_verifier() {
4067        let xml = r#"<?xml version="1.0" encoding="UTF-8" ?>
4068<CompositionPlaylist xmlns="http://www.smpte-ra.org/schemas/2067-3/2013">
4069<Id>urn:uuid:0eb3d1b9-b77b-4d3f-bbe5-7c69b15dca85</Id>
4070<IssueDate>2024-01-01T00:00:00Z</IssueDate>
4071<ContentTitle>Signed</ContentTitle>
4072<ContentKind>Test</ContentKind>
4073<Signature>dummy</Signature>
4074<SegmentList><Segment><Id>urn:uuid:00000000-0000-0000-0000-000000000001</Id><SequenceList></SequenceList></Segment></SegmentList>
4075</CompositionPlaylist>"#;
4076
4077        let options = CplParseOptions {
4078            signature_validation_mode: SignatureValidationMode::RequireValid,
4079            ..Default::default()
4080        };
4081        let result = parse_cpl_with_options(xml, &options);
4082        assert!(matches!(
4083            result,
4084            Err(CplParseError::SignatureVerifierRequired)
4085        ));
4086    }
4087
4088    #[test]
4089    fn signature_mode_require_valid_uses_verifier() {
4090        let xml = r#"<?xml version="1.0" encoding="UTF-8" ?>
4091<CompositionPlaylist xmlns="http://www.smpte-ra.org/schemas/2067-3/2013">
4092<Id>urn:uuid:0eb3d1b9-b77b-4d3f-bbe5-7c69b15dca85</Id>
4093<IssueDate>2024-01-01T00:00:00Z</IssueDate>
4094<ContentTitle>Signed</ContentTitle>
4095<ContentKind>Test</ContentKind>
4096<Signature>dummy</Signature>
4097<SegmentList><Segment><Id>urn:uuid:00000000-0000-0000-0000-000000000001</Id><SequenceList><MainImageSequence><Id>urn:uuid:11111111-1111-1111-1111-111111111111</Id><TrackId>urn:uuid:22222222-2222-2222-2222-222222222222</TrackId><ResourceList><Resource><Id>urn:uuid:33333333-3333-3333-3333-333333333333</Id><IntrinsicDuration>1</IntrinsicDuration></Resource></ResourceList></MainImageSequence></SequenceList></Segment></SegmentList>
4098</CompositionPlaylist>"#;
4099
4100        let verifier = AcceptAllSignatureVerifier;
4101        let options = CplParseOptions {
4102            signature_validation_mode: SignatureValidationMode::RequireValid,
4103            signature_verifier: Some(&verifier),
4104            ..Default::default()
4105        };
4106        let result = parse_cpl_with_options(xml, &options);
4107        assert!(
4108            result.is_ok(),
4109            "signature verifier should allow parse: {result:?}"
4110        );
4111    }
4112
4113    #[test]
4114    fn signature_mode_require_valid_surfaces_verification_failure() {
4115        let xml = r#"<?xml version="1.0" encoding="UTF-8" ?>
4116<CompositionPlaylist xmlns="http://www.smpte-ra.org/schemas/2067-3/2013">
4117<Id>urn:uuid:0eb3d1b9-b77b-4d3f-bbe5-7c69b15dca85</Id>
4118<IssueDate>2024-01-01T00:00:00Z</IssueDate>
4119<ContentTitle>Signed</ContentTitle>
4120<ContentKind>Test</ContentKind>
4121<Signature>dummy</Signature>
4122<SegmentList><Segment><Id>urn:uuid:00000000-0000-0000-0000-000000000001</Id><SequenceList></SequenceList></Segment></SegmentList>
4123</CompositionPlaylist>"#;
4124
4125        let verifier = RejectingSignatureVerifier;
4126        let options = CplParseOptions {
4127            signature_validation_mode: SignatureValidationMode::RequireValid,
4128            signature_verifier: Some(&verifier),
4129            ..Default::default()
4130        };
4131        let result = parse_cpl_with_options(xml, &options);
4132        assert!(matches!(
4133            result,
4134            Err(CplParseError::SignatureVerificationFailed(_))
4135        ));
4136    }
4137
4138    fn build_signed_cpl_with_reference_digest(tamper_digest: bool) -> String {
4139        let unsigned_xml = r#"<?xml version="1.0" encoding="UTF-8" ?>
4140<CompositionPlaylist xmlns="http://www.smpte-ra.org/schemas/2067-3/2013">
4141<Id>urn:uuid:0eb3d1b9-b77b-4d3f-bbe5-7c69b15dca85</Id>
4142<IssueDate>2024-01-01T00:00:00Z</IssueDate>
4143<ContentTitle>Signed Digest CPL</ContentTitle>
4144<ContentKind>Test</ContentKind>
4145<SegmentList>
4146<Segment>
4147<Id>urn:uuid:00000000-0000-0000-0000-000000000001</Id>
4148<SequenceList></SequenceList>
4149</Segment>
4150</SegmentList>
4151</CompositionPlaylist>"#;
4152
4153        let normalized = normalize_xml_for_digest(unsigned_xml);
4154        let digest = compute_hash(HashAlgorithm::Sha256, normalized.as_bytes());
4155        let mut digest_b64 = base64::engine::general_purpose::STANDARD.encode(digest);
4156        if tamper_digest {
4157            digest_b64 = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=".to_string();
4158        }
4159
4160        format!(
4161            r#"<?xml version="1.0" encoding="UTF-8" ?>
4162<CompositionPlaylist xmlns="http://www.smpte-ra.org/schemas/2067-3/2013">
4163<Id>urn:uuid:0eb3d1b9-b77b-4d3f-bbe5-7c69b15dca85</Id>
4164<IssueDate>2024-01-01T00:00:00Z</IssueDate>
4165<ContentTitle>Signed Digest CPL</ContentTitle>
4166<ContentKind>Test</ContentKind>
4167<Signature xmlns="http://www.w3.org/2000/09/xmldsig#">
4168  <SignedInfo>
4169    <CanonicalizationMethod Algorithm="http://www.w3.org/TR/2001/REC-xml-c14n-20010315"/>
4170    <SignatureMethod Algorithm="http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"/>
4171    <Reference URI="">
4172      <Transforms>
4173        <Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature"/>
4174      </Transforms>
4175      <DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/>
4176      <DigestValue>{}</DigestValue>
4177    </Reference>
4178  </SignedInfo>
4179  <SignatureValue>AQ==</SignatureValue>
4180</Signature>
4181<SegmentList>
4182<Segment>
4183<Id>urn:uuid:00000000-0000-0000-0000-000000000001</Id>
4184<SequenceList></SequenceList>
4185</Segment>
4186</SegmentList>
4187</CompositionPlaylist>"#,
4188            digest_b64
4189        )
4190    }
4191
4192    #[test]
4193    fn reference_digest_verifier_accepts_valid_uri_empty_digest() {
4194        let xml = build_signed_cpl_with_reference_digest(false);
4195        let verifier = ReferenceDigestXmlDsigVerifier;
4196        assert!(verifier.verify(&xml).is_ok());
4197    }
4198
4199    #[test]
4200    fn reference_digest_verifier_rejects_mismatched_digest() {
4201        let xml = build_signed_cpl_with_reference_digest(true);
4202        let verifier = ReferenceDigestXmlDsigVerifier;
4203        let result = verifier.verify(&xml);
4204        assert!(result.is_err());
4205        let error = result.unwrap_err();
4206        assert!(
4207            error.contains("DigestValue mismatch"),
4208            "unexpected error: {error}"
4209        );
4210    }
4211
4212    #[test]
4213    fn signature_mode_require_valid_with_reference_digest_verifier() {
4214        let xml = build_signed_cpl_with_reference_digest(false);
4215        let verifier = ReferenceDigestXmlDsigVerifier;
4216        let options = CplParseOptions {
4217            signature_validation_mode: SignatureValidationMode::RequireValid,
4218            signature_verifier: Some(&verifier),
4219            ..Default::default()
4220        };
4221        let result = parse_cpl_with_options(&xml, &options);
4222        assert!(
4223            result.is_ok(),
4224            "expected valid signature digest path to parse: {result:?}"
4225        );
4226    }
4227
4228    #[cfg(all(feature = "xmlsec1", not(target_arch = "wasm32")))]
4229    #[test]
4230    fn xmlsec_verifier_surfaces_missing_binary() {
4231        let xml = minimal_cpl_with_ns("http://www.smpte-ra.org/schemas/2067-3/2013");
4232        let verifier = XmlSec1Verifier::new().with_binary_path("xmlsec1-definitely-not-installed");
4233        let error = verifier
4234            .verify(&xml)
4235            .expect_err("expected missing binary error");
4236        assert!(
4237            error.contains("failed to execute"),
4238            "unexpected error message: {error}"
4239        );
4240    }
4241
4242    #[cfg(all(feature = "xmlsec", not(target_arch = "wasm32")))]
4243    #[test]
4244    fn xmlsec_crate_verifier_rejects_invalid_key_material() {
4245        let xml = build_signed_cpl_with_reference_digest(false);
4246        let verifier = XmlSecCrateVerifier::from_pem("not-a-valid-key");
4247        let error = verifier
4248            .verify(&xml)
4249            .expect_err("expected xmlsec key load error");
4250        assert!(
4251            error.contains("xmlsec key load failed") || error.contains("xmlsec verify failed"),
4252            "unexpected error message: {error}"
4253        );
4254    }
4255}