otio: write importable OTIO/FCPXML (DaVinci Resolve rejects both)
CI / Build & test (Linux) (push) Successful in 23m59s
CI / Build & test (Windows) (push) Successful in 31m18s

- available_range declared a zero duration for every clip (the media
  was 'unusable', so Resolve invalidated all of them): it now covers
  the whole media - video clips in the probed frame rate, audio clips
  in the probed sample rate (no more hardcoded 48000/0).
- Gap ranges mixed time bases (a 24 fps block start, a 1.0 trailing
  duration): gaps now stay in the sequence's time base, the trailing
  pad in whole frames.
- Empty sequences (no clips) are skipped, so a lone edited sequence
  exports as a bare Timeline root instead of a SerializableCollection
  Resolve cannot read.
- FCPXML: an asset used on both video and audio tracks now declares
  hasVideo AND hasAudio (the first registration's track kind used to
  hide the other stream); asset durations recover with available_range.
This commit is contained in:
2026-09-10 16:55:12 +08:00
parent 71e41d2d82
commit 280118a342
4 changed files with 387 additions and 22 deletions
+13
View File
@@ -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<i32> {
let guard = lock_project(project);
let f = guard
.graph
.get(footage)?
.behavior
.as_any()?
.downcast_ref::<FootageBehavior>()?;
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(
+60 -22
View File
@@ -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 <project>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