use std::{collections::HashMap, error::Error, fmt};
use otio_types::{
Clip, ClipMetadataEnvelope, CollectionMetadataEnvelope, MissingReference, PulseClipMetadata,
PulseClipMetadataDefaultClipMode, PulseClipMetadataDirection, PulseClipMetadataKind,
PulseCollectionMetadata, PulseTimelineMetadata, SerializableCollection, Stack,
Timeline as OtioTimeline, TimelineMetadataEnvelope, Track,
};
use serde_json::{json, Map, Value};
use crate::{
Collection, CollectionId, CollectionMembership, CollectionMembershipId, Shot, ShotDirection,
ShotEntry, ShotEntryMode, ShotId, ShotKind, Timeline, TimelineId,
};
pub const OTIO_PROFILE_VERSION: u64 = 3;
pub const OTIO_COLLECTION_PROFILE_VERSION: u64 = 1;
#[derive(Clone, Debug, PartialEq)]
pub struct ImportedTimeline {
pub timeline: Timeline,
pub shots: Vec<Shot>,
}
#[derive(Clone, Debug, PartialEq)]
pub struct ImportedCollectionBundle {
pub collections: Vec<Collection>,
pub memberships: Vec<CollectionMembership>,
pub timelines: Vec<Timeline>,
pub shots: Vec<Shot>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct OtioError(String);
impl OtioError {
fn new(message: impl Into<String>) -> Self {
Self(message.into())
}
}
impl fmt::Display for OtioError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(&self.0)
}
}
impl Error for OtioError {}
pub fn export_timeline_otio(
timeline: &Timeline,
shots: &[Shot],
) -> Result<OtioTimeline, OtioError> {
let shots_by_id = shots
.iter()
.map(|shot| (shot.id.to_string(), shot))
.collect::<HashMap<_, _>>();
let mut clips = Vec::with_capacity(timeline.entries.len());
for entry in &timeline.entries {
if entry.direction == ShotEntryMode::Excluded {
continue;
}
let shot = shots_by_id.get(&entry.shot_id).ok_or_else(|| {
OtioError::new(format!(
"timeline references missing shot {}",
entry.shot_id
))
})?;
let resolved = entry.resolved_shot(shot);
for direction in entry.directions() {
clips.push(shot_clip(
&resolved,
&entry.capture_key(*direction),
*direction,
)?);
}
}
Ok(OtioTimeline {
global_start_time: None,
metadata: TimelineMetadataEnvelope {
pulse_pixelstream: PulseTimelineMetadata {
format_version: (OTIO_PROFILE_VERSION as f64).try_into().map_err(|error| {
OtioError::new(format!("invalid OTIO profile version: {error}"))
})?,
library_id: timeline
.effective_library_id()
.try_into()
.map_err(|error| OtioError::new(format!("invalid library id: {error}")))?,
timeline_id: timeline
.id
.to_string()
.try_into()
.map_err(|error| OtioError::new(format!("invalid timeline id: {error}")))?,
timeline_revision: timeline.revision,
},
},
name: timeline.name.clone(),
otio_schema: json!("Timeline.1"),
tracks: Stack {
children: vec![Track {
children: clips,
color: None,
effects: Vec::new(),
enabled: Some(true),
kind: json!("Video"),
markers: Vec::new(),
metadata: Map::new(),
name: Some("Pulse shots".to_owned()),
otio_schema: json!("Track.1"),
source_range: None,
}],
color: None,
effects: Vec::new(),
enabled: Some(true),
markers: Vec::new(),
metadata: Map::new(),
name: Some("Pulse timeline".to_owned()),
otio_schema: json!("Stack.1"),
source_range: None,
},
})
}
pub fn export_timeline_otio_json(timeline: &Timeline, shots: &[Shot]) -> Result<String, OtioError> {
let timeline = export_timeline_otio(timeline, shots)?;
serde_json::to_string_pretty(&timeline)
.map_err(|error| OtioError::new(format!("could not serialize OTIO: {error}")))
}
pub fn export_collection_otio_json(
collection_id: &str,
collections: &[Collection],
memberships: &[CollectionMembership],
timelines: &[Timeline],
shots: &[Shot],
) -> Result<String, OtioError> {
let document =
export_collection_otio(collection_id, collections, memberships, timelines, shots)?;
serde_json::to_string_pretty(&document)
.map_err(|error| OtioError::new(format!("could not serialize collection OTIO: {error}")))
}
pub fn export_collection_otio(
collection_id: &str,
collections: &[Collection],
memberships: &[CollectionMembership],
timelines: &[Timeline],
shots: &[Shot],
) -> Result<SerializableCollection, OtioError> {
let collections_by_id = collections
.iter()
.map(|collection| (collection.id.to_string(), collection))
.collect::<HashMap<_, _>>();
let timelines_by_id = timelines
.iter()
.map(|timeline| (timeline.id.to_string(), timeline))
.collect::<HashMap<_, _>>();
collection_document(
collection_id,
&collections_by_id,
memberships,
&timelines_by_id,
shots,
&mut std::collections::HashSet::new(),
)
}
fn collection_document(
collection_id: &str,
collections_by_id: &HashMap<String, &Collection>,
memberships: &[CollectionMembership],
timelines_by_id: &HashMap<String, &Timeline>,
shots: &[Shot],
ancestors: &mut std::collections::HashSet<String>,
) -> Result<SerializableCollection, OtioError> {
if !ancestors.insert(collection_id.to_owned()) {
return Err(OtioError::new(format!(
"collection hierarchy contains a cycle at {collection_id}"
)));
}
let collection = collections_by_id
.get(collection_id)
.copied()
.ok_or_else(|| OtioError::new(format!("collection {collection_id} does not exist")))?;
let mut children = Vec::new();
let mut child_collections = collections_by_id
.values()
.copied()
.filter(|child| child.parent_id == collection_id)
.collect::<Vec<_>>();
child_collections.sort_by(|left, right| {
(
left.sort_order,
left.name.to_lowercase(),
left.id.to_string(),
)
.cmp(&(
right.sort_order,
right.name.to_lowercase(),
right.id.to_string(),
))
});
for child in child_collections {
children.push(
serde_json::to_value(collection_document(
child.id.as_ref(),
collections_by_id,
memberships,
timelines_by_id,
shots,
ancestors,
)?)
.map_err(|error| OtioError::new(format!("could not serialize child: {error}")))?,
);
}
let mut direct_memberships = memberships
.iter()
.filter(|membership| membership.collection_id == collection_id)
.collect::<Vec<_>>();
direct_memberships.sort_by(|left, right| {
let left_timeline = timelines_by_id.get(&left.timeline_id).copied();
let right_timeline = timelines_by_id.get(&right.timeline_id).copied();
(
left.sort_order,
left_timeline
.map(|timeline| timeline.name.to_lowercase())
.unwrap_or_default(),
&left.timeline_id,
)
.cmp(&(
right.sort_order,
right_timeline
.map(|timeline| timeline.name.to_lowercase())
.unwrap_or_default(),
&right.timeline_id,
))
});
for membership in direct_memberships {
let timeline = timelines_by_id
.get(&membership.timeline_id)
.copied()
.ok_or_else(|| {
OtioError::new(format!(
"collection references missing timeline {}",
membership.timeline_id
))
})?;
if timeline.effective_library_id() != collection.library_id {
return Err(OtioError::new(format!(
"timeline {} belongs to a different library",
membership.timeline_id
)));
}
children.push(
serde_json::to_value(export_timeline_otio(timeline, shots)?).map_err(|error| {
OtioError::new(format!("could not serialize timeline: {error}"))
})?,
);
}
ancestors.remove(collection_id);
Ok(SerializableCollection {
children,
metadata: CollectionMetadataEnvelope {
pulse_pixelstream: PulseCollectionMetadata {
collection_id: collection
.id
.to_string()
.try_into()
.map_err(|error| OtioError::new(format!("invalid collection id: {error}")))?,
collection_metadata: collection.metadata.clone(),
format_version: json!(OTIO_COLLECTION_PROFILE_VERSION),
library_id: collection
.library_id
.as_str()
.try_into()
.map_err(|error| OtioError::new(format!("invalid library id: {error}")))?,
sort_order: collection.sort_order,
},
},
name: collection.name.clone(),
otio_schema: json!("SerializableCollection.1"),
})
}
pub fn import_collection_otio_json(input: &str) -> Result<ImportedCollectionBundle, OtioError> {
let value: Value = serde_json::from_str(input)
.map_err(|error| OtioError::new(format!("invalid OTIO JSON: {error}")))?;
let root: SerializableCollection = serde_json::from_value(value)
.map_err(|error| OtioError::new(format!("invalid Pulse collection OTIO: {error}")))?;
let mut imported = ImportedCollectionBundle {
collections: Vec::new(),
memberships: Vec::new(),
timelines: Vec::new(),
shots: Vec::new(),
};
import_collection_node(root, "", None, 1, &mut imported)?;
Ok(imported)
}
fn import_collection_node(
document: SerializableCollection,
parent_id: &str,
expected_library_id: Option<&str>,
depth: usize,
imported: &mut ImportedCollectionBundle,
) -> Result<(), OtioError> {
if depth > 16 {
return Err(OtioError::new("collection hierarchy exceeds 16 levels"));
}
require_schema(
&document.otio_schema,
"SerializableCollection.1",
"collection",
)?;
let metadata = document.metadata.pulse_pixelstream;
if metadata.format_version != json!(OTIO_COLLECTION_PROFILE_VERSION) {
return Err(OtioError::new(format!(
"unsupported Pulse OTIO profile version {}",
metadata.format_version
)));
}
let collection_id: String = metadata.collection_id.into();
let library_id: String = metadata.library_id.into();
if expected_library_id.is_some_and(|expected| expected != library_id) {
return Err(OtioError::new(
"all collections in one OTIO bundle must use the same library",
));
}
if imported
.collections
.iter()
.any(|collection| collection.id.as_ref() == collection_id)
{
return Err(OtioError::new(format!(
"duplicate collection id in OTIO bundle: {collection_id}"
)));
}
let name = document.name.trim().to_owned();
if name.is_empty() || name.chars().count() > 128 {
return Err(OtioError::new(
"collection names must contain 1 to 128 characters",
));
}
imported.collections.push(Collection {
id: CollectionId::from(collection_id.clone()),
library_id: library_id.clone(),
parent_id: parent_id.to_owned(),
name,
sort_order: metadata.sort_order,
metadata: metadata.collection_metadata,
});
for (position, child) in document.children.into_iter().enumerate() {
match child.get("OTIO_SCHEMA").and_then(Value::as_str) {
Some("SerializableCollection.1") => {
let child = serde_json::from_value(child).map_err(|error| {
OtioError::new(format!("invalid child collection OTIO: {error}"))
})?;
import_collection_node(
child,
&collection_id,
Some(&library_id),
depth + 1,
imported,
)?;
}
Some("Timeline.1") => {
let mut child = child;
migrate_legacy_timeline_json(&mut child);
let timeline = serde_json::from_value(child).map_err(|error| {
OtioError::new(format!("invalid child timeline OTIO: {error}"))
})?;
let child = import_timeline_otio(timeline)?;
if child.timeline.effective_library_id() != library_id {
return Err(OtioError::new(
"collection and child timeline use different libraries",
));
}
let timeline_id = child.timeline.id.to_string();
merge_imported_timeline(imported, child)?;
let membership_id = CollectionMembership::stable_id(&collection_id, &timeline_id);
if !imported.memberships.iter().any(|membership| {
membership.collection_id == collection_id
&& membership.timeline_id == timeline_id
}) {
imported.memberships.push(CollectionMembership {
id: CollectionMembershipId::from(membership_id),
collection_id: collection_id.clone(),
timeline_id,
sort_order: u32::try_from(position).unwrap_or(u32::MAX),
});
}
}
Some(schema) => {
return Err(OtioError::new(format!(
"unsupported child schema {schema} in collection"
)));
}
None => return Err(OtioError::new("collection child has no OTIO_SCHEMA")),
}
}
Ok(())
}
fn merge_imported_timeline(
imported: &mut ImportedCollectionBundle,
child: ImportedTimeline,
) -> Result<(), OtioError> {
for shot in child.shots {
if let Some(existing) = imported.shots.iter().find(|row| row.id == shot.id) {
if existing != &shot {
return Err(OtioError::new(format!(
"conflicting definitions for shot {}",
shot.id
)));
}
} else {
imported.shots.push(shot);
}
}
if let Some(existing) = imported
.timelines
.iter()
.find(|row| row.id == child.timeline.id)
{
if existing != &child.timeline {
return Err(OtioError::new(format!(
"conflicting definitions for timeline {}",
child.timeline.id
)));
}
} else {
imported.timelines.push(child.timeline);
}
Ok(())
}
pub fn import_timeline_otio_json(input: &str) -> Result<ImportedTimeline, OtioError> {
let mut value: Value = serde_json::from_str(input)
.map_err(|error| OtioError::new(format!("invalid Pulse OTIO document: {error}")))?;
migrate_legacy_timeline_json(&mut value);
let timeline: OtioTimeline = serde_json::from_value(value)
.map_err(|error| OtioError::new(format!("invalid Pulse OTIO document: {error}")))?;
import_timeline_otio(timeline)
}
fn migrate_legacy_timeline_json(value: &mut Value) {
let Some(root) = value.as_object_mut() else {
return;
};
if let Some(pulse) = root
.get_mut("metadata")
.and_then(Value::as_object_mut)
.and_then(|metadata| metadata.get_mut("pulse_pixelstream"))
.and_then(Value::as_object_mut)
{
move_json_key(pulse, "shot_list_id", "timeline_id");
move_json_key(pulse, "shot_list_version", "timeline_revision");
move_json_key(pulse, "timeline_version", "timeline_revision");
}
let Some(tracks) = root
.get_mut("tracks")
.and_then(Value::as_object_mut)
.and_then(|stack| stack.get_mut("children"))
.and_then(Value::as_array_mut)
else {
return;
};
for track in tracks {
let Some(clips) = track
.as_object_mut()
.and_then(|track| track.get_mut("children"))
.and_then(Value::as_array_mut)
else {
continue;
};
for clip in clips {
if let Some(pulse) = clip
.as_object_mut()
.and_then(|clip| clip.get_mut("metadata"))
.and_then(Value::as_object_mut)
.and_then(|metadata| metadata.get_mut("pulse_pixelstream"))
.and_then(Value::as_object_mut)
{
move_json_key(pulse, "cue_id", "shot_entry_id");
move_json_key(pulse, "clip_id", "shot_entry_id");
move_json_key(pulse, "default_list_mode", "default_clip_mode");
}
}
}
}
fn move_json_key(object: &mut Map<String, Value>, old: &str, new: &str) {
if !object.contains_key(new) {
if let Some(value) = object.remove(old) {
object.insert(new.to_owned(), value);
}
}
}
pub fn import_timeline_otio(timeline: OtioTimeline) -> Result<ImportedTimeline, OtioError> {
require_schema(&timeline.otio_schema, "Timeline.1", "timeline")?;
require_schema(&timeline.tracks.otio_schema, "Stack.1", "timeline stack")?;
let metadata = timeline.metadata.pulse_pixelstream;
let format_version = *metadata.format_version;
if !matches!(format_version as u64, 1 | 2 | OTIO_PROFILE_VERSION) {
return Err(OtioError::new(format!(
"unsupported Pulse OTIO profile version {}",
format_version
)));
}
let library_id: String = metadata.library_id.into();
let timeline_id: String = metadata.timeline_id.into();
let mut shots = Vec::<Shot>::new();
let mut entries = Vec::new();
let mut entry_position = 0_usize;
for track in timeline.tracks.children {
require_schema(&track.otio_schema, "Track.1", "track")?;
if track.kind != json!("Video") {
continue;
}
for clip in track.children {
require_schema(&clip.otio_schema, "Clip.2", "clip")?;
let pulse = clip.metadata.pulse_pixelstream;
let shot_id: String = pulse.shot_id.into();
let directions: &[ShotDirection] = match pulse.direction {
PulseClipMetadataDirection::Forward => &[ShotDirection::Forward],
PulseClipMetadataDirection::Reverse => &[ShotDirection::Reverse],
PulseClipMetadataDirection::Both => {
&[ShotDirection::Forward, ShotDirection::Reverse]
}
};
let kind = match pulse.kind {
PulseClipMetadataKind::Moving => ShotKind::Moving,
PulseClipMetadataKind::Static => ShotKind::Static,
};
let default_entry_mode = match pulse.default_clip_mode {
PulseClipMetadataDefaultClipMode::Forward => ShotEntryMode::Forward,
PulseClipMetadataDefaultClipMode::Reverse => ShotEntryMode::Reverse,
PulseClipMetadataDefaultClipMode::Both => ShotEntryMode::Both,
PulseClipMetadataDefaultClipMode::Excluded => ShotEntryMode::Excluded,
};
let translation_speed_cm_s =
valid_f32(pulse.translation_speed_cm_s, "translation_speed_cm_s")?;
let rotation_speed_deg_s =
valid_f32(pulse.rotation_speed_deg_s, "rotation_speed_deg_s")?;
let target_name: String = pulse.target_name.into();
let candidate = Shot {
id: ShotId::from(shot_id.clone()),
library_id: library_id.clone(),
streamer_id: String::new(),
name: clip.name,
kind,
target_name,
translation_speed_cm_s,
rotation_speed_deg_s,
hold_duration_ms: pulse.hold_duration_ms,
travel_duration_ms: pulse
.travel_duration_ms
.unwrap_or(crate::DEFAULT_SHOT_TRAVEL_DURATION_MS),
default_entry_mode,
shot_index: pulse.shot_index,
};
if let Some(existing) = shots.iter().find(|shot| shot.id == candidate.id) {
if existing.kind != candidate.kind || existing.target_name != candidate.target_name
{
return Err(OtioError::new(format!(
"conflicting source definition for repeated shot id {shot_id}"
)));
}
} else {
shots.push(candidate.clone());
}
let imported_entry_id = pulse
.shot_entry_id
.map(Into::<String>::into)
.unwrap_or_else(|| format!("{timeline_id}:entry:{entry_position}"));
for direction in directions {
let entry_id = if directions.len() > 1 {
format!(
"{imported_entry_id}:{}",
direction.label().to_ascii_lowercase()
)
} else {
imported_entry_id.clone()
};
entries.push(ShotEntry::from_shot_with_id(
&candidate, *direction, entry_id,
));
}
entry_position = entry_position.saturating_add(1);
}
}
let mut timeline = Timeline {
id: TimelineId::from(timeline_id),
library_id,
streamer_id: String::new(),
name: timeline.name,
revision: metadata.timeline_revision,
entries,
sort_order: 0,
};
timeline.normalize_entries();
Ok(ImportedTimeline { timeline, shots })
}
fn shot_clip(shot: &Shot, entry_id: &str, direction: ShotDirection) -> Result<Clip, OtioError> {
let direction = match direction {
ShotDirection::Forward => PulseClipMetadataDirection::Forward,
ShotDirection::Reverse => PulseClipMetadataDirection::Reverse,
};
let kind = match shot.kind {
ShotKind::Moving => PulseClipMetadataKind::Moving,
ShotKind::Static => PulseClipMetadataKind::Static,
};
let default_clip_mode = match shot.default_entry_mode {
ShotEntryMode::Forward => PulseClipMetadataDefaultClipMode::Forward,
ShotEntryMode::Reverse => PulseClipMetadataDefaultClipMode::Reverse,
ShotEntryMode::Both => PulseClipMetadataDefaultClipMode::Both,
ShotEntryMode::Excluded => PulseClipMetadataDefaultClipMode::Excluded,
};
let mut media_references = HashMap::new();
media_references.insert(
"DEFAULT_MEDIA".to_owned(),
MissingReference {
available_image_bounds: None,
available_range: None,
metadata: Map::new(),
name: None,
otio_schema: json!("MissingReference.1"),
},
);
Ok(Clip {
active_media_reference_key: Some("DEFAULT_MEDIA".to_owned()),
color: None,
effects: Vec::new(),
enabled: Some(true),
markers: Vec::new(),
media_references,
metadata: ClipMetadataEnvelope {
pulse_pixelstream: PulseClipMetadata {
shot_entry_id: Some(
entry_id.try_into().map_err(|error| {
OtioError::new(format!("invalid shot entry id: {error}"))
})?,
),
default_clip_mode,
direction,
hold_duration_ms: shot.hold_duration_ms,
travel_duration_ms: Some(shot.travel_duration_ms),
kind,
rotation_speed_deg_s: f64::from(shot.rotation_speed_deg_s),
shot_id: shot
.id
.to_string()
.try_into()
.map_err(|error| OtioError::new(format!("invalid shot id: {error}")))?,
shot_index: shot.shot_index,
target_name: shot
.target_name
.as_str()
.try_into()
.map_err(|error| OtioError::new(format!("invalid target name: {error}")))?,
translation_speed_cm_s: f64::from(shot.translation_speed_cm_s),
},
},
name: shot.name.clone(),
otio_schema: json!("Clip.2"),
source_range: None,
})
}
fn require_schema(actual: &Value, expected: &str, location: &str) -> Result<(), OtioError> {
if actual == &json!(expected) {
Ok(())
} else {
Err(OtioError::new(format!(
"unsupported {location} schema {actual}; expected {expected}"
)))
}
}
fn valid_f32(value: f64, field: &str) -> Result<f32, OtioError> {
if value.is_finite() && value >= 0.0 && value <= f64::from(f32::MAX) {
Ok(value as f32)
} else {
Err(OtioError::new(format!("invalid {field}: {value}")))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{ShotDirection, DEFAULT_SHOT_LIBRARY_ID};
fn fixture() -> (Timeline, Vec<Shot>) {
let shot = Shot {
id: ShotId::from("shared:shot:moving:Floor Dolly"),
library_id: DEFAULT_SHOT_LIBRARY_ID.to_owned(),
streamer_id: String::new(),
name: "Floor Dolly".to_owned(),
kind: ShotKind::Moving,
target_name: "Floor Dolly".to_owned(),
translation_speed_cm_s: 10.0,
rotation_speed_deg_s: 2.0,
hold_duration_ms: 5_000,
travel_duration_ms: 42_000,
default_entry_mode: ShotEntryMode::Forward,
shot_index: 7,
};
let timeline = Timeline {
id: TimelineId::from("shared:timeline:hero"),
library_id: DEFAULT_SHOT_LIBRARY_ID.to_owned(),
streamer_id: String::new(),
name: "Hero".to_owned(),
revision: 3,
entries: vec![
ShotEntry::from_shot_with_id(&shot, ShotDirection::Forward, "entry-forward"),
ShotEntry::from_shot_with_id(&shot, ShotDirection::Reverse, "entry-reverse"),
],
sort_order: 0,
};
(timeline, vec![shot])
}
#[test]
fn pulse_otio_round_trip_preserves_camera_semantics() {
let (timeline, shots) = fixture();
let json = export_timeline_otio_json(&timeline, &shots).unwrap();
assert!(json.contains(r#""OTIO_SCHEMA": "Timeline.1""#));
assert!(json.contains(r#""timeline_id""#));
assert!(json.contains(r#""shot_entry_id""#));
assert!(json.contains(r#""default_clip_mode""#));
assert!(!json.contains(r#""shot_list_id""#));
assert!(!json.contains(r#""cue_id""#));
assert!(!json.contains(r#""default_list_mode""#));
let imported = import_timeline_otio_json(&json).unwrap();
assert_eq!(imported.timeline.id, timeline.id);
assert_eq!(imported.timeline.library_id, DEFAULT_SHOT_LIBRARY_ID);
assert_eq!(imported.timeline.revision, 3);
assert_eq!(imported.timeline.entries, timeline.entries);
assert_eq!(imported.shots, shots);
}
#[test]
fn retired_profile_keys_import_into_canonical_timeline_and_clips() {
let (timeline, shots) = fixture();
let legacy_json = export_timeline_otio_json(&timeline, &shots)
.unwrap()
.replace("\"timeline_id\"", "\"shot_list_id\"")
.replace("\"timeline_revision\"", "\"shot_list_version\"")
.replace("\"shot_entry_id\"", "\"cue_id\"")
.replace("\"default_clip_mode\"", "\"default_list_mode\"");
let imported = import_timeline_otio_json(&legacy_json).unwrap();
assert_eq!(imported.timeline, timeline);
assert_eq!(imported.shots, shots);
let canonical = export_timeline_otio_json(&imported.timeline, &imported.shots).unwrap();
assert!(canonical.contains(r#""timeline_id""#));
assert!(canonical.contains(r#""shot_entry_id""#));
assert!(!canonical.contains(r#""shot_list_id""#));
assert!(!canonical.contains(r#""cue_id""#));
}
#[test]
fn collection_otio_round_trip_preserves_hierarchy_and_reusable_membership() {
let (timeline, shots) = fixture();
let collections = vec![
Collection {
id: CollectionId::from("autumn-campaign"),
library_id: DEFAULT_SHOT_LIBRARY_ID.to_owned(),
parent_id: String::new(),
name: "Autumn campaign".to_owned(),
sort_order: 2,
metadata: HashMap::from([("client.code".to_owned(), "ACME".to_owned())]),
},
Collection {
id: CollectionId::from("launch-film"),
library_id: DEFAULT_SHOT_LIBRARY_ID.to_owned(),
parent_id: "autumn-campaign".to_owned(),
name: "Launch film".to_owned(),
sort_order: 3,
metadata: HashMap::new(),
},
];
let memberships = vec![
CollectionMembership {
id: CollectionMembershipId::from(CollectionMembership::stable_id(
"autumn-campaign",
timeline.id.as_ref(),
)),
collection_id: "autumn-campaign".to_owned(),
timeline_id: timeline.id.to_string(),
sort_order: 0,
},
CollectionMembership {
id: CollectionMembershipId::from(CollectionMembership::stable_id(
"launch-film",
timeline.id.as_ref(),
)),
collection_id: "launch-film".to_owned(),
timeline_id: timeline.id.to_string(),
sort_order: 0,
},
];
let json = export_collection_otio_json(
"autumn-campaign",
&collections,
&memberships,
std::slice::from_ref(&timeline),
&shots,
)
.unwrap();
assert!(json.contains(r#""OTIO_SCHEMA": "SerializableCollection.1""#));
let imported = import_collection_otio_json(&json).unwrap();
assert_eq!(imported.collections.len(), 2);
assert_eq!(imported.memberships.len(), 2);
assert_eq!(imported.timelines, vec![timeline]);
assert_eq!(imported.shots, shots);
assert_eq!(
imported
.collections
.iter()
.find(|collection| collection.id.as_ref() == "launch-film")
.unwrap()
.parent_id,
"autumn-campaign"
);
}
#[test]
fn generic_otio_without_pulse_metadata_is_rejected() {
let generic = r#"{
\"OTIO_SCHEMA\":\"Timeline.1\",
\"metadata\":{},
\"name\":\"Generic\",
\"tracks\":{\"OTIO_SCHEMA\":\"Stack.1\",\"children\":[]}
}"#;
assert!(import_timeline_otio_json(generic).is_err());
}
#[test]
fn direction_model_still_maps_to_capture_directions() {
assert_eq!(
ShotEntryMode::Both.directions(),
&[ShotDirection::Forward, ShotDirection::Reverse]
);
}
}