- open_hw_accel marks the device unavailable when the decoder OPEN fails (cuvidCreateDecoder OOM at 4K) too, not just device-context creation: without it every subsequent decoder session retried CUDA and flooded the log per open. - Decoder gains hardware_decoding(); the oak-render decode-session LRU evicts hardware sessions first (each pins a GPU surface pool — ~100 MB at 4K), so a full cache cannot exhaust video memory before the next open. - Worker pool count now factors GPU vram: per-worker budget = 1 GiB (1080p peak) scaled by pixel ratio + 256 MiB idle floor, 10% reserve of free vram; applied when hardware decoding is on (nvidia-smi query, None otherwise falls back to the RAM/CPU policy). - Dynamic pool resize: ProcessDispatcher::set_target_workers grows or retires workers; retiring ones stop claiming, drain their in-flight batch (future playback frames included), then exit naturally on the shutdown signal — no mid-work kill (30 s deadline only as a hung- decoder last resort). A retiring worker that dies re-queues its frames to surviving workers. Resizes are throttled to 2 s (a resolution burst merges; only the latest target applies) so 1080p<->4K flaps cannot thrash process spawns. - RenderManager::set_workspace_size announces the sequence resolution; RealEngine calls it from refresh_sequence_info. - Integration test: shrink 3->1 mid-wave (all frames complete, retired workers exit naturally) then regrow 1->3 and render a fresh wave.
oak-worker (Rust)
Headless render worker process — the Rust rewrite of worker/workermain.cpp
(contract: engine/include/oakengine/worker.h and
engine/include/oakengine/ipc.h).
Build and test
cargo build --release # binary: target/release/oak-worker
cargo test # unit + integration tests
The worker is self-contained (M14 R2): the whole runtime is compiled
into this binary and links the module crates directly — no liboakengine
dylib is needed at build or run time.
src/worker.rsis the port ofengine/src/capi/worker.cppoakengine_worker_main()and owns the whole runtime: render backend selection through the oakrender crate's direct Rust API (dynamic → OpenGL fallback), the startup handshake and the NDJSON control loop. Since M15 S1 it also renders for real:load_graphdeserializes the snapshot throughoaknode::serializer, andrender_frame/render_batchrender throughoakrender::eval(generated frames, footage decode via oakcodec/ffmpeg, montage compositing) directly into the main-assigned shared-memory slots. M15 S3 addsrender_audio_batch(audio range pulls mixed byoakrender::eval::render_audio_samples_intointoSLOT_FORMAT_AUDIO_F32slots — interleaved f32 — so audio plugin crashes take down this process, not the editor).src/ipc.rsis a shim re-exportingoakrender::ipc(M15 S1): the NDJSON protocol and the shared-memory frame-slot transport moved to the oakrender crate so both ends of the pipe link one copy (the main-process dispatcher inoakrender::procpoolcreates the segments; this worker attaches to them).
The oakrender module crate (../oakrender) is a plain Rust dependency;
it depends on ocio-rs with the bundled feature, whose first-time build
fetches a vendored OpenColorIO dependency (sse2neon) from github.com. On
networks without github access, build with a shared target directory that
already contains a completed oakrender build tree, e.g.:
CARGO_TARGET_DIR=/path/to/oak/crates/oakrender/target cargo build --release
What the worker does
Same flow as the C++ main, in the same order:
- parse
--backend <name>(the pool spawns workers withauto;noneskips renderer creation and the process exits 1, like the C++ main;cpuis the M15 headless render mode — no renderer, but the session stays fully operational and renders through the CPU evaluation path). - initialize the render backend (inside
src/worker.rs): the oakrenderDisplayRendererdirect Rust API. Underautoa failed initialization degrades to a cpu-mode session (logged) instead of exiting — GPU-less machines keep rendering through the CPU fallback. Then the runtime services load (color-manager default config, the oakplugin render executor). - write the startup handshake (protocol version 1, empty shared-memory geometry — same as the C++ worker's startup handshake; the parent creates the segments and announces their geometry in its reply).
- serve the NDJSON control loop on stdin/stdout until a
shutdownmessage or EOF:handshakeattaches the announced shared-memory frame-slot pools through the real transport and answershello_caps(protocol v2: supported slot formats + max slot size);load_graphdeserializes the graph snapshot;render_framerenders one frame into an acquired slot;render_batchrenders a batch of main-assigned-slot tickets (batch_acceptedclaim confirmation, then oneframe_ready/frame_failedper ticket);cancel/shutdownare dispatched by the session. Responses are one compact JSON line per message.
Where the rendering happens (no render thread)
A frequent reading trap: oak-worker spawns no render thread. The
binary is a deliberately single-threaded NDJSON loop — main.rs →
worker::worker_main (src/worker.rs) reads one control line from
stdin, handles it, writes the response, repeat. Rendering happens
synchronously on that loop thread the moment a batch arrives:
stdin "render_batch"
→ WorkerSession::handle_render_batch_stream (src/worker.rs)
→ render_ticket_to_slot (acquire shm slot)
→ render_spec_pixels (the actual render)
→ stdout "frame_ready" / "frame_failed" (one line per ticket)
render_spec_pixels picks between two render paths per ticket:
- Graph path (the default when a project snapshot is loaded):
tickets carry
viewer_node(the sequence's node identity), the worker evaluates the deserialized project throughoak_node::traverser(time-aware, keyframe-resolving), and every nodevalue()pushes a job payload thatoakrender::eval's resolve hooks execute — footage decode, shader effects as wgpu fullscreen passes (oakrender::shaderfx: the nodes' embedded GLSL is translated to WGSL through naga, uniforms packed std140), OFX plugins through the oakplugin render driver. Textures stay GPU-resident across the effect chain; the frame is read back once, at the end, into the shm slot. - Montage path (fallback: no snapshot loaded / legacy tickets): the
flat wire-spec CPU pipeline (
render_montage_frame_into).
The parallelism is across processes, not threads: the main process
(app side) spawns the pool of oak-worker children in
oakrender::procpool::ProcessDispatcher (spawn_worker in
crates/oak-render/src/procpool.rs), one shared-memory segment and one
reader thread per child, and shards ticket batches across them. So
"no worker ⇒ no rendering" does not imply a hidden render thread — the
dispatcher has no in-process rendering path at all (M15 S2 deleted it;
only the test-only inline backend renders in-process). Audio follows
the same shape via render_audio_batch → render_audio_ticket_to_slot
→ oakrender::eval::render_audio_samples_into.
Consequences worth knowing before editing this file:
- A frame render blocks the control loop:
cancelis observed only between batches (batch granularity), which is why the main process also stops the render loop on its side. - A crash mid-render kills the whole loop — that is the point (OFX crash isolation); the dispatcher reaps, re-queues and respawns.
Implemented vs stubbed (nothing is faked)
Real: argument parsing, render backend initialization (real wgpu
renderer, dynamic → OpenGL fallback), runtime initialization (color
config + oakplugin render executor), startup handshake, NDJSON framing,
message validation (protocol version, handshake geometry, load_graph
file existence/size), the shared-memory frame-slot transport
(oakrender::ipc — POSIX shm_open/mmap/munmap/shm_unlink, the
SPSC ring buffer and the frame-slot pool with the exact version-1 shared
layout; a handshake genuinely attaches the output and input pools),
graph deserialization (oaknode::serializer::load, plus the minimal
{"project_copy":N} identity payload), frame rendering into shm
slots (render_frame v1 + render_batch v2: generated frames,
footage decode, montage compositing, end-of-pipe F32→BGRA8 conversion),
unknown-type/malformed-message errors, shutdown/EOF termination.
Deferred / known limits: the graph path is live (tickets with
viewer_node evaluate the loaded project through the node graph). Still
open: transition blocks render as plain cuts, polygon/mask's CPU
rasterization stage (the matte generators pass through with a TODO), and
audio still renders from the montage wire spec (graph audio evaluation
is later work).
Deviation from the C++: the startup handshake omits gl_major/
gl_minor — the oakrender module exposes no GL context version (the C++
worker reads them off its QOpenGLContext).
Crash-isolation test hooks
The batch render path honors two environment variables used by the
crash-isolation integration tests (tests/procpool_integration.rs):
OAK_WORKER_CRASH_ON_TICKET=<n>— raiseSIGSEGVwhile rendering ticketn(like a real plugin crash).OAK_WORKER_CRASH_MARKER=<path>— when the marker file exists the crash is skipped; the hook writes the marker before dying, making the crash one-shot so the restarted worker renders the re-queued frame.
They are test-only; unset in production.
Layout
src/
main.rs argv --backend scanning (default opengl; last flag wins);
forwards to worker::worker_main
worker.rs the real worker runtime: backend selection (oakrender
DisplayRenderer, dynamic -> OpenGL fallback), WorkerSession,
handshake + NDJSON loop, real load_graph + render_frame +
render_batch (M15 S1)
ipc.rs shim re-exporting oakrender::ipc (M15 S1: both pipe ends
link one copy of the protocol + shm transport)
tests/worker.rs binary-level tests (--backend none exit 1;
--backend cpu handshake + clean exit)
tests/procpool_integration.rs M15 S1 end-to-end: real workers spawned by
oakrender::procpool::ProcessDispatcher — batch
renders into shm slots, crash isolation with
restart + re-dispatch, zero-copy assertions
The NDJSON control-loop behavior is exercised in-process in src/worker.rs
against the local real shared memory (--backend none / --backend cpu
sessions, no GPU needed); tests/procpool_integration.rs drives real
worker processes end-to-end through the main-process dispatcher. Run the
binary against a created segment to see the real attach path:
target/release/oak-worker --backend cpu <<< '{"type":"shutdown"}'
M15 S2 status
The process-isolated backend is now the default RenderManager
backend; the in-process render thread pool was deleted (the app drives the
dispatcher from its UI tick and from blocking ticket waits, and the
pre-render window feeds the scheduler ahead of the playhead). The worker
binary is located at target/debug/oak-worker next to the main executable
during development (or via OAK_WORKER_BIN / DispatcherConfig::worker_bin),
and bundled alongside the main binaries by the packager (root Cargo.toml).