workspace: kebab-case crates, app under crates/oak-app, shared versions
CI / Build & test (Windows) (push) Failing after 7s
CI / Build & test (Windows) (push) Failing after 7s
All crates take the oak-* kebab-case naming (oak-audio, oak-codec, oak-common, oak-core, oak-ffmpeg-link, oak-node, oak-otio, oak-plugin, oak-render, oak-storage, oak-task, oak-timeline, oak-undo), with the lib identifiers rewritten (oakrender:: -> oak_render::, oakcore_rs:: -> oak_core::, ...) across all 226 referencing files. The GUI application moves from the workspace root into crates/oak-app/: src/, build.rs (paths fixed for the new location) and tests/ travel with it, the root Cargo.toml becomes workspace-only ([workspace] + workspace.package + profiles), and the app package inherits the workspace version. The screenshots example becomes a standalone crate examples/simple_player/ with its own Cargo.toml. Every crate now inherits the single workspace version (version.workspace = true), and the workflows' crate paths and the build docs follow the renames. Validated with a clean cargo check --workspace.
This commit is contained in:
@@ -0,0 +1,112 @@
|
||||
// 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/>.
|
||||
|
||||
//! Codec task submitter registration, mirroring
|
||||
//! `src/task/src/codecbridge.h`.
|
||||
//!
|
||||
//! Wires oakcodec's task-submit callback (`oak_codec::task::set_task_submit_cb`)
|
||||
//! so conform/proxy requests from the codec side land back in the task
|
||||
//! module. The registration is a direct Rust closure (single-lib
|
||||
//! unification: the old extern-C submit callback is gone).
|
||||
//!
|
||||
//! CPP-PARITY: src/task/src/codecbridge.h
|
||||
|
||||
use oak_codec::error::Error as CodecError;
|
||||
use oak_codec::proxymanager::ProxyManager;
|
||||
use oak_codec::task::{
|
||||
set_task_submit_cb, task_submit_is_registered, TaskKind, TaskRequest, TaskSubmitFn,
|
||||
};
|
||||
|
||||
use crate::conform::ConformTask;
|
||||
use crate::error::{Error, Result};
|
||||
use crate::proxy::{ProxyParams, ProxyTask};
|
||||
use crate::task::Task;
|
||||
|
||||
/// Convert oakcodec proxy params into the Rust [`ProxyParams`].
|
||||
fn proxy_params_from_codec(params: &oak_codec::proxymanager::ProxyParams) -> ProxyParams {
|
||||
ProxyParams {
|
||||
width: params.width,
|
||||
height: params.height,
|
||||
divider: params.divider,
|
||||
version: params.version,
|
||||
crf: params.crf,
|
||||
include_audio: params.include_audio != 0,
|
||||
extension: cstr_buf_to_string(¶ms.extension),
|
||||
preset: cstr_buf_to_string(¶ms.preset),
|
||||
}
|
||||
}
|
||||
|
||||
/// Read a NUL-terminated byte array into a String (lossy).
|
||||
fn cstr_buf_to_string(buf: &[u8]) -> String {
|
||||
let len = buf.iter().position(|&c| c == 0).unwrap_or(buf.len());
|
||||
String::from_utf8_lossy(&buf[..len]).into_owned()
|
||||
}
|
||||
|
||||
/// The installed submit callback, mirroring `submit_codec_task` in
|
||||
/// codecbridge.cpp (interim contract: submission is synchronous).
|
||||
fn submit_codec_task(
|
||||
req: &TaskRequest,
|
||||
_userdata: *mut std::ffi::c_void,
|
||||
) -> oak_codec::error::Result<()> {
|
||||
// Build the concrete task on an outer base so the behavior can be
|
||||
// driven by `start()`; both share the cancellation atom.
|
||||
match req.kind {
|
||||
TaskKind::Conform => {
|
||||
let task = ConformTask::new(req);
|
||||
let atom = task.base.get_cancel_atom();
|
||||
let title = task.base.title().to_string();
|
||||
let mut outer = Task::new(&title, Some(atom));
|
||||
outer.set_behavior(Box::new(task));
|
||||
outer
|
||||
.start()
|
||||
.map_err(|_| CodecError::Failed("conform task failed".to_string()))
|
||||
}
|
||||
TaskKind::Proxy => {
|
||||
let codec_params = ProxyManager::proxy_params_from_config();
|
||||
let params = proxy_params_from_codec(&codec_params);
|
||||
let task = ProxyTask::new(req, params);
|
||||
let atom = task.base.get_cancel_atom();
|
||||
let title = task.base.title().to_string();
|
||||
let mut outer = Task::new(&title, Some(atom));
|
||||
outer.set_behavior(Box::new(task));
|
||||
outer
|
||||
.start()
|
||||
.map_err(|_| CodecError::Failed("proxy task failed".to_string()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Install the task-module submitter as oakcodec's callback. Returns
|
||||
/// `Err(Error::State)` if already registered.
|
||||
pub fn register_codec_task_submitter() -> Result<()> {
|
||||
if is_codec_task_submitter_registered() {
|
||||
return Err(Error::State);
|
||||
}
|
||||
let cb: &'static TaskSubmitFn = Box::leak(Box::new(submit_codec_task));
|
||||
set_task_submit_cb(Some(cb), std::ptr::null_mut());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove the task-module submitter from oakcodec. Idempotent.
|
||||
pub fn unregister_codec_task_submitter() -> Result<()> {
|
||||
set_task_submit_cb(None, std::ptr::null_mut());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Whether a submitter is currently installed (queries oakcodec).
|
||||
pub fn is_codec_task_submitter_registered() -> bool {
|
||||
task_submit_is_registered()
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
// 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/>.
|
||||
|
||||
//! `ConformTask`, mirroring `src/task/src/conform/conform.h`.
|
||||
//!
|
||||
//! Transcodes an audio stream to a PCM cache file (oakcodec kind
|
||||
//! `OAKCODEC_TASK_CONFORM`) and reports progress as it decodes. The
|
||||
//! decoder is reached through the direct Rust API
|
||||
//! (`oak_codec::decoder::{receive_list_of_all_decoders, Decoder}`) —
|
||||
//! single-lib unification; the old oakcodec decoder C ABI is gone.
|
||||
//!
|
||||
//! CPP-PARITY: src/task/src/conform/conform.h
|
||||
|
||||
use oak_codec::decoder::CodecStream;
|
||||
use oak_codec::task::TaskRequest;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::task::{Task, TaskBehavior};
|
||||
|
||||
/// A conform task: copies/channels of one audio stream into per-channel
|
||||
/// working PCM cache files, then renames them to the final names.
|
||||
pub struct ConformTask {
|
||||
/// The shared task base.
|
||||
pub base: Task,
|
||||
/// Absolute input media filename.
|
||||
pub input_filename: String,
|
||||
/// Final destination path (replaces the source path extension).
|
||||
pub output_filename: String,
|
||||
/// Audio stream index inside the source media.
|
||||
pub stream_index: i32,
|
||||
/// Target sample rate (Hz).
|
||||
pub sample_rate: i32,
|
||||
/// Target channel layout mask (ffmpeg-style).
|
||||
pub channel_layout: u64,
|
||||
/// Target sample format (enum as int; `oak_core::SampleFormat`).
|
||||
pub sample_format: i32,
|
||||
/// Resolved final per-channel filenames (from [`ConformTask::derive_filenames`]).
|
||||
final_names: Vec<String>,
|
||||
/// Resolved working per-channel filenames.
|
||||
working_names: Vec<String>,
|
||||
}
|
||||
|
||||
impl ConformTask {
|
||||
/// Build a conform task from an oakcodec request, mirroring the C++
|
||||
/// constructor.
|
||||
pub fn new(request: &TaskRequest) -> ConformTask {
|
||||
let title = format!(
|
||||
"Conforming Audio {}:{}",
|
||||
request.input_filename, request.stream_index
|
||||
);
|
||||
ConformTask {
|
||||
base: Task::new(&title, None),
|
||||
input_filename: request.input_filename.to_string(),
|
||||
output_filename: request.output_filename.to_string(),
|
||||
stream_index: request.stream_index,
|
||||
sample_rate: request.sample_rate,
|
||||
channel_layout: request.channel_layout,
|
||||
sample_format: request.sample_format,
|
||||
final_names: Vec::new(),
|
||||
working_names: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Derive the final and working PCM filenames for the given first-channel
|
||||
/// final filename and channel count.
|
||||
///
|
||||
/// C++ contract: the first-channel final name must end in `.0.pcm`; the
|
||||
/// final name for channel `i` replaces the trailing index, and the
|
||||
/// working name appends `.working`. See `derive_filenames` in
|
||||
/// conform.cpp — this is a parity/golden candidate (see
|
||||
/// tests/parity_test.rs).
|
||||
///
|
||||
/// CPP-PARITY: src/task/src/conform/conform.cpp (derive_filenames)
|
||||
pub fn derive_filenames(
|
||||
first_channel_final: &str,
|
||||
channel_count: usize,
|
||||
) -> Result<(Vec<String>, Vec<String>)> {
|
||||
const SUFFIX: &str = ".0.pcm";
|
||||
|
||||
if channel_count < 1
|
||||
|| first_channel_final.len() <= SUFFIX.len()
|
||||
|| !first_channel_final.ends_with(SUFFIX)
|
||||
{
|
||||
return Err(Error::Failed("Invalid conform output filename".to_string()));
|
||||
}
|
||||
|
||||
let base = &first_channel_final[..first_channel_final.len() - SUFFIX.len()];
|
||||
let mut final_names = Vec::with_capacity(channel_count);
|
||||
let mut working_names = Vec::with_capacity(channel_count);
|
||||
for i in 0..channel_count {
|
||||
let final_name = format!("{base}.{i}.pcm");
|
||||
final_names.push(final_name.clone());
|
||||
working_names.push(format!("{final_name}.working"));
|
||||
}
|
||||
Ok((final_names, working_names))
|
||||
}
|
||||
}
|
||||
|
||||
impl TaskBehavior for ConformTask {
|
||||
/// Run the conform via the direct oakcodec decoder API, emitting
|
||||
/// progress as audio is decoded and written to the working files, then
|
||||
/// rename working → final.
|
||||
fn run(&mut self, task: &mut Task) -> Result<()> {
|
||||
let channel_count = self.channel_layout.count_ones() as usize;
|
||||
let (final_names, working_names) =
|
||||
Self::derive_filenames(&self.output_filename, channel_count).map_err(|_| {
|
||||
task.set_error("Invalid conform output filename");
|
||||
Error::Failed("Invalid conform output filename".to_string())
|
||||
})?;
|
||||
self.final_names = final_names;
|
||||
self.working_names = working_names;
|
||||
|
||||
let atom = task.get_cancel_atom();
|
||||
|
||||
// Pick the first registered decoder that can probe the file (the
|
||||
// C++ picks the decoder the footage was created with; probing in
|
||||
// registry order is the direct-Rust equivalent).
|
||||
let decoder = oak_codec::decoder::receive_list_of_all_decoders()
|
||||
.into_iter()
|
||||
.find(|d| d.probe(&self.input_filename, Some(&atom)).is_some());
|
||||
let Some(decoder) = decoder else {
|
||||
task.set_error("Failed to create decoder");
|
||||
return Err(Error::Failed("Failed to create decoder".to_string()));
|
||||
};
|
||||
|
||||
let result = (|| {
|
||||
let stream =
|
||||
CodecStream::with_block(self.input_filename.clone(), self.stream_index, None);
|
||||
if let Err(e) = decoder.open(&stream) {
|
||||
task.set_error(&format!("Failed to open decoder for audio conform: {e}"));
|
||||
return false;
|
||||
}
|
||||
|
||||
let conform_result = decoder.conform_audio(
|
||||
&self.working_names,
|
||||
self.sample_rate,
|
||||
self.channel_layout,
|
||||
self.sample_format,
|
||||
Some(&atom),
|
||||
);
|
||||
let _ = decoder.close();
|
||||
|
||||
match conform_result {
|
||||
Ok(()) => {
|
||||
// Rename each working file into place; a failure aborts
|
||||
// the rest (mirroring the C++ loop).
|
||||
for i in 0..self.working_names.len() {
|
||||
if std::fs::rename(&self.working_names[i], &self.final_names[i]).is_err() {
|
||||
task.set_error("Failed to move conformed audio into place");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
Err(_) => {
|
||||
// Clean up any partial working files.
|
||||
for name in &self.working_names {
|
||||
let _ = std::fs::remove_file(name);
|
||||
}
|
||||
if atom.is_cancelled() {
|
||||
task.set_error("Audio conform was cancelled");
|
||||
} else {
|
||||
task.set_error("Audio conform failed");
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
if result {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(Error::Failed("Audio conform failed".to_string()))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
// 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/>.
|
||||
|
||||
//! `CustomCacheTask`, mirroring `src/task/src/customcache/customcachetask.h`.
|
||||
//!
|
||||
//! A task that delegates its completion to an external cache fill: it does
|
||||
//! not do work itself but waits until the cache reports it is done (via
|
||||
//! [`CustomCacheTask::finish`]) or is cancelled.
|
||||
//!
|
||||
//! CPP-PARITY: src/task/src/customcache/customcachetask.h
|
||||
|
||||
use std::sync::{Arc, Condvar, Mutex};
|
||||
|
||||
use oak_common::cancelatom::CancelAtom;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::task::{Task, TaskBehavior};
|
||||
|
||||
/// Shared state between the parked task thread, the owning cache
|
||||
/// ([`CustomCacheTask::finish`]) and the cancel hook. Mirrors the C++
|
||||
/// `mutex_`/`wait_cond_`/`cancelled_through_finish_` trio; the cancel atom
|
||||
/// copy lets `finish()` wake the task through the same channel the C++
|
||||
/// `cancel()` uses.
|
||||
pub struct CustomCacheState {
|
||||
/// Set by `finish()` before the cancellation request.
|
||||
cancelled_through_finish: Mutex<bool>,
|
||||
/// Parked-task wakeup.
|
||||
wait: Condvar,
|
||||
/// Callback invoked when the task is cancelled (not by `finish()`).
|
||||
cancelled_callback: Mutex<Option<Box<dyn FnMut() + Send>>>,
|
||||
/// Copy of the task's cancellation atom.
|
||||
atom: Arc<CancelAtom>,
|
||||
}
|
||||
|
||||
impl CustomCacheState {
|
||||
/// Mark the cache fill as complete and wake the parked task.
|
||||
pub fn finish(&self) {
|
||||
*self.cancelled_through_finish.lock().unwrap() = true;
|
||||
self.atom.cancel();
|
||||
self.wait.notify_one();
|
||||
}
|
||||
|
||||
/// The C++ `cancel_event()` hook: fires the callback unless the task was
|
||||
/// finished through `finish()`, then wakes the parked task.
|
||||
pub fn cancel_event(&self) {
|
||||
if !*self.cancelled_through_finish.lock().unwrap() {
|
||||
if let Some(cb) = self.cancelled_callback.lock().unwrap().as_mut() {
|
||||
cb();
|
||||
}
|
||||
}
|
||||
self.wait.notify_one();
|
||||
}
|
||||
|
||||
/// The full C++ `cancel()`: cancel the atom and run the cancel-event
|
||||
/// hook. Used by the owning cache to abort a fill.
|
||||
pub fn cancel(&self) {
|
||||
self.atom.cancel();
|
||||
self.cancel_event();
|
||||
}
|
||||
|
||||
/// Whether the fill was completed through `finish()`.
|
||||
fn is_finished_through_finish(&self) -> bool {
|
||||
*self.cancelled_through_finish.lock().unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
/// A cache-fill task that finishes only when [`CustomCacheTask::finish`] is
|
||||
/// called by the owning cache. Uses a `Mutex` + `Condvar` to park the task
|
||||
/// thread until completion or cancellation.
|
||||
pub struct CustomCacheTask {
|
||||
/// The shared task base.
|
||||
pub base: Task,
|
||||
/// The name of the sequence being cached.
|
||||
pub sequence_name: String,
|
||||
/// Shared park/notify state.
|
||||
state: Arc<CustomCacheState>,
|
||||
}
|
||||
|
||||
impl CustomCacheTask {
|
||||
/// Create a cache-fill task with the given sequence name. The cancel
|
||||
/// hook that wakes the parked thread is installed on the base task.
|
||||
pub fn new(sequence_name: &str) -> CustomCacheTask {
|
||||
let base = Task::new(
|
||||
&format!("Caching custom range for \"{sequence_name}\""),
|
||||
None,
|
||||
);
|
||||
let state = Arc::new(CustomCacheState {
|
||||
cancelled_through_finish: Mutex::new(false),
|
||||
wait: Condvar::new(),
|
||||
cancelled_callback: Mutex::new(None),
|
||||
atom: base.get_cancel_atom(),
|
||||
});
|
||||
let mut task = CustomCacheTask {
|
||||
base,
|
||||
sequence_name: sequence_name.to_string(),
|
||||
state,
|
||||
};
|
||||
let hook_state = task.state.clone();
|
||||
task.base
|
||||
.set_cancel_event(Box::new(move || hook_state.cancel_event()));
|
||||
task
|
||||
}
|
||||
|
||||
/// Mark the cache fill as complete, waking the parked task thread.
|
||||
pub fn finish(&mut self) {
|
||||
self.state.finish();
|
||||
}
|
||||
|
||||
/// Install the callback invoked when the task is cancelled.
|
||||
pub fn set_cancelled_callback(&mut self, cb: Box<dyn FnMut() + Send>) {
|
||||
*self.state.cancelled_callback.lock().unwrap() = Some(cb);
|
||||
}
|
||||
|
||||
/// Shared-state accessor for the owning cache (and tests) to call
|
||||
/// [`CustomCacheTask::finish`] from another thread.
|
||||
pub fn state(&self) -> Arc<CustomCacheState> {
|
||||
self.state.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl TaskBehavior for CustomCacheTask {
|
||||
/// Park until [`CustomCacheTask::finish`] or cancellation; return
|
||||
/// `Err(Error::Cancelled)` if cancelled while waiting.
|
||||
fn run(&mut self, task: &mut Task) -> Result<()> {
|
||||
// Install the cancel hook on the task this behavior actually runs
|
||||
// under (the outer task driven by the manager). The
|
||||
// constructor-installed hook on the inner `base` only fires when the
|
||||
// base itself is cancelled; without this the outer `cancel()` would
|
||||
// set the shared atom but never wake the parked thread.
|
||||
let hook_state = self.state.clone();
|
||||
task.set_cancel_event(Box::new(move || hook_state.cancel_event()));
|
||||
|
||||
let mut guard = self.state.cancelled_through_finish.lock().unwrap();
|
||||
while !task.is_cancelled() {
|
||||
guard = self.state.wait.wait(guard).unwrap();
|
||||
}
|
||||
drop(guard);
|
||||
if self.state.is_finished_through_finish() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(Error::Cancelled)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
// 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/>.
|
||||
|
||||
//! Error codes, mirroring `include/task/error.h` verbatim; project-wide
|
||||
//! -MMCCCC scheme (module registry in include/common/error.h), pass-through
|
||||
//! untranslated. Module number for task is **08**; task additionally defines
|
||||
//! `OAKTASK_E_CANCELLED` (see include/task/error.h).
|
||||
|
||||
/// Success.
|
||||
pub const OAKTASK_OK: i32 = 0;
|
||||
/// Null handle or invalid argument.
|
||||
pub const OAKTASK_E_INVALID: i32 = -80001;
|
||||
/// Call not valid in the current state.
|
||||
pub const OAKTASK_E_STATE: i32 = -80002;
|
||||
/// The underlying operation failed.
|
||||
pub const OAKTASK_E_FAILED: i32 = -80003;
|
||||
/// Index out of range / entry not found.
|
||||
pub const OAKTASK_E_NOT_FOUND: i32 = -80004;
|
||||
/// Allocation failed.
|
||||
pub const OAKTASK_E_NOMEM: i32 = -80005;
|
||||
/// The task was cancelled.
|
||||
pub const OAKTASK_E_CANCELLED: i32 = -80006;
|
||||
|
||||
/// Crate-internal result type; the FFI layer maps it to the codes.
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
|
||||
/// Crate-internal error.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum Error {
|
||||
/// Null handle or invalid argument.
|
||||
#[error("task: invalid argument")]
|
||||
Invalid,
|
||||
/// Wrong state.
|
||||
#[error("task: wrong state")]
|
||||
State,
|
||||
/// Operation failed (context string is log-only).
|
||||
#[error("task: operation failed: {0}")]
|
||||
Failed(String),
|
||||
/// Not found.
|
||||
#[error("task: not found")]
|
||||
NotFound,
|
||||
/// Out of memory.
|
||||
#[error("task: out of memory")]
|
||||
NoMem,
|
||||
/// The operation was cancelled.
|
||||
#[error("task: cancelled")]
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
impl Error {
|
||||
/// Map to the public error code.
|
||||
pub fn code(&self) -> i32 {
|
||||
match self {
|
||||
Error::Invalid => OAKTASK_E_INVALID,
|
||||
Error::State => OAKTASK_E_STATE,
|
||||
Error::Failed(_) => OAKTASK_E_FAILED,
|
||||
Error::NotFound => OAKTASK_E_NOT_FOUND,
|
||||
Error::NoMem => OAKTASK_E_NOMEM,
|
||||
Error::Cancelled => OAKTASK_E_CANCELLED,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Every variant renders a non-empty, module-prefixed message.
|
||||
#[test]
|
||||
fn display_is_non_empty() {
|
||||
let cases = [
|
||||
(Error::Invalid, "task: invalid argument"),
|
||||
(Error::State, "task: wrong state"),
|
||||
(Error::Failed("boom".into()), "task: operation failed: boom"),
|
||||
(Error::NotFound, "task: not found"),
|
||||
(Error::NoMem, "task: out of memory"),
|
||||
(Error::Cancelled, "task: cancelled"),
|
||||
];
|
||||
for (err, expect) in cases {
|
||||
let msg = err.to_string();
|
||||
assert!(!msg.is_empty(), "Display for {err:?} is empty");
|
||||
assert_eq!(msg, expect, "Display for {err:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// `Failed` carries the context string in its message.
|
||||
#[test]
|
||||
fn failed_includes_context() {
|
||||
let msg = Error::Failed("disk full".into()).to_string();
|
||||
assert!(msg.contains("disk full"));
|
||||
}
|
||||
|
||||
/// The error is object-safe: every variant boxes into
|
||||
/// `Box<dyn std::error::Error>`.
|
||||
#[test]
|
||||
fn object_safe() {
|
||||
let errs: Vec<Box<dyn std::error::Error>> = vec![
|
||||
Box::new(Error::Invalid),
|
||||
Box::new(Error::State),
|
||||
Box::new(Error::Failed("boom".into())),
|
||||
Box::new(Error::NotFound),
|
||||
Box::new(Error::NoMem),
|
||||
Box::new(Error::Cancelled),
|
||||
];
|
||||
assert_eq!(errs.len(), 6);
|
||||
for err in &errs {
|
||||
assert!(!err.to_string().is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
/// Implementing `std::error::Error` must not change the FFI code mapping.
|
||||
#[test]
|
||||
fn codes_unchanged() {
|
||||
assert_eq!(Error::Invalid.code(), OAKTASK_E_INVALID);
|
||||
assert_eq!(Error::State.code(), OAKTASK_E_STATE);
|
||||
assert_eq!(Error::Failed("x".into()).code(), OAKTASK_E_FAILED);
|
||||
assert_eq!(Error::NotFound.code(), OAKTASK_E_NOT_FOUND);
|
||||
assert_eq!(Error::NoMem.code(), OAKTASK_E_NOMEM);
|
||||
assert_eq!(Error::Cancelled.code(), OAKTASK_E_CANCELLED);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
// 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/>.
|
||||
|
||||
//! `ExportTask`, mirroring `src/task/src/export/export.h`.
|
||||
//!
|
||||
//! Renders the viewer output through [`crate::render::RenderTask`] and
|
||||
//! writes it to a file via the direct oakcodec encoder API
|
||||
//! (`oak_codec::encoder::{create_from_params, Encoder}` — single-lib
|
||||
//! unification; the old encoder C ABI is gone), mapping each rendered
|
||||
//! frame to an `Encoder::write_video` call and each rendered audio buffer
|
||||
//! to `Encoder::write_audio`.
|
||||
//!
|
||||
//! The viewer and color-manager arguments of the deleted C ABI path are
|
||||
//! replaced by a single [`crate::nodeops::NodeRef`] viewer (the color
|
||||
//! manager no longer crosses into the render tickets — the direct ticket
|
||||
//! arena performs no color management). Rendered frames arrive as
|
||||
//! `oak_render::texture::Texture` values and are converted to
|
||||
//! `oak_codec::frame::Frame` values for the encoder.
|
||||
//!
|
||||
//! **Simplifications over the C++**: no temporary-file rename dance (the
|
||||
//! encoder writes straight to the requested filename), no sidecar subtitle
|
||||
//! encoder (subtitles go through the main encoder), and no frame/audio
|
||||
//! reordering maps — the [`RenderTask`] loop delivers frames and audio in
|
||||
//! timestamp order (its reorder buffer), so the encoder is written directly.
|
||||
//!
|
||||
//! CPP-PARITY: src/task/src/export/export.h
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use oak_codec::encoder::{create_from_params, Encoder};
|
||||
use oak_codec::encodingparams::EncodingParams as CodecEncodingParams;
|
||||
use oak_common::videoparams::VideoParams as CommonVideoParams;
|
||||
use oak_render::texture::Texture;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::nodeops::{self, NodeRef};
|
||||
use crate::render::{ForceParams, RenderTask, RenderTaskBehavior};
|
||||
use crate::task::{Task, TaskBehavior};
|
||||
use oak_core::{Rational, TimeRange};
|
||||
|
||||
/// An export task. Owns the encoder; the base [`RenderTask`] does the
|
||||
/// frame/audio production.
|
||||
pub struct ExportTask {
|
||||
/// The render base (itself a task).
|
||||
pub render: RenderTask,
|
||||
/// The node being exported (footage or sequence).
|
||||
pub viewer_node: NodeRef,
|
||||
/// The encoding parameters (mirror of the codec-side params).
|
||||
pub encoding_params: EncodingParams,
|
||||
/// The opened encoder (direct oakcodec trait object).
|
||||
encoder: Option<Arc<dyn Encoder>>,
|
||||
}
|
||||
|
||||
/// Mirror of the codec encoding params, kept as plain fields so the export
|
||||
/// task can be configured without a codec-side handle. Only the fields the
|
||||
/// task reads are declared; see `oak_codec::encodingparams::EncodingParams`
|
||||
/// for the full inventory.
|
||||
pub struct EncodingParams {
|
||||
/// Output filename.
|
||||
pub filename: String,
|
||||
/// Export format id (`olive::ExportFormat::Format`).
|
||||
pub format: i32,
|
||||
/// Whether video is exported.
|
||||
pub video_enabled: bool,
|
||||
/// Video codec id.
|
||||
pub video_codec: i32,
|
||||
/// Output width.
|
||||
pub video_width: i32,
|
||||
/// Output height.
|
||||
pub video_height: i32,
|
||||
/// Frame duration rational (numerator/denominator).
|
||||
pub video_time_base_num: i32,
|
||||
/// Frame duration denominator.
|
||||
pub video_time_base_den: i32,
|
||||
/// Delivery pixel format (`oak_core::PixelFormat` as int).
|
||||
pub video_pixel_format: i32,
|
||||
/// Whether audio is exported.
|
||||
pub audio_enabled: bool,
|
||||
/// Audio codec id.
|
||||
pub audio_codec: i32,
|
||||
/// Audio sample rate (Hz) the encoder opens with.
|
||||
pub audio_sample_rate: i32,
|
||||
/// ffmpeg-style channel layout mask the encoder opens with.
|
||||
pub audio_channel_layout: u64,
|
||||
/// Whether subtitles are exported.
|
||||
pub subtitles_enabled: bool,
|
||||
/// Export length numerator (seconds rational).
|
||||
pub export_length_num: i32,
|
||||
/// Export length denominator.
|
||||
pub export_length_den: i32,
|
||||
/// Whether a custom in/out export range is set (work-area export). When
|
||||
/// set, [`ExportTask::export_range`] renders exactly `[in, out)` instead
|
||||
/// of the whole viewer length.
|
||||
pub has_custom_range: bool,
|
||||
/// Custom range in point numerator (seconds rational).
|
||||
pub custom_range_in_num: i32,
|
||||
/// Custom range in point denominator.
|
||||
pub custom_range_in_den: i32,
|
||||
/// Custom range out point numerator (seconds rational).
|
||||
pub custom_range_out_num: i32,
|
||||
/// Custom range out point denominator.
|
||||
pub custom_range_out_den: i32,
|
||||
}
|
||||
|
||||
impl ExportTask {
|
||||
/// Build a new export task from the viewer node and the encoding
|
||||
/// params. The old `viewer: CHandle` / `color_manager: CHandle`
|
||||
/// signature is replaced by the domain viewer [`NodeRef`] (single-lib
|
||||
/// unification; the color manager is dropped with the C ABI — see the
|
||||
/// module docs).
|
||||
pub fn new(viewer: NodeRef, params: EncodingParams) -> ExportTask {
|
||||
let label = nodeops::node_label(&viewer.0, viewer.1);
|
||||
let title = format!("Exporting \"{label}\"");
|
||||
let base = Task::new(&title, None);
|
||||
// The render base needs the viewer's frame rate to step one frame
|
||||
// per video frame (the export range is in sequence/footage time);
|
||||
// without it the frame loop falls back to a 1/1 timebase and
|
||||
// exports one frame per second (observed in the facade's
|
||||
// `it_export` run).
|
||||
let video_params = nodeops::sequence_video_params(&viewer.0, viewer.1, 0)
|
||||
.or_else(|| nodeops::footage_video_params(&viewer.0, viewer.1, 0));
|
||||
let render = RenderTask::new(base, video_params, viewer.clone(), ForceParams::default(), None);
|
||||
ExportTask {
|
||||
render,
|
||||
viewer_node: viewer,
|
||||
encoding_params: params,
|
||||
encoder: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the codec `EncodingParams` POD for the encoder from the Rust
|
||||
/// mirror (zeroed fields = disabled/defaults).
|
||||
fn build_codec_params(&self) -> CodecEncodingParams {
|
||||
let mut params = CodecEncodingParams::default();
|
||||
let bytes = self.encoding_params.filename.as_bytes();
|
||||
let n = bytes.len().min(1023);
|
||||
params.filename[..n].copy_from_slice(&bytes[..n]);
|
||||
params.format = self.encoding_params.format;
|
||||
params.video_enabled = self.encoding_params.video_enabled as i32;
|
||||
params.video_codec = self.encoding_params.video_codec;
|
||||
params.video_width = self.encoding_params.video_width;
|
||||
params.video_height = self.encoding_params.video_height;
|
||||
params.video_time_base_num = self.encoding_params.video_time_base_num;
|
||||
params.video_time_base_den = self.encoding_params.video_time_base_den;
|
||||
params.video_pixel_format =
|
||||
crate::nodeops::pixel_format_from_code(self.encoding_params.video_pixel_format);
|
||||
params.audio_enabled = self.encoding_params.audio_enabled as i32;
|
||||
params.audio_codec = self.encoding_params.audio_codec;
|
||||
params.audio_sample_rate = self.encoding_params.audio_sample_rate;
|
||||
params.audio_channel_layout = self.encoding_params.audio_channel_layout;
|
||||
params.subtitles_enabled = self.encoding_params.subtitles_enabled as i32;
|
||||
params.export_length_num = self.encoding_params.export_length_num;
|
||||
params.export_length_den = self.encoding_params.export_length_den;
|
||||
params
|
||||
}
|
||||
|
||||
/// Resolve the export range: the custom range when set, otherwise the
|
||||
/// whole viewer length (direct `oaknode` domain query; the deleted
|
||||
/// `oaknode_sequence_get_length` stub is gone). The custom range is the
|
||||
/// work-area / in-out export: `export_params_pod` copies it from the
|
||||
/// facade params handle, so `oakengine_export_render_with_params` and the
|
||||
/// app's work-area export render exactly `[in, out)`.
|
||||
fn export_range(&self) -> TimeRange {
|
||||
if self.encoding_params.has_custom_range
|
||||
&& self.encoding_params.custom_range_in_den != 0
|
||||
&& self.encoding_params.custom_range_out_den != 0
|
||||
{
|
||||
let in_ = Rational::new(
|
||||
i64::from(self.encoding_params.custom_range_in_num),
|
||||
i64::from(self.encoding_params.custom_range_in_den),
|
||||
);
|
||||
let out = Rational::new(
|
||||
i64::from(self.encoding_params.custom_range_out_num),
|
||||
i64::from(self.encoding_params.custom_range_out_den),
|
||||
);
|
||||
if out > in_ {
|
||||
return TimeRange::new(in_, out);
|
||||
}
|
||||
}
|
||||
let length = nodeops::node_length(&self.viewer_node.0, self.viewer_node.1);
|
||||
TimeRange::new(Rational::new(0, 1), length)
|
||||
}
|
||||
|
||||
/// Copy a rendered `oakrender` CPU texture into an `oakcodec` frame
|
||||
/// with the matching video params (row-wise copy — line sizes may
|
||||
/// differ between the render and codec frame layouts).
|
||||
fn to_codec_frame(texture: &Texture) -> Result<oak_codec::frame::Frame> {
|
||||
let Texture::Cpu(frame) = texture else {
|
||||
return Err(Error::Failed(
|
||||
"Render produced a GPU texture; the CPU encoder path cannot consume it"
|
||||
.to_string(),
|
||||
));
|
||||
};
|
||||
let params = CommonVideoParams::new_basic(
|
||||
frame.width,
|
||||
frame.height,
|
||||
oak_common::ocioutils::PixelFormat::from_code(frame.format as i32),
|
||||
4,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
1,
|
||||
);
|
||||
let mut out = oak_codec::frame::Frame::with_params(params);
|
||||
out.set_timestamp(frame.timestamp);
|
||||
out.allocate().map_err(|e| {
|
||||
Error::Failed(format!("Failed to allocate encoder frame: {e:?}"))
|
||||
})?;
|
||||
let dst_stride = out.linesize_bytes() as usize;
|
||||
let Some(dst) = out.data_mut() else {
|
||||
return Err(Error::Failed(
|
||||
"Encoder frame allocation produced no buffer".to_string(),
|
||||
));
|
||||
};
|
||||
let src = frame.data.as_slice();
|
||||
let src_stride = frame.linesize_bytes();
|
||||
let row_bytes = std::cmp::min(src_stride, dst_stride)
|
||||
.min(src.len())
|
||||
.min(dst.len());
|
||||
if src_stride == dst_stride && src.len() == dst.len() {
|
||||
dst.copy_from_slice(src);
|
||||
} else {
|
||||
for y in 0..frame.height as usize {
|
||||
let src_start = y * src_stride;
|
||||
let dst_start = y * dst_stride;
|
||||
if src_start + row_bytes > src.len() || dst_start + row_bytes > dst.len() {
|
||||
break;
|
||||
}
|
||||
dst[dst_start..dst_start + row_bytes]
|
||||
.copy_from_slice(&src[src_start..src_start + row_bytes]);
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
impl TaskBehavior for ExportTask {
|
||||
fn run(&mut self, task: &mut Task) -> Result<()> {
|
||||
// Share the caller's cancellation atom with the inner render base.
|
||||
self.render.base.set_cancel_atom(task.get_cancel_atom());
|
||||
|
||||
let codec_params = self.build_codec_params();
|
||||
let Some(encoder) = create_from_params(&codec_params) else {
|
||||
task.set_error("Failed to create encoder");
|
||||
return Err(Error::Failed("Failed to create encoder".to_string()));
|
||||
};
|
||||
self.encoder = Some(encoder.clone());
|
||||
|
||||
if let Err(e) = encoder.open() {
|
||||
let err = encoder.get_error();
|
||||
task.set_error(&format!("Failed to open file: {err}"));
|
||||
let _ = e;
|
||||
return Err(Error::Failed("Failed to open file".to_string()));
|
||||
}
|
||||
|
||||
let export_range = self.export_range();
|
||||
|
||||
// Force params: force the renderer to the requested output size and
|
||||
// pixel format (no color-matrix scaling in this simplified path).
|
||||
let mut force = ForceParams::default();
|
||||
if self.encoding_params.video_enabled {
|
||||
force.force_width = self.encoding_params.video_width;
|
||||
force.force_height = self.encoding_params.video_height;
|
||||
force.force_format = encoder
|
||||
.desired_pixel_format()
|
||||
.map(|f| f as i32)
|
||||
.unwrap_or(-1);
|
||||
force.force_channel_count = 4; // RGBA
|
||||
}
|
||||
self.render.force_params = force;
|
||||
self.render.set_render_inputs(
|
||||
0, // RenderMode::k_online
|
||||
self.encoding_params.audio_enabled,
|
||||
export_range,
|
||||
);
|
||||
|
||||
// Drive the render with `self` as the subclass behavior (the C++
|
||||
// virtual dispatch receiver). The render is temporarily moved out of
|
||||
// `self` to avoid a self-referential borrow; it is put back right
|
||||
// after, before the encoder flush below.
|
||||
let mut render =
|
||||
std::mem::replace(&mut self.render, crate::render::RenderTask::placeholder());
|
||||
let result = render.render(task, self);
|
||||
self.render = render;
|
||||
result?;
|
||||
|
||||
// Flush the encoder and surface any trailing error.
|
||||
if let Err(_) = encoder.flush() {
|
||||
// Fall through to the error read below (the flush error is
|
||||
// surfaced through `get_error()` like the C ABI did).
|
||||
}
|
||||
let err = encoder.get_error();
|
||||
if !err.is_empty() {
|
||||
task.set_error(&err);
|
||||
return Err(Error::Failed("Encoder flush failed".to_string()));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderTaskBehavior for ExportTask {
|
||||
fn frame_downloaded(&mut self, task: &mut Task, frame: &Texture) -> Result<()> {
|
||||
let Some(encoder) = &self.encoder else {
|
||||
return Ok(());
|
||||
};
|
||||
let codec_frame = Self::to_codec_frame(frame)?;
|
||||
if let Err(_) = encoder.write_video(&codec_frame) {
|
||||
let err = encoder.get_error();
|
||||
task.set_error(&err);
|
||||
return Err(Error::Failed("Failed to write frame".to_string()));
|
||||
}
|
||||
// Progress is reported by the render loop itself (native signalling).
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn audio_downloaded(
|
||||
&mut self,
|
||||
task: &mut Task,
|
||||
samples: &oak_render::ticket::AudioSamples,
|
||||
) -> Result<()> {
|
||||
let Some(encoder) = &self.encoder else {
|
||||
return Ok(());
|
||||
};
|
||||
let frame_count = if samples.channel_count > 0 {
|
||||
samples.samples.len() / samples.channel_count as usize
|
||||
} else {
|
||||
0
|
||||
};
|
||||
if let Err(_) = encoder.write_audio(&samples.samples, frame_count as i32) {
|
||||
let err = encoder.get_error();
|
||||
task.set_error(&err);
|
||||
return Err(Error::Failed("Failed to write audio".to_string()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn encode_subtitle(&mut self, task: &mut Task, text: &str) -> Result<()> {
|
||||
if !self.encoding_params.subtitles_enabled {
|
||||
return Ok(());
|
||||
}
|
||||
let Some(encoder) = &self.encoder else {
|
||||
return Ok(());
|
||||
};
|
||||
// The simplified path does not carry the subtitle block's in/out
|
||||
// times; write with 0.0/0.0 (the encoder default interval).
|
||||
if let Err(_) = encoder.write_subtitle(text, 0.0, 0.0) {
|
||||
let err = encoder.get_error();
|
||||
task.set_error(&err);
|
||||
return Err(Error::Failed("Failed to write subtitle".to_string()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
// 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/>.
|
||||
|
||||
//! Refcounted-handle scaffolding. Same pattern as the oaknode/oakplugin
|
||||
//! crates (`src/node/rust/src/handle.rs`); intentionally duplicated rather
|
||||
//! than shared — each module DLL must run its own addref/release code (the
|
||||
//! function pointers in a handle always point into the DLL that created the
|
||||
//! object).
|
||||
//!
|
||||
//! M14 R5: only the parts the oakengine facade needs remain (owned
|
||||
//! box/addref/release plus typed `get`/`get_mut` views — the facade boxes
|
||||
//! its task payloads through [`make_owned`] and reads them back with
|
||||
//! `get`/`get_mut`). The borrowed-handle and panic-guard helpers had no
|
||||
//! in-crate callers and were removed.
|
||||
|
||||
use std::sync::atomic::AtomicU32;
|
||||
|
||||
/// ABI version stamped into every handle.
|
||||
pub const OAKTASK_ABI_VERSION: u32 = 1;
|
||||
|
||||
/// Heap box behind a handle's `ctx`.
|
||||
pub struct RefBox<T: ?Sized> {
|
||||
/// Atomic reference count.
|
||||
pub refs: AtomicU32,
|
||||
/// Boxed value.
|
||||
pub value: T,
|
||||
}
|
||||
|
||||
/// The shared ABI value-handle type (single-lib unification, see
|
||||
/// `docs/zh/plans/riir/single-lib.md`): one canonical
|
||||
/// `{ctx, addref, release, abi_version}` type in `oakcore-rs`, re-exported
|
||||
/// here so the crate's `ffi.rs` signatures and handle scaffolding stay
|
||||
/// source-compatible. `Send + Sync` come from the shared type.
|
||||
pub use oak_core::handle::CHandle;
|
||||
|
||||
unsafe extern "C" fn owned_addref<T: 'static>(ctx: *mut std::ffi::c_void) {
|
||||
if !ctx.is_null() {
|
||||
// CPP-PARITY: src/task/c_api/taskhandle.h (task_addref)
|
||||
unsafe {
|
||||
(*(ctx as *const RefBox<T>))
|
||||
.refs
|
||||
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
unsafe extern "C" fn owned_release<T: 'static>(ctx: *mut std::ffi::c_void) {
|
||||
if ctx.is_null() {
|
||||
return;
|
||||
}
|
||||
// CPP-PARITY: src/task/c_api/taskhandle.h (task_release)
|
||||
let b = ctx as *const RefBox<T>;
|
||||
let last = unsafe { (*b).refs.fetch_sub(1, std::sync::atomic::Ordering::SeqCst) };
|
||||
if last == 1 {
|
||||
unsafe {
|
||||
drop(Box::from_raw(ctx as *mut RefBox<T>));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Owned handle with count 1; empty on allocation failure.
|
||||
pub fn make_owned<T: Send + 'static>(value: T) -> CHandle {
|
||||
let b = Box::new(RefBox {
|
||||
refs: AtomicU32::new(1),
|
||||
value,
|
||||
});
|
||||
CHandle {
|
||||
ctx: Box::into_raw(b) as *mut std::ffi::c_void,
|
||||
addref: Some(owned_addref::<T>),
|
||||
release: Some(owned_release::<T>),
|
||||
abi_version: OAKTASK_ABI_VERSION,
|
||||
}
|
||||
}
|
||||
|
||||
/// Typed view into a handle; `None` for empty handles.
|
||||
///
|
||||
/// # Safety
|
||||
/// `T` must be the boxed type.
|
||||
pub unsafe fn get<T: 'static>(h: &CHandle) -> Option<&T> {
|
||||
if h.ctx.is_null() {
|
||||
return None;
|
||||
}
|
||||
unsafe { Some(&(*(h.ctx as *const RefBox<T>)).value) }
|
||||
}
|
||||
|
||||
/// Typed mutable view into a handle; `None` for empty handles.
|
||||
///
|
||||
/// # Safety
|
||||
/// `T` must be the boxed type, and the handle must not be concurrently
|
||||
/// shared mutably.
|
||||
pub unsafe fn get_mut<T: 'static>(h: &CHandle) -> Option<&mut T> {
|
||||
if h.ctx.is_null() {
|
||||
return None;
|
||||
}
|
||||
unsafe { Some(&mut (*(h.ctx as *mut RefBox<T>)).value) }
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
// 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/>.
|
||||
|
||||
//! # oaktask — the task execution module (Rust)
|
||||
//!
|
||||
//! Reimplements the C++ task module (`src/task/src`) as direct Rust
|
||||
//! (single-lib unification): inheritance → one module per class + trait
|
||||
//! objects, cancellation via `oak_common::cancelatom::CancelAtom`, events
|
||||
//! as boxed callbacks. All node-graph, timeline and render work goes
|
||||
//! through the direct `oaknode` / `oakrender` Rust APIs
|
||||
//! ([`nodeops`] holds the graph operations the tasks share).
|
||||
|
||||
#![deny(unsafe_op_in_unsafe_fn)]
|
||||
#![warn(missing_docs)]
|
||||
|
||||
pub mod codecbridge;
|
||||
pub mod conform;
|
||||
pub mod customcache;
|
||||
pub mod error;
|
||||
pub mod export;
|
||||
pub mod handle;
|
||||
pub mod manager;
|
||||
pub mod nodeops;
|
||||
pub mod precache;
|
||||
pub mod project;
|
||||
pub mod proxy;
|
||||
pub mod render;
|
||||
pub mod task;
|
||||
@@ -0,0 +1,264 @@
|
||||
// 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/>.
|
||||
|
||||
//! The `TaskManager` singleton, mirroring `src/task/src/taskmanager.h`.
|
||||
//!
|
||||
//! Holds the set of live [`crate::task::Task`] instances; starting a task
|
||||
//! hands its ownership to the manager, which also exposes the codec task
|
||||
//! submitter registration (see [`crate::codecbridge`]).
|
||||
//!
|
||||
//! CPP-PARITY: src/task/src/taskmanager.h
|
||||
//!
|
||||
//! ## Threading model
|
||||
//!
|
||||
//! [`TaskManager::add_task`] spawns a worker thread that runs the task's
|
||||
//! [`Task::start`]. The worker thread only touches the task (through a raw
|
||||
//! pointer) and never the manager, so the manager's own mutex is never held
|
||||
//! across a join in the C ABI paths (joins happen lock-free via
|
||||
//! [`TaskManager::drain_finished`] / [`TaskManager::take_thread_by_ptr`]).
|
||||
|
||||
use std::sync::Mutex;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::task::Task;
|
||||
|
||||
/// Process-wide singleton manager. C++ is a lazy singleton; the Rust side
|
||||
/// keeps a `Mutex<Option<...>>` so init/shutdown is explicit and races are
|
||||
/// rejected with `OAKTASK_E_STATE` (matching `oaktask_manager_init`).
|
||||
pub struct TaskManager {
|
||||
/// Live tasks, most-recently-added last (mirrors the C++ `task_list_`).
|
||||
tasks: Vec<Box<dyn TaskBox>>,
|
||||
/// Worker threads, parallel to `tasks` (moved out before joining).
|
||||
threads: Vec<Option<std::thread::JoinHandle<()>>>,
|
||||
/// Whether the codec task submitter is currently registered.
|
||||
codec_submitter_registered: bool,
|
||||
}
|
||||
|
||||
/// Boxable shim so tasks of heterogeneous concrete types can be stored in
|
||||
/// the manager's list.
|
||||
pub trait TaskBox: Send {
|
||||
/// Downcast/erase operations the manager needs.
|
||||
fn task(&self) -> &Task;
|
||||
}
|
||||
|
||||
impl TaskBox for Task {
|
||||
fn task(&self) -> &Task {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// The process-wide instance slot. A raw pointer (never freed while the
|
||||
/// slot is set) so [`TaskManager::instance`] can hand out a `&'static`.
|
||||
static INSTANCE: Mutex<Option<ManagerPtr>> = Mutex::new(None);
|
||||
|
||||
/// `Send`/`Sync` wrapper for the manager pointer (the manager's state is
|
||||
/// guarded by the `INSTANCE` mutex; the pointer itself is plain).
|
||||
struct ManagerPtr(*mut TaskManager);
|
||||
|
||||
// Safety: all access to the pointee goes through the `INSTANCE` mutex.
|
||||
unsafe impl Send for ManagerPtr {}
|
||||
unsafe impl Sync for ManagerPtr {}
|
||||
|
||||
impl TaskManager {
|
||||
/// The process-wide instance; `None` until [`TaskManager::init`].
|
||||
///
|
||||
/// CPP-PARITY: src/task/src/taskmanager.h (instance)
|
||||
pub fn instance() -> Option<&'static TaskManager> {
|
||||
let guard = INSTANCE.lock().unwrap();
|
||||
guard.as_ref().map(|p| unsafe { &*p.0 })
|
||||
}
|
||||
|
||||
/// Create the singleton and register the codec task submitter. Returns
|
||||
/// `Err(Error::State)` if already initialized (mirrors
|
||||
/// `oaktask_manager_init`).
|
||||
pub fn init() -> Result<()> {
|
||||
let mut guard = INSTANCE.lock().unwrap();
|
||||
if guard.is_some() {
|
||||
return Err(Error::State);
|
||||
}
|
||||
let mgr = Box::into_raw(Box::new(TaskManager {
|
||||
tasks: Vec::new(),
|
||||
threads: Vec::new(),
|
||||
codec_submitter_registered: false,
|
||||
}));
|
||||
*guard = Some(ManagerPtr(mgr));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Destroy the singleton. Idempotent.
|
||||
pub fn shutdown() {
|
||||
let ptr = {
|
||||
let mut guard = INSTANCE.lock().unwrap();
|
||||
guard.take()
|
||||
};
|
||||
if let Some(p) = ptr {
|
||||
// The Drop impl cancels and joins every worker thread.
|
||||
unsafe {
|
||||
drop(Box::from_raw(p.0));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Run `f` with the singleton (when initialized), without taking
|
||||
/// ownership. Used by the C ABI layer.
|
||||
pub fn with_manager<R>(f: impl FnOnce(&TaskManager) -> R) -> Option<R> {
|
||||
let guard = INSTANCE.lock().unwrap();
|
||||
guard.as_ref().map(|p| f(unsafe { &*p.0 }))
|
||||
}
|
||||
|
||||
/// Run `f` with the singleton mutably (when initialized). Used by the C
|
||||
/// ABI layer. The caller must not call [`TaskManager::with_manager`]
|
||||
/// from a worker callback while this is held (same rule as the C++
|
||||
/// `mutex_`).
|
||||
pub fn with_manager_mut<R>(f: impl FnOnce(&mut TaskManager) -> R) -> Option<R> {
|
||||
let mut guard = INSTANCE.lock().unwrap();
|
||||
guard.as_mut().map(|p| f(unsafe { &mut *p.0 }))
|
||||
}
|
||||
|
||||
/// Add a task, taking ownership (the manager now owns it). A worker
|
||||
/// thread is spawned that runs [`Task::start`] on the task.
|
||||
///
|
||||
/// The worker runs through a raw pointer while the box lives in
|
||||
/// `self.tasks`; the shared reference produced by `task()` ends before
|
||||
/// the pointer is used, so the cast is safe in this exclusively-owned
|
||||
/// setting.
|
||||
#[allow(invalid_reference_casting)]
|
||||
pub fn add_task(&mut self, task: Box<dyn TaskBox>) {
|
||||
let ptr = task.task() as *const Task as usize as *mut Task;
|
||||
self.tasks.push(task);
|
||||
// The worker only touches the task through the raw pointer; it never
|
||||
// reaches the manager, so the box stays alive in `self.tasks`.
|
||||
let ptr_usize = ptr as usize;
|
||||
let handle = std::thread::spawn(move || {
|
||||
unsafe {
|
||||
// The manager tracks success/failure through the task's own
|
||||
// finished state; the Result is intentionally ignored.
|
||||
let _ = (&mut *(ptr_usize as *mut Task)).start();
|
||||
}
|
||||
});
|
||||
self.threads.push(Some(handle));
|
||||
}
|
||||
|
||||
/// Cancel the task at `index`.
|
||||
#[allow(invalid_reference_casting)]
|
||||
pub fn cancel_task(&mut self, index: usize) -> Result<()> {
|
||||
let task = self.tasks.get(index).ok_or(Error::NotFound)?;
|
||||
let ptr = task.task() as *const Task as usize as *mut Task;
|
||||
unsafe {
|
||||
(&mut *ptr).cancel();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Cancel the task at `index` and block until it finishes.
|
||||
pub fn cancel_task_and_wait(&mut self, index: usize) -> Result<()> {
|
||||
self.cancel_task(index)?;
|
||||
let handle = self.threads.get_mut(index).and_then(|h| h.take());
|
||||
if let Some(h) = handle {
|
||||
let _ = h.join();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove finished tasks from the list.
|
||||
pub fn delete_finished(&mut self) {
|
||||
for (_task, handle) in self.drain_finished() {
|
||||
let _ = handle.join();
|
||||
}
|
||||
}
|
||||
|
||||
/// Number of live tasks.
|
||||
pub fn get_task_count(&self) -> usize {
|
||||
self.tasks.len()
|
||||
}
|
||||
|
||||
/// Raw pointer to the task at `index` (stable while the manager owns
|
||||
/// it). Used by `oaktask_manager_at` to build a borrowed task handle.
|
||||
pub fn task_ptr_at(&self, index: usize) -> Result<*mut Task> {
|
||||
let task = self.tasks.get(index).ok_or(Error::NotFound)?;
|
||||
Ok(task.task() as *const Task as *mut Task)
|
||||
}
|
||||
|
||||
/// Index of the task with the given address.
|
||||
pub fn find_index(&self, ptr: *const Task) -> Option<usize> {
|
||||
self.tasks
|
||||
.iter()
|
||||
.position(|t| t.task() as *const Task == ptr)
|
||||
}
|
||||
|
||||
/// Cancel the task with the given address (no-op when absent).
|
||||
pub fn cancel_task_by_ptr(&mut self, ptr: *const Task) {
|
||||
if let Some(index) = self.find_index(ptr) {
|
||||
let _ = self.cancel_task(index);
|
||||
}
|
||||
}
|
||||
|
||||
/// Move the worker thread of the task with the given address out of the
|
||||
/// list so the caller can join it without holding the manager lock.
|
||||
pub fn take_thread_by_ptr(&mut self, ptr: *const Task) -> Option<std::thread::JoinHandle<()>> {
|
||||
let index = self.find_index(ptr)?;
|
||||
self.threads.get_mut(index).and_then(|h| h.take())
|
||||
}
|
||||
|
||||
/// Remove every finished task (and its worker thread) from the list,
|
||||
/// returning the task boxes and thread handles so the caller can join
|
||||
/// them lock-free (the boxes must stay alive until the threads exit).
|
||||
pub fn drain_finished(&mut self) -> Vec<(Box<dyn TaskBox>, std::thread::JoinHandle<()>)> {
|
||||
let mut out = Vec::new();
|
||||
let mut i = 0;
|
||||
while i < self.tasks.len() {
|
||||
if self.tasks[i].task().is_finished() {
|
||||
let task = self.tasks.remove(i);
|
||||
let handle = self.threads.remove(i);
|
||||
if let Some(handle) = handle {
|
||||
out.push((task, handle));
|
||||
}
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Whether this manager registered the codec submitter itself.
|
||||
pub fn codec_submitter_registered(&self) -> bool {
|
||||
self.codec_submitter_registered
|
||||
}
|
||||
|
||||
/// Mark whether this manager registered the codec submitter.
|
||||
pub fn set_codec_submitter_registered(&mut self, registered: bool) {
|
||||
self.codec_submitter_registered = registered;
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TaskManager {
|
||||
fn drop(&mut self) {
|
||||
// Cancel every task, then join every worker (mirrors the C++
|
||||
// destructor: cancel all first, then join all).
|
||||
#[allow(invalid_reference_casting)]
|
||||
for task in &self.tasks {
|
||||
let ptr = task.task() as *const Task as usize as *mut Task;
|
||||
unsafe {
|
||||
(&mut *ptr).cancel();
|
||||
}
|
||||
}
|
||||
for handle in self.threads.iter_mut() {
|
||||
if let Some(h) = handle.take() {
|
||||
let _ = h.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,139 @@
|
||||
// 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/>.
|
||||
|
||||
//! `PreCacheTask`, mirroring `src/task/src/precache/precachetask.h`.
|
||||
//!
|
||||
//! Renders a footage node through [`crate::render::RenderTask`] to fill
|
||||
//! the playback cache. The footage and its sequence are now
|
||||
//! [`crate::nodeops::NodeRef`] domain references (the deleted oaknode C
|
||||
//! ABI stubs are gone); video params come from the sequence's parameter
|
||||
//! streams and the cache range is the full footage video length.
|
||||
//!
|
||||
//! **Simplifications over the C++**: the deep project copy / viewer
|
||||
//! wiring is not built (the render drives the given footage directly —
|
||||
//! the ticket carries the footage filename and the footage node's
|
||||
//! identity as the cache key) and the timeline work-area intersection is
|
||||
//! replaced by the full footage length. The direct ticket arena's eval
|
||||
//! producer does not persist frames to the frame cache yet, so this
|
||||
//! render pass warms nothing on disk; the plumbing is in place.
|
||||
//!
|
||||
//! CPP-PARITY: src/task/src/precache/precachetask.h
|
||||
|
||||
use oak_common::videoparams::VideoParams;
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::nodeops::{self, NodeRef};
|
||||
use crate::render::{RenderTask, RenderTaskBehavior};
|
||||
use crate::task::{Task, TaskBehavior};
|
||||
use oak_core::{Rational, TimeRange};
|
||||
|
||||
/// A pre-cache task: renders frames of a footage node into the playback
|
||||
/// cache without any output file.
|
||||
pub struct PreCacheTask {
|
||||
/// The render base (itself a task).
|
||||
pub render: RenderTask,
|
||||
/// The footage being cached.
|
||||
pub footage: NodeRef,
|
||||
/// Frame index within the footage being cached.
|
||||
pub index: i32,
|
||||
/// The sequence context the footage lives in.
|
||||
pub sequence: NodeRef,
|
||||
}
|
||||
|
||||
impl PreCacheTask {
|
||||
/// Create a pre-cache task for the given footage at `index` inside
|
||||
/// `sequence`. The old `footage: CHandle` / `sequence: CHandle`
|
||||
/// signature is replaced by domain [`NodeRef`]s (single-lib
|
||||
/// unification).
|
||||
pub fn new(footage: NodeRef, index: i32, sequence: NodeRef) -> PreCacheTask {
|
||||
// The sequence's video params drive the render timebase (the
|
||||
// deleted `oaknode_sequence_get_video_params` stub is replaced by
|
||||
// the direct behavior query).
|
||||
let video_params: Option<VideoParams> =
|
||||
nodeops::sequence_video_params(&sequence.0, sequence.1, 0);
|
||||
|
||||
// The footage filename labels the task (the deleted
|
||||
// `oaknode_footage_filename` stub is replaced by the direct
|
||||
// behavior query).
|
||||
let filename = nodeops::footage_filename(&footage.0, footage.1);
|
||||
let title = format!("Pre-caching {filename}:{index}");
|
||||
let base = Task::new(&title, None);
|
||||
|
||||
// The render target is the footage node itself (pre-cache fills
|
||||
// that footage's frame cache).
|
||||
let render = RenderTask::new(base, video_params, footage.clone(), Default::default(), None);
|
||||
|
||||
PreCacheTask {
|
||||
render,
|
||||
footage,
|
||||
index,
|
||||
sequence,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TaskBehavior for PreCacheTask {
|
||||
fn run(&mut self, task: &mut Task) -> Result<()> {
|
||||
// Share the caller's cancellation atom with the inner render base.
|
||||
self.render.base.set_cancel_atom(task.get_cancel_atom());
|
||||
|
||||
// The full footage length is the cache range (simplified: no
|
||||
// work-area intersection). The deleted `oaknode_footage_get_video_length`
|
||||
// stub is replaced by the direct behavior query.
|
||||
let length = nodeops::node_length(&self.footage.0, self.footage.1);
|
||||
let range = TimeRange::new(Rational::new(0, 1), length);
|
||||
|
||||
// No color manager / frame-cache handles exist on the direct
|
||||
// ticket path (the arena keys the cache by the footage node
|
||||
// identity — see `RenderTask::build_video_ticket`).
|
||||
self.render.set_render_inputs(0 /* k_online */, false, range);
|
||||
|
||||
// Drive the render with `self` as the subclass behavior (the C++
|
||||
// virtual dispatch receiver); the render is temporarily moved out to
|
||||
// avoid a self-referential borrow and put back before returning.
|
||||
let mut render =
|
||||
std::mem::replace(&mut self.render, crate::render::RenderTask::placeholder());
|
||||
let result = render.render(task, self);
|
||||
self.render = render;
|
||||
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderTaskBehavior for PreCacheTask {
|
||||
fn frame_downloaded(&mut self, task: &mut Task, frame: &oak_render::texture::Texture) -> Result<()> {
|
||||
// Do nothing: pre-cache just fills the frame cache (the direct
|
||||
// ticket arena records the render through the ticket's cache
|
||||
// identity; see the module docs).
|
||||
let _ = (task, frame);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn audio_downloaded(
|
||||
&mut self,
|
||||
task: &mut Task,
|
||||
buffer: &oak_render::ticket::AudioSamples,
|
||||
) -> Result<()> {
|
||||
// Pre-cache doesn't cache any audio.
|
||||
let _ = (task, buffer);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn encode_subtitle(&mut self, task: &mut Task, text: &str) -> Result<()> {
|
||||
let _ = (task, text);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
// 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/>.
|
||||
|
||||
//! Project load/save/import tasks, mirroring `src/task/src/project/*`.
|
||||
//!
|
||||
//! These tasks move data through the direct oaknode domain model
|
||||
//! (`Arc<Mutex<oak_node::project::Project>>` + `oak_node::id::NodeId`);
|
||||
//! they borrow the project while running and hand ownership over on the
|
||||
//! `take_*` accessors (architectural decision #5 in README.md).
|
||||
|
||||
pub mod format;
|
||||
pub mod import;
|
||||
pub mod load;
|
||||
pub mod loadotio;
|
||||
pub mod save;
|
||||
pub mod saveotio;
|
||||
@@ -0,0 +1,65 @@
|
||||
// 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/>.
|
||||
|
||||
//! Interchange-format dispatch for the OTIO tasks.
|
||||
//!
|
||||
//! `LoadOTIOTask` / `SaveOTIOTask` accept both OpenTimelineIO JSON
|
||||
//! (`.otio`) and FCPXML (`.fcpxml`) documents. The format is inferred from
|
||||
//! the filename extension (case-insensitive), so the frozen C ABI
|
||||
//! (`include/task/project.h`) needs no format parameter: the filename
|
||||
//! passed to `oaktask_create_project_load_otio` /
|
||||
//! `oaktask_create_project_save_otio` selects the codec. Format handling
|
||||
//! ends at the `oakotio` parse/serialize call in each task — the
|
||||
//! track/clip/footage building is shared between both formats.
|
||||
//!
|
||||
//! No OTIO or FCPXML type crosses the oaktask C ABI (README decision #6).
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
|
||||
/// The interchange formats the OTIO tasks can read and write.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum InterchangeFormat {
|
||||
/// OpenTimelineIO JSON (`.otio`).
|
||||
OtioJson,
|
||||
/// Final Cut Pro XML (`.fcpxml`).
|
||||
Fcpxml,
|
||||
}
|
||||
|
||||
/// The filename extensions each format dispatches on.
|
||||
pub const SUPPORTED_EXTENSIONS: &str = ".otio, .fcpxml";
|
||||
|
||||
impl InterchangeFormat {
|
||||
/// Dispatch on a filename's extension (case-insensitive).
|
||||
///
|
||||
/// `foo.OTIO` and `foo.FcpXml` select the same format as `foo.otio` /
|
||||
/// `foo.fcpxml`; any other extension (or a filename without one) is
|
||||
/// `Error::Failed` with a message naming the supported extensions.
|
||||
pub fn of(filename: &str) -> Result<InterchangeFormat> {
|
||||
let ext = Path::new(filename)
|
||||
.extension()
|
||||
.map(|e| e.to_string_lossy().into_owned())
|
||||
.unwrap_or_default();
|
||||
match ext.to_ascii_lowercase().as_str() {
|
||||
"otio" => Ok(InterchangeFormat::OtioJson),
|
||||
"fcpxml" => Ok(InterchangeFormat::Fcpxml),
|
||||
_ => Err(Error::Failed(format!(
|
||||
"Unknown project file format for \"{filename}\" (supported: {SUPPORTED_EXTENSIONS})"
|
||||
))),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,454 @@
|
||||
// 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/>.
|
||||
|
||||
//! `ProjectImportTask`, mirroring `src/task/src/project/import/import.h`.
|
||||
//!
|
||||
//! Imports media files into a folder of a project. Produces an undoable
|
||||
//! [`oak_undo::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 oak_common::configstore::ConfigStore;
|
||||
use oak_common::videoparams::VideoType;
|
||||
use oak_undo::undocommand::UndoCommand;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::nodeops::{self, NodeRef, ProjectRef};
|
||||
use crate::task::{Task, TaskBehavior};
|
||||
|
||||
/// Callback used to confirm whether a detected image sequence should be
|
||||
/// imported as a sequence rather than individual frames, mirroring
|
||||
/// `ImageSequenceConfirmFn` in import.h.
|
||||
pub type ImageSequenceConfirmFn = Box<dyn FnMut(&str, &str) -> bool + Send>;
|
||||
|
||||
/// A project-import task. Borrows the destination folder and project.
|
||||
pub struct ProjectImportTask {
|
||||
/// The shared task base.
|
||||
pub base: Task,
|
||||
/// 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<UndoCommand>,
|
||||
/// Files that failed to import.
|
||||
invalid_files: Vec<String>,
|
||||
/// Imported footage, taken via `get_imported_footage`.
|
||||
imported_footage: Vec<NodeRef>,
|
||||
/// Optional image-sequence confirmation callback.
|
||||
image_sequence_confirm: Option<ImageSequenceConfirmFn>,
|
||||
/// Total number of files to import (directories counted recursively).
|
||||
file_count: usize,
|
||||
/// Image-sequence files already decided not to be imported as sequences.
|
||||
image_sequence_ignore_files: Vec<String>,
|
||||
}
|
||||
|
||||
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: NodeRef,
|
||||
project: ProjectRef,
|
||||
filenames: Vec<String>,
|
||||
image_sequence_confirm: Option<ImageSequenceConfirmFn>,
|
||||
file_count: usize,
|
||||
) -> ProjectImportTask {
|
||||
ProjectImportTask {
|
||||
base,
|
||||
folder,
|
||||
project,
|
||||
filenames,
|
||||
command: None,
|
||||
invalid_files: Vec::new(),
|
||||
imported_footage: Vec::new(),
|
||||
image_sequence_confirm,
|
||||
file_count,
|
||||
image_sequence_ignore_files: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Take ownership of the produced undo command; `Err(Error::State)` if
|
||||
/// the task has not run yet.
|
||||
pub fn take_command(&mut self) -> Result<UndoCommand> {
|
||||
self.command.take().ok_or(Error::State)
|
||||
}
|
||||
|
||||
/// Number of files that failed to import.
|
||||
pub fn get_invalid_file_count(&self) -> usize {
|
||||
self.invalid_files.len()
|
||||
}
|
||||
|
||||
/// Whether any file failed to import.
|
||||
pub fn has_invalid_files(&self) -> bool {
|
||||
!self.invalid_files.is_empty()
|
||||
}
|
||||
|
||||
/// 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.
|
||||
pub fn get_file_count(&self) -> usize {
|
||||
self.imported_footage.len()
|
||||
}
|
||||
|
||||
/// Set the image-sequence confirmation callback.
|
||||
pub fn set_image_sequence_confirm_callback(&mut self, cb: ImageSequenceConfirmFn) {
|
||||
self.image_sequence_confirm = Some(cb);
|
||||
}
|
||||
|
||||
/// The number of files counted at construction (progress denominator).
|
||||
pub fn file_count(&self) -> usize {
|
||||
self.file_count
|
||||
}
|
||||
|
||||
fn import(
|
||||
&mut self,
|
||||
task: &mut Task,
|
||||
folder: NodeRef,
|
||||
entries: &mut Vec<String>,
|
||||
counter: &mut usize,
|
||||
parent_command: &mut UndoCommand,
|
||||
) {
|
||||
let atom = task.get_cancel_atom();
|
||||
let mut i = 0;
|
||||
while i < entries.len() {
|
||||
if task.is_cancelled() {
|
||||
break;
|
||||
}
|
||||
|
||||
let file_path = &entries[i];
|
||||
|
||||
if Path::new(file_path).is_dir() {
|
||||
// Create a folder corresponding to the directory and recurse.
|
||||
let entry_list: Vec<String> = match std::fs::read_dir(file_path) {
|
||||
Ok(rd) => rd
|
||||
.filter_map(|e| e.ok())
|
||||
.map(|e| e.path().to_string_lossy().into_owned())
|
||||
.collect(),
|
||||
Err(_) => Vec::new(),
|
||||
};
|
||||
|
||||
if !entry_list.is_empty() {
|
||||
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.clone(),
|
||||
(self.project.clone(), folder_id),
|
||||
parent_command,
|
||||
);
|
||||
let mut sub_entries = entry_list;
|
||||
self.import(
|
||||
task,
|
||||
(self.project.clone(), folder_id),
|
||||
&mut sub_entries,
|
||||
counter,
|
||||
parent_command,
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let Some(footage) = nodeops::footage_create(&self.project, None) else {
|
||||
i += 1;
|
||||
continue;
|
||||
};
|
||||
|
||||
// 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 && 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,
|
||||
(self.project.clone(), footage),
|
||||
entries,
|
||||
i,
|
||||
);
|
||||
|
||||
// Create the undoable command that adds the item.
|
||||
self.add_item_to_folder(
|
||||
folder.clone(),
|
||||
(self.project.clone(), footage),
|
||||
parent_command,
|
||||
);
|
||||
|
||||
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 =
|
||||
nodeops::remove_node_command(self.project.clone(), footage);
|
||||
remove.redo_now();
|
||||
}
|
||||
|
||||
*counter += 1;
|
||||
task.emit_progress(*counter as f64 / self.file_count.max(1) as f64);
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
|
||||
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: NodeRef,
|
||||
info_list: &mut Vec<String>,
|
||||
index: usize,
|
||||
) {
|
||||
let filename = nodeops::footage_filename(&footage.0, footage.1);
|
||||
if filename.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Direct oakcodec calls (single-lib unification).
|
||||
let digit_count = oak_codec::decoder::get_image_sequence_digit_count(&filename);
|
||||
if digit_count <= 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
if self
|
||||
.image_sequence_ignore_files
|
||||
.iter()
|
||||
.any(|f| f == &filename)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if !self.item_is_still_image_footage_only(&footage) {
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(mut video_stream) = nodeops::footage_video_params(&footage.0, footage.1, 0)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
let width = video_stream.width();
|
||||
let height = video_stream.height();
|
||||
|
||||
// Direct oakcodec call (single-lib unification).
|
||||
let seq_index = oak_codec::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 = nodeops::footage_create(&self.project, Some(&prev_fn));
|
||||
let next_file = nodeops::footage_create(&self.project, Some(&next_fn));
|
||||
|
||||
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).
|
||||
let is_sequence = match self.image_sequence_confirm.as_mut() {
|
||||
Some(cb) => cb(&filename, &filename),
|
||||
None => false,
|
||||
};
|
||||
|
||||
let start_index = get_image_sequence_limit(&filename, seq_index, false, digit_count);
|
||||
let end_index = get_image_sequence_limit(&filename, seq_index, true, digit_count);
|
||||
|
||||
for j in start_index..=end_index {
|
||||
let entry_fn = transform_sequence_filename(&filename, j, digit_count);
|
||||
|
||||
if is_sequence {
|
||||
// Remove later entries of this sequence from the import
|
||||
// list (they are imported as one footage). The C++
|
||||
// `info_list.size()` is re-evaluated after each erase.
|
||||
let mut k = index + 1;
|
||||
while k < info_list.len() {
|
||||
if info_list[k] == entry_fn {
|
||||
info_list.remove(k);
|
||||
break;
|
||||
}
|
||||
k += 1;
|
||||
}
|
||||
} else {
|
||||
self.image_sequence_ignore_files.push(entry_fn);
|
||||
}
|
||||
}
|
||||
|
||||
if is_sequence {
|
||||
video_stream.set_video_type(VideoType::ImageSequence);
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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].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: &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 Some(vp) = nodeops::footage_video_params(&footage.0, footage.1, 0) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
vp.is_valid() && vp.video_type() == VideoType::Video
|
||||
}
|
||||
|
||||
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 Some(stream) = nodeops::footage_video_params(&footage.0, footage.1, 0) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
stream.width() == width && stream.height() == height
|
||||
}
|
||||
}
|
||||
|
||||
impl TaskBehavior for ProjectImportTask {
|
||||
/// 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 = UndoCommand::multi();
|
||||
|
||||
let mut counter = 0;
|
||||
let mut entries = self.filenames.clone();
|
||||
let folder = (self.folder.0.clone(), self.folder.1);
|
||||
self.import(task, folder, &mut entries, &mut counter, &mut command);
|
||||
|
||||
if task.is_cancelled() {
|
||||
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 basename_of(path: &str) -> String {
|
||||
Path::new(path)
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().into_owned())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
let width = digit_count.max(0) as usize;
|
||||
match filename.rfind('.') {
|
||||
Some(dot) => {
|
||||
// The digits are the `width` chars immediately before the
|
||||
// extension; they are replaced by `number`.
|
||||
let digits_start = dot.saturating_sub(width);
|
||||
let prefix = &filename[..digits_start];
|
||||
format!(
|
||||
"{prefix}{:0width$}{}",
|
||||
number,
|
||||
&filename[dot..],
|
||||
width = width
|
||||
)
|
||||
}
|
||||
None => format!("{filename}{:0width$}", number, width = width),
|
||||
}
|
||||
}
|
||||
|
||||
/// Walk the index from `start` in the given direction while the transformed
|
||||
/// filename exists (mirrors `get_image_sequence_limit` in import.cpp).
|
||||
fn get_image_sequence_limit(start_fn: &str, start: i64, up: bool, digit_count: i32) -> i64 {
|
||||
let mut current = start;
|
||||
loop {
|
||||
let test_index = if up { current + 1 } else { current - 1 };
|
||||
let test_filename = transform_sequence_filename(start_fn, test_index, digit_count);
|
||||
if !Path::new(&test_filename).exists() {
|
||||
break;
|
||||
}
|
||||
current = test_index;
|
||||
}
|
||||
current
|
||||
}
|
||||
|
||||
/// Parse a "num/den" frame-rate string.
|
||||
fn parse_rational(s: &str) -> Option<(i32, i32)> {
|
||||
let mut parts = s.trim().split('/');
|
||||
let num: i32 = parts.next()?.trim().parse().ok()?;
|
||||
let den: i32 = parts.next()?.trim().parse().ok()?;
|
||||
Some((num, den))
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
// 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/>.
|
||||
|
||||
//! `ProjectLoadBaseTask` / `ProjectLoadTask`, mirroring
|
||||
//! `src/task/src/project/load/load.h`.
|
||||
//!
|
||||
//! Loads an `.oakproj` file into a new `oak_node::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
|
||||
//! (`oak_node::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 std::sync::{Arc, Mutex};
|
||||
|
||||
use oak_node::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`].
|
||||
pub struct ProjectLoadBaseTask {
|
||||
/// The shared task base.
|
||||
pub base: Task,
|
||||
/// Absolute project filename to load.
|
||||
pub filename: String,
|
||||
/// The loaded project, produced by the task and taken via `take_project`.
|
||||
loaded_project: Option<Arc<Mutex<Project>>>,
|
||||
}
|
||||
|
||||
impl ProjectLoadBaseTask {
|
||||
/// Create a base load task for the given filename.
|
||||
pub fn new(base: Task, filename: String) -> ProjectLoadBaseTask {
|
||||
ProjectLoadBaseTask {
|
||||
base,
|
||||
filename,
|
||||
loaded_project: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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<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: Arc<Mutex<Project>>) {
|
||||
self.loaded_project = Some(project);
|
||||
}
|
||||
}
|
||||
|
||||
impl TaskBehavior for ProjectLoadBaseTask {
|
||||
/// Load the project from `filename` via the direct oaknode serializer
|
||||
/// (`oak_node::serializer::load`), storing the resulting project.
|
||||
fn run(&mut self, task: &mut Task) -> Result<()> {
|
||||
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()));
|
||||
}
|
||||
};
|
||||
|
||||
let project = match oak_node::serializer::load(&xml) {
|
||||
Ok(project) => project,
|
||||
Err(e) => {
|
||||
task.set_error(&format!(
|
||||
"Failed to read XML document. File may be corrupt. Error was: {e}"
|
||||
));
|
||||
return Err(Error::Failed("Failed to load project".to_string()));
|
||||
}
|
||||
};
|
||||
|
||||
{
|
||||
let mut guard = project.lock().unwrap_or_else(|e| e.into_inner());
|
||||
guard.set_filename(&self.filename);
|
||||
}
|
||||
|
||||
self.store_project(project);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// The concrete native project loader (no OTIO path).
|
||||
pub struct ProjectLoadTask {
|
||||
/// The shared task base.
|
||||
pub base: ProjectLoadBaseTask,
|
||||
}
|
||||
|
||||
impl TaskBehavior for ProjectLoadTask {
|
||||
fn run(&mut self, task: &mut Task) -> Result<()> {
|
||||
<ProjectLoadBaseTask as TaskBehavior>::run(&mut self.base, task)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,518 @@
|
||||
// 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/>.
|
||||
|
||||
//! `LoadOTIOTask`, mirroring `src/task/src/project/loadotio/loadotio.h`.
|
||||
//!
|
||||
//! Loads an OpenTimelineIO (`.otio`) or FCPXML (`.fcpxml`) file into a new
|
||||
//! 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).
|
||||
//!
|
||||
//! **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<oak_node::project::Project>>` stored on the base task
|
||||
//! (`take_project()`).
|
||||
//!
|
||||
//! CPP-PARITY: src/task/src/project/loadotio/loadotio.cpp
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use oak_node::id::NodeId;
|
||||
use oak_node::track::TrackType;
|
||||
use oak_otio::Serializable;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::nodeops::{self, NodeRef, ProjectRef};
|
||||
use crate::project::format::InterchangeFormat;
|
||||
use crate::project::load::ProjectLoadBaseTask;
|
||||
use crate::task::{Task, TaskBehavior};
|
||||
|
||||
/// Callback used to confirm whether the sequences of an OTIO document should
|
||||
/// be imported, mirroring `ImportConfirmFn` in loadotio.h (receives the
|
||||
/// sequence labels in order; return true to accept).
|
||||
pub type ImportConfirmFn = Box<dyn FnMut(&[String]) -> bool + Send>;
|
||||
|
||||
/// Global import-confirmation callback, mirroring the C++ static
|
||||
/// `LoadOTIOTask::confirm_callback_` (loadotio.h). When unset, everything is
|
||||
/// imported (the headless default).
|
||||
static CONFIRM_CALLBACK: Mutex<Option<ImportConfirmFn>> = Mutex::new(None);
|
||||
|
||||
/// Register (or clear, with `None`) the global import-confirmation callback.
|
||||
///
|
||||
/// CPP-PARITY: src/task/src/project/loadotio/loadotio.h
|
||||
/// (`LoadOTIOTask::set_import_confirm_callback`)
|
||||
pub fn set_import_confirm_callback(cb: Option<ImportConfirmFn>) {
|
||||
*CONFIRM_CALLBACK.lock().unwrap() = cb;
|
||||
}
|
||||
|
||||
/// An OTIO project loader.
|
||||
pub struct LoadOTIOTask {
|
||||
/// The base load task (owns the filename and yields the project).
|
||||
pub base: ProjectLoadBaseTask,
|
||||
}
|
||||
|
||||
impl LoadOTIOTask {
|
||||
/// Create an OTIO loader for the given base load task.
|
||||
pub fn new(base: ProjectLoadBaseTask) -> LoadOTIOTask {
|
||||
LoadOTIOTask { base }
|
||||
}
|
||||
}
|
||||
|
||||
impl TaskBehavior for LoadOTIOTask {
|
||||
/// Load and parse the document (`oakotio`), then convert it into the
|
||||
/// project stored on the base task.
|
||||
fn run(&mut self, task: &mut Task) -> Result<()> {
|
||||
// Dispatch the interchange format from the filename extension.
|
||||
// Format handling ends here: `parse_timelines` returns one
|
||||
// `oak_otio::Timeline` per sequence whatever the source format, so
|
||||
// the project building below is shared between `.otio` and
|
||||
// `.fcpxml` (C++ parity for the track/clip/footage code).
|
||||
let format = match InterchangeFormat::of(&self.base.filename) {
|
||||
Ok(f) => f,
|
||||
Err(e) => {
|
||||
if let Error::Failed(msg) = &e {
|
||||
task.set_error(msg);
|
||||
}
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
|
||||
let timelines = parse_timelines(task, &self.base.filename, format)?;
|
||||
|
||||
// Build the project directly through the oaknode domain model
|
||||
// (the deleted `oaknode_project_init` stub is gone).
|
||||
let project: ProjectRef = oak_node::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, 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<NodeRef> = Vec::new();
|
||||
|
||||
// Variables used for loading bar
|
||||
let mut number_of_clips: f64 = 0.0;
|
||||
|
||||
for timeline in &timelines {
|
||||
let Some(sequence) = nodeops::sequence_create(&project) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let label = if !timeline.name().is_empty() {
|
||||
timeline.name().to_string()
|
||||
} else {
|
||||
// If the otio timeline does not provide a name, create a
|
||||
// default one here.
|
||||
unnamed_sequence_count += 1;
|
||||
format!("Sequence {unnamed_sequence_count}")
|
||||
};
|
||||
nodeops::set_node_label(&project, sequence, &label);
|
||||
|
||||
// Get number of clips for loading bar
|
||||
for track in timeline.tracks().children() {
|
||||
if let Some(otio_track) = track.as_track() {
|
||||
number_of_clips += otio_track.children().len() as f64;
|
||||
}
|
||||
}
|
||||
|
||||
sequences.push((project.clone(), sequence));
|
||||
}
|
||||
if number_of_clips <= 0.0 {
|
||||
number_of_clips = 1.0;
|
||||
}
|
||||
|
||||
// Ask the user which sequences to import (facade callback; headless
|
||||
// default accepts everything).
|
||||
let sequence_names: Vec<String> = sequences
|
||||
.iter()
|
||||
.map(|(p, s)| nodeops::node_label(p, *s))
|
||||
.collect();
|
||||
let mut confirm = CONFIRM_CALLBACK.lock().unwrap().take();
|
||||
let accepted = match confirm.as_mut() {
|
||||
Some(cb) => cb(&sequence_names),
|
||||
None => true,
|
||||
};
|
||||
|
||||
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 (the C++ base-task destructor does the same).
|
||||
task.cancel();
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
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 = *sequence;
|
||||
|
||||
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 = 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
|
||||
for track_composable in timeline.tracks().children() {
|
||||
let Some(otio_track) = track_composable.as_track() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
// Determine what kind of track it is
|
||||
let track_type = match otio_track.kind() {
|
||||
"Video" => TrackType::Video,
|
||||
"Audio" => TrackType::Audio,
|
||||
other => {
|
||||
eprintln!("Found unknown track type: {other}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// Create a new track
|
||||
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: Option<NodeId> = None;
|
||||
let mut prev_block_transition = false;
|
||||
|
||||
for otio_block in otio_track.children() {
|
||||
if task.is_cancelled() {
|
||||
break;
|
||||
}
|
||||
|
||||
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.
|
||||
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}");
|
||||
nodeops::BlockKind::Gap
|
||||
}
|
||||
};
|
||||
let Some(block) = nodeops::block_create(&project, block_kind) else {
|
||||
continue;
|
||||
};
|
||||
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() {
|
||||
let start_seconds = source_range.start_time().to_seconds();
|
||||
let duration_seconds = source_range.duration().to_seconds();
|
||||
|
||||
let start_time = oak_core::Rational::from_double(start_seconds);
|
||||
let duration = oak_core::Rational::from_double(duration_seconds);
|
||||
|
||||
if otio_block.schema_name() == "Clip" {
|
||||
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 {
|
||||
if let Some(prev) = previous_block {
|
||||
nodeops::node_connect(
|
||||
&project,
|
||||
block_node,
|
||||
prev,
|
||||
nodeops::TRANSITION_IN_BLOCK_INPUT,
|
||||
);
|
||||
}
|
||||
prev_block_transition = false;
|
||||
}
|
||||
|
||||
if let Some(otio_transition) = otio_block.as_transition() {
|
||||
// Set how far the transition eats into the previous
|
||||
// clip.
|
||||
let in_offset = otio_transition.in_offset().to_rational();
|
||||
let out_offset = otio_transition.out_offset().to_rational();
|
||||
nodeops::transition_set_offsets_and_length(
|
||||
&project,
|
||||
block_node,
|
||||
in_offset.numerator(),
|
||||
in_offset.denominator(),
|
||||
out_offset.numerator(),
|
||||
out_offset.denominator(),
|
||||
);
|
||||
|
||||
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.
|
||||
set_own_context_position(&project, block_node);
|
||||
}
|
||||
|
||||
if otio_block.schema_name() == "Gap" {
|
||||
// Position gap in its own context.
|
||||
set_own_context_position(&project, block_node);
|
||||
}
|
||||
|
||||
// Update this after it's used but before any continue
|
||||
// statements.
|
||||
previous_block = Some(block_node);
|
||||
|
||||
if otio_block.schema_name() == "Clip" {
|
||||
let Some(otio_clip) = otio_block.as_clip() else {
|
||||
continue;
|
||||
};
|
||||
let Some(media_reference) = otio_clip.media_reference() else {
|
||||
continue;
|
||||
};
|
||||
if let Some(external) = media_reference.as_external_reference() {
|
||||
// Link footage
|
||||
let footage_url = external.target_url().to_string();
|
||||
|
||||
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();
|
||||
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,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
clips_done += 1.0;
|
||||
task.emit_progress(clips_done / number_of_clips);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.base.store_project(project);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a node from the factory registry (`oaknode_factory_create_from_id`).
|
||||
fn factory_create(project: &ProjectRef, type_id: &str) -> Option<NodeId> {
|
||||
let meta = oak_node::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).
|
||||
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 `oak_otio::Timeline` per sequence, dispatching
|
||||
/// on the filename extension:
|
||||
///
|
||||
/// - `.otio`: OpenTimelineIO JSON. A `Timeline` root yields one timeline, a
|
||||
/// `SerializableCollection` yields one timeline per `Timeline` child, and
|
||||
/// any other root is the C++ "Unknown OpenTimelineIO root element" error.
|
||||
/// - `.fcpxml`: FCPXML (`oak_otio::fcpxml`), one timeline per `<sequence>`.
|
||||
///
|
||||
/// Format handling ends here — the caller builds the project from the
|
||||
/// returned timelines regardless of the source format (C++ parity), so the
|
||||
/// track/clip/footage code never forks.
|
||||
fn parse_timelines(
|
||||
task: &mut Task,
|
||||
filename: &str,
|
||||
format: InterchangeFormat,
|
||||
) -> Result<Vec<oak_otio::Timeline>> {
|
||||
match format {
|
||||
InterchangeFormat::OtioJson => {
|
||||
let root = oak_otio::from_json_file(filename).map_err(|e| {
|
||||
task.set_error(&format!(
|
||||
"Failed to load OpenTimelineIO from file \"{}\" \n\nOpenTimelineIO Error:\n\n{}",
|
||||
filename, e
|
||||
));
|
||||
Error::Failed("Failed to load OpenTimelineIO".to_string())
|
||||
})?;
|
||||
match &root {
|
||||
Serializable::SerializableCollection(collection) => Ok(collection
|
||||
.children()
|
||||
.iter()
|
||||
.filter_map(|child| child.as_timeline().cloned())
|
||||
.collect()),
|
||||
Serializable::Timeline(timeline) => Ok(vec![timeline.clone()]),
|
||||
_ => {
|
||||
task.set_error("Unknown OpenTimelineIO root element");
|
||||
Err(Error::Failed(
|
||||
"Unknown OpenTimelineIO root element".to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
InterchangeFormat::Fcpxml => oak_otio::from_fcpxml_file(filename).map_err(|e| {
|
||||
task.set_error(&format!(
|
||||
"Failed to load FCPXML from file \"{}\" \n\nFCPXML Error:\n\n{}",
|
||||
filename, e
|
||||
));
|
||||
Error::Failed("Failed to load FCPXML".to_string())
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
// 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/>.
|
||||
|
||||
//! `ProjectSaveTask`, mirroring `src/task/src/project/save/save.h`.
|
||||
//!
|
||||
//! Serializes a borrowed `oak_node::project::Project` to an `.oakproj` file
|
||||
//! via the direct Rust serializer (`oak_node::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 std::sync::{Arc, Mutex};
|
||||
|
||||
use oak_node::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 (`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. 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,
|
||||
}
|
||||
|
||||
impl ProjectSaveTask {
|
||||
/// Set the output filename override.
|
||||
pub fn set_override_filename(&mut self, filename: &str) {
|
||||
self.override_filename = Some(filename.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
impl TaskBehavior for ProjectSaveTask {
|
||||
/// Serialize the project via the direct oaknode serializer
|
||||
/// (`oak_node::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 => self
|
||||
.project
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.filename()
|
||||
.to_string(),
|
||||
};
|
||||
|
||||
if using_filename.is_empty() {
|
||||
task.set_error("Project has no filename to save to.");
|
||||
return Err(Error::Failed(
|
||||
"Project has no filename to save to.".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let xml = match oak_node::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()));
|
||||
}
|
||||
};
|
||||
|
||||
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()));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,447 @@
|
||||
// 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<oak_node::project::Project>>` instead of a borrowed `CHandle`.
|
||||
//!
|
||||
//! CPP-PARITY: src/task/src/project/saveotio/saveotio.cpp
|
||||
|
||||
use oak_core::Rational;
|
||||
use oak_node::id::NodeId;
|
||||
use oak_node::track::TrackType;
|
||||
use oak_otio::{
|
||||
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 `oak_otio::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::<oak_node::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 => {
|
||||
oak_otio::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)
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
// 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/>.
|
||||
|
||||
//! `ProxyTask`, mirroring `src/task/src/proxy/proxy.h`.
|
||||
//!
|
||||
//! Transcodes a video stream to a proxy via an external `ffmpeg` process
|
||||
//! (oakcodec kind `OAKCODEC_TASK_PROXY`). The two pure functions
|
||||
//! [`ProxyTask::build_arguments`] and [`ProxyTask::parse_progress`] are
|
||||
//! parity/golden candidates (see tests/parity_test.rs).
|
||||
//!
|
||||
//! CPP-PARITY: src/task/src/proxy/proxy.h
|
||||
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
use oak_codec::proxymanager::ProxyManager;
|
||||
use oak_codec::task::TaskRequest;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::task::{Task, TaskBehavior};
|
||||
|
||||
/// The proxy parameters, mirroring `oakcodec_proxy_params` in
|
||||
/// `include/codec/proxy.h` (kept as plain fields so no oakcodec handle is
|
||||
/// needed to build arguments).
|
||||
pub struct ProxyParams {
|
||||
/// Target width (0 = unspecified/divider-based).
|
||||
pub width: i32,
|
||||
/// Target height (0 = unspecified/divider-based).
|
||||
pub height: i32,
|
||||
/// Resolution divider.
|
||||
pub divider: i32,
|
||||
/// Proxy format version.
|
||||
pub version: i32,
|
||||
/// CRF for the proxy encode.
|
||||
pub crf: i32,
|
||||
/// Whether audio is included in the proxy.
|
||||
pub include_audio: bool,
|
||||
/// Output container extension.
|
||||
pub extension: String,
|
||||
/// ffmpeg preset name.
|
||||
pub preset: String,
|
||||
}
|
||||
|
||||
/// A proxy transcode task: runs `ffmpeg` with arguments from
|
||||
/// [`ProxyTask::build_arguments`], parsing progress lines via
|
||||
/// [`ProxyTask::parse_progress`].
|
||||
pub struct ProxyTask {
|
||||
/// The shared task base.
|
||||
pub base: Task,
|
||||
/// Source media filename.
|
||||
pub source_filename: String,
|
||||
/// Target stream index.
|
||||
pub stream_index: i32,
|
||||
/// Proxy parameters.
|
||||
pub params: ProxyParams,
|
||||
/// Output proxy filename.
|
||||
pub output_filename: String,
|
||||
/// Total media duration in seconds (for progress scaling).
|
||||
duration_seconds: f64,
|
||||
}
|
||||
|
||||
impl ProxyTask {
|
||||
/// Build a proxy task from an oakcodec request and proxy params,
|
||||
/// mirroring the C++ constructor (divider-based requests take the source
|
||||
/// fraction).
|
||||
pub fn new(request: &TaskRequest, params: ProxyParams) -> ProxyTask {
|
||||
let mut params = params;
|
||||
if request.proxy_width > 0 && request.proxy_height > 0 {
|
||||
params.width = request.proxy_width;
|
||||
params.height = request.proxy_height;
|
||||
params.divider = 1;
|
||||
}
|
||||
let title = format!(
|
||||
"Generating Proxy {}:{}",
|
||||
request.input_filename, request.stream_index
|
||||
);
|
||||
ProxyTask {
|
||||
base: Task::new(&title, None),
|
||||
source_filename: request.input_filename.to_string(),
|
||||
stream_index: request.stream_index,
|
||||
params,
|
||||
output_filename: request.output_filename.to_string(),
|
||||
duration_seconds: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the `ffmpeg` command-line argument vector for the proxy
|
||||
/// transcode. Mirrors `build_arguments` in proxy.cpp; the exact ordering
|
||||
/// and flag spelling are parity-locked (golden test).
|
||||
///
|
||||
/// CPP-PARITY: src/task/src/proxy/proxy.cpp (build_arguments)
|
||||
pub fn build_arguments(
|
||||
source_filename: &str,
|
||||
stream_index: i32,
|
||||
params: &ProxyParams,
|
||||
output_filename: &str,
|
||||
) -> Vec<String> {
|
||||
let scale_filter = if params.divider > 1 {
|
||||
format!(
|
||||
"scale=w=trunc(iw/{}/2)*2:h=trunc(ih/{}/2)*2",
|
||||
params.divider, params.divider
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"scale=w={}:h={}:force_original_aspect_ratio=decrease",
|
||||
params.width, params.height
|
||||
)
|
||||
};
|
||||
|
||||
let container_format = if params.extension.is_empty() {
|
||||
"mp4".to_string()
|
||||
} else {
|
||||
params.extension.clone()
|
||||
};
|
||||
|
||||
let mut args: Vec<String> = vec![
|
||||
"-y".to_string(),
|
||||
"-nostats".to_string(),
|
||||
"-progress".to_string(),
|
||||
"pipe:1".to_string(),
|
||||
"-i".to_string(),
|
||||
source_filename.to_string(),
|
||||
"-map".to_string(),
|
||||
format!("0:{stream_index}"),
|
||||
];
|
||||
|
||||
if params.include_audio {
|
||||
args.extend([
|
||||
"-map".to_string(),
|
||||
"0:a?".to_string(),
|
||||
"-c:a".to_string(),
|
||||
"aac".to_string(),
|
||||
"-b:a".to_string(),
|
||||
"128k".to_string(),
|
||||
]);
|
||||
} else {
|
||||
args.push("-an".to_string());
|
||||
}
|
||||
|
||||
args.extend([
|
||||
"-vf".to_string(),
|
||||
scale_filter,
|
||||
"-c:v".to_string(),
|
||||
"libx264".to_string(),
|
||||
"-preset".to_string(),
|
||||
params.preset.clone(),
|
||||
"-crf".to_string(),
|
||||
params.crf.to_string(),
|
||||
"-pix_fmt".to_string(),
|
||||
"yuv420p".to_string(),
|
||||
"-movflags".to_string(),
|
||||
"+faststart".to_string(),
|
||||
"-f".to_string(),
|
||||
container_format,
|
||||
output_filename.to_string(),
|
||||
]);
|
||||
|
||||
args
|
||||
}
|
||||
|
||||
/// Parse a single `ffmpeg -progress` output line into a progress value in
|
||||
/// 0.0..=1.0 given the total duration. Mirrors `parse_progress` in
|
||||
/// proxy.cpp (golden test).
|
||||
///
|
||||
/// CPP-PARITY: src/task/src/proxy/proxy.cpp (parse_progress)
|
||||
pub fn parse_progress(line: &str, duration_seconds: f64) -> Option<f64> {
|
||||
if duration_seconds <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let out_time_us: i64 = if let Some(rest) = line.strip_prefix("out_time_us=") {
|
||||
rest.trim().parse().unwrap_or(-1)
|
||||
} else if let Some(rest) = line.strip_prefix("out_time_ms=") {
|
||||
// Despite the name, ffmpeg reports this value in microseconds.
|
||||
rest.trim().parse().unwrap_or(-1)
|
||||
} else {
|
||||
-1
|
||||
};
|
||||
|
||||
if out_time_us < 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let progress = out_time_us as f64 / 1_000_000.0 / duration_seconds;
|
||||
Some(if progress < 0.0 {
|
||||
0.0
|
||||
} else if progress > 1.0 {
|
||||
1.0
|
||||
} else {
|
||||
progress
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl TaskBehavior for ProxyTask {
|
||||
/// Spawn `ffmpeg` with the built arguments, feed `-progress` lines to
|
||||
/// [`ProxyTask::parse_progress`] and emit them as task progress.
|
||||
fn run(&mut self, task: &mut Task) -> Result<()> {
|
||||
// Direct call into oakcodec's proxy manager (single-lib
|
||||
// unification: the old two-stage C ABI getter is gone; an empty
|
||||
// string means "not found"). The `FFmpegPath` config takes
|
||||
// precedence when set (C++ `OAK_CONFIG("FFmpegPath")`).
|
||||
let configured = oak_common::configstore::ConfigStore::instance()
|
||||
.get(None, "FFmpegPath")
|
||||
.unwrap_or_default();
|
||||
let ffmpeg_path = ProxyManager::find_ffmpeg(&configured);
|
||||
if ffmpeg_path.is_empty() {
|
||||
task.set_error(
|
||||
"Failed to generate proxy: ffmpeg executable was not found. Set the ffmpeg path in Preferences > Disk > Proxy Settings.",
|
||||
);
|
||||
return Err(Error::Failed("ffmpeg executable was not found".to_string()));
|
||||
}
|
||||
|
||||
// Create the output directory if needed.
|
||||
if let Some(parent) = std::path::Path::new(&self.output_filename).parent() {
|
||||
if !parent.as_os_str().is_empty() && !parent.exists() {
|
||||
if std::fs::create_dir_all(parent).is_err() {
|
||||
task.set_error("Failed to create proxy output directory");
|
||||
return Err(Error::Failed(
|
||||
"Failed to create proxy output directory".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let _ = std::fs::remove_file(&self.output_filename);
|
||||
let working_filename = format!("{}.working.mp4", self.output_filename);
|
||||
let _ = std::fs::remove_file(&working_filename);
|
||||
|
||||
let args = Self::build_arguments(
|
||||
&self.source_filename,
|
||||
self.stream_index,
|
||||
&self.params,
|
||||
&working_filename,
|
||||
);
|
||||
|
||||
// Probe the source duration for progress scaling (0 when unknown).
|
||||
self.duration_seconds = probe_source_duration_seconds(&ffmpeg_path, &self.source_filename);
|
||||
|
||||
let mut command = Command::new(&ffmpeg_path);
|
||||
command
|
||||
.args(&args)
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
let mut child = match command.spawn() {
|
||||
Ok(child) => child,
|
||||
Err(_) => {
|
||||
task.set_error("Failed to start ffmpeg for proxy generation");
|
||||
return Err(Error::Failed("Failed to start ffmpeg".to_string()));
|
||||
}
|
||||
};
|
||||
|
||||
// Drain stderr in a thread so a chatty ffmpeg cannot block us.
|
||||
let stderr = child.stderr.take();
|
||||
if let Some(stderr) = stderr {
|
||||
std::thread::spawn(move || {
|
||||
let _ = BufReader::new(stderr).lines().count();
|
||||
});
|
||||
}
|
||||
|
||||
let stdout = child.stdout.take();
|
||||
let mut last_progress = 0.0_f64;
|
||||
if let Some(stdout) = stdout {
|
||||
let reader = BufReader::new(stdout);
|
||||
for line in reader.lines() {
|
||||
match line {
|
||||
Ok(line) => {
|
||||
if let Some(progress) = Self::parse_progress(&line, self.duration_seconds) {
|
||||
if progress >= 0.0 && progress - last_progress > 0.001 {
|
||||
last_progress = progress;
|
||||
task.emit_progress(progress);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
if task.is_cancelled() {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
let _ = std::fs::remove_file(&working_filename);
|
||||
task.set_error("Proxy generation was cancelled");
|
||||
return Err(Error::Cancelled);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let exit_status = child.wait();
|
||||
if !matches!(exit_status, Ok(status) if status.success()) {
|
||||
let _ = std::fs::remove_file(&working_filename);
|
||||
task.set_error("ffmpeg failed to generate proxy");
|
||||
return Err(Error::Failed("ffmpeg failed to generate proxy".to_string()));
|
||||
}
|
||||
|
||||
if !std::path::Path::new(&working_filename).exists() {
|
||||
task.set_error("ffmpeg finished but proxy file was not created");
|
||||
return Err(Error::Failed(
|
||||
"ffmpeg finished but proxy file was not created".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
if std::fs::rename(&working_filename, &self.output_filename).is_err() {
|
||||
task.set_error("Failed to move proxy into place");
|
||||
return Err(Error::Failed("Failed to move proxy into place".to_string()));
|
||||
}
|
||||
|
||||
task.emit_progress(1.0);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Probe the source duration via `ffprobe` next to ffmpeg; 0.0 when
|
||||
/// unavailable (in which case no intermediate progress is reported).
|
||||
fn probe_source_duration_seconds(ffmpeg_path: &str, source_filename: &str) -> f64 {
|
||||
let ffprobe = std::path::Path::new(ffmpeg_path).with_file_name("ffprobe");
|
||||
if !ffprobe.exists() {
|
||||
return 0.0;
|
||||
}
|
||||
let output = Command::new(&ffprobe)
|
||||
.args([
|
||||
"-v",
|
||||
"error",
|
||||
"-show_entries",
|
||||
"format=duration",
|
||||
"-of",
|
||||
"default=noprint_wrappers=1:nokey=1",
|
||||
source_filename,
|
||||
])
|
||||
.output();
|
||||
match output {
|
||||
Ok(out) if out.status.success() => {
|
||||
let text = String::from_utf8_lossy(&out.stdout);
|
||||
text.trim().parse().unwrap_or(0.0)
|
||||
}
|
||||
_ => 0.0,
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,320 @@
|
||||
// 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/>.
|
||||
|
||||
//! The `Task` base class, mirroring `src/task/src/task.h` (`olive::Task`).
|
||||
//!
|
||||
//! The C++ class is abstract with a protected virtual `run()`. In Rust each
|
||||
//! concrete task (`ConformTask`, `ProxyTask`, …) is its own struct and the
|
||||
//! shared lifecycle lives here; the per-task work is supplied through
|
||||
//! [`TaskBehavior`] (a trait object, per architectural decision #1 in
|
||||
//! README.md). Cancellation rides on a shared
|
||||
//! `oak_common::cancelatom::CancelAtom` (single-lib unification: the old
|
||||
//! oakrender cancelatom C ABI is gone).
|
||||
//!
|
||||
//! CPP-PARITY: src/task/src/task.h
|
||||
//!
|
||||
//! ## Concurrency contract
|
||||
//!
|
||||
//! Like the C++ original, the task's `title`/`error` strings are mutated on
|
||||
//! the task's own thread (`run()`). The C ABI layer must therefore only read
|
||||
//! them while the task is not concurrently running — i.e. before
|
||||
//! [`Task::start`] or after the task finished (the `finished` flag is
|
||||
//! synchronized through the `done` condvar, so `is_finished()`/
|
||||
//! `succeeded()` are always race-free). This mirrors the C++ semantics
|
||||
//! exactly.
|
||||
|
||||
use std::sync::atomic::{AtomicI64, Ordering};
|
||||
use std::sync::{Arc, Condvar, Mutex};
|
||||
|
||||
use oak_common::cancelatom::CancelAtom;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
|
||||
/// Event type emitted through a task's [`EventListener`], mirroring the
|
||||
/// C++ `EventType` enum (`k_event_started`/`k_event_progress`/`k_event_finished`).
|
||||
///
|
||||
/// CPP-PARITY: src/task/src/task.h (EventType)
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum TaskEvent {
|
||||
/// Task began executing.
|
||||
Started,
|
||||
/// Progress updated; the payload is in 0.0..=1.0.
|
||||
Progress(f64),
|
||||
/// Task finished (success or failure).
|
||||
Finished,
|
||||
}
|
||||
|
||||
/// A listener notified of [`TaskEvent`]s, mirroring the C++
|
||||
/// `std::function<void(EventType,double)>` listener. Delivered under the
|
||||
/// task's lock; kept as the single async return channel (decision #3).
|
||||
///
|
||||
/// CPP-PARITY: src/task/src/task.h (EventListener)
|
||||
pub type EventListener = Box<dyn FnMut(TaskEvent) + Send>;
|
||||
|
||||
/// Trait supplying the per-task work, standing in for the C++ protected
|
||||
/// virtual `run()`. Concrete tasks implement this; [`Task::start`] drives it.
|
||||
///
|
||||
/// CPP-PARITY: src/task/src/task.h (run)
|
||||
pub trait TaskBehavior {
|
||||
/// Perform the task's work. Returning `Err(Error::Cancelled)` is treated
|
||||
/// as a cancellation; any other `Err` marks the task failed.
|
||||
fn run(&mut self, task: &mut Task) -> Result<()>;
|
||||
}
|
||||
|
||||
/// Values the subscribe wrapper needs to re-encode [`TaskEvent`]s into the
|
||||
/// legacy `(event_id, value, userdata)` callback signature. The C++ emits
|
||||
/// the start timestamp with `k_event_started` and 1.0/0.0 with
|
||||
/// `k_event_finished`; `TaskEvent` carries neither, so the task publishes
|
||||
/// them into this shared state before emitting (decision #1 in README.md).
|
||||
#[derive(Debug, Default)]
|
||||
pub struct SubscriberState {
|
||||
/// Start timestamp (ms since epoch) published before `Started` is emitted.
|
||||
pub start_ms: AtomicI64,
|
||||
/// 1.0/0.0 published before `Finished` is emitted.
|
||||
pub finished_value: AtomicI64,
|
||||
}
|
||||
|
||||
/// Finish state, protected by the `done` condvar pair so `is_finished`/
|
||||
/// `succeeded` are readable from any thread while the task thread mutates
|
||||
/// them.
|
||||
#[derive(Default)]
|
||||
struct TaskDone {
|
||||
/// Whether the task has finished (success or failure).
|
||||
finished: bool,
|
||||
/// Whether the task finished successfully.
|
||||
succeeded: bool,
|
||||
}
|
||||
|
||||
/// The base task. Owns lifecycle state plus a shared `CancelAtom`; the
|
||||
/// concrete behavior lives in a [`TaskBehavior`] trait object.
|
||||
pub struct Task {
|
||||
title: String,
|
||||
error: Option<String>,
|
||||
start_time: Option<std::time::Instant>,
|
||||
cancel_atom: Arc<CancelAtom>,
|
||||
event_listener: Option<EventListener>,
|
||||
cancel_event: Option<Box<dyn FnMut() + Send>>,
|
||||
started: bool,
|
||||
finished: bool,
|
||||
succeeded: bool,
|
||||
behavior: Option<Box<dyn TaskBehavior + Send>>,
|
||||
/// Finish/success flag pair + wakeup condvar (race-free readers).
|
||||
done: Arc<(Mutex<TaskDone>, Condvar)>,
|
||||
/// Values published for the legacy subscribe wrapper.
|
||||
subscriber: Option<Arc<SubscriberState>>,
|
||||
}
|
||||
|
||||
impl Task {
|
||||
/// Create a new task with the given title. When `cancel_atom` is `None`
|
||||
/// a fresh atom is created and owned by the task (mirroring the C++
|
||||
/// constructor); `Some` shares the caller's atom (mirrors the old
|
||||
/// borrowed-oakrender-atom constructor).
|
||||
pub fn new(title: &str, cancel_atom: Option<Arc<CancelAtom>>) -> Task {
|
||||
Task {
|
||||
title: title.to_string(),
|
||||
error: None,
|
||||
start_time: None,
|
||||
cancel_atom: cancel_atom.unwrap_or_else(|| Arc::new(CancelAtom::new())),
|
||||
event_listener: None,
|
||||
cancel_event: None,
|
||||
started: false,
|
||||
finished: false,
|
||||
succeeded: false,
|
||||
behavior: None,
|
||||
done: Arc::new((Mutex::new(TaskDone::default()), Condvar::new())),
|
||||
subscriber: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Attach the concrete behavior (defaults to a no-op).
|
||||
pub fn set_behavior(&mut self, behavior: Box<dyn TaskBehavior + Send>) {
|
||||
self.behavior = Some(behavior);
|
||||
}
|
||||
|
||||
/// Start the task: set `started`, emit [`TaskEvent::Started`], run the
|
||||
/// behavior, then mark finished and emit [`TaskEvent::Finished`].
|
||||
///
|
||||
/// The returned `Result` mirrors the C++ `start()` bool: `Ok(())` when
|
||||
/// the behavior succeeded, `Err(..)` otherwise.
|
||||
pub fn start(&mut self) -> Result<()> {
|
||||
let start_ms = system_time_ms();
|
||||
self.start_time = Some(std::time::Instant::now());
|
||||
self.started = true;
|
||||
if let Some(s) = &self.subscriber {
|
||||
s.start_ms.store(start_ms, Ordering::SeqCst);
|
||||
}
|
||||
self.emit_event(TaskEvent::Started);
|
||||
|
||||
// Take the behavior out so it can receive `self` (avoids a
|
||||
// self-referential borrow); put it back afterwards.
|
||||
let behavior = self.behavior.take();
|
||||
let ret = if let Some(mut b) = behavior {
|
||||
let r = b.run(self);
|
||||
self.behavior = Some(b);
|
||||
r
|
||||
} else {
|
||||
Ok(())
|
||||
};
|
||||
|
||||
let succeeded = ret.is_ok();
|
||||
{
|
||||
let mut done = self.done.0.lock().unwrap();
|
||||
done.finished = true;
|
||||
done.succeeded = succeeded;
|
||||
self.done.1.notify_all();
|
||||
}
|
||||
if let Some(s) = &self.subscriber {
|
||||
s.finished_value
|
||||
.store(if succeeded { 1 } else { 0 }, Ordering::SeqCst);
|
||||
}
|
||||
self.emit_event(TaskEvent::Finished);
|
||||
|
||||
// One-shot subscription: drop the listener after the final event.
|
||||
self.event_listener = None;
|
||||
|
||||
ret
|
||||
}
|
||||
|
||||
/// Request cancellation through the shared cancel atom, then invoke the
|
||||
/// cancel event callback if one is registered.
|
||||
pub fn cancel(&mut self) {
|
||||
self.cancel_atom.cancel();
|
||||
if let Some(cb) = self.cancel_event.as_mut() {
|
||||
cb();
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether cancellation was requested (queries the shared atom).
|
||||
pub fn is_cancelled(&self) -> bool {
|
||||
self.cancel_atom.is_cancelled()
|
||||
}
|
||||
|
||||
/// The shared cancel atom (a clone of the task's `Arc`).
|
||||
pub fn get_cancel_atom(&self) -> Arc<CancelAtom> {
|
||||
self.cancel_atom.clone()
|
||||
}
|
||||
|
||||
/// Replace the cancel atom. Used to share one atom between a task and
|
||||
/// its behavior's inner base task.
|
||||
pub fn set_cancel_atom(&mut self, atom: Arc<CancelAtom>) {
|
||||
self.cancel_atom = atom;
|
||||
}
|
||||
|
||||
/// Register the event listener. Replaces any previous listener.
|
||||
pub fn set_event_listener(&mut self, listener: EventListener) {
|
||||
self.event_listener = Some(listener);
|
||||
}
|
||||
|
||||
/// Register a hook invoked when [`Task::cancel`] is called.
|
||||
pub fn set_cancel_event(&mut self, cb: Box<dyn FnMut() + Send>) {
|
||||
self.cancel_event = Some(cb);
|
||||
}
|
||||
|
||||
/// Publish the shared values used by the legacy subscribe wrapper.
|
||||
pub fn set_subscriber(&mut self, state: Arc<SubscriberState>) {
|
||||
self.subscriber = Some(state);
|
||||
}
|
||||
|
||||
/// Emit a progress update, clamping to 0.0..=1.0 and delivering it to the
|
||||
/// listener as [`TaskEvent::Progress`].
|
||||
pub fn emit_progress(&mut self, progress: f64) {
|
||||
self.emit_event(TaskEvent::Progress(progress.clamp(0.0, 1.0)));
|
||||
}
|
||||
|
||||
/// Reset lifecycle state so the task can be run again.
|
||||
pub fn reset(&mut self) {
|
||||
self.started = false;
|
||||
self.finished = false;
|
||||
self.succeeded = false;
|
||||
self.error = None;
|
||||
self.start_time = None;
|
||||
{
|
||||
let mut done = self.done.0.lock().unwrap();
|
||||
done.finished = false;
|
||||
done.succeeded = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Record a failure message and mark the task failed.
|
||||
pub fn set_error(&mut self, message: &str) {
|
||||
self.error = Some(message.to_string());
|
||||
}
|
||||
|
||||
/// Change the task title.
|
||||
pub fn set_title(&mut self, title: &str) {
|
||||
self.title = title.to_string();
|
||||
}
|
||||
|
||||
/// The task title.
|
||||
pub fn title(&self) -> &str {
|
||||
&self.title
|
||||
}
|
||||
|
||||
/// The failure message, if the task failed.
|
||||
pub fn error(&self) -> Option<&str> {
|
||||
self.error.as_deref()
|
||||
}
|
||||
|
||||
/// Whether the task has finished (success or failure).
|
||||
pub fn is_finished(&self) -> bool {
|
||||
self.done.0.lock().unwrap().finished
|
||||
}
|
||||
|
||||
/// Whether the task finished successfully.
|
||||
pub fn succeeded(&self) -> bool {
|
||||
self.done.0.lock().unwrap().succeeded
|
||||
}
|
||||
|
||||
/// Block until the task finishes. Mirrors the C++ finished-wait condvar;
|
||||
/// the manager joins worker threads instead, but this is useful for
|
||||
/// direct (non-manager) runs. Returns immediately when the task has not
|
||||
/// been started yet (nothing to wait for) or is already finished.
|
||||
pub fn wait_finished(&self) {
|
||||
if !self.started {
|
||||
return;
|
||||
}
|
||||
let mut done = self.done.0.lock().unwrap();
|
||||
while !done.finished {
|
||||
done = self.done.1.wait(done).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
/// The elapsed time since [`Task::start`], for duration reporting.
|
||||
pub fn elapsed(&self) -> Option<std::time::Duration> {
|
||||
self.start_time.map(|t| t.elapsed())
|
||||
}
|
||||
|
||||
fn emit_event(&mut self, ev: TaskEvent) {
|
||||
if let Some(listener) = self.event_listener.as_mut() {
|
||||
listener(ev);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Current wall-clock time in milliseconds since the Unix epoch, matching
|
||||
/// the C++ `start_time_` convention (std::chrono::system_clock ms).
|
||||
pub fn system_time_ms() -> i64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as i64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Marker error returned when a task is cancelled, so the legacy ABI can map
|
||||
/// it to `OAKTASK_E_CANCELLED` (distinct from a generic failure).
|
||||
pub fn cancelled() -> Error {
|
||||
Error::Cancelled
|
||||
}
|
||||
Reference in New Issue
Block a user