Skip to main content

imferno_core/scm/
mod.rs

1//! SMPTE ST 2067-9:2018 — Sidecar Composition Map (SCM) parser.
2//!
3//! The Sidecar Composition Map associates sidecar assets (e.g. IAB audio,
4//! subtitle tracks) with a Composition Playlist by UUID, without modifying
5//! the CPL itself.
6//!
7//! Normative namespace: `http://www.smpte-ra.org/ns/2067-9/2018`
8
9pub mod codes;
10
11use crate::assetmap::ImfUuid;
12use thiserror::Error;
13
14// ── Raw deserialization types ────────────────────────────────────────────────
15
16mod raw {
17    use serde::{Deserialize, Deserializer};
18
19    /// Deserializes any XML element (with children) and discards the value.
20    /// Used to detect presence of optional elements like `<Signer>` / `<Signature>`.
21    #[derive(Debug)]
22    pub struct Present;
23
24    impl<'de> Deserialize<'de> for Present {
25        fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
26            serde::de::IgnoredAny::deserialize(d)?;
27            Ok(Present)
28        }
29    }
30
31    /// Per ST 2067-9:2018 §7.2 Table 2: the SCM root contains `<Id>`, a
32    /// `<Properties>` wrapper (Annotation + IssueDate + Issuer), a
33    /// `<SidecarAssetList>`, and optional Signer/Signature elements.
34    /// `Creator` is **not** in the canonical XSD — earlier versions of
35    /// this parser carried it as an Option, but the field can never be
36    /// populated from a conformant document, so it's been removed.
37    #[derive(Deserialize)]
38    pub struct SidecarCompositionMap {
39        #[serde(rename = "Id")]
40        pub id: String,
41        #[serde(rename = "Properties")]
42        pub properties: Properties,
43        /// Presence flag for §7.2.4 Signer/Signature cross-check.
44        #[serde(rename = "Signer")]
45        pub signer: Option<Present>,
46        /// Presence flag for §7.2.5 Signer/Signature cross-check.
47        #[serde(rename = "Signature")]
48        pub signature: Option<Present>,
49        #[serde(rename = "SidecarAssetList")]
50        pub sidecar_asset_list: Option<SidecarAssetList>,
51    }
52
53    #[derive(Deserialize)]
54    pub struct Properties {
55        #[serde(rename = "Annotation")]
56        pub annotation: Option<String>,
57        #[serde(rename = "IssueDate")]
58        pub issue_date: String,
59        #[serde(rename = "Issuer")]
60        pub issuer: Option<String>,
61    }
62
63    #[derive(Deserialize)]
64    pub struct SidecarAssetList {
65        #[serde(rename = "SidecarAsset", default)]
66        pub sidecar_assets: Vec<SidecarAsset>,
67    }
68
69    /// Per ST 2067-9:2018 §7.3 Table 3: each sidecar asset carries an
70    /// `AssociatedCPLList` containing one or more `CPLId` elements.
71    #[derive(Deserialize)]
72    pub struct SidecarAsset {
73        #[serde(rename = "Id")]
74        pub id: String,
75        #[serde(rename = "AssociatedCPLList")]
76        pub associated_cpl_list: Option<AssociatedCplList>,
77    }
78
79    #[derive(Deserialize, Default)]
80    pub struct AssociatedCplList {
81        #[serde(rename = "CPLId", default)]
82        pub cpl_ids: Vec<String>,
83    }
84}
85
86// ── Domain types ─────────────────────────────────────────────────────────────
87
88/// A parsed Sidecar Composition Map document (ST 2067-9:2018).
89///
90/// Field naming flattens the XSD's `<Properties>` wrapper into the
91/// top-level struct; on the wire the document still carries
92/// `<Properties>{Annotation,IssueDate,Issuer}</Properties>` per
93/// ST 2067-9:2018 §7.2 Table 2.
94#[derive(Debug, Clone, PartialEq, serde::Serialize)]
95#[serde(rename_all = "camelCase")]
96pub struct SidecarCompositionMap {
97    /// UUID of this SCM document.
98    pub id: ImfUuid,
99    pub issue_date: String,
100    pub issuer: Option<String>,
101    pub annotation: Option<String>,
102    /// True if a `<Signer>` element was present (§7.2.4).
103    pub has_signer: bool,
104    /// True if a `<Signature>` element was present (§7.2.5).
105    pub has_signature: bool,
106    /// Sidecar asset associations.
107    pub sidecar_assets: Vec<SidecarAsset>,
108}
109
110/// A single sidecar asset association within an SCM document.
111#[derive(Debug, Clone, PartialEq, serde::Serialize)]
112#[serde(rename_all = "camelCase")]
113pub struct SidecarAsset {
114    /// UUID of the sidecar asset (must be present in the package AssetMap).
115    pub id: ImfUuid,
116    /// CPLs this sidecar asset is associated with (§7.3.1.1: one or more).
117    pub cpl_ids: Vec<ImfUuid>,
118}
119
120// ── Error type ───────────────────────────────────────────────────────────────
121
122#[derive(Debug, Error)]
123pub enum ScmParseError {
124    #[error("XML parse error: {0}")]
125    Xml(#[from] quick_xml::DeError),
126    #[error("Invalid UUID '{0}': {1}")]
127    InvalidUuid(String, String),
128}
129
130// ── Parser ───────────────────────────────────────────────────────────────────
131
132/// Parse a SidecarCompositionMap XML document.
133pub fn parse_scm(xml: &str) -> Result<SidecarCompositionMap, ScmParseError> {
134    let raw: raw::SidecarCompositionMap = quick_xml::de::from_str(xml)?;
135
136    // XSD-strict: SMPTE dcml:UUIDType requires the `urn:uuid:` prefix.
137    let id = ImfUuid::parse_urn(&raw.id)
138        .map_err(|e| ScmParseError::InvalidUuid(raw.id.clone(), e.to_string()))?;
139
140    let sidecar_assets = raw
141        .sidecar_asset_list
142        .map(|list| list.sidecar_assets)
143        .unwrap_or_default()
144        .into_iter()
145        .map(|a| {
146            let asset_id = ImfUuid::parse_urn(&a.id)
147                .map_err(|e| ScmParseError::InvalidUuid(a.id.clone(), e.to_string()))?;
148            let cpl_ids = a
149                .associated_cpl_list
150                .unwrap_or_default()
151                .cpl_ids
152                .into_iter()
153                .map(|s| {
154                    ImfUuid::parse_urn(&s)
155                        .map_err(|e| ScmParseError::InvalidUuid(s.clone(), e.to_string()))
156                })
157                .collect::<Result<Vec<_>, ScmParseError>>()?;
158            Ok(SidecarAsset {
159                id: asset_id,
160                cpl_ids,
161            })
162        })
163        .collect::<Result<Vec<_>, ScmParseError>>()?;
164
165    Ok(SidecarCompositionMap {
166        id,
167        issue_date: raw.properties.issue_date,
168        issuer: raw.properties.issuer,
169        annotation: raw.properties.annotation,
170        has_signer: raw.signer.is_some(),
171        has_signature: raw.signature.is_some(),
172        sidecar_assets,
173    })
174}
175
176// ── Tests ────────────────────────────────────────────────────────────────────
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181    use pretty_assertions::assert_eq;
182
183    const MINIMAL_SCM: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
184<SidecarCompositionMap xmlns="http://www.smpte-ra.org/ns/2067-9/2018">
185    <Id>urn:uuid:a1b2c3d4-e5f6-7890-abcd-ef1234567890</Id>
186    <Properties>
187        <IssueDate>2024-01-01T00:00:00</IssueDate>
188    </Properties>
189    <SidecarAssetList>
190        <SidecarAsset>
191            <Id>urn:uuid:11111111-2222-3333-4444-555555555555</Id>
192            <AssociatedCPLList>
193                <CPLId>urn:uuid:aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee</CPLId>
194            </AssociatedCPLList>
195        </SidecarAsset>
196    </SidecarAssetList>
197</SidecarCompositionMap>"#;
198
199    #[test]
200    fn parses_minimal_scm() {
201        let scm = parse_scm(MINIMAL_SCM).unwrap();
202        assert_eq!(scm.id.to_string(), "a1b2c3d4-e5f6-7890-abcd-ef1234567890");
203        assert_eq!(scm.issue_date, "2024-01-01T00:00:00");
204        assert_eq!(scm.sidecar_assets.len(), 1);
205        assert_eq!(
206            scm.sidecar_assets[0].id.to_string(),
207            "11111111-2222-3333-4444-555555555555"
208        );
209        assert_eq!(scm.sidecar_assets[0].cpl_ids.len(), 1);
210        assert_eq!(
211            scm.sidecar_assets[0].cpl_ids[0].to_string(),
212            "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
213        );
214    }
215
216    #[test]
217    fn parses_multiple_cpl_ids() {
218        let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
219<SidecarCompositionMap xmlns="http://www.smpte-ra.org/ns/2067-9/2018">
220    <Id>urn:uuid:a1b2c3d4-e5f6-7890-abcd-ef1234567890</Id>
221    <Properties>
222        <IssueDate>2024-01-01T00:00:00</IssueDate>
223    </Properties>
224    <SidecarAssetList>
225        <SidecarAsset>
226            <Id>urn:uuid:11111111-2222-3333-4444-555555555555</Id>
227            <AssociatedCPLList>
228                <CPLId>urn:uuid:aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee</CPLId>
229                <CPLId>urn:uuid:bbbbbbbb-cccc-dddd-eeee-ffffffffffff</CPLId>
230            </AssociatedCPLList>
231        </SidecarAsset>
232    </SidecarAssetList>
233</SidecarCompositionMap>"#;
234        let scm = parse_scm(xml).unwrap();
235        assert_eq!(scm.sidecar_assets[0].cpl_ids.len(), 2);
236        assert_eq!(
237            scm.sidecar_assets[0].cpl_ids[1].to_string(),
238            "bbbbbbbb-cccc-dddd-eeee-ffffffffffff"
239        );
240    }
241
242    #[test]
243    fn parses_optional_fields() {
244        // `Creator` is intentionally NOT used here — ST 2067-9:2018 §7.2
245        // Table 2 has no Creator element under Properties. Earlier
246        // versions of this parser carried it, but the field could never
247        // populate from a conformant document.
248        let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
249<SidecarCompositionMap xmlns="http://www.smpte-ra.org/ns/2067-9/2018">
250    <Id>urn:uuid:a1b2c3d4-e5f6-7890-abcd-ef1234567890</Id>
251    <Properties>
252        <Annotation>IAB sidecar for main feature</Annotation>
253        <IssueDate>2024-06-15T12:00:00</IssueDate>
254        <Issuer>Test Facility</Issuer>
255    </Properties>
256    <SidecarAssetList/>
257</SidecarCompositionMap>"#;
258        let scm = parse_scm(xml).unwrap();
259        assert_eq!(scm.issuer.as_deref(), Some("Test Facility"));
260        assert_eq!(
261            scm.annotation.as_deref(),
262            Some("IAB sidecar for main feature")
263        );
264        assert!(scm.sidecar_assets.is_empty());
265        assert!(!scm.has_signer);
266        assert!(!scm.has_signature);
267    }
268
269    #[test]
270    fn detects_signer_presence() {
271        let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
272<SidecarCompositionMap xmlns="http://www.smpte-ra.org/ns/2067-9/2018">
273    <Id>urn:uuid:a1b2c3d4-e5f6-7890-abcd-ef1234567890</Id>
274    <Properties>
275        <IssueDate>2024-01-01T00:00:00</IssueDate>
276    </Properties>
277    <SidecarAssetList/>
278    <Signer><X509Data/></Signer>
279</SidecarCompositionMap>"#;
280        let scm = parse_scm(xml).unwrap();
281        assert!(scm.has_signer);
282        assert!(!scm.has_signature);
283    }
284
285    #[test]
286    fn rejects_malformed_xml() {
287        assert!(parse_scm("<not valid xml").is_err());
288    }
289
290    #[test]
291    fn rejects_invalid_uuid() {
292        let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
293<SidecarCompositionMap xmlns="http://www.smpte-ra.org/ns/2067-9/2018">
294    <Id>not-a-uuid</Id>
295    <Properties>
296        <IssueDate>2024-01-01T00:00:00</IssueDate>
297    </Properties>
298</SidecarCompositionMap>"#;
299        assert!(parse_scm(xml).is_err());
300    }
301}