Skip to main content

suture_driver_otio/
lib.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2#![allow(clippy::collapsible_match)]
3//! OTIO semantic driver — timeline-level diff and merge for OpenTimelineIO files.
4//!
5//! ## Architecture
6//!
7//! OpenTimelineIO (OTIO) files are JSON documents describing video editing timelines.
8//! The key challenge is identity: OTIO doesn't require unique IDs for elements,
9//! so we use content-based identity heuristics.
10//!
11//! Identity strategy:
12//! - **Clips:** Identified by `media_reference.target_url` + `source_range.start_time`
13//!   (fallback: `name` + `source_range`). This means renaming a clip without changing
14//!   its source preserves identity.
15//! - **Tracks/Stacks:** Identified by `name` + `kind` + position in parent.
16//! - **Transitions:** Identified by `name` + position in parent.
17//! - **Timeline:** Always identity element (there's only one root).
18//!
19//! The merge operates at the JSON level: it parses the OTIO JSON, compares
20//! element trees using content-based identity, performs a three-way merge,
21//! and serializes the result back to JSON.
22
23use 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// =============================================================================
50// OTIO Schema Types (minimal subset of OpenTimelineIO)
51//
52// These types store children as serde_json::Value (not OtioNode) so they
53// can derive Serialize/Deserialize.  We convert to/from OtioNode manually.
54// =============================================================================
55
56/// All recognized OTIO node types. Unknown types are stored as opaque JSON.
57#[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 OTIO type — stored as opaque JSON to avoid parse failures.
66    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    /// Return child OtioNodes for containers that hold them.
86    #[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    /// Serialize this node back to a JSON value.
110    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
123// --- Serde-friendly struct types (children stored as raw JSON) ---
124
125fn 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    /// Children stored as raw JSON so Timeline can derive Serialize/Deserialize.
143    #[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
251// =============================================================================
252// OTIO JSON Parsing (with unknown type handling)
253// =============================================================================
254
255fn 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    // Try to deserialize as known types
268    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        // Unknown schema — store as opaque JSON (graceful degradation)
290        _ => 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    // Validate it has OTIO_SCHEMA
300    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
313// =============================================================================
314// Content-Based Identity
315// =============================================================================
316
317/// Compute a content-based identity fingerprint for an OTIO node.
318///
319/// The fingerprint is used to match elements across base/ours/theirs versions.
320/// - Clips: `name` + `source_range.start_time` + `media_reference.target_url`
321/// - Tracks/Stacks: `name` + `kind`
322/// - Transitions: `name` + `in_offset`
323/// - Unknown: JSON serialization hash
324fn 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            // Extract target_url from media_reference
336            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// =============================================================================
375// Tree Diffing
376// =============================================================================
377
378/// Recursively collect all nodes with their paths and fingerprints.
379#[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    // Detect additions
440    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    // Detect removals
454    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    // Detect modifications (same fingerprint but different JSON)
468    // Skip containers (Timeline, Track, Stack, SerializableCollection) — their
469    // changes are already captured by child-level adds/removes/modifications.
470    for new_node in new_nodes {
471        if let Some(base_node) = base_by_fp.get(new_node.fingerprint.as_str()) {
472            // Only check leaf nodes for modifications
473            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
498// =============================================================================
499// Three-Way Merge
500// =============================================================================
501
502/// Three-way merge of OTIO element trees.
503///
504/// Strategy:
505/// - Build flat node lists from base/ours/theirs with content fingerprints
506/// - Match nodes by fingerprint across versions
507/// - For unmatched nodes: additions by one side are included; removals by both are honored
508/// - For matched nodes with modifications: if only one side modified, take that; if both modified identically, take either; if both modified differently, CONFLICT
509fn 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    // Collect all nodes that should be in the merged result
535    let mut merged_nodes: Vec<(String, FlatNode)> = Vec::new(); // (fingerprint, node)
536
537    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            // All three have it — check for modifications (leaf nodes only)
544            (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                // Only consider leaf nodes as "modified" — container changes
550                // are captured by child-level adds/removes/modifications.
551                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                            // Genuine conflict
574                            return None;
575                        }
576                    }
577                }
578            }
579
580            // Added by ours only
581            (false, true, false) => {
582                merged_nodes.push((fp.to_string(), ours_by_fp[fp].clone()));
583            }
584
585            // Added by theirs only
586            (false, false, true) => {
587                merged_nodes.push((fp.to_string(), theirs_by_fp[fp].clone()));
588            }
589
590            // Added by both
591            (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            // Removed by ours, kept by theirs — non-conflicting delete
600            (true, false, true) => {
601                // Delete wins
602            }
603
604            // Kept by ours, removed by theirs — non-conflicting delete
605            (true, true, false) => {
606                // Delete wins
607            }
608
609            // Removed by both
610            (true, false, false) => {
611                // Delete wins
612            }
613
614            (false, false, false) => {
615                return None;
616            }
617        }
618    }
619
620    // Reconstruct the OTIO JSON from the merged flat nodes.
621    // Use ours as the structural template, then replace children with merged nodes.
622    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    // Use whichever version has the root node
633    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    // Rebuild the tree by matching fingerprints
642    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
649/// Recursively rebuild children arrays using the merged node set.
650///
651/// `placed_fps` tracks all fingerprints already placed somewhere in the tree,
652/// so the second pass doesn't add duplicates to wrong containers.
653fn 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
713// =============================================================================
714// SutureDriver Implementation
715// =============================================================================
716
717pub 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// =============================================================================
815// Legacy API (kept for backward compatibility with E2E tests)
816// =============================================================================
817
818#[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
877/// Legacy OtioDriver that supports the old API.
878pub 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                // Skip unknown nodes in legacy mode
974            }
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// =============================================================================
1111// Tests
1112// =============================================================================
1113
1114#[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    // --- Legacy API tests (preserved) ---
1162
1163    #[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    // --- SutureDriver implementation tests ---
1255
1256    #[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; // unchanged
1550        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        // Two clips with same fingerprint (same source) but reordered
1563        // should be treated as the same clip, not add/remove
1564        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        // Reordering should NOT produce adds/removes for clips with same source
1597        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        // The Gap should be in the flat list
1630        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}