collective

Browse / github.com/storytold/filmcraft / patch

interchange: infer OTIO canvas from linked source video

interchange: infer OTIO canvas from linked source video OTIO imports without explicit sequence settings now derive their canvas from the first successfully linked video in video-track and timeline order. This fixes a portrait source opening in the default landscape canvas. Explicit settings (including 1920×1080) remain authoritative, and unavailable media keep the existing fallback. Nested sequences preserve explicit canvases; inferred canvases are resolved before automatic Motion positions/anchors. The affected import, linking and sizing edits share one undo step. Frame rate remains unchanged. Refs #3256. Validation: - Real host-probed relative-

Status
open
Score
0.00
Adopters
0 other operators
Author
jnohclee-rgb (imported from pull request #3263, open)
Base
a504f3c5adc4cd60d9c1120916cddb50c9b24b86
Size
+249 −12 in 5 files
Review
pending
Shared
2026-10-28
Id
c2136cf12ee92d020e9bfae5

How it went

No other operator has reported using it yet.

Diff

raw · crates/engine/src/interchange.rs crates/engine/src/interchange_auto_points_tests.rs crates/interchange/src/common.rs crates/interchange/src/lib.rs crates/interchange/src/otio.rs

diff --git a/crates/engine/src/interchange.rs b/crates/engine/src/interchange.rs--- a/crates/engine/src/interchange.rs+++ b/crates/engine/src/interchange.rs@@ -80,16 +80,37 @@ pub fn import(s: &mut Session, path: &str, bytes: &[u8], format: Format) -> Resu             sequence.settings.validate().map_err(|e| EngineError::Other(format!("{path}: invalid imported sequence settings: {e}")))?;         }     }-    let before: std::collections::HashSet<ItemId> = s.project.items.keys().copied().collect();     let name = opts.name.clone().unwrap_or_else(|| format.name().to_string());-    let seqs = s.edit(&format!("Import {name}"), |proj, _| {-        let seqs = filmcraft_interchange::merge_into(proj, fragment, None);+    let infer_size = !fragment.source_size_sequences.is_empty();+    let apply = |session: &mut Session| import_fragment(session, path, fragment, report, format, &name);+    if infer_size {+        // Sizing and its auto-points must undo with the import, never expose a half-sized cut.+        s.edit_group("Import OTIO", apply)+    } else {+        apply(s)+    }+}++fn import_fragment(+    s: &mut Session,+    path: &str,+    fragment: filmcraft_interchange::Imported,+    report: filmcraft_interchange::Report,+    format: Format,+    name: &str,+) -> Result<Value> {+    let before: std::collections::HashSet<ItemId> = s.project.items.keys().copied().collect();+    let (seqs, source_size_sequences) = s.edit(&format!("Import {name}"), |proj, _| {+        let (seqs, source_size_sequences) = filmcraft_interchange::merge_with_provenance(proj, fragment, None);         // A document that sets only some Motion parameters leaves the others at their "auto"         // (NaN) defaults. Resolve them as placing a clip does, for every format. A media file's         // picture size is only what the document says so far (an EDL says nothing: the importer         // assumes the sequence size), so its clips' anchors wait for Link Media below.-        proj.resolve_placed_auto_points(|seq| !before.contains(&seq), |source| !is_file_media(source));-        Ok(seqs)+        proj.resolve_placed_auto_points(+            |seq| !before.contains(&seq) && !source_size_sequences.contains(&seq),+            |source| !is_file_media(source) && !source_size_sequences.contains(&source.id),+        );+        Ok((seqs, source_size_sequences))     })?;     // Link media: probe each new file-backed item that exists, through the host's reader as a     // direct import does (only the container index is read now, not the whole file: #461).@@ -109,6 +130,7 @@ pub fn import(s: &mut Session, path: &str, bytes: &[u8], format: Format) -> Resu     // The document's media paths are opened as stored paths of the document.     s.media.add_stored_paths(new_media.iter().map(|(_, p)| p.clone()), Some(path));     let mut linked = 0;+    let mut linked_items = std::collections::HashSet::new();     let mut offline = Vec::new();     for (id, mpath) in new_media {         let file = match s.media.follow(&mpath) {@@ -137,15 +159,37 @@ pub fn import(s: &mut Session, path: &str, bytes: &[u8], format: Format) -> Resu                         }                     }                     // the picture size is now the file's own: centre the anchors of its clips in it-                    proj.resolve_placed_auto_points(|seq| !before.contains(&seq), |source| source.id == id);+                    proj.resolve_placed_auto_points(|seq| !before.contains(&seq) && !source_size_sequences.contains(&seq), |source| source.id == id);                     Ok(())                 })?;                 s.media.insert_file(id, &mpath, src);                 linked += 1;+                linked_items.insert(id);             }             None => offline.push(mpath),         }     }+    if !source_size_sequences.is_empty() {+        s.edit("Size imported sequences", |proj, _| {+            let sizes: Vec<_> = source_size_sequences+                .iter()+                .filter_map(|&id| first_linked_picture(proj, id, &source_size_sequences, &linked_items).map(|size| (id, size)))+                .collect();+            for (id, (width, height)) in sizes {+                if let Some(sequence) = proj.sequence_mut(id) {+                    let mut settings = sequence.settings.clone();+                    settings.width = width;+                    settings.height = height;+                    settings.preset = format!("Custom {width}x{height} {}", settings.frame_rate.label());+                    if settings.validate().is_ok() {+                        sequence.settings = settings;+                    }+                }+            }+            proj.resolve_placed_auto_points(|seq| !before.contains(&seq), |_| true);+            Ok(())+        })?;+    }     if let Some(&first) = seqs.first() {         s.state.active_sequence = Some(first);         if !s.state.open_sequences.contains(&first) {@@ -161,6 +205,46 @@ pub fn import(s: &mut Session, path: &str, bytes: &[u8], format: Format) -> Resu     })) } +// Choose the earliest picture in each video track, visiting tracks in document order.+// Only successfully linked files provide dimensions; parser placeholders are not evidence.+fn first_linked_picture(+    project: &filmcraft_project::Project,+    root: ItemId,+    source_size_sequences: &[ItemId],+    linked: &std::collections::HashSet<ItemId>,+) -> Option<(u32, u32)> {+    let mut visited = std::collections::HashSet::new();+    let mut pending = vec![root];+    while let Some(id) = pending.pop() {+        if !visited.insert(id) {+            continue;+        }+        let Some(item) = project.item(id) else { continue };+        match &item.kind {+            ItemKind::Media(media) if linked.contains(&id) => {+                if let Some(video) = &media.info.video+                    && filmcraft_project::validate_frame_size(video.width, video.height).is_ok()+                {+                    return Some((video.width, video.height));+                }+            }+            ItemKind::Subclip { parent, .. } => pending.push(*parent),+            ItemKind::Sequence(sequence) => {+                if !source_size_sequences.contains(&id) {+                    return Some((sequence.settings.width, sequence.settings.height));+                }+                for track in sequence.video_tracks.iter().rev() {+                    let mut clips: Vec<_> = track.items.iter().collect();+                    clips.sort_by_key(|clip| clip.start);+                    pending.extend(clips.into_iter().rev().map(|clip| clip.item));+                }+            }+            _ => {}+        }+    }+    None+}+ /// `file.exportInterchange {format: "edl"|"xml"|"fcpxml"|"otio"|"aaf"|"omf" = "xml", path, sequence?}` pub fn export(s: &mut Session, p: &Value) -> Result<Value> {     // FCP7 XML only when no format is asked for: a format we don't know is an error, not XML.diff --git a/crates/engine/src/interchange_auto_points_tests.rs b/crates/engine/src/interchange_auto_points_tests.rs--- a/crates/engine/src/interchange_auto_points_tests.rs+++ b/crates/engine/src/interchange_auto_points_tests.rs@@ -256,3 +256,145 @@ fn an_interchange_import_links_media_without_reading_the_files_whole() {     assert_eq!(s.project.source_size(clip.item), Some((320, 180)), "the file's real size, from its index");     let _ = std::fs::remove_dir_all(&d); }++#[test]+fn otio_without_sequence_settings_uses_the_linked_picture_size() {+    let d = tmp_dir("otio-source-size");+    make_movie(&d.join("portrait.mov"), DemoScene::OceanSunset, 48, 80, 24);+    let range = json!({"OTIO_SCHEMA":"TimeRange.1","start_time":{"OTIO_SCHEMA":"RationalTime.1","value":0,"rate":24},"duration":{"OTIO_SCHEMA":"RationalTime.1","value":24,"rate":24}});+    let doc = json!({"OTIO_SCHEMA":"Timeline.1","name":"Portrait cut","tracks":{"OTIO_SCHEMA":"Stack.1","children":[{"OTIO_SCHEMA":"Track.1","kind":"Video","children":[{"OTIO_SCHEMA":"Clip.2","name":"Portrait","source_range":range,"media_reference":{"OTIO_SCHEMA":"ExternalReference.1","target_url":"portrait.mov","available_range":range}}]}]}});+    let path = d.join("cut.otio");+    std::fs::write(&path, serde_json::to_vec(&doc).unwrap()).unwrap();+    let mut s = Session::default();+    let (result, seq) = import(&mut s, &path);+    assert_eq!(result["linkedMedia"], 1, "the host actually probed the local source");+    let settings = &s.project.sequence(seq).unwrap().settings;+    assert_eq!((settings.width, settings.height), (48, 80));+    let clip = first_clip(&s, seq);+    assert_eq!(motion_point(&clip, "position"), Vec2::new(24.0, 40.0), "resolve placement against the inferred canvas");+    assert_eq!(motion_point(&clip, "anchor"), Vec2::new(24.0, 40.0));+}++fn portrait_otio(path: &str) -> serde_json::Value {+    let range = json!({"OTIO_SCHEMA":"TimeRange.1","start_time":{"OTIO_SCHEMA":"RationalTime.1","value":0,"rate":24},"duration":{"OTIO_SCHEMA":"RationalTime.1","value":24,"rate":24}});+    json!({"OTIO_SCHEMA":"Timeline.1","name":"Cut","tracks":{"OTIO_SCHEMA":"Stack.1","children":[{"OTIO_SCHEMA":"Track.1","kind":"Video","children":[{"OTIO_SCHEMA":"Clip.2","source_range":range,"media_reference":{"OTIO_SCHEMA":"ExternalReference.1","target_url":path,"available_range":range}}]}]}})+}++#[test]+fn otio_explicit_hd_canvas_overrides_a_linked_portrait() {+    let d = tmp_dir("otio-explicit-size");+    make_movie(&d.join("portrait.mov"), DemoScene::OceanSunset, 48, 80, 24);+    let mut doc = portrait_otio("portrait.mov");+    doc["metadata"] = json!({"filmcraft":{"settings":filmcraft_project::SequenceSettings::default()}});+    let path = d.join("cut.otio");+    std::fs::write(&path, serde_json::to_vec(&doc).unwrap()).unwrap();+    let mut session = Session::default();+    let (_, id) = import(&mut session, &path);+    let settings = &session.project.sequence(id).unwrap().settings;+    assert_eq!((settings.width, settings.height), (1920, 1080));+    assert_eq!(motion_point(&first_clip(&session, id), "position"), Vec2::new(960., 540.));+    std::fs::remove_dir_all(d).unwrap();+}++#[test]+fn otio_offline_picture_keeps_the_default_canvas() {+    let d = tmp_dir("otio-offline-size");+    let path = d.join("cut.otio");+    std::fs::write(&path, serde_json::to_vec(&portrait_otio("absent.mov")).unwrap()).unwrap();+    let mut session = Session::default();+    let (result, id) = import(&mut session, &path);+    assert_eq!(result["linkedMedia"], 0);+    let settings = &session.project.sequence(id).unwrap().settings;+    assert_eq!((settings.width, settings.height), (1920, 1080));+    assert_eq!(motion_point(&first_clip(&session, id), "position"), Vec2::new(960., 540.));+    std::fs::remove_dir_all(d).unwrap();+}++#[test]+fn otio_nested_canvas_uses_explicit_settings_or_the_linked_picture() {+    let d = tmp_dir("otio-nested-size");+    make_movie(&d.join("portrait.mov"), DemoScene::OceanSunset, 48, 80, 24);+    for explicit in [false, true] {+        let original = portrait_otio("portrait.mov");+        let mut nested = original["tracks"].clone();+        nested["source_range"] = original["tracks"]["children"][0]["children"][0]["source_range"].clone();+        if explicit {+            nested["metadata"] = json!({"filmcraft":{"settings":filmcraft_project::SequenceSettings::default()}});+        }+        let mut doc = original;+        doc["tracks"]["children"][0]["children"] = json!([nested]);+        let path = d.join("cut.otio");+        std::fs::write(&path, serde_json::to_vec(&doc).unwrap()).unwrap();+        let mut session = Session::default();+        let (_, id) = import(&mut session, &path);+        let settings = &session.project.sequence(id).unwrap().settings;+        let expected = if explicit { (1920, 1080) } else { (48, 80) };+        assert_eq!((settings.width, settings.height), expected);+        assert_eq!(motion_point(&first_clip(&session, id), "position"), Vec2::new(expected.0 as f64 / 2., expected.1 as f64 / 2.));+        assert!(auto_points(&session.project).is_empty());+    }+    std::fs::remove_dir_all(d).unwrap();+}++#[test]+fn otio_source_sizing_and_auto_points_undo_and_redo_together() {+    let d = tmp_dir("otio-size-history");+    make_movie(&d.join("portrait.mov"), DemoScene::OceanSunset, 48, 80, 24);+    let path = d.join("cut.otio");+    std::fs::write(&path, serde_json::to_vec(&portrait_otio("portrait.mov")).unwrap()).unwrap();+    let mut session = Session::default();+    let before = session.project.clone();+    let (_, _) = import(&mut session, &path);+    let imported = session.project.clone();+    session.execute("edit.undo", json!({})).unwrap();+    assert_eq!(*session.project, *before);+    session.execute("edit.redo", json!({})).unwrap();+    assert_eq!(*session.project, *imported);+    std::fs::remove_dir_all(d).unwrap();+}++#[test]+fn otio_picture_choice_follows_video_timeline_not_media_allocation() {+    let d = tmp_dir("otio-picture-order");+    make_movie(&d.join("portrait.mov"), DemoScene::OceanSunset, 48, 80, 24);+    make_movie(&d.join("landscape.mov"), DemoScene::OceanSunset, 80, 48, 24);+    let mut doc = portrait_otio("portrait.mov");+    let landscape = portrait_otio("landscape.mov");+    let mut audio = landscape["tracks"]["children"][0].clone();+    audio["kind"] = json!("Audio");+    let mut video = doc["tracks"]["children"][0].clone();+    video["children"].as_array_mut().unwrap().push(landscape["tracks"]["children"][0]["children"][0].clone());+    // Audio creates landscape's media item first; it must not choose the video canvas.+    doc["tracks"]["children"] = json!([audio, video]);+    let path = d.join("cut.otio");+    std::fs::write(&path, serde_json::to_vec(&doc).unwrap()).unwrap();+    let mut session = Session::default();+    let (result, id) = import(&mut session, &path);+    assert_eq!(result["linkedMedia"], 2);+    let settings = &session.project.sequence(id).unwrap().settings;+    assert_eq!((settings.width, settings.height), (48, 80));+    assert_eq!(settings.frame_rate, filmcraft_time::FrameRate::new(24, 1));+    assert_eq!(settings.preset, "Custom 48x80 24");+    assert!(auto_points(&session.project).is_empty());+    std::fs::remove_dir_all(d).unwrap();+}++#[test]+fn otio_explicit_parent_waits_for_an_inferred_nested_anchor() {+    let d = tmp_dir("otio-nested-anchor");+    make_movie(&d.join("portrait.mov"), DemoScene::OceanSunset, 48, 80, 24);+    let original = portrait_otio("portrait.mov");+    let mut nested = original["tracks"].clone();+    nested["source_range"] = original["tracks"]["children"][0]["children"][0]["source_range"].clone();+    let mut doc = original;+    doc["metadata"] = json!({"filmcraft":{"settings":filmcraft_project::SequenceSettings::default()}});+    doc["tracks"]["children"][0]["children"] = json!([nested]);+    let path = d.join("cut.otio");+    std::fs::write(&path, serde_json::to_vec(&doc).unwrap()).unwrap();+    let mut session = Session::default();+    let (_, id) = import(&mut session, &path);+    let clip = first_clip(&session, id);+    assert_eq!(motion_point(&clip, "position"), Vec2::new(960., 540.));+    assert_eq!(motion_point(&clip, "anchor"), Vec2::new(24., 40.));+    std::fs::remove_dir_all(d).unwrap();+}diff --git a/crates/interchange/src/common.rs b/crates/interchange/src/common.rs--- a/crates/interchange/src/common.rs+++ b/crates/interchange/src/common.rs@@ -348,12 +348,13 @@ pub(crate) struct Builder {     pub p: Project,     media: HashMap<String, ItemId>,     pub top: Vec<ItemId>,+    pub source_size_sequences: Vec<ItemId>,     next_link: u64, }  impl Builder {     pub fn new(name: &str) -> Self {-        Self { p: Project::new(name), media: HashMap::new(), top: Vec::new(), next_link: 1 }+        Self { p: Project::new(name), media: HashMap::new(), top: Vec::new(), source_size_sequences: Vec::new(), next_link: 1 }     }      pub fn bin(&mut self, name: &str, parent: Option<BinId>) -> BinId {@@ -512,7 +513,7 @@ impl Builder {                 m.info.duration = end;             }         }-        Imported { project: self.p, sequences: self.top }+        Imported { project: self.p, sequences: self.top, source_size_sequences: self.source_size_sequences }     } } diff --git a/crates/interchange/src/lib.rs b/crates/interchange/src/lib.rs--- a/crates/interchange/src/lib.rs+++ b/crates/interchange/src/lib.rs@@ -236,6 +236,8 @@ pub struct Imported {     pub project: Project,     /// Top-level sequences, in document order (nested sequences are items too but not listed here).     pub sequences: Vec<ItemId>,+    /// OTIO sequences without explicit settings, eligible for host-probed picture sizing.+    pub source_size_sequences: Vec<ItemId>, }  /// Import options beyond the defaults of [`import`].@@ -424,7 +426,12 @@ pub fn export_edl_per_track(project: &Project, sequence: ItemId, opts: &ExportOp /// fragment's root children go into `bin` (or the root). Returns the new ids of the fragment's /// top-level sequences. pub fn merge_into(target: &mut Project, fragment: Imported, bin: Option<BinId>) -> Vec<ItemId> {-    let Imported { project: frag, sequences } = fragment;+    merge_with_provenance(target, fragment, bin).0+}++/// Merge a fragment, also returning remapped sequences eligible for source picture sizing.+pub fn merge_with_provenance(target: &mut Project, fragment: Imported, bin: Option<BinId>) -> (Vec<ItemId>, Vec<ItemId>) {+    let Imported { project: frag, sequences, source_size_sequences } = fragment;     let mut items: HashMap<ItemId, ItemId> = HashMap::new();     for id in frag.items.keys() {         items.insert(*id, ItemId(target.alloc_id()));@@ -498,7 +505,7 @@ pub fn merge_into(target: &mut Project, fragment: Imported, bin: Option<BinId>)         None => &mut target.root,     };     dest.children.extend(root.children);-    sequences.iter().filter_map(|s| items.get(s).copied()).collect()+    (sequences.iter().filter_map(|s| items.get(s).copied()).collect(), source_size_sequences.iter().filter_map(|s| items.get(s).copied()).collect()) }  /// After probing a media file imported from an EDL (whose source times are absolute timecode),diff --git a/crates/interchange/src/otio.rs b/crates/interchange/src/otio.rs--- a/crates/interchange/src/otio.rs+++ b/crates/interchange/src/otio.rs@@ -434,9 +434,12 @@ impl Imp<'_, '_> {     }      fn sequence(&mut self, stack: &Value, name: &str, meta: Option<&Value>, rate: FrameRate) -> ItemId {-        let settings: SequenceSettings =-            meta.and_then(|m| m.get("settings")).and_then(|s| serde_json::from_value(s.clone()).ok()).unwrap_or_else(|| settings_for(rate, 1920, 1080, false));+        let explicit: Option<SequenceSettings> = meta.and_then(|m| m.get("settings")).and_then(|s| serde_json::from_value(s.clone()).ok());+        let settings = explicit.clone().unwrap_or_else(|| settings_for(rate, 1920, 1080, false));         let id = self.b.reserve_sequence(name, settings.clone(), None);+        if explicit.is_none() {+            self.b.source_size_sequences.push(id);+        }         if let Some(l) = meta.and_then(|m| m.get("label")).and_then(|l| serde_json::from_value::<Label>(l.clone()).ok())             && let Some(it) = self.b.p.item_mut(id)         {