diff --git a/crates/oak-otio/src/fcpxml.rs b/crates/oak-otio/src/fcpxml.rs index b6af66cde..c815487bf 100644 --- a/crates/oak-otio/src/fcpxml.rs +++ b/crates/oak-otio/src/fcpxml.rs @@ -953,6 +953,15 @@ impl ResourceSet { }; let src = external.target_url(); if self.asset_ids.contains_key(src) { + // Already registered from another track: union the + // stream flags so a file used on both a video and an + // audio track declares hasVideo AND hasAudio (the + // first registration's track kind must not hide the + // other stream from the importer). + if let Some(existing) = self.assets.iter_mut().find(|a| a.src == src) { + existing.has_video |= is_video; + existing.has_audio |= is_audio; + } continue; } let (asset_format_id, asset_duration) = match external.available_range() { diff --git a/crates/oak-task/src/nodeops.rs b/crates/oak-task/src/nodeops.rs index 39f1f2da9..28e021dae 100644 --- a/crates/oak-task/src/nodeops.rs +++ b/crates/oak-task/src/nodeops.rs @@ -472,6 +472,19 @@ pub fn footage_video_params( f.video_params(index).map(common_from_node_video) } +/// The footage's first audio stream's sample rate (`oaknode_footage_get_ +/// audio_params`), `None` when unprobed or the file has no audio stream. +pub fn footage_audio_sample_rate(project: &ProjectRef, footage: NodeId) -> Option { + let guard = lock_project(project); + let f = guard + .graph + .get(footage)? + .behavior + .as_any()? + .downcast_ref::()?; + f.audio_params(0).map(|a| a.sample_rate) +} + /// Overwrite (or create) the footage's `index`th video stream parameters /// (`oaknode_footage_set_video_params`). pub fn footage_set_video_params( diff --git a/crates/oak-task/src/project/saveotio.rs b/crates/oak-task/src/project/saveotio.rs index 821c8de70..ce6f49cf0 100644 --- a/crates/oak-task/src/project/saveotio.rs +++ b/crates/oak-task/src/project/saveotio.rs @@ -188,30 +188,42 @@ impl SaveOTIOTask { let media = nodeops::node_find_input_footage(project, block); if let Some(media) = media { + // The available range covers the WHOLE media: start + // 0, duration = the media length in the reference's + // own time base (the video rate for video clips, the + // sample rate for audio clips — OTIO's External- + // Reference convention). A zero duration declares + // the media unusable and DaVinci Resolve rejects + // every clip whose source range falls outside it. + let duration_seconds = nodeops::node_length(project, media).to_f64(); let available_range = if track_type == TrackType::Video { - // OTIO ExternalReference uses the source clips - // frame rate (or sample rate) as opposed to the - // sequences rate. - let (source_frame_rate, duration) = - nodeops::footage_video_params(project, media, 0) - .map(|vp| { - let (num, den) = vp.frame_rate(); - let rate = if den != 0 { - num as f64 / den as f64 - } else { - 0.0 - }; - (rate, vp.duration() as f64) - }) - .unwrap_or((0.0, 0.0)); + let source_frame_rate = nodeops::footage_video_params(project, media, 0) + .map(|vp| { + let (num, den) = vp.frame_rate(); + if den != 0 { + num as f64 / den as f64 + } else { + 0.0 + } + }) + .filter(|rate| *rate > 0.0) + .unwrap_or(sequence_rate); TimeRange::new( RationalTime::new(0.0, source_frame_rate), - RationalTime::new(duration, source_frame_rate), + RationalTime::new( + (duration_seconds * source_frame_rate).round(), + source_frame_rate, + ), ) } else { + let sample_rate = nodeops::footage_audio_sample_rate(project, media) + .unwrap_or(48000) as f64; TimeRange::new( - RationalTime::new(0.0, 48000.0), - RationalTime::new(0.0, 48000.0), + RationalTime::new(0.0, sample_rate), + RationalTime::new( + (duration_seconds * sample_rate).round(), + sample_rate, + ), ) }; @@ -226,9 +238,12 @@ impl SaveOTIOTask { Some(Composable::Clip(otio_clip)) } nodeops::BlockKind::Gap => Some(Composable::Gap(Gap::new( + // Gaps live in timeline time: the SEQUENCE rate, for both + // fields — a TimeRange mixing bases (a 24 fps start with + // a 1.0 duration) is unreadable to strict importers. TimeRange::new( - RationalTime::from_rational(block_in_of(project, block), 24.0), - RationalTime::from_rational(block_length_of(project, block), 24.0), + RationalTime::from_rational(block_in_of(project, block), sequence_rate), + RationalTime::from_rational(block_length_of(project, block), sequence_rate), ), nodeops::node_label(project, block), ))), @@ -256,14 +271,18 @@ impl SaveOTIOTask { } // All OTIO tracks must have the same duration so we add a Gap to - // fill the remaining time. + // fill the remaining time. Like the gap blocks above, the range + // stays in the sequence's time base (duration in whole frames). let duration = track_duration(&otio_track); let duration_seconds = duration.clone().to_seconds(); if duration_seconds < max_track_length.to_f64() { let time_left = max_track_length.to_f64() - duration_seconds; let gap = Gap::new( - TimeRange::new(duration, RationalTime::new(time_left, 1.0)), + TimeRange::new( + RationalTime::new(0.0, sequence_rate), + RationalTime::new((time_left * sequence_rate).round(), sequence_rate), + ), "", ); otio_track.append_child(Composable::Gap(gap)); @@ -353,6 +372,25 @@ impl TaskBehavior for SaveOTIOTask { } } + // Drop timelines with no clips at all (an empty sequence the user + // never touched): DaVinci Resolve's OTIO import expects a single + // Timeline root and chokes on a SerializableCollection — skipping + // the empty ones lets a lone edited sequence export as a bare + // Timeline. FCPXML benefits the same way (no empty s). + serialized.retain(|timeline| { + timeline.tracks().children().iter().any(|child| { + child + .as_track() + .is_some_and(|track| track.children().iter().any(|b| b.as_clip().is_some())) + }) + }); + if serialized.is_empty() { + task.set_error("Project contains no edited sequences to export."); + return Err(Error::Failed( + "Project contains no edited sequences to export.".to_string(), + )); + } + // Write the serialized timelines, dispatching on the format: OTIO // JSON writes a `Timeline` root for one sequence or a // `SerializableCollection` for several; FCPXML writes all timelines diff --git a/crates/oak-task/tests/saveotio_test.rs b/crates/oak-task/tests/saveotio_test.rs new file mode 100644 index 000000000..9a941d812 --- /dev/null +++ b/crates/oak-task/tests/saveotio_test.rs @@ -0,0 +1,305 @@ +// Oak Video Editor - Non-Linear Video Editor +// Copyright (C) 2026 Oak Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +//! Interchange-export correctness (the "DaVinci Resolve imports nothing" +//! report): the OTIO JSON must declare a usable `available_range` for +//! every clip (a zero duration rejects the whole file), gap ranges must +//! stay in the sequence's time base, an edited lone sequence exports as +//! a bare `Timeline` root (empty sequences are skipped), and the FCPXML +//! assets of a file used on both video and audio tracks declare both +//! stream flags. + +use std::sync::{Arc, Mutex}; + +use oak_core::{Rational, TimeRange}; +use oak_node::block::ClipBlockBehavior; +use oak_node::footage::FootageBehavior; +use oak_node::id::NodeId; +use oak_node::node::NodeCore; +use oak_node::project::Project; +use oak_node::sequence::SequenceBehavior; +use oak_node::track::{TrackBehavior, TrackListBehavior, TrackType}; +use oak_otio::{MediaReference, Serializable}; + +/// The sequence's frame rate (the `set_default_parameters` NTSC +/// default) every timeline-range must share. +fn sequence_rate() -> f64 { + 30000.0 / 1001.0 +} + +fn clip_path(tag: &str, ext: &str) -> std::path::PathBuf { + std::env::temp_dir().join(format!("oaktask_otio_{tag}_{}.{ext}", std::process::id())) +} + +/// Add a track of `kind` carrying one clip of `footage` covering +/// `[in, out)` (media seconds) to `list` — the graph plumbing shared by +/// the two sequences built below. +fn add_track_with_clip( + p: &mut Project, + footage: NodeId, + kind: TrackType, + in_: Rational, + out: Rational, +) -> NodeId { + let (tcore, tbehavior) = TrackBehavior::create(); + let track = p.graph.add_node(tcore, tbehavior); + p.graph + .get_mut(track) + .unwrap() + .behavior + .as_any_mut() + .unwrap() + .downcast_mut::() + .unwrap() + .kind = kind; + + let (ccore, cbehavior) = oak_node::block::clip_create(); + let clip = p.graph.add_node(ccore, cbehavior); + p.graph + .connect(footage, clip, oak_node::block::clip_input::TEXTURE_INPUT, -1) + .expect("connect footage to clip"); + p.graph + .get_mut(clip) + .unwrap() + .behavior + .as_any_mut() + .unwrap() + .downcast_mut::() + .expect("clip block") + .core + .range = TimeRange::new(in_, out); + p.graph + .get_mut(track) + .unwrap() + .behavior + .as_any_mut() + .unwrap() + .downcast_mut::() + .unwrap() + .append_block(clip); + track +} + +/// A project with two sequences: "Edited" (a 1s video clip on one video +/// track, a 0.5s video clip on a second — leaving a trailing gap — and +/// the same media on an audio track) and "Empty" (no tracks). Both sit +/// in the root folder, like the app's project bin. +fn build_project(media: &str) -> Arc> { + let project = Project::new(); + { + let mut p = project.lock().unwrap(); + p.initialize().expect("root folder"); + + let footage = { + let mut f = FootageBehavior::new(media); + f.probe().expect("probe the generated clip"); + p.graph.add_node(NodeCore::new(), Box::new(f)) + }; + + // "Edited": video list (two tracks) + audio list (one track). + let (score, sbehavior) = SequenceBehavior::create(); + let edited = p.graph.add_node(score, sbehavior); + { + let video_track_a = add_track_with_clip( + &mut p, + footage, + TrackType::Video, + Rational::new(0, 1), + Rational::new(1, 1), + ); + let video_track_b = add_track_with_clip( + &mut p, + footage, + TrackType::Video, + Rational::new(0, 1), + Rational::new(1, 2), + ); + let audio_track = add_track_with_clip( + &mut p, + footage, + TrackType::Audio, + Rational::new(0, 1), + Rational::new(1, 1), + ); + for (kind, tracks) in [ + (TrackType::Video, vec![video_track_a, video_track_b]), + (TrackType::Audio, vec![audio_track]), + ] { + let (lcore, lbehavior) = TrackListBehavior::create(); + let list = p.graph.add_node(lcore, lbehavior); + { + let list_b = p + .graph + .get_mut(list) + .unwrap() + .behavior + .as_any_mut() + .unwrap() + .downcast_mut::() + .unwrap(); + list_b.kind = kind; + list_b.tracks = tracks; + } + p.graph + .get_mut(edited) + .unwrap() + .behavior + .as_any_mut() + .unwrap() + .downcast_mut::() + .unwrap() + .track_lists + .push(list); + } + p.graph + .get_mut(edited) + .unwrap() + .core + .label = "Edited".to_string(); + } + + // "Empty": a sequence with no track lists at all. + let (ecore, ebehavior) = SequenceBehavior::create(); + let empty = p.graph.add_node(ecore, ebehavior); + p.graph.get_mut(empty).unwrap().core.label = "Empty".to_string(); + + // Both live in the root folder (the save task enumerates it). + let root = p.root; + p.graph + .get_mut(root) + .unwrap() + .behavior + .as_any_mut() + .unwrap() + .downcast_mut::() + .expect("root folder") + .children + .extend([edited, empty]); + } + project +} + +/// Run the save task for `project` to `filename`. +fn save(project: &Arc>, filename: String) { + let mut driver = oak_task::task::Task::new("Saving project...", None); + driver.set_behavior(Box::new(oak_task::project::saveotio::SaveOTIOTask { + base: oak_task::task::Task::new("Saving project...", None), + project: project.clone(), + filename: filename.clone(), + })); + if let Err(e) = driver.start() { + panic!( + "save to {filename} failed: {e:?} / {}", + driver.error().unwrap_or("unknown error") + ); + } +} + +/// OTIO JSON: a lone edited sequence exports as a bare `Timeline` root; +/// every clip declares a usable available range; every gap range is in +/// the sequence's time base. +#[test] +fn otio_export_is_importable() { + let media = clip_path("src_otio", "mp4"); + oak_codec::testmedia::write_test_clip(&media, 64, 64, 10, 10).expect("clip generation"); + let project = build_project(&media.to_string_lossy()); + let out = clip_path("out", "otio"); + let _ = std::fs::remove_file(&out); + save(&project, out.to_string_lossy().into_owned()); + + let doc = oak_otio::from_json_file(&out).expect("the export parses as OTIO"); + let Serializable::Timeline(timeline) = doc else { + panic!("one edited sequence must export as a bare Timeline root, not a collection"); + }; + assert_eq!(timeline.name(), "Edited"); + + let mut clips = 0; + let mut gaps = 0; + for child in timeline.tracks().children() { + let track = child.as_track().expect("track"); + for block in track.children() { + if let Some(clip) = block.as_clip() { + clips += 1; + let range = clip + .media_reference() + .and_then(MediaReference::as_external_reference) + .expect("external reference") + .available_range() + .expect("available_range"); + assert!( + range.duration().value() > 0.0, + "a zero available range rejects the file everywhere" + ); + assert!(range.duration().rate() > 0.0); + if track.kind() == "Audio" { + assert_eq!( + range.duration().rate(), + 48000.0, + "an audio clip's available range is in the media's sample rate" + ); + } + } + if let Some(gap) = block.as_gap() { + gaps += 1; + let range = gap.source_range().expect("gap range"); + assert!( + (range.duration().rate() - sequence_rate()).abs() < 1e-6, + "gap duration rate {} is not the sequence rate", + range.duration().rate() + ); + assert!( + (range.start_time().rate() - sequence_rate()).abs() < 1e-6, + "gap start rate {} is not the sequence rate", + range.start_time().rate() + ); + } + } + } + assert_eq!(clips, 3, "two video clips + one audio clip"); + assert!(gaps >= 1, "the half-length video track gains a trailing gap"); + + let _ = std::fs::remove_file(&media); + let _ = std::fs::remove_file(&out); +} + +/// FCPXML: the shared media's single asset declares hasVideo AND +/// hasAudio, and no asset carries a zero duration. +#[test] +fn fcpxml_export_declares_both_streams_and_real_durations() { + let media = clip_path("src_fcpxml", "mp4"); + oak_codec::testmedia::write_test_clip(&media, 64, 64, 10, 10).expect("clip generation"); + let project = build_project(&media.to_string_lossy()); + let out = clip_path("out", "fcpxml"); + let _ = std::fs::remove_file(&out); + save(&project, out.to_string_lossy().into_owned()); + + let xml = std::fs::read_to_string(&out).expect("read the fcpxml export"); + assert!( + xml.contains("hasVideo=\"1\""), + "the shared media's asset declares video: {xml}" + ); + assert!( + xml.contains("hasAudio=\"1\""), + "the shared media's asset declares audio: {xml}" + ); + assert!( + !xml.contains("duration=\"0/1s\""), + "no asset carries a zero duration: {xml}" + ); + + let _ = std::fs::remove_file(&media); + let _ = std::fs::remove_file(&out); +}