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    #[derive(Deserialize)]
32    pub struct SidecarCompositionMap {
33        #[serde(rename = "Id")]
34        pub id: String,
35        #[serde(rename = "IssueDate")]
36        pub issue_date: String,
37        #[serde(rename = "Issuer")]
38        pub issuer: Option<String>,
39        #[serde(rename = "Creator")]
40        pub creator: Option<String>,
41        #[serde(rename = "Annotation")]
42        pub annotation: Option<String>,
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 SidecarAssetList {
55        #[serde(rename = "SidecarAsset", default)]
56        pub sidecar_assets: Vec<SidecarAsset>,
57    }
58
59    /// Per ST 2067-9:2018 §7.3 Table 3: each sidecar asset carries an
60    /// `AssociatedCPLList` containing one or more `CPLId` elements.
61    #[derive(Deserialize)]
62    pub struct SidecarAsset {
63        #[serde(rename = "Id")]
64        pub id: String,
65        #[serde(rename = "AssociatedCPLList")]
66        pub associated_cpl_list: Option<AssociatedCplList>,
67    }
68
69    #[derive(Deserialize, Default)]
70    pub struct AssociatedCplList {
71        #[serde(rename = "CPLId", default)]
72        pub cpl_ids: Vec<String>,
73    }
74}
75
76// ── Domain types ─────────────────────────────────────────────────────────────
77
78/// A parsed Sidecar Composition Map document (ST 2067-9:2018).
79#[derive(Debug, Clone, PartialEq, serde::Serialize)]
80#[serde(rename_all = "camelCase")]
81pub struct SidecarCompositionMap {
82    /// UUID of this SCM document.
83    pub id: ImfUuid,
84    pub issue_date: String,
85    pub issuer: Option<String>,
86    pub creator: Option<String>,
87    pub annotation: Option<String>,
88    /// True if a `<Signer>` element was present (§7.2.4).
89    pub has_signer: bool,
90    /// True if a `<Signature>` element was present (§7.2.5).
91    pub has_signature: bool,
92    /// Sidecar asset associations.
93    pub sidecar_assets: Vec<SidecarAsset>,
94}
95
96/// A single sidecar asset association within an SCM document.
97#[derive(Debug, Clone, PartialEq, serde::Serialize)]
98#[serde(rename_all = "camelCase")]
99pub struct SidecarAsset {
100    /// UUID of the sidecar asset (must be present in the package AssetMap).
101    pub id: ImfUuid,
102    /// CPLs this sidecar asset is associated with (§7.3.1.1: one or more).
103    pub cpl_ids: Vec<ImfUuid>,
104}
105
106// ── Error type ───────────────────────────────────────────────────────────────
107
108#[derive(Debug, Error)]
109pub enum ScmParseError {
110    #[error("XML parse error: {0}")]
111    Xml(#[from] quick_xml::DeError),
112    #[error("Invalid UUID '{0}': {1}")]
113    InvalidUuid(String, String),
114}
115
116// ── Parser ───────────────────────────────────────────────────────────────────
117
118/// Parse a SidecarCompositionMap XML document.
119pub fn parse_scm(xml: &str) -> Result<SidecarCompositionMap, ScmParseError> {
120    let raw: raw::SidecarCompositionMap = quick_xml::de::from_str(xml)?;
121
122    let id = ImfUuid::parse(&raw.id)
123        .map_err(|e| ScmParseError::InvalidUuid(raw.id.clone(), e.to_string()))?;
124
125    let sidecar_assets = raw
126        .sidecar_asset_list
127        .map(|list| list.sidecar_assets)
128        .unwrap_or_default()
129        .into_iter()
130        .map(|a| {
131            let asset_id = ImfUuid::parse(&a.id)
132                .map_err(|e| ScmParseError::InvalidUuid(a.id.clone(), e.to_string()))?;
133            let cpl_ids = a
134                .associated_cpl_list
135                .unwrap_or_default()
136                .cpl_ids
137                .into_iter()
138                .map(|s| {
139                    ImfUuid::parse(&s)
140                        .map_err(|e| ScmParseError::InvalidUuid(s.clone(), e.to_string()))
141                })
142                .collect::<Result<Vec<_>, ScmParseError>>()?;
143            Ok(SidecarAsset {
144                id: asset_id,
145                cpl_ids,
146            })
147        })
148        .collect::<Result<Vec<_>, ScmParseError>>()?;
149
150    Ok(SidecarCompositionMap {
151        id,
152        issue_date: raw.issue_date,
153        issuer: raw.issuer,
154        creator: raw.creator,
155        annotation: raw.annotation,
156        has_signer: raw.signer.is_some(),
157        has_signature: raw.signature.is_some(),
158        sidecar_assets,
159    })
160}
161
162// ── Tests ────────────────────────────────────────────────────────────────────
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167    use pretty_assertions::assert_eq;
168
169    const MINIMAL_SCM: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
170<SidecarCompositionMap xmlns="http://www.smpte-ra.org/ns/2067-9/2018">
171    <Id>urn:uuid:a1b2c3d4-e5f6-7890-abcd-ef1234567890</Id>
172    <IssueDate>2024-01-01T00:00:00</IssueDate>
173    <SidecarAssetList>
174        <SidecarAsset>
175            <Id>urn:uuid:11111111-2222-3333-4444-555555555555</Id>
176            <AssociatedCPLList>
177                <CPLId>urn:uuid:aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee</CPLId>
178            </AssociatedCPLList>
179        </SidecarAsset>
180    </SidecarAssetList>
181</SidecarCompositionMap>"#;
182
183    #[test]
184    fn parses_minimal_scm() {
185        let scm = parse_scm(MINIMAL_SCM).unwrap();
186        assert_eq!(scm.id.to_string(), "a1b2c3d4-e5f6-7890-abcd-ef1234567890");
187        assert_eq!(scm.issue_date, "2024-01-01T00:00:00");
188        assert_eq!(scm.sidecar_assets.len(), 1);
189        assert_eq!(
190            scm.sidecar_assets[0].id.to_string(),
191            "11111111-2222-3333-4444-555555555555"
192        );
193        assert_eq!(scm.sidecar_assets[0].cpl_ids.len(), 1);
194        assert_eq!(
195            scm.sidecar_assets[0].cpl_ids[0].to_string(),
196            "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
197        );
198    }
199
200    #[test]
201    fn parses_multiple_cpl_ids() {
202        let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
203<SidecarCompositionMap xmlns="http://www.smpte-ra.org/ns/2067-9/2018">
204    <Id>urn:uuid:a1b2c3d4-e5f6-7890-abcd-ef1234567890</Id>
205    <IssueDate>2024-01-01T00:00:00</IssueDate>
206    <SidecarAssetList>
207        <SidecarAsset>
208            <Id>urn:uuid:11111111-2222-3333-4444-555555555555</Id>
209            <AssociatedCPLList>
210                <CPLId>urn:uuid:aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee</CPLId>
211                <CPLId>urn:uuid:bbbbbbbb-cccc-dddd-eeee-ffffffffffff</CPLId>
212            </AssociatedCPLList>
213        </SidecarAsset>
214    </SidecarAssetList>
215</SidecarCompositionMap>"#;
216        let scm = parse_scm(xml).unwrap();
217        assert_eq!(scm.sidecar_assets[0].cpl_ids.len(), 2);
218        assert_eq!(
219            scm.sidecar_assets[0].cpl_ids[1].to_string(),
220            "bbbbbbbb-cccc-dddd-eeee-ffffffffffff"
221        );
222    }
223
224    #[test]
225    fn parses_optional_fields() {
226        let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
227<SidecarCompositionMap xmlns="http://www.smpte-ra.org/ns/2067-9/2018">
228    <Id>urn:uuid:a1b2c3d4-e5f6-7890-abcd-ef1234567890</Id>
229    <IssueDate>2024-06-15T12:00:00</IssueDate>
230    <Issuer>Test Facility</Issuer>
231    <Creator>Test Tool 1.0</Creator>
232    <Annotation>IAB sidecar for main feature</Annotation>
233    <SidecarAssetList/>
234</SidecarCompositionMap>"#;
235        let scm = parse_scm(xml).unwrap();
236        assert_eq!(scm.issuer.as_deref(), Some("Test Facility"));
237        assert_eq!(scm.creator.as_deref(), Some("Test Tool 1.0"));
238        assert_eq!(
239            scm.annotation.as_deref(),
240            Some("IAB sidecar for main feature")
241        );
242        assert!(scm.sidecar_assets.is_empty());
243        assert!(!scm.has_signer);
244        assert!(!scm.has_signature);
245    }
246
247    #[test]
248    fn detects_signer_presence() {
249        let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
250<SidecarCompositionMap xmlns="http://www.smpte-ra.org/ns/2067-9/2018">
251    <Id>urn:uuid:a1b2c3d4-e5f6-7890-abcd-ef1234567890</Id>
252    <IssueDate>2024-01-01T00:00:00</IssueDate>
253    <Signer><X509Data/></Signer>
254    <SidecarAssetList/>
255</SidecarCompositionMap>"#;
256        let scm = parse_scm(xml).unwrap();
257        assert!(scm.has_signer);
258        assert!(!scm.has_signature);
259    }
260
261    #[test]
262    fn rejects_malformed_xml() {
263        assert!(parse_scm("<not valid xml").is_err());
264    }
265
266    #[test]
267    fn rejects_invalid_uuid() {
268        let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
269<SidecarCompositionMap xmlns="http://www.smpte-ra.org/ns/2067-9/2018">
270    <Id>not-a-uuid</Id>
271    <IssueDate>2024-01-01T00:00:00</IssueDate>
272</SidecarCompositionMap>"#;
273        assert!(parse_scm(xml).is_err());
274    }
275}