Files
oak-editor/crates/oak-worker
Mike-Solar 4babbf5de8
CI / Build & test (Linux) (push) Successful in 24m6s
CI / Build & test (Windows) (push) Successful in 31m14s
core: merge oak-common into oak-core
oak-common is gone; its modules (configstore, xmlutils, ocioutils,
oiioutils, colormath, colortransform, videoparams, ffmpegutils, ...)
now live in oak-core alongside the value types. The render value/GPU
types moved too: backend (wgpu context + DisplayRenderer), color
(ColorProcessor over ocio-rs), texture, frame, and the commonutil
config helpers.

Fix-ups to make the merged tree build and pass tests:

- oak-core Cargo.toml: wgpu back to 25 (the moved backend code is
  written against that API generation); add the toml/quick-xml/image
  deps oak-common carried.
- lib.rs: drop the duplicate 'pub mod error;'.
- error.rs: unified OAKCORE_* codes; restore Error::new() and
  From<OcioError> from oak-common's error type.
- backend.rs/color.rs: oak_core::/oak_render:: self-references
  rewritten to crate::; the shaderfx-dependent GPU effect test moved
  to oak-render's shaderfx tests (shaderfx depends on oak-node and
  cannot live in oak-core).
- oak-render's error module re-exports oak_core::error::{Error,
  Result}; the OAKRENDER_* codes stay as the public-code contract.
- oak-node jobs.rs: ColorProcessor imported from oak_core::color.
- Integration tests repointed at oak_core::{texture, frame, backend,
  color, colormath}.
- the display-ICC regression test treats an empty OAK_DISPLAY_ICC as
  unset, matching displayicc::env_override_icc.
2026-09-03 17:42:20 +08:00
..
2026-09-03 17:42:20 +08:00

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.rs is the port of engine/src/capi/worker.cpp oakengine_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_graph deserializes the snapshot through oaknode::serializer, and render_frame / render_batch render through oakrender::eval (generated frames, footage decode via oakcodec/ffmpeg, montage compositing) directly into the main-assigned shared-memory slots. M15 S3 adds render_audio_batch (audio range pulls mixed by oakrender::eval::render_audio_samples_into into SLOT_FORMAT_AUDIO_F32 slots — interleaved f32 — so audio plugin crashes take down this process, not the editor).
  • src/ipc.rs is a shim re-exporting oakrender::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 in oakrender::procpool creates 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:

  1. parse --backend <name> (the pool spawns workers with auto; none skips renderer creation and the process exits 1, like the C++ main; cpu is the M15 headless render mode — no renderer, but the session stays fully operational and renders through the CPU evaluation path).
  2. initialize the render backend (inside src/worker.rs): the oakrender DisplayRenderer direct Rust API. Under auto a 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).
  3. 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).
  4. serve the NDJSON control loop on stdin/stdout until a shutdown message or EOF: handshake attaches the announced shared-memory frame-slot pools through the real transport and answers hello_caps (protocol v2: supported slot formats + max slot size); load_graph deserializes the graph snapshot; render_frame renders one frame into an acquired slot; render_batch renders a batch of main-assigned-slot tickets (batch_accepted claim confirmation, then one frame_ready/frame_failed per ticket); cancel / shutdown are 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.rsworker::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 through oak_node::traverser (time-aware, keyframe-resolving), and every node value() pushes a job payload that oakrender::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_batchrender_audio_ticket_to_slotoakrender::eval::render_audio_samples_into.

Consequences worth knowing before editing this file:

  • A frame render blocks the control loop: cancel is 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> — raise SIGSEGV while rendering ticket n (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).