1use 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
137pub 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
461fn 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}