// 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 worker runtime — the Rust port of //! `engine/src/capi/worker.cpp`, owned by the oak-worker binary since //! M14 R2 (the facade keeps its own copy for the frozen //! `oakengine_worker_*` C ABI). //! //! - **Backend selection.** [`Renderer::create`] initializes the render //! backend through the oakrender crate's direct Rust API //! ([`oak_core::backend::DisplayRenderer`]), falling back to the //! direct OpenGL renderer exactly like the C++ `create_renderer()` //! chain. The headless `"cpu"` backend (M15 S1) skips the renderer //! entirely — the render path is CPU evaluation + decode, driven //! through `render_batch`. //! - **The session.** [`WorkerSession`] holds the renderer, the //! loaded node graph, the shared-memory frame-slot pools //! ([`crate::ipc::FrameSlotPool`]) and the shutdown flag, and answers //! one NDJSON control message at a time. //! - **The main loop.** [`worker_main`] creates the session, loads the //! runtime config (including the oakplugin render executor), writes //! the startup handshake, and serves the stdin/stdout NDJSON loop //! until a `shutdown` message or EOF. The worker is single-threaded //! by design — there is no render thread: a `render_batch` renders //! its tickets synchronously on this loop thread, and parallelism //! comes from the main process's worker pool //! (`oak_render::procpool`), not from threads here (see "Where the //! rendering happens" in this crate's README). //! //! Real rendering landed in M15 S1: `load_graph` deserializes the graph //! snapshot file (oaknode project XML, with the minimal //! `{"project_copy":N}` payload fallback); `render_frame` and //! `render_batch` render through [`oak_render::eval`] (generated frames, //! footage decode, montage compositing) directly into the main-assigned //! shm slots and publish `frame_ready` / `frame_failed` (protocol v2). use std::io::{self, BufRead, Write}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; use serde_json::{json, Value}; use oak_core::backend::{BackendKind, DisplayRenderer}; use oak_core::{PixelFormat, Rational}; use oak_render::eval; use oak_render::ticket::{AdjustmentSpan, AudioTicketParams, MontageClip, VideoTicketParams}; use crate::framecache::FrameCache; use crate::ipc::{ error_message, write_message, AudioTicketSpec, BatchTicketSpec, FrameSlotMeta, FrameSlotPool, HandshakeMsg, LoadGraphMsg, PluginProgressMsg, RenderAudioBatchMsg, RenderBatchMsg, RenderFrameMsg, SharedMemoryRegion, ShmMode, SLOT_FORMAT_AUDIO_F32, SLOT_FORMAT_BGRA8, TYPE_CANCEL, TYPE_HANDSHAKE, TYPE_LOAD_GRAPH, TYPE_PLUGIN_CANCEL, TYPE_RENDER_AUDIO_BATCH, TYPE_RENDER_BATCH, TYPE_RENDER_FRAME, TYPE_SHUTDOWN, }; use crate::{log_error, PROTOCOL_VERSION}; /// Serializes unit tests that install or drive the process-global /// plugin/progress factories (the `ofx_host` tests share this lock: the /// worker's and the host's reporter factories are process-wide). #[cfg(test)] pub(crate) static GLOBAL_FACTORY_TEST_LOCK: Mutex<()> = Mutex::new(()); /// A loaded graph snapshot (M15 S1): the snapshot file path plus what it /// deserialized into — a full oaknode project, or only the copied-project /// identity (the minimal `{"project_copy":N}` payload the /// [`oak_render::worker::GraphSnapshotStore`] wrote before M16 S1 shipped /// full graph snapshots). struct LoadedGraph { /// Snapshot file path (S2: graph_update diffing is path-based). #[allow(dead_code)] path: String, /// The deserialized project (M16 S1: node-graph tickets render the /// viewer's frame from here; montage/footage/generate tickets use the /// loaded context too). project: Option>>, /// The loaded snapshot's owning project uuid; `None` for the /// identity-only legacy payload. Graph-mode tickets (which carry their /// owning project's uuid) only render from this graph when it matches — /// a stale snapshot from a different project must not answer foreign /// viewer identities (M16 S1 cross-process snapshot race). project_uuid: Option, /// Source-identity -> loaded-id map (see /// [`oak_node::serializer::load_with_id_map`]): tickets carry viewer /// identities from the *saved* project, while the loaded graph's arena /// slots differ (deleted slots shift every later node). id_map: std::collections::HashMap, project_copy: u64, } // --------------------------------------------------------------------------- // Worker-side plugin-progress forwarding // --------------------------------------------------------------------------- // // OFX plugin rendering happens in this process (crash isolation), so the // main process's inline progress reporter is not in effect here. The // worker installs its own progress reporter factory (see // [`install_worker_progress_factory`]) whose reporters push `plugin_progress` // NDJSON events into [`WORKER_PROGRESS_EVENTS`]; the main loop drains the // buffer after each control message ([`flush_worker_progress`]). Plugin // renders are synchronous on the loop thread, so the buffer only ever // mutates there — a plain `Mutex` suffices. // // Cancel: the main process broadcasts `plugin_cancel` (the progress // dialog's Cancel button); the worker sets [`WORKER_PLUGIN_CANCEL`] and // every live reporter answers false (the plugin aborts at its next // progressUpdate). Mirrors the main-process reporter factory: a fresh // render (progressStart) resets the sticky flag. Because the worker // processes control messages between batches, an in-flight frame // completes before the cancel is observed (batch granularity); the main // process stops the render loop separately (export cancel atom / preview // window invalidation). /// The worker's sticky plugin-cancel flag (set by the `plugin_cancel` /// control message, read by every live progress reporter). static WORKER_PLUGIN_CANCEL: AtomicBool = AtomicBool::new(false); /// Buffered `plugin_progress` events awaiting the next flush. static WORKER_PROGRESS_EVENTS: Mutex> = Mutex::new(Vec::new()); /// Queue one `plugin_progress` NDJSON event for the main loop to flush. fn push_worker_progress(fraction: f64, label: &str, message: &str) { let event = PluginProgressMsg { label: label.to_string(), message: message.to_string(), fraction, } .to_json(); WORKER_PROGRESS_EVENTS .lock() .unwrap_or_else(|e| e.into_inner()) .push(event); } /// Write every buffered progress event to `out` (called by the main loop /// after each control message; `out` is the NDJSON stdout writer). fn flush_worker_progress(out: &mut impl Write) { let events = std::mem::take( &mut *WORKER_PROGRESS_EVENTS .lock() .unwrap_or_else(|e| e.into_inner()), ); for event in events { if write_message(out, &event).is_err() { break; } } let _ = out.flush(); } /// The worker-side `UiProgressReporter`: forwards (label, message, /// fraction) to the main process and honours the sticky cancel flag. struct WorkerProgressReporter { label: String, message: String, } impl oak_plugin::progress::UiProgressReporter for WorkerProgressReporter { fn update(&mut self, progress: f64) -> bool { push_worker_progress(progress, &self.label, &self.message); !WORKER_PLUGIN_CANCEL.load(Ordering::Relaxed) } fn end(&mut self) { // progressEnd: forward completion (fraction 1.0) so the app closes // the progress dialog without waiting for a 1.0 update. push_worker_progress(1.0, &self.label, &self.message); } } /// Install the worker-side progress reporter factory. Called from /// [`WorkerSession::initialize_runtime`]; mirrors the main-process factory /// (a fresh progressStart resets the sticky cancel flag). fn install_worker_progress_factory() { oak_plugin::progress::set_reporter_factory(Some(Arc::new(|label, message| { // A fresh render begins: reset the sticky cancel flag. WORKER_PLUGIN_CANCEL.store(false, Ordering::Relaxed); push_worker_progress(0.0, label, message); Box::new(WorkerProgressReporter { label: label.to_string(), message: message.to_string(), }) }))); } // --------------------------------------------------------------------------- // Renderer (backend selection) // --------------------------------------------------------------------------- /// Whether `backend` requests no renderer (worker.cpp /// `backend_requests_no_renderer()`: NULL, "" and "none"). pub fn is_no_backend(backend: &str) -> bool { backend.is_empty() || backend.eq_ignore_ascii_case("none") } /// Whether `backend` is the M15 headless CPU render mode: like "none" /// (no GPU renderer) but the session stays fully operational — frames /// render through the CPU evaluation path ([`oak_render::eval`]). pub fn is_cpu_backend(backend: &str) -> bool { backend.eq_ignore_ascii_case("cpu") } /// A live, initialized oakrender display renderer (destroyed on drop). pub struct Renderer { /// The oakrender crate's value-typed display renderer (single-lib /// unification; the CHandle-based C ABI is deleted). inner: DisplayRenderer, } impl Renderer { /// Create and initialize a renderer through the oakrender crate's /// direct Rust API, trying the named dynamic backend first and falling /// back to the direct OpenGL renderer — the exact fallback chain of /// worker.cpp `create_renderer()`. pub fn create(backend: &str) -> Result { match Self::create_dynamic(backend) { Ok(r) => Ok(r), Err(first) => { log_error(&format!( "failed to initialize dynamic {backend} backend: {first}; falling back to direct OpenGL renderer" )); Self::create_opengl().map_err(|second| { format!("{first}; direct OpenGL fallback also failed: {second}") }) } } } /// Try the named dynamic backend (`DisplayRenderer::new` + /// `init`, the single-lib equivalent of /// `oakrender_display_renderer_create_dynamic` + `_init`). fn create_dynamic(backend: &str) -> Result { let renderer = DisplayRenderer::new(BackendKind::from_config_string(backend)); Self::init_inner(renderer, &format!("dynamic {backend}")) } /// Fall back to the direct OpenGL renderer. fn create_opengl() -> Result { let renderer = DisplayRenderer::new(BackendKind::Gl); Self::init_inner(renderer, "direct OpenGL") } /// Initialize a freshly created renderer. fn init_inner(mut renderer: DisplayRenderer, what: &str) -> Result { // NULL gl_context makes the backend use its default device/context // path. if let Err(e) = renderer.init(std::ptr::null_mut()) { return Err(format!("failed to initialize {what} renderer ({e})")); } Ok(Renderer { inner: renderer }) } /// 1 when the renderer is OpenGL-based (the C++ worker uses the GL /// context to announce the negotiated GL version in the handshake). /// /// Not called yet: the oakrender module exposes no GL context /// version, so the startup handshake omits `gl_major`/`gl_minor`. #[allow(dead_code)] pub fn is_open_gl(&self) -> bool { self.inner.is_open_gl() } } // --------------------------------------------------------------------------- // WorkerSession // --------------------------------------------------------------------------- /// The worker-side session state machine — the Rust mirror of /// `OakWorkerSession` in worker.cpp. Holds the renderer, the attached /// shared-memory frame-slot pools and the shutdown flag, and answers one /// NDJSON control message at a time. pub struct WorkerSession { renderer: Option, shutdown_requested: bool, runtime_initialized: bool, output_region: Option, output_pool: Option, input_region: Option, input_pool: Option, graph: Option, /// Reusable F32 staging buffer (BGRA8 slot conversion; the F32 /// pipeline renders there before the end-of-pipe format convert). f32_scratch: Vec, /// LRU byte-budgeted memo of rendered F32 frames, keyed by the /// render-deterministic ticket-spec subset (M16 S2; see /// [`crate::framecache`]). frame_cache: FrameCache, } impl WorkerSession { /// Create a session for `backend`, mirroring /// `oakengine_worker_session_create()`: "none"/"" skips renderer /// creation, anything else initializes the render backend through the /// oakrender crate's direct Rust API (dynamic -> OpenGL fallback). /// The M15 `"cpu"` backend is the headless render mode: no renderer, /// CPU evaluation + decode via [`oak_render::eval`]. M16 S1: a failed /// renderer init for any other backend (e.g. "auto" on a GPU-less /// host) is tolerated — the session continues headless and tickets /// evaluate through the CPU path. pub fn create(backend: &str) -> Result { let renderer = match backend { b if is_no_backend(b) || is_cpu_backend(b) => None, b => match Renderer::create(b) { Ok(r) => Some(r), Err(e) => { log_error(&format!( "session: {b} renderer init failed ({e}); continuing headless (CPU eval path)" )); None } }, }; Ok(WorkerSession { renderer, shutdown_requested: false, runtime_initialized: false, output_region: None, output_pool: None, input_region: None, input_pool: None, graph: None, f32_scratch: Vec::new(), frame_cache: FrameCache::new(), }) } /// 1 when the session holds a successfully initialized render backend. pub fn has_renderer(&self) -> bool { self.renderer.is_some() } /// 1 once a shutdown control message has been received. pub fn shutdown_requested(&self) -> bool { self.shutdown_requested } /// Load the runtime services the session depends on — the Rust analog /// of the C++ `initialize_runtime()`. Of the C++ list (config, node /// factory, color manager, frame/disk managers, project serializer) /// only the color-manager default config has a Rust backing linked into /// the worker binary; the rest are logged and skipped. Always returns /// true (the C++ returns true unconditionally). pub fn initialize_runtime(&mut self) -> bool { if self.runtime_initialized { return true; } log_error("runtime: loading color-manager default config"); if let Err(e) = oak_core::color::set_up_default_config() { log_error(&format!( "runtime: color-manager default config failed ({e}); continuing" )); } // The text nodes rasterize through the textbackend hooks: the pool // renders text clips too, so every worker installs the same // cosmic-text engine as the UI process (installing it only there // left text invisible in previews — the hooks are per-process). log_error("runtime: installing text layout backends"); oak_render::textengine::install(); // M15 S1: the plugin execution stack lives in the worker process // (OFX crashes take down this process, not the editor — design // §3.6). oakplugin installs its render driver into the oakrender // executor slot. log_error("runtime: installing oakplugin render executor"); oak_plugin::node_factory::install_render_executor(); // M15 S2: graphs carrying OFX plugin nodes deserialize/evaluate in // the worker too, so the per-process node factory must register the // discovered plugins exactly like the main process. A failed scan is // non-fatal: the worker stays up for plugin-free graphs. log_error("runtime: scanning and registering OFX plugins"); if let Err(e) = oak_plugin::host::Host::global().cache.scan() { log_error(&format!( "runtime: OFX plugin scan failed ({e}); continuing" )); } let discovered = oak_plugin::host::Host::global().cache.count(); let registered = oak_plugin::node_factory::register_plugin_nodes(); log_error(&format!( "runtime: discovered {} OFX plugin(s), registered {} node type(s)", discovered, registered.len() )); // Worker-side plugin progress forwarding (see the module docs): the // plugin progress suite runs in this process, so progress events // must cross the IPC boundary to reach the app's progress dialog. log_error("runtime: installing worker plugin-progress reporter factory"); install_worker_progress_factory(); log_error( "runtime: config / frame manager / disk manager / project \ serializer have no Rust backing in the worker binary; skipped", ); self.runtime_initialized = true; true } /// The startup handshake the worker sends to its parent /// (`worker.cpp startup_handshake()`): protocol version 1 and empty /// shared-memory geometry — the parent creates the segments and /// announces their geometry in its handshake reply. /// /// Deviation from the C++: `gl_major`/`gl_minor` are omitted because /// the oakrender module exposes no GL context version. pub fn startup_handshake(&self) -> Value { HandshakeMsg { protocol_version: PROTOCOL_VERSION, shm_key: String::new(), input_shm_key: String::new(), input_slots: 0, output_slots: 0, slot_data_bytes: 0, input_slot_data_bytes: 0, } .to_json() } /// Handle one complete NDJSON control line and produce the response, if /// any — the port of worker.cpp `handle()`. A malformed line yields an /// error response (the loop continues), never a failure. pub fn handle_line(&mut self, line: &str) -> Option { let msg: Value = match serde_json::from_str::(line) { Ok(v) if v.is_object() => v, _ => return Some(error_message("malformed control message", None)), }; let typ = msg.get("type").and_then(Value::as_str).unwrap_or(""); match typ { TYPE_HANDSHAKE => self.handle_handshake(&msg), TYPE_LOAD_GRAPH => self.handle_load_graph(&msg), TYPE_RENDER_FRAME => self.handle_render_frame(&msg), // cancel: the worker does synchronous single-frame work // (nothing in flight), so a cancel produces no response. TYPE_CANCEL => None, // plugin_cancel: the user cancelled the plugin render; sticky // until the next progressStart resets it (main-process // semantics). TYPE_PLUGIN_CANCEL => { WORKER_PLUGIN_CANCEL.store(true, Ordering::Relaxed); None } TYPE_SHUTDOWN => { self.shutdown_requested = true; None } other => Some(error_message( &format!("unknown message type: {other}"), None, )), } } /// `handshake`: validate and attach the shared-memory frame-slot pools /// — the real port of worker.cpp `attach_output_pool()`. fn handle_handshake(&mut self, msg: &Value) -> Option { let hs: HandshakeMsg = match serde_json::from_value(msg.clone()) { Ok(hs) => hs, Err(_) => return Some(error_message("invalid handshake message", None)), }; if hs.protocol_version != PROTOCOL_VERSION { return Some(error_message( &format!("unsupported protocol version {}", hs.protocol_version), None, )); } if hs.shm_key.is_empty() || hs.output_slots <= 0 || hs.slot_data_bytes <= 0 { return Some(error_message( "handshake missing output shared-memory geometry", None, )); } // A re-handshake replaces the pools (worker.cpp resets the input // pool before attaching the output). self.input_pool = None; self.input_region = None; self.output_pool = None; self.output_region = None; let bytes = FrameSlotPool::bytes_needed(hs.output_slots as u32, hs.slot_data_bytes as usize); let mut output_region = SharedMemoryRegion::new(); if !output_region.open(&hs.shm_key, bytes, ShmMode::Attach) { return Some(error_message( &format!("failed to attach shared memory: {}", output_region.error()), None, )); } // SAFETY: `output_region` is a live mapping of at least `bytes` // bytes (checked above). let output_pool = unsafe { FrameSlotPool::attach(output_region.data()) }; if !output_pool.is_valid() { return Some(error_message( "shared memory does not contain a frame slot pool", None, )); } self.output_region = Some(output_region); self.output_pool = Some(output_pool); if hs.input_slots > 0 { if hs.input_shm_key.is_empty() || hs.input_slot_data_bytes <= 0 { return Some(error_message( "handshake missing input shared-memory geometry", None, )); } let input_bytes = FrameSlotPool::bytes_needed( hs.input_slots as u32, hs.input_slot_data_bytes as usize, ); let mut input_region = SharedMemoryRegion::new(); if !input_region.open(&hs.input_shm_key, input_bytes, ShmMode::Attach) { return Some(error_message( &format!( "failed to attach input shared memory: {}", input_region.error() ), None, )); } // SAFETY: `input_region` is a live mapping of at least // `input_bytes` bytes (checked above). let input_pool = unsafe { FrameSlotPool::attach(input_region.data()) }; if !input_pool.is_valid() { return Some(error_message( "input shared memory does not contain a frame slot pool", None, )); } self.input_region = Some(input_region); self.input_pool = Some(input_pool); } // Success: protocol v2 answers the geometry handshake with the // capability announcement (worker.cpp left the response empty; // main now waits for hello_caps to mark the worker alive). Some(json!({ "type": crate::ipc::TYPE_HELLO_CAPS, "protocol_version": PROTOCOL_VERSION, "formats": [PixelFormat::F32 as i32, SLOT_FORMAT_BGRA8, SLOT_FORMAT_AUDIO_F32], "max_slot_bytes": hs.slot_data_bytes, })) } /// `load_graph` (M15 S1): the file checks mirror worker.cpp; the /// payload is deserialized for real — an oaknode project XML /// ([`oak_node::serializer::load`]) or the minimal /// `{"project_copy":N}` identity payload written by the snapshot /// store before full graph uploads land in S2. Success answers /// nothing (v1 semantics); failures answer an `error` message. fn handle_load_graph(&mut self, msg: &Value) -> Option { let load: LoadGraphMsg = match serde_json::from_value(msg.clone()) { Ok(l) => l, Err(_) => return Some(error_message("invalid load_graph message", None)), }; match std::fs::metadata(&load.path) { Err(_) => Some(error_message( &format!("graph file does not exist: {}", load.path), None, )), Ok(md) if md.len() == 0 => Some(error_message( &format!("graph file is empty: {}", load.path), None, )), Ok(md) => { log_error(&format!( "LoadGraph: loading {} ({} bytes)", load.path, md.len() )); let content = match std::fs::read_to_string(&load.path) { Ok(c) => c, Err(e) => { return Some(error_message(&format!("graph file unreadable: {e}"), None)) } }; match oak_node::serializer::load_with_id_map(&content) { Ok((project, id_map)) => { let (working, output_spec) = { let guard = project.lock().unwrap_or_else(|e| e.into_inner()); (guard.working_color_space(), guard.output_color_spec()) }; let project_uuid = project .lock() .unwrap_or_else(|e| e.into_inner()) .uuid .clone(); self.graph = Some(LoadedGraph { path: load.path.clone(), project: Some(project), project_uuid: Some(project_uuid), id_map, project_copy: 0, }); // The project's color pipeline properties drive this // process's input/output transforms (the oakrender // process global read by eval + the output node). oak_core::color::set_pipeline_color_settings(working, output_spec); // A fresh graph snapshot can change what any viewer // identity renders; cached pixels from the previous // graph must not be served (M16 S2 frame cache). self.frame_cache.clear(); log_error("LoadGraph: oaknode project deserialized"); None } Err(graph_err) => { // Fallback: the snapshot store's minimal payload // `{"project_copy":N}` (identity-only graph context). if let Ok(v) = serde_json::from_str::(&content) { if let Some(pc) = v.get("project_copy").and_then(Value::as_u64) { self.graph = Some(LoadedGraph { path: load.path.clone(), project: None, project_uuid: None, id_map: std::collections::HashMap::new(), project_copy: pc, }); // See the full-snapshot branch: any reload // invalidates the frame cache (M16 S2). self.frame_cache.clear(); log_error(&format!( "LoadGraph: identity-only snapshot (project_copy {pc})" )); return None; } } Some(error_message( &format!("graph deserialization failed: {graph_err}"), None, )) } } } } } /// `render_frame` (v1 single-frame path, M15 S1 real): generate the /// frame through [`oak_render::eval`], write it into an acquired shm /// slot and answer `frame_ready`. The v1 message carries no montage /// or footage fields, so this path renders the pipeline's generated /// frame; montage/footage tickets arrive via `render_batch`. fn handle_render_frame(&mut self, msg: &Value) -> Option { let render: RenderFrameMsg = match serde_json::from_value(msg.clone()) { Ok(r) => r, Err(_) => return Some(error_message("invalid render_frame message", None)), }; let pool = match self.output_pool.as_ref() { Some(p) if p.is_valid() => p, _ => { return Some(error_message( "render_frame: no shared-memory pool attached", Some(render.ticket), )) } }; let (w, h) = if render.width > 0 && render.height > 0 { (render.width, render.height) } else { ( oak_core::frame::VideoParamsPod::DEFAULT_WIDTH, oak_core::frame::VideoParamsPod::DEFAULT_HEIGHT, ) }; let format = if render.format < 0 { PixelFormat::F32 } else { match render.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, other => { return Some(error_message( &format!("render_frame: unsupported format {other}"), Some(render.ticket), )) } } }; let time = Rational::new(render.time_num, render.time_den); let frame = match eval::generate_frame(time, (w, h), format) { Ok(f) => f, Err(e) => { return Some(error_message( &format!("render_frame: generation failed: {e}"), Some(render.ticket), )) } }; let slot = match self.acquire_slot(pool) { Some(s) => s, None => { return Some(error_message( "render_frame: no free shm slot", Some(render.ticket), )) } }; // Write meta + pixels into the slot, then publish. let data_size = frame.data.len(); if data_size > pool.slot_data_bytes() { return Some(error_message( "render_frame: frame larger than the shm slot", Some(render.ticket), )); } // SAFETY: `slot` was acquired; the slot block and meta are live // shared memory of the attached pool. unsafe { std::ptr::copy_nonoverlapping(frame.data.as_ptr(), pool.slot_data(slot), data_size); let meta = &mut *pool.meta(slot); *meta = FrameSlotMeta::default(); meta.id = render.ticket; meta.time_num = render.time_num; meta.time_den = render.time_den; meta.width = w; meta.height = h; meta.format = format as i32; meta.channel_count = 4; meta.linesize = frame.linesize_bytes() as i32; meta.data_size = data_size as i32; if !pool.publish(slot) { return Some(error_message( "render_frame: ready ring full", Some(render.ticket), )); } } Some(json!({ "type": crate::ipc::TYPE_FRAME_READY, "ticket": render.ticket, "slot": slot, })) } /// `render_batch` (protocol v2; M15 S1): claim confirmation followed /// by in-order rendering of every ticket into its main-assigned shm /// slot. Responses stream to `out`: one `batch_accepted`, then one /// `frame_ready` or `frame_failed` per ticket. Crashes the process /// deliberately when the crash-mode environment asks for it (the /// crash-isolation test hook). fn handle_render_batch_stream(&mut self, line: &str, out: &mut impl Write) -> io::Result<()> { let batch: RenderBatchMsg = match serde_json::from_str(line) { Ok(b) => b, Err(_) => { return write_message(out, &error_message("invalid render_batch message", None)) } }; // Explicit claim confirmation (design §3.3): these tickets are // owned by this worker now — no work stealing. The `type` tag is // built by hand because [`BatchAcceptedMsg`] only carries the // payload fields. let accepted = json!({ "type": crate::ipc::TYPE_BATCH_ACCEPTED, "batch_id": batch.batch_id, "tickets": batch.tickets.iter().map(|t| t.ticket).collect::>(), }); write_message(out, &accepted)?; out.flush()?; for spec in &batch.tickets { // Crash-isolation test hook: OAK_WORKER_CRASH_ON_TICKET= // segfaults while rendering ticket n. A marker file (env // OAK_WORKER_CRASH_MARKER) makes the crash one-shot so the // restarted worker renders the frame for real. self.maybe_crash_for_testing(spec.ticket); let response = match self.render_ticket_to_slot(spec) { Ok(slot) => json!({ "type": crate::ipc::TYPE_FRAME_READY, "ticket": spec.ticket, "slot": slot, }), Err(e) => { log_error(&format!("render_batch: ticket {} failed: {e}", spec.ticket)); json!({ "type": crate::ipc::TYPE_FRAME_FAILED, "ticket": spec.ticket, "error": e, }) } }; write_message(out, &response)?; out.flush()?; } Ok(()) } /// The crash-mode test hook (see [`Self::handle_render_batch_stream`]). fn maybe_crash_for_testing(&self, ticket: i64) { let Ok(want) = std::env::var("OAK_WORKER_CRASH_ON_TICKET") else { return; }; let Ok(crash_on) = want.parse::() else { return; }; if crash_on != ticket { return; } let marker = std::env::var("OAK_WORKER_CRASH_MARKER").ok(); let should_crash = match &marker { Some(path) => !std::path::Path::new(path).exists(), None => true, }; if !should_crash { return; } if let Some(path) = &marker { let _ = std::fs::write(path, b"crashed"); } log_error(&format!("crash mode: dying on ticket {ticket}")); // Raise SIGSEGV like a real plugin crash; abort as the fallback. unsafe { libc::raise(libc::SIGSEGV) }; std::process::abort(); } /// Acquire a free output slot, polling until one appears (the free /// ring is the only filler-side entry point; flow control). `None` /// on shutdown or after the 30 s safety deadline. fn acquire_slot(&self, pool: &FrameSlotPool) -> Option { let deadline = Instant::now() + Duration::from_secs(30); let mut slot = 0u32; loop { if self.shutdown_requested { return None; } // SAFETY: valid attached pool; the worker is the filler, so // popping the free ring is its SPSC role. if unsafe { pool.acquire(&mut slot) } { return Some(slot); } if Instant::now() > deadline { return None; } std::thread::sleep(Duration::from_millis(1)); } } /// Render one batch ticket into its main-assigned slot (acquire, /// render, publish). Returns the published slot index. fn render_ticket_to_slot(&mut self, spec: &BatchTicketSpec) -> Result { // Clone the pool view (a cheap mapping-shared copy) so the render // path below can borrow `self` mutably (scratch buffer). let pool = match self.output_pool.clone() { Some(p) if p.is_valid() => p, _ => return Err("no shared-memory pool attached".to_string()), }; // Flow control: acquire through the free ring. Main seeds and // releases slots in assignment order, so the pop yields exactly // the assigned slot — anything else is a protocol violation. let acquired = self .acquire_slot(&pool) .ok_or_else(|| "no free shm slot (shutdown or timeout)".to_string())?; if acquired != spec.slot as u32 { return Err(format!( "slot assignment mismatch: acquired {acquired}, assigned {}", spec.slot )); } let slot = acquired; let result = self.render_spec_pixels(spec, &pool); match result { Ok(()) => { // SAFETY: `slot` was acquired above and rendered into. let published = unsafe { pool.publish(slot) }; if !published { Err("ready ring full".to_string()) } else { Ok(slot) } } // The slot was acquired but never published; main recycles it // when it sees frame_failed (the worker cannot push back to // the free ring — that is the drainer's SPSC role). Err(e) => Err(e), } } /// `render_audio_batch` (protocol v2, M15 S3): claim confirmation /// followed by in-order mixing of every audio range pull into its /// main-assigned shm slot (interleaved f32, wire format /// [`SLOT_FORMAT_AUDIO_F32`]). Responses stream to `out`: one /// `batch_accepted`, then one `frame_ready` or `frame_failed` per /// ticket — the same claim/credit/frame_ready flow as /// [`Self::handle_render_batch_stream`]. Crashes the process /// deliberately when the crash-mode environment asks for it (the /// audio crash-isolation test hook; the audio slot geometry check is /// below the video one in `handle_line`). fn handle_render_audio_batch_stream( &mut self, line: &str, out: &mut impl Write, ) -> io::Result<()> { let batch: RenderAudioBatchMsg = match serde_json::from_str(line) { Ok(b) => b, Err(_) => { return write_message( out, &error_message("invalid render_audio_batch message", None), ) } }; let accepted = json!({ "type": crate::ipc::TYPE_BATCH_ACCEPTED, "batch_id": batch.batch_id, "tickets": batch.tickets.iter().map(|t| t.ticket).collect::>(), }); write_message(out, &accepted)?; out.flush()?; for spec in &batch.tickets { self.maybe_crash_for_testing(spec.ticket); let response = match self.render_audio_ticket_to_slot(spec) { Ok(slot) => json!({ "type": crate::ipc::TYPE_FRAME_READY, "ticket": spec.ticket, "slot": slot, }), Err(e) => { log_error(&format!( "render_audio_batch: ticket {} failed: {e}", spec.ticket )); json!({ "type": crate::ipc::TYPE_FRAME_FAILED, "ticket": spec.ticket, "error": e, }) } }; write_message(out, &response)?; out.flush()?; } Ok(()) } /// Mix one audio range pull into its main-assigned slot (acquire, /// mix, publish). Returns the published slot index. fn render_audio_ticket_to_slot(&mut self, spec: &AudioTicketSpec) -> Result { let pool = match self.output_pool.clone() { Some(p) if p.is_valid() => p, _ => return Err("no shared-memory pool attached".to_string()), }; let acquired = self .acquire_slot(&pool) .ok_or_else(|| "no free shm slot (shutdown or timeout)".to_string())?; if acquired != spec.slot as u32 { return Err(format!( "slot assignment mismatch: acquired {acquired}, assigned {}", spec.slot )); } let slot = acquired; let params = self.audio_ticket_params(spec)?; let need = eval::audio_samples_byte_len(¶ms).map_err(|e| e.to_string())?; if need > pool.slot_data_bytes() { // The slot was acquired but never published; main recycles it on // frame_failed (see render_ticket_to_slot). return Err(format!( "audio range needs {need} bytes, slot holds {}", pool.slot_data_bytes() )); } // SAFETY: `slot` was acquired above; the block is live shared memory // of the attached pool. let dst = unsafe { std::slice::from_raw_parts_mut(pool.slot_data(spec.slot as u32), pool.slot_data_bytes()) }; eval::render_audio_samples_into(¶ms, &mut dst[..need]).map_err(|e| e.to_string())?; // SAFETY: slot in range of the attached pool. unsafe { let meta = &mut *pool.meta(spec.slot as u32); *meta = FrameSlotMeta::default(); meta.id = spec.ticket; meta.time_num = spec.time_num; meta.time_den = spec.time_den; // Audio slots reuse the video meta fields: `width` carries the // sample rate, `channel_count`/`linesize` describe the interleaved // layout, `data_size` is the sample bytes (SLOT_FORMAT_AUDIO_F32). meta.width = params.sample_rate; meta.height = 0; meta.format = SLOT_FORMAT_AUDIO_F32; meta.channel_count = params.channel_layout.count_ones().max(1) as i32; meta.linesize = meta.channel_count * 4; meta.data_size = need as i32; } let published = unsafe { pool.publish(slot) }; if !published { Err("ready ring full".to_string()) } else { Ok(slot) } } /// Map a wire audio ticket spec to the eval producer's audio params. fn audio_ticket_params(&self, spec: &AudioTicketSpec) -> Result { if spec.time_den <= 0 || spec.duration_den <= 0 || spec.sample_rate <= 0 { return Err(format!( "bad audio geometry: {}x{}, rate {}", spec.time_den, spec.duration_den, spec.sample_rate )); } let start = Rational::new(spec.time_num, spec.time_den); let duration = Rational::new(spec.duration_num, spec.duration_den); let montage: Vec = spec .montage .iter() .map(|c| MontageClip { filename: c.filename.clone(), stream_index: c.stream_index, in_time: Rational::new(c.in_num, c.in_den), out_time: Rational::new(c.out_num, c.out_den), media_in: Rational::new(c.media_in_num, c.media_in_den), gain: c.gain, effects: c .effects .iter() .map(crate::ipc::montage_effect_from) .collect(), }) .collect(); Ok(AudioTicketParams { viewer: self.graph.as_ref().map(|g| g.project_copy).unwrap_or(0), range: oak_core::TimeRange::new(start, start + duration), sample_rate: spec.sample_rate, channel_layout: spec.channel_layout, montage, }) } /// Refresh the process-global pipeline color settings from the loaded /// project snapshot (the source of truth for what this worker renders). /// Returns true when the settings changed — the caller must drop the /// frame cache, whose F32 bytes were produced under the old settings /// (M16 S2). The resync keeps the global current via `load_graph`; this /// covers tickets in flight before that IPC lands, and mirrors /// [`handle_load_graph`]'s adopt step. fn sync_pipeline_color_from_graph(&mut self) -> bool { let Some(graph) = &self.graph else { return false; }; let Some(project) = &graph.project else { return false; }; let (working, output) = { let guard = project.lock().unwrap_or_else(|e| e.into_inner()); (guard.working_color_space(), guard.output_color_spec()) }; if oak_core::color::pipeline_working_space() == working && oak_core::color::pipeline_output_spec() == output { return false; } oak_core::color::set_pipeline_color_settings(working, output); true } /// Render `spec` into the slot's data block and fill the slot meta. fn render_spec_pixels( &mut self, spec: &BatchTicketSpec, pool: &FrameSlotPool, ) -> Result<(), String> { // Derive the pipeline colors from the loaded project on every render: // eval's decode linearization and the output node below read the // process global, which the resync (load_graph) keeps current — but a // ticket in flight before that IPC lands must not render under stale // colors. A change invalidates the F32 frame cache: cached bytes were // produced under the previous settings (M16 S2). if self.sync_pipeline_color_from_graph() { self.frame_cache.clear(); } let w = spec.width; let h = spec.height; if w <= 0 || h <= 0 { return Err(format!("bad render size {w}x{h}")); } let time = Rational::new(spec.time_num, spec.time_den); let params = self.ticket_params(spec, time); let bgra8 = spec.format == SLOT_FORMAT_BGRA8; let (dst_bpp, dst_linesize) = if bgra8 { (4, w * 4) } else { (16, w * 16) }; let dst_need = (h as usize) * (dst_linesize as usize); if dst_need > pool.slot_data_bytes() { return Err(format!( "frame {}x{} needs {dst_need} bytes, slot holds {}", w, h, pool.slot_data_bytes() )); } let _ = dst_bpp; // SAFETY: `spec.slot` was acquired by render_ticket_to_slot; the // block is live shared memory of the attached pool. let dst = unsafe { std::slice::from_raw_parts_mut(pool.slot_data(spec.slot as u32), pool.slot_data_bytes()) }; // M16 S2 frame cache: memoized F32 pipeline bytes keyed by the // render-deterministic spec subset (ticket/slot/format/channels are // delivery, not picture — an F32 and a BGRA8 request for the same // frame share one entry). A hit is a copy (plus the end-of-pipe // convert for BGRA8 slots); a miss renders as before and memoizes // the F32 bytes. See [`crate::framecache`]. let f32_need = (w as usize) * (h as usize) * 16; let key = crate::framecache::spec_cache_key(spec); let mut cached = self.frame_cache.get(&key).map(|b| b.to_vec()); if let Some(c) = &cached { if c.len() < f32_need { // Defensive: an entry smaller than this geometry is not for // this frame (keys are geometry-signed); re-render. cached = None; } } if !bgra8 { // F32 RGBA: render straight into the slot (no staging copy). match &cached { Some(c) => dst[..f32_need].copy_from_slice(&c[..f32_need]), None => { render_f32_into( spec, ¶ms, &self.graph, time, (w, h), &mut dst[..dst_need], )?; self.frame_cache.insert(key, dst[..f32_need].to_vec()); } } } else { // BGRA8: the F32 pipeline frame comes from the cache or the // session scratch, then converts into the slot (the end-of-pipe // format convert is not an extra frame copy, design §3.1). // Before quantization the output node runs: working space // (ACEScg) → the project's output colorspace, so the 8-bit // pixels carry gamma-encoded display values, not linear light. match cached.as_mut() { Some(c) => { apply_output_node(c, (w * h) as usize); convert_f32_rgba_to_bgra8(&c[..f32_need], &mut dst[..dst_need]); } None => { if self.f32_scratch.len() < f32_need { self.f32_scratch.resize(f32_need, 0); } render_f32_into( spec, ¶ms, &self.graph, time, (w, h), &mut self.f32_scratch[..f32_need], )?; self.frame_cache .insert(key, self.f32_scratch[..f32_need].to_vec()); apply_output_node(&mut self.f32_scratch, (w * h) as usize); convert_f32_rgba_to_bgra8(&self.f32_scratch[..f32_need], &mut dst[..dst_need]); } } } // Slot meta (fresh each publish). // SAFETY: slot in range of the attached pool. unsafe { let meta = &mut *pool.meta(spec.slot as u32); *meta = FrameSlotMeta::default(); meta.id = spec.ticket; meta.time_num = spec.time_num; meta.time_den = spec.time_den; meta.width = w; meta.height = h; meta.format = spec.format; meta.channel_count = spec.channels.max(4); meta.linesize = dst_linesize; meta.data_size = dst_need as i32; } Ok(()) } /// Map a wire ticket spec to the eval producer's ticket params. fn ticket_params(&self, spec: &BatchTicketSpec, time: Rational) -> VideoTicketParams { let footage = if spec.footage_file.is_empty() { None } else { Some((spec.footage_file.clone(), spec.footage_stream)) }; let montage: Vec = spec .montage .iter() .map(|c| MontageClip { filename: c.filename.clone(), stream_index: c.stream_index, in_time: Rational::new(c.in_num, c.in_den), out_time: Rational::new(c.out_num, c.out_den), media_in: Rational::new(c.media_in_num, c.media_in_den), gain: c.gain, effects: c .effects .iter() .map(crate::ipc::montage_effect_from) .collect(), }) .collect(); let adjustments: Vec = spec .adjustments .iter() .map(oak_render::ipc::adjustment_from_wire) .collect(); VideoTicketParams { viewer: if spec.viewer_node != 0 { spec.viewer_node } else { self.graph.as_ref().map(|g| g.project_copy).unwrap_or(0) }, project: spec.project_key.clone(), time, force_size: Some((spec.width, spec.height)), force_format: Some(PixelFormat::F32), cache: None, cache_dir: None, cache_id: None, cache_timebase: None, footage, montage, adjustments, } } } /// Render the F32 RGBA pipeline frame for `spec` into `dst` /// (`(w*h*16)` bytes): graph-mode viewer frame, generated transparent /// black, footage decode, or montage composite — through /// [`oak_render::eval`]. fn render_f32_into( spec: &BatchTicketSpec, params: &VideoTicketParams, graph: &Option, time: Rational, size: (i32, i32), dst: &mut [u8], ) -> Result<(), String> { let (w, h) = size; let stride = w * 16; // M16 S1 graph mode: render the ticket's viewer node from the loaded // snapshot — but only when the snapshot belongs to the ticket's own // project. The ticket carries its owning project's uuid; a stale graph // from a different project (fresh projects reuse small identity // numbers, so a foreign viewer identity can resolve successfully and // silently render the wrong picture) must never answer it — that ticket // falls through to the montage path instead, and is NOT logged (the // mismatch is the normal suite-order case, not an error). A missing // graph / viewer is logged once and also falls back. if spec.viewer_node != 0 { let project_matches = graph .as_ref() .and_then(|g| g.project_uuid.as_deref()) .map(|uuid| uuid == spec.project_key.as_str()) .unwrap_or(false); if project_matches { if let Some(project) = graph.as_ref().and_then(|g| g.project.as_ref()) { // The ticket's viewer is an identity in the *saved* project; // translate through the load map first, then fall back to the // raw packed id (identity-only snapshots, foreign files). let viewer_id = graph .as_ref() .and_then(|g| g.id_map.get(&spec.viewer_node).copied()) .or_else(|| oak_node::id::NodeId::from_identity(spec.viewer_node)); match viewer_id { Some(viewer_id) => { let rendered = eval::render_graph_frame( project, viewer_id, time, (w, h), PixelFormat::F32, ); // M2: the graph renders all-GPU in-process; the worker's // wire format is a CPU shm slot, so this is the explicit // readback boundary of the process backend. let frame = match rendered { Ok(oak_core::texture::Texture::Cpu(frame)) => Some(frame), Ok(texture @ oak_core::texture::Texture::Gpu { .. }) => { match texture.to_frame() { Ok(frame) => Some(frame), Err(e) => { warn_graph_fallback(spec.viewer_node, &e.to_string()); None } } } Ok(oak_core::texture::Texture::Planar(_)) => { // The footage path resolves imported planar // frames before returning; a planar texture // here is a bug, not a frame. warn_graph_fallback( spec.viewer_node, "unresolved planar texture", ); None } Err(e) => { warn_graph_fallback(spec.viewer_node, &e.to_string()); None } }; if let Some(frame) = frame { let src_stride = frame.linesize_bytes(); let row_bytes = (w as usize) * 16; if frame.data.len() < src_stride * (h as usize) || dst.len() < row_bytes * (h as usize) { return Err("graph frame geometry mismatch".to_string()); } for y in 0..h as usize { dst[y * row_bytes..(y + 1) * row_bytes].copy_from_slice( &frame.data[y * src_stride..y * src_stride + row_bytes], ); } return Ok(()); } } None => warn_graph_fallback(spec.viewer_node, "viewer node not in graph"), } } else { warn_graph_fallback(spec.viewer_node, "no loaded graph"); } } } if !params.montage.is_empty() { return eval::render_montage_frame_into(time, params, (w, h), dst, stride) .map_err(|e| format!("montage render: {e}")); } if !spec.footage_file.is_empty() { let decoded = eval::render_footage_frame( &spec.footage_file, spec.footage_stream, time, (w, h), PixelFormat::F32, ) .map_err(|e| format!("footage decode: {e}"))?; // M2: decode stays CPU for now (M5 makes it GPU); an imported GPU // texture would still have to cross into the shm slot here. let frame = match &decoded { oak_core::texture::Texture::Cpu(frame) => frame.clone(), gpu @ oak_core::texture::Texture::Gpu { .. } => gpu .to_frame() .map_err(|e| format!("decode readback: {e}"))?, // Resolved by the footage path; a planar frame cannot cross // into the shm slot without a readback. oak_core::texture::Texture::Planar(_) => { return Err("unresolved planar decode texture".to_string()) } }; let src_stride = frame.linesize_bytes() as usize; let row_bytes = (w as usize) * 16; if frame.data.len() < src_stride * (h as usize) || dst.len() < row_bytes * (h as usize) { return Err("decoded frame geometry mismatch".to_string()); } for y in 0..h as usize { dst[y * row_bytes..(y + 1) * row_bytes] .copy_from_slice(&frame.data[y * src_stride..y * src_stride + row_bytes]); } return Ok(()); } // Generated frame: transparent black. dst[..(h as usize) * (stride as usize)].fill(0); Ok(()) } /// Log (once per process) why a graph-mode ticket fell back to the /// montage path — a missing snapshot, an absent viewer node, or a graph /// render error. AtomicBool keeps a GPU-less or graph-less host from /// spamming the worker log on every frame. fn warn_graph_fallback(viewer: u64, why: &str) { static WARNED: AtomicBool = AtomicBool::new(false); if !WARNED.swap(true, Ordering::Relaxed) { log_error(&format!( "graph-mode ticket viewer {viewer}: {why}; falling back to montage" )); } } /// Convert F32 RGBA (`src`, 16 bytes/px) to 8-bit BGRA (`dst`, 4 /// bytes/px) with clamping — the worker-side end-of-pipe convert for /// BGRA8 preview slots. fn convert_f32_rgba_to_bgra8(src: &[u8], dst: &mut [u8]) { let to_u8 = |v: f32| -> u8 { (v.clamp(0.0, 1.0) * 255.0 + 0.5) as u8 }; let pixels = dst.len() / 4; for i in 0..pixels { let s = &src[i * 16..i * 16 + 16]; let r = f32::from_le_bytes(s[0..4].try_into().unwrap()); let g = f32::from_le_bytes(s[4..8].try_into().unwrap()); let b = f32::from_le_bytes(s[8..12].try_into().unwrap()); let a = f32::from_le_bytes(s[12..16].try_into().unwrap()); let d = &mut dst[i * 4..i * 4 + 4]; d[0] = to_u8(b); d[1] = to_u8(g); d[2] = to_u8(r); d[3] = to_u8(a); } } /// The output node for the BGRA8 delivery path: convert the first /// `pixels` pixels of an F32 RGBA byte buffer from the pipeline working /// space to the project's output colorspace (in place), so the 8-bit /// quantization encodes display-referred values instead of linear light. /// A no-op in the legacy sRGB working space (content already is /// display-referred sRGB). fn apply_output_node(bytes: &mut [u8], pixels: usize) { if oak_core::color::pipeline_working_space() == oak_core::colormath::WorkingColorSpace::SrgbLegacy { return; } let spec = oak_core::color::pipeline_output_spec(); oak_core::colormath::acescg_to_output_bytes(bytes, pixels, spec); } // --------------------------------------------------------------------------- // Main // --------------------------------------------------------------------------- /// Full render-worker main, transport-agnostic in the backend name. /// /// Mirrors `oakengine_worker_main()` in worker.cpp: create the session /// (which initializes the render backend), load the runtime config, write /// the startup handshake, then serve the NDJSON control loop on /// stdin/stdout until a `shutdown` message or EOF. Returns the process /// exit code. pub fn worker_main(backend: &str) -> i32 { // 1. Session creation initializes the render backend through the // oakrender crate's direct Rust API // (oakengine_worker_session_create()). The M15 "cpu" backend is // headless: no renderer, CPU evaluation + decode only. M16 S1: a // failed GPU init for any other backend (e.g. "auto" on a GPU-less // host) is tolerated — the session continues headless. let mut session = match WorkerSession::create(backend) { Ok(s) => s, Err(msg) => { log_error(&msg); return 1; } }; if !session.has_renderer() && is_no_backend(backend) { // Mirrors oakengine_worker_main(): an explicit no-renderer request // (""/"none") leaves nothing to evaluate, so the worker exits 1. // Any other backend failure already logged headless continuation. log_error("no renderer initialized"); return 1; } // 2. Runtime services (config load, plugin executor install). if !session.initialize_runtime() { return 1; } // 3. Startup handshake before the loop (mirrors worker.cpp main). let handshake = session.startup_handshake(); let stdout = io::stdout(); let mut out = io::BufWriter::new(stdout.lock()); if let Err(e) = write_message(&mut out, &handshake) { log_error(&format!("failed to write startup handshake: {e}")); return 1; } if let Err(e) = out.flush() { log_error(&format!("failed to flush startup handshake: {e}")); return 1; } // 4. NDJSON control loop until a shutdown message or EOF. let stdin = io::stdin(); let mut reader = stdin.lock(); let mut line = String::new(); let mut exit_code = 0; while !session.shutdown_requested() { line.clear(); match reader.read_line(&mut line) { Ok(0) => break, // EOF: the parent closed the control pipe. Ok(_) => {} Err(e) => { log_error(&format!("failed to read control line: {e}")); break; } } if line.trim().is_empty() { // Blank lines are skipped silently (read_message() semantics). continue; } // Protocol v2: render_batch streams its responses (one // batch_accepted + one frame_ready/frame_failed per ticket). let typ = serde_json::from_str::(&line) .ok() .and_then(|v| v.get("type").and_then(Value::as_str).map(str::to_string)); match typ.as_deref() { Some(TYPE_RENDER_BATCH) => { if let Err(e) = session.handle_render_batch_stream(&line, &mut out) { log_error(&format!("failed to serve render_batch: {e}")); exit_code = 1; break; } // Plugin progress events buffered during the batch go out now. flush_worker_progress(&mut out); continue; } // M15 S3: render_audio_batch streams the same way (audio range // pulls into shm slots, interleaved f32). Some(TYPE_RENDER_AUDIO_BATCH) => { if let Err(e) = session.handle_render_audio_batch_stream(&line, &mut out) { log_error(&format!("failed to serve render_audio_batch: {e}")); exit_code = 1; break; } flush_worker_progress(&mut out); continue; } _ => {} } if let Some(response) = session.handle_line(&line) { if let Err(e) = write_message(&mut out, &response) { log_error(&format!("failed to write response: {e}")); exit_code = 1; break; } if let Err(e) = out.flush() { log_error(&format!("failed to flush response: {e}")); exit_code = 1; break; } } // Progress events buffered while serving the control message. flush_worker_progress(&mut out); } exit_code } #[cfg(test)] mod tests { use super::*; use crate::ipc::{ FrameSlotPool, SharedMemoryRegion, ShmMode, WireEffectParam, WireMontageClip, WireMontageEffect, WireNodeValue, }; use oak_core::colormath::{OutputColorSpec, WorkingColorSpace}; use oak_node::id::NodeId; use oak_node::project::Project; use oak_node::sequence::SequenceBehavior; use oak_node::track::TrackListBehavior; use serde_json::json; use std::collections::HashMap; use std::ptr; /// Serializes the tests that mutate the process-global pipeline color /// settings (the two pre-existing adopt/sync tests included). static COLOR_TEST_LOCK: Mutex<()> = Mutex::new(()); /// Saves the process-global pipeline color settings and restores them on /// drop, so a panicking assertion cannot leak the override into the next /// serialized test. All users hold [`COLOR_TEST_LOCK`]. struct ColorSettingsGuard { working: oak_core::colormath::WorkingColorSpace, output: oak_core::colormath::OutputColorSpec, } impl ColorSettingsGuard { fn capture() -> ColorSettingsGuard { ColorSettingsGuard { working: oak_core::color::pipeline_working_space(), output: oak_core::color::pipeline_output_spec(), } } } impl Drop for ColorSettingsGuard { fn drop(&mut self) { oak_core::color::set_pipeline_color_settings(self.working, self.output); } } /// Test-only RAII environment override: restores the previous value (or /// absence) on drop, so a panicking assertion cannot leak a crash-mode /// override into the next serialized test. struct EnvGuard { name: &'static str, previous: Option, } impl EnvGuard { fn set(name: &'static str, value: impl AsRef) -> Self { let previous = std::env::var_os(name); std::env::set_var(name, value); Self { name, previous } } } impl Drop for EnvGuard { fn drop(&mut self) { match self.previous.take() { Some(value) => std::env::set_var(self.name, value), None => std::env::remove_var(self.name), } } } fn test_key(name: &str) -> String { static COUNTER: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0); let n = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed); SharedMemoryRegion::make_key(i64::from(std::process::id()), (n & 0x7FFF) as i32) + &format!("-w-{name}") } /// The "parent" side of a handshake: create an output segment holding a /// pool, optionally an input segment, and return the handshake message /// plus the owner regions (kept alive by the caller). fn parent_side( slots: i32, slot_bytes: i64, input: bool, ) -> (Value, SharedMemoryRegion, Option) { let out_key = test_key("out"); let out_bytes = FrameSlotPool::bytes_needed(slots as u32, slot_bytes as usize); let mut out_region = SharedMemoryRegion::new(); let opened = out_region.open(&out_key, out_bytes, ShmMode::Create); let open_error = out_region.error(); assert!(opened, "{open_error}"); // SAFETY: live mapping sized by bytes_needed. let _pool = unsafe { FrameSlotPool::create(out_region.data(), slots as u32, slot_bytes as usize) }; let (in_key, _in_bytes, in_region) = if input { let in_key = test_key("in"); let in_bytes = FrameSlotPool::bytes_needed(slots as u32, slot_bytes as usize); let mut in_region = SharedMemoryRegion::new(); assert!(in_region.open(&in_key, in_bytes, ShmMode::Create)); // SAFETY: live mapping. let _ = unsafe { FrameSlotPool::create(in_region.data(), slots as u32, slot_bytes as usize) }; (Some(in_key), Some(in_bytes), Some(in_region)) } else { (None, None, None) }; let hs = json!({ "type": "handshake", "protocol_version": PROTOCOL_VERSION, "shm_key": out_key, "input_shm_key": in_key.unwrap_or_default(), "input_slots": if input { slots } else { 0 }, "output_slots": slots, "slot_data_bytes": slot_bytes, "input_slot_data_bytes": if input { slot_bytes } else { 0 }, }); (hs, out_region, in_region) } #[test] fn no_backend_detection_matches_cpp() { assert!(is_no_backend("")); assert!(is_no_backend("none")); assert!(is_no_backend("NONE")); assert!(!is_no_backend("opengl")); assert!(!is_no_backend("vulkan")); } #[test] fn none_backend_session_has_no_renderer_but_serves_messages() { let mut s = WorkerSession::create("none").unwrap(); assert!(!s.has_renderer()); let resp = s.handle_line(r#"{"type":"shutdown"}"#); assert!(resp.is_none()); assert!(s.shutdown_requested()); } /// The M15 headless "cpu" backend: no renderer, but the session stays /// fully operational (generated frames render through CPU eval). #[test] fn cpu_backend_session_is_headless_but_operational() { assert!(is_cpu_backend("cpu")); assert!(is_cpu_backend("CPU")); assert!(!is_cpu_backend("auto")); let mut s = WorkerSession::create("cpu").unwrap(); assert!(!s.has_renderer(), "cpu means no GPU renderer"); assert!(!s.shutdown_requested()); assert!(s.handle_line(r#"{"type":"shutdown"}"#).is_none()); assert!(s.shutdown_requested()); } #[test] fn startup_handshake_is_protocol_version_1_with_empty_geometry() { let s = WorkerSession::create("none").unwrap(); let hs = s.startup_handshake(); assert_eq!( hs, json!({ "type": "handshake", "protocol_version": 1, "shm_key": "", "input_shm_key": "", "input_slots": 0, "output_slots": 0, "slot_data_bytes": 0, "input_slot_data_bytes": 0, }) ); } #[test] fn malformed_line_yields_error_response() { let mut s = WorkerSession::create("none").unwrap(); let resp = s.handle_line("this is not json").unwrap(); assert_eq!(resp["type"], "error"); assert_eq!(resp["message"], "malformed control message"); } #[test] fn unknown_message_type_yields_error_response() { let mut s = WorkerSession::create("none").unwrap(); let resp = s.handle_line(r#"{"type":"frobnicate"}"#).unwrap(); assert_eq!(resp["message"], "unknown message type: frobnicate"); } #[test] fn cancel_and_shutdown_produce_no_response() { let mut s = WorkerSession::create("none").unwrap(); assert!(s.handle_line(r#"{"type":"cancel","ticket":5}"#).is_none()); assert!(s.handle_line(r#"{"type":"shutdown"}"#).is_none()); assert!(s.shutdown_requested()); } #[test] fn handshake_wrong_protocol_version() { let mut s = WorkerSession::create("none").unwrap(); let resp = s .handle_line( r#"{"type":"handshake","protocol_version":99,"shm_key":"k","output_slots":1,"slot_data_bytes":16}"#, ) .unwrap(); assert_eq!(resp["message"], "unsupported protocol version 99"); } #[test] fn handshake_missing_geometry() { let mut s = WorkerSession::create("none").unwrap(); let resp = s .handle_line(r#"{"type":"handshake","protocol_version":1}"#) .unwrap(); assert_eq!( resp["message"], "handshake missing output shared-memory geometry" ); } #[test] fn handshake_attaches_real_output_pool() { let mut s = WorkerSession::create("none").unwrap(); let (hs, out_region, _in) = parent_side(4, 4096, false); let resp = s.handle_line(&hs.to_string()).expect("hello_caps response"); // Protocol v2: a successful attach answers the geometry handshake // with the capability announcement. assert_eq!(resp["type"], crate::ipc::TYPE_HELLO_CAPS); assert_eq!(resp["protocol_version"], PROTOCOL_VERSION); let formats: Vec = resp["formats"] .as_array() .unwrap() .iter() .map(|v| v.as_i64().unwrap()) .collect(); assert!(formats.contains(&(PixelFormat::F32 as i64))); assert!(formats.contains(&(SLOT_FORMAT_BGRA8 as i64))); assert_eq!(resp["max_slot_bytes"], 4096); // The session now holds a real attached pool with the parent's // geometry. let out_pool = s.output_pool.as_ref().unwrap(); assert_eq!(out_pool.slot_count(), 4); assert_eq!(out_pool.slot_data_bytes(), 4096); // The two views share the same rings, not copies: the parent pops a // free slot and the worker's pool sees the ring cursor move; the // parent's publish lands in the worker's ready ring. // SAFETY: `out_region` is a live mapping containing the pool the // session attached to. let parent_pool = unsafe { FrameSlotPool::attach(out_region.data()) }; let mut parent_slot = 0; assert!(unsafe { parent_pool.acquire(&mut parent_slot) }); assert_eq!(parent_slot, 0); let mut worker_slot = 0; assert!(unsafe { out_pool.acquire(&mut worker_slot) }); assert_eq!(worker_slot, 1, "worker must see the parent's free-ring pop"); // SAFETY: `parent_slot` was acquired by the parent; slot_bytes // writable. unsafe { ptr::write_bytes(parent_pool.slot_data(parent_slot), 0xAB, 64); } assert!(unsafe { parent_pool.publish(parent_slot) }); let mut consumed = 0; assert!(unsafe { out_pool.consume(&mut consumed) }); assert_eq!(consumed, parent_slot); // SAFETY: `consumed` was consumed by the worker's pool. assert_eq!(unsafe { *out_pool.slot_data_const(consumed) }, 0xAB); // Clean up so the region drop at test end unlinks cleanly. unsafe { out_pool.release(consumed) }; unsafe { out_pool.release(worker_slot) }; } #[test] fn handshake_attaches_input_pool_too() { let mut s = WorkerSession::create("none").unwrap(); let (hs, _out, _in) = parent_side(2, 256, true); let resp = s.handle_line(&hs.to_string()).expect("hello_caps response"); assert_eq!( resp["type"], crate::ipc::TYPE_HELLO_CAPS, "handshake failed: {resp}" ); assert!(s.input_pool.is_some()); let in_pool = s.input_pool.as_ref().unwrap(); assert_eq!(in_pool.slot_count(), 2); assert_eq!(in_pool.slot_data_bytes(), 256); } #[test] fn handshake_attach_failure_reports_error() { let mut s = WorkerSession::create("none").unwrap(); // A key that was never created. let resp = s .handle_line( &json!({ "type": "handshake", "protocol_version": 1, "shm_key": format!("olive-rw-{}-missing", std::process::id()), "output_slots": 4, "slot_data_bytes": 4096, }) .to_string(), ) .unwrap(); assert_eq!(resp["type"], "error"); assert!(resp["message"] .as_str() .unwrap() .starts_with("failed to attach shared memory: ")); assert!(s.output_pool.is_none()); } #[test] fn handshake_rejects_non_pool_segment() { let mut s = WorkerSession::create("none").unwrap(); // A real segment of the right size that does not contain a pool // (zeroed memory → wrong magic). Sized so the attach size check // passes and the magic check fires. let key = test_key("nopool"); let bytes = FrameSlotPool::bytes_needed(4, 4096); let mut region = SharedMemoryRegion::new(); assert!(region.open(&key, bytes, ShmMode::Create)); let resp = s .handle_line( &json!({ "type": "handshake", "protocol_version": 1, "shm_key": key, "output_slots": 4, "slot_data_bytes": 4096, }) .to_string(), ) .unwrap(); assert_eq!( resp["message"], "shared memory does not contain a frame slot pool" ); } #[test] fn handshake_missing_input_geometry_is_an_error() { let mut s = WorkerSession::create("none").unwrap(); let (mut hs, _out, _in) = parent_side(2, 256, false); // Ask for input slots without announcing their geometry. hs["input_slots"] = json!(2); let resp = s.handle_line(&hs.to_string()).unwrap(); assert_eq!( resp["message"], "handshake missing input shared-memory geometry" ); } #[test] fn load_graph_file_checks_then_real_deserialization() { let mut s = WorkerSession::create("none").unwrap(); let missing = "/definitely/not/a/real/graph.ove"; let resp = s .handle_line(&json!({ "type": "load_graph", "path": missing }).to_string()) .unwrap(); assert_eq!( resp["message"], format!("graph file does not exist: {missing}") ); let empty = std::env::temp_dir().join("oak_worker_main_test_empty.ove"); std::fs::write(&empty, b"").unwrap(); let resp = s .handle_line( &json!({ "type": "load_graph", "path": empty.display().to_string() }).to_string(), ) .unwrap(); assert_eq!( resp["message"], format!("graph file is empty: {}", empty.display()) ); let _ = std::fs::remove_file(&empty); // A real oaknode project round-trips through the serializer. let project = oak_node::project::Project::new(); let xml = oak_node::serializer::save(&project.lock().unwrap_or_else(|e| e.into_inner())) .expect("serialize empty project"); let real = std::env::temp_dir().join("oak_worker_main_test_graph.ove"); std::fs::write(&real, &xml).unwrap(); let resp = s.handle_line( &json!({ "type": "load_graph", "path": real.display().to_string() }).to_string(), ); assert!(resp.is_none(), "unexpected error: {resp:?}"); let graph = s.graph.as_ref().expect("graph loaded"); assert!(graph.project.is_some(), "full project deserialized"); let _ = std::fs::remove_file(&real); // The minimal identity-only payload (`{"project_copy":N}`) loads as // a copied-project context. let ident = std::env::temp_dir().join("oak_worker_main_test_identity.ove"); std::fs::write(&ident, r#"{"project_copy":7}"#).unwrap(); let resp = s.handle_line( &json!({ "type": "load_graph", "path": ident.display().to_string() }).to_string(), ); assert!(resp.is_none(), "unexpected error: {resp:?}"); let graph = s.graph.as_ref().expect("graph loaded"); assert!(graph.project.is_none()); assert_eq!(graph.project_copy, 7); let _ = std::fs::remove_file(&ident); // Garbage that is neither project XML nor identity JSON fails // explainably. let bad = std::env::temp_dir().join("oak_worker_main_test_bad.ove"); std::fs::write(&bad, b"definitely not a graph").unwrap(); let resp = s .handle_line( &json!({ "type": "load_graph", "path": bad.display().to_string() }).to_string(), ) .unwrap(); assert!(resp["message"] .as_str() .unwrap() .starts_with("graph deserialization failed: ")); let _ = std::fs::remove_file(&bad); } #[test] fn load_graph_adopts_pipeline_colors_from_snapshot() { use oak_core::colormath::{OutputColorSpec, WorkingColorSpace}; let _guard = COLOR_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); let _colors = ColorSettingsGuard::capture(); // Reset the process global to something different from the snapshot's // settings so the adopt step is observable. oak_core::color::set_pipeline_color_settings( WorkingColorSpace::SrgbLegacy, OutputColorSpec::default(), ); let project = oak_node::project::Project::new(); { let mut guard = project.lock().unwrap_or_else(|e| e.into_inner()); guard.set_working_color_space(WorkingColorSpace::AcesCg); } let xml = oak_node::serializer::save(&project.lock().unwrap_or_else(|e| e.into_inner())) .expect("serialize project"); let path = std::env::temp_dir().join("oak_worker_main_test_acescg.ove"); std::fs::write(&path, &xml).unwrap(); let mut s = WorkerSession::create("none").unwrap(); let resp = s.handle_line( &json!({ "type": "load_graph", "path": path.display().to_string() }).to_string(), ); assert!(resp.is_none(), "unexpected error: {resp:?}"); assert_eq!( oak_core::color::pipeline_working_space(), WorkingColorSpace::AcesCg, "load_graph must adopt the snapshot's working space" ); let _ = std::fs::remove_file(&path); } #[test] fn sync_pipeline_color_from_graph_restores_stale_global() { use oak_core::colormath::WorkingColorSpace; let _guard = COLOR_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); let _colors = ColorSettingsGuard::capture(); let project = oak_node::project::Project::new(); { let mut guard = project.lock().unwrap_or_else(|e| e.into_inner()); guard.set_working_color_space(WorkingColorSpace::AcesCg); } let xml = oak_node::serializer::save(&project.lock().unwrap_or_else(|e| e.into_inner())) .expect("serialize project"); let path = std::env::temp_dir().join("oak_worker_main_test_sync.ove"); std::fs::write(&path, &xml).unwrap(); let mut s = WorkerSession::create("none").unwrap(); let resp = s.handle_line( &json!({ "type": "load_graph", "path": path.display().to_string() }).to_string(), ); assert!(resp.is_none(), "unexpected error: {resp:?}"); // Simulate the app committing new project settings: the process // global flips immediately, but the resync (load_graph re-broadcast) // reaches this worker later. A render ticket in that window must not // run under the stale colors. oak_core::color::set_pipeline_color_settings( WorkingColorSpace::SrgbLegacy, oak_core::color::pipeline_output_spec(), ); assert!( s.sync_pipeline_color_from_graph(), "stale global must be refreshed from the loaded project" ); assert_eq!( oak_core::color::pipeline_working_space(), WorkingColorSpace::AcesCg, "sync must restore the snapshot's working space" ); assert!( !s.sync_pipeline_color_from_graph(), "no change means no frame-cache invalidation" ); let _ = std::fs::remove_file(&path); } #[test] fn acescg_pipeline_round_trip_preserves_srgb_encoded_midgray() { // The ACEScg overexposure bug: F32 bytes produced by the legacy sRGB // pass-through (already sRGB-encoded) are re-encoded by the ACEScg // output transform, applying the sRGB OETF a second time — 0.5 // becomes srgb_oetf(0.5) ≈ 0.735. The correct pipeline linearizes // the code (sRGB EOTF) into ACEScg and then encodes for output, so // the mid-gray comes back near 0.5. use oak_core::colormath::{ acescg_to_output_bytes, decode_to_acescg_bytes, srgb_oetf, OutputColorSpec, OutputGamut, OutputTransfer, SourcePrimaries, SourceTransfer, WorkingColorSpace, }; let spec = OutputColorSpec { gamut: OutputGamut::Srgb, transfer: OutputTransfer::Srgb, }; // Buggy path: treating the already-encoded code as ACEScg linear. let mut double_encoded = [0.5f32, 0.5, 0.5, 1.0]; oak_core::colormath::working_to_display_target( &mut double_encoded, WorkingColorSpace::AcesCg, spec, ); assert!( (double_encoded[0] - srgb_oetf(0.5)).abs() < 1e-4, "mis-encoding 0.5 as ACEScg-linear must yield srgb_oetf(0.5) ≈ 0.735, got {}", double_encoded[0] ); // Correct path: decode (sRGB EOTF) → ACEScg → output encode. let mut bytes = [0.5f32, 0.5, 0.5, 1.0].map(f32::to_le_bytes).concat(); decode_to_acescg_bytes( &mut bytes, 1, SourcePrimaries::Bt709, SourceTransfer::SdrGamma, ); acescg_to_output_bytes(&mut bytes, 1, spec); let out = f32::from_le_bytes(bytes[0..4].try_into().unwrap()); assert!( (out - 0.5).abs() < 1e-3, "correct pipeline must preserve mid-gray, got {out}" ); } #[test] fn render_frame_without_pool_reports_error_with_ticket() { let mut s = WorkerSession::create("none").unwrap(); let resp = s .handle_line(r#"{"type":"render_frame","ticket":123,"node":"abc"}"#) .unwrap(); assert_eq!(resp["type"], "error"); assert_eq!(resp["ticket"], 123); assert_eq!( resp["message"], "render_frame: no shared-memory pool attached" ); } #[test] fn render_frame_v1_renders_generated_frame_into_slot() { let mut s = WorkerSession::create("none").unwrap(); let (hs, out_region, _in) = parent_side(4, 4 * 4 * 16, false); let resp = s.handle_line(&hs.to_string()).expect("hello_caps response"); assert_eq!(resp["type"], crate::ipc::TYPE_HELLO_CAPS); // The v1 single-frame path renders the pipeline's generated frame // (transparent black F32; format -1 = pipeline default) into an // acquired slot and reports it. let resp = s .handle_line( r#"{"type":"render_frame","ticket":123,"time_num":1,"time_den":2,"width":4,"height":4,"format":-1}"#, ) .unwrap(); assert_eq!(resp["type"], "frame_ready", "unexpected: {resp}"); assert_eq!(resp["ticket"], 123); let slot = resp["slot"].as_i64().unwrap() as u32; // The parent (drainer) consumes the published slot and sees the // meta the worker wrote. let parent_pool = unsafe { FrameSlotPool::attach(out_region.data()) }; let mut consumed = 0; assert!(unsafe { parent_pool.consume(&mut consumed) }); assert_eq!(consumed, slot); let meta = unsafe { &*parent_pool.meta_const(consumed) }; assert_eq!(meta.id, 123); assert_eq!(meta.width, 4); assert_eq!(meta.height, 4); assert_eq!(meta.format, PixelFormat::F32 as i32); assert_eq!(meta.data_size, 4 * 4 * 16); // Generated frame: transparent black. let data = unsafe { std::slice::from_raw_parts(parent_pool.slot_data_const(consumed), 4 * 4 * 16) }; assert!(data.iter().all(|&b| b == 0)); unsafe { parent_pool.release(consumed) }; } #[test] fn render_batch_stream_renders_generated_frames_and_reports_failures() { // The crash-mode env vars are process-wide: serialize with the // crash-hook test that mutates them. let _guard = GLOBAL_FACTORY_TEST_LOCK .lock() .unwrap_or_else(|e| e.into_inner()); let mut s = WorkerSession::create("none").unwrap(); // Slots sized for 8x8 BGRA8. let (hs, out_region, _in) = parent_side(4, 8 * 8 * 4, false); let resp = s.handle_line(&hs.to_string()).expect("hello_caps response"); assert_eq!(resp["type"], crate::ipc::TYPE_HELLO_CAPS); let batch = json!({ "type": "render_batch", "batch_id": 9, "tickets": [ { "ticket": 1, "slot": 0, "time_num": 0, "time_den": 1, "width": 8, "height": 8, "format": SLOT_FORMAT_BGRA8, "channels": 4 }, { "ticket": 2, "slot": 1, "time_num": 0, "time_den": 1, "width": 0, "height": 8, "format": SLOT_FORMAT_BGRA8, "channels": 4 }, ], }); let mut out: Vec = Vec::new(); s.handle_render_batch_stream(&batch.to_string(), &mut out) .unwrap(); let lines: Vec = String::from_utf8(out) .unwrap() .lines() .map(|l| serde_json::from_str(l).unwrap()) .collect(); assert_eq!(lines.len(), 3, "accepted + one reply per ticket"); assert_eq!(lines[0]["type"], "batch_accepted"); assert_eq!(lines[0]["batch_id"], 9); assert_eq!(lines[1]["type"], "frame_ready"); assert_eq!(lines[1]["ticket"], 1); assert_eq!(lines[1]["slot"], 0); assert_eq!(lines[2]["type"], "frame_failed"); assert_eq!(lines[2]["ticket"], 2); // The rendered slot holds opaque black BGRA8 (generated transparent // black F32 converted: alpha 0). let parent_pool = unsafe { FrameSlotPool::attach(out_region.data()) }; let mut consumed = 0; assert!(unsafe { parent_pool.consume(&mut consumed) }); assert_eq!(consumed, 0); let meta = unsafe { &*parent_pool.meta_const(consumed) }; assert_eq!(meta.id, 1); assert_eq!(meta.format, SLOT_FORMAT_BGRA8); assert_eq!(meta.linesize, 8 * 4); assert_eq!(meta.data_size, 8 * 8 * 4); unsafe { parent_pool.release(consumed) }; // The failed ticket acquired slot 1 but never published it, so it // is still owned by the filler side (not in the ready ring) — the // ready ring is empty now. assert!(!unsafe { parent_pool.consume(&mut consumed) }); } #[test] fn render_audio_batch_stream_mixes_silence_into_slot() { // Serialize with the crash-hook env test (see render_batch_stream). let _guard = GLOBAL_FACTORY_TEST_LOCK .lock() .unwrap_or_else(|e| e.into_inner()); // M15 S3: an empty-montage audio range pull (1/24 s at 48 kHz // stereo = 2000 frames x 2 ch = 16000 bytes) renders total silence // into the assigned slot and reports frame_ready with the audio // slot meta. let mut s = WorkerSession::create("none").unwrap(); let slot_bytes = 2000 * 2 * 4; let (hs, out_region, _in) = parent_side(4, slot_bytes as i64, false); let resp = s.handle_line(&hs.to_string()).expect("hello_caps response"); assert_eq!(resp["type"], crate::ipc::TYPE_HELLO_CAPS); let batch = json!({ "type": "render_audio_batch", "batch_id": 3, "tickets": [ { "ticket": 11, "slot": 0, "time_num": 0, "time_den": 1, "duration_num": 1, "duration_den": 24, "sample_rate": 48000, "channel_layout": 0x3, "channels": 2, }, { "ticket": 12, "slot": 1, "time_num": 0, "time_den": 1, "duration_num": 1, "duration_den": 24, "sample_rate": 0, "channel_layout": 0x3, "channels": 2, }, ], }); let mut out: Vec = Vec::new(); s.handle_render_audio_batch_stream(&batch.to_string(), &mut out) .unwrap(); let lines: Vec = String::from_utf8(out) .unwrap() .lines() .map(|l| serde_json::from_str(l).unwrap()) .collect(); assert_eq!(lines.len(), 3, "accepted + one reply per ticket"); assert_eq!(lines[0]["type"], "batch_accepted"); assert_eq!(lines[0]["batch_id"], 3); assert_eq!(lines[1]["type"], "frame_ready"); assert_eq!(lines[1]["ticket"], 11); assert_eq!(lines[1]["slot"], 0); assert_eq!(lines[2]["type"], "frame_failed"); assert_eq!(lines[2]["ticket"], 12); // The rendered slot holds the audio meta + silent samples. let parent_pool = unsafe { FrameSlotPool::attach(out_region.data()) }; let mut consumed = 0; assert!(unsafe { parent_pool.consume(&mut consumed) }); assert_eq!(consumed, 0); let meta = unsafe { &*parent_pool.meta_const(consumed) }; assert_eq!(meta.id, 11); assert_eq!(meta.format, SLOT_FORMAT_AUDIO_F32); assert_eq!(meta.width, 48000, "width carries the sample rate"); assert_eq!(meta.height, 0); assert_eq!(meta.channel_count, 2); assert_eq!(meta.linesize, 2 * 4); assert_eq!(meta.data_size, slot_bytes as i32); let data = unsafe { std::slice::from_raw_parts(parent_pool.slot_data_const(consumed), slot_bytes) }; assert!(data.iter().all(|&b| b == 0), "empty montage is silence"); // The samples parse back as 2000 stereo frames. let parsed: Vec = data .as_chunks::<4>() .0 .iter() .map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]])) .collect(); assert_eq!(parsed.len(), 2000 * 2); assert!(parsed.iter().all(|&v| v == 0.0)); unsafe { parent_pool.release(consumed) }; // The failed ticket (bad sample rate) acquired slot 1 but never // published it. assert!(!unsafe { parent_pool.consume(&mut consumed) }); } #[test] fn render_audio_batch_rejects_oversized_range() { // Serialize with the crash-hook env test (see render_batch_stream). let _guard = GLOBAL_FACTORY_TEST_LOCK .lock() .unwrap_or_else(|e| e.into_inner()); // A range longer than the slot can hold must fail with frame_failed // (never a buffer overflow into the next slot). let mut s = WorkerSession::create("none").unwrap(); // Slot sized for 1/48 s of stereo audio (2000 bytes). let (hs, out_region, _in) = parent_side(4, 2000, false); let resp = s.handle_line(&hs.to_string()).expect("hello_caps response"); assert_eq!(resp["type"], crate::ipc::TYPE_HELLO_CAPS); let batch = json!({ "type": "render_audio_batch", "batch_id": 4, "tickets": [ { "ticket": 21, "slot": 0, "time_num": 0, "time_den": 1, "duration_num": 1, "duration_den": 24, "sample_rate": 48000, "channel_layout": 0x3, "channels": 2, }, ], }); let mut out: Vec = Vec::new(); s.handle_render_audio_batch_stream(&batch.to_string(), &mut out) .unwrap(); let lines: Vec = String::from_utf8(out) .unwrap() .lines() .map(|l| serde_json::from_str(l).unwrap()) .collect(); assert_eq!(lines[0]["type"], "batch_accepted"); assert_eq!(lines[1]["type"], "frame_failed"); assert_eq!(lines[1]["ticket"], 21); let error_text = lines[1]["error"].as_str().unwrap().to_string(); assert!( error_text.contains("needs 16000 bytes"), "oversized range reported: {error_text}" ); // Slot 0 acquired but never published. let parent_pool = unsafe { FrameSlotPool::attach(out_region.data()) }; let mut consumed = 0; assert!(!unsafe { parent_pool.consume(&mut consumed) }); } // ---- oak-worker's in-process session tests (M14 R2: folded from the // ---- former src/session.rs mirror; the facade's production session // ---- tests above cover the rest) ------------------------------------- #[test] fn session_starts_without_pools() { let s = WorkerSession::create("none").unwrap(); assert!(s.output_pool.is_none()); assert!(s.input_pool.is_none()); assert!(!s.shutdown_requested()); } #[test] fn non_object_json_yields_error_response() { let mut s = WorkerSession::create("none").unwrap(); let resp = s.handle_line("[1,2,3]").unwrap(); assert_eq!(resp["message"], "malformed control message"); } #[test] fn missing_type_field_yields_unknown_error() { let mut s = WorkerSession::create("none").unwrap(); let resp = s.handle_line(r#"{"hello":1}"#).unwrap(); assert_eq!(resp["message"], "unknown message type: "); } #[test] fn handshake_bad_json_shape_is_invalid_handshake() { let mut s = WorkerSession::create("none").unwrap(); let resp = s .handle_line(r#"{"type":"handshake","protocol_version":"x"}"#) .unwrap(); assert_eq!(resp["message"], "invalid handshake message"); } // ---- M16 R2 branch-coverage additions --------------------------------- /// One render-batch ticket with the pipeline defaults for everything /// the tests do not care about. fn batch_spec(ticket: i64, slot: i32, width: i32, height: i32, format: i32) -> BatchTicketSpec { BatchTicketSpec { ticket, slot, time_num: 0, time_den: 1, width, height, format, channels: 4, ..Default::default() } } /// A project holding one empty sequence viewer; returns the project, /// the viewer's loaded id and the project uuid. fn sequence_project() -> (Arc>, NodeId, String) { let project = Project::new(); let seq; { let mut guard = project.lock().unwrap_or_else(|e| e.into_inner()); let (core, behavior) = SequenceBehavior::create(); seq = guard.graph.add_node(core, behavior); } let uuid = project.lock().unwrap_or_else(|e| e.into_inner()).uuid.clone(); (project, seq, uuid) } #[test] fn renderer_creation_falls_back_or_succeeds_headless() { // On a GPU-less host the dynamic -> direct-OpenGL fallback fails and // the session continues headless (M16 S1); on a GPU host it simply // initializes. Either is a valid production outcome — creation must // never error out of `WorkerSession::create`, and the session's // renderer flag must agree with what the factory can do on this host // (the second `create` is the availability oracle; the renderer flag // is not hand-rolled anywhere else). let session = WorkerSession::create("auto").expect("session creation is tolerant"); assert_eq!( session.has_renderer(), Renderer::create("auto").is_ok(), "the session carries a renderer exactly when the factory can create one" ); } #[test] fn renderer_is_open_gl_reports_the_backend_kind() { let gl = Renderer { inner: DisplayRenderer::new(BackendKind::Gl), }; assert!(gl.is_open_gl()); let vk = Renderer { inner: DisplayRenderer::new(BackendKind::Vulkan), }; assert!(!vk.is_open_gl()); } #[test] fn initialize_runtime_second_call_is_a_noop() { let mut s = WorkerSession::create("none").unwrap(); s.runtime_initialized = true; assert!(s.initialize_runtime(), "already initialized"); } #[test] fn worker_progress_events_flush_in_order_and_cancel_is_sticky() { let _guard = GLOBAL_FACTORY_TEST_LOCK .lock() .unwrap_or_else(|e| e.into_inner()); WORKER_PROGRESS_EVENTS .lock() .unwrap_or_else(|e| e.into_inner()) .clear(); WORKER_PLUGIN_CANCEL.store(false, Ordering::Relaxed); install_worker_progress_factory(); assert!( oak_plugin::progress::has_reporter_factory(), "the worker factory must be installed for the plugin progress suite" ); // Drive progressStart -> worker factory -> update -> progressEnd // through the plugin progress suite, exactly like a real render. oak_plugin::suites::progress::set_current(Some( oak_plugin::progress::ProgressReporter::silent(), )); let v2 = oak_plugin::suites::progress::suite_v2(); let v1 = oak_plugin::suites::progress::suite_v1(); let label = std::ffi::CString::new("render").unwrap(); let message = std::ffi::CString::new("frame 1").unwrap(); // SAFETY: the suite takes the null handle by contract; the C strings // outlive the calls. unsafe { assert_eq!( (v2.start)(std::ptr::null_mut(), label.as_ptr(), message.as_ptr()), oak_plugin::suites::status::OK ); assert_eq!( (v2.update)(std::ptr::null_mut(), 0.25), oak_plugin::suites::status::OK ); // progressEnd is forwarded by the v1 suite (v2's end is a no-op). assert_eq!( (v1.end)(std::ptr::null_mut()), oak_plugin::suites::status::OK ); } oak_plugin::suites::progress::set_current(None); let mut out: Vec = Vec::new(); flush_worker_progress(&mut out); let events: Vec = String::from_utf8(out) .unwrap() .lines() .map(|line| serde_json::from_str(line).unwrap()) .collect(); assert_eq!(events.len(), 3, "start + update + end"); assert_eq!(events[0]["type"], crate::ipc::TYPE_PLUGIN_PROGRESS); assert_eq!(events[0]["label"], "render"); assert_eq!(events[0]["message"], "frame 1"); assert_eq!(events[0]["fraction"], 0.0); assert_eq!(events[1]["fraction"], 0.25); assert_eq!(events[2]["fraction"], 1.0); // plugin_cancel is sticky until a fresh progressStart resets it. let mut s = WorkerSession::create("none").unwrap(); assert!(s .handle_line(r#"{"type":"plugin_cancel"}"#) .is_none()); assert!(WORKER_PLUGIN_CANCEL.load(Ordering::Relaxed)); WORKER_PLUGIN_CANCEL.store(false, Ordering::Relaxed); } /// A writer whose pipe is already closed. struct FailingWriter; impl Write for FailingWriter { fn write(&mut self, _buf: &[u8]) -> io::Result { Err(io::Error::new(io::ErrorKind::BrokenPipe, "closed")) } fn flush(&mut self) -> io::Result<()> { Err(io::Error::new(io::ErrorKind::BrokenPipe, "closed")) } } #[test] fn flush_worker_progress_breaks_on_the_first_write_error() { let _guard = GLOBAL_FACTORY_TEST_LOCK .lock() .unwrap_or_else(|e| e.into_inner()); WORKER_PROGRESS_EVENTS .lock() .unwrap_or_else(|e| e.into_inner()) .clear(); push_worker_progress(0.1, "a", "b"); push_worker_progress(0.2, "c", "d"); let mut failing = FailingWriter; flush_worker_progress(&mut failing); // The buffer is drained even though the pipe failed. let mut out: Vec = Vec::new(); flush_worker_progress(&mut out); assert!(out.is_empty()); } #[test] fn crash_hook_env_error_paths_are_inert() { // The env vars are process-wide; the batch tests that call the hook // take this same lock. The `EnvGuard`s restore the environment even // if an assertion below fails (a leaked crash-mode override could // abort a later serialized test). let _guard = GLOBAL_FACTORY_TEST_LOCK .lock() .unwrap_or_else(|e| e.into_inner()); let session = WorkerSession::create("none").unwrap(); let marker = std::env::temp_dir().join(format!( "oak_worker_crash_marker_{}", std::process::id() )); let _ = std::fs::remove_file(&marker); let _marker_env = EnvGuard::set("OAK_WORKER_CRASH_MARKER", &marker); // A non-numeric ticket id is ignored (no parse, no crash, and no // marker file side effect). let _ticket = EnvGuard::set("OAK_WORKER_CRASH_ON_TICKET", "not-a-number"); session.maybe_crash_for_testing(1); assert!( !marker.exists(), "a non-numeric ticket never reaches the marker write" ); drop(_ticket); // A matching ticket whose one-shot marker already exists does not // crash again (the restarted worker renders for real) and leaves the // marker untouched. std::fs::write(&marker, b"crashed").unwrap(); let _ticket = EnvGuard::set("OAK_WORKER_CRASH_ON_TICKET", i64::MIN.to_string()); session.maybe_crash_for_testing(i64::MIN); assert_eq!( std::fs::read(&marker).unwrap(), b"crashed".as_slice(), "the existing marker is left untouched" ); session.maybe_crash_for_testing(7); // different ticket: early return assert_eq!( std::fs::read(&marker).unwrap(), b"crashed".as_slice(), "a mismatching ticket writes nothing" ); let _ = std::fs::remove_file(&marker); } #[test] fn handshake_input_attach_failure_reports_error() { let mut s = WorkerSession::create("none").unwrap(); let (mut hs, _out, _in) = parent_side(2, 256, false); hs["input_slots"] = json!(2); hs["input_slot_data_bytes"] = json!(256); hs["input_shm_key"] = json!(format!("olive-rw-{}-missing-in", std::process::id())); let resp = s.handle_line(&hs.to_string()).unwrap(); assert_eq!(resp["type"], "error"); assert!( resp["message"] .as_str() .unwrap() .starts_with("failed to attach input shared memory: "), "{resp}" ); } #[test] fn handshake_rejects_non_pool_input_segment() { let mut s = WorkerSession::create("none").unwrap(); let (mut hs, _out, _in) = parent_side(2, 256, false); let key = test_key("nopool-in"); let bytes = FrameSlotPool::bytes_needed(2, 256); let mut region = SharedMemoryRegion::new(); assert!(region.open(&key, bytes, ShmMode::Create)); hs["input_slots"] = json!(2); hs["input_slot_data_bytes"] = json!(256); hs["input_shm_key"] = json!(key); let resp = s.handle_line(&hs.to_string()).unwrap(); assert_eq!( resp["message"], "input shared memory does not contain a frame slot pool" ); } #[test] fn load_graph_rejects_bad_shape_and_unreadable_files() { let mut s = WorkerSession::create("none").unwrap(); let resp = s.handle_line(r#"{"type":"load_graph","path":42}"#).unwrap(); assert_eq!(resp["message"], "invalid load_graph message"); // A non-empty file that is not valid UTF-8: it passes the metadata // checks but cannot be read as a project. let unreadable = std::env::temp_dir().join(format!( "oak_worker_unreadable_{}.ove", std::process::id() )); std::fs::write(&unreadable, [0xFFu8, 0xFE, 0x00, 0x80]).unwrap(); let resp = s .handle_line( &json!({ "type": "load_graph", "path": unreadable.display().to_string() }) .to_string(), ) .unwrap(); assert!( resp["message"] .as_str() .unwrap() .starts_with("graph file unreadable: "), "{resp}" ); let _ = std::fs::remove_file(&unreadable); // The identity-only payload still lands as a graph context. let ident = std::env::temp_dir().join(format!( "oak_worker_identity_{}.ove", std::process::id() )); std::fs::write(&ident, r#"{"project_copy":11}"#).unwrap(); assert!(s .handle_line( &json!({ "type": "load_graph", "path": ident.display().to_string() }) .to_string(), ) .is_none()); let graph = s.graph.as_ref().expect("identity graph loaded"); assert!(graph.project.is_none()); assert_eq!(graph.project_copy, 11); let _ = std::fs::remove_file(&ident); } #[test] fn render_frame_maps_every_supported_pixel_format() { let mut s = WorkerSession::create("none").unwrap(); // Five slots so each render can publish without draining. let (hs, _out, _in) = parent_side(5, 64, false); assert!(s.handle_line(&hs.to_string()).is_some()); for format in [ PixelFormat::U8, PixelFormat::U10, PixelFormat::U16, PixelFormat::F16, PixelFormat::F32, ] { let line = json!({ "type": "render_frame", "ticket": 100 + format as i64, "time_num": 0, "time_den": 1, "width": 2, "height": 2, "format": format as i32, }) .to_string(); let resp = s.handle_line(&line).unwrap(); assert_eq!(resp["type"], "frame_ready", "{format:?}: {resp}"); } } #[test] fn render_frame_rejects_invalid_message_and_unsupported_format() { let mut s = WorkerSession::create("none").unwrap(); let resp = s .handle_line(r#"{"type":"render_frame","ticket":"nope"}"#) .unwrap(); assert_eq!(resp["message"], "invalid render_frame message"); let (hs, _out, _in) = parent_side(2, 256, false); assert_eq!( s.handle_line(&hs.to_string()).unwrap()["type"], crate::ipc::TYPE_HELLO_CAPS ); let resp = s .handle_line( r#"{"type":"render_frame","ticket":9,"time_num":0,"time_den":1,"width":4,"height":4,"format":7}"#, ) .unwrap(); assert_eq!(resp["message"], "render_frame: unsupported format 7"); assert_eq!(resp["ticket"], 9); } #[test] fn render_frame_defaults_zero_size_and_rejects_the_oversized_frame() { let mut s = WorkerSession::create("none").unwrap(); let (hs, _out, _in) = parent_side(1, 64, false); assert!(s.handle_line(&hs.to_string()).is_some()); // 0x0 means "pipeline default" (1920x1080), which cannot fit the // 64-byte slot. let resp = s .handle_line( r#"{"type":"render_frame","ticket":10,"time_num":0,"time_den":1,"width":0,"height":0,"format":-1}"#, ) .unwrap(); assert_eq!( resp["message"], "render_frame: frame larger than the shm slot" ); } #[test] fn render_frame_reports_no_free_slot_on_shutdown() { let mut s = WorkerSession::create("none").unwrap(); let (hs, _out, _in) = parent_side(1, 256, false); assert!(s.handle_line(&hs.to_string()).is_some()); s.shutdown_requested = true; let resp = s .handle_line( r#"{"type":"render_frame","ticket":11,"time_num":0,"time_den":1,"width":4,"height":4,"format":-1}"#, ) .unwrap(); assert_eq!(resp["message"], "render_frame: no free shm slot"); } #[test] fn render_frame_reports_ready_ring_full() { let mut s = WorkerSession::create("none").unwrap(); let (hs, _out, _in) = parent_side(1, 256, false); assert!(s.handle_line(&hs.to_string()).is_some()); // Fill the single-entry ready ring, then recycle the slot through // the free ring so the render can acquire it again. let pool = s.output_pool.clone().unwrap(); // SAFETY: live attached pool; single-threaded test. unsafe { let mut slot = 0u32; assert!(pool.acquire(&mut slot)); assert!(pool.publish(slot)); assert!(pool.release(slot)); } let resp = s .handle_line( r#"{"type":"render_frame","ticket":12,"time_num":0,"time_den":1,"width":4,"height":4,"format":-1}"#, ) .unwrap(); assert_eq!(resp["message"], "render_frame: ready ring full"); } #[test] fn batch_and_audio_batch_reject_invalid_messages() { let mut s = WorkerSession::create("none").unwrap(); let mut out: Vec = Vec::new(); s.handle_render_batch_stream(r#"{"type":"render_batch","batch_id":"x"}"#, &mut out) .unwrap(); s.handle_render_audio_batch_stream(r#"{"type":"render_audio_batch","tickets":7}"#, &mut out) .unwrap(); let lines: Vec = String::from_utf8(out) .unwrap() .lines() .map(|line| serde_json::from_str(line).unwrap()) .collect(); assert_eq!(lines.len(), 2); assert_eq!(lines[0]["type"], "error"); assert_eq!(lines[0]["message"], "invalid render_batch message"); assert_eq!(lines[1]["type"], "error"); assert_eq!(lines[1]["message"], "invalid render_audio_batch message"); } #[test] fn render_ticket_without_pool_and_with_wrong_slot_assignment() { let mut s = WorkerSession::create("none").unwrap(); let spec = batch_spec(1, 0, 4, 4, PixelFormat::F32 as i32); assert_eq!( s.render_ticket_to_slot(&spec).unwrap_err(), "no shared-memory pool attached" ); let (hs, _out, _in) = parent_side(2, 256, false); assert!(s.handle_line(&hs.to_string()).is_some()); let spec = batch_spec(1, 7, 4, 4, PixelFormat::F32 as i32); assert_eq!( s.render_ticket_to_slot(&spec).unwrap_err(), "slot assignment mismatch: acquired 0, assigned 7" ); } #[test] fn render_ticket_reports_a_full_ready_ring() { let mut s = WorkerSession::create("none").unwrap(); let (hs, _out, _in) = parent_side(1, 256, false); assert!(s.handle_line(&hs.to_string()).is_some()); let pool = s.output_pool.clone().unwrap(); // SAFETY: live attached pool; single-threaded test. unsafe { let mut slot = 0u32; assert!(pool.acquire(&mut slot)); assert!(pool.publish(slot)); assert!(pool.release(slot)); } let spec = batch_spec(2, 0, 4, 4, PixelFormat::F32 as i32); assert_eq!(s.render_ticket_to_slot(&spec).unwrap_err(), "ready ring full"); } #[test] fn render_spec_pixels_rejects_oversized_and_rerenders_short_cache_entries() { let mut s = WorkerSession::create("none").unwrap(); let (hs, _out, _in) = parent_side(1, 64, false); assert!(s.handle_line(&hs.to_string()).is_some()); let pool = s.output_pool.clone().unwrap(); // 4x4 F32 needs 256 bytes; the slot holds 64. let spec = batch_spec(3, 0, 4, 4, PixelFormat::F32 as i32); let err = s.render_spec_pixels(&spec, &pool).unwrap_err(); assert!(err.starts_with("frame 4x4 needs 256 bytes"), "{err}"); // A 2x2 frame fits exactly; a stale entry smaller than this // geometry is defensively discarded and re-rendered. let spec = batch_spec(4, 0, 2, 2, PixelFormat::F32 as i32); let key = crate::framecache::spec_cache_key(&spec); s.frame_cache.insert(key.clone(), vec![0u8; 4]); s.render_spec_pixels(&spec, &pool).expect("2x2 fits"); let cached = s.frame_cache.get(&key).expect("re-rendered and memoized"); assert_eq!(cached.len(), 64); } #[test] fn render_spec_pixels_reports_footage_decode_failures() { let mut s = WorkerSession::create("none").unwrap(); let (hs, _out, _in) = parent_side(2, 64, false); assert!(s.handle_line(&hs.to_string()).is_some()); let pool = s.output_pool.clone().unwrap(); let missing = "/definitely/not/a/real/clip.mp4"; // F32 slot: the decode error propagates out of `render_f32_into`. let spec = BatchTicketSpec { footage_file: missing.to_string(), ..batch_spec(5, 0, 2, 2, PixelFormat::F32 as i32) }; let err = s.render_spec_pixels(&spec, &pool).unwrap_err(); assert!(err.starts_with("footage decode: "), "{err}"); // BGRA8 slot: the same failure through the scratch-buffer path. let spec = BatchTicketSpec { footage_file: missing.to_string(), ..batch_spec(6, 1, 2, 2, SLOT_FORMAT_BGRA8) }; let err = s.render_spec_pixels(&spec, &pool).unwrap_err(); assert!(err.starts_with("footage decode: "), "{err}"); } #[test] fn render_audio_ticket_without_pool_wrong_slot_and_full_ring() { let mut s = WorkerSession::create("none").unwrap(); assert_eq!( s.render_audio_ticket_to_slot(&AudioTicketSpec::default()) .unwrap_err(), "no shared-memory pool attached" ); let (hs, _out, _in) = parent_side(2, 64, false); assert!(s.handle_line(&hs.to_string()).is_some()); let spec = AudioTicketSpec { slot: 5, ..Default::default() }; assert_eq!( s.render_audio_ticket_to_slot(&spec).unwrap_err(), "slot assignment mismatch: acquired 0, assigned 5" ); // One sample frame of stereo audio needs 8 bytes; fill the // single-entry ready ring first so the publish fails. let mut s = WorkerSession::create("none").unwrap(); let (hs, _out, _in) = parent_side(1, 8, false); assert!(s.handle_line(&hs.to_string()).is_some()); let pool = s.output_pool.clone().unwrap(); // SAFETY: live attached pool; single-threaded test. unsafe { let mut slot = 0u32; assert!(pool.acquire(&mut slot)); assert!(pool.publish(slot)); assert!(pool.release(slot)); } let spec = AudioTicketSpec { ticket: 2, slot: 0, time_num: 0, time_den: 1, duration_num: 1, duration_den: 48000, sample_rate: 48000, channel_layout: 0x3, channels: 2, montage: Vec::new(), }; assert_eq!( s.render_audio_ticket_to_slot(&spec).unwrap_err(), "ready ring full" ); } #[test] fn audio_ticket_params_maps_montage_and_effects() { let s = WorkerSession::create("none").unwrap(); let spec = AudioTicketSpec { ticket: 1, slot: 0, time_num: 3, time_den: 2, duration_num: 1, duration_den: 4, sample_rate: 44100, channel_layout: 0x3, channels: 2, montage: vec![WireMontageClip { filename: "clip.mp4".to_string(), stream_index: 2, in_num: 1, in_den: 2, out_num: 3, out_den: 2, media_in_num: 0, media_in_den: 1, gain: 0.5, effects: vec![WireMontageEffect { type_id: "org.oak.gain".to_string(), enabled: true, effect_input_id: "Source".to_string(), params: vec![WireEffectParam { input: "gain".to_string(), value: WireNodeValue::Float(2.0), }], }], }], }; let params = s.audio_ticket_params(&spec).expect("valid geometry"); assert_eq!(params.viewer, 0); assert_eq!(params.range.in_(), Rational::new(3, 2)); assert_eq!(params.range.out(), Rational::new(7, 4)); assert_eq!(params.sample_rate, 44100); assert_eq!(params.channel_layout, 0x3); assert_eq!(params.montage.len(), 1); let clip = ¶ms.montage[0]; assert_eq!(clip.filename, "clip.mp4"); assert_eq!(clip.stream_index, 2); assert_eq!(clip.in_time, Rational::new(1, 2)); assert_eq!(clip.out_time, Rational::new(3, 2)); assert_eq!(clip.media_in, Rational::new(0, 1)); assert_eq!(clip.gain, 0.5); assert_eq!(clip.effects.len(), 1); assert_eq!(clip.effects[0].type_id, "org.oak.gain"); assert!(clip.effects[0].enabled); assert_eq!(clip.effects[0].effect_input_id.as_deref(), Some("Source")); assert_eq!(clip.effects[0].params.len(), 1); assert_eq!(clip.effects[0].params[0].0, "gain"); assert_eq!( clip.effects[0].params[0].1, oak_node::value::NodeValue::Float(2.0) ); } #[test] fn sync_pipeline_color_without_a_project_is_a_noop() { let mut s = WorkerSession::create("none").unwrap(); assert!(!s.sync_pipeline_color_from_graph(), "no graph at all"); s.graph = Some(LoadedGraph { path: "identity".to_string(), project: None, project_uuid: None, id_map: HashMap::new(), project_copy: 3, }); assert!( !s.sync_pipeline_color_from_graph(), "identity-only graphs carry no color settings" ); } #[test] fn render_spec_pixels_graph_mode_copies_the_sequence_frame() { let _color = COLOR_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); let (project, seq, uuid) = sequence_project(); let mut s = WorkerSession::create("none").unwrap(); let (hs, _out, _in) = parent_side(1, 64, false); assert!(s.handle_line(&hs.to_string()).is_some()); let pool = s.output_pool.clone().unwrap(); let mut graph = LoadedGraph { path: "graph".to_string(), project: Some(project), project_uuid: Some(uuid.clone()), id_map: HashMap::new(), project_copy: 0, }; graph.id_map.insert(7, seq); s.graph = Some(graph); let spec = BatchTicketSpec { viewer_node: 7, project_key: uuid, ..batch_spec(41, 0, 2, 2, PixelFormat::F32 as i32) }; s.render_spec_pixels(&spec, &pool) .expect("an empty sequence renders"); // SAFETY: slot 0 of the attached pool is live and 64 bytes. let dst = unsafe { std::slice::from_raw_parts(pool.slot_data_const(0), 64) }; assert!( dst.iter().all(|&b| b == 0), "an empty sequence is transparent black" ); } #[test] fn graph_mode_falls_back_for_mismatched_or_broken_viewers() { let _color = COLOR_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); let (project, seq, uuid) = sequence_project(); // A second, non-sequence node: mapping a viewer to it makes // `render_graph_frame` fail and take the fallback path. let track_list; { let mut guard = project.lock().unwrap_or_else(|e| e.into_inner()); let (core, behavior) = TrackListBehavior::create(); track_list = guard.graph.add_node(core, behavior); } let mut s = WorkerSession::create("none").unwrap(); let (hs, _out, _in) = parent_side(1, 64, false); assert!(s.handle_line(&hs.to_string()).is_some()); let pool = s.output_pool.clone().unwrap(); let mut graph = LoadedGraph { path: "graph".to_string(), project: Some(project), project_uuid: Some(uuid.clone()), id_map: HashMap::new(), project_copy: 0, }; graph.id_map.insert(7, seq); graph.id_map.insert(8, track_list); s.graph = Some(graph); // (a) A stale project key: the snapshot must not answer it. let spec = BatchTicketSpec { viewer_node: 7, project_key: "another-project".to_string(), ..batch_spec(51, 0, 2, 2, PixelFormat::F32 as i32) }; s.render_spec_pixels(&spec, &pool) .expect("stale identity falls back"); // (b) A viewer identity that is not even a valid NodeId. let spec = BatchTicketSpec { viewer_node: u64::MAX, project_key: uuid.clone(), ..spec.clone() }; s.render_spec_pixels(&spec, &pool) .expect("absent viewer falls back"); // (c) A viewer mapped to a non-sequence node: graph render errors. let spec = BatchTicketSpec { viewer_node: 8, project_key: uuid.clone(), ..spec.clone() }; s.render_spec_pixels(&spec, &pool) .expect("broken viewer falls back"); // (d) A loaded identity-only graph (uuid matches, no project). s.graph = Some(LoadedGraph { path: "identity".to_string(), project: None, project_uuid: Some(uuid.clone()), id_map: HashMap::new(), project_copy: 0, }); let spec = BatchTicketSpec { viewer_node: 7, project_key: uuid, ..spec }; s.render_spec_pixels(&spec, &pool) .expect("identity-only graph falls back"); } #[test] fn warn_graph_fallback_is_one_shot() { // Smoke test: the only observable effect of the warning is a single // stderr line, and the worker has no test-installable log sink, so // the one-shot suppression itself cannot be asserted. What must hold // either way: repeated and concurrent calls all return without // corrupting shared state (the marker is an AtomicBool and the sink // is stderr, so no call may deadlock or panic). warn_graph_fallback(123, "first reason"); warn_graph_fallback(123, "second reason"); let completed = std::sync::atomic::AtomicUsize::new(0); std::thread::scope(|scope| { for viewer in 0..8u64 { let completed = &completed; scope.spawn(move || { warn_graph_fallback(viewer, "concurrent reason"); completed.fetch_add(1, std::sync::atomic::Ordering::Relaxed); }); } }); assert_eq!( completed.load(std::sync::atomic::Ordering::Relaxed), 8, "every concurrent fallback warning must return" ); } #[test] fn stale_pipeline_colors_are_refreshed_and_clear_the_frame_cache() { let _color = COLOR_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); // Loop over both starting states so the opposite-selection branch // runs both ways despite the shared process-global color state. for current in [WorkingColorSpace::SrgbLegacy, WorkingColorSpace::AcesCg] { oak_core::color::set_pipeline_color_settings(current, OutputColorSpec::default()); let opposite = if current == WorkingColorSpace::SrgbLegacy { WorkingColorSpace::AcesCg } else { WorkingColorSpace::SrgbLegacy }; let project = Project::new(); { let mut guard = project.lock().unwrap_or_else(|e| e.into_inner()); guard.set_working_color_space(opposite); } let uuid = project.lock().unwrap_or_else(|e| e.into_inner()).uuid.clone(); let mut s = WorkerSession::create("none").unwrap(); s.graph = Some(LoadedGraph { path: "graph".to_string(), project: Some(project), project_uuid: Some(uuid), id_map: HashMap::new(), project_copy: 0, }); s.frame_cache.insert("stale".to_string(), vec![1u8; 8]); let (hs, _out, _in) = parent_side(1, 64, false); assert!(s.handle_line(&hs.to_string()).is_some()); let pool = s.output_pool.clone().unwrap(); let spec = batch_spec(61, 0, 2, 2, PixelFormat::F32 as i32); s.render_spec_pixels(&spec, &pool) .expect("renders under the refreshed colors"); assert_eq!( oak_core::color::pipeline_working_space(), opposite, "the loaded graph's colors are adopted" ); assert!( s.frame_cache.get("stale").is_none(), "a color change invalidates the frame cache" ); } oak_core::color::set_pipeline_color_settings( WorkingColorSpace::default(), OutputColorSpec::default(), ); } #[test] fn apply_output_node_is_a_noop_in_the_legacy_working_space() { let _color = COLOR_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); oak_core::color::set_pipeline_color_settings( WorkingColorSpace::SrgbLegacy, OutputColorSpec::default(), ); let mut bytes: Vec = [0.25f32, 0.5, 0.75, 1.0] .iter() .flat_map(|value| value.to_le_bytes()) .collect(); let before = bytes.clone(); apply_output_node(&mut bytes, 1); assert_eq!(bytes, before, "legacy sRGB is display-referred already"); oak_core::color::set_pipeline_color_settings( WorkingColorSpace::default(), OutputColorSpec::default(), ); } #[test] fn worker_main_without_renderer_exits_one() { // Mirrors oakengine_worker_main(): an explicit no-renderer backend // leaves nothing to evaluate, so the process exits 1 before the // control loop. assert_eq!(worker_main("none"), 1); } // ---- M16 R2 branch-coverage additions (runtime + renderer) ------------ /// The full `initialize_runtime` body on a fresh session: color config, /// text backends, plugin executor, OFX scan/registration and the /// process-global progress factory. A plugin scan failure is tolerated, /// so either outcome is a valid production result. #[test] fn initialize_runtime_installs_the_full_stack() { // The reporter factory is process-global; serialize with every // other factory test (worker and ofx_host share this lock). let _guard = GLOBAL_FACTORY_TEST_LOCK .lock() .unwrap_or_else(|e| e.into_inner()); let _color = COLOR_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); let mut s = WorkerSession::create("none").unwrap(); assert!(!s.runtime_initialized); assert!(s.initialize_runtime(), "the runtime always initializes"); assert!(s.runtime_initialized); assert!( oak_plugin::progress::has_reporter_factory(), "the worker progress factory is installed" ); // The second call short-circuits before re-installing anything. assert!(s.initialize_runtime()); } /// The dynamic / direct-OpenGL renderer factories: a GPU host /// initializes one, a GPU-less host fails both — either outcome is a /// valid production result, so the assertions pin the invariants that /// must hold either way: a successful dynamic attempt reports the /// requested backend kind (init resolves adapters internally but never /// rewrites the kind), a failure names the stage that failed, and the /// chained `create` succeeds exactly when one of its two stages can, /// reporting OpenGL exactly when the fallback produced it. #[test] fn renderer_create_variants_are_tolerant() { match Renderer::create_dynamic("gl") { Ok(renderer) => assert!(renderer.is_open_gl(), "the gl request reports OpenGL"), Err(error) => assert!( error.starts_with("failed to initialize dynamic gl renderer"), "the gl failure names the stage: {error}" ), } let dynamic_auto = Renderer::create_dynamic("auto"); let dynamic_metal = Renderer::create_dynamic("metal"); for (label, attempt) in [("auto", &dynamic_auto), ("metal", &dynamic_metal)] { match attempt { Ok(renderer) => assert!( !renderer.is_open_gl(), "{label} is not an OpenGL request" ), Err(error) => assert!( error.starts_with(&format!("failed to initialize dynamic {label} renderer")), "the {label} failure names the stage: {error}" ), } } let opengl = Renderer::create_opengl(); match &opengl { Ok(renderer) => assert!(renderer.is_open_gl(), "direct OpenGL reports OpenGL"), Err(error) => assert!( error.starts_with("failed to initialize direct OpenGL renderer"), "the fallback failure names the stage: {error}" ), } // `Renderer::create` = dynamic stage, then direct-OpenGL fallback: // its outcome is fully determined by the two stages observed above. for (request, dynamic) in [("bogus", &dynamic_auto), ("metal", &dynamic_metal)] { match Renderer::create(request) { Ok(renderer) => { assert_eq!( renderer.is_open_gl(), dynamic.is_err(), "{request}: OpenGL is reported exactly when the dynamic stage failed" ); if dynamic.is_err() { assert!( opengl.is_ok(), "{request}: the fallback must have initialized" ); } } Err(error) => { assert!( dynamic.is_err() && opengl.is_err(), "{request}: a chained error means both stages failed" ); assert!( error.contains(&format!("dynamic {request}")) && error.contains("direct OpenGL fallback also failed"), "{request}: the chained error names both stages: {error}" ); } } } } /// An F32 cache hit copies the memoized bytes into the slot instead of /// re-rendering (a miss would produce transparent black). #[test] fn render_spec_pixels_f32_cache_hit_copies_without_render() { let mut s = WorkerSession::create("none").unwrap(); let (hs, _out, _in) = parent_side(1, 64, false); assert!(s.handle_line(&hs.to_string()).is_some()); let pool = s.output_pool.clone().unwrap(); let spec = batch_spec(71, 0, 2, 2, PixelFormat::F32 as i32); let key = crate::framecache::spec_cache_key(&spec); let known: Vec = (0..64).map(|i| i as u8).collect(); s.frame_cache.insert(key.clone(), known.clone()); s.render_spec_pixels(&spec, &pool).expect("cache hit"); // SAFETY: slot 0 of the attached pool is live and 64 bytes. let dst = unsafe { std::slice::from_raw_parts(pool.slot_data_const(0), 64) }; assert_eq!(dst, &known[..], "the cached F32 bytes land in the slot"); assert!(s.frame_cache.get(&key).is_some(), "the entry stays memoized"); } /// A BGRA8 cache hit runs the output node + format convert on the /// memoized F32 bytes (a miss would produce transparent black BGRA8). #[test] fn render_spec_pixels_bgra8_cache_hit_converts_the_memoized_frame() { let _color = COLOR_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); oak_core::color::set_pipeline_color_settings( WorkingColorSpace::SrgbLegacy, OutputColorSpec::default(), ); let mut s = WorkerSession::create("none").unwrap(); let (hs, _out, _in) = parent_side(1, 64, false); assert!(s.handle_line(&hs.to_string()).is_some()); let pool = s.output_pool.clone().unwrap(); let spec = batch_spec(72, 0, 2, 2, SLOT_FORMAT_BGRA8); let key = crate::framecache::spec_cache_key(&spec); // Four opaque-white F32 pixels. let white: Vec = std::iter::repeat_n(1.0f32.to_le_bytes(), 2 * 2 * 4) .flatten() .collect(); s.frame_cache.insert(key.clone(), white); s.render_spec_pixels(&spec, &pool).expect("cache hit"); // SAFETY: slot 0 of the attached pool holds the 2x2 BGRA8 frame. let dst = unsafe { std::slice::from_raw_parts(pool.slot_data_const(0), 2 * 2 * 4) }; assert!( dst.iter().all(|&b| b == 255), "cached white F32 converts to opaque white BGRA8: {dst:?}" ); oak_core::color::set_pipeline_color_settings( WorkingColorSpace::default(), OutputColorSpec::default(), ); } /// The batch path's acquire failure (shutdown) and a render error both /// surface as `Err` from `render_ticket_to_slot` (the slot is never /// published in either case). #[test] fn render_ticket_to_slot_reports_shutdown_and_render_errors() { let mut s = WorkerSession::create("none").unwrap(); let (hs, _out, _in) = parent_side(2, 256, false); assert!(s.handle_line(&hs.to_string()).is_some()); // Shutdown: `acquire_slot` refuses before touching the rings. s.shutdown_requested = true; let spec = batch_spec(1, 0, 4, 4, PixelFormat::F32 as i32); assert_eq!( s.render_ticket_to_slot(&spec).unwrap_err(), "no free shm slot (shutdown or timeout)" ); s.shutdown_requested = false; // A render failure (missing footage) propagates out of the batch // path unchanged. let spec = BatchTicketSpec { footage_file: "/definitely/not/a/real/clip.mp4".to_string(), ..batch_spec(2, 0, 2, 2, PixelFormat::F32 as i32) }; let err = s.render_ticket_to_slot(&spec).unwrap_err(); assert!(err.starts_with("footage decode: "), "{err}"); } /// The audio batch path's acquire failure: a shutdown session returns /// "no free shm slot" before mixing anything. #[test] fn render_audio_ticket_to_slot_reports_shutdown() { let mut s = WorkerSession::create("none").unwrap(); let (hs, _out, _in) = parent_side(1, 64, false); assert!(s.handle_line(&hs.to_string()).is_some()); s.shutdown_requested = true; let spec = AudioTicketSpec { ticket: 5, slot: 0, time_num: 0, time_den: 1, duration_num: 1, duration_den: 48000, sample_rate: 48000, channel_layout: 0x3, channels: 2, montage: Vec::new(), }; assert_eq!( s.render_audio_ticket_to_slot(&spec).unwrap_err(), "no free shm slot (shutdown or timeout)" ); } }