// 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 . //! The render manager: process-wide singleton owning the process //! dispatcher, the ticket arena, the auto-cacher, and backend selection //! (C++ `RenderManager`). //! //! The singleton lives behind a `Mutex>>` 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::procpool::{DispatcherConfig, ProcessDispatcher, ShmAudioRef, ShmFrameRef}; use crate::ticket::{TicketArena, TicketId}; use crate::worker::{GraphSnapshotStore, InlineDispatcher, JobDispatch}; static MANAGER: Mutex>> = Mutex::new(None); fn lock(m: &Mutex) -> MutexGuard<'_, T> { m.lock().unwrap_or_else(|e| e.into_inner()) } /// The render backend the manager initializes (M15 S2). pub enum RenderBackendChoice { /// In-process thread-free dispatch (**test-only** after M15 S2: the /// internal render thread pool was deleted by mandate; this backend /// runs jobs synchronously on the calling thread). Kept so manager / /// integration tests do not spawn oak-worker children. Threads, /// Process-isolated oak-worker pool (crash isolation + shm frames). /// The M15 S2 default. Processes(DispatcherConfig), } /// The manager. Created by `oakrender_manager_init` (C ABI), accessed /// internally through [`RenderManager::global`]. pub struct RenderManager { /// Video job dispatch (the process dispatcher, M15). pub dispatch: Arc, /// Audio job dispatch (M15 S3: the process dispatcher, like video — /// audio renders in oak-worker so plugin crashes are isolated; the /// inline fallback lives in the arena, design §3.7). pub audio_dispatch: Arc, /// Ticket arena. pub tickets: Arc, /// 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>, /// Aggressive decoder GC toggle. aggressive_gc: AtomicBool, /// Graph snapshot files shared with worker processes (M16 S1). snapshots: GraphSnapshotStore, /// The snapshot path currently shipped to the worker pool (None until /// the app pushes one). current_snapshot: Mutex>, /// The (project uuid, undo revision) the current snapshot was written /// for (M16 S1: dedup key — revisions alone collide across projects, /// since every fresh project shares small revision numbers). current_key: Mutex>, /// Teardown in progress (M16 S1): set first thing in /// [`RenderManager::shutdown`]; `set_graph_snapshot` / /// `clear_graph_snapshot` become no-ops afterwards so a stale push /// from a dying test/app cannot re-arm the worker pool mid-shutdown. stopping: AtomicBool, } impl RenderManager { /// Initialize the process-wide manager with the default backend — the /// process-isolated oak-worker pool (M15 S2 mandate; idempotent; C++ /// instance() semantics — only the main GUI process does this). pub fn init() -> Result<()> { Self::init_with_backend(RenderBackendChoice::Processes(DispatcherConfig::default())) } /// Initialize the process-wide manager with an explicit backend. /// `Threads` is the test-only inline backend (no worker threads, no /// child processes); `Processes` spawns the oak-worker pool. pub fn init_with_backend(choice: RenderBackendChoice) -> Result<()> { let mut guard = lock(&MANAGER); if guard.is_some() { return Err(Error::State); } let backend = BackendKind::from_user_config(); let producer: crate::ticket::Producer = Arc::new(|time, params| { eval::render_produced_frame(time, params) .map(crate::ticket::TicketPayload::Video) }); let (dispatch, audio_dispatch, audio_fallback): ( Arc, Arc, Option>, ) = match choice { RenderBackendChoice::Threads => { // Test-only inline backend: synchronous execution on the // calling thread, shared by video and audio. let inline = InlineDispatcher::sync(); (inline.clone(), inline, None) } RenderBackendChoice::Processes(config) => { let dispatcher = ProcessDispatcher::new(config)?; dispatcher.start()?; // M15 S3: audio rendering runs in the worker pool too (a // plugin crash during an audio render must not take down // the main process — design §3.7). The inline dispatcher // stays as the fallback when the process dispatcher is // unavailable (teardown). let fallback = InlineDispatcher::sync(); (dispatcher.clone(), dispatcher, Some(fallback)) } }; let tickets = Arc::new(TicketArena::new_with_audio_fallback( dispatch.clone(), audio_dispatch.clone(), audio_fallback, producer, )); *guard = Some(Arc::new(RenderManager { dispatch, audio_dispatch, tickets, backend, requested_backend: backend, autocacher: Mutex::new(None), aggressive_gc: AtomicBool::new(false), snapshots: GraphSnapshotStore::new(), current_snapshot: Mutex::new(None), current_key: Mutex::new(None), stopping: AtomicBool::new(false), })); Ok(()) } /// Global access; `None` before init. pub fn global() -> Option> { lock(&MANAGER).clone() } /// The auto-cacher, creating it on first access (C++ `get_cacher`). pub fn get_cacher(&self) -> MutexGuard<'_, Option> { let mut guard = lock(&self.autocacher); if guard.is_none() { *guard = Some(PreviewAutoCacher::new(self.tickets.clone())); } guard } /// Shut down: cancel tickets, drain both dispatch backends. pub fn shutdown() { let manager = lock(&MANAGER).take(); if let Some(manager) = manager { // Mark stopping FIRST: from here on `set_graph_snapshot`, // `clear_graph_snapshot` and `poll` become no-ops, so a // concurrent app thread racing the teardown cannot re-arm the // dispatcher after it is drained. manager.stopping.store(true, Ordering::Release); manager.tickets.cancel_all(); // Drain after the cancels so queued completions fire. Both // dispatches are idempotent. manager.dispatch.shutdown(); manager.audio_dispatch.shutdown(); // Release the graph snapshot (the file is retained: a worker // may still hold the path for a late load_graph). if let Some(path) = lock(&manager.current_snapshot).take() { manager.snapshots.release(&path); } // The store directory is cleared here and only here — no worker // can reference a snapshot file once the dispatchers are down. manager.snapshots.cleanup(); drop(manager); } } /// M16 S1 graph mode: snapshot the project to the worker pool. The /// snapshot is serialized once per (project, revision) — the undo-stack /// position; the key includes the project's uuid because fresh projects /// reuse small identity numbers and two projects at the same revision /// would otherwise collide on one file (the cross-project snapshot race /// that shipped before M16 S1). A new key rewrites the file and /// re-sends `load_graph` to every live worker, releasing the previous /// snapshot (file retained at zero refs). pub fn set_graph_snapshot( &self, project: &std::sync::Mutex, revision: u64, ) -> Result<()> { if self.stopping.load(Ordering::Acquire) { return Ok(()); // teardown: no re-arm after the drain } let uuid = lock(project).uuid.clone(); if *lock(&self.current_key) == Some((uuid.clone(), revision)) { return Ok(()); // unchanged state: no rewrite, no re-send } let path = self.snapshots.acquire(project, revision)?; if let Some(old) = lock(&self.current_snapshot).replace(path.clone()) { self.snapshots.release(&old); } self.dispatch.set_graph_snapshot(Some(path)); *lock(&self.current_key) = Some((uuid, revision)); Ok(()) } /// M16 S1 graph mode: drop the current snapshot (project closed). The /// protocol has no clear message, so alive workers keep their loaded /// graph; new/restarted workers no longer load it and the snapshot file /// is retained (removed wholesale at manager shutdown). pub fn clear_graph_snapshot(&self) { if self.stopping.load(Ordering::Acquire) { return; } if let Some(old) = lock(&self.current_snapshot).take() { self.snapshots.release(&old); } *lock(&self.current_key) = None; self.dispatch.set_graph_snapshot(None); } /// Pump the video backend's control plane (M15 S2): the process /// dispatcher delivers ticket completions from its poll loop, so the /// UI tick and any blocking wait must call this regularly. No-op on /// backends that deliver inline. pub fn poll(&self) { self.dispatch.poll(); } /// Release a consumed shm frame's slot back to its worker (M15 S2 /// zero-copy onscreen path: slot release = cache eviction). No-op on /// backends that hold no slots. pub fn release_frame(&self, frame: &ShmFrameRef) { self.dispatch.release_frame(frame); } /// Release a consumed shm audio frame's slot (M15 S3; see /// [`RenderManager::release_frame`]). pub fn release_audio_frame(&self, frame: &ShmAudioRef) { self.dispatch.release_audio_frame(frame); } /// Cancel every pending AND claimed frame of `sequence` (M15 S2 /// preview-window invalidation); their completions fire with /// `Error::State`. No-op on backends that schedule no window. pub fn cancel_preview_sequence(&self, sequence: u64) { self.dispatch.cancel_preview_sequence(sequence); } /// 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 { oak_common::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 { 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 { 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. The manager tests use the test-only inline // backend (the process backend spawns real oak-worker children). RenderManager::shutdown(); RenderManager::init_with_backend(RenderBackendChoice::Threads).unwrap(); assert!(RenderManager::global().is_some()); // Idempotence: second init is a state error. assert_eq!( RenderManager::init_with_backend(RenderBackendChoice::Threads) .unwrap_err() .code(), Error::State.code() ); RenderManager::shutdown(); assert!(RenderManager::global().is_none()); // Re-init works after shutdown (C++ destroy_instance semantics). RenderManager::init_with_backend(RenderBackendChoice::Threads).unwrap(); RenderManager::shutdown(); } #[test] fn aggressive_gc_toggle() { let _lock = manager_lock(); RenderManager::shutdown(); RenderManager::init_with_backend(RenderBackendChoice::Threads).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_with_backend(RenderBackendChoice::Threads).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::commonutil::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::commonutil::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"); } }