Two compounding causes behind 'playhead advances but the picture stays frozen' and 'pause freezes the app': - The wall-anchored clock teleported the playhead past the pre-render window during any long stall (the first render after pressing play costs seconds while the worker pool spins up: measured +104 frames in one 4.1s block). The window then started behind and, with stale in-flight frames occupying the workers, never converged. RealClock::tick now clamps the advance to 2 frames/tick and re-anchors the dropped time (NLE drop-frames semantics). - Window frames the playhead had already passed stayed pending/in flight, burning worker time on frames that could never be displayed. update_preview_window now cancels them per tick via the new JobDispatch::cancel_preview_frame, keeping the workers on frames around the playhead. Includes a production-shaped regression test (real 1080p media on the timeline, actual cpu_frame display path) that failed with the exact production signature (playhead 240 / displayed 0 / 36 stale slots) before the fix and passes after.
oakrender Rust crate
Status: implemented (M7 render wave). Every
todo!()from the declaration draft is implemented; the crate builds, tests green (cargo test), and the C ABI surface ininclude/render/*.his exported fromsrc/ffi.rs. Deferred items are documented inline and in the Deferred section below.
Scope
Replaces the C++ oakrender module (src/render/src): render manager,
ticket system + worker pool, textures and GPU backend dispatch, playback/
frame-hash caches, color processing (OCIO), the preview auto-cacher, and
the blit/display path.
Public contract: include/render/*.h (8 headers, ~165 functions) —
frozen, implemented verbatim by src/ffi.rs.
Key architectural decisions (C++ → Rust mapping)
- The ProjectCopier inversion disappears. C++ render deep-copied
the node project with raw C++ calls (the biggest render→node
coupling). In Rust this is impossible by construction: the copier
calls
oaknode_project_deep_copy/sync_copy(designed in the oaknode crate) through the C ABI.copier.rshere is a thin client. - RenderProcessor's inheritance disappears. C++
RenderProcessor : NodeTraverserbecomeseval.rs(the closedJobSpecset + the CPU-side hook implementations; graph traversal stays in oaknode). - Ticket/watchers. C++ RenderTicket/RenderTicketWatcher (Qt
signals) become a ticket arena with completion callbacks —
exactly-once delivery (
ticket.rs),FnOnceboxes fired on the worker thread. - GPU backend = wgpu (v25). The C++ tree's backend plugin split
(liboakgl2/liboakvulkan behind
renderbackend_c.h) exists because C++ had no portable GPU abstraction. Rust haswgpu(Metal/Vulkan/ GL/DX12 in one safe API), so the Rust crate useswgpudirectly — no backend plugins, norenderbackend_c.h, no dlopen.backend.rsowns the wgpu instance/device/queue, the texture registry and the WGSL blit pipeline. Headless status: verified — texture create/upload/download and the plain-copy blit run without any surface or event loop on macOS Metal (the GPU tests exercise them and skip gracefully when no adapter is available). - Threading. The worker pool is scoped threads with a job
channel; every shared structure is
Mutex/RwLock. Process isolation (ProcessPool) is preserved as a documented stub: it needs the oakengine_ipc worker binary, which is not wired this pass. - OFX disappears from render. pluginrenderer.cpp's functionality moves to the oakplugin crate; this crate only sees plugin jobs as opaque C ABI calls.
Dependencies (registered)
| Crate | Version | Reason |
|---|---|---|
oakcore-rs |
path | Rational/TimeRange/PixelFormat value types (crate-internal) |
wgpu |
25 | portable GPU backend — the direct replacement for the C++ GL/Vulkan backend plugins |
ocio-rs |
0.2 | safe Rust bindings for OpenColorIO v2.5.2 (bundled real-OCIO build); the ColorProcessor implementation — OCIO is never rewritten |
Layout
src/
lib.rs crate doc + module map
error.rs error codes (mirrors include/render/error.h)
handle.rs refcounted-handle scaffolding (facade entry points only)
texture.rs Texture value type (wraps backend textures / CPU frames)
frame.rs VideoParamsPod + Frame helpers
cache.rs PlaybackCache / FrameHashCache family + C++-parity disk state
color.rs ColorProcessor over ocio-rs + default config + LUT library
manager.rs RenderManager singleton + lifecycle + disk cache
ticket.rs Ticket arena, params, exactly-once completion delivery
worker.rs JobDispatch seam + thread-free InlineDispatcher (audio
fallback / test backend) + graph snapshot store
scheduler.rs M15 PreviewScheduler: interleaved shard claiming,
priority lanes (seek/playback/background), crash reclaim,
per-request slot-bytes capacity filtering (S3)
ipc.rs M15 render-worker IPC (moved from oak-worker): NDJSON
control protocol (v1 + v2 messages) + the POSIX
shared-memory frame-slot transport both pipe ends link
procpool.rs M15 ProcessDispatcher: spawn/handshake oak-worker
processes, main-assigned slot batches, crash detection +
restart, zero-copy ShmFrameRef / ShmAudioRef completions,
grow-on-demand segment geometry (S3)
autocacher.rs PreviewAutoCacher
eval.rs RenderHooks impl: the CPU evaluation seam
backend.rs wgpu device/queue/texture management + DisplayRenderer
copier.rs Render-side project copy client (bridge::node)
cancelatom.rs the cancellation primitive
bridge/ C ABI imports: node.rs, common.rs, codec.rs (dlsym-resolved)
ffi.rs include/render/*.h export layer
tests/ contract + golden tests (common/ has shared helpers)
Hard rules
CHandleonly appears at the facade boundary: the crate's internal calls pass Rust types directly;handle::make_owned/get/get_mutare the facade entry points the oakengine stubs call.- No
unsafeoutsidebackend.rs(GPU FFI),bridge/, and the M15 process-isolation transport (ipc.rs/procpool.rs: POSIX shm + SPSC rings; every block carries its own SAFETY comment). - F32 + ACEScg pipeline invariants are asserted in tests, not in comments (see tests/pipeline_test.rs).
Deferred (documented; tests gated with #[ignore])
- oakcodec frame payload I/O — disk frame-cache read/write
(
oakrender_frame_cache_load/save) and footage decode go through thebridge::codecC ABI (EXR/JPEG). The oakcodec crate is a concurrent wave; until it lands these fail explainably and the success-path tests are#[ignore = "needs oakcodec final"]. - oaknode C ABI —
oakrender_project_copier_set_project/get_copysuccess paths needoaknode_project_deep_copy; the success-path copier tests are#[ignore = "needs oaknode C ABI"]. - Color-managed GPU blit — the OCIO→WGSL shader generation is not
in this pass:
GpuContext::blithandles the plain copy and returnsError::Failedfor a processor; the CPU path applies the processor in float.oakrender_color_processor_create_transformresolves the destination transform against the default config's reference role until the oakcommon color-transform bridge lands. - Worker process isolation — landed in M15:
procpool.rs(ProcessDispatcher) +scheduler.rs+ipc.rsdrive real oak-worker processes (spawn, handshake, batched renders into main-assigned shm slots, crash restart, zero-copy completions); the end-to-end and crash-isolation tests live incrates/oak-worker/tests/procpool_integration.rs. M15 S2 made the process backend theRenderManagerdefault, removed the in-process thread pool (worker::WorkerPool) and the frozen pre-M15worker::ProcessPoolfacade stub, and wired the app's onscreen path to read the shm slots zero-copy. M15 S3 added audio over shm (render_audio_batch→TicketPayload::ShmAudio, with the main-process inlineInlineDispatcheras the fallback when the process dispatcher is unavailable), per-ticket slot formats (forced-F32 tickets get F32 slots; the export path reads them directly, eliminating the BGRA8→F32 round trip; segments grow on demand), and adaptive pool tuning (default_slots_per_worker/default_batch_size/default_worker_countpolicies plus thebench_processexample that reports 1080p throughput and adjacent-frame completion deltas). - Audio rendering — M15 S3 migrated audio tickets onto the process
dispatcher (
render_audio_batch; the worker mixes viaeval::render_audio_samples_intointoSLOT_FORMAT_AUDIO_F32slots; the main process readsShmAudioRefand releases). The app's real-time pull (pull_audio_tick) renders chunks ahead asynchronously (prefetch depth 4 ≈ 66 ms) so it never blocks on a busy worker;worker::InlineDispatcher::syncremains the fallback (design §3.7). - Borrowed caches —
oakrender_cache_wrap_borrowedboxes an opaque marker; queries on borrowed caches returnOAKRENDER_E_INVALIDuntil the C++ interop layer lands. RenderManager::global()returnsOption<Arc<…>>instead of the draft'sOption<&'static …>— a resettable singleton cannot hand out stable references safely.
Coverage
COVERAGE.md maps every C++ class of src/render/src to its Rust
home. cargo tarpaulin ≥ 80% excluding the deferred areas listed
above.