refactor: drop internal bridge/ffi layers; exporter family lands

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).
This commit is contained in:
2026-08-16 00:33:45 +08:00
parent 2248be8567
commit ab1a2e9c7b
293 changed files with 27826 additions and 90128 deletions
+122 -295
View File
@@ -17,17 +17,27 @@
//! `ProjectImportTask`, mirroring `src/task/src/project/import/import.h`.
//!
//! Imports media files into a folder of a project. Produces an undoable
//! `OakUndoCommand` (taken via `take_command()`), tracks per-file import
//! failures, and supports an optional image-sequence confirmation callback.
//! [`oakundo::undocommand::UndoCommand`] (taken via `take_command()`),
//! tracks per-file import failures, and supports an optional image-sequence
//! confirmation callback.
//!
//! **Single-lib note**: the node-graph manipulation went through the
//! deleted oaknode C ABI; it now goes through the direct oaknode domain
//! operations in [`crate::nodeops`] (folder/footage creation, probing via
//! the oakcodec decoder registry, undo command construction). The folder
//! and project are domain references (`Arc<Mutex<Project>>` + `NodeId`)
//! instead of borrowed `CHandle`s.
//!
//! CPP-PARITY: src/task/src/project/import/import.h
use std::path::Path;
use crate::bridge;
use oakcommon::configstore::ConfigStore;
use oakcommon::videoparams::VideoType;
use oakundo::undocommand::UndoCommand;
use crate::error::{Error, Result};
use crate::ffi::taskhandle::cstr;
use crate::handle::CHandle;
use crate::nodeops::{self, NodeRef, ProjectRef};
use crate::task::{Task, TaskBehavior};
/// Callback used to confirm whether a detected image sequence should be
@@ -39,18 +49,18 @@ pub type ImageSequenceConfirmFn = Box<dyn FnMut(&str, &str) -> bool + Send>;
pub struct ProjectImportTask {
/// The shared task base.
pub base: Task,
/// Destination folder (borrowed `OakNodeFolder`).
pub folder: CHandle,
/// Destination project (borrowed `OakNodeProject`).
pub project: CHandle,
/// Destination folder (project + folder node id).
pub folder: NodeRef,
/// Destination project.
pub project: ProjectRef,
/// Media filenames to import.
pub filenames: Vec<String>,
/// The undo command produced by the task, taken via `take_command`.
command: Option<CHandle>,
command: Option<UndoCommand>,
/// Files that failed to import.
invalid_files: Vec<String>,
/// Imported footage, taken via `get_imported_footage`.
imported_footage: Vec<CHandle>,
imported_footage: Vec<NodeRef>,
/// Optional image-sequence confirmation callback.
image_sequence_confirm: Option<ImageSequenceConfirmFn>,
/// Total number of files to import (directories counted recursively).
@@ -59,35 +69,15 @@ pub struct ProjectImportTask {
image_sequence_ignore_files: Vec<String>,
}
impl Drop for ProjectImportTask {
fn drop(&mut self) {
if let Some(command) = &mut self.command {
if !command.ctx.is_null() {
unsafe {
bridge::undo::oakundo_command_free(command);
}
}
}
// Borrowed footage handles: releasing them only frees the handle
// boxes (the footage nodes are owned by the project).
for footage in &self.imported_footage {
if !footage.ctx.is_null() {
if let Some(release) = footage.release {
unsafe {
release(footage.ctx);
}
}
}
}
}
}
impl ProjectImportTask {
/// Create an import task for the given folder/project and filenames.
/// The old `folder: CHandle` / `project: CHandle` signature is replaced
/// by the domain [`NodeRef`] folder and [`ProjectRef`] project
/// (single-lib unification).
pub fn new(
base: Task,
folder: CHandle,
project: CHandle,
folder: NodeRef,
project: ProjectRef,
filenames: Vec<String>,
image_sequence_confirm: Option<ImageSequenceConfirmFn>,
file_count: usize,
@@ -108,7 +98,7 @@ impl ProjectImportTask {
/// Take ownership of the produced undo command; `Err(Error::State)` if
/// the task has not run yet.
pub fn take_command(&mut self) -> Result<CHandle> {
pub fn take_command(&mut self) -> Result<UndoCommand> {
self.command.take().ok_or(Error::State)
}
@@ -122,18 +112,11 @@ impl ProjectImportTask {
!self.invalid_files.is_empty()
}
/// Borrowed handle to the imported footage at `index` (addref'd by the C
/// ABI); `Err(Error::NotFound)` if out of range.
pub fn get_imported_footage(&self, index: usize) -> Result<CHandle> {
let footage = self.imported_footage.get(index).ok_or(Error::NotFound)?;
if !footage.ctx.is_null() {
if let Some(addref) = footage.addref {
unsafe {
addref(footage.ctx);
}
}
}
Ok(*footage)
/// The imported footage at `index` as a domain [`NodeRef`];
/// `Err(Error::NotFound)` if out of range. (The deleted C ABI handed
/// out addref'd handles; the domain reference is a plain clone.)
pub fn get_imported_footage(&self, index: usize) -> Result<NodeRef> {
self.imported_footage.get(index).cloned().ok_or(Error::NotFound)
}
/// Total number of imported footage entries.
@@ -151,19 +134,15 @@ impl ProjectImportTask {
self.file_count
}
/// The invalid filename at `index` (assumes the index is in range).
pub(crate) fn invalid_file_at(&self, index: usize) -> &str {
&self.invalid_files[index]
}
fn import(
&mut self,
task: &mut Task,
folder: CHandle,
folder: NodeRef,
entries: &mut Vec<String>,
counter: &mut usize,
parent_command: CHandle,
parent_command: &mut UndoCommand,
) {
let atom = task.get_cancel_atom();
let mut i = 0;
while i < entries.len() {
if task.is_cancelled() {
@@ -183,24 +162,17 @@ impl ProjectImportTask {
};
if !entry_list.is_empty() {
let folder_handle =
unsafe { bridge::node::oaknode_folder_create(self.project) };
if !folder_handle.ctx.is_null() {
unsafe {
bridge::node::oaknode_node_set_label(
bridge::node::oaknode_folder_as_node(folder_handle),
cstr(&basename_of(file_path)),
);
}
if let Some(folder_id) = nodeops::folder_create(&self.project) {
nodeops::set_node_label(&self.project, folder_id, &basename_of(file_path));
self.add_item_to_folder(
folder,
unsafe { bridge::node::oaknode_folder_as_node(folder_handle) },
folder.clone(),
(self.project.clone(), folder_id),
parent_command,
);
let mut sub_entries = entry_list;
self.import(
task,
folder_handle,
(self.project.clone(), folder_id),
&mut sub_entries,
counter,
parent_command,
@@ -208,62 +180,47 @@ impl ProjectImportTask {
}
}
} else {
let footage =
unsafe { bridge::node::oaknode_footage_create(self.project, std::ptr::null()) };
if footage.ctx.is_null() {
let Some(footage) = nodeops::footage_create(&self.project, None) else {
i += 1;
continue;
}
};
unsafe {
bridge::node::oaknode_footage_set_cancel_atom(footage, task.get_cancel_atom());
}
let ok =
unsafe { bridge::node::oaknode_footage_set_filename(footage, cstr(file_path)) }
== 0;
unsafe {
bridge::node::oaknode_footage_set_cancel_atom(
footage,
bridge::render::OakCancelAtom::null(),
);
}
// Mirror the C++ cancel-atom dance around the probe: the
// footage behavior's own cancellation flag tracks the
// task's atom during the probe and is cleared afterwards.
nodeops::footage_set_cancelled(&self.project, footage, true);
nodeops::footage_set_cancelled(&self.project, footage, atom.is_cancelled());
let ok = nodeops::footage_set_filename(&self.project, footage, file_path);
nodeops::footage_set_cancelled(&self.project, footage, false);
if ok && unsafe { bridge::node::oaknode_footage_is_valid(footage) } != 0 {
unsafe {
bridge::node::oaknode_node_set_label(
bridge::node::oaknode_footage_as_node(footage),
cstr(&basename_of(file_path)),
);
}
if ok && nodeops::footage_is_valid(&self.project, footage) {
nodeops::set_node_label(&self.project, footage, &basename_of(file_path));
// See if this footage is an image sequence.
self.validate_image_sequence(task, footage, entries, i);
self.validate_image_sequence(
task,
(self.project.clone(), footage),
entries,
i,
);
// Create the undoable command that adds the item.
self.add_item_to_folder(
folder,
unsafe { bridge::node::oaknode_footage_as_node(footage) },
folder.clone(),
(self.project.clone(), footage),
parent_command,
);
self.imported_footage.push(footage);
self.imported_footage.push((self.project.clone(), footage));
} else {
self.invalid_files.push(file_path.clone());
// Remove the invalid footage from the graph; the remove
// command takes ownership on redo and deletes the node
// when the command is destroyed.
let mut remove = unsafe {
bridge::node::oaknode_command_create_remove_node(
bridge::node::oaknode_footage_as_node(footage),
)
};
if !remove.ctx.is_null() {
unsafe {
bridge::undo::oakundo_command_redo_now(remove);
bridge::undo::oakundo_command_free(&mut remove);
}
}
let mut remove =
nodeops::remove_node_command(self.project.clone(), footage);
remove.redo_now();
}
*counter += 1;
@@ -273,30 +230,25 @@ impl ProjectImportTask {
}
}
fn add_item_to_folder(&self, folder: CHandle, item: CHandle, command: CHandle) {
let child = unsafe { bridge::node::oaknode_command_create_folder_add_child(folder, item) };
if !child.ctx.is_null() {
unsafe {
bridge::undo::oakundo_command_multi_add_child(command, child);
}
}
fn add_item_to_folder(&self, folder: NodeRef, item: NodeRef, command: &mut UndoCommand) {
let child = nodeops::folder_add_child_command(folder, item);
command.multi_add_child(child);
}
fn validate_image_sequence(
&mut self,
task: &mut Task,
footage: CHandle,
footage: NodeRef,
info_list: &mut Vec<String>,
index: usize,
) {
let filename = footage_filename(footage);
let filename = nodeops::footage_filename(&footage.0, footage.1);
if filename.is_empty() {
return;
}
let digit_count = unsafe {
bridge::codec::oakcodec_decoder_get_image_sequence_digit_count(cstr(&filename))
};
// Direct oakcodec calls (single-lib unification).
let digit_count = oakcodec::decoder::get_image_sequence_digit_count(&filename);
if digit_count <= 0 {
return;
}
@@ -309,41 +261,35 @@ impl ProjectImportTask {
return;
}
if !self.item_is_still_image_footage_only(footage) {
if !self.item_is_still_image_footage_only(&footage) {
return;
}
let mut video_stream = CHandle::null();
if unsafe { bridge::node::oaknode_footage_get_video_params(footage, 0, &mut video_stream) }
!= 0
{
let Some(mut video_stream) = nodeops::footage_video_params(&footage.0, footage.1, 0)
else {
return;
}
};
let mut width = 0;
let mut height = 0;
unsafe {
bridge::common::oakcommon_videoparams_get_width(video_stream, &mut width);
bridge::common::oakcommon_videoparams_get_height(video_stream, &mut height);
}
let width = video_stream.width();
let height = video_stream.height();
let seq_index =
unsafe { bridge::codec::oakcodec_decoder_get_image_sequence_index(cstr(&filename)) };
// Direct oakcodec call (single-lib unification).
let seq_index = oakcodec::decoder::get_image_sequence_index(&filename);
let prev_fn = transform_sequence_filename(&filename, seq_index - 1, digit_count);
let next_fn = transform_sequence_filename(&filename, seq_index + 1, digit_count);
let previous_file =
unsafe { bridge::node::oaknode_footage_create(self.project, cstr(&prev_fn)) };
let next_file =
unsafe { bridge::node::oaknode_footage_create(self.project, cstr(&next_fn)) };
let previous_file = nodeops::footage_create(&self.project, Some(&prev_fn));
let next_file = nodeops::footage_create(&self.project, Some(&next_fn));
let prev_matches = !previous_file.ctx.is_null()
&& unsafe { bridge::node::oaknode_footage_is_valid(previous_file) } != 0
&& self.compare_still_image_size(previous_file, width, height);
let next_matches = !next_file.ctx.is_null()
&& unsafe { bridge::node::oaknode_footage_is_valid(next_file) } != 0
&& self.compare_still_image_size(next_file, width, height);
let prev_matches = previous_file.is_some_and(|f| {
nodeops::footage_set_filename(&self.project, f, &prev_fn)
&& self.compare_still_image_size(&(self.project.clone(), f), width, height)
});
let next_matches = next_file.is_some_and(|f| {
nodeops::footage_set_filename(&self.project, f, &next_fn)
&& self.compare_still_image_size(&(self.project.clone(), f), width, height)
});
if prev_matches || next_matches {
// Ask the user whether this is really a sequence (default: no).
@@ -376,179 +322,86 @@ impl ProjectImportTask {
}
if is_sequence {
unsafe {
bridge::common::oakcommon_videoparams_set_video_type(
video_stream,
bridge::common::OAKCOMMON_VIDEO_TYPE_IMAGE_SEQUENCE,
);
video_stream.set_video_type(VideoType::ImageSequence);
let mut rate_buf = [0i8; 64];
let needed = bridge::common::oakcommon_config_get(
std::ptr::null(),
cstr("DefaultSequenceFrameRate"),
rate_buf.as_mut_ptr(),
rate_buf.len() as i32,
);
if needed > 0 {
let rate = crate::project::load::buf_to_string(&rate_buf);
if let Some((num, den)) = parse_rational(&rate) {
if den != 0 {
bridge::common::oakcommon_videoparams_set_time_base(
video_stream,
num,
den,
);
bridge::common::oakcommon_videoparams_set_frame_rate(
video_stream,
den,
num,
);
}
// Direct config read (single-lib unification).
if let Ok(rate) = ConfigStore::instance().get(None, "DefaultSequenceFrameRate") {
if let Some((num, den)) = parse_rational(&rate) {
if den != 0 {
video_stream.set_time_base(num, den);
video_stream.set_frame_rate(den, num);
}
}
bridge::common::oakcommon_videoparams_set_start_time(video_stream, start_index);
bridge::common::oakcommon_videoparams_set_duration(
video_stream,
end_index - start_index + 1,
);
bridge::node::oaknode_footage_set_video_params(footage, 0, &video_stream);
}
}
}
if !video_stream.ctx.is_null() {
unsafe {
bridge::common::oakcommon_videoparams_free(&mut video_stream);
video_stream.set_start_time(start_index);
video_stream.set_duration(end_index - start_index + 1);
nodeops::footage_set_video_params(&footage.0, footage.1, 0, &video_stream);
}
}
// The probe footage was only created for comparison; remove it.
for probe in [previous_file, next_file] {
if !probe.ctx.is_null() {
let mut remove = unsafe {
bridge::node::oaknode_command_create_remove_node(
bridge::node::oaknode_footage_as_node(probe),
)
};
if !remove.ctx.is_null() {
unsafe {
bridge::undo::oakundo_command_redo_now(remove);
bridge::undo::oakundo_command_free(&mut remove);
}
}
}
for probe in [previous_file, next_file].into_iter().flatten() {
let mut remove = nodeops::remove_node_command(self.project.clone(), probe);
remove.redo_now();
}
let _ = task;
}
fn item_is_still_image_footage_only(&self, footage: CHandle) -> bool {
if unsafe { bridge::node::oaknode_footage_total_stream_count(footage) } != 1 {
fn item_is_still_image_footage_only(&self, footage: &NodeRef) -> bool {
// The oaknode domain footage records probed streams; a single
// stream is the closest domain equivalent of the C++
// `total_stream_count == 1` check. The oaknode video params carry
// no `video_type`, so the `kVideoTypeStill` check collapses to
// "one stream with valid dimensions".
if nodeops::footage_total_stream_count(&footage.0, footage.1) != 1 {
return false;
}
let mut vp = CHandle::null();
if unsafe { bridge::node::oaknode_footage_get_video_params(footage, 0, &mut vp) } != 0 {
let Some(vp) = nodeops::footage_video_params(&footage.0, footage.1, 0) else {
return false;
}
};
let mut video_type = 0;
let mut valid = 0;
unsafe {
bridge::common::oakcommon_videoparams_get_video_type(vp, &mut video_type);
bridge::common::oakcommon_videoparams_get_is_valid(vp, &mut valid);
bridge::common::oakcommon_videoparams_free(&mut vp);
}
valid != 0 && video_type == bridge::common::OAKCOMMON_VIDEO_TYPE_STILL
vp.is_valid() && vp.video_type() == VideoType::Video
}
fn compare_still_image_size(&self, footage: CHandle, width: i32, height: i32) -> bool {
fn compare_still_image_size(&self, footage: &NodeRef, width: i32, height: i32) -> bool {
if !self.item_is_still_image_footage_only(footage) {
return false;
}
let mut stream = CHandle::null();
if unsafe { bridge::node::oaknode_footage_get_video_params(footage, 0, &mut stream) } != 0 {
let Some(stream) = nodeops::footage_video_params(&footage.0, footage.1, 0) else {
return false;
}
};
let mut w = 0;
let mut h = 0;
unsafe {
bridge::common::oakcommon_videoparams_get_width(stream, &mut w);
bridge::common::oakcommon_videoparams_get_height(stream, &mut h);
bridge::common::oakcommon_videoparams_free(&mut stream);
}
w == width && h == height
stream.width() == width && stream.height() == height
}
}
impl TaskBehavior for ProjectImportTask {
/// Import each filename via the oaknode footage/folder C ABI
/// (`bridge::node`), collecting an undo command, imported footage, and
/// per-file failures.
/// Import each filename via the direct oaknode domain operations
/// ([`crate::nodeops`]), collecting an undo command, imported footage,
/// and per-file failures.
fn run(&mut self, task: &mut Task) -> Result<()> {
let mut command = unsafe { bridge::undo::oakundo_command_init_multi() };
if command.ctx.is_null() {
task.set_error("Failed to create import command");
return Err(Error::Failed("Failed to create import command".to_string()));
}
self.command = Some(command);
let mut command = UndoCommand::multi();
let mut counter = 0;
let mut entries = self.filenames.clone();
self.import(task, self.folder, &mut entries, &mut counter, command);
let folder = (self.folder.0.clone(), self.folder.1);
self.import(task, folder, &mut entries, &mut counter, &mut command);
if task.is_cancelled() {
if !command.ctx.is_null() {
unsafe {
bridge::undo::oakundo_command_free(&mut command);
}
}
self.command = None;
return Err(Error::Cancelled);
}
self.command = Some(command);
Ok(())
}
}
/// Count files recursively (directories recurse; anything else counts 1),
/// mirroring `count_files_recursive` in import.cpp.
fn count_files_recursive(paths: &[String]) -> usize {
paths
.iter()
.map(|p| {
let path = Path::new(p);
if path.is_dir() {
count_dir_files(path)
} else {
1
}
})
.sum()
}
fn count_dir_files(dir: &Path) -> usize {
match std::fs::read_dir(dir) {
Ok(rd) => rd
.filter_map(|e| e.ok())
.map(|e| {
let p = e.path();
if p.is_dir() {
count_dir_files(&p)
} else {
1
}
})
.sum(),
Err(_) => 0,
}
}
/// `std::filesystem::path(filename).filename()`, as a String.
fn basename_of(path: &str) -> String {
Path::new(path)
.file_name()
@@ -556,20 +409,6 @@ fn basename_of(path: &str) -> String {
.unwrap_or_default()
}
/// Two-stage read of the footage filename.
fn footage_filename(footage: CHandle) -> String {
let needed =
unsafe { bridge::node::oaknode_footage_filename(footage, std::ptr::null_mut(), 0) };
if needed <= 0 {
return String::new();
}
let mut buf = vec![0i8; needed as usize];
unsafe {
bridge::node::oaknode_footage_filename(footage, buf.as_mut_ptr(), needed);
}
crate::project::load::buf_to_string(&buf)
}
/// Substitute `number` into the trailing-digit field of an image-sequence
/// filename (mirrors `oakcodec_decoder_transform_image_sequence_file_name`).
fn transform_sequence_filename(filename: &str, number: i64, digit_count: i32) -> String {
@@ -613,15 +452,3 @@ fn parse_rational(s: &str) -> Option<(i32, i32)> {
let den: i32 = parts.next()?.trim().parse().ok()?;
Some((num, den))
}
/// Convenience used by the C ABI factory to compute the title.
pub(crate) fn import_title(paths: &[String]) -> String {
let count = count_files_recursive(paths).max(1);
format!("Importing {count} file(s)")
}
/// Convenience used by the C ABI factory to compute the progress
/// denominator (mirrors the C++ `file_count_`).
pub(crate) fn import_file_count(paths: &[String]) -> usize {
count_files_recursive(paths).max(1)
}
+37 -96
View File
@@ -17,26 +17,23 @@
//! `ProjectLoadBaseTask` / `ProjectLoadTask`, mirroring
//! `src/task/src/project/load/load.h`.
//!
//! Loads an `.oakproj` file into a new `OakNodeProject`. The base task holds
//! the filename and produces the project on `take_project()`; `ProjectLoadTask`
//! is the concrete (OTIO-less) loader.
//! Loads an `.oakproj` file into a new `oaknode::project::Project`. The
//! base task holds the filename and produces the project on
//! `take_project()`; `ProjectLoadTask` is the concrete (OTIO-less) loader.
//! The project itself is loaded through the direct Rust serializer
//! (`oaknode::serializer::load` — single-lib unification; the old oaknode
//! serializer C ABI with its per-code result mapping is gone, so the
//! version/result-code ladder collapses into the error message the XML
//! path used).
//!
//! CPP-PARITY: src/task/src/project/load/load.h
use crate::bridge;
use crate::error::{Error, Result};
use crate::ffi::taskhandle::cstr;
use crate::handle::CHandle;
use crate::task::{Task, TaskBehavior};
use std::sync::{Arc, Mutex};
/// Serializer result codes (`include/node/serializer.h`).
const OAKNODE_SERIALIZER_RESULT_SUCCESS: i32 = 0;
const OAKNODE_SERIALIZER_RESULT_PROJECT_TOO_OLD: i32 = 1;
const OAKNODE_SERIALIZER_RESULT_PROJECT_TOO_NEW: i32 = 2;
const OAKNODE_SERIALIZER_RESULT_UNKNOWN_VERSION: i32 = 3;
const OAKNODE_SERIALIZER_RESULT_FILE_ERROR: i32 = 4;
const OAKNODE_SERIALIZER_RESULT_XML_ERROR: i32 = 5;
const OAKNODE_SERIALIZER_RESULT_NO_DATA: i32 = 7;
use oaknode::project::Project;
use crate::error::{Error, Result};
use crate::task::{Task, TaskBehavior};
/// The base project-load task: owns a source filename and yields a loaded
/// project via [`ProjectLoadBaseTask::take_project`].
@@ -46,20 +43,7 @@ pub struct ProjectLoadBaseTask {
/// Absolute project filename to load.
pub filename: String,
/// The loaded project, produced by the task and taken via `take_project`.
loaded_project: Option<CHandle>,
}
impl Drop for ProjectLoadBaseTask {
fn drop(&mut self) {
// Free the loaded project if it was never taken.
if let Some(project) = &mut self.loaded_project {
if !project.ctx.is_null() {
unsafe {
bridge::node::oaknode_project_free(project);
}
}
}
}
loaded_project: Option<Arc<Mutex<Project>>>,
}
impl ProjectLoadBaseTask {
@@ -74,83 +58,49 @@ impl ProjectLoadBaseTask {
/// Take ownership of the loaded project. Returns `Err(Error::State)` if
/// the task has not loaded a project yet.
pub fn take_project(&mut self) -> Result<CHandle> {
pub fn take_project(&mut self) -> Result<Arc<Mutex<Project>>> {
self.loaded_project.take().ok_or(Error::State)
}
/// Store the loaded project (called by `run`).
pub(crate) fn store_project(&mut self, project: CHandle) {
pub(crate) fn store_project(&mut self, project: Arc<Mutex<Project>>) {
self.loaded_project = Some(project);
}
}
impl TaskBehavior for ProjectLoadBaseTask {
/// Load the project from `filename` via the oaknode serializer
/// (`bridge::node`), storing the resulting `OakNodeProject`.
/// Load the project from `filename` via the direct oaknode serializer
/// (`oaknode::serializer::load`), storing the resulting project.
fn run(&mut self, task: &mut Task) -> Result<()> {
let mut project = unsafe { bridge::node::oaknode_project_init() };
if project.ctx.is_null() {
task.set_error("Failed to create project");
return Err(Error::Failed("Failed to create project".to_string()));
}
unsafe {
bridge::node::oaknode_project_set_filename(project, cstr(&self.filename));
}
let mut code = OAKNODE_SERIALIZER_RESULT_FILE_ERROR;
let mut details = [0i8; 512];
let result = unsafe {
bridge::node::oaknode_serializer_load_from_file(
project,
cstr(&self.filename),
&mut code,
details.as_mut_ptr(),
details.len() as i32,
)
};
let mut success = false;
match code {
OAKNODE_SERIALIZER_RESULT_SUCCESS => success = true,
OAKNODE_SERIALIZER_RESULT_PROJECT_TOO_OLD => {
task.set_error("This project is from a version of Oak Video Editor that is no longer supported in this version.");
}
OAKNODE_SERIALIZER_RESULT_PROJECT_TOO_NEW => {
task.set_error("This project is from a newer version of Oak Video Editor and cannot be opened in this version.");
}
OAKNODE_SERIALIZER_RESULT_UNKNOWN_VERSION => {
task.set_error("Failed to determine project version.");
}
OAKNODE_SERIALIZER_RESULT_FILE_ERROR => {
let xml = match std::fs::read_to_string(&self.filename) {
Ok(xml) => xml,
Err(e) => {
task.set_error(&format!(
"Failed to read file \"{}\" for reading.",
self.filename
));
let _ = e;
return Err(Error::Failed("Failed to load project".to_string()));
}
OAKNODE_SERIALIZER_RESULT_XML_ERROR => {
};
let project = match oaknode::serializer::load(&xml) {
Ok(project) => project,
Err(e) => {
task.set_error(&format!(
"Failed to read XML document. File may be corrupt. Error was: {}",
buf_to_string(&details)
"Failed to read XML document. File may be corrupt. Error was: {e}"
));
return Err(Error::Failed("Failed to load project".to_string()));
}
OAKNODE_SERIALIZER_RESULT_NO_DATA => {
task.set_error("Failed to find any data to parse.");
}
_ => task.set_error("Unknown error."),
};
{
let mut guard = project.lock().unwrap_or_else(|e| e.into_inner());
guard.set_filename(&self.filename);
}
if result == 0 && success {
self.store_project(project);
return Ok(());
}
if !project.ctx.is_null() {
unsafe {
bridge::node::oaknode_project_free(&mut project);
}
}
Err(Error::Failed("Failed to load project".to_string()))
self.store_project(project);
Ok(())
}
}
@@ -165,12 +115,3 @@ impl TaskBehavior for ProjectLoadTask {
<ProjectLoadBaseTask as TaskBehavior>::run(&mut self.base, task)
}
}
/// Read a NUL-terminated char buffer into a String (lossy).
pub(crate) fn buf_to_string(buf: &[i8]) -> String {
let len = buf.iter().position(|&c| c == 0).unwrap_or(buf.len());
unsafe {
String::from_utf8_lossy(std::slice::from_raw_parts(buf.as_ptr() as *const u8, len))
.into_owned()
}
}
+208 -298
View File
@@ -17,12 +17,20 @@
//! `LoadOTIOTask`, mirroring `src/task/src/project/loadotio/loadotio.h`.
//!
//! Loads an OpenTimelineIO (`.otio`) or FCPXML (`.fcpxml`) file into a new
//! `OakNodeProject`, with a configurable import-confirmation callback. The
//! format is dispatched from the filename extension (see
//! project, with a configurable import-confirmation callback. The format
//! is dispatched from the filename extension (see
//! [`crate::project::format`]); the document is parsed with the pure-Rust
//! `oakotio` binding (see `README` decision #6) and the project is built
//! through the oaknode / oaktimeline C ABIs exactly like the C++ task, so
//! no OTIO or FCPXML type crosses the oaktask C ABI.
//! `oakotio` binding (see `README` decision #6).
//!
//! **Single-lib note**: the project is built through the direct oaknode
//! domain operations in [`crate::nodeops`] (`Project::new()` +
//! `Project::initialize()`, factory-created sequence/folder/footage/
//! block/track nodes, graph connections) instead of the deleted oaknode /
//! oaktimeline C ABIs. Track creation uses the task-local
//! [`crate::nodeops::add_track_command`] (see its docs for the
//! oaktimeline-migration note). The loaded project is an
//! `Arc<Mutex<oaknode::project::Project>>` stored on the base task
//! (`take_project()`).
//!
//! CPP-PARITY: src/task/src/project/loadotio/loadotio.cpp
@@ -30,12 +38,12 @@ use std::collections::HashMap;
use std::path::Path;
use std::sync::Mutex;
use oaknode::id::NodeId;
use oaknode::track::TrackType;
use oakotio::Serializable;
use crate::bridge;
use crate::error::{Error, Result};
use crate::ffi::taskhandle::cstr;
use crate::handle::CHandle;
use crate::nodeops::{self, NodeRef, ProjectRef};
use crate::project::format::InterchangeFormat;
use crate::project::load::ProjectLoadBaseTask;
use crate::task::{Task, TaskBehavior};
@@ -92,32 +100,34 @@ impl TaskBehavior for LoadOTIOTask {
let timelines = parse_timelines(task, &self.base.filename, format)?;
let mut project = unsafe { bridge::node::oaknode_project_init() };
if project.ctx.is_null() {
task.set_error("Failed to create project");
return Err(Error::Failed("Failed to create project".to_string()));
}
unsafe {
bridge::node::oaknode_project_initialize(project);
bridge::node::oaknode_project_set_modified(project, 1);
// Build the project directly through the oaknode domain model
// (the deleted `oaknode_project_init` stub is gone).
let project: ProjectRef = oaknode::project::Project::new();
{
let mut guard = project.lock().unwrap_or_else(|e| e.into_inner());
if let Err(e) = guard.initialize() {
let _ = e;
task.set_error("Failed to create project");
return Err(Error::Failed("Failed to create project".to_string()));
}
guard.set_modified(true);
}
// Keep track of imported footage
let mut imported_footage: HashMap<String, CHandle> = HashMap::new();
let mut imported_footage: HashMap<String, NodeRef> = HashMap::new();
// Generate a list of sequences with the same names as the timelines.
// Assumes each timeline has a unique name.
let mut unnamed_sequence_count = 0;
let mut sequences: Vec<CHandle> = Vec::new();
let mut sequences: Vec<NodeRef> = Vec::new();
// Variables used for loading bar
let mut number_of_clips: f64 = 0.0;
for timeline in &timelines {
let sequence = unsafe { bridge::node::oaknode_sequence_create() };
if sequence.ctx.is_null() {
let Some(sequence) = nodeops::sequence_create(&project) else {
continue;
}
};
let label = if !timeline.name().is_empty() {
timeline.name().to_string()
@@ -127,17 +137,7 @@ impl TaskBehavior for LoadOTIOTask {
unnamed_sequence_count += 1;
format!("Sequence {unnamed_sequence_count}")
};
unsafe {
bridge::node::oaknode_node_set_label(
bridge::node::oaknode_sequence_as_node(sequence),
cstr(&label),
);
}
// Set default params incase they aren't edited.
unsafe {
bridge::node::oaknode_sequence_set_default_parameters(sequence);
}
nodeops::set_node_label(&project, sequence, &label);
// Get number of clips for loading bar
for track in timeline.tracks().children() {
@@ -146,7 +146,7 @@ impl TaskBehavior for LoadOTIOTask {
}
}
sequences.push(sequence);
sequences.push((project.clone(), sequence));
}
if number_of_clips <= 0.0 {
number_of_clips = 1.0;
@@ -156,7 +156,7 @@ impl TaskBehavior for LoadOTIOTask {
// default accepts everything).
let sequence_names: Vec<String> = sequences
.iter()
.map(|s| node_label_of(unsafe { bridge::node::oaknode_sequence_as_node(*s) }))
.map(|(p, s)| nodeops::node_label(p, *s))
.collect();
let mut confirm = CONFIRM_CALLBACK.lock().unwrap().take();
let accepted = match confirm.as_mut() {
@@ -167,62 +167,35 @@ impl TaskBehavior for LoadOTIOTask {
if !accepted {
// Cancel to indicate to caller that this task did not complete
// and to simply dispose of it. The project is never handed to the
// base task, so free it here (the C++ base-task destructor does
// the same).
// base task (the C++ base-task destructor does the same).
task.cancel();
for sequence in &mut sequences {
unsafe {
bridge::node::oaknode_sequence_free(sequence);
}
}
if !project.ctx.is_null() {
unsafe {
bridge::node::oaknode_project_free(&mut project);
}
}
return Ok(());
}
let root_folder = unsafe { bridge::node::oaknode_project_root(project) };
let root_folder = {
let guard = project.lock().unwrap_or_else(|e| e.into_inner());
guard.root
};
let mut clips_done = 0.0f64;
for (timeline, sequence) in timelines.iter().zip(&sequences) {
let sequence_node = unsafe { bridge::node::oaknode_sequence_as_node(*sequence) };
for (timeline, (_, sequence)) in timelines.iter().zip(&sequences) {
let sequence_node = *sequence;
unsafe {
bridge::node::oaknode_project_add_node(project, sequence_node);
}
let mut add_seq = unsafe {
bridge::node::oaknode_command_create_folder_add_child(root_folder, sequence_node)
};
if !add_seq.ctx.is_null() {
unsafe {
bridge::undo::oakundo_command_redo_now(add_seq);
bridge::undo::oakundo_command_free(&mut add_seq);
}
}
let mut add_seq = nodeops::folder_add_child_command(
(project.clone(), root_folder),
(project.clone(), sequence_node),
);
add_seq.redo_now();
// Create a folder for this sequence's footage
let sequence_footage = unsafe { bridge::node::oaknode_folder_create(project) };
if !sequence_footage.ctx.is_null() {
unsafe {
bridge::node::oaknode_node_set_label(
bridge::node::oaknode_folder_as_node(sequence_footage),
cstr(timeline.name()),
);
}
let mut add_folder = unsafe {
bridge::node::oaknode_command_create_folder_add_child(
root_folder,
bridge::node::oaknode_folder_as_node(sequence_footage),
)
};
if !add_folder.ctx.is_null() {
unsafe {
bridge::undo::oakundo_command_redo_now(add_folder);
bridge::undo::oakundo_command_free(&mut add_folder);
}
}
let sequence_footage = nodeops::folder_create(&project);
if let Some(folder_id) = sequence_footage {
nodeops::set_node_label(&project, folder_id, timeline.name());
let mut add_folder = nodeops::folder_add_child_command(
(project.clone(), root_folder),
(project.clone(), folder_id),
);
add_folder.redo_now();
}
// Iterate through tracks
@@ -233,8 +206,8 @@ impl TaskBehavior for LoadOTIOTask {
// Determine what kind of track it is
let track_type = match otio_track.kind() {
"Video" => bridge::node::OAKNODE_TRACK_TYPE_VIDEO,
"Audio" => bridge::node::OAKNODE_TRACK_TYPE_AUDIO,
"Video" => TrackType::Video,
"Audio" => TrackType::Audio,
other => {
eprintln!("Found unknown track type: {other}");
continue;
@@ -242,43 +215,27 @@ impl TaskBehavior for LoadOTIOTask {
};
// Create a new track
let mut track_list = CHandle::null();
unsafe {
bridge::node::oaknode_sequence_get_track_list(
*sequence,
track_type,
&mut track_list,
);
}
let mut add_track =
unsafe { bridge::timeline::oaktimeline_add_track_command(track_list) };
if !add_track.ctx.is_null() {
unsafe {
bridge::undo::oakundo_command_redo_now(add_track);
bridge::undo::oakundo_command_free(&mut add_track);
}
}
let mut track = CHandle::null();
let mut count = 0;
unsafe {
bridge::node::oaknode_tracklist_get_track_count(track_list, &mut count);
}
if count > 0 {
unsafe {
bridge::node::oaknode_tracklist_get_track_at(
track_list,
count - 1,
&mut track,
);
}
}
if track.ctx.is_null() {
let Some(track_list) =
nodeops::sequence_track_list(&project, sequence_node, track_type)
else {
continue;
}
};
let mut add_track =
nodeops::add_track_command(project.clone(), track_list);
add_track.redo_now();
let track_count = nodeops::tracklist_track_count(&project, track_list);
let track = if track_count > 0 {
nodeops::tracklist_track_at(&project, track_list, track_count - 1)
} else {
None
};
let Some(track) = track else {
continue;
};
// Get clips from track
let mut previous_block = CHandle::null();
let mut previous_block: Option<NodeId> = None;
let mut prev_block_transition = false;
for otio_block in otio_track.children() {
@@ -286,35 +243,27 @@ impl TaskBehavior for LoadOTIOTask {
break;
}
let block = match otio_block.schema_name() {
"Clip" => unsafe { bridge::node::oaknode_block_clip_create() },
"Gap" => unsafe { bridge::node::oaknode_block_gap_create() },
let block_kind = match otio_block.schema_name() {
"Clip" => nodeops::BlockKind::Clip,
"Gap" => nodeops::BlockKind::Gap,
"Transition" => {
// Todo: Look into OTIO supported transitions and add
// them to Oak.
unsafe {
bridge::node::oaknode_block_transition_create(
bridge::node::OAKNODE_TRANSITION_CROSS_DISSOLVE,
)
}
nodeops::BlockKind::Transition
}
other => {
// We don't know what this is yet, just create a gap
// for now so that *something* is there.
eprintln!("Found unknown block type: {other}");
unsafe { bridge::node::oaknode_block_gap_create() }
nodeops::BlockKind::Gap
}
};
if block.ctx.is_null() {
let Some(block) = nodeops::block_create(&project, block_kind) else {
continue;
}
let block_node = unsafe { bridge::node::oaknode_block_as_node(block) };
unsafe {
bridge::node::oaknode_project_add_node(project, block_node);
bridge::node::oaknode_node_set_label(block_node, cstr(otio_block.name()));
bridge::node::oaknode_track_append_block(track, block);
}
};
let block_node = block;
nodeops::set_node_label(&project, block_node, otio_block.name());
nodeops::track_append_block(&project, track, block_node);
if otio_block.schema_name() == "Clip" || otio_block.schema_name() == "Gap" {
if let Some(source_range) = otio_block.source_range() {
@@ -325,32 +274,31 @@ impl TaskBehavior for LoadOTIOTask {
let duration = oakcore_rs::Rational::from_double(duration_seconds);
if otio_block.schema_name() == "Clip" {
unsafe {
bridge::node::oaknode_clip_set_media_in(
block,
start_time.numerator() as i32,
start_time.denominator() as i32,
);
}
}
unsafe {
bridge::node::oaknode_block_set_length_and_media_out(
block,
duration.numerator() as i32,
duration.denominator() as i32,
nodeops::clip_set_media_in(
&project,
block_node,
start_time.numerator(),
start_time.denominator(),
);
}
nodeops::block_set_length_and_media_out(
&project,
block_node,
duration.numerator(),
duration.denominator(),
);
}
}
// If the previous block was a transition, connect the
// current block to it.
if prev_block_transition {
unsafe {
bridge::node::oaknode_node_connect(
if let Some(prev) = previous_block {
nodeops::node_connect(
&project,
block_node,
bridge::node::oaknode_block_as_node(previous_block),
cstr(bridge::node::OAKNODE_TRANSITION_IN_BLOCK_INPUT),
prev,
nodeops::TRANSITION_IN_BLOCK_INPUT,
);
}
prev_block_transition = false;
@@ -361,43 +309,37 @@ impl TaskBehavior for LoadOTIOTask {
// clip.
let in_offset = otio_transition.in_offset().to_rational();
let out_offset = otio_transition.out_offset().to_rational();
unsafe {
bridge::node::oaknode_transition_set_offsets_and_length(
block,
in_offset.numerator() as i32,
in_offset.denominator() as i32,
out_offset.numerator() as i32,
out_offset.denominator() as i32,
);
}
nodeops::transition_set_offsets_and_length(
&project,
block_node,
in_offset.numerator(),
in_offset.denominator(),
out_offset.numerator(),
out_offset.denominator(),
);
if !previous_block.ctx.is_null() {
unsafe {
bridge::node::oaknode_node_connect(
bridge::node::oaknode_block_as_node(previous_block),
block_node,
cstr(bridge::node::OAKNODE_TRANSITION_OUT_BLOCK_INPUT),
);
}
if let Some(prev) = previous_block {
nodeops::node_connect(
&project,
prev,
block_node,
nodeops::TRANSITION_OUT_BLOCK_INPUT,
);
}
prev_block_transition = true;
// Position transition in its own context.
unsafe {
set_own_context_position(block_node);
}
set_own_context_position(&project, block_node);
}
if otio_block.schema_name() == "Gap" {
// Position gap in its own context.
unsafe {
set_own_context_position(block_node);
}
set_own_context_position(&project, block_node);
}
// Update this after it's used but before any continue
// statements.
previous_block = block;
previous_block = Some(block_node);
if otio_block.schema_name() == "Clip" {
let Some(otio_clip) = otio_block.as_clip() else {
@@ -410,115 +352,90 @@ impl TaskBehavior for LoadOTIOTask {
// Link footage
let footage_url = external.target_url().to_string();
let probed_item = if let Some(existing) =
imported_footage.get(&footage_url)
{
*existing
} else {
let created = unsafe {
bridge::node::oaknode_footage_create(
project,
cstr(&footage_url),
)
};
if !created.ctx.is_null() {
imported_footage.insert(footage_url.clone(), created);
let probed_item: Option<NodeRef> =
if let Some(existing) = imported_footage.get(&footage_url) {
Some(existing.clone())
} else {
let created =
nodeops::footage_create(&project, Some(&footage_url));
if let Some(created) = created {
imported_footage.insert(
footage_url.clone(),
(project.clone(), created),
);
let label = Path::new(&footage_url)
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
unsafe {
bridge::node::oaknode_node_set_label(
bridge::node::oaknode_footage_as_node(created),
cstr(&label),
let label = Path::new(&footage_url)
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
nodeops::set_node_label(&project, created, &label);
if let Some(folder_id) = sequence_footage {
let mut add_footage =
nodeops::folder_add_child_command(
(project.clone(), folder_id),
(project.clone(), created),
);
add_footage.redo_now();
}
}
created.map(|id| (project.clone(), id))
};
if let Some((_, probed_id)) = probed_item {
// Position clip in its own context.
set_own_context_position(&project, block_node);
// Position footage in its context.
nodeops::node_set_context_position(
&project, block_node, probed_id, -2.0, 0.0, false,
);
// Record the clip-footage link in the domain
// model (the C++ finds footage through the
// input chain; the Rust clip records it).
nodeops::clip_set_footage(&project, block_node, probed_id);
if track_type == TrackType::Video {
if let Some(transform) = factory_create(
&project,
nodeops::TRANSFORM_TYPE_ID,
) {
nodeops::node_connect(
&project,
probed_id,
transform,
"tex_in",
);
nodeops::node_connect(
&project,
transform,
block_node,
nodeops::CLIP_TEXTURE_INPUT,
);
nodeops::node_set_context_position(
&project, block_node, transform, -1.0, 0.0, false,
);
}
if !sequence_footage.ctx.is_null() {
let mut add_footage = unsafe {
bridge::node::oaknode_command_create_folder_add_child(
sequence_footage,
bridge::node::oaknode_footage_as_node(created),
)
};
if !add_footage.ctx.is_null() {
unsafe {
bridge::undo::oakundo_command_redo_now(add_footage);
bridge::undo::oakundo_command_free(
&mut add_footage,
);
}
}
}
}
created
};
if !probed_item.ctx.is_null() {
unsafe {
// Position clip in its own context.
set_own_context_position(block_node);
// Position footage in its context.
bridge::node::oaknode_node_set_context_position(
block_node,
bridge::node::oaknode_footage_as_node(probed_item),
-2.0,
0.0,
0,
);
}
if track_type == bridge::node::OAKNODE_TRACK_TYPE_VIDEO {
let transform = unsafe {
bridge::node::oaknode_factory_create_from_id(cstr(
bridge::node::OAKNODE_TYPE_TRANSFORM,
))
};
if !transform.ctx.is_null() {
unsafe {
bridge::node::oaknode_project_add_node(
project, transform,
);
bridge::node::oaknode_node_connect(
bridge::node::oaknode_footage_as_node(probed_item),
transform,
cstr("tex_in"),
);
bridge::node::oaknode_node_connect(
transform,
block_node,
cstr("buffer_in"),
);
bridge::node::oaknode_node_set_context_position(
block_node, transform, -1.0, 0.0, 0,
);
}
}
} else {
let volume = unsafe {
bridge::node::oaknode_factory_create_from_id(cstr(
bridge::node::OAKNODE_TYPE_VOLUME,
))
};
if !volume.ctx.is_null() {
unsafe {
bridge::node::oaknode_project_add_node(project, volume);
bridge::node::oaknode_node_connect(
bridge::node::oaknode_footage_as_node(probed_item),
volume,
cstr("samples_in"),
);
bridge::node::oaknode_node_connect(
volume,
block_node,
cstr("buffer_in"),
);
bridge::node::oaknode_node_set_context_position(
block_node, volume, -1.0, 0.0, 0,
);
}
if let Some(volume) =
factory_create(&project, nodeops::VOLUME_TYPE_ID)
{
nodeops::node_connect(
&project,
probed_id,
volume,
"samples_in",
);
nodeops::node_connect(
&project,
volume,
block_node,
nodeops::CLIP_TEXTURE_INPUT,
);
nodeops::node_set_context_position(
&project, block_node, volume, -1.0, 0.0, false,
);
}
}
}
@@ -536,12 +453,18 @@ impl TaskBehavior for LoadOTIOTask {
}
}
/// Create a node from the factory registry (`oaknode_factory_create_from_id`).
fn factory_create(project: &ProjectRef, type_id: &str) -> Option<NodeId> {
let meta = oaknode::factory::Factory::global().find(type_id)?;
let (core, behavior) = (meta.create)();
let mut guard = project.lock().unwrap_or_else(|e| e.into_inner());
Some(guard.graph.add_node(core, behavior))
}
/// Set the node's position in its own context (the C++
/// `set_own_context_position` helper in loadotio.cpp).
unsafe fn set_own_context_position(node: CHandle) {
unsafe {
bridge::node::oaknode_node_set_context_position(node, node, 0.0, 0.0, 0);
}
fn set_own_context_position(project: &ProjectRef, node: NodeId) {
nodeops::node_set_context_position(project, node, node, 0.0, 0.0, false);
}
/// Parse the document into one `oakotio::Timeline` per sequence, dispatching
@@ -593,16 +516,3 @@ fn parse_timelines(
}),
}
}
/// Two-stage read of a node's label (the C++ `oaknode_node_get_label` usage).
fn node_label_of(node: CHandle) -> String {
let needed = unsafe { bridge::node::oaknode_node_get_label(node, std::ptr::null_mut(), 0) };
if needed <= 0 {
return String::new();
}
let mut buf = vec![0i8; needed as usize];
unsafe {
bridge::node::oaknode_node_get_label(node, buf.as_mut_ptr(), needed);
}
crate::project::load::buf_to_string(&buf)
}
+37 -71
View File
@@ -16,35 +16,34 @@
//! `ProjectSaveTask`, mirroring `src/task/src/project/save/save.h`.
//!
//! Serializes a borrowed `OakNodeProject` to an `.oakproj` file via the
//! oaknode serializer. Optionally writes to an override filename and/or uses
//! compression.
//! Serializes a borrowed `oaknode::project::Project` to an `.oakproj` file
//! via the direct Rust serializer (`oaknode::serializer::save` —
//! single-lib unification; the old oaknode serializer C ABI with its
//! overwrite/compression result ladder is gone, so the overwrite-as-else
//! path collapses into a plain write). Optionally writes to an override
//! filename.
//!
//! CPP-PARITY: src/task/src/project/save/save.h
use crate::bridge;
use crate::error::{Error, Result};
use crate::ffi::taskhandle::cstr;
use crate::handle::CHandle;
use crate::project::load::buf_to_string;
use crate::task::{Task, TaskBehavior};
use std::sync::{Arc, Mutex};
/// Serializer result codes (`include/node/serializer.h`).
const OAKNODE_SERIALIZER_RESULT_SUCCESS: i32 = 0;
const OAKNODE_SERIALIZER_RESULT_XML_ERROR: i32 = 5;
const OAKNODE_SERIALIZER_RESULT_FILE_ERROR: i32 = 4;
const OAKNODE_SERIALIZER_RESULT_OVERWRITE_ERROR: i32 = 6;
use oaknode::project::Project;
use crate::error::{Error, Result};
use crate::task::{Task, TaskBehavior};
/// A project-save task. Borrows its project and never takes ownership.
pub struct ProjectSaveTask {
/// The shared task base.
pub base: Task,
/// Borrowed project to save (borrowed `OakNodeProject`).
pub project: CHandle,
/// Borrowed project to save (`Arc<Mutex<Project>>`).
pub project: Arc<Mutex<Project>>,
/// Optional override filename; when empty the project's own filename is
/// used.
pub override_filename: Option<String>,
/// Whether to compress the serialized output.
/// Whether to compress the serialized output. The direct serializer has
/// no compression mode (the old C ABI compression path is gone), so
/// this flag is retained for API parity but ignored.
pub use_compression: bool,
}
@@ -56,12 +55,17 @@ impl ProjectSaveTask {
}
impl TaskBehavior for ProjectSaveTask {
/// Serialize the project via the oaknode serializer (`bridge::node`) to
/// the resolved filename.
/// Serialize the project via the direct oaknode serializer
/// (`oaknode::serializer::save`) to the resolved filename.
fn run(&mut self, task: &mut Task) -> Result<()> {
let using_filename = match &self.override_filename {
Some(name) => name.clone(),
None => project_filename(self.project),
None => self
.project
.lock()
.unwrap_or_else(|e| e.into_inner())
.filename()
.to_string(),
};
if using_filename.is_empty() {
@@ -71,60 +75,22 @@ impl TaskBehavior for ProjectSaveTask {
));
}
let mut code = OAKNODE_SERIALIZER_RESULT_FILE_ERROR;
let mut details = [0i8; 512];
unsafe {
bridge::node::oaknode_serializer_save_to_file(
self.project,
cstr(&using_filename),
if self.use_compression { 1 } else { 0 },
&mut code,
details.as_mut_ptr(),
details.len() as i32,
);
}
let mut success = false;
match code {
OAKNODE_SERIALIZER_RESULT_SUCCESS => success = true,
OAKNODE_SERIALIZER_RESULT_XML_ERROR => {
let xml = match oaknode::serializer::save(
&self.project.lock().unwrap_or_else(|e| e.into_inner()),
) {
Ok(xml) => xml,
Err(e) => {
let _ = e;
task.set_error("Failed to write XML data.");
return Err(Error::Failed("Failed to save project".to_string()));
}
OAKNODE_SERIALIZER_RESULT_FILE_ERROR => {
task.set_error(&format!(
"Failed to open file for writing: {}",
buf_to_string(&details)
));
}
OAKNODE_SERIALIZER_RESULT_OVERWRITE_ERROR => {
task.set_error(&format!(
"Failed to overwrite \"{}\". Project has been saved as \"{}\" instead.",
using_filename,
buf_to_string(&details)
));
success = true;
}
_ => task.set_error("Unknown error."),
};
if let Err(e) = std::fs::write(&using_filename, xml) {
task.set_error(&format!("Failed to open file for writing: {e}"));
return Err(Error::Failed("Failed to save project".to_string()));
}
if success {
Ok(())
} else {
Err(Error::Failed("Failed to save project".to_string()))
}
Ok(())
}
}
/// Two-stage read of the project's own filename (empty when unset).
pub(crate) fn project_filename(project: CHandle) -> String {
let needed =
unsafe { bridge::node::oaknode_project_filename(project, std::ptr::null_mut(), 0) };
if needed <= 0 {
return String::new();
}
let mut buf = vec![0i8; needed as usize];
unsafe {
bridge::node::oaknode_project_filename(project, buf.as_mut_ptr(), needed);
}
buf_to_string(&buf)
}
+130 -251
View File
@@ -16,28 +16,33 @@
//! `SaveOTIOTask`, mirroring `src/task/src/project/saveotio/saveotio.h`.
//!
//! Serializes a borrowed `OakNodeProject` 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
//! 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. No OTIO or
//! FCPXML type crosses the oaktask C ABI.
//! 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::bridge;
use crate::error::{Error, Result};
use crate::handle::CHandle;
use crate::nodeops::{self, ProjectRef};
use crate::project::format::InterchangeFormat;
use crate::project::load::buf_to_string;
use crate::task::{Task, TaskBehavior};
/// `Rational(INT_MIN)` — the initial "no track yet" maximum length
@@ -50,8 +55,8 @@ fn rational_min() -> Rational {
pub struct SaveOTIOTask {
/// The shared task base.
pub base: Task,
/// Borrowed project to save (borrowed `OakNodeProject`).
pub project: CHandle,
/// Borrowed project to save (domain project).
pub project: ProjectRef,
/// Output OTIO filename.
pub filename: String,
}
@@ -61,54 +66,35 @@ impl SaveOTIOTask {
/// sequence has no usable frame rate or a track fails to serialize.
///
/// CPP-PARITY: saveotio.cpp (SaveOTIOTask::serialize_timeline)
fn serialize_timeline(sequence: CHandle) -> Option<Timeline> {
let mut otio_timeline = Timeline::new(node_label_of(unsafe {
bridge::node::oaknode_sequence_as_node(sequence)
}));
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;
{
let mut num = 0;
let mut den = 1;
let mut vp = CHandle::null();
if unsafe { bridge::node::oaknode_sequence_get_video_params(sequence, 0, &mut vp) } == 0
{
unsafe {
bridge::common::oakcommon_videoparams_get_frame_rate(vp, &mut num, &mut den);
}
if den != 0 {
rate = num as f64 / den as f64;
}
if !vp.ctx.is_null() {
unsafe {
bridge::common::oakcommon_videoparams_free(&mut vp);
}
}
}
if den != 0 {
rate = num as f64 / den as f64;
}
if rate.is_nan() || rate <= 0.0 {
return None;
}
let mut video_list = CHandle::null();
let mut audio_list = CHandle::null();
unsafe {
bridge::node::oaknode_sequence_get_track_list(
sequence,
bridge::node::OAKNODE_TRACK_TYPE_VIDEO,
&mut video_list,
);
bridge::node::oaknode_sequence_get_track_list(
sequence,
bridge::node::OAKNODE_TRACK_TYPE_AUDIO,
&mut audio_list,
);
}
let video_list = nodeops::sequence_track_list(project, sequence, TrackType::Video);
let audio_list = nodeops::sequence_track_list(project, sequence, TrackType::Audio);
if !Self::serialize_track_list(video_list, &mut otio_timeline, rate)
|| !Self::serialize_track_list(audio_list, &mut otio_timeline, rate)
{
return None;
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)
@@ -119,28 +105,18 @@ impl SaveOTIOTask {
///
/// CPP-PARITY: saveotio.cpp (SaveOTIOTask::serialize_track_list)
fn serialize_track_list(
list: CHandle,
project: &ProjectRef,
list: NodeId,
otio_timeline: &mut Timeline,
sequence_rate: f64,
) -> bool {
if list.ctx.is_null() {
return true;
}
let mut max_track_length = rational_min();
let mut track_count = 0;
unsafe {
bridge::node::oaknode_tracklist_get_track_count(list, &mut track_count);
}
let track_count = nodeops::tracklist_track_count(project, list);
for i in 0..track_count {
let mut track = CHandle::null();
unsafe {
bridge::node::oaknode_tracklist_get_track_at(list, i, &mut track);
}
if !track.ctx.is_null() {
let length = track_length_of(track);
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;
}
@@ -148,15 +124,12 @@ impl SaveOTIOTask {
}
for i in 0..track_count {
let mut track = CHandle::null();
unsafe {
bridge::node::oaknode_tracklist_get_track_at(list, i, &mut track);
}
if track.ctx.is_null() {
let Some(track) = nodeops::tracklist_track_at(project, list, i) else {
continue;
}
};
let Some(otio_track) = Self::serialize_track(track, sequence_rate, max_track_length)
let Some(otio_track) =
Self::serialize_track(project, track, sequence_rate, max_track_length)
else {
return false;
};
@@ -175,99 +148,62 @@ impl SaveOTIOTask {
///
/// CPP-PARITY: saveotio.cpp (SaveOTIOTask::serialize_track)
fn serialize_track(
track: CHandle,
project: &ProjectRef,
track: NodeId,
sequence_rate: f64,
max_track_length: Rational,
) -> Option<Track> {
let mut track_type = bridge::node::OAKNODE_TRACK_TYPE_NONE;
unsafe {
bridge::node::oaknode_track_get_type(track, &mut track_type);
}
let track_type = nodeops::track_type(project, track)?;
let kind = match track_type {
bridge::node::OAKNODE_TRACK_TYPE_VIDEO => "Video",
bridge::node::OAKNODE_TRACK_TYPE_AUDIO => "Audio",
other => {
eprintln!("Don't know OTIO track kind for native type {other}");
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 mut block_count = 0;
unsafe {
bridge::node::oaknode_track_get_block_count(track, &mut block_count);
}
let block_count = nodeops::track_block_count(project, track);
for i in 0..block_count {
let mut block = CHandle::null();
unsafe {
bridge::node::oaknode_track_get_block_at(track, i, &mut block);
}
if block.ctx.is_null() {
let Some(block) = nodeops::track_block_at(project, track, i) else {
continue;
}
};
let mut kind = bridge::node::OAKNODE_BLOCK_OTHER;
unsafe {
bridge::node::oaknode_block_get_kind(block, &mut kind);
}
let kind = nodeops::block_kind(project, block);
let otio_block: Option<Composable> = match kind {
bridge::node::OAKNODE_BLOCK_CLIP => {
let mut otio_clip = Clip::new(node_label_of(unsafe {
bridge::node::oaknode_block_as_node(block)
}));
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(block), sequence_rate),
RationalTime::from_rational(block_length_of(block), sequence_rate),
RationalTime::from_rational(block_in_of(project, block), sequence_rate),
RationalTime::from_rational(block_length_of(project, block), sequence_rate),
));
let mut media = CHandle::null();
unsafe {
bridge::node::oaknode_node_find_input_footage(
bridge::node::oaknode_block_as_node(block),
&mut media,
);
}
if !media.ctx.is_null() {
let available_range = if track_type
== bridge::node::OAKNODE_TRACK_TYPE_VIDEO
{
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 mut source_frame_rate = 0.0f64;
let mut duration = 0.0f64;
let mut num = 0;
let mut den = 1;
let mut vp = CHandle::null();
if unsafe {
bridge::node::oaknode_footage_get_video_params(media, 0, &mut vp)
} == 0
{
unsafe {
bridge::common::oakcommon_videoparams_get_frame_rate(
vp, &mut num, &mut den,
);
}
if den != 0 {
source_frame_rate = num as f64 / den as f64;
}
let mut dur = 0i64;
unsafe {
bridge::common::oakcommon_videoparams_get_duration(
vp, &mut dur,
);
}
duration = dur as f64;
if !vp.ctx.is_null() {
unsafe {
bridge::common::oakcommon_videoparams_free(&mut vp);
}
}
}
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),
@@ -279,7 +215,7 @@ impl SaveOTIOTask {
)
};
let media_url = footage_filename(media);
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)),
@@ -289,32 +225,25 @@ impl SaveOTIOTask {
Some(Composable::Clip(otio_clip))
}
bridge::node::OAKNODE_BLOCK_GAP => Some(Composable::Gap(Gap::new(
nodeops::BlockKind::Gap => Some(Composable::Gap(Gap::new(
TimeRange::new(
RationalTime::from_rational(block_in_of(block), 24.0),
RationalTime::from_rational(block_length_of(block), 24.0),
RationalTime::from_rational(block_in_of(project, block), 24.0),
RationalTime::from_rational(block_length_of(project, block), 24.0),
),
node_label_of(unsafe { bridge::node::oaknode_block_as_node(block) }),
nodeops::node_label(project, block),
))),
bridge::node::OAKNODE_BLOCK_TRANSITION => {
let mut otio_transition = Transition::new(node_label_of(unsafe {
bridge::node::oaknode_block_as_node(block)
}));
nodeops::BlockKind::Transition => {
let mut otio_transition =
Transition::new(nodeops::node_label(project, block));
let (n, d) = transition_offset_of(block, true);
otio_transition.set_in_offset(RationalTime::from_rational(
Rational::new(n as i64, d as i64),
24.0,
));
let (n, d) = transition_offset_of(block, false);
otio_transition.set_out_offset(RationalTime::from_rational(
Rational::new(n as i64, d as i64),
24.0,
));
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))
}
_ => None,
nodeops::BlockKind::Other => None,
};
let Some(otio_block) = otio_block else {
@@ -364,25 +293,42 @@ impl TaskBehavior for SaveOTIOTask {
// Collect sequences from the root folder (non-recursive, matching
// the original list_children_of_type behavior closely enough for
// OTIO).
let root = unsafe { bridge::node::oaknode_project_root(self.project) };
if root.ctx.is_null() {
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<CHandle> = Vec::new();
let child_count = unsafe { bridge::node::oaknode_folder_child_count(root) };
for i in 0..child_count {
let child = unsafe { bridge::node::oaknode_folder_child_at(root, i) };
if child.ctx.is_null() {
continue;
}
if node_id_of(child) == bridge::node::OAKNODE_TYPE_SEQUENCE {
// Borrowed sequence alias of the child node handle (all
// oaknode handles share the same box layout).
sequences.push(child);
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);
}
}
}
}
@@ -395,12 +341,12 @@ impl TaskBehavior for SaveOTIOTask {
let mut serialized: Vec<Timeline> = Vec::with_capacity(sequences.len());
for sequence in &sequences {
match Self::serialize_timeline(*sequence) {
match Self::serialize_timeline(&self.project, *sequence) {
Some(timeline) => serialized.push(timeline),
None => {
task.set_error(&format!(
"Failed to serialize sequence \"{}\"",
node_label_of(unsafe { bridge::node::oaknode_sequence_as_node(*sequence) })
nodeops::node_label(&self.project, *sequence)
));
return Err(Error::Failed("Failed to serialize sequence".to_string()));
}
@@ -449,90 +395,23 @@ impl TaskBehavior for SaveOTIOTask {
}
}
/// Two-stage read of a node's label.
fn node_label_of(node: CHandle) -> String {
let needed = unsafe { bridge::node::oaknode_node_get_label(node, std::ptr::null_mut(), 0) };
if needed <= 0 {
return String::new();
}
let mut buf = vec![0i8; needed as usize];
unsafe {
bridge::node::oaknode_node_get_label(node, buf.as_mut_ptr(), needed);
}
buf_to_string(&buf)
}
/// Two-stage read of a node's type id.
fn node_id_of(node: CHandle) -> String {
let needed = unsafe { bridge::node::oaknode_node_get_id(node, std::ptr::null_mut(), 0) };
if needed <= 0 {
return String::new();
}
let mut buf = vec![0i8; needed as usize];
unsafe {
bridge::node::oaknode_node_get_id(node, buf.as_mut_ptr(), needed);
}
buf_to_string(&buf)
}
/// The block's in point as a `Rational` (default 0/1 when unset).
fn block_in_of(block: CHandle) -> Rational {
let mut n = 0;
let mut d = 1;
unsafe {
bridge::node::oaknode_block_get_in(block, &mut n, &mut d);
}
Rational::new(n as i64, d as i64)
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(block: CHandle) -> Rational {
let mut n = 0;
let mut d = 1;
unsafe {
bridge::node::oaknode_block_get_length(block, &mut n, &mut d);
}
Rational::new(n as i64, d as i64)
}
/// The track's total length as a `Rational` (default 0/1 when unset).
fn track_length_of(track: CHandle) -> Rational {
let mut n = 0;
let mut d = 1;
unsafe {
bridge::node::oaknode_track_get_length(track, &mut n, &mut d);
}
Rational::new(n as i64, d as i64)
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(block: CHandle, in_offset: bool) -> (i32, i32) {
let mut n = 0;
let mut d = 1;
fn transition_offset_of(project: &ProjectRef, block: NodeId, in_offset: bool) -> Rational {
if in_offset {
unsafe {
bridge::node::oaknode_transition_get_in_offset(block, &mut n, &mut d);
}
nodeops::transition_in_offset(project, block)
} else {
unsafe {
bridge::node::oaknode_transition_get_out_offset(block, &mut n, &mut d);
}
nodeops::transition_out_offset(project, block)
}
(n, d)
}
/// Two-stage read of a footage's filename (empty when unset).
fn footage_filename(footage: CHandle) -> String {
let needed =
unsafe { bridge::node::oaknode_footage_filename(footage, std::ptr::null_mut(), 0) };
if needed <= 0 {
return String::new();
}
let mut buf = vec![0i8; needed as usize];
unsafe {
bridge::node::oaknode_footage_filename(footage, buf.as_mut_ptr(), needed);
}
buf_to_string(&buf)
}
/// The serialized track's duration as an `OTIO::RationalTime` — the sum of