Skip to main content

pulse_pixelstream_types/
otio.rs

1//! Lossless OpenTimelineIO interchange for shared Pulse timelines.
2//!
3//! OTIO supplies the timeline envelope; Pulse-specific camera semantics live in
4//! the `pulse_pixelstream` metadata namespace defined by the generated profile.
5
6use std::{collections::HashMap, error::Error, fmt};
7
8use otio_types::{
9    Clip, ClipMetadataEnvelope, CollectionMetadataEnvelope, MissingReference, PulseClipMetadata,
10    PulseClipMetadataDefaultClipMode, PulseClipMetadataDirection, PulseClipMetadataKind,
11    PulseCollectionMetadata, PulseTimelineMetadata, SerializableCollection, Stack,
12    Timeline as OtioTimeline, TimelineMetadataEnvelope, Track,
13};
14use serde_json::{json, Map, Value};
15
16use crate::{
17    Collection, CollectionId, CollectionMembership, CollectionMembershipId, Shot, ShotDirection,
18    ShotEntry, ShotEntryMode, ShotId, ShotKind, Timeline, TimelineId,
19};
20
21pub const OTIO_PROFILE_VERSION: u64 = 3;
22pub const OTIO_COLLECTION_PROFILE_VERSION: u64 = 1;
23
24#[derive(Clone, Debug, PartialEq)]
25pub struct ImportedTimeline {
26    pub timeline: Timeline,
27    pub shots: Vec<Shot>,
28}
29
30#[derive(Clone, Debug, PartialEq)]
31pub struct ImportedCollectionBundle {
32    pub collections: Vec<Collection>,
33    pub memberships: Vec<CollectionMembership>,
34    pub timelines: Vec<Timeline>,
35    pub shots: Vec<Shot>,
36}
37
38#[derive(Clone, Debug, PartialEq, Eq)]
39pub struct OtioError(String);
40
41impl OtioError {
42    fn new(message: impl Into<String>) -> Self {
43        Self(message.into())
44    }
45}
46
47impl fmt::Display for OtioError {
48    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
49        formatter.write_str(&self.0)
50    }
51}
52
53impl Error for OtioError {}
54
55pub fn export_timeline_otio(
56    timeline: &Timeline,
57    shots: &[Shot],
58) -> Result<OtioTimeline, OtioError> {
59    let shots_by_id = shots
60        .iter()
61        .map(|shot| (shot.id.to_string(), shot))
62        .collect::<HashMap<_, _>>();
63
64    let mut clips = Vec::with_capacity(timeline.entries.len());
65    for entry in &timeline.entries {
66        if entry.direction == ShotEntryMode::Excluded {
67            continue;
68        }
69        let shot = shots_by_id.get(&entry.shot_id).ok_or_else(|| {
70            OtioError::new(format!(
71                "timeline references missing shot {}",
72                entry.shot_id
73            ))
74        })?;
75        let resolved = entry.resolved_shot(shot);
76        for direction in entry.directions() {
77            clips.push(shot_clip(
78                &resolved,
79                &entry.capture_key(*direction),
80                *direction,
81            )?);
82        }
83    }
84
85    Ok(OtioTimeline {
86        global_start_time: None,
87        metadata: TimelineMetadataEnvelope {
88            pulse_pixelstream: PulseTimelineMetadata {
89                format_version: (OTIO_PROFILE_VERSION as f64).try_into().map_err(|error| {
90                    OtioError::new(format!("invalid OTIO profile version: {error}"))
91                })?,
92                library_id: timeline
93                    .effective_library_id()
94                    .try_into()
95                    .map_err(|error| OtioError::new(format!("invalid library id: {error}")))?,
96                timeline_id: timeline
97                    .id
98                    .to_string()
99                    .try_into()
100                    .map_err(|error| OtioError::new(format!("invalid timeline id: {error}")))?,
101                timeline_revision: timeline.revision,
102            },
103        },
104        name: timeline.name.clone(),
105        otio_schema: json!("Timeline.1"),
106        tracks: Stack {
107            children: vec![Track {
108                children: clips,
109                color: None,
110                effects: Vec::new(),
111                enabled: Some(true),
112                kind: json!("Video"),
113                markers: Vec::new(),
114                metadata: Map::new(),
115                name: Some("Pulse shots".to_owned()),
116                otio_schema: json!("Track.1"),
117                source_range: None,
118            }],
119            color: None,
120            effects: Vec::new(),
121            enabled: Some(true),
122            markers: Vec::new(),
123            metadata: Map::new(),
124            name: Some("Pulse timeline".to_owned()),
125            otio_schema: json!("Stack.1"),
126            source_range: None,
127        },
128    })
129}
130
131pub fn export_timeline_otio_json(timeline: &Timeline, shots: &[Shot]) -> Result<String, OtioError> {
132    let timeline = export_timeline_otio(timeline, shots)?;
133    serde_json::to_string_pretty(&timeline)
134        .map_err(|error| OtioError::new(format!("could not serialize OTIO: {error}")))
135}
136
137/// Export one collection subtree using OTIO's standard
138/// `SerializableCollection` hierarchy. Direct timeline memberships are
139/// timelines within their collection; a reusable timeline may therefore appear in
140/// more than one collection without duplicating its server-side identity.
141pub fn export_collection_otio_json(
142    collection_id: &str,
143    collections: &[Collection],
144    memberships: &[CollectionMembership],
145    timelines: &[Timeline],
146    shots: &[Shot],
147) -> Result<String, OtioError> {
148    let document =
149        export_collection_otio(collection_id, collections, memberships, timelines, shots)?;
150    serde_json::to_string_pretty(&document)
151        .map_err(|error| OtioError::new(format!("could not serialize collection OTIO: {error}")))
152}
153
154pub fn export_collection_otio(
155    collection_id: &str,
156    collections: &[Collection],
157    memberships: &[CollectionMembership],
158    timelines: &[Timeline],
159    shots: &[Shot],
160) -> Result<SerializableCollection, OtioError> {
161    let collections_by_id = collections
162        .iter()
163        .map(|collection| (collection.id.to_string(), collection))
164        .collect::<HashMap<_, _>>();
165    let timelines_by_id = timelines
166        .iter()
167        .map(|timeline| (timeline.id.to_string(), timeline))
168        .collect::<HashMap<_, _>>();
169    collection_document(
170        collection_id,
171        &collections_by_id,
172        memberships,
173        &timelines_by_id,
174        shots,
175        &mut std::collections::HashSet::new(),
176    )
177}
178
179fn collection_document(
180    collection_id: &str,
181    collections_by_id: &HashMap<String, &Collection>,
182    memberships: &[CollectionMembership],
183    timelines_by_id: &HashMap<String, &Timeline>,
184    shots: &[Shot],
185    ancestors: &mut std::collections::HashSet<String>,
186) -> Result<SerializableCollection, OtioError> {
187    if !ancestors.insert(collection_id.to_owned()) {
188        return Err(OtioError::new(format!(
189            "collection hierarchy contains a cycle at {collection_id}"
190        )));
191    }
192    let collection = collections_by_id
193        .get(collection_id)
194        .copied()
195        .ok_or_else(|| OtioError::new(format!("collection {collection_id} does not exist")))?;
196
197    let mut children = Vec::new();
198    let mut child_collections = collections_by_id
199        .values()
200        .copied()
201        .filter(|child| child.parent_id == collection_id)
202        .collect::<Vec<_>>();
203    child_collections.sort_by(|left, right| {
204        (
205            left.sort_order,
206            left.name.to_lowercase(),
207            left.id.to_string(),
208        )
209            .cmp(&(
210                right.sort_order,
211                right.name.to_lowercase(),
212                right.id.to_string(),
213            ))
214    });
215    for child in child_collections {
216        children.push(
217            serde_json::to_value(collection_document(
218                child.id.as_ref(),
219                collections_by_id,
220                memberships,
221                timelines_by_id,
222                shots,
223                ancestors,
224            )?)
225            .map_err(|error| OtioError::new(format!("could not serialize child: {error}")))?,
226        );
227    }
228
229    let mut direct_memberships = memberships
230        .iter()
231        .filter(|membership| membership.collection_id == collection_id)
232        .collect::<Vec<_>>();
233    direct_memberships.sort_by(|left, right| {
234        let left_timeline = timelines_by_id.get(&left.timeline_id).copied();
235        let right_timeline = timelines_by_id.get(&right.timeline_id).copied();
236        (
237            left.sort_order,
238            left_timeline
239                .map(|timeline| timeline.name.to_lowercase())
240                .unwrap_or_default(),
241            &left.timeline_id,
242        )
243            .cmp(&(
244                right.sort_order,
245                right_timeline
246                    .map(|timeline| timeline.name.to_lowercase())
247                    .unwrap_or_default(),
248                &right.timeline_id,
249            ))
250    });
251    for membership in direct_memberships {
252        let timeline = timelines_by_id
253            .get(&membership.timeline_id)
254            .copied()
255            .ok_or_else(|| {
256                OtioError::new(format!(
257                    "collection references missing timeline {}",
258                    membership.timeline_id
259                ))
260            })?;
261        if timeline.effective_library_id() != collection.library_id {
262            return Err(OtioError::new(format!(
263                "timeline {} belongs to a different library",
264                membership.timeline_id
265            )));
266        }
267        children.push(
268            serde_json::to_value(export_timeline_otio(timeline, shots)?).map_err(|error| {
269                OtioError::new(format!("could not serialize timeline: {error}"))
270            })?,
271        );
272    }
273
274    ancestors.remove(collection_id);
275    Ok(SerializableCollection {
276        children,
277        metadata: CollectionMetadataEnvelope {
278            pulse_pixelstream: PulseCollectionMetadata {
279                collection_id: collection
280                    .id
281                    .to_string()
282                    .try_into()
283                    .map_err(|error| OtioError::new(format!("invalid collection id: {error}")))?,
284                collection_metadata: collection.metadata.clone(),
285                format_version: json!(OTIO_COLLECTION_PROFILE_VERSION),
286                library_id: collection
287                    .library_id
288                    .as_str()
289                    .try_into()
290                    .map_err(|error| OtioError::new(format!("invalid library id: {error}")))?,
291                sort_order: collection.sort_order,
292            },
293        },
294        name: collection.name.clone(),
295        otio_schema: json!("SerializableCollection.1"),
296    })
297}
298
299pub fn import_collection_otio_json(input: &str) -> Result<ImportedCollectionBundle, OtioError> {
300    let value: Value = serde_json::from_str(input)
301        .map_err(|error| OtioError::new(format!("invalid OTIO JSON: {error}")))?;
302    let root: SerializableCollection = serde_json::from_value(value)
303        .map_err(|error| OtioError::new(format!("invalid Pulse collection OTIO: {error}")))?;
304    let mut imported = ImportedCollectionBundle {
305        collections: Vec::new(),
306        memberships: Vec::new(),
307        timelines: Vec::new(),
308        shots: Vec::new(),
309    };
310    import_collection_node(root, "", None, 1, &mut imported)?;
311    Ok(imported)
312}
313
314fn import_collection_node(
315    document: SerializableCollection,
316    parent_id: &str,
317    expected_library_id: Option<&str>,
318    depth: usize,
319    imported: &mut ImportedCollectionBundle,
320) -> Result<(), OtioError> {
321    if depth > 16 {
322        return Err(OtioError::new("collection hierarchy exceeds 16 levels"));
323    }
324    require_schema(
325        &document.otio_schema,
326        "SerializableCollection.1",
327        "collection",
328    )?;
329    let metadata = document.metadata.pulse_pixelstream;
330    if metadata.format_version != json!(OTIO_COLLECTION_PROFILE_VERSION) {
331        return Err(OtioError::new(format!(
332            "unsupported Pulse OTIO profile version {}",
333            metadata.format_version
334        )));
335    }
336    let collection_id: String = metadata.collection_id.into();
337    let library_id: String = metadata.library_id.into();
338    if expected_library_id.is_some_and(|expected| expected != library_id) {
339        return Err(OtioError::new(
340            "all collections in one OTIO bundle must use the same library",
341        ));
342    }
343    if imported
344        .collections
345        .iter()
346        .any(|collection| collection.id.as_ref() == collection_id)
347    {
348        return Err(OtioError::new(format!(
349            "duplicate collection id in OTIO bundle: {collection_id}"
350        )));
351    }
352    let name = document.name.trim().to_owned();
353    if name.is_empty() || name.chars().count() > 128 {
354        return Err(OtioError::new(
355            "collection names must contain 1 to 128 characters",
356        ));
357    }
358    imported.collections.push(Collection {
359        id: CollectionId::from(collection_id.clone()),
360        library_id: library_id.clone(),
361        parent_id: parent_id.to_owned(),
362        name,
363        sort_order: metadata.sort_order,
364        metadata: metadata.collection_metadata,
365    });
366
367    for (position, child) in document.children.into_iter().enumerate() {
368        match child.get("OTIO_SCHEMA").and_then(Value::as_str) {
369            Some("SerializableCollection.1") => {
370                let child = serde_json::from_value(child).map_err(|error| {
371                    OtioError::new(format!("invalid child collection OTIO: {error}"))
372                })?;
373                import_collection_node(
374                    child,
375                    &collection_id,
376                    Some(&library_id),
377                    depth + 1,
378                    imported,
379                )?;
380            }
381            Some("Timeline.1") => {
382                let mut child = child;
383                migrate_legacy_timeline_json(&mut child);
384                let timeline = serde_json::from_value(child).map_err(|error| {
385                    OtioError::new(format!("invalid child timeline OTIO: {error}"))
386                })?;
387                let child = import_timeline_otio(timeline)?;
388                if child.timeline.effective_library_id() != library_id {
389                    return Err(OtioError::new(
390                        "collection and child timeline use different libraries",
391                    ));
392                }
393                let timeline_id = child.timeline.id.to_string();
394                merge_imported_timeline(imported, child)?;
395                let membership_id = CollectionMembership::stable_id(&collection_id, &timeline_id);
396                if !imported.memberships.iter().any(|membership| {
397                    membership.collection_id == collection_id
398                        && membership.timeline_id == timeline_id
399                }) {
400                    imported.memberships.push(CollectionMembership {
401                        id: CollectionMembershipId::from(membership_id),
402                        collection_id: collection_id.clone(),
403                        timeline_id,
404                        sort_order: u32::try_from(position).unwrap_or(u32::MAX),
405                    });
406                }
407            }
408            Some(schema) => {
409                return Err(OtioError::new(format!(
410                    "unsupported child schema {schema} in collection"
411                )));
412            }
413            None => return Err(OtioError::new("collection child has no OTIO_SCHEMA")),
414        }
415    }
416    Ok(())
417}
418
419fn merge_imported_timeline(
420    imported: &mut ImportedCollectionBundle,
421    child: ImportedTimeline,
422) -> Result<(), OtioError> {
423    for shot in child.shots {
424        if let Some(existing) = imported.shots.iter().find(|row| row.id == shot.id) {
425            if existing != &shot {
426                return Err(OtioError::new(format!(
427                    "conflicting definitions for shot {}",
428                    shot.id
429                )));
430            }
431        } else {
432            imported.shots.push(shot);
433        }
434    }
435    if let Some(existing) = imported
436        .timelines
437        .iter()
438        .find(|row| row.id == child.timeline.id)
439    {
440        if existing != &child.timeline {
441            return Err(OtioError::new(format!(
442                "conflicting definitions for timeline {}",
443                child.timeline.id
444            )));
445        }
446    } else {
447        imported.timelines.push(child.timeline);
448    }
449    Ok(())
450}
451
452pub fn import_timeline_otio_json(input: &str) -> Result<ImportedTimeline, OtioError> {
453    let mut value: Value = serde_json::from_str(input)
454        .map_err(|error| OtioError::new(format!("invalid Pulse OTIO document: {error}")))?;
455    migrate_legacy_timeline_json(&mut value);
456    let timeline: OtioTimeline = serde_json::from_value(value)
457        .map_err(|error| OtioError::new(format!("invalid Pulse OTIO document: {error}")))?;
458    import_timeline_otio(timeline)
459}
460
461/// Normalize the retired Pixelstream profile spelling before the generated
462/// OTIO schema validates it. Compatibility is intentionally confined here;
463/// generated types and active application code expose only Timeline/Clip.
464fn migrate_legacy_timeline_json(value: &mut Value) {
465    let Some(root) = value.as_object_mut() else {
466        return;
467    };
468    if let Some(pulse) = root
469        .get_mut("metadata")
470        .and_then(Value::as_object_mut)
471        .and_then(|metadata| metadata.get_mut("pulse_pixelstream"))
472        .and_then(Value::as_object_mut)
473    {
474        move_json_key(pulse, "shot_list_id", "timeline_id");
475        move_json_key(pulse, "shot_list_version", "timeline_revision");
476        move_json_key(pulse, "timeline_version", "timeline_revision");
477    }
478    let Some(tracks) = root
479        .get_mut("tracks")
480        .and_then(Value::as_object_mut)
481        .and_then(|stack| stack.get_mut("children"))
482        .and_then(Value::as_array_mut)
483    else {
484        return;
485    };
486    for track in tracks {
487        let Some(clips) = track
488            .as_object_mut()
489            .and_then(|track| track.get_mut("children"))
490            .and_then(Value::as_array_mut)
491        else {
492            continue;
493        };
494        for clip in clips {
495            if let Some(pulse) = clip
496                .as_object_mut()
497                .and_then(|clip| clip.get_mut("metadata"))
498                .and_then(Value::as_object_mut)
499                .and_then(|metadata| metadata.get_mut("pulse_pixelstream"))
500                .and_then(Value::as_object_mut)
501            {
502                move_json_key(pulse, "cue_id", "shot_entry_id");
503                move_json_key(pulse, "clip_id", "shot_entry_id");
504                move_json_key(pulse, "default_list_mode", "default_clip_mode");
505            }
506        }
507    }
508}
509
510fn move_json_key(object: &mut Map<String, Value>, old: &str, new: &str) {
511    if !object.contains_key(new) {
512        if let Some(value) = object.remove(old) {
513            object.insert(new.to_owned(), value);
514        }
515    }
516}
517
518pub fn import_timeline_otio(timeline: OtioTimeline) -> Result<ImportedTimeline, OtioError> {
519    require_schema(&timeline.otio_schema, "Timeline.1", "timeline")?;
520    require_schema(&timeline.tracks.otio_schema, "Stack.1", "timeline stack")?;
521    let metadata = timeline.metadata.pulse_pixelstream;
522    let format_version = *metadata.format_version;
523    if !matches!(format_version as u64, 1 | 2 | OTIO_PROFILE_VERSION) {
524        return Err(OtioError::new(format!(
525            "unsupported Pulse OTIO profile version {}",
526            format_version
527        )));
528    }
529
530    let library_id: String = metadata.library_id.into();
531    let timeline_id: String = metadata.timeline_id.into();
532    let mut shots = Vec::<Shot>::new();
533    let mut entries = Vec::new();
534    let mut entry_position = 0_usize;
535
536    for track in timeline.tracks.children {
537        require_schema(&track.otio_schema, "Track.1", "track")?;
538        if track.kind != json!("Video") {
539            continue;
540        }
541        for clip in track.children {
542            require_schema(&clip.otio_schema, "Clip.2", "clip")?;
543            let pulse = clip.metadata.pulse_pixelstream;
544            let shot_id: String = pulse.shot_id.into();
545            let directions: &[ShotDirection] = match pulse.direction {
546                PulseClipMetadataDirection::Forward => &[ShotDirection::Forward],
547                PulseClipMetadataDirection::Reverse => &[ShotDirection::Reverse],
548                PulseClipMetadataDirection::Both => {
549                    &[ShotDirection::Forward, ShotDirection::Reverse]
550                }
551            };
552            let kind = match pulse.kind {
553                PulseClipMetadataKind::Moving => ShotKind::Moving,
554                PulseClipMetadataKind::Static => ShotKind::Static,
555            };
556            let default_entry_mode = match pulse.default_clip_mode {
557                PulseClipMetadataDefaultClipMode::Forward => ShotEntryMode::Forward,
558                PulseClipMetadataDefaultClipMode::Reverse => ShotEntryMode::Reverse,
559                PulseClipMetadataDefaultClipMode::Both => ShotEntryMode::Both,
560                PulseClipMetadataDefaultClipMode::Excluded => ShotEntryMode::Excluded,
561            };
562            let translation_speed_cm_s =
563                valid_f32(pulse.translation_speed_cm_s, "translation_speed_cm_s")?;
564            let rotation_speed_deg_s =
565                valid_f32(pulse.rotation_speed_deg_s, "rotation_speed_deg_s")?;
566            let target_name: String = pulse.target_name.into();
567            let candidate = Shot {
568                id: ShotId::from(shot_id.clone()),
569                library_id: library_id.clone(),
570                streamer_id: String::new(),
571                name: clip.name,
572                kind,
573                target_name,
574                translation_speed_cm_s,
575                rotation_speed_deg_s,
576                hold_duration_ms: pulse.hold_duration_ms,
577                travel_duration_ms: pulse
578                    .travel_duration_ms
579                    .unwrap_or(crate::DEFAULT_SHOT_TRAVEL_DURATION_MS),
580                default_entry_mode,
581                shot_index: pulse.shot_index,
582            };
583            if let Some(existing) = shots.iter().find(|shot| shot.id == candidate.id) {
584                if existing.kind != candidate.kind || existing.target_name != candidate.target_name
585                {
586                    return Err(OtioError::new(format!(
587                        "conflicting source definition for repeated shot id {shot_id}"
588                    )));
589                }
590            } else {
591                shots.push(candidate.clone());
592            }
593            let imported_entry_id = pulse
594                .shot_entry_id
595                .map(Into::<String>::into)
596                .unwrap_or_else(|| format!("{timeline_id}:entry:{entry_position}"));
597            for direction in directions {
598                let entry_id = if directions.len() > 1 {
599                    format!(
600                        "{imported_entry_id}:{}",
601                        direction.label().to_ascii_lowercase()
602                    )
603                } else {
604                    imported_entry_id.clone()
605                };
606                entries.push(ShotEntry::from_shot_with_id(
607                    &candidate, *direction, entry_id,
608                ));
609            }
610            entry_position = entry_position.saturating_add(1);
611        }
612    }
613
614    let mut timeline = Timeline {
615        id: TimelineId::from(timeline_id),
616        library_id,
617        streamer_id: String::new(),
618        name: timeline.name,
619        revision: metadata.timeline_revision,
620        entries,
621        sort_order: 0,
622    };
623    timeline.normalize_entries();
624    Ok(ImportedTimeline { timeline, shots })
625}
626
627fn shot_clip(shot: &Shot, entry_id: &str, direction: ShotDirection) -> Result<Clip, OtioError> {
628    let direction = match direction {
629        ShotDirection::Forward => PulseClipMetadataDirection::Forward,
630        ShotDirection::Reverse => PulseClipMetadataDirection::Reverse,
631    };
632    let kind = match shot.kind {
633        ShotKind::Moving => PulseClipMetadataKind::Moving,
634        ShotKind::Static => PulseClipMetadataKind::Static,
635    };
636    let default_clip_mode = match shot.default_entry_mode {
637        ShotEntryMode::Forward => PulseClipMetadataDefaultClipMode::Forward,
638        ShotEntryMode::Reverse => PulseClipMetadataDefaultClipMode::Reverse,
639        ShotEntryMode::Both => PulseClipMetadataDefaultClipMode::Both,
640        ShotEntryMode::Excluded => PulseClipMetadataDefaultClipMode::Excluded,
641    };
642    let mut media_references = HashMap::new();
643    media_references.insert(
644        "DEFAULT_MEDIA".to_owned(),
645        MissingReference {
646            available_image_bounds: None,
647            available_range: None,
648            metadata: Map::new(),
649            name: None,
650            otio_schema: json!("MissingReference.1"),
651        },
652    );
653
654    Ok(Clip {
655        active_media_reference_key: Some("DEFAULT_MEDIA".to_owned()),
656        color: None,
657        effects: Vec::new(),
658        enabled: Some(true),
659        markers: Vec::new(),
660        media_references,
661        metadata: ClipMetadataEnvelope {
662            pulse_pixelstream: PulseClipMetadata {
663                shot_entry_id: Some(
664                    entry_id.try_into().map_err(|error| {
665                        OtioError::new(format!("invalid shot entry id: {error}"))
666                    })?,
667                ),
668                default_clip_mode,
669                direction,
670                hold_duration_ms: shot.hold_duration_ms,
671                travel_duration_ms: Some(shot.travel_duration_ms),
672                kind,
673                rotation_speed_deg_s: f64::from(shot.rotation_speed_deg_s),
674                shot_id: shot
675                    .id
676                    .to_string()
677                    .try_into()
678                    .map_err(|error| OtioError::new(format!("invalid shot id: {error}")))?,
679                shot_index: shot.shot_index,
680                target_name: shot
681                    .target_name
682                    .as_str()
683                    .try_into()
684                    .map_err(|error| OtioError::new(format!("invalid target name: {error}")))?,
685                translation_speed_cm_s: f64::from(shot.translation_speed_cm_s),
686            },
687        },
688        name: shot.name.clone(),
689        otio_schema: json!("Clip.2"),
690        source_range: None,
691    })
692}
693
694fn require_schema(actual: &Value, expected: &str, location: &str) -> Result<(), OtioError> {
695    if actual == &json!(expected) {
696        Ok(())
697    } else {
698        Err(OtioError::new(format!(
699            "unsupported {location} schema {actual}; expected {expected}"
700        )))
701    }
702}
703
704fn valid_f32(value: f64, field: &str) -> Result<f32, OtioError> {
705    if value.is_finite() && value >= 0.0 && value <= f64::from(f32::MAX) {
706        Ok(value as f32)
707    } else {
708        Err(OtioError::new(format!("invalid {field}: {value}")))
709    }
710}
711
712#[cfg(test)]
713mod tests {
714    use super::*;
715    use crate::{ShotDirection, DEFAULT_SHOT_LIBRARY_ID};
716
717    fn fixture() -> (Timeline, Vec<Shot>) {
718        let shot = Shot {
719            id: ShotId::from("shared:shot:moving:Floor Dolly"),
720            library_id: DEFAULT_SHOT_LIBRARY_ID.to_owned(),
721            streamer_id: String::new(),
722            name: "Floor Dolly".to_owned(),
723            kind: ShotKind::Moving,
724            target_name: "Floor Dolly".to_owned(),
725            translation_speed_cm_s: 10.0,
726            rotation_speed_deg_s: 2.0,
727            hold_duration_ms: 5_000,
728            travel_duration_ms: 42_000,
729            default_entry_mode: ShotEntryMode::Forward,
730            shot_index: 7,
731        };
732        let timeline = Timeline {
733            id: TimelineId::from("shared:timeline:hero"),
734            library_id: DEFAULT_SHOT_LIBRARY_ID.to_owned(),
735            streamer_id: String::new(),
736            name: "Hero".to_owned(),
737            revision: 3,
738            entries: vec![
739                ShotEntry::from_shot_with_id(&shot, ShotDirection::Forward, "entry-forward"),
740                ShotEntry::from_shot_with_id(&shot, ShotDirection::Reverse, "entry-reverse"),
741            ],
742            sort_order: 0,
743        };
744        (timeline, vec![shot])
745    }
746
747    #[test]
748    fn pulse_otio_round_trip_preserves_camera_semantics() {
749        let (timeline, shots) = fixture();
750        let json = export_timeline_otio_json(&timeline, &shots).unwrap();
751        assert!(json.contains(r#""OTIO_SCHEMA": "Timeline.1""#));
752        assert!(json.contains(r#""timeline_id""#));
753        assert!(json.contains(r#""shot_entry_id""#));
754        assert!(json.contains(r#""default_clip_mode""#));
755        assert!(!json.contains(r#""shot_list_id""#));
756        assert!(!json.contains(r#""cue_id""#));
757        assert!(!json.contains(r#""default_list_mode""#));
758        let imported = import_timeline_otio_json(&json).unwrap();
759
760        assert_eq!(imported.timeline.id, timeline.id);
761        assert_eq!(imported.timeline.library_id, DEFAULT_SHOT_LIBRARY_ID);
762        assert_eq!(imported.timeline.revision, 3);
763        assert_eq!(imported.timeline.entries, timeline.entries);
764        assert_eq!(imported.shots, shots);
765    }
766
767    #[test]
768    fn retired_profile_keys_import_into_canonical_timeline_and_clips() {
769        let (timeline, shots) = fixture();
770        let legacy_json = export_timeline_otio_json(&timeline, &shots)
771            .unwrap()
772            .replace("\"timeline_id\"", "\"shot_list_id\"")
773            .replace("\"timeline_revision\"", "\"shot_list_version\"")
774            .replace("\"shot_entry_id\"", "\"cue_id\"")
775            .replace("\"default_clip_mode\"", "\"default_list_mode\"");
776
777        let imported = import_timeline_otio_json(&legacy_json).unwrap();
778        assert_eq!(imported.timeline, timeline);
779        assert_eq!(imported.shots, shots);
780
781        let canonical = export_timeline_otio_json(&imported.timeline, &imported.shots).unwrap();
782        assert!(canonical.contains(r#""timeline_id""#));
783        assert!(canonical.contains(r#""shot_entry_id""#));
784        assert!(!canonical.contains(r#""shot_list_id""#));
785        assert!(!canonical.contains(r#""cue_id""#));
786    }
787
788    #[test]
789    fn collection_otio_round_trip_preserves_hierarchy_and_reusable_membership() {
790        let (timeline, shots) = fixture();
791        let collections = vec![
792            Collection {
793                id: CollectionId::from("autumn-campaign"),
794                library_id: DEFAULT_SHOT_LIBRARY_ID.to_owned(),
795                parent_id: String::new(),
796                name: "Autumn campaign".to_owned(),
797                sort_order: 2,
798                metadata: HashMap::from([("client.code".to_owned(), "ACME".to_owned())]),
799            },
800            Collection {
801                id: CollectionId::from("launch-film"),
802                library_id: DEFAULT_SHOT_LIBRARY_ID.to_owned(),
803                parent_id: "autumn-campaign".to_owned(),
804                name: "Launch film".to_owned(),
805                sort_order: 3,
806                metadata: HashMap::new(),
807            },
808        ];
809        let memberships = vec![
810            CollectionMembership {
811                id: CollectionMembershipId::from(CollectionMembership::stable_id(
812                    "autumn-campaign",
813                    timeline.id.as_ref(),
814                )),
815                collection_id: "autumn-campaign".to_owned(),
816                timeline_id: timeline.id.to_string(),
817                sort_order: 0,
818            },
819            CollectionMembership {
820                id: CollectionMembershipId::from(CollectionMembership::stable_id(
821                    "launch-film",
822                    timeline.id.as_ref(),
823                )),
824                collection_id: "launch-film".to_owned(),
825                timeline_id: timeline.id.to_string(),
826                sort_order: 0,
827            },
828        ];
829
830        let json = export_collection_otio_json(
831            "autumn-campaign",
832            &collections,
833            &memberships,
834            std::slice::from_ref(&timeline),
835            &shots,
836        )
837        .unwrap();
838        assert!(json.contains(r#""OTIO_SCHEMA": "SerializableCollection.1""#));
839        let imported = import_collection_otio_json(&json).unwrap();
840
841        assert_eq!(imported.collections.len(), 2);
842        assert_eq!(imported.memberships.len(), 2);
843        assert_eq!(imported.timelines, vec![timeline]);
844        assert_eq!(imported.shots, shots);
845        assert_eq!(
846            imported
847                .collections
848                .iter()
849                .find(|collection| collection.id.as_ref() == "launch-film")
850                .unwrap()
851                .parent_id,
852            "autumn-campaign"
853        );
854    }
855
856    #[test]
857    fn generic_otio_without_pulse_metadata_is_rejected() {
858        let generic = r#"{
859            \"OTIO_SCHEMA\":\"Timeline.1\",
860            \"metadata\":{},
861            \"name\":\"Generic\",
862            \"tracks\":{\"OTIO_SCHEMA\":\"Stack.1\",\"children\":[]}
863        }"#;
864        assert!(import_timeline_otio_json(generic).is_err());
865    }
866
867    #[test]
868    fn direction_model_still_maps_to_capture_directions() {
869        assert_eq!(
870            ShotEntryMode::Both.directions(),
871            &[ShotDirection::Forward, ShotDirection::Reverse]
872        );
873    }
874}