Adjustment layers (docs/zh/plans/adjustment-layers-and-transitions.md): a new timeline block type whose effect chain grades the composite of every video track below it, over its own range (spanning clips or a slice of one). The graph path flushes the lower tracks at the block's track boundary and sweeps the composite through the chain via a transient texture-source node; the montage path mirrors it with AdjustmentSpan tickets (wire-compatible), so worker previews and exports agree. An empty-area context menu creates one; the block trims/moves/deletes like a clip, with undo everywhere. Transitions: seam blocks come alive - cross dissolve/fade/wipe/slide evaluate both neighbors through the graph path with progress from the transition's own range (never the whole clip). Ctrl+Shift+D or the clip menu inserts a default transition; the gpui wedges render and drag to resize offsets undoably, and TransitionRemoveCommand now restores offsets and edges on undo. The transitionfx node form runs the same shaders on an adjustment layer with progress_in auto-filled from the layer's span (explicit value wins). Also: every built-in effect name and parameter name is now translatable (360 node.* keys per locale, zh-CN fully translated, two coverage tests guard future gaps); the new nodes register in nodes/mod.rs with the factory smoke table updated; textfootage and adjustment-layer i18n keys included.
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).