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

Single-lib cleanup: the per-crate src/bridge/ and src/ffi.rs layers are
gone (oakundo/oakcommon/oaknode/oaktimeline/oakcodec/oakaudio/
oakrender/oaktask/oakplugin/oakstorage); cross-crate calls are plain
Rust, CHandle marshalling shrinks to the oakengine boundary, and tests
call the Rust APIs directly (pure C-ABI wrapper tests removed where
the domain layer already covers the behavior).

exporter.h family implemented: oakengine_export_render (CLI contract),
oakengine_export_render_with_params (was a stub), last_error and
progress callback; synchronous path reuses task_create_export +
start_sync. Fixes on the way: oaktask video ticket self-deadlock,
audio params dropped on the export path, codec encoder AAC slicing and
H.264 time base. Real-mp4 tests cover both entry points, progress and
the illegal-argument matrix.

Also: oakstorage session maps null project handles to None (version-
info path), configstore test double literal 3.14 -> 3.15 (clippy PI
lint), oakaudio output callback scratch buffer + env-aware P1 test,
cli media round-trip test uses a generated 16-frame clip (no more
minute-long debug runs).
This commit is contained in:
2026-08-16 00:33:45 +08:00
parent 2248be8567
commit ab1a2e9c7b
293 changed files with 27826 additions and 90128 deletions
File diff suppressed because it is too large Load Diff
+1 -13
View File
@@ -18,9 +18,6 @@
//! or a real cancellation (`Err(Cancelled)`); the cancelled callback fires
//! only for real cancellations.
mod common;
use common::*;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
@@ -36,7 +33,7 @@ fn spawn_parked(
let state = cc.state();
let atom = cc.base.get_cancel_atom();
let title = cc.base.title().to_string();
let mut outer = Task::new(&title, atom);
let mut outer = Task::new(&title, Some(atom));
outer.set_behavior(Box::new(cc));
let handle = std::thread::spawn(move || outer.start());
// Let the worker park on the condvar.
@@ -48,9 +45,6 @@ fn spawn_parked(
/// task which then reports success.
#[test]
fn finish_completes_parked_task() {
let _guard = MANAGER_LOCK.lock().unwrap();
reset_stubs();
let cc = CustomCacheTask::new("my-sequence");
assert_eq!(cc.base.title(), "Caching custom range for \"my-sequence\"");
let (handle, state) = spawn_parked(cc);
@@ -74,9 +68,6 @@ fn finish_completes_parked_task() {
/// `finish()`.
#[test]
fn cancelled_callback_fires_only_on_real_cancel() {
let _guard = MANAGER_LOCK.lock().unwrap();
reset_stubs();
// finish(): the callback must NOT fire.
let fired_on_finish = Arc::new(AtomicBool::new(false));
let mut cc = CustomCacheTask::new("finish-seq");
@@ -111,9 +102,6 @@ fn cancelled_callback_fires_only_on_real_cancel() {
/// The cancel hook wakes the parked task even without finish().
#[test]
fn cancel_hook_wakes_parked_task() {
let _guard = MANAGER_LOCK.lock().unwrap();
reset_stubs();
let cc = CustomCacheTask::new("wake-seq");
let (handle, state) = spawn_parked(cc);
// Simulate the manager's cancel path (base.cancel -> atom + hook).
-335
View File
@@ -1,335 +0,0 @@
// 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/>.
//! C ABI contract tests (ffi.rs). One normal + one error path per
//! export family; the exhaustive matrix is driven from the existing
//! C++ gtest suite (`src/task/tests`, unchanged) running against this
//! crate — these tests only pin Rust-side specifics.
mod common;
use common::*;
use std::ffi::{c_char, c_int, c_void};
use std::sync::atomic::{AtomicI32, Ordering};
use oaktask::error::{OAKTASK_E_INVALID, OAKTASK_E_STATE, OAKTASK_OK};
use oaktask::ffi::manager::{
oaktask_manager_at, oaktask_manager_count, oaktask_manager_delete_finished,
oaktask_manager_init, oaktask_manager_shutdown,
};
use oaktask::ffi::project::oaktask_create_project_save;
use oaktask::ffi::task::{
oaktask_debug_alive_count, oaktask_task_cancel, oaktask_task_error, oaktask_task_free,
oaktask_task_is_finished, oaktask_task_start, oaktask_task_start_sync, oaktask_task_subscribe,
oaktask_task_succeeded, oaktask_task_title, oaktask_task_wait,
};
use oaktask::handle::CHandle;
fn free_task(t: &mut CHandle) {
unsafe {
oaktask_task_free(t);
}
}
/// Every exported handle-returning function returns ctx==NULL on
/// failure and a valid refcounted handle on success (abi_version
/// stamped).
#[test]
fn handle_contract_all_exports() {
let _guard = MANAGER_LOCK.lock().unwrap();
// create_project_save with a null project -> empty.
let empty = unsafe { oaktask_create_project_save(CHandle::null(), std::ptr::null(), 0) };
assert!(empty.is_null());
// create_project_save with a valid project -> valid handle with the
// stamped ABI version.
*PROJECT_FILENAME.lock().unwrap() = "/tmp/oak-contract-save.oakproj".to_string();
let task = unsafe { oaktask_create_project_save(fake_handle(), std::ptr::null(), 1) };
assert!(!task.ctx.is_null());
assert_eq!(task.abi_version, 1);
// Title getter round-trip (two-stage).
let mut buf = [0i8; 512];
let needed = unsafe { oaktask_task_title(task, buf.as_mut_ptr(), buf.len() as c_int) };
assert!(needed > 0);
assert!(needed <= buf.len() as c_int);
// Exact-size query with a null buffer returns the same needed size.
let query = unsafe { oaktask_task_title(task, std::ptr::null_mut(), 0) };
assert_eq!(query, needed);
// The save title embeds the project filename.
assert!(needed > 10);
let mut t = task;
free_task(&mut t);
assert!(t.is_null());
}
/// free(NULL)/free(empty) are no-ops across every free export.
#[test]
fn free_null_noop_all_exports() {
unsafe {
oaktask_task_free(std::ptr::null_mut());
}
let mut empty = CHandle::null();
unsafe {
oaktask_task_free(&mut empty);
}
assert!(empty.ctx.is_null());
}
/// Two-stage string functions: size query, short buffer truncation
/// rule, and exact-fit write — for every string getter
/// (task_title, task_error, import_invalid_at).
#[test]
fn two_stage_string_contract() {
let _guard = MANAGER_LOCK.lock().unwrap();
*PROJECT_FILENAME.lock().unwrap() = "/tmp/oak-two-stage.oakproj".to_string();
let task = unsafe { oaktask_create_project_save(fake_handle(), std::ptr::null(), 0) };
assert!(!task.ctx.is_null());
// Null buffer -> needed size only.
let needed = unsafe { oaktask_task_title(task, std::ptr::null_mut(), 0) };
assert!(needed > 0);
// Too-small buffer -> needed size, no write.
let mut small = [0i8; 4];
let r = unsafe { oaktask_task_title(task, small.as_mut_ptr(), small.len() as c_int) };
assert_eq!(r, needed);
// C++ only writes when the buffer fits; a short buffer stays untouched.
assert_ne!(small[0], b'S' as i8);
// Exact-fit write.
let mut buf = vec![0i8; needed as usize];
let r = unsafe { oaktask_task_title(task, buf.as_mut_ptr(), needed) };
assert_eq!(r, needed);
assert_eq!(buf[0], b'S' as i8);
assert_eq!(buf[needed as usize - 1], 0);
// Error getter on a fresh task reports the default.
let err_needed = unsafe { oaktask_task_error(task, std::ptr::null_mut(), 0) };
assert!(err_needed > 0);
// Error-path: empty handle -> E_INVALID.
assert_eq!(
unsafe { oaktask_task_title(CHandle::null(), std::ptr::null_mut(), 0) },
OAKTASK_E_INVALID
);
assert_eq!(
unsafe { oaktask_task_error(CHandle::null(), std::ptr::null_mut(), 0) },
OAKTASK_E_INVALID
);
let mut t = task;
free_task(&mut t);
}
/// Task manager singleton: init → create/start → wait → delete_finished
/// round-trips; alive-count accounting returns to baseline.
#[test]
fn identity_registry_roundtrip() {
let _guard = MANAGER_LOCK.lock().unwrap();
reset_stubs();
unsafe { oaktask_manager_shutdown() }; // ensure clean slate
assert_eq!(unsafe { oaktask_manager_init() }, OAKTASK_OK);
assert_eq!(unsafe { oaktask_manager_count() }, 0);
// Async start of a save task; the worker writes the file.
*PROJECT_FILENAME.lock().unwrap() = "/tmp/oak-identity-roundtrip.oakproj".to_string();
let _ = std::fs::remove_file("/tmp/oak-identity-roundtrip.oakproj");
let mut task = unsafe { oaktask_create_project_save(fake_handle(), std::ptr::null(), 0) };
assert_eq!(unsafe { oaktask_task_start(task) }, OAKTASK_OK);
assert_eq!(unsafe { oaktask_task_start(task) }, OAKTASK_E_STATE); // already running
assert_eq!(unsafe { oaktask_task_wait(task) }, OAKTASK_OK);
assert_eq!(unsafe { oaktask_task_is_finished(task) }, 1);
assert_eq!(unsafe { oaktask_task_succeeded(task) }, 1);
assert!(std::path::Path::new("/tmp/oak-identity-roundtrip.oakproj").exists());
unsafe { oaktask_manager_delete_finished() };
assert_eq!(unsafe { oaktask_manager_count() }, 0);
free_task(&mut task);
assert_eq!(unsafe { oaktask_debug_alive_count() }, 0);
unsafe { oaktask_manager_shutdown() };
}
/// alive count: task create/destroy moves oaktask_debug_alive_count
/// predictably and returns to baseline.
#[test]
fn alive_count_accounting() {
let _guard = MANAGER_LOCK.lock().unwrap();
reset_stubs();
unsafe { oaktask_manager_shutdown() };
let baseline = unsafe { oaktask_debug_alive_count() };
assert_eq!(baseline, 0);
*PROJECT_FILENAME.lock().unwrap() = "/tmp/oak-alive-count.oakproj".to_string();
let mut t1 = unsafe { oaktask_create_project_save(fake_handle(), std::ptr::null(), 0) };
let mut t2 = unsafe { oaktask_create_project_save(fake_handle(), std::ptr::null(), 0) };
assert_eq!(unsafe { oaktask_debug_alive_count() }, baseline + 2);
free_task(&mut t1);
assert_eq!(unsafe { oaktask_debug_alive_count() }, baseline + 1);
free_task(&mut t2);
assert_eq!(unsafe { oaktask_debug_alive_count() }, baseline);
}
/// subscribe + async start: the callback observes STARTED (with a start
/// time), then FINISHED (1.0 on success); the subscription is one-shot.
#[test]
fn subscribe_records_started_and_finished() {
let _guard = MANAGER_LOCK.lock().unwrap();
reset_stubs();
unsafe { oaktask_manager_shutdown() };
unsafe { oaktask_manager_init() };
static STARTED: AtomicI32 = AtomicI32::new(0);
static FINISHED: AtomicI32 = AtomicI32::new(0);
static FINISHED_VALUE: AtomicI32 = AtomicI32::new(-1);
unsafe extern "C" fn recorder(event_id: c_int, value: f64, _ud: *mut c_void) {
match event_id {
0 => {
STARTED.fetch_add(1, Ordering::SeqCst);
assert!(value > 0.0); // start time in ms
}
2 => {
FINISHED.fetch_add(1, Ordering::SeqCst);
FINISHED_VALUE.store(value as i32, Ordering::SeqCst);
}
_ => {}
}
}
*PROJECT_FILENAME.lock().unwrap() = "/tmp/oak-subscribe.oakproj".to_string();
let mut task = unsafe { oaktask_create_project_save(fake_handle(), std::ptr::null(), 0) };
// Null callback -> E_INVALID.
assert_eq!(
unsafe { oaktask_task_subscribe(task, None, std::ptr::null_mut()) },
OAKTASK_E_INVALID as i64
);
// Empty handle -> E_INVALID.
assert_eq!(
unsafe { oaktask_task_subscribe(CHandle::null(), Some(recorder), std::ptr::null_mut()) },
OAKTASK_E_INVALID as i64
);
assert_eq!(
unsafe { oaktask_task_subscribe(task, Some(recorder), std::ptr::null_mut()) },
0
);
assert_eq!(unsafe { oaktask_task_start(task) }, OAKTASK_OK);
assert_eq!(unsafe { oaktask_task_wait(task) }, OAKTASK_OK);
assert_eq!(STARTED.load(Ordering::SeqCst), 1);
assert_eq!(FINISHED.load(Ordering::SeqCst), 1);
assert_eq!(FINISHED_VALUE.load(Ordering::SeqCst), 1);
assert_eq!(unsafe { oaktask_task_is_finished(task) }, 1);
unsafe { oaktask_manager_delete_finished() };
free_task(&mut task);
assert_eq!(unsafe { oaktask_debug_alive_count() }, 0);
unsafe { oaktask_manager_shutdown() };
}
/// start_sync on a task with a failing serializer reports failure (0) with
/// an error message, and the error getter returns it (two-stage).
#[test]
fn start_sync_failure_paths() {
let _guard = MANAGER_LOCK.lock().unwrap();
*PROJECT_FILENAME.lock().unwrap() = "".to_string(); // no filename
let mut task = unsafe { oaktask_create_project_save(fake_handle(), std::ptr::null(), 0) };
assert_eq!(unsafe { oaktask_task_start_sync(task) }, 0);
assert_eq!(unsafe { oaktask_task_is_finished(task) }, 1);
assert_eq!(unsafe { oaktask_task_succeeded(task) }, 0);
let needed = unsafe { oaktask_task_error(task, std::ptr::null_mut(), 0) };
assert!(needed > 0);
let mut buf = vec![0i8; needed as usize];
unsafe { oaktask_task_error(task, buf.as_mut_ptr(), needed) };
let msg = cstr_read(&buf);
assert!(msg.contains("no filename"), "error was: {msg}");
// Null handle -> E_INVALID.
assert_eq!(
unsafe { oaktask_task_start_sync(CHandle::null()) },
OAKTASK_E_INVALID
);
assert_eq!(
unsafe { oaktask_task_cancel(CHandle::null()) },
OAKTASK_E_INVALID
);
assert_eq!(
unsafe { oaktask_task_wait(CHandle::null()) },
OAKTASK_E_INVALID
);
assert_eq!(
unsafe { oaktask_task_is_finished(CHandle::null()) },
OAKTASK_E_INVALID
);
assert_eq!(
unsafe { oaktask_task_succeeded(CHandle::null()) },
OAKTASK_E_INVALID
);
free_task(&mut task);
assert_eq!(unsafe { oaktask_debug_alive_count() }, 0);
}
/// cancel on a fresh task is a no-op success; is_finished stays 0.
#[test]
fn cancel_is_noop_before_start() {
let _guard = MANAGER_LOCK.lock().unwrap();
*PROJECT_FILENAME.lock().unwrap() = "/tmp/oak-cancel.oakproj".to_string();
let mut task = unsafe { oaktask_create_project_save(fake_handle(), std::ptr::null(), 0) };
assert_eq!(unsafe { oaktask_task_cancel(task) }, OAKTASK_OK);
assert_eq!(unsafe { oaktask_task_is_finished(task) }, 0);
free_task(&mut task);
assert_eq!(unsafe { oaktask_debug_alive_count() }, 0);
}
/// start without an initialized manager -> E_STATE.
#[test]
fn start_without_manager_is_state_error() {
let _guard = MANAGER_LOCK.lock().unwrap();
reset_stubs();
unsafe { oaktask_manager_shutdown() };
*PROJECT_FILENAME.lock().unwrap() = "/tmp/oak-no-manager.oakproj".to_string();
let mut task = unsafe { oaktask_create_project_save(fake_handle(), std::ptr::null(), 0) };
assert_eq!(unsafe { oaktask_task_start(task) }, OAKTASK_E_STATE);
// Manager accessors also report E_STATE / empty without a manager.
assert_eq!(unsafe { oaktask_manager_count() }, OAKTASK_E_STATE);
assert!(unsafe { oaktask_manager_at(0) }.is_null());
unsafe { oaktask_manager_delete_finished() }; // no-op
// start_sync still works without a manager.
assert_eq!(unsafe { oaktask_task_start_sync(task) }, 1);
free_task(&mut task);
assert_eq!(unsafe { oaktask_debug_alive_count() }, 0);
}
/// cstr_read helper: read a NUL-terminated c_char buffer.
fn cstr_read(buf: &[i8]) -> String {
let len = buf.iter().position(|&c| c == 0).unwrap_or(buf.len());
let bytes = unsafe { std::slice::from_raw_parts(buf.as_ptr() as *const u8, len) };
String::from_utf8_lossy(bytes).into_owned()
}
-378
View File
@@ -1,378 +0,0 @@
// 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/>.
//! TaskManager singleton lifecycle tests (manager.rs + ffi/manager.rs).
mod common;
use common::*;
use std::sync::atomic::Ordering;
use oaktask::error::{OAKTASK_E_STATE, OAKTASK_OK};
use oaktask::ffi::manager::{
oaktask_manager_at, oaktask_manager_count, oaktask_manager_delete_finished,
oaktask_manager_init, oaktask_manager_shutdown, oaktask_register_codec_submitter,
};
use oaktask::ffi::project::oaktask_create_project_save;
use oaktask::ffi::task::{
oaktask_debug_alive_count, oaktask_task_free, oaktask_task_is_finished, oaktask_task_start,
oaktask_task_wait,
};
use oaktask::handle::CHandle;
/// Given an uninitialized manager, `oaktask_manager_count` reports 0
/// and `oaktask_manager_at` returns an empty handle.
#[test]
fn uninitialized_manager_is_empty() {
let _guard = MANAGER_LOCK.lock().unwrap();
reset_stubs();
unsafe { oaktask_manager_shutdown() };
assert_eq!(unsafe { oaktask_manager_count() }, OAKTASK_E_STATE);
assert!(unsafe { oaktask_manager_at(0) }.is_null());
assert!(unsafe { oaktask_manager_at(-1) }.is_null());
assert_eq!(unsafe { oaktask_debug_alive_count() }, 0);
}
/// Given an initialized manager, starting a task adds it to the list
/// and `oaktask_manager_at` returns a borrowed handle at that index.
#[test]
fn started_task_is_listed_at_index() {
let _guard = MANAGER_LOCK.lock().unwrap();
reset_stubs();
unsafe { oaktask_manager_shutdown() };
unsafe { oaktask_manager_init() };
*PROJECT_FILENAME.lock().unwrap() = "/tmp/oak-manager-at.oakproj".to_string();
let mut task = unsafe { oaktask_create_project_save(fake_handle(), std::ptr::null(), 0) };
assert_eq!(unsafe { oaktask_task_start(task) }, OAKTASK_OK);
assert_eq!(unsafe { oaktask_manager_count() }, 1);
// The borrowed handle reports the same task once it finishes.
assert_eq!(unsafe { oaktask_task_wait(task) }, OAKTASK_OK);
let borrowed = unsafe { oaktask_manager_at(0) };
assert!(!borrowed.ctx.is_null());
assert_eq!(unsafe { oaktask_task_is_finished(borrowed) }, 1);
let mut b = borrowed;
unsafe { oaktask_task_free(&mut b) };
// Out of range -> empty.
assert!(unsafe { oaktask_manager_at(5) }.is_null());
unsafe { oaktask_manager_delete_finished() };
assert_eq!(unsafe { oaktask_manager_count() }, 0);
free(&mut task);
assert_eq!(unsafe { oaktask_debug_alive_count() }, 0);
unsafe { oaktask_manager_shutdown() };
}
/// Given finished tasks, `oaktask_manager_delete_finished` removes them
/// and `oaktask_manager_count` shrinks accordingly.
#[test]
fn delete_finished_removes_finished_tasks() {
let _guard = MANAGER_LOCK.lock().unwrap();
reset_stubs();
unsafe { oaktask_manager_shutdown() };
unsafe { oaktask_manager_init() };
*PROJECT_FILENAME.lock().unwrap() = "/tmp/oak-delete-finished.oakproj".to_string();
let mut t1 = unsafe { oaktask_create_project_save(fake_handle(), std::ptr::null(), 0) };
let mut t2 = unsafe { oaktask_create_project_save(fake_handle(), std::ptr::null(), 0) };
assert_eq!(unsafe { oaktask_task_start(t1) }, OAKTASK_OK);
assert_eq!(unsafe { oaktask_task_start(t2) }, OAKTASK_OK);
assert_eq!(unsafe { oaktask_manager_count() }, 2);
// Wait for both to finish, then delete_finished removes both.
unsafe { oaktask_task_wait(t1) };
unsafe { oaktask_task_wait(t2) };
unsafe { oaktask_manager_delete_finished() };
assert_eq!(unsafe { oaktask_manager_count() }, 0);
free(&mut t1);
free(&mut t2);
assert_eq!(unsafe { oaktask_debug_alive_count() }, 0);
unsafe { oaktask_manager_shutdown() };
}
/// Given a registered codec submitter, codec tasks are routed through
/// the manager; `oaktask_register_codec_submitter` is idempotent.
#[test]
fn codec_submitter_registration_is_idempotent() {
let _guard = MANAGER_LOCK.lock().unwrap();
reset_stubs();
unsafe { oaktask_manager_shutdown() };
// The submitter can be registered without a manager (it is a codec-side
// callback, not a manager task).
assert_eq!(unsafe { oaktask_register_codec_submitter() }, OAKTASK_OK);
assert_eq!(unsafe { oaktask_register_codec_submitter() }, OAKTASK_OK);
unsafe { oaktask_manager_init() };
// Manager init registers too; repeated registration stays OK.
assert_eq!(unsafe { oaktask_register_codec_submitter() }, OAKTASK_OK);
// The stub captured the callback.
assert!(SUBMIT_CB.lock().unwrap().is_some());
unsafe { oaktask_manager_shutdown() };
}
/// Given a shutdown manager, subsequent start/at calls are safe and
/// report no tasks without panicking.
#[test]
fn shutdown_is_safe_and_idempotent() {
let _guard = MANAGER_LOCK.lock().unwrap();
reset_stubs();
unsafe { oaktask_manager_shutdown() };
unsafe { oaktask_manager_init() };
unsafe { oaktask_manager_shutdown() };
// Idempotent.
unsafe { oaktask_manager_shutdown() };
// After shutdown, start/at are safe (E_STATE / empty).
assert_eq!(unsafe { oaktask_manager_count() }, OAKTASK_E_STATE);
assert!(unsafe { oaktask_manager_at(0) }.is_null());
unsafe { oaktask_manager_delete_finished() };
assert_eq!(unsafe { oaktask_debug_alive_count() }, 0);
// Re-init works after shutdown.
assert_eq!(unsafe { oaktask_manager_init() }, OAKTASK_OK);
unsafe { oaktask_manager_shutdown() };
}
/// A conform request submitted through the codec callback runs the
/// ConformTask synchronously (interim contract).
#[test]
fn codec_submit_runs_conform_synchronously() {
let _guard = MANAGER_LOCK.lock().unwrap();
reset_stubs();
unsafe { oaktask_manager_shutdown() };
assert_eq!(unsafe { oaktask_register_codec_submitter() }, OAKTASK_OK);
// Prepare real working files so the conform rename succeeds.
let dir = std::env::temp_dir().join("oak-conform-submit");
let _ = std::fs::create_dir_all(&dir);
let final0 = dir.join("out.0.pcm");
let final1 = dir.join("out.1.pcm");
let _ = std::fs::remove_file(&final0);
let _ = std::fs::remove_file(&final1);
let working0 = dir.join("out.0.pcm.working");
let working1 = dir.join("out.1.pcm.working");
std::fs::write(&working0, b"pcm").unwrap();
std::fs::write(&working1, b"pcm").unwrap();
DECODER_OPEN_RESULT.store(0, Ordering::SeqCst);
DECODER_CONFORM_RESULT.store(0, Ordering::SeqCst);
let request = oaktask::bridge::codec::OakCodecTaskRequest {
kind: 0, // OAKCODEC_TASK_CONFORM
input_filename: common::cstr_of("/tmp/oak-conform-src.mov"),
output_filename: common::cstr_of(&final0.to_string_lossy()),
stream_index: 1,
sample_rate: 48000,
channel_layout: 3, // stereo
sample_format: 0,
proxy_width: 0,
proxy_height: 0,
};
let cb = SUBMIT_CB.lock().unwrap().unwrap();
let ret = unsafe { cb(&request, std::ptr::null_mut()) };
assert_eq!(ret, 0); // OAKCODEC_OK
assert!(final0.exists(), "final pcm file was not moved into place");
assert!(
final1.exists(),
"second channel pcm file was not moved into place"
);
let _ = std::fs::remove_file(&final0);
let _ = std::fs::remove_file(&final1);
let _ = std::fs::remove_file(&working0);
let _ = std::fs::remove_file(&working1);
// Null request -> OAKCODEC_E_INVALID.
let ret = unsafe { cb(std::ptr::null(), std::ptr::null_mut()) };
assert_eq!(ret, oaktask::bridge::codec::OAKCODEC_E_INVALID);
unsafe { oaktask_manager_shutdown() };
}
/// A proxy request submitted through the codec callback runs ffmpeg (a real
/// test script), parses progress lines, and renames the working file into
/// place.
#[test]
fn codec_submit_runs_proxy_with_script() {
let _guard = MANAGER_LOCK.lock().unwrap();
reset_stubs();
unsafe { oaktask_manager_shutdown() };
assert_eq!(unsafe { oaktask_register_codec_submitter() }, OAKTASK_OK);
let dir = std::env::temp_dir().join("oak-proxy-run");
let _ = std::fs::create_dir_all(&dir);
let script = dir.join("fake-ffmpeg.sh");
let output = dir.join("proxy.mp4");
let _ = std::fs::remove_file(&output);
// The script reports progress on stdout and creates its last argument
// (the working file), then exits 0.
std::fs::write(
&script,
"#!/bin/bash\nprintf 'out_time_us=5000000\\n'\nprintf 'out_time_us=9000000\\n'\nOUT=\"${@: -1}\"\ntouch \"$OUT\"\nexit 0\n",
)
.unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
}
*PROXY_FFMPEG_PATH.lock().unwrap() = script.to_string_lossy().into_owned();
let request = oaktask::bridge::codec::OakCodecTaskRequest {
kind: 1, // OAKCODEC_TASK_PROXY
input_filename: common::cstr_of("/tmp/oak-proxy-src.mov"),
output_filename: common::cstr_of(&output.to_string_lossy()),
stream_index: 0,
sample_rate: 0,
channel_layout: 0,
sample_format: 0,
proxy_width: 0,
proxy_height: 0,
};
let cb = SUBMIT_CB.lock().unwrap().unwrap();
let ret = unsafe { cb(&request, std::ptr::null_mut()) };
assert_eq!(ret, 0, "proxy submit should succeed");
assert!(output.exists(), "proxy output file was not created");
let _ = std::fs::remove_file(&output);
let _ = std::fs::remove_file(&script);
unsafe { oaktask_manager_shutdown() };
}
/// Proxy failure paths: ffmpeg missing, failing script, and a script that
/// does not produce the output.
#[test]
fn proxy_failure_paths() {
let _guard = MANAGER_LOCK.lock().unwrap();
reset_stubs();
unsafe { oaktask_manager_shutdown() };
assert_eq!(unsafe { oaktask_register_codec_submitter() }, OAKTASK_OK);
let dir = std::env::temp_dir().join("oak-proxy-fail");
let _ = std::fs::create_dir_all(&dir);
let output = dir.join("proxy.mp4");
let cb = SUBMIT_CB.lock().unwrap().unwrap();
let request = oaktask::bridge::codec::OakCodecTaskRequest {
kind: 1,
input_filename: common::cstr_of("/tmp/oak-proxy-src.mov"),
output_filename: common::cstr_of(&output.to_string_lossy()),
stream_index: 0,
sample_rate: 0,
channel_layout: 0,
sample_format: 0,
proxy_width: 0,
proxy_height: 0,
};
// 1. No ffmpeg found.
PROXY_FFMPEG_PATH.lock().unwrap().clear();
let ret = unsafe { cb(&request, std::ptr::null_mut()) };
assert_eq!(ret, oaktask::bridge::codec::OAKCODEC_E_FAILED);
// 2. ffmpeg exits non-zero.
let script = dir.join("fail-ffmpeg.sh");
std::fs::write(&script, "#!/bin/bash\nexit 1\n").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
}
*PROXY_FFMPEG_PATH.lock().unwrap() = script.to_string_lossy().into_owned();
let ret = unsafe { cb(&request, std::ptr::null_mut()) };
assert_eq!(ret, oaktask::bridge::codec::OAKCODEC_E_FAILED);
// 3. ffmpeg exits 0 but creates nothing.
let script2 = dir.join("noout-ffmpeg.sh");
std::fs::write(&script2, "#!/bin/bash\nexit 0\n").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&script2, std::fs::Permissions::from_mode(0o755)).unwrap();
}
*PROXY_FFMPEG_PATH.lock().unwrap() = script2.to_string_lossy().into_owned();
let ret = unsafe { cb(&request, std::ptr::null_mut()) };
assert_eq!(ret, oaktask::bridge::codec::OAKCODEC_E_FAILED);
let _ = std::fs::remove_file(&output);
let _ = std::fs::remove_file(&script);
let _ = std::fs::remove_file(&script2);
unsafe { oaktask_manager_shutdown() };
}
/// Conform failure paths: decoder open fails and conform returns a
/// non-OK code.
#[test]
fn conform_failure_paths() {
let _guard = MANAGER_LOCK.lock().unwrap();
reset_stubs();
unsafe { oaktask_manager_shutdown() };
assert_eq!(unsafe { oaktask_register_codec_submitter() }, OAKTASK_OK);
let dir = std::env::temp_dir().join("oak-conform-fail");
let _ = std::fs::create_dir_all(&dir);
let final0 = dir.join("out.0.pcm");
let working0 = dir.join("out.0.pcm.working");
let cb = SUBMIT_CB.lock().unwrap().unwrap();
let request = oaktask::bridge::codec::OakCodecTaskRequest {
kind: 0,
input_filename: common::cstr_of("/tmp/oak-conform-src.mov"),
output_filename: common::cstr_of(&final0.to_string_lossy()),
stream_index: 1,
sample_rate: 48000,
channel_layout: 3,
sample_format: 0,
proxy_width: 0,
proxy_height: 0,
};
// Decoder open fails.
DECODER_OPEN_RESULT.store(-1, Ordering::SeqCst);
let ret = unsafe { cb(&request, std::ptr::null_mut()) };
assert_eq!(ret, oaktask::bridge::codec::OAKCODEC_E_FAILED);
// Conform reports a failure.
DECODER_OPEN_RESULT.store(0, Ordering::SeqCst);
DECODER_CONFORM_RESULT.store(-50003, Ordering::SeqCst);
std::fs::write(&working0, b"pcm").unwrap();
let ret = unsafe { cb(&request, std::ptr::null_mut()) };
assert_eq!(ret, oaktask::bridge::codec::OAKCODEC_E_FAILED);
// The partial working file is cleaned up.
assert!(!working0.exists(), "partial working file should be removed");
// Conform reports cancellation.
DECODER_CONFORM_RESULT.store(-50006, Ordering::SeqCst);
std::fs::write(&working0, b"pcm").unwrap();
let ret = unsafe { cb(&request, std::ptr::null_mut()) };
assert_eq!(ret, oaktask::bridge::codec::OAKCODEC_E_FAILED);
let _ = std::fs::remove_file(&working0);
unsafe { oaktask_manager_shutdown() };
}
fn free(t: &mut CHandle) {
unsafe {
oaktask_task_free(t);
}
}
File diff suppressed because it is too large Load Diff
-615
View File
@@ -1,615 +0,0 @@
// 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 task factory tests (project.rs + ffi/project.rs): load, save,
//! import, OTIO load/save and pre-cache/export factories.
mod common;
use common::*;
use std::ffi::{c_char, c_int, c_void};
use std::sync::atomic::Ordering;
use oaktask::error::{OAKTASK_E_INVALID, OAKTASK_E_NOT_FOUND};
use oaktask::ffi::manager::{oaktask_manager_init, oaktask_manager_shutdown};
use oaktask::ffi::project::{
oaktask_create_export, oaktask_create_precache, oaktask_create_project_import,
oaktask_create_project_load, oaktask_create_project_load_otio, oaktask_create_project_save,
oaktask_create_project_save_otio, oaktask_import_footage_at, oaktask_import_footage_count,
oaktask_import_invalid_at, oaktask_import_invalid_count,
oaktask_import_set_image_sequence_confirm_cb, oaktask_import_take_command,
oaktask_load_otio_set_confirm_cb, oaktask_load_otio_take_project, oaktask_load_take_project,
};
use oaktask::ffi::task::{
oaktask_debug_alive_count, oaktask_task_error, oaktask_task_free, oaktask_task_is_finished,
oaktask_task_start_sync, oaktask_task_title,
};
use oaktask::handle::CHandle;
unsafe extern "C" fn accept_all(
_seq: *const *const c_char,
_count: c_int,
_ud: *mut c_void,
) -> c_int {
1
}
unsafe extern "C" fn reject_all(
_seq: *const *const c_char,
_count: c_int,
_ud: *mut c_void,
) -> c_int {
0
}
unsafe extern "C" fn image_seq_confirm(_filename: *const c_char, _ud: *mut c_void) -> c_int {
1
}
fn free(t: &mut CHandle) {
unsafe {
oaktask_task_free(t);
}
}
/// Given a valid project file, `create_project_load` returns a task whose
/// `start_sync` succeeds and whose `load_take_project` transfers an
/// initialized project with a root folder.
#[test]
fn project_load_transfers_initialized_project() {
let _guard = MANAGER_LOCK.lock().unwrap();
reset_stubs();
let path = std::env::temp_dir().join("oak-load-ok.oakproj");
std::fs::write(&path, b"oakproj-stub").unwrap();
let path_str = path.to_string_lossy().into_owned();
let mut task = unsafe { oaktask_create_project_load(common::cstr_of(&path_str)) };
assert!(!task.ctx.is_null());
// Title embeds the filename.
let needed = unsafe { oaktask_task_title(task, std::ptr::null_mut(), 0) };
assert!(needed > 10);
assert_eq!(unsafe { oaktask_task_start_sync(task) }, 1);
assert_eq!(unsafe { oaktask_task_is_finished(task) }, 1);
// take_project transfers a non-empty project.
let project = unsafe { oaktask_load_take_project(task) };
assert!(!project.ctx.is_null(), "load_take_project returned empty");
// Second take is empty (ownership transferred).
let project2 = unsafe { oaktask_load_take_project(task) };
assert!(project2.ctx.is_null());
free(&mut task);
assert_eq!(unsafe { oaktask_debug_alive_count() }, 0);
let _ = std::fs::remove_file(&path);
}
/// Loading a missing file fails: start_sync=0, an error is set, and
/// take_project is empty.
#[test]
fn project_load_missing_file_fails() {
let _guard = MANAGER_LOCK.lock().unwrap();
reset_stubs();
let missing = "/tmp/oak-does-not-exist-12345.oakproj";
let _ = std::fs::remove_file(missing);
let mut task = unsafe { oaktask_create_project_load(common::cstr_of(missing)) };
assert!(!task.ctx.is_null());
assert_eq!(unsafe { oaktask_task_start_sync(task) }, 0);
assert_eq!(unsafe { oaktask_task_is_finished(task) }, 1);
let needed = unsafe { oaktask_task_error(task, std::ptr::null_mut(), 0) };
assert!(needed > 0);
let mut buf = vec![0i8; needed as usize];
unsafe { oaktask_task_error(task, buf.as_mut_ptr(), needed) };
assert!(cstr_read(&buf).contains("Failed to read file"));
let project = unsafe { oaktask_load_take_project(task) };
assert!(project.ctx.is_null());
// Null filename -> empty handle (factory failure path).
let empty = unsafe { oaktask_create_project_load(std::ptr::null()) };
assert!(empty.ctx.is_null());
free(&mut task);
assert_eq!(unsafe { oaktask_debug_alive_count() }, 0);
}
/// Given a loaded project, `create_project_save` writes the project and the
/// task succeeds with compression on and off.
#[test]
fn project_save_writes_with_and_without_compression() {
let _guard = MANAGER_LOCK.lock().unwrap();
reset_stubs();
let path = std::env::temp_dir().join("oak-save-compressed.oakproj");
let path_str = path.to_string_lossy().into_owned();
let _ = std::fs::remove_file(&path);
// Without compression.
let mut task =
unsafe { oaktask_create_project_save(fake_handle(), common::cstr_of(&path_str), 0) };
assert!(!task.ctx.is_null());
assert_eq!(unsafe { oaktask_task_start_sync(task) }, 1);
assert!(path.exists(), "saved file was not written");
free(&mut task);
// With compression (overwrites).
let _ = std::fs::remove_file(&path);
let mut task =
unsafe { oaktask_create_project_save(fake_handle(), common::cstr_of(&path_str), 1) };
assert!(!task.ctx.is_null());
assert_eq!(unsafe { oaktask_task_start_sync(task) }, 1);
assert!(path.exists());
free(&mut task);
// Null project -> empty handle.
let empty =
unsafe { oaktask_create_project_save(CHandle::null(), common::cstr_of(&path_str), 0) };
assert!(empty.ctx.is_null());
let _ = std::fs::remove_file(&path);
assert_eq!(unsafe { oaktask_debug_alive_count() }, 0);
}
/// Given an import folder and a media URL, `create_project_import` runs to
/// completion, records one footage and exposes it via `import_footage_at`.
#[test]
fn project_import_records_and_exposes_footage() {
let _guard = MANAGER_LOCK.lock().unwrap();
reset_stubs();
let file = std::env::temp_dir().join("oak-import-ok.mp4");
std::fs::write(&file, b"media").unwrap();
let file_str = file.to_string_lossy().into_owned();
let urls = [common::cstr_of(&file_str)];
let mut task = unsafe {
oaktask_create_project_import(
fake_handle(),
fake_handle(),
urls.as_ptr(),
urls.len() as c_int,
)
};
assert!(!task.ctx.is_null());
assert_eq!(unsafe { oaktask_task_start_sync(task) }, 1);
assert_eq!(unsafe { oaktask_import_footage_count(task) }, 1);
assert_eq!(unsafe { oaktask_import_invalid_count(task) }, 0);
// footage_at returns an addref'd handle for in-range indices.
let footage = unsafe { oaktask_import_footage_at(task, 0) };
assert!(!footage.ctx.is_null());
// Out of range -> empty.
assert!(unsafe { oaktask_import_footage_at(task, 5) }.ctx.is_null());
assert!(unsafe { oaktask_import_footage_at(task, -1) }.ctx.is_null());
// The undo command is produced; redo adds the folder child, undo
// removes it again.
let mut command = unsafe { oaktask_import_take_command(task) };
assert!(!command.ctx.is_null());
// Second take is empty.
assert!(unsafe { oaktask_import_take_command(task) }.ctx.is_null());
assert_eq!(
unsafe { oaktask::bridge::undo::oakundo_command_redo_now(command) },
0
);
assert_eq!(
unsafe { oaktask::bridge::node::oaknode_folder_child_count(fake_handle()) },
1
);
assert_eq!(
unsafe { oaktask::bridge::undo::oakundo_command_undo_now(command) },
0
);
assert_eq!(
unsafe { oaktask::bridge::node::oaknode_folder_child_count(fake_handle()) },
0
);
unsafe {
oaktask::bridge::undo::oakundo_command_free(&mut command);
}
free(&mut task);
assert_eq!(unsafe { oaktask_debug_alive_count() }, 0);
let _ = std::fs::remove_file(&file);
}
/// Given an unreadable import URL, `create_project_import` records the
/// invalid filename and `import_invalid_at` reports it (two-stage).
#[test]
fn project_import_reports_invalid_urls() {
let _guard = MANAGER_LOCK.lock().unwrap();
reset_stubs();
FOOTAGE_VALID.store(0, Ordering::SeqCst);
let bad = "/tmp/oak-invalid-media-12345.mp4";
let _ = std::fs::remove_file(bad);
let urls = [common::cstr_of(bad)];
let mut task = unsafe {
oaktask_create_project_import(
fake_handle(),
fake_handle(),
urls.as_ptr(),
urls.len() as c_int,
)
};
assert!(!task.ctx.is_null());
assert_eq!(unsafe { oaktask_task_start_sync(task) }, 1);
assert_eq!(unsafe { oaktask_import_invalid_count(task) }, 1);
assert_eq!(unsafe { oaktask_import_footage_count(task) }, 0);
// Two-stage invalid filename getter.
let needed = unsafe { oaktask_import_invalid_at(task, 0, std::ptr::null_mut(), 0) };
assert!(needed > 0);
let mut buf = vec![0i8; needed as usize];
let r = unsafe { oaktask_import_invalid_at(task, 0, buf.as_mut_ptr(), needed) };
assert_eq!(r, needed);
assert_eq!(cstr_read(&buf), bad);
// Out of range -> E_NOT_FOUND.
assert_eq!(
unsafe { oaktask_import_invalid_at(task, 3, std::ptr::null_mut(), 0) },
OAKTASK_E_NOT_FOUND
);
// Factory failure paths: null folder, null project, negative count.
assert!(unsafe {
oaktask_create_project_import(CHandle::null(), fake_handle(), std::ptr::null(), 0)
}
.ctx
.is_null());
assert!(unsafe {
oaktask_create_project_import(fake_handle(), CHandle::null(), std::ptr::null(), 0)
}
.ctx
.is_null());
let urls_null = [std::ptr::null()];
assert!(unsafe {
oaktask_create_project_import(fake_handle(), fake_handle(), urls_null.as_ptr(), 1)
}
.ctx
.is_null());
// Accessors on a non-import task -> E_INVALID / empty.
let mut save_task =
unsafe { oaktask_create_project_save(fake_handle(), common::cstr_of("/tmp/x.oakproj"), 0) };
assert_eq!(
unsafe { oaktask_import_footage_count(save_task) },
OAKTASK_E_INVALID
);
assert!(unsafe { oaktask_import_take_command(save_task) }
.ctx
.is_null());
free(&mut save_task);
free(&mut task);
assert_eq!(unsafe { oaktask_debug_alive_count() }, 0);
}
/// An image sequence with a confirmation callback imports once and prunes
/// the remaining frames from the list.
#[test]
fn project_import_image_sequence_confirmed() {
let _guard = MANAGER_LOCK.lock().unwrap();
reset_stubs();
let dir = std::env::temp_dir().join("oak-seq");
let _ = std::fs::create_dir_all(&dir);
let files = ["seq001.png", "seq002.png", "seq003.png"];
let mut paths = Vec::new();
for f in &files {
let p = dir.join(f);
std::fs::write(&p, b"png").unwrap();
paths.push(p.to_string_lossy().into_owned());
}
IMAGE_SEQUENCE_DIGIT_COUNT.store(3, Ordering::SeqCst);
CONFIG_DEFAULT_SEQ_FRAME_RATE.store(1, Ordering::SeqCst);
unsafe {
oaktask_import_set_image_sequence_confirm_cb(Some(image_seq_confirm), std::ptr::null_mut())
};
let urls: Vec<*const c_char> = paths.iter().map(|p| common::cstr_of(p)).collect();
let mut task = unsafe {
oaktask_create_project_import(
fake_handle(),
fake_handle(),
urls.as_ptr(),
urls.len() as c_int,
)
};
assert!(!task.ctx.is_null());
assert_eq!(unsafe { oaktask_task_start_sync(task) }, 1);
// The three frames collapse into a single footage import.
assert_eq!(unsafe { oaktask_import_footage_count(task) }, 1);
assert_eq!(unsafe { oaktask_import_invalid_count(task) }, 0);
unsafe { oaktask_import_set_image_sequence_confirm_cb(None, std::ptr::null_mut()) };
IMAGE_SEQUENCE_DIGIT_COUNT.store(0, Ordering::SeqCst);
free(&mut task);
for p in &paths {
let _ = std::fs::remove_file(p);
}
assert_eq!(unsafe { oaktask_debug_alive_count() }, 0);
}
/// Given a valid OTIO file, `create_project_load_otio` runs to completion and
/// `load_otio_take_project` transfers an initialized project; a missing file
/// fails cleanly. (OTIO parsing now happens inside the task through the
/// `oakotio` binding — README decision #6.)
#[test]
fn otio_load_transfers_project_with_default_confirm() {
let _guard = MANAGER_LOCK.lock().unwrap();
reset_stubs();
// A minimal Timeline document (no tracks).
let path = std::env::temp_dir().join("oak-load-ok.otio");
let otio = r#"{
"OTIO_SCHEMA": "Timeline.1",
"metadata": {},
"name": "T",
"global_start_time": null,
"tracks": {
"OTIO_SCHEMA": "Stack.1",
"metadata": {},
"name": "tracks",
"source_range": null,
"effects": [],
"markers": [],
"enabled": true,
"children": []
}
}"#;
std::fs::write(&path, otio).unwrap();
let path_str = path.to_string_lossy().into_owned();
let mut task = unsafe { oaktask_create_project_load_otio(common::cstr_of(&path_str)) };
assert!(!task.ctx.is_null());
assert_eq!(unsafe { oaktask_task_start_sync(task) }, 1);
assert_eq!(unsafe { oaktask_task_is_finished(task) }, 1);
assert!(!(unsafe { oaktask_load_otio_take_project(task) })
.ctx
.is_null());
free(&mut task);
// Missing file -> failure with a clear error.
let missing = std::env::temp_dir().join("oak-missing-otio.otio");
let missing_str = missing.to_string_lossy().into_owned();
let _ = std::fs::remove_file(&missing);
let mut task = unsafe { oaktask_create_project_load_otio(common::cstr_of(&missing_str)) };
assert!(!task.ctx.is_null());
assert_eq!(unsafe { oaktask_task_start_sync(task) }, 0);
let needed = unsafe { oaktask_task_error(task, std::ptr::null_mut(), 0) };
assert!(needed > 0);
let mut buf = vec![0i8; needed as usize];
unsafe { oaktask_task_error(task, buf.as_mut_ptr(), needed) };
assert!(
cstr_read(&buf).contains("Failed to load OpenTimelineIO"),
"error was: {}",
cstr_read(&buf)
);
assert!(unsafe { oaktask_load_otio_take_project(task) }
.ctx
.is_null());
// The confirm callback setters are no-ops (null clears).
unsafe {
oaktask_load_otio_set_confirm_cb(Some(accept_all), std::ptr::null_mut());
oaktask_load_otio_set_confirm_cb(Some(reject_all), std::ptr::null_mut());
oaktask_load_otio_set_confirm_cb(None, std::ptr::null_mut());
}
free(&mut task);
let _ = std::fs::remove_file(&path);
assert_eq!(unsafe { oaktask_debug_alive_count() }, 0);
}
/// Given a project with a sequence, `create_project_save_otio` writes the
/// OTIO file and the task succeeds; a project without sequences fails.
#[test]
fn otio_save_writes_file() {
let _guard = MANAGER_LOCK.lock().unwrap();
reset_stubs();
// One sequence in the root folder, one video track with a single clip.
FOLDER_CHILD_COUNT.store(1, Ordering::SeqCst);
*NODE_ID.lock().unwrap() = "org.olivevideoeditor.Olive.sequence".to_string();
*NODE_LABEL.lock().unwrap() = "My Sequence".to_string();
VIDEO_FRAME_RATE_NUM.store(25, Ordering::SeqCst);
VIDEO_FRAME_RATE_DEN.store(1, Ordering::SeqCst);
TRACK_LIST_TRACK_COUNT.store(1, Ordering::SeqCst);
TRACK_BLOCK_COUNT.store(1, Ordering::SeqCst);
BLOCK_KIND.store(1, Ordering::SeqCst); // OAKNODE_BLOCK_CLIP
BLOCK_IN_NUM.store(0, Ordering::SeqCst);
BLOCK_IN_DEN.store(1, Ordering::SeqCst);
BLOCK_LENGTH_NUM.store(25, Ordering::SeqCst);
BLOCK_LENGTH_DEN.store(1, Ordering::SeqCst);
*FOOTAGE_FILENAME.lock().unwrap() = "file:///tmp/video.mp4".to_string();
let out = std::env::temp_dir().join("oak-otio-out.otio");
let out_str = out.to_string_lossy().into_owned();
let _ = std::fs::remove_file(&out);
let mut task =
unsafe { oaktask_create_project_save_otio(fake_handle(), common::cstr_of(&out_str)) };
assert!(!task.ctx.is_null());
assert_eq!(unsafe { oaktask_task_start_sync(task) }, 1);
assert!(out.exists(), "OTIO file was not written");
// Failure paths: null project or null filename.
assert!(unsafe {
oaktask_create_project_save_otio(CHandle::null(), common::cstr_of("/tmp/x.otio"))
}
.ctx
.is_null());
assert!(
unsafe { oaktask_create_project_save_otio(fake_handle(), std::ptr::null()) }
.ctx
.is_null()
);
free(&mut task);
let _ = std::fs::remove_file(&out);
assert_eq!(unsafe { oaktask_debug_alive_count() }, 0);
}
/// Given a footage/sequence pair, `create_precache` runs to completion and
/// succeeds.
#[test]
fn precache_runs_and_succeeds() {
let _guard = MANAGER_LOCK.lock().unwrap();
reset_stubs();
FOOTAGE_FILENAME.lock().unwrap().clear();
let mut task = unsafe { oaktask_create_precache(fake_handle(), 0, fake_handle()) };
assert!(!task.ctx.is_null());
let needed = unsafe { oaktask_task_title(task, std::ptr::null_mut(), 0) };
assert!(needed > 0);
assert_eq!(unsafe { oaktask_task_start_sync(task) }, 1);
assert_eq!(unsafe { oaktask_task_is_finished(task) }, 1);
// Failure path: null footage/sequence.
assert!(
unsafe { oaktask_create_precache(CHandle::null(), 0, fake_handle()) }
.ctx
.is_null()
);
assert!(
unsafe { oaktask_create_precache(fake_handle(), 0, CHandle::null()) }
.ctx
.is_null()
);
free(&mut task);
assert_eq!(unsafe { oaktask_debug_alive_count() }, 0);
}
/// create_export runs the full render+encoder path against the stubs and
/// succeeds; the encoder-failure path fails cleanly.
#[test]
fn export_runs_with_stubbed_encoder() {
let _guard = MANAGER_LOCK.lock().unwrap();
reset_stubs();
*PROJECT_FILENAME.lock().unwrap() = "/tmp/oak-export-test.mp4".to_string();
NODE_LABEL.lock().unwrap().clear();
let mut params = oaktask::bridge::codec::OakCodecEncodingParams {
filename: [0; 1024],
format: 0,
video_enabled: 1,
video_codec: 0,
video_width: 1920,
video_height: 1080,
video_time_base_num: 1,
video_time_base_den: 25,
video_pixel_format: 0,
video_interlacing: 0,
video_pixel_aspect_num: 1,
video_pixel_aspect_den: 1,
video_bit_rate: 0,
video_min_bit_rate: 0,
video_max_bit_rate: 0,
video_buffer_size: 0,
video_threads: 0,
video_pix_fmt: [0; 64],
video_is_image_sequence: 0,
video_scaling_method: 0,
audio_enabled: 0,
audio_codec: 0,
audio_sample_rate: 0,
audio_channel_layout: 0,
audio_sample_format: 0,
audio_bit_rate: 0,
subtitles_enabled: 0,
subtitles_codec: 0,
subtitles_are_sidecar: 0,
subtitles_sidecar_format: 0,
color_transform_output: [0; 256],
export_length_num: 0,
export_length_den: 0,
has_custom_range: 0,
custom_range_in_num: 0,
custom_range_in_den: 0,
custom_range_out_num: 0,
custom_range_out_den: 0,
};
let filename = std::env::temp_dir()
.join("oak-export-test.mp4")
.to_string_lossy()
.into_owned();
write_cstr(&filename, params.filename.as_mut_ptr(), 1024);
ENCODER_INIT_NULL.store(0, Ordering::SeqCst);
ENCODER_OPEN_RESULT.store(0, Ordering::SeqCst);
ENCODER_WRITE_VIDEO_RESULT.store(0, Ordering::SeqCst);
SEQUENCE_LENGTH_NUM.store(25, Ordering::SeqCst);
SEQUENCE_LENGTH_DEN.store(1, Ordering::SeqCst);
VIDEO_TIME_BASE_NUM.store(1, Ordering::SeqCst);
VIDEO_TIME_BASE_DEN.store(1, Ordering::SeqCst); // 1s frames -> 25 frames
let mut task = unsafe { oaktask_create_export(fake_handle(), fake_handle(), &params) };
assert!(!task.ctx.is_null());
assert_eq!(unsafe { oaktask_task_start_sync(task) }, 1);
assert_eq!(unsafe { oaktask_task_is_finished(task) }, 1);
free(&mut task);
// Failure path: encoder init fails.
ENCODER_INIT_NULL.store(1, Ordering::SeqCst);
let mut task = unsafe { oaktask_create_export(fake_handle(), fake_handle(), &params) };
assert_eq!(unsafe { oaktask_task_start_sync(task) }, 0);
let needed = unsafe { oaktask_task_error(task, std::ptr::null_mut(), 0) };
assert!(needed > 0);
let mut buf = vec![0i8; needed as usize];
unsafe { oaktask_task_error(task, buf.as_mut_ptr(), needed) };
assert!(cstr_read(&buf).contains("Failed to create encoder"));
free(&mut task);
// Failure path: null viewer / null params.
assert!(
unsafe { oaktask_create_export(CHandle::null(), fake_handle(), &params) }
.ctx
.is_null()
);
assert!(
unsafe { oaktask_create_export(fake_handle(), fake_handle(), std::ptr::null()) }
.ctx
.is_null()
);
reset_stubs();
assert_eq!(unsafe { oaktask_debug_alive_count() }, 0);
}
/// The manager is needed for nothing in these factories; a fresh
/// init/shutdown cycle leaves the alive count at zero.
#[test]
fn manager_cycle_leaves_no_leaks() {
let _guard = MANAGER_LOCK.lock().unwrap();
reset_stubs();
unsafe { oaktask_manager_shutdown() };
assert_eq!(unsafe { oaktask_manager_init() }, 0);
unsafe { oaktask_manager_shutdown() };
assert_eq!(unsafe { oaktask_debug_alive_count() }, 0);
}
/// cstr_read helper: read a NUL-terminated c_char buffer.
fn cstr_read(buf: &[i8]) -> String {
let len = buf.iter().position(|&c| c == 0).unwrap_or(buf.len());
let bytes = unsafe { std::slice::from_raw_parts(buf.as_ptr() as *const u8, len) };
String::from_utf8_lossy(bytes).into_owned()
}
-338
View File
@@ -1,338 +0,0 @@
// 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/>.
//! Concurrency contract of the [`RenderTask`] render loop (src/render.rs):
//! up to `max_inflight` oakrender tickets run concurrently, results reach
//! the behavior hooks in timestamp order through the reorder buffer (even
//! when tickets complete out of order), cancellation drains in-flight
//! tickets (their completions still fire exactly once), progress is
//! monotonic, and a hook error stops dispatch immediately.
//!
//! The link-time stubs in `common` simulate the oakrender ticket arena;
//! with `TICKET_DEFER = 1` tickets stay in flight until the test fires
//! them with `stub_complete`, so completion order is fully scripted. Frame
//! handles encode the submitting ticket id (0x10000 + id), so the tests
//! can assert the exact delivery order of frames.
#[path = "common/mod.rs"]
mod common;
use std::sync::atomic::Ordering;
use std::sync::{Arc, Mutex};
use common::*;
use oakcore_rs::{Rational, TimeRange};
use oaktask::error::{Error, Result, OAKTASK_E_CANCELLED, OAKTASK_E_FAILED};
use oaktask::handle::CHandle;
use oaktask::render::{ForceParams, RenderTask, RenderTaskBehavior};
use oaktask::task::{Task, TaskEvent};
/// Poison-tolerant serialization (the shared stub registry is process-wide).
fn lock() -> std::sync::MutexGuard<'static, ()> {
MANAGER_LOCK.lock().unwrap_or_else(|e| e.into_inner())
}
/// Frame-handle ctx base (see common: STUB_FRAME_CTX_BASE).
const FRAME_CTX_BASE: usize = 0x10000;
/// A RenderTask rendering `frames` 1-second frames (timebase 1/1) from
/// 0, optionally with audio enabled for the whole range.
fn render_task(frames: i64, audio: bool) -> RenderTask {
VIDEO_TIME_BASE_NUM.store(1, Ordering::SeqCst);
VIDEO_TIME_BASE_DEN.store(1, Ordering::SeqCst);
let range = TimeRange::new(Rational::new(0, 1), Rational::new(frames, 1));
let base = Task::new("render-loop-test", fake_atom());
let mut rt = RenderTask::new(
base,
CHandle::null(),
CHandle::null(),
fake_handle(),
ForceParams::default(),
None,
);
rt.set_render_inputs(fake_handle(), CHandle::null(), 0, audio, range);
rt
}
/// Records the hook calls in delivery order; `fail_on_frame` (0-based
/// index) makes that frame's `frame_downloaded` return an error.
#[derive(Default)]
struct RecordingBehavior {
/// ctx values of delivered frames, in delivery order (0x10000 + id).
frames: Vec<usize>,
/// number of `audio_downloaded` calls.
audio_calls: usize,
/// fail the frame at this delivery index, if set.
fail_on_frame: Option<usize>,
}
impl RenderTaskBehavior for RecordingBehavior {
fn frame_downloaded(&mut self, _task: &mut Task, frame: CHandle) -> Result<()> {
if let Some(i) = self.fail_on_frame {
if self.frames.len() == i {
return Err(Error::Failed("stub encoder write failure".to_string()));
}
}
self.frames.push(frame.ctx as usize);
Ok(())
}
fn audio_downloaded(&mut self, _task: &mut Task, _buffer: CHandle) -> Result<()> {
self.audio_calls += 1;
Ok(())
}
fn encode_subtitle(&mut self, _task: &mut Task, _text: &str) -> Result<()> {
Ok(())
}
}
/// Drive `render` on a spawned thread. The base task lives inside the
/// render task; it is moved out first so the test can keep its cancel atom.
fn run_render(
mut rt: RenderTask,
behavior: RecordingBehavior,
) -> std::thread::JoinHandle<(Result<()>, RecordingBehavior)> {
std::thread::spawn(move || {
let mut base = std::mem::replace(&mut rt.base, Task::new("", CHandle::null()));
let mut behavior = behavior;
let result = rt.render(&mut base, &mut behavior);
(result, behavior)
})
}
/// Frames complete in scrambled order; the hooks still see them in
/// timestamp order, and every completion fires exactly once.
#[test]
fn render_delivers_frames_in_order_under_scrambled_completion() {
let _g = lock();
reset_stubs();
TICKET_DEFER.store(1, Ordering::SeqCst);
let mut rt = render_task(5, false);
rt.set_max_inflight(8); // window >= frame count: all tickets submitted upfront
let handle = run_render(rt, RecordingBehavior::default());
stub_wait_submitted(5);
for id in [2usize, 0, 1, 4, 3] {
stub_complete(id);
}
let (result, behavior) = handle.join().unwrap();
assert!(result.is_ok());
assert_eq!(
behavior.frames,
vec![
FRAME_CTX_BASE,
FRAME_CTX_BASE + 1,
FRAME_CTX_BASE + 2,
FRAME_CTX_BASE + 3,
FRAME_CTX_BASE + 4
],
"frames must be delivered in timestamp order despite scrambled completion"
);
assert_eq!(
stub_completed_count(),
5,
"each ticket completes exactly once"
);
}
/// The audio ticket completes last of all; it is still delivered before any
/// frame (the observable audio-first contract).
#[test]
fn render_delivers_audio_before_frames_under_scrambled_completion() {
let _g = lock();
reset_stubs();
TICKET_DEFER.store(1, Ordering::SeqCst);
let mut rt = render_task(3, true);
rt.set_max_inflight(4); // audio + 3 frames, all submitted upfront
let handle = run_render(rt, RecordingBehavior::default());
stub_wait_submitted(4);
// Frames 2,3 and 1 complete before the audio ticket (id 0).
for id in [2usize, 3, 1, 0] {
stub_complete(id);
}
let (result, behavior) = handle.join().unwrap();
assert!(result.is_ok());
assert_eq!(behavior.audio_calls, 1, "audio delivered exactly once");
assert_eq!(
behavior.frames,
vec![FRAME_CTX_BASE + 1, FRAME_CTX_BASE + 2, FRAME_CTX_BASE + 3],
"audio first, then frames in timestamp order"
);
assert_eq!(stub_completed_count(), 4);
}
/// Cancellation between frames: the loop aborts, every in-flight ticket is
/// cancelled+waited, and each of their completions still fires exactly once.
#[test]
fn render_cancel_drains_inflight_and_fires_their_completions() {
let _g = lock();
reset_stubs();
TICKET_DEFER.store(1, Ordering::SeqCst);
let mut rt = render_task(5, false);
rt.set_max_inflight(2);
let atom = rt.base.get_cancel_atom();
let handle = run_render(rt, RecordingBehavior::default());
// Frame 0 completes normally; the loop delivers it and tops up ticket 2.
stub_wait_submitted(2);
stub_complete(0);
stub_wait_submitted(3);
// Cancel through the atom. The stub atom-cancel finishes the pending
// tickets (real cancellation propagates into in-flight renders), waking
// the loop; the loop then sees the cancellation and drains.
unsafe {
oaktask::bridge::render::oakrender_cancelatom_cancel(atom);
}
let (result, behavior) = handle.join().unwrap();
assert_eq!(result.unwrap_err().code(), OAKTASK_E_CANCELLED);
assert_eq!(
behavior.frames,
vec![FRAME_CTX_BASE],
"only frame 0 delivered before cancel"
);
assert_eq!(
stub_completed_count(),
3,
"every submitted ticket's completion fires (incl. on cancel)"
);
assert_eq!(
stub_submitted_count(),
3,
"no new tickets are dispatched after cancellation"
);
}
/// Progress events are non-decreasing and end at 1.0 (5 frames, 1s each).
#[test]
fn render_progress_is_monotonic_and_reaches_one() {
let _g = lock();
reset_stubs();
let mut rt = render_task(5, false);
rt.set_max_inflight(8);
let progress: Arc<Mutex<Vec<f64>>> = Default::default();
{
let progress = progress.clone();
rt.base.set_event_listener(Box::new(move |ev: TaskEvent| {
if let TaskEvent::Progress(v) = ev {
progress.lock().unwrap().push(v);
}
}));
}
let handle = run_render(rt, RecordingBehavior::default());
let (result, behavior) = handle.join().unwrap();
assert!(result.is_ok());
assert_eq!(behavior.frames.len(), 5);
let seen = progress.lock().unwrap().clone();
assert_eq!(seen.len(), 5, "one progress event per delivered frame");
assert!(
seen.windows(2).all(|w| w[0] <= w[1]),
"progress must be non-decreasing, got {seen:?}"
);
assert!(
(seen[seen.len() - 1] - 1.0).abs() < 1e-9,
"progress reaches 1.0"
);
}
/// A hook error mid-stream stops dispatching new tickets; the in-flight
/// tickets are drained and their completions fire.
#[test]
fn render_error_stops_dispatching_new_tickets() {
let _g = lock();
reset_stubs();
TICKET_DEFER.store(1, Ordering::SeqCst);
let mut behavior = RecordingBehavior::default();
behavior.fail_on_frame = Some(2); // the third delivered frame fails
let mut rt = render_task(10, false);
rt.set_max_inflight(2);
let handle = run_render(rt, behavior);
stub_wait_submitted(2);
stub_complete(0);
stub_wait_submitted(3); // frame 0 delivered, ticket 2 submitted
stub_complete(1);
stub_wait_submitted(4); // frame 1 delivered, ticket 3 submitted
stub_complete(2); // frame 2 fails in the hook
let (result, behavior) = handle.join().unwrap();
assert_eq!(result.unwrap_err().code(), OAKTASK_E_FAILED);
assert_eq!(
behavior.frames,
vec![FRAME_CTX_BASE, FRAME_CTX_BASE + 1],
"two frames delivered before the error"
);
assert_eq!(
stub_submitted_count(),
4,
"no ticket past the failing frame"
);
assert_eq!(
stub_completed_count(),
4,
"in-flight tickets are drained, completions fire"
);
}
/// Default stub mode (completions fire synchronously on submit): the loop
/// still windows the submissions and delivers in timestamp order.
#[test]
fn render_succeeds_with_immediate_completion_and_windowing() {
let _g = lock();
reset_stubs();
let mut rt = render_task(7, false);
rt.set_max_inflight(3);
let handle = run_render(rt, RecordingBehavior::default());
let (result, behavior) = handle.join().unwrap();
assert!(result.is_ok());
assert_eq!(behavior.frames.len(), 7);
assert_eq!(
behavior.frames,
(0..7).map(|i| FRAME_CTX_BASE + i).collect::<Vec<_>>(),
"frames delivered in timestamp order"
);
assert_eq!(stub_completed_count(), 7);
}
/// An empty export range renders nothing and succeeds.
#[test]
fn render_empty_range_succeeds_without_tickets() {
let _g = lock();
reset_stubs();
let mut rt = render_task(0, false);
rt.set_max_inflight(1);
let handle = run_render(rt, RecordingBehavior::default());
let (result, behavior) = handle.join().unwrap();
assert!(result.is_ok());
assert!(behavior.frames.is_empty());
assert_eq!(stub_submitted_count(), 0);
}
@@ -1,167 +0,0 @@
// 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/>.
//! Real-oakrender integration: drive oaktask's [`RenderTask`] render loop
//! against the **actual** oakrender ticket arena instead of the link-time
//! stubs of `tests/common/mod.rs`.
//!
//! The two crates' C ABIs are joined the way the app joins them: oaktask's
//! `bridge::render` `extern "C"` declarations resolve against oakrender's
//! `#[no_mangle]` exports (both rlibs linked into this one test binary),
//! the `oakrender_ticket_finished_fn` callback (the frozen 2-argument
//! `(ticket, userdata)` contract) fires on the arena's worker threads, and
//! the loop's reorder buffer delivers the frames to the hooks in timestamp
//! order. The render crate's CPU path is used (`eval::render_produced_frame`
//! generates F32 frames; no GPU is initialized).
//!
//! Only the render symbols are real; the oaknode/oakcodec/oakcommon/undo
//! sides still come from the `common` stubs (the render loop only touches
//! them for graph queries and timebase reads).
//!
//! Build / run (both rlibs in one binary — duplicate `#[no_mangle]`
//! symbols make this incompatible with the stub-based test binaries, so it
//! must be built with the feature and the `--test` filter):
//!
//! ```text
//! cargo test --features real-oakrender --test render_real_integration_test
//! ```
#![cfg(feature = "real-oakrender")]
#![allow(clippy::missing_safety_doc)]
#[path = "common/mod.rs"]
mod common;
use std::sync::atomic::Ordering;
use oakcore_rs::{Rational, TimeRange};
use oaktask::error::Result;
use oaktask::handle::CHandle;
use oaktask::render::{ForceParams, RenderTask, RenderTaskBehavior};
use oaktask::task::Task;
/// Records what the render loop delivers: each frame's timestamp (the
/// producer stamps `Frame::timestamp` with the ticket time) and size.
#[derive(Default)]
struct RecordingBehavior {
timestamps: Vec<Rational>,
sizes: Vec<(i32, i32)>,
}
impl RenderTaskBehavior for RecordingBehavior {
fn frame_downloaded(&mut self, _task: &mut Task, frame: CHandle) -> Result<()> {
assert!(!frame.is_null(), "real arena must hand out an owned frame");
// The frame handle boxes a `oakrender::texture::Frame` (created by
// `oakrender_ticket_get_frame`); read it through oakrender's own
// handle API to assert the real payload.
let rh = oakrender::handle::CHandle {
ctx: frame.ctx,
addref: frame.addref,
release: frame.release,
abi_version: frame.abi_version,
};
let f = unsafe { oakrender::handle::get::<oakrender::texture::Frame>(&rh) }
.expect("frame handle must box a real oakrender Frame");
self.timestamps.push(f.timestamp);
self.sizes.push((f.width, f.height));
Ok(())
}
fn audio_downloaded(&mut self, _task: &mut Task, _buffer: CHandle) -> Result<()> {
panic!("audio disabled for this test")
}
fn encode_subtitle(&mut self, _task: &mut Task, _text: &str) -> Result<()> {
Ok(())
}
}
/// Serializes against anything else touching the process-wide manager.
fn manager_guard() -> std::sync::MutexGuard<'static, ()> {
static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
LOCK.lock().unwrap_or_else(|e| e.into_inner())
}
/// The real-oakrender end-to-end run: 3 one-second frames (timebase 1/1)
/// through the task render loop into the arena, delivered in timestamp
/// order with the forced 64x64 size.
#[test]
fn render_loop_submits_real_tickets_and_delivers_in_timestamp_order() {
let _guard = manager_guard();
common::reset_stubs();
common::VIDEO_TIME_BASE_NUM.store(1, Ordering::SeqCst);
common::VIDEO_TIME_BASE_DEN.store(1, Ordering::SeqCst);
// Fresh real manager (CPU path; the ticket producer is eval-based, no
// GPU backend is created).
oakrender::manager::RenderManager::shutdown();
oakrender::manager::RenderManager::init().expect("real oakrender manager init");
let baseline = oakrender::handle::alive_count();
{
let range = TimeRange::new(Rational::new(0, 1), Rational::new(3, 1));
let base = Task::new("real-render-integration", CHandle::null());
let mut rt = RenderTask::new(
base,
common::fake_handle(), // borrowed video params (ctx checked only)
CHandle::null(), // no audio
common::fake_handle(), // viewer node (the node stub supplies the output)
ForceParams {
force_width: 64,
force_height: 64,
..Default::default()
},
None,
);
rt.set_render_inputs(CHandle::null(), CHandle::null(), 0, false, range);
rt.set_max_inflight(3);
let mut behavior = RecordingBehavior::default();
let mut task = std::mem::replace(&mut rt.base, Task::new("", CHandle::null()));
let result = rt.render(&mut task, &mut behavior);
drop(rt);
assert!(
result.is_ok(),
"render through the real arena must succeed: {result:?}"
);
assert_eq!(
behavior.timestamps,
vec![
Rational::new(0, 1),
Rational::new(1, 1),
Rational::new(2, 1),
],
"frames must arrive in timestamp order (reorder buffer over the real arena)"
);
assert_eq!(
behavior.sizes,
vec![(64, 64), (64, 64), (64, 64)],
"force params must reach the real producer"
);
// `task` owns the real cancel atom created by Task::new; dropping it
// releases the atom so the handle accounting below returns to
// baseline.
drop(task);
}
oakrender::manager::RenderManager::shutdown();
assert_eq!(
oakrender::handle::alive_count(),
baseline,
"every handle created by the run (tickets, frames, cancel atom) must be released"
);
}
-550
View File
@@ -1,550 +0,0 @@
// 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/>.
//! Run-path coverage: conform/proxy/export/render task bodies driven end to
//! end against the link-time stubs in `common` (codec-submit routing,
//! decoder/encoder knobs, a fake ffmpeg executable), plus the base
//! [`Task`] API (reset/elapsed/wait/cancel) and the subscribe event stream.
//!
//! These tests exercise the same paths the C++ gtest suite covers with the
//! real dylibs (`src/task/tests/task_test.cpp`); here the stubs make them
//! deterministic under plain `cargo test`.
#[path = "common/mod.rs"]
mod common;
use std::ffi::c_int;
use std::sync::atomic::Ordering;
use common::*;
use oaktask::bridge::codec::{
OakCodecTaskRequest, OAKCODEC_E_CANCELLED, OAKCODEC_E_FAILED, OAKCODEC_TASK_CONFORM,
OAKCODEC_TASK_PROXY,
};
use oaktask::error::Result;
use oaktask::ffi::manager::oaktask_manager_shutdown;
use oaktask::ffi::project::oaktask_create_export;
use oaktask::ffi::task::{oaktask_task_error, oaktask_task_free, oaktask_task_start_sync};
use oaktask::task::{Task, TaskBehavior, TaskEvent};
// ---------------------------------------------------------------------------
// Codec-submit routing (conform/proxy run bodies)
// ---------------------------------------------------------------------------
/// Build a codec task request and drive it through the registered submit
/// callback (the codec module's view of the task module).
/// Poison-tolerant serialization (a failing sibling test must not cascade).
fn lock() -> std::sync::MutexGuard<'static, ()> {
MANAGER_LOCK.lock().unwrap_or_else(|e| e.into_inner())
}
fn submit(request: &OakCodecTaskRequest) -> c_int {
let cb = SUBMIT_CB.lock().unwrap().clone();
match cb {
Some(f) => unsafe { f(request, std::ptr::null_mut()) },
None => OAKCODEC_E_FAILED,
}
}
/// Given a conform request with pre-existing working files, the conform task
/// transcodes (stub) and renames the working files into place — the submit
/// callback returns OAKCODEC_OK.
#[test]
fn conform_run_succeeds_via_codec_submit() {
let _g = lock();
reset_stubs();
unsafe { oaktask::ffi::manager::oaktask_register_codec_submitter() };
DECODER_OPEN_RESULT.store(0, Ordering::SeqCst);
DECODER_CONFORM_RESULT.store(0, Ordering::SeqCst);
let dir = std::env::temp_dir();
let out = dir.join("oak-conform.0.pcm");
let out_str = out.to_string_lossy().into_owned();
let working = [
dir.join("oak-conform.0.pcm.working"),
dir.join("oak-conform.1.pcm.working"),
];
for w in &working {
std::fs::write(w, b"pcm").unwrap();
}
let request = OakCodecTaskRequest {
kind: OAKCODEC_TASK_CONFORM,
input_filename: cstr_of("/nonexistent/input.mp4"),
output_filename: cstr_of(&out_str),
stream_index: 0,
sample_rate: 48000,
channel_layout: 0x3, // stereo -> 2 working files
sample_format: 4,
proxy_width: 0,
proxy_height: 0,
};
assert_eq!(submit(&request), 0, "conform submit must succeed");
// The working files were renamed into their final names.
assert!(out.exists(), "final pcm file must exist after rename");
assert!(!working[0].exists(), "working file must be renamed away");
assert!(!working[1].exists());
for w in &working {
let _ = std::fs::remove_file(w);
}
for f in [&out, &dir.join("oak-conform.1.pcm")] {
let _ = std::fs::remove_file(f);
}
unsafe { oaktask_manager_shutdown() };
}
/// Given a conform request whose decoder fails to open, the task reports the
/// decoder error and the submit callback returns OAKCODEC_E_FAILED.
#[test]
fn conform_run_fails_when_decoder_open_fails() {
let _g = lock();
reset_stubs();
unsafe { oaktask::ffi::manager::oaktask_register_codec_submitter() };
DECODER_OPEN_RESULT.store(-50003, Ordering::SeqCst);
let request = OakCodecTaskRequest {
kind: OAKCODEC_TASK_CONFORM,
input_filename: cstr_of("/nonexistent/input.mp4"),
output_filename: cstr_of("/nonexistent/audio.0.pcm"),
stream_index: 0,
sample_rate: 48000,
channel_layout: 0x3,
sample_format: 4,
proxy_width: 0,
proxy_height: 0,
};
assert_eq!(submit(&request), OAKCODEC_E_FAILED);
unsafe { oaktask_manager_shutdown() };
}
/// Given a conform request whose decode is cancelled, the task cleans up the
/// working files and fails (the cancelled error is surfaced through the
/// task's error string).
#[test]
fn conform_run_reports_cancelled_conform() {
let _g = lock();
reset_stubs();
unsafe { oaktask::ffi::manager::oaktask_register_codec_submitter() };
DECODER_OPEN_RESULT.store(0, Ordering::SeqCst);
DECODER_CONFORM_RESULT.store(OAKCODEC_E_CANCELLED, Ordering::SeqCst);
let dir = std::env::temp_dir();
let out = dir.join("oak-cancel.0.pcm");
let working = dir.join("oak-cancel.0.pcm.working");
std::fs::write(&working, b"pcm").unwrap();
let request = OakCodecTaskRequest {
kind: OAKCODEC_TASK_CONFORM,
input_filename: cstr_of("/nonexistent/input.mp4"),
output_filename: cstr_of(&out.to_string_lossy()),
stream_index: 0,
sample_rate: 48000,
channel_layout: 0x3,
sample_format: 4,
proxy_width: 0,
proxy_height: 0,
};
assert_eq!(submit(&request), OAKCODEC_E_FAILED);
// The cancelled conform removes its partial working files.
assert!(
!working.exists(),
"working files must be cleaned up on cancel"
);
assert!(!out.exists());
unsafe { oaktask_manager_shutdown() };
}
/// Write an executable fake-ffmpeg script that creates its last argument
/// (the working proxy file) and reports some progress.
fn write_fake_ffmpeg(path: &std::path::Path, body: &str) {
std::fs::write(path, body).unwrap();
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).unwrap();
}
/// Given a proxy request and a working fake ffmpeg, the proxy task spawns it,
/// parses progress, renames the working file into place and succeeds.
#[test]
fn proxy_run_succeeds_with_fake_ffmpeg() {
let _g = lock();
reset_stubs();
unsafe { oaktask::ffi::manager::oaktask_register_codec_submitter() };
let dir = std::env::temp_dir();
let script = dir.join("oak-fake-ffmpeg.sh");
write_fake_ffmpeg(
&script,
"#!/bin/sh\nfor a in \"$@\"; do :; done\necho \"out_time_us=5000000\"\ntouch \"$a\"\nexit 0\n",
);
*PROXY_FFMPEG_PATH.lock().unwrap() = script.to_string_lossy().into_owned();
let out = dir.join("oak-proxy-out.mp4");
let out_str = out.to_string_lossy().into_owned();
let working = dir.join("oak-proxy-out.mp4.working.mp4");
let _ = std::fs::remove_file(&out);
let _ = std::fs::remove_file(&working);
let request = OakCodecTaskRequest {
kind: OAKCODEC_TASK_PROXY,
input_filename: cstr_of("/nonexistent/source.mp4"),
output_filename: cstr_of(&out_str),
stream_index: 0,
sample_rate: 0,
channel_layout: 0,
sample_format: 0,
proxy_width: 0,
proxy_height: 0,
};
assert_eq!(submit(&request), 0, "proxy submit must succeed");
assert!(out.exists(), "proxy output must exist after rename");
assert!(!working.exists());
let _ = std::fs::remove_file(&out);
let _ = std::fs::remove_file(&script);
unsafe { oaktask_manager_shutdown() };
}
/// Given a proxy request whose ffmpeg exits non-zero, the task cleans up the
/// working file and fails.
#[test]
fn proxy_run_fails_when_ffmpeg_fails() {
let _g = lock();
reset_stubs();
unsafe { oaktask::ffi::manager::oaktask_register_codec_submitter() };
let dir = std::env::temp_dir();
let script = dir.join("oak-fake-ffmpeg-fail.sh");
write_fake_ffmpeg(&script, "#!/bin/sh\nexit 1\n");
*PROXY_FFMPEG_PATH.lock().unwrap() = script.to_string_lossy().into_owned();
let out = dir.join("oak-proxy-fail.mp4");
let request = OakCodecTaskRequest {
kind: OAKCODEC_TASK_PROXY,
input_filename: cstr_of("/nonexistent/source.mp4"),
output_filename: cstr_of(&out.to_string_lossy()),
stream_index: 0,
sample_rate: 0,
channel_layout: 0,
sample_format: 0,
proxy_width: 0,
proxy_height: 0,
};
assert_eq!(submit(&request), OAKCODEC_E_FAILED);
assert!(!out.exists());
let _ = std::fs::remove_file(&script);
unsafe { oaktask_manager_shutdown() };
}
/// Given no configured ffmpeg, the proxy task fails with the documented
/// "not found" error.
#[test]
fn proxy_run_fails_without_ffmpeg() {
let _g = lock();
reset_stubs();
unsafe { oaktask::ffi::manager::oaktask_register_codec_submitter() };
PROXY_FFMPEG_PATH.lock().unwrap().clear();
let request = OakCodecTaskRequest {
kind: OAKCODEC_TASK_PROXY,
input_filename: cstr_of("/nonexistent/source.mp4"),
output_filename: cstr_of("/nonexistent/out.mp4"),
stream_index: 0,
sample_rate: 0,
channel_layout: 0,
sample_format: 0,
proxy_width: 0,
proxy_height: 0,
};
assert_eq!(submit(&request), OAKCODEC_E_FAILED);
unsafe { oaktask_manager_shutdown() };
}
/// Given an unknown codec task kind, the submit callback rejects it with
/// OAKCODEC_E_INVALID.
#[test]
fn codec_submit_rejects_unknown_kind() {
let _g = lock();
reset_stubs();
unsafe { oaktask::ffi::manager::oaktask_register_codec_submitter() };
let request = OakCodecTaskRequest {
kind: 99,
input_filename: std::ptr::null(),
output_filename: std::ptr::null(),
stream_index: 0,
sample_rate: 0,
channel_layout: 0,
sample_format: 0,
proxy_width: 0,
proxy_height: 0,
};
assert_eq!(submit(&request), oaktask::bridge::codec::OAKCODEC_E_INVALID);
unsafe { oaktask_manager_shutdown() };
}
// ---------------------------------------------------------------------------
// Export run paths (render + encoder)
// ---------------------------------------------------------------------------
/// A base encoding-params POD with video enabled.
fn export_params(filename: &str) -> oaktask::bridge::codec::OakCodecEncodingParams {
let mut params = oaktask::bridge::codec::OakCodecEncodingParams {
filename: [0; 1024],
format: 0,
video_enabled: 1,
video_codec: 0,
video_width: 1280,
video_height: 720,
video_time_base_num: 1,
video_time_base_den: 25,
video_pixel_format: 0,
video_interlacing: 0,
video_pixel_aspect_num: 1,
video_pixel_aspect_den: 1,
video_bit_rate: 0,
video_min_bit_rate: 0,
video_max_bit_rate: 0,
video_buffer_size: 0,
video_threads: 0,
video_pix_fmt: [0; 64],
video_is_image_sequence: 0,
video_scaling_method: 0,
audio_enabled: 0,
audio_codec: 0,
audio_sample_rate: 0,
audio_channel_layout: 0,
audio_sample_format: 0,
audio_bit_rate: 0,
subtitles_enabled: 0,
subtitles_codec: 0,
subtitles_are_sidecar: 0,
subtitles_sidecar_format: 0,
color_transform_output: [0; 256],
export_length_num: 0,
export_length_den: 0,
has_custom_range: 0,
custom_range_in_num: 0,
custom_range_in_den: 0,
custom_range_out_num: 0,
custom_range_out_den: 0,
};
write_cstr(filename, params.filename.as_mut_ptr(), 1024);
params
}
/// The export task with audio enabled renders the audio ticket and succeeds;
/// the encoder receives the frames.
#[test]
fn export_runs_audio_path() {
let _g = lock();
reset_stubs();
let mut params = export_params("/tmp/oak-export-audio.mp4");
params.audio_enabled = 1;
SEQUENCE_LENGTH_NUM.store(10, Ordering::SeqCst);
SEQUENCE_LENGTH_DEN.store(1, Ordering::SeqCst);
VIDEO_TIME_BASE_NUM.store(1, Ordering::SeqCst);
VIDEO_TIME_BASE_DEN.store(1, Ordering::SeqCst); // 1s frames
let mut task = unsafe { oaktask_create_export(fake_handle(), fake_handle(), &params) };
assert!(!task.ctx.is_null());
assert_eq!(unsafe { oaktask_task_start_sync(task) }, 1);
unsafe { oaktask_task_free(&mut task) };
}
/// When the encoder cannot open the file, the export fails with the
/// documented error.
#[test]
fn export_fails_on_encoder_open() {
let _g = lock();
reset_stubs();
ENCODER_OPEN_RESULT.store(-50003, Ordering::SeqCst);
let params = export_params("/tmp/oak-export-open-fail.mp4");
let mut task = unsafe { oaktask_create_export(fake_handle(), fake_handle(), &params) };
assert_eq!(unsafe { oaktask_task_start_sync(task) }, 0);
let needed = unsafe { oaktask_task_error(task, std::ptr::null_mut(), 0) };
assert!(needed > 0);
unsafe { oaktask_task_free(&mut task) };
}
/// When the encoder rejects a video frame, the export aborts with the
/// encoder's error.
#[test]
fn export_fails_on_video_write() {
let _g = lock();
reset_stubs();
ENCODER_WRITE_VIDEO_RESULT.store(-50003, Ordering::SeqCst);
SEQUENCE_LENGTH_NUM.store(10, Ordering::SeqCst);
SEQUENCE_LENGTH_DEN.store(1, Ordering::SeqCst);
VIDEO_TIME_BASE_NUM.store(1, Ordering::SeqCst);
VIDEO_TIME_BASE_DEN.store(1, Ordering::SeqCst);
let params = export_params("/tmp/oak-export-write-fail.mp4");
let mut task = unsafe { oaktask_create_export(fake_handle(), fake_handle(), &params) };
assert_eq!(unsafe { oaktask_task_start_sync(task) }, 0);
unsafe { oaktask_task_free(&mut task) };
}
/// When the encoder flush surfaces an error, the export fails after the
/// render loop.
#[test]
fn export_fails_on_flush_error() {
let _g = lock();
reset_stubs();
ENCODER_FLUSH_ERROR.store(1, Ordering::SeqCst);
*ENCODER_LAST_ERROR.lock().unwrap() = "stub flush failure".to_string();
SEQUENCE_LENGTH_NUM.store(1, Ordering::SeqCst);
SEQUENCE_LENGTH_DEN.store(1, Ordering::SeqCst);
VIDEO_TIME_BASE_NUM.store(1, Ordering::SeqCst);
VIDEO_TIME_BASE_DEN.store(1, Ordering::SeqCst);
let params = export_params("/tmp/oak-export-flush-fail.mp4");
let mut task = unsafe { oaktask_create_export(fake_handle(), fake_handle(), &params) };
assert_eq!(unsafe { oaktask_task_start_sync(task) }, 0);
let mut err = [0i8; 256];
let needed = unsafe { oaktask_task_error(task, err.as_mut_ptr(), err.len() as i32) };
assert!(needed > 0);
unsafe { oaktask_task_free(&mut task) };
}
/// When the viewer has no connected output node, the first frame render fails
/// and the export aborts.
#[test]
fn export_fails_without_connected_output() {
let _g = lock();
reset_stubs();
CONNECTED_NODE.store(0, Ordering::SeqCst);
SEQUENCE_LENGTH_NUM.store(10, Ordering::SeqCst);
SEQUENCE_LENGTH_DEN.store(1, Ordering::SeqCst);
VIDEO_TIME_BASE_NUM.store(1, Ordering::SeqCst);
VIDEO_TIME_BASE_DEN.store(1, Ordering::SeqCst);
let params = export_params("/tmp/oak-export-no-output.mp4");
let mut task = unsafe { oaktask_create_export(fake_handle(), fake_handle(), &params) };
assert_eq!(unsafe { oaktask_task_start_sync(task) }, 0);
unsafe { oaktask_task_free(&mut task) };
}
/// When the render ticket hands out null frames repeatedly, the export
/// aborts after the null-frame streak limit (C++ `null_frame_streak_`).
#[test]
fn export_fails_on_null_frame_streak() {
let _g = lock();
reset_stubs();
TICKET_FRAME_VALID.store(0, Ordering::SeqCst);
SEQUENCE_LENGTH_NUM.store(30, Ordering::SeqCst);
SEQUENCE_LENGTH_DEN.store(1, Ordering::SeqCst);
VIDEO_TIME_BASE_NUM.store(1, Ordering::SeqCst);
VIDEO_TIME_BASE_DEN.store(1, Ordering::SeqCst);
let params = export_params("/tmp/oak-export-null-frames.mp4");
let mut task = unsafe { oaktask_create_export(fake_handle(), fake_handle(), &params) };
assert_eq!(unsafe { oaktask_task_start_sync(task) }, 0);
let mut err = [0i8; 256];
let needed = unsafe { oaktask_task_error(task, err.as_mut_ptr(), err.len() as i32) };
assert!(needed > 0);
unsafe { oaktask_task_free(&mut task) };
}
// ---------------------------------------------------------------------------
// Base Task API + subscribe event stream
// ---------------------------------------------------------------------------
/// A no-op behavior for direct `Task` API tests.
struct NoopBehavior;
impl TaskBehavior for NoopBehavior {
fn run(&mut self, _task: &mut Task) -> Result<()> {
Ok(())
}
}
/// The base task API: title/error/elapsed/reset/wait/cancel and the
/// lifecycle event stream (STARTED/PROGRESS/FINISHED).
#[test]
fn task_base_api_and_events() {
let _g = lock();
reset_stubs();
let mut task = Task::new("Base API", common::fake_atom());
task.set_title("Renamed");
assert_eq!(task.title(), "Renamed");
assert!(task.error().is_none());
assert!(!task.is_finished());
assert!(!task.succeeded());
assert!(task.elapsed().is_none());
// Progress is clamped to 0..1 and delivered to the listener.
let events: std::sync::Arc<std::sync::Mutex<Vec<TaskEvent>>> = Default::default();
let listener = {
let events = events.clone();
Box::new(move |ev: TaskEvent| events.lock().unwrap().push(ev))
};
task.set_event_listener(listener);
task.emit_progress(2.0);
task.set_behavior(Box::new(NoopBehavior));
let result = task.start();
assert!(result.is_ok());
assert!(task.is_finished());
assert!(task.succeeded());
assert!(task.elapsed().is_some());
// The listener saw PROGRESS (emitted before start), then STARTED and
// FINISHED from `start()`.
let seen = events.lock().unwrap().clone();
assert_eq!(seen.len(), 3, "PROGRESS + STARTED + FINISHED");
assert!(matches!(seen[0], TaskEvent::Progress(p) if p == 1.0));
assert!(matches!(seen[1], TaskEvent::Started));
assert!(matches!(seen[2], TaskEvent::Finished));
// Cancellation through the atom.
let atom = task.get_cancel_atom();
task.set_cancel_event(Box::new(|| {}));
task.cancel();
assert!(task.is_cancelled());
unsafe {
oaktask::bridge::render::oakrender_cancelatom_cancel(atom);
}
assert!(task.is_cancelled());
// reset clears the lifecycle state.
task.reset();
assert!(!task.is_finished());
assert!(!task.succeeded());
assert!(task.error().is_none());
assert!(task.elapsed().is_none());
// error reporting.
task.set_error("boom");
assert_eq!(task.error(), Some("boom"));
// wait_finished on a finished task returns immediately.
task.wait_finished();
drop(atom);
}
/// The error marker `Task::cancelled()` maps to OAKTASK_E_CANCELLED.
#[test]
fn cancelled_marker_maps_to_code() {
assert_eq!(
oaktask::task::cancelled().code(),
oaktask::error::OAKTASK_E_CANCELLED
);
}