1#![allow(clippy::collapsible_match)]
3use std::collections::HashMap;
24
25use serde::{Deserialize, Serialize};
26use thiserror::Error;
27
28use suture_driver::{DriverError, SemanticChange, SutureDriver};
29
30#[derive(Error, Debug)]
31pub enum OtioError {
32 #[error("failed to read OTIO file: {0}")]
33 Io(#[from] std::io::Error),
34
35 #[error("failed to parse OTIO JSON: {0}")]
36 Parse(#[from] serde_json::Error),
37
38 #[error("invalid OTIO structure: {0}")]
39 InvalidStructure(String),
40
41 #[error("element not found: {0}")]
42 ElementNotFound(String),
43
44
45}
46
47pub type Result<T> = std::result::Result<T, OtioError>;
48
49#[derive(Clone, Debug)]
58pub enum OtioNode {
59 Timeline(Timeline),
60 Track(Track),
61 Stack(Stack),
62 Clip(Clip),
63 Transition(Transition),
64 SerializableCollection(SerializableCollection),
65 Unknown {
67 schema: String,
68 value: serde_json::Value,
69 },
70}
71
72impl OtioNode {
73 fn schema_type(&self) -> &str {
74 match self {
75 OtioNode::Timeline(_) => "Timeline",
76 OtioNode::Track(_) => "Track",
77 OtioNode::Stack(_) => "Stack",
78 OtioNode::Clip(_) => "Clip",
79 OtioNode::Transition(_) => "Transition",
80 OtioNode::SerializableCollection(_) => "SerializableCollection",
81 OtioNode::Unknown { schema, .. } => schema.as_str(),
82 }
83 }
84
85 #[allow(dead_code)]
87 fn children(&self) -> Vec<OtioNode> {
88 match self {
89 OtioNode::Timeline(tl) => tl.child_nodes(),
90 OtioNode::Track(tr) => tr.child_nodes(),
91 OtioNode::Stack(st) => st.child_nodes(),
92 OtioNode::SerializableCollection(sc) => sc.child_nodes(),
93 _ => Vec::new(),
94 }
95 }
96
97 fn name(&self) -> Option<&str> {
98 match self {
99 OtioNode::Timeline(tl) => Some(&tl.name),
100 OtioNode::Track(tr) => Some(&tr.name),
101 OtioNode::Stack(st) => Some(&st.name),
102 OtioNode::Clip(cl) => Some(&cl.name),
103 OtioNode::Transition(tr) => Some(&tr.name),
104 OtioNode::SerializableCollection(sc) => Some(&sc.name),
105 OtioNode::Unknown { value, .. } => value.get("name").and_then(|v| v.as_str()),
106 }
107 }
108
109 fn to_json(&self) -> serde_json::Value {
111 match self {
112 OtioNode::Timeline(tl) => serde_json::to_value(tl).unwrap_or_default(),
113 OtioNode::Track(tr) => serde_json::to_value(tr).unwrap_or_default(),
114 OtioNode::Stack(st) => serde_json::to_value(st).unwrap_or_default(),
115 OtioNode::Clip(cl) => serde_json::to_value(cl).unwrap_or_default(),
116 OtioNode::Transition(tr) => serde_json::to_value(tr).unwrap_or_default(),
117 OtioNode::SerializableCollection(sc) => serde_json::to_value(sc).unwrap_or_default(),
118 OtioNode::Unknown { value, .. } => value.clone(),
119 }
120 }
121}
122
123fn parse_children(json_children: &[serde_json::Value]) -> Vec<OtioNode> {
126 json_children
127 .iter()
128 .filter_map(|v| parse_otio_node(v).ok())
129 .collect()
130}
131
132#[allow(dead_code)]
133fn children_to_json(nodes: &[OtioNode]) -> Vec<serde_json::Value> {
134 nodes.iter().map(|n| n.to_json()).collect()
135}
136
137#[derive(Clone, Debug, Serialize, Deserialize)]
138pub struct Timeline {
139 pub name: String,
140 #[serde(default)]
141 pub metadata: serde_json::Value,
142 #[serde(default, rename = "tracks")]
144 pub tracks_json: Vec<serde_json::Value>,
145 #[serde(default, skip_serializing_if = "Option::is_none")]
146 pub global_start_time: Option<RationalTime>,
147}
148
149impl Timeline {
150 fn child_nodes(&self) -> Vec<OtioNode> {
151 parse_children(&self.tracks_json)
152 }
153 #[allow(dead_code)]
154 fn with_children(mut self, nodes: Vec<OtioNode>) -> Self {
155 self.tracks_json = children_to_json(&nodes);
156 self
157 }
158}
159
160#[derive(Clone, Debug, Serialize, Deserialize)]
161pub struct Track {
162 pub name: String,
163 #[serde(default)]
164 pub metadata: serde_json::Value,
165 #[serde(rename = "kind")]
166 pub kind: String,
167 #[serde(default, rename = "children")]
168 pub children_json: Vec<serde_json::Value>,
169}
170
171impl Track {
172 fn child_nodes(&self) -> Vec<OtioNode> {
173 parse_children(&self.children_json)
174 }
175 #[allow(dead_code)]
176 fn with_children(mut self, nodes: Vec<OtioNode>) -> Self {
177 self.children_json = children_to_json(&nodes);
178 self
179 }
180}
181
182#[derive(Clone, Debug, Serialize, Deserialize)]
183pub struct Stack {
184 pub name: String,
185 #[serde(default)]
186 pub metadata: serde_json::Value,
187 #[serde(default, rename = "children")]
188 pub children_json: Vec<serde_json::Value>,
189}
190
191impl Stack {
192 fn child_nodes(&self) -> Vec<OtioNode> {
193 parse_children(&self.children_json)
194 }
195 #[allow(dead_code)]
196 fn with_children(mut self, nodes: Vec<OtioNode>) -> Self {
197 self.children_json = children_to_json(&nodes);
198 self
199 }
200}
201
202#[derive(Clone, Debug, Serialize, Deserialize)]
203pub struct Clip {
204 pub name: String,
205 #[serde(default)]
206 pub metadata: serde_json::Value,
207 #[serde(default, skip_serializing_if = "Option::is_none")]
208 pub source_range: Option<TimeRange>,
209 #[serde(default, skip_serializing_if = "Option::is_none")]
210 pub media_reference: Option<serde_json::Value>,
211}
212
213#[derive(Clone, Debug, Serialize, Deserialize)]
214pub struct Transition {
215 pub name: String,
216 #[serde(default)]
217 pub metadata: serde_json::Value,
218 #[serde(default, skip_serializing_if = "Option::is_none")]
219 pub in_offset: Option<RationalTime>,
220 #[serde(default, skip_serializing_if = "Option::is_none")]
221 pub out_offset: Option<RationalTime>,
222}
223
224#[derive(Clone, Debug, Serialize, Deserialize)]
225pub struct SerializableCollection {
226 pub name: String,
227 #[serde(default)]
228 pub metadata: serde_json::Value,
229 #[serde(default, rename = "children")]
230 pub children_json: Vec<serde_json::Value>,
231}
232
233impl SerializableCollection {
234 fn child_nodes(&self) -> Vec<OtioNode> {
235 parse_children(&self.children_json)
236 }
237}
238
239#[derive(Clone, Debug, Serialize, Deserialize)]
240pub struct RationalTime {
241 pub value: f64,
242 pub rate: f64,
243}
244
245#[derive(Clone, Debug, Serialize, Deserialize)]
246pub struct TimeRange {
247 pub start_time: RationalTime,
248 pub duration: RationalTime,
249}
250
251fn parse_otio_node(value: &serde_json::Value) -> Result<OtioNode> {
256 let schema = value
257 .get("OTIO_SCHEMA")
258 .and_then(|v| v.as_str())
259 .unwrap_or("");
260
261 if schema.is_empty() {
262 return Err(OtioError::InvalidStructure(
263 "missing OTIO_SCHEMA field".into(),
264 ));
265 }
266
267 match schema {
269 "otio.schema.Timeline" => serde_json::from_value::<Timeline>(value.clone())
270 .map(OtioNode::Timeline)
271 .map_err(|_| OtioError::InvalidStructure(format!("failed to parse {}", schema))),
272 "otio.schema.Track" => serde_json::from_value::<Track>(value.clone())
273 .map(OtioNode::Track)
274 .map_err(|_| OtioError::InvalidStructure(format!("failed to parse {}", schema))),
275 "otio.schema.Stack" => serde_json::from_value::<Stack>(value.clone())
276 .map(OtioNode::Stack)
277 .map_err(|_| OtioError::InvalidStructure(format!("failed to parse {}", schema))),
278 "otio.schema.Clip" => serde_json::from_value::<Clip>(value.clone())
279 .map(OtioNode::Clip)
280 .map_err(|_| OtioError::InvalidStructure(format!("failed to parse {}", schema))),
281 "otio.schema.Transition" => serde_json::from_value::<Transition>(value.clone())
282 .map(OtioNode::Transition)
283 .map_err(|_| OtioError::InvalidStructure(format!("failed to parse {}", schema))),
284 "otio.schema.SerializableCollection" => {
285 serde_json::from_value::<SerializableCollection>(value.clone())
286 .map(OtioNode::SerializableCollection)
287 .map_err(|_| OtioError::InvalidStructure(format!("failed to parse {}", schema)))
288 }
289 _ => Ok(OtioNode::Unknown {
291 schema: schema.to_string(),
292 value: value.clone(),
293 }),
294 }
295}
296
297fn parse_otio_json(input: &str) -> Result<serde_json::Value> {
298 let value: serde_json::Value = serde_json::from_str(input)?;
299 if !value.is_object()
301 || !value
302 .as_object()
303 .map(|o| o.contains_key("OTIO_SCHEMA"))
304 .unwrap_or(false)
305 {
306 return Err(OtioError::InvalidStructure(
307 "root is not an OTIO object (missing OTIO_SCHEMA)".into(),
308 ));
309 }
310 Ok(value)
311}
312
313fn content_fingerprint(node: &OtioNode) -> String {
325 use std::hash::{Hash, Hasher};
326 let mut hasher = std::collections::hash_map::DefaultHasher::new();
327
328 match node {
329 OtioNode::Clip(cl) => {
330 "clip".hash(&mut hasher);
331 cl.name.hash(&mut hasher);
332 if let Some(sr) = &cl.source_range {
333 sr.start_time.value.to_bits().hash(&mut hasher);
334 }
335 if let Some(mr) = &cl.media_reference
337 && let Some(url) = mr.get("target_url").and_then(|v| v.as_str())
338 {
339 url.hash(&mut hasher);
340 }
341 }
342 OtioNode::Track(tr) => {
343 "track".hash(&mut hasher);
344 tr.name.hash(&mut hasher);
345 tr.kind.hash(&mut hasher);
346 }
347 OtioNode::Stack(st) => {
348 "stack".hash(&mut hasher);
349 st.name.hash(&mut hasher);
350 }
351 OtioNode::Transition(tr) => {
352 "transition".hash(&mut hasher);
353 tr.name.hash(&mut hasher);
354 if let Some(io) = &tr.in_offset {
355 io.value.to_bits().hash(&mut hasher);
356 }
357 }
358 OtioNode::Timeline(tl) => {
359 "timeline".hash(&mut hasher);
360 tl.name.hash(&mut hasher);
361 }
362 OtioNode::SerializableCollection(sc) => {
363 "collection".hash(&mut hasher);
364 sc.name.hash(&mut hasher);
365 }
366 OtioNode::Unknown { value, .. } => {
367 format!("{:?}", value).hash(&mut hasher);
368 }
369 }
370
371 format!("{:016x}", hasher.finish())
372}
373
374#[derive(Clone)]
380struct FlatNode {
381 path: String,
382 fingerprint: String,
383 parent_fp: Option<String>,
384 node: OtioNode,
385 raw_json: serde_json::Value,
386}
387
388fn flatten_tree_with_raw(
389 value: &serde_json::Value,
390 parent_path: &str,
391 parent_fp: Option<&str>,
392) -> Vec<FlatNode> {
393 let mut result = Vec::new();
394 let node = match parse_otio_node(value) {
395 Ok(n) => n,
396 Err(_) => return result,
397 };
398 let fp = content_fingerprint(&node);
399
400 let path = if parent_path.is_empty() {
401 format!("/{}", node.schema_type())
402 } else {
403 format!("{}/{}", parent_path, node.schema_type())
404 };
405
406 result.push(FlatNode {
407 path: path.clone(),
408 fingerprint: fp.clone(),
409 parent_fp: parent_fp.map(|s| s.to_string()),
410 raw_json: value.clone(),
411 node: node.clone(),
412 });
413
414 let child_keys = ["tracks", "children"];
415 for key in &child_keys {
416 if let Some(arr) = value.get(key).and_then(|v| v.as_array()) {
417 for (i, child_val) in arr.iter().enumerate() {
418 let child_path = format!("{}/[{}]", path, i);
419 result.extend(flatten_tree_with_raw(child_val, &child_path, Some(&fp)));
420 }
421 }
422 }
423
424 result
425}
426
427fn diff_trees(base_nodes: &[FlatNode], new_nodes: &[FlatNode]) -> Vec<SemanticChange> {
428 let new_by_fp: HashMap<&str, &FlatNode> = new_nodes
429 .iter()
430 .map(|n| (n.fingerprint.as_str(), n))
431 .collect();
432 let base_by_fp: HashMap<&str, &FlatNode> = base_nodes
433 .iter()
434 .map(|n| (n.fingerprint.as_str(), n))
435 .collect();
436
437 let mut changes = Vec::new();
438
439 for node in new_nodes {
441 if !base_by_fp.contains_key(node.fingerprint.as_str()) {
442 changes.push(SemanticChange::Added {
443 path: node.path.clone(),
444 value: format!(
445 "{} ({})",
446 node.node.name().unwrap_or("?"),
447 node.node.schema_type()
448 ),
449 });
450 }
451 }
452
453 for node in base_nodes {
455 if !new_by_fp.contains_key(node.fingerprint.as_str()) {
456 changes.push(SemanticChange::Removed {
457 path: node.path.clone(),
458 old_value: format!(
459 "{} ({})",
460 node.node.name().unwrap_or("?"),
461 node.node.schema_type()
462 ),
463 });
464 }
465 }
466
467 for new_node in new_nodes {
471 if let Some(base_node) = base_by_fp.get(new_node.fingerprint.as_str()) {
472 let is_leaf = matches!(
474 new_node.node,
475 OtioNode::Clip(_) | OtioNode::Transition(_) | OtioNode::Unknown { .. }
476 );
477 if is_leaf && base_node.raw_json != new_node.raw_json {
478 changes.push(SemanticChange::Modified {
479 path: new_node.path.clone(),
480 old_value: format!(
481 "{} ({})",
482 base_node.node.name().unwrap_or("?"),
483 base_node.node.schema_type()
484 ),
485 new_value: format!(
486 "{} ({})",
487 new_node.node.name().unwrap_or("?"),
488 new_node.node.schema_type()
489 ),
490 });
491 }
492 }
493 }
494
495 changes
496}
497
498fn merge_trees(
510 base_nodes: &[FlatNode],
511 ours_nodes: &[FlatNode],
512 theirs_nodes: &[FlatNode],
513) -> Option<serde_json::Value> {
514 let base_by_fp: HashMap<&str, &FlatNode> = base_nodes
515 .iter()
516 .map(|n| (n.fingerprint.as_str(), n))
517 .collect();
518 let ours_by_fp: HashMap<&str, &FlatNode> = ours_nodes
519 .iter()
520 .map(|n| (n.fingerprint.as_str(), n))
521 .collect();
522 let theirs_by_fp: HashMap<&str, &FlatNode> = theirs_nodes
523 .iter()
524 .map(|n| (n.fingerprint.as_str(), n))
525 .collect();
526
527 let all_fps: std::collections::HashSet<&str> = base_by_fp
528 .keys()
529 .chain(ours_by_fp.keys())
530 .chain(theirs_by_fp.keys())
531 .copied()
532 .collect();
533
534 let mut merged_nodes: Vec<(String, FlatNode)> = Vec::new(); for &fp in &all_fps {
538 let in_base = base_by_fp.contains_key(fp);
539 let in_ours = ours_by_fp.contains_key(fp);
540 let in_theirs = theirs_by_fp.contains_key(fp);
541
542 match (in_base, in_ours, in_theirs) {
543 (true, true, true) => {
545 let base_node = base_by_fp[fp];
546 let ours_node = ours_by_fp[fp];
547 let theirs_node = theirs_by_fp[fp];
548
549 let is_leaf = matches!(
552 base_node.node,
553 OtioNode::Clip(_) | OtioNode::Transition(_) | OtioNode::Unknown { .. }
554 );
555
556 let ours_modified = is_leaf && base_node.raw_json != ours_node.raw_json;
557 let theirs_modified = is_leaf && base_node.raw_json != theirs_node.raw_json;
558
559 match (ours_modified, theirs_modified) {
560 (false, false) => {
561 merged_nodes.push((fp.to_string(), base_by_fp[fp].clone()));
562 }
563 (true, false) => {
564 merged_nodes.push((fp.to_string(), ours_by_fp[fp].clone()));
565 }
566 (false, true) => {
567 merged_nodes.push((fp.to_string(), theirs_by_fp[fp].clone()));
568 }
569 (true, true) => {
570 if ours_by_fp[fp].raw_json == theirs_by_fp[fp].raw_json {
571 merged_nodes.push((fp.to_string(), ours_by_fp[fp].clone()));
572 } else {
573 return None;
575 }
576 }
577 }
578 }
579
580 (false, true, false) => {
582 merged_nodes.push((fp.to_string(), ours_by_fp[fp].clone()));
583 }
584
585 (false, false, true) => {
587 merged_nodes.push((fp.to_string(), theirs_by_fp[fp].clone()));
588 }
589
590 (false, true, true) => {
592 if ours_by_fp[fp].raw_json == theirs_by_fp[fp].raw_json {
593 merged_nodes.push((fp.to_string(), ours_by_fp[fp].clone()));
594 } else {
595 return None;
596 }
597 }
598
599 (true, false, true) => {
601 }
603
604 (true, true, false) => {
606 }
608
609 (true, false, false) => {
611 }
613
614 (false, false, false) => {
615 return None;
616 }
617 }
618 }
619
620 let ours_json: serde_json::Value = match ours_nodes.first() {
623 Some(node) => node.raw_json.clone(),
624 None => serde_json::Value::Null,
625 };
626
627 let theirs_json: serde_json::Value = match theirs_nodes.first() {
628 Some(node) => node.raw_json.clone(),
629 None => serde_json::Value::Null,
630 };
631
632 let template = if ours_json.is_object() {
634 ours_json
635 } else if theirs_json.is_object() {
636 theirs_json
637 } else {
638 return base_nodes.first().map(|n| n.raw_json.clone());
639 };
640
641 let mut result = template;
643 let mut placed_fps = std::collections::HashSet::new();
644 rebuild_children_with_merged(&mut result, &merged_nodes, &mut placed_fps);
645
646 Some(result)
647}
648
649fn rebuild_children_with_merged(
654 value: &mut serde_json::Value,
655 merged_nodes: &[(String, FlatNode)],
656 placed_fps: &mut std::collections::HashSet<String>,
657) {
658 let container_fp = value
659 .get("OTIO_SCHEMA")
660 .and_then(|v| v.as_str())
661 .and_then(|_| parse_otio_node(value).ok())
662 .map(|n| content_fingerprint(&n));
663
664 if let Some(obj) = value.as_object_mut() {
665 for key in ["tracks", "children"] {
666 if let Some(arr) = obj.get_mut(key).and_then(|v| v.as_array_mut()) {
667 let mut new_arr = Vec::new();
668
669 for item in arr.iter() {
670 if let Some(_schema) = item.get("OTIO_SCHEMA").and_then(|v| v.as_str())
671 && let Ok(node) = parse_otio_node(item)
672 {
673 let fp = content_fingerprint(&node);
674 if let Some((_, merged_node)) = merged_nodes.iter().find(|(f, _)| *f == fp)
675 {
676 placed_fps.insert(fp.clone());
677 new_arr.push(merged_node.raw_json.clone());
678 continue;
679 }
680 continue;
681 }
682 new_arr.push(item.clone());
683 }
684
685 if let Some(ref cp_fp) = container_fp {
686 for (fp, merged_node) in merged_nodes {
687 if !placed_fps.contains(fp.as_str())
688 && merged_node.parent_fp.as_deref() == Some(cp_fp.as_str())
689 {
690 new_arr.push(merged_node.raw_json.clone());
691 placed_fps.insert(fp.clone());
692 }
693 }
694 }
695
696 *arr = new_arr;
697 }
698 }
699
700 if let Some(children) = obj.get_mut("children").and_then(|v| v.as_array_mut()) {
701 for child in children.iter_mut() {
702 rebuild_children_with_merged(child, merged_nodes, placed_fps);
703 }
704 }
705 if let Some(tracks) = obj.get_mut("tracks").and_then(|v| v.as_array_mut()) {
706 for child in tracks.iter_mut() {
707 rebuild_children_with_merged(child, merged_nodes, placed_fps);
708 }
709 }
710 }
711}
712
713pub struct OtioDriver;
718
719impl OtioDriver {
720 pub fn new() -> Self {
721 Self
722 }
723
724 fn parse_and_flatten(input: &str) -> std::result::Result<Vec<FlatNode>, DriverError> {
725 let value = parse_otio_json(input).map_err(|e| DriverError::ParseError(e.to_string()))?;
726 let _node = parse_otio_node(&value).map_err(|e| DriverError::ParseError(e.to_string()))?;
727 Ok(flatten_tree_with_raw(&value, "", None))
728 }
729}
730
731impl Default for OtioDriver {
732 fn default() -> Self {
733 Self::new()
734 }
735}
736
737impl SutureDriver for OtioDriver {
738 fn name(&self) -> &str {
739 "OpenTimelineIO"
740 }
741 fn supported_extensions(&self) -> &[&str] {
742 &[".otio"]
743 }
744
745 fn diff(
746 &self,
747 base_content: Option<&str>,
748 new_content: &str,
749 ) -> std::result::Result<Vec<SemanticChange>, DriverError> {
750 let new_nodes = Self::parse_and_flatten(new_content)?;
751
752 let base_nodes = match base_content {
753 None => Vec::new(),
754 Some(base) => Self::parse_and_flatten(base)?,
755 };
756
757 Ok(diff_trees(&base_nodes, &new_nodes))
758 }
759
760 fn format_diff(
761 &self,
762 base_content: Option<&str>,
763 new_content: &str,
764 ) -> std::result::Result<String, DriverError> {
765 let changes = self.diff(base_content, new_content)?;
766 if changes.is_empty() {
767 return Ok("no changes".to_string());
768 }
769 let lines: Vec<String> = changes
770 .iter()
771 .map(|c| match c {
772 SemanticChange::Added { path, value } => format!(" ADDED {}: {}", path, value),
773 SemanticChange::Removed { path, old_value } => {
774 format!(" REMOVED {}: {}", path, old_value)
775 }
776 SemanticChange::Modified {
777 path,
778 old_value,
779 new_value,
780 } => format!(" MODIFIED {}: {} -> {}", path, old_value, new_value),
781 SemanticChange::Moved {
782 old_path,
783 new_path,
784 value,
785 } => format!(" MOVED {} -> {}: {}", old_path, new_path, value),
786 })
787 .collect();
788 Ok(lines.join("\n"))
789 }
790
791 fn merge(
792 &self,
793 base: &str,
794 ours: &str,
795 theirs: &str,
796 ) -> std::result::Result<Option<String>, DriverError> {
797 let base_nodes = Self::parse_and_flatten(base)?;
798 let ours_nodes = Self::parse_and_flatten(ours)?;
799 let theirs_nodes = Self::parse_and_flatten(theirs)?;
800
801 let merged = merge_trees(&base_nodes, &ours_nodes, &theirs_nodes);
802
803 match merged {
804 Some(value) => {
805 let json = serde_json::to_string_pretty(&value)
806 .map_err(|e| DriverError::SerializationError(e.to_string()))?;
807 Ok(Some(json))
808 }
809 None => Ok(None),
810 }
811 }
812}
813
814#[derive(Clone, Debug, PartialEq)]
819pub enum TimelineElement {
820 Timeline {
821 id: String,
822 name: String,
823 },
824 Track {
825 id: String,
826 name: String,
827 kind: String,
828 parent_id: Option<String>,
829 },
830 Clip {
831 id: String,
832 name: String,
833 parent_id: Option<String>,
834 },
835 Transition {
836 id: String,
837 name: String,
838 parent_id: Option<String>,
839 },
840}
841
842impl TimelineElement {
843 pub fn id(&self) -> &str {
844 match self {
845 TimelineElement::Timeline { id, .. } => id,
846 TimelineElement::Track { id, .. } => id,
847 TimelineElement::Clip { id, .. } => id,
848 TimelineElement::Transition { id, .. } => id,
849 }
850 }
851 pub fn element_type(&self) -> &str {
852 match self {
853 TimelineElement::Timeline { .. } => "Timeline",
854 TimelineElement::Track { .. } => "Track",
855 TimelineElement::Clip { .. } => "Clip",
856 TimelineElement::Transition { .. } => "Transition",
857 }
858 }
859 pub fn name(&self) -> &str {
860 match self {
861 TimelineElement::Timeline { name, .. } => name,
862 TimelineElement::Track { name, .. } => name,
863 TimelineElement::Clip { name, .. } => name,
864 TimelineElement::Transition { name, .. } => name,
865 }
866 }
867}
868
869#[derive(Clone, Debug, PartialEq)]
870pub struct ChangeDescription {
871 pub element_id: String,
872 pub field_path: String,
873 pub old_value: Option<String>,
874 pub new_value: Option<String>,
875}
876
877pub struct LegacyOtioDriver {
879 elements: Vec<TimelineElement>,
880 raw_json: serde_json::Value,
881}
882
883impl Default for LegacyOtioDriver {
884 fn default() -> Self {
885 Self::new()
886 }
887}
888
889impl LegacyOtioDriver {
890 pub fn new() -> Self {
891 Self {
892 elements: Vec::new(),
893 raw_json: serde_json::Value::Null,
894 }
895 }
896
897 pub fn parse_otio(&mut self, input: &str) -> Result<()> {
898 let root: serde_json::Value = serde_json::from_str(input)?;
899 self.raw_json = root.clone();
900
901 let node = parse_otio_node(&root)?;
902
903 self.elements.clear();
904 self.collect_elements(node, None, 0)?;
905 Ok(())
906 }
907
908 fn collect_elements(
909 &mut self,
910 node: OtioNode,
911 parent_id: Option<String>,
912 index: usize,
913 ) -> Result<()> {
914 match &node {
915 OtioNode::Timeline(tl) => {
916 let element_id =
917 Self::element_id("timeline", &tl.name, index, parent_id.as_deref());
918 self.elements.push(TimelineElement::Timeline {
919 id: element_id.clone(),
920 name: tl.name.clone(),
921 });
922 for (i, child) in tl.child_nodes().iter().enumerate() {
923 self.collect_elements(child.clone(), Some(element_id.clone()), i)?;
924 }
925 }
926 OtioNode::Stack(st) => {
927 let element_id = Self::element_id("stack", &st.name, index, parent_id.as_deref());
928 self.elements.push(TimelineElement::Track {
929 id: element_id.clone(),
930 name: st.name.clone(),
931 kind: "Stack".to_string(),
932 parent_id: parent_id.clone(),
933 });
934 for (i, child) in st.child_nodes().iter().enumerate() {
935 self.collect_elements(child.clone(), Some(element_id.clone()), i)?;
936 }
937 }
938 OtioNode::Track(tr) => {
939 let element_id = Self::element_id("track", &tr.name, index, parent_id.as_deref());
940 self.elements.push(TimelineElement::Track {
941 id: element_id.clone(),
942 name: tr.name.clone(),
943 kind: tr.kind.clone(),
944 parent_id: parent_id.clone(),
945 });
946 for (i, child) in tr.child_nodes().iter().enumerate() {
947 self.collect_elements(child.clone(), Some(element_id.clone()), i)?;
948 }
949 }
950 OtioNode::Clip(cl) => {
951 let element_id = Self::element_id("clip", &cl.name, index, parent_id.as_deref());
952 self.elements.push(TimelineElement::Clip {
953 id: element_id,
954 name: cl.name.clone(),
955 parent_id,
956 });
957 }
958 OtioNode::Transition(tr) => {
959 let element_id =
960 Self::element_id("transition", &tr.name, index, parent_id.as_deref());
961 self.elements.push(TimelineElement::Transition {
962 id: element_id,
963 name: tr.name.clone(),
964 parent_id,
965 });
966 }
967 OtioNode::SerializableCollection(sc) => {
968 for (i, child) in sc.child_nodes().iter().enumerate() {
969 self.collect_elements(child.clone(), parent_id.clone(), i)?;
970 }
971 }
972 OtioNode::Unknown { .. } => {
973 }
975 }
976 Ok(())
977 }
978
979 fn element_id(ty: &str, name: &str, index: usize, parent_id: Option<&str>) -> String {
980 match parent_id {
981 Some(pid) => format!("{pid}/{}:{}:{}", index, ty, name),
982 None => format!("{}:{}:{}", index, ty, name),
983 }
984 }
985
986 pub fn elements(&self) -> &[TimelineElement] {
987 &self.elements
988 }
989
990 pub fn find_element(&self, id: &str) -> Option<&TimelineElement> {
991 self.elements.iter().find(|e| e.id() == id)
992 }
993
994 pub fn compute_touch_set(&self, changes: &[ChangeDescription]) -> Vec<String> {
995 let mut affected = Vec::new();
996 let mut seen = std::collections::HashSet::<String>::new();
997
998 for change in changes {
999 if !seen.insert(change.element_id.clone()) {
1000 continue;
1001 }
1002 affected.push(change.element_id.clone());
1003
1004 if let Some(elem) = self.find_element(&change.element_id)
1005 && !matches!(elem, TimelineElement::Timeline { .. })
1006 {
1007 for other in &self.elements {
1008 match other {
1009 TimelineElement::Track {
1010 parent_id: Some(pid),
1011 ..
1012 }
1013 | TimelineElement::Clip {
1014 parent_id: Some(pid),
1015 ..
1016 }
1017 | TimelineElement::Transition {
1018 parent_id: Some(pid),
1019 ..
1020 } => {
1021 if pid == elem.id() && seen.insert(other.id().to_owned()) {
1022 affected.push(other.id().to_string());
1023 }
1024 }
1025 _ => {}
1026 }
1027 }
1028 }
1029 }
1030
1031 affected
1032 }
1033
1034 pub fn serialize_diff(&self, old_json: &str, new_json: &str) -> Result<String> {
1035 let old_val: serde_json::Value = serde_json::from_str(old_json)?;
1036 let new_val: serde_json::Value = serde_json::from_str(new_json)?;
1037
1038 let mut lines = Vec::new();
1039 Self::diff_values(&old_val, &new_val, "".to_string(), &mut lines);
1040
1041 if lines.is_empty() {
1042 lines.push("(no differences)".to_string());
1043 }
1044
1045 Ok(lines.join("\n"))
1046 }
1047
1048 fn diff_values(
1049 old: &serde_json::Value,
1050 new: &serde_json::Value,
1051 path: String,
1052 lines: &mut Vec<String>,
1053 ) {
1054 match (old, new) {
1055 (serde_json::Value::Object(old_map), serde_json::Value::Object(new_map)) => {
1056 let all_keys: std::collections::HashSet<&String> =
1057 old_map.keys().chain(new_map.keys()).collect();
1058 for key in all_keys {
1059 let child_path = if path.is_empty() {
1060 key.clone()
1061 } else {
1062 format!("{path}.{key}")
1063 };
1064 match (old_map.get(key), new_map.get(key)) {
1065 (Some(o), Some(n)) => {
1066 if o != n {
1067 Self::diff_values(o, n, child_path, lines);
1068 }
1069 }
1070 (None, Some(n)) => {
1071 lines.push(format!("+ {child_path}: {n}"));
1072 }
1073 (Some(o), None) => {
1074 lines.push(format!("- {child_path}: {o}"));
1075 }
1076 (None, None) => continue,
1077 }
1078 }
1079 }
1080 (serde_json::Value::Array(old_arr), serde_json::Value::Array(new_arr)) => {
1081 let max_len = old_arr.len().max(new_arr.len());
1082 for i in 0..max_len {
1083 let child_path = format!("{path}[{i}]");
1084 match (old_arr.get(i), new_arr.get(i)) {
1085 (Some(o), Some(n)) => {
1086 if o != n {
1087 Self::diff_values(o, n, child_path, lines);
1088 }
1089 }
1090 (None, Some(n)) => {
1091 lines.push(format!("+ {child_path}: {n}"));
1092 }
1093 (Some(o), None) => {
1094 lines.push(format!("- {child_path}: {o}"));
1095 }
1096 (None, None) => continue,
1097 }
1098 }
1099 }
1100 _ => {
1101 if old != new {
1102 lines.push(format!("- {path}: {old}"));
1103 lines.push(format!("+ {path}: {new}"));
1104 }
1105 }
1106 }
1107 }
1108}
1109
1110#[cfg(test)]
1115mod tests {
1116 use super::*;
1117
1118 fn minimal_timeline_otio() -> &'static str {
1119 r#"{
1120 "OTIO_SCHEMA": "otio.schema.Timeline",
1121 "name": "TestTimeline",
1122 "metadata": {},
1123 "tracks": [
1124 {
1125 "OTIO_SCHEMA": "otio.schema.Track",
1126 "name": "Video",
1127 "kind": "Video",
1128 "metadata": {},
1129 "children": [
1130 {
1131 "OTIO_SCHEMA": "otio.schema.Clip",
1132 "name": "Intro",
1133 "metadata": {},
1134 "source_range": {
1135 "start_time": { "value": 0.0, "rate": 24.0 },
1136 "duration": { "value": 100.0, "rate": 24.0 }
1137 }
1138 },
1139 {
1140 "OTIO_SCHEMA": "otio.schema.Transition",
1141 "name": "Dissolve",
1142 "metadata": {},
1143 "in_offset": { "value": 12.0, "rate": 24.0 },
1144 "out_offset": { "value": 12.0, "rate": 24.0 }
1145 },
1146 {
1147 "OTIO_SCHEMA": "otio.schema.Clip",
1148 "name": "Main",
1149 "metadata": {},
1150 "source_range": {
1151 "start_time": { "value": 100.0, "rate": 24.0 },
1152 "duration": { "value": 200.0, "rate": 24.0 }
1153 }
1154 }
1155 ]
1156 }
1157 ]
1158 }"#
1159 }
1160
1161 #[test]
1164 fn test_parse_minimal_timeline() {
1165 let mut driver = LegacyOtioDriver::new();
1166 driver.parse_otio(minimal_timeline_otio()).unwrap();
1167 assert_eq!(driver.elements().len(), 5);
1168 assert_eq!(driver.elements()[0].element_type(), "Timeline");
1169 assert_eq!(driver.elements()[2].element_type(), "Clip");
1170 assert_eq!(driver.elements()[3].element_type(), "Transition");
1171 }
1172
1173 #[test]
1174 fn test_parse_empty_timeline() {
1175 let json = r#"{
1176 "OTIO_SCHEMA": "otio.schema.Timeline",
1177 "name": "Empty",
1178 "metadata": {},
1179 "tracks": []
1180 }"#;
1181 let mut driver = LegacyOtioDriver::new();
1182 driver.parse_otio(json).unwrap();
1183 assert_eq!(driver.elements().len(), 1);
1184 }
1185
1186 #[test]
1187 fn test_find_element() {
1188 let mut driver = LegacyOtioDriver::new();
1189 driver.parse_otio(minimal_timeline_otio()).unwrap();
1190 assert!(driver.find_element("nonexistent").is_none());
1191 }
1192
1193 #[test]
1194 fn test_compute_touch_set() {
1195 let mut driver = LegacyOtioDriver::new();
1196 driver.parse_otio(minimal_timeline_otio()).unwrap();
1197 let track_id = "0:timeline:TestTimeline/0:track:Video";
1198 let changes = vec![ChangeDescription {
1199 element_id: track_id.to_string(),
1200 field_path: "name".to_string(),
1201 old_value: Some("Video".to_string()),
1202 new_value: Some("Audio".to_string()),
1203 }];
1204 let touch_set = driver.compute_touch_set(&changes);
1205 assert!(touch_set.contains(&track_id.to_string()));
1206 }
1207
1208 #[test]
1209 fn test_serialize_diff_identical() {
1210 let json =
1211 r#"{"OTIO_SCHEMA":"otio.schema.Timeline","name":"Test","metadata":{},"tracks":[]}"#;
1212 let driver = LegacyOtioDriver::new();
1213 let diff = driver.serialize_diff(json, json).unwrap();
1214 assert_eq!(diff, "(no differences)");
1215 }
1216
1217 #[test]
1218 fn test_large_timeline_performance() {
1219 let mut tracks = Vec::new();
1220 for t in 0..10 {
1221 let mut clips = Vec::new();
1222 for c in 0..50 {
1223 clips.push(serde_json::json!({
1224 "OTIO_SCHEMA": "otio.schema.Clip",
1225 "name": format!("Clip_{t}_{c}"),
1226 "metadata": {},
1227 "source_range": {
1228 "start_time": {"value": (c as f64) * 100.0, "rate": 24.0},
1229 "duration": {"value": 100.0, "rate": 24.0}
1230 }
1231 }));
1232 }
1233 tracks.push(serde_json::json!({
1234 "OTIO_SCHEMA": "otio.schema.Track",
1235 "name": format!("Track_{t}"),
1236 "kind": if t < 3 { "Video" } else { "Audio" },
1237 "metadata": {},
1238 "children": clips
1239 }));
1240 }
1241 let timeline = serde_json::json!({
1242 "OTIO_SCHEMA": "otio.schema.Timeline",
1243 "name": "LargeTimeline",
1244 "metadata": {"project": "perf_test"},
1245 "tracks": tracks
1246 });
1247 let json_str = serde_json::to_string(&timeline).unwrap();
1248 let mut driver = LegacyOtioDriver::new();
1249 let start = std::time::Instant::now();
1250 driver.parse_otio(&json_str).unwrap();
1251 assert!(start.elapsed().as_secs() < 5);
1252 }
1253
1254 #[test]
1257 fn test_driver_name() {
1258 assert_eq!(OtioDriver::new().name(), "OpenTimelineIO");
1259 }
1260 #[test]
1261 fn test_extensions() {
1262 assert_eq!(OtioDriver::new().supported_extensions(), &[".otio"]);
1263 }
1264
1265 #[test]
1266 fn test_diff_added_clip() {
1267 let base = r#"{
1268 "OTIO_SCHEMA": "otio.schema.Timeline",
1269 "name": "Test",
1270 "metadata": {},
1271 "tracks": [{
1272 "OTIO_SCHEMA": "otio.schema.Track",
1273 "name": "V1",
1274 "kind": "Video",
1275 "metadata": {},
1276 "children": [
1277 {"OTIO_SCHEMA":"otio.schema.Clip","name":"A","metadata":{},"source_range":{"start_time":{"value":0.0,"rate":24.0},"duration":{"value":100.0,"rate":24.0}}}
1278 ]
1279 }]
1280 }"#;
1281 let modified = r#"{
1282 "OTIO_SCHEMA": "otio.schema.Timeline",
1283 "name": "Test",
1284 "metadata": {},
1285 "tracks": [{
1286 "OTIO_SCHEMA": "otio.schema.Track",
1287 "name": "V1",
1288 "kind": "Video",
1289 "metadata": {},
1290 "children": [
1291 {"OTIO_SCHEMA":"otio.schema.Clip","name":"A","metadata":{},"source_range":{"start_time":{"value":0.0,"rate":24.0},"duration":{"value":100.0,"rate":24.0}}},
1292 {"OTIO_SCHEMA":"otio.schema.Clip","name":"B","metadata":{},"source_range":{"start_time":{"value":100.0,"rate":24.0},"duration":{"value":100.0,"rate":24.0}}}
1293 ]
1294 }]
1295 }"#;
1296 let driver = OtioDriver::new();
1297 let changes = driver.diff(Some(base), modified).unwrap();
1298 assert_eq!(changes.len(), 1);
1299 assert!(matches!(&changes[0], SemanticChange::Added { .. }));
1300 }
1301
1302 #[test]
1303 fn test_diff_removed_clip() {
1304 let base = r#"{
1305 "OTIO_SCHEMA": "otio.schema.Timeline",
1306 "name": "Test",
1307 "metadata": {},
1308 "tracks": [{
1309 "OTIO_SCHEMA": "otio.schema.Track",
1310 "name": "V1",
1311 "kind": "Video",
1312 "metadata": {},
1313 "children": [
1314 {"OTIO_SCHEMA":"otio.schema.Clip","name":"A","metadata":{},"source_range":{"start_time":{"value":0.0,"rate":24.0},"duration":{"value":100.0,"rate":24.0}}},
1315 {"OTIO_SCHEMA":"otio.schema.Clip","name":"B","metadata":{},"source_range":{"start_time":{"value":100.0,"rate":24.0},"duration":{"value":100.0,"rate":24.0}}}
1316 ]
1317 }]
1318 }"#;
1319 let modified = r#"{
1320 "OTIO_SCHEMA": "otio.schema.Timeline",
1321 "name": "Test",
1322 "metadata": {},
1323 "tracks": [{
1324 "OTIO_SCHEMA": "otio.schema.Track",
1325 "name": "V1",
1326 "kind": "Video",
1327 "metadata": {},
1328 "children": [
1329 {"OTIO_SCHEMA":"otio.schema.Clip","name":"A","metadata":{},"source_range":{"start_time":{"value":0.0,"rate":24.0},"duration":{"value":100.0,"rate":24.0}}}
1330 ]
1331 }]
1332 }"#;
1333 let driver = OtioDriver::new();
1334 let changes = driver.diff(Some(base), modified).unwrap();
1335 assert_eq!(changes.len(), 1);
1336 assert!(matches!(&changes[0], SemanticChange::Removed { .. }));
1337 }
1338
1339 #[test]
1340 fn test_diff_modified_clip() {
1341 let base = r#"{
1342 "OTIO_SCHEMA": "otio.schema.Timeline",
1343 "name": "Test",
1344 "metadata": {},
1345 "tracks": [{
1346 "OTIO_SCHEMA": "otio.schema.Track",
1347 "name": "V1",
1348 "kind": "Video",
1349 "metadata": {},
1350 "children": [
1351 {"OTIO_SCHEMA":"otio.schema.Clip","name":"A","metadata":{},"source_range":{"start_time":{"value":0.0,"rate":24.0},"duration":{"value":100.0,"rate":24.0}}}
1352 ]
1353 }]
1354 }"#;
1355 let modified = r#"{
1356 "OTIO_SCHEMA": "otio.schema.Timeline",
1357 "name": "Test",
1358 "metadata": {},
1359 "tracks": [{
1360 "OTIO_SCHEMA": "otio.schema.Track",
1361 "name": "V1",
1362 "kind": "Video",
1363 "metadata": {},
1364 "children": [
1365 {"OTIO_SCHEMA":"otio.schema.Clip","name":"A","metadata":{},"source_range":{"start_time":{"value":0.0,"rate":24.0},"duration":{"value":200.0,"rate":24.0}}}
1366 ]
1367 }]
1368 }"#;
1369 let driver = OtioDriver::new();
1370 let changes = driver.diff(Some(base), modified).unwrap();
1371 assert_eq!(changes.len(), 1);
1372 assert!(matches!(&changes[0], SemanticChange::Modified { .. }));
1373 }
1374
1375 #[test]
1376 fn test_diff_no_change() {
1377 let driver = OtioDriver::new();
1378 let changes = driver
1379 .diff(Some(minimal_timeline_otio()), minimal_timeline_otio())
1380 .unwrap();
1381 assert!(changes.is_empty());
1382 }
1383
1384 #[test]
1385 fn test_diff_new_file() {
1386 let driver = OtioDriver::new();
1387 let changes = driver.diff(None, minimal_timeline_otio()).unwrap();
1388 assert!(!changes.is_empty());
1389 assert!(
1390 changes
1391 .iter()
1392 .all(|c| matches!(c, SemanticChange::Added { .. }))
1393 );
1394 }
1395
1396 #[test]
1397 fn test_format_diff() {
1398 let driver = OtioDriver::new();
1399 let fmt = driver.format_diff(None, minimal_timeline_otio()).unwrap();
1400 assert!(fmt.contains("ADDED"));
1401 let fmt = driver
1402 .format_diff(Some(minimal_timeline_otio()), minimal_timeline_otio())
1403 .unwrap();
1404 assert_eq!(fmt, "no changes");
1405 }
1406
1407 #[test]
1408 fn test_merge_add_different_clips() {
1409 let base = r#"{
1410 "OTIO_SCHEMA": "otio.schema.Timeline",
1411 "name": "MergeTest",
1412 "metadata": {},
1413 "tracks": [{
1414 "OTIO_SCHEMA": "otio.schema.Track",
1415 "name": "V1",
1416 "kind": "Video",
1417 "metadata": {},
1418 "children": [
1419 {"OTIO_SCHEMA":"otio.schema.Clip","name":"A","metadata":{},"source_range":{"start_time":{"value":0.0,"rate":24.0},"duration":{"value":100.0,"rate":24.0}}}
1420 ]
1421 }]
1422 }"#;
1423 let ours = r#"{
1424 "OTIO_SCHEMA": "otio.schema.Timeline",
1425 "name": "MergeTest",
1426 "metadata": {},
1427 "tracks": [{
1428 "OTIO_SCHEMA": "otio.schema.Track",
1429 "name": "V1",
1430 "kind": "Video",
1431 "metadata": {},
1432 "children": [
1433 {"OTIO_SCHEMA":"otio.schema.Clip","name":"A","metadata":{},"source_range":{"start_time":{"value":0.0,"rate":24.0},"duration":{"value":100.0,"rate":24.0}}},
1434 {"OTIO_SCHEMA":"otio.schema.Clip","name":"B","metadata":{},"source_range":{"start_time":{"value":100.0,"rate":24.0},"duration":{"value":100.0,"rate":24.0}}}
1435 ]
1436 }]
1437 }"#;
1438 let theirs = r#"{
1439 "OTIO_SCHEMA": "otio.schema.Timeline",
1440 "name": "MergeTest",
1441 "metadata": {},
1442 "tracks": [{
1443 "OTIO_SCHEMA": "otio.schema.Track",
1444 "name": "V1",
1445 "kind": "Video",
1446 "metadata": {},
1447 "children": [
1448 {"OTIO_SCHEMA":"otio.schema.Clip","name":"A","metadata":{},"source_range":{"start_time":{"value":0.0,"rate":24.0},"duration":{"value":100.0,"rate":24.0}}},
1449 {"OTIO_SCHEMA":"otio.schema.Clip","name":"C","metadata":{},"source_range":{"start_time":{"value":200.0,"rate":24.0},"duration":{"value":100.0,"rate":24.0}}}
1450 ]
1451 }]
1452 }"#;
1453 let driver = OtioDriver::new();
1454 let result = driver.merge(base, ours, theirs).unwrap();
1455 assert!(
1456 result.is_some(),
1457 "merge should succeed (non-conflicting adds)"
1458 );
1459 let merged = result.unwrap();
1460 assert!(
1461 merged.contains("\"B\""),
1462 "merged should contain clip B from ours"
1463 );
1464 assert!(
1465 merged.contains("\"C\""),
1466 "merged should contain clip C from theirs"
1467 );
1468 }
1469
1470 #[test]
1471 fn test_merge_conflict() {
1472 let base = r#"{
1473 "OTIO_SCHEMA": "otio.schema.Timeline",
1474 "name": "ConflictTest",
1475 "metadata": {},
1476 "tracks": [{
1477 "OTIO_SCHEMA": "otio.schema.Track",
1478 "name": "V1",
1479 "kind": "Video",
1480 "metadata": {},
1481 "children": [
1482 {"OTIO_SCHEMA":"otio.schema.Clip","name":"A","metadata":{},"source_range":{"start_time":{"value":0.0,"rate":24.0},"duration":{"value":100.0,"rate":24.0}}}
1483 ]
1484 }]
1485 }"#;
1486 let ours = r#"{
1487 "OTIO_SCHEMA": "otio.schema.Timeline",
1488 "name": "ConflictTest",
1489 "metadata": {},
1490 "tracks": [{
1491 "OTIO_SCHEMA": "otio.schema.Track",
1492 "name": "V1",
1493 "kind": "Video",
1494 "metadata": {},
1495 "children": [
1496 {"OTIO_SCHEMA":"otio.schema.Clip","name":"A","metadata":{},"source_range":{"start_time":{"value":0.0,"rate":24.0},"duration":{"value":200.0,"rate":24.0}}}
1497 ]
1498 }]
1499 }"#;
1500 let theirs = r#"{
1501 "OTIO_SCHEMA": "otio.schema.Timeline",
1502 "name": "ConflictTest",
1503 "metadata": {},
1504 "tracks": [{
1505 "OTIO_SCHEMA": "otio.schema.Track",
1506 "name": "V1",
1507 "kind": "Video",
1508 "metadata": {},
1509 "children": [
1510 {"OTIO_SCHEMA":"otio.schema.Clip","name":"A","metadata":{},"source_range":{"start_time":{"value":0.0,"rate":24.0},"duration":{"value":300.0,"rate":24.0}}}
1511 ]
1512 }]
1513 }"#;
1514 let driver = OtioDriver::new();
1515 let result = driver.merge(base, ours, theirs).unwrap();
1516 assert!(result.is_none(), "merge should detect conflict");
1517 }
1518
1519 #[test]
1520 fn test_merge_one_side_modify() {
1521 let base = r#"{
1522 "OTIO_SCHEMA": "otio.schema.Timeline",
1523 "name": "ModifyTest",
1524 "metadata": {},
1525 "tracks": [{
1526 "OTIO_SCHEMA": "otio.schema.Track",
1527 "name": "V1",
1528 "kind": "Video",
1529 "metadata": {},
1530 "children": [
1531 {"OTIO_SCHEMA":"otio.schema.Clip","name":"A","metadata":{},"source_range":{"start_time":{"value":0.0,"rate":24.0},"duration":{"value":100.0,"rate":24.0}}}
1532 ]
1533 }]
1534 }"#;
1535 let ours = r#"{
1536 "OTIO_SCHEMA": "otio.schema.Timeline",
1537 "name": "ModifyTest",
1538 "metadata": {},
1539 "tracks": [{
1540 "OTIO_SCHEMA": "otio.schema.Track",
1541 "name": "V1",
1542 "kind": "Video",
1543 "metadata": {},
1544 "children": [
1545 {"OTIO_SCHEMA":"otio.schema.Clip","name":"A","metadata":{},"source_range":{"start_time":{"value":0.0,"rate":24.0},"duration":{"value":200.0,"rate":24.0}}}
1546 ]
1547 }]
1548 }"#;
1549 let theirs = base; let driver = OtioDriver::new();
1551 let result = driver.merge(base, ours, theirs).unwrap();
1552 assert!(result.is_some());
1553 let merged = result.unwrap();
1554 assert!(
1555 merged.contains("200.0"),
1556 "merged should have ours' duration"
1557 );
1558 }
1559
1560 #[test]
1561 fn test_content_based_identity_reorder() {
1562 let base = r#"{
1565 "OTIO_SCHEMA": "otio.schema.Timeline",
1566 "name": "ReorderTest",
1567 "metadata": {},
1568 "tracks": [{
1569 "OTIO_SCHEMA": "otio.schema.Track",
1570 "name": "V1",
1571 "kind": "Video",
1572 "metadata": {},
1573 "children": [
1574 {"OTIO_SCHEMA":"otio.schema.Clip","name":"ShotA","metadata":{},"source_range":{"start_time":{"value":0.0,"rate":24.0},"duration":{"value":100.0,"rate":24.0}}},
1575 {"OTIO_SCHEMA":"otio.schema.Clip","name":"ShotB","metadata":{},"source_range":{"start_time":{"value":100.0,"rate":24.0},"duration":{"value":100.0,"rate":24.0}}}
1576 ]
1577 }]
1578 }"#;
1579 let reordered = r#"{
1580 "OTIO_SCHEMA": "otio.schema.Timeline",
1581 "name": "ReorderTest",
1582 "metadata": {},
1583 "tracks": [{
1584 "OTIO_SCHEMA": "otio.schema.Track",
1585 "name": "V1",
1586 "kind": "Video",
1587 "metadata": {},
1588 "children": [
1589 {"OTIO_SCHEMA":"otio.schema.Clip","name":"ShotB","metadata":{},"source_range":{"start_time":{"value":100.0,"rate":24.0},"duration":{"value":100.0,"rate":24.0}}},
1590 {"OTIO_SCHEMA":"otio.schema.Clip","name":"ShotA","metadata":{},"source_range":{"start_time":{"value":0.0,"rate":24.0},"duration":{"value":100.0,"rate":24.0}}}
1591 ]
1592 }]
1593 }"#;
1594 let driver = OtioDriver::new();
1595 let changes = driver.diff(Some(base), reordered).unwrap();
1596 let clip_adds: Vec<_> = changes
1598 .iter()
1599 .filter(|c| matches!(c, SemanticChange::Added { .. }))
1600 .collect();
1601 let clip_removes: Vec<_> = changes
1602 .iter()
1603 .filter(|c| matches!(c, SemanticChange::Removed { .. }))
1604 .collect();
1605 assert!(
1606 clip_adds.is_empty(),
1607 "reordering should not produce adds for same-source clips"
1608 );
1609 assert!(
1610 clip_removes.is_empty(),
1611 "reordering should not produce removes for same-source clips"
1612 );
1613 }
1614
1615 #[test]
1616 fn test_unknown_type_graceful() {
1617 let json = r#"{
1618 "OTIO_SCHEMA": "otio.schema.Gap",
1619 "name": "gap1",
1620 "metadata": {},
1621 "source_range": {"start_time": {"value": 10.0, "rate": 24.0}, "duration": {"value": 5.0, "rate": 24.0}}
1622 }"#;
1623 let result = OtioDriver::parse_and_flatten(json);
1624 assert!(
1625 result.is_ok(),
1626 "should handle unknown OTIO types gracefully"
1627 );
1628 let nodes = result.unwrap();
1629 assert!(!nodes.is_empty());
1631 }
1632
1633 #[test]
1634 fn test_parse_invalid_json() {
1635 let mut driver = LegacyOtioDriver::new();
1636 assert!(driver.parse_otio("not json").is_err());
1637 }
1638
1639 #[test]
1640 fn test_parse_missing_schema() {
1641 let mut driver = LegacyOtioDriver::new();
1642 assert!(
1643 driver
1644 .parse_otio(r#"{"name": "NoSchema", "tracks": []}"#)
1645 .is_err()
1646 );
1647 }
1648
1649 #[test]
1650 fn test_merge_no_nesting_of_documents() {
1651 let base = r#"{
1652 "OTIO_SCHEMA": "otio.schema.Timeline",
1653 "name": "NestingTest",
1654 "metadata": {},
1655 "tracks": [
1656 {
1657 "OTIO_SCHEMA": "otio.schema.Track",
1658 "name": "Video",
1659 "kind": "Video",
1660 "metadata": {},
1661 "children": [
1662 {"OTIO_SCHEMA":"otio.schema.Clip","name":"SharedClip","metadata":{},"source_range":{"start_time":{"value":0.0,"rate":24.0},"duration":{"value":100.0,"rate":24.0}}}
1663 ]
1664 },
1665 {
1666 "OTIO_SCHEMA": "otio.schema.Track",
1667 "name": "Sound",
1668 "kind": "Audio",
1669 "metadata": {},
1670 "children": []
1671 }
1672 ]
1673 }"#;
1674 let ours = r#"{
1675 "OTIO_SCHEMA": "otio.schema.Timeline",
1676 "name": "NestingTest",
1677 "metadata": {},
1678 "tracks": [
1679 {
1680 "OTIO_SCHEMA": "otio.schema.Track",
1681 "name": "Video",
1682 "kind": "Video",
1683 "metadata": {},
1684 "children": [
1685 {"OTIO_SCHEMA":"otio.schema.Clip","name":"SharedClip","metadata":{},"source_range":{"start_time":{"value":0.0,"rate":24.0},"duration":{"value":100.0,"rate":24.0}}}
1686 ]
1687 },
1688 {
1689 "OTIO_SCHEMA": "otio.schema.Track",
1690 "name": "Sound",
1691 "kind": "Audio",
1692 "metadata": {},
1693 "children": []
1694 },
1695 {
1696 "OTIO_SCHEMA": "otio.schema.Track",
1697 "name": "VFX",
1698 "kind": "Video",
1699 "metadata": {},
1700 "children": [
1701 {"OTIO_SCHEMA":"otio.schema.Clip","name":"VfxClip","metadata":{},"source_range":{"start_time":{"value":0.0,"rate":24.0},"duration":{"value":50.0,"rate":24.0}}}
1702 ]
1703 }
1704 ]
1705 }"#;
1706 let theirs = r#"{
1707 "OTIO_SCHEMA": "otio.schema.Timeline",
1708 "name": "NestingTest",
1709 "metadata": {},
1710 "tracks": [
1711 {
1712 "OTIO_SCHEMA": "otio.schema.Track",
1713 "name": "Video",
1714 "kind": "Video",
1715 "metadata": {},
1716 "children": [
1717 {"OTIO_SCHEMA":"otio.schema.Clip","name":"SharedClip","metadata":{},"source_range":{"start_time":{"value":0.0,"rate":24.0},"duration":{"value":100.0,"rate":24.0}}}
1718 ]
1719 },
1720 {
1721 "OTIO_SCHEMA": "otio.schema.Track",
1722 "name": "Sound",
1723 "kind": "Audio",
1724 "metadata": {},
1725 "children": []
1726 },
1727 {
1728 "OTIO_SCHEMA": "otio.schema.Track",
1729 "name": "Music",
1730 "kind": "Audio",
1731 "metadata": {},
1732 "children": [
1733 {"OTIO_SCHEMA":"otio.schema.Clip","name":"MusicClip","metadata":{},"source_range":{"start_time":{"value":0.0,"rate":24.0},"duration":{"value":200.0,"rate":24.0}}}
1734 ]
1735 }
1736 ]
1737 }"#;
1738 let driver = OtioDriver::new();
1739 let result = driver.merge(base, ours, theirs).unwrap();
1740 assert!(result.is_some(), "merge should succeed");
1741 let merged: serde_json::Value = serde_json::from_str(&result.unwrap()).unwrap();
1742
1743 let tracks = merged.get("tracks").unwrap().as_array().unwrap();
1744
1745 assert_eq!(
1746 tracks.len(),
1747 4,
1748 "should have 4 tracks: Video, Sound, VFX, Music"
1749 );
1750
1751 for track in tracks {
1752 let schema = track.get("OTIO_SCHEMA").and_then(|v| v.as_str()).unwrap();
1753 assert_eq!(
1754 schema, "otio.schema.Track",
1755 "every item in tracks must be a Track, got: {}",
1756 schema
1757 );
1758 }
1759
1760 let track_names: Vec<&str> = tracks
1761 .iter()
1762 .filter_map(|t| t.get("name").and_then(|n| n.as_str()))
1763 .collect();
1764 assert!(track_names.contains(&"Video"), "should have Video track");
1765 assert!(track_names.contains(&"Sound"), "should have Sound track");
1766 assert!(
1767 track_names.contains(&"VFX"),
1768 "should have VFX track from ours"
1769 );
1770 assert!(
1771 track_names.contains(&"Music"),
1772 "should have Music track from theirs"
1773 );
1774
1775 let vfx_track = tracks
1776 .iter()
1777 .find(|t| t.get("name").and_then(|n| n.as_str()) == Some("VFX"))
1778 .unwrap();
1779 let vfx_children = vfx_track.get("children").unwrap().as_array().unwrap();
1780 assert_eq!(vfx_children.len(), 1, "VFX track should have 1 clip");
1781 assert_eq!(
1782 vfx_children[0].get("name").and_then(|n| n.as_str()),
1783 Some("VfxClip")
1784 );
1785
1786 let music_track = tracks
1787 .iter()
1788 .find(|t| t.get("name").and_then(|n| n.as_str()) == Some("Music"))
1789 .unwrap();
1790 let music_children = music_track.get("children").unwrap().as_array().unwrap();
1791 assert_eq!(music_children.len(), 1, "Music track should have 1 clip");
1792 assert_eq!(
1793 music_children[0].get("name").and_then(|n| n.as_str()),
1794 Some("MusicClip")
1795 );
1796 }
1797
1798 #[test]
1799 fn test_merge_clips_in_correct_parent_not_tracks_array() {
1800 let base = r#"{
1801 "OTIO_SCHEMA": "otio.schema.Timeline",
1802 "name": "ClipPlacementTest",
1803 "metadata": {},
1804 "tracks": [{
1805 "OTIO_SCHEMA": "otio.schema.Track",
1806 "name": "V1",
1807 "kind": "Video",
1808 "metadata": {},
1809 "children": [
1810 {"OTIO_SCHEMA":"otio.schema.Clip","name":"A","metadata":{},"source_range":{"start_time":{"value":0.0,"rate":24.0},"duration":{"value":100.0,"rate":24.0}}}
1811 ]
1812 }]
1813 }"#;
1814 let ours = r#"{
1815 "OTIO_SCHEMA": "otio.schema.Timeline",
1816 "name": "ClipPlacementTest",
1817 "metadata": {},
1818 "tracks": [{
1819 "OTIO_SCHEMA": "otio.schema.Track",
1820 "name": "V1",
1821 "kind": "Video",
1822 "metadata": {},
1823 "children": [
1824 {"OTIO_SCHEMA":"otio.schema.Clip","name":"A","metadata":{},"source_range":{"start_time":{"value":0.0,"rate":24.0},"duration":{"value":100.0,"rate":24.0}}},
1825 {"OTIO_SCHEMA":"otio.schema.Clip","name":"B","metadata":{},"source_range":{"start_time":{"value":100.0,"rate":24.0},"duration":{"value":100.0,"rate":24.0}}}
1826 ]
1827 }]
1828 }"#;
1829 let theirs = r#"{
1830 "OTIO_SCHEMA": "otio.schema.Timeline",
1831 "name": "ClipPlacementTest",
1832 "metadata": {},
1833 "tracks": [{
1834 "OTIO_SCHEMA": "otio.schema.Track",
1835 "name": "V1",
1836 "kind": "Video",
1837 "metadata": {},
1838 "children": [
1839 {"OTIO_SCHEMA":"otio.schema.Clip","name":"A","metadata":{},"source_range":{"start_time":{"value":0.0,"rate":24.0},"duration":{"value":100.0,"rate":24.0}}},
1840 {"OTIO_SCHEMA":"otio.schema.Clip","name":"C","metadata":{},"source_range":{"start_time":{"value":200.0,"rate":24.0},"duration":{"value":100.0,"rate":24.0}}}
1841 ]
1842 }]
1843 }"#;
1844 let driver = OtioDriver::new();
1845 let result = driver.merge(base, ours, theirs).unwrap().unwrap();
1846 let merged: serde_json::Value = serde_json::from_str(&result).unwrap();
1847
1848 let tracks = merged.get("tracks").unwrap().as_array().unwrap();
1849 assert_eq!(tracks.len(), 1, "should have 1 track");
1850
1851 let v1_children = tracks[0].get("children").unwrap().as_array().unwrap();
1852 let child_names: Vec<&str> = v1_children
1853 .iter()
1854 .filter_map(|c| c.get("name").and_then(|n| n.as_str()))
1855 .collect();
1856 assert!(child_names.contains(&"A"), "should have clip A");
1857 assert!(child_names.contains(&"B"), "should have clip B from ours");
1858 assert!(child_names.contains(&"C"), "should have clip C from theirs");
1859
1860 for child in v1_children {
1861 let schema = child.get("OTIO_SCHEMA").and_then(|v| v.as_str()).unwrap();
1862 assert_eq!(
1863 schema, "otio.schema.Clip",
1864 "every item in track children must be a Clip, got: {}",
1865 schema
1866 );
1867 }
1868 }
1869}