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:
@@ -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
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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, ¶ms).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
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user