- oakengine_sequence_move_clip implemented for real (oaktimeline TrackMoveBlockCommand; fixes the graph-ownership/gap-anchor/ripple trim bugs the stub was hiding); same-track via the frozen C ABI, cross-track supported by the module command - oaknode clip blocks now declare a tex_in texture input and set effect_input to it, so timeline clips can host effect chains; facade test covers effect insert/remove on a real clip - oakffmpeg-link: FFMPEG_DIR is now mandatory with a clear panic (a Homebrew upgrade left the system ffmpeg .pc pointing at a deleted dav1d Cellar path, breaking links); reads a git-ignored workspace .env for IDEs that cannot inject env vars (RustRover); links the C++ stdlib for C++ codec libs (svt-av1) - oakengine re-exports oaknode so tests share one crate instance; it_node uses the direct instance's value type where it calls the module FFI (the --workspace dev-dependency feature split builds oaknode twice)
379 lines
13 KiB
Rust
379 lines
13 KiB
Rust
// Oak Video Editor - Non-Linear Video Editor
|
|
// Copyright (C) 2026 Oak Team
|
|
//
|
|
// This program is free software: you can redistribute it and/or modify
|
|
// it under the terms of the GNU General Public License as published by
|
|
// the Free Software Foundation, either version 3 of the License, or
|
|
// (at your option) any later version.
|
|
//
|
|
// This program is distributed in the hope that it will be useful,
|
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
// GNU General Public License for more details.
|
|
//
|
|
// You should have received a copy of the GNU General Public License
|
|
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
|
|
//! 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);
|
|
}
|
|
}
|