// 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 .
//! The M1 thread pipeline: the same ticket stream on the inline (test)
//! backend and on the thread pipeline must produce byte-identical frames.
//!
//! Covers the pipeline backend selection (`OAK_PIPELINE=threads`), the
//! decode-service LRU under seek patterns, the prefetch gate under render
//! queue backpressure, and the decode-service install/uninstall lifecycle.
//! The manager singleton, the `OAK_PIPELINE` variable and the decode
//! service slot are process-wide, so every test here serializes on `LOCK`
//! and each creates and tears its manager down explicitly.
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{mpsc, Arc, Condvar, Mutex, MutexGuard};
use std::time::{Duration, Instant};
use oak_core::commonutil::ENV_TEST_LOCK;
use oak_core::texture::{Frame, Texture};
use oak_core::{PixelFormat, Rational, TimeRange};
use oak_node::block::{clip_create, clip_input, ClipBlockBehavior};
use oak_node::footage::FootageBehavior;
use oak_node::id::NodeId;
use oak_node::node::NodeCore;
use oak_node::project::Project;
use oak_node::sequence::SequenceBehavior;
use oak_node::track::{TrackBehavior, TrackListBehavior};
use oak_render::error::Error;
use oak_render::eval::{decode_invocations, reset_decode_invocations};
use oak_render::manager::{RenderBackendChoice, RenderManager};
use oak_render::pipeline::{
decode_service, DecodeRequest, DecodeStats, PipelineBackend, PipelineStats, RENDER_QUEUE_CAP,
};
use oak_render::ticket::{
Completion, MontageClip, Producer, TicketPayload, TicketResult, VideoTicketParams,
};
use oak_render::worker::{Job, JobDispatch, JobSchedule};
mod common;
/// Serializes this binary's tests: the manager singleton, the environment
/// variable, the decode-service slot and the eval decode caches are all
/// process-wide.
static LOCK: Mutex<()> = Mutex::new(());
fn lock() -> MutexGuard<'static, ()> {
LOCK.lock().unwrap_or_else(|e| e.into_inner())
}
/// A unique clip per test (the process id separates test binaries, the
/// tag separates tests inside one binary — the decode caches are
/// process-wide).
fn test_clip(tag: &str) -> PathBuf {
let path = std::env::temp_dir().join(format!(
"oakrender_threads_{tag}_{}.mp4",
std::process::id()
));
oak_codec::testmedia::write_test_clip(&path, 64, 64, 10, 10).expect("test clip generation");
path
}
/// A second copy of `src` under a fresh name: the decode and eval frame
/// caches are keyed by filename, so the two backends must not share one
/// (otherwise the pipeline run would replay the inline run's cache instead
/// of decoding).
fn test_clip_copy(src: &Path, tag: &str) -> PathBuf {
let path = std::env::temp_dir().join(format!(
"oakrender_threads_{tag}_{}.mp4",
std::process::id()
));
std::fs::copy(src, &path).expect("copy the test clip");
path
}
/// Pin the working space to the legacy sRGB pass-through: these tests
/// assert the decoded pattern, not the color transform (the ACEScg
/// default would remap the values).
fn pin_legacy_working_space() {
oak_core::color::set_pipeline_color_settings(
oak_core::colormath::WorkingColorSpace::SrgbLegacy,
oak_core::colormath::OutputColorSpec::default(),
);
}
/// Force software decoding for the inline-vs-pipeline byte-exact
/// comparisons: hardware decoders (NVDEC/VAAPI) may differ from the
/// software decoder by a few LSBs, which is a decode-path property, not a
/// pipeline bug. Every test in this binary takes `lock()`, so the
/// process-wide env override is race-free here.
struct SoftwareDecodeGuard {
prev: Option,
}
impl SoftwareDecodeGuard {
fn set() -> Self {
let prev = std::env::var("OAK_HWACCEL").ok();
std::env::set_var("OAK_HWACCEL", "0");
Self { prev }
}
}
impl Drop for SoftwareDecodeGuard {
fn drop(&mut self) {
match &self.prev {
Some(p) => std::env::set_var("OAK_HWACCEL", p),
None => std::env::remove_var("OAK_HWACCEL"),
}
}
}
fn base_params(time: Rational) -> VideoTicketParams {
VideoTicketParams {
viewer: 0,
project: String::new(),
time,
force_size: Some((64, 64)),
force_format: Some(PixelFormat::F32),
cache: None,
cache_dir: None,
cache_id: None,
cache_timebase: None,
footage: None,
montage: Vec::new(),
adjustments: Vec::new(),
}
}
/// A one-clip montage ticket over `[0s, 1s)`.
fn montage_params(filename: &Path, time: Rational) -> VideoTicketParams {
VideoTicketParams {
montage: vec![MontageClip {
filename: filename.to_string_lossy().to_string(),
stream_index: 0,
in_time: Rational::new(0, 1),
out_time: Rational::new(1, 1),
media_in: Rational::new(0, 1),
gain: 1.0,
effects: Vec::new(),
}],
..base_params(time)
}
}
/// A viewer ticket: the manager's graph mode renders `viewer` of the
/// project whose uuid is `uuid` (armed via `set_inline_project`).
fn viewer_params(uuid: &str, viewer: u64, time: Rational) -> VideoTicketParams {
VideoTicketParams {
viewer,
project: uuid.to_string(),
..base_params(time)
}
}
/// Submit one video ticket and wait for its frame. The reserved id keeps
/// the arena slot alive past the completion; `result()` reaps it.
fn render_video(params: VideoTicketParams) -> Texture {
let manager = RenderManager::global().expect("manager installed");
let id = manager.tickets.next_id();
let (tx, rx) = mpsc::sync_channel::(1);
let done: Completion = Box::new(move |result: TicketResult| {
let _ = tx.send(result);
});
manager.tickets.submit_video_with_id(id, params, done);
let result = rx
.recv_timeout(Duration::from_secs(60))
.expect("ticket completed within 60s");
let _ = manager.tickets.result(id);
match result {
Ok(TicketPayload::Video(texture)) => texture,
Ok(other) => panic!("unexpected ticket payload: {other:?}"),
Err(e) => panic!("ticket failed: {e}"),
}
}
/// The frame bytes of a rendered texture. GPU textures (the pipeline
/// backend on a GPU-capable host) are read back for the assertion; the
/// playback path itself never downloads.
fn frame_of(texture: &Texture) -> Frame {
texture.to_frame().expect("ticket frame readback")
}
/// Byte-for-byte frame equality with a first-difference report.
fn assert_same_frame(expected: &Frame, actual: &Frame, tag: &str) {
assert_eq!(
(expected.width, expected.height),
(actual.width, actual.height),
"{tag}: frame size"
);
assert_eq!(expected.format, actual.format, "{tag}: pixel format");
assert_eq!(expected.channels, actual.channels, "{tag}: channel count");
assert_eq!(expected.data.len(), actual.data.len(), "{tag}: data length");
if let Some(offset) = expected
.data
.iter()
.zip(actual.data.iter())
.position(|(a, b)| a != b)
{
let stride = expected.linesize_bytes();
let row = offset / stride;
let byte_column = offset % stride;
panic!(
"{tag}: pixel bytes differ at offset {offset} (row {row}, byte column {byte_column}): \
expected {}, got {}",
expected.data[offset], actual.data[offset]
);
}
}
/// The decoded test pattern: a red|blue split that steps with the frame
/// index — `oak_codec::testmedia` shifts it by `index * width / (2 * fps)`
/// columns (9 columns for frame 3 of this 64 px / 10 fps clip). The
/// sampled columns track that shift; MPEG-2 is lossy, so the assertions
/// use dominance with generous margins.
fn assert_known_pattern(frame: &Frame, index: i32, tag: &str) {
assert_eq!((frame.width, frame.height), (64, 64), "{tag}");
assert_eq!(frame.format, PixelFormat::F32, "{tag}");
let stride = frame.linesize_bytes();
let shift = (index * frame.width / 20).rem_euclid(frame.width);
let read = |x: usize, y: usize| -> [f32; 4] {
let off = y * stride + x * 16;
let mut out = [0f32; 4];
for i in 0..4 {
out[i] =
f32::from_le_bytes(frame.data[off + i * 4..off + i * 4 + 4].try_into().unwrap());
}
out
};
// Sample the middle of each half: the generator's split has the red
// half where `(x + shift) % 64` is below 32.
let [r, g, b, a] = read((16 - shift).rem_euclid(64) as usize, 32);
assert!(r > 0.5 && g < 0.4 && b < 0.4, "{tag}: red half {r},{g},{b}");
assert!(a > 0.9, "{tag}: opaque {a}");
let [r, g, b, a] = read((48 - shift).rem_euclid(64) as usize, 32);
assert!(
b > 0.5 && r < 0.4 && g < 0.4,
"{tag}: blue half {r},{g},{b}"
);
assert!(a > 0.9, "{tag}: opaque {a}");
}
/// Poll `check` until it holds (10s cap) — the pipeline counters advance
/// on the render thread, so a fixed sleep is both slow and flaky.
fn wait_until(what: &str, check: &mut dyn FnMut() -> bool) {
let deadline = Instant::now() + Duration::from_secs(10);
if check() {
return;
}
while Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(5));
if check() {
return;
}
}
panic!("timed out waiting for {what}");
}
/// Render `times` on the inline backend (no extra threads, no children).
fn render_inline(
project: Option<&Arc>>,
times: &[Rational],
params: impl Fn(Rational) -> VideoTicketParams,
) -> Vec {
let _guard = common::ManagerGuard::init();
if let Some(project) = project {
RenderManager::global()
.expect("manager installed")
.set_inline_project(project.clone());
}
times
.iter()
.map(|&time| frame_of(&render_video(params(time))).clone())
.collect()
}
/// Render `times` on the thread pipeline, then drain and report its
/// counters. The manager is torn down before returning (the decode service
/// must be uninstalled with it).
fn render_pipeline(
project: Option<&Arc>>,
times: &[Rational],
params: impl Fn(Rational) -> VideoTicketParams,
) -> (Vec, PipelineStats, DecodeStats) {
let guard = common::ManagerGuard::init_with(RenderBackendChoice::Pipeline);
let manager = RenderManager::global().expect("manager installed");
if let Some(project) = project {
manager.set_inline_project(project.clone());
}
let backend = manager
.pipeline_backend()
.expect("thread pipeline selected");
reset_decode_invocations();
let frames: Vec = times
.iter()
.map(|&time| frame_of(&render_video(params(time))).clone())
.collect();
let rendered = frames.len() as u64;
wait_until("all pipeline jobs executed", &mut || {
backend.stats().executed == rendered && backend.queue_depth() == 0
});
let stats = backend.stats();
let service = backend.decode_service();
assert!(service.wait_idle(), "decode service drained");
let decode = service.stats();
drop(service);
drop(backend);
drop(manager);
drop(guard);
assert!(
decode_service().is_none(),
"manager shutdown uninstalls the decode service"
);
(frames, stats, decode)
}
/// A job whose producer always fails: fills the queue / proves that a
/// stopped backend refuses work.
fn filler_job() -> Job {
let params = Arc::new(montage_params(
Path::new("/definitely/not/here-filler.mp4"),
Rational::new(0, 1),
));
let produce: Producer = Arc::new(
|_time: Rational, _params: &VideoTicketParams| -> TicketResult { Err(Error::State) },
);
Job {
node_identity: 0,
time: Rational::new(0, 1),
params,
audio: None,
produce,
done: Box::new(|_result: TicketResult| {}),
schedule: JobSchedule::seek(),
cancelled: None,
}
}
/// One sequence + one video track list with one track per clip
/// `(filename, [in, out))`. The LAST entry's track composites on top
/// (NLE stacking: the highest-numbered track is topmost).
///
/// The project is initialized like a real one, so the root folder takes
/// the first arena slot: a ticket names its viewer by `NodeId::identity`,
/// and identity 0 is the ticket API's "no graph viewer" sentinel — a
/// sequence created into slot 0 would silently take the montage fall-back
/// instead of the graph.
fn build_project(clips: &[(&str, Rational, Rational)]) -> (Arc>, NodeId) {
pin_legacy_working_space();
let project = Project::new();
let seq;
{
let mut p = project.lock().unwrap();
p.initialize().expect("initialize the project");
let (score, sbehavior) = SequenceBehavior::create();
seq = p.graph.add_node(score, sbehavior);
let (tcore, tbehavior) = TrackListBehavior::create();
let tl = p.graph.add_node(tcore, tbehavior);
for &(path, in_, out) in clips {
let (tcore, tbehavior) = TrackBehavior::create();
let track = p.graph.add_node(tcore, tbehavior);
let mut footage = FootageBehavior::new(path);
footage.probe().expect("probe the generated clip");
let footage = p.graph.add_node(NodeCore::new(), Box::new(footage));
let (ccore, cbehavior) = clip_create();
let clip = p.graph.add_node(ccore, cbehavior);
p.graph
.connect(footage, clip, clip_input::TEXTURE_INPUT, -1)
.expect("connect footage to clip");
let clip_behavior = p
.graph
.get_mut(clip)
.unwrap()
.behavior
.as_any_mut()
.unwrap()
.downcast_mut::()
.expect("clip block");
clip_behavior.core.range = TimeRange::new(in_, out);
p.graph
.get_mut(track)
.unwrap()
.behavior
.as_any_mut()
.unwrap()
.downcast_mut::()
.expect("video track")
.append_block(clip);
p.graph
.get_mut(tl)
.unwrap()
.behavior
.as_any_mut()
.unwrap()
.downcast_mut::()
.expect("video track list")
.tracks
.push(track);
}
p.graph
.get_mut(seq)
.unwrap()
.behavior
.as_any_mut()
.unwrap()
.downcast_mut::()
.expect("sequence")
.track_lists
.push(tl);
}
(project, seq)
}
/// Create one footage clip on `track` (not appended: the layered fixture
/// orders V1 as clip/transition/clip explicitly).
fn add_clip(
graph: &mut oak_node::graph::Graph,
path: &Path,
in_: Rational,
out: Rational,
) -> NodeId {
let mut footage = FootageBehavior::new(path.to_string_lossy().as_ref());
footage.probe().expect("probe the generated clip");
let footage = graph.add_node(NodeCore::new(), Box::new(footage));
let (ccore, cbehavior) = clip_create();
let clip = graph.add_node(ccore, cbehavior);
graph
.connect(footage, clip, clip_input::TEXTURE_INPUT, -1)
.expect("connect footage to clip");
graph
.get_mut(clip)
.unwrap()
.behavior
.as_any_mut()
.unwrap()
.downcast_mut::()
.expect("clip block")
.core
.range = TimeRange::new(in_, out);
clip
}
fn track_mut(graph: &mut oak_node::graph::Graph, id: NodeId) -> &mut TrackBehavior {
graph
.get_mut(id)
.unwrap()
.behavior
.as_any_mut()
.unwrap()
.downcast_mut::()
.expect("video track")
}
/// The layered M2 playback fixture: V1 carries two clips joined by a
/// transition, V2 an overlapping clip (multi-track composite) and V3 an
/// adjustment layer with an Opacity effect (the sweep). At the transition
/// seam (t=1) one frame exercises all three mechanisms — the transitions'
/// two decoded sides, the multi-track composite and the adjustment sweep
/// — end to end.
fn build_layered_project(
first: &Path,
second: &Path,
below: &Path,
) -> (Arc>, NodeId) {
pin_legacy_working_space();
let project = Project::new();
let seq;
{
let mut p = project.lock().unwrap();
p.initialize().expect("initialize the project");
let (score, sbehavior) = SequenceBehavior::create();
seq = p.graph.add_node(score, sbehavior);
let (tl_core, tl_beh) = TrackListBehavior::create();
let tl = p.graph.add_node(tl_core, tl_beh);
// V1: A [0,1) + transition [0.5,1.5) + B [1,2).
let (v1_core, v1_beh) = TrackBehavior::create();
let v1 = p.graph.add_node(v1_core, v1_beh);
let a = add_clip(
&mut p.graph,
first,
Rational::new(0, 1),
Rational::new(1, 1),
);
let b = add_clip(
&mut p.graph,
second,
Rational::new(1, 1),
Rational::new(2, 1),
);
let (tcore, tbehavior) = oak_node::block::transition_create();
let transition = p.graph.add_node(tcore, tbehavior);
{
let behavior = p
.graph
.get_mut(transition)
.unwrap()
.behavior
.as_any_mut()
.unwrap()
.downcast_mut::()
.expect("transition block");
behavior.core.range = TimeRange::new(Rational::new(1, 2), Rational::new(3, 2));
behavior.in_offset = Rational::new(1, 2);
behavior.out_offset = Rational::new(1, 2);
}
p.graph
.connect(
a,
transition,
oak_node::block::transition_input::OUT_BLOCK,
-1,
)
.expect("connect the outgoing clip to the transition");
p.graph
.connect(
b,
transition,
oak_node::block::transition_input::IN_BLOCK,
-1,
)
.expect("connect the incoming clip to the transition");
track_mut(&mut p.graph, v1).blocks = vec![a, transition, b];
// V2: C [0,2), overlapping the transition track.
let (v2_core, v2_beh) = TrackBehavior::create();
let v2 = p.graph.add_node(v2_core, v2_beh);
let c = add_clip(
&mut p.graph,
below,
Rational::new(0, 1),
Rational::new(2, 1),
);
track_mut(&mut p.graph, v2).append_block(c);
// V3: an adjustment layer [0,2) with an Opacity(0.75) chain.
let (v3_core, v3_beh) = TrackBehavior::create();
let v3 = p.graph.add_node(v3_core, v3_beh);
let (acore, abehavior) = oak_node::block::adjustment_create();
let adjustment = p.graph.add_node(acore, abehavior);
p.graph
.get_mut(adjustment)
.unwrap()
.behavior
.as_any_mut()
.unwrap()
.downcast_mut::()
.expect("adjustment block")
.core
.range = TimeRange::new(Rational::new(0, 1), Rational::new(2, 1));
let (ecore, ebehavior) = oak_node::nodes::opacity::create();
let effect = p.graph.add_node(ecore, ebehavior);
p.graph
.connect(
effect,
adjustment,
oak_node::block::adjustment_input::TEXTURE_INPUT,
-1,
)
.expect("connect opacity to the adjustment block");
p.graph.get_mut(effect).unwrap().core.set_standard_value(
oak_node::nodes::opacity::VALUE_INPUT,
-1,
oak_node::value::NodeValue::Float(0.75),
);
track_mut(&mut p.graph, v3).append_block(adjustment);
// V3 is last = topmost: its sweep covers V1 and V2.
{
let tl = p
.graph
.get_mut(tl)
.unwrap()
.behavior
.as_any_mut()
.unwrap()
.downcast_mut::()
.expect("video track list");
tl.tracks.push(v1);
tl.tracks.push(v2);
tl.tracks.push(v3);
}
p.graph
.get_mut(seq)
.unwrap()
.behavior
.as_any_mut()
.unwrap()
.downcast_mut::()
.expect("sequence")
.track_lists
.push(tl);
}
(project, seq)
}
/// `OAK_PIPELINE=threads` selects the thread pipeline (the default stays
/// the process backend, which the manager-guard init below exercises as
/// the test-only inline choice).
#[test]
fn oak_pipeline_env_selects_the_thread_backend() {
let _lock = lock();
let _env = ENV_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
RenderManager::shutdown();
std::env::set_var("OAK_PIPELINE", "threads");
RenderManager::init().expect("manager init with OAK_PIPELINE=threads");
{
let manager = RenderManager::global().expect("manager installed");
let backend = manager
.pipeline_backend()
.expect("OAK_PIPELINE=threads selects the thread pipeline");
assert_eq!(backend.queue_depth(), 0, "idle queue");
assert_eq!(backend.queue_free(), RENDER_QUEUE_CAP);
let stats = backend.stats();
assert_eq!(
(stats.posted, stats.executed, stats.drained),
(0, 0, 0),
"fresh pipeline counters"
);
assert!(
decode_service().is_some(),
"the pipeline installs its decode service"
);
}
RenderManager::shutdown();
std::env::remove_var("OAK_PIPELINE");
assert!(
decode_service().is_none(),
"shutdown uninstalls the decode service"
);
let _guard = common::ManagerGuard::init();
assert!(
RenderManager::global()
.unwrap()
.pipeline_backend()
.is_none(),
"the inline test backend runs no thread pipeline"
);
}
/// Six consecutive frames: the two backends must agree byte for byte and
/// every frame must be a real decode through the service.
#[test]
fn pipeline_matches_inline_pixels_across_consecutive_frames() {
let _lock = lock();
let _software = SoftwareDecodeGuard::set();
pin_legacy_working_space();
let inline_path = test_clip("consecutive_inline");
let pipeline_path = test_clip_copy(&inline_path, "consecutive_pipeline");
let times: Vec = (0..6).map(|n| Rational::new(n, 10)).collect();
let inline_frames = render_inline(None, ×, |time| montage_params(&inline_path, time));
let (pipeline_frames, stats, decode) =
render_pipeline(None, ×, |time| montage_params(&pipeline_path, time));
assert_eq!((stats.posted, stats.executed, stats.drained), (6, 6, 0));
assert_eq!(decode.requests, 6, "one decode request per frame");
assert_eq!(decode.decodes, 6, "each frame decodes once");
assert_eq!(decode.lru_hits, 0, "consecutive frames never repeat");
assert_eq!(decode_invocations(), 6, "the service did the decoding");
for (frame, time) in pipeline_frames.iter().zip(times.iter()) {
assert_eq!(frame.timestamp, *time, "the rendered frame keeps its time");
}
for (index, (a, b)) in inline_frames.iter().zip(pipeline_frames.iter()).enumerate() {
assert_same_frame(a, b, &format!("frame {index}"));
}
assert_known_pattern(&inline_frames[0], 0, "inline frame 0");
assert_known_pattern(&pipeline_frames[0], 0, "pipeline frame 0");
assert!(
inline_frames.windows(2).any(|w| w[0].data != w[1].data),
"the generated clip really moves between frames"
);
let _ = std::fs::remove_file(&inline_path);
let _ = std::fs::remove_file(&pipeline_path);
}
/// Out-of-order seeks with a repeat: the service's LRU must absorb the
/// repeated frame, and the pixels must still match the inline path.
#[test]
fn pipeline_seek_out_of_order_matches_inline() {
let _lock = lock();
let _software = SoftwareDecodeGuard::set();
pin_legacy_working_space();
let inline_path = test_clip("seek_inline");
let pipeline_path = test_clip_copy(&inline_path, "seek_pipeline");
let seeks: [i64; 5] = [7, 2, 5, 0, 5];
let times: Vec = seeks.iter().map(|&n| Rational::new(n, 10)).collect();
let inline_frames = render_inline(None, ×, |time| montage_params(&inline_path, time));
let (pipeline_frames, stats, decode) =
render_pipeline(None, ×, |time| montage_params(&pipeline_path, time));
assert_eq!((stats.posted, stats.executed, stats.drained), (5, 5, 0));
assert_eq!(decode.requests, 5, "one decode request per seek");
assert_eq!(decode.lru_hits, 1, "the repeated 5/10 seek hits the LRU");
assert_eq!(decode.decodes, 4, "four distinct frames decode");
assert_eq!(decode_invocations(), 4, "the LRU absorbed the repeat");
for (index, (a, b)) in inline_frames.iter().zip(pipeline_frames.iter()).enumerate() {
assert_same_frame(a, b, &format!("seek {index}"));
}
assert_known_pattern(&inline_frames[3], 0, "inline seek to 0/10");
assert_known_pattern(&pipeline_frames[3], 0, "pipeline seek to 0/10");
let _ = std::fs::remove_file(&inline_path);
let _ = std::fs::remove_file(&pipeline_path);
}
/// The graph (viewer) path through the pipeline: the same node-graph
/// render as the inline backend — not a silent fall-back to a blank
/// generated frame (which the pattern assertions would catch).
#[test]
fn pipeline_viewer_ticket_matches_inline_pixels() {
let _lock = lock();
let _software = SoftwareDecodeGuard::set();
let path = test_clip("viewer");
let filename = path.to_string_lossy().to_string();
let clip = (filename.as_str(), Rational::new(0, 1), Rational::new(1, 1));
let (project, sequence) = build_project(&[clip]);
let uuid = project.lock().unwrap().uuid.clone();
let viewer = sequence.identity();
let times = [
Rational::new(0, 1),
Rational::new(3, 10),
Rational::new(8, 10),
];
let inline_frames = render_inline(Some(&project), ×, |time| {
viewer_params(&uuid, viewer, time)
});
let (pipeline_frames, stats, decode) = render_pipeline(Some(&project), ×, |time| {
viewer_params(&uuid, viewer, time)
});
assert_eq!((stats.posted, stats.executed, stats.drained), (3, 3, 0));
assert_eq!(decode.requests, 3, "the graph path decodes via the service");
assert_known_pattern(&inline_frames[0], 0, "inline graph frame 0");
assert_known_pattern(&pipeline_frames[0], 0, "pipeline graph frame 0");
for (index, (a, b)) in inline_frames.iter().zip(pipeline_frames.iter()).enumerate() {
assert_same_frame(a, b, &format!("graph frame {index}"));
}
let _ = std::fs::remove_file(&path);
}
// M2: the playback graph path keeps every frame on the GPU. Rendering
// a sequence graph through the thread pipeline must transfer pixels
// CPU→GPU once per decoded frame (the M5 gap — decode is still CPU)
// and never read back; the final texture is GPU-resident until the
// presentation boundary.
#[test]
fn pipeline_graph_playback_has_zero_gpu_readbacks() {
let _lock = lock();
pin_legacy_working_space();
if oak_core::backend::shared_gpu_or_skip("the pipeline GPU zero-copy assertion").is_none() {
return;
}
let path = test_clip("gpu_zero");
let filename = path.to_string_lossy().to_string();
let clip = (filename.as_str(), Rational::new(0, 1), Rational::new(1, 1));
let (project, sequence) = build_project(&[clip]);
let uuid = project.lock().unwrap().uuid.clone();
let viewer = sequence.identity();
let times = [
Rational::new(0, 1),
Rational::new(3, 10),
Rational::new(6, 10),
];
let guard = common::ManagerGuard::init_with(RenderBackendChoice::Pipeline);
let manager = RenderManager::global().expect("manager installed");
manager.set_inline_project(project.clone());
if let Some(backend) = manager.pipeline_backend() {
assert!(backend.decode_service().wait_idle(), "service drained");
}
oak_core::backend::reset_gpu_transfer_counters();
let mut rendered = 0u64;
for &time in × {
let texture = render_video(viewer_params(&uuid, viewer, time));
assert!(
matches!(texture, Texture::Gpu { .. }),
"the thread-pipeline graph path must produce a GPU texture"
);
rendered += 1;
}
let (uploads, downloads) = oak_core::backend::gpu_transfer_counters();
assert_eq!(downloads, 0, "playback must not read the frame back to CPU");
assert_eq!(
uploads, rendered,
"one decode upload per frame until M5 imports the decode surface"
);
drop(guard);
let _ = std::fs::remove_file(&path);
}
/// M2: the layered playback path — multi-track composite + transition
/// blend + adjustment sweep — is zero-readback too. The single clip test
/// above covers the common case; this one proves the per-clip readback
/// pattern that used to exist in each of these paths is gone: every clip
/// uploads once (the M5 gap) and nothing comes back.
#[test]
fn pipeline_layered_playback_has_zero_gpu_readbacks() {
let _lock = lock();
if oak_core::backend::shared_gpu_or_skip("the layered playback zero-readback assertion")
.is_none()
{
return;
}
let first = test_clip("layered_first");
let second = test_clip_copy(&first, "layered_second");
let below = test_clip_copy(&first, "layered_below");
let (project, sequence) = build_layered_project(&first, &second, &below);
let uuid = project.lock().unwrap().uuid.clone();
let viewer = sequence.identity();
let guard = common::ManagerGuard::init_with(RenderBackendChoice::Pipeline);
let manager = RenderManager::global().expect("manager installed");
manager.set_inline_project(project.clone());
if let Some(backend) = manager.pipeline_backend() {
assert!(backend.decode_service().wait_idle(), "service drained");
}
oak_core::backend::reset_gpu_transfer_counters();
// The transition seam: V1 blends A/B, V2 composites underneath and V3
// sweeps the result with Opacity(0.75).
let texture = render_video(viewer_params(&uuid, viewer, Rational::new(1, 1)));
assert!(
matches!(texture, Texture::Gpu { .. }),
"layered playback must produce a GPU texture"
);
let (uploads, downloads) = oak_core::backend::gpu_transfer_counters();
assert_eq!(
downloads, 0,
"multi-track/transition/adjustment playback must not read back"
);
assert_eq!(
uploads, 3,
"each decoded clip uploads exactly once (A, B, C); the passes are GPU→GPU"
);
// The adjustment sweep must have participated: the final alpha is the
// Opacity(0.75) value (readback only for the assertion, after the
// counter sample above).
let frame = texture.to_frame().expect("frame readback");
let alpha = f32::from_le_bytes(frame.data[12..16].try_into().unwrap());
assert!(
alpha > 0.0 && alpha < 0.99,
"the adjustment sweep applied (alpha {alpha})"
);
drop(guard);
let _ = std::fs::remove_file(&first);
let _ = std::fs::remove_file(&second);
let _ = std::fs::remove_file(&below);
}
/// M4: every playback post queues its footage read-ahead and every
/// distinct frame ends up decoded exactly once — by the prefetch or, if
/// the render request wins the race, by the rendezvous. This is a smoke
/// test for the wiring; that the read-ahead is actually *used* is proven
/// deterministically by `pipeline_prefetch_is_the_frame_the_render_request_uses`,
/// and the priority order by `pipeline_orders_seek_ahead_of_background_end_to_end`.
#[test]
fn pipeline_playback_prefetches_ahead_of_the_render() {
let _lock = lock();
pin_legacy_working_space();
let guard = common::ManagerGuard::init_with(RenderBackendChoice::Pipeline);
let manager = RenderManager::global().expect("manager installed");
let backend = manager.pipeline_backend().expect("pipeline selected");
let path = test_clip("playback_prefetch");
let times: Vec = (0..6).map(|n| Rational::new(n, 10)).collect();
let (tx, rx) = mpsc::channel();
for (n, time) in times.iter().enumerate() {
let tx = tx.clone();
let done: Completion = Box::new(move |result| {
let ok = matches!(result, Ok(TicketPayload::Video(_)));
let _ = tx.send((n, ok));
});
manager
.tickets
.submit_playback(montage_params(&path, *time), n as i64, n as i64, 0, done);
}
drop(tx);
for _ in 0..times.len() {
match rx.recv_timeout(Duration::from_secs(60)) {
Ok((n, true)) => {
let _ = n;
}
Ok((n, false)) => panic!("frame {n} did not produce a video payload"),
Err(err) => panic!("playback completion timeout: {err}"),
}
}
let stats = backend.decode_service().stats();
assert_eq!(
stats.prefetches,
times.len() as u64,
"every playback post queued its footage prefetch"
);
// Each distinct frame is produced at least once. The hand-off LRU holds
// only `DECODE_LRU_CAP` (2) frames, so under scheduling pressure a
// request can miss the prefetched copy and re-run the producer; the
// eval-side cache still serves the pixels without touching the media,
// but the counter moves. Bound this to one production per command
// (prefetch + request) instead of asserting an exact count; the
// deterministic `pipeline_prefetch_is_the_frame_the_render_request_uses`
// pins that the read-ahead is actually used.
assert!(
stats.decodes >= times.len() as u64,
"each distinct frame is produced at least once ({} < {})",
stats.decodes,
times.len()
);
assert!(
stats.decodes <= 2 * times.len() as u64,
"at most one production per prefetch and per request ({} > {})",
stats.decodes,
2 * times.len()
);
// Note: `procpool::main_heap_frame_copies` only counts the shm path,
// which the thread pipeline never touches, so asserting it here would
// be vacuous. The in-process frame path does clone `Frame.data` at the
// eval-cache and service-LRU boundaries (M5 narrows this); the bench
// comparison must not claim "no heap copies" for the pipeline.
drop(guard);
let _ = std::fs::remove_file(&path);
}
/// A producer that parks the render thread until `release` is signalled,
/// recording `tag` when it finally runs. Two independent gates let a test
/// keep a frame in flight while it posts more work.
fn parked_producer(
started: Arc<(Mutex, Condvar)>,
release: Arc<(Mutex, Condvar)>,
order: Arc>>,
tag: &'static str,
) -> Producer {
Arc::new(move |_time: Rational, _params: &VideoTicketParams| {
{
let (started, work) = &*started;
*started.lock().unwrap_or_else(|e| e.into_inner()) = true;
work.notify_all();
}
let (released, work) = &*release;
let mut released = released.lock().unwrap_or_else(|e| e.into_inner());
while !*released {
released = work.wait(released).unwrap_or_else(|e| e.into_inner());
}
order.lock().unwrap_or_else(|e| e.into_inner()).push(tag);
Err(Error::State)
})
}
/// A producer that records `tag` when the render thread runs it and fails.
fn recording_producer(order: Arc>>, tag: &'static str) -> Producer {
Arc::new(move |_time: Rational, _params: &VideoTicketParams| {
order.lock().unwrap_or_else(|e| e.into_inner()).push(tag);
Err(Error::State)
})
}
/// A no-footage job (no prefetch side effects) carrying `produce`.
fn scheduled_job(produce: Producer, schedule: JobSchedule) -> Job {
Job {
node_identity: 0,
time: Rational::new(0, 1),
params: Arc::new(base_params(Rational::new(0, 1))),
audio: None,
produce,
done: Box::new(|_result: TicketResult| {}),
schedule,
cancelled: None,
}
}
fn open_gate(gate: &Arc<(Mutex, Condvar)>) {
let (open, work) = &**gate;
*open.lock().unwrap_or_else(|e| e.into_inner()) = true;
work.notify_all();
}
/// M4: priorities are real end to end, not just a `VecDeque` sort. With
/// the render thread parked on an in-flight playback frame and the queue
/// full of background work, a Seek (a) is accepted without blocking its
/// submitter — it over-admits past the bound — and (b) runs before every
/// queued background job once the in-flight frame finishes.
#[test]
fn pipeline_orders_seek_ahead_of_background_end_to_end() {
let _lock = lock();
let backend = PipelineBackend::new().expect("pipeline backend starts");
let order = Arc::new(Mutex::new(Vec::<&'static str>::new()));
let started = Arc::new((Mutex::new(false), Condvar::new()));
let release = Arc::new((Mutex::new(false), Condvar::new()));
let produce = parked_producer(started.clone(), release.clone(), order.clone(), "in-flight");
assert!(
backend.try_post(scheduled_job(produce, JobSchedule::playback(0, 0, 0))),
"the in-flight playback frame is accepted"
);
wait_until("the in-flight playback frame to start", &mut || {
*started.0.lock().unwrap_or_else(|e| e.into_inner())
});
// Fill the bounded queue to capacity with background work.
for _ in 0..RENDER_QUEUE_CAP {
assert!(backend.try_post(scheduled_job(
recording_producer(order.clone(), "background"),
JobSchedule::background(),
)));
}
assert_eq!(backend.queue_depth(), RENDER_QUEUE_CAP);
// The seek must not block on the full queue: it over-admits and sits
// in front of everything queued (the UI thread never stalls here).
assert!(
backend.post(scheduled_job(
recording_producer(order.clone(), "seek"),
JobSchedule::seek(),
)),
"the seek is accepted despite the full queue"
);
assert_eq!(
backend.queue_depth(),
RENDER_QUEUE_CAP + 1,
"the seek over-admits instead of waiting for room"
);
open_gate(&release);
wait_until("every queued job to execute", &mut || {
backend.stats().executed == RENDER_QUEUE_CAP as u64 + 2
});
let mut expected = vec!["in-flight", "seek"];
expected.extend(vec!["background"; RENDER_QUEUE_CAP]);
assert_eq!(
*order.lock().unwrap_or_else(|e| e.into_inner()),
expected,
"the seek preempts the queued background work end to end"
);
backend.shutdown();
}
/// M4: the read-ahead claim is falsifiable here. The in-flight job parks
/// the render thread, so the playback post's prefetch has the decode
/// thread to itself; when the render request then runs it must reuse that
/// decoded frame — a second decode or zero LRU hits fails the test.
#[test]
fn pipeline_prefetch_is_the_frame_the_render_request_uses() {
let _lock = lock();
pin_legacy_working_space();
let path = test_clip("prefetch_hit");
let backend = PipelineBackend::new().expect("pipeline backend starts");
let started = Arc::new((Mutex::new(false), Condvar::new()));
let release = Arc::new((Mutex::new(false), Condvar::new()));
let produce = parked_producer(
started.clone(),
release.clone(),
Arc::new(Mutex::new(Vec::new())),
"parking",
);
assert!(backend.try_post(scheduled_job(produce, JobSchedule::seek())));
wait_until("the parking job to start", &mut || {
*started.0.lock().unwrap_or_else(|e| e.into_inner())
});
let params = Arc::new(VideoTicketParams {
footage: Some((path.to_string_lossy().to_string(), 0)),
..base_params(Rational::new(0, 1))
});
let service = backend.decode_service();
let (done_tx, done_rx) = mpsc::channel();
let produce: Producer = Arc::new(|time: Rational, params: &VideoTicketParams| {
oak_render::eval::render_produced_frame(time, params).map(TicketPayload::Video)
});
let playback = Job {
node_identity: 1,
time: Rational::new(0, 1),
params,
audio: None,
produce,
done: Box::new(move |result: TicketResult| {
let _ = done_tx.send(matches!(result, Ok(TicketPayload::Video(_))));
}),
schedule: JobSchedule::playback(0, 0, 0),
cancelled: None,
};
assert!(backend.post(playback), "the playback frame is accepted");
assert!(
service.wait_idle(),
"the read-ahead decode completes while the render thread is parked"
);
let after_prefetch = service.stats();
assert_eq!(
after_prefetch.prefetches, 1,
"the post queued one read-ahead"
);
assert_eq!(after_prefetch.decodes, 1, "the read-ahead decoded once");
open_gate(&release);
assert!(
done_rx
.recv_timeout(Duration::from_secs(60))
.expect("the playback frame renders"),
"the playback frame produced a video payload"
);
let stats = service.stats();
assert!(
stats.lru_hits >= 1,
"the render request reused the prefetched frame (lru_hits {})",
stats.lru_hits
);
assert_eq!(
stats.decodes, 1,
"the render request must not decode the frame a second time"
);
backend.shutdown();
let _ = std::fs::remove_file(&path);
}
/// M4: cancelling a preview window drops only that window's queued frame.
/// Two viewers can queue the same frame number in the same version; the
/// cancel must match on the sequence identity too.
#[test]
fn pipeline_cancel_preview_frame_matches_the_sequence() {
let _lock = lock();
let backend = PipelineBackend::new().expect("pipeline backend starts");
let started = Arc::new((Mutex::new(false), Condvar::new()));
let release = Arc::new((Mutex::new(false), Condvar::new()));
let produce = parked_producer(
started.clone(),
release.clone(),
Arc::new(Mutex::new(Vec::new())),
"parking",
);
assert!(backend.try_post(scheduled_job(produce, JobSchedule::seek())));
wait_until("the parking job to start", &mut || {
*started.0.lock().unwrap_or_else(|e| e.into_inner())
});
let (tx, rx) = mpsc::channel();
for identity in [1u64, 2] {
let tx = tx.clone();
let produce: Producer = Arc::new(|_time: Rational, _params: &VideoTicketParams| {
Ok(TicketPayload::Video(Texture::dummy()))
});
let job = Job {
node_identity: identity,
time: Rational::new(0, 1),
params: Arc::new(base_params(Rational::new(0, 1))),
audio: None,
produce,
done: Box::new(move |result: TicketResult| {
let _ = tx.send((identity, result.is_ok()));
}),
// Same frame number, same version — only the sequence differs.
schedule: JobSchedule::playback(5, 0, 0),
cancelled: None,
};
assert!(backend.post(job), "viewer {identity}'s frame is queued");
}
backend.cancel_preview_frame(1, 5, 0);
open_gate(&release);
let mut results = Vec::new();
for _ in 0..2 {
results.push(
rx.recv_timeout(Duration::from_secs(60))
.expect("both queued frames complete"),
);
}
results.sort_unstable();
assert_eq!(
results,
vec![(1, false), (2, true)],
"only the cancelled sequence's frame is dropped"
);
backend.shutdown();
}
/// Audit B: a job whose ticket was cancelled after posting must not run
/// its producer. The arena installs `Job.cancelled` from the slot's cancel
/// atom; this hand-built job pins the dispatcher behaviour and the
/// exactly-once completion (`Error::State`).
#[test]
fn pipeline_skips_a_cancelled_job() {
let _lock = lock();
let backend = PipelineBackend::new().expect("pipeline backend starts");
let ran = Arc::new(AtomicBool::new(false));
let flag = Arc::new(AtomicBool::new(true));
let ran_producer = ran.clone();
let produce: Producer = Arc::new(move |_time: Rational, _params: &VideoTicketParams| {
ran_producer.store(true, Ordering::Release);
Ok(TicketPayload::Video(Texture::dummy()))
});
let probe = flag.clone();
let (tx, rx) = mpsc::channel();
let job = Job {
node_identity: 0,
time: Rational::new(0, 1),
params: Arc::new(base_params(Rational::new(0, 1))),
audio: None,
produce,
done: Box::new(move |result: TicketResult| {
let _ = tx.send(result.is_err());
}),
schedule: JobSchedule::seek(),
cancelled: Some(Arc::new(move || probe.load(Ordering::Acquire))),
};
assert!(backend.try_post(job), "the cancelled job is still accepted");
assert!(
rx.recv_timeout(Duration::from_secs(10))
.expect("the completion fires exactly once"),
"a cancelled job completes with Error::State"
);
assert!(
!ran.load(Ordering::Acquire),
"the producer must not run for a cancelled job"
);
backend.shutdown();
}
/// A saturated render queue closes the decode service's prefetch gate: a
/// speculative decode must be refused while a frame is in flight and the
/// queue is full, and everything queued must still run once the in-flight
/// frame completes.
#[test]
fn pipeline_queue_backpressure_closes_the_prefetch_gate() {
let _lock = lock();
assert!(
decode_service().is_none(),
"the decode service slot starts empty"
);
let backend = PipelineBackend::new().expect("pipeline backend starts");
// The in-flight job parks in its producer until released; that is what
// lets this test fill the queue deterministically.
let started = Arc::new((Mutex::new(None::), Condvar::new()));
let release = Arc::new((Mutex::new(false), Condvar::new()));
let job_started = started.clone();
let job_release = release.clone();
let produce: Producer = Arc::new(
move |_time: Rational, _params: &VideoTicketParams| -> TicketResult {
{
let (name, work) = &*job_started;
*name.lock().unwrap_or_else(|e| e.into_inner()) = Some(
std::thread::current()
.name()
.unwrap_or_default()
.to_string(),
);
work.notify_all();
}
let (released, work) = &*job_release;
let mut released = released.lock().unwrap_or_else(|e| e.into_inner());
while !*released {
released = work.wait(released).unwrap_or_else(|e| e.into_inner());
}
Err(Error::State)
},
);
let hold_job = Job {
node_identity: 0,
time: Rational::new(0, 1),
params: Arc::new(montage_params(
Path::new("/definitely/not/here-hold.mp4"),
Rational::new(0, 1),
)),
audio: None,
produce,
done: Box::new(|_result: TicketResult| {}),
schedule: JobSchedule::seek(),
cancelled: None,
};
assert!(backend.try_post(hold_job), "the in-flight job is accepted");
wait_until(
"the render thread to pick up the in-flight job",
&mut || {
started
.0
.lock()
.unwrap_or_else(|e| e.into_inner())
.is_some()
},
);
let thread_name = started.0.lock().unwrap_or_else(|e| e.into_inner()).clone();
assert_eq!(
thread_name.as_deref(),
Some("oak-render"),
"the render thread runs the producer"
);
let mut accepted = 0usize;
while accepted < RENDER_QUEUE_CAP && backend.try_post(filler_job()) {
accepted += 1;
}
assert_eq!(accepted, RENDER_QUEUE_CAP, "the bounded queue fills up");
assert_eq!(backend.queue_depth(), RENDER_QUEUE_CAP);
assert_eq!(backend.queue_free(), 0, "the prefetch gate's depth reads 0");
assert!(
!backend.try_post(filler_job()),
"the queue refuses overflow"
);
let service = backend.decode_service();
let refused = service.prefetch(DecodeRequest {
filename: "/definitely/not/here-prefetch.mp4".to_string(),
stream_index: 0,
time: Rational::new(0, 1),
size: (64, 64),
format: PixelFormat::F32,
allow_import: true,
});
assert!(!refused, "a saturated pipeline refuses prefetch");
let decode = service.stats();
assert_eq!(decode.prefetch_refused, 1, "the refusal is counted");
assert_eq!(decode.prefetches, 0, "nothing was queued");
assert_eq!(decode.decodes, 0, "nothing was decoded");
assert!(
!backend.try_post(filler_job()),
"the refused prefetch made no room"
);
{
let (released, work) = &*release;
*released.lock().unwrap_or_else(|e| e.into_inner()) = true;
work.notify_all();
}
wait_until("every queued job to execute", &mut || {
backend.stats().executed == RENDER_QUEUE_CAP as u64 + 1 && backend.queue_depth() == 0
});
let stats = backend.stats();
assert_eq!(
(stats.posted, stats.drained),
(RENDER_QUEUE_CAP as u64 + 1, 0),
"all posted jobs ran"
);
backend.shutdown();
assert!(!backend.try_post(filler_job()), "shutdown rejects new work");
assert!(
decode_service().is_none(),
"shutdown uninstalls the service"
);
}
/// The backend owns the process-wide decode service slot: it is installed
/// at startup and uninstalled on shutdown.
#[test]
fn pipeline_installs_and_uninstalls_the_decode_service() {
let _lock = lock();
assert!(
decode_service().is_none(),
"the decode service slot starts empty"
);
let backend = PipelineBackend::new().expect("pipeline backend starts");
let installed = decode_service().expect("decode service installed");
assert!(Arc::ptr_eq(&installed, &backend.decode_service()));
backend.shutdown();
assert!(
decode_service().is_none(),
"shutdown uninstalls the decode service"
);
assert!(!backend.try_post(filler_job()), "shutdown rejects new work");
}