feat(oakrender): render-process isolation S2 - process backend by default, zero-copy onscreen

- WorkerPool thread pool deleted; RenderManager defaults to the
  Processes backend (oak-worker children), Threads kept as a test-only
  inline dispatcher; audio tickets stay in-process until S3.
- Onscreen path reads worker shm slots directly: BGRA8 slot format,
  RenderedFrame::Shm wrapped into the display buffer (single disclosed
  GPU-staging memcpy), scopes analyze BGRA8; the long-lived full-res /
  thumbnail paths take the counted slot_to_vec copy and release.
- Playback pre-render window: forward 120 frames (configurable) fed to
  the PreviewScheduler at Playback priority, interleaved across
  workers, cached in shm slots until the playhead consumes them;
  generation-based invalidation cancels and releases on edits.
- oaktask export and oak-cli run on private ProcessDispatchers (fixed
  a pump-while-locked self-deadlock in the export loop); facade
  get_frame handles ShmFrame payloads.
- Acceptance: preview path main_heap_frame_copies == 0 with spawned
  workers, CLI transcode/render verified end to end.
This commit is contained in:
2026-08-18 20:45:24 +08:00
parent 74b080f88a
commit cad1d93544
22 changed files with 1644 additions and 671 deletions
+28 -1
View File
@@ -47,6 +47,7 @@ use oaktimeline::undogeneral::TimelineAddTrackCommand;
use oaktimeline::undopointer::TrackPlaceBlockCommand;
use oaktimeline::util::NodeRef;
use oakrender::manager::RenderManager;
use oakrender::procpool::bgra8_to_rgba8;
use oakrender::ticket::{
AudioTicketParams, MontageClip, TicketPayload, VideoTicketParams,
};
@@ -628,7 +629,9 @@ pub struct RenderedFrame {
}
/// Render one frame of the sequence's montage at `time` (seconds
/// rational) into a `(width, height)` F32 frame.
/// rational) into a `(width, height)` F32 frame. M15 S2: the process
/// backend renders a BGRA8 shm slot; the frame is copied out once as
/// RGBA8 u8 (the PPM writer's format 0) and the slot released.
pub fn render_frame(
seq_id: NodeId,
time: Rational,
@@ -663,11 +666,35 @@ pub fn render_frame(
data: frame.data.clone(),
})
}
Ok(TicketPayload::ShmFrame(frame)) => {
let out = shm_to_rendered_frame(frame);
m.release_frame(frame);
Ok(out)
}
Ok(TicketPayload::Video(_)) => Err("render produced a non-CPU frame".to_string()),
_ => Err("render produced no video frame".to_string()),
}
}
/// Copy a process-backend shm frame (BGRA8 slot) out into the CLI's
/// RGBA8 u8 frame layout (format 0, 4 channels — the PPM writer's RGB
/// order). Necessary copy: the CLI owns the pixels it writes to disk.
fn shm_to_rendered_frame(frame: &oakrender::procpool::ShmFrameRef) -> RenderedFrame {
let meta = &frame.meta;
let pixels = frame
.shm
.slot_bytes(frame.slot)
.get(..meta.data_size.max(0) as usize)
.unwrap_or_default();
RenderedFrame {
width: meta.width,
height: meta.height,
format: 0,
linesize: meta.width.max(0) * 4,
data: bgra8_to_rgba8(pixels),
}
}
/// Rendered interleaved f32 audio (the module audio ticket payload).
pub struct RenderedAudio {
/// Interleaved samples (`frame_count * channel_count` values).
+10
View File
@@ -134,3 +134,13 @@ binary against a created segment to see the real attach path:
```sh
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`).
@@ -36,7 +36,7 @@ use oakrender::procpool::{
main_heap_frame_copies, reset_main_heap_frame_copies, DispatcherConfig, ProcessDispatcher,
};
use oakrender::ticket::{TicketPayload, TicketResult, VideoTicketParams};
use oakrender::worker::{Job, JobDispatch};
use oakrender::worker::{Job, JobDispatch, JobSchedule};
/// Serialize every test in this file (shared process environment +
/// real child processes).
@@ -104,6 +104,7 @@ fn submit(
done: Box::new(move |result| {
results.lock().unwrap_or_else(|e| e.into_inner()).push(result);
}),
schedule: JobSchedule::seek(),
};
assert!(dispatcher.post(job), "post accepted while alive");
}
+25
View File
@@ -12672,6 +12672,31 @@ pub mod render {
}
Err(e) => e.code(),
},
Some(Ok(TicketPayload::ShmFrame(frame))) => {
// M15 S2 process backend: the frame lives in a worker shm
// slot. Copy it out once as an RGBA8 u8 frame (necessary
// copy — the C ABI consumer owns its buffer), release the
// slot, and hand the frame back.
let meta = &frame.meta;
let pixels = frame
.shm
.slot_bytes(frame.slot)
.get(..meta.data_size.max(0) as usize)
.unwrap_or_default();
let mut f = oakrender::texture::Frame::new();
f.width = meta.width;
f.height = meta.height;
f.format = oakcore_rs::PixelFormat::U8;
f.channels = 4;
f.timestamp = oakcore_rs::Rational::new(meta.time_num, meta.time_den);
f.data = oakrender::procpool::bgra8_to_rgba8(pixels);
if let Some(m) = oakrender::manager::RenderManager::global() {
m.release_frame(&frame);
}
// SAFETY: valid out pointer.
unsafe { *out = oakrender::handle::make_owned(f) };
0
}
Some(Err(e)) => e.code(),
_ => {
// SAFETY: valid out pointer.
+4 -4
View File
@@ -17,7 +17,7 @@
|---|---|
| `create_instance` / `destroy_instance` / `instance` | `manager::RenderManager::init/shutdown/global` |
| `render_frame` / `render_audio`RenderVideoParams/RenderAudioParams | `manager` + `ticket::TicketArena::submit_video/submit_audio`params 结构在 ticket.rs marshalling |
| `RenderThread`start/add_ticket/remove_ticket/quit/wait/run | `worker::WorkerPool`scoped 线程 + channelquit/wait → shutdown |
| `RenderThread`start/add_ticket/remove_ticket/quit/wait/run | 已删除(M15 S2 删线程池);视频经 `procpool::ProcessDispatcher`,单测/音频经 `worker::InlineDispatcher` |
| `backend()` / `requested_backend` / `backend_from_string` / `backend_to_string` | `backend::BackendKind` + 字符串互转 |
| `get_cacher` | `manager.autocacher` |
| `set_project` | `manager`(持有项目身份,不持指针) |
@@ -37,11 +37,11 @@
| C++ | Rust 落点 |
|---|---|
| `start` / `submit_frame` / `remove_ticket` / `shutdown` / `worker_loop` / `process_job(_attempt)` | `worker::WorkerPool`(线程池路径 |
| `PooledWorker` / `acquire_worker` / `return_worker` / `shutdown_worker` / `clear_graph_cache` | `worker::ProcessPool`(子进程池;复用现有 oakengine_ipc C ABIRust 只做客户端 |
| `start` / `submit_frame` / `remove_ticket` / `shutdown` / `worker_loop` / `process_job(_attempt)` | `procpool::ProcessDispatcher`oak-worker 子进程,M15 默认后端;`worker::WorkerPool` 已删除 |
| `PooledWorker` / `acquire_worker` / `return_worker` / `shutdown_worker` / `clear_graph_cache` | `procpool::ProcessDispatcher` + `WorkerHandle`spawn/handshake/崩溃重启;pre-M15 `worker::ProcessPool` 桩已删除 |
| `write_graph_snapshot` / `cleanup_graph_file` / `add/release_graph_path_ref(_locked)` / `set_graph_path_cached(_locked)` | `worker::GraphSnapshotStore`(图快照文件的引用计数缓存) |
| `is_supported` / `prepare_job` / `finish_with_frame` / `cancel_active_process` / `set/clear_active_worker` | `worker` 内部 |
| **决策注记**:进程隔离 worker 保留(崩溃隔离是线上特性)。线程池与进程池并存于 `worker.rs``enum WorkerBackend { Threads(WorkerPool), Processes(ProcessPool) }`,选择策略同 C++config 键)。 | |
| **决策注记**:进程隔离 worker 是唯一视频后端(M15 S2 用户要求删线程池)。`manager::RenderBackendChoice::{Threads,Processes}``Threads` = 测试专用同步 inline`Processes` = 默认。`worker::InlineDispatcher`sync=音频/测试,queued=测试确定性)。 | |
## 4. Renderer 抽象(renderer.h,后端接口)
+12 -6
View File
@@ -117,16 +117,22 @@ tests/ contract + golden tests (common/ has shared helpers)
in float. `oakrender_color_processor_create_transform` resolves the
destination transform against the default config's reference role
until the oakcommon color-transform bridge lands.
- **Worker process isolation** — landed in M15 S1: `procpool.rs`
- **Worker process isolation** — landed in M15: `procpool.rs`
(`ProcessDispatcher`) + `scheduler.rs` + `ipc.rs` drive 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 in
`crates/oak-worker/tests/procpool_integration.rs`. The frozen pre-M15
`worker::ProcessPool` facade stub remains for the C ABI.
- **Audio rendering** — audio tickets complete with `Error::Failed`
(the audio graph path is not implemented); `oakrender_ticket_get_samples`
fails explainably.
`crates/oak-worker/tests/procpool_integration.rs`. M15 S2 made the
process backend the `RenderManager` default, removed the in-process
thread pool (`worker::WorkerPool`) and the frozen pre-M15
`worker::ProcessPool` facade stub, and wired the app's onscreen path
to read the shm slots zero-copy (`worker::InlineDispatcher` supplies
the thread-free audio/test backends).
- **Audio rendering** — audio tickets run on main-process inline
execution (`worker::InlineDispatcher::sync`; design §3.7 — the crash
risk is dominated by video plugins, which live in oak-worker) through
`eval::render_audio_samples`; the S3 work is migrating them to the
shared-memory transport.
- **Borrowed caches** — `oakrender_cache_wrap_borrowed` boxes an
opaque marker; queries on borrowed caches return `OAKRENDER_E_INVALID`
until the C++ interop layer lands.
+38 -35
View File
@@ -297,7 +297,7 @@ mod tests {
use crate::frame::VideoParamsPod;
use crate::texture::{Frame, Texture};
use crate::worker::WorkerPool;
use crate::worker::{InlineDispatcher, JobDispatch};
fn frame_producer() -> crate::ticket::Producer {
Arc::new(|_, _| {
@@ -311,16 +311,18 @@ mod tests {
})
}
fn new_cacher() -> (PreviewAutoCacher, WorkerPool) {
let mut pool = WorkerPool::new(2);
pool.start();
let arena = Arc::new(TicketArena::new(Arc::new(pool.clone()), frame_producer()));
(PreviewAutoCacher::new(arena), pool)
/// A cacher on a queued inline dispatcher: jobs run only when the test
/// drains it with `InlineDispatcher::run` (deterministic ordering, no
/// worker threads).
fn new_cacher() -> (PreviewAutoCacher, Arc<InlineDispatcher>) {
let d = InlineDispatcher::queued();
let arena = Arc::new(TicketArena::new(d.clone(), frame_producer()));
(PreviewAutoCacher::new(arena), d)
}
#[test]
fn attach_detach_lifecycle() {
let (mut c, mut pool) = new_cacher();
let (mut c, d) = new_cacher();
assert!(c.attach(0).is_err(), "zero identity rejected");
c.attach(42).unwrap();
assert_eq!(c.copied_project, 42);
@@ -332,31 +334,32 @@ mod tests {
c.detach();
assert_eq!(c.copied_project, 0);
assert!(c.live_jobs().is_empty());
pool.shutdown();
d.shutdown();
}
#[test]
fn single_frame_cancels_previous() {
// The race this asserts ("the previous frame is cancelled") is only
// deterministic when the first job cannot finish before the
// superseding submit lands — produce frames slowly for this test.
let (mut c, mut pool) = new_cacher_slow();
// The race this asserts ("the previous frame is cancelled") is
// deterministic on the queued inline dispatcher: the first job stays
// queued until `run`, so the superseding submit's cancel lands first.
let (mut c, d) = new_cacher_slow();
c.attach(7);
let first = c.single_frame(Rational::new(0, 1));
let second = c.single_frame(Rational::new(1, 1));
assert_ne!(first, second);
// The first ticket is cancelled: its completion fired with State.
d.run();
c.arena.wait(first).unwrap();
let r = c.arena.result(first).unwrap();
assert!(r.is_err());
pool.shutdown();
d.shutdown();
}
/// A cacher whose frames take ~100ms to produce (see
/// [`single_frame_cancels_previous`]).
fn new_cacher_slow() -> (PreviewAutoCacher, WorkerPool) {
let mut pool = WorkerPool::new(2);
pool.start();
/// [`single_frame_cancels_previous`] — the sleep is irrelevant on the
/// queued dispatcher, kept for the original async-backend semantics).
fn new_cacher_slow() -> (PreviewAutoCacher, Arc<InlineDispatcher>) {
let d = InlineDispatcher::queued();
let producer: crate::ticket::Producer = Arc::new(|_, _| {
std::thread::sleep(std::time::Duration::from_millis(100));
let mut f = Frame::new();
@@ -367,41 +370,43 @@ mod tests {
f.allocate();
Ok(crate::ticket::TicketPayload::Video(Texture::wrap_frame(f)))
});
let arena = Arc::new(TicketArena::new(Arc::new(pool.clone()), producer));
(PreviewAutoCacher::new(arena), pool)
let arena = Arc::new(TicketArena::new(d.clone(), producer));
(PreviewAutoCacher::new(arena), d)
}
#[test]
fn ignore_requests_suppresses_jobs() {
let (mut c, mut pool) = new_cacher();
let (mut c, d) = new_cacher();
c.attach(7);
c.ignore_requests = true;
c.on_cache_request(7, TimeRange::new(Rational::new(0, 1), Rational::new(5, 1)));
assert!(c.live_jobs().is_empty());
pool.shutdown();
d.shutdown();
}
#[test]
fn renders_paused_queues_but_does_not_start() {
let (mut c, mut pool) = new_cacher();
let (mut c, d) = new_cacher();
c.attach(7);
c.renders_paused = true;
c.on_cache_request(7, TimeRange::new(Rational::new(0, 1), Rational::new(5, 1)));
assert!(c.live_jobs().is_empty(), "paused: no jobs started");
assert_eq!(c.pending_requests().len(), 1);
pool.shutdown();
d.shutdown();
}
#[test]
fn cancel_video_tasks_wait_blocks_until_idle() {
let (mut c, mut pool) = new_cacher();
let (mut c, d) = new_cacher();
c.attach(7);
c.force_range(TimeRange::new(Rational::new(0, 1), Rational::new(1, 1)));
assert!(c.is_rendering_custom_range() || c.live_jobs().len() == 1);
// Drain so the wait below observes every job finished.
d.run();
c.cancel_video_tasks(true);
assert!(c.live_jobs().iter().all(|id| c.arena.is_finished(*id)));
assert!(!c.is_rendering_custom_range());
pool.shutdown();
d.shutdown();
}
struct Probe {
@@ -421,7 +426,7 @@ mod tests {
#[test]
fn events_deliver_progress() {
let (mut c, mut pool) = new_cacher();
let (mut c, d) = new_cacher();
let probe = Arc::new(Probe {
progress: AtomicU32::new(0),
stop: AtomicU32::new(0),
@@ -432,7 +437,7 @@ mod tests {
c.request_stop_proxy_tasks();
assert_eq!(probe.progress.load(Ordering::Relaxed), 1);
assert_eq!(probe.stop.load(Ordering::Relaxed), 1);
pool.shutdown();
d.shutdown();
}
struct ProbeEvents {
@@ -450,25 +455,23 @@ mod tests {
#[test]
fn clear_finished_single_frames_removes_done() {
let (mut c, mut pool) = new_cacher();
let (mut c, d) = new_cacher();
c.attach(7);
let id = c.single_frame(Rational::new(0, 1));
d.run();
c.arena.wait(id).unwrap();
assert_eq!(c.live_jobs().len(), 1, "finished job still tracked");
c.clear_finished_single_frames();
assert!(c.live_jobs().is_empty());
pool.shutdown();
d.shutdown();
}
#[test]
fn noop_events_sink_never_panics() {
let mut c = {
let mut pool = WorkerPool::new(1);
pool.start();
let arena = Arc::new(TicketArena::new(Arc::new(pool.clone()), frame_producer()));
let c = PreviewAutoCacher::new(arena);
pool.shutdown();
c
let d = InlineDispatcher::sync();
let arena = Arc::new(TicketArena::new(d, frame_producer()));
PreviewAutoCacher::new(arena)
};
c.set_events(Box::new(NoopEvents));
c.report_progress(1.0);
+2 -1
View File
@@ -25,7 +25,8 @@
//! - `cache` — PlaybackCache/FrameHashCache family + disk state
//! - `color` — ColorProcessor over `ocio-rs` + LUT library
//! - `ticket` — ticket arena with exactly-once completion
//! - `worker` — worker pool + graph snapshot store
//! - `worker` — the JobDispatch seam + thread-free inline dispatcher +
//! graph snapshot store (the in-process thread pool was deleted in M15 S2)
//! - `manager` — RenderManager singleton + disk cache
//! - `autocacher` — PreviewAutoCacher
//! - `eval` — the evaluation seam (RenderHooks)
+62 -34
View File
@@ -14,8 +14,8 @@
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//! The render manager: process-wide singleton owning the worker pool,
//! the ticket arena, the auto-cacher, and backend selection
//! The render manager: process-wide singleton owning the process
//! dispatcher, the ticket arena, the auto-cacher, and backend selection
//! (C++ `RenderManager`).
//!
//! The singleton lives behind a `Mutex<Option<Arc<…>>>` so `init` /
@@ -31,9 +31,9 @@ use crate::autocacher::PreviewAutoCacher;
use crate::backend::BackendKind;
use crate::error::{Error, Result};
use crate::eval;
use crate::procpool::{DispatcherConfig, ProcessDispatcher};
use crate::procpool::{DispatcherConfig, ProcessDispatcher, ShmFrameRef};
use crate::ticket::{TicketArena, TicketId};
use crate::worker::{JobDispatch, WorkerPool};
use crate::worker::{InlineDispatcher, JobDispatch};
static MANAGER: Mutex<Option<Arc<RenderManager>>> = Mutex::new(None);
@@ -41,23 +41,26 @@ fn lock<T>(m: &Mutex<T>) -> MutexGuard<'_, T> {
m.lock().unwrap_or_else(|e| e.into_inner())
}
/// The render backend the manager initializes (M15 S1: the thread pool
/// and the process-isolated dispatcher coexist; S2 makes Processes the
/// default and removes the pool).
/// The render backend the manager initializes (M15 S2).
pub enum RenderBackendChoice {
/// In-process thread pool (the default; C++ parity).
/// In-process thread-free dispatch (**test-only** after M15 S2: the
/// internal render thread pool was deleted by mandate; this backend
/// runs jobs synchronously on the calling thread). Kept so manager /
/// integration tests do not spawn oak-worker children.
Threads,
/// Process-isolated oak-worker pool (crash isolation + shm frames).
/// The M15 S2 default.
Processes(DispatcherConfig),
}
/// The manager. Created by `oakrender_manager_init` (C ABI), accessed
/// internally through [`RenderManager::global`].
pub struct RenderManager {
/// Video job dispatch (thread pool or process dispatcher, M15).
/// Video job dispatch (the process dispatcher, M15).
pub dispatch: Arc<dyn JobDispatch>,
/// Audio job dispatch — kept on main-process threads until S3
/// (design §3.7: crash risk is dominated by video plugins).
/// Audio job dispatch — kept on main-process inline execution until S3
/// (design §3.7: crash risk is dominated by video plugins, which live
/// in oak-worker).
pub audio_dispatch: Arc<dyn JobDispatch>,
/// Ticket arena.
pub tickets: Arc<TicketArena>,
@@ -73,16 +76,16 @@ pub struct RenderManager {
}
impl RenderManager {
/// Initialize the process-wide manager with the default backend
/// (in-process threads; idempotent; C++ instance() semantics — only
/// the main GUI process does this).
/// Initialize the process-wide manager with the default backend — the
/// process-isolated oak-worker pool (M15 S2 mandate; idempotent; C++
/// instance() semantics — only the main GUI process does this).
pub fn init() -> Result<()> {
Self::init_with_backend(RenderBackendChoice::Threads)
Self::init_with_backend(RenderBackendChoice::Processes(DispatcherConfig::default()))
}
/// Initialize the process-wide manager with an explicit backend
/// (M15 S1: `Threads` keeps the C++ parity path, `Processes` spawns
/// the oak-worker pool).
/// Initialize the process-wide manager with an explicit backend.
/// `Threads` is the test-only inline backend (no worker threads, no
/// child processes); `Processes` spawns the oak-worker pool.
pub fn init_with_backend(choice: RenderBackendChoice) -> Result<()> {
let mut guard = lock(&MANAGER);
if guard.is_some() {
@@ -96,18 +99,19 @@ impl RenderManager {
let (dispatch, audio_dispatch): (Arc<dyn JobDispatch>, Arc<dyn JobDispatch>) =
match choice {
RenderBackendChoice::Threads => {
let mut pool = WorkerPool::new(0);
pool.start();
let pool = Arc::new(pool);
(pool.clone(), pool)
// Test-only inline backend: synchronous execution on the
// calling thread, shared by video and audio.
let inline = InlineDispatcher::sync();
(inline.clone(), inline)
}
RenderBackendChoice::Processes(config) => {
let dispatcher = ProcessDispatcher::new(config)?;
dispatcher.start()?;
// Audio stays on main-process threads (design §3.7).
let mut audio = WorkerPool::new(2);
audio.start();
(dispatcher, Arc::new(audio))
// Audio stays on main-process inline execution until S3
// (design §3.7): render_audio_samples runs synchronously
// on the posting thread.
let audio = InlineDispatcher::sync();
(dispatcher, audio)
}
};
let tickets = Arc::new(TicketArena::new_with_audio(
@@ -147,14 +151,35 @@ impl RenderManager {
if let Some(manager) = manager {
manager.tickets.cancel_all();
// Drain after the cancels so queued completions fire. Both
// dispatches are idempotent (the Threads backend shares one
// Arc for video + audio).
// dispatches are idempotent.
manager.dispatch.shutdown();
manager.audio_dispatch.shutdown();
drop(manager);
}
}
/// Pump the video backend's control plane (M15 S2): the process
/// dispatcher delivers ticket completions from its poll loop, so the
/// UI tick and any blocking wait must call this regularly. No-op on
/// backends that deliver inline.
pub fn poll(&self) {
self.dispatch.poll();
}
/// Release a consumed shm frame's slot back to its worker (M15 S2
/// zero-copy onscreen path: slot release = cache eviction). No-op on
/// backends that hold no slots.
pub fn release_frame(&self, frame: &ShmFrameRef) {
self.dispatch.release_frame(frame);
}
/// Cancel every pending AND claimed frame of `sequence` (M15 S2
/// preview-window invalidation); their completions fire with
/// `Error::State`. No-op on backends that schedule no window.
pub fn cancel_preview_sequence(&self, sequence: u64) {
self.dispatch.cancel_preview_sequence(sequence);
}
/// Aggressive-GC toggle (C++ `SetAggressiveGarbageCollection`).
pub fn set_aggressive_gc(&self, on: bool) {
self.aggressive_gc.store(on, Ordering::Release);
@@ -243,19 +268,22 @@ mod tests {
#[test]
fn init_shutdown_roundtrip() {
let _lock = manager_lock();
// Ensure a clean slate.
// Ensure a clean slate. The manager tests use the test-only inline
// backend (the process backend spawns real oak-worker children).
RenderManager::shutdown();
RenderManager::init().unwrap();
RenderManager::init_with_backend(RenderBackendChoice::Threads).unwrap();
assert!(RenderManager::global().is_some());
// Idempotence: second init is a state error.
assert_eq!(
RenderManager::init().unwrap_err().code(),
RenderManager::init_with_backend(RenderBackendChoice::Threads)
.unwrap_err()
.code(),
Error::State.code()
);
RenderManager::shutdown();
assert!(RenderManager::global().is_none());
// Re-init works after shutdown (C++ destroy_instance semantics).
RenderManager::init().unwrap();
RenderManager::init_with_backend(RenderBackendChoice::Threads).unwrap();
RenderManager::shutdown();
}
@@ -263,7 +291,7 @@ mod tests {
fn aggressive_gc_toggle() {
let _lock = manager_lock();
RenderManager::shutdown();
RenderManager::init().unwrap();
RenderManager::init_with_backend(RenderBackendChoice::Threads).unwrap();
let m = RenderManager::global().unwrap();
assert!(!m.aggressive_gc());
m.set_aggressive_gc(true);
@@ -275,7 +303,7 @@ mod tests {
fn cacher_is_lazily_created() {
let _lock = manager_lock();
RenderManager::shutdown();
RenderManager::init().unwrap();
RenderManager::init_with_backend(RenderBackendChoice::Threads).unwrap();
let m = RenderManager::global().unwrap();
{
let g = m.get_cacher();
+115 -12
View File
@@ -49,9 +49,12 @@
//! worker dead: its claimed frames are re-queued to the scheduler
//! (any healthy worker may claim them), the child is reaped, the
//! segment recreated and the process respawned (bounded restarts).
//! - **Coexistence.** S1 keeps the in-process [`crate::worker::WorkerPool`]
//! alive; [`crate::manager::RenderManager`] picks the backend at
//! init. S2 deletes the thread pool.
//! - **S2 model.** The in-process [`crate::worker::WorkerPool`] is
//! gone (M15 S2 mandate); [`crate::manager::RenderManager`] defaults
//! to this backend. The ticket arena also routes **playback-window**
//! frames here via [`JobSchedule::playback`], and the app pumps the
//! control plane from the UI tick ([`ProcessDispatcher::poll`]) and
//! from blocking ticket waits.
use std::collections::{HashMap, HashSet, VecDeque};
use std::io::Write as _;
@@ -71,7 +74,7 @@ use crate::ipc::{
WireMontageClip, SLOT_FORMAT_BGRA8, TYPE_BATCH_ACCEPTED, TYPE_ERROR, TYPE_FRAME_FAILED,
TYPE_FRAME_READY, TYPE_HANDSHAKE, TYPE_HELLO_CAPS,
};
use crate::scheduler::{FrameKey, FramePriority, FrameRequest, PreviewScheduler};
use crate::scheduler::{FrameKey, FrameRequest, PreviewScheduler, SubmitOutcome};
use crate::ticket::{Completion, TicketPayload, TicketResult, VideoTicketParams};
use crate::worker::{Job, JobDispatch};
@@ -261,6 +264,45 @@ impl std::fmt::Debug for ShmFrameRef {
}
}
// ---------------------------------------------------------------------------
// Slot pixel conversions (M15 S2)
// ---------------------------------------------------------------------------
//
// The process backend writes BGRA8 into slots (the viewer preview format).
// Long-lived consumers that need the bytes in a different order/format
// convert once after copying out of the slot.
/// Convert a BGRA8 block into an RGBA8 block (swapping R and B). Used by
/// PNG writers (footage thumbnails) and PPM/CLI output, which require
/// RGB-order buffers. `src.len()` must be a multiple of 4.
pub fn bgra8_to_rgba8(src: &[u8]) -> Vec<u8> {
let mut out = Vec::with_capacity(src.len());
for px in src.chunks_exact(4) {
out.push(px[2]); // R
out.push(px[1]); // G
out.push(px[0]); // B
out.push(px[3]); // A
}
out
}
/// Convert a BGRA8 block into tightly-packed F32 RGBA samples (`0..=1`).
/// Used by the export/encoder path, which declares F32 input: the worker
/// converts its F32 pipeline output to BGRA8 for the slot (design §3.1),
/// and the export converts back — a necessary conversion at the encoder
/// boundary with 8-bit quantization (S2; per-ticket slot formats are S3
/// work).
pub fn bgra8_to_f32_rgba(src: &[u8]) -> Vec<f32> {
let mut out = Vec::with_capacity(src.len() / 4 * 4);
for px in src.chunks_exact(4) {
out.push(f32::from(px[2]) / 255.0); // R
out.push(f32::from(px[1]) / 255.0); // G
out.push(f32::from(px[0]) / 255.0); // B
out.push(f32::from(px[3]) / 255.0); // A
}
out
}
// ---------------------------------------------------------------------------
// Configuration
// ---------------------------------------------------------------------------
@@ -1128,10 +1170,15 @@ impl ProcessDispatcher {
impl JobDispatch for ProcessDispatcher {
/// Submit one frame job (the ticket-arena seam). The job joins the
/// scheduler as a Seek-priority request and is dispatched on the
/// next pump; the completion fires with
/// scheduler under its [`JobSchedule`] (Seek single-frame by default,
/// Playback for the pre-render window, Background for exports) and is
/// dispatched on the next pump; the completion fires with
/// `TicketPayload::ShmFrame(ShmFrameRef)` — never a pixel buffer.
/// Re-submitting a key that is still pending replaces the old request
/// and cancels its ticket; a key already in flight is left running
/// (its result is still valid for the same params).
fn post(&self, job: Job) -> bool {
let mut fired: Vec<(Completion, TicketResult)> = Vec::new();
{
let mut inner = lock(&self.inner);
if inner.shutting_down {
@@ -1139,10 +1186,11 @@ impl JobDispatch for ProcessDispatcher {
}
let id = inner.next_ticket;
inner.next_ticket += 1;
let frame = job.schedule.frame.unwrap_or(id);
let key = FrameKey {
sequence: job.node_identity,
frame: id,
version: 0,
frame,
version: job.schedule.version,
};
inner.tickets.insert(
id,
@@ -1152,18 +1200,73 @@ impl JobDispatch for ProcessDispatcher {
done: Some(job.done),
},
);
inner.scheduler.submit(FrameRequest {
let request = FrameRequest {
key,
priority: FramePriority::Seek,
distance: 0,
priority: job.schedule.priority,
distance: job.schedule.distance,
payload: id,
});
};
match inner.scheduler.submit(request) {
SubmitOutcome::Accepted => {}
SubmitOutcome::Replaced(old) => {
// A newer request for the same key superseded the old
// pending one: cancel the old ticket's completion.
if let Some(pt) = inner.tickets.get_mut(&old.payload) {
if let Some(done) = pt.done.take() {
fired.push((done, Err(Error::State)));
}
}
}
SubmitOutcome::InFlight => {
// Already claimed by a worker; the rendered frame is
// still valid for the same params (playback window
// slides re-request frames that are in flight).
}
}
}
for (done, result) in fired {
done(result);
}
// Pump once so a live worker picks the frame up immediately.
self.poll();
true
}
/// Cancel every pending AND claimed request of `sequence` (M15 S2
/// preview-window invalidation — graph/proxy/resolution/color bump or
/// a sequence switch). Dropped completions fire `Error::State`;
/// frames already dispatched recycle their slots when the late
/// `frame_ready` arrives.
fn cancel_preview_sequence(&self, sequence: u64) {
let mut fired: Vec<(Completion, TicketResult)> = Vec::new();
{
let mut inner = lock(&self.inner);
let dropped = inner.scheduler.cancel_sequence(sequence);
for request in dropped {
if let Some(pt) = inner.tickets.get_mut(&request.payload) {
if let Some(done) = pt.done.take() {
fired.push((done, Err(Error::State)));
}
}
}
}
for (done, result) in fired {
done(result);
}
}
/// Pump the control plane (delegates to the inherent poll — the UI
/// tick and blocking ticket waits call this through the trait seam).
fn poll(&self) {
self.poll();
}
/// Release a consumed frame's slot (delegates to the inherent
/// release — see [`ProcessDispatcher::release_frame`]).
fn release_frame(&self, frame: &ShmFrameRef) {
self.release_frame(frame);
}
/// Graceful shutdown: `shutdown` messages, a short drain pumping
/// completions, then kill stragglers; every ticket still open
/// completes with `Error::State`.
+81 -21
View File
@@ -62,9 +62,10 @@ pub struct FrameKey {
}
/// Frame request priority class (lower value = more urgent).
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Default)]
pub enum FramePriority {
/// Seek / current playhead frame (single-frame insert, top priority).
#[default]
Seek,
/// Playback window frames (ordered by [`FrameRequest::distance`]).
Playback,
@@ -112,6 +113,19 @@ struct PendingEntry<P> {
any_worker: bool,
}
/// The outcome of [`PreviewScheduler::submit`].
#[derive(Debug)]
pub enum SubmitOutcome<P> {
/// A new request was accepted (nothing was pending under its key).
Accepted,
/// An existing pending request under the same key was replaced; the
/// superseded request is returned so the dispatcher can cancel its
/// ticket completion (M15 S2).
Replaced(FrameRequest<P>),
/// The key is already claimed (in flight); the request was rejected.
InFlight,
}
/// The scheduler state machine (single-threaded by contract).
pub struct PreviewScheduler<P> {
workers: usize,
@@ -155,21 +169,25 @@ impl<P: Clone> PreviewScheduler<P> {
/// Submit a frame request. An already-pending request with the same
/// key is replaced; a key already claimed (in flight) is rejected —
/// the dispatcher must cancel/re-version it first. Returns true when
/// the request was accepted.
pub fn submit(&mut self, request: FrameRequest<P>) -> bool {
/// the dispatcher must cancel/re-version it first.
pub fn submit(&mut self, request: FrameRequest<P>) -> SubmitOutcome<P> {
if self.claimed.contains_key(&request.key) {
return false;
return SubmitOutcome::InFlight;
}
if let Some(entry) = self.pending.iter_mut().find(|e| e.request.key == request.key) {
entry.request = request;
return true;
let old = std::mem::replace(&mut entry.request, request);
return SubmitOutcome::Replaced(old);
}
self.pending.push(PendingEntry {
request,
any_worker: false,
});
true
SubmitOutcome::Accepted
}
/// Whether `key` is currently claimed (in flight).
pub fn is_claimed(&self, key: &FrameKey) -> bool {
self.claimed.contains_key(key)
}
/// Claim the next batch for `worker`: the worker's interleaved shard
@@ -300,14 +318,27 @@ impl<P: Clone> PreviewScheduler<P> {
/// Cancel every pending AND claimed frame of `sequence` (frame-key
/// invalidation; the `cancel` wire message covers the worker side).
/// Returns the number of dropped requests.
pub fn cancel_sequence(&mut self, sequence: u64) -> usize {
let before_pending = self.pending.len();
self.pending.retain(|e| e.request.key.sequence != sequence);
let dropped_pending = before_pending - self.pending.len();
let before_claimed = self.claimed.len();
self.claimed.retain(|_, c| c.request.key.sequence != sequence);
dropped_pending + (before_claimed - self.claimed.len())
/// Returns the dropped requests so the dispatcher can fire their
/// completions with `Error::State`.
pub fn cancel_sequence(&mut self, sequence: u64) -> Vec<FrameRequest<P>> {
let mut dropped = Vec::new();
self.pending.retain(|e| {
if e.request.key.sequence == sequence {
dropped.push(e.request.clone());
false
} else {
true
}
});
self.claimed.retain(|_, c| {
if c.request.key.sequence == sequence {
dropped.push(c.request.clone());
false
} else {
true
}
});
dropped
}
/// Pending (unclaimed) request count.
@@ -373,7 +404,7 @@ mod tests {
fn no_stealing_every_frame_claimed_exactly_once() {
let mut s: PreviewScheduler<u64> = PreviewScheduler::new(3, 4);
for f in 0..40 {
assert!(s.submit(req(1, f, FramePriority::Playback)));
assert!(matches!(s.submit(req(1, f, FramePriority::Playback)), SubmitOutcome::Accepted));
}
let claims = claim_all(&mut s);
assert_eq!(claims.len(), 40, "every frame claimed");
@@ -527,14 +558,42 @@ mod tests {
let mut s: PreviewScheduler<u64> = PreviewScheduler::new(1, 4);
let r = req(1, 5, FramePriority::Playback);
let key = r.key;
s.submit(r);
assert!(matches!(s.submit(r), SubmitOutcome::Accepted));
let _ = s.claim_batch(0, 4).unwrap();
// In flight: rejected.
assert!(!s.submit(req(1, 5, FramePriority::Seek)));
assert!(matches!(
s.submit(req(1, 5, FramePriority::Seek)),
SubmitOutcome::InFlight
));
s.frame_done(&key);
// After completion the same key may be requested again (new
// version in practice).
assert!(s.submit(req(1, 5, FramePriority::Seek)));
assert!(matches!(
s.submit(req(1, 5, FramePriority::Seek)),
SubmitOutcome::Accepted
));
}
#[test]
fn resubmit_replaces_pending_and_returns_the_old_request() {
let mut s: PreviewScheduler<u64> = PreviewScheduler::new(1, 4);
let first = req(1, 5, FramePriority::Playback);
let key = first.key;
assert!(matches!(s.submit(first), SubmitOutcome::Accepted));
// A second submission under the same key replaces the pending entry
// and hands the superseded request back (the dispatcher cancels its
// ticket completion with Error::State).
let second = req(1, 5, FramePriority::Seek);
let second_key = second.key;
match s.submit(second) {
SubmitOutcome::Replaced(old) => {
assert_eq!(old.key, key);
assert_eq!(old.priority, FramePriority::Playback);
}
other => panic!("expected Replaced, got {other:?}"),
}
let _ = s.claim_batch(0, 4).unwrap();
assert_eq!(s.claimed_worker(&second_key), Some(0));
}
#[test]
@@ -545,7 +604,8 @@ mod tests {
}
let _ = s.claim_batch(0, 4).unwrap(); // claims 4 of sequence 7
s.submit(req(8, 0, FramePriority::Playback)); // other sequence
assert_eq!(s.cancel_sequence(7), 6);
let dropped = s.cancel_sequence(7);
assert_eq!(dropped.len(), 6, "all 6 sequence-7 requests dropped");
assert_eq!(s.pending_count(), 1, "sequence 8 untouched");
assert_eq!(s.claimed_count(), 0);
}
+128 -48
View File
@@ -33,7 +33,7 @@ use oakcore_rs::{Rational, TimeRange};
use crate::error::{Error, Result};
use crate::eval;
use crate::texture::Texture;
use crate::worker::JobDispatch;
use crate::worker::{JobDispatch, JobSchedule};
/// One clip of a sequence montage (M12 P0): the facade resolves the
/// timeline into an ordered list of clips; the producer decodes each and
@@ -194,11 +194,21 @@ struct TicketSlot {
delivered: AtomicBool,
completion: Mutex<Option<Completion>>,
result: Mutex<Option<Arc<TicketResult>>>,
/// The dispatch the ticket posted through (M15 S2: a cancelled ticket
/// whose render completed must recycle its shm slot — the consumer
/// never sees the `ShmFrame` payload).
dispatch: Arc<dyn JobDispatch>,
}
impl TicketSlot {
fn finish(&self, mut result: TicketResult) {
if self.cancel.load(Ordering::Acquire) {
// A cancelled ticket still holds its shm slot when the render
// completed: recycle it now, before the payload is replaced by
// `Error::State` and the consumer loses it.
if let Ok(TicketPayload::ShmFrame(frame)) = &result {
self.dispatch.release_frame(frame);
}
result = Err(Error::State);
}
// Publish the result before flipping the state flag: `wait()` only
@@ -296,12 +306,27 @@ impl TicketArena {
/// Submit a video ticket with a caller-reserved id (allocated by
/// [`TicketArena::next_id`]); completion fires exactly once, including
/// on cancellation (with `Error::State`).
/// on cancellation (with `Error::State`). The job posts as a Seek
/// single-frame request (M15 S2; see
/// [`TicketArena::submit_playback`] for the pre-render window).
pub fn submit_video_with_id(
&self,
id: TicketId,
params: VideoTicketParams,
done: Completion,
) -> TicketId {
self.submit_video_impl(id, params, done, JobSchedule::seek())
}
/// The shared video-ticket submit path: register the slot, post the
/// job with `schedule` (M15 S2), and deliver `Error::State`
/// immediately when the backend is gone.
fn submit_video_impl(
&self,
id: TicketId,
params: VideoTicketParams,
done: Completion,
schedule: JobSchedule,
) -> TicketId {
let meta = TicketMeta {
kind: Some(ticket_kind::VIDEO),
@@ -322,6 +347,7 @@ impl TicketArena {
delivered: AtomicBool::new(false),
completion: Mutex::new(Some(done)),
result: Mutex::new(None),
dispatch: self.dispatch.clone(),
});
self.allocate(slot.clone());
@@ -334,6 +360,7 @@ impl TicketArena {
params,
produce: producer,
done: Box::new(move |result| slot_done.finish(result)),
schedule,
};
if !self.dispatch.post(job) {
// Backend is gone (shutdown raced the submit): deliver now.
@@ -342,6 +369,51 @@ impl TicketArena {
id
}
/// Submit a playback-window frame (M15 S2 pre-render window): the
/// frame joins the scheduler at Playback priority, ordered by
/// `distance` from the playhead and keyed under `version`. `frame` is
/// the sequence frame number (the scheduler key / interleaved shard).
/// The completion fires (from the dispatcher's poll) with
/// `TicketPayload::ShmFrame`.
pub fn submit_playback(
&self,
params: VideoTicketParams,
frame: i64,
distance: i64,
version: u64,
done: Completion,
) -> TicketId {
let id = self.next_id();
self.submit_video_impl(
id,
params,
done,
JobSchedule::playback(frame, distance, version),
)
}
/// Submit a Background-priority frame (M15 S2 exports/precache): the
/// scheduler renders it whenever no Seek/Playback work is pending.
pub fn submit_video_background(
&self,
params: VideoTicketParams,
done: Completion,
) -> TicketId {
let id = self.next_id();
self.submit_video_background_with_id(id, params, done)
}
/// [`TicketArena::submit_video_background`] with a caller-reserved id
/// (the export loop pre-allocates ids through [`TicketArena::next_id`]).
pub fn submit_video_background_with_id(
&self,
id: TicketId,
params: VideoTicketParams,
done: Completion,
) -> TicketId {
self.submit_video_impl(id, params, done, JobSchedule::background())
}
/// Submit a video ticket; completion fires exactly once, including
/// on cancellation (with `Error::State`).
pub fn submit_video(&self, params: VideoTicketParams, done: Completion) -> TicketId {
@@ -376,6 +448,7 @@ impl TicketArena {
delivered: AtomicBool::new(false),
completion: Mutex::new(Some(done)),
result: Mutex::new(None),
dispatch: self.audio_dispatch.clone(),
});
self.allocate(slot.clone());
@@ -403,6 +476,7 @@ impl TicketArena {
}),
produce: producer,
done: Box::new(move |result| slot_done.finish(result)),
schedule: JobSchedule::seek(),
};
if !self.audio_dispatch.post(job) {
slot.finish(Err(Error::State));
@@ -425,12 +499,28 @@ impl TicketArena {
}
}
/// Blocking wait for completion (C++ wait_for_finished).
/// Blocking wait for completion (C++ wait_for_finished). M15 S2: the
/// process dispatcher delivers completions from its poll loop, so a
/// blocking wait pumps it (the UI tick may be blocked right here).
pub fn wait(&self, id: TicketId) -> Result<()> {
let slot = lock(&self.slots).get(&id).cloned().ok_or(Error::NotFound)?;
let mut state = lock(&slot.state);
while !matches!(*state, SlotState::Finished) {
state = slot.cv.wait(state).unwrap_or_else(|e| e.into_inner());
// Pump the backend before blocking again. The slot lock is
// dropped first: poll() delivers completions that finish this
// very slot (finish() takes the same lock).
drop(state);
self.dispatch.poll();
state = lock(&slot.state);
if matches!(*state, SlotState::Finished) {
break;
}
let timeout = std::time::Duration::from_millis(5);
let (g, _) = slot
.cv
.wait_timeout(state, timeout)
.unwrap_or_else(|e| e.into_inner());
state = g;
}
Ok(())
}
@@ -470,8 +560,8 @@ impl TicketArena {
}
/// Cancel all pending tickets (manager shutdown path). Delivery happens
/// when the pool drains the queued jobs (or the running jobs finish);
/// call [`WorkerPool::shutdown`] afterwards to guarantee all
/// when the backend drains the queued jobs (or the running jobs
/// finish); call the backend's `shutdown` afterwards to guarantee all
/// completions have fired.
pub fn cancel_all(&self) {
self.shutting_down.store(true, Ordering::Release);
@@ -494,7 +584,7 @@ mod tests {
use crate::frame::VideoParamsPod;
use crate::texture::Frame;
use crate::worker::WorkerPool;
use crate::worker::{InlineDispatcher, JobDispatch};
fn small_frame() -> Frame {
let mut f = Frame::new();
@@ -510,12 +600,19 @@ mod tests {
Arc::new(|_, _| Ok(TicketPayload::Video(Texture::wrap_frame(small_frame()))))
}
/// A queued inline dispatcher for video (deterministic cancel-race and
/// shutdown semantics) plus a sync inline dispatcher for audio (the
/// production audio mode). `run` drains the video queue.
fn test_arena(producer: Producer) -> (TicketArena, Arc<InlineDispatcher>) {
let video = InlineDispatcher::queued();
let audio = InlineDispatcher::sync();
let arena = TicketArena::new_with_audio(video.clone(), audio, producer);
(arena, video)
}
#[test]
fn completion_fires_exactly_once_on_success() {
let pool = WorkerPool::new(2);
let mut pool = pool;
pool.start();
let arena = TicketArena::new(Arc::new(pool.clone()), ok_producer());
let (arena, video) = test_arena(ok_producer());
let (tx, rx) = mpsc::channel();
let id = arena.submit_video(
@@ -536,6 +633,7 @@ mod tests {
}),
);
video.run();
arena.wait(id).unwrap();
let ok = rx.recv_timeout(Duration::from_secs(5)).unwrap();
assert!(ok, "completion fired with success");
@@ -553,24 +651,16 @@ mod tests {
(4, 4)
);
assert!(arena.is_finished(id));
pool.shutdown();
video.shutdown();
}
#[test]
fn completion_fires_exactly_once_on_cancel() {
let pool = WorkerPool::new(1);
let mut pool = pool;
pool.start();
// Producer blocks until cancelled: exercises the cancel race.
let release = Arc::new(std::sync::atomic::AtomicBool::new(false));
let release2 = release.clone();
let producer: Producer = Arc::new(move |_, _| {
while !release2.load(Ordering::Acquire) {
std::thread::sleep(Duration::from_millis(1));
}
Ok(TicketPayload::Video(Texture::wrap_frame(small_frame())))
});
let arena = TicketArena::new(Arc::new(pool.clone()), producer);
// Queued mode: the job is posted but not run yet, so a cancel
// before `run` must deliver State exactly once (the old worker-pool
// test used a blocking producer; the inline queue makes the same
// cancel-before-completion race deterministic without threads).
let (arena, video) = test_arena(ok_producer());
let (tx, rx) = mpsc::channel();
let id = arena.submit_video(
@@ -591,9 +681,9 @@ mod tests {
}),
);
// Cancel while the job is running, then release the job.
// Cancel before the job runs.
arena.cancel(id);
release.store(true, Ordering::Release);
video.run();
arena.wait(id).unwrap();
let res = rx.recv_timeout(Duration::from_secs(5)).unwrap();
@@ -602,39 +692,32 @@ mod tests {
rx.recv_timeout(Duration::from_millis(50)).is_err(),
"exactly once"
);
pool.shutdown();
video.shutdown();
}
#[test]
fn cancel_of_unknown_id_is_ignored() {
let pool = WorkerPool::new(1);
let mut pool = pool;
pool.start();
let arena = TicketArena::new(Arc::new(pool.clone()), ok_producer());
let (arena, video) = test_arena(ok_producer());
arena.cancel(TicketId(12345));
assert!(!arena.is_finished(TicketId(12345)));
pool.shutdown();
video.shutdown();
}
#[test]
fn wait_unknown_id_errors() {
let pool = WorkerPool::new(1);
let mut pool = pool;
pool.start();
let arena = TicketArena::new(Arc::new(pool.clone()), ok_producer());
let (arena, video) = test_arena(ok_producer());
assert_eq!(
arena.wait(TicketId(999)).unwrap_err().code(),
Error::NotFound.code()
);
pool.shutdown();
video.shutdown();
}
#[test]
fn audio_ticket_meta_and_kind() {
let pool = WorkerPool::new(1);
let mut pool = pool;
pool.start();
let arena = TicketArena::new(Arc::new(pool.clone()), ok_producer());
// Audio runs on the sync inline backend (the production audio
// mode), so the completion fires during the submit.
let (arena, video) = test_arena(ok_producer());
let (tx, rx) = mpsc::channel();
let range = TimeRange::new(Rational::new(0, 1), Rational::new(10, 1));
@@ -659,15 +742,12 @@ mod tests {
assert!(!rx.recv_timeout(Duration::from_secs(5)).unwrap());
// get_time equivalent: audio tickets report range.in.
assert_eq!(arena.time(id), Some(range.in_()));
pool.shutdown();
video.shutdown();
}
#[test]
fn ticket_ids_are_monotonic() {
let pool = WorkerPool::new(1);
let mut pool = pool;
pool.start();
let arena = TicketArena::new(Arc::new(pool.clone()), ok_producer());
let (arena, video) = test_arena(ok_producer());
let a = arena.submit_video(
VideoTicketParams {
viewer: 1,
@@ -700,6 +780,6 @@ mod tests {
);
assert!(b.0 > a.0);
assert_ne!(a, b);
pool.shutdown();
video.shutdown();
}
}
+215 -260
View File
@@ -14,28 +14,41 @@
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//! The worker layer (C++ RenderWorkerPool + RenderThread +
//! workerprocess/workerjson): thread pool AND process-isolated pool
//! behind one dispatch seam.
//! The job-dispatch seam (M15 S2): what a render ticket posts through.
//!
//! This pass ships the in-process [`WorkerPool`] fully. The
//! process-isolated backend landed in M15 S1 as
//! [`crate::procpool::ProcessDispatcher`] (spawn/handshake/crash-restart
//! of oak-worker binaries over NDJSON + shared memory); both backends
//! implement the [`JobDispatch`] seam the ticket arena posts through.
//! [`ProcessPool`] below is the frozen pre-M15 facade stub kept for C
//! ABI parity.
//! The in-process thread pool was **removed** in M15 S2 (user mandate:
//! "delete the internal render thread pool"); the only video backend is
//! the process-isolated [`crate::procpool::ProcessDispatcher`]
//! (oak-worker children over NDJSON + shared memory). This module keeps
//! the ticket-facing surface:
//!
//! - [`Job`] — one unit of render work plus its scheduler hints.
//! - [`JobDispatch`] — the backend seam the arena posts through, with
//! default no-ops for the process-backend extras (poll / release /
//! preview-window cancellation).
//! - [`InlineDispatcher`] — a thread-free dispatcher that executes jobs
//! on the calling thread. Used as the **audio** backend (audio stays on
//! main-process inline execution until S3 — design §3.7) and by the
//! manager's test-only `Threads` backend and by unit tests.
//! - [`GraphSnapshotStore`] — the graph-snapshot file refcounting cache
//! shared with worker processes.
use std::collections::{HashMap, VecDeque};
use std::panic::{catch_unwind, AssertUnwindSafe};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Condvar, Mutex, MutexGuard};
use std::sync::{Arc, Mutex, MutexGuard};
use oakcore_rs::Rational;
use crate::error::{Error, Result};
use crate::procpool::ShmFrameRef;
use crate::scheduler::FramePriority;
use crate::ticket::{Completion, Producer, VideoTicketParams};
fn lock<T>(m: &Mutex<T>) -> MutexGuard<'_, T> {
m.lock().unwrap_or_else(|e| e.into_inner())
}
/// A unit of render work (produced by the ticket arena).
pub struct Job {
/// The graph position this job evaluates.
@@ -44,21 +57,63 @@ pub struct Job {
pub time: Rational,
/// Ticket parameters (size/format overrides).
pub params: Arc<VideoTicketParams>,
/// Frame producer (arena-installed).
/// Frame producer (arena-installed; the process backend never invokes
/// it — workers render from the wire spec).
pub produce: Producer,
/// Completion delivery.
pub done: Completion,
/// Scheduler hints (M15 S2). Defaults to a Seek single-frame request.
pub schedule: JobSchedule,
}
fn lock<T>(m: &Mutex<T>) -> MutexGuard<'_, T> {
m.lock().unwrap_or_else(|e| e.into_inner())
/// Scheduler hints a posted job carries (M15 S2). The process dispatcher
/// maps these onto [`crate::scheduler::FrameKey`] / priority; the inline
/// dispatcher ignores them.
#[derive(Clone, Debug, Default)]
pub struct JobSchedule {
/// Priority class. Default [`FramePriority::Seek`] (single-frame).
pub priority: FramePriority,
/// Scheduler key frame number. `None` = the ticket id (the Seek
/// single-frame convention).
pub frame: Option<i64>,
/// Playhead distance (orders the Playback class).
pub distance: i64,
/// Parameter version (graph/proxy/resolution/color); bumping it
/// invalidates stale requests for the same sequence+frame.
pub version: u64,
}
impl JobSchedule {
/// A Seek single-frame request (the default for every ticket).
pub fn seek() -> Self {
Self::default()
}
/// A Background request (exports / precache): rendered whenever the
/// workers have no Seek/Playback work.
pub fn background() -> Self {
Self {
priority: FramePriority::Background,
..Default::default()
}
}
/// A Playback-window request at `frame`, ordered by `distance` from
/// the playhead and keyed under `version`.
pub fn playback(frame: i64, distance: i64, version: u64) -> Self {
Self {
priority: FramePriority::Playback,
frame: Some(frame),
distance,
version,
}
}
}
/// The job-dispatch seam (M15 S1): the ticket arena posts [`Job`]s
/// through this interface without knowing the backend. Implemented by
/// the in-process [`WorkerPool`] (threads) and the process-isolated
/// [`crate::procpool::ProcessDispatcher`] (oak-worker children); S2
/// removes the thread pool and this seam becomes process-only.
/// through this interface without knowing the backend. Implemented by the
/// process-isolated [`crate::procpool::ProcessDispatcher`] (oak-worker
/// children) and the thread-free [`InlineDispatcher`] (audio / tests).
pub trait JobDispatch: Send + Sync {
/// Enqueue a job; false when the backend is gone (the arena then
/// delivers the completion itself with `Error::State`).
@@ -67,197 +122,127 @@ pub trait JobDispatch: Send + Sync {
/// Stop accepting work, deliver the queued completions (cancelled)
/// and release the backend. Idempotent.
fn shutdown(&self);
/// Pump backend completions (the process dispatcher's poll loop).
/// Default no-op: backends that deliver inline have nothing to pump.
/// The UI tick and blocking ticket waits call this so the process
/// backend's completions are delivered without a dedicated thread.
fn poll(&self) {}
/// Release a consumed shm frame's slot back to its worker (slot
/// release = cache eviction, design §3.1). Default no-op: only the
/// process backend holds slots.
fn release_frame(&self, _frame: &ShmFrameRef) {}
/// Cancel every pending AND claimed request of `sequence` (M15 S2
/// preview-window invalidation); their completions fire with
/// `Error::State`. Default no-op: only the process backend schedules.
fn cancel_preview_sequence(&self, _sequence: u64) {}
}
/// Thread-pool backend (C++ RenderThread model). Cheap to clone (all
/// state is behind an `Arc`); the manager and the ticket arena share one
/// pool.
#[derive(Clone)]
pub struct WorkerPool {
inner: Arc<PoolInner>,
/// Thread-free job dispatcher (M15 S2). Executes jobs on the calling
/// thread — there are deliberately **no worker threads**:
///
/// - **Sync mode** ([`InlineDispatcher::sync`]): every `post` runs its
/// job immediately on the caller's thread. This is the production
/// **audio** backend (audio stays on main-process inline execution
/// until S3 — design §3.7: the crash risk is dominated by video
/// plugins, which already live in oak-worker) and the manager's
/// test-only `Threads` backend.
/// - **Queued mode** ([`InlineDispatcher::queued`]): `post` queues the
/// job; the test drains it with [`InlineDispatcher::run`]. This keeps
/// the arena's cancel-race and shutdown semantics deterministic
/// without any threads.
pub struct InlineDispatcher {
inner: Arc<InlineInner>,
}
struct PoolInner {
workers: usize,
struct InlineInner {
queue: Mutex<VecDeque<Job>>,
cv: Condvar,
sync: bool,
stopping: AtomicBool,
threads: Mutex<Vec<std::thread::JoinHandle<()>>>,
}
impl WorkerPool {
/// Pool with `workers` threads (0 = hardware concurrency).
pub fn new(workers: usize) -> Self {
let workers = if workers == 0 {
std::thread::available_parallelism()
.map(|n| n.get())
.unwrap_or(1)
} else {
workers
};
Self {
inner: Arc::new(PoolInner {
workers,
impl InlineDispatcher {
/// A sync-mode dispatcher: jobs run immediately on the posting thread.
pub fn sync() -> Arc<Self> {
Arc::new(Self {
inner: Arc::new(InlineInner {
queue: Mutex::new(VecDeque::new()),
cv: Condvar::new(),
sync: true,
stopping: AtomicBool::new(false),
threads: Mutex::new(Vec::new()),
}),
}
})
}
/// The number of worker threads.
pub fn worker_count(&self) -> usize {
self.inner.workers
/// A queued-mode dispatcher: jobs wait for [`InlineDispatcher::run`].
pub fn queued() -> Arc<Self> {
Arc::new(Self {
inner: Arc::new(InlineInner {
queue: Mutex::new(VecDeque::new()),
sync: false,
stopping: AtomicBool::new(false),
}),
})
}
/// Start threads (idempotent).
pub fn start(&mut self) {
let mut threads = lock(&self.inner.threads);
if !threads.is_empty() {
/// Run every queued job synchronously on the calling thread (queued
/// mode). Jobs posted after `shutdown` are refused by `post`.
pub fn run(&self) {
if self.inner.stopping.load(Ordering::Acquire) {
return;
}
for _ in 0..self.inner.workers {
let inner = self.inner.clone();
let handle = std::thread::spawn(move || worker_loop(inner));
threads.push(handle);
loop {
let job = lock(&self.inner.queue).pop_front();
let Some(job) = job else { break };
execute_job(job);
}
}
/// True when threads are running.
pub fn is_running(&self) -> bool {
!lock(&self.inner.threads).is_empty()
/// The number of queued (not yet run) jobs.
pub fn queued_count(&self) -> usize {
lock(&self.inner.queue).len()
}
}
/// Enqueue a job. Returns false when the pool is shut down.
pub fn post(&self, job: Job) -> bool {
// The stopping check and the push share one queue lock: a shutdown
// racing the check would otherwise leave the job queued after every
// worker exited (and after the defensive drain), so its completion
// could never fire.
fn execute_job(job: Job) {
let result = catch_unwind(AssertUnwindSafe(|| (job.produce)(job.time, &job.params)))
.unwrap_or_else(|_| Err(Error::Failed("frame producer panicked".into())));
(job.done)(result);
}
impl JobDispatch for InlineDispatcher {
fn post(&self, job: Job) -> bool {
if self.inner.sync {
// Sync mode: run now (refusing only when shutting down).
if self.inner.stopping.load(Ordering::Acquire) {
return false;
}
execute_job(job);
return true;
}
let mut queue = lock(&self.inner.queue);
if self.inner.stopping.load(Ordering::Acquire) {
return false;
}
queue.push_back(job);
self.inner.cv.notify_one();
true
}
/// Stop accepting, drain, join all workers. In-flight job completions
/// fire with cancellation (queued jobs are delivered `Error::State`
/// without running); running jobs are joined so no completion fires
/// after shutdown returns.
pub fn shutdown(&mut self) {
self.shutdown_ref();
}
/// [`Self::shutdown`] on a shared reference (the [`JobDispatch`]
/// seam; all state is interior-mutable). Idempotent.
pub fn shutdown_ref(&self) {
// Set the flag and wake the workers while holding the queue lock.
// Workers decide whether to block in `cv.wait` while holding that
// lock, so a flag set outside it could land between a worker's
// predicate check and its wait: the wakeup is lost, the worker
// sleeps forever, and the join below hangs. Serializing store +
// notify with the waiters' lock closes that window.
{
let _guard = lock(&self.inner.queue);
self.inner.stopping.store(true, Ordering::Release);
self.inner.cv.notify_all();
}
let threads = std::mem::take(&mut *lock(&self.inner.threads));
for handle in threads {
let _ = handle.join();
}
// Defensive drain: any job that landed between `stopping` and the
// workers' exit (post() refuses them, so this is normally empty).
let mut queue = lock(&self.inner.queue);
while let Some(job) = queue.pop_front() {
deliver_cancelled(job);
}
}
}
impl JobDispatch for WorkerPool {
fn post(&self, job: Job) -> bool {
WorkerPool::post(self, job)
}
fn shutdown(&self) {
self.shutdown_ref();
}
}
fn worker_loop(inner: Arc<PoolInner>) {
loop {
let job = {
let mut queue = lock(&inner.queue);
while !inner.stopping.load(Ordering::Acquire) && queue.is_empty() {
queue = inner.cv.wait(queue).unwrap_or_else(|e| e.into_inner());
}
queue.pop_front()
// Stop accepting and drain the queue with cancellation (queued
// jobs never run after shutdown).
let jobs: Vec<Job> = {
let mut queue = lock(&self.inner.queue);
self.inner.stopping.store(true, Ordering::Release);
queue.drain(..).collect()
};
let Some(job) = job else {
return; // stopping and queue drained
};
if inner.stopping.load(Ordering::Acquire) {
// Shutdown raced this pop: deliver cancellation.
deliver_cancelled(job);
continue;
for job in jobs {
(job.done)(Err(Error::State));
}
let result = catch_unwind(AssertUnwindSafe(|| (job.produce)(job.time, &job.params)))
.unwrap_or_else(|_| Err(Error::Failed("frame producer panicked".into())));
(job.done)(result);
}
}
fn deliver_cancelled(job: Job) {
(job.done)(Err(Error::State));
}
/// Process-isolated worker backend (C++ RenderWorkerPool +
/// PooledWorker). Child processes talk the oakengine_ipc C ABI; this side
/// is only a client (spawn, dispatch, reap). Not wired in this pass.
pub struct ProcessPool {
workers: usize,
}
impl ProcessPool {
/// Pool of `workers` child processes.
pub fn new(workers: usize) -> Self {
Self { workers }
}
/// The configured child count.
pub fn worker_count(&self) -> usize {
self.workers
}
/// Spawn children and handshake.
pub fn start(&mut self) -> Result<()> {
Err(Error::Failed(
"oakengine_ipc worker-process bridge not implemented in this pass".into(),
))
}
/// Dispatch a job to a free child.
pub fn post(&self, _job: Job) -> Result<()> {
Err(Error::Failed(
"oakengine_ipc worker-process bridge not implemented in this pass".into(),
))
}
/// Cancel the job running in a child (C++ cancel_active_process).
pub fn cancel_active(&self, _process_slot: usize) {}
/// Terminate and reap all children; pending jobs complete with
/// cancellation.
pub fn shutdown(&mut self) {}
}
/// Graph snapshot files shared with worker processes (C++
/// write_graph_snapshot + path refcounting): a snapshot is written once
/// and reference-counted; the file is unlinked at zero.
@@ -355,27 +340,20 @@ impl Default for GraphSnapshotStore {
}
}
/// The pool the manager runs (config-selected, C++ parity).
pub enum WorkerBackend {
/// In-process threads.
Threads(WorkerPool),
/// Child processes (crash isolation).
Processes(ProcessPool),
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::AtomicUsize;
use std::sync::mpsc;
use std::time::Duration;
use crate::texture::Texture;
fn job(tag: u64, tx: mpsc::Sender<u64>, gate: Option<Arc<AtomicUsize>>) -> Job {
fn job(tag: u64, tx: mpsc::Sender<u64>, gate: Option<Arc<AtomicBool>>) -> Job {
let produce: Producer = Arc::new(move |_, _| {
if let Some(g) = &gate {
g.fetch_add(1, Ordering::SeqCst);
if g.load(Ordering::Acquire) {
return Err(Error::Failed("gated producer".into()));
}
}
Ok(crate::ticket::TicketPayload::Video(Texture::dummy()))
});
@@ -399,56 +377,58 @@ mod tests {
assert!(r.is_ok(), "producer must succeed here");
let _ = tx.send(tag);
}),
schedule: JobSchedule::seek(),
}
}
#[test]
fn pool_saturation_all_jobs_complete() {
let mut pool = WorkerPool::new(4);
pool.start();
fn sync_dispatcher_runs_every_job_immediately() {
let d = InlineDispatcher::sync();
let (tx, rx) = mpsc::channel();
for i in 0..64u64 {
assert!(pool.post(job(i, tx.clone(), None)));
for i in 0..8u64 {
assert!(d.post(job(i, tx.clone(), None)));
}
drop(tx);
let mut seen = Vec::new();
while let Ok(tag) = rx.recv_timeout(Duration::from_secs(10)) {
while let Ok(tag) = rx.recv_timeout(Duration::from_secs(5)) {
seen.push(tag);
}
assert_eq!(seen.len(), 64);
seen.sort_unstable();
for (i, tag) in seen.iter().enumerate() {
assert_eq!(*tag, i as u64, "every job runs exactly once");
}
pool.shutdown();
assert_eq!(seen.len(), 8, "every job ran on the posting thread");
d.shutdown();
// Post after shutdown is refused.
let (tx2, _rx2) = mpsc::channel();
assert!(!d.post(job(9, tx2, None)), "post after shutdown is refused");
}
#[test]
fn shutdown_delivers_cancellation_to_queued_jobs() {
// 1 worker + a gate that blocks: jobs 2..N stay queued and must be
// delivered Err(State) at shutdown.
let gate = Arc::new(AtomicUsize::new(0));
let mut pool = WorkerPool::new(1);
pool.start();
fn queued_dispatcher_runs_on_demand() {
let d = InlineDispatcher::queued();
let (tx, rx) = mpsc::channel();
for i in 0..8u64 {
assert!(d.post(job(i, tx.clone(), None)));
}
assert_eq!(d.queued_count(), 8, "nothing ran yet");
d.run();
assert_eq!(d.queued_count(), 0);
drop(tx);
let mut seen = Vec::new();
while let Ok(tag) = rx.recv_timeout(Duration::from_secs(5)) {
seen.push(tag);
}
assert_eq!(seen.len(), 8);
d.shutdown();
}
#[test]
fn queued_dispatcher_shutdown_delivers_cancellation() {
let d = InlineDispatcher::queued();
let (tx, rx) = mpsc::channel();
for _ in 0..4 {
let tx = tx.clone();
let gate = gate.clone();
let produce: Producer = Arc::new(move |_, _| {
if i == 0 {
// First job blocks until shutdown begins.
let start = std::time::Instant::now();
while gate.load(Ordering::Acquire) == 0
&& start.elapsed() < Duration::from_secs(5)
{
std::thread::sleep(Duration::from_millis(1));
}
}
Ok(crate::ticket::TicketPayload::Video(Texture::dummy()))
});
let job = Job {
node_identity: i,
time: Rational::new(i as i64, 1),
let p: Producer = Arc::new(|_, _| Ok(crate::ticket::TicketPayload::Video(Texture::dummy())));
d.post(Job {
node_identity: 1,
time: Rational::new(0, 1),
params: Arc::new(VideoTicketParams {
viewer: 0,
time: Rational::new(0, 1),
@@ -461,44 +441,28 @@ mod tests {
footage: None,
montage: Vec::new(),
}),
produce,
produce: p,
done: Box::new(move |r| {
let _ = tx.send(r.is_err());
}),
};
pool.post(job);
schedule: JobSchedule::seek(),
});
}
drop(tx);
gate.store(1, Ordering::Release);
pool.shutdown();
d.shutdown();
let mut delivered = Vec::new();
while let Ok(is_err) = rx.recv_timeout(Duration::from_secs(5)) {
delivered.push(is_err);
while let Ok(err) = rx.recv_timeout(Duration::from_secs(5)) {
delivered.push(err);
}
assert_eq!(delivered.len(), 8, "all 8 completions fire");
assert!(
delivered.iter().filter(|&&e| e).count() >= 7,
"queued jobs complete with cancellation"
);
assert_eq!(delivered.len(), 4, "all queued completions fire");
assert!(delivered.iter().all(|&e| e), "queued jobs cancel at shutdown");
}
#[test]
fn post_after_shutdown_is_refused() {
let mut pool = WorkerPool::new(1);
pool.start();
pool.shutdown();
let (tx, _rx) = mpsc::channel();
assert!(!pool.post(job(1, tx, None)));
}
#[test]
fn producer_panic_does_not_kill_worker() {
let mut pool = WorkerPool::new(1);
pool.start();
fn producer_panic_does_not_kill_the_dispatcher() {
let d = InlineDispatcher::sync();
let (tx, rx) = mpsc::channel();
let tx1 = tx.clone();
let tx2 = tx.clone();
let boom: Producer = Arc::new(|_, _| panic!("boom"));
let ok: Producer = Arc::new(|_, _| Ok(crate::ticket::TicketPayload::Video(Texture::dummy())));
let params = Arc::new(VideoTicketParams {
@@ -513,7 +477,7 @@ mod tests {
footage: None,
montage: Vec::new(),
});
pool.post(Job {
d.post(Job {
node_identity: 0,
time: Rational::new(0, 1),
params: params.clone(),
@@ -522,34 +486,25 @@ mod tests {
assert!(r.is_err());
let _ = tx1.send(1u64);
}),
schedule: JobSchedule::seek(),
});
pool.post(Job {
d.post(Job {
node_identity: 1,
time: Rational::new(1, 1),
params,
produce: ok,
done: Box::new(move |r| {
assert!(r.is_ok());
let _ = tx2.send(2u64);
let _ = tx.send(2u64);
}),
schedule: JobSchedule::seek(),
});
let mut got = Vec::new();
while let Ok(v) = rx.recv_timeout(Duration::from_secs(5)) {
got.push(v);
}
assert_eq!(got.len(), 2, "worker survives a panicking producer");
pool.shutdown();
}
#[test]
fn process_pool_is_documented_stub() {
let mut pp = ProcessPool::new(2);
assert_eq!(pp.worker_count(), 2);
assert!(pp.start().is_err(), "oakengine_ipc bridge pending");
let (tx, _rx) = mpsc::channel();
assert!(pp.post(job(1, tx, None)).is_err());
pp.cancel_active(0); // no-op
pp.shutdown(); // no-op
assert_eq!(got.len(), 2, "the dispatcher survives a panicking producer");
d.shutdown();
}
#[test]
+6 -2
View File
@@ -30,11 +30,15 @@ pub struct ManagerGuard {
}
impl ManagerGuard {
/// Initialize the manager and hold the serialization lock.
/// Initialize the manager and hold the serialization lock. Uses the
/// test-only inline backend so no oak-worker children are spawned.
pub fn init() -> Self {
let guard = MANAGER_LOCK.lock().unwrap_or_else(|e| e.into_inner());
oakrender::manager::RenderManager::shutdown();
oakrender::manager::RenderManager::init().expect("manager init");
oakrender::manager::RenderManager::init_with_backend(
oakrender::manager::RenderBackendChoice::Threads,
)
.expect("manager init");
Self { _guard: guard }
}
}
+11 -13
View File
@@ -30,7 +30,7 @@ use oakrender::error::Error;
use oakrender::frame::VideoParamsPod;
use oakrender::texture::{Frame, Texture};
use oakrender::ticket::TicketArena;
use oakrender::worker::WorkerPool;
use oakrender::worker::{InlineDispatcher, JobDispatch};
fn frame_producer() -> oakrender::ticket::Producer {
Arc::new(|_, _| {
@@ -44,14 +44,10 @@ fn frame_producer() -> oakrender::ticket::Producer {
})
}
fn cacher() -> (oakrender::autocacher::PreviewAutoCacher, WorkerPool) {
let mut pool = WorkerPool::new(2);
pool.start();
let arena = Arc::new(TicketArena::new(
Arc::new(pool.clone()),
frame_producer(),
));
(oakrender::autocacher::PreviewAutoCacher::new(arena), pool)
fn cacher() -> (oakrender::autocacher::PreviewAutoCacher, Arc<InlineDispatcher>) {
let d = InlineDispatcher::queued();
let arena = Arc::new(TicketArena::new(d.clone(), frame_producer()));
(oakrender::autocacher::PreviewAutoCacher::new(arena), d)
}
/// Deep-copy through oaknode with a valid (non-empty) project identity:
@@ -111,24 +107,26 @@ fn autocacher_attach_detach() {
}
/// cancel_video_tasks(wait=false) returns immediately with jobs
/// cancelled; wait=true blocks until workers are idle.
/// cancelled; wait=true blocks until the dispatcher is idle.
#[test]
fn cancel_video_tasks_semantics() {
let (mut c, mut pool) = cacher();
let (mut c, d) = cacher();
c.attach(1);
c.force_range(TimeRange::new(Rational::new(0, 1), Rational::new(5, 1)));
assert_eq!(c.live_jobs().len(), 1);
// wait=false: returns immediately; the ticket may still be draining.
c.cancel_video_tasks(false);
// wait=true: blocks until every job finished.
// wait=true: drain the queued jobs first (the inline dispatcher has no
// background threads), then blocks until every job finished.
c.force_range(TimeRange::new(Rational::new(5, 1), Rational::new(10, 1)));
d.run();
c.cancel_video_tasks(true);
assert!(
!c.is_rendering_custom_range(),
"all custom-range jobs finished after wait"
);
pool.shutdown();
d.shutdown();
}
/// Change-record marshalling: every ChangeRecord kind survives the
+29 -89
View File
@@ -14,7 +14,10 @@
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//! Ticket / worker-pool contract tests.
//! Ticket / job-dispatch contract tests (M15 S2: the in-process thread
//! pool is gone; the tests drive the thread-free inline dispatcher, which
//! makes the arena's exactly-once / cancel / shutdown semantics
//! deterministic without any worker threads).
mod common;
@@ -28,6 +31,7 @@ use oakrender::error::Error;
use oakrender::frame::VideoParamsPod;
use oakrender::texture::{Frame, Texture};
use oakrender::ticket::{TicketArena, TicketId, VideoTicketParams};
use oakrender::worker::{GraphSnapshotStore, InlineDispatcher, JobDispatch};
/// Unwrap a video ticket payload for assertions.
fn res_video(res: &oakrender::ticket::TicketPayload) -> &oakrender::texture::Texture {
@@ -36,7 +40,6 @@ fn res_video(res: &oakrender::ticket::TicketPayload) -> &oakrender::texture::Tex
_ => panic!("expected a video payload"),
}
}
use oakrender::worker::{GraphSnapshotStore, WorkerPool};
fn small_frame() -> Frame {
let mut f = Frame::new();
@@ -67,23 +70,19 @@ fn params(time: Rational) -> VideoTicketParams {
}
}
/// Opens a worker gate on drop (also on panic), so a failing assertion
/// can never leave workers spinning while the manager shuts down.
struct GateRelease(Arc<AtomicBool>);
impl Drop for GateRelease {
fn drop(&mut self) {
self.0.store(true, Ordering::Release);
}
/// An arena on a queued inline dispatcher: jobs run only when the test
/// drains it with `InlineDispatcher::run` / `shutdown`.
fn test_arena(producer: oakrender::ticket::Producer) -> (TicketArena, Arc<InlineDispatcher>) {
let d = InlineDispatcher::queued();
let arena = TicketArena::new(d.clone(), producer);
(arena, d)
}
/// Completion fires exactly once on success; the payload texture has
/// the requested size/format.
#[test]
fn ticket_completion_once_success() {
let mut pool = WorkerPool::new(2);
pool.start();
let arena = TicketArena::new(Arc::new(pool.clone()), ok_producer());
let (arena, d) = test_arena(ok_producer());
let (tx, rx) = mpsc::channel();
let id = arena.submit_video(
@@ -92,6 +91,7 @@ fn ticket_completion_once_success() {
let _ = tx.send(r.is_ok());
}),
);
d.run();
arena.wait(id).unwrap();
assert!(rx.recv_timeout(Duration::from_secs(5)).unwrap());
assert!(
@@ -103,24 +103,16 @@ fn ticket_completion_once_success() {
assert_eq!(res_video(&res).size(), (8, 4));
assert_eq!(res_video(&res).format(), oakcore_rs::PixelFormat::F32);
assert!(arena.is_finished(id));
pool.shutdown();
d.shutdown();
}
/// Completion fires exactly once on cancel (Error::State), even when
/// cancel races the running job.
/// cancel races the running job. On the queued inline dispatcher the
/// "running" state is the queued-but-not-yet-drained job: a cancel before
/// `run` delivers State deterministically.
#[test]
fn ticket_completion_once_on_cancel() {
let mut pool = WorkerPool::new(1);
pool.start();
let release = Arc::new(AtomicBool::new(false));
let release2 = release.clone();
let blocking: oakrender::ticket::Producer = Arc::new(move |_, _| {
while !release2.load(Ordering::Acquire) {
std::thread::sleep(Duration::from_millis(1));
}
Ok(oakrender::ticket::TicketPayload::Video(Texture::wrap_frame(small_frame())))
});
let arena = TicketArena::new(Arc::new(pool.clone()), blocking);
let (arena, d) = test_arena(ok_producer());
let (tx, rx) = mpsc::channel();
let id = arena.submit_video(
@@ -130,7 +122,7 @@ fn ticket_completion_once_on_cancel() {
}),
);
arena.cancel(id);
release.store(true, Ordering::Release);
d.run();
arena.wait(id).unwrap();
let res = rx.recv_timeout(Duration::from_secs(5)).unwrap();
assert_eq!(res.unwrap_err().code(), Error::State.code());
@@ -138,28 +130,14 @@ fn ticket_completion_once_on_cancel() {
rx.recv_timeout(Duration::from_millis(50)).is_err(),
"exactly once"
);
pool.shutdown();
d.shutdown();
}
/// cancel_all during shutdown delivers cancellation to every pending
/// ticket; no completion fires after shutdown returns.
#[test]
fn shutdown_drains_completions() {
let mut pool = WorkerPool::new(1);
pool.start();
// The producer blocks until released so no job can finish before the
// shutdown (otherwise the timing of which jobs ran is nondeterministic).
let gate = Arc::new(AtomicBool::new(false));
let blocking: oakrender::ticket::Producer = {
let gate = gate.clone();
Arc::new(move |_, _| {
while !gate.load(Ordering::Acquire) {
std::thread::sleep(Duration::from_millis(1));
}
Ok(oakrender::ticket::TicketPayload::Video(Texture::wrap_frame(small_frame())))
})
};
let arena = TicketArena::new(Arc::new(pool.clone()), blocking);
let (arena, d) = test_arena(ok_producer());
let (tx, rx) = mpsc::channel();
let mut ids = Vec::new();
@@ -175,8 +153,7 @@ fn shutdown_drains_completions() {
}
drop(tx);
arena.cancel_all();
gate.store(true, Ordering::Release);
pool.shutdown();
d.shutdown();
let mut completions = Vec::new();
while let Ok(err) = rx.recv_timeout(Duration::from_secs(5)) {
@@ -191,13 +168,11 @@ fn shutdown_drains_completions() {
assert!(ids.iter().all(|id| arena.is_finished(*id)));
}
/// Pool saturation: 4 workers × 64 jobs all complete; no job runs
/// twice (arena ids unique).
/// Saturation: 64 jobs all complete on the queued dispatcher after one
/// drain; no job runs twice (arena ids unique).
#[test]
fn pool_saturation() {
let mut pool = WorkerPool::new(4);
pool.start();
let arena = TicketArena::new(Arc::new(pool.clone()), ok_producer());
let (arena, d) = test_arena(ok_producer());
let (tx, rx) = mpsc::channel();
let mut ids = Vec::new();
for i in 0..64u64 {
@@ -211,6 +186,7 @@ fn pool_saturation() {
ids.push(id);
}
drop(tx);
d.run();
let mut ok = 0;
while let Ok(true) = rx.recv_timeout(Duration::from_secs(10)) {
ok += 1;
@@ -219,55 +195,19 @@ fn pool_saturation() {
ids.sort_by_key(|i| i.0);
let unique: std::collections::HashSet<_> = ids.iter().collect();
assert_eq!(unique.len(), 64, "unique arena ids");
pool.shutdown();
d.shutdown();
}
/// Ticket arena ids are monotonic and never reused within a manager
/// lifetime.
#[test]
fn ticket_id_monotonic() {
let mut pool = WorkerPool::new(1);
pool.start();
let arena = TicketArena::new(Arc::new(pool.clone()), ok_producer());
let (arena, d) = test_arena(ok_producer());
let a = arena.submit_video(params(Rational::new(0, 1)), Box::new(|_| {}));
let b = arena.submit_video(params(Rational::new(1, 1)), Box::new(|_| {}));
let c = arena.submit_video(params(Rational::new(2, 1)), Box::new(|_| {}));
assert!(a.0 < b.0 && b.0 < c.0);
pool.shutdown();
}
/// Process pool: documented stub until the oakengine_ipc worker binary
/// is wired (see worker.rs).
#[test]
#[ignore = "needs oakengine_ipc worker-process binary"]
fn process_pool_roundtrip() {
let mut pp = oakrender::worker::ProcessPool::new(2);
pp.start().unwrap();
let (tx, rx) = mpsc::channel();
let produce: oakrender::ticket::Producer =
Arc::new(|_, _| Ok(oakrender::ticket::TicketPayload::Video(Texture::wrap_frame(small_frame()))));
let job = oakrender::worker::Job {
node_identity: 1,
time: Rational::new(0, 1),
params: Arc::new(params(Rational::new(0, 1))),
produce,
done: Box::new(move |r| {
let _ = tx.send(r.is_ok());
}),
};
pp.post(job).unwrap();
assert!(rx.recv_timeout(Duration::from_secs(5)).unwrap());
pp.shutdown();
}
/// Crash isolation: a child killed mid-job fails that ticket with
/// Error::Failed and the pool stays usable.
#[test]
#[ignore = "needs oakengine_ipc worker-process binary"]
fn process_crash_isolation() {
let mut pp = oakrender::worker::ProcessPool::new(1);
pp.start().unwrap();
pp.shutdown();
d.shutdown();
}
/// GraphSnapshotStore: acquire twice shares one file; release to zero
+127 -32
View File
@@ -29,8 +29,8 @@
//! clip montage (`VideoTicketParams::montage`) resolved from the
//! sequence's track lists. Tickets run on the process-wide
//! `oakrender::manager::RenderManager` arena when the manager is
//! initialized, otherwise on a private worker pool + arena owned by this
//! render run.
//! initialized, otherwise on a private process dispatcher + arena owned
//! by this render run (M15 S2: the in-process thread pool is gone).
//!
//! ## Concurrent render loop
//!
@@ -57,11 +57,12 @@ use oakcommon::videoparams::VideoParams;
use oaknode::footage::FootageBehavior;
use oaknode::sequence::SequenceBehavior;
use oaknode::track::{TrackBehavior, TrackListBehavior, TrackType};
use oakrender::procpool::{bgra8_to_f32_rgba, DispatcherConfig, ProcessDispatcher, ShmFrameRef};
use oakrender::ticket::{
ticket_kind, AudioTicketParams, MontageClip, TicketArena, TicketId, TicketPayload,
TicketResult, VideoTicketParams,
};
use oakrender::worker::WorkerPool;
use oakrender::worker::JobDispatch;
use crate::error::{Error, Result};
use crate::nodeops::{
@@ -477,7 +478,10 @@ impl RenderTask {
/// Submit one video frame ticket at `time` (mirrors the C++
/// `start_video_ticket`). `dispatch` is the shared completion channel
/// handed to the ticket's finished callback.
/// handed to the ticket's finished callback. M15 S2: export/precache
/// tickets post at Background priority (the scheduler serves them when
/// no Seek/Playback work is pending; credit flow control caps how many
/// are in flight).
fn submit_video_ticket(
&self,
arena: &TicketArena,
@@ -487,7 +491,7 @@ impl RenderTask {
let params = self.build_video_ticket(time)?;
let id = arena.next_id();
let dispatch_ptr = DispatchPtr(dispatch);
arena.submit_video_with_id(id, params, Box::new(move |result| {
arena.submit_video_background_with_id(id, params, Box::new(move |result| {
push_finished(id, result, dispatch_ptr);
}));
Ok(id)
@@ -649,19 +653,33 @@ impl RenderTask {
let total_slots = slots.len();
// The ticket arena: the process-wide manager arena when the
// manager is initialized, otherwise a private worker pool + arena
// owned by this run (keeps headless/test runs self-contained).
let (arena, mut private_pool) = match oakrender::manager::RenderManager::global() {
// manager is initialized, otherwise a private process dispatcher +
// arena owned by this run (keeps headless/test runs
// self-contained; M15 S2 — the in-process thread pool is gone).
let (arena, mut private_dispatch) = match oakrender::manager::RenderManager::global() {
Some(manager) => (manager.tickets.clone(), None),
None => {
let mut pool = WorkerPool::new(0);
pool.start();
let dispatcher = ProcessDispatcher::new(DispatcherConfig::default())
.map_err(|e| Error::Failed(format!("render worker pool: {e}")))?;
dispatcher
.start()
.map_err(|e| Error::Failed(format!("render worker pool start: {e}")))?;
let producer: oakrender::ticket::Producer = Arc::new(|time, params| {
oakrender::eval::render_produced_frame(time, params)
.map(TicketPayload::Video)
});
let arena = Arc::new(TicketArena::new(Arc::new(pool.clone()), producer));
(arena, Some(pool))
let arena = Arc::new(TicketArena::new(dispatcher.clone(), producer));
(arena, Some(dispatcher))
}
};
// M15 S2: the process dispatcher delivers completions from its poll
// loop; the render thread must pump it while it waits (there is no
// UI tick on the export thread).
let pump = || {
if let Some(m) = oakrender::manager::RenderManager::global() {
m.poll();
} else if let Some(d) = &private_dispatch {
d.poll();
}
};
@@ -769,6 +787,27 @@ impl RenderTask {
task.emit_progress(progress_counter / total_length);
}
}
Ok(TicketPayload::ShmFrame(frame)) => {
// M15 S2 process backend: the frame lives in a
// worker shm slot. Copy it out once into a
// texture the encoder consumes (necessary copy —
// the encoder needs an owned F32 buffer), then
// release the slot.
let texture = shm_frame_to_texture(&frame);
if let Err(e) = behavior.frame_downloaded(task, &texture) {
result = Err(e);
break;
}
if let Some(m) = oakrender::manager::RenderManager::global() {
m.release_frame(&frame);
} else if let Some(d) = &private_dispatch {
d.release_frame(&frame);
}
if self.native_progress_signalling {
progress_counter += 1.0;
task.emit_progress(progress_counter / total_length);
}
}
Ok(_) => {
result = Err(Error::Failed(
"Video render ticket delivered a non-video payload".to_string(),
@@ -813,20 +852,28 @@ impl RenderTask {
// Wait for the next completion (a cancellation or a hook error
// aborts the wait; in-flight tickets then finish, waking us).
let mut guard = dispatch_ref
.finished
.lock()
.unwrap_or_else(|e| e.into_inner());
while guard.is_empty()
&& dispatch_ref.running.load(Ordering::SeqCst) > 0
&& !task.is_cancelled()
{
guard = dispatch_ref
.cv
.wait(guard)
// The process dispatcher is pumped so its poll loop delivers the
// completions — never while holding the finished-queue lock
// (pump()'s delivered completions lock the same queue).
loop {
pump();
let mut guard = dispatch_ref
.finished
.lock()
.unwrap_or_else(|e| e.into_inner());
let done = !guard.is_empty()
|| dispatch_ref.running.load(Ordering::SeqCst) == 0
|| task.is_cancelled();
if done {
break;
}
let (g, _) = dispatch_ref
.cv
.wait_timeout(guard, std::time::Duration::from_millis(5))
.unwrap_or_else(|e| e.into_inner());
guard = g;
drop(guard); // release before the next pump
}
drop(guard);
if dispatch_ref
.finished
.lock()
@@ -850,19 +897,23 @@ impl RenderTask {
// Tear down. On cancellation or error, cancel and wait every ticket
// still in flight (the C++ abort path), so their completions still
// fire exactly once. Then wait until every callback has returned
// and free the completion channel; a private pool is shut down.
// and free the completion channel; a private dispatcher is shut
// down (M15 S2: the process dispatcher, not a thread pool).
if result.is_err() || task.is_cancelled() {
for &id in &in_flight {
arena.cancel(id);
let _ = arena.wait(id);
}
}
dispatch_ref.wait_idle();
dispatch_ref.wait_idle(&pump);
unsafe {
drop(Box::from_raw(dispatch));
}
if let Some(mut pool) = private_pool.take() {
pool.shutdown();
// The pump closure borrows `private_dispatch`; drop it before the
// take below.
drop(pump);
if let Some(d) = private_dispatch.take() {
d.shutdown();
}
result
@@ -928,10 +979,22 @@ impl RenderDispatch {
/// Block until every submitted ticket has fired its callback. Called
/// before freeing `self`, so no callback can touch the state afterwards.
fn wait_idle(&self) {
let mut guard = self.finished.lock().unwrap_or_else(|e| e.into_inner());
while self.running.load(Ordering::SeqCst) > 0 {
guard = self.cv.wait(guard).unwrap_or_else(|e| e.into_inner());
/// `pump` drives the process dispatcher's poll loop (M15 S2) — never
/// while holding the finished-queue lock (pump's delivered completions
/// lock the same queue).
fn wait_idle(&self, pump: &dyn Fn()) {
loop {
pump();
let mut guard = self.finished.lock().unwrap_or_else(|e| e.into_inner());
if self.running.load(Ordering::SeqCst) == 0 {
break;
}
let (g, _) = self
.cv
.wait_timeout(guard, std::time::Duration::from_millis(5))
.unwrap_or_else(|e| e.into_inner());
guard = g;
drop(guard); // release before the next pump
}
}
}
@@ -965,3 +1028,35 @@ fn push_finished(id: TicketId, result: TicketResult, dispatch: DispatchPtr) {
dispatch.running.fetch_sub(1, Ordering::SeqCst);
dispatch.cv.notify_all();
}
/// Copy a process-backend shm frame (BGRA8 slot) out into an F32 CPU
/// texture the encoder consumes (M15 S2). The worker converted its F32
/// pipeline output to BGRA8 for the slot (design §3.1); the encoder
/// declares F32 input, so the export converts back — a necessary
/// conversion at the encoder boundary with 8-bit quantization (S2).
fn shm_frame_to_texture(frame: &ShmFrameRef) -> oakrender::texture::Texture {
let meta = &frame.meta;
let pixels = frame
.shm
.slot_bytes(frame.slot)
.get(..meta.data_size.max(0) as usize)
.unwrap_or_default();
let samples = bgra8_to_f32_rgba(pixels);
let mut f = oakrender::texture::Frame::new();
f.width = meta.width;
f.height = meta.height;
f.format = oakcore_rs::PixelFormat::F32;
f.channels = 4;
f.timestamp = oakcore_rs::Rational::new(meta.time_num, meta.time_den);
f.data = samples
.chunks_exact(4)
.flat_map(|px| {
let mut bytes = [0u8; 16];
for (i, v) in px.iter().enumerate() {
bytes[i * 4..i * 4 + 4].copy_from_slice(&v.to_le_bytes());
}
bytes
})
.collect();
oakrender::texture::Texture::wrap_frame(f)
}
@@ -2,6 +2,7 @@
> 状态:已批准(用户 2026-08-18 提出,作为独立追加任务,不阻塞 M12 其余阶段)。
> 前置调研:见会话调研报告(oak-worker/ipc.rs 传输层已完整、TicketArena 投递口收敛、上屏链路 6 处拷贝点)。
> 进度:S1 完成(2026-08,协议 v2 + ProcessDispatcher + PreviewScheduler + worker 真实渲染,与线程池并存);S2 完成(默认 Processes、删除 WorkerPool、oaktask/oak-cli/app 接入、上屏零拷贝、播放预渲染窗口)。S3 待做。
## 1. 目标(用户原文要求)
@@ -68,11 +69,15 @@
唯一投递口 `TicketArena.pool.post`ticket.rs:321,390)。消费者 4 个(app renderops、oak-cli、oaktask 导出、oakengine facadeAPI 不变。`WorkerBackend::{Threads,Processes}` 枚举已预留。oaktask 导出自建私有池改为自建私有 ProcessDispatchermax_inflight 语义由调度器接管)。**WorkerPool 及其线程在 S2 彻底删除**(用户明确要求;单测需要的同步执行语义由 dispatcher 的 `run_inline` 测试模式提供,不保留生产线程池)。
> **S2 落地**`worker::WorkerPool`/`ProcessPool`/`WorkerBackend` 已删除;`worker.rs` 保留 `Job`/`JobDispatch`/`GraphSnapshotStore` 并新增线程无关的 `InlineDispatcher``sync` 模式 = 生产音频后端,`queued` 模式 = 测试确定性执行)。`RenderManager::init()` 默认 `Processes``init_with_backend(Threads)` 仅供测试(同步 inline)。oaktask 导出/CLI 走 manager 进程后端;`TicketArena::wait` 与 oaktask 渲染循环在等待时泵 `poll()`(进程后端无独立泵线程,全部非阻塞)。
### 3.5 上屏零拷贝改造(S2src/ 侧)
现状拷贝点(调研报告):ticket result `frame.data.clone()` → linesize 重排 → F32→BGRA8 → 缓存 → atlas write_texture。
改后:ticket 完成返回 `ShmFrameRef{worker, slot, meta}`viewer 预览帧(BGRA8 槽)直接切片喂 `write_texture`scopes 分析改为读 BGRA8(精度足够)或另请 F32 票;长期缓存(静止全分辨率单帧槽、缩略图)从 shm 拷出一次(必要拷贝,槽需回收)。断言手段:`renderops` 加拷贝字节计数器,测试断言播放路径帧字节拷贝 = 0(GPU upload 除外)。
> **S2 落地**`renderops::RenderedFrame` 变为 `Shm(ShmFrameRef)` / `CpuF32{..}` 枚举;`to_display()` 用槽 BGRA8 字节构造显示图(GPU 上传 staging 拷贝,走 `slot_bytes` 不计入 `main_heap_frame_copies`);scopes 读 BGRA8`analyze_bgra8`8-bit 量化精度损失已在注释说明);全分辨率/缩略图走 `slot_to_vec`(唯一计数拷贝)后立即 `release_frame`。`real_render_frame_e2e`/`process_backend_preview_path_is_zero_copy` 断言播放路径计数 = 0。
### 3.6 OFX 崩溃隔离
插件执行器(oakplugin `install_render_executor`)装在 **worker 进程**;主进程不再链接执行栈(app 只经 ticket API)。测试插件加"崩溃模式"(环境变量触发 raise(SIGSEGV))→ 验收:主进程存活、受影响帧重派、worker 自动重启、渲染结果仍正确。
@@ -86,7 +91,7 @@
| 期 | 范围 | 验收 |
|---|---|---|
| S1crates only | shm spikemacOS 段上限);协议 v2ProcessDispatcher + WorkerHandle + 崩溃重启;worker 侧真实渲染(图快照反序列化 + montage/解码/合成 + 插件执行器);PreviewScheduler(交织批量认领);与线程池**并存**(配置切换)。单测 + 集成测试(崩溃隔离/无窃取/均匀性/零拷贝计数)。 | `cargo test -p oakrender -p oak-worker` 全绿;集成测试演示 4 worker 渲 480 帧无重分配、相邻帧完成时间差有界 |
| S2 | RenderManager 默认 Processes**删除 WorkerPool**oaktask/oak-cli 接入;src/ 上屏零拷贝(real.rs/renderops.rs/frames.rs);app 播放前向窗口接入调度器 | `cargo test` 全绿;`cargo run` 播放流畅;拷贝计数=0kill -SEGV worker 后播放继续 |
| S2(默认切进程 + 删线程池 + 接入) | **完成**RenderManager 默认 Processes;删除 WorkerPool`InlineDispatcher` 替代单测同步执行,音频走 sync inline);oaktask/oak-cli/facade 接入(ShmFrame 消费 + 等待时泵 poll);UI tick 泵;上屏零拷贝(renderops/real/frames/scopesscopes 读 BGRA8);播放前向窗口(120 帧可配)喂 PreviewSchedulercpu_frame 先命中 shm 槽缓存 | `cargo test` 全绿;`cargo run` 播放流畅;拷贝计数=0`main_heap_frame_copies`kill -SEGV worker 后播放继续S1 集成测试覆盖) |
| S3 | 音频迁移;压测调优(B、槽数、worker 数自适应);README/docs 收尾 | 性能报告;文档 |
## 5. 风险
+22
View File
@@ -113,6 +113,28 @@ pub(crate) fn f32_rgba_to_bgra_image(width: u32, height: u32, samples: &[f32]) -
RenderImage::new(smallvec::SmallVec::from_elem(image::Frame::new(buffer), 1))
}
/// Wraps a BGRA8 pixel block into the viewers' display image (M15 S2
/// zero-copy onscreen path). The bytes come straight from a worker's
/// shared-memory slot (already in display order) — this is the
/// GPU-upload staging buffer, the single permitted main-process copy on
/// the preview path (design §3.5). Returns `None` when `bytes` is
/// shorter than `width * height * 4`.
pub(crate) fn bgra_bytes_to_render_image(
width: u32,
height: u32,
bytes: &[u8],
) -> Option<RenderImage> {
let need = (width * height * 4) as usize;
if bytes.len() < need {
return None;
}
let buffer = image::RgbaImage::from_raw(width, height, bytes[..need].to_vec())?;
Some(RenderImage::new(smallvec::SmallVec::from_elem(
image::Frame::new(buffer),
1,
)))
}
#[cfg(test)]
mod tests {
use super::*;
+520 -69
View File
@@ -59,7 +59,7 @@
//! worker holds the project's `Arc`, so a project drop mid-render is a
//! non-event (the drained frame is discarded by the generation check).
use std::collections::{BTreeSet, HashMap, HashSet};
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::sync::mpsc;
use std::sync::{Arc, Mutex};
@@ -80,15 +80,17 @@ use gpui_widgets::viewer::PlaybackClock;
use oaknode::id::NodeId;
use oaknode::track::TrackType;
use oakrender::manager::RenderManager;
use oakrender::procpool::{bgra8_to_rgba8, ShmFrameRef};
use oaktimeline::handle::CHandle;
use super::engine::{
AppEngine, EngineGateway, ExportSession, LibraryProject, Monitor, Project, ScopeData, Sequence,
VideoFormat,
};
use super::frames::{f32_rgba_to_bgra_image, synthetic_frame_samples};
use super::frames::{bgra_bytes_to_render_image, f32_rgba_to_bgra_image, synthetic_frame_samples};
use super::graphops::{self, ProjectRef};
use super::scopes::analyze_f32_rgba;
use super::scopes::{analyze_bgra8, analyze_f32_rgba};
use super::transport::TransportState;
/// The project name of a blank project before it is saved.
@@ -235,6 +237,42 @@ enum FullResTarget {
Footage(NodeId),
}
// ---------------------------------------------------------------------------
// Playback pre-render window (M15 S2)
// ---------------------------------------------------------------------------
//
// During playback the engine feeds the forward window (default 120 frames,
// config `PlaybackPreRenderFrames`) to the `PreviewScheduler` through the
// process dispatcher: the scheduler interleaves the frames across the
// workers, so by the time the playhead reaches a frame its pixels are
// already sitting in a shm slot. `cpu_frame` hits this slot cache first
// (zero copy — build the display image from the slot bytes, then release
// the slot); the synchronous render path is the miss fallback. Frames
// that fall out of the window (or whose params were invalidated) release
// their slots back to the workers.
/// The default forward pre-render window (frames).
const DEFAULT_PREVIEW_WINDOW_FORWARD: i64 = 120;
/// Config key for the forward pre-render window size (frames).
const CONFIG_KEY_PREVIEW_WINDOW: &str = "PlaybackPreRenderFrames";
/// One monitor's playback pre-render state (UI-thread-owned; the completions
/// delivered by the dispatcher's poll run on the UI thread too).
#[derive(Default)]
struct PreviewWindow {
/// The node identity the window renders (sequence / footage).
sequence: u64,
/// The render-params generation the window was built for (bumped on
/// invalidation; a mismatch cancels the old requests and rebuilds).
generation: u64,
/// Frames already submitted to the scheduler (pending or in flight).
submitted: BTreeSet<i64>,
/// Rendered frames held in shm slots, keyed by frame number. A frame
/// is consumed by `cpu_frame` (slot released after the display image
/// is built) or released when it falls out of the window.
slots: BTreeMap<i64, ShmFrameRef>,
}
/// One background full-resolution render request (built on the UI thread
/// at schedule time; the worker thread owns it from there).
struct FullResRequest {
@@ -323,28 +361,70 @@ fn thumbnail_path(filename: &str) -> PathBuf {
// Frame conversion
// ---------------------------------------------------------------------------
/// Repack one F32 RGBA rendered frame (rows padded to linesize) into
/// tightly packed samples. Returns `(width, height, samples)` when the
/// frame is well-formed (positive geometry, the pipeline's F32 format).
/// Repack one in-process F32 RGBA rendered frame (rows padded to
/// linesize) into tightly packed samples. Returns `(width, height,
/// samples)` when the frame is the in-process F32 variant (the shm
/// variant is BGRA8 and is read as bytes instead).
fn read_f32_frame(frame: &super::renderops::RenderedFrame) -> Option<(u32, u32, Vec<f32>)> {
let (width, height, linesize) = (frame.width, frame.height, frame.linesize);
if width <= 0 || height <= 0 || frame.format != super::renderops::PIXEL_FORMAT_F32 {
let super::renderops::RenderedFrame::CpuF32 {
width,
height,
linesize,
data,
} = frame
else {
return None;
};
if *width <= 0 || *height <= 0 {
return None;
}
let row_bytes = (width * 4 * 4) as usize;
let linesize = (linesize as usize).max(row_bytes);
if frame.data.len() < linesize * height as usize {
let row_bytes = (*width * 4 * 4) as usize;
let linesize = (*linesize as usize).max(row_bytes);
if data.len() < linesize * *height as usize {
return None;
}
let mut samples = vec![0.0f32; (width * height * 4) as usize];
for y in 0..height as usize {
let row = &frame.data[y * linesize..y * linesize + row_bytes];
let mut samples = vec![0.0f32; (*width * *height * 4) as usize];
for y in 0..*height as usize {
let row = &data[y * linesize..y * linesize + row_bytes];
for (i, px) in row.chunks_exact(4).enumerate() {
let v = f32::from_ne_bytes([px[0], px[1], px[2], px[3]]);
samples[y * (width as usize) * 4 + i] = v;
samples[y * (*width as usize) * 4 + i] = v;
}
}
Some((*width as u32, *height as u32, samples))
}
/// Release the shm slot a rendered frame holds, if any (M15 S2: the
/// zero-copy onscreen path builds the display image from the slot bytes,
/// then returns the slot to its worker — slot release = cache eviction).
/// No-op for in-process frames and when the manager is down.
fn release_rendered_frame(rendered: &super::renderops::RenderedFrame) {
if let super::renderops::RenderedFrame::Shm(frame) = rendered {
if let Some(m) = RenderManager::global() {
m.release_frame(frame);
}
}
}
/// Build an owned viewer image from a rendered frame for a long-lived
/// cache (full-res fills, thumbnails). The shm variant copies the slot
/// bytes out once (`slot_to_vec` — the counted copy path, necessary
/// because the image must outlive the slot), then the caller releases the
/// slot; the in-process variant converts F32→BGRA8.
fn rendered_to_owned_image(rendered: &super::renderops::RenderedFrame) -> Option<Arc<RenderImage>> {
match rendered {
super::renderops::RenderedFrame::Shm(f) => {
let meta = &f.meta;
let (w, h) = (meta.width.max(0) as u32, meta.height.max(0) as u32);
let pixels = f.shm.slot_to_vec(f.slot);
let data = pixels.get(..meta.data_size.max(0) as usize)?;
bgra_bytes_to_render_image(w, h, data).map(Arc::new)
}
super::renderops::RenderedFrame::CpuF32 { .. } => {
let (w, h, samples) = read_f32_frame(rendered)?;
Some(Arc::new(f32_rgba_to_bgra_image(w, h, &samples)))
}
}
Some((width as u32, height as u32, samples))
}
// ---------------------------------------------------------------------------
@@ -675,6 +755,15 @@ pub struct RealEngine {
/// project drop); completions tagged with a stale generation are
/// discarded by the drain.
full_res_generation: u64,
/// M15 S2 playback pre-render state, keyed by monitor (see
/// [`PreviewWindow`]). An `Arc` so the ticket completions (which fire
/// from the dispatcher's poll on the UI thread) can reach the cache;
/// the mutex keeps the engine `Sync`.
preview_windows: Arc<Mutex<HashMap<Monitor, PreviewWindow>>>,
/// Bumped whenever the preview content can change (edit / proxy
/// toggle / selection / project drop); the pre-render window rebuilds
/// against the new generation.
preview_generation: u64,
/// The channel background full-res jobs report finished frames through;
/// drained on the app tick. The mutex keeps the engine `Sync` (the
/// channel is only ever touched on the UI thread).
@@ -769,6 +858,8 @@ impl RealEngine {
meter_phase: 0,
cpu_frame_cache: Mutex::new(HashMap::new()),
full_res_generation: 0,
preview_windows: Arc::new(Mutex::new(HashMap::new())),
preview_generation: 0,
full_res_rx: Mutex::new(full_res_rx),
full_res_tx: Mutex::new(full_res_tx),
renderer: Mutex::new(RendererSlot::Untried),
@@ -858,10 +949,13 @@ impl RealEngine {
/// Renders one program-monitor frame through the oakrender ticket
/// arena: builds the sequence's montage at `frame`, renders at the
/// proxy geometry, analyzes the scope samples from the F32 RGBA
/// result, and downconverts to BGRA8. Returns `None` (the caller falls
/// back to the synthetic pattern) when no sequence is open, the render
/// manager is unavailable, or the render itself fails.
/// proxy geometry, and produces the viewer display image plus the
/// scope samples (M15 S2: the process backend delivers a BGRA8 shm
/// slot; the display image is built straight from the slot bytes —
/// the GPU-upload staging copy — and the slot released). Returns
/// `None` (the caller falls back to the synthetic pattern) when no
/// sequence is open, the render manager is unavailable, or the render
/// itself fails.
fn render_program_frame(&self, frame: Frame) -> Option<(RenderImage, ScopeData)> {
let project = self.project_ref()?.clone();
let seq = self.sequence?;
@@ -877,9 +971,9 @@ impl RealEngine {
match super::renderops::render_sequence_frame(&project, seq, frame.0, tb, width, height) {
Ok(rendered) => {
*slot = RendererSlot::Ready;
let (width, height, samples) = read_f32_frame(&rendered)?;
let scope = analyze_f32_rgba(width, height, &samples);
Some((f32_rgba_to_bgra_image(width, height, &samples), scope))
let out = rendered.to_display();
release_rendered_frame(&rendered);
out
}
Err(error) => {
println!("[real engine] render_frame failed: {error}");
@@ -902,9 +996,10 @@ impl RealEngine {
/// Renders one source-monitor frame through the ticket arena: the
/// currently selected footage node decoded at the proxy geometry (same
/// pipeline as the program monitor). Returns `None` (the caller falls
/// back to the synthetic pattern) when no footage is selected, the
/// render manager is unavailable, or the render itself fails.
/// pipeline as the program monitor, M15 S2 shm slot + release).
/// Returns `None` (the caller falls back to the synthetic pattern)
/// when no footage is selected, the render manager is unavailable, or
/// the render itself fails.
fn render_source_frame(&self, frame: Frame) -> Option<(RenderImage, ScopeData)> {
let project = self.project_ref()?.clone();
let node = self.selected_footage_node()?;
@@ -920,9 +1015,9 @@ impl RealEngine {
match super::renderops::render_footage_frame(&project, node, frame.0, tb, width, height) {
Ok(rendered) => {
*slot = RendererSlot::Ready;
let (width, height, samples) = read_f32_frame(&rendered)?;
let scope = analyze_f32_rgba(width, height, &samples);
Some((f32_rgba_to_bgra_image(width, height, &samples), scope))
let out = rendered.to_display();
release_rendered_frame(&rendered);
out
}
Err(error) => {
println!("[real engine] source render_frame failed: {error}");
@@ -981,12 +1076,17 @@ impl RealEngine {
}
};
if let Ok(rendered) = rendered {
if let Some((width, height, samples)) = read_f32_frame(&rendered) {
// M15 S2: the process backend delivers a shm slot — copy the
// pixels out once (counted, long-lived cache) and release the
// slot.
let image = rendered_to_owned_image(&rendered);
release_rendered_frame(&rendered);
if let Some(image) = image {
event = Some(FullResEvent {
monitor,
frame,
generation,
image: Arc::new(f32_rgba_to_bgra_image(width, height, &samples)),
image,
});
}
}
@@ -1040,6 +1140,216 @@ impl RealEngine {
}
}
// -----------------------------------------------------------------------
// M15 S2: playback pre-render window
// -----------------------------------------------------------------------
/// Feeds the playback pre-render window for `monitor` into the
/// scheduler (M15 S2): while playing, the forward window's frames are
/// submitted at Playback priority (ordered by playhead distance), so
/// workers render them ahead of the playhead into shm slots. The
/// window rebuilds when the node or the render-params generation
/// changed; slots that fell behind the playhead are released (credit
/// returns to the workers).
fn update_preview_window(&mut self, monitor: Monitor, cx: &mut Context<Self>) {
// Only during playback: a paused viewer uses the synchronous miss
// path (and the resting full-res fill).
let playing = self.clock(monitor).read(cx).transport.is_playing();
if !playing {
return;
}
let Some(project) = self.project.clone() else { return };
let Some(tb) = self.time_base() else { return };
let Some((width, height)) = self.proxy_render_size() else { return };
let Some(node) = (match monitor {
Monitor::Program => self.sequence,
Monitor::Source => self.selected_footage_node(),
}) else {
return;
};
let node_id = node.identity();
let playhead = self.clock_frame(monitor, cx).0;
if playhead < 0 {
return;
}
let length = match monitor {
Monitor::Program => self.sequence_length().0,
Monitor::Source => self.source_length().0,
};
let Some(m) = RenderManager::global() else { return };
let forward = config_get_int(CONFIG_KEY_PREVIEW_WINDOW, DEFAULT_PREVIEW_WINDOW_FORWARD)
.clamp(8, 1200);
let end = length.max(playhead).min(playhead + forward);
// Reset / rebuild when the node changed or an invalidation bumped the
// generation (the old pending/claimed requests are cancelled).
let mut windows = self.preview_windows.lock().unwrap_or_else(|e| e.into_inner());
let window = windows.entry(monitor).or_default();
if window.sequence != node_id || window.generation != self.preview_generation {
m.cancel_preview_sequence(window.sequence);
for slot in window.slots.values() {
m.release_frame(slot);
}
window.slots.clear();
window.submitted.clear();
window.sequence = node_id;
window.generation = self.preview_generation;
}
// Release slots that fell behind the playhead (frames passed without
// being displayed); the just-displayed frame was already consumed by
// `cpu_frame`. Prune the submitted set the same way.
let keep_from = (playhead - 2).max(0);
let stale: Vec<i64> = window
.slots
.keys()
.copied()
.filter(|f| *f < keep_from)
.collect();
for f in stale {
if let Some(slot) = window.slots.remove(&f) {
m.release_frame(&slot);
}
}
window
.submitted
.retain(|f| *f >= keep_from || (*f >= playhead && *f < end));
let new_frames: Vec<i64> = (playhead.max(0)..end)
.filter(|f| !window.submitted.contains(f))
.collect();
drop(windows);
for frame in new_frames {
let params = match monitor {
Monitor::Program => super::renderops::sequence_frame_params(
&project,
node,
frame,
tb,
width,
height,
),
Monitor::Source => {
super::renderops::footage_frame_params(&project, node, frame, tb, width, height)
}
};
let Ok(params) = params else { continue };
let distance = frame.saturating_sub(playhead).abs();
let version = self.preview_generation;
let preview_windows = self.preview_windows.clone();
let done: oakrender::ticket::Completion = Box::new(move |result| {
let mut windows =
preview_windows.lock().unwrap_or_else(|e| e.into_inner());
let window = windows.entry(monitor).or_default();
match result {
Ok(oakrender::ticket::TicketPayload::ShmFrame(slot)) => {
// The rendered frame is cached in its shm slot until
// the playhead reaches it (cpu_frame) or it falls out
// of the window.
window.slots.insert(frame, slot);
}
_ => {
// Render failed / cancelled: allow a re-request.
window.submitted.remove(&frame);
}
}
});
m.tickets.submit_playback(params, frame, distance, version, done);
self.preview_windows
.lock()
.unwrap_or_else(|e| e.into_inner())
.entry(monitor)
.or_default()
.submitted
.insert(frame);
}
}
/// Looks a frame up in the pre-rendered playback window's shm slot
/// cache (M15 S2): builds the viewer display image + scope samples
/// straight from the slot bytes (the GPU-upload staging copy) and
/// releases the slot. Returns `None` when the frame is not cached.
fn preview_slot_frame(
&self,
monitor: Monitor,
frame: Frame,
) -> Option<(Arc<RenderImage>, ScopeData)> {
let node = match monitor {
Monitor::Program => self.sequence?,
Monitor::Source => self.selected_footage_node()?,
};
let node_id = node.identity();
let mut windows = self.preview_windows.lock().unwrap_or_else(|e| e.into_inner());
let window = windows.get_mut(&monitor)?;
if window.sequence != node_id || window.generation != self.preview_generation {
return None;
}
let slot = window.slots.remove(&frame.0)?;
let out = {
let meta = &slot.meta;
let (w, h) = (meta.width.max(0) as u32, meta.height.max(0) as u32);
let data = slot
.shm
.slot_bytes(slot.slot)
.get(..meta.data_size.max(0) as usize)?;
let image = bgra_bytes_to_render_image(w, h, data)?;
let scope = analyze_bgra8(w, h, data);
(Arc::new(image), scope)
};
if let Some(m) = RenderManager::global() {
m.release_frame(&slot);
}
Some(out)
}
/// Cancels every monitor's pre-render window and releases its held
/// slots (edit / selection change / project drop / preview-media
/// invalidation).
fn cancel_preview_windows(&mut self) {
let mut windows = self.preview_windows.lock().unwrap_or_else(|e| e.into_inner());
let m = RenderManager::global();
for window in windows.values_mut() {
if let Some(m) = &m {
m.cancel_preview_sequence(window.sequence);
for slot in window.slots.values() {
m.release_frame(slot);
}
}
window.slots.clear();
window.submitted.clear();
}
}
/// Cancels one monitor's pre-render window (source selection change:
/// the old footage's window is stale even though the program window is
/// untouched).
fn cancel_preview_window(&mut self, monitor: Monitor) {
let mut windows = self.preview_windows.lock().unwrap_or_else(|e| e.into_inner());
let Some(window) = windows.get_mut(&monitor) else {
return;
};
if let Some(m) = RenderManager::global() {
m.cancel_preview_sequence(window.sequence);
for slot in window.slots.values() {
m.release_frame(slot);
}
}
window.slots.clear();
window.submitted.clear();
}
/// Invalidates every cached/rendered preview frame: the CPU cache is
/// cleared, the full-res and pre-render-window generations bumped, and
/// the pre-render windows cancelled (pending/claimed requests and held
/// slots released). Shared by edits, undo-stack changes, project drops
/// and preview-media invalidation.
fn invalidate_rendered_frames(&mut self) {
self.cpu_frame_cache.lock().unwrap().clear();
self.full_res_generation = self.full_res_generation.wrapping_add(1);
self.preview_generation = self.preview_generation.wrapping_add(1);
self.cancel_preview_windows();
}
/// Attaches cached thumbnails to the bin entries, spawning a background
/// generation job for every footage that has none yet. Entries without a
/// renderable frame keep the widget's placeholder.
@@ -1110,7 +1420,9 @@ impl RealEngine {
return None;
}
// Frame zero: the timebase only scales the timestamp, so any valid
// pair produces time 0.
// pair produces time 0. M15 S2: the process backend delivers a shm
// slot — copy the pixels out once (counted, the PNG must outlive the
// slot), convert BGRA→RGBA, release the slot.
let rendered = super::renderops::render_footage_frame(
project,
node,
@@ -1120,11 +1432,24 @@ impl RealEngine {
THUMBNAIL_HEIGHT,
)
.ok()?;
let (width, height, samples) = read_f32_frame(&rendered)?;
let bytes: Vec<u8> = samples
.iter()
.map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8)
.collect();
let (width, height, bytes) = match &rendered {
super::renderops::RenderedFrame::Shm(f) => {
let meta = &f.meta;
let (w, h) = (meta.width.max(0) as u32, meta.height.max(0) as u32);
let pixels = f.shm.slot_to_vec(f.slot);
let data = pixels.get(..meta.data_size.max(0) as usize)?;
(w, h, bgra8_to_rgba8(data))
}
super::renderops::RenderedFrame::CpuF32 { .. } => {
let (w, h, samples) = read_f32_frame(&rendered)?;
let bytes: Vec<u8> = samples
.iter()
.map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8)
.collect();
(w, h, bytes)
}
};
release_rendered_frame(&rendered);
let image = image::RgbaImage::from_raw(width, height, bytes)?;
std::fs::create_dir_all(path.parent()?).ok()?;
// Write aside then rename so readers never see a partial file.
@@ -1149,13 +1474,11 @@ impl RealEngine {
}
/// Invalidates every monitor's rendered frames (the preview media
/// changed — proxy toggled, generated or deleted): the CPU cache is
/// cleared and the full-res generation bumped so in-flight fills are
/// discarded on arrival. No timeline rebuild is needed — the montage
/// resolution reads the proxy switch on every pull.
/// changed — proxy toggled, generated or deleted). No timeline rebuild
/// is needed — the montage resolution reads the proxy switch on every
/// pull.
fn invalidate_preview_frames(&mut self, cx: &mut Context<Self>) {
self.cpu_frame_cache.lock().unwrap().clear();
self.full_res_generation = self.full_res_generation.wrapping_add(1);
self.invalidate_rendered_frames();
cx.notify();
}
@@ -1696,11 +2019,10 @@ impl RealEngine {
}
self.project = None;
self.sequence = None;
self.cpu_frame_cache.lock().unwrap().clear();
// The sequence an in-flight full-res job may still be rendering is
// gone (the job holds its own project `Arc`, so it stays valid, but
// its frame belongs to the dropped project): mark it stale.
self.full_res_generation = self.full_res_generation.wrapping_add(1);
self.invalidate_rendered_frames();
// Same for in-flight thumbnail jobs; the cache is per-project too
// (identities are only unique within one graph).
self.thumb_generation = self.thumb_generation.wrapping_add(1);
@@ -1787,8 +2109,7 @@ impl RealEngine {
fn apply_stack_change(&mut self, cx: &mut Context<Self>) {
self.refresh_sequence_info();
self.rebuild_timeline();
self.cpu_frame_cache.lock().unwrap().clear();
self.full_res_generation = self.full_res_generation.wrapping_add(1);
self.invalidate_rendered_frames();
cx.notify();
}
@@ -1966,9 +2287,9 @@ impl RealEngine {
self.refresh_sequence_info();
self.rebuild_timeline();
// The sequence content changed: cached rendered frames are stale,
// and so are any in-flight full-res renders (M12 P5a).
self.cpu_frame_cache.lock().unwrap().clear();
self.full_res_generation = self.full_res_generation.wrapping_add(1);
// and so are any in-flight full-res renders and pre-render windows
// (M12 P5a / M15 S2).
self.invalidate_rendered_frames();
cx.notify();
}
}
@@ -2075,6 +2396,16 @@ impl EngineGateway for RealEngine {
}
self.mirror_program_playhead(cx);
self.meter_phase = self.meter_phase.wrapping_add(1);
// M15 S2: pump the process dispatcher — ticket completions (the
// pre-render window, full-res fills, synchronous renders) are
// delivered from its poll loop, which must run on the UI tick.
// Then feed the playback pre-render windows before draining the
// completion channels.
if let Some(m) = RenderManager::global() {
m.poll();
}
self.update_preview_window(Monitor::Source, cx);
self.update_preview_window(Monitor::Program, cx);
// M12 P1: while the program plays, pull the audio for the
// current playhead window and queue it for the output device.
if self.program_playing {
@@ -2246,6 +2577,17 @@ impl AppEngine for RealEngine {
if let Some(image) = cache.entry(monitor).or_default().image_for(frame.0) {
return image.clone();
}
// M15 S2: try the pre-rendered playback window first — the frame's
// pixels are already in a worker shm slot (zero copy: build the
// display image from the slot bytes, then release the slot).
if let Some((image, scope)) = self.preview_slot_frame(monitor, frame) {
cache.entry(monitor).or_default().proxy = Some(ProxyEntry {
frame: frame.0,
image: image.clone(),
scope,
});
return image;
}
// Both monitors render through the oakrender ticket arena (falling
// back to the synthetic pattern when rendering is unavailable): the
// program monitor renders the current sequence, the source monitor
@@ -2329,10 +2671,12 @@ impl AppEngine for RealEngine {
// The source monitor renders the selected footage node: a new
// selection must drop the stale cached frame (the cache key only
// tracks the playhead frame), and any in-flight full-res job for
// the old selection is stale.
// the old selection is stale. The source pre-render window (M15
// S2) rebuilds against the new footage.
*self.source_renderer.lock().unwrap() = RendererSlot::Untried;
self.cpu_frame_cache.lock().unwrap().remove(&Monitor::Source);
self.full_res_generation = self.full_res_generation.wrapping_add(1);
self.cancel_preview_window(Monitor::Source);
}
cx.notify();
}
@@ -3930,6 +4274,35 @@ mod tests {
crate::oakui::graphops::test_lock()
}
/// Points the render manager's worker resolution at the built
/// oak-worker binary (dev layout `target/debug/oak-worker`) for tests
/// that render through the process backend. The default resolver looks
/// next to the *test* binary (`target/debug/deps`), which holds no
/// oak-worker. Restored on drop; only used inside [`media_lock`]
/// sections so the process env stays serialized.
struct WorkerBinGuard {
prev: Option<String>,
}
impl WorkerBinGuard {
fn set() -> Self {
let prev = std::env::var("OAK_WORKER_BIN").ok();
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("target")
.join("debug")
.join("oak-worker");
std::env::set_var("OAK_WORKER_BIN", path);
Self { prev }
}
}
impl Drop for WorkerBinGuard {
fn drop(&mut self) {
match &self.prev {
Some(p) => std::env::set_var("OAK_WORKER_BIN", p),
None => std::env::remove_var("OAK_WORKER_BIN"),
}
}
}
#[test]
fn project_format_dispatches_by_extension() {
assert_eq!(
@@ -4162,14 +4535,17 @@ mod tests {
/// End-to-end CPU render through the same path
/// [`RealEngine::render_program_frame`] uses: with the render manager
/// up, rendering an in-memory sequence produces a real F32 frame at the
/// requested proxy geometry, and the samples are well-formed (finite, in
/// range). The sequence starts empty, so the picture is black; with a
/// clip of real media on the video track the same render produces the
/// decoded footage (known content, non-black).
/// up (the default M15 S2 process backend), rendering an in-memory
/// sequence produces a real BGRA8 frame in a shared-memory slot at the
/// requested proxy geometry, the display image builds straight from
/// the slot bytes (zero copy), and the picture is well-formed. The
/// sequence starts empty, so the picture is black; with a clip of real
/// media on the video track the same render produces the decoded
/// footage (known content, non-black).
#[test]
fn real_render_frame_e2e() {
let _media = media_lock();
let _worker = WorkerBinGuard::set();
if !crate::oakui::renderops::ensure_render_manager() {
panic!("the render manager failed to start");
}
@@ -4180,21 +4556,23 @@ mod tests {
.expect("the sequence has a frame rate");
// The app's proxy size: sequence aspect (default 1920x1080) scaled
// to a 480px long edge, F32 at the sequence's rate.
// to a 480px long edge.
let frame = crate::oakui::renderops::render_sequence_frame(&project, seq, 0, tb, 480, 270)
.expect("render_frame must produce a frame");
assert_eq!((frame.width, frame.height), (480, 270));
assert_eq!(frame.format, crate::oakui::renderops::PIXEL_FORMAT_F32);
assert!(frame.linesize >= 480 * 4 * 4, "linesize covers a full row");
let (_, _, samples) = read_f32_frame(&frame).expect("well-formed F32 frame");
assert_eq!((frame.width(), frame.height()), (480, 270));
assert!(
samples.iter().all(|v| v.is_finite() && (0.0..=1.0).contains(v)),
"samples in range"
frame.is_shm(),
"the default process backend delivers shm slots (got format {})",
frame.format()
);
let (image, _scope) = frame.to_display().expect("display image from the slot");
let bytes = image.as_bytes(0).expect("one frame");
assert_eq!(bytes.len(), 480 * 270 * 4, "BGRA8 proxy geometry");
assert!(
samples.iter().all(|&v| v == 0.0),
bytes.iter().all(|&b| b == 0),
"an empty sequence renders transparent black"
);
release_rendered_frame(&frame);
// M12 P0: with a clip of real media on the video track, the same
// render must produce the decoded footage (known content, non
@@ -4212,20 +4590,22 @@ mod tests {
.expect("clip placement");
let frame = crate::oakui::renderops::render_sequence_frame(&project, seq, 0, tb, 480, 270)
.expect("render_frame with a clip must produce a frame");
let (_, _, samples) = read_f32_frame(&frame).expect("well-formed F32 frame");
let nonzero = samples.iter().filter(|&&v| v != 0.0).count();
let (image, _scope) = frame.to_display().expect("display image from the slot");
let bytes = image.as_bytes(0).expect("one frame");
let nonzero = bytes.chunks(4).filter(|px| px[..3].iter().any(|&c| c != 0)).count();
assert!(
nonzero > 0,
"the sequence with a footage clip must render non-black pixels"
);
// Known content: the test clip's left half is red on frame 0 —
// the center-left pixel must be red-dominant.
let px = |x: usize, y: usize| &samples[(y * 480 + x) * 4..][..4];
// the center-left pixel must be red-dominant (BGRA bytes: R at 2).
let px = |x: usize, y: usize| &bytes[(y * 480 + x) * 4..][..4];
let center_left = px(120, 135);
assert!(
center_left[0] > 0.5 && center_left[1] < 0.4 && center_left[2] < 0.4,
center_left[2] > 127 && center_left[0] < 102 && center_left[1] < 102,
"center-left pixel stays red from the decoded clip: {center_left:?}"
);
release_rendered_frame(&frame);
// A second frame at a later timestamp renders too.
assert!(crate::oakui::renderops::render_sequence_frame(&project, seq, 30, tb, 480, 270).is_ok());
@@ -4237,6 +4617,74 @@ mod tests {
let _ = std::fs::remove_file(&media);
}
/// M15 S2 acceptance: the app's onscreen path reads the process
/// backend's shm slots without the counted main-process copy. The
/// preview path (`RenderedFrame::to_display`) wraps the slot bytes
/// into the display buffer — the GPU-upload staging copy — and must
/// leave `main_heap_frame_copies == 0`; the long-lived full-res path
/// (`rendered_to_owned_image`) is the one counted copy
/// (`slot_to_vec`), released right after.
#[test]
fn process_backend_preview_path_is_zero_copy() {
let _media = media_lock();
let _worker = WorkerBinGuard::set();
use oakrender::manager::{RenderBackendChoice, RenderManager};
use oakrender::procpool::{
main_heap_frame_copies, reset_main_heap_frame_copies, DispatcherConfig,
};
RenderManager::shutdown();
let config = DispatcherConfig {
worker_bin: Some(
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("target/debug/oak-worker"),
),
workers: 1,
slots_per_worker: 2,
width: 64,
height: 64,
batch_size: 2,
..Default::default()
};
RenderManager::init_with_backend(RenderBackendChoice::Processes(config))
.expect("process backend init");
let project = graphops::create_project();
let seq = graphops::create_sequence(&project, "Zero Copy");
let tb = graphops::sequence_time_base(&graphops::lock(&project).graph, seq).unwrap();
reset_main_heap_frame_copies();
let frame = crate::oakui::renderops::render_sequence_frame(&project, seq, 0, tb, 64, 64)
.expect("render_frame must produce a frame");
let crate::oakui::renderops::RenderedFrame::Shm(slot) = &frame else {
panic!("the process backend must deliver a shm slot");
};
assert_eq!(slot.meta.format, crate::oakui::renderops::SLOT_FORMAT_BGRA8);
let (image, _scope) = frame.to_display().expect("display image from the slot");
assert_eq!(image.as_bytes(0).expect("one frame").len(), 64 * 64 * 4);
assert_eq!(
main_heap_frame_copies(),
0,
"the preview path must not copy through slot_to_vec"
);
release_rendered_frame(&frame);
assert_eq!(main_heap_frame_copies(), 0);
// The long-lived full-res path is the one counted copy.
let frame = crate::oakui::renderops::render_sequence_frame(&project, seq, 0, tb, 64, 64)
.expect("render_frame must produce a frame");
let image = rendered_to_owned_image(&frame).expect("owned full-res image");
assert_eq!(image.as_bytes(0).expect("one frame").len(), 64 * 64 * 4);
assert_eq!(
main_heap_frame_copies(),
1,
"the long-lived cache path is the counted copy"
);
release_rendered_frame(&frame);
RenderManager::shutdown();
oakundo::global::clear().unwrap();
}
/// M12 P3 acceptance: importing a media file makes it appear in the
/// project browser's real folder tree.
#[test]
@@ -4489,6 +4937,7 @@ mod tests {
#[test]
fn full_res_worker_outlives_a_dropped_project() {
let _media = media_lock();
let _worker = WorkerBinGuard::set();
if !crate::oakui::renderops::ensure_render_manager() {
panic!("the render manager failed to start");
}
@@ -4660,6 +5109,7 @@ mod tests {
#[test]
fn full_res_worker_renders_real_frame() {
let _media = media_lock();
let _worker = WorkerBinGuard::set();
if !crate::oakui::renderops::ensure_render_manager() {
panic!("the render manager failed to start");
}
@@ -4721,6 +5171,7 @@ mod tests {
#[gpui::test]
async fn real_engine_fills_full_res_behind_the_proxy(cx: &mut gpui::TestAppContext) {
let _media = media_lock();
let _worker = WorkerBinGuard::set();
let engine = cx.update(|cx| cx.new(|cx| RealEngine::create(cx)));
cx.update(|app| engine.update(app, |engine, cx| engine.new_project(cx)));
+173 -42
View File
@@ -28,22 +28,31 @@
use std::sync::mpsc;
use gpui::RenderImage;
use oakcore_rs::{Rational, TimeRange};
use oaknode::id::NodeId;
use oaknode::track::TrackType;
use oakrender::manager::RenderManager;
use oakrender::procpool::ShmFrameRef;
use oakrender::texture::Texture;
use oakrender::ticket::{AudioTicketParams, MontageClip, TicketPayload, VideoTicketParams};
use super::engine::{ExportEvent, ExportSession};
use super::frames::{bgra_bytes_to_render_image, f32_rgba_to_bgra_image};
use super::graphops::{
clip_behavior, lock, sequence_behavior, track_behavior, track_list_behavior, ProjectRef,
};
use super::scopes::{analyze_bgra8, analyze_f32_rgba, ScopeData};
/// The pixel format the viewers render in (`oakcore_rs::PixelFormat::F32`,
/// the pipeline's internal format; the app downconverts to BGRA itself).
pub const PIXEL_FORMAT_F32: i32 = 4;
/// The BGRA8 slot wire format the process backend renders into (M15 S2):
/// the worker converts its F32 pipeline output to BGRA8 at the end of the
/// render, so the main process reads display-ready bytes from the slot.
pub const SLOT_FORMAT_BGRA8: i32 = oakrender::ipc::SLOT_FORMAT_BGRA8;
// ---------------------------------------------------------------------------
// Render manager
// ---------------------------------------------------------------------------
@@ -232,19 +241,113 @@ pub fn audio_montage(p: &ProjectRef, seq: NodeId, range: TimeRange) -> Vec<Monta
// Ticket rendering
// ---------------------------------------------------------------------------
/// A rendered frame's pixel payload (the module frame a video ticket
/// produces).
pub struct RenderedFrame {
/// A rendered frame's pixel payload (M15 S2): a process-backend shm slot
/// (BGRA8, zero-copy read) or an in-process F32 CPU frame (the test-only
/// inline backend).
pub enum RenderedFrame {
/// Process backend: a BGRA8 frame in a worker's shared-memory slot.
/// Read with `shm.slot_bytes(slot)` (no counted copy on the preview
/// path), build the display image, then release the slot through the
/// render manager.
Shm(ShmFrameRef),
/// In-process (test) backend: an F32 RGBA CPU frame.
CpuF32 {
/// Width in pixels.
width: i32,
/// Height in pixels.
height: i32,
/// Bytes per scanline (stride).
linesize: i32,
/// Pixel data (at least `linesize * height` bytes).
data: Vec<u8>,
},
}
impl RenderedFrame {
/// Width in pixels.
pub width: i32,
pub fn width(&self) -> i32 {
match self {
RenderedFrame::Shm(f) => f.meta.width,
RenderedFrame::CpuF32 { width, .. } => *width,
}
}
/// Height in pixels.
pub height: i32,
/// Pixel format (`oakcore_rs::PixelFormat` as int).
pub format: i32,
/// Bytes per scanline (stride).
pub linesize: i32,
/// Pixel data (at least `linesize * height` bytes).
pub data: Vec<u8>,
pub fn height(&self) -> i32 {
match self {
RenderedFrame::Shm(f) => f.meta.height,
RenderedFrame::CpuF32 { height, .. } => *height,
}
}
/// The pixel format: the BGRA8 slot wire format for the shm variant,
/// `PIXEL_FORMAT_F32` for the in-process variant.
pub fn format(&self) -> i32 {
match self {
RenderedFrame::Shm(_) => SLOT_FORMAT_BGRA8,
RenderedFrame::CpuF32 { .. } => PIXEL_FORMAT_F32,
}
}
/// True for the process-backend shm variant.
pub fn is_shm(&self) -> bool {
matches!(self, RenderedFrame::Shm(_))
}
/// Build the viewer display image plus the scope samples (M15 S2
/// zero-copy onscreen path). For the shm variant the slot's BGRA8
/// bytes are wrapped into the display buffer — the GPU-upload staging
/// copy, the single permitted main-process copy on the preview path
/// (design §3.5). The caller releases the slot afterwards.
pub fn to_display(&self) -> Option<(RenderImage, ScopeData)> {
match self {
RenderedFrame::Shm(f) => {
let meta = &f.meta;
let (w, h) = (meta.width.max(0) as u32, meta.height.max(0) as u32);
let pixels = f.shm.slot_bytes(f.slot);
let data = pixels.get(..meta.data_size.max(0) as usize)?;
let image = bgra_bytes_to_render_image(w, h, data)?;
let scope = analyze_bgra8(w, h, data);
Some((image, scope))
}
RenderedFrame::CpuF32 {
width,
height,
linesize,
data,
} => {
let (w, h) = ((*width).max(0) as u32, (*height).max(0) as u32);
let samples = repack_f32_rows(*width, *height, *linesize, data)?;
Some((
f32_rgba_to_bgra_image(w, h, &samples),
analyze_f32_rgba(w, h, &samples),
))
}
}
}
}
/// Repack one F32 RGBA rendered frame (rows padded to `linesize`) into
/// tightly packed samples. Returns `(width, height, samples)`-style
/// samples only; geometry is validated by the caller.
fn repack_f32_rows(width: i32, height: i32, linesize: i32, data: &[u8]) -> Option<Vec<f32>> {
if width <= 0 || height <= 0 {
return None;
}
let row_bytes = (width * 4 * 4) as usize;
let linesize = (linesize as usize).max(row_bytes);
if data.len() < linesize * height as usize {
return None;
}
let mut samples = vec![0.0f32; (width * height * 4) as usize];
for y in 0..height as usize {
let row = &data[y * linesize..y * linesize + row_bytes];
for (i, px) in row.chunks_exact(4).enumerate() {
let v = f32::from_ne_bytes([px[0], px[1], px[2], px[3]]);
samples[y * (width as usize) * 4 + i] = v;
}
}
Some(samples)
}
/// The renderer geometry / format validation (the facade's
@@ -256,7 +359,9 @@ fn validate_geometry(width: i32, height: i32, tb: (i64, i64)) -> Result<(), Stri
Ok(())
}
/// Drive one video ticket synchronously.
/// Drive one video ticket synchronously (M15 S2: returns the payload
/// variant without copying frame bytes — the shm slot is read zero-copy
/// by the caller and released after building the display image).
fn render_video(params: VideoTicketParams) -> Result<RenderedFrame, String> {
let m = RenderManager::global().ok_or_else(|| "render manager is not initialized".to_string())?;
let id = m.tickets.next_id();
@@ -267,33 +372,31 @@ fn render_video(params: VideoTicketParams) -> Result<RenderedFrame, String> {
.result(id)
.ok_or_else(|| "render ticket produced no result".to_string())?;
match &result {
Ok(TicketPayload::Video(Texture::Cpu(frame))) => Ok(RenderedFrame {
Ok(TicketPayload::Video(Texture::Cpu(frame))) => Ok(RenderedFrame::CpuF32 {
width: frame.width,
height: frame.height,
format: frame.format as i32,
linesize: frame.linesize_bytes() as i32,
data: frame.data.clone(),
}),
Ok(TicketPayload::ShmFrame(frame)) => Ok(RenderedFrame::Shm(frame.clone())),
Ok(TicketPayload::Video(_)) => Err("render produced a non-CPU frame".to_string()),
_ => Err("render produced no video frame".to_string()),
}
}
/// Render one frame of the sequence's montage at `frame_ts` (a timestamp
/// in the `(tb.1 / tb.0)`-per-frame timebase) into a `(width, height)`
/// F32 frame.
pub fn render_sequence_frame(
/// Build the video ticket params for one sequence-montage frame (M15 S2:
/// shared by the synchronous render and the playback pre-render window).
pub fn sequence_frame_params(
p: &ProjectRef,
seq: NodeId,
frame_ts: i64,
tb: (i64, i64),
width: i32,
height: i32,
) -> Result<RenderedFrame, String> {
) -> Result<VideoTicketParams, String> {
validate_geometry(width, height, tb)?;
let time = Rational::new(frame_ts * tb.0, tb.1);
let montage = video_montage(p, seq, time);
render_video(VideoTicketParams {
Ok(VideoTicketParams {
viewer: seq.identity(),
time,
force_size: Some((width, height)),
@@ -303,7 +406,54 @@ pub fn render_sequence_frame(
cache_id: None,
cache_timebase: None,
footage: None,
montage,
montage: video_montage(p, seq, time),
})
}
/// Render one frame of the sequence's montage at `frame_ts` (a timestamp
/// in the `(tb.1 / tb.0)`-per-frame timebase) into a `(width, height)`
/// frame.
pub fn render_sequence_frame(
p: &ProjectRef,
seq: NodeId,
frame_ts: i64,
tb: (i64, i64),
width: i32,
height: i32,
) -> Result<RenderedFrame, String> {
render_video(sequence_frame_params(p, seq, frame_ts, tb, width, height)?)
}
/// Build the video ticket params for one single-footage frame (M15 S2:
/// shared by the synchronous render and the source-monitor pre-render
/// window).
pub fn footage_frame_params(
p: &ProjectRef,
footage: NodeId,
frame_ts: i64,
tb: (i64, i64),
width: i32,
height: i32,
) -> Result<VideoTicketParams, String> {
validate_geometry(width, height, tb)?;
let (filename, stream_index) = {
let g = lock(p);
super::graphops::footage_behavior(&g.graph, footage)
.map(|f| preview_footage_media(f, true))
.ok_or_else(|| "the node is not footage".to_string())?
};
let time = Rational::new(frame_ts * tb.0, tb.1);
Ok(VideoTicketParams {
viewer: footage.identity(),
time,
force_size: Some((width, height)),
force_format: None,
cache: None,
cache_dir: None,
cache_id: None,
cache_timebase: None,
footage: Some((filename, stream_index)),
montage: Vec::new(),
})
}
@@ -317,26 +467,7 @@ pub fn render_footage_frame(
width: i32,
height: i32,
) -> Result<RenderedFrame, String> {
validate_geometry(width, height, tb)?;
let (filename, stream_index) = {
let g = lock(p);
super::graphops::footage_behavior(&g.graph, footage)
.map(|f| preview_footage_media(f, true))
.ok_or_else(|| "the node is not footage".to_string())?
};
let time = Rational::new(frame_ts * tb.0, tb.1);
render_video(VideoTicketParams {
viewer: footage.identity(),
time,
force_size: Some((width, height)),
force_format: None,
cache: None,
cache_dir: None,
cache_id: None,
cache_timebase: None,
footage: Some((filename, stream_index)),
montage: Vec::new(),
})
render_video(footage_frame_params(p, footage, frame_ts, tb, width, height)?)
}
/// Rendered interleaved f32 audio (the module audio ticket payload).
+28
View File
@@ -77,6 +77,34 @@ pub(crate) fn analyze_f32_rgba(width: u32, height: u32, samples: &[f32]) -> Scop
}
}
/// Analyzes one BGRA8 frame (the process backend's slot format, M15 S2)
/// into its [`ScopeData`]. The worker converts its F32 pipeline output to
/// BGRA8 at the end of the render, so the scopes read exactly the
/// displayed values with the viewer's 8-bit quantization — precision loss
/// vs the F32 analysis is bounded by 1/255 per channel (acceptable for
/// the scopes; the F32 path stays for the in-process test backend).
/// `bytes` must hold at least `width * height * 4` values.
pub(crate) fn analyze_bgra8(width: u32, height: u32, bytes: &[u8]) -> ScopeData {
let pixels = (width * height) as usize;
let mut luma = Vec::with_capacity(pixels);
let mut chroma = Vec::with_capacity(pixels);
let src = bytes.get(..pixels * 4).unwrap_or_default();
for px in src.chunks_exact(4) {
let b = f32::from(px[0]) / 255.0;
let g = f32::from(px[1]) / 255.0;
let r = f32::from(px[2]) / 255.0;
let y = KR * r + KG * g + KB * b;
let cb = 0.5 + (b - y) / (2.0 * (1.0 - KB));
let cr = 0.5 + (r - y) / (2.0 * (1.0 - KR));
luma.push(y);
chroma.push((cb.clamp(0.0, 1.0), cr.clamp(0.0, 1.0)));
}
ScopeData {
luma: Arc::new(luma),
chroma: Arc::new(chroma),
}
}
#[cfg(test)]
mod tests {
use super::*;