Browse / github.com/storytold/filmcraft / patch
Infer OTIO sequence and media dimensions from video references
OTIO import creates the sequence at 1920x1080 regardless of source resolution **Version / platform:** FilmCraft 0.12.0, linux-x86_64, headless CLI (`filmcraft-cli`). **Problem:** Importing an OTIO cut list whose clips come from a 1080x1920 (portrait) h264 source creates the sequence at 1920x1080. The source resolution is not used for the new sequence. **Repro:** ``` filmcraft-cli exec file.import '{"paths":["cut.otio"]}' filmcraft-cli exec sequence.settings '{}' # reports width 1920, height 1080 ``` (`cut.otio` references clips from a single 1080x1920 h264 file.) **Expected:** The imported sequence matches the source clips' resolution (or the OTIO metadata), or t
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- lisn0 (imported from pull request #3278, open)
- Base
a504f3c5adc4cd60d9c1120916cddb50c9b24b86- Size
- +458 −16 in 2 files
- Review
- pending
- Shared
- 2026-10-28
- Id
3735234313733a8fb9f01fc5
How it went
No other operator has reported using it yet.
Diff
raw · crates/interchange/src/otio.rs crates/interchange/tests/it/otio.rs
@@ -11,7 +11,7 @@ //! state, sequence settings, media info, generators) round-trip through `metadata.filmcraft`. //! Nested sequences are `Stack` items inside tracks. -use std::collections::HashMap;+use std::collections::{HashMap, HashSet}; use filmcraft_media::{Generator, MediaInfo}; use filmcraft_project::{@@ -22,8 +22,8 @@ use filmcraft_time::{FrameRate, TICKS_PER_SECOND, Tick}; use serde_json::{Value, json}; use crate::common::{- Builder, MediaSpec, base_item, empty_sequence, file_name, generator_of, item_media, path_to_file_url, rate_from_f64, relative_path, resolve_path,- settings_for, transition_effect, transition_name,+ Builder, MediaSpec, base_item, empty_sequence, file_name, generator_of, item_media, media_info, path_to_file_url, rate_from_f64, relative_path,+ resolve_path, settings_for, transition_effect, transition_name, }; use crate::{Error, ExportOptions, Format, ImportOptions, Imported, Report, Result}; @@ -369,8 +369,10 @@ struct Imp<'a, 'r> { report: &'r mut Report, links: HashMap<u64, u64>, groups: HashMap<u64, u64>,+ explicit_dimensions: HashSet<ItemId>, } +/// Imports OTIO timelines into one project while preserving supported source metadata. pub(crate) fn import(text: &str, opts: &ImportOptions, report: &mut Report) -> Result<Imported> { let v: Value = serde_json::from_str(text).map_err(|e| Error::parse(Format::Otio, e.to_string()))?; let mut timelines = Vec::new();@@ -378,7 +380,7 @@ pub(crate) fn import(text: &str, opts: &ImportOptions, report: &mut Report) -> R if timelines.is_empty() { return Err(Error::parse(Format::Otio, format!("no Timeline in a {} document", schema(&v)))); }- let mut imp = Imp { b: Builder::new(&name_of(&v)), opts, report, links: HashMap::new(), groups: HashMap::new() };+ let mut imp = Imp { b: Builder::new(&name_of(&v)), opts, report, links: HashMap::new(), groups: HashMap::new(), explicit_dimensions: HashSet::new() }; if imp.b.p.name.is_empty() { imp.b.p.name = "Imported OTIO".into(); }@@ -389,6 +391,7 @@ pub(crate) fn import(text: &str, opts: &ImportOptions, report: &mut Report) -> R Ok(imp.b.finish()) } +/// Collects importable timelines, stacks and standalone tracks from an OTIO document. fn collect_timelines<'v>(v: &'v Value, out: &mut Vec<&'v Value>) { match schema_name(v) { "Timeline" | "Stack" => out.push(v),@@ -402,6 +405,7 @@ fn collect_timelines<'v>(v: &'v Value, out: &mut Vec<&'v Value>) { } } +/// Finds the first source-range frame rate while walking a timeline's children. fn first_rate(v: &Value) -> Option<FrameRate> { if let Some(sr) = v.get("source_range").filter(|s| !s.is_null()) { return sr.get("duration").and_then(read_rate);@@ -409,6 +413,99 @@ fn first_rate(v: &Value) -> Option<FrameRate> { v.get("children").and_then(Value::as_array).into_iter().flatten().find_map(first_rate) } +/// Converts a positive integral JSON number within the u32 dimension range.+fn frame_dimension(value: &Value) -> Option<u32> {+ if let Some(integer) = value.as_u64() {+ let dimension = u32::try_from(integer).ok()?;+ return (dimension > 0).then_some(dimension);+ }+ frame_dimension_number(value.as_f64()?)+}++/// Converts a finite, positive integral float within the frame-size integer range.+fn frame_dimension_number(value: f64) -> Option<u32> {+ if !value.is_finite() || value.fract() != 0.0 || value <= 0.0 || value > f64::from(u32::MAX) {+ return None;+ }+ Some(value as u32)+}++/// Reads and validates dimensions from media metadata or an OTIO image bounds box.+fn image_dimensions(v: &Value) -> Option<(u32, u32)> {+ let (width, height) = if let (Some(width), Some(height)) = (v.get("width"), v.get("height")) {+ (frame_dimension(width)?, frame_dimension(height)?)+ } else {+ let min = v.get("min")?;+ let max = v.get("max")?;+ let width = max.get("x")?.as_f64()? - min.get("x")?.as_f64()?;+ let height = max.get("y")?.as_f64()? - min.get("y")?.as_f64()?;+ (frame_dimension_number(width)?, frame_dimension_number(height)?)+ };+ filmcraft_project::validate_frame_size(width, height).ok()?;+ Some((width, height))+}++/// Prefers FilmCraft media info, then OTIO metadata, then available image bounds.+fn reference_dimensions(reference: &Value) -> Option<(u32, u32)> {+ let metadata = reference.get("metadata");+ let filmcraft = metadata.and_then(|m| m.get("filmcraft"));+ filmcraft+ .and_then(|m| m.get("info"))+ .and_then(|i| i.get("video"))+ .and_then(image_dimensions)+ .or_else(|| metadata.and_then(image_dimensions))+ .or_else(|| reference.get("available_image_bounds").and_then(image_dimensions))+}++/// Selects the active OTIO media reference, falling back to the first one if needed.+fn active_media_reference(clip: &Value) -> Option<&Value> {+ let refs = clip.get("media_references");+ let key = clip.get("active_media_reference_key").and_then(Value::as_str).unwrap_or("DEFAULT_MEDIA");+ match refs {+ Some(refs) => refs.get(key).or_else(|| refs.as_object().and_then(|o| o.values().next())),+ None => clip.get("media_reference"),+ }+}++/// Returns dimensions only for a clip import() will retain (positive source duration).+fn media_reference_dimensions(clip: &Value) -> Option<(u32, u32)> {+ let reference = active_media_reference(clip)?;+ let available = reference.get("available_range").and_then(read_tr);+ let (_, duration) = clip.get("source_range").and_then(read_tr).or(available)?;+ if duration <= Tick::ZERO {+ return None;+ }+ reference_dimensions(reference)+}++/// Finds dimensions from the first importable video clip, respecting nested stacks.+fn first_video_dimensions_at(stack: &Value, depth: usize) -> Option<(u32, u32)> {+ if depth >= 64 {+ return None;+ }+ for track in stack.get("children").and_then(Value::as_array).into_iter().flatten() {+ if schema_name(track) != "Track" || track.get("kind").and_then(Value::as_str) == Some("Audio") {+ continue;+ }+ for child in track.get("children").and_then(Value::as_array).into_iter().flatten() {+ let dimensions = match schema_name(child) {+ "Clip" => media_reference_dimensions(child),+ "Stack" => child.get("source_range").and_then(read_tr).and_then(|_| first_video_dimensions_at(child, depth + 1)),+ _ => None,+ };+ if dimensions.is_some() {+ return dimensions;+ }+ }+ }+ None+}++/// Returns the first usable video clip dimensions in a stack.+fn first_video_dimensions(stack: &Value) -> Option<(u32, u32)> {+ first_video_dimensions_at(stack, 0)+}+ impl Imp<'_, '_> { fn timeline(&mut self, tl: &Value) -> ItemId { let name = name_of(tl);@@ -433,9 +530,12 @@ impl Imp<'_, '_> { id } + /// Imports a sequence, preferring explicit settings over inferred video dimensions. 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 settings: SequenceSettings = meta.and_then(|m| m.get("settings")).and_then(|s| serde_json::from_value(s.clone()).ok()).unwrap_or_else(|| {+ let (width, height) = first_video_dimensions(stack).unwrap_or((1920, 1080));+ settings_for(rate, width, height, false)+ }); let id = self.b.reserve_sequence(name, settings.clone(), None); 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)@@ -618,16 +718,9 @@ impl Imp<'_, '_> { } } + /// Imports a positive-duration clip using the same reference selection as dimension discovery. fn clip(&mut self, c: &Value, kind: TrackKind, pos: Tick) -> Option<TrackItem> {- let mref = match c.get("media_references") {- Some(refs) => {- let key = c.get("active_media_reference_key").and_then(Value::as_str).unwrap_or("DEFAULT_MEDIA");- refs.get(key).or_else(|| refs.as_object().and_then(|o| o.values().next()))- }- None => c.get("media_reference"),- }- .cloned()- .unwrap_or(Value::Null);+ let mref = active_media_reference(c).cloned().unwrap_or(Value::Null); let name = name_of(c); let avail = mref.get("available_range").and_then(read_tr); let (src_in, dur) = c.get("source_range").and_then(read_tr).or(avail)?;@@ -674,6 +767,7 @@ impl Imp<'_, '_> { Some(ti) } + /// Registers a referenced media item with dimensions inferred from its reference metadata. fn media(&mut self, r: &Value, clip_name: &str, kind: TrackKind, avail: Option<(Tick, Tick)>) -> Option<ItemId> { let meta = fc(r); let info: Option<MediaInfo> = meta.and_then(|m| m.get("info")).and_then(|i| serde_json::from_value(i.clone()).ok());@@ -681,9 +775,13 @@ impl Imp<'_, '_> { let n = name_of(r); if n.is_empty() { clip_name.to_string() } else { n } };+ let dimensions = reference_dimensions(r); let spec = MediaSpec { duration: avail.map(|a| a.1),- video: (kind == TrackKind::Video).then(|| (1920, 1080, FrameRate::default())),+ video: (kind == TrackKind::Video).then(|| {+ let (width, height) = dimensions.unwrap_or((1920, 1080));+ (width, height, FrameRate::default())+ }), audio: (kind == TrackKind::Audio).then_some((48_000, 2)), start_tc: None, kind: None,@@ -745,12 +843,33 @@ impl Imp<'_, '_> { id } };+ if kind == TrackKind::Video+ && let Some((width, height)) = dimensions+ && !self.explicit_dimensions.contains(&id)+ && let Some(media) = self.b.p.item_mut(id).and_then(|item| item.as_media_mut())+ {+ if let Some(video) = media.info.video.as_mut() {+ video.width = width;+ video.height = height;+ } else {+ media.info.video = media_info(&media.info.name, &spec).video;+ }+ if media.info.video.is_some() {+ if media.info.kind == filmcraft_media::MediaKind::AudioOnly {+ media.info.kind = filmcraft_media::MediaKind::Movie;+ }+ self.explicit_dimensions.insert(id);+ }+ } Some(id) } + /// Applies FilmCraft media info and availability metadata to an imported media item. fn fill_media(&mut self, id: ItemId, info: Option<MediaInfo>, meta: Option<&Value>, kind: TrackKind) { let label = meta.and_then(|m| m.get("label")).and_then(|l| serde_json::from_value::<Label>(l.clone()).ok()); let offline = meta.and_then(|m| m.get("offline")).and_then(Value::as_bool);+ let explicit_video_dimensions =+ info.as_ref().and_then(|i| i.video.as_ref()).is_some_and(|v| filmcraft_project::validate_frame_size(v.width, v.height).is_ok()); let Some(it) = self.b.p.item_mut(id) else { return }; if let Some(l) = label { it.label = l;@@ -767,6 +886,9 @@ impl Imp<'_, '_> { m.offline = o; } }+ if explicit_video_dimensions {+ self.explicit_dimensions.insert(id);+ } } fn markers(&mut self, v: &Value) -> Vec<Marker> {@@ -252,6 +252,326 @@ fn lossless_roundtrip_with_filmcraft_metadata() { assert_eq!(structure(&imp2.project, imp2.sequences[0], TrackKind::Video), structure(&p, s, TrackKind::Video)); } +/// Explicit sequence metadata is absent; the first video's FilmCraft media info supplies the size.+#[test]+fn sequence_dimensions_default_from_first_video_clip_media_info() {+ let document = r#"{+ "OTIO_SCHEMA":"Timeline.1",+ "tracks":{"OTIO_SCHEMA":"Stack.1","children":[+ {"OTIO_SCHEMA":"Track.1","kind":"Video","children":[+ {"OTIO_SCHEMA":"Clip.2","name":"portrait",+ "source_range":{"OTIO_SCHEMA":"TimeRange.1","start_time":{"OTIO_SCHEMA":"RationalTime.1","rate":24,"value":0},"duration":{"OTIO_SCHEMA":"RationalTime.1","rate":24,"value":24}},+ "media_reference":{"OTIO_SCHEMA":"ExternalReference.1","target_url":"portrait.mp4",+ "available_range":{"OTIO_SCHEMA":"TimeRange.1","start_time":{"OTIO_SCHEMA":"RationalTime.1","rate":24,"value":0},"duration":{"OTIO_SCHEMA":"RationalTime.1","rate":24,"value":100}},+ "metadata":{"filmcraft":{"info":{"video":{"width":1080,"height":1920}}}}}}+ ]}+ ]}+ }"#;+ let (imp, _) = import(document.as_bytes(), Format::Otio, None).unwrap();+ let sequence = imp.project.sequence(imp.sequences[0]).unwrap();+ assert_eq!((sequence.settings.width, sequence.settings.height), (1080, 1920));+ let media_id = sequence.video_tracks[0].items[0].item;+ let media = imp.project.item(media_id).unwrap().as_media().unwrap();+ assert_eq!(media.info.video.as_ref().map(|v| (v.width, v.height)), Some((1080, 1920)));+}++/// Image bounds supply portrait dimensions without any FilmCraft metadata.+#[test]+fn sequence_dimensions_default_from_first_video_clip_image_bounds() {+ let document = r#"{+ "OTIO_SCHEMA":"Timeline.1",+ "tracks":{"OTIO_SCHEMA":"Stack.1","children":[+ {"OTIO_SCHEMA":"Track.1","kind":"Video","children":[+ {"OTIO_SCHEMA":"Clip.2","name":"portrait",+ "source_range":{"OTIO_SCHEMA":"TimeRange.1","start_time":{"OTIO_SCHEMA":"RationalTime.1","rate":24,"value":0},"duration":{"OTIO_SCHEMA":"RationalTime.1","rate":24,"value":24}},+ "media_reference":{"OTIO_SCHEMA":"ExternalReference.1","target_url":"portrait.mp4",+ "available_range":{"OTIO_SCHEMA":"TimeRange.1","start_time":{"OTIO_SCHEMA":"RationalTime.1","rate":24,"value":0},"duration":{"OTIO_SCHEMA":"RationalTime.1","rate":24,"value":100}},+ "available_image_bounds":{"OTIO_SCHEMA":"Box2d.1","min":{"x":0,"y":0},"max":{"x":1080,"y":1920}}}}+ ]}+ ]}+ }"#;+ let (imp, _) = import(document.as_bytes(), Format::Otio, None).unwrap();+ let sequence = imp.project.sequence(imp.sequences[0]).unwrap();+ assert_eq!((sequence.settings.width, sequence.settings.height), (1080, 1920));+ let media_id = sequence.video_tracks[0].items[0].item;+ let media = imp.project.item(media_id).unwrap().as_media().unwrap();+ assert_eq!(media.info.video.as_ref().map(|v| (v.width, v.height)), Some((1080, 1920)));+}++/// Integral JSON floats preserve portrait dimensions for both the sequence and imported media.+#[test]+fn sequence_dimensions_accept_integral_float_media_metadata() {+ let document = r#"{+ "OTIO_SCHEMA":"Timeline.1",+ "tracks":{"OTIO_SCHEMA":"Stack.1","children":[+ {"OTIO_SCHEMA":"Track.1","kind":"Video","children":[+ {"OTIO_SCHEMA":"Clip.2","name":"portrait",+ "source_range":{"OTIO_SCHEMA":"TimeRange.1","start_time":{"OTIO_SCHEMA":"RationalTime.1","rate":24,"value":0},"duration":{"OTIO_SCHEMA":"RationalTime.1","rate":24,"value":24}},+ "media_reference":{"OTIO_SCHEMA":"ExternalReference.1","target_url":"portrait.mp4",+ "available_range":{"OTIO_SCHEMA":"TimeRange.1","start_time":{"OTIO_SCHEMA":"RationalTime.1","rate":24,"value":0},"duration":{"OTIO_SCHEMA":"RationalTime.1","rate":24,"value":100}},+ "metadata":{"width":1080.0,"height":1920.0}}}+ ]}+ ]}+ }"#;+ let (imp, _) = import(document.as_bytes(), Format::Otio, None).unwrap();+ let sequence = imp.project.sequence(imp.sequences[0]).unwrap();+ assert_eq!((sequence.settings.width, sequence.settings.height), (1080, 1920));+ let media_id = sequence.video_tracks[0].items[0].item;+ let media = imp.project.item(media_id).unwrap().as_media().unwrap();+ assert_eq!(media.info.video.as_ref().map(|v| (v.width, v.height)), Some((1080, 1920)));+}++/// Invalid numeric dimensions retain the HD fallback rather than admitting unsafe frame sizes.+#[test]+fn sequence_dimensions_reject_invalid_metadata_widths() {+ let template = r#"{+ "OTIO_SCHEMA":"Timeline.1",+ "tracks":{"OTIO_SCHEMA":"Stack.1","children":[+ {"OTIO_SCHEMA":"Track.1","kind":"Video","children":[+ {"OTIO_SCHEMA":"Clip.2","name":"portrait",+ "source_range":{"OTIO_SCHEMA":"TimeRange.1","start_time":{"OTIO_SCHEMA":"RationalTime.1","rate":24,"value":0},"duration":{"OTIO_SCHEMA":"RationalTime.1","rate":24,"value":24}},+ "media_reference":{"OTIO_SCHEMA":"ExternalReference.1","target_url":"portrait.mp4",+ "available_range":{"OTIO_SCHEMA":"TimeRange.1","start_time":{"OTIO_SCHEMA":"RationalTime.1","rate":24,"value":0},"duration":{"OTIO_SCHEMA":"RationalTime.1","rate":24,"value":100}},+ "metadata":{"width":WIDTH,"height":1920}}}+ ]}+ ]}+ }"#;++ for width in ["1080.5", "-1", "0", "4294967296"] {+ let document = template.replace("\"width\":WIDTH", &format!("\"width\":{width}"));+ let (imp, _) = import(document.as_bytes(), Format::Otio, None).unwrap();+ let sequence = imp.project.sequence(imp.sequences[0]).unwrap();+ assert_eq!((sequence.settings.width, sequence.settings.height), (1920, 1080), "width={width}");+ }+}++/// A present but empty media_references map does not fall back to the legacy field.+#[test]+fn empty_media_references_do_not_use_legacy_reference_dimensions() {+ let document = r#"{+ "OTIO_SCHEMA":"Timeline.1",+ "tracks":{"OTIO_SCHEMA":"Stack.1","children":[+ {"OTIO_SCHEMA":"Track.1","kind":"Video","children":[+ {"OTIO_SCHEMA":"Clip.2","name":"clip",+ "source_range":{"OTIO_SCHEMA":"TimeRange.1","start_time":{"OTIO_SCHEMA":"RationalTime.1","rate":24,"value":0},"duration":{"OTIO_SCHEMA":"RationalTime.1","rate":24,"value":24}},+ "media_references":{},+ "active_media_reference_key":"DEFAULT_MEDIA",+ "media_reference":{"OTIO_SCHEMA":"ExternalReference.1","target_url":"legacy.mp4",+ "metadata":{"filmcraft":{"info":{"video":{"width":1080,"height":1920}}}}}}+ ]}+ ]}+ }"#;+ let (imp, _) = import(document.as_bytes(), Format::Otio, None).unwrap();+ let sequence = imp.project.sequence(imp.sequences[0]).unwrap();+ assert_eq!((sequence.settings.width, sequence.settings.height), (1920, 1080));+}++/// Updates deduplicated default media dimensions when a later reference supplies them.+#[test]+fn later_same_path_metadata_updates_default_media_dimensions() {+ let document = r#"{+ "OTIO_SCHEMA":"Timeline.1",+ "tracks":{"OTIO_SCHEMA":"Stack.1","children":[+ {"OTIO_SCHEMA":"Track.1","kind":"Video","children":[+ {"OTIO_SCHEMA":"Clip.2","name":"first",+ "source_range":{"OTIO_SCHEMA":"TimeRange.1","start_time":{"OTIO_SCHEMA":"RationalTime.1","rate":24,"value":0},"duration":{"OTIO_SCHEMA":"RationalTime.1","rate":24,"value":24}},+ "media_reference":{"OTIO_SCHEMA":"ExternalReference.1","target_url":"shared.mp4",+ "metadata":{}}},+ {"OTIO_SCHEMA":"Clip.2","name":"second",+ "source_range":{"OTIO_SCHEMA":"TimeRange.1","start_time":{"OTIO_SCHEMA":"RationalTime.1","rate":24,"value":24},"duration":{"OTIO_SCHEMA":"RationalTime.1","rate":24,"value":24}},+ "media_reference":{"OTIO_SCHEMA":"ExternalReference.1","target_url":"shared.mp4",+ "metadata":{"filmcraft":{"info":{"video":{"width":1080,"height":1920}}}}}}+ ]}+ ]}+ }"#;+ let (imp, _) = import(document.as_bytes(), Format::Otio, None).unwrap();+ let sequence = imp.project.sequence(imp.sequences[0]).unwrap();+ assert_eq!((sequence.settings.width, sequence.settings.height), (1080, 1920));+ let media = imp.project.item(sequence.video_tracks[0].items[0].item).unwrap().as_media().unwrap();+ assert_eq!(media.info.video.as_ref().map(|v| (v.width, v.height)), Some((1080, 1920)));+}++/// Adds a later video stream when an audio reference created the shared media item first.+#[test]+fn later_video_reference_adds_dimensions_to_audio_only_shared_media() {+ let document = r#"{+ "OTIO_SCHEMA":"Timeline.1",+ "tracks":{"OTIO_SCHEMA":"Stack.1","children":[+ {"OTIO_SCHEMA":"Track.1","kind":"Audio","children":[+ {"OTIO_SCHEMA":"Clip.2","name":"audio first",+ "source_range":{"OTIO_SCHEMA":"TimeRange.1","start_time":{"OTIO_SCHEMA":"RationalTime.1","rate":24,"value":0},"duration":{"OTIO_SCHEMA":"RationalTime.1","rate":24,"value":24}},+ "media_reference":{"OTIO_SCHEMA":"ExternalReference.1","target_url":"shared.mp4",+ "metadata":{}}}+ ]},+ {"OTIO_SCHEMA":"Track.1","kind":"Video","children":[+ {"OTIO_SCHEMA":"Clip.2","name":"video later",+ "source_range":{"OTIO_SCHEMA":"TimeRange.1","start_time":{"OTIO_SCHEMA":"RationalTime.1","rate":24,"value":0},"duration":{"OTIO_SCHEMA":"RationalTime.1","rate":24,"value":24}},+ "media_reference":{"OTIO_SCHEMA":"ExternalReference.1","target_url":"shared.mp4",+ "metadata":{"filmcraft":{"info":{"video":{"width":1080,"height":1920}}}}}}+ ]}+ ]}+ }"#;+ let (imp, _) = import(document.as_bytes(), Format::Otio, None).unwrap();+ let sequence = imp.project.sequence(imp.sequences[0]).unwrap();+ let audio_item = sequence.audio_tracks[0].items[0].item;+ let video_item = sequence.video_tracks[0].items[0].item;+ assert_eq!(audio_item, video_item);+ let media = imp.project.item(audio_item).unwrap().as_media().unwrap();+ assert_eq!(media.info.video.as_ref().map(|v| (v.width, v.height)), Some((1080, 1920)));+ assert_eq!(media.info.kind, filmcraft_media::MediaKind::Movie);+ assert_eq!(media.info.audio_streams.len(), 1);+}++/// Keeps the first explicit dimensions when later references to the same file disagree.+#[test]+fn first_explicit_same_path_dimensions_are_preserved() {+ let document = r#"{+ "OTIO_SCHEMA":"Timeline.1",+ "tracks":{"OTIO_SCHEMA":"Stack.1","children":[+ {"OTIO_SCHEMA":"Track.1","kind":"Video","children":[+ {"OTIO_SCHEMA":"Clip.2","name":"first",+ "source_range":{"OTIO_SCHEMA":"TimeRange.1","start_time":{"OTIO_SCHEMA":"RationalTime.1","rate":24,"value":0},"duration":{"OTIO_SCHEMA":"RationalTime.1","rate":24,"value":24}},+ "media_reference":{"OTIO_SCHEMA":"ExternalReference.1","target_url":"shared.mp4",+ "metadata":{"filmcraft":{"info":{"video":{"width":1920,"height":1080}}}}}},+ {"OTIO_SCHEMA":"Clip.2","name":"second",+ "source_range":{"OTIO_SCHEMA":"TimeRange.1","start_time":{"OTIO_SCHEMA":"RationalTime.1","rate":24,"value":24},"duration":{"OTIO_SCHEMA":"RationalTime.1","rate":24,"value":24}},+ "media_reference":{"OTIO_SCHEMA":"ExternalReference.1","target_url":"shared.mp4",+ "metadata":{"filmcraft":{"info":{"video":{"width":1080,"height":1920}}}}}}+ ]}+ ]}+ }"#;+ let (imp, _) = import(document.as_bytes(), Format::Otio, None).unwrap();+ let sequence = imp.project.sequence(imp.sequences[0]).unwrap();+ assert_eq!((sequence.settings.width, sequence.settings.height), (1920, 1080));+ let media = imp.project.item(sequence.video_tracks[0].items[0].item).unwrap().as_media().unwrap();+ assert_eq!(media.info.video.as_ref().map(|v| (v.width, v.height)), Some((1920, 1080)));+}++/// Preserves complete first-reference media info when it includes video on an audio track.+#[test]+fn first_audio_reference_complete_media_info_keeps_explicit_dimensions() {+ let info = serde_json::to_value(media_info("shared.mp4", true, true, FrameRate::FPS_24, 60)).unwrap();+ let document = serde_json::json!({+ "OTIO_SCHEMA": "Timeline.1",+ "tracks": {+ "OTIO_SCHEMA": "Stack.1",+ "children": [+ {+ "OTIO_SCHEMA": "Track.1",+ "kind": "Audio",+ "children": [{+ "OTIO_SCHEMA": "Clip.2",+ "name": "audio first",+ "source_range": {+ "OTIO_SCHEMA": "TimeRange.1",+ "start_time": {"OTIO_SCHEMA": "RationalTime.1", "rate": 24, "value": 0},+ "duration": {"OTIO_SCHEMA": "RationalTime.1", "rate": 24, "value": 24}+ },+ "media_reference": {+ "OTIO_SCHEMA": "ExternalReference.1",+ "target_url": "shared.mp4",+ "metadata": {"filmcraft": {"info": info}}+ }+ }]+ },+ {+ "OTIO_SCHEMA": "Track.1",+ "kind": "Video",+ "children": [{+ "OTIO_SCHEMA": "Clip.2",+ "name": "video later",+ "source_range": {+ "OTIO_SCHEMA": "TimeRange.1",+ "start_time": {"OTIO_SCHEMA": "RationalTime.1", "rate": 24, "value": 0},+ "duration": {"OTIO_SCHEMA": "RationalTime.1", "rate": 24, "value": 24}+ },+ "media_reference": {+ "OTIO_SCHEMA": "ExternalReference.1",+ "target_url": "shared.mp4",+ "metadata": {"filmcraft": {"info": {"video": {"width": 1080, "height": 1920}}}}+ }+ }]+ }+ ]+ }+ });+ let bytes = serde_json::to_vec(&document).unwrap();+ let (imp, _) = import(&bytes, Format::Otio, None).unwrap();+ let sequence = imp.project.sequence(imp.sequences[0]).unwrap();+ let media = imp.project.item(sequence.video_tracks[0].items[0].item).unwrap().as_media().unwrap();+ assert_eq!(media.info.video.as_ref().map(|v| (v.width, v.height)), Some((1920, 1080)));+ assert_eq!(media.info.audio_streams.len(), 1);+}++/// Selects dimensions only from nested stacks that the importer can actually include.+#[test]+fn sequence_dimensions_skip_stacks_without_readable_source_ranges() {+ let template = r#"{+ "OTIO_SCHEMA":"Timeline.1",+ "tracks":{"OTIO_SCHEMA":"Stack.1","children":[+ {"OTIO_SCHEMA":"Track.1","kind":"Video","children":[+ {"OTIO_SCHEMA":"Stack.1","name":"unreadable nested",NESTED_RANGE "children":[+ {"OTIO_SCHEMA":"Track.1","kind":"Video","children":[+ {"OTIO_SCHEMA":"Clip.2","name":"nested portrait",+ "source_range":{"OTIO_SCHEMA":"TimeRange.1","start_time":{"OTIO_SCHEMA":"RationalTime.1","rate":24,"value":0},"duration":{"OTIO_SCHEMA":"RationalTime.1","rate":24,"value":24}},+ "media_reference":{"OTIO_SCHEMA":"ExternalReference.1","target_url":"portrait.mp4",+ "metadata":{"filmcraft":{"info":{"video":{"width":1080,"height":1920}}}}}}+ ]}+ ]},+ {"OTIO_SCHEMA":"Clip.2","name":"direct landscape",+ "source_range":{"OTIO_SCHEMA":"TimeRange.1","start_time":{"OTIO_SCHEMA":"RationalTime.1","rate":24,"value":0},"duration":{"OTIO_SCHEMA":"RationalTime.1","rate":24,"value":24}},+ "media_reference":{"OTIO_SCHEMA":"ExternalReference.1","target_url":"landscape.mp4",+ "metadata":{"filmcraft":{"info":{"video":{"width":1920,"height":1080}}}}}}+ ]}+ ]}+ }"#;++ for source_range in ["", r#""source_range":null,"#, r#""source_range":{},"#] {+ let document = template.replace("NESTED_RANGE", source_range);+ let (imp, _) = import(document.as_bytes(), Format::Otio, None).unwrap();+ let sequence = imp.project.sequence(imp.sequences[0]).unwrap();+ assert_eq!((sequence.settings.width, sequence.settings.height), (1920, 1080));+ assert_eq!(sequence.video_tracks[0].items.len(), 1);+ }++ let nested_range = r#""source_range":{"OTIO_SCHEMA":"TimeRange.1","start_time":{"OTIO_SCHEMA":"RationalTime.1","rate":24,"value":0},"duration":{"OTIO_SCHEMA":"RationalTime.1","rate":24,"value":24}},"#;+ let document = template.replace("NESTED_RANGE", nested_range);+ let (imp, _) = import(document.as_bytes(), Format::Otio, None).unwrap();+ let sequence = imp.project.sequence(imp.sequences[0]).unwrap();+ assert_eq!((sequence.settings.width, sequence.settings.height), (1080, 1920));+ assert_eq!(sequence.video_tracks[0].items.len(), 2);+ let nested = sequence.video_tracks[0].items[0].item;+ let nested = imp.project.sequence(nested).unwrap();+ assert_eq!((nested.settings.width, nested.settings.height), (1080, 1920));+ assert_eq!(nested.video_tracks[0].items.len(), 1);+}++/// Empty clips are skipped when choosing the sequence's video dimensions.+#[test]+fn sequence_dimensions_skip_zero_duration_video_clips() {+ let document = r#"{+ "OTIO_SCHEMA":"Timeline.1",+ "tracks":{"OTIO_SCHEMA":"Stack.1","children":[+ {"OTIO_SCHEMA":"Track.1","kind":"Video","children":[+ {"OTIO_SCHEMA":"Clip.2","name":"empty landscape",+ "source_range":{"OTIO_SCHEMA":"TimeRange.1","start_time":{"OTIO_SCHEMA":"RationalTime.1","rate":24,"value":0},"duration":{"OTIO_SCHEMA":"RationalTime.1","rate":24,"value":0}},+ "media_reference":{"OTIO_SCHEMA":"ExternalReference.1","target_url":"empty.mp4",+ "available_range":{"OTIO_SCHEMA":"TimeRange.1","start_time":{"OTIO_SCHEMA":"RationalTime.1","rate":24,"value":0},"duration":{"OTIO_SCHEMA":"RationalTime.1","rate":24,"value":100}},+ "metadata":{"filmcraft":{"info":{"video":{"width":1920,"height":1080}}}}}},+ {"OTIO_SCHEMA":"Clip.2","name":"portrait",+ "source_range":{"OTIO_SCHEMA":"TimeRange.1","start_time":{"OTIO_SCHEMA":"RationalTime.1","rate":24,"value":0},"duration":{"OTIO_SCHEMA":"RationalTime.1","rate":24,"value":24}},+ "media_reference":{"OTIO_SCHEMA":"ExternalReference.1","target_url":"portrait.mp4",+ "available_range":{"OTIO_SCHEMA":"TimeRange.1","start_time":{"OTIO_SCHEMA":"RationalTime.1","rate":24,"value":0},"duration":{"OTIO_SCHEMA":"RationalTime.1","rate":24,"value":100}},+ "metadata":{"filmcraft":{"info":{"video":{"width":1080,"height":1920}}}}}}+ ]}+ ]}+ }"#;+ let (imp, _) = import(document.as_bytes(), Format::Otio, None).unwrap();+ let sequence = imp.project.sequence(imp.sequences[0]).unwrap();+ assert_eq!((sequence.settings.width, sequence.settings.height), (1080, 1920));+ assert_eq!(sequence.video_tracks[0].items.len(), 1);+}+ #[test] fn collections_and_bare_tracks() { let one = r#"{"OTIO_SCHEMA": "SerializableCollection.1", "name": "Bundle", "children": [