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"))]
1217    #[cfg_attr(feature = "wasm", serde(rename = "locales", alias = "Locale"))]
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
2143/// IAB soundfield label sub-descriptor — contains language for Atmos tracks
2144#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
2145#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
2146#[cfg_attr(feature = "typescript", derive(TS))]
2147#[cfg_attr(feature = "typescript", ts(export, rename_all = "camelCase"))]
2148#[cfg_attr(feature = "wasm", derive(Tsify))]
2149#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))]
2150pub struct IABSoundfieldLabelSubDescriptor {
2151    #[serde(rename = "InstanceID", alias = "InstanceUID", default)]
2152    pub instance_id: Option<String>,
2153
2154    #[serde(
2155        rename = "MCATagSymbol",
2156        default,
2157        deserialize_with = "de_helpers::de_optional_mca_tag_symbol"
2158    )]
2159    pub mca_tag_symbol: Option<McaTagSymbol>,
2160
2161    #[serde(rename = "MCATagName", default)]
2162    pub mca_tag_name: Option<String>,
2163
2164    /// ST 2067-201 §5.9: MCALabelDictionaryID shall be `urn:smpte:ul:060e2b34.0401010d.03020221.00000000`.
2165    #[serde(rename = "MCALabelDictionaryID", default)]
2166    pub mca_label_dictionary_id: Option<String>,
2167
2168    #[serde(
2169        rename = "RFC5646SpokenLanguage",
2170        alias = "RFC5646AudioLanguageCode",
2171        default,
2172        deserialize_with = "de_helpers::de_optional_language_tag"
2173    )]
2174    pub rfc5646_spoken_language: Option<LanguageTag>,
2175}
2176
2177/// SubDescriptors for ISXD essence descriptor
2178#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
2179#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, Default)]
2180#[cfg_attr(feature = "typescript", derive(TS))]
2181#[cfg_attr(feature = "typescript", ts(export, rename_all = "camelCase"))]
2182#[cfg_attr(feature = "wasm", derive(Tsify))]
2183#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))]
2184pub struct IsxdSubDescriptors {
2185    /// ST 2067-202: ContainerConstraintsSubDescriptor shall be present.
2186    #[serde(rename = "ContainerConstraintsSubDescriptor", default)]
2187    pub container_constraints_sub_descriptor: Option<ContainerConstraintsSubDescriptor>,
2188}
2189
2190/// ContainerConstraintsSubDescriptor — presence required by ST 2067-202 §5
2191#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
2192#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
2193#[cfg_attr(feature = "typescript", derive(TS))]
2194#[cfg_attr(feature = "typescript", ts(export, rename_all = "camelCase"))]
2195#[cfg_attr(feature = "wasm", derive(Tsify))]
2196#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))]
2197pub struct ContainerConstraintsSubDescriptor {
2198    #[serde(rename = "InstanceID", alias = "InstanceUID", default)]
2199    pub instance_id: Option<String>,
2200}
2201
2202/// ISXD (Immersive Sound XML Data) essence descriptor — Dolby Atmos sidecar format
2203#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
2204#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
2205#[cfg_attr(feature = "typescript", derive(TS))]
2206#[cfg_attr(feature = "typescript", ts(export, rename_all = "camelCase"))]
2207#[cfg_attr(feature = "wasm", derive(Tsify))]
2208#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))]
2209pub struct ISXDDataEssenceDescriptor {
2210    #[serde(rename = "InstanceID", alias = "InstanceUID", default)]
2211    pub instance_id: Option<String>,
2212
2213    #[serde(rename = "LinkedTrackID", default)]
2214    pub linked_track_id: Option<u32>,
2215
2216    #[serde(
2217        rename = "SampleRate",
2218        default,
2219        deserialize_with = "de_helpers::de_optional_edit_rate"
2220    )]
2221    pub sample_rate: Option<EditRate>,
2222
2223    #[serde(rename = "DataEssenceCoding", default)]
2224    pub data_essence_coding: Option<String>,
2225
2226    #[serde(rename = "NamespaceURI", default)]
2227    pub namespace_uri: Option<String>,
2228
2229    #[serde(rename = "SubDescriptors", default)]
2230    pub sub_descriptors: Option<IsxdSubDescriptors>,
2231}
2232
2233// =============================================================================
2234// Root CPL structure
2235// =============================================================================
2236
2237/// Root CPL structure - defines a complete IMF composition
2238#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
2239#[derive(Debug, Serialize, Deserialize, PartialEq)]
2240#[cfg_attr(feature = "typescript", derive(TS))]
2241#[cfg_attr(feature = "typescript", ts(export, rename_all = "camelCase"))]
2242#[cfg_attr(feature = "wasm", derive(Tsify))]
2243#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))]
2244pub struct CompositionPlaylist {
2245    /// The SMPTE spec version detected from the root xmlns of the CPL XML.
2246    /// Set after deserialization by `parse_cpl()`.
2247    #[serde(skip)]
2248    pub namespace: CplNamespace,
2249
2250    #[cfg_attr(not(feature = "wasm"), serde(rename = "Id"))]
2251    #[cfg_attr(feature = "wasm", serde(rename = "id", alias = "Id"))]
2252    #[cfg_attr(feature = "typescript", ts(rename = "id"))]
2253    pub id: ImfUuid,
2254
2255    #[cfg_attr(not(feature = "wasm"), serde(rename = "Annotation", default))]
2256    #[cfg_attr(
2257        feature = "wasm",
2258        serde(rename = "annotation", alias = "Annotation", default)
2259    )]
2260    #[cfg_attr(feature = "typescript", ts(rename = "annotation"))]
2261    pub annotation: Option<LanguageString>,
2262
2263    #[cfg_attr(not(feature = "wasm"), serde(rename = "IssueDate"))]
2264    #[cfg_attr(feature = "wasm", serde(rename = "issueDate", alias = "IssueDate"))]
2265    #[cfg_attr(feature = "typescript", ts(rename = "issueDate"))]
2266    pub issue_date: String, // ISO 8601 datetime
2267
2268    #[cfg_attr(not(feature = "wasm"), serde(rename = "Issuer", default))]
2269    #[cfg_attr(feature = "wasm", serde(rename = "issuer", alias = "Issuer", default))]
2270    #[cfg_attr(feature = "typescript", ts(rename = "issuer"))]
2271    pub issuer: Option<LanguageString>,
2272
2273    #[cfg_attr(not(feature = "wasm"), serde(rename = "Creator", default))]
2274    #[cfg_attr(
2275        feature = "wasm",
2276        serde(rename = "creator", alias = "Creator", default)
2277    )]
2278    #[cfg_attr(feature = "typescript", ts(rename = "creator"))]
2279    pub creator: Option<LanguageString>,
2280
2281    #[cfg_attr(not(feature = "wasm"), serde(rename = "ContentOriginator", default))]
2282    #[cfg_attr(
2283        feature = "wasm",
2284        serde(rename = "contentOriginator", alias = "ContentOriginator", default)
2285    )]
2286    #[cfg_attr(feature = "typescript", ts(rename = "contentOriginator"))]
2287    pub content_originator: Option<LanguageString>,
2288
2289    #[cfg_attr(not(feature = "wasm"), serde(rename = "ContentTitle"))]
2290    #[cfg_attr(
2291        feature = "wasm",
2292        serde(rename = "contentTitle", alias = "ContentTitle")
2293    )]
2294    #[cfg_attr(feature = "typescript", ts(rename = "contentTitle"))]
2295    pub content_title: LanguageString,
2296
2297    #[cfg_attr(
2298        not(feature = "wasm"),
2299        serde(rename = "ContentKind", default = "default_content_kind")
2300    )]
2301    #[cfg_attr(
2302        feature = "wasm",
2303        serde(
2304            rename = "contentKind",
2305            alias = "ContentKind",
2306            default = "default_content_kind"
2307        )
2308    )]
2309    #[cfg_attr(feature = "typescript", ts(rename = "contentKind"))]
2310    pub content_kind: ContentKindElement,
2311
2312    #[cfg_attr(not(feature = "wasm"), serde(rename = "ContentVersionList", default))]
2313    #[cfg_attr(
2314        feature = "wasm",
2315        serde(rename = "contentVersionList", alias = "ContentVersionList", default)
2316    )]
2317    #[cfg_attr(feature = "typescript", ts(rename = "contentVersionList"))]
2318    pub content_version_list: Option<ContentVersionList>,
2319
2320    #[cfg_attr(
2321        not(feature = "wasm"),
2322        serde(rename = "EssenceDescriptorList", default)
2323    )]
2324    #[cfg_attr(
2325        feature = "wasm",
2326        serde(
2327            rename = "essenceDescriptorList",
2328            alias = "EssenceDescriptorList",
2329            default
2330        )
2331    )]
2332    #[cfg_attr(feature = "typescript", ts(rename = "essenceDescriptorList"))]
2333    pub essence_descriptor_list: Option<EssenceDescriptorList>,
2334
2335    #[cfg_attr(
2336        not(feature = "wasm"),
2337        serde(
2338            rename = "EditRate",
2339            default,
2340            deserialize_with = "de_helpers::de_optional_edit_rate"
2341        )
2342    )]
2343    #[cfg_attr(
2344        feature = "wasm",
2345        serde(
2346            rename = "editRate",
2347            alias = "EditRate",
2348            default,
2349            deserialize_with = "de_helpers::de_optional_edit_rate"
2350        )
2351    )]
2352    #[cfg_attr(feature = "typescript", ts(rename = "editRate"))]
2353    pub edit_rate: Option<EditRate>,
2354
2355    #[cfg_attr(not(feature = "wasm"), serde(rename = "TotalRunningTime", default))]
2356    #[cfg_attr(
2357        feature = "wasm",
2358        serde(rename = "totalRunningTime", alias = "TotalRunningTime", default)
2359    )]
2360    #[cfg_attr(feature = "typescript", ts(rename = "totalRunningTime"))]
2361    pub total_running_time: Option<String>,
2362
2363    #[cfg_attr(not(feature = "wasm"), serde(rename = "LocaleList", default))]
2364    #[cfg_attr(
2365        feature = "wasm",
2366        serde(rename = "localeList", alias = "LocaleList", default)
2367    )]
2368    #[cfg_attr(feature = "typescript", ts(rename = "localeList"))]
2369    pub locale_list: Option<LocaleList>,
2370
2371    #[cfg_attr(not(feature = "wasm"), serde(rename = "ExtensionProperties", default))]
2372    #[cfg_attr(
2373        feature = "wasm",
2374        serde(rename = "extensionProperties", alias = "ExtensionProperties", default)
2375    )]
2376    #[cfg_attr(feature = "typescript", ts(rename = "extensionProperties"))]
2377    pub extension_properties: Option<ExtensionProperties>,
2378
2379    #[cfg_attr(not(feature = "wasm"), serde(rename = "CompositionTimecode", default))]
2380    #[cfg_attr(
2381        feature = "wasm",
2382        serde(rename = "compositionTimecode", alias = "CompositionTimecode", default)
2383    )]
2384    #[cfg_attr(feature = "typescript", ts(rename = "compositionTimecode"))]
2385    pub composition_timecode: Option<CompositionTimecode>,
2386
2387    /// Whether the original CPL XML contained a `<Signer>` element.
2388    /// Set by `parse_cpl()` from raw XML before namespace stripping.
2389    #[serde(skip)]
2390    pub has_signer: bool,
2391
2392    /// Whether the original CPL XML contained a `<Signature>` element.
2393    /// Set by `parse_cpl()` from raw XML before namespace stripping.
2394    #[serde(skip)]
2395    pub has_signature: bool,
2396
2397    #[cfg_attr(not(feature = "wasm"), serde(rename = "SegmentList"))]
2398    #[cfg_attr(feature = "wasm", serde(rename = "segmentList", alias = "SegmentList"))]
2399    #[cfg_attr(feature = "typescript", ts(rename = "segmentList"))]
2400    pub segment_list: SegmentList,
2401}
2402
2403// =============================================================================
2404// CompositionTimecode
2405// =============================================================================
2406
2407#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
2408#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
2409#[cfg_attr(not(feature = "wasm"), serde(rename_all = "PascalCase"))]
2410#[cfg_attr(feature = "wasm", serde(rename_all = "camelCase"))]
2411#[cfg_attr(feature = "typescript", derive(TS))]
2412#[cfg_attr(feature = "typescript", ts(export, rename_all = "camelCase"))]
2413#[cfg_attr(feature = "wasm", derive(Tsify))]
2414#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))]
2415pub struct CompositionTimecode {
2416    #[cfg_attr(not(feature = "wasm"), serde(rename = "TimecodeDropFrame"))]
2417    #[cfg_attr(
2418        feature = "wasm",
2419        serde(rename = "timecodeDropFrame", alias = "TimecodeDropFrame")
2420    )]
2421    pub timecode_drop_frame: Option<bool>,
2422
2423    #[cfg_attr(not(feature = "wasm"), serde(rename = "TimecodeRate"))]
2424    #[cfg_attr(
2425        feature = "wasm",
2426        serde(rename = "timecodeRate", alias = "TimecodeRate")
2427    )]
2428    pub timecode_rate: Option<u32>,
2429
2430    #[cfg_attr(not(feature = "wasm"), serde(rename = "TimecodeStartAddress"))]
2431    #[cfg_attr(
2432        feature = "wasm",
2433        serde(rename = "timecodeStartAddress", alias = "TimecodeStartAddress")
2434    )]
2435    pub timecode_start_address: Option<String>,
2436}
2437
2438// =============================================================================
2439// Content Version types
2440// =============================================================================
2441
2442#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
2443#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
2444#[cfg_attr(feature = "typescript", derive(TS))]
2445#[cfg_attr(feature = "typescript", ts(export, rename_all = "camelCase"))]
2446#[cfg_attr(feature = "wasm", derive(Tsify))]
2447#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))]
2448pub struct ContentVersionList {
2449    #[cfg_attr(not(feature = "wasm"), serde(rename = "ContentVersion"))]
2450    #[cfg_attr(
2451        feature = "wasm",
2452        serde(rename = "contentVersions", alias = "ContentVersion")
2453    )]
2454    pub content_versions: Vec<ContentVersion>,
2455}
2456
2457#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
2458#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
2459#[cfg_attr(feature = "typescript", derive(TS))]
2460#[cfg_attr(feature = "typescript", ts(export, rename_all = "camelCase"))]
2461#[cfg_attr(feature = "wasm", derive(Tsify))]
2462#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))]
2463pub struct ContentVersion {
2464    #[cfg_attr(not(feature = "wasm"), serde(rename = "Id"))]
2465    #[cfg_attr(feature = "wasm", serde(rename = "id", alias = "Id"))]
2466    pub id: String,
2467
2468    #[cfg_attr(not(feature = "wasm"), serde(rename = "LabelText", default))]
2469    #[cfg_attr(
2470        feature = "wasm",
2471        serde(rename = "labelText", alias = "LabelText", default)
2472    )]
2473    pub label_text: Option<LanguageString>,
2474}
2475
2476// =============================================================================
2477// Segment and Sequence types
2478// =============================================================================
2479
2480#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
2481#[derive(Debug, Serialize, Deserialize, PartialEq)]
2482#[cfg_attr(feature = "typescript", derive(TS))]
2483#[cfg_attr(feature = "typescript", ts(export, rename_all = "camelCase"))]
2484#[cfg_attr(feature = "wasm", derive(Tsify))]
2485#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))]
2486pub struct SegmentList {
2487    #[cfg_attr(not(feature = "wasm"), serde(rename = "Segment"))]
2488    #[cfg_attr(feature = "wasm", serde(rename = "segments", alias = "Segment"))]
2489    pub segments: Vec<Segment>,
2490}
2491
2492#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
2493#[derive(Debug, Serialize, Deserialize, PartialEq)]
2494#[cfg_attr(feature = "typescript", derive(TS))]
2495#[cfg_attr(feature = "typescript", ts(export, rename_all = "camelCase"))]
2496#[cfg_attr(feature = "wasm", derive(Tsify))]
2497#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))]
2498pub struct Segment {
2499    #[cfg_attr(not(feature = "wasm"), serde(rename = "Id"))]
2500    #[cfg_attr(feature = "wasm", serde(rename = "id", alias = "Id"))]
2501    pub id: ImfUuid,
2502
2503    #[cfg_attr(not(feature = "wasm"), serde(rename = "SequenceList"))]
2504    #[cfg_attr(
2505        feature = "wasm",
2506        serde(rename = "sequenceList", alias = "SequenceList")
2507    )]
2508    pub sequence_list: SequenceList,
2509}
2510
2511#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
2512#[derive(Debug, Serialize, Deserialize, PartialEq)]
2513#[cfg_attr(feature = "typescript", derive(TS))]
2514#[cfg_attr(feature = "typescript", ts(export, rename_all = "camelCase"))]
2515#[cfg_attr(feature = "wasm", derive(Tsify))]
2516#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))]
2517pub struct SequenceList {
2518    #[cfg_attr(not(feature = "wasm"), serde(rename = "MarkerSequence", default))]
2519    #[cfg_attr(
2520        feature = "wasm",
2521        serde(rename = "markerSequences", alias = "MarkerSequence", default)
2522    )]
2523    pub marker_sequences: Vec<MarkerSequence>,
2524
2525    #[cfg_attr(not(feature = "wasm"), serde(rename = "MainImageSequence", default))]
2526    #[cfg_attr(
2527        feature = "wasm",
2528        serde(rename = "mainImageSequences", alias = "MainImageSequence", default)
2529    )]
2530    pub main_image_sequences: Vec<MainImageSequence>,
2531
2532    #[cfg_attr(not(feature = "wasm"), serde(rename = "MainAudioSequence", default))]
2533    #[cfg_attr(
2534        feature = "wasm",
2535        serde(rename = "mainAudioSequences", alias = "MainAudioSequence", default)
2536    )]
2537    pub main_audio_sequences: Vec<MainAudioSequence>,
2538
2539    #[cfg_attr(
2540        not(feature = "wasm"),
2541        serde(rename = "SubtitlesSequence", alias = "MainSubtitleSequence", default)
2542    )]
2543    #[cfg_attr(
2544        feature = "wasm",
2545        serde(
2546            rename = "subtitlesSequences",
2547            alias = "SubtitlesSequence",
2548            alias = "MainSubtitleSequence",
2549            default
2550        )
2551    )]
2552    pub subtitles_sequences: Vec<SubtitlesSequence>,
2553
2554    #[cfg_attr(
2555        not(feature = "wasm"),
2556        serde(rename = "HearingImpairedCaptionsSequence", default)
2557    )]
2558    #[cfg_attr(
2559        feature = "wasm",
2560        serde(
2561            rename = "hearingImpairedCaptionsSequences",
2562            alias = "HearingImpairedCaptionsSequence",
2563            default
2564        )
2565    )]
2566    pub hearing_impaired_captions_sequences: Vec<HearingImpairedCaptionsSequence>,
2567
2568    #[cfg_attr(
2569        not(feature = "wasm"),
2570        serde(rename = "ForcedNarrativeSequence", default)
2571    )]
2572    #[cfg_attr(
2573        feature = "wasm",
2574        serde(
2575            rename = "forcedNarrativeSequences",
2576            alias = "ForcedNarrativeSequence",
2577            default
2578        )
2579    )]
2580    pub forced_narrative_sequences: Vec<ForcedNarrativeSequence>,
2581
2582    #[cfg_attr(not(feature = "wasm"), serde(rename = "IABSequence", default))]
2583    #[cfg_attr(
2584        feature = "wasm",
2585        serde(rename = "iabSequences", alias = "IABSequence", default)
2586    )]
2587    pub iab_sequences: Vec<IABSequence>,
2588
2589    #[cfg_attr(not(feature = "wasm"), serde(rename = "ISXDSequence", default))]
2590    #[cfg_attr(
2591        feature = "wasm",
2592        serde(rename = "isxdSequences", alias = "ISXDSequence", default)
2593    )]
2594    pub isxd_sequences: Vec<ISXDSequence>,
2595}
2596
2597impl SequenceList {
2598    /// Return all non-marker sequences as trait objects.
2599    pub fn all_sequences(&self) -> Vec<&dyn SequenceAccess> {
2600        let mut v: Vec<&dyn SequenceAccess> = Vec::new();
2601        for s in &self.main_image_sequences {
2602            v.push(s);
2603        }
2604        for s in &self.main_audio_sequences {
2605            v.push(s);
2606        }
2607        for s in &self.subtitles_sequences {
2608            v.push(s);
2609        }
2610        for s in &self.hearing_impaired_captions_sequences {
2611            v.push(s);
2612        }
2613        for s in &self.forced_narrative_sequences {
2614            v.push(s);
2615        }
2616        for s in &self.iab_sequences {
2617            v.push(s);
2618        }
2619        for s in &self.isxd_sequences {
2620            v.push(s);
2621        }
2622        v
2623    }
2624
2625    /// Return all non-marker sequences paired with their type name.
2626    pub fn all_sequences_typed(&self) -> Vec<(&dyn SequenceAccess, &'static str)> {
2627        let mut v: Vec<(&dyn SequenceAccess, &'static str)> = Vec::new();
2628        for s in &self.main_image_sequences {
2629            v.push((s, "MainImage"));
2630        }
2631        for s in &self.main_audio_sequences {
2632            v.push((s, "MainAudio"));
2633        }
2634        for s in &self.subtitles_sequences {
2635            v.push((s, "Subtitles"));
2636        }
2637        for s in &self.hearing_impaired_captions_sequences {
2638            v.push((s, "HearingImpairedCaptions"));
2639        }
2640        for s in &self.forced_narrative_sequences {
2641            v.push((s, "ForcedNarrative"));
2642        }
2643        for s in &self.iab_sequences {
2644            v.push((s, "IAB"));
2645        }
2646        for s in &self.isxd_sequences {
2647            v.push((s, "ISXD"));
2648        }
2649        v
2650    }
2651}
2652
2653// All sequence types share the same structure: Id, TrackId, ResourceList
2654/// Trait for accessing common sequence fields
2655pub trait SequenceAccess {
2656    fn id(&self) -> &ImfUuid;
2657    fn track_id(&self) -> &ImfUuid;
2658    fn resource_list(&self) -> &ResourceList;
2659}
2660
2661macro_rules! define_sequence_type {
2662    ($name:ident) => {
2663        #[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
2664        #[derive(Debug, Serialize, Deserialize, PartialEq)]
2665        #[cfg_attr(feature = "typescript", derive(TS))]
2666        #[cfg_attr(feature = "typescript", ts(export, rename_all = "camelCase"))]
2667        #[cfg_attr(feature = "wasm", derive(Tsify))]
2668        #[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))]
2669        pub struct $name {
2670            #[cfg_attr(not(feature = "wasm"), serde(rename = "Id"))]
2671            #[cfg_attr(feature = "wasm", serde(rename = "id", alias = "Id"))]
2672            pub id: ImfUuid,
2673
2674            #[cfg_attr(not(feature = "wasm"), serde(rename = "TrackId"))]
2675            #[cfg_attr(feature = "wasm", serde(rename = "trackId", alias = "TrackId"))]
2676            pub track_id: ImfUuid,
2677
2678            #[cfg_attr(not(feature = "wasm"), serde(rename = "ResourceList"))]
2679            #[cfg_attr(
2680                feature = "wasm",
2681                serde(rename = "resourceList", alias = "ResourceList")
2682            )]
2683            pub resource_list: ResourceList,
2684        }
2685
2686        impl SequenceAccess for $name {
2687            fn id(&self) -> &ImfUuid {
2688                &self.id
2689            }
2690            fn track_id(&self) -> &ImfUuid {
2691                &self.track_id
2692            }
2693            fn resource_list(&self) -> &ResourceList {
2694                &self.resource_list
2695            }
2696        }
2697    };
2698}
2699
2700define_sequence_type!(MarkerSequence);
2701define_sequence_type!(MainImageSequence);
2702define_sequence_type!(MainAudioSequence);
2703define_sequence_type!(SubtitlesSequence);
2704define_sequence_type!(HearingImpairedCaptionsSequence);
2705define_sequence_type!(ForcedNarrativeSequence);
2706define_sequence_type!(IABSequence);
2707define_sequence_type!(ISXDSequence);
2708
2709// =============================================================================
2710// Resource types
2711// =============================================================================
2712
2713#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
2714#[derive(Debug, Serialize, Deserialize, PartialEq)]
2715#[cfg_attr(feature = "typescript", derive(TS))]
2716#[cfg_attr(feature = "typescript", ts(export, rename_all = "camelCase"))]
2717#[cfg_attr(feature = "wasm", derive(Tsify))]
2718#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))]
2719pub struct ResourceList {
2720    #[cfg_attr(not(feature = "wasm"), serde(rename = "Resource", default))]
2721    #[cfg_attr(
2722        feature = "wasm",
2723        serde(rename = "resources", alias = "Resource", default)
2724    )]
2725    pub resources: Vec<Resource>,
2726}
2727
2728#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
2729#[derive(Debug, Serialize, Deserialize, PartialEq)]
2730#[cfg_attr(feature = "typescript", derive(TS))]
2731#[cfg_attr(feature = "typescript", ts(export, rename_all = "camelCase"))]
2732#[cfg_attr(feature = "wasm", derive(Tsify))]
2733#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))]
2734pub struct Resource {
2735    #[cfg_attr(not(feature = "wasm"), serde(rename = "Id"))]
2736    #[cfg_attr(feature = "wasm", serde(rename = "id", alias = "Id"))]
2737    pub id: ImfUuid,
2738
2739    #[cfg_attr(not(feature = "wasm"), serde(rename = "Annotation", default))]
2740    #[cfg_attr(
2741        feature = "wasm",
2742        serde(rename = "annotation", alias = "Annotation", default)
2743    )]
2744    pub annotation: Option<LanguageString>,
2745
2746    #[cfg_attr(
2747        not(feature = "wasm"),
2748        serde(
2749            rename = "EditRate",
2750            default,
2751            deserialize_with = "de_helpers::de_optional_edit_rate"
2752        )
2753    )]
2754    #[cfg_attr(
2755        feature = "wasm",
2756        serde(
2757            rename = "editRate",
2758            alias = "EditRate",
2759            default,
2760            deserialize_with = "de_helpers::de_optional_edit_rate"
2761        )
2762    )]
2763    pub edit_rate: Option<EditRate>,
2764
2765    #[cfg_attr(not(feature = "wasm"), serde(rename = "IntrinsicDuration"))]
2766    #[cfg_attr(
2767        feature = "wasm",
2768        serde(rename = "intrinsicDuration", alias = "IntrinsicDuration")
2769    )]
2770    pub intrinsic_duration: u64,
2771
2772    #[cfg_attr(not(feature = "wasm"), serde(rename = "EntryPoint", default))]
2773    #[cfg_attr(
2774        feature = "wasm",
2775        serde(rename = "entryPoint", alias = "EntryPoint", default)
2776    )]
2777    pub entry_point: Option<u64>,
2778
2779    #[cfg_attr(not(feature = "wasm"), serde(rename = "SourceDuration", default))]
2780    #[cfg_attr(
2781        feature = "wasm",
2782        serde(rename = "sourceDuration", alias = "SourceDuration", default)
2783    )]
2784    pub source_duration: Option<u64>,
2785
2786    #[cfg_attr(not(feature = "wasm"), serde(rename = "SourceEncoding", default))]
2787    #[cfg_attr(
2788        feature = "wasm",
2789        serde(rename = "sourceEncoding", alias = "SourceEncoding", default)
2790    )]
2791    pub source_encoding: Option<ImfUuid>, // UUID reference to EssenceDescriptor
2792
2793    #[cfg_attr(not(feature = "wasm"), serde(rename = "TrackFileId", default))]
2794    #[cfg_attr(
2795        feature = "wasm",
2796        serde(rename = "trackFileId", alias = "TrackFileId", default)
2797    )]
2798    pub track_file_id: Option<ImfUuid>, // UUID reference to MXF file in AssetMap
2799
2800    #[cfg_attr(not(feature = "wasm"), serde(rename = "RepeatCount", default))]
2801    #[cfg_attr(
2802        feature = "wasm",
2803        serde(rename = "repeatCount", alias = "RepeatCount", default)
2804    )]
2805    pub repeat_count: Option<u64>,
2806
2807    #[cfg_attr(not(feature = "wasm"), serde(rename = "KeyId", default))]
2808    #[cfg_attr(feature = "wasm", serde(rename = "keyId", alias = "KeyId", default))]
2809    pub key_id: Option<ImfUuid>, // UUID reference to encryption key
2810
2811    #[cfg_attr(not(feature = "wasm"), serde(rename = "Hash", default))]
2812    #[cfg_attr(feature = "wasm", serde(rename = "hash", alias = "Hash", default))]
2813    pub hash: Option<String>,
2814
2815    #[cfg_attr(not(feature = "wasm"), serde(rename = "Marker", default))]
2816    #[cfg_attr(feature = "wasm", serde(rename = "markers", alias = "Marker", default))]
2817    pub markers: Vec<MarkerInfo>,
2818}
2819
2820#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
2821#[derive(Debug, Serialize, Deserialize, PartialEq)]
2822#[cfg_attr(feature = "typescript", derive(TS))]
2823#[cfg_attr(feature = "typescript", ts(export, rename_all = "camelCase"))]
2824#[cfg_attr(feature = "wasm", derive(Tsify))]
2825#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))]
2826pub struct MarkerInfo {
2827    #[cfg_attr(not(feature = "wasm"), serde(rename = "Annotation", default))]
2828    #[cfg_attr(
2829        feature = "wasm",
2830        serde(rename = "annotation", alias = "Annotation", default)
2831    )]
2832    pub annotation: Option<String>,
2833
2834    #[cfg_attr(not(feature = "wasm"), serde(rename = "Label"))]
2835    #[cfg_attr(feature = "wasm", serde(rename = "label", alias = "Label"))]
2836    pub label: MarkerLabelElement,
2837
2838    #[cfg_attr(not(feature = "wasm"), serde(rename = "Offset"))]
2839    #[cfg_attr(feature = "wasm", serde(rename = "offset", alias = "Offset"))]
2840    pub offset: u64,
2841}
2842
2843// =============================================================================
2844// Track information (legacy, kept for backward compatibility)
2845// =============================================================================
2846
2847/// Track information with codec details (legacy — use EssenceDescriptor parsing instead)
2848#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
2849#[derive(Debug, Clone, Serialize, Deserialize)]
2850#[cfg_attr(feature = "typescript", derive(TS))]
2851#[cfg_attr(feature = "typescript", ts(export, rename_all = "camelCase"))]
2852#[cfg_attr(feature = "wasm", derive(Tsify))]
2853#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))]
2854pub struct TrackInfo {
2855    pub track_id: String,
2856    pub track_type: String, // "video", "audio", "subtitle"
2857    pub codec: String,
2858    pub language: Option<String>,
2859    pub channels: Option<String>,
2860    pub format_details: Option<String>,
2861    pub resolution: Option<String>,
2862    pub framerate: Option<String>,
2863    pub bit_depth: Option<String>,
2864    pub subtitle_type: Option<String>,
2865}
2866
2867// =============================================================================
2868// Parser functions
2869// =============================================================================
2870
2871/// Parse CPL XML content with namespace stripping.
2872///
2873/// Detects the SMPTE spec version from the root `xmlns` attribute and stores it
2874/// on the returned `CompositionPlaylist.namespace` field. This enables downstream
2875/// code to apply version-specific validation rules.
2876pub fn parse_cpl(xml_content: &str) -> Result<CompositionPlaylist, CplParseError> {
2877    parse_cpl_with_options(xml_content, &CplParseOptions::default())
2878}
2879
2880/// Parse CPL XML content with configurable hardening options.
2881pub fn parse_cpl_with_options(
2882    xml_content: &str,
2883    options: &CplParseOptions<'_>,
2884) -> Result<CompositionPlaylist, CplParseError> {
2885    // Detect namespace before stripping (stripping preserves default xmlns but
2886    // removes prefixed xmlns:xxx declarations)
2887    let namespace = crate::assetmap::detect_root_namespace(xml_content)
2888        .map(|uri| CplNamespace::from_uri(&uri))
2889        .unwrap_or_default();
2890
2891    // Detect Signer/Signature presence from raw XML before stripping
2892    let has_signer = xml_content.contains("<Signer") || xml_content.contains(":Signer");
2893    let has_signature = xml_content.contains("<Signature") || xml_content.contains(":Signature");
2894
2895    match options.signature_validation_mode {
2896        SignatureValidationMode::Ignore => {}
2897        SignatureValidationMode::RequirePresence => {
2898            if !has_signature {
2899                return Err(CplParseError::StrictSchema(
2900                    "Signature element is required by selected signature mode".to_string(),
2901                ));
2902            }
2903        }
2904        SignatureValidationMode::VerifyIfPresent => {
2905            if has_signature {
2906                let verifier = options
2907                    .signature_verifier
2908                    .ok_or(CplParseError::SignatureVerifierRequired)?;
2909                verifier
2910                    .verify(xml_content)
2911                    .map_err(CplParseError::SignatureVerificationFailed)?;
2912            }
2913        }
2914        SignatureValidationMode::RequireValid => {
2915            if !has_signature {
2916                return Err(CplParseError::StrictSchema(
2917                    "Signature element is required by selected signature mode".to_string(),
2918                ));
2919            }
2920            let verifier = options
2921                .signature_verifier
2922                .ok_or(CplParseError::SignatureVerifierRequired)?;
2923            verifier
2924                .verify(xml_content)
2925                .map_err(CplParseError::SignatureVerificationFailed)?;
2926        }
2927    }
2928
2929    let stripped = strip_xml_namespaces(xml_content);
2930
2931    if options.unknown_field_mode == UnknownFieldMode::Error {
2932        let unknown = collect_unknown_xml_tokens(&stripped).map_err(|e| {
2933            CplParseError::StrictUnknownXml(format!("unknown token scan failed: {}", e))
2934        })?;
2935        if !unknown.is_empty() {
2936            let list = unknown.into_iter().collect::<Vec<_>>().join(", ");
2937            return Err(CplParseError::StrictUnknownXml(list));
2938        }
2939    }
2940
2941    let mut cpl: CompositionPlaylist = quick_xml::de::from_str(&stripped)?;
2942
2943    if options.schema_strict_mode == SchemaStrictMode::Basic {
2944        validate_basic_schema_constraints(&cpl)?;
2945    }
2946
2947    cpl.namespace = namespace;
2948    cpl.has_signer = has_signer;
2949    cpl.has_signature = has_signature;
2950    Ok(cpl)
2951}
2952
2953fn validate_basic_schema_constraints(cpl: &CompositionPlaylist) -> Result<(), CplParseError> {
2954    if cpl.segment_list.segments.is_empty() {
2955        return Err(CplParseError::StrictSchema(
2956            "SegmentList must contain at least one Segment".to_string(),
2957        ));
2958    }
2959
2960    for (segment_index, segment) in cpl.segment_list.segments.iter().enumerate() {
2961        let sequence_count = segment.sequence_list.marker_sequences.len()
2962            + segment.sequence_list.main_image_sequences.len()
2963            + segment.sequence_list.main_audio_sequences.len()
2964            + segment.sequence_list.subtitles_sequences.len()
2965            + segment
2966                .sequence_list
2967                .hearing_impaired_captions_sequences
2968                .len()
2969            + segment.sequence_list.forced_narrative_sequences.len()
2970            + segment.sequence_list.iab_sequences.len()
2971            + segment.sequence_list.isxd_sequences.len();
2972
2973        if sequence_count == 0 {
2974            return Err(CplParseError::StrictSchema(format!(
2975                "Segment[{}] must contain at least one sequence",
2976                segment_index
2977            )));
2978        }
2979    }
2980
2981    Ok(())
2982}
2983
2984fn collect_unknown_xml_tokens(xml: &str) -> Result<BTreeSet<String>, String> {
2985    let mut reader = quick_xml::Reader::from_str(xml);
2986    reader.trim_text(true);
2987
2988    let allowed_elements: BTreeSet<&'static str> = [
2989        "CompositionPlaylist",
2990        "Id",
2991        "Annotation",
2992        "IssueDate",
2993        "Issuer",
2994        "Creator",
2995        "ContentOriginator",
2996        "ContentTitle",
2997        "ContentKind",
2998        "ContentVersionList",
2999        "ContentVersion",
3000        "LabelText",
3001        "EssenceDescriptorList",
3002        "EssenceDescriptor",
3003        "EditRate",
3004        "TotalRunningTime",
3005        "LocaleList",
3006        "Locale",
3007        "LanguageList",
3008        "Language",
3009        "RegionList",
3010        "Region",
3011        "ContentMaturityRatingList",
3012        "ContentMaturityRating",
3013        "Agency",
3014        "Rating",
3015        "Audience",
3016        "ExtensionProperties",
3017        "ApplicationIdentification",
3018        "MaxCLL",
3019        "MaxFALL",
3020        "CompositionTimecode",
3021        "TimecodeDropFrame",
3022        "TimecodeRate",
3023        "TimecodeStartAddress",
3024        "SegmentList",
3025        "Segment",
3026        "SequenceList",
3027        "MarkerSequence",
3028        "MainImageSequence",
3029        "MainAudioSequence",
3030        "SubtitlesSequence",
3031        "MainSubtitleSequence",
3032        "HearingImpairedCaptionsSequence",
3033        "ForcedNarrativeSequence",
3034        "IABSequence",
3035        "ISXDSequence",
3036        "TrackId",
3037        "ResourceList",
3038        "Resource",
3039        "IntrinsicDuration",
3040        "EntryPoint",
3041        "SourceDuration",
3042        "SourceEncoding",
3043        "TrackFileId",
3044        "RepeatCount",
3045        "KeyId",
3046        "Hash",
3047        "Marker",
3048        "Label",
3049        "Offset",
3050        "RGBADescriptor",
3051        "CDCIDescriptor",
3052        "WAVEPCMDescriptor",
3053        "DCTimedTextDescriptor",
3054        "IABEssenceDescriptor",
3055        "ISXDDataEssenceDescriptor",
3056        "InstanceID",
3057        "InstanceUID",
3058        "DisplayWidth",
3059        "DisplayHeight",
3060        "StoredWidth",
3061        "StoredHeight",
3062        "SampleRate",
3063        "ImageAspectRatio",
3064        "ColorPrimaries",
3065        "TransferCharacteristic",
3066        "CodingEquations",
3067        "PictureCompression",
3068        "FrameLayout",
3069        "DisplayF2Offset",
3070        "ComponentMaxRef",
3071        "ComponentMinRef",
3072        "ScanningDirection",
3073        "StoredF2Offset",
3074        "SampledWidth",
3075        "SampledHeight",
3076        "SampledXOffset",
3077        "SampledYOffset",
3078        "AlphaTransparency",
3079        "ImageAlignmentOffset",
3080        "ImageStartOffset",
3081        "ImageEndOffset",
3082        "FieldDominance",
3083        "AlphaMaxRef",
3084        "AlphaMinRef",
3085        "Palette",
3086        "PaletteLayout",
3087        "LinkedTrackID",
3088        "SubDescriptors",
3089        "ActiveWidth",
3090        "ActiveHeight",
3091        "ComponentDepth",
3092        "HorizontalSubsampling",
3093        "VerticalSubsampling",
3094        "ColorSiting",
3095        "BlackRefLevel",
3096        "WhiteRefLevel",
3097        "ColorRange",
3098        "ReversedByteOrder",
3099        "PaddingBits",
3100        "AlphaSampleDepth",
3101        "PHDRMetadataTrackSubDescriptor",
3102        "JPEG2000SubDescriptor",
3103        "Rsiz",
3104        "Xsiz",
3105        "Ysiz",
3106        "XOsiz",
3107        "YOsiz",
3108        "XTsiz",
3109        "YTsiz",
3110        "XTOsiz",
3111        "YTOsiz",
3112        "Csiz",
3113        "CodingStyleDefault",
3114        "QuantizationDefault",
3115        "J2CLayout",
3116        "J2KExtendedCapabilities",
3117        "PictureComponentSizing",
3118        "RGBAComponent",
3119        "Code",
3120        "ComponentSize",
3121        "Pcap",
3122        "J2KComponentSizing",
3123        "Ssiz",
3124        "XRSiz",
3125        "YRSiz",
3126        "PHDRMetadataTrackSubDescriptor_DataDefinition",
3127        "PHDRMetadataTrackSubDescriptor_SimplePayloadSID",
3128        "PHDRMetadataTrackSubDescriptor_SourceTrackID",
3129        "AudioSampleRate",
3130        "ChannelCount",
3131        "QuantizationBits",
3132        "SoundfieldGroupLabelSubDescriptor",
3133        "MCATagSymbol",
3134        "MCATagName",
3135        "MCAAudioContentKind",
3136        "RFC5646SpokenLanguage",
3137        "RFC5646AudioLanguageCode",
3138        "RFC5646LanguageTagList",
3139        "NamespaceURI",
3140        "SoundCompression",
3141        "IABSoundfieldLabelSubDescriptor",
3142        "ContainerFormat",
3143        "Codec",
3144        "ElectrospatialFormulation",
3145        "MCALabelDictionaryID",
3146        "EssenceLength",
3147        "Locked",
3148        "MCALinkID",
3149        "MCAChannelID",
3150        "AudioChannelLabelSubDescriptor",
3151        "MCATitle",
3152        "MCATitleVersion",
3153        "MCAAudioElementKind",
3154        "SoundfieldGroupLinkID",
3155        "DataEssenceCoding",
3156        "ContainerConstraintsSubDescriptor",
3157        "Signer",
3158        "Signature",
3159    ]
3160    .into_iter()
3161    .collect();
3162
3163    let allowed_attributes: BTreeSet<&'static str> =
3164        ["xmlns", "scope", "language"].into_iter().collect();
3165
3166    let mut unknown = BTreeSet::new();
3167
3168    loop {
3169        match reader.read_event() {
3170            Ok(Event::Start(e)) | Ok(Event::Empty(e)) => {
3171                let name = std::str::from_utf8(e.name().as_ref())
3172                    .map_err(|e| e.to_string())?
3173                    .to_string();
3174                if !allowed_elements.contains(name.as_str()) {
3175                    unknown.insert(format!("element:{}", name));
3176                }
3177                for attr in e.attributes() {
3178                    let attr = attr.map_err(|e| e.to_string())?;
3179                    let key = std::str::from_utf8(attr.key.as_ref())
3180                        .map_err(|e| e.to_string())?
3181                        .to_string();
3182                    if !(allowed_attributes.contains(key.as_str()) || key.starts_with("xmlns:")) {
3183                        unknown.insert(format!("attribute:{}@{}", key, name));
3184                    }
3185                }
3186            }
3187            Ok(Event::Eof) => break,
3188            Ok(_) => {}
3189            Err(e) => return Err(e.to_string()),
3190        }
3191    }
3192
3193    Ok(unknown)
3194}
3195
3196/// Extract all languages found in a CPL
3197pub fn extract_cpl_languages(cpl: &CompositionPlaylist) -> Vec<LanguageTag> {
3198    let mut languages: Vec<LanguageTag> = Vec::new();
3199
3200    let add_lang = |languages: &mut Vec<LanguageTag>, lang_opt: &Option<LanguageTag>| {
3201        if let Some(lang) = lang_opt {
3202            if !lang.as_str().is_empty() && !languages.contains(lang) {
3203                languages.push(lang.clone());
3204            }
3205        }
3206    };
3207
3208    let add_lang_string = |languages: &mut Vec<LanguageTag>,
3209                           lang_string: &Option<LanguageString>| {
3210        if let Some(ls) = lang_string {
3211            add_lang(languages, &ls.language);
3212        }
3213    };
3214
3215    let add_required_lang_string =
3216        |languages: &mut Vec<LanguageTag>, lang_string: &LanguageString| {
3217            add_lang(languages, &lang_string.language);
3218        };
3219
3220    // Extract from main CPL fields
3221    add_lang_string(&mut languages, &cpl.annotation);
3222    add_lang_string(&mut languages, &cpl.issuer);
3223    add_lang_string(&mut languages, &cpl.creator);
3224    add_lang_string(&mut languages, &cpl.content_originator);
3225    add_required_lang_string(&mut languages, &cpl.content_title);
3226
3227    // Extract from content versions
3228    if let Some(content_version_list) = &cpl.content_version_list {
3229        for version in &content_version_list.content_versions {
3230            add_lang_string(&mut languages, &version.label_text);
3231        }
3232    }
3233
3234    // Extract from LocaleList
3235    if let Some(locale_list) = &cpl.locale_list {
3236        for locale in &locale_list.locales {
3237            if let Some(language_list) = &locale.language_list {
3238                for lang in &language_list.languages {
3239                    if !lang.as_str().is_empty() && !languages.contains(lang) {
3240                        languages.push(lang.clone());
3241                    }
3242                }
3243            }
3244        }
3245    }
3246
3247    // Extract from EssenceDescriptors
3248    if let Some(edl) = &cpl.essence_descriptor_list {
3249        for ed in &edl.essence_descriptors {
3250            // Audio language from WAVEPCMDescriptor
3251            if let Some(wave) = &ed.wave_pcm_descriptor {
3252                if let Some(subs) = &wave.sub_descriptors {
3253                    if let Some(sf) = &subs.soundfield_group_label_sub_descriptor {
3254                        add_lang(&mut languages, &sf.rfc5646_spoken_language);
3255                    }
3256                }
3257            }
3258            // Audio language from IABEssenceDescriptor
3259            if let Some(iab) = &ed.iab_essence_descriptor {
3260                if let Some(subs) = &iab.sub_descriptors {
3261                    if let Some(sf) = &subs.iab_soundfield_label_sub_descriptor {
3262                        add_lang(&mut languages, &sf.rfc5646_spoken_language);
3263                    }
3264                }
3265            }
3266            // Timed text language from DCTimedTextDescriptor
3267            if let Some(tt) = &ed.dc_timed_text_descriptor {
3268                for lang in &tt.rfc5646_language_tag_list {
3269                    if !lang.as_str().is_empty() && !languages.contains(lang) {
3270                        languages.push(lang.clone());
3271                    }
3272                }
3273            }
3274        }
3275    }
3276
3277    languages.sort_by(|a, b| a.as_str().cmp(b.as_str()));
3278    languages.dedup();
3279    languages
3280}
3281
3282/// Extract track-level codec information from CPL XML, returning an error on parse failure.
3283pub fn try_extract_cpl_track_codecs_from_xml(
3284    xml_content: &str,
3285) -> Result<Vec<TrackInfo>, CplParseError> {
3286    let cpl = parse_cpl(xml_content)?;
3287    Ok(extract_tracks_from_cpl(&cpl, xml_content))
3288}
3289
3290/// Extract track-level codec information from CPL XML content.
3291///
3292/// Returns an empty `Vec` if the CPL fails to parse. Prefer
3293/// [`try_extract_cpl_track_codecs_from_xml`] to distinguish parse failure from empty tracks.
3294pub fn extract_cpl_track_codecs_from_xml(xml_content: &str) -> Vec<TrackInfo> {
3295    try_extract_cpl_track_codecs_from_xml(xml_content).unwrap_or_default()
3296}
3297
3298/// Extract track info from a properly parsed CPL (replaces regex-based extraction)
3299fn extract_tracks_from_cpl(cpl: &CompositionPlaylist, _raw_xml: &str) -> Vec<TrackInfo> {
3300    let mut tracks = Vec::new();
3301
3302    // Build essence descriptor lookup by ID
3303    let descriptors: std::collections::HashMap<ImfUuid, &EssenceDescriptor> =
3304        if let Some(edl) = &cpl.essence_descriptor_list {
3305            edl.essence_descriptors
3306                .iter()
3307                .map(|ed| (ed.id, ed))
3308                .collect()
3309        } else {
3310            std::collections::HashMap::new()
3311        };
3312
3313    for segment in &cpl.segment_list.segments {
3314        let seq_list = &segment.sequence_list;
3315
3316        // Video tracks from MainImageSequence
3317        for seq in &seq_list.main_image_sequences {
3318            for resource in &seq.resource_list.resources {
3319                if let Some(source_encoding) = &resource.source_encoding {
3320                    if let Some(ed) = descriptors.get(source_encoding) {
3321                        let (codec, resolution, bit_depth) = extract_video_info_from_descriptor(ed);
3322                        let framerate = resource
3323                            .edit_rate
3324                            .as_ref()
3325                            .or(cpl.edit_rate.as_ref())
3326                            .map(format_framerate);
3327                        tracks.push(TrackInfo {
3328                            track_id: seq.track_id.to_string(),
3329                            track_type: "video".to_string(),
3330                            codec,
3331                            language: None,
3332                            channels: None,
3333                            format_details: None,
3334                            resolution,
3335                            framerate,
3336                            bit_depth,
3337                            subtitle_type: None,
3338                        });
3339                    }
3340                }
3341            }
3342        }
3343
3344        // Audio tracks from MainAudioSequence
3345        for seq in &seq_list.main_audio_sequences {
3346            for resource in &seq.resource_list.resources {
3347                if let Some(source_encoding) = &resource.source_encoding {
3348                    if let Some(ed) = descriptors.get(source_encoding) {
3349                        let (codec, channels, format_details, language) =
3350                            extract_audio_info_from_descriptor(ed);
3351                        tracks.push(TrackInfo {
3352                            track_id: seq.track_id.to_string(),
3353                            track_type: "audio".to_string(),
3354                            codec,
3355                            language,
3356                            channels,
3357                            format_details,
3358                            resolution: None,
3359                            framerate: None,
3360                            bit_depth: None,
3361                            subtitle_type: None,
3362                        });
3363                    }
3364                }
3365            }
3366        }
3367
3368        // IAB (Atmos) tracks
3369        for seq in &seq_list.iab_sequences {
3370            for resource in &seq.resource_list.resources {
3371                if let Some(source_encoding) = &resource.source_encoding {
3372                    if let Some(ed) = descriptors.get(source_encoding) {
3373                        let language = ed
3374                            .iab_essence_descriptor
3375                            .as_ref()
3376                            .and_then(|iab| iab.sub_descriptors.as_ref())
3377                            .and_then(|sd| sd.iab_soundfield_label_sub_descriptor.as_ref())
3378                            .and_then(|sf| sf.rfc5646_spoken_language.as_ref())
3379                            .map(|lt| lt.as_str().to_string());
3380                        tracks.push(TrackInfo {
3381                            track_id: seq.track_id.to_string(),
3382                            track_type: "audio".to_string(),
3383                            codec: "IAB (Dolby Atmos)".to_string(),
3384                            language,
3385                            channels: Some("Object-based".to_string()),
3386                            format_details: Some("Immersive Audio".to_string()),
3387                            resolution: None,
3388                            framerate: None,
3389                            bit_depth: None,
3390                            subtitle_type: None,
3391                        });
3392                    }
3393                }
3394            }
3395        }
3396
3397        // Subtitle tracks
3398        let subtitle_sequences: Vec<(&str, &[SubtitlesSequence])> = vec![
3399            // We need to handle each type separately due to different types
3400        ];
3401        let _ = subtitle_sequences; // suppress warning
3402
3403        for seq in &seq_list.subtitles_sequences {
3404            if let Some(track) =
3405                extract_timed_text_track(seq.track_id, "standard", &seq.resource_list, &descriptors)
3406            {
3407                tracks.push(track);
3408            }
3409        }
3410        for seq in &seq_list.hearing_impaired_captions_sequences {
3411            if let Some(track) =
3412                extract_timed_text_track(seq.track_id, "hi", &seq.resource_list, &descriptors)
3413            {
3414                tracks.push(track);
3415            }
3416        }
3417        for seq in &seq_list.forced_narrative_sequences {
3418            if let Some(track) =
3419                extract_timed_text_track(seq.track_id, "forced", &seq.resource_list, &descriptors)
3420            {
3421                tracks.push(track);
3422            }
3423        }
3424    }
3425
3426    tracks
3427}
3428
3429fn extract_video_info_from_descriptor(
3430    ed: &EssenceDescriptor,
3431) -> (String, Option<String>, Option<String>) {
3432    if let Some(rgba) = &ed.rgba_descriptor {
3433        let width = rgba.display_width.or(rgba.stored_width);
3434        let height = rgba.display_height.or(rgba.stored_height);
3435        let resolution = match (width, height) {
3436            (Some(w), Some(h)) => Some(format!("{}x{}", w, h)),
3437            _ => None,
3438        };
3439        let codec = rgba
3440            .picture_compression
3441            .as_ref()
3442            .map(|c| c.to_string())
3443            .unwrap_or_else(|| "JPEG 2000".to_string());
3444        return (codec, resolution, None);
3445    }
3446    if let Some(cdci) = &ed.cdci_descriptor {
3447        let width = cdci
3448            .active_width
3449            .or(cdci.display_width)
3450            .or(cdci.stored_width);
3451        let height = cdci
3452            .active_height
3453            .or(cdci.display_height)
3454            .or(cdci.stored_height);
3455        let resolution = match (width, height) {
3456            (Some(w), Some(h)) => Some(format!("{}x{}", w, h)),
3457            _ => None,
3458        };
3459        let codec = cdci
3460            .picture_compression
3461            .as_ref()
3462            .map(|c| c.to_string())
3463            .unwrap_or_else(|| "JPEG 2000".to_string());
3464        let bit_depth = cdci.component_depth.map(|d| format!("{}-bit", d));
3465        return (codec, resolution, bit_depth);
3466    }
3467    ("Unknown".to_string(), None, None)
3468}
3469
3470fn extract_audio_info_from_descriptor(
3471    ed: &EssenceDescriptor,
3472) -> (String, Option<String>, Option<String>, Option<String>) {
3473    if let Some(wave) = &ed.wave_pcm_descriptor {
3474        let codec = wave
3475            .quantization_bits
3476            .map(|b| format!("PCM {}-bit", b))
3477            .unwrap_or_else(|| "PCM".to_string());
3478        let (channels, format_details) = match wave.channel_count {
3479            Some(1) => (Some("1.0".to_string()), Some("Mono".to_string())),
3480            Some(2) => (Some("2.0".to_string()), Some("Stereo".to_string())),
3481            Some(6) => (Some("5.1".to_string()), Some("Surround".to_string())),
3482            Some(8) => (Some("7.1".to_string()), Some("Surround".to_string())),
3483            Some(n) => (Some(format!("{}.0", n)), Some(format!("{} Channel", n))),
3484            None => (None, None),
3485        };
3486        let language = wave
3487            .sub_descriptors
3488            .as_ref()
3489            .and_then(|sd| sd.soundfield_group_label_sub_descriptor.as_ref())
3490            .and_then(|sf| sf.rfc5646_spoken_language.as_ref())
3491            .map(|lt| lt.as_str().to_string());
3492        return (codec, channels, format_details, language);
3493    }
3494    ("Unknown".to_string(), None, None, None)
3495}
3496
3497fn extract_timed_text_track(
3498    track_id: ImfUuid,
3499    subtitle_type: &str,
3500    resource_list: &ResourceList,
3501    descriptors: &std::collections::HashMap<ImfUuid, &EssenceDescriptor>,
3502) -> Option<TrackInfo> {
3503    for resource in &resource_list.resources {
3504        if let Some(source_encoding) = &resource.source_encoding {
3505            if let Some(ed) = descriptors.get(source_encoding) {
3506                let language = ed
3507                    .dc_timed_text_descriptor
3508                    .as_ref()
3509                    .map(|tt| {
3510                        tt.rfc5646_language_tag_list
3511                            .iter()
3512                            .map(|lt| lt.as_str())
3513                            .filter(|s| !s.is_empty())
3514                            .collect::<Vec<_>>()
3515                            .join(",")
3516                    })
3517                    .filter(|s| !s.is_empty());
3518                return Some(TrackInfo {
3519                    track_id: track_id.to_string(),
3520                    track_type: "subtitle".to_string(),
3521                    codec: "IMSC1 (Timed Text)".to_string(),
3522                    language,
3523                    channels: None,
3524                    format_details: None,
3525                    resolution: None,
3526                    framerate: None,
3527                    bit_depth: None,
3528                    subtitle_type: Some(subtitle_type.to_string()),
3529                });
3530            }
3531        }
3532    }
3533    None
3534}
3535
3536pub fn format_framerate(edit_rate: &EditRate) -> String {
3537    let fps = edit_rate.as_f64();
3538    if (fps - 23.976).abs() < 0.01 {
3539        "23.976".to_string()
3540    } else if (fps - 29.97).abs() < 0.01 {
3541        "29.97".to_string()
3542    } else if (fps - 59.94).abs() < 0.01 {
3543        "59.94".to_string()
3544    } else if fps == fps.round() {
3545        format!("{}", fps as u32)
3546    } else {
3547        format!("{:.3}", fps)
3548    }
3549}
3550
3551// =============================================================================
3552// Tests
3553// =============================================================================
3554
3555#[cfg(test)]
3556mod tests {
3557    use super::*;
3558    use crate::assetmap::ImfUuid;
3559
3560    fn make_seq_list_with_all_types() -> SequenceList {
3561        let uuid = || ImfUuid::parse("urn:uuid:00000000-0000-0000-0000-000000000001").unwrap();
3562        let rl = || ResourceList { resources: vec![] };
3563        SequenceList {
3564            marker_sequences: vec![MarkerSequence {
3565                id: uuid(),
3566                track_id: uuid(),
3567                resource_list: rl(),
3568            }],
3569            main_image_sequences: vec![MainImageSequence {
3570                id: uuid(),
3571                track_id: uuid(),
3572                resource_list: rl(),
3573            }],
3574            main_audio_sequences: vec![MainAudioSequence {
3575                id: uuid(),
3576                track_id: uuid(),
3577                resource_list: rl(),
3578            }],
3579            subtitles_sequences: vec![SubtitlesSequence {
3580                id: uuid(),
3581                track_id: uuid(),
3582                resource_list: rl(),
3583            }],
3584            hearing_impaired_captions_sequences: vec![HearingImpairedCaptionsSequence {
3585                id: uuid(),
3586                track_id: uuid(),
3587                resource_list: rl(),
3588            }],
3589            forced_narrative_sequences: vec![ForcedNarrativeSequence {
3590                id: uuid(),
3591                track_id: uuid(),
3592                resource_list: rl(),
3593            }],
3594            iab_sequences: vec![IABSequence {
3595                id: uuid(),
3596                track_id: uuid(),
3597                resource_list: rl(),
3598            }],
3599            isxd_sequences: vec![ISXDSequence {
3600                id: uuid(),
3601                track_id: uuid(),
3602                resource_list: rl(),
3603            }],
3604        }
3605    }
3606
3607    #[test]
3608    fn all_sequences_excludes_markers() {
3609        let sl = make_seq_list_with_all_types();
3610        // 7 non-marker sequence types, 1 of each
3611        assert_eq!(sl.all_sequences().len(), 7);
3612    }
3613
3614    #[test]
3615    fn all_sequences_typed_returns_type_names() {
3616        let sl = make_seq_list_with_all_types();
3617        let typed = sl.all_sequences_typed();
3618        assert_eq!(typed.len(), 7);
3619        let names: Vec<&str> = typed.iter().map(|(_, n)| *n).collect();
3620        assert!(names.contains(&"MainImage"));
3621        assert!(names.contains(&"MainAudio"));
3622        assert!(names.contains(&"Subtitles"));
3623        assert!(names.contains(&"HearingImpairedCaptions"));
3624        assert!(names.contains(&"ForcedNarrative"));
3625        assert!(names.contains(&"IAB"));
3626        assert!(names.contains(&"ISXD"));
3627    }
3628
3629    #[test]
3630    fn all_sequences_empty_list() {
3631        let sl = SequenceList {
3632            marker_sequences: vec![],
3633            main_image_sequences: vec![],
3634            main_audio_sequences: vec![],
3635            subtitles_sequences: vec![],
3636            hearing_impaired_captions_sequences: vec![],
3637            forced_narrative_sequences: vec![],
3638            iab_sequences: vec![],
3639            isxd_sequences: vec![],
3640        };
3641        assert!(sl.all_sequences().is_empty());
3642        assert!(sl.all_sequences_typed().is_empty());
3643    }
3644
3645    #[test]
3646    fn try_extract_cpl_track_codecs_invalid_xml() {
3647        let result = try_extract_cpl_track_codecs_from_xml("<not-a-cpl/>");
3648        assert!(result.is_err());
3649    }
3650
3651    struct AcceptAllSignatureVerifier;
3652    impl XmlSignatureVerifier for AcceptAllSignatureVerifier {
3653        fn verify(&self, _xml_content: &str) -> Result<(), String> {
3654            Ok(())
3655        }
3656    }
3657
3658    struct RejectingSignatureVerifier;
3659    impl XmlSignatureVerifier for RejectingSignatureVerifier {
3660        fn verify(&self, _xml_content: &str) -> Result<(), String> {
3661            Err("bad signature".to_string())
3662        }
3663    }
3664
3665    #[test]
3666    fn strict_production_options_enable_all_strict_checks() {
3667        let verifier = ReferenceDigestXmlDsigVerifier;
3668        let options = strict_production_parse_options(&verifier);
3669        assert_eq!(options.unknown_field_mode, UnknownFieldMode::Error);
3670        assert_eq!(options.schema_strict_mode, SchemaStrictMode::Basic);
3671        assert_eq!(
3672            options.signature_validation_mode,
3673            SignatureValidationMode::RequireValid
3674        );
3675        assert!(options.signature_verifier.is_some());
3676    }
3677
3678    #[test]
3679    fn recommended_signature_verifier_rejects_unsigned_xml() {
3680        let xml = minimal_cpl_with_ns("http://www.smpte-ra.org/schemas/2067-3/2013");
3681        let verifier = recommended_signature_verifier();
3682        assert!(verifier.verify(&xml).is_err());
3683    }
3684
3685    #[test]
3686    fn test_strip_xml_namespaces() {
3687        let input = r#"<r0:RGBADescriptor xmlns:r0="http://example.com"><r1:DisplayWidth>3840</r1:DisplayWidth></r0:RGBADescriptor>"#;
3688        let result = strip_xml_namespaces(input);
3689        assert!(result.contains("<RGBADescriptor"));
3690        assert!(result.contains("<DisplayWidth>3840</DisplayWidth>"));
3691        assert!(result.contains("</RGBADescriptor>"));
3692        assert!(!result.contains("xmlns:r0"));
3693    }
3694
3695    #[test]
3696    fn test_strip_preserves_content_with_colons() {
3697        let input = r#"<PictureCompression>urn:smpte:ul:060e2b34</PictureCompression>"#;
3698        let result = strip_xml_namespaces(input);
3699        assert_eq!(result, input); // No namespace prefixes to strip
3700    }
3701
3702    #[test]
3703    fn test_parse_simple_cpl() {
3704        let xml = r#"<?xml version="1.0" encoding="UTF-8" ?>
3705<CompositionPlaylist xmlns="http://www.smpte-ra.org/schemas/2067-3/2013">
3706<Id>urn:uuid:0eb3d1b9-b77b-4d3f-bbe5-7c69b15dca85</Id>
3707<Annotation>Test CPL</Annotation>
3708<IssueDate>2016-10-06T08:35:02-00:00</IssueDate>
3709<ContentTitle>Test Content</ContentTitle>
3710<ContentKind>Test</ContentKind>
3711<SegmentList>
3712<Segment>
3713<Id>urn:uuid:00000000-0000-0000-0000-000000000001</Id>
3714<SequenceList>
3715</SequenceList>
3716</Segment>
3717</SegmentList>
3718</CompositionPlaylist>"#;
3719
3720        let result = parse_cpl(xml);
3721        match result {
3722            Ok(cpl) => {
3723                assert_eq!(
3724                    cpl.id,
3725                    ImfUuid::parse("urn:uuid:0eb3d1b9-b77b-4d3f-bbe5-7c69b15dca85").unwrap()
3726                );
3727                assert_eq!(cpl.content_title.text, "Test Content");
3728                assert_eq!(cpl.content_kind, ContentKind::Test);
3729                assert!(!cpl.segment_list.segments.is_empty());
3730            }
3731            Err(e) => panic!("Failed to parse CPL: {:?}", e),
3732        }
3733    }
3734
3735    #[test]
3736    fn test_content_kind_scope_attribute() {
3737        // Verify scope attribute is captured from ContentKind XML element
3738        let xml = r#"<?xml version="1.0" encoding="UTF-8" ?>
3739<CompositionPlaylist xmlns="http://www.smpte-ra.org/schemas/2067-3/2013">
3740<Id>urn:uuid:0eb3d1b9-b77b-4d3f-bbe5-7c69b15dca85</Id>
3741<IssueDate>2024-01-01T00:00:00Z</IssueDate>
3742<ContentTitle>Test</ContentTitle>
3743<ContentKind scope="http://www.smpte-ra.org/schemas/2067-3/2013#content-kind">feature</ContentKind>
3744<SegmentList>
3745<Segment>
3746<Id>urn:uuid:00000000-0000-0000-0000-000000000001</Id>
3747<SequenceList>
3748</SequenceList>
3749</Segment>
3750</SegmentList>
3751</CompositionPlaylist>"#;
3752
3753        let cpl = parse_cpl(xml).expect("Failed to parse CPL with ContentKind scope");
3754        assert_eq!(cpl.content_kind.kind, ContentKind::Feature);
3755        assert_eq!(
3756            cpl.content_kind.scope.as_deref(),
3757            Some("http://www.smpte-ra.org/schemas/2067-3/2013#content-kind")
3758        );
3759        assert_eq!(
3760            cpl.content_kind.effective_scope(),
3761            "http://www.smpte-ra.org/schemas/2067-3/2013#content-kind"
3762        );
3763    }
3764
3765    #[test]
3766    fn test_content_kind_custom_scope() {
3767        // Verify custom (non-default) scope is preserved
3768        let xml = r#"<?xml version="1.0" encoding="UTF-8" ?>
3769<CompositionPlaylist xmlns="http://www.smpte-ra.org/schemas/2067-3/2013">
3770<Id>urn:uuid:0eb3d1b9-b77b-4d3f-bbe5-7c69b15dca85</Id>
3771<IssueDate>2024-01-01T00:00:00Z</IssueDate>
3772<ContentTitle>Test</ContentTitle>
3773<ContentKind scope="http://example.com/custom-kinds">my-custom-kind</ContentKind>
3774<SegmentList>
3775<Segment>
3776<Id>urn:uuid:00000000-0000-0000-0000-000000000001</Id>
3777<SequenceList>
3778</SequenceList>
3779</Segment>
3780</SegmentList>
3781</CompositionPlaylist>"#;
3782
3783        let cpl = parse_cpl(xml).expect("Failed to parse CPL with custom scope");
3784        assert_eq!(
3785            cpl.content_kind.kind,
3786            ContentKind::Other("my-custom-kind".to_string())
3787        );
3788        assert_eq!(
3789            cpl.content_kind.scope.as_deref(),
3790            Some("http://example.com/custom-kinds")
3791        );
3792    }
3793
3794    #[test]
3795    fn test_content_kind_no_scope_uses_default() {
3796        // When no scope attribute is present, effective_scope() returns the XSD default
3797        let xml = r#"<?xml version="1.0" encoding="UTF-8" ?>
3798<CompositionPlaylist xmlns="http://www.smpte-ra.org/schemas/2067-3/2013">
3799<Id>urn:uuid:0eb3d1b9-b77b-4d3f-bbe5-7c69b15dca85</Id>
3800<IssueDate>2024-01-01T00:00:00Z</IssueDate>
3801<ContentTitle>Test</ContentTitle>
3802<ContentKind>Test</ContentKind>
3803<SegmentList>
3804<Segment>
3805<Id>urn:uuid:00000000-0000-0000-0000-000000000001</Id>
3806<SequenceList>
3807</SequenceList>
3808</Segment>
3809</SegmentList>
3810</CompositionPlaylist>"#;
3811
3812        let cpl = parse_cpl(xml).expect("Failed to parse CPL without scope");
3813        assert_eq!(cpl.content_kind.kind, ContentKind::Test);
3814        assert!(cpl.content_kind.scope.is_none());
3815        assert_eq!(
3816            cpl.content_kind.effective_scope(),
3817            CONTENT_KIND_DEFAULT_SCOPE
3818        );
3819    }
3820
3821    #[test]
3822    fn test_malformed_xml_handling() {
3823        let malformed_xml = r#"<?xml version="1.0" encoding="UTF-8" ?>
3824<CompositionPlaylist xmlns="http://www.smpte-ra.org/schemas/2067-3/2013">
3825<Id>urn:uuid:test</Id>
3826<ContentTitle>Broken XML"#;
3827
3828        let result: Result<CompositionPlaylist, CplParseError> = parse_cpl(malformed_xml);
3829        assert!(result.is_err(), "Should fail with malformed XML");
3830    }
3831
3832    // ── Namespace compatibility ──────────────────────────────────────────────
3833
3834    /// Helper: build a minimal CPL XML with the given xmlns.
3835    fn minimal_cpl_with_ns(ns: &str) -> String {
3836        format!(
3837            r#"<?xml version="1.0" encoding="UTF-8" ?>
3838<CompositionPlaylist xmlns="{ns}">
3839<Id>urn:uuid:0eb3d1b9-b77b-4d3f-bbe5-7c69b15dca85</Id>
3840<IssueDate>2024-01-01T00:00:00Z</IssueDate>
3841<ContentTitle>NS Test</ContentTitle>
3842<ContentKind>Test</ContentKind>
3843<SegmentList>
3844<Segment>
3845<Id>urn:uuid:00000000-0000-0000-0000-000000000001</Id>
3846<SequenceList></SequenceList>
3847</Segment>
3848</SegmentList>
3849</CompositionPlaylist>"#
3850        )
3851    }
3852
3853    /// SMPTE ST 2067-3:2013 namespace (original).
3854    #[test]
3855    fn cpl_parses_with_2067_3_2013_namespace() {
3856        let xml = minimal_cpl_with_ns("http://www.smpte-ra.org/schemas/2067-3/2013");
3857        let cpl = parse_cpl(&xml).expect("2013 namespace should parse");
3858        assert_eq!(cpl.content_title.text, "NS Test");
3859        assert_eq!(cpl.namespace, CplNamespace::Smpte2067_3_2013);
3860        assert_eq!(cpl.namespace.spec_id(), "ST 2067-3:2013");
3861        assert_eq!(cpl.namespace.year(), Some(2013));
3862    }
3863
3864    /// SMPTE ST 2067-3:2016 namespace.
3865    #[test]
3866    fn cpl_parses_with_2067_3_2016_namespace() {
3867        let xml = minimal_cpl_with_ns("http://www.smpte-ra.org/schemas/2067-3/2016");
3868        let cpl = parse_cpl(&xml).expect("2016 namespace should parse");
3869        assert_eq!(cpl.content_title.text, "NS Test");
3870        assert_eq!(cpl.namespace, CplNamespace::Smpte2067_3_2016);
3871        assert_eq!(cpl.namespace.year(), Some(2016));
3872    }
3873
3874    /// SMPTE ST 2067-3:2020 namespace (note: `schemas` → `ns` path change).
3875    #[test]
3876    fn cpl_parses_with_2067_3_2020_namespace() {
3877        let xml = minimal_cpl_with_ns("http://www.smpte-ra.org/ns/2067-3/2020");
3878        let cpl = parse_cpl(&xml).expect("2020 namespace should parse");
3879        assert_eq!(cpl.content_title.text, "NS Test");
3880        assert_eq!(cpl.namespace, CplNamespace::Smpte2067_3_2020);
3881        assert_eq!(cpl.namespace.year(), Some(2020));
3882    }
3883
3884    /// DCI CPL namespace compatibility (pre-IMF era, ST 429 series).
3885    #[test]
3886    fn cpl_parses_with_dci_429_7_namespace() {
3887        let xml = minimal_cpl_with_ns("http://www.smpte-ra.org/schemas/429-7/2006/CPL");
3888        let cpl = parse_cpl(&xml).expect("DCI 429-7 namespace should parse");
3889        assert_eq!(cpl.content_title.text, "NS Test");
3890        assert_eq!(cpl.namespace, CplNamespace::Dci429_7);
3891        assert_eq!(cpl.namespace.year(), Some(2006));
3892    }
3893
3894    /// Real test corpus: MERIDIAN CPL should detect 2013 namespace.
3895    #[test]
3896    fn cpl_meridian_detects_2013_namespace() {
3897        let xml = include_str!("../../../../test-data/MERIDIAN_Netflix_Photon_161006/CPL_0eb3d1b9-b77b-4d3f-bbe5-7c69b15dca85.xml");
3898        let cpl = parse_cpl(xml).expect("MERIDIAN CPL should parse");
3899        assert_eq!(cpl.namespace, CplNamespace::Smpte2067_3_2013);
3900    }
3901
3902    #[test]
3903    fn strict_unknown_mode_rejects_unknown_elements() {
3904        let xml = r#"<?xml version="1.0" encoding="UTF-8" ?>
3905<CompositionPlaylist xmlns="http://www.smpte-ra.org/schemas/2067-3/2013">
3906<Id>urn:uuid:0eb3d1b9-b77b-4d3f-bbe5-7c69b15dca85</Id>
3907<IssueDate>2024-01-01T00:00:00Z</IssueDate>
3908<ContentTitle>Strict Unknown</ContentTitle>
3909<ContentKind>Test</ContentKind>
3910<UnknownElement>oops</UnknownElement>
3911<SegmentList><Segment><Id>urn:uuid:00000000-0000-0000-0000-000000000001</Id><SequenceList></SequenceList></Segment></SegmentList>
3912</CompositionPlaylist>"#;
3913
3914        let options = CplParseOptions {
3915            unknown_field_mode: UnknownFieldMode::Error,
3916            ..Default::default()
3917        };
3918        let result = parse_cpl_with_options(xml, &options);
3919        assert!(matches!(result, Err(CplParseError::StrictUnknownXml(_))));
3920    }
3921
3922    #[test]
3923    fn strict_schema_mode_rejects_empty_sequence_list_per_segment() {
3924        let xml = r#"<?xml version="1.0" encoding="UTF-8" ?>
3925<CompositionPlaylist xmlns="http://www.smpte-ra.org/schemas/2067-3/2013">
3926<Id>urn:uuid:0eb3d1b9-b77b-4d3f-bbe5-7c69b15dca85</Id>
3927<IssueDate>2024-01-01T00:00:00Z</IssueDate>
3928<ContentTitle>Strict Schema</ContentTitle>
3929<ContentKind>Test</ContentKind>
3930<SegmentList><Segment><Id>urn:uuid:00000000-0000-0000-0000-000000000001</Id><SequenceList></SequenceList></Segment></SegmentList>
3931</CompositionPlaylist>"#;
3932
3933        let options = CplParseOptions {
3934            schema_strict_mode: SchemaStrictMode::Basic,
3935            ..Default::default()
3936        };
3937        let result = parse_cpl_with_options(xml, &options);
3938        assert!(matches!(result, Err(CplParseError::StrictSchema(_))));
3939    }
3940
3941    #[test]
3942    fn signature_mode_require_presence_rejects_unsigned_cpl() {
3943        let xml = minimal_cpl_with_ns("http://www.smpte-ra.org/schemas/2067-3/2013");
3944        let options = CplParseOptions {
3945            signature_validation_mode: SignatureValidationMode::RequirePresence,
3946            ..Default::default()
3947        };
3948        let result = parse_cpl_with_options(&xml, &options);
3949        assert!(matches!(result, Err(CplParseError::StrictSchema(_))));
3950    }
3951
3952    #[test]
3953    fn signature_mode_require_valid_needs_verifier() {
3954        let xml = r#"<?xml version="1.0" encoding="UTF-8" ?>
3955<CompositionPlaylist xmlns="http://www.smpte-ra.org/schemas/2067-3/2013">
3956<Id>urn:uuid:0eb3d1b9-b77b-4d3f-bbe5-7c69b15dca85</Id>
3957<IssueDate>2024-01-01T00:00:00Z</IssueDate>
3958<ContentTitle>Signed</ContentTitle>
3959<ContentKind>Test</ContentKind>
3960<Signature>dummy</Signature>
3961<SegmentList><Segment><Id>urn:uuid:00000000-0000-0000-0000-000000000001</Id><SequenceList></SequenceList></Segment></SegmentList>
3962</CompositionPlaylist>"#;
3963
3964        let options = CplParseOptions {
3965            signature_validation_mode: SignatureValidationMode::RequireValid,
3966            ..Default::default()
3967        };
3968        let result = parse_cpl_with_options(xml, &options);
3969        assert!(matches!(
3970            result,
3971            Err(CplParseError::SignatureVerifierRequired)
3972        ));
3973    }
3974
3975    #[test]
3976    fn signature_mode_require_valid_uses_verifier() {
3977        let xml = r#"<?xml version="1.0" encoding="UTF-8" ?>
3978<CompositionPlaylist xmlns="http://www.smpte-ra.org/schemas/2067-3/2013">
3979<Id>urn:uuid:0eb3d1b9-b77b-4d3f-bbe5-7c69b15dca85</Id>
3980<IssueDate>2024-01-01T00:00:00Z</IssueDate>
3981<ContentTitle>Signed</ContentTitle>
3982<ContentKind>Test</ContentKind>
3983<Signature>dummy</Signature>
3984<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>
3985</CompositionPlaylist>"#;
3986
3987        let verifier = AcceptAllSignatureVerifier;
3988        let options = CplParseOptions {
3989            signature_validation_mode: SignatureValidationMode::RequireValid,
3990            signature_verifier: Some(&verifier),
3991            ..Default::default()
3992        };
3993        let result = parse_cpl_with_options(xml, &options);
3994        assert!(
3995            result.is_ok(),
3996            "signature verifier should allow parse: {result:?}"
3997        );
3998    }
3999
4000    #[test]
4001    fn signature_mode_require_valid_surfaces_verification_failure() {
4002        let xml = r#"<?xml version="1.0" encoding="UTF-8" ?>
4003<CompositionPlaylist xmlns="http://www.smpte-ra.org/schemas/2067-3/2013">
4004<Id>urn:uuid:0eb3d1b9-b77b-4d3f-bbe5-7c69b15dca85</Id>
4005<IssueDate>2024-01-01T00:00:00Z</IssueDate>
4006<ContentTitle>Signed</ContentTitle>
4007<ContentKind>Test</ContentKind>
4008<Signature>dummy</Signature>
4009<SegmentList><Segment><Id>urn:uuid:00000000-0000-0000-0000-000000000001</Id><SequenceList></SequenceList></Segment></SegmentList>
4010</CompositionPlaylist>"#;
4011
4012        let verifier = RejectingSignatureVerifier;
4013        let options = CplParseOptions {
4014            signature_validation_mode: SignatureValidationMode::RequireValid,
4015            signature_verifier: Some(&verifier),
4016            ..Default::default()
4017        };
4018        let result = parse_cpl_with_options(xml, &options);
4019        assert!(matches!(
4020            result,
4021            Err(CplParseError::SignatureVerificationFailed(_))
4022        ));
4023    }
4024
4025    fn build_signed_cpl_with_reference_digest(tamper_digest: bool) -> String {
4026        let unsigned_xml = r#"<?xml version="1.0" encoding="UTF-8" ?>
4027<CompositionPlaylist xmlns="http://www.smpte-ra.org/schemas/2067-3/2013">
4028<Id>urn:uuid:0eb3d1b9-b77b-4d3f-bbe5-7c69b15dca85</Id>
4029<IssueDate>2024-01-01T00:00:00Z</IssueDate>
4030<ContentTitle>Signed Digest CPL</ContentTitle>
4031<ContentKind>Test</ContentKind>
4032<SegmentList>
4033<Segment>
4034<Id>urn:uuid:00000000-0000-0000-0000-000000000001</Id>
4035<SequenceList></SequenceList>
4036</Segment>
4037</SegmentList>
4038</CompositionPlaylist>"#;
4039
4040        let normalized = normalize_xml_for_digest(unsigned_xml);
4041        let digest = compute_hash(HashAlgorithm::Sha256, normalized.as_bytes());
4042        let mut digest_b64 = base64::engine::general_purpose::STANDARD.encode(digest);
4043        if tamper_digest {
4044            digest_b64 = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=".to_string();
4045        }
4046
4047        format!(
4048            r#"<?xml version="1.0" encoding="UTF-8" ?>
4049<CompositionPlaylist xmlns="http://www.smpte-ra.org/schemas/2067-3/2013">
4050<Id>urn:uuid:0eb3d1b9-b77b-4d3f-bbe5-7c69b15dca85</Id>
4051<IssueDate>2024-01-01T00:00:00Z</IssueDate>
4052<ContentTitle>Signed Digest CPL</ContentTitle>
4053<ContentKind>Test</ContentKind>
4054<Signature xmlns="http://www.w3.org/2000/09/xmldsig#">
4055  <SignedInfo>
4056    <CanonicalizationMethod Algorithm="http://www.w3.org/TR/2001/REC-xml-c14n-20010315"/>
4057    <SignatureMethod Algorithm="http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"/>
4058    <Reference URI="">
4059      <Transforms>
4060        <Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature"/>
4061      </Transforms>
4062      <DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/>
4063      <DigestValue>{}</DigestValue>
4064    </Reference>
4065  </SignedInfo>
4066  <SignatureValue>AQ==</SignatureValue>
4067</Signature>
4068<SegmentList>
4069<Segment>
4070<Id>urn:uuid:00000000-0000-0000-0000-000000000001</Id>
4071<SequenceList></SequenceList>
4072</Segment>
4073</SegmentList>
4074</CompositionPlaylist>"#,
4075            digest_b64
4076        )
4077    }
4078
4079    #[test]
4080    fn reference_digest_verifier_accepts_valid_uri_empty_digest() {
4081        let xml = build_signed_cpl_with_reference_digest(false);
4082        let verifier = ReferenceDigestXmlDsigVerifier;
4083        assert!(verifier.verify(&xml).is_ok());
4084    }
4085
4086    #[test]
4087    fn reference_digest_verifier_rejects_mismatched_digest() {
4088        let xml = build_signed_cpl_with_reference_digest(true);
4089        let verifier = ReferenceDigestXmlDsigVerifier;
4090        let result = verifier.verify(&xml);
4091        assert!(result.is_err());
4092        let error = result.unwrap_err();
4093        assert!(
4094            error.contains("DigestValue mismatch"),
4095            "unexpected error: {error}"
4096        );
4097    }
4098
4099    #[test]
4100    fn signature_mode_require_valid_with_reference_digest_verifier() {
4101        let xml = build_signed_cpl_with_reference_digest(false);
4102        let verifier = ReferenceDigestXmlDsigVerifier;
4103        let options = CplParseOptions {
4104            signature_validation_mode: SignatureValidationMode::RequireValid,
4105            signature_verifier: Some(&verifier),
4106            ..Default::default()
4107        };
4108        let result = parse_cpl_with_options(&xml, &options);
4109        assert!(
4110            result.is_ok(),
4111            "expected valid signature digest path to parse: {result:?}"
4112        );
4113    }
4114
4115    #[cfg(all(feature = "xmlsec1", not(target_arch = "wasm32")))]
4116    #[test]
4117    fn xmlsec_verifier_surfaces_missing_binary() {
4118        let xml = minimal_cpl_with_ns("http://www.smpte-ra.org/schemas/2067-3/2013");
4119        let verifier = XmlSec1Verifier::new().with_binary_path("xmlsec1-definitely-not-installed");
4120        let error = verifier
4121            .verify(&xml)
4122            .expect_err("expected missing binary error");
4123        assert!(
4124            error.contains("failed to execute"),
4125            "unexpected error message: {error}"
4126        );
4127    }
4128
4129    #[cfg(all(feature = "xmlsec", not(target_arch = "wasm32")))]
4130    #[test]
4131    fn xmlsec_crate_verifier_rejects_invalid_key_material() {
4132        let xml = build_signed_cpl_with_reference_digest(false);
4133        let verifier = XmlSecCrateVerifier::from_pem("not-a-valid-key");
4134        let error = verifier
4135            .verify(&xml)
4136            .expect_err("expected xmlsec key load error");
4137        assert!(
4138            error.contains("xmlsec key load failed") || error.contains("xmlsec verify failed"),
4139            "unexpected error message: {error}"
4140        );
4141    }
4142}