- 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.
481 lines
14 KiB
Rust
481 lines
14 KiB
Rust
// Oak Video Editor - Non-Linear Video Editor
|
|
// Copyright (C) 2026 Oak Team
|
|
//
|
|
// This program is free software: you can redistribute it and/or modify
|
|
// it under the terms of the GNU General Public License as published by
|
|
// the Free Software Foundation, either version 3 of the License, or
|
|
// (at your option) any later version.
|
|
//
|
|
// This program is distributed in the hope that it will be useful,
|
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
// GNU General Public License for more details.
|
|
//
|
|
// 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 preview auto-cacher (C++ `PreviewAutoCacher`): watches cache
|
|
//! requests on the copied project and pre-renders invalidated ranges
|
|
//! in the background.
|
|
//!
|
|
//! Per the frozen cache ABI (M7 §2.2) no cache events cross the
|
|
//! boundary — the facade re-emits notifications — so this pass drives
|
|
//! the cacher explicitly: [`PreviewAutoCacher::on_cache_request`] is the
|
|
//! facade-facing entry that queues range-caching jobs; `single_frame` is
|
|
//! the one-shot preview request path. Job bookkeeping lives here; the
|
|
//! actual frame production is the arena's CPU producer.
|
|
|
|
use std::collections::HashSet;
|
|
use std::sync::{Arc, Mutex, MutexGuard};
|
|
|
|
use oakcore_rs::{Rational, TimeRange};
|
|
|
|
use crate::error::Result;
|
|
use crate::ticket::{TicketArena, TicketId, VideoTicketParams};
|
|
|
|
/// Progress/stop callbacks toward the facade (C++
|
|
/// set_cache_progress_callback / set_stop_cache_proxy_tasks_callback).
|
|
pub trait AutoCacheEvents: Send {
|
|
/// Cache progress 0.0..=1.0.
|
|
fn progress(&mut self, value: f64);
|
|
/// Ask proxy tasks to stop.
|
|
fn stop_proxy_tasks(&mut self);
|
|
}
|
|
|
|
fn lock<T>(m: &Mutex<T>) -> MutexGuard<'_, T> {
|
|
m.lock().unwrap_or_else(|e| e.into_inner())
|
|
}
|
|
|
|
/// The auto-cacher.
|
|
pub struct PreviewAutoCacher {
|
|
/// Identity of the copied project being cached.
|
|
pub copied_project: u64,
|
|
/// Custom range override (C++ force_cache_range).
|
|
pub custom_range: Option<TimeRange>,
|
|
/// Playhead position (C++ set_playhead).
|
|
pub playhead: Rational,
|
|
/// Pause toggles.
|
|
pub renders_paused: bool,
|
|
/// Thumbnail pause toggle.
|
|
pub thumbnails_paused: bool,
|
|
/// Multicam source node identity (C++ set_multicam_node).
|
|
pub multicam_node: Option<u64>,
|
|
/// Ignore cache requests (C++ set_ignore_cache_requests).
|
|
pub ignore_requests: bool,
|
|
/// Display color processor identity (C++ set_display_color_processor).
|
|
pub display_processor: Option<u64>,
|
|
/// The ticket arena the cacher submits through.
|
|
arena: Arc<TicketArena>,
|
|
/// Viewer identity for single-frame renders.
|
|
viewer_identity: Option<u64>,
|
|
/// In-flight job tickets.
|
|
jobs: Mutex<HashSet<TicketId>>,
|
|
/// Requested-but-not-yet-cached ranges per owner (internal bookkeeping).
|
|
pending: Mutex<Vec<(u64, TimeRange)>>,
|
|
/// The most recent single-frame ticket (cancelled before the next).
|
|
last_single_frame: Mutex<Option<TicketId>>,
|
|
/// Facade callbacks.
|
|
events: Mutex<Option<Box<dyn AutoCacheEvents>>>,
|
|
}
|
|
|
|
impl PreviewAutoCacher {
|
|
/// A cacher submitting through `arena`.
|
|
pub fn new(arena: Arc<TicketArena>) -> Self {
|
|
Self {
|
|
copied_project: 0,
|
|
custom_range: None,
|
|
playhead: Rational::NULL,
|
|
renders_paused: false,
|
|
thumbnails_paused: false,
|
|
multicam_node: None,
|
|
ignore_requests: false,
|
|
display_processor: None,
|
|
arena,
|
|
viewer_identity: None,
|
|
jobs: Mutex::new(HashSet::new()),
|
|
pending: Mutex::new(Vec::new()),
|
|
last_single_frame: Mutex::new(None),
|
|
events: Mutex::new(None),
|
|
}
|
|
}
|
|
|
|
/// Set the viewer identity used by [`PreviewAutoCacher::single_frame`]
|
|
/// (the facade passes the oaknode viewer handle identity).
|
|
pub fn set_viewer_identity(&mut self, identity: Option<u64>) {
|
|
self.viewer_identity = identity;
|
|
}
|
|
|
|
/// Attach to a copied project (registers on its caches).
|
|
pub fn attach(&mut self, copied_project: u64) -> Result<()> {
|
|
if copied_project == 0 {
|
|
return Err(crate::error::Error::Invalid);
|
|
}
|
|
self.copied_project = copied_project;
|
|
lock(&self.pending).clear();
|
|
Ok(())
|
|
}
|
|
|
|
/// Detach and cancel all pending cache jobs (C++
|
|
/// project_destroyed / disconnect_from_node_cache).
|
|
pub fn detach(&mut self) {
|
|
self.cancel_all_jobs();
|
|
self.copied_project = 0;
|
|
lock(&self.pending).clear();
|
|
lock(&self.last_single_frame).take();
|
|
}
|
|
|
|
/// A cache request arrived for `owner` (facade entry; C++ cache
|
|
/// `requested` handling). Queues a caching job unless paused or
|
|
/// ignored.
|
|
pub fn on_cache_request(&mut self, owner: u64, range: TimeRange) {
|
|
if self.ignore_requests || owner == 0 {
|
|
return;
|
|
}
|
|
lock(&self.pending).push((owner, range));
|
|
if self.renders_paused {
|
|
return;
|
|
}
|
|
self.start_range_job(owner, range);
|
|
}
|
|
|
|
/// Start a caching job for `range` of `owner`.
|
|
fn start_range_job(&mut self, owner: u64, range: TimeRange) {
|
|
let id = self.arena.submit_video(
|
|
VideoTicketParams {
|
|
viewer: owner,
|
|
time: range.in_(),
|
|
force_size: None,
|
|
force_format: None,
|
|
cache: Some(owner),
|
|
cache_dir: None,
|
|
cache_id: None,
|
|
cache_timebase: None,
|
|
footage: None,
|
|
montage: Vec::new(),
|
|
},
|
|
Box::new(|_| {}),
|
|
);
|
|
lock(&self.jobs).insert(id);
|
|
}
|
|
|
|
/// One-off single-frame render (C++ get_single_frame): returns a
|
|
/// ticket; a queued-but-not-started previous single-frame render is
|
|
/// cancelled first.
|
|
pub fn single_frame(&mut self, time: Rational) -> TicketId {
|
|
self.single_frame_with_completion(time, Box::new(|_| {}))
|
|
}
|
|
|
|
/// [`PreviewAutoCacher::single_frame`] with a custom completion (the
|
|
/// FFI frame-request path delivers its callback here).
|
|
pub fn single_frame_with_completion(
|
|
&mut self,
|
|
time: Rational,
|
|
done: crate::ticket::Completion,
|
|
) -> TicketId {
|
|
if let Some(prev) = lock(&self.last_single_frame).take() {
|
|
self.arena.cancel(prev);
|
|
lock(&self.jobs).remove(&prev);
|
|
}
|
|
let viewer = self.viewer_identity.unwrap_or(self.copied_project);
|
|
let id = self.arena.submit_video(
|
|
VideoTicketParams {
|
|
viewer,
|
|
time,
|
|
force_size: None,
|
|
force_format: None,
|
|
cache: None,
|
|
cache_dir: None,
|
|
cache_id: None,
|
|
cache_timebase: None,
|
|
footage: None,
|
|
montage: Vec::new(),
|
|
},
|
|
done,
|
|
);
|
|
*lock(&self.last_single_frame) = Some(id);
|
|
lock(&self.jobs).insert(id);
|
|
id
|
|
}
|
|
|
|
/// Clear completed single-frame renders (C++
|
|
/// clear_single_frame_renders_that_arent_running).
|
|
pub fn clear_finished_single_frames(&mut self) {
|
|
let mut jobs = lock(&self.jobs);
|
|
jobs.retain(|id| !self.arena.is_finished(*id));
|
|
}
|
|
|
|
/// Force-cache a range (C++ force_cache_range).
|
|
pub fn force_range(&mut self, range: TimeRange) {
|
|
self.custom_range = Some(range);
|
|
self.start_range_job(self.copied_project, range);
|
|
}
|
|
|
|
/// True while a custom range render is active.
|
|
pub fn is_rendering_custom_range(&self) -> bool {
|
|
if self.custom_range.is_none() {
|
|
return false;
|
|
}
|
|
let jobs = lock(&self.jobs);
|
|
jobs.iter().any(|id| !self.arena.is_finished(*id))
|
|
}
|
|
|
|
/// Cancel in-flight video tasks (C++ cancel_video_tasks). With
|
|
/// `wait`, blocks until every cancelled ticket finished.
|
|
pub fn cancel_video_tasks(&self, wait: bool) {
|
|
let ids: Vec<TicketId> = lock(&self.jobs).iter().copied().collect();
|
|
for id in &ids {
|
|
self.arena.cancel(*id);
|
|
}
|
|
if wait {
|
|
for id in &ids {
|
|
let _ = self.arena.wait(*id);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Cancel in-flight audio tasks (C++ cancel_audio_tasks). Audio
|
|
/// caching is not implemented in this pass (no audio jobs are ever
|
|
/// submitted); kept for the facade contract.
|
|
pub fn cancel_audio_tasks(&self, _wait: bool) {}
|
|
|
|
/// Display color processor for preview output (C++
|
|
/// set_display_color_processor).
|
|
pub fn set_display_color_processor(&mut self, processor: Option<u64>) {
|
|
self.display_processor = processor;
|
|
}
|
|
|
|
/// Event sink registration (facade).
|
|
pub fn set_events(&mut self, events: Box<dyn AutoCacheEvents>) {
|
|
*lock(&self.events) = Some(events);
|
|
}
|
|
|
|
/// Report progress to the facade (0.0..=1.0).
|
|
pub fn report_progress(&self, value: f64) {
|
|
if let Some(events) = lock(&self.events).as_mut() {
|
|
events.progress(value);
|
|
}
|
|
}
|
|
|
|
/// Ask proxy tasks to stop.
|
|
pub fn request_stop_proxy_tasks(&self) {
|
|
if let Some(events) = lock(&self.events).as_mut() {
|
|
events.stop_proxy_tasks();
|
|
}
|
|
}
|
|
|
|
fn cancel_all_jobs(&mut self) {
|
|
let ids: Vec<TicketId> = lock(&self.jobs).iter().copied().collect();
|
|
for id in &ids {
|
|
self.arena.cancel(*id);
|
|
}
|
|
lock(&self.jobs).clear();
|
|
}
|
|
|
|
/// Pending (not yet cached) request ranges — test introspection.
|
|
pub fn pending_requests(&self) -> Vec<(u64, TimeRange)> {
|
|
lock(&self.pending).clone()
|
|
}
|
|
|
|
/// Live job ticket ids — test introspection.
|
|
pub fn live_jobs(&self) -> Vec<TicketId> {
|
|
lock(&self.jobs).iter().copied().collect()
|
|
}
|
|
}
|
|
|
|
/// A no-op event sink (facade may pass one in tests).
|
|
pub struct NoopEvents;
|
|
|
|
impl AutoCacheEvents for NoopEvents {
|
|
fn progress(&mut self, _value: f64) {}
|
|
fn stop_proxy_tasks(&mut self) {}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use std::sync::atomic::{AtomicU32, Ordering};
|
|
|
|
use crate::frame::VideoParamsPod;
|
|
use crate::texture::{Frame, Texture};
|
|
use crate::worker::{InlineDispatcher, JobDispatch};
|
|
|
|
fn frame_producer() -> crate::ticket::Producer {
|
|
Arc::new(|_, _| {
|
|
let mut f = Frame::new();
|
|
let mut p = VideoParamsPod::default();
|
|
p.width = 4;
|
|
p.height = 4;
|
|
f.set_video_params(p);
|
|
f.allocate();
|
|
Ok(crate::ticket::TicketPayload::Video(Texture::wrap_frame(f)))
|
|
})
|
|
}
|
|
|
|
/// 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, d) = new_cacher();
|
|
assert!(c.attach(0).is_err(), "zero identity rejected");
|
|
c.attach(42).unwrap();
|
|
assert_eq!(c.copied_project, 42);
|
|
c.on_cache_request(
|
|
42,
|
|
TimeRange::new(Rational::new(0, 1), Rational::new(10, 1)),
|
|
);
|
|
assert_eq!(c.live_jobs().len(), 1);
|
|
c.detach();
|
|
assert_eq!(c.copied_project, 0);
|
|
assert!(c.live_jobs().is_empty());
|
|
d.shutdown();
|
|
}
|
|
|
|
#[test]
|
|
fn single_frame_cancels_previous() {
|
|
// 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());
|
|
d.shutdown();
|
|
}
|
|
|
|
/// A cacher whose frames take ~100ms to produce (see
|
|
/// [`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();
|
|
let mut p = VideoParamsPod::default();
|
|
p.width = 4;
|
|
p.height = 4;
|
|
f.set_video_params(p);
|
|
f.allocate();
|
|
Ok(crate::ticket::TicketPayload::Video(Texture::wrap_frame(f)))
|
|
});
|
|
let arena = Arc::new(TicketArena::new(d.clone(), producer));
|
|
(PreviewAutoCacher::new(arena), d)
|
|
}
|
|
|
|
#[test]
|
|
fn ignore_requests_suppresses_jobs() {
|
|
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());
|
|
d.shutdown();
|
|
}
|
|
|
|
#[test]
|
|
fn renders_paused_queues_but_does_not_start() {
|
|
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);
|
|
d.shutdown();
|
|
}
|
|
|
|
#[test]
|
|
fn cancel_video_tasks_wait_blocks_until_idle() {
|
|
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());
|
|
d.shutdown();
|
|
}
|
|
|
|
struct Probe {
|
|
progress: AtomicU32,
|
|
stop: AtomicU32,
|
|
}
|
|
|
|
impl AutoCacheEvents for Probe {
|
|
fn progress(&mut self, value: f64) {
|
|
assert_eq!(value, 0.5);
|
|
self.progress.fetch_add(1, Ordering::Relaxed);
|
|
}
|
|
fn stop_proxy_tasks(&mut self) {
|
|
self.stop.fetch_add(1, Ordering::Relaxed);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn events_deliver_progress() {
|
|
let (mut c, d) = new_cacher();
|
|
let probe = Arc::new(Probe {
|
|
progress: AtomicU32::new(0),
|
|
stop: AtomicU32::new(0),
|
|
});
|
|
let probe2 = probe.clone();
|
|
c.set_events(Box::new(ProbeEvents { probe: probe2 }));
|
|
c.report_progress(0.5);
|
|
c.request_stop_proxy_tasks();
|
|
assert_eq!(probe.progress.load(Ordering::Relaxed), 1);
|
|
assert_eq!(probe.stop.load(Ordering::Relaxed), 1);
|
|
d.shutdown();
|
|
}
|
|
|
|
struct ProbeEvents {
|
|
probe: Arc<Probe>,
|
|
}
|
|
impl AutoCacheEvents for ProbeEvents {
|
|
fn progress(&mut self, value: f64) {
|
|
self.probe.progress.fetch_add(1, Ordering::Relaxed);
|
|
let _ = value;
|
|
}
|
|
fn stop_proxy_tasks(&mut self) {
|
|
self.probe.stop.fetch_add(1, Ordering::Relaxed);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn clear_finished_single_frames_removes_done() {
|
|
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());
|
|
d.shutdown();
|
|
}
|
|
|
|
#[test]
|
|
fn noop_events_sink_never_panics() {
|
|
let mut 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);
|
|
c.request_stop_proxy_tasks();
|
|
}
|
|
}
|