Single-lib cleanup: the per-crate src/bridge/ and src/ffi.rs layers are gone (oakundo/oakcommon/oaknode/oaktimeline/oakcodec/oakaudio/ oakrender/oaktask/oakplugin/oakstorage); cross-crate calls are plain Rust, CHandle marshalling shrinks to the oakengine boundary, and tests call the Rust APIs directly (pure C-ABI wrapper tests removed where the domain layer already covers the behavior). exporter.h family implemented: oakengine_export_render (CLI contract), oakengine_export_render_with_params (was a stub), last_error and progress callback; synchronous path reuses task_create_export + start_sync. Fixes on the way: oaktask video ticket self-deadlock, audio params dropped on the export path, codec encoder AAC slicing and H.264 time base. Real-mp4 tests cover both entry points, progress and the illegal-argument matrix. Also: oakstorage session maps null project handles to None (version- info path), configstore test double literal 3.14 -> 3.15 (clippy PI lint), oakaudio output callback scratch buffer + env-aware P1 test, cli media round-trip test uses a generated 16-frame clip (no more minute-long debug runs).
448 lines
14 KiB
Rust
448 lines
14 KiB
Rust
// 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 <http://www.gnu.org/licenses/>.
|
|
|
|
//! `SaveOTIOTask`, mirroring `src/task/src/project/saveotio/saveotio.h`.
|
|
//!
|
|
//! Serializes a borrowed project to an OpenTimelineIO (`.otio`) or FCPXML
|
|
//! (`.fcpxml`) file through the pure-Rust `oakotio` binding (see `README`
|
|
//! decision #6): the project's sequences become `OTIO::Timeline`s
|
|
//! (`serialize_timeline` / `serialize_track` / `serialize_track_list`),
|
|
//! exactly like the C++ `serialize_*` helpers. The format is dispatched
|
|
//! from the filename extension (see [`crate::project::format`]) at the
|
|
//! final write only — the serialization itself is shared.
|
|
//!
|
|
//! **Single-lib note**: the project graph is read through the direct
|
|
//! oaknode domain operations in [`crate::nodeops`] (folder children,
|
|
//! sequence track lists, track blocks, footage parameters) instead of the
|
|
//! deleted oaknode C ABI stubs; the project is an
|
|
//! `Arc<Mutex<oaknode::project::Project>>` instead of a borrowed `CHandle`.
|
|
//!
|
|
//! CPP-PARITY: src/task/src/project/saveotio/saveotio.cpp
|
|
|
|
use oakcore_rs::Rational;
|
|
use oaknode::id::NodeId;
|
|
use oaknode::track::TrackType;
|
|
use oakotio::{
|
|
Clip, Composable, ExternalReference, Gap, MediaReference, RationalTime, Serializable,
|
|
SerializableCollection, TimeRange, Timeline, Track, Transition,
|
|
};
|
|
|
|
use crate::error::{Error, Result};
|
|
use crate::nodeops::{self, ProjectRef};
|
|
use crate::project::format::InterchangeFormat;
|
|
use crate::task::{Task, TaskBehavior};
|
|
|
|
/// `Rational(INT_MIN)` — the initial "no track yet" maximum length
|
|
/// (`RATIONAL_MIN` in saveotio.cpp).
|
|
fn rational_min() -> Rational {
|
|
Rational::new(-2147483647, 1)
|
|
}
|
|
|
|
/// An OTIO project-save task. Borrows its project.
|
|
pub struct SaveOTIOTask {
|
|
/// The shared task base.
|
|
pub base: Task,
|
|
/// Borrowed project to save (domain project).
|
|
pub project: ProjectRef,
|
|
/// Output OTIO filename.
|
|
pub filename: String,
|
|
}
|
|
|
|
impl SaveOTIOTask {
|
|
/// Serialize one sequence into an `OTIO::Timeline`; `None` when the
|
|
/// sequence has no usable frame rate or a track fails to serialize.
|
|
///
|
|
/// CPP-PARITY: saveotio.cpp (SaveOTIOTask::serialize_timeline)
|
|
fn serialize_timeline(project: &ProjectRef, sequence: NodeId) -> Option<Timeline> {
|
|
let mut otio_timeline =
|
|
Timeline::new(nodeops::node_label(project, sequence));
|
|
|
|
// Direct video-params value type (single-lib unification); absent
|
|
// params leave rate at 0.0 → the documented `None` path.
|
|
let (num, den) = nodeops::sequence_video_params(project, sequence, 0)
|
|
.map(|vp| vp.frame_rate())
|
|
.unwrap_or((0, 1));
|
|
let mut rate = 0.0f64;
|
|
if den != 0 {
|
|
rate = num as f64 / den as f64;
|
|
}
|
|
if rate.is_nan() || rate <= 0.0 {
|
|
return None;
|
|
}
|
|
|
|
let video_list = nodeops::sequence_track_list(project, sequence, TrackType::Video);
|
|
let audio_list = nodeops::sequence_track_list(project, sequence, TrackType::Audio);
|
|
|
|
if let Some(list) = video_list {
|
|
if !Self::serialize_track_list(project, list, &mut otio_timeline, rate) {
|
|
return None;
|
|
}
|
|
}
|
|
if let Some(list) = audio_list {
|
|
if !Self::serialize_track_list(project, list, &mut otio_timeline, rate) {
|
|
return None;
|
|
}
|
|
}
|
|
|
|
Some(otio_timeline)
|
|
}
|
|
|
|
/// Serialize all tracks of one track list (video or audio) into the
|
|
/// timeline. Each track is padded to the list's maximum track length.
|
|
///
|
|
/// CPP-PARITY: saveotio.cpp (SaveOTIOTask::serialize_track_list)
|
|
fn serialize_track_list(
|
|
project: &ProjectRef,
|
|
list: NodeId,
|
|
otio_timeline: &mut Timeline,
|
|
sequence_rate: f64,
|
|
) -> bool {
|
|
let mut max_track_length = rational_min();
|
|
|
|
let track_count = nodeops::tracklist_track_count(project, list);
|
|
|
|
for i in 0..track_count {
|
|
if let Some(track) = nodeops::tracklist_track_at(project, list, i) {
|
|
let length = nodeops::track_length(project, track);
|
|
if length > max_track_length {
|
|
max_track_length = length;
|
|
}
|
|
}
|
|
}
|
|
|
|
for i in 0..track_count {
|
|
let Some(track) = nodeops::tracklist_track_at(project, list, i) else {
|
|
continue;
|
|
};
|
|
|
|
let Some(otio_track) =
|
|
Self::serialize_track(project, track, sequence_rate, max_track_length)
|
|
else {
|
|
return false;
|
|
};
|
|
|
|
otio_timeline
|
|
.tracks_mut()
|
|
.append_child(Composable::Track(otio_track));
|
|
}
|
|
|
|
true
|
|
}
|
|
|
|
/// Serialize one track: a `Track` whose kind matches the native track
|
|
/// type, one OTIO block per native block, plus a trailing `Gap` when the
|
|
/// track is shorter than `max_track_length`.
|
|
///
|
|
/// CPP-PARITY: saveotio.cpp (SaveOTIOTask::serialize_track)
|
|
fn serialize_track(
|
|
project: &ProjectRef,
|
|
track: NodeId,
|
|
sequence_rate: f64,
|
|
max_track_length: Rational,
|
|
) -> Option<Track> {
|
|
let track_type = nodeops::track_type(project, track)?;
|
|
|
|
let kind = match track_type {
|
|
TrackType::Video => "Video",
|
|
TrackType::Audio => "Audio",
|
|
TrackType::Subtitle => {
|
|
eprintln!(
|
|
"Don't know OTIO track kind for native type {}",
|
|
track_type.to_c()
|
|
);
|
|
return None;
|
|
}
|
|
};
|
|
let mut otio_track = Track::new(kind);
|
|
|
|
let block_count = nodeops::track_block_count(project, track);
|
|
|
|
for i in 0..block_count {
|
|
let Some(block) = nodeops::track_block_at(project, track, i) else {
|
|
continue;
|
|
};
|
|
|
|
let kind = nodeops::block_kind(project, block);
|
|
|
|
let otio_block: Option<Composable> = match kind {
|
|
nodeops::BlockKind::Clip => {
|
|
let mut otio_clip = Clip::new(nodeops::node_label(project, block));
|
|
|
|
otio_clip.set_source_range(TimeRange::new(
|
|
RationalTime::from_rational(block_in_of(project, block), sequence_rate),
|
|
RationalTime::from_rational(block_length_of(project, block), sequence_rate),
|
|
));
|
|
|
|
let media = nodeops::node_find_input_footage(project, block);
|
|
if let Some(media) = media {
|
|
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));
|
|
TimeRange::new(
|
|
RationalTime::new(0.0, source_frame_rate),
|
|
RationalTime::new(duration, source_frame_rate),
|
|
)
|
|
} else {
|
|
TimeRange::new(
|
|
RationalTime::new(0.0, 48000.0),
|
|
RationalTime::new(0.0, 48000.0),
|
|
)
|
|
};
|
|
|
|
let media_url = nodeops::footage_filename(project, media);
|
|
if !media_url.is_empty() {
|
|
otio_clip.set_media_reference(MediaReference::ExternalReference(
|
|
ExternalReference::new(media_url, Some(available_range)),
|
|
));
|
|
}
|
|
}
|
|
|
|
Some(Composable::Clip(otio_clip))
|
|
}
|
|
nodeops::BlockKind::Gap => Some(Composable::Gap(Gap::new(
|
|
TimeRange::new(
|
|
RationalTime::from_rational(block_in_of(project, block), 24.0),
|
|
RationalTime::from_rational(block_length_of(project, block), 24.0),
|
|
),
|
|
nodeops::node_label(project, block),
|
|
))),
|
|
nodeops::BlockKind::Transition => {
|
|
let mut otio_transition =
|
|
Transition::new(nodeops::node_label(project, block));
|
|
|
|
let n = transition_offset_of(project, block, true);
|
|
otio_transition.set_in_offset(RationalTime::from_rational(n, 24.0));
|
|
let n = transition_offset_of(project, block, false);
|
|
otio_transition.set_out_offset(RationalTime::from_rational(n, 24.0));
|
|
|
|
Some(Composable::Transition(otio_transition))
|
|
}
|
|
nodeops::BlockKind::Other => None,
|
|
};
|
|
|
|
let Some(otio_block) = otio_block else {
|
|
// We shouldn't ever get here, but catch without crashing if
|
|
// we ever do.
|
|
return None;
|
|
};
|
|
|
|
otio_track.append_child(otio_block);
|
|
}
|
|
|
|
// All OTIO tracks must have the same duration so we add a Gap to
|
|
// fill the remaining time.
|
|
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)),
|
|
"",
|
|
);
|
|
otio_track.append_child(Composable::Gap(gap));
|
|
}
|
|
|
|
Some(otio_track)
|
|
}
|
|
}
|
|
|
|
impl TaskBehavior for SaveOTIOTask {
|
|
/// Serialize the project to OTIO/FCPXML via `oakotio` and write the file.
|
|
fn run(&mut self, task: &mut Task) -> Result<()> {
|
|
// Dispatch the interchange format from the filename extension.
|
|
// Format handling ends at the write below — the serialization
|
|
// produces `oakotio::Timeline`s and is shared between `.otio` and
|
|
// `.fcpxml` (C++ parity for the serialize_* helpers).
|
|
let format = match InterchangeFormat::of(&self.filename) {
|
|
Ok(f) => f,
|
|
Err(e) => {
|
|
if let Error::Failed(msg) = &e {
|
|
task.set_error(msg);
|
|
}
|
|
return Err(e);
|
|
}
|
|
};
|
|
|
|
// Collect sequences from the root folder (non-recursive, matching
|
|
// the original list_children_of_type behavior closely enough for
|
|
// OTIO).
|
|
let root = {
|
|
let guard = self.project.lock().unwrap_or_else(|e| e.into_inner());
|
|
guard.root
|
|
};
|
|
if !root.valid() {
|
|
task.set_error("Project contains no sequences to export.");
|
|
return Err(Error::Failed(
|
|
"Project contains no sequences to export.".to_string(),
|
|
));
|
|
}
|
|
|
|
let mut sequences: Vec<NodeId> = Vec::new();
|
|
{
|
|
let guard = self.project.lock().unwrap_or_else(|e| e.into_inner());
|
|
let Some(entry) = guard.graph.get(root) else {
|
|
task.set_error("Project contains no sequences to export.");
|
|
return Err(Error::Failed(
|
|
"Project contains no sequences to export.".to_string(),
|
|
));
|
|
};
|
|
let Some(folder) = entry
|
|
.behavior
|
|
.as_any()
|
|
.and_then(|a| a.downcast_ref::<oaknode::folder::FolderBehavior>())
|
|
else {
|
|
task.set_error("Project contains no sequences to export.");
|
|
return Err(Error::Failed(
|
|
"Project contains no sequences to export.".to_string(),
|
|
));
|
|
};
|
|
for child in &folder.children {
|
|
if let Some(child_entry) = guard.graph.get(*child) {
|
|
if child_entry.behavior.type_id() == nodeops::SEQUENCE_TYPE_ID {
|
|
sequences.push(*child);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if sequences.is_empty() {
|
|
task.set_error("Project contains no sequences to export.");
|
|
return Err(Error::Failed(
|
|
"Project contains no sequences to export.".to_string(),
|
|
));
|
|
}
|
|
|
|
let mut serialized: Vec<Timeline> = Vec::with_capacity(sequences.len());
|
|
for sequence in &sequences {
|
|
match Self::serialize_timeline(&self.project, *sequence) {
|
|
Some(timeline) => serialized.push(timeline),
|
|
None => {
|
|
task.set_error(&format!(
|
|
"Failed to serialize sequence \"{}\"",
|
|
nodeops::node_label(&self.project, *sequence)
|
|
));
|
|
return Err(Error::Failed("Failed to serialize sequence".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
|
|
// into one document (one `<project>` per timeline).
|
|
let result: std::result::Result<(), String> = match format {
|
|
InterchangeFormat::OtioJson => {
|
|
if serialized.len() == 1 {
|
|
serialized[0]
|
|
.to_json_file(&self.filename)
|
|
.map_err(|e| e.to_string())
|
|
} else {
|
|
// Serialize all into a SerializableCollection.
|
|
let collection = SerializableCollection::new(
|
|
"Sequences",
|
|
serialized.into_iter().map(Serializable::Timeline).collect(),
|
|
);
|
|
Serializable::SerializableCollection(collection)
|
|
.to_json_file(&self.filename)
|
|
.map_err(|e| e.to_string())
|
|
}
|
|
}
|
|
InterchangeFormat::Fcpxml => {
|
|
oakotio::to_fcpxml_file(&serialized, &self.filename).map_err(|e| e.to_string())
|
|
}
|
|
};
|
|
|
|
result.map_err(|e| {
|
|
let (label, what) = match format {
|
|
InterchangeFormat::OtioJson => (
|
|
"Failed to save OpenTimelineIO to file",
|
|
"Failed to save OpenTimelineIO",
|
|
),
|
|
InterchangeFormat::Fcpxml => {
|
|
("Failed to save FCPXML to file", "Failed to save FCPXML")
|
|
}
|
|
};
|
|
task.set_error(&format!("{label} \"{}\": {e}", self.filename));
|
|
Error::Failed(what.to_string())
|
|
})
|
|
}
|
|
}
|
|
|
|
/// The block's in point as a `Rational` (default 0/1 when unset).
|
|
fn block_in_of(project: &ProjectRef, block: NodeId) -> Rational {
|
|
nodeops::block_in(project, block)
|
|
}
|
|
|
|
/// The block's length as a `Rational` (default 0/1 when unset).
|
|
fn block_length_of(project: &ProjectRef, block: NodeId) -> Rational {
|
|
nodeops::block_length(project, block)
|
|
}
|
|
|
|
/// A transition's in (`in_offset == true`) or out offset as a `Rational`.
|
|
fn transition_offset_of(project: &ProjectRef, block: NodeId, in_offset: bool) -> Rational {
|
|
if in_offset {
|
|
nodeops::transition_in_offset(project, block)
|
|
} else {
|
|
nodeops::transition_out_offset(project, block)
|
|
}
|
|
}
|
|
|
|
/// The serialized track's duration as an `OTIO::RationalTime` — the sum of
|
|
/// its children's source-range durations, keeping the first child's rate
|
|
/// (the C++ `otio_track->duration(&es)` result for contiguous children).
|
|
fn track_duration(track: &Track) -> RationalTime {
|
|
let mut acc: Option<RationalTime> = None;
|
|
for child in track.children() {
|
|
let duration = match child
|
|
.as_clip()
|
|
.and_then(|c| c.source_range())
|
|
.map(|r| r.duration())
|
|
.or_else(|| {
|
|
child
|
|
.as_gap()
|
|
.and_then(|g| g.source_range())
|
|
.map(|r| r.duration())
|
|
}) {
|
|
Some(d) => d,
|
|
// Transitions occupy no time.
|
|
None => RationalTime::new(0.0, 1.0),
|
|
};
|
|
match acc {
|
|
None => acc = Some(duration),
|
|
Some(a) => {
|
|
let rate = a.rate();
|
|
let value = a.value() + duration.rescaled_to(rate).value();
|
|
acc = Some(RationalTime::new(value, rate));
|
|
}
|
|
}
|
|
}
|
|
acc.unwrap_or_else(RationalTime::invalid_time)
|
|
}
|