refactor: workspace layout — crates/, app at root, legacy C++ removed

Single mechanical restructure commit:
- root Cargo.toml = oakapp bin + workspace; one cargo build produces
  oakapp, oak-cli, oak-worker, liboakengine.dylib
- app/rust/src -> src/ (app at repo root, no rust/ nesting)
- src/<mod>/rust -> crates/oak<mod>; src/oakcore-rs -> crates/oakcore;
  src/bindings/oakotio -> crates/oakotio; src/engine/rust ->
  crates/oakengine (keeps cdylib+staticlib+rlib)
- public C headers include/<mod>/ -> crates/oakengine/include/<mod>/
- OFX SDK headers vendored into crates/oakplugin/ofx/ (HostSupport gone)
- legacy deleted: old src/ C++ modules, engine/, core/, ffmpeg_bridge/,
  app/ (Qt), cli/worker C++, root CMakeLists, third_party/KDDockWidgets
  submodule, otio-install, all build-* output (~40GB)
- oakstorage kept but excluded from the workspace (skeleton w/ todos);
  gpui excluded (own workspace)
- verified: cargo build green, cargo test --workspace 1845/0
  (with the documented OCIO_RS_* env override for the homebrew OCIO)
This commit is contained in:
2026-08-10 20:24:25 +08:00
parent f8540e3892
commit 013a175707
4212 changed files with 8331 additions and 2274987 deletions
+448
View File
@@ -0,0 +1,448 @@
// Oak Video Editor - Non-Linear Video Editor
// Copyright (C) 2026 Oak Team
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//! The preview auto-cacher (C++ `PreviewAutoCacher`): watches cache
//! requests on the copied project and pre-renders invalidated ranges
//! in the background.
//!
//! Per the frozen cache ABI (M7 §2.2) no cache events cross the
//! boundary — the facade re-emits notifications — so this pass drives
//! the cacher explicitly: [`PreviewAutoCacher::on_cache_request`] is the
//! facade-facing entry that queues range-caching jobs; `single_frame` is
//! the one-shot preview request path. Job bookkeeping lives here; the
//! actual frame production is the arena's CPU producer.
use std::collections::HashSet;
use std::sync::{Arc, Mutex, MutexGuard};
use oakcore_rs::{Rational, TimeRange};
use crate::error::Result;
use crate::ticket::{TicketArena, TicketId, VideoTicketParams};
/// Progress/stop callbacks toward the facade (C++
/// set_cache_progress_callback / set_stop_cache_proxy_tasks_callback).
pub trait AutoCacheEvents: Send {
/// Cache progress 0.0..=1.0.
fn progress(&mut self, value: f64);
/// Ask proxy tasks to stop.
fn stop_proxy_tasks(&mut self);
}
fn lock<T>(m: &Mutex<T>) -> MutexGuard<'_, T> {
m.lock().unwrap_or_else(|e| e.into_inner())
}
/// The auto-cacher.
pub struct PreviewAutoCacher {
/// Identity of the copied project being cached.
pub copied_project: u64,
/// Custom range override (C++ force_cache_range).
pub custom_range: Option<TimeRange>,
/// Playhead position (C++ set_playhead).
pub playhead: Rational,
/// Pause toggles.
pub renders_paused: bool,
/// Thumbnail pause toggle.
pub thumbnails_paused: bool,
/// Multicam source node identity (C++ set_multicam_node).
pub multicam_node: Option<u64>,
/// Ignore cache requests (C++ set_ignore_cache_requests).
pub ignore_requests: bool,
/// Display color processor identity (C++ set_display_color_processor).
pub display_processor: Option<u64>,
/// The ticket arena the cacher submits through.
arena: Arc<TicketArena>,
/// Viewer identity for single-frame renders.
viewer_identity: Option<u64>,
/// In-flight job tickets.
jobs: Mutex<HashSet<TicketId>>,
/// Requested-but-not-yet-cached ranges per owner (internal bookkeeping).
pending: Mutex<Vec<(u64, TimeRange)>>,
/// The most recent single-frame ticket (cancelled before the next).
last_single_frame: Mutex<Option<TicketId>>,
/// Facade callbacks.
events: Mutex<Option<Box<dyn AutoCacheEvents>>>,
}
impl PreviewAutoCacher {
/// A cacher submitting through `arena`.
pub fn new(arena: Arc<TicketArena>) -> Self {
Self {
copied_project: 0,
custom_range: None,
playhead: Rational::NULL,
renders_paused: false,
thumbnails_paused: false,
multicam_node: None,
ignore_requests: false,
display_processor: None,
arena,
viewer_identity: None,
jobs: Mutex::new(HashSet::new()),
pending: Mutex::new(Vec::new()),
last_single_frame: Mutex::new(None),
events: Mutex::new(None),
}
}
/// Set the viewer identity used by [`PreviewAutoCacher::single_frame`]
/// (the facade passes the oaknode viewer handle identity).
pub fn set_viewer_identity(&mut self, identity: Option<u64>) {
self.viewer_identity = identity;
}
/// Attach to a copied project (registers on its caches).
pub fn attach(&mut self, copied_project: u64) -> Result<()> {
if copied_project == 0 {
return Err(crate::error::Error::Invalid);
}
self.copied_project = copied_project;
lock(&self.pending).clear();
Ok(())
}
/// Detach and cancel all pending cache jobs (C++
/// project_destroyed / disconnect_from_node_cache).
pub fn detach(&mut self) {
self.cancel_all_jobs();
self.copied_project = 0;
lock(&self.pending).clear();
lock(&self.last_single_frame).take();
}
/// A cache request arrived for `owner` (facade entry; C++ cache
/// `requested` handling). Queues a caching job unless paused or
/// ignored.
pub fn on_cache_request(&mut self, owner: u64, range: TimeRange) {
if self.ignore_requests || owner == 0 {
return;
}
lock(&self.pending).push((owner, range));
if self.renders_paused {
return;
}
self.start_range_job(owner, range);
}
/// Start a caching job for `range` of `owner`.
fn start_range_job(&mut self, owner: u64, range: TimeRange) {
let id = self.arena.submit_video(
VideoTicketParams {
viewer: owner,
time: range.in_(),
force_size: None,
force_format: None,
cache: Some(owner),
cache_dir: None,
cache_id: None,
cache_timebase: None,
},
Box::new(|_| {}),
);
lock(&self.jobs).insert(id);
}
/// One-off single-frame render (C++ get_single_frame): returns a
/// ticket; a queued-but-not-started previous single-frame render is
/// cancelled first.
pub fn single_frame(&mut self, time: Rational) -> TicketId {
self.single_frame_with_completion(time, Box::new(|_| {}))
}
/// [`PreviewAutoCacher::single_frame`] with a custom completion (the
/// FFI frame-request path delivers its callback here).
pub fn single_frame_with_completion(
&mut self,
time: Rational,
done: crate::ticket::Completion,
) -> TicketId {
if let Some(prev) = lock(&self.last_single_frame).take() {
self.arena.cancel(prev);
lock(&self.jobs).remove(&prev);
}
let viewer = self.viewer_identity.unwrap_or(self.copied_project);
let id = self.arena.submit_video(
VideoTicketParams {
viewer,
time,
force_size: None,
force_format: None,
cache: None,
cache_dir: None,
cache_id: None,
cache_timebase: None,
},
done,
);
*lock(&self.last_single_frame) = Some(id);
lock(&self.jobs).insert(id);
id
}
/// Clear completed single-frame renders (C++
/// clear_single_frame_renders_that_arent_running).
pub fn clear_finished_single_frames(&mut self) {
let mut jobs = lock(&self.jobs);
jobs.retain(|id| !self.arena.is_finished(*id));
}
/// Force-cache a range (C++ force_cache_range).
pub fn force_range(&mut self, range: TimeRange) {
self.custom_range = Some(range);
self.start_range_job(self.copied_project, range);
}
/// True while a custom range render is active.
pub fn is_rendering_custom_range(&self) -> bool {
if self.custom_range.is_none() {
return false;
}
let jobs = lock(&self.jobs);
jobs.iter().any(|id| !self.arena.is_finished(*id))
}
/// Cancel in-flight video tasks (C++ cancel_video_tasks). With
/// `wait`, blocks until every cancelled ticket finished.
pub fn cancel_video_tasks(&self, wait: bool) {
let ids: Vec<TicketId> = lock(&self.jobs).iter().copied().collect();
for id in &ids {
self.arena.cancel(*id);
}
if wait {
for id in &ids {
let _ = self.arena.wait(*id);
}
}
}
/// Cancel in-flight audio tasks (C++ cancel_audio_tasks). Audio
/// caching is not implemented in this pass (no audio jobs are ever
/// submitted); kept for the facade contract.
pub fn cancel_audio_tasks(&self, _wait: bool) {}
/// Display color processor for preview output (C++
/// set_display_color_processor).
pub fn set_display_color_processor(&mut self, processor: Option<u64>) {
self.display_processor = processor;
}
/// Event sink registration (facade).
pub fn set_events(&mut self, events: Box<dyn AutoCacheEvents>) {
*lock(&self.events) = Some(events);
}
/// Report progress to the facade (0.0..=1.0).
pub fn report_progress(&self, value: f64) {
if let Some(events) = lock(&self.events).as_mut() {
events.progress(value);
}
}
/// Ask proxy tasks to stop.
pub fn request_stop_proxy_tasks(&self) {
if let Some(events) = lock(&self.events).as_mut() {
events.stop_proxy_tasks();
}
}
fn cancel_all_jobs(&mut self) {
let ids: Vec<TicketId> = lock(&self.jobs).iter().copied().collect();
for id in &ids {
self.arena.cancel(*id);
}
lock(&self.jobs).clear();
}
/// Pending (not yet cached) request ranges — test introspection.
pub fn pending_requests(&self) -> Vec<(u64, TimeRange)> {
lock(&self.pending).clone()
}
/// Live job ticket ids — test introspection.
pub fn live_jobs(&self) -> Vec<TicketId> {
lock(&self.jobs).iter().copied().collect()
}
}
/// A no-op event sink (facade may pass one in tests).
pub struct NoopEvents;
impl AutoCacheEvents for NoopEvents {
fn progress(&mut self, _value: f64) {}
fn stop_proxy_tasks(&mut self) {}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicU32, Ordering};
use crate::frame::VideoParamsPod;
use crate::texture::{Frame, Texture};
use crate::worker::WorkerPool;
fn frame_producer() -> crate::ticket::Producer {
Arc::new(|_, _| {
let mut f = Frame::new();
let mut p = VideoParamsPod::default();
p.width = 4;
p.height = 4;
f.set_video_params(p);
f.allocate();
Ok(Texture::wrap_frame(f))
})
}
fn new_cacher() -> (PreviewAutoCacher, WorkerPool) {
let mut pool = WorkerPool::new(2);
pool.start();
let arena = Arc::new(TicketArena::new(pool.clone(), frame_producer()));
(PreviewAutoCacher::new(arena), pool)
}
#[test]
fn attach_detach_lifecycle() {
let (mut c, mut pool) = new_cacher();
assert!(c.attach(0).is_err(), "zero identity rejected");
c.attach(42).unwrap();
assert_eq!(c.copied_project, 42);
c.on_cache_request(42, TimeRange::new(Rational::new(0, 1), Rational::new(10, 1)));
assert_eq!(c.live_jobs().len(), 1);
c.detach();
assert_eq!(c.copied_project, 0);
assert!(c.live_jobs().is_empty());
pool.shutdown();
}
#[test]
fn single_frame_cancels_previous() {
let (mut c, mut pool) = new_cacher();
c.attach(7);
let first = c.single_frame(Rational::new(0, 1));
let second = c.single_frame(Rational::new(1, 1));
assert_ne!(first, second);
// The first ticket is cancelled: its completion fired with State.
c.arena.wait(first).unwrap();
let r = c.arena.result(first).unwrap();
assert!(r.is_err());
pool.shutdown();
}
#[test]
fn ignore_requests_suppresses_jobs() {
let (mut c, mut pool) = new_cacher();
c.attach(7);
c.ignore_requests = true;
c.on_cache_request(7, TimeRange::new(Rational::new(0, 1), Rational::new(5, 1)));
assert!(c.live_jobs().is_empty());
pool.shutdown();
}
#[test]
fn renders_paused_queues_but_does_not_start() {
let (mut c, mut pool) = new_cacher();
c.attach(7);
c.renders_paused = true;
c.on_cache_request(7, TimeRange::new(Rational::new(0, 1), Rational::new(5, 1)));
assert!(c.live_jobs().is_empty(), "paused: no jobs started");
assert_eq!(c.pending_requests().len(), 1);
pool.shutdown();
}
#[test]
fn cancel_video_tasks_wait_blocks_until_idle() {
let (mut c, mut pool) = new_cacher();
c.attach(7);
c.force_range(TimeRange::new(Rational::new(0, 1), Rational::new(1, 1)));
assert!(c.is_rendering_custom_range() || c.live_jobs().len() == 1);
c.cancel_video_tasks(true);
assert!(c.live_jobs().iter().all(|id| c.arena.is_finished(*id)));
assert!(!c.is_rendering_custom_range());
pool.shutdown();
}
struct Probe {
progress: AtomicU32,
stop: AtomicU32,
}
impl AutoCacheEvents for Probe {
fn progress(&mut self, value: f64) {
assert_eq!(value, 0.5);
self.progress.fetch_add(1, Ordering::Relaxed);
}
fn stop_proxy_tasks(&mut self) {
self.stop.fetch_add(1, Ordering::Relaxed);
}
}
#[test]
fn events_deliver_progress() {
let (mut c, mut pool) = new_cacher();
let probe = Arc::new(Probe {
progress: AtomicU32::new(0),
stop: AtomicU32::new(0),
});
let probe2 = probe.clone();
c.set_events(Box::new(ProbeEvents { probe: probe2 }));
c.report_progress(0.5);
c.request_stop_proxy_tasks();
assert_eq!(probe.progress.load(Ordering::Relaxed), 1);
assert_eq!(probe.stop.load(Ordering::Relaxed), 1);
pool.shutdown();
}
struct ProbeEvents {
probe: Arc<Probe>,
}
impl AutoCacheEvents for ProbeEvents {
fn progress(&mut self, value: f64) {
self.probe.progress.fetch_add(1, Ordering::Relaxed);
let _ = value;
}
fn stop_proxy_tasks(&mut self) {
self.probe.stop.fetch_add(1, Ordering::Relaxed);
}
}
#[test]
fn clear_finished_single_frames_removes_done() {
let (mut c, mut pool) = new_cacher();
c.attach(7);
let id = c.single_frame(Rational::new(0, 1));
c.arena.wait(id).unwrap();
assert_eq!(c.live_jobs().len(), 1, "finished job still tracked");
c.clear_finished_single_frames();
assert!(c.live_jobs().is_empty());
pool.shutdown();
}
#[test]
fn noop_events_sink_never_panics() {
let mut c = {
let mut pool = WorkerPool::new(1);
pool.start();
let arena = Arc::new(TicketArena::new(pool.clone(), frame_producer()));
let c = PreviewAutoCacher::new(arena);
pool.shutdown();
c
};
c.set_events(Box::new(NoopEvents));
c.report_progress(1.0);
c.request_stop_proxy_tasks();
}
}
File diff suppressed because it is too large Load Diff
+114
View File
@@ -0,0 +1,114 @@
// 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/>.
//! oakcommon C ABI calls (config, file functions, strings) — now direct
//! Rust calls into the oakcommon crate (single-lib unification, see
//! `docs/zh/plans/riir/single-lib.md`). The configuration-location and
//! disk-cache helpers delegate to oakcommon's own implementation.
use std::ffi::{c_char, c_int};
use std::sync::Mutex;
/// Read a config string via the two-stage C ABI
/// (`oakcommon_config_get(group, key, buf, n)`, C++ `Config::current()
/// [key].toString()`).
pub fn config_get_string(group: Option<&str>, key: &str) -> Option<String> {
let group_c = group.and_then(|g| std::ffi::CString::new(g).ok());
let key_c = std::ffi::CString::new(key).ok()?;
let group_ptr = || group_c.as_ref().map_or(std::ptr::null(), |c| c.as_ptr());
let size = unsafe {
oakcommon::ffi::config::oakcommon_config_get(group_ptr(), key_c.as_ptr(), std::ptr::null_mut(), 0)
};
if size <= 1 {
return None; // missing or empty
}
let mut buf = vec![0u8; size as usize];
let got = unsafe {
oakcommon::ffi::config::oakcommon_config_get(
group_ptr(),
key_c.as_ptr(),
buf.as_mut_ptr() as *mut c_char,
size,
)
};
if got <= 0 {
return None;
}
let end = buf.iter().position(|&b| b == 0).unwrap_or(buf.len());
Some(String::from_utf8_lossy(&buf[..end]).into_owned())
}
/// `oakcommon_config_get_int(group, key, default)`.
pub fn config_get_int(group: Option<&str>, key: &str, default: i32) -> i32 {
let group_c = group.and_then(|g| std::ffi::CString::new(g).ok());
let key_c = match std::ffi::CString::new(key) {
Ok(c) => c,
Err(_) => return default,
};
let group_ptr = group_c.as_ref().map_or(std::ptr::null(), |c| c.as_ptr());
unsafe { oakcommon::ffi::config::oakcommon_config_get_int(group_ptr, key_c.as_ptr(), default) }
}
/// The configuration directory — oakcommon's implementation
/// (`FileFunctions::get_configuration_location`, honoring `OAK_CONFIG_DIR`
/// and the platform fallbacks).
pub fn configuration_location() -> String {
oakcommon::filefunctions::FileFunctions::new()
.get_configuration_location()
.unwrap_or_default()
}
/// Serializes tests that mutate `OAK_CONFIG_DIR` / `OAK_RENDER_BACKEND`
/// (env is process-global; the manager tests share this lock too).
pub static ENV_TEST_LOCK: Mutex<()> = Mutex::new(());
/// The default disk cache directory (C++ `DiskManager::
/// get_default_disk_cache_path`): `<configuration_location>/mediacache`.
pub fn default_disk_cache_path() -> String {
oakcommon::filefunctions::default_disk_cache_path()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn config_missing_key_falls_back() {
// The real config store is empty under cargo test: defaults apply.
assert_eq!(config_get_int(None, "GraphicsBackend", 7), 7);
assert_eq!(config_get_string(None, "missing"), None);
}
#[test]
fn configuration_location_uses_env_override() {
let _guard = crate::bridge::common::ENV_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let dir = std::env::temp_dir().join("oakrender-config-test");
std::env::set_var("OAK_CONFIG_DIR", &dir);
let loc = configuration_location();
assert_eq!(loc, dir.to_string_lossy());
std::env::remove_var("OAK_CONFIG_DIR");
}
#[test]
fn default_disk_cache_path_is_under_config() {
let _guard = crate::bridge::common::ENV_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let dir = std::env::temp_dir().join("oakrender-cache-test");
std::env::set_var("OAK_CONFIG_DIR", &dir);
let p = default_disk_cache_path();
assert!(p.ends_with("/mediacache") || p.ends_with("\\mediacache"));
std::env::remove_var("OAK_CONFIG_DIR");
}
}
+67
View File
@@ -0,0 +1,67 @@
// 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 imports from other oak modules.
//!
//! Every submodule follows the same dual-mode pattern as `src/plugin/rust`
//! (bridge/mod.rs): the default path resolves symbols at runtime through
//! [`dlsym`] (`RTLD_DEFAULT`; the module is force-loaded into the app
//! process, so oaknode/oakcommon symbols are in the global scope); when a
//! symbol is missing — e.g. `cargo test` without the C++ modules linked —
//! the wrapper returns a documented fallback (empty handle / negative
//! error code) instead of a link error.
pub mod common;
pub mod node;
/// Shared `dlsym(RTLD_DEFAULT)` runtime resolution.
pub(crate) mod dlsym {
use std::ffi::{c_char, c_void};
/// RTLD_DEFAULT (macOS: -2; Linux: 0).
#[cfg(target_os = "macos")]
pub(crate) const RTLD_DEFAULT: *mut c_void = -2isize as *mut c_void;
#[cfg(target_os = "linux")]
pub(crate) const RTLD_DEFAULT: *mut c_void = 0isize as *mut c_void;
extern "C" {
fn dlsym(handle: *mut c_void, symbol: *const c_char) -> *mut c_void;
}
/// Resolve a global-scope symbol; `None` when missing.
pub(crate) fn resolve(name: &str) -> Option<*mut c_void> {
let c = std::ffi::CString::new(name).ok()?;
let p = unsafe { dlsym(RTLD_DEFAULT, c.as_ptr()) };
if p.is_null() {
None
} else {
Some(p)
}
}
/// Resolve and call by signature; `None` when the symbol is missing.
///
/// # Safety
/// The caller guarantees `T` matches the symbol's real function type.
pub(crate) fn call<T, R>(name: &str, f: impl FnOnce(T) -> R) -> Option<R>
where
T: Copy,
{
let p = resolve(name)?;
let f_ptr: T = unsafe { std::mem::transmute_copy(&p) };
Some(f(f_ptr))
}
}
+175
View File
@@ -0,0 +1,175 @@
// 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/>.
//! oaknode C ABI imports (project copies, node queries).
//!
//! The functions here are declared against the frozen `include/node/*.h`
//! contract and resolved through [`crate::bridge::dlsym`]. When the
//! symbols are missing (cargo test without liboaknode) the wrappers fail
//! with the documented fallbacks; the copier tests that require a live
//! node module are `#[ignore = "needs oaknode C ABI"]`.
use std::ffi::c_int;
use crate::error::{Error, Result};
use crate::handle::CHandle;
/// oaknode project handle.
pub type ProjectHandle = CHandle;
/// oaknode node handle.
pub type NodeHandle = CHandle;
/// Mirror of oaknode's change record (marshalled as plain C structs;
/// layout per include/node/project.h).
#[repr(C)]
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ChangeRecord {
/// Discriminant (see oaknode `ChangeRecord`).
pub kind: u32,
/// Payload bytes (per-kind layout documented in project.h).
pub payload: [u8; 48],
}
/// Change-record discriminants (oaknode project.h).
pub mod change_kind {
/// Node added.
pub const NODE_ADD: u32 = 0;
/// Node removed.
pub const NODE_REMOVE: u32 = 1;
/// Edge added.
pub const EDGE_ADD: u32 = 2;
/// Edge removed.
pub const EDGE_REMOVE: u32 = 3;
/// Value change.
pub const VALUE_CHANGE: u32 = 4;
/// Value hint change.
pub const VALUE_HINT_CHANGE: u32 = 5;
/// Project setting change.
pub const PROJECT_SETTING_CHANGE: u32 = 6;
/// Footage proxy change.
pub const FOOTAGE_PROXY: u32 = 7;
}
/// `oaknode_project_deep_copy(project)` — new in the Rust-era node C ABI.
/// Returns an owned copied-project handle; empty when the symbol is
/// missing or the copy failed.
pub fn project_deep_copy(project: ProjectHandle) -> CHandle {
type F = unsafe extern "C" fn(ProjectHandle) -> CHandle;
crate::bridge::dlsym::call::<F, CHandle>("oaknode_project_deep_copy", |f| unsafe { f(project) })
.unwrap_or_else(CHandle::null)
}
/// `oaknode_project_sync_copy(source, copy, changes, count)` — pushes a
/// recorded change set into the copy. `OAKNODE_OK` (0) on success; a
/// negative error otherwise (missing symbol → `Error::Failed`).
pub fn project_sync_copy(
source: ProjectHandle,
copy: ProjectHandle,
changes: &[ChangeRecord],
) -> Result<()> {
type F = unsafe extern "C" fn(
ProjectHandle,
ProjectHandle,
*const ChangeRecord,
c_int,
) -> c_int;
let rc = crate::bridge::dlsym::call::<F, c_int>("oaknode_project_sync_copy", |f| unsafe {
f(source, copy, changes.as_ptr(), changes.len() as c_int)
})
.ok_or_else(|| Error::Failed("oaknode_project_sync_copy missing".into()))?;
if rc == 0 {
Ok(())
} else {
Err(Error::Failed(format!("oaknode_project_sync_copy rc={rc}")))
}
}
/// `oaknode_node_get_video_frame_cache(node, out)` — borrowed cache
/// handle of a node. `OAKNODE_OK` (0) on success with `*out` set;
/// otherwise a negative error (missing symbol → `Error::Failed`).
///
/// # Safety
/// `node` must be a valid handle; `out` a valid pointer.
pub unsafe fn node_get_video_frame_cache(node: NodeHandle, out: *mut CHandle) -> Result<()> {
type F = unsafe extern "C" fn(NodeHandle, *mut CHandle) -> c_int;
let rc = crate::bridge::dlsym::call::<F, c_int>("oaknode_node_get_video_frame_cache", |f| unsafe {
f(node, out)
})
.ok_or_else(|| Error::Failed("oaknode_node_get_video_frame_cache missing".into()))?;
if rc == 0 {
Ok(())
} else {
Err(Error::Failed(format!("oaknode_node_get_video_frame_cache rc={rc}")))
}
}
/// Whether the oaknode C ABI is present in the process (tests use this to
/// gate success-path copier tests).
pub fn node_abi_available() -> bool {
crate::bridge::dlsym::resolve("oaknode_project_deep_copy").is_some()
}
/// Node identity of a handle (the box pointer; matches the copier's
/// identity tracking).
pub fn node_identity(node: &NodeHandle) -> u64 {
node.ctx as u64
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn deep_copy_missing_symbol_yields_empty_handle() {
// Without liboaknode the deep copy cannot exist.
let h = project_deep_copy(CHandle::null());
assert!(h.is_null());
}
#[test]
fn sync_copy_missing_symbol_fails_explainably() {
let changes = [ChangeRecord {
kind: change_kind::NODE_ADD,
payload: [0u8; 48],
}];
let rc = project_sync_copy(CHandle::null(), CHandle::null(), &changes);
assert!(rc.is_err(), "missing symbol → explainable failure");
}
#[test]
fn change_record_layout_is_c_stable() {
// kind first, then 48 payload bytes (include/node/project.h).
let c = ChangeRecord {
kind: change_kind::VALUE_CHANGE,
payload: [7u8; 48],
};
assert_eq!(std::mem::size_of::<ChangeRecord>(), 4 + 48);
let bytes: [u8; 52] = unsafe { std::mem::transmute(c) };
assert_eq!(u32::from_le_bytes(bytes[0..4].try_into().unwrap()), change_kind::VALUE_CHANGE);
assert_eq!(bytes[4], 7);
}
#[test]
fn node_identity_is_ctx_value() {
let h = CHandle {
ctx: 0x1234 as *mut std::ffi::c_void,
addref: None,
release: None,
abi_version: 1,
};
assert_eq!(node_identity(&h), 0x1234);
}
}
+888
View File
@@ -0,0 +1,888 @@
// 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/>.
//! Playback caches: validated/requested range bookkeeping and the
//! on-disk frame hash cache (C++ `PlaybackCache`/`FrameHashCache`/
//! `AudioPlaybackCache`/`AudioWaveformCache`/`ThumbnailCache`).
//!
//! CPP-PARITY notes:
//! - The on-disk state file layout matches `BinaryStreamReader/Writer`
//! (src/render/transition/binarystream.h) byte for byte: big-endian
//! u32/i32 fields, 16 raw UUID bytes — C++ and Rust builds share the
//! same `<cache_dir>/<uuid>/state` files.
//! - The frame filename scheme matches `FrameHashCache::cache_path_name`:
//! `<cache_dir>/<uuid>/<timestamp>` with the timestamp computed via
//! `Timecode::time_to_timestamp(…, k_round)`.
use std::io::Write;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{LazyLock, Mutex};
use oakcore_rs::{Rational, TimeRange, TimeRangeList};
use crate::error::{Error, Result};
/// Opaque identity of the owning node (the oaknode NodeId identity
/// integer; the cache never calls back into node code — the C++
/// `parent_` back-pointer is reduced to this identity plus explicit
/// C ABI calls where unavoidable).
pub type OwnerIdentity = u64;
/// Cache flavor (the C++ subclasses become one type + kind).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CacheKind {
/// Video frame hash cache (disk-backed).
VideoFrame,
/// Thumbnail cache (disk-backed, frame-hash family).
Thumbnail,
/// Audio playback cache.
AudioPlayback,
/// Audio waveform cache.
AudioWaveform,
}
impl CacheKind {
/// True for the frame-hash flavors (disk-backed, carry a timebase and
/// frame filenames).
pub fn is_frame_hash(self) -> bool {
matches!(self, CacheKind::VideoFrame | CacheKind::Thumbnail)
}
}
// ---- canonical UUID text ("{8-4-4-4-12}", lowercase) ----------------------
struct UuidRng(u64);
fn uuid_rng() -> std::sync::MutexGuard<'static, UuidRng> {
static RNG: LazyLock<Mutex<UuidRng>> = LazyLock::new(|| {
let seed = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(0x9E3779B97F4A7C15)
| 1;
Mutex::new(UuidRng(seed ^ 0x9E3779B97F4A7C15))
});
RNG.lock().unwrap_or_else(|e| e.into_inner())
}
impl UuidRng {
fn next_u64(&mut self) -> u64 {
// xorshift64*
let mut x = self.0;
x ^= x >> 12;
x ^= x << 25;
x ^= x >> 27;
self.0 = x;
x.wrapping_mul(0x2545F4914F6CDD1D)
}
}
/// C++ `create_uuid_text()`: canonical "{8-4-4-4-12}" lowercase text with
/// version-4 and RFC-4122 variant bits.
pub fn create_uuid_text() -> String {
let mut bytes = [0u8; 16];
{
let mut rng = uuid_rng();
let hi = rng.next_u64();
let lo = rng.next_u64();
for i in 0..8 {
bytes[i] = (hi >> (i * 8)) as u8;
}
for i in 0..8 {
bytes[8 + i] = (lo >> (i * 8)) as u8;
}
}
bytes[6] = (bytes[6] & 0x0F) | 0x40; // version 4
bytes[8] = (bytes[8] & 0x3F) | 0x80; // variant 1
const HEX: &[u8; 16] = b"0123456789abcdef";
let mut out = String::with_capacity(38);
out.push('{');
for i in 0..16 {
if i == 4 || i == 6 || i == 8 || i == 10 {
out.push('-');
}
out.push(HEX[(bytes[i] >> 4) as usize] as char);
out.push(HEX[(bytes[i] & 0xF) as usize] as char);
}
out.push('}');
out
}
// ---- binary stream helpers (QDataStream big-endian subset) -----------------
fn write_be_u32(out: &mut Vec<u8>, v: u32) {
out.extend_from_slice(&v.to_be_bytes());
}
fn write_be_i32(out: &mut Vec<u8>, v: i32) {
out.extend_from_slice(&v.to_be_bytes());
}
/// 16 raw bytes in RFC 4122 order from the canonical text form.
fn uuid_text_to_bytes(uuid: &str) -> [u8; 16] {
let mut bytes = [0u8; 16];
let mut nibble = 0usize;
for c in uuid.chars() {
if c == '{' || c == '}' || c == '-' {
continue;
}
if nibble >= 32 {
break;
}
let d = c.to_digit(16).unwrap_or(0) as u8;
if nibble % 2 == 0 {
bytes[nibble / 2] = d << 4;
} else {
bytes[nibble / 2] |= d;
}
nibble += 1;
}
bytes
}
/// Canonical "{8-4-4-4-12}" lowercase text from 16 raw bytes.
fn bytes_to_uuid_text(bytes: &[u8; 16]) -> String {
const HEX: &[u8; 16] = b"0123456789abcdef";
let mut out = String::with_capacity(38);
out.push('{');
for i in 0..16 {
if i == 4 || i == 6 || i == 8 || i == 10 {
out.push('-');
}
out.push(HEX[(bytes[i] >> 4) as usize] as char);
out.push(HEX[(bytes[i] & 0xF) as usize] as char);
}
out.push('}');
out
}
/// Reader over a byte slice; reads past the end yield zeroed values
/// (QDataStream ReadPastEnd semantics).
struct ByteReader<'a> {
data: &'a [u8],
pos: usize,
}
impl<'a> ByteReader<'a> {
fn new(data: &'a [u8]) -> Self {
Self { data, pos: 0 }
}
fn read_u32(&mut self) -> u32 {
let v = self.read_be(4);
v as u32
}
fn read_i32(&mut self) -> i32 {
let v = self.read_be(4);
v as i32
}
fn read_uuid(&mut self) -> [u8; 16] {
let mut b = [0u8; 16];
let n = self.take(&mut b);
let _ = n;
b
}
fn take(&mut self, out: &mut [u8]) -> usize {
let n = (self.data.len() - self.pos).min(out.len());
out[..n].copy_from_slice(&self.data[self.pos..self.pos + n]);
self.pos += n;
n
}
fn read_be(&mut self, bytes: usize) -> u64 {
let mut b = [0u8; 8];
let n = (self.data.len() - self.pos).min(bytes);
b[8 - bytes..8 - bytes + n].copy_from_slice(&self.data[self.pos..self.pos + n]);
self.pos += n;
let mut v: u64 = 0;
for i in 0..bytes {
v = (v << 8) | b[8 - bytes + i] as u64;
}
v
}
}
/// Modification time in milliseconds since the epoch (C++
/// `modification_time_msecs`; 0 when unknown).
fn modification_time_msecs(path: &std::path::Path) -> i64 {
std::fs::metadata(path)
.and_then(|m| m.modified())
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_millis() as i64)
.unwrap_or(0)
}
/// The unified cache.
pub struct PlaybackCache {
/// Flavor.
pub kind: CacheKind,
/// Owner identity.
pub owner: OwnerIdentity,
/// UUID (canonical text; project-file compatible).
pub uuid: String,
/// Frame timebase (frame-hash flavors only).
pub timebase: Option<Rational>,
/// Validated ranges.
validated: TimeRangeList,
/// Requested-but-not-yet-validated ranges.
requested: TimeRangeList,
/// Passthrough target uuids with their ranges.
passthroughs: Vec<(TimeRange, String)>,
/// Persist toggle.
saving_enabled: bool,
/// Root disk cache directory (defaults to the process-wide default;
/// project-owned caches re-point it through the node bridge later).
disk_dir: String,
/// mtime of the last loaded state file (skip reloads of unchanged files).
last_loaded_state: i64,
/// External mutex exposed to the C ABI `oakrender_cache_lock/unlock`
/// (C++ `PlaybackCache::mutex()`; always pair the calls).
pub lock: Mutex<()>,
}
impl PlaybackCache {
/// New cache for `owner` (C++ `PlaybackCache(parent)`).
pub fn new(kind: CacheKind, owner: OwnerIdentity) -> Self {
let disk_dir = crate::bridge::common::default_disk_cache_path();
Self {
kind,
owner,
uuid: create_uuid_text(),
timebase: None,
validated: TimeRangeList::new(),
requested: TimeRangeList::new(),
passthroughs: Vec::new(),
saving_enabled: true,
disk_dir,
last_loaded_state: 0,
lock: Mutex::new(()),
}
}
/// The cache UUID text.
pub fn uuid(&self) -> &str {
&self.uuid
}
/// Set the UUID and reload the disk state (C++ `set_uuid`).
pub fn set_uuid(&mut self, uuid: &str) {
self.uuid = uuid.to_string();
let dir = self.disk_dir.clone();
let _ = self.load_state(&std::path::Path::new(&dir));
}
/// Set the frame timebase (frame-hash flavors; C++ `set_timebase`).
pub fn set_timebase(&mut self, tb: Rational) {
self.timebase = Some(tb);
}
/// The frame timebase (null when unset).
pub fn timebase(&self) -> Rational {
self.timebase.unwrap_or(Rational::NULL)
}
/// Root disk cache directory.
pub fn disk_dir(&self) -> &str {
&self.disk_dir
}
/// Re-point the disk cache root (project-owned caches).
pub fn set_disk_dir(&mut self, dir: &str) {
self.disk_dir = dir.to_string();
}
/// Mark a range invalid (C++ `invalidate`).
pub fn invalidate(&mut self, range: TimeRange) {
if range.in_() == range.out() {
eprintln!("Tried to invalidate zero-length range");
return;
}
self.validated.remove(range);
self.passthroughs.retain(|(r, _)| !overlaps(*r, range));
if self.saving_enabled {
let dir = self.disk_dir.clone();
let _ = self.save_state(&std::path::Path::new(&dir));
}
}
/// Mark a range valid (C++ `validate`).
pub fn validate(&mut self, range: TimeRange) {
self.validated.insert(range);
if self.saving_enabled {
let dir = self.disk_dir.clone();
let _ = self.save_state(&std::path::Path::new(&dir));
}
}
/// True when any validated range exists (C++
/// `has_validated_ranges`).
pub fn has_validated_ranges(&self) -> bool {
!self.validated.is_empty()
}
/// The validated ranges (C++ `get_validated_ranges`).
pub fn validated_ranges(&self) -> &TimeRangeList {
&self.validated
}
/// Invalidated sub-ranges of `within` (C++
/// `get_invalidated_ranges`).
pub fn invalidated_ranges(&self, within: TimeRange) -> TimeRangeList {
// Clamp to >= 0 (C++ does this for safety).
let zero = Rational::new(0, 1);
let mut in_ = within.in_();
let mut out = within.out();
if in_ < zero {
in_ = zero;
}
if out < zero {
out = zero;
}
let intersecting = TimeRange::new(in_, out);
let mut invalidated = TimeRangeList::new();
invalidated.insert(intersecting);
for range in self.validated.ranges() {
invalidated.remove(*range);
}
for (range, _) in &self.passthroughs {
invalidated.remove(*range);
}
invalidated
}
/// Record a request (C++ `request`).
pub fn request(&mut self, range: TimeRange) {
self.requested.insert(range);
}
/// The requested-but-not-yet-validated ranges.
pub fn requested_ranges(&self) -> &TimeRangeList {
&self.requested
}
/// Clear a requested range (C++ `clear_request_range`).
pub fn clear_request_range(&mut self, range: TimeRange) {
self.requested.remove(range);
}
/// Passthrough link (C++ `set_passthrough`).
pub fn set_passthrough(&mut self, other: &PlaybackCache) {
for range in other.validated.ranges() {
self.passthroughs.push((*range, other.uuid.clone()));
}
for (range, uuid) in &other.passthroughs {
self.passthroughs.push((*range, uuid.clone()));
}
// FrameHashCache::set_passthrough also adopts the source timebase.
if self.kind.is_frame_hash() {
if let Some(tb) = other.timebase {
self.timebase = Some(tb);
}
}
if self.saving_enabled {
let dir = self.disk_dir.clone();
let _ = self.save_state(&std::path::Path::new(&dir));
}
}
/// Passthrough from a snapshot (avoids aliasing when the two handles
/// may refer to the same cache).
pub fn set_passthrough_snapshot(&mut self, snapshot: PassthroughSnapshot) {
for range in snapshot.validated.ranges() {
self.passthroughs.push((*range, snapshot.uuid.clone()));
}
for (range, uuid) in &snapshot.passthroughs {
self.passthroughs.push((*range, uuid.clone()));
}
if self.kind.is_frame_hash() {
if let Some(tb) = snapshot.timebase {
self.timebase = Some(tb);
}
}
if self.saving_enabled {
let dir = self.disk_dir.clone();
let _ = self.save_state(&std::path::Path::new(&dir));
}
}
/// The passthrough ranges (C++ `get_passthroughs`).
pub fn passthroughs(&self) -> &[(TimeRange, String)] {
&self.passthroughs
}
/// Persist toggle (C++ `set_saving_enabled`).
pub fn set_saving_enabled(&mut self, enabled: bool) {
self.saving_enabled = enabled;
}
/// The persist toggle.
pub fn saving_enabled(&self) -> bool {
self.saving_enabled
}
/// `<cache_dir>/<uuid>` (C++ `get_this_cache_directory`).
pub fn cache_directory(&self, cache_dir: &std::path::Path) -> std::path::PathBuf {
cache_dir.join(&self.uuid)
}
/// Disk state load (C++ `load_state`); `cache_dir` is the root cache
/// directory. Missing state clears the ranges (C++ behavior).
pub fn load_state(&mut self, cache_dir: &std::path::Path) -> Result<()> {
let state_path = self.cache_directory(cache_dir).join("state");
if !state_path.exists() {
self.validated = TimeRangeList::new();
self.passthroughs.clear();
return Ok(());
}
let file_time = modification_time_msecs(&state_path);
if file_time <= self.last_loaded_state {
return Ok(());
}
let data = match std::fs::read(&state_path) {
Ok(d) => d,
Err(e) => return Err(Error::Failed(format!("read state: {e}"))),
};
let mut r = ByteReader::new(&data);
let version = r.read_u32();
if self.kind.is_frame_hash() {
// FrameHashCache::LoadStateEvent
let event_version = r.read_u32();
if event_version == 1 {
let num = r.read_i32();
let den = r.read_i32();
if num > 0 && den > 0 {
self.timebase = Some(Rational::new(num as i64, den as i64));
}
}
}
if version == 1 {
let valid_count = r.read_i32();
for _ in 0..valid_count {
let in_num = r.read_i32() as i64;
let in_den = r.read_i32() as i64;
let out_num = r.read_i32() as i64;
let out_den = r.read_i32() as i64;
self.validated.insert(TimeRange::new(
Rational::new(in_num, in_den),
Rational::new(out_num, out_den),
));
}
let pass_count = r.read_i32();
for _ in 0..pass_count {
let in_num = r.read_i32() as i64;
let in_den = r.read_i32() as i64;
let out_num = r.read_i32() as i64;
let out_den = r.read_i32() as i64;
let uuid = bytes_to_uuid_text(&r.read_uuid());
self.passthroughs.push((
TimeRange::new(
Rational::new(in_num, in_den),
Rational::new(out_num, out_den),
),
uuid,
));
}
}
self.last_loaded_state = file_time;
Ok(())
}
/// See [`PlaybackCache::load_state`].
pub fn save_state(&self, cache_dir: &std::path::Path) -> Result<()> {
let dir = self.cache_directory(cache_dir);
let state_path = dir.join("state");
if self.validated.is_empty() && self.passthroughs.is_empty() {
let _ = std::fs::remove_file(&state_path);
return Ok(());
}
std::fs::create_dir_all(&dir)
.map_err(|e| Error::Failed(format!("create cache dir: {e}")))?;
let mut out = Vec::new();
write_be_u32(&mut out, 1); // PlaybackCache version
if self.kind.is_frame_hash() {
// FrameHashCache::SaveStateEvent
write_be_u32(&mut out, 1);
let tb = self.timebase.unwrap_or(Rational::new(1, 1));
write_be_i32(&mut out, tb.numerator() as i32);
write_be_i32(&mut out, tb.denominator() as i32);
}
write_be_i32(&mut out, self.validated.ranges().len() as i32);
for range in self.validated.ranges() {
write_be_i32(&mut out, range.in_().numerator() as i32);
write_be_i32(&mut out, range.in_().denominator() as i32);
write_be_i32(&mut out, range.out().numerator() as i32);
write_be_i32(&mut out, range.out().denominator() as i32);
}
write_be_i32(&mut out, self.passthroughs.len() as i32);
for (range, uuid) in &self.passthroughs {
write_be_i32(&mut out, range.in_().numerator() as i32);
write_be_i32(&mut out, range.in_().denominator() as i32);
write_be_i32(&mut out, range.out().numerator() as i32);
write_be_i32(&mut out, range.out().denominator() as i32);
out.extend_from_slice(&uuid_text_to_bytes(uuid));
}
let mut file = std::fs::File::create(&state_path)
.map_err(|e| Error::Failed(format!("create state: {e}")))?;
file.write_all(&out)
.map_err(|e| Error::Failed(format!("write state: {e}")))?;
file.flush()
.map_err(|e| Error::Failed(format!("flush state: {e}")))?;
Ok(())
}
/// The on-disk filename for a frame time (C++
/// `FrameHashCache::get_valid_cache_filename`). `None` when the frame is
/// not cached and no passthrough covers the time.
pub fn frame_filename(&self, time: Rational) -> Option<String> {
if !self.kind.is_frame_hash() {
return None;
}
if is_cached_at(&self.validated, time) {
return Some(self.cache_path_name(time, &self.uuid));
}
for (range, uuid) in &self.passthroughs {
if range.contains(time) {
return Some(self.cache_path_name(time, uuid));
}
}
None
}
/// `cache_path_name(time)`: `<disk_dir>/<uuid>/<timestamp>` where the
/// timestamp is `time_to_timestamp(time, tb, k_round)`.
fn cache_path_name(&self, time: Rational, uuid: &str) -> String {
let timestamp = match self.timebase {
Some(tb) => tb.time_to_timestamp(time),
// No valid timebase: whole seconds.
None => time.to_f64().round() as i64,
};
std::path::Path::new(&self.disk_dir)
.join(uuid)
.join(timestamp.to_string())
.to_string_lossy()
.into_owned()
}
/// The static `cache_path_name(cache_path, cache_id, time, tb)`
/// variant used by the FFI frame-cache load/save exports.
pub fn frame_cache_path(
cache_path: &str,
cache_id: &str,
time: Rational,
timebase: Rational,
) -> String {
let timestamp = timebase.time_to_timestamp(time);
std::path::Path::new(cache_path)
.join(cache_id)
.join(timestamp.to_string())
.to_string_lossy()
.into_owned()
}
}
/// Half-open overlap (C++ `TimeRange::overlaps_with`, default inclusivity).
fn overlaps(a: TimeRange, b: TimeRange) -> bool {
!(b.out() <= a.in_() || b.in_() >= a.out())
}
/// An owned snapshot of another cache's passthrough-relevant data
/// (validated ranges, passthroughs, timebase, uuid) so `set_passthrough`
/// cannot alias.
#[derive(Clone, Debug)]
pub struct PassthroughSnapshot {
/// The source's validated ranges.
pub validated: TimeRangeList,
/// The source's passthroughs.
pub passthroughs: Vec<(TimeRange, String)>,
/// The source's timebase.
pub timebase: Option<Rational>,
/// The source's uuid.
pub uuid: String,
}
/// True when `t` lies in any range (C++ `TimeRangeList::contains(Rational)`).
fn is_cached_at(list: &TimeRangeList, t: Rational) -> bool {
list.ranges().iter().any(|r| r.contains(t))
}
/// Monotonic identity counter for caches without a real node identity.
static NEXT_CACHE_ID: AtomicU64 = AtomicU64::new(1);
/// A synthetic owner identity for detached caches (never collides with
/// node identities, which are pointer values).
pub fn next_owner_identity() -> OwnerIdentity {
NEXT_CACHE_ID.fetch_add(1, Ordering::Relaxed)
}
#[cfg(test)]
mod tests {
use super::*;
fn tb_cache() -> PlaybackCache {
let mut c = PlaybackCache::new(CacheKind::VideoFrame, 1);
c.set_timebase(Rational::new(1, 30));
c.set_saving_enabled(false);
c
}
#[test]
fn uuid_is_canonical_v4() {
let u = create_uuid_text();
assert_eq!(u.len(), 38);
assert!(u.starts_with('{') && u.ends_with('}'));
assert_eq!(u.chars().filter(|&c| c == '-').count(), 4);
// version nibble at position 15 ("-4...").
let b = u.as_bytes();
assert_eq!(b[15], b'4');
// variant nibble at position 20.
let v = (b[20] as char).to_digit(16).unwrap();
assert!(v == 8 || v == 9 || v == 10 || v == 11);
}
#[test]
fn uuid_text_bytes_roundtrip() {
let u = create_uuid_text();
let b = uuid_text_to_bytes(&u);
assert_eq!(bytes_to_uuid_text(&b), u);
}
#[test]
fn invalidate_validate_roundtrip() {
let mut c = tb_cache();
let r = TimeRange::new(Rational::new(0, 1), Rational::new(10, 1));
c.validate(r);
assert!(c.has_validated_ranges());
assert_eq!(c.invalidated_ranges(r).ranges().len(), 0);
c.invalidate(TimeRange::new(Rational::new(4, 1), Rational::new(6, 1)));
// C++ semantics: invalidated = intersecting − validated − passthrough.
let inv = c.invalidated_ranges(r);
assert_eq!(inv.ranges().len(), 1);
assert_eq!(inv.ranges()[0].in_(), Rational::new(4, 1));
assert_eq!(inv.ranges()[0].out(), Rational::new(6, 1));
// The validated list still covers the two surviving sub-ranges.
assert_eq!(c.validated_ranges().ranges().len(), 2);
}
#[test]
fn zero_length_invalidate_is_rejected() {
let mut c = tb_cache();
let r = TimeRange::new(Rational::new(0, 1), Rational::new(5, 1));
c.validate(r);
c.invalidate(TimeRange::new(Rational::new(2, 1), Rational::new(2, 1)));
assert!(c.has_validated_ranges(), "zero-length invalidate is a no-op");
}
#[test]
fn invalidated_ranges_clamps_below_zero() {
let mut c = tb_cache();
c.validate(TimeRange::new(Rational::new(0, 1), Rational::new(5, 1)));
let inv = c.invalidated_ranges(TimeRange::new(
Rational::new(-5, 1),
Rational::new(10, 1),
));
// Only [5,10) remains after the clamp + validation removal.
assert_eq!(inv.ranges().len(), 1);
assert_eq!(inv.ranges()[0].in_(), Rational::new(5, 1));
assert_eq!(inv.ranges()[0].out(), Rational::new(10, 1));
}
#[test]
fn passthrough_excludes_ranges() {
let mut a = tb_cache();
let mut b = PlaybackCache::new(CacheKind::VideoFrame, 2);
b.set_timebase(Rational::new(1, 30));
b.set_saving_enabled(false);
b.validate(TimeRange::new(Rational::new(0, 1), Rational::new(5, 1)));
a.set_passthrough(&b);
assert_eq!(a.passthroughs().len(), 1);
assert_eq!(a.passthroughs()[0].1, b.uuid);
let inv = a.invalidated_ranges(TimeRange::new(
Rational::new(0, 1),
Rational::new(10, 1),
));
assert_eq!(inv.ranges().len(), 1);
assert_eq!(inv.ranges()[0].in_(), Rational::new(5, 1));
// Invalidate a range overlapping the passthrough: it is unlinked.
a.invalidate(TimeRange::new(Rational::new(2, 1), Rational::new(8, 1)));
assert!(a.passthroughs().is_empty());
}
#[test]
fn frame_filename_parity_scheme() {
let mut c = tb_cache();
c.set_uuid("{01234567-89ab-cdef-0123-456789abcdef}");
let time = Rational::new(1, 2); // 0.5 s at 30fps → frame 15
c.validate(TimeRange::new(time, time + Rational::new(1, 30)));
let dir = std::env::temp_dir();
c.set_disk_dir(&dir.to_string_lossy());
let name = c.frame_filename(time).unwrap();
assert_eq!(
name,
dir.join("{01234567-89ab-cdef-0123-456789abcdef}")
.join("15")
.to_string_lossy()
);
}
#[test]
fn frame_filename_none_when_not_cached() {
let c = tb_cache();
assert!(c.frame_filename(Rational::new(1, 30)).is_none());
}
#[test]
fn audio_kind_has_no_frame_filename() {
let c = PlaybackCache::new(CacheKind::AudioPlayback, 1);
assert!(c.frame_filename(Rational::new(1, 1)).is_none());
}
#[test]
fn request_ranges_and_clear() {
let mut c = tb_cache();
let r = TimeRange::new(Rational::new(0, 1), Rational::new(5, 1));
c.request(r);
assert_eq!(c.requested_ranges().ranges().len(), 1);
c.request(TimeRange::new(Rational::new(3, 1), Rational::new(8, 1)));
assert_eq!(c.requested_ranges().ranges().len(), 1, "merges on insert");
assert_eq!(
c.requested_ranges().ranges()[0].in_(),
Rational::new(0, 1)
);
c.clear_request_range(TimeRange::new(Rational::new(2, 1), Rational::new(3, 1)));
assert_eq!(c.requested_ranges().ranges().len(), 2, "splits on clear");
}
#[test]
fn audio_cache_disk_state_uses_base_format() {
let dir = std::env::temp_dir().join(format!("oakrender-audio-test-{}", next_owner_identity()));
std::fs::create_dir_all(&dir).unwrap();
let mut c = PlaybackCache::new(CacheKind::AudioPlayback, 1);
c.set_saving_enabled(false);
c.validate(TimeRange::new(Rational::new(0, 1), Rational::new(2, 1)));
c.save_state(&dir).unwrap();
// Base format: no timebase block (audio has no frame-hash event).
let bytes = std::fs::read(dir.join(&c.uuid).join("state")).unwrap();
let mut expect = Vec::new();
write_be_u32(&mut expect, 1); // version
write_be_i32(&mut expect, 1); // valid_count
write_be_i32(&mut expect, 0);
write_be_i32(&mut expect, 1);
write_be_i32(&mut expect, 2);
write_be_i32(&mut expect, 1);
write_be_i32(&mut expect, 0); // pass_count
assert_eq!(bytes, expect);
// frame_filename is not available for audio kinds.
assert!(c.frame_filename(Rational::new(0, 1)).is_none());
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn cache_directory_helper() {
let c = tb_cache();
let dir = std::path::Path::new("/tmp/cache");
assert_eq!(c.cache_directory(dir), dir.join(&c.uuid));
}
#[test]
fn disk_state_roundtrip_binary_parity() {
let dir = std::env::temp_dir().join(format!("oakrender-test-{}", next_owner_identity()));
std::fs::create_dir_all(&dir).unwrap();
let mut c = tb_cache();
c.set_timebase(Rational::new(1, 30));
c.set_saving_enabled(true);
c.validate(TimeRange::new(Rational::new(0, 1), Rational::new(10, 1)));
c.validate(TimeRange::new(Rational::new(20, 1), Rational::new(30, 1)));
c.save_state(&dir).unwrap();
// Byte layout: ver=1, ver2=1, tb=1/30, count=2, 2×4×i32, count=0.
let bytes = std::fs::read(dir.join(&c.uuid).join("state")).unwrap();
let mut expect = Vec::new();
write_be_u32(&mut expect, 1);
write_be_u32(&mut expect, 1);
write_be_i32(&mut expect, 1);
write_be_i32(&mut expect, 30);
write_be_i32(&mut expect, 2);
for (i, o) in [(0i32, 10i32), (20, 30)] {
write_be_i32(&mut expect, i);
write_be_i32(&mut expect, 1);
write_be_i32(&mut expect, o);
write_be_i32(&mut expect, 1);
}
write_be_i32(&mut expect, 0);
assert_eq!(bytes, expect, "C++ binary state layout parity");
let mut c2 = PlaybackCache::new(CacheKind::VideoFrame, 99);
c2.set_uuid(&c.uuid.clone());
c2.set_timebase(Rational::new(1, 30));
c2.set_saving_enabled(false);
c2.load_state(&dir).unwrap();
assert_eq!(c2.validated.ranges().len(), 2);
assert_eq!(
c2.validated.ranges()[0],
TimeRange::new(Rational::new(0, 1), Rational::new(10, 1))
);
assert_eq!(
c2.validated.ranges()[1],
TimeRange::new(Rational::new(20, 1), Rational::new(30, 1))
);
// Re-save from the loaded cache → identical bytes.
c2.set_saving_enabled(true);
c2.save_state(&dir).unwrap();
let bytes2 = std::fs::read(dir.join(&c2.uuid).join("state")).unwrap();
assert_eq!(bytes, bytes2);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn save_state_removes_file_when_empty() {
let dir = std::env::temp_dir().join(format!("oakrender-test-{}", next_owner_identity()));
std::fs::create_dir_all(&dir).unwrap();
let mut c = tb_cache();
c.set_saving_enabled(true);
c.set_disk_dir(&dir.to_string_lossy());
c.validate(TimeRange::new(Rational::new(0, 1), Rational::new(1, 1)));
c.save_state(&dir).unwrap();
assert!(dir.join(&c.uuid).join("state").exists());
c.invalidate(TimeRange::new(Rational::new(0, 1), Rational::new(1, 1)));
assert!(!dir.join(&c.uuid).join("state").exists());
std::fs::remove_dir_all(&dir).ok();
}
}
+26
View File
@@ -0,0 +1,26 @@
// Oak Video Editor - Non-Linear Video Editor
// Copyright (C) 2026 Oak Team
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//! The cancellation primitive (`olive::CancelAtom`): a thread-safe cancel
//! flag shared between a render/encode caller and its worker.
//!
//! Single-lib unification (see `docs/zh/plans/riir/single-lib.md`): the
//! implementation moved to oakcommon; this module re-exports it so the
//! render ffi's `oakrender_cancelatom_*` exports (and their C-ABI
//! consumers, e.g. oakcodec) keep working unchanged.
/// Shared cancellation atom (oakcommon).
pub use oakcommon::cancelatom::CancelAtom;
+629
View File
@@ -0,0 +1,629 @@
// 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/>.
//! Color processing: OCIO processors and the process-wide default config
//! (C++ `ColorProcessor` + `ColorManager` statics).
//!
//! Implemented over the `ocio-rs` crate (safe Rust bindings for
//! OpenColorIO v2.5.2, bundled real-OCIO build). OCIO is never rewritten —
//! this module maps the C++ call surface onto ocio-rs.
use std::sync::{LazyLock, Mutex};
use oakcore_rs::PixelFormat;
use crate::error::{Error, Result};
use crate::texture::Frame;
/// A color processor (wraps an OCIO processor). A processor whose OCIO
/// lookup failed holds `None` — conversions then pass through, mirroring
/// the C++ non-fatal creation behavior.
pub struct ColorProcessor {
/// The OCIO processor (None = pass-through).
inner: Option<ocio_rs::Processor>,
/// The cached CPU processor used for pixel conversions (C++
/// `cpu_processor_`).
cpu: Option<ocio_rs::CPUProcessor>,
}
/// Processor direction.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Direction {
/// src -> dst.
Normal,
/// dst -> src.
Inverse,
}
impl Direction {
/// The ocio-rs transform direction for the swap already applied at the
/// call site.
fn to_ocio(self) -> ocio_rs::TransformDirection {
match self {
Direction::Normal => ocio_rs::TransformDirection::Forward,
Direction::Inverse => ocio_rs::TransformDirection::Inverse,
}
}
}
// OCIO Config/Processor are refcounted handles over C++ objects that are
// thread-safe for CPU processing (the C++ ColorProcessor is shared across
// render threads the same way); the safe wrapper is Send+Sync.
unsafe impl Send for ColorProcessor {}
unsafe impl Sync for ColorProcessor {}
impl ColorProcessor {
/// Create from two colorspace names on the default config
/// (C++ `ColorProcessor::create(config, input, dest)`).
///
/// Role names (e.g. "scene_linear") are resolved to canonical names.
/// OCIO failures are non-fatal: a processor with `inner == None` is
/// returned and conversions pass through.
pub fn create(src_space: &str, dst_transform: &str, dir: Direction) -> Option<Self> {
let config = default_config()?;
let src = if config.has_role(src_space) {
config.canonical_name(src_space).unwrap_or_else(|| src_space.to_string())
} else {
src_space.to_string()
};
let processor = match dir {
Direction::Normal => config.processor(&src, dst_transform),
Direction::Inverse => config.processor(dst_transform, &src),
}
.ok();
let cpu = processor.as_ref().and_then(|p| p.default_cpu_processor().ok());
Some(Self { inner: processor, cpu })
}
/// Create from a LUT file (C++ `create_lut` semantics): an OCIO
/// FileTransform with linear interpolation on the default config.
pub fn create_lut(path: &str, dir: Direction) -> Option<Self> {
let config = default_config()?;
let transform = ocio_rs::transform::FileTransform::create().ok()?;
transform.set_src(path).ok()?;
transform.set_interpolation(ocio_rs::Interpolation::Linear);
transform.set_direction(dir.to_ocio());
let processor = config
.processor_from_transform(&transform, dir.to_ocio())
.ok();
let cpu = processor.as_ref().and_then(|p| p.default_cpu_processor().ok());
Some(Self { inner: processor, cpu })
}
/// Create from a LUT file on a specific config (C++
/// `ColorProcessor::create_lut` family with a ColorManager config).
pub fn create_lut_on(config: &ocio_rs::Config, path: &str, dir: Direction) -> Option<Self> {
let transform = ocio_rs::transform::FileTransform::create().ok()?;
transform.set_src(path).ok()?;
transform.set_interpolation(ocio_rs::Interpolation::Linear);
transform.set_direction(dir.to_ocio());
let processor = config
.processor_from_transform(&transform, dir.to_ocio())
.ok();
let cpu = processor.as_ref().and_then(|p| p.default_cpu_processor().ok());
Some(Self { inner: processor, cpu })
}
/// Create a dynamic grading-primary processor on the default config
/// (C++ `ColorProcessor` grading-primary family).
pub fn create_grading_primary(style: GradingStyle) -> Option<Self> {
let config = default_config()?;
let transform = ocio_rs::transform::GradingPrimaryTransform::create(style.to_ocio()).ok()?;
transform.make_dynamic();
transform.set_direction(ocio_rs::TransformDirection::Forward);
let processor = config
.processor_from_transform(&transform, ocio_rs::TransformDirection::Forward)
.ok();
let cpu = processor.as_ref().and_then(|p| p.default_cpu_processor().ok());
Some(Self { inner: processor, cpu })
}
/// Create from an explicit OCIO processor (C++
/// `ColorProcessor::create(ConstProcessorRcPtr)`).
pub fn from_processor(processor: ocio_rs::Processor) -> Self {
let cpu = processor.default_cpu_processor().ok();
Self {
inner: Some(processor),
cpu,
}
}
/// Create a pass-through processor (invalid OCIO processor).
pub fn pass_through() -> Self {
Self {
inner: None,
cpu: None,
}
}
/// The underlying OCIO processor handle, when valid.
pub fn processor(&self) -> Option<&ocio_rs::Processor> {
self.inner.as_ref()
}
/// Convert one RGBA color (C++ `convert_color`).
pub fn convert_color(&self, rgba: [f64; 4]) -> [f64; 4] {
match &self.cpu {
Some(cpu) => {
let mut c = [
rgba[0] as f32,
rgba[1] as f32,
rgba[2] as f32,
rgba[3] as f32,
];
cpu.apply_rgba(&mut c);
[c[0] as f64, c[1] as f64, c[2] as f64, c[3] as f64]
}
None => rgba,
}
}
/// Convert a whole F32 frame in place (row-major RGBA).
pub fn convert_frame(&self, frame: &mut Frame) -> Result<()> {
let Some(cpu) = &self.cpu else {
return Ok(()); // pass-through
};
if frame.format != PixelFormat::F32 {
return Err(Error::Invalid);
}
// Tightly packed RGBA: stride is 4 f32 elements per pixel
// (ocio-rs counts elements, not bytes).
let pixels = frame.pixel_count() as i64;
let buf: &mut [f32] = bytemuck_f32_slice(&mut frame.data)
.ok_or_else(|| Error::Failed("pixel buffer not f32 aligned".to_string()))?;
cpu.apply_rgba_pixels(buf, pixels, 4);
Ok(())
}
/// True when the underlying OCIO processor is valid (C++
/// `get_processor() != null`).
pub fn is_valid(&self) -> bool {
self.inner.is_some()
}
/// The OCIO processor cache id (C++ `ColorProcessor::id()`), or the
/// empty string for a pass-through processor.
pub fn cache_id(&self) -> String {
match &self.inner {
Some(p) => p.cache_id().unwrap_or_default(),
None => String::new(),
}
}
}
/// Grading-primary transform style (mirrors the C++ enum values: LIN=0,
/// LOG=1 in `include/render/color.h`).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum GradingStyle {
/// OCIO GRADING_LIN.
Lin,
/// OCIO GRADING_LOG.
Log,
}
impl GradingStyle {
fn to_ocio(self) -> ocio_rs::GradingStyle {
match self {
GradingStyle::Lin => ocio_rs::GradingStyle::Lin,
GradingStyle::Log => ocio_rs::GradingStyle::Log,
}
}
}
/// Reinterpret a byte buffer as f32 when its length is a multiple of 4
/// (the pipeline guarantees F32 RGBA frames, so alignment is exact).
fn bytemuck_f32_slice(data: &mut [u8]) -> Option<&mut [f32]> {
if data.len() % 4 != 0 {
return None;
}
// SAFETY: length is a multiple of 4 and `data` is byte-aligned; the
// cast is valid for any alignment (f32 has no stricter requirement
// than u8 for mutable slice casts at this length).
Some(unsafe {
std::slice::from_raw_parts_mut(data.as_mut_ptr() as *mut f32, data.len() / 4)
})
}
// ---- process-wide default config (C++ ColorManager statics) ----------------
/// Send+Sync wrapper around `ocio_rs::Config` (a `NonNull`-based handle;
/// the underlying OCIO config is a shared pointer safe for concurrent
/// reads).
pub struct SafeConfig(ocio_rs::Config);
unsafe impl Send for SafeConfig {}
unsafe impl Sync for SafeConfig {}
impl std::ops::Deref for SafeConfig {
type Target = ocio_rs::Config;
fn deref(&self) -> &ocio_rs::Config {
&self.0
}
}
static DEFAULT_CONFIG: LazyLock<Mutex<Option<std::sync::Arc<SafeConfig>>>> =
LazyLock::new(|| Mutex::new(None));
/// Borrow the default config (C++ `ColorManager::get_default_config()`).
/// The `Arc` keeps the config alive across threads.
pub fn default_config() -> Option<std::sync::Arc<SafeConfig>> {
DEFAULT_CONFIG
.lock()
.unwrap_or_else(|e| e.into_inner())
.clone()
}
/// Load the process-wide default config from $OCIO or the bundled config
/// (C++ `ColorManager::SetUpDefaultConfig`).
pub fn set_up_default_config() -> Result<()> {
let config = match std::env::var("OCIO") {
Ok(path) if !path.is_empty() => {
ocio_rs::Config::from_file(&path)
.map_err(|e| Error::Failed(format!("load $OCIO config: {e}")))?
}
_ => ocio_rs::Config::create_from_builtin_config("default")
.or_else(|_| ocio_rs::Config::create_from_builtin_config("ocio-2.2-default"))
.map_err(|e| Error::Failed(format!("load bundled config: {e}")))?,
};
*DEFAULT_CONFIG
.lock()
.unwrap_or_else(|e| e.into_inner()) = Some(std::sync::Arc::new(SafeConfig(config)));
Ok(())
}
/// The active default config's display transform cache id for
/// (display, view) — computed from the config's reference colorspace
/// (C++ `ColorManager::display_transform` cache-id semantics).
///
/// `Err(Error::State)` when no default config exists; `Ok(None)` when the
/// display or view is unknown.
pub fn display_transform_result(
display: &str,
view: &str,
) -> std::result::Result<Option<String>, Error> {
let config = default_config().ok_or(Error::State)?;
let mut display_found = false;
for i in 0..config.num_displays_all() {
if config.display_all(i).as_deref() == Some(display) {
display_found = true;
break;
}
}
if !display_found {
return Ok(None);
}
let mut view_found = false;
let views = config.num_views_by_reference_space(ocio_rs::SearchReferenceSpaceType::Scene, display);
for i in 0..views {
if config
.view_by_reference_space(ocio_rs::SearchReferenceSpaceType::Scene, display, i)
.as_deref()
== Some(view)
{
view_found = true;
break;
}
}
if !view_found {
return Ok(None);
}
// OCIO's ROLE_REFERENCE role name ("reference"): `get_color_space`
// resolves name-or-role (C++ `getColorSpace(ROLE_REFERENCE)`); the
// role-name lookups are the fallback for configs that do not bind it.
let src = config
.get_color_space("reference")
.and_then(|cs| cs.name())
.filter(|s| !s.is_empty())
.or_else(|| config.role_color_space("reference").filter(|s| !s.is_empty()))
.or_else(|| config.role_color_space("aces_interchange").filter(|s| !s.is_empty()))
.or_else(|| config.role_color_space("scene_linear").filter(|s| !s.is_empty()))
.ok_or(Error::State)?;
let processor = config
.processor_display(src, display, view, ocio_rs::TransformDirection::Forward)
.map_err(|_| Error::NotFound)?;
Ok(processor.cache_id())
}
/// [`display_transform_result`] as a plain option (unknown → None).
pub fn display_transform(display: &str, view: &str) -> Option<String> {
display_transform_result(display, view).ok().flatten()
}
/// The $OCIO path when set, otherwise the extracted default config path
/// (C++ `ColorManager::get_config_path`; the actual file extraction is
/// deferred to the app layer — the Rust default config stays in memory).
pub fn config_path() -> Option<String> {
if let Ok(path) = std::env::var("OCIO") {
if !path.is_empty() {
return Some(path);
}
}
if default_config().is_none() {
return None;
}
Some(format!(
"{}/ocioconf/config.ocio",
crate::bridge::common::configuration_location()
))
}
// ---- LUT library (C++ LUTLibrary) ------------------------------------------
/// Supported LUT extensions (C++ `LUTLibrary::supported_extensions()`).
pub const SUPPORTED_LUT_EXTENSIONS: [&str; 9] = [
"cube", "3dl", "spi1d", "spi3d", "spimtx", "csp", "clf", "ctf", "cub",
];
/// 1 when `extension` (dot optional, case-insensitive) is supported.
pub fn is_supported_lut_extension(extension: &str) -> bool {
let ext = extension.strip_prefix('.').unwrap_or(extension);
SUPPORTED_LUT_EXTENSIONS
.iter()
.any(|e| e.eq_ignore_ascii_case(ext))
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::{Mutex, MutexGuard};
/// Serializes config-dependent color tests (the process-wide default
/// config is global).
static CONFIG_TEST_LOCK: Mutex<()> = Mutex::new(());
fn config_lock() -> MutexGuard<'static, ()> {
CONFIG_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner())
}
#[test]
fn lut_extensions_case_insensitive_and_dot_optional() {
assert!(is_supported_lut_extension("cube"));
assert!(is_supported_lut_extension(".CUBE"));
assert!(is_supported_lut_extension("clf"));
assert!(!is_supported_lut_extension("exr"));
assert!(!is_supported_lut_extension(""));
assert_eq!(SUPPORTED_LUT_EXTENSIONS.len(), 9);
}
#[test]
fn pass_through_processor_identity() {
let p = ColorProcessor::pass_through();
assert!(!p.is_valid());
assert_eq!(p.convert_color([0.25, 0.5, 0.75, 1.0]), [0.25, 0.5, 0.75, 1.0]);
let mut f = Frame::dummy();
assert!(p.convert_frame(&mut f).is_ok());
}
#[test]
fn convert_frame_rejects_non_f32() {
// A pass-through processor accepts anything (no conversion needed).
let p = ColorProcessor::pass_through();
let mut f = Frame::new();
f.format = PixelFormat::U8;
assert!(p.convert_frame(&mut f).is_ok(), "pass-through converts nothing");
// A *valid* processor requires the F32 pipeline format.
if set_up_default_config().is_err() {
return;
}
let valid = ColorProcessor::create("scene_linear", "sdr-video", Direction::Normal)
.and_then(|p| p.is_valid().then_some(p));
if let Some(valid) = valid {
let mut f = Frame::new();
f.format = PixelFormat::U8;
assert_eq!(
valid.convert_frame(&mut f).unwrap_err().code(),
crate::error::OAKRENDER_E_INVALID
);
}
}
#[test]
fn create_without_config_yields_none() {
let _lock = config_lock();
// When no default config has been set up, creation is None.
let saved = default_config();
*DEFAULT_CONFIG.lock().unwrap_or_else(|e| e.into_inner()) = None;
assert!(ColorProcessor::create("scene_linear", "sdr-video", Direction::Normal).is_none());
*DEFAULT_CONFIG.lock().unwrap_or_else(|e| e.into_inner()) = saved;
}
#[test]
fn default_config_setup_and_role_resolution() {
let _lock = config_lock();
if set_up_default_config().is_err() {
// Bundled OCIO missing (e.g. stub build): skip.
return;
}
let config = default_config().expect("config set up");
// The built-in default config carries a scene_linear role.
assert!(config.has_role("scene_linear") || config.has_role("aces_interchange"));
}
#[test]
fn processor_from_builtin_converts() {
let _lock = config_lock();
if set_up_default_config().is_err() {
return;
}
// Create via the built-in config; a valid processor must exist for
// the ACES scene→display-encoded pairing and must preserve alpha.
let p = ColorProcessor::create("ACEScg", "sRGB Encoded Rec.709 (sRGB)", Direction::Normal);
let p = p.expect("processor handle always returned");
assert!(p.is_valid(), "ACEScg→sRGB Encoded must be a valid processor");
let out = p.convert_color([0.18, 0.18, 0.18, 1.0]);
assert!((out[3] - 1.0).abs() < 1e-6, "alpha passes through");
assert!(out[0].is_finite() && out[1].is_finite() && out[2].is_finite());
assert!((out[0] - 0.18).abs() > 1e-3, "the transform must change the value");
}
#[test]
fn unknown_colorspaces_yield_invalid_processor() {
let _lock = config_lock();
if set_up_default_config().is_err() {
return;
}
let p = ColorProcessor::create(
"not_a_real_colorspace",
"also_not_real",
Direction::Normal,
)
.unwrap();
assert!(!p.is_valid(), "lookup failure is non-fatal (pass-through)");
assert_eq!(
p.convert_color([1.0, 0.5, 0.25, 0.0]),
[1.0, 0.5, 0.25, 0.0]
);
}
#[test]
fn display_transform_queries() {
let _lock = config_lock();
if set_up_default_config().is_err() {
return;
}
let config = default_config().unwrap();
if config.get_num_displays_all() <= 0 {
return;
}
let display = config.get_display_all(0).unwrap();
let n = config.get_num_views_v2(ocio_rs::SearchReferenceSpaceType::Scene, &display);
assert!(n >= 0);
if n > 0 {
let view = config
.get_view_v2(ocio_rs::SearchReferenceSpaceType::Scene, &display, 0)
.unwrap();
let id = display_transform(&display, &view);
assert!(id.is_some());
assert!(!id.unwrap().is_empty());
}
assert!(display_transform("no-such-display", "x").is_none());
}
#[test]
fn convert_frame_with_valid_processor_changes_pixels() {
let _lock = config_lock();
if set_up_default_config().is_err() {
return;
}
let p = ColorProcessor::create("ACEScg", "sRGB Encoded Rec.709 (sRGB)", Direction::Normal)
.expect("handle always returned");
if !p.is_valid() {
return;
}
let mut f = Frame::new();
let mut pod = crate::frame::VideoParamsPod::default();
pod.width = 4;
pod.height = 2;
f.set_video_params(pod);
f.allocate();
// Fill with 0.18 grey via f32 view.
let f32s: &mut [f32] = unsafe {
std::slice::from_raw_parts_mut(f.data.as_mut_ptr() as *mut f32, f.pixel_count() * 4)
};
for px in f32s.chunks_exact_mut(4) {
px[0] = 0.18;
px[1] = 0.18;
px[2] = 0.18;
px[3] = 1.0;
}
p.convert_frame(&mut f).unwrap();
let out: &[f32] = unsafe {
std::slice::from_raw_parts(f.data.as_ptr() as *const f32, f.pixel_count() * 4)
};
assert!(
(out[0] - 0.18).abs() > 1e-4,
"scene_linear→sdr-video must change 0.18 grey (got {})",
out[0]
);
assert!((out[3] - 1.0).abs() < 1e-5, "alpha preserved");
}
#[test]
fn inverse_direction_reverses() {
let _lock = config_lock();
if set_up_default_config().is_err() {
return;
}
let fwd = ColorProcessor::create("ACEScg", "sRGB Encoded Rec.709 (sRGB)", Direction::Normal)
.expect("handle");
let inv = ColorProcessor::create("ACEScg", "sRGB Encoded Rec.709 (sRGB)", Direction::Inverse)
.expect("handle");
if !fwd.is_valid() || !inv.is_valid() {
return;
}
let x = fwd.convert_color([0.18, 0.18, 0.18, 1.0]);
let back = inv.convert_color(x);
assert!(
(back[0] - 0.18).abs() < 0.05,
"inverse undoes forward ({} -> {})",
x[0],
back[0]
);
}
#[test]
fn create_lut_from_real_file() {
let _lock = config_lock();
if set_up_default_config().is_err() {
return;
}
// A tiny 1D .cube LUT (identity-ish with a slight lift).
let lut = "TITLE oakrender test\nLUT_1D_SIZE 2\n0.0 0.0 0.0\n1.0 1.0 1.0\n";
let path = std::env::temp_dir().join("oakrender-test-lut.cube");
std::fs::write(&path, lut).unwrap();
let p = ColorProcessor::create_lut(path.to_string_lossy().as_ref(), Direction::Normal)
.expect("processor handle always returned");
assert!(p.is_valid(), "a readable .cube must produce a valid processor");
let _ = std::fs::remove_file(&path);
}
#[test]
fn grading_primary_and_from_processor() {
let _lock = config_lock();
if set_up_default_config().is_err() {
return;
}
if let Some(p) = ColorProcessor::create_grading_primary(GradingStyle::Log) {
// A grading-primary processor is valid and converts.
if p.is_valid() {
let out = p.convert_color([0.18, 0.5, 0.7, 1.0]);
assert!(out.iter().all(|v| v.is_finite()));
}
}
if let Some(config) = default_config() {
if let Ok(proc) = config.processor("ACEScg", "sRGB Encoded Rec.709 (sRGB)") {
let p = ColorProcessor::from_processor(proc);
assert!(p.is_valid());
assert!(!p.cache_id().is_empty(), "OCIO cache id present");
}
}
}
#[test]
fn create_lut_missing_file_is_invalid() {
let _lock = config_lock();
if set_up_default_config().is_err() {
return;
}
let p = ColorProcessor::create_lut("/nonexistent/never.cube", Direction::Normal).unwrap();
assert!(!p.is_valid(), "unreadable LUT → pass-through processor");
}
}
+192
View File
@@ -0,0 +1,192 @@
// 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/>.
//! Render-side project copy client (the C++ ProjectCopier, inverted):
//! all copying happens inside oaknode
//! (`oaknode_project_deep_copy` / `sync_copy`); this module only tracks
//! which copy belongs to which viewer and when to re-sync.
//!
//! The oaknode C ABI functions are resolved through [`crate::bridge::node`]
//! (dlsym); without liboaknode linked (cargo test) the copy operations
//! fail explainably and the success-path tests are `#[ignore]`d.
use crate::bridge::node::{ChangeRecord, ProjectHandle};
use crate::error::{Error, Result};
/// A handle to a render-side project copy.
pub struct ProjectCopy {
/// Identity of the source project.
pub source: u64,
/// Identity of the copied project (oaknode-owned).
pub copy: u64,
/// Owned oaknode handle to the copy (kept alive for the copier's
/// lifetime; released on drop).
copy_handle: Option<ProjectHandle>,
/// Change-generation counter of the last successful sync.
pub last_sync_generation: u64,
/// True while recorded changes await `sync`.
pub has_pending_updates: bool,
}
impl ProjectCopy {
/// A copier with no project attached yet (C++ `ProjectCopier()`).
pub fn new() -> Self {
Self {
source: 0,
copy: 0,
copy_handle: None,
last_sync_generation: 0,
has_pending_updates: false,
}
}
/// Create a deep copy of `source` through the oaknode C ABI
/// (C++ `ProjectCopier::set_project`).
pub fn set_project(&mut self, source: ProjectHandle) -> Result<()> {
if source.is_null() {
return Err(Error::Invalid);
}
// Release any previous copy.
self.release_copy();
let copy = crate::bridge::node::project_deep_copy(source);
if copy.is_null() {
return Err(Error::Failed(
"oaknode_project_deep_copy failed (symbol missing or copy error)".into(),
));
}
self.source = source.ctx as u64;
self.copy = copy.ctx as u64;
self.copy_handle = Some(copy);
self.last_sync_generation = 0;
self.has_pending_updates = false;
Ok(())
}
/// Push a recorded change set into the copy (C++
/// ProjectCopier::process_update_queue).
pub fn sync(&mut self, changes: &[ChangeRecord]) -> Result<()> {
let source = ProjectHandle {
ctx: self.source as *mut std::ffi::c_void,
addref: None,
release: None,
abi_version: crate::handle::OAKRENDER_ABI_VERSION,
};
let copy = self.copy_handle.unwrap_or_else(ProjectHandle::null);
if copy.is_null() {
return Err(Error::State);
}
crate::bridge::node::project_sync_copy(source, copy, changes)?;
self.last_sync_generation += 1;
self.has_pending_updates = false;
Ok(())
}
/// The copied project handle (owned by this copier; borrowed for the
/// caller).
pub fn copied_project(&self) -> Option<ProjectHandle> {
self.copy_handle
}
/// The copied counterpart of an original node — requires the oaknode
/// node-map query (`oaknode_project_copy_of_node`), which is part of
/// the pending node bridge; returns `None` until then.
pub fn copy_of_node(&self, _original: u64) -> Option<u64> {
None
}
/// Drop the copy (releases the oaknode handle).
pub fn destroy(&mut self) {
self.release_copy();
}
fn release_copy(&mut self) {
if let Some(handle) = self.copy_handle.take() {
if let Some(release) = handle.release {
// SAFETY: the handle came from oaknode_project_deep_copy;
// releasing the last reference destroys the copy.
unsafe { release(handle.ctx) };
}
}
self.copy = 0;
}
}
impl Default for ProjectCopy {
fn default() -> Self {
Self::new()
}
}
impl Drop for ProjectCopy {
fn drop(&mut self) {
self.release_copy();
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fresh_copier_has_no_copy() {
let pc = ProjectCopy::new();
assert_eq!(pc.source, 0);
assert_eq!(pc.copy, 0);
assert!(pc.copied_project().is_none());
assert!(!pc.has_pending_updates);
}
#[test]
fn set_project_rejects_empty_handle() {
let mut pc = ProjectCopy::new();
assert_eq!(pc.set_project(ProjectHandle::null()).unwrap_err().code(), Error::Invalid.code());
}
#[test]
fn sync_without_project_is_state_error() {
let mut pc = ProjectCopy::new();
let changes = [ChangeRecord {
kind: crate::bridge::node::change_kind::NODE_ADD,
payload: [0u8; 48],
}];
assert_eq!(pc.sync(&changes).unwrap_err().code(), Error::State.code());
}
#[test]
fn destroy_releases_cleanly() {
let mut pc = ProjectCopy::new();
pc.destroy();
assert_eq!(pc.copy, 0);
}
#[test]
#[ignore = "needs oaknode C ABI (oaknode_project_deep_copy)"]
fn deep_copy_roundtrip_with_real_node() {
// Requires a live liboaknode; run with the app linked.
let mut pc = ProjectCopy::new();
let src = ProjectHandle {
ctx: 1 as *mut std::ffi::c_void,
addref: None,
release: None,
abi_version: crate::handle::OAKRENDER_ABI_VERSION,
};
if crate::bridge::node::node_abi_available() {
pc.set_project(src).unwrap();
assert_ne!(pc.copy, 0);
assert!(pc.copied_project().is_some());
}
}
}
+62
View File
@@ -0,0 +1,62 @@
// Oak Video Editor - Non-Linear Video Editor
// Copyright (C) 2026 Oak Team
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//! Error codes, mirroring `include/render/error.h` verbatim; project-wide
//! -MMCCCC scheme (module registry in include/common/error.h), pass-through untranslated.
/// Success.
pub const OAKRENDER_OK: i32 = 0;
/// Null handle or invalid argument.
pub const OAKRENDER_E_INVALID: i32 = -70001;
/// Call not valid in the current state.
pub const OAKRENDER_E_STATE: i32 = -70002;
/// The underlying operation failed.
pub const OAKRENDER_E_FAILED: i32 = -70003;
/// Index out of range / entry not found.
pub const OAKRENDER_E_NOT_FOUND: i32 = -70004;
/// Allocation failed.
pub const OAKRENDER_E_NOMEM: i32 = -70005;
/// Crate-internal result type.
pub type Result<T> = std::result::Result<T, Error>;
/// Crate-internal error.
#[derive(Clone, Debug)]
pub enum Error {
/// Null handle or invalid argument.
Invalid,
/// Wrong state.
State,
/// Operation failed (context string is log-only).
Failed(String),
/// Not found.
NotFound,
/// Out of memory.
NoMem,
}
impl Error {
/// Map to the public error code.
pub fn code(&self) -> i32 {
match self {
Error::Invalid => OAKRENDER_E_INVALID,
Error::State => OAKRENDER_E_STATE,
Error::Failed(_) => OAKRENDER_E_FAILED,
Error::NotFound => OAKRENDER_E_NOT_FOUND,
Error::NoMem => OAKRENDER_E_NOMEM,
}
}
}
+333
View File
@@ -0,0 +1,333 @@
// Oak Video Editor - Non-Linear Video Editor
// Copyright (C) 2026 Oak Team
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//! The evaluation seam (C++ `RenderProcessor : NodeTraverser`,
//! flattened): turns node-graph evaluation into render jobs by
//! implementing oaknode's `RenderHooks`. Each C++ `process_*` virtual
//! is one hook method.
//!
//! This pass implements the CPU-side, graph-free parts of the hooks:
//! frame generation and color transforms run fully; footage decode,
//! shader execution, plugin jobs and the disk frame-cache payload I/O
//! depend on the oakcodec / oaknode / oakplugin C ABIs and fail with
//! explainable errors (their success-path tests are `#[ignore]`d).
use oakcore_rs::{PixelFormat, Rational};
use crate::error::{Error, Result};
use crate::frame::VideoParamsPod;
use crate::texture::{Frame, Texture};
/// Job specification: the closed set of C++ `*Job` payloads
/// (AcceleratedJob family) as internal evaluation records — jobs no
/// longer travel inside values across module boundaries.
#[derive(Clone, Debug)]
pub enum JobSpec {
/// Shader job (frag/vert source + params).
Shader {
/// Fragment source.
frag: String,
/// Vertex source.
vert: String,
},
/// Color transform job.
ColorTransform {
/// Processor identity (color::ProcessorCache key).
processor: u64,
},
/// Direct frame generation (CPU nodes).
Generate,
/// Disk cache read (C++ CacheJob).
Cache {
/// Cache file path.
path: String,
},
/// Footage decode (C++ FootageJob; decode via bridge::codec).
Footage {
/// Decoder/stream id.
decoder_id: String,
},
/// Sample generation (C++ SampleJob).
Sample,
/// OFX plugin job — forwarded to the oakplugin crate C ABI
/// (render never sees OFX types).
Plugin {
/// OakPluginInstance identity.
instance: u64,
},
}
/// The hooks implementation handed to the oaknode traverser.
pub struct RenderEvalHooks {
/// Cache usage toggle (C++ use_cache).
pub use_cache: bool,
/// Active ticket identity (for cancellation polling).
pub ticket: Option<crate::ticket::TicketId>,
}
#[allow(dead_code)]
impl RenderEvalHooks {
pub fn new() -> Self {
Self {
use_cache: false,
ticket: None,
}
}
/// C++ process_video_footage: decode + upload into `destination`.
fn process_video_footage(
&mut self,
destination: &mut Texture,
spec: &JobSpec,
) -> Result<()> {
let JobSpec::Footage { decoder_id } = spec else {
return Err(Error::Invalid);
};
let _ = (destination, decoder_id);
Err(Error::Failed(
"footage decode deferred: oakcodec decoder bridge pending".into(),
))
}
/// C++ process_audio_footage.
fn process_audio_footage(&mut self, spec: &JobSpec) -> Result<()> {
let _ = spec;
Err(Error::Failed(
"audio footage deferred: oakcodec decoder bridge pending".into(),
))
}
/// C++ process_shader.
fn process_shader(&mut self, destination: &mut Texture, spec: &JobSpec) -> Result<()> {
let JobSpec::Shader { frag, vert } = spec else {
return Err(Error::Invalid);
};
let _ = (destination, frag, vert);
Err(Error::Failed(
"shader execution on CPU deferred: shader evaluation needs the GPU graph path".into(),
))
}
/// C++ process_color_transform.
fn process_color_transform(&mut self, _destination: &mut Texture, spec: &JobSpec) -> Result<()> {
let JobSpec::ColorTransform { processor } = spec else {
return Err(Error::Invalid);
};
// The processor is looked up by identity in the process-wide
// processor cache; this pass resolves the identity through the
// default config (the processor cache lands with the manager).
let _ = processor;
Err(Error::Failed(
"color-transform-by-identity deferred: processor registry pending".into(),
))
}
/// C++ process_frame_generation: fill the destination with a generated
/// F32 frame (transparent black for now).
fn process_frame_generation(
&mut self,
destination: &mut Texture,
time: Rational,
) -> Result<()> {
let Texture::Cpu(frame) = destination else {
return Err(Error::Failed(
"frame generation on GPU deferred: CPU path only this pass".into(),
));
};
let generated = generate_frame(time, (frame.width, frame.height), frame.format)?;
frame.data = generated.data;
frame.timestamp = time;
Ok(())
}
/// C++ process_plugin_job (forwarded to oakplugin C ABI).
fn process_plugin_job(&mut self, texture: Texture, spec: &JobSpec) -> Result<Texture> {
let JobSpec::Plugin { instance } = spec else {
return Err(Error::Invalid);
};
let _ = instance;
let _ = texture;
Err(Error::Failed(
"plugin jobs deferred: forwarded to the oakplugin crate C ABI".into(),
))
}
/// C++ process_video_cache_job.
fn process_video_cache_job(&mut self, spec: &JobSpec) -> Result<Texture> {
let JobSpec::Cache { path } = spec else {
return Err(Error::Invalid);
};
let _ = path;
Err(Error::Failed(
"disk frame-cache load deferred: oakcodec EXR/JPEG decode pending".into(),
))
}
}
impl Default for RenderEvalHooks {
fn default() -> Self {
Self::new()
}
}
/// Generate the pipeline's canonical frame: F32 RGBA, transparent black,
/// with the given timestamp (the CPU-backend producer for video tickets).
pub fn generate_frame(time: Rational, size: (i32, i32), format: PixelFormat) -> Result<Frame> {
let (w, h) = size;
if w <= 0 || h <= 0 {
return Err(Error::Invalid);
}
let mut frame = Frame::new();
let mut pod = VideoParamsPod::default();
pod.width = w;
pod.height = h;
pod.format = format as i32;
frame.set_video_params(pod);
frame.timestamp = time;
if !frame.allocate() {
return Err(Error::NoMem);
}
Ok(frame)
}
/// The manager-installed ticket producer: render the frame the ticket
/// asks for (F32 pipeline frame). This is the CPU-backend render path.
pub fn render_produced_frame(
time: Rational,
params: &crate::ticket::VideoTicketParams,
) -> Result<Texture> {
let (w, h) = params.render_size();
let format = params.force_format.unwrap_or(PixelFormat::F32);
let frame = generate_frame(time, (w, h), format)?;
Ok(Texture::wrap_frame(frame))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn generated_frame_is_f32_transparent_black() {
let f = generate_frame(Rational::new(5, 1), (64, 48), PixelFormat::F32).unwrap();
assert_eq!(f.width, 64);
assert_eq!(f.height, 48);
assert_eq!(f.format, PixelFormat::F32);
assert_eq!(f.timestamp, Rational::new(5, 1));
assert!(f.data.iter().all(|&b| b == 0), "transparent black");
assert_eq!(f.data.len(), 64 * 48 * 4 * 4);
}
#[test]
fn generated_frame_rejects_bad_size() {
assert!(generate_frame(Rational::new(0, 1), (0, 10), PixelFormat::F32).is_err());
assert!(generate_frame(Rational::new(0, 1), (-1, 10), PixelFormat::F32).is_err());
}
#[test]
fn produced_frame_honors_ticket_params() {
let params = crate::ticket::VideoTicketParams {
viewer: 1,
time: Rational::new(2, 1),
force_size: Some((16, 9)),
force_format: Some(PixelFormat::F32),
cache: None,
cache_dir: None,
cache_id: None,
cache_timebase: None,
};
let tex = render_produced_frame(params.time, &params).unwrap();
assert_eq!(tex.size(), (16, 9));
assert_eq!(tex.format(), PixelFormat::F32);
}
#[test]
fn hooks_fail_explainably_for_deferred_jobs() {
let mut hooks = RenderEvalHooks::new();
let mut dest = Texture::dummy();
assert!(hooks
.process_video_footage(
&mut dest,
&JobSpec::Footage {
decoder_id: "d".into()
}
)
.is_err());
assert!(hooks.process_shader(&mut dest, &JobSpec::Shader {
frag: "f".into(),
vert: "v".into()
}).is_err());
assert!(hooks.process_video_cache_job(&JobSpec::Cache { path: "p".into() }).is_err());
assert!(hooks.process_audio_footage(&JobSpec::Sample).is_err());
assert!(hooks.process_plugin_job(Texture::dummy(), &JobSpec::Plugin { instance: 1 }).is_err());
assert!(hooks
.process_color_transform(&mut dest, &JobSpec::ColorTransform { processor: 1 })
.is_err());
// Wrong spec kinds are invalid, not deferred.
assert_eq!(
hooks.process_shader(&mut dest, &JobSpec::Generate)
.unwrap_err()
.code(),
Error::Invalid.code()
);
}
#[test]
fn generation_fills_cpu_texture() {
let mut hooks = RenderEvalHooks::new();
let mut tex = Texture::wrap_frame(generate_frame(Rational::new(1, 1), (8, 8), PixelFormat::F32).unwrap());
hooks
.process_frame_generation(&mut tex, Rational::new(3, 1))
.unwrap();
let Texture::Cpu(f) = &tex else { unreachable!() };
assert_eq!(f.timestamp, Rational::new(3, 1));
assert!(f.data.iter().all(|&b| b == 0));
// GPU destination rejected.
let mut gpu = Texture::Gpu {
token: 0,
backend: crate::backend::BackendKind::Cpu,
width: 8,
height: 8,
format: PixelFormat::F32,
ctx: Arc::new(UnusedCtx),
};
assert!(hooks.process_frame_generation(&mut gpu, Rational::new(1, 1)).is_err());
}
/// Stand-in context for the "GPU destination" test (never used for
/// real GPU work).
struct UnusedCtx;
impl crate::backend::GpuContextLike for UnusedCtx {
fn kind(&self) -> crate::backend::BackendKind {
crate::backend::BackendKind::Cpu
}
fn destroy_texture(&self, _token: u64) {}
fn upload(&self, _token: u64, _frame: &Frame) -> Result<()> {
Err(Error::Failed("unused".into()))
}
fn download(&self, _token: u64) -> Result<Frame> {
Err(Error::Failed("unused".into()))
}
fn blit(
&self,
_src: u64,
_dst: u64,
_processor: Option<&crate::color::ColorProcessor>,
) -> Result<()> {
Err(Error::Failed("unused".into()))
}
}
use std::sync::Arc;
}
File diff suppressed because it is too large Load Diff
+146
View File
@@ -0,0 +1,146 @@
// 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/>.
//! CPU frame payloads and the [`VideoParamsPod`] value (the Rust mirror of
//! the `oakrender_video_params` POD in `include/render/renderer.h`).
//!
//! The C ABI frame functions (`oakrender_codec_frame_*`) marshal this
//! type; the FFI layer stores [`Frame`] values in `OakCodecFrame` handles.
use oakcore_rs::{PixelFormat, Rational};
/// Mirror of the `oakrender_video_params` POD (include/render/renderer.h,
/// field order and semantics verbatim). Stored inside [`crate::texture::Frame`]
/// so the ffi `*_get_params` exports can report the full metadata.
#[repr(C)]
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct VideoParamsPod {
/// Frame width (full resolution).
pub width: i32,
/// Frame height (full resolution).
pub height: i32,
/// Frame duration numerator (e.g. 1001/30000 s).
pub time_base_num: i32,
/// Frame duration denominator.
pub time_base_den: i32,
/// `olive::PixelFormat::Format` as int.
pub format: i32,
/// Pixel aspect numerator.
pub pixel_aspect_num: i32,
/// Pixel aspect denominator.
pub pixel_aspect_den: i32,
/// `olive::VideoParams::Interlacing` as int.
pub interlacing: i32,
/// `olive::VideoParams::ColorRange` as int.
pub color_range: i32,
/// Preview resolution divider (1 = full).
pub divider: i32,
/// `olive::VideoParams::Type` (0 = video).
pub video_type: i32,
/// 0/1.
pub premultiplied_alpha: i32,
}
impl Default for VideoParamsPod {
fn default() -> Self {
Self {
width: 0,
height: 0,
time_base_num: 1,
time_base_den: 1,
format: PixelFormat::F32 as i32,
pixel_aspect_num: 1,
pixel_aspect_den: 1,
interlacing: 0,
color_range: 0,
divider: 1,
video_type: 0,
premultiplied_alpha: 0,
}
}
}
impl VideoParamsPod {
/// The default render size used when a ticket carries no force size and
/// the output node's video params cannot be queried (oakcommon bridge
/// pending).
pub const DEFAULT_WIDTH: i32 = 1920;
/// See [`VideoParamsPod::DEFAULT_WIDTH`].
pub const DEFAULT_HEIGHT: i32 = 1080;
/// Frame rate as a rational (time base flipped; null for a null time
/// base).
pub fn frame_rate(&self) -> Rational {
if self.time_base_num <= 0 || self.time_base_den <= 0 {
return Rational::NULL;
}
Rational::new(self.time_base_den as i64, self.time_base_num as i64)
}
/// The time base (frame duration).
pub fn time_base(&self) -> Rational {
if self.time_base_num <= 0 || self.time_base_den <= 0 {
return Rational::NULL;
}
Rational::new(self.time_base_num as i64, self.time_base_den as i64)
}
/// The internal (fixed) channel count — the C++ engine uses 4 for the
/// video pipeline; not exposed in the POD.
pub const INTERNAL_CHANNEL_COUNT: i32 = 4;
/// Pixel dimensions of the data buffer honoring the preview divider
/// (C++ `VideoParams::effective_width/height` with a divider).
pub fn effective_width(&self) -> i32 {
(self.width / self.divider.max(1)).max(0)
}
/// See [`VideoParamsPod::effective_width`].
pub fn effective_height(&self) -> i32 {
(self.height / self.divider.max(1)).max(0)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pod_defaults() {
let p = VideoParamsPod::default();
assert_eq!(p.format, PixelFormat::F32 as i32);
assert_eq!(p.time_base(), Rational::new(1, 1));
}
#[test]
fn effective_size_honors_divider() {
let mut p = VideoParamsPod::default();
p.width = 3840;
p.height = 2160;
p.divider = 2;
assert_eq!(p.effective_width(), 1920);
assert_eq!(p.effective_height(), 1080);
}
#[test]
fn frame_rate_is_flipped_time_base() {
let mut p = VideoParamsPod::default();
p.time_base_num = 1001;
p.time_base_den = 30000;
assert_eq!(p.frame_rate(), Rational::new(30000, 1001));
assert_eq!(p.time_base(), Rational::new(1001, 30000));
}
}
+296
View File
@@ -0,0 +1,296 @@
// Oak Video Editor - Non-Linear Video Editor
// Copyright (C) 2026 Oak Team
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//! Refcounted-handle scaffolding (same per-module pattern as the
//! oakplugin/oaknode crates; duplicated on purpose — handle function
//! pointers must run code from the creating DLL).
//!
//! Mirrors `src/render/c_api/internalhandles.h`: every public oakrender
//! handle is `{ctx, addref, release, abi_version}`; `ctx` points at a
//! [`RefBox<T>`] on this crate's heap. `owns == false` boxes (borrowed
//! wrappers) only free the box at zero.
//!
//! Live-object accounting mirrors the C++ `alive_inc`/`alive_dec`:
//! [`make_owned`] counts the handle, the owned release un-counts it, so
//! `oakrender_debug_alive_count()` stays meaningful for leak assertions.
use std::any::Any;
use std::panic::{catch_unwind, AssertUnwindSafe};
use std::sync::atomic::{AtomicU32, AtomicUsize, Ordering};
/// ABI version stamped into every handle.
pub const OAKRENDER_ABI_VERSION: u32 = 1;
/// Heap box behind a handle's `ctx`.
pub struct RefBox<T: ?Sized> {
/// Atomic reference count.
pub refs: AtomicU32,
/// Boxed value.
pub value: T,
}
/// The shared ABI value-handle type (single-lib unification, see
/// `docs/zh/plans/riir/single-lib.md`): one canonical
/// `{ctx, addref, release, abi_version}` type in `oakcore-rs`, re-exported
/// here so the crate's `ffi.rs` signatures and handle scaffolding stay
/// source-compatible. `Send + Sync` come from the shared type.
pub use oakcore_rs::handle::CHandle;
/// Global live-object count (owned handles + cancel-atom boxes).
static ALIVE_COUNT: AtomicUsize = AtomicUsize::new(0);
/// Increment the live-object count (owned handle creation).
pub fn alive_inc() {
ALIVE_COUNT.fetch_add(1, Ordering::Relaxed);
}
/// Decrement the live-object count (owned handle destruction).
pub fn alive_dec() {
ALIVE_COUNT.fetch_sub(1, Ordering::Relaxed);
}
/// Current live-object count (`oakrender_debug_alive_count`).
pub fn alive_count() -> i32 {
ALIVE_COUNT.load(Ordering::Relaxed) as i32
}
/// addref implementation: atomic +1. Shared by owned and borrowed boxes —
/// borrowing only extends the box's lifetime, not the borrowed object's.
unsafe extern "C" fn refbox_addref<T: Any + Send>(ctx: *mut std::ffi::c_void) {
unsafe {
let rb = ctx as *const RefBox<T>;
// The caller guarantees the handle stays valid for the call.
(*rb).refs.fetch_add(1, Ordering::Relaxed);
}
}
/// release implementation (owned): atomic -1; at zero, reclaim the box and
/// destroy the contained object.
unsafe extern "C" fn refbox_release_owned<T: Any + Send>(ctx: *mut std::ffi::c_void) {
unsafe {
let rb = ctx as *mut RefBox<T>;
// AcqRel: the thread that drops the last reference must observe all
// prior writes (including internal state the destructor needs).
if (*rb).refs.fetch_sub(1, Ordering::AcqRel) == 1 {
alive_dec();
drop(Box::from_raw(rb));
}
}
}
/// release implementation (borrowed, produced by [`make_borrowed`]): at
/// zero only reclaim the box memory, forgetting the contained object — its
/// ownership stays with the borrower.
unsafe extern "C" fn refbox_release_borrowed<T: Any + Send>(ctx: *mut std::ffi::c_void) {
unsafe {
let rb = ctx as *mut RefBox<T>;
if (*rb).refs.fetch_sub(1, Ordering::AcqRel) == 1 {
// Partial move: move the value out of the temporary Box so the
// Box drop only frees the allocation; forget the value so it is
// never dropped (double-free guard).
std::mem::forget((Box::from_raw(rb)).value);
}
}
}
/// Owned handle with count 1; empty on allocation failure.
pub fn make_owned<T: Any + Send>(value: T) -> CHandle {
let rb = Box::into_raw(Box::new(RefBox {
refs: AtomicU32::new(1),
value,
}));
alive_inc();
CHandle {
ctx: rb as *mut std::ffi::c_void,
addref: Some(refbox_addref::<T>),
release: Some(refbox_release_owned::<T>),
abi_version: OAKRENDER_ABI_VERSION,
}
}
/// Borrowed handle for an object owned elsewhere.
///
/// # Safety
/// Caller guarantees `ptr` outlives every derived handle.
pub unsafe fn make_borrowed<T: Any + Send>(ptr: *mut T) -> CHandle {
if ptr.is_null() {
return CHandle::null();
}
let rb = Box::into_raw(Box::new(RefBox {
refs: AtomicU32::new(1),
value: unsafe { std::ptr::read(ptr) },
}));
CHandle {
ctx: rb as *mut std::ffi::c_void,
addref: Some(refbox_addref::<T>),
release: Some(refbox_release_borrowed::<T>),
abi_version: OAKRENDER_ABI_VERSION,
}
}
/// Typed view into a handle; `None` for empty handles.
///
/// # Safety
/// `T` must be the boxed type.
pub unsafe fn get<T: Any>(h: &CHandle) -> Option<&T> {
if h.is_null() {
return None;
}
unsafe { Some(&(*(h.ctx as *const RefBox<T>)).value) }
}
/// Typed mutable view into a handle; `None` for empty handles.
///
/// # Safety
/// `T` must be the boxed type, and the caller must guarantee the handle
/// is not concurrently borrowed (the C ABI contract: a handle passed by
/// value is exclusively owned for the duration of the call).
pub unsafe fn get_mut<T: Any>(h: &CHandle) -> Option<&mut T> {
if h.is_null() {
return None;
}
unsafe { Some(&mut (*(h.ctx as *mut RefBox<T>)).value) }
}
/// A boxed handle that does **not** participate in the live-object count
/// (mirrors the C++ borrowed `make_handle(…, owns=false)` boxes): the
/// release only frees the box and its value, never a foreign object.
pub fn make_borrowed_owned<T: Any + Send>(value: T) -> CHandle {
let rb = Box::into_raw(Box::new(RefBox {
refs: AtomicU32::new(1),
value,
}));
CHandle {
ctx: rb as *mut std::ffi::c_void,
addref: Some(refbox_addref::<T>),
release: Some(refbox_release_borrowed::<T>),
abi_version: OAKRENDER_ABI_VERSION,
}
}
/// Panic-catching FFI wrapper for i32-returning exports.
pub fn guard<F: FnOnce() -> crate::error::Result<()>>(f: F) -> i32 {
match catch_unwind(AssertUnwindSafe(f)) {
Ok(Ok(())) => crate::error::OAKRENDER_OK,
Ok(Err(e)) => e.code(),
Err(_) => crate::error::OAKRENDER_E_FAILED,
}
}
/// Panic-catching FFI wrapper for handle-returning exports.
pub fn guard_handle<F: FnOnce() -> crate::error::Result<CHandle>>(f: F) -> CHandle {
match catch_unwind(AssertUnwindSafe(f)) {
Ok(Ok(h)) => h,
Ok(Err(_)) | Err(_) => CHandle::null(),
}
}
/// Panic-catching FFI wrapper for void exports.
pub fn guard_void<F: FnOnce()>(f: F) {
let _ = catch_unwind(AssertUnwindSafe(f));
}
#[cfg(test)]
mod tests {
use super::*;
#[derive(Debug, PartialEq)]
struct Obj(u32);
#[test]
fn owned_handle_refcount_and_destruction() {
let h = make_owned(Obj(7));
assert!(!h.is_null());
assert_eq!(h.abi_version, OAKRENDER_ABI_VERSION);
let before = alive_count();
// addref/release through the stored function pointers.
unsafe { h.addref.unwrap()(h.ctx) };
unsafe { h.release.unwrap()(h.ctx) };
unsafe { h.release.unwrap()(h.ctx) };
assert_eq!(alive_count(), before - 1);
}
#[test]
fn get_returns_boxed_value() {
let h = make_owned(Obj(42));
let v = unsafe { get::<Obj>(&h) };
assert_eq!(v, Some(&Obj(42)));
assert!(unsafe { get::<Obj>(&CHandle::null()) }.is_none());
unsafe { h.release.unwrap()(h.ctx) };
}
#[test]
fn borrowed_release_does_not_count() {
let mut obj = Obj(5);
let before = alive_count();
let h = unsafe { make_borrowed(&mut obj) };
assert!(!h.is_null());
assert_eq!(alive_count(), before, "borrowed boxes are not counted");
unsafe { h.release.unwrap()(h.ctx) };
assert_eq!(alive_count(), before);
// The borrowed value is intact (never dropped).
assert_eq!(obj, Obj(5));
}
#[test]
fn guard_maps_results() {
assert_eq!(guard(|| Ok(())), 0);
assert_eq!(guard(|| Err(crate::error::Error::Invalid)), -70001);
assert_eq!(guard(|| panic!("boom")), -70003);
}
#[test]
fn guard_handle_and_void_panic_safety() {
// Panics map to empty handles / are swallowed.
let h = guard_handle(|| panic!("boom"));
assert!(h.is_null());
let h = guard_handle(|| Err(crate::error::Error::State));
assert!(h.is_null());
let h = guard_handle(|| Ok(make_owned(Obj(1))));
assert!(!h.is_null());
unsafe { h.release.unwrap()(h.ctx) };
guard_void(|| panic!("swallowed"));
guard_void(|| {});
}
#[test]
fn make_borrowed_null_yields_empty() {
let h = unsafe { make_borrowed::<Obj>(std::ptr::null_mut()) };
assert!(h.is_null());
}
#[test]
fn get_mut_mutates_boxed_value() {
let h = make_owned(Obj(3));
unsafe {
let v = get_mut::<Obj>(&h).unwrap();
v.0 = 9;
assert_eq!(get::<Obj>(&h).unwrap().0, 9);
}
unsafe { h.release.unwrap()(h.ctx) };
}
#[test]
fn make_borrowed_owned_does_not_count() {
let before = alive_count();
let h = make_borrowed_owned(Obj(4));
assert_eq!(alive_count(), before, "borrowed-owned boxes are not counted");
assert!(!h.is_null());
unsafe { h.release.unwrap()(h.ctx) };
assert_eq!(alive_count(), before);
}
}
+56
View File
@@ -0,0 +1,56 @@
// 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/>.
//! # oakrender — the render engine (Rust)
//!
//! Reimplements the C++ oakrender module behind its frozen C ABI
//! (`include/render/*.h`). See README.md for the architectural mapping.
//!
//! Module map (mirrors `COVERAGE.md`):
//! - `handle` — refcounted C-handle scaffolding (C++ internalhandles.h)
//! - `texture`/`frame` — Texture + CPU Frame values (C++ texture.h/frame)
//! - `cache` — PlaybackCache/FrameHashCache family + disk state
//! - `color` — ColorProcessor over `ocio-rs` + LUT library
//! - `ticket` — ticket arena with exactly-once completion
//! - `worker` — worker pool + graph snapshot store
//! - `manager` — RenderManager singleton + disk cache
//! - `autocacher` — PreviewAutoCacher
//! - `eval` — the evaluation seam (RenderHooks)
//! - `backend` — wgpu GPU context + display renderer
//! - `copier` — render-side project-copy client (oaknode C ABI)
//! - `cancelatom` — the cancellation primitive
//! - `bridge` — C ABI imports (oakcommon/oaknode/oakcodec, dlsym-resolved)
//! - `ffi` — the `include/render/*.h` export layer
#![deny(unsafe_op_in_unsafe_fn)]
#![warn(missing_docs)]
pub mod autocacher;
pub mod backend;
pub mod bridge;
pub mod cache;
pub mod cancelatom;
pub mod color;
pub mod copier;
pub mod error;
pub mod eval;
pub mod ffi;
pub mod frame;
pub mod handle;
pub mod manager;
pub mod texture;
pub mod ticket;
pub mod worker;
+263
View File
@@ -0,0 +1,263 @@
// Oak Video Editor - Non-Linear Video Editor
// Copyright (C) 2026 Oak Team
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//! The render manager: process-wide singleton owning the worker pool,
//! the ticket arena, the auto-cacher, and backend selection
//! (C++ `RenderManager`).
//!
//! The singleton lives behind a `Mutex<Option<Arc<…>>>` so `init` /
//! `shutdown` round-trips (C++ `create_instance` / `destroy_instance`),
//! and consumers share the `Arc`. `global()` returns the `Arc` (the
//! skeleton's `&'static` reference was replaced because a resettable
//! singleton cannot hand out stable references safely).
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, MutexGuard};
use crate::autocacher::PreviewAutoCacher;
use crate::backend::BackendKind;
use crate::error::{Error, Result};
use crate::eval;
use crate::ticket::{TicketArena, TicketId};
use crate::worker::WorkerPool;
static MANAGER: Mutex<Option<Arc<RenderManager>>> = Mutex::new(None);
fn lock<T>(m: &Mutex<T>) -> MutexGuard<'_, T> {
m.lock().unwrap_or_else(|e| e.into_inner())
}
/// The manager. Created by `oakrender_manager_init` (C ABI), accessed
/// internally through [`RenderManager::global`].
pub struct RenderManager {
/// Worker pool.
pub pool: WorkerPool,
/// Ticket arena.
pub tickets: Arc<TicketArena>,
/// Active GPU backend.
pub backend: BackendKind,
/// The backend the user requested (C++ `requested_backend`).
pub requested_backend: BackendKind,
/// Auto-cacher (None until first access; created lazily by
/// [`RenderManager::get_cacher`]).
pub autocacher: Mutex<Option<PreviewAutoCacher>>,
/// Aggressive decoder GC toggle.
aggressive_gc: AtomicBool,
}
impl RenderManager {
/// Initialize the process-wide manager (idempotent; C++ instance()
/// semantics — only the main GUI process does this).
pub fn init() -> Result<()> {
let mut guard = lock(&MANAGER);
if guard.is_some() {
return Err(Error::State);
}
let backend = BackendKind::from_user_config();
let mut pool = WorkerPool::new(0);
pool.start();
let producer: crate::ticket::Producer =
Arc::new(|time, params| eval::render_produced_frame(time, params));
let tickets = Arc::new(TicketArena::new(pool.clone(), producer));
*guard = Some(Arc::new(RenderManager {
pool,
tickets,
backend,
requested_backend: backend,
autocacher: Mutex::new(None),
aggressive_gc: AtomicBool::new(false),
}));
Ok(())
}
/// Global access; `None` before init.
pub fn global() -> Option<Arc<RenderManager>> {
lock(&MANAGER).clone()
}
/// The auto-cacher, creating it on first access (C++ `get_cacher`).
pub fn get_cacher(&self) -> MutexGuard<'_, Option<PreviewAutoCacher>> {
let mut guard = lock(&self.autocacher);
if guard.is_none() {
*guard = Some(PreviewAutoCacher::new(self.tickets.clone()));
}
guard
}
/// Shut down: cancel tickets, drain pool, release backend.
pub fn shutdown() {
let manager = lock(&MANAGER).take();
if let Some(manager) = manager {
manager.tickets.cancel_all();
// Drop the manager (releases the pool clone) after the pool is
// drained; the drain delivers queued completions.
let mut pool = manager.pool.clone();
pool.shutdown();
drop(manager);
}
}
/// Aggressive-GC toggle (C++ `SetAggressiveGarbageCollection`).
pub fn set_aggressive_gc(&self, on: bool) {
self.aggressive_gc.store(on, Ordering::Release);
}
/// The aggressive-GC toggle.
pub fn aggressive_gc(&self) -> bool {
self.aggressive_gc.load(Ordering::Acquire)
}
/// Submit a video ticket through the manager's arena (used by the
/// auto-cacher and the FFI request path).
pub fn submit_video(
&self,
params: crate::ticket::VideoTicketParams,
done: crate::ticket::Completion,
) -> TicketId {
self.tickets.submit_video(params, done)
}
}
/// The default disk cache directory (C++ `DiskManager::
/// get_default_disk_cache_path`); shared with oaknode via oakcommon
/// (single-lib unification).
pub fn disk_cache_path() -> String {
oakcommon::filefunctions::default_disk_cache_path()
}
/// Bytes consumed by the default disk cache folder (direct filesystem
/// scan; the C++ DiskManager index is replaced by the folder walk).
pub fn disk_cache_size() -> Result<i64> {
let path = disk_cache_path();
let root = std::path::Path::new(&path);
if !root.exists() {
return Ok(0);
}
let mut total: i64 = 0;
for entry in walk(root) {
total = total.saturating_add(entry.metadata().map(|m| m.len() as i64).unwrap_or(0));
}
Ok(total)
}
/// Clear the default disk cache folder (C++ `DiskManager::
/// clear_disk_cache`).
pub fn disk_cache_clear() -> Result<()> {
let path = disk_cache_path();
let root = std::path::Path::new(&path);
if root.exists() {
std::fs::remove_dir_all(root)
.map_err(|e| Error::Failed(format!("clear disk cache: {e}")))?;
}
std::fs::create_dir_all(root)
.map_err(|e| Error::Failed(format!("recreate disk cache: {e}")))?;
Ok(())
}
/// Recursively walk a directory (files only).
fn walk(dir: &std::path::Path) -> Vec<std::path::PathBuf> {
let mut out = Vec::new();
if let Ok(rd) = std::fs::read_dir(dir) {
for entry in rd.flatten() {
let path = entry.path();
if path.is_dir() {
out.extend(walk(&path));
} else {
out.push(path);
}
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::{Mutex, MutexGuard};
/// Serializes the manager-singleton tests (the singleton is global).
static MANAGER_TEST_LOCK: Mutex<()> = Mutex::new(());
fn manager_lock() -> MutexGuard<'static, ()> {
MANAGER_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner())
}
#[test]
fn init_shutdown_roundtrip() {
let _lock = manager_lock();
// Ensure a clean slate.
RenderManager::shutdown();
RenderManager::init().unwrap();
assert!(RenderManager::global().is_some());
// Idempotence: second init is a state error.
assert_eq!(RenderManager::init().unwrap_err().code(), Error::State.code());
RenderManager::shutdown();
assert!(RenderManager::global().is_none());
// Re-init works after shutdown (C++ destroy_instance semantics).
RenderManager::init().unwrap();
RenderManager::shutdown();
}
#[test]
fn aggressive_gc_toggle() {
let _lock = manager_lock();
RenderManager::shutdown();
RenderManager::init().unwrap();
let m = RenderManager::global().unwrap();
assert!(!m.aggressive_gc());
m.set_aggressive_gc(true);
assert!(m.aggressive_gc());
RenderManager::shutdown();
}
#[test]
fn cacher_is_lazily_created() {
let _lock = manager_lock();
RenderManager::shutdown();
RenderManager::init().unwrap();
let m = RenderManager::global().unwrap();
{
let g = m.get_cacher();
assert!(g.is_some());
}
RenderManager::shutdown();
}
#[test]
fn disk_cache_size_and_clear() {
let _guard = crate::bridge::common::ENV_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let dir = std::env::temp_dir().join("oakrender-diskcache-test");
std::env::set_var("OAK_CONFIG_DIR", &dir);
std::fs::create_dir_all(dir.join("mediacache").join("sub")).unwrap();
std::fs::write(dir.join("mediacache").join("sub").join("a.bin"), [1u8; 100]).unwrap();
assert_eq!(disk_cache_size().unwrap(), 100);
disk_cache_clear().unwrap();
assert_eq!(disk_cache_size().unwrap(), 0);
assert!(dir.join("mediacache").exists());
std::fs::remove_dir_all(&dir).ok();
std::env::remove_var("OAK_CONFIG_DIR");
}
#[test]
fn disk_cache_size_missing_dir_is_zero() {
let _guard = crate::bridge::common::ENV_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let dir = std::env::temp_dir().join("oakrender-diskcache-missing");
std::env::set_var("OAK_CONFIG_DIR", &dir);
let _ = std::fs::remove_dir_all(&dir);
assert_eq!(disk_cache_size().unwrap(), 0);
std::env::remove_var("OAK_CONFIG_DIR");
}
}
+367
View File
@@ -0,0 +1,367 @@
// Oak Video Editor - Non-Linear Video Editor
// Copyright (C) 2026 Oak Team
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//! Textures and CPU frames.
use std::sync::Arc;
use oakcore_rs::{PixelFormat, Rational};
use crate::backend::{BackendKind, GpuContextLike};
use crate::error::Result;
use crate::frame::VideoParamsPod;
/// A CPU frame (the payload oakcodec frames bridge into, and the value
/// `OakCodecFrame` handles box).
#[derive(Clone, Debug, PartialEq)]
pub struct Frame {
/// Width of the pixel buffer (effective resolution).
pub width: i32,
/// Height of the pixel buffer (effective resolution).
pub height: i32,
/// Pixel format (F32 on the main pipeline).
pub format: PixelFormat,
/// Channel count (4 on the main pipeline).
pub channels: i32,
/// Timestamp in the sequence timebase.
pub timestamp: Rational,
/// Pixel payload (row-major, tightly packed).
pub data: Vec<u8>,
/// Full video metadata (divider/aspect/interlacing etc.).
pub params: VideoParamsPod,
}
impl Default for Frame {
fn default() -> Self {
Self {
width: 0,
height: 0,
format: PixelFormat::Invalid,
channels: 0,
timestamp: Rational::NULL,
data: Vec::new(),
params: VideoParamsPod::default(),
}
}
}
impl Frame {
/// An empty frame (C++ `Frame::create()` before allocation).
pub fn new() -> Self {
Self::default()
}
/// The dummy frame: 0×0, transparent black, never uploaded
/// (`Texture::dummy` semantics).
pub fn dummy() -> Self {
Self {
width: 0,
height: 0,
format: PixelFormat::F32,
channels: VideoParamsPod::INTERNAL_CHANNEL_COUNT,
timestamp: Rational::new(0, 1),
data: Vec::new(),
params: VideoParamsPod::default(),
}
}
/// Bytes per channel for the frame's format.
pub fn bytes_per_channel(&self) -> usize {
self.format.bytes_per_channel()
}
/// Line stride in bytes (tightly packed rows: `width * channels * bpc`).
pub fn linesize_bytes(&self) -> usize {
(self.width as usize)
.saturating_mul(self.channels as usize)
.saturating_mul(self.bytes_per_channel())
}
/// Total pixel payload size.
pub fn allocated_size(&self) -> usize {
(self.height as usize).saturating_mul(self.linesize_bytes())
}
/// True when the pixel buffer is allocated.
pub fn is_allocated(&self) -> bool {
!self.data.is_empty()
}
/// Set the frame's video metadata (dims, format, divider, aspect…).
/// The channel count stays at the pipeline constant (4).
pub fn set_video_params(&mut self, pod: VideoParamsPod) {
self.params = pod;
self.width = pod.effective_width();
self.height = pod.effective_height();
self.format = match pod.format {
f if f == PixelFormat::U8 as i32 => PixelFormat::U8,
f if f == PixelFormat::U10 as i32 => PixelFormat::U10,
f if f == PixelFormat::U16 as i32 => PixelFormat::U16,
f if f == PixelFormat::F16 as i32 => PixelFormat::F16,
f if f == PixelFormat::F32 as i32 => PixelFormat::F32,
_ => PixelFormat::Invalid,
};
self.channels = VideoParamsPod::INTERNAL_CHANNEL_COUNT;
}
/// The frame's video metadata as the public POD.
pub fn video_params(&self) -> VideoParamsPod {
let mut p = self.params;
p.width = self.width;
p.height = self.height;
p.format = self.format as i32;
p
}
/// Allocate (or re-allocate) the pixel buffer per the current metadata,
/// zeroed. Returns false when the metadata is invalid
/// (C++ `Frame::allocate`).
pub fn allocate(&mut self) -> bool {
if self.width <= 0 || self.height <= 0 || self.channels <= 0 {
return false;
}
let size = self.allocated_size();
if size == 0 {
return false;
}
if self.data.len() != size {
self.data = vec![0u8; size];
} else {
self.data.fill(0);
}
true
}
/// Borrowed pixel data pointer (empty when not allocated).
pub fn data(&self) -> *const u8 {
self.data.as_ptr()
}
/// Mutable pixel data pointer (empty when not allocated).
pub fn data_mut(&mut self) -> *mut u8 {
self.data.as_mut_ptr()
}
/// True for the dummy frame.
pub fn is_dummy(&self) -> bool {
self.width == 0 && self.height == 0 && self.data.is_empty()
}
/// True when the pixel format is a float type.
pub fn is_float(&self) -> bool {
matches!(self.format, PixelFormat::F16 | PixelFormat::F32)
}
/// Number of pixels.
pub fn pixel_count(&self) -> usize {
(self.width as usize).saturating_mul(self.height as usize)
}
}
/// A texture: either backend-resident (GPU) or a CPU-frame wrapper.
/// `Clone` is safe: the GPU token destroy is idempotent (registry
/// lookup), so two clones both release safely at their own drop.
///
/// GPU textures carry an `Arc` to their [`GpuContext`] (the C++ `TexturePtr`
/// keeps its renderer alive the same way), so a texture value can upload/
/// download/blit without a separate renderer handle. `Drop` releases the
/// backend token; destroying a token twice is harmless (registry lookup).
#[derive(Clone)]
pub enum Texture {
/// Backend GPU texture.
Gpu {
/// Backend token (wgpu texture registry key).
token: u64,
/// Owning backend.
backend: BackendKind,
/// Width.
width: i32,
/// Height.
height: i32,
/// Pixel format.
format: PixelFormat,
/// The context owning the texture (trait object so tests can fake
/// the GPU side; `GpuContext` is the only production implementor).
ctx: Arc<dyn GpuContextLike>,
},
/// CPU-frame wrapper (uploaded lazily by the backend).
Cpu(Frame),
}
impl std::fmt::Debug for Texture {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Texture::Gpu {
token,
backend,
width,
height,
format,
..
} => f
.debug_struct("Texture::Gpu")
.field("token", token)
.field("backend", backend)
.field("width", width)
.field("height", height)
.field("format", format)
.finish(),
Texture::Cpu(frame) => f.debug_tuple("Texture::Cpu").field(frame).finish(),
}
}
}
impl Drop for Texture {
fn drop(&mut self) {
if let Texture::Gpu { token, ctx, .. } = self {
ctx.destroy_texture(*token);
}
}
}
impl Texture {
/// A dummy/empty texture (C++ `Texture::dummy` semantics): reads as
/// transparent black, never uploaded.
pub fn dummy() -> Self {
Texture::Cpu(Frame::dummy())
}
/// True for dummy textures.
pub fn is_dummy(&self) -> bool {
match self {
Texture::Gpu { .. } => false,
Texture::Cpu(f) => f.is_dummy(),
}
}
/// Wrap a CPU frame (no copy).
pub fn wrap_frame(frame: Frame) -> Self {
Texture::Cpu(frame)
}
/// Read back into a CPU frame (downloads for GPU textures).
pub fn to_frame(&self) -> Result<Frame> {
match self {
Texture::Cpu(f) => Ok(f.clone()),
Texture::Gpu { token, ctx, .. } => ctx.download(*token),
}
}
/// Dimensions (0x0 for dummy).
pub fn size(&self) -> (i32, i32) {
match self {
Texture::Gpu { width, height, .. } => (*width, *height),
Texture::Cpu(f) => (f.width, f.height),
}
}
/// The texture's pixel format.
pub fn format(&self) -> PixelFormat {
match self {
Texture::Gpu { format, .. } => *format,
Texture::Cpu(f) => f.format,
}
}
/// The backend kind hosting the texture.
pub fn backend(&self) -> BackendKind {
match self {
Texture::Gpu { backend, .. } => *backend,
Texture::Cpu(_) => BackendKind::Cpu,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn frame_allocate_and_linesize() {
let mut f = Frame::new();
let mut p = VideoParamsPod::default();
p.width = 4;
p.height = 3;
f.set_video_params(p);
assert_eq!(f.width, 4);
assert_eq!(f.height, 3);
assert_eq!(f.channels, 4);
assert_eq!(f.linesize_bytes(), 4 * 4 * 4);
assert!(f.allocate());
assert_eq!(f.data.len(), 4 * 3 * 4 * 4);
assert!(f.data.iter().all(|&b| b == 0));
}
#[test]
fn allocate_rejects_invalid() {
let mut f = Frame::new();
assert!(!f.allocate());
f.width = 0;
f.height = 10;
f.channels = 4;
f.format = PixelFormat::F32;
assert!(!f.allocate());
}
#[test]
fn dummy_frame_semantics() {
let d = Frame::dummy();
assert!(d.is_dummy());
assert_eq!(d.width, 0);
let t = Texture::dummy();
assert!(t.is_dummy());
assert_eq!(t.size(), (0, 0));
assert_eq!(t.backend(), BackendKind::Cpu);
}
#[test]
fn video_params_roundtrip_and_pointers() {
let mut f = Frame::new();
let mut p = VideoParamsPod::default();
p.width = 6;
p.height = 4;
p.divider = 2;
p.pixel_aspect_num = 2;
p.pixel_aspect_den = 1;
f.set_video_params(p);
// Divider shrinks the buffer dims (effective resolution).
assert_eq!(f.width, 3);
assert_eq!(f.height, 2);
let pod = f.video_params();
assert_eq!(pod.width, 3);
assert_eq!(pod.pixel_aspect_num, 2);
assert_eq!(f.is_float(), true);
assert_eq!(f.pixel_count(), 6);
f.allocate();
assert!(!f.data().is_null());
assert!(!f.data_mut().is_null());
// timestamp default null.
assert!(f.timestamp.is_null());
}
#[test]
fn wrap_and_to_frame_roundtrip() {
let mut f = Frame::new();
let mut p = VideoParamsPod::default();
p.width = 2;
p.height = 2;
f.set_video_params(p);
f.allocate();
f.data[0] = 0xAB;
let t = Texture::wrap_frame(f.clone());
assert_eq!(t.to_frame().unwrap(), f);
}
}
+592
View File
@@ -0,0 +1,592 @@
// 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/>.
//! Render tickets: async render requests with completion delivery
//! (C++ `RenderTicket`/`RenderTicketWatcher`, Qt signals replaced by
//! boxed callbacks on a delivery thread).
//!
//! Exactly-once contract: a ticket's completion fires exactly once — on
//! success with the result, on cancel (or pool shutdown) with
//! `Error::State`. Cancellation never races the delivery: `cancel` only
//! sets a flag that `finish` honors, and `finish` is the single delivery
//! point.
use std::collections::HashMap;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Condvar, Mutex, MutexGuard};
use oakcore_rs::{Rational, TimeRange};
use crate::error::{Error, Result};
use crate::texture::Texture;
use crate::worker::WorkerPool;
/// Ticket parameters (Rust view of `oakrender_video_ticket_params`).
#[derive(Clone, Debug)]
pub struct VideoTicketParams {
/// Node graph context (copied project identity).
pub viewer: u64,
/// Frame time.
pub time: Rational,
/// Forced size override (None = sequence size).
pub force_size: Option<(i32, i32)>,
/// Forced pixel format (None = pipeline default F32).
pub force_format: Option<oakcore_rs::PixelFormat>,
/// Frame cache to record into (cache identity).
pub cache: Option<u64>,
/// Cache directory (marshalled from the cache handle; frame-cache
/// write path).
pub cache_dir: Option<String>,
/// Cache uuid.
pub cache_id: Option<String>,
/// Cache timebase.
pub cache_timebase: Option<oakcore_rs::Rational>,
}
impl VideoTicketParams {
/// The render size: force_size when set, else the pipeline default.
pub fn render_size(&self) -> (i32, i32) {
self.force_size.unwrap_or((
crate::frame::VideoParamsPod::DEFAULT_WIDTH,
crate::frame::VideoParamsPod::DEFAULT_HEIGHT,
))
}
}
/// Completion payload: the rendered texture or the failure reason.
pub type TicketResult = Result<Texture>;
/// Completion callback (exactly-once delivery).
pub type Completion = Box<dyn FnOnce(TicketResult) + Send>;
/// Frame producer: renders the frame for a ticket. Installed by the
/// manager (eval-based CPU generation for this pass); tests install
/// custom producers.
pub type Producer = Arc<dyn Fn(Rational, &VideoTicketParams) -> TicketResult + Send + Sync>;
/// Ticket metadata: the closed set of C++ `set_property` keys
/// (no Variant property bag).
#[derive(Clone, Debug, Default)]
pub struct TicketMeta {
/// Ticket kind (video/audio).
pub kind: Option<i32>,
/// Frame time.
pub time: Option<Rational>,
/// Cache directory/uuid/timebase (video tickets writing the frame
/// cache).
pub cache_dir: Option<String>,
/// Cache uuid.
pub cache_id: Option<String>,
/// Cache timebase.
pub cache_timebase: Option<Rational>,
}
/// Ticket kinds (C++ `RenderManager::TicketType`).
pub mod ticket_kind {
/// Video ticket.
pub const VIDEO: i32 = 0;
/// Audio ticket.
pub const AUDIO: i32 = 1;
}
/// A submitted ticket (arena id).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct TicketId(pub u64);
enum SlotState {
Running,
Finished,
}
/// A single in-flight ticket (shared between the arena, the worker's job
/// closure and the FFI ticket handle).
struct TicketSlot {
id: TicketId,
kind: i32,
time: Rational,
range: TimeRange,
meta: Mutex<TicketMeta>,
state: Mutex<SlotState>,
cv: Condvar,
cancel: AtomicBool,
delivered: AtomicBool,
completion: Mutex<Option<Completion>>,
result: Mutex<Option<Arc<TicketResult>>>,
}
impl TicketSlot {
fn finish(&self, mut result: TicketResult) {
if self.cancel.load(Ordering::Acquire) {
result = Err(Error::State);
}
// Publish the result before flipping the state flag: `wait()` only
// observes the state, so a waiter must never see `Finished` before
// the result is readable (otherwise `wait()` → `result()` races).
{
let mut s = lock(&self.state);
if !matches!(*s, SlotState::Running) {
return; // already finished: keep exactly-once
}
*lock(&self.result) = Some(Arc::new(result));
*s = SlotState::Finished;
}
self.cv.notify_all();
if self.delivered.swap(true, Ordering::AcqRel) {
return;
}
let done = lock(&self.completion).take();
if let Some(done) = done {
let stored = lock(&self.result).clone();
match stored {
Some(r) => done((*r).clone()),
None => done(Err(Error::State)),
}
}
}
fn is_finished(&self) -> bool {
matches!(*lock(&self.state), SlotState::Finished)
}
fn result(&self) -> Option<TicketResult> {
lock(&self.result).as_ref().map(|r| (**r).clone())
}
}
fn lock<T>(m: &Mutex<T>) -> MutexGuard<'_, T> {
m.lock().unwrap_or_else(|e| e.into_inner())
}
/// The ticket arena (owned by the manager).
pub struct TicketArena {
next: AtomicU64,
pool: WorkerPool,
slots: Mutex<HashMap<TicketId, Arc<TicketSlot>>>,
shutting_down: AtomicBool,
producer: Producer,
}
impl TicketArena {
/// Arena dispatching through `pool`; `producer` renders frames.
pub fn new(pool: WorkerPool, producer: Producer) -> Self {
Self {
next: AtomicU64::new(1),
pool,
slots: Mutex::new(HashMap::new()),
shutting_down: AtomicBool::new(false),
producer,
}
}
/// A producer that always fails (audio rendering is not implemented in
/// this pass; used for audio tickets).
fn audio_producer() -> Producer {
Arc::new(|_, _| Err(Error::Failed("audio rendering not implemented".into())))
}
fn allocate(&self, slot: Arc<TicketSlot>) -> TicketId {
let id = slot.id;
lock(&self.slots).insert(id, slot);
id
}
/// Allocate the next ticket id without submitting a job. Used by the
/// FFI ticket layer to stamp its handle *before* the job is posted —
/// a fast worker must never observe the placeholder id in a completion
/// callback (see `oakrender_ticket_render_frame` in ffi.rs).
pub fn next_id(&self) -> TicketId {
TicketId(self.next.fetch_add(1, Ordering::Relaxed))
}
/// Submit a video ticket with a caller-reserved id (allocated by
/// [`TicketArena::next_id`]); completion fires exactly once, including
/// on cancellation (with `Error::State`).
pub fn submit_video_with_id(
&self,
id: TicketId,
params: VideoTicketParams,
done: Completion,
) -> TicketId {
let meta = TicketMeta {
kind: Some(ticket_kind::VIDEO),
time: Some(params.time),
cache_dir: params.cache_dir.clone(),
cache_id: params.cache_id.clone(),
cache_timebase: params.cache_timebase,
};
let slot = Arc::new(TicketSlot {
id,
kind: ticket_kind::VIDEO,
time: params.time,
range: TimeRange::new(params.time, params.time),
meta: Mutex::new(meta),
state: Mutex::new(SlotState::Running),
cv: Condvar::new(),
cancel: AtomicBool::new(false),
delivered: AtomicBool::new(false),
completion: Mutex::new(Some(done)),
result: Mutex::new(None),
});
self.allocate(slot.clone());
let params = Arc::new(params);
let producer = self.producer.clone();
let slot_done = slot.clone();
let job = crate::worker::Job {
node_identity: params.viewer,
time: params.time,
params,
produce: producer,
done: Box::new(move |result| slot_done.finish(result)),
};
if !self.pool.post(job) {
// Pool is gone (shutdown raced the submit): deliver now.
slot.finish(Err(Error::State));
}
id
}
/// Submit a video ticket; completion fires exactly once, including
/// on cancellation (with `Error::State`).
pub fn submit_video(&self, params: VideoTicketParams, done: Completion) -> TicketId {
let id = self.next_id();
self.submit_video_with_id(id, params, done)
}
/// Submit an audio ticket (range pull; C++ render_audio) with a
/// caller-reserved id (allocated by [`TicketArena::next_id`]). Audio
/// rendering is not implemented in this pass; the completion still
/// fires exactly once with `Error::Failed`.
pub fn submit_audio_with_id(
&self,
id: TicketId,
viewer: u64,
range: TimeRange,
done: Completion,
) -> TicketId {
let meta = TicketMeta {
kind: Some(ticket_kind::AUDIO),
time: Some(range.in_()),
..Default::default()
};
let slot = Arc::new(TicketSlot {
id,
kind: ticket_kind::AUDIO,
time: range.in_(),
range,
meta: Mutex::new(meta),
state: Mutex::new(SlotState::Running),
cv: Condvar::new(),
cancel: AtomicBool::new(false),
delivered: AtomicBool::new(false),
completion: Mutex::new(Some(done)),
result: Mutex::new(None),
});
self.allocate(slot.clone());
let producer = Self::audio_producer();
let slot_done = slot.clone();
let job = crate::worker::Job {
node_identity: viewer,
time: range.in_(),
params: Arc::new(VideoTicketParams {
viewer,
time: range.in_(),
force_size: None,
force_format: None,
cache: None,
cache_dir: None,
cache_id: None,
cache_timebase: None,
}),
produce: producer,
done: Box::new(move |result| slot_done.finish(result)),
};
if !self.pool.post(job) {
slot.finish(Err(Error::State));
}
id
}
/// Submit an audio ticket (range pull; C++ render_audio). Audio
/// rendering is not implemented in this pass; the completion still
/// fires exactly once with `Error::Failed`.
pub fn submit_audio(&self, viewer: u64, range: TimeRange, done: Completion) -> TicketId {
let id = self.next_id();
self.submit_audio_with_id(id, viewer, range, done)
}
/// True when the ticket has finished.
pub fn is_finished(&self, id: TicketId) -> bool {
match lock(&self.slots).get(&id) {
Some(slot) => slot.is_finished(),
None => false,
}
}
/// Blocking wait for completion (C++ wait_for_finished).
pub fn wait(&self, id: TicketId) -> Result<()> {
let slot = lock(&self.slots)
.get(&id)
.cloned()
.ok_or(Error::NotFound)?;
let mut state = lock(&slot.state);
while !matches!(*state, SlotState::Finished) {
state = slot.cv.wait(state).unwrap_or_else(|e| e.into_inner());
}
Ok(())
}
/// The ticket result, when finished (clone; unknown/unfinished ids give
/// `None`).
pub fn result(&self, id: TicketId) -> Option<TicketResult> {
lock(&self.slots).get(&id).and_then(|s| s.result())
}
/// Ticket metadata query (C++ property()).
pub fn meta(&self, id: TicketId) -> Option<TicketMeta> {
lock(&self.slots)
.get(&id)
.map(|s| lock(&s.meta).clone())
}
/// The ticket's kind.
pub fn kind(&self, id: TicketId) -> Option<i32> {
lock(&self.slots).get(&id).map(|s| s.kind)
}
/// The ticket's time (video tickets).
pub fn time(&self, id: TicketId) -> Option<Rational> {
lock(&self.slots).get(&id).map(|s| s.time)
}
/// The ticket's range (audio tickets).
pub fn range(&self, id: TicketId) -> Option<TimeRange> {
lock(&self.slots).get(&id).map(|s| s.range)
}
/// Cancel a pending ticket (its completion still fires with
/// `Error::State`; unknown ids are ignored).
pub fn cancel(&self, id: TicketId) {
if let Some(slot) = lock(&self.slots).get(&id) {
slot.cancel.store(true, Ordering::Release);
}
}
/// Cancel all pending tickets (manager shutdown path). Delivery happens
/// when the pool drains the queued jobs (or the running jobs finish);
/// call [`WorkerPool::shutdown`] afterwards to guarantee all
/// completions have fired.
pub fn cancel_all(&self) {
self.shutting_down.store(true, Ordering::Release);
for slot in lock(&self.slots).values() {
slot.cancel.store(true, Ordering::Release);
}
}
/// True after [`TicketArena::cancel_all`].
pub fn is_shutting_down(&self) -> bool {
self.shutting_down.load(Ordering::Acquire)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::mpsc;
use std::time::Duration;
use crate::frame::VideoParamsPod;
use crate::texture::Frame;
fn small_frame() -> Frame {
let mut f = Frame::new();
let mut p = VideoParamsPod::default();
p.width = 4;
p.height = 4;
f.set_video_params(p);
f.allocate();
f
}
fn ok_producer() -> Producer {
Arc::new(|_, _| Ok(Texture::wrap_frame(small_frame())))
}
#[test]
fn completion_fires_exactly_once_on_success() {
let pool = WorkerPool::new(2);
let mut pool = pool;
pool.start();
let arena = TicketArena::new(pool.clone(), ok_producer());
let (tx, rx) = mpsc::channel();
let id = arena.submit_video(
VideoTicketParams {
viewer: 1,
time: Rational::new(0, 1),
force_size: Some((4, 4)),
force_format: None,
cache: None,
cache_dir: None,
cache_id: None,
cache_timebase: None,
},
Box::new(move |r| {
let _ = tx.send(r.is_ok());
}),
);
arena.wait(id).unwrap();
let ok = rx.recv_timeout(Duration::from_secs(5)).unwrap();
assert!(ok, "completion fired with success");
assert!(rx.recv_timeout(Duration::from_millis(50)).is_err(), "exactly once");
let res = arena.result(id).unwrap().unwrap();
assert_eq!(res.size(), (4, 4));
assert!(arena.is_finished(id));
pool.shutdown();
}
#[test]
fn completion_fires_exactly_once_on_cancel() {
let pool = WorkerPool::new(1);
let mut pool = pool;
pool.start();
// Producer blocks until cancelled: exercises the cancel race.
let release = Arc::new(std::sync::atomic::AtomicBool::new(false));
let release2 = release.clone();
let producer: Producer = Arc::new(move |_, _| {
while !release2.load(Ordering::Acquire) {
std::thread::sleep(Duration::from_millis(1));
}
Ok(Texture::wrap_frame(small_frame()))
});
let arena = TicketArena::new(pool.clone(), producer);
let (tx, rx) = mpsc::channel();
let id = arena.submit_video(
VideoTicketParams {
viewer: 1,
time: Rational::new(0, 1),
force_size: None,
force_format: None,
cache: None,
cache_dir: None,
cache_id: None,
cache_timebase: None,
},
Box::new(move |r| {
let _ = tx.send(r);
}),
);
// Cancel while the job is running, then release the job.
arena.cancel(id);
release.store(true, Ordering::Release);
arena.wait(id).unwrap();
let res = rx.recv_timeout(Duration::from_secs(5)).unwrap();
assert_eq!(res.unwrap_err().code(), Error::State.code());
assert!(rx.recv_timeout(Duration::from_millis(50)).is_err(), "exactly once");
pool.shutdown();
}
#[test]
fn cancel_of_unknown_id_is_ignored() {
let pool = WorkerPool::new(1);
let mut pool = pool;
pool.start();
let arena = TicketArena::new(pool.clone(), ok_producer());
arena.cancel(TicketId(12345));
assert!(!arena.is_finished(TicketId(12345)));
pool.shutdown();
}
#[test]
fn wait_unknown_id_errors() {
let pool = WorkerPool::new(1);
let mut pool = pool;
pool.start();
let arena = TicketArena::new(pool.clone(), ok_producer());
assert_eq!(arena.wait(TicketId(999)).unwrap_err().code(), Error::NotFound.code());
pool.shutdown();
}
#[test]
fn audio_ticket_meta_and_kind() {
let pool = WorkerPool::new(1);
let mut pool = pool;
pool.start();
let arena = TicketArena::new(pool.clone(), ok_producer());
let (tx, rx) = mpsc::channel();
let range = TimeRange::new(Rational::new(0, 1), Rational::new(10, 1));
let id = arena.submit_audio(
7,
range,
Box::new(move |r| {
let _ = tx.send(r.is_err());
}),
);
assert_eq!(arena.kind(id), Some(ticket_kind::AUDIO));
assert_eq!(arena.meta(id).unwrap().kind, Some(ticket_kind::AUDIO));
assert_eq!(arena.range(id), Some(range));
arena.wait(id).unwrap();
assert!(rx.recv_timeout(Duration::from_secs(5)).unwrap());
// get_time equivalent: audio tickets report range.in.
assert_eq!(arena.time(id), Some(range.in_()));
pool.shutdown();
}
#[test]
fn ticket_ids_are_monotonic() {
let pool = WorkerPool::new(1);
let mut pool = pool;
pool.start();
let arena = TicketArena::new(pool.clone(), ok_producer());
let a = arena.submit_video(
VideoTicketParams {
viewer: 1,
time: Rational::new(0, 1),
force_size: None,
force_format: None,
cache: None,
cache_dir: None,
cache_id: None,
cache_timebase: None,
},
Box::new(|_| {}),
);
let b = arena.submit_video(
VideoTicketParams {
viewer: 1,
time: Rational::new(1, 1),
force_size: None,
force_format: None,
cache: None,
cache_dir: None,
cache_id: None,
cache_timebase: None,
},
Box::new(|_| {}),
);
assert!(b.0 > a.0);
assert_ne!(a, b);
pool.shutdown();
}
}
+539
View File
@@ -0,0 +1,539 @@
// Oak Video Editor - Non-Linear Video Editor
// Copyright (C) 2026 Oak Team
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//! The worker layer (C++ RenderWorkerPool + RenderThread +
//! workerprocess/workerjson): thread pool AND process-isolated pool
//! behind one enum.
//!
//! This pass ships the in-process [`WorkerPool`] fully. The
//! [`ProcessPool`] (crash isolation via oakengine_ipc worker processes)
//! is a documented stub: the oakengine_ipc C ABI worker binary is not
//! wired into the Rust world yet, so `start`/`post` fail with
//! `Error::Failed` and the crash-isolation tests are `#[ignore]`d.
use std::collections::{HashMap, VecDeque};
use std::panic::{catch_unwind, AssertUnwindSafe};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Condvar, Mutex, MutexGuard};
use oakcore_rs::Rational;
use crate::error::{Error, Result};
use crate::ticket::{Completion, Producer, VideoTicketParams};
/// A unit of render work (produced by the ticket arena).
pub struct Job {
/// The graph position this job evaluates.
pub node_identity: u64,
/// Frame time.
pub time: Rational,
/// Ticket parameters (size/format overrides).
pub params: Arc<VideoTicketParams>,
/// Frame producer (arena-installed).
pub produce: Producer,
/// Completion delivery.
pub done: Completion,
}
fn lock<T>(m: &Mutex<T>) -> MutexGuard<'_, T> {
m.lock().unwrap_or_else(|e| e.into_inner())
}
/// Thread-pool backend (C++ RenderThread model). Cheap to clone (all
/// state is behind an `Arc`); the manager and the ticket arena share one
/// pool.
#[derive(Clone)]
pub struct WorkerPool {
inner: Arc<PoolInner>,
}
struct PoolInner {
workers: usize,
queue: Mutex<VecDeque<Job>>,
cv: Condvar,
stopping: AtomicBool,
threads: Mutex<Vec<std::thread::JoinHandle<()>>>,
}
impl WorkerPool {
/// Pool with `workers` threads (0 = hardware concurrency).
pub fn new(workers: usize) -> Self {
let workers = if workers == 0 {
std::thread::available_parallelism()
.map(|n| n.get())
.unwrap_or(1)
} else {
workers
};
Self {
inner: Arc::new(PoolInner {
workers,
queue: Mutex::new(VecDeque::new()),
cv: Condvar::new(),
stopping: AtomicBool::new(false),
threads: Mutex::new(Vec::new()),
}),
}
}
/// The number of worker threads.
pub fn worker_count(&self) -> usize {
self.inner.workers
}
/// Start threads (idempotent).
pub fn start(&mut self) {
let mut threads = lock(&self.inner.threads);
if !threads.is_empty() {
return;
}
for _ in 0..self.inner.workers {
let inner = self.inner.clone();
let handle = std::thread::spawn(move || worker_loop(inner));
threads.push(handle);
}
}
/// True when threads are running.
pub fn is_running(&self) -> bool {
!lock(&self.inner.threads).is_empty()
}
/// Enqueue a job. Returns false when the pool is shut down.
pub fn post(&self, job: Job) -> bool {
// The stopping check and the push share one queue lock: a shutdown
// racing the check would otherwise leave the job queued after every
// worker exited (and after the defensive drain), so its completion
// could never fire.
let mut queue = lock(&self.inner.queue);
if self.inner.stopping.load(Ordering::Acquire) {
return false;
}
queue.push_back(job);
self.inner.cv.notify_one();
true
}
/// Stop accepting, drain, join all workers. In-flight job completions
/// fire with cancellation (queued jobs are delivered `Error::State`
/// without running); running jobs are joined so no completion fires
/// after shutdown returns.
pub fn shutdown(&mut self) {
// Set the flag and wake the workers while holding the queue lock.
// Workers decide whether to block in `cv.wait` while holding that
// lock, so a flag set outside it could land between a worker's
// predicate check and its wait: the wakeup is lost, the worker
// sleeps forever, and the join below hangs. Serializing store +
// notify with the waiters' lock closes that window.
{
let _guard = lock(&self.inner.queue);
self.inner.stopping.store(true, Ordering::Release);
self.inner.cv.notify_all();
}
let threads = std::mem::take(&mut *lock(&self.inner.threads));
for handle in threads {
let _ = handle.join();
}
// Defensive drain: any job that landed between `stopping` and the
// workers' exit (post() refuses them, so this is normally empty).
let mut queue = lock(&self.inner.queue);
while let Some(job) = queue.pop_front() {
deliver_cancelled(job);
}
}
}
fn worker_loop(inner: Arc<PoolInner>) {
loop {
let job = {
let mut queue = lock(&inner.queue);
while !inner.stopping.load(Ordering::Acquire) && queue.is_empty() {
queue = inner.cv.wait(queue).unwrap_or_else(|e| e.into_inner());
}
queue.pop_front()
};
let Some(job) = job else {
return; // stopping and queue drained
};
if inner.stopping.load(Ordering::Acquire) {
// Shutdown raced this pop: deliver cancellation.
deliver_cancelled(job);
continue;
}
let result = catch_unwind(AssertUnwindSafe(|| (job.produce)(job.time, &job.params)))
.unwrap_or_else(|_| Err(Error::Failed("frame producer panicked".into())));
(job.done)(result);
}
}
fn deliver_cancelled(job: Job) {
(job.done)(Err(Error::State));
}
/// Process-isolated worker backend (C++ RenderWorkerPool +
/// PooledWorker). Child processes talk the oakengine_ipc C ABI; this side
/// is only a client (spawn, dispatch, reap). Not wired in this pass.
pub struct ProcessPool {
workers: usize,
}
impl ProcessPool {
/// Pool of `workers` child processes.
pub fn new(workers: usize) -> Self {
Self { workers }
}
/// The configured child count.
pub fn worker_count(&self) -> usize {
self.workers
}
/// Spawn children and handshake.
pub fn start(&mut self) -> Result<()> {
Err(Error::Failed(
"oakengine_ipc worker-process bridge not implemented in this pass".into(),
))
}
/// Dispatch a job to a free child.
pub fn post(&self, _job: Job) -> Result<()> {
Err(Error::Failed(
"oakengine_ipc worker-process bridge not implemented in this pass".into(),
))
}
/// Cancel the job running in a child (C++ cancel_active_process).
pub fn cancel_active(&self, _process_slot: usize) {}
/// Terminate and reap all children; pending jobs complete with
/// cancellation.
pub fn shutdown(&mut self) {}
}
/// Graph snapshot files shared with worker processes (C++
/// write_graph_snapshot + path refcounting): a snapshot is written once
/// and reference-counted; the file is unlinked at zero.
pub struct GraphSnapshotStore {
entries: Mutex<HashMap<String, SnapshotEntry>>,
dir: std::path::PathBuf,
}
struct SnapshotEntry {
refs: u64,
cached: bool,
}
impl GraphSnapshotStore {
/// Empty store rooted in the process temp directory.
pub fn new() -> Self {
let dir = std::env::temp_dir().join(format!(
"oakrender-snapshots-{}",
std::process::id()
));
let _ = std::fs::create_dir_all(&dir);
Self {
entries: Mutex::new(HashMap::new()),
dir,
}
}
/// The store's root directory (tests).
pub fn root(&self) -> &std::path::Path {
&self.dir
}
/// Write (or reuse) the snapshot for a project copy; returns the path
/// token with the reference count incremented.
pub fn acquire(&mut self, project_copy: u64) -> Result<String> {
let path = self.dir.join(format!("{project_copy}.json"));
let path_str = path.to_string_lossy().into_owned();
let mut entries = lock(&self.entries);
if let Some(entry) = entries.get_mut(&path_str) {
entry.refs += 1;
return Ok(path_str);
}
// Minimal snapshot payload: the copied-project identity. The real
// graph serialization is owned by oaknode.
let payload = format!("{{\"project_copy\":{project_copy}}}\n");
std::fs::write(&path, payload)
.map_err(|e| Error::Failed(format!("write snapshot: {e}")))?;
entries.insert(
path_str.clone(),
SnapshotEntry {
refs: 1,
cached: false,
},
);
Ok(path_str)
}
/// Drop one reference; unlinks the file at zero.
pub fn release(&mut self, path: &str) {
let mut entries = lock(&self.entries);
let remove = if let Some(entry) = entries.get_mut(path) {
entry.refs = entry.refs.saturating_sub(1);
entry.refs == 0
} else {
false
};
if remove {
entries.remove(path);
let _ = std::fs::remove_file(path);
}
}
/// Mark a snapshot as already uploaded to all live children
/// (C++ set_graph_path_cached).
pub fn mark_cached(&mut self, path: &str, cached: bool) {
if let Some(entry) = lock(&self.entries).get_mut(path) {
entry.cached = cached;
}
}
/// Whether the snapshot is marked cached (tests).
pub fn is_cached(&self, path: &str) -> bool {
lock(&self.entries)
.get(path)
.map(|e| e.cached)
.unwrap_or(false)
}
/// Current reference count for a path (tests).
pub fn refs(&self, path: &str) -> u64 {
lock(&self.entries)
.get(path)
.map(|e| e.refs)
.unwrap_or(0)
}
}
impl Default for GraphSnapshotStore {
fn default() -> Self {
Self::new()
}
}
/// The pool the manager runs (config-selected, C++ parity).
pub enum WorkerBackend {
/// In-process threads.
Threads(WorkerPool),
/// Child processes (crash isolation).
Processes(ProcessPool),
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::mpsc;
use std::sync::atomic::AtomicUsize;
use std::time::Duration;
use crate::texture::Texture;
fn job(tag: u64, tx: mpsc::Sender<u64>, gate: Option<Arc<AtomicUsize>>) -> Job {
let produce: Producer = Arc::new(move |_, _| {
if let Some(g) = &gate {
g.fetch_add(1, Ordering::SeqCst);
}
Ok(Texture::dummy())
});
Job {
node_identity: tag,
time: Rational::new(tag as i64, 1),
params: Arc::new(VideoTicketParams {
viewer: 0,
time: Rational::new(0, 1),
force_size: None,
force_format: None,
cache: None,
cache_dir: None,
cache_id: None,
cache_timebase: None,
}),
produce,
done: Box::new(move |r| {
assert!(r.is_ok(), "producer must succeed here");
let _ = tx.send(tag);
}),
}
}
#[test]
fn pool_saturation_all_jobs_complete() {
let mut pool = WorkerPool::new(4);
pool.start();
let (tx, rx) = mpsc::channel();
for i in 0..64u64 {
assert!(pool.post(job(i, tx.clone(), None)));
}
drop(tx);
let mut seen = Vec::new();
while let Ok(tag) = rx.recv_timeout(Duration::from_secs(10)) {
seen.push(tag);
}
assert_eq!(seen.len(), 64);
seen.sort_unstable();
for (i, tag) in seen.iter().enumerate() {
assert_eq!(*tag, i as u64, "every job runs exactly once");
}
pool.shutdown();
}
#[test]
fn shutdown_delivers_cancellation_to_queued_jobs() {
// 1 worker + a gate that blocks: jobs 2..N stay queued and must be
// delivered Err(State) at shutdown.
let gate = Arc::new(AtomicUsize::new(0));
let mut pool = WorkerPool::new(1);
pool.start();
let (tx, rx) = mpsc::channel();
for i in 0..8u64 {
let tx = tx.clone();
let gate = gate.clone();
let produce: Producer = Arc::new(move |_, _| {
if i == 0 {
// First job blocks until shutdown begins.
let start = std::time::Instant::now();
while gate.load(Ordering::Acquire) == 0
&& start.elapsed() < Duration::from_secs(5)
{
std::thread::sleep(Duration::from_millis(1));
}
}
Ok(Texture::dummy())
});
let job = Job {
node_identity: i,
time: Rational::new(i as i64, 1),
params: Arc::new(VideoTicketParams {
viewer: 0,
time: Rational::new(0, 1),
force_size: None,
force_format: None,
cache: None,
cache_dir: None,
cache_id: None,
cache_timebase: None,
}),
produce,
done: Box::new(move |r| {
let _ = tx.send(r.is_err());
}),
};
pool.post(job);
}
drop(tx);
gate.store(1, Ordering::Release);
pool.shutdown();
let mut delivered = Vec::new();
while let Ok(is_err) = rx.recv_timeout(Duration::from_secs(5)) {
delivered.push(is_err);
}
assert_eq!(delivered.len(), 8, "all 8 completions fire");
assert!(
delivered.iter().filter(|&&e| e).count() >= 7,
"queued jobs complete with cancellation"
);
}
#[test]
fn post_after_shutdown_is_refused() {
let mut pool = WorkerPool::new(1);
pool.start();
pool.shutdown();
let (tx, _rx) = mpsc::channel();
assert!(!pool.post(job(1, tx, None)));
}
#[test]
fn producer_panic_does_not_kill_worker() {
let mut pool = WorkerPool::new(1);
pool.start();
let (tx, rx) = mpsc::channel();
let tx1 = tx.clone();
let tx2 = tx.clone();
let boom: Producer = Arc::new(|_, _| panic!("boom"));
let ok: Producer = Arc::new(|_, _| Ok(Texture::dummy()));
let params = Arc::new(VideoTicketParams {
viewer: 0,
time: Rational::new(0, 1),
force_size: None,
force_format: None,
cache: None,
cache_dir: None,
cache_id: None,
cache_timebase: None,
});
pool.post(Job {
node_identity: 0,
time: Rational::new(0, 1),
params: params.clone(),
produce: boom,
done: Box::new(move |r| {
assert!(r.is_err());
let _ = tx1.send(1u64);
}),
});
pool.post(Job {
node_identity: 1,
time: Rational::new(1, 1),
params,
produce: ok,
done: Box::new(move |r| {
assert!(r.is_ok());
let _ = tx2.send(2u64);
}),
});
let mut got = Vec::new();
while let Ok(v) = rx.recv_timeout(Duration::from_secs(5)) {
got.push(v);
}
assert_eq!(got.len(), 2, "worker survives a panicking producer");
pool.shutdown();
}
#[test]
fn process_pool_is_documented_stub() {
let mut pp = ProcessPool::new(2);
assert_eq!(pp.worker_count(), 2);
assert!(pp.start().is_err(), "oakengine_ipc bridge pending");
let (tx, _rx) = mpsc::channel();
assert!(pp.post(job(1, tx, None)).is_err());
pp.cancel_active(0); // no-op
pp.shutdown(); // no-op
}
#[test]
fn snapshot_store_refcount_and_unlink() {
let mut store = GraphSnapshotStore::new();
let p1 = store.acquire(42).unwrap();
let p2 = store.acquire(42).unwrap();
assert_eq!(p1, p2, "second acquire reuses the file");
assert!(std::path::Path::new(&p1).exists());
store.mark_cached(&p1, true);
assert!(store.is_cached(&p1));
assert_eq!(store.refs(&p1), 2);
store.release(&p1);
assert!(std::path::Path::new(&p1).exists(), "refcount 1: still alive");
store.release(&p1);
assert!(!std::path::Path::new(&p1).exists(), "refcount 0: unlinked");
assert_eq!(store.refs(&p1), 0);
}
}