diff --git a/crates/oak-render/src/cache.rs b/crates/oak-render/src/cache.rs
index 0c27fb402..c633d7654 100644
--- a/crates/oak-render/src/cache.rs
+++ b/crates/oak-render/src/cache.rs
@@ -880,4 +880,314 @@ mod tests {
assert!(!dir.join(&c.uuid).join("state").exists());
std::fs::remove_dir_all(&dir).ok();
}
+
+ // ---- remaining boundary surface --------------------------------------
+
+ fn work_dir(tag: &str) -> std::path::PathBuf {
+ let dir = std::env::temp_dir().join(format!("oakrender-test-{tag}-{}", next_owner_identity()));
+ std::fs::create_dir_all(&dir).unwrap();
+ dir
+ }
+
+ #[test]
+ fn accessors_and_null_timebase_defaults() {
+ let mut c = PlaybackCache::new(CacheKind::AudioPlayback, 42);
+ assert_eq!(c.uuid().len(), 38);
+ assert_eq!(c.timebase(), Rational::NULL);
+ assert!(c.saving_enabled());
+ assert!(!c.disk_dir().is_empty());
+ assert!(c.requested_ranges().is_empty());
+ assert!(c.passthroughs().is_empty());
+
+ c.set_timebase(Rational::new(1, 25));
+ assert_eq!(c.timebase(), Rational::new(1, 25));
+ c.set_saving_enabled(false);
+ assert!(!c.saving_enabled());
+ c.set_disk_dir("/tmp/oak-cache-test");
+ assert_eq!(c.disk_dir(), "/tmp/oak-cache-test");
+ c.set_uuid("{00000000-0000-4000-8000-000000000000}");
+ assert_eq!(c.uuid(), "{00000000-0000-4000-8000-000000000000}");
+ }
+
+ #[test]
+ fn request_and_clear_ranges() {
+ let mut c = PlaybackCache::new(CacheKind::VideoFrame, 1);
+ c.set_saving_enabled(false);
+ let range = TimeRange::new(Rational::new(1, 1), Rational::new(2, 1));
+ c.request(range);
+ assert_eq!(c.requested_ranges().ranges(), &[range]);
+ c.clear_request_range(range);
+ assert!(c.requested_ranges().is_empty());
+ }
+
+ #[test]
+ fn byte_reader_reads_past_the_end_as_zero() {
+ let data = [0x12u8, 0x34, 0x56, 0x78];
+ let mut r = ByteReader::new(&data);
+ assert_eq!(r.read_u32(), 0x1234_5678);
+ // Past the end: zeroed values, no panic.
+ assert_eq!(r.read_u32(), 0);
+ assert_eq!(r.read_i32(), 0);
+ assert_eq!(r.read_uuid(), [0u8; 16]);
+ let mut out = [0xFFu8; 4];
+ assert_eq!(r.take(&mut out), 0);
+ assert_eq!(out, [0xFFu8; 4]);
+
+ // Partial reads copy only the bytes that exist.
+ let mut r = ByteReader::new(&data);
+ assert_eq!(r.read_be(2), 0x1234);
+ // A partial read is left-aligned and zero-padded on the right.
+ assert_eq!(r.read_be(4), 0x5678_0000);
+ assert_eq!(r.read_be(8), 0);
+ let mut short = [0u8; 2];
+ let mut r = ByteReader::new(&data);
+ assert_eq!(r.take(&mut short), 2);
+ assert_eq!(short, [0x12, 0x34]);
+ }
+
+ #[test]
+ fn uuid_text_conversion_ignores_trailing_nibbles() {
+ let canonical = "{00112233-4455-6677-8899-aabbccddeeff}";
+ let bytes = uuid_text_to_bytes(canonical);
+ assert_eq!(
+ bytes,
+ [
+ 0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc,
+ 0xdd, 0xee, 0xff
+ ]
+ );
+ assert_eq!(bytes_to_uuid_text(&bytes), canonical);
+ // Anything past the 32nd nibble is ignored (QDataStream parity).
+ assert_eq!(uuid_text_to_bytes(&format!("{canonical}ffff")), bytes);
+ }
+
+ #[test]
+ fn modification_time_is_zero_for_missing_paths() {
+ assert_eq!(
+ modification_time_msecs(std::path::Path::new("/definitely/not/here")),
+ 0
+ );
+ }
+
+ #[test]
+ fn set_uuid_reloads_the_disk_state() {
+ let dir = work_dir("uuid-reload");
+ let mut source = tb_cache();
+ source.set_saving_enabled(true);
+ source.set_disk_dir(&dir.to_string_lossy());
+ let uuid = source.uuid.clone();
+ source.validate(TimeRange::new(Rational::new(3, 1), Rational::new(9, 1)));
+ source.save_state(&dir).unwrap();
+
+ let mut target = PlaybackCache::new(CacheKind::VideoFrame, 2);
+ target.set_saving_enabled(false);
+ target.set_disk_dir(&dir.to_string_lossy());
+ target.set_uuid(&uuid);
+ assert_eq!(
+ target.validated_ranges().ranges(),
+ &[TimeRange::new(Rational::new(3, 1), Rational::new(9, 1))]
+ );
+
+ // A uuid without a state file clears the ranges (missing state).
+ target.set_uuid("{00000000-0000-4000-8000-000000000000}");
+ assert!(!target.has_validated_ranges());
+
+ std::fs::remove_dir_all(&dir).ok();
+ }
+
+ #[test]
+ fn load_state_loads_a_clean_cache_and_skips_unchanged_files() {
+ let dir = work_dir("load-skip");
+
+ // One validated range persisted as `
//state` by a
+ // separate producer instance.
+ let mut source = tb_cache();
+ source.set_saving_enabled(true);
+ source.set_disk_dir(&dir.to_string_lossy());
+ let loaded = TimeRange::new(Rational::new(3, 1), Rational::new(9, 1));
+ source.validate(loaded);
+ source.save_state(&dir).unwrap();
+
+ // The consumer starts with genuinely empty in-memory ranges (and a
+ // zero `last_loaded_state`): the state has to come from the file.
+ // `validate` is deliberately not called on this instance — that is
+ // what made the old construction isomorphic. A no-op load or an
+ // inverted mtime guard now leaves the assertions below failing.
+ let mut target = PlaybackCache::new(CacheKind::VideoFrame, 2);
+ target.set_saving_enabled(false);
+ target.set_disk_dir(&dir.to_string_lossy());
+ target.uuid = source.uuid.clone();
+ assert!(
+ target.validated_ranges().is_empty(),
+ "the consumer starts with no ranges"
+ );
+
+ target.load_state(&dir).unwrap();
+ assert_eq!(
+ target.validated_ranges().ranges(),
+ &[loaded],
+ "the state file loads into empty memory"
+ );
+ assert_ne!(
+ target.last_loaded_state, 0,
+ "the load records the state file's mtime"
+ );
+
+ // Drop the memory only, then load the unchanged file again: the
+ // mtime guard must skip it, so the range stays gone. A guard that
+ // was removed would resurrect the range here (and an inverted one
+ // would already have failed the load above).
+ target.validated = TimeRangeList::new();
+ target.load_state(&dir).unwrap();
+ assert!(
+ target.validated_ranges().is_empty(),
+ "an unchanged state file is not reloaded"
+ );
+
+ // Force the reload path (a rewrite within the same millisecond
+ // would make the mtime comparison flaky): the now-larger file is
+ // re-read in full.
+ source.validate(TimeRange::new(Rational::new(10, 1), Rational::new(11, 1)));
+ source.save_state(&dir).unwrap();
+ target.last_loaded_state = 0;
+ target.load_state(&dir).unwrap();
+ assert_eq!(
+ target.validated_ranges().ranges(),
+ source.validated_ranges().ranges(),
+ "a forced load re-reads the whole state"
+ );
+
+ std::fs::remove_dir_all(&dir).ok();
+ }
+
+ #[test]
+ fn save_state_reports_directory_creation_errors() {
+ let dir = work_dir("save-error");
+ let blocker = dir.join("not-a-dir");
+ std::fs::write(&blocker, b"file").unwrap();
+ let mut c = tb_cache();
+ c.set_saving_enabled(false);
+ c.set_disk_dir(&blocker.to_string_lossy());
+ c.validate(TimeRange::new(Rational::new(0, 1), Rational::new(1, 1)));
+ let err = c.save_state(&blocker).unwrap_err();
+ assert!(format!("{err}").contains("create cache dir"), "{err}");
+ std::fs::remove_dir_all(&dir).ok();
+ }
+
+ #[test]
+ fn passthrough_snapshot_links_ranges_without_aliasing() {
+ let mut source = PlaybackCache::new(CacheKind::VideoFrame, 3);
+ source.set_saving_enabled(false);
+ source.set_timebase(Rational::new(1, 24));
+ source.validate(TimeRange::new(Rational::new(2, 1), Rational::new(3, 1)));
+ let snapshot = PassthroughSnapshot {
+ validated: source.validated.clone(),
+ passthroughs: vec![(
+ TimeRange::new(Rational::new(8, 1), Rational::new(9, 1)),
+ source.uuid.clone(),
+ )],
+ timebase: source.timebase,
+ uuid: source.uuid.clone(),
+ };
+
+ let mut target = PlaybackCache::new(CacheKind::VideoFrame, 4);
+ target.set_saving_enabled(false);
+ target.set_passthrough_snapshot(snapshot.clone());
+ assert_eq!(target.timebase(), Rational::new(1, 24));
+ assert_eq!(
+ target.passthroughs().len(),
+ 2,
+ "validated source range plus the explicit entry"
+ );
+ // Passthroughs cover exactly the linked ranges; the gap between them
+ // is still reported as invalidated.
+ let inv = target.invalidated_ranges(TimeRange::new(Rational::new(2, 1), Rational::new(9, 1)));
+ assert_eq!(
+ inv.ranges(),
+ &[TimeRange::new(Rational::new(3, 1), Rational::new(8, 1))]
+ );
+
+ // Audio caches keep their own timebase (frame-hash-only adoption).
+ let mut audio = PlaybackCache::new(CacheKind::AudioPlayback, 5);
+ audio.set_saving_enabled(false);
+ audio.set_passthrough_snapshot(snapshot);
+ assert_eq!(audio.timebase(), Rational::NULL);
+ }
+
+ #[test]
+ fn passthrough_with_saving_enabled_persists_the_state() {
+ let dir = work_dir("passthrough-save");
+ let mut source = PlaybackCache::new(CacheKind::VideoFrame, 6);
+ source.set_saving_enabled(false);
+ source.validate(TimeRange::new(Rational::new(0, 1), Rational::new(2, 1)));
+
+ let mut target = PlaybackCache::new(CacheKind::VideoFrame, 7);
+ target.set_disk_dir(&dir.to_string_lossy());
+ target.set_passthrough(&source);
+ let state = dir.join(&target.uuid).join("state");
+ assert!(state.exists(), "set_passthrough persists when saving is on");
+
+ // Snapshot variant takes the same saving path.
+ let snapshot = PassthroughSnapshot {
+ validated: source.validated.clone(),
+ passthroughs: Vec::new(),
+ timebase: None,
+ uuid: source.uuid.clone(),
+ };
+ target.set_passthrough_snapshot(snapshot);
+ assert!(state.exists());
+ assert_eq!(target.passthroughs().len(), 2);
+
+ std::fs::remove_dir_all(&dir).ok();
+ }
+
+ #[test]
+ fn frame_paths_use_the_timebase_or_whole_seconds() {
+ // Instance path with a timebase: 15s at 1/30 → frame 450.
+ let mut with_tb = tb_cache();
+ with_tb.validate(TimeRange::new(Rational::new(0, 1), Rational::new(16, 1)));
+ let name = with_tb.frame_filename(Rational::new(15, 1)).expect("cached");
+ assert!(name.ends_with("/450"), "{name}");
+
+ let path = PlaybackCache::frame_cache_path(
+ "/cache",
+ "id",
+ Rational::new(15, 1),
+ Rational::new(1, 30),
+ );
+ assert_eq!(
+ path,
+ std::path::Path::new("/cache")
+ .join("id")
+ .join("450")
+ .to_string_lossy()
+ );
+ assert_eq!(
+ PlaybackCache::frame_cache_path(
+ "/cache",
+ "id",
+ Rational::new(1, 10),
+ Rational::new(1, 1)
+ ),
+ std::path::Path::new("/cache")
+ .join("id")
+ .join("0")
+ .to_string_lossy()
+ );
+
+ // No timebase: whole seconds (round-half-away-from-zero).
+ let mut c = PlaybackCache::new(CacheKind::VideoFrame, 8);
+ c.set_saving_enabled(false);
+ c.validate(TimeRange::new(Rational::new(0, 1), Rational::new(4, 1)));
+ let name = c.frame_filename(Rational::new(1, 2)).expect("cached");
+ assert!(name.ends_with("/1"), "{name}");
+ assert!(name.contains(c.uuid()), "{name}");
+ }
+
+ #[test]
+ fn next_owner_identity_is_monotonic() {
+ let a = next_owner_identity();
+ let b = next_owner_identity();
+ assert!(b > a);
+ }
}
diff --git a/crates/oak-render/src/eval.rs b/crates/oak-render/src/eval.rs
index bf2d35a42..c1ad2fa69 100644
--- a/crates/oak-render/src/eval.rs
+++ b/crates/oak-render/src/eval.rs
@@ -3239,6 +3239,18 @@ fn apply_montage_effect(
}
}
+/// The crate-level test lock serializing every test that reads or writes
+/// the process-global pipeline color settings
+/// ([`oak_core::color::set_pipeline_color_settings`]). The `pipeline` decode
+/// tests pin the legacy working space for the whole decoded-pattern section
+/// while the tests below temporarily switch to ACEScg; both modules run in
+/// the same test binary, so one shared lock is required.
+#[cfg(test)]
+pub(crate) fn working_space_test_lock() -> &'static Mutex<()> {
+ static LOCK: Mutex<()> = Mutex::new(());
+ &LOCK
+}
+
#[cfg(test)]
mod tests {
use super::*;
@@ -3307,6 +3319,14 @@ mod tests {
.expect("value is still a job box (unresolved)")
}
+ /// The helper's guard: a table without a texture channel panics
+ /// instead of silently returning a placeholder.
+ #[test]
+ #[should_panic(expected = "no texture in the table")]
+ fn resolved_texture_rejects_a_textureless_table() {
+ resolved_texture(&NodeValueTable::default());
+ }
+
/// A frame-cache job box around `payload`.
fn cache_job_box(payload: CacheJobPayload) -> NodeValue {
NodeValue::Texture(oak_node::handle::make_owned(Job::CacheJob(payload)))
@@ -4729,6 +4749,2421 @@ mod tests {
let _ = std::fs::remove_file(&path);
}
+ // ---- Coverage edges: the eval seam's error and fallback paths --------
+
+ use oak_core::handle::CHandle;
+ use oak_node::project::Project;
+
+ /// A GPU-like context whose readback returns a fixed frame: the
+ /// non-wgpu fallback and the foreign-context readback paths need a
+ /// `Texture::Gpu` without a real device.
+ struct FrameEchoCtx {
+ frame: Frame,
+ }
+
+ impl oak_core::backend::GpuContextLike for FrameEchoCtx {
+ fn kind(&self) -> oak_core::backend::BackendKind {
+ oak_core::backend::BackendKind::Cpu
+ }
+ fn destroy_texture(&self, _token: u64) {}
+ fn upload(&self, _token: u64, _frame: &Frame) -> Result<()> {
+ Ok(())
+ }
+ fn download(&self, _token: u64) -> Result {
+ Ok(self.frame.clone())
+ }
+ fn blit(
+ &self,
+ _src: u64,
+ _dst: u64,
+ _processor: Option<&oak_core::color::ColorProcessor>,
+ ) -> Result<()> {
+ Err(Error::Failed("FrameEchoCtx cannot blit".into()))
+ }
+ }
+
+ /// A `Texture::Gpu` on [`FrameEchoCtx`] reporting `size`.
+ fn echo_gpu_texture(size: (i32, i32), rgba: [f32; 4], token: u64) -> Texture {
+ let frame = filled_frame(size, rgba)
+ .to_frame()
+ .unwrap_or_else(|_| Frame::dummy());
+ Texture::gpu(
+ Arc::new(FrameEchoCtx { frame }),
+ token,
+ size.0,
+ size.1,
+ PixelFormat::F32,
+ )
+ }
+
+ /// An imported planar YUV texture on the non-wgpu stand-in context
+ /// (the real decode path never produces one in these tests).
+ fn planar_texture(size: (i32, i32)) -> Texture {
+ Texture::wrap_planar(oak_core::texture::PlanarTexture::new(
+ Arc::new(UnusedCtx),
+ oak_core::texture::PlanarFormat::Nv12,
+ size,
+ (1, 2),
+ oak_core::backend::YuvTransform::bt709_limited(),
+ (2, 2),
+ ))
+ }
+
+ /// A valid OCIO processor (a 1D LUT doubling red) or `None` (with a
+ /// printed reason) when the bundled config is unavailable.
+ fn red_doubling_processor(tag: &str) -> Option {
+ lut_processor(tag, 2.0)
+ }
+
+ /// A valid OCIO processor for the 1D LUT `0 -> 0`, `1 -> scale`.
+ fn lut_processor(tag: &str, scale: f32) -> Option {
+ if oak_core::color::set_up_default_config().is_err() {
+ eprintln!("{tag}: bundled OCIO missing; skipping");
+ return None;
+ }
+ let path = std::env::temp_dir().join(format!(
+ "oakrender_eval_{tag}_{}.cube",
+ std::process::id()
+ ));
+ std::fs::write(
+ &path,
+ format!("LUT_1D_SIZE 2\n0.0 0.0 0.0\n{scale} 1.0 1.0\n"),
+ )
+ .ok()?;
+ let processor = oak_core::color::ColorProcessor::create_lut(
+ path.to_str()?,
+ oak_core::color::Direction::Normal,
+ )
+ .filter(|p| p.is_valid());
+ let _ = std::fs::remove_file(&path);
+ if processor.is_none() {
+ eprintln!("{tag}: LUT processor unavailable; skipping");
+ }
+ processor
+ }
+
+ #[test]
+ fn hooks_default_disables_cache_and_reports_it() {
+ use oak_node::traverser::RenderHooks;
+ let hooks = RenderEvalHooks::default();
+ assert!(!hooks.use_cache());
+ let mut on = RenderEvalHooks::new();
+ on.use_cache = true;
+ assert!(on.use_cache());
+ assert!(on.ticket.is_none() && on.frame_size.is_none());
+ }
+
+ #[test]
+ fn color_transform_job_rejects_non_texture_and_null_inputs() {
+ let processor = Arc::new(oak_core::color::ColorProcessor::pass_through());
+ let mut hooks = RenderEvalHooks::new();
+
+ let payload = ColorTransformJobPayload {
+ color_processor: processor.clone(),
+ input: NodeValue::Float(1.0),
+ time: Rational::new(0, 1),
+ };
+ assert!(matches!(
+ hooks.process_color_transform_job(&payload),
+ Err(Error::Invalid)
+ ));
+
+ let payload = ColorTransformJobPayload {
+ color_processor: processor,
+ input: NodeValue::Texture(CHandle::null()),
+ time: Rational::new(0, 1),
+ };
+ assert!(matches!(
+ hooks.process_color_transform_job(&payload),
+ Err(Error::Invalid)
+ ));
+ }
+
+ #[test]
+ fn color_transform_job_passes_planar_textures_through() {
+ let Some(processor) = red_doubling_processor("planar") else {
+ return;
+ };
+ let payload = ColorTransformJobPayload {
+ color_processor: Arc::new(processor),
+ input: NodeValue::Texture(oak_node::handle::make_owned(planar_texture((2, 2)))),
+ time: Rational::new(0, 1),
+ };
+ let out = RenderEvalHooks::new()
+ .process_color_transform_job(&payload)
+ .expect("planar pass-through");
+ assert!(out.is_planar());
+ }
+
+ #[test]
+ fn color_transform_job_without_a_wgpu_context_converts_on_cpu() {
+ let Some(processor) = red_doubling_processor("ctxfallback") else {
+ return;
+ };
+ let input = echo_gpu_texture((2, 2), [0.25, 0.25, 0.25, 1.0], 4242);
+ let payload = ColorTransformJobPayload {
+ color_processor: Arc::new(processor),
+ input: NodeValue::Texture(oak_node::handle::make_owned(input)),
+ time: Rational::new(0, 1),
+ };
+ let out = RenderEvalHooks::new()
+ .process_color_transform_job(&payload)
+ .expect("cpu fallback");
+ assert!(matches!(out, Texture::Cpu(_)), "the fallback wraps a CPU frame");
+ let px = first_pixel(&out);
+ assert!((px[0] - 0.5).abs() < 1e-3, "red doubled on the CPU: {px:?}");
+ }
+
+ #[test]
+ fn plugin_job_rejects_a_non_plugin_spec() {
+ let mut hooks = RenderEvalHooks::new();
+ let src = Texture::wrap_frame(
+ generate_frame(Rational::new(0, 1), (2, 2), PixelFormat::F32).unwrap(),
+ );
+ assert!(matches!(
+ hooks.process_plugin_job(src, &JobSpec::Generate),
+ Err(Error::Invalid)
+ ));
+ }
+
+ #[test]
+ fn resolve_value_guards_depth_null_and_in_flight() {
+ let mut hooks = RenderEvalHooks::new();
+ let mut in_flight = HashSet::new();
+
+ // Depth ceiling: even a job box is left untouched.
+ let mut deep = NodeValue::Texture(oak_node::handle::make_owned(Job::FootageJob(
+ FootageJobPayload::default(),
+ )));
+ hooks.resolve_value(&mut deep, 64, &mut in_flight);
+ let NodeValue::Texture(deep_handle) = &deep else {
+ unreachable!()
+ };
+ assert!(unsafe { oak_node::jobs::job_ref(deep_handle) }.is_some());
+ assert!(in_flight.is_empty());
+
+ // A non-texture value passes through untouched.
+ let mut scalar = NodeValue::Float(0.5);
+ hooks.resolve_value(&mut scalar, 0, &mut in_flight);
+ assert!(matches!(scalar, NodeValue::Float(v) if v == 0.5));
+
+ // Empty handle: nothing to resolve.
+ let mut empty = NodeValue::Texture(CHandle::null());
+ hooks.resolve_value(&mut empty, 0, &mut in_flight);
+ assert!(matches!(empty, NodeValue::Texture(_)));
+
+ // A handle already in flight is skipped (the cycle guard).
+ let mut boxed = NodeValue::Texture(oak_node::handle::make_owned(Job::FootageJob(
+ FootageJobPayload::default(),
+ )));
+ let key = match &boxed {
+ NodeValue::Texture(h) => h.ctx as usize,
+ _ => unreachable!(),
+ };
+ in_flight.insert(key);
+ hooks.resolve_value(&mut boxed, 0, &mut in_flight);
+ let NodeValue::Texture(still) = &boxed else {
+ unreachable!()
+ };
+ assert!(unsafe { oak_node::jobs::job_ref(still) }.is_some());
+ assert!(in_flight.contains(&key));
+ }
+
+ #[test]
+ fn footage_job_with_missing_media_keeps_its_box() {
+ let payload = FootageJobPayload {
+ filename: std::env::temp_dir()
+ .join(format!("oakrender_missing_{}.mp4", std::process::id()))
+ .to_string_lossy()
+ .into_owned(),
+ stream_index: 0,
+ time: Rational::new(0, 1),
+ };
+ assert!(RenderEvalHooks::new()
+ .process_footage_job_value(&payload)
+ .is_none());
+ }
+
+ #[test]
+ fn plugin_job_value_splits_clip_inputs_from_scalar_values() {
+ use oak_node::nodes::plugin::{PluginInstanceHandle, PluginJobPayload};
+
+ let _guard = PLUGIN_TEST_LOCK.lock().unwrap();
+ crate::ofxhost::install_client(None);
+ set_plugin_executor(Some(Arc::new(|req: &PluginJobRequest<'_>| {
+ let JobSpec::Plugin {
+ effect_input_id,
+ inputs,
+ values,
+ ..
+ } = req.spec
+ else {
+ return Err(Error::Invalid);
+ };
+ assert!(effect_input_id.is_none(), "empty id maps to None");
+ assert_eq!(inputs.len(), 1, "only the genuine texture box is a clip");
+ assert_eq!(inputs[0].0, "Source");
+ assert!(values.iter().any(|(k, _)| k == "gain"));
+ assert!(values.iter().all(|(k, _)| k != "Nothing"));
+ let mut frame =
+ generate_frame(Rational::new(0, 1), req.src.size(), PixelFormat::F32)?;
+ for pixel in frame.data.as_chunks_mut::<16>().0 {
+ for (c, v) in pixel
+ .as_chunks_mut::<4>()
+ .0
+ .iter_mut()
+ .zip([0.5f32, 0.25, 0.125, 1.0])
+ {
+ c.copy_from_slice(&v.to_le_bytes());
+ }
+ }
+ Ok(Texture::wrap_frame(frame))
+ })));
+
+ let mut values = NodeValueRow::new();
+ values.insert(
+ "Source".into(),
+ texture_value(filled_frame((2, 1), [0.1, 0.2, 0.3, 1.0])),
+ );
+ values.insert("gain".into(), NodeValue::Float(0.25));
+ values.insert("Nothing".into(), NodeValue::None);
+ // A null texture box is skipped by the type guard.
+ values.insert("Null".into(), NodeValue::Texture(CHandle::null()));
+ // A non-texture box in the texture channel logs and is skipped.
+ values.insert(
+ "Boxed".into(),
+ NodeValue::Texture(oak_node::handle::make_owned(Job::FootageJob(
+ FootageJobPayload::default(),
+ ))),
+ );
+ let payload = PluginJobPayload {
+ instance: PluginInstanceHandle(7),
+ type_id: "org.oak.test-plugin".into(),
+ time: Rational::new(1, 2),
+ effect_input_id: String::new(),
+ values,
+ };
+
+ let out = RenderEvalHooks::new()
+ .process_plugin_job_value(&payload, 0, &mut HashSet::new())
+ .expect("plugin value resolves");
+ let NodeValue::Texture(handle) = &out else {
+ panic!("expected a texture value, got {out:?}");
+ };
+ let texture = unsafe { oak_node::handle::get_checked::(handle) }
+ .cloned()
+ .expect("resolved texture");
+ assert_eq!(first_pixel(&texture), [0.5, 0.25, 0.125, 1.0]);
+ set_plugin_executor(None);
+ }
+
+ #[test]
+ fn color_transform_job_value_falls_back_to_its_input() {
+ let payload = ColorTransformJobPayload {
+ color_processor: Arc::new(oak_core::color::ColorProcessor::pass_through()),
+ input: NodeValue::None,
+ time: Rational::new(0, 1),
+ };
+ let out = RenderEvalHooks::new().process_color_transform_job_value(
+ &payload,
+ 0,
+ &mut HashSet::new(),
+ );
+ assert!(matches!(out, NodeValue::None));
+ }
+
+ #[test]
+ fn composite_tracks_rejects_nonpositive_sizes() {
+ assert!(composite_tracks(Vec::new(), (0, 4)).is_dummy());
+ assert!(composite_tracks(Vec::new(), (4, -1)).is_dummy());
+ }
+
+ #[test]
+ fn evaluate_block_frame_handles_missing_and_textureless_roots() {
+ let mut graph = oak_node::graph::Graph::new();
+ let (score, sbehavior) = oak_node::sequence::SequenceBehavior::create();
+ let seq = graph.add_node(score, sbehavior);
+ let mut traverser = oak_node::traverser::Traverser::new();
+ let mut hooks = RenderEvalHooks::new();
+
+ // A stale root surfaces as `Failed` (the NotFound mapping).
+ let missing = evaluate_block_frame(
+ &graph,
+ &mut traverser,
+ &mut hooks,
+ oak_node::id::NodeId::INVALID,
+ Rational::new(0, 1),
+ );
+ assert!(matches!(missing, Err(Error::Failed(_))));
+
+ // A root with no texture channel is `Ok(None)`.
+ let none = evaluate_block_frame(
+ &graph,
+ &mut traverser,
+ &mut hooks,
+ seq,
+ Rational::new(0, 1),
+ );
+ assert!(matches!(none, Ok(None)));
+ }
+
+ #[test]
+ fn blend_transition_without_sides_is_inert() {
+ let graph = oak_node::graph::Graph::new();
+ let mut traverser = oak_node::traverser::Traverser::new();
+ let mut hooks = RenderEvalHooks::new();
+ let out = blend_transition(
+ &graph,
+ &mut traverser,
+ &mut hooks,
+ None,
+ None,
+ Rational::new(0, 1),
+ 0.5,
+ "linear",
+ );
+ assert!(matches!(out, Ok(None)));
+ }
+
+ #[test]
+ fn adjustment_chain_head_needs_an_effect_input() {
+ let mut graph = oak_node::graph::Graph::new();
+ let (core, behavior) = oak_node::track::TrackBehavior::create();
+ let track = graph.add_node(core, behavior);
+ assert!(adjustment_chain_head(&graph, track).is_none());
+ assert!(adjustment_chain_head(&graph, oak_node::id::NodeId::INVALID).is_none());
+ }
+
+ #[test]
+ fn layer_progress_clamps_and_reports_degenerate_spans() {
+ assert_eq!(
+ layer_progress(Rational::new(1, 1), Rational::new(1, 1), Rational::new(1, 1)),
+ 0.0
+ );
+ assert_eq!(
+ layer_progress(Rational::new(2, 1), Rational::new(1, 1), Rational::new(1, 1)),
+ 0.0
+ );
+ assert_eq!(
+ layer_progress(Rational::new(0, 1), Rational::new(2, 1), Rational::new(1, 1)),
+ 0.5
+ );
+ assert_eq!(
+ layer_progress(Rational::new(0, 1), Rational::new(2, 1), Rational::new(9, 1)),
+ 1.0
+ );
+ }
+
+ #[test]
+ fn audio_layout_rejects_degenerate_and_oversized_ranges() {
+ // Null denominator: the duration seconds stay 0.
+ let params = audio_params(TimeRange::new(Rational::NULL, Rational::NULL));
+ assert!(render_audio_samples(¶ms).is_err());
+
+ // Longer than an hour.
+ let params = audio_params(TimeRange::new(Rational::new(0, 1), Rational::new(7200, 1)));
+ assert!(render_audio_samples(¶ms).is_err());
+ }
+
+ #[test]
+ fn mix_audio_montage_skips_clips_outside_the_range() {
+ let mut params = audio_params(TimeRange::new(Rational::new(0, 1), Rational::new(1, 48)));
+ let clip = |in_: Rational, out: Rational| crate::ticket::MontageClip {
+ filename: String::new(),
+ stream_index: 0,
+ in_time: in_,
+ out_time: out,
+ media_in: Rational::new(0, 1),
+ gain: 1.0,
+ effects: Vec::new(),
+ };
+ params.montage = vec![
+ // No overlap at all.
+ clip(Rational::new(2, 1), Rational::new(3, 1)),
+ // Starts at the last sample (start_frame >= total_frames).
+ clip(Rational::new(1999, 96000), Rational::new(1, 48)),
+ // Rounds to zero frames.
+ clip(Rational::new(1, 480000), Rational::new(4, 480000)),
+ ];
+ let mut acc = vec![0.0f32; 1000 * 2];
+ mix_audio_montage(¶ms, 48000, 2, 1000, &mut acc).expect("skips");
+ assert!(acc.iter().all(|&v| v == 0.0), "uncovered range stays silent");
+ }
+
+ #[test]
+ fn scale_rgba_f32_bilinear_scales_and_clamps_alpha() {
+ let src_w = 2i32;
+ let src_h = 2i32;
+ let src_stride = (src_w * 16) as usize;
+ let mut src = vec![0u8; src_stride * src_h as usize];
+ let put = |src: &mut [u8], x: usize, y: usize, rgba: [f32; 4]| {
+ let off = y * src_stride + x * 16;
+ for (i, v) in rgba.iter().enumerate() {
+ src[off + i * 4..off + i * 4 + 4].copy_from_slice(&v.to_le_bytes());
+ }
+ };
+ put(&mut src, 0, 0, [1.0, 0.0, 0.0, 3.0]);
+ put(&mut src, 1, 0, [0.0, 1.0, 0.0, 3.0]);
+ put(&mut src, 0, 1, [0.0, 0.0, 1.0, 3.0]);
+ put(&mut src, 1, 1, [1.0, 1.0, 1.0, 3.0]);
+
+ let dst_w = 4i32;
+ let dst_h = 4i32;
+ let dst_stride = (dst_w * 16) as usize;
+ let mut dst = vec![0u8; dst_stride * dst_h as usize];
+ scale_rgba_f32(
+ src.as_ptr(),
+ src_stride as i32,
+ src_w,
+ src_h,
+ &mut dst,
+ dst_stride as i32,
+ dst_w,
+ dst_h,
+ );
+ let read = |dst: &[u8], x: usize, y: usize| -> [f32; 4] {
+ let off = y * dst_stride + x * 16;
+ let mut out = [0f32; 4];
+ for (i, v) in out.iter_mut().enumerate() {
+ *v = f32::from_le_bytes(dst[off + i * 4..off + i * 4 + 4].try_into().unwrap());
+ }
+ out
+ };
+
+ // The top-left destination texel maps exactly onto the source
+ // corner; alpha 3.0 clamps to 1.0.
+ assert_eq!(read(&dst, 0, 0), [1.0, 0.0, 0.0, 1.0]);
+ // The bottom-right texel lands on the far corner.
+ assert_eq!(read(&dst, 3, 3), [1.0, 1.0, 1.0, 1.0]);
+ // A mid texel interpolates bilinearly (0.25, 0.25 in source space).
+ let mid = read(&dst, 1, 1);
+ for (got, want) in mid.iter().zip([0.625f32, 0.25, 0.25, 1.0]) {
+ assert!((got - want).abs() < 1e-5, "bilinear {mid:?}");
+ }
+
+ // Invalid geometry: nothing is written.
+ let mut untouched = vec![0xAAu8; 16];
+ scale_rgba_f32(
+ src.as_ptr(),
+ src_stride as i32,
+ src_w,
+ src_h,
+ &mut untouched,
+ 16,
+ 0,
+ 1,
+ );
+ assert!(untouched.iter().all(|&b| b == 0xAA));
+ }
+
+ #[test]
+ fn copy_rows_copies_strided_rows_and_rejects_bad_geometry() {
+ let src: Vec = (0..64u16).map(|i| i as u8).collect();
+ let mut dst = vec![0u8; 128];
+ assert!(copy_rows(&mut dst, 64, &src, 32, 2, 2));
+ assert_eq!(&dst[..32], &src[..32]);
+ assert_eq!(&dst[64..96], &src[32..64]);
+ assert!(dst[32..64].iter().all(|&b| b == 0), "gap untouched");
+ assert!(dst[96..].iter().all(|&b| b == 0), "gap untouched");
+
+ assert!(!copy_rows(&mut dst, 64, &src, 32, 0, 2));
+ assert!(!copy_rows(&mut dst, 64, &src, 32, 2, 0));
+ assert!(!copy_rows(&mut dst, 64, &src[..40], 32, 2, 2), "src too small");
+ let mut small = vec![0u8; 64];
+ assert!(!copy_rows(&mut small, 64, &src, 32, 2, 2), "dst too small");
+ }
+
+ /// A one-row F32 RGBA frame as raw bytes.
+ fn f32_frame_bytes(size: (i32, i32), rgba: [f32; 4]) -> Vec {
+ let mut frame = generate_frame(Rational::new(0, 1), size, PixelFormat::F32).unwrap();
+ for px in frame.data.as_chunks_mut::<16>().0 {
+ for (c, v) in px.as_chunks_mut::<4>().0.iter_mut().zip(rgba) {
+ c.copy_from_slice(&v.to_le_bytes());
+ }
+ }
+ frame.data
+ }
+
+ fn read_f32_px(data: &[u8], stride: usize, x: usize, y: usize) -> [f32; 4] {
+ let off = y * stride + x * 16;
+ let mut out = [0f32; 4];
+ for (i, v) in out.iter_mut().enumerate() {
+ *v = f32::from_le_bytes(data[off + i * 4..off + i * 4 + 4].try_into().unwrap());
+ }
+ out
+ }
+
+ fn adjustment_span(
+ in_: i64,
+ out: i64,
+ track_index: usize,
+ effects: Vec,
+ ) -> crate::ticket::AdjustmentSpan {
+ crate::ticket::AdjustmentSpan {
+ in_time: Rational::new(in_, 1),
+ out_time: Rational::new(out, 1),
+ track_index,
+ effects,
+ }
+ }
+
+ fn unknown_effect(type_id: &str) -> crate::ticket::MontageEffect {
+ crate::ticket::MontageEffect {
+ type_id: type_id.to_string(),
+ enabled: true,
+ effect_input_id: Some("Source".to_string()),
+ params: Vec::new(),
+ }
+ }
+
+ #[test]
+ fn adjustment_span_opacity_scales_and_unknown_effects_stage() {
+ let stride = 2 * 16;
+ // Opacity-only: the fast in-place scale.
+ let mut dst = f32_frame_bytes((2, 1), [0.8, 0.4, 0.2, 1.0]);
+ let span = adjustment_span(0, 2, 0, vec![opacity_effect(true, 0.5)]);
+ apply_adjustment_span(&mut dst, stride as i32, 2, 1, &span, Rational::new(0, 1));
+ let px = read_f32_px(&dst, stride, 0, 0);
+ assert!((px[0] - 0.4).abs() < 1e-6, "{px:?}");
+ assert!((px[3] - 0.5).abs() < 1e-6, "{px:?}");
+
+ // Unity opacity is skipped, an unknown effect forces the staged
+ // path and passes the frame through (with the warn-once log).
+ let _guard = PLUGIN_TEST_LOCK.lock().unwrap();
+ crate::ofxhost::install_client(None);
+ set_plugin_instance_factory(Some(Arc::new(|_id: &str| None)));
+ let mut dst = f32_frame_bytes((2, 1), [0.8, 0.4, 0.2, 1.0]);
+ let span = adjustment_span(
+ 0,
+ 2,
+ 0,
+ vec![
+ opacity_effect(true, 1.0),
+ unknown_effect("com.example.eval-unknown"),
+ ],
+ );
+ apply_adjustment_span(&mut dst, stride as i32, 2, 1, &span, Rational::new(0, 1));
+ assert_eq!(read_f32_px(&dst, stride, 0, 0), [0.8, 0.4, 0.2, 1.0]);
+ set_plugin_instance_factory(None);
+
+ // A span that does not cover the time is inert.
+ let mut dst = f32_frame_bytes((2, 1), [0.8, 0.4, 0.2, 1.0]);
+ let span = adjustment_span(5, 6, 0, vec![opacity_effect(true, 0.5)]);
+ apply_adjustment_span(&mut dst, stride as i32, 2, 1, &span, Rational::new(0, 1));
+ assert_eq!(read_f32_px(&dst, stride, 0, 0), [0.8, 0.4, 0.2, 1.0]);
+ }
+
+ #[test]
+ fn montage_frame_into_rejects_bad_geometry_and_skips_uncovered_clips() {
+ let params = crate::ticket::VideoTicketParams {
+ viewer: 1,
+ project: String::new(),
+ time: Rational::new(0, 1),
+ force_size: Some((2, 1)),
+ force_format: Some(PixelFormat::F32),
+ cache: None,
+ cache_dir: None,
+ cache_id: None,
+ cache_timebase: None,
+ footage: None,
+ montage: vec![crate::ticket::MontageClip {
+ filename: String::new(),
+ stream_index: 0,
+ in_time: Rational::new(1, 1),
+ out_time: Rational::new(2, 1),
+ media_in: Rational::new(0, 1),
+ gain: 1.0,
+ effects: Vec::new(),
+ }],
+ adjustments: Vec::new(),
+ };
+
+ // A short destination is rejected before anything is decoded.
+ let mut tiny = [0u8; 8];
+ assert!(render_montage_frame_into(
+ Rational::new(0, 1),
+ ¶ms,
+ (2, 1),
+ &mut tiny,
+ 32
+ )
+ .is_err());
+
+ // A non-positive size is rejected too.
+ let mut dst = vec![0u8; 64];
+ assert!(render_montage_frame_into(
+ Rational::new(0, 1),
+ ¶ms,
+ (0, 1),
+ &mut dst,
+ 32
+ )
+ .is_err());
+
+ // The clip does not cover t=0: the frame stays transparent black.
+ let mut dst = vec![0xFFu8; 2 * 16];
+ render_montage_frame_into(Rational::new(0, 1), ¶ms, (2, 1), &mut dst, 32)
+ .expect("uncovered clip is skipped");
+ assert!(dst.iter().all(|&b| b == 0));
+ }
+
+ #[test]
+ fn resolve_planar_footage_rejects_non_wgpu_contexts() {
+ assert!(resolve_planar_footage(&Texture::dummy()).is_err());
+ assert!(resolve_planar_footage(&planar_texture((2, 2))).is_err());
+ }
+
+ #[test]
+ fn decoded_frame_cache_dedups_breaks_and_prunes_planar() {
+ let key = |n: i32| -> DecodedFrameKey {
+ (format!("frame{n}.mp4"), 0, (n as i64, 1), 2, 2)
+ };
+
+ // A duplicate key is a no-op.
+ let mut cache = std::collections::HashMap::new();
+ insert_cached_frame(&mut cache, key(0), Texture::dummy(), 1);
+ insert_cached_frame(&mut cache, key(0), Texture::dummy(), 2);
+ assert_eq!(cache.len(), 1);
+ // Planar insertions take the planar-pruning path.
+ insert_cached_frame(&mut cache, key(1), planar_texture((2, 2)), 3);
+ assert_eq!(cache.len(), 2);
+
+ // A cache full of tick-0 entries has no victim: the loop breaks.
+ let mut cache = std::collections::HashMap::new();
+ for i in 0..MAX_CACHED_FRAMES {
+ cache.insert(key(i as i32), (Texture::dummy(), 0));
+ }
+ insert_cached_frame(&mut cache, key(100), Texture::dummy(), 7);
+ assert_eq!(cache.len(), MAX_CACHED_FRAMES + 1);
+
+ // Planar entries are pruned to the keep budget, LRU first.
+ let mut cache = std::collections::HashMap::new();
+ for i in 0..(MAX_CACHED_PLANAR_FRAMES + 3) {
+ cache.insert(key(i as i32), (planar_texture((2, 2)), i as u64 + 1));
+ }
+ prune_planar_frames(&mut cache, 2);
+ assert_eq!(
+ cache.values().filter(|(t, _)| t.is_planar()).count(),
+ 2,
+ "planar entries pruned to the budget"
+ );
+ // The oldest planar entries went first.
+ assert!(cache.contains_key(&key(MAX_CACHED_PLANAR_FRAMES as i32 + 2)));
+ // At or under the budget: a no-op.
+ prune_planar_frames(&mut cache, 99);
+ assert_eq!(cache.values().filter(|(t, _)| t.is_planar()).count(), 2);
+ }
+
+ #[test]
+ fn eviction_victim_falls_back_to_software_sessions() {
+ type DecoderMap =
+ std::collections::HashMap<(String, i32), (Arc, u64)>;
+ let mut cache: DecoderMap = std::collections::HashMap::new();
+ cache.insert(
+ ("a.mp4".to_string(), 0),
+ (Arc::new(FFmpegDecoder::new()), 5),
+ );
+ cache.insert(
+ ("b.mp4".to_string(), 0),
+ (Arc::new(FFmpegDecoder::new()), 3),
+ );
+ assert_eq!(
+ eviction_victim(&cache),
+ Some(("b.mp4".to_string(), 0)),
+ "no hardware session: the LRU software one goes"
+ );
+ assert_eq!(eviction_victim(&DecoderMap::new()), None);
+ }
+
+ #[test]
+ fn missing_colorimetry_warning_is_callable_more_than_once() {
+ warn_missing_colorimetry_once();
+ warn_missing_colorimetry_once();
+ }
+
+ #[test]
+ fn opacity_factor_and_channel_scaling_edge_cases() {
+ assert!(
+ opacity_factor(&unknown_effect("com.example.opacity-test")).is_none(),
+ "a non-opacity effect has no factor"
+ );
+ assert!(opacity_factor(&opacity_effect(true, 1.0)).is_none(), "unity is skipped");
+ assert_eq!(opacity_factor(&opacity_effect(true, 0.5)), Some(0.5));
+ assert_eq!(
+ opacity_factor(&opacity_effect(true, 2.0)),
+ Some(2.0),
+ "values above one scale up"
+ );
+ // An Opacity effect without the value input defaults to unity.
+ let mut no_param = opacity_effect(true, 0.5);
+ no_param.params.clear();
+ assert!(opacity_factor(&no_param).is_none());
+
+ let mut bytes = [0u8; 32];
+ scale_channels_in_place(&mut bytes, 0, 2, 1, 2.0);
+ scale_channels_in_place(&mut bytes, 32, 0, 1, 2.0);
+ scale_channels_in_place(&mut bytes, 32, 2, 0, 2.0);
+ assert_eq!(bytes, [0u8; 32], "invalid geometry is inert");
+ }
+
+ #[test]
+ fn montage_opacity_on_non_cpu_textures_warns_and_passes_through() {
+ let effect = opacity_effect(true, 0.5);
+ let out = apply_montage_effect(planar_texture((2, 1)), &effect, Rational::new(0, 1));
+ assert!(out.is_planar(), "a planar texture is returned unchanged");
+ let gpu = Texture::gpu(Arc::new(UnusedCtx), 1, 2, 1, PixelFormat::F32);
+ let out = apply_montage_effect(gpu, &effect, Rational::new(0, 1));
+ assert!(matches!(out, Texture::Gpu { .. }));
+ }
+
+ #[test]
+ fn montage_effect_without_a_resolvable_evaluator_passes_through() {
+ let clip = clip_with_effects(vec![unknown_effect("com.example.no-evaluator")]);
+ let _guard = PLUGIN_TEST_LOCK.lock().unwrap();
+ crate::ofxhost::install_client(None);
+ set_plugin_instance_factory(Some(Arc::new(|_id: &str| None)));
+ let out = apply_clip_effects(
+ solid_texture(0.8, 0.4, 0.2, 1.0),
+ &clip,
+ Rational::new(0, 1),
+ );
+ assert_eq!(first_pixel(&out), [0.8, 0.4, 0.2, 1.0]);
+ set_plugin_instance_factory(None);
+ }
+
+ #[test]
+ fn produced_frame_non_f32_uses_the_cpu_generator() {
+ for format in [PixelFormat::U8, PixelFormat::U16] {
+ let params = crate::ticket::VideoTicketParams {
+ viewer: 1,
+ project: String::new(),
+ time: Rational::new(0, 1),
+ force_size: Some((3, 2)),
+ force_format: Some(format),
+ cache: None,
+ cache_dir: None,
+ cache_id: None,
+ cache_timebase: None,
+ footage: None,
+ montage: Vec::new(),
+ adjustments: Vec::new(),
+ };
+ let tex = render_produced_frame(Rational::new(1, 1), ¶ms).unwrap();
+ assert!(matches!(tex, Texture::Cpu(_)), "{format:?} uses the CPU producer");
+ assert_eq!(tex.size(), (3, 2));
+ assert_eq!(tex.format(), format);
+ }
+ }
+
+ #[test]
+ fn generate_frame_with_an_invalid_format_reports_nomem() {
+ assert!(generate_frame(
+ Rational::new(0, 1),
+ (4, 4),
+ PixelFormat::Invalid
+ )
+ .is_err());
+ }
+
+ #[test]
+ fn convert_decoded_to_working_handles_missing_metadata_and_short_buffers() {
+ let _guard = working_space_test_lock()
+ .lock()
+ .unwrap_or_else(|e| e.into_inner());
+ let previous = oak_core::color::pipeline_working_space();
+ let output = oak_core::color::pipeline_output_spec();
+ oak_core::color::set_pipeline_color_settings(
+ oak_core::colormath::WorkingColorSpace::AcesCg,
+ output,
+ );
+ let mut decoded = oak_codec::frame::Frame::new();
+ decoded.params = None; // no colorimetry metadata
+
+ // The generic sRGB fallback converts in place.
+ let mut dst = generate_frame(Rational::new(0, 1), (2, 2), PixelFormat::F32).unwrap();
+ convert_decoded_to_working(&mut dst, &decoded);
+
+ // Zero-sized destinations return before touching the buffer.
+ let mut zero = Frame::new();
+ convert_decoded_to_working(&mut zero, &decoded);
+
+ // A short buffer breaks out of the row loop.
+ let mut short = generate_frame(Rational::new(0, 1), (2, 2), PixelFormat::F32).unwrap();
+ short.data.truncate(16);
+ convert_decoded_to_working(&mut short, &decoded);
+
+ oak_core::color::set_pipeline_color_settings(previous, output);
+ }
+
+ #[test]
+ fn footage_working_lut_caches_and_clears() {
+ let _guard = working_space_test_lock()
+ .lock()
+ .unwrap_or_else(|e| e.into_inner());
+ let previous = oak_core::color::pipeline_working_space();
+ let output = oak_core::color::pipeline_output_spec();
+ oak_core::color::set_pipeline_color_settings(
+ oak_core::colormath::WorkingColorSpace::AcesCg,
+ output,
+ );
+
+ let first = footage_working_lut(1, 1);
+ assert!(first.is_some());
+ let second = footage_working_lut(1, 1);
+ assert_eq!(
+ first.expect("first LUT").0,
+ second.expect("cached LUT").0,
+ "the second request hits the cache"
+ );
+ // 16+ distinct colorimetries flush the per-process cache.
+ for i in 0..20i32 {
+ let _ = footage_working_lut(10 + i * 3, 20 + i * 7);
+ }
+
+ oak_core::color::set_pipeline_color_settings(previous, output);
+ }
+
+ #[test]
+ fn color_transform_lut_cache_hits_and_clears() {
+ let Some(processor) = red_doubling_processor("lutcache") else {
+ return;
+ };
+ assert!(color_transform_lut(&processor).is_some());
+ assert!(
+ color_transform_lut(&processor).is_some(),
+ "the second request hits the processor's cache entry"
+ );
+
+ // Distinct processors flush the cache once it holds 16 entries.
+ let mut ids = std::collections::HashSet::new();
+ for i in 1..20 {
+ if let Some(p) = lut_processor(&format!("lutflush{i}"), 1.0 + i as f32 * 0.25) {
+ ids.insert(p.cache_id());
+ let _ = color_transform_lut(&p);
+ }
+ }
+ if ids.len() < 2 {
+ eprintln!("OCIO cache ids are not distinct; cache flush not exercised");
+ }
+ }
+
+ /// Serializes the tests that set process-wide environment variables
+ /// (`OAK_PERF`) or the decode-service slot.
+ static ENV_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
+
+ /// Saves `OAK_PERF` and restores its previous value (or absence) on
+ /// drop, so a panicking assertion cannot leak the perf override into
+ /// the next serialized env test. Callers hold [`ENV_TEST_LOCK`].
+ struct PerfEnvGuard(Option);
+
+ impl PerfEnvGuard {
+ fn enable() -> PerfEnvGuard {
+ let previous = std::env::var_os("OAK_PERF");
+ std::env::set_var("OAK_PERF", "1");
+ PerfEnvGuard(previous)
+ }
+ }
+
+ impl Drop for PerfEnvGuard {
+ fn drop(&mut self) {
+ match self.0.take() {
+ Some(value) => std::env::set_var("OAK_PERF", value),
+ None => std::env::remove_var("OAK_PERF"),
+ }
+ }
+ }
+
+ /// Serializes the tests that flip [`GPU_COMPOSITE_FAILED`].
+ static COMPOSITE_FLAG_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
+
+ /// A footage node probed from `path`, wired to a clip block spanning
+ /// `[in_, out)`, on `graph`.
+ fn add_footage_clip(
+ graph: &mut oak_node::graph::Graph,
+ path: &str,
+ in_: Rational,
+ out: Rational,
+ ) -> oak_node::id::NodeId {
+ let mut footage = oak_node::footage::FootageBehavior::new(path);
+ footage.probe().expect("probe the test clip");
+ let footage = graph.add_node(oak_node::node::NodeCore::new(), Box::new(footage));
+ let (ccore, cbehavior) = oak_node::block::clip_create();
+ let clip = graph.add_node(ccore, cbehavior);
+ graph
+ .connect(
+ footage,
+ clip,
+ oak_node::block::clip_input::TEXTURE_INPUT,
+ -1,
+ )
+ .expect("footage -> clip");
+ graph
+ .get_mut(clip)
+ .unwrap()
+ .behavior
+ .as_any_mut()
+ .unwrap()
+ .downcast_mut::()
+ .expect("clip block")
+ .core
+ .range = TimeRange::new(in_, out);
+ clip
+ }
+
+ /// An enabled clip block of `[in_, out)` with nothing connected.
+ fn add_bare_clip(graph: &mut oak_node::graph::Graph, in_: Rational, out: Rational) -> oak_node::id::NodeId {
+ let (ccore, cbehavior) = oak_node::block::clip_create();
+ let clip = graph.add_node(ccore, cbehavior);
+ graph
+ .get_mut(clip)
+ .unwrap()
+ .behavior
+ .as_any_mut()
+ .unwrap()
+ .downcast_mut::()
+ .expect("clip block")
+ .core
+ .range = TimeRange::new(in_, out);
+ clip
+ }
+
+ /// One sequence with three video tracks: V0 (a plain footage clip),
+ /// V1 (two clips joined by a transition covering t=1) and V2 (an
+ /// adjustment block with an opacity chain). Rendering t=1 walks the
+ /// clip, transition and adjustment steps.
+ fn build_three_step_project(path: &str) -> (Arc>, oak_node::id::NodeId) {
+ let project = Project::new();
+ let seq;
+ {
+ let mut p = project.lock().unwrap();
+ let graph = &mut p.graph;
+ let (score, sbehavior) = oak_node::sequence::SequenceBehavior::create();
+ seq = graph.add_node(score, sbehavior);
+ let (tlcore, tlbehavior) = oak_node::track::TrackListBehavior::create();
+ let tl = graph.add_node(tlcore, tlbehavior);
+
+ // V0: one clip covering t=1.
+ let (tcore, tbehavior) = oak_node::track::TrackBehavior::create();
+ let v0 = graph.add_node(tcore, tbehavior);
+ let c0 = add_footage_clip(graph, path, Rational::new(0, 1), Rational::new(2, 1));
+ graph
+ .get_mut(v0)
+ .unwrap()
+ .behavior
+ .as_any_mut()
+ .unwrap()
+ .downcast_mut::()
+ .unwrap()
+ .append_block(c0);
+
+ // V1: two clips joined by a transition covering t=1.
+ let (tcore, tbehavior) = oak_node::track::TrackBehavior::create();
+ let v1 = graph.add_node(tcore, tbehavior);
+ let a = add_footage_clip(graph, path, Rational::new(0, 1), Rational::new(1, 1));
+ let b = add_footage_clip(graph, path, Rational::new(1, 1), Rational::new(2, 1));
+ let (trcore, trbehavior) = oak_node::block::transition_create();
+ let transition = graph.add_node(trcore, trbehavior);
+ {
+ let t = graph
+ .get_mut(transition)
+ .unwrap()
+ .behavior
+ .as_any_mut()
+ .unwrap()
+ .downcast_mut::()
+ .unwrap();
+ t.core.range = TimeRange::new(Rational::new(1, 2), Rational::new(3, 2));
+ t.core.enabled = true;
+ }
+ graph
+ .connect(
+ a,
+ transition,
+ oak_node::block::transition_input::OUT_BLOCK,
+ -1,
+ )
+ .unwrap();
+ graph
+ .connect(
+ b,
+ transition,
+ oak_node::block::transition_input::IN_BLOCK,
+ -1,
+ )
+ .unwrap();
+ {
+ let track = graph
+ .get_mut(v1)
+ .unwrap()
+ .behavior
+ .as_any_mut()
+ .unwrap()
+ .downcast_mut::()
+ .unwrap();
+ track.append_block(a);
+ track.append_block(transition);
+ track.append_block(b);
+ }
+
+ // V2: an adjustment block with an opacity chain on top.
+ let (tcore, tbehavior) = oak_node::track::TrackBehavior::create();
+ let v2 = graph.add_node(tcore, tbehavior);
+ let (acore, abehavior) = oak_node::block::adjustment_create();
+ let adjustment = graph.add_node(acore, abehavior);
+ {
+ let a = graph
+ .get_mut(adjustment)
+ .unwrap()
+ .behavior
+ .as_any_mut()
+ .unwrap()
+ .downcast_mut::()
+ .unwrap();
+ a.core.range = TimeRange::new(Rational::new(0, 1), Rational::new(2, 1));
+ a.core.enabled = true;
+ }
+ let (ecore, ebehavior) = oak_node::nodes::opacity::create();
+ let effect = graph.add_node(ecore, ebehavior);
+ graph
+ .connect(
+ effect,
+ adjustment,
+ oak_node::block::adjustment_input::TEXTURE_INPUT,
+ -1,
+ )
+ .unwrap();
+ graph.get_mut(effect).unwrap().core.set_standard_value(
+ oak_node::nodes::opacity::VALUE_INPUT,
+ -1,
+ NodeValue::Float(0.5),
+ );
+ graph
+ .get_mut(v2)
+ .unwrap()
+ .behavior
+ .as_any_mut()
+ .unwrap()
+ .downcast_mut::()
+ .unwrap()
+ .append_block(adjustment);
+
+ let tl_behavior = graph
+ .get_mut(tl)
+ .unwrap()
+ .behavior
+ .as_any_mut()
+ .unwrap()
+ .downcast_mut::()
+ .unwrap();
+ tl_behavior.tracks.push(v0);
+ tl_behavior.tracks.push(v1);
+ tl_behavior.tracks.push(v2);
+ graph
+ .get_mut(seq)
+ .unwrap()
+ .behavior
+ .as_any_mut()
+ .unwrap()
+ .downcast_mut::()
+ .unwrap()
+ .track_lists
+ .push(tl);
+ }
+ (project, seq)
+ }
+
+ /// A sequence whose track/track-block lists mix stale ids, foreign
+ /// behaviors and degenerate blocks; returns the sequence plus the
+ /// bare and undecodable clips for direct evaluation.
+ fn build_degenerate_project(
+ missing_media: &str,
+ ) -> (
+ Arc>,
+ oak_node::id::NodeId,
+ oak_node::id::NodeId,
+ oak_node::id::NodeId,
+ ) {
+ use oak_node::id::NodeId;
+ let project = Project::new();
+ let empty_clip;
+ let bad_clip;
+ let seq;
+ {
+ let mut p = project.lock().unwrap();
+ let graph = &mut p.graph;
+ let (score, sbehavior) = oak_node::sequence::SequenceBehavior::create();
+ seq = graph.add_node(score, sbehavior);
+ let (tlcore, tlbehavior) = oak_node::track::TrackListBehavior::create();
+ let tl = graph.add_node(tlcore, tlbehavior);
+
+ // Stale and foreign entries in the sequence's track lists.
+ let stale_list = NodeId::from_identity(900_001).unwrap();
+ let (score2, sbehavior2) = oak_node::sequence::SequenceBehavior::create();
+ let not_a_tracklist = graph.add_node(score2, sbehavior2);
+
+ // A real list with stale/foreign entries in its track list.
+ let stale_track = NodeId::from_identity(900_002).unwrap();
+ let (score3, sbehavior3) = oak_node::sequence::SequenceBehavior::create();
+ let not_a_track = graph.add_node(score3, sbehavior3);
+ let (tcore, tbehavior) = oak_node::track::TrackBehavior::create();
+ let track = graph.add_node(tcore, tbehavior);
+
+ // Blocks: a stale id, a transition with no neighbors and a
+ // bare clip.
+ let stale_block = NodeId::from_identity(900_003).unwrap();
+ let (trcore, trbehavior) = oak_node::block::transition_create();
+ let transition = graph.add_node(trcore, trbehavior);
+ {
+ let t = graph
+ .get_mut(transition)
+ .unwrap()
+ .behavior
+ .as_any_mut()
+ .unwrap()
+ .downcast_mut::()
+ .unwrap();
+ t.core.range = TimeRange::new(Rational::new(0, 1), Rational::new(2, 1));
+ t.core.enabled = true;
+ }
+ empty_clip = add_bare_clip(graph, Rational::new(0, 1), Rational::new(2, 1));
+ {
+ let track_behavior = graph
+ .get_mut(track)
+ .unwrap()
+ .behavior
+ .as_any_mut()
+ .unwrap()
+ .downcast_mut::()
+ .unwrap();
+ track_behavior.blocks.push(stale_block);
+ track_behavior.blocks.push(transition);
+ track_behavior.blocks.push(empty_clip);
+ }
+
+ // A second real track whose clip references a missing file:
+ // the unresolved footage-job box is left in the table.
+ let (tcore, tbehavior) = oak_node::track::TrackBehavior::create();
+ let track2 = graph.add_node(tcore, tbehavior);
+ bad_clip = add_footage_clip(
+ graph,
+ missing_media,
+ Rational::new(0, 1),
+ Rational::new(2, 1),
+ );
+ graph
+ .get_mut(track2)
+ .unwrap()
+ .behavior
+ .as_any_mut()
+ .unwrap()
+ .downcast_mut::()
+ .unwrap()
+ .append_block(bad_clip);
+
+ {
+ let tl_behavior = graph
+ .get_mut(tl)
+ .unwrap()
+ .behavior
+ .as_any_mut()
+ .unwrap()
+ .downcast_mut::()
+ .unwrap();
+ tl_behavior.tracks.push(stale_track);
+ tl_behavior.tracks.push(not_a_track);
+ tl_behavior.tracks.push(track);
+ tl_behavior.tracks.push(track2);
+ }
+ graph
+ .get_mut(seq)
+ .unwrap()
+ .behavior
+ .as_any_mut()
+ .unwrap()
+ .downcast_mut::()
+ .unwrap()
+ .track_lists
+ .push(stale_list);
+ graph
+ .get_mut(seq)
+ .unwrap()
+ .behavior
+ .as_any_mut()
+ .unwrap()
+ .downcast_mut::()
+ .unwrap()
+ .track_lists
+ .push(not_a_tracklist);
+ graph
+ .get_mut(seq)
+ .unwrap()
+ .behavior
+ .as_any_mut()
+ .unwrap()
+ .downcast_mut::()
+ .unwrap()
+ .track_lists
+ .push(tl);
+ }
+ (project, seq, empty_clip, bad_clip)
+ }
+
+ #[test]
+ fn render_graph_frame_rejects_bad_format_and_size() {
+ let project = Project::new();
+ let seq;
+ {
+ let mut p = project.lock().unwrap();
+ let (score, sbehavior) = oak_node::sequence::SequenceBehavior::create();
+ seq = p.graph.add_node(score, sbehavior);
+ }
+ assert!(render_graph_frame(
+ &project,
+ seq,
+ Rational::new(0, 1),
+ (16, 16),
+ PixelFormat::U8
+ )
+ .is_err());
+ assert!(render_graph_frame(
+ &project,
+ seq,
+ Rational::new(0, 1),
+ (0, 16),
+ PixelFormat::F32
+ )
+ .is_err());
+ }
+
+ #[test]
+ fn render_graph_frame_skips_stale_and_textureless_steps() {
+ // Probe a real clip, then delete the file: the footage job is built
+ // at evaluation time but its decode fails, so the unresolved box
+ // reaches the texture-channel checks.
+ let path = std::env::temp_dir().join(format!(
+ "oakrender_degenerate_{}.mp4",
+ std::process::id()
+ ));
+ oak_codec::testmedia::write_test_clip(&path, 16, 16, 10, 10).expect("test clip");
+ let name = path.to_string_lossy().into_owned();
+ let (project, seq, empty_clip, bad_clip) = build_degenerate_project(&name);
+ let _ = std::fs::remove_file(&path);
+
+ let out = render_graph_frame(
+ &project,
+ seq,
+ Rational::new(1, 2),
+ (4, 4),
+ PixelFormat::F32,
+ );
+ assert_eq!(out.expect("degenerate render").size(), (4, 4));
+
+ // Directly: a clip with nothing connected yields no texture, an
+ // undecodable footage job leaves its box behind.
+ let mut traverser = oak_node::traverser::Traverser::new();
+ let mut hooks = RenderEvalHooks::new();
+ {
+ let guard = project.lock().unwrap();
+ let empty = evaluate_block_frame(
+ &guard.graph,
+ &mut traverser,
+ &mut hooks,
+ empty_clip,
+ Rational::new(1, 2),
+ );
+ assert!(matches!(empty, Ok(None)), "a bare clip produces nothing");
+ let bad = evaluate_block_frame(
+ &guard.graph,
+ &mut traverser,
+ &mut hooks,
+ bad_clip,
+ Rational::new(1, 2),
+ );
+ assert!(
+ matches!(bad, Ok(None)),
+ "an unresolved footage job is not a texture"
+ );
+ }
+ }
+
+ #[test]
+ fn render_graph_frame_stamps_cpu_frames_when_the_gpu_composite_is_off() {
+ let project = Project::new();
+ let seq;
+ {
+ let mut p = project.lock().unwrap();
+ let (score, sbehavior) = oak_node::sequence::SequenceBehavior::create();
+ seq = p.graph.add_node(score, sbehavior);
+ }
+ let _guard = COMPOSITE_FLAG_LOCK.lock().unwrap();
+ let previous =
+ GPU_COMPOSITE_FAILED.swap(true, std::sync::atomic::Ordering::Relaxed);
+ let out = render_graph_frame(
+ &project,
+ seq,
+ Rational::new(3, 1),
+ (4, 4),
+ PixelFormat::F32,
+ );
+ GPU_COMPOSITE_FAILED.store(previous, std::sync::atomic::Ordering::Relaxed);
+ match out.expect("cpu composite") {
+ Texture::Cpu(frame) => {
+ assert_eq!(frame.timestamp, Rational::new(3, 1));
+ assert_eq!((frame.width, frame.height), (4, 4));
+ }
+ Texture::Gpu { .. } | Texture::Planar(_) => {
+ panic!("the CPU fallback must produce a CPU frame")
+ }
+ }
+ }
+
+ #[test]
+ fn oak_perf_graph_render_logs_every_step() {
+ let _guard = ENV_TEST_LOCK.lock().unwrap();
+ let path = std::env::temp_dir().join(format!(
+ "oakrender_perf_graph_{}.mp4",
+ std::process::id()
+ ));
+ oak_codec::testmedia::write_test_clip(&path, 16, 16, 10, 10).expect("test clip");
+ let (project, seq) = build_three_step_project(&path.to_string_lossy());
+ let _perf = PerfEnvGuard::enable();
+ let out = render_graph_frame(
+ &project,
+ seq,
+ Rational::new(1, 1),
+ (16, 16),
+ PixelFormat::F32,
+ );
+ assert_eq!(out.expect("perf render").size(), (16, 16));
+ let _ = std::fs::remove_file(&path);
+ }
+
+ #[test]
+ fn oak_perf_footage_decode_logs_and_reuses_sessions() {
+ let _guard = ENV_TEST_LOCK.lock().unwrap();
+ let path = std::env::temp_dir().join(format!(
+ "oakrender_perf_decode_{}.mp4",
+ std::process::id()
+ ));
+ oak_codec::testmedia::write_test_clip(&path, 16, 16, 10, 10).expect("test clip");
+ let name = path.to_string_lossy().into_owned();
+ let _perf = PerfEnvGuard::enable();
+ let first = open_decoder(&name, 0);
+ let second = open_decoder(&name, 0);
+ let decoded = render_footage_frame(&name, 0, Rational::new(0, 1), (16, 16), PixelFormat::F32);
+ assert!(first.is_ok(), "the first open logs (request)");
+ assert!(second.is_ok(), "the second open logs CACHED");
+ assert!(decoded.is_ok(), "the decode logs [decode]");
+ let _ = std::fs::remove_file(&path);
+ }
+
+ #[test]
+ fn shut_down_decode_service_falls_back_to_inline_decode() {
+ let _guard = ENV_TEST_LOCK.lock().unwrap();
+ let path = std::env::temp_dir().join(format!(
+ "oakrender_stopped_service_{}.mp4",
+ std::process::id()
+ ));
+ oak_codec::testmedia::write_test_clip(&path, 16, 16, 10, 10).expect("test clip");
+ let name = path.to_string_lossy().into_owned();
+ let service = crate::pipeline::DecodeService::new(1, Arc::new(|| true));
+ service.shutdown();
+ crate::pipeline::install_decode_service(Some(service));
+ let direct = render_footage_frame(&name, 0, Rational::new(0, 1), (16, 16), PixelFormat::F32);
+ let staged =
+ render_footage_frame_staged(&name, 0, Rational::new(0, 1), (16, 16), PixelFormat::F32);
+ crate::pipeline::install_decode_service(None);
+ assert!(direct.is_ok(), "a gone service falls back inline");
+ assert!(staged.is_ok(), "the staged path falls back inline too");
+ let _ = std::fs::remove_file(&path);
+ }
+
+ // ---- Coverage edges: GPU shader jobs and composites -----------------
+
+ /// A payload that asks a registered node for a shader variant nobody
+ /// implements: the job warns and yields nothing.
+ #[test]
+ fn gpu_shader_job_reports_missing_shaders_and_bad_bindings() {
+ if oak_core::backend::shared_gpu_or_skip("an eval GPU test").is_none() {
+ return;
+ }
+
+ let payload = ShaderJobPayload {
+ type_id: "org.olivevideoeditor.Olive.checkerboard".into(),
+ shader_id: "no-such-shader".into(),
+ effect_input: "tex_in".into(),
+ ..ShaderJobPayload::default()
+ };
+ assert!(
+ RenderEvalHooks::new().process_shader_job(&payload).is_none(),
+ "an unknown shader id yields no texture"
+ );
+
+ // merge (no declared effect input): a real base plus a null handle
+ // and an unresolved planar texture. Both bad inputs are skipped,
+ // the pass still runs at the base's size.
+ let mut params = NodeValueRow::new();
+ params.insert(
+ "base_in".into(),
+ texture_value(filled_frame((4, 4), [1.0, 0.0, 0.0, 1.0])),
+ );
+ params.insert("blend_in".into(), NodeValue::Texture(CHandle::null()));
+ params.insert(
+ "extra_in".into(),
+ NodeValue::Texture(oak_node::handle::make_owned(planar_texture((4, 4)))),
+ );
+ let payload = ShaderJobPayload {
+ type_id: "org.olivevideoeditor.Olive.merge".into(),
+ shader_id: String::new(),
+ effect_input: String::new(),
+ params,
+ ..ShaderJobPayload::default()
+ };
+ let out = RenderEvalHooks::new()
+ .process_shader_job(&payload)
+ .expect("the merge runs with the skippable inputs unbound");
+ assert_eq!(out.size(), (4, 4));
+ }
+
+ #[test]
+ fn gpu_shader_job_binds_nested_shader_payloads() {
+ if oak_core::backend::shared_gpu_or_skip("an eval GPU test").is_none() {
+ return;
+ }
+ let nested = Job::ShaderJob(ShaderJobPayload {
+ type_id: "org.olivevideoeditor.Olive.solidgenerator".into(),
+ params: NodeValueRow::from([(
+ "color_in".to_string(),
+ NodeValue::Color([0.0, 1.0, 0.0, 1.0]),
+ )]),
+ ..ShaderJobPayload::default()
+ });
+ let mut params = NodeValueRow::new();
+ params.insert(
+ "base_in".into(),
+ texture_value(filled_frame((4, 4), [1.0, 0.0, 0.0, 1.0])),
+ );
+ params.insert(
+ "blend_in".into(),
+ NodeValue::Texture(oak_node::handle::make_owned(nested)),
+ );
+ let payload = ShaderJobPayload {
+ type_id: "org.olivevideoeditor.Olive.merge".into(),
+ shader_id: String::new(),
+ effect_input: String::new(),
+ params,
+ ..ShaderJobPayload::default()
+ };
+ let out = RenderEvalHooks::new()
+ .process_shader_job(&payload)
+ .expect("the nested generator resolves through the bind");
+ let px = first_pixel(&out);
+ assert!(
+ px[1] > 0.9 && px[0] < 0.1,
+ "the generated green covers the red base: {px:?}"
+ );
+ }
+
+ #[test]
+ fn gpu_grading_job_splices_the_ocio_grading_stub() {
+ if oak_core::backend::shared_gpu_or_skip("an eval GPU test").is_none() {
+ return;
+ }
+ if oak_core::color::set_up_default_config().is_err() {
+ eprintln!("bundled OCIO missing; skipping");
+ return;
+ }
+ if oak_core::color::grading_primary_function_shader(oak_core::color::GradingStyle::Lin)
+ .is_none()
+ {
+ eprintln!("no OCIO grading stub; skipping");
+ return;
+ }
+ let mut params = NodeValueRow::new();
+ params.insert(
+ "tex_in".into(),
+ texture_value(filled_frame((4, 4), [0.5, 0.5, 0.5, 1.0])),
+ );
+ params.insert(
+ "OCIO_NAMESPACE_grading_primary_brightness".into(),
+ NodeValue::Float(1.0),
+ );
+ let payload = ShaderJobPayload {
+ type_id: "org.olivevideoeditor.Olive.ociogradingtransformlinear".into(),
+ shader_id: String::new(),
+ effect_input: "tex_in".into(),
+ params,
+ ..ShaderJobPayload::default()
+ };
+ let out = RenderEvalHooks::new().process_shader_job(&payload);
+ assert!(
+ out.is_some(),
+ "the grading node renders with the spliced OCIO stub"
+ );
+ }
+
+ #[test]
+ fn gpu_composite_reads_foreign_textures_and_rejects_planar() {
+ let Some(ctx) = oak_core::backend::shared_gpu_or_skip("an eval GPU test") else {
+ return;
+ };
+ assert!(composite_tracks_gpu(&ctx, &[], (0, 1)).is_err());
+
+ // A GPU texture from another context is read back and re-uploaded.
+ let foreign = echo_gpu_texture((2, 1), [0.25, 0.5, 0.75, 1.0], 0xDEAD_BEEF);
+ let out = composite_tracks_gpu(&ctx, &[foreign], (2, 1)).expect("foreign readback");
+ let px = first_pixel(&out);
+ assert!(
+ (px[0] - 0.25).abs() < 5e-3
+ && (px[1] - 0.5).abs() < 5e-3
+ && (px[2] - 0.75).abs() < 5e-3,
+ "foreign texture survives the readback: {px:?}"
+ );
+
+ // An unresolved planar frame is a hard error; the accumulator is
+ // torn down on the way out.
+ let planar = planar_texture((2, 1));
+ assert!(composite_tracks_gpu(&ctx, &[planar], (2, 1)).is_err());
+ }
+
+ #[test]
+ fn composite_tracks_cpu_fallback_skips_unreadable_and_mismatched_frames() {
+ let _guard = COMPOSITE_FLAG_LOCK.lock().unwrap();
+ let previous = GPU_COMPOSITE_FAILED.swap(false, std::sync::atomic::Ordering::Relaxed);
+ // The planar frame fails the GPU pass (and flips the sticky flag);
+ // the CPU fallback then skips it, skips a wrongly-sized frame and
+ // composites the valid one.
+ let planar = planar_texture((2, 1));
+ let wrong = filled_frame((3, 1), [0.0, 1.0, 0.0, 1.0]);
+ let right = filled_frame((2, 1), [1.0, 0.0, 0.0, 1.0]);
+ let out = composite_tracks(vec![planar, wrong, right], (2, 1));
+ GPU_COMPOSITE_FAILED.store(previous, std::sync::atomic::Ordering::Relaxed);
+ assert_eq!(out.size(), (2, 1));
+ let px = first_pixel(&out);
+ assert!(
+ (px[0] - 1.0).abs() < 1e-4 && px[1].abs() < 1e-4 && (px[3] - 1.0).abs() < 1e-4,
+ "only the valid frame composites: {px:?}"
+ );
+ }
+
+ // ---- Coverage edges: montage clip effects with GPU results ----------
+
+ fn montage_params_with_effect(
+ path: &str,
+ effect_type_id: &str,
+ ) -> crate::ticket::VideoTicketParams {
+ crate::ticket::VideoTicketParams {
+ viewer: 1,
+ project: String::new(),
+ time: Rational::new(0, 1),
+ force_size: Some((16, 16)),
+ force_format: Some(PixelFormat::F32),
+ cache: None,
+ cache_dir: None,
+ cache_id: None,
+ cache_timebase: None,
+ footage: None,
+ montage: vec![crate::ticket::MontageClip {
+ filename: path.to_string(),
+ stream_index: 0,
+ in_time: Rational::new(0, 1),
+ out_time: Rational::new(2, 1),
+ media_in: Rational::new(0, 1),
+ gain: 1.0,
+ effects: vec![unknown_effect(effect_type_id)],
+ }],
+ adjustments: Vec::new(),
+ }
+ }
+
+ #[test]
+ fn montage_reads_back_gpu_textures_from_clip_effects() {
+ let _env = ENV_TEST_LOCK.lock().unwrap();
+ let _guard = PLUGIN_TEST_LOCK.lock().unwrap();
+ let path = std::env::temp_dir().join(format!(
+ "oakrender_montage_gpu_{}.mp4",
+ std::process::id()
+ ));
+ oak_codec::testmedia::write_test_clip(&path, 16, 16, 10, 10).expect("test clip");
+ crate::ofxhost::install_client(None);
+ set_plugin_instance_factory(Some(Arc::new(|_id: &str| Some(7))));
+ set_plugin_executor(Some(Arc::new(|req: &PluginJobRequest<'_>| {
+ let frame = filled_frame(req.src.size(), [0.5, 0.5, 0.5, 1.0]).to_frame()?;
+ Ok(Texture::gpu(
+ Arc::new(FrameEchoCtx { frame }),
+ 0xE0,
+ req.src.size().0,
+ req.src.size().1,
+ PixelFormat::F32,
+ ))
+ })));
+ let params = montage_params_with_effect(&path.to_string_lossy(), "com.example.gpu-effect");
+ let mut dst = vec![0u8; 16 * 16 * 16];
+ render_montage_frame_into(Rational::new(0, 1), ¶ms, (16, 16), &mut dst, 256)
+ .expect("the GPU effect result is read back and composited");
+ let px = read_f32_px(&dst, 256, 1, 1);
+ assert!((px[0] - 0.5).abs() < 1e-4, "gpu effect pixels land: {px:?}");
+ set_plugin_executor(None);
+ set_plugin_instance_factory(None);
+ let _ = std::fs::remove_file(&path);
+ }
+
+ #[test]
+ fn montage_rejects_planar_textures_from_clip_effects() {
+ let _env = ENV_TEST_LOCK.lock().unwrap();
+ let _guard = PLUGIN_TEST_LOCK.lock().unwrap();
+ let path = std::env::temp_dir().join(format!(
+ "oakrender_montage_planar_{}.mp4",
+ std::process::id()
+ ));
+ oak_codec::testmedia::write_test_clip(&path, 16, 16, 10, 10).expect("test clip");
+ crate::ofxhost::install_client(None);
+ set_plugin_instance_factory(Some(Arc::new(|_id: &str| Some(7))));
+ set_plugin_executor(Some(Arc::new(|req: &PluginJobRequest<'_>| {
+ Ok(planar_texture(req.src.size()))
+ })));
+ let params =
+ montage_params_with_effect(&path.to_string_lossy(), "com.example.planar-effect");
+ let mut dst = vec![0u8; 16 * 16 * 16];
+ let err = render_montage_frame_into(Rational::new(0, 1), ¶ms, (16, 16), &mut dst, 256);
+ set_plugin_executor(None);
+ set_plugin_instance_factory(None);
+ let _ = std::fs::remove_file(&path);
+ assert!(err.is_err(), "an unresolved planar texture is rejected");
+ }
+
+ /// The single OFX host client owns dispatch when one is installed:
+ /// the montage effect path takes the host branch, and a host that
+ /// cannot come up yields the purple failure frame.
+ #[test]
+ #[cfg(unix)]
+ fn montage_effect_uses_the_installed_ofx_host() {
+ let _guard = PLUGIN_TEST_LOCK.lock().unwrap();
+ let host = crate::ofxhost::OfxHost::new(crate::ofxhost::OfxHostConfig {
+ host_bin: Some(std::path::PathBuf::from("/bin/false")),
+ max_failures: 1,
+ ..Default::default()
+ })
+ .unwrap();
+ crate::ofxhost::install_client(Some(host));
+ let out = apply_montage_effect(
+ solid_texture(0.8, 0.4, 0.2, 1.0),
+ &unknown_effect("com.example.host-effect"),
+ Rational::new(0, 1),
+ );
+ crate::ofxhost::install_client(None);
+ assert_eq!(
+ first_pixel(&out),
+ [1.0, 0.0, 1.0, 1.0],
+ "a failed host render paints the purple frame"
+ );
+ }
+
+ /// A test-only node behavior that emits a null texture handle, so the
+ /// graph seams' null-handle guards are reachable without a producer
+ /// bug.
+ struct NullTextureBehavior;
+
+ impl oak_node::node::NodeBehavior for NullTextureBehavior {
+ fn name(&self) -> &str {
+ "NullTexture"
+ }
+ fn type_id(&self) -> &str {
+ "org.oak.test.nulltexture"
+ }
+ fn duplicate(
+ &self,
+ _core: &oak_node::node::NodeCore,
+ ) -> Option> {
+ Some(Box::new(Self))
+ }
+ fn value(
+ &self,
+ _core: &oak_node::node::NodeCore,
+ _inputs: &NodeValueRow,
+ _time: Rational,
+ table: &mut NodeValueTable,
+ ) {
+ table.push(
+ oak_node::value::ValueType::Texture,
+ NodeValue::Texture(CHandle::null()),
+ None,
+ );
+ }
+ }
+
+ #[test]
+ fn null_texture_outputs_are_skipped_by_the_graph_paths() {
+ let project = Project::new();
+ let seq;
+ let clip;
+ {
+ let mut p = project.lock().unwrap();
+ let graph = &mut p.graph;
+ let (score, sbehavior) = oak_node::sequence::SequenceBehavior::create();
+ seq = graph.add_node(score, sbehavior);
+ let (tlcore, tlbehavior) = oak_node::track::TrackListBehavior::create();
+ let tl = graph.add_node(tlcore, tlbehavior);
+ let (tcore, tbehavior) = oak_node::track::TrackBehavior::create();
+ let track = graph.add_node(tcore, tbehavior);
+
+ let null_node = graph.add_node(
+ oak_node::node::NodeCore::new(),
+ Box::new(NullTextureBehavior),
+ );
+ clip = add_bare_clip(graph, Rational::new(0, 1), Rational::new(1, 1));
+ graph
+ .connect(
+ null_node,
+ clip,
+ oak_node::block::clip_input::TEXTURE_INPUT,
+ -1,
+ )
+ .expect("null node -> clip");
+ graph
+ .get_mut(track)
+ .unwrap()
+ .behavior
+ .as_any_mut()
+ .unwrap()
+ .downcast_mut::()
+ .unwrap()
+ .append_block(clip);
+ graph
+ .get_mut(tl)
+ .unwrap()
+ .behavior
+ .as_any_mut()
+ .unwrap()
+ .downcast_mut::()
+ .unwrap()
+ .tracks
+ .push(track);
+ graph
+ .get_mut(seq)
+ .unwrap()
+ .behavior
+ .as_any_mut()
+ .unwrap()
+ .downcast_mut::()
+ .unwrap()
+ .track_lists
+ .push(tl);
+ }
+
+ // Direct: a null handle is not a texture (`Ok(None)`).
+ {
+ let guard = project.lock().unwrap();
+ let mut traverser = oak_node::traverser::Traverser::new();
+ let mut hooks = RenderEvalHooks::new();
+ let out = evaluate_block_frame(
+ &guard.graph,
+ &mut traverser,
+ &mut hooks,
+ clip,
+ Rational::new(1, 2),
+ );
+ assert!(matches!(out, Ok(None)));
+ }
+
+ // Render: the clip's null texture is skipped and the frame stays
+ // transparent black.
+ let frame = render_graph_frame(
+ &project,
+ seq,
+ Rational::new(1, 2),
+ (4, 4),
+ PixelFormat::F32,
+ )
+ .expect("null texture render");
+ assert_eq!(frame.size(), (4, 4));
+ let readback = frame.to_frame().expect("readback");
+ assert!(readback.data.iter().all(|&b| b == 0));
+ }
+
+ // ---- Coverage edges: shader bind failures and adjustment sweeps ----
+
+ /// A behavior with a synthetic texture output, so the transition and
+ /// sweep seams can be driven without a real producer. `value` is
+ /// pushed on the texture channel (nothing when `None`).
+ struct FixedTextureBehavior {
+ value: Option,
+ }
+
+ impl oak_node::node::NodeBehavior for FixedTextureBehavior {
+ fn name(&self) -> &str {
+ "FixedTexture"
+ }
+ fn type_id(&self) -> &str {
+ "org.oak.test.fixedtexture"
+ }
+ fn duplicate(
+ &self,
+ _core: &oak_node::node::NodeCore,
+ ) -> Option> {
+ Some(Box::new(Self {
+ value: self.value.clone(),
+ }))
+ }
+ fn value(
+ &self,
+ _core: &oak_node::node::NodeCore,
+ _inputs: &NodeValueRow,
+ _time: Rational,
+ table: &mut NodeValueTable,
+ ) {
+ if let Some(value) = &self.value {
+ table.push(oak_node::value::ValueType::Texture, value.clone(), None);
+ }
+ }
+ }
+
+ /// A behavior whose fragment source cannot compile (the shader-job
+ /// path must warn and report no texture).
+ struct BadShaderBehavior;
+
+ impl oak_node::node::NodeBehavior for BadShaderBehavior {
+ fn name(&self) -> &str {
+ "BadShader"
+ }
+ fn type_id(&self) -> &str {
+ "org.oak.test.badshader"
+ }
+ fn duplicate(
+ &self,
+ _core: &oak_node::node::NodeCore,
+ ) -> Option> {
+ Some(Box::new(Self))
+ }
+ fn shader_code(&self, _request: &str) -> Option {
+ Some("%%% this is not valid glsl %%%".to_string())
+ }
+ }
+
+ /// A core with an effect input `tex_in`, optionally not connectable
+ /// (the sweep's refused-connection error path).
+ fn effect_head_core(connectable: bool) -> oak_node::node::NodeCore {
+ let mut core = oak_node::node::NodeCore::new();
+ let mut input = oak_node::input::Input::new(
+ "tex_in",
+ oak_node::value::ValueType::Texture,
+ NodeValue::None,
+ );
+ if !connectable {
+ input.flags |= oak_node::input::flags::NOT_CONNECTABLE;
+ }
+ core.add_input(input);
+ core.effect_input = "tex_in".to_string();
+ core
+ }
+
+ /// The nested-payload recursion ceiling in `bind`: a job box at the
+ /// depth limit is left unbound instead of recursing, and the pass
+ /// still runs against the placeholders.
+ #[test]
+ fn gpu_shader_job_depth_ceiling_leaves_the_nested_box_unbound() {
+ if oak_core::backend::shared_gpu_or_skip("an eval GPU test").is_none() {
+ return;
+ }
+ let nested = Job::ShaderJob(ShaderJobPayload {
+ type_id: "org.olivevideoeditor.Olive.solidgenerator".into(),
+ params: NodeValueRow::from([(
+ "color_in".to_string(),
+ NodeValue::Color([0.0, 1.0, 0.0, 1.0]),
+ )]),
+ ..ShaderJobPayload::default()
+ });
+ let mut params = NodeValueRow::new();
+ params.insert(
+ "blend_in".into(),
+ NodeValue::Texture(oak_node::handle::make_owned(nested)),
+ );
+ let payload = ShaderJobPayload {
+ type_id: "org.olivevideoeditor.Olive.merge".into(),
+ shader_id: String::new(),
+ effect_input: String::new(),
+ params,
+ ..ShaderJobPayload::default()
+ };
+ let out = RenderEvalHooks::new()
+ .process_shader_job_depth(&payload, 8)
+ .expect("the pass runs with the nested box unbound");
+ assert_eq!(out.size(), (1, 1), "no input bound: the 1x1 fallback");
+ }
+
+ /// Inputs whose scratch texture cannot be created (a 0x0 frame) or
+ /// uploaded (a frame with a short buffer) are skipped; the pass runs
+ /// against the placeholders.
+ #[test]
+ fn gpu_shader_job_skips_uncreatable_and_unuploadable_inputs() {
+ if oak_core::backend::shared_gpu_or_skip("an eval GPU test").is_none() {
+ return;
+ }
+ // A 0x0 CPU texture: `create_texture` rejects the geometry.
+ let zero = Texture::dummy();
+ // A 2x2 frame with no payload: `upload` rejects the short buffer.
+ let mut short = generate_frame(Rational::new(0, 1), (2, 2), PixelFormat::F32).unwrap();
+ short.data.clear();
+ let mut params = NodeValueRow::new();
+ params.insert("base_in".into(), texture_value(zero));
+ params.insert("blend_in".into(), texture_value(Texture::wrap_frame(short)));
+ let payload = ShaderJobPayload {
+ type_id: "org.olivevideoeditor.Olive.merge".into(),
+ shader_id: String::new(),
+ effect_input: String::new(),
+ params,
+ ..ShaderJobPayload::default()
+ };
+ let out = RenderEvalHooks::new()
+ .process_shader_job(&payload)
+ .expect("the pass runs with both bad inputs skipped");
+ assert_eq!(out.size(), (1, 1));
+ }
+
+ /// A bound token the context does not own fails the pass at run time
+ /// (and the reserved output texture is released).
+ #[test]
+ fn gpu_shader_job_run_failure_reports_none() {
+ let Some(ctx) = oak_core::backend::shared_gpu_or_skip("an eval GPU test") else {
+ return;
+ };
+ let bogus = Texture::gpu(ctx, 0xDEAD_BEEF, 4, 4, PixelFormat::F32);
+ let mut params = NodeValueRow::new();
+ params.insert("base_in".into(), texture_value(bogus));
+ let payload = ShaderJobPayload {
+ type_id: "org.olivevideoeditor.Olive.merge".into(),
+ shader_id: String::new(),
+ effect_input: String::new(),
+ params,
+ ..ShaderJobPayload::default()
+ };
+ assert!(
+ RenderEvalHooks::new().process_shader_job(&payload).is_none(),
+ "a foreign token fails the bind group and yields no texture"
+ );
+ }
+
+ /// A registered node whose shader source does not translate to WGSL
+ /// reports a compile failure instead of running.
+ #[test]
+ fn gpu_shader_job_reports_compile_failures() {
+ if oak_core::backend::shared_gpu_or_skip("an eval GPU test").is_none() {
+ return;
+ }
+ let _ = oak_node::factory::Factory::global().register_dynamic(
+ oak_node::factory::DynamicNodeMeta {
+ type_id: "org.oak.test.badshader".into(),
+ name: "BadShader".into(),
+ categories: Vec::new(),
+ sub_category: String::new(),
+ description: String::new(),
+ create: Arc::new(|| {
+ (
+ oak_node::node::NodeCore::new(),
+ Box::new(BadShaderBehavior),
+ )
+ }),
+ },
+ );
+ let payload = ShaderJobPayload {
+ type_id: "org.oak.test.badshader".into(),
+ shader_id: String::new(),
+ ..ShaderJobPayload::default()
+ };
+ assert!(
+ RenderEvalHooks::new().process_shader_job(&payload).is_none(),
+ "an untranslatable shader compiles to nothing"
+ );
+ }
+
+ /// A color transform on a GPU texture whose token cannot be read back
+ /// surfaces the readback error (the LUT pass refused it first).
+ #[test]
+ fn color_transform_job_reports_a_failed_gpu_readback() {
+ if oak_core::backend::shared_gpu_or_skip("an eval GPU test").is_none() {
+ return;
+ }
+ let Some(processor) = red_doubling_processor("badtoken") else {
+ return;
+ };
+ let Some(ctx) = oak_core::backend::GpuContext::shared() else {
+ return;
+ };
+ let input = Texture::gpu(ctx, 0x0BAD_7001, 2, 2, PixelFormat::F32);
+ let payload = ColorTransformJobPayload {
+ color_processor: Arc::new(processor),
+ input: texture_value(input),
+ time: Rational::new(0, 1),
+ };
+ assert!(
+ RenderEvalHooks::new()
+ .process_color_transform_job(&payload)
+ .is_err(),
+ "the invalid token cannot be converted"
+ );
+ }
+
+ /// An imported planar texture whose plane tokens the context does not
+ /// own fails the YUV pass and reports the error (the caller then
+ /// stages through the CPU decoder).
+ #[test]
+ fn resolve_planar_footage_reports_a_failed_yuv_pass() {
+ let Some(ctx) = oak_core::backend::shared_gpu_or_skip("an eval GPU test") else {
+ return;
+ };
+ let planar = Texture::wrap_planar(oak_core::texture::PlanarTexture::new(
+ ctx,
+ oak_core::texture::PlanarFormat::Nv12,
+ (4, 4),
+ (0x0BAD_A001, 0x0BAD_A002),
+ oak_core::backend::YuvTransform::bt709_limited(),
+ (2, 2),
+ ));
+ assert!(
+ resolve_planar_footage(&planar).is_err(),
+ "missing plane tokens fail the planar pass"
+ );
+ }
+
+ /// A single-sided transition pads the missing side with transparent
+ /// black and blends (the head/tail fade); a 0x0 side cannot be padded
+ /// and falls through to the native side without a blend job.
+ #[test]
+ fn blend_transition_fills_a_missing_side_and_skips_the_blend_without_a_pair() {
+ let mut graph = oak_node::graph::Graph::new();
+ let mut traverser = oak_node::traverser::Traverser::new();
+ let mut hooks = RenderEvalHooks::new();
+
+ let from = graph.add_node(
+ oak_node::node::NodeCore::new(),
+ Box::new(FixedTextureBehavior {
+ value: Some(texture_value(filled_frame((4, 4), [1.0, 0.0, 0.0, 1.0]))),
+ }),
+ );
+ let out = blend_transition(
+ &graph,
+ &mut traverser,
+ &mut hooks,
+ Some(from),
+ None,
+ Rational::new(0, 1),
+ 0.25,
+ "crossdissolve",
+ )
+ .expect("single-sided blend");
+ let blended = out.expect("the missing side is filled with black");
+ assert_eq!(blended.size(), (4, 4));
+
+ // A 0x0 side: black() cannot create the fill, so the pair match
+ // falls through to the native side.
+ let dummy = graph.add_node(
+ oak_node::node::NodeCore::new(),
+ Box::new(FixedTextureBehavior {
+ value: Some(texture_value(Texture::dummy())),
+ }),
+ );
+ let out = blend_transition(
+ &graph,
+ &mut traverser,
+ &mut hooks,
+ Some(dummy),
+ None,
+ Rational::new(0, 1),
+ 0.25,
+ "crossdissolve",
+ )
+ .expect("degenerate single-sided blend");
+ let native = out.expect("the native side is returned");
+ assert_eq!(native.size(), (0, 0));
+ }
+
+ /// The sweep's head table can carry no texture, a null handle, or an
+ /// unboxable job handle: all three leave the boundary unchanged.
+ #[test]
+ fn flush_adjustment_layer_handles_textureless_heads() {
+ let cases: [(&str, Option); 3] = [
+ ("none", None),
+ ("null", Some(NodeValue::Texture(CHandle::null()))),
+ (
+ "job",
+ Some(NodeValue::Texture(oak_node::handle::make_owned(
+ Job::FootageJob(FootageJobPayload::default()),
+ ))),
+ ),
+ ];
+ for (tag, value) in cases {
+ let mut graph = oak_node::graph::Graph::new();
+ let (acore, abehavior) = oak_node::block::adjustment_create();
+ let block = graph.add_node(acore, abehavior);
+ let head = graph.add_node(
+ effect_head_core(true),
+ Box::new(FixedTextureBehavior { value }),
+ );
+ graph
+ .connect(
+ head,
+ block,
+ oak_node::block::adjustment_input::TEXTURE_INPUT,
+ -1,
+ )
+ .expect("head -> block");
+ let mut traverser = oak_node::traverser::Traverser::new();
+ let mut hooks = RenderEvalHooks::new();
+ let sweep = flush_adjustment_layer(
+ &mut graph,
+ &mut traverser,
+ &mut hooks,
+ block,
+ &[],
+ (4, 4),
+ Rational::new(0, 1),
+ 0.5,
+ )
+ .expect(tag);
+ assert!(sweep.is_none(), "{tag}: the boundary is unchanged");
+ }
+ }
+
+ /// A chain head whose effect input is not connectable cannot be fed:
+ /// the sweep reports the refused connection. The graph driver maps the
+ /// same error out of `render_graph_frame`.
+ #[test]
+ fn flush_adjustment_layer_reports_a_refused_connection() {
+ let mut graph = oak_node::graph::Graph::new();
+ let (acore, abehavior) = oak_node::block::adjustment_create();
+ let block = graph.add_node(acore, abehavior);
+ let head = graph.add_node(
+ effect_head_core(false),
+ Box::new(FixedTextureBehavior {
+ value: Some(texture_value(filled_frame((2, 2), [0.0, 0.0, 0.0, 1.0]))),
+ }),
+ );
+ graph
+ .connect(
+ head,
+ block,
+ oak_node::block::adjustment_input::TEXTURE_INPUT,
+ -1,
+ )
+ .expect("head -> block");
+ let mut traverser = oak_node::traverser::Traverser::new();
+ let mut hooks = RenderEvalHooks::new();
+ let err = flush_adjustment_layer(
+ &mut graph,
+ &mut traverser,
+ &mut hooks,
+ block,
+ &[],
+ (4, 4),
+ Rational::new(0, 1),
+ 0.5,
+ )
+ .unwrap_err();
+ assert!(
+ err.to_string().contains("cannot feed"),
+ "the error names the refused feed: {err}"
+ );
+ }
+
+ /// `render_graph_frame` propagates an adjustment sweep failure instead
+ /// of dropping the frame.
+ #[test]
+ fn render_graph_frame_reports_a_refused_adjustment_feed() {
+ let project = Project::new();
+ let seq;
+ {
+ let mut p = project.lock().unwrap();
+ let graph = &mut p.graph;
+ let (score, sbehavior) = oak_node::sequence::SequenceBehavior::create();
+ seq = graph.add_node(score, sbehavior);
+ let (tlcore, tlbehavior) = oak_node::track::TrackListBehavior::create();
+ let tl = graph.add_node(tlcore, tlbehavior);
+ let (tcore, tbehavior) = oak_node::track::TrackBehavior::create();
+ let track = graph.add_node(tcore, tbehavior);
+
+ let (acore, abehavior) = oak_node::block::adjustment_create();
+ let block = graph.add_node(acore, abehavior);
+ {
+ let a = graph
+ .get_mut(block)
+ .unwrap()
+ .behavior
+ .as_any_mut()
+ .unwrap()
+ .downcast_mut::()
+ .unwrap();
+ a.core.range = TimeRange::new(Rational::new(0, 1), Rational::new(2, 1));
+ a.core.enabled = true;
+ }
+ let head = graph.add_node(
+ effect_head_core(false),
+ Box::new(FixedTextureBehavior {
+ value: Some(texture_value(filled_frame((4, 4), [0.0, 0.0, 0.0, 1.0]))),
+ }),
+ );
+ graph
+ .connect(
+ head,
+ block,
+ oak_node::block::adjustment_input::TEXTURE_INPUT,
+ -1,
+ )
+ .expect("head -> block");
+ graph
+ .get_mut(track)
+ .unwrap()
+ .behavior
+ .as_any_mut()
+ .unwrap()
+ .downcast_mut::()
+ .unwrap()
+ .append_block(block);
+ graph
+ .get_mut(tl)
+ .unwrap()
+ .behavior
+ .as_any_mut()
+ .unwrap()
+ .downcast_mut::()
+ .unwrap()
+ .tracks
+ .push(track);
+ graph
+ .get_mut(seq)
+ .unwrap()
+ .behavior
+ .as_any_mut()
+ .unwrap()
+ .downcast_mut::()
+ .unwrap()
+ .track_lists
+ .push(tl);
+ }
+ let err = render_graph_frame(
+ &project,
+ seq,
+ Rational::new(0, 1),
+ (4, 4),
+ PixelFormat::F32,
+ )
+ .unwrap_err();
+ assert!(
+ err.to_string().contains("cannot feed"),
+ "the sweep error reaches the caller: {err}"
+ );
+ }
+
+ /// A montage clip whose media cannot be opened propagates the decode
+ /// error rather than compositing a hole.
+ #[test]
+ fn montage_reports_a_failed_clip_decode() {
+ let path = std::env::temp_dir().join(format!(
+ "oakrender_missing_montage_{}.mp4",
+ std::process::id()
+ ));
+ let params = montage_params_with_effect(&path.to_string_lossy(), "com.example.unused");
+ let mut dst = vec![0u8; 16 * 16 * 16];
+ assert!(
+ render_montage_frame_into(
+ Rational::new(0, 1),
+ ¶ms,
+ (16, 16),
+ &mut dst,
+ 256
+ )
+ .is_err(),
+ "a missing clip file fails the montage frame"
+ );
+ }
+
+ /// A clip effect that hands back a GPU texture on a context whose
+ /// readback fails surfaces the readback error.
+ #[test]
+ fn montage_reports_a_failed_gpu_readback() {
+ let _env = ENV_TEST_LOCK.lock().unwrap();
+ let _guard = PLUGIN_TEST_LOCK.lock().unwrap();
+ let path = std::env::temp_dir().join(format!(
+ "oakrender_montage_readback_{}.mp4",
+ std::process::id()
+ ));
+ oak_codec::testmedia::write_test_clip(&path, 16, 16, 10, 10).expect("test clip");
+ crate::ofxhost::install_client(None);
+ set_plugin_instance_factory(Some(Arc::new(|_id: &str| Some(7))));
+ set_plugin_executor(Some(Arc::new(|req: &PluginJobRequest<'_>| {
+ Ok(Texture::gpu(
+ Arc::new(UnusedCtx),
+ 0xE1,
+ req.src.size().0,
+ req.src.size().1,
+ PixelFormat::F32,
+ ))
+ })));
+ let params = montage_params_with_effect(&path.to_string_lossy(), "com.example.no-readback");
+ let mut dst = vec![0u8; 16 * 16 * 16];
+ let result = render_montage_frame_into(
+ Rational::new(0, 1),
+ ¶ms,
+ (16, 16),
+ &mut dst,
+ 256,
+ );
+ set_plugin_executor(None);
+ set_plugin_instance_factory(None);
+ let _ = std::fs::remove_file(&path);
+ assert!(
+ result.is_err(),
+ "an unreadable GPU effect result fails the montage frame"
+ );
+ }
+
+ /// Degenerate adjustment-span geometry is inert: a zero width cannot
+ /// stage a frame, and a destination too small for the staged copy is
+ /// left untouched.
+ #[test]
+ fn adjustment_span_degenerate_geometry_is_inert() {
+ let span = adjustment_span(
+ 0,
+ 2,
+ 0,
+ vec![unknown_effect("com.example.span-geometry")],
+ );
+
+ // w == 0: the staging frame cannot be generated.
+ let mut dst = vec![0xABu8; 16];
+ apply_adjustment_span(&mut dst, 16, 0, 1, &span, Rational::new(0, 1));
+ assert!(dst.iter().all(|&b| b == 0xAB), "zero width is inert");
+
+ // The destination is shorter than the staged frame geometry.
+ let mut dst = vec![0xCDu8; 8];
+ apply_adjustment_span(&mut dst, 8, 2, 1, &span, Rational::new(0, 1));
+ assert!(dst.iter().all(|&b| b == 0xCD), "a short buffer is inert");
+ }
+
+ /// The tests' GPU stand-ins and the null-texture behavior expose their
+ /// documented error/duplication contracts.
+ #[test]
+ fn test_context_stubs_report_their_contracts() {
+ use oak_core::backend::GpuContextLike;
+ use oak_node::node::NodeBehavior;
+
+ let unused = UnusedCtx;
+ assert!(unused.upload(0, &Frame::dummy()).is_err());
+ assert!(unused.download(0).is_err());
+ assert!(unused.blit(0, 0, None).is_err());
+ unused.destroy_texture(0);
+
+ let echo = FrameEchoCtx {
+ frame: Frame::dummy(),
+ };
+ assert!(echo.upload(7, &Frame::dummy()).is_ok());
+ assert_eq!(echo.download(7).unwrap().width, 0);
+ assert!(echo.blit(0, 0, None).is_err());
+
+ let behavior = NullTextureBehavior;
+ assert_eq!(behavior.name(), "NullTexture");
+ assert_eq!(behavior.type_id(), "org.oak.test.nulltexture");
+ assert!(behavior.duplicate(&oak_node::node::NodeCore::new()).is_some());
+ }
+
}
-+ static LOCK: Mutex<()> = Mutex::new(());
diff --git a/crates/oak-render/src/pipeline.rs b/crates/oak-render/src/pipeline.rs
index 61a3f4d09..22b28d403 100644
--- a/crates/oak-render/src/pipeline.rs
+++ b/crates/oak-render/src/pipeline.rs
@@ -953,14 +953,21 @@ mod tests {
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() {
+ /// Pin the working space to the legacy sRGB pass-through and hold the
+ /// crate-wide working-space test lock for the entire decoded-pattern
+ /// section: these tests assert the decoded pattern, not the color
+ /// transform (the ACEScg default would remap the values), while the
+ /// eval tests temporarily switch the same process-global settings. The
+ /// caller must keep the returned guard alive.
+ fn pin_legacy_working_space() -> std::sync::MutexGuard<'static, ()> {
+ let guard = crate::eval::working_space_test_lock()
+ .lock()
+ .unwrap_or_else(|e| e.into_inner());
oak_core::color::set_pipeline_color_settings(
oak_core::colormath::WorkingColorSpace::SrgbLegacy,
oak_core::colormath::OutputColorSpec::default(),
);
+ guard
}
fn request(filename: &std::path::Path, time: Rational) -> DecodeRequest {
@@ -970,6 +977,7 @@ mod tests {
time,
size: (64, 64),
format: PixelFormat::F32,
+ allow_import: true,
}
}
@@ -1020,7 +1028,7 @@ mod tests {
/// The service decodes real media through the real codec path.
#[test]
fn request_decodes_real_media() {
- pin_legacy_working_space();
+ let _guard = pin_legacy_working_space();
let path = test_clip("real");
let service = DecodeService::new(DECODE_LRU_CAP, always());
@@ -1045,7 +1053,7 @@ mod tests {
/// follows is served from the cache with no decode at all.
#[test]
fn prefetch_then_request_hits_the_lru() {
- pin_legacy_working_space();
+ let _guard = pin_legacy_working_space();
let path = test_clip("prefetch");
let service = DecodeService::new(DECODE_LRU_CAP, always());
@@ -1084,6 +1092,7 @@ mod tests {
time: Rational::new(0, 1),
size: (64, 64),
format: PixelFormat::F32,
+ allow_import: true,
};
let err = service
.request(req)
@@ -1101,7 +1110,7 @@ mod tests {
/// frame must be decoded again).
#[test]
fn lru_evicts_bounded() {
- pin_legacy_working_space();
+ let _guard = pin_legacy_working_space();
let path = test_clip("evict");
let service = DecodeService::new(2, always());
let time = |n: i64| request(&path, Rational::new(n, 10));
@@ -1135,7 +1144,7 @@ mod tests {
/// (and counts) without queueing anything.
#[test]
fn prefetch_gate_refuses_and_recovers() {
- pin_legacy_working_space();
+ let _guard = pin_legacy_working_space();
let path = test_clip("gate");
let open = Arc::new(AtomicBool::new(false));
let gate_open = open.clone();
@@ -1166,7 +1175,7 @@ mod tests {
/// prefetch sent before it has been decoded when it returns.
#[test]
fn wait_idle_barrier_covers_queued_commands() {
- pin_legacy_working_space();
+ let _guard = pin_legacy_working_space();
let path = test_clip("barrier");
// The barrier test needs four live entries; the production
// hand-off capacity is deliberately tiny (see DECODE_LRU_CAP), so
@@ -1188,7 +1197,7 @@ mod tests {
/// eval path falls back to decoding inline instead of failing frames.
#[test]
fn shutdown_makes_the_service_unavailable() {
- pin_legacy_working_space();
+ let _guard = pin_legacy_working_space();
let path = test_clip("shutdown");
let service = DecodeService::new(DECODE_LRU_CAP, always());
service.shutdown();
@@ -1256,6 +1265,7 @@ mod tests {
time,
size: (16, 16),
format: PixelFormat::F32,
+ allow_import: true,
};
{
let mut queue = lock(&shared.queue);
diff --git a/crates/oak-render/src/procpool.rs b/crates/oak-render/src/procpool.rs
index 59ebbb07a..877e099ba 100644
--- a/crates/oak-render/src/procpool.rs
+++ b/crates/oak-render/src/procpool.rs
@@ -2521,6 +2521,7 @@ fn build_audio_ticket_spec(ticket: i64, slot: u32, params: &AudioTicketParams) -
#[cfg(test)]
mod tests {
use super::*;
+ use crate::worker::JobSchedule;
#[test]
fn slot_bytes_for_formats() {
@@ -2577,8 +2578,23 @@ mod tests {
assert_eq!(total, 8 << 30);
assert_eq!(free, 6 << 30);
- // No non-zero card -> None (a totally attr-less tree).
- for f in [&dir.join("card1"), &dir.join("card0")] {
+ // card3: a numeric total but no `used` file -> used defaults to 0;
+ // card4: an unparsable total -> skipped.
+ let card3 = dir.join("card3").join("device");
+ std::fs::create_dir_all(&card3).unwrap();
+ std::fs::write(card3.join("mem_info_vram_total"), (4u64 << 30).to_string()).unwrap();
+ let card4 = dir.join("card4").join("device");
+ std::fs::create_dir_all(&card4).unwrap();
+ std::fs::write(card4.join("mem_info_vram_total"), "not-a-number").unwrap();
+ std::fs::write(card4.join("mem_info_vram_used"), "0").unwrap();
+ // Remove the first winner so the walk reaches card3.
+ let _ = std::fs::remove_dir_all(dir.join("card1"));
+ let (free, total) = linux_drm_vram_bytes_from(&dir).expect("card3 wins");
+ assert_eq!(total, 4 << 30);
+ assert_eq!(free, 4 << 30, "a missing `used` file counts as zero");
+
+ // No usable card -> None (display-only + zero-total + garbage total).
+ for f in [&dir.join("card0"), &dir.join("card3"), &dir.join("card4")] {
let _ = std::fs::remove_dir_all(f);
}
assert!(linux_drm_vram_bytes_from(&dir).is_none());
@@ -2722,6 +2738,7 @@ mod tests {
/// that drives the main-process plugin-progress dialog.
#[test]
fn plugin_progress_line_forwards_to_callback() {
+ let _lock = pool_test_lock();
let config = DispatcherConfig {
worker_bin: Some(std::path::PathBuf::from("/bin/true")),
workers: 1,
@@ -2769,4 +2786,2104 @@ mod tests {
assert_eq!(events.len(), 1);
assert_eq!(events[0], ("render".to_string(), "pass 1".to_string(), 0.5));
}
+
+ // ---- Branch-coverage fill-ins: pure helpers and synthetic workers ------
+
+ /// Serializes the synthetic-process / global-env tests below (they
+ /// register the process-wide dispatcher slot and mutate env vars).
+ static POOL_TEST_LOCK: Mutex<()> = Mutex::new(());
+
+ fn pool_test_lock() -> MutexGuard<'static, ()> {
+ POOL_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner())
+ }
+
+ fn shm_key(name: &str) -> String {
+ format!("oak-procpool-ut-{}-{name}", std::process::id())
+ }
+
+ fn test_config(workers: usize, slots: u32) -> DispatcherConfig {
+ DispatcherConfig {
+ worker_bin: Some(std::path::PathBuf::from("/bin/true")),
+ workers,
+ slots_per_worker: slots,
+ width: 16,
+ height: 16,
+ batch_size: 2,
+ ..Default::default()
+ }
+ }
+
+ fn video_params(frame: i64) -> VideoTicketParams {
+ VideoTicketParams {
+ viewer: 1,
+ project: String::new(),
+ time: oak_core::Rational::new(frame, 25),
+ force_size: Some((16, 16)),
+ force_format: None,
+ cache: None,
+ cache_dir: None,
+ cache_id: None,
+ cache_timebase: None,
+ footage: None,
+ montage: Vec::new(),
+ adjustments: Vec::new(),
+ }
+ }
+
+ fn test_job(
+ sequence: u64,
+ frame: i64,
+ audio: Option>,
+ results: &Arc>>,
+ ) -> Job {
+ let results = results.clone();
+ Job {
+ node_identity: sequence,
+ time: oak_core::Rational::new(frame, 25),
+ params: Arc::new(video_params(frame)),
+ audio,
+ produce: Arc::new(|_, _| {
+ Err(Error::Failed(
+ "process backend does not use the in-process producer".into(),
+ ))
+ }),
+ done: Box::new(move |result| {
+ results
+ .lock()
+ .unwrap_or_else(|e| e.into_inner())
+ .push(result);
+ }),
+ schedule: JobSchedule::seek(),
+ cancelled: None,
+ }
+ }
+
+ fn pump_until(
+ dispatcher: &ProcessDispatcher,
+ results: &Mutex>,
+ expected: usize,
+ ) {
+ let deadline = Instant::now() + Duration::from_secs(120);
+ loop {
+ dispatcher.poll();
+ if results.lock().unwrap_or_else(|e| e.into_inner()).len() >= expected {
+ return;
+ }
+ if Instant::now() > deadline {
+ let have = results.lock().unwrap_or_else(|e| e.into_inner()).len();
+ panic!("timeout: {have}/{expected} completions");
+ }
+ std::thread::sleep(Duration::from_millis(5));
+ }
+ }
+
+ /// Allocate a slot from `view`'s free ring, stamp `id` into its metadata
+ /// and publish it — what a worker does before `frame_ready`.
+ fn publish_slot(view: &ShmRegionView, id: i64) -> u32 {
+ let pool = view.pool();
+ let mut slot = 0u32;
+ unsafe {
+ assert!(pool.acquire(&mut slot), "a free slot");
+ let meta = pool.meta(slot);
+ (*meta).id = id;
+ (*meta).data_size = 8;
+ assert!(pool.publish(slot), "ready ring has room");
+ }
+ slot
+ }
+
+ /// Saves an environment variable and restores it on drop (the env is
+ /// process-global; all users hold [`POOL_TEST_LOCK`]).
+ struct EnvRestore {
+ key: &'static str,
+ value: Option,
+ }
+
+ impl EnvRestore {
+ fn set(key: &'static str, value: &str) -> EnvRestore {
+ let saved = std::env::var_os(key);
+ std::env::set_var(key, value);
+ EnvRestore { key, value: saved }
+ }
+ }
+
+ impl Drop for EnvRestore {
+ fn drop(&mut self) {
+ match self.value.take() {
+ Some(value) => std::env::set_var(self.key, value),
+ None => std::env::remove_var(self.key),
+ }
+ }
+ }
+
+ /// The oak-worker binary: `$OAK_WORKER_BIN`, else the sibling of the
+ /// test executable under `target//`. Tests skip (with a
+ /// printed reason) when it was never built.
+ fn find_real_worker() -> Option {
+ if let Ok(p) = std::env::var("OAK_WORKER_BIN") {
+ let p = std::path::PathBuf::from(p);
+ if p.exists() {
+ return Some(p);
+ }
+ }
+ let exe = std::env::current_exe().ok()?;
+ let candidate = exe
+ .parent()?
+ .parent()?
+ .join(format!("oak-worker{}", std::env::consts::EXE_SUFFIX));
+ candidate.exists().then_some(candidate)
+ }
+
+ #[test]
+ fn slot_bytes_for_format_matrix() {
+ assert_eq!(slot_bytes_for(2, 2, SLOT_FORMAT_BGRA8), 16);
+ // U8: 1 byte/channel * 4 channels.
+ assert_eq!(slot_bytes_for(2, 2, 0), 16);
+ // U10 reports 4 bytes/channel (the packed RGBA10A2 word), so the
+ // slot math is 4 * channels bytes per pixel.
+ assert_eq!(slot_bytes_for(2, 2, 1), 64);
+ assert_eq!(slot_bytes_for(2, 2, 2), 32); // U16
+ assert_eq!(slot_bytes_for(2, 2, 3), 32); // F16
+ assert_eq!(slot_bytes_for(2, 2, 4), 64); // F32
+ assert_eq!(slot_bytes_for(2, 2, 9), 64); // unknown -> F32
+ // Negative dimensions clamp to zero pixels.
+ assert_eq!(slot_bytes_for(-3, 5, 0), 0);
+ assert_eq!(slot_bytes_for(3, -5, 0), 0);
+ }
+
+ #[test]
+ fn bgra8_to_f32_rgba_converts_and_ignores_tail() {
+ let out = bgra8_to_f32_rgba(&[255, 128, 0, 255, 10]);
+ assert_eq!(out.len(), 4, "trailing byte without a full pixel is dropped");
+ assert_eq!(out[0], 0.0);
+ assert!((out[1] - 128.0 / 255.0).abs() < 1e-6);
+ assert_eq!(out[2], 1.0);
+ assert_eq!(out[3], 1.0);
+ assert!(bgra8_to_f32_rgba(&[]).is_empty());
+ }
+
+ #[test]
+ fn defaults_policies_edge_inputs() {
+ // slot_bytes 0 still yields a sane count (the max(1) guards the divide).
+ assert_eq!(default_slots_for_bytes(0, 8), 8);
+ // Shrinking below the floor clamps to 2.
+ assert_eq!(default_slots_for_bytes(8_300_000, 1), 2);
+ // A null divider falls back to a per-worker count >= 1.
+ assert!(default_worker_count(0, 0) >= 1);
+ assert_eq!(default_batch_size(0, 0), 1);
+ }
+
+ #[test]
+ fn shm_view_refs_samples_and_debug() {
+ let key = shm_key("refs");
+ let view = ShmRegionView::create(&key, 2, 64).expect("shm");
+ let slot = 0u32;
+ let samples_in = [1.0f32, -2.5, 0.0];
+ let bytes: Vec = samples_in.iter().flat_map(|v| v.to_le_bytes()).collect();
+ unsafe {
+ let pool = view.pool();
+ let dst = std::slice::from_raw_parts_mut(pool.slot_data(slot), bytes.len());
+ dst.copy_from_slice(&bytes);
+ (*pool.meta(slot)).id = 7;
+ }
+ assert_eq!(view.key(), key);
+ assert_eq!(view.slot_count(), 2);
+ assert_eq!(view.slot_data_bytes(), 64);
+ assert_eq!(view.slot_bytes(slot).len(), 64);
+ assert_eq!(view.meta_copy(slot).id, 7);
+
+ let meta = ShmFrameMeta {
+ id: 7,
+ time_num: 1,
+ time_den: 25,
+ width: 2,
+ height: 2,
+ format: crate::ipc::SLOT_FORMAT_AUDIO_F32,
+ channel_count: 2,
+ linesize: 8,
+ data_size: bytes.len() as i32,
+ colorspace: "linear".to_string(),
+ };
+ let audio = ShmAudioRef {
+ worker: 0,
+ slot,
+ meta: meta.clone(),
+ shm: view.clone(),
+ sample_rate: 48000,
+ channel_layout: 0x3,
+ channel_count: 2,
+ };
+ assert_eq!(audio.samples(), samples_in.to_vec());
+ let decoded = audio.to_audio_samples();
+ assert_eq!(decoded.samples, samples_in.to_vec());
+ assert_eq!(decoded.sample_rate, 48000);
+ assert_eq!(decoded.channel_layout, 0x3);
+ assert_eq!(decoded.channel_count, 2);
+ assert!(format!("{audio:?}").contains("ShmAudioRef"));
+ let frame = audio.frame_ref();
+ assert_eq!(frame.worker, 0);
+ assert_eq!(frame.slot, slot);
+ assert_eq!(frame.meta.id, 7);
+ assert!(format!("{frame:?}").contains("ShmFrameRef"));
+
+ // A bogus (oversized / negative) data size yields no samples.
+ let mut clipped = audio.clone();
+ clipped.meta.data_size = i32::MAX;
+ assert!(clipped.samples().is_empty());
+ clipped.meta.data_size = -1;
+ assert!(clipped.samples().is_empty());
+ }
+
+ #[test]
+ fn shm_region_create_invalid_key_errors() {
+ // A NUL in the key makes every shm_open attempt fail: the create
+ // helper unlinks and retries once, then surfaces the error.
+ let err = ShmRegionView::create("bad\0key", 1, 64)
+ .err()
+ .expect("a NUL key can never create a segment");
+ assert!(format!("{err}").contains("create shm segment"));
+ }
+
+ #[cfg(target_os = "linux")]
+ #[test]
+ fn linux_drm_vram_default_query_smoke() {
+ // The real sysfs walk: no assertion on the result (GPU-dependent),
+ // just exercise the path that forwards to the fixture-testable walk.
+ let _ = linux_drm_vram_bytes();
+ assert!(linux_drm_vram_bytes_from(std::path::Path::new("/nonexistent")).is_none());
+ }
+
+ #[test]
+ fn worker_count_for_size_with_hwaccel_disabled() {
+ let _lock = pool_test_lock();
+ let _hwaccel = EnvRestore::set("OAK_HWACCEL", "0");
+ assert!(!hwdecode_available());
+ // Hardware decoding off: the GPU-vram policy never engages.
+ let base = default_worker_count(2, 128);
+ assert_eq!(worker_count_for_size(2, 128, (64, 64), 30), base);
+ // Re-enabling leaves the CPU/RAM policy as the upper bound.
+ std::env::remove_var("OAK_HWACCEL");
+ let with_gpu = worker_count_for_size(2, 128, (64, 64), 30);
+ assert!(
+ with_gpu >= 1 && with_gpu <= base,
+ "the GPU-vram bound only caps the CPU/RAM policy ({with_gpu} vs {base})"
+ );
+ }
+
+ #[test]
+ fn cancel_frame_pending_claimed_and_unknown() {
+ let _lock = pool_test_lock();
+ let dispatcher = ProcessDispatcher::new(test_config(1, 2)).expect("dispatcher");
+ let key = FrameKey {
+ sequence: 9,
+ frame: 3,
+ version: 1,
+ };
+ // Unknown key: the scheduler reports false and nothing fires.
+ dispatcher.cancel_frame(&key);
+ let results: Arc>> = Arc::new(Mutex::new(Vec::new()));
+ let results2 = results.clone();
+ {
+ let mut inner = lock(&dispatcher.inner);
+ inner.scheduler.submit(FrameRequest {
+ key,
+ priority: crate::scheduler::FramePriority::Seek,
+ distance: 0,
+ payload: 1,
+ slot_bytes: 64,
+ });
+ inner.tickets.insert(
+ 1,
+ PendingTicket {
+ key,
+ params: Arc::new(video_params(3)),
+ audio: None,
+ done: Some(Box::new(move |result| {
+ results2
+ .lock()
+ .unwrap_or_else(|e| e.into_inner())
+ .push(result);
+ })),
+ },
+ );
+ }
+ // The cancel locks the dispatcher internally: never call it while
+ // holding `dispatcher.inner` (that deadlocks the test).
+ dispatcher.cancel_frame(&key);
+ {
+ let inner = lock(&dispatcher.inner);
+ assert!(inner.tickets.is_empty());
+ }
+ assert_eq!(results.lock().unwrap().len(), 1);
+ assert!(results.lock().unwrap()[0].is_err());
+
+ // Claimed (in-flight) cancel removes the claim and fires once.
+ let key2 = FrameKey {
+ sequence: 9,
+ frame: 4,
+ version: 1,
+ };
+ let results2: Arc>> = Arc::new(Mutex::new(Vec::new()));
+ let sink = results2.clone();
+ {
+ let mut inner = lock(&dispatcher.inner);
+ inner.scheduler.submit(FrameRequest {
+ key: key2,
+ priority: crate::scheduler::FramePriority::Playback,
+ distance: 0,
+ payload: 2,
+ slot_bytes: 64,
+ });
+ let claimed = inner.scheduler.claim_batch(0, 2, 64).expect("claimed");
+ assert_eq!(claimed.frames.len(), 1);
+ inner.tickets.insert(
+ 2,
+ PendingTicket {
+ key: key2,
+ params: Arc::new(video_params(4)),
+ audio: None,
+ done: Some(Box::new(move |result| {
+ sink.lock().unwrap_or_else(|e| e.into_inner()).push(result)
+ })),
+ },
+ );
+ }
+ dispatcher.cancel_frame(&key2);
+ assert_eq!(results2.lock().unwrap().len(), 1);
+ assert!(results2.lock().unwrap()[0].is_err());
+ assert_eq!(dispatcher.worker_count(), 1);
+ }
+
+#[test]
+ fn release_frame_stale_double_and_missing_worker() {
+ let _lock = pool_test_lock();
+ let dispatcher = ProcessDispatcher::new(test_config(1, 2)).expect("dispatcher");
+ let key = shm_key("release");
+ let shm = ShmRegionView::create(&key, 2, 64).expect("shm");
+ {
+ let mut inner = lock(&dispatcher.inner);
+ let mut handle = WorkerHandle::shell(0, shm.clone(), 2, 64);
+ handle.state = WorkerState::Alive;
+ inner.workers.push(handle);
+ }
+ let meta = shm.meta_copy(0);
+ // Unknown worker index: ignored.
+ let unknown = crate::procpool::ShmFrameRef {
+ worker: 42,
+ slot: 0,
+ meta: meta.clone(),
+ shm: shm.clone(),
+ };
+ dispatcher.release_frame(&unknown);
+ // Stale ref (same worker index, different segment): ignored.
+ let other_key = shm_key("release-other");
+ let other = ShmRegionView::create(&other_key, 1, 64).expect("shm");
+ let stale = crate::procpool::ShmFrameRef {
+ worker: 0,
+ slot: 0,
+ meta: meta.clone(),
+ shm: other,
+ };
+ dispatcher.release_frame(&stale);
+ {
+ let inner = lock(&dispatcher.inner);
+ assert!(inner.workers[0].held.is_empty());
+ }
+ // A held slot releases once; the second release is a no-op.
+ {
+ let mut inner = lock(&dispatcher.inner);
+ inner.workers[0].held.insert(0);
+ inner.workers[0].free_slots.clear();
+ }
+ let frame = crate::procpool::ShmFrameRef {
+ worker: 0,
+ slot: 0,
+ meta,
+ shm,
+ };
+ dispatcher.release_frame(&frame);
+ {
+ let inner = lock(&dispatcher.inner);
+ assert_eq!(
+ inner.workers[0].free_slots.iter().filter(|&&s| s == 0).count(),
+ 1
+ );
+ }
+ dispatcher.release_frame(&frame);
+ {
+ let inner = lock(&dispatcher.inner);
+ assert_eq!(
+ inner.workers[0].free_slots.iter().filter(|&&s| s == 0).count(),
+ 1,
+ "double release is ignored"
+ );
+ }
+ }
+
+ #[test]
+ fn audio_ref_release_delegates_and_cancels_sequence() {
+ let _lock = pool_test_lock();
+ let dispatcher = ProcessDispatcher::new(test_config(1, 1)).expect("dispatcher");
+ // The audio release delegate reaches release_frame (unknown frame
+ // index -> early return, no panic).
+ let key = shm_key("audio-release");
+ let shm = ShmRegionView::create(&key, 1, 64).expect("shm");
+ let audio = ShmAudioRef {
+ worker: 99,
+ slot: 0,
+ meta: shm.meta_copy(0),
+ shm,
+ sample_rate: 48000,
+ channel_layout: 0x3,
+ channel_count: 2,
+ };
+ dispatcher.release_audio_frame(&audio);
+
+ // cancel_preview_sequence drops pending + claimed requests and
+ // fires their completions.
+ let results: Arc>> = Arc::new(Mutex::new(Vec::new()));
+ for (i, frame) in [10i64, 11].into_iter().enumerate() {
+ let key = FrameKey {
+ sequence: 77,
+ frame,
+ version: 0,
+ };
+ let sink = results.clone();
+ let mut inner = lock(&dispatcher.inner);
+ inner.scheduler.submit(FrameRequest {
+ key,
+ priority: crate::scheduler::FramePriority::Playback,
+ distance: 0,
+ payload: i as i64,
+ slot_bytes: 64,
+ });
+ inner.tickets.insert(
+ i as i64,
+ PendingTicket {
+ key,
+ params: Arc::new(video_params(frame)),
+ audio: None,
+ done: Some(Box::new(move |result| {
+ sink.lock().unwrap_or_else(|e| e.into_inner()).push(result)
+ })),
+ },
+ );
+ }
+ {
+ let mut inner = lock(&dispatcher.inner);
+ // The Playback reserve claims one now; the other stays pending,
+ // so cancel_sequence covers both the pending and claimed arms.
+ let claimed = inner.scheduler.claim_batch(0, 2, 64).expect("claimed");
+ assert_eq!(claimed.frames.len(), 1);
+ }
+ dispatcher.cancel_preview_sequence(77);
+ assert_eq!(results.lock().unwrap().len(), 2);
+ assert!(results.lock().unwrap().iter().all(|r| r.is_err()));
+ let _ = shm;
+ }
+
+ #[test]
+ fn job_dispatch_trait_delegations() {
+ let _lock = pool_test_lock();
+ let dispatcher = ProcessDispatcher::new(test_config(1, 1)).expect("dispatcher");
+ let raw: &ProcessDispatcher = &dispatcher;
+ JobDispatch::poll(raw);
+ assert_eq!(
+ JobDispatch::preview_window_capacity(raw),
+ Some(dispatcher.preview_window_capacity())
+ );
+ JobDispatch::cancel_preview_frame(raw, 1, 2, 3);
+ JobDispatch::set_graph_snapshot(raw, Some("/nonexistent/graph.xml".into()));
+ JobDispatch::set_graph_snapshot(raw, None);
+ let key = shm_key("trait-release");
+ let shm = ShmRegionView::create(&key, 1, 64).expect("shm");
+ let frame = ShmFrameRef {
+ worker: 99,
+ slot: 0,
+ meta: shm.meta_copy(0),
+ shm: shm.clone(),
+ };
+ // Unknown worker: both release delegates take the early return.
+ JobDispatch::release_frame(raw, &frame);
+ let audio = ShmAudioRef {
+ worker: 99,
+ slot: 0,
+ meta: shm.meta_copy(0),
+ shm,
+ sample_rate: 48000,
+ channel_layout: 0x3,
+ channel_count: 2,
+ };
+ JobDispatch::release_audio_frame(raw, &audio);
+ }
+
+ #[test]
+ fn on_line_protocol_dispatch_and_failures() {
+ let _lock = pool_test_lock();
+ let dispatcher = ProcessDispatcher::new(test_config(1, 2)).expect("dispatcher");
+ let mut fired: Vec<(Completion, TicketResult)> = Vec::new();
+ {
+ let mut inner = lock(&dispatcher.inner);
+ let key = shm_key("on-line");
+ let shm = ShmRegionView::create(&key, 2, 64).expect("shm");
+ let mut handle = WorkerHandle::shell(7, shm, 2, 64);
+ handle.state = WorkerState::Starting;
+ inner.workers.push(handle);
+
+ // Malformed / non-object lines and unknown workers return early.
+ dispatcher.on_line(&mut inner, 0, "not json", &mut fired);
+ dispatcher.on_line(&mut inner, 0, "[1,2]", &mut fired);
+ dispatcher.on_line(&mut inner, 99, r#"{"type":"hello_caps"}"#, &mut fired);
+
+ // A handshake on a handle without stdin fails to reply -> Dead.
+ dispatcher.on_line(
+ &mut inner,
+ 0,
+ r#"{"type":"handshake","protocol_version":1}"#,
+ &mut fired,
+ );
+ assert!(inner.workers[0].startup_seen);
+ assert_eq!(inner.workers[0].state, WorkerState::Dead);
+
+ // hello_caps marks the worker alive; the pending load_graph send
+ // then fails (no stdin) and kills it again.
+ inner.config.graph_snapshot = Some("/nonexistent/graph.xml".into());
+ inner.workers[0].state = WorkerState::Starting;
+ inner.workers[0].graph_sent = false;
+ dispatcher.on_line(
+ &mut inner,
+ 0,
+ r#"{"type":"hello_caps","protocol_version":1,"formats":[4],"max_slot_bytes":4096}"#,
+ &mut fired,
+ );
+ assert!(inner.workers[0].caps.is_some());
+ assert!(!inner.workers[0].reconfiguring);
+ assert!(inner.workers[0].graph_sent);
+ assert_eq!(inner.workers[0].state, WorkerState::Dead);
+
+ // batch_accepted increments the metric (no reply needed).
+ dispatcher.on_line(
+ &mut inner,
+ 0,
+ r#"{"type":"batch_accepted","batch_id":1,"tickets":[1,2]}"#,
+ &mut fired,
+ );
+ assert_eq!(inner.workers[0].accepted_batches, 1);
+
+ // A session-level error on a Starting worker recycles it.
+ inner.workers[0].state = WorkerState::Starting;
+ dispatcher.on_line(
+ &mut inner,
+ 0,
+ r#"{"type":"error","message":"shm attach failed"}"#,
+ &mut fired,
+ );
+ assert_eq!(inner.workers[0].state, WorkerState::Dead);
+
+ // An error WITH a ticket routes to on_frame_failed.
+ dispatcher.on_line(
+ &mut inner,
+ 0,
+ r#"{"type":"error","ticket":5,"message":"boom"}"#,
+ &mut fired,
+ );
+ // Unknown type and malformed known messages are ignored.
+ dispatcher.on_line(&mut inner, 0, r#"{"type":"something_else"}"#, &mut fired);
+ dispatcher.on_line(
+ &mut inner,
+ 0,
+ r#"{"type":"hello_caps","protocol_version":"x"}"#,
+ &mut fired,
+ );
+ dispatcher.on_line(
+ &mut inner,
+ 0,
+ r#"{"type":"batch_accepted","batch_id":"x"}"#,
+ &mut fired,
+ );
+ dispatcher.on_line(
+ &mut inner,
+ 0,
+ r#"{"type":"frame_ready","ticket":"x"}"#,
+ &mut fired,
+ );
+ dispatcher.on_line(&mut inner, 0, r#"{"type":"frame_failed"}"#, &mut fired);
+ // plugin_progress forwards only when a callback is registered.
+ set_plugin_progress_cb(None);
+ dispatcher.on_line(
+ &mut inner,
+ 0,
+ r#"{"type":"plugin_progress","label":"a","message":"b","fraction":0.5}"#,
+ &mut fired,
+ );
+ }
+ assert!(fired.is_empty());
+ }
+
+ #[test]
+ fn on_frame_ready_variants() {
+ let _lock = pool_test_lock();
+ let dispatcher = ProcessDispatcher::new(test_config(1, 8)).expect("dispatcher");
+ let key = shm_key("frame-ready");
+ let shm = ShmRegionView::create(&key, 8, 64).expect("shm");
+ {
+ let mut inner = lock(&dispatcher.inner);
+ let mut handle = WorkerHandle::shell(0, shm.clone(), 8, 64);
+ handle.state = WorkerState::Alive;
+ inner.workers.push(handle);
+ }
+ let mut fired: Vec<(Completion, TicketResult)> = Vec::new();
+
+ // Unknown ticket: a late / duplicate frame_ready is ignored (the
+ // debug trace covers its log line).
+ {
+ let mut inner = lock(&dispatcher.inner);
+ let env = EnvRestore::set("OAK_DEBUG_DISPATCH", "1");
+ dispatcher.on_frame_ready(&mut inner, 0, 404, 0, &mut fired);
+ drop(env);
+ }
+ assert!(fired.is_empty());
+
+ // Video ticket: the published slot completes as ShmFrame.
+ let video_slot = publish_slot(&shm, 42);
+ let video_results: Arc>> = Arc::new(Mutex::new(Vec::new()));
+ {
+ let mut inner = lock(&dispatcher.inner);
+ inner.workers[0].outstanding.insert(11, video_slot);
+ let sink = video_results.clone();
+ inner.tickets.insert(
+ 11,
+ PendingTicket {
+ key: FrameKey {
+ sequence: 1,
+ frame: 0,
+ version: 0,
+ },
+ params: Arc::new(video_params(0)),
+ audio: None,
+ done: Some(Box::new(move |result| {
+ sink.lock().unwrap_or_else(|e| e.into_inner()).push(result)
+ })),
+ },
+ );
+ dispatcher.on_frame_ready(&mut inner, 0, 11, video_slot as i32, &mut fired);
+ }
+ assert_eq!(fired.len(), 1);
+ {
+ let (done, result) = fired.pop().unwrap();
+ done(result);
+ }
+ let video_got = video_results.lock().unwrap();
+ assert!(
+ matches!(&video_got[0], Ok(TicketPayload::ShmFrame(_))),
+ "expected ShmFrame, got {:?}",
+ video_got[0]
+ );
+ if let Ok(TicketPayload::ShmFrame(frame)) = &video_got[0] {
+ assert_eq!(frame.meta.id, 42);
+ assert_eq!(frame.worker, 0);
+ assert_eq!(frame.slot, video_slot);
+ }
+
+ // Audio ticket: the same slot hand-off yields ShmAudio.
+ let audio_slot = publish_slot(&shm, 43);
+ let audio_results: Arc>> = Arc::new(Mutex::new(Vec::new()));
+ {
+ let mut inner = lock(&dispatcher.inner);
+ inner.workers[0].outstanding.insert(12, audio_slot);
+ let sink = audio_results.clone();
+ inner.tickets.insert(
+ 12,
+ PendingTicket {
+ key: FrameKey {
+ sequence: 2,
+ frame: 0,
+ version: 0,
+ },
+ params: Arc::new(video_params(0)),
+ audio: Some(Arc::new(AudioTicketParams {
+ viewer: 1,
+ range: oak_core::TimeRange::new(
+ oak_core::Rational::new(0, 1),
+ oak_core::Rational::new(1, 48),
+ ),
+ sample_rate: 48000,
+ channel_layout: 0x3,
+ montage: Vec::new(),
+ })),
+ done: Some(Box::new(move |result| {
+ sink.lock().unwrap_or_else(|e| e.into_inner()).push(result)
+ })),
+ },
+ );
+ dispatcher.on_frame_ready(&mut inner, 0, 12, audio_slot as i32, &mut fired);
+ }
+ assert_eq!(fired.len(), 1);
+ {
+ let (done, result) = fired.pop().unwrap();
+ done(result);
+ }
+ let audio_got = audio_results.lock().unwrap();
+ assert!(
+ matches!(&audio_got[0], Ok(TicketPayload::ShmAudio(_))),
+ "expected ShmAudio, got {:?}",
+ audio_got[0]
+ );
+ if let Ok(TicketPayload::ShmAudio(audio)) = &audio_got[0] {
+ assert_eq!(audio.meta.id, 43);
+ assert_eq!(audio.sample_rate, 48000);
+ assert_eq!(audio.channel_count, 2);
+ }
+
+ // Cancelled in flight (completion taken): the slot recycles now.
+ let cancelled_slot = publish_slot(&shm, 44);
+ {
+ let mut inner = lock(&dispatcher.inner);
+ inner.workers[0].outstanding.insert(13, cancelled_slot);
+ inner.tickets.insert(
+ 13,
+ PendingTicket {
+ key: FrameKey {
+ sequence: 3,
+ frame: 0,
+ version: 0,
+ },
+ params: Arc::new(video_params(0)),
+ audio: None,
+ done: None,
+ },
+ );
+ let before = inner.workers[0].free_slots.len();
+ dispatcher.on_frame_ready(&mut inner, 0, 13, cancelled_slot as i32, &mut fired);
+ assert_eq!(inner.workers[0].free_slots.len(), before + 1);
+ assert!(!inner.workers[0].held.contains(&cancelled_slot));
+ }
+ // An outstanding entry with no ticket row recycles too.
+ let orphan_slot = publish_slot(&shm, 45);
+ {
+ let mut inner = lock(&dispatcher.inner);
+ inner.workers[0].outstanding.insert(14, orphan_slot);
+ dispatcher.on_frame_ready(&mut inner, 0, 14, orphan_slot as i32, &mut fired);
+ }
+ assert!(fired.is_empty());
+
+ // Ready-ring out of sync: the reported slot wins, the mismatch is
+ // logged and the completion still lands.
+ let published = publish_slot(&shm, 46);
+ let reported = if published == 0 { 1 } else { 0 };
+ unsafe {
+ (*shm.pool().meta(reported)).id = 46;
+ }
+ let results: Arc>> = Arc::new(Mutex::new(Vec::new()));
+ {
+ let mut inner = lock(&dispatcher.inner);
+ inner.workers[0].outstanding.insert(15, reported);
+ let sink = results.clone();
+ inner.tickets.insert(
+ 15,
+ PendingTicket {
+ key: FrameKey {
+ sequence: 4,
+ frame: 0,
+ version: 0,
+ },
+ params: Arc::new(video_params(0)),
+ audio: None,
+ done: Some(Box::new(move |result| {
+ sink.lock().unwrap_or_else(|e| e.into_inner()).push(result)
+ })),
+ },
+ );
+ dispatcher.on_frame_ready(&mut inner, 0, 15, reported as i32, &mut fired);
+ }
+ assert_eq!(fired.len(), 1);
+ {
+ let (done, result) = fired.pop().unwrap();
+ done(result);
+ }
+ assert!(matches!(
+ &results.lock().unwrap()[0],
+ Ok(TicketPayload::ShmFrame(frame)) if frame.meta.id == 46
+ ));
+ }
+
+ #[test]
+ fn on_frame_failed_paths() {
+ let _lock = pool_test_lock();
+ let dispatcher = ProcessDispatcher::new(test_config(1, 2)).expect("dispatcher");
+ let mut fired: Vec<(Completion, TicketResult)> = Vec::new();
+ let results: Arc>> = Arc::new(Mutex::new(Vec::new()));
+ {
+ let mut inner = lock(&dispatcher.inner);
+ let shm = ShmRegionView::create(&shm_key("frame-failed"), 2, 64).expect("shm");
+ let mut handle = WorkerHandle::shell(0, shm, 2, 64);
+ handle.state = WorkerState::Alive;
+ inner.workers.push(handle);
+
+ // Unknown worker -> early return (with the debug trace on).
+ let env = EnvRestore::set("OAK_DEBUG_DISPATCH", "1");
+ dispatcher.on_frame_failed(&mut inner, 99, 1, "x", &mut fired);
+ drop(env);
+ // Known worker, unknown ticket -> early return.
+ dispatcher.on_frame_failed(&mut inner, 0, 1, "x", &mut fired);
+ // recycle_slot with an unknown worker is a no-op.
+ dispatcher.recycle_slot(&mut inner, 99, 0);
+
+ // A known outstanding ticket fails: slot recycled, completion Err.
+ inner.workers[0].outstanding.insert(3, 1);
+ inner.workers[0].held.insert(1);
+ let sink = results.clone();
+ inner.tickets.insert(
+ 3,
+ PendingTicket {
+ key: FrameKey {
+ sequence: 2,
+ frame: 0,
+ version: 0,
+ },
+ params: Arc::new(video_params(0)),
+ audio: None,
+ done: Some(Box::new(move |result| {
+ sink.lock().unwrap_or_else(|e| e.into_inner()).push(result)
+ })),
+ },
+ );
+ dispatcher.on_frame_failed(&mut inner, 0, 3, "boom", &mut fired);
+ assert!(inner.workers[0].held.is_empty());
+ assert!(inner.workers[0].free_slots.contains(&1));
+ assert!(inner.tickets.is_empty());
+ }
+ assert_eq!(fired.len(), 1);
+ {
+ let (done, result) = fired.pop().unwrap();
+ done(result);
+ }
+ assert!(results.lock().unwrap()[0].is_err());
+ }
+
+ #[test]
+ fn rebuild_segment_failure_paths() {
+ let _lock = pool_test_lock();
+ let dispatcher = ProcessDispatcher::new(test_config(1, 2)).expect("dispatcher");
+ {
+ let mut inner = lock(&dispatcher.inner);
+ let shm = ShmRegionView::create(&shm_key("rebuild"), 2, 128).expect("shm");
+ let mut handle = WorkerHandle::shell(0, shm, 2, 128);
+ handle.state = WorkerState::Alive;
+ inner.workers.push(handle);
+
+ // A valid small grow with no stdin: the re-attach handshake
+ // send fails and the worker is marked Dead.
+ dispatcher.rebuild_segment(&mut inner, 0, 128).unwrap();
+ assert!(inner.workers[0].reconfiguring);
+ assert_eq!(inner.workers[0].slot_bytes, 128);
+ assert_eq!(inner.workers[0].state, WorkerState::Dead);
+
+ // A hostile slot size makes the segment create fail: the grow
+ // error is logged and the handle keeps its old geometry.
+ inner.workers[0].state = WorkerState::Alive;
+ inner.workers[0].outstanding.clear();
+ inner.workers[0].free_slots = (0..2).collect();
+ inner.scheduler.submit(FrameRequest {
+ key: FrameKey {
+ sequence: 3,
+ frame: 0,
+ version: 0,
+ },
+ priority: crate::scheduler::FramePriority::Seek,
+ distance: 0,
+ payload: 1,
+ slot_bytes: 1 << 50,
+ });
+ dispatcher.dispatch_to(&mut inner, 0);
+ assert_eq!(inner.workers[0].slot_bytes, 128);
+ assert_eq!(inner.workers[0].state, WorkerState::Alive);
+ }
+ }
+
+ #[test]
+ fn dispatch_to_grow_failure_and_starvation_log() {
+ let _lock = pool_test_lock();
+ let dispatcher = ProcessDispatcher::new(test_config(1, 2)).expect("dispatcher");
+ {
+ let mut inner = lock(&dispatcher.inner);
+ let shm = ShmRegionView::create(&shm_key("starvation"), 2, 128).expect("shm");
+ let mut handle = WorkerHandle::shell(0, shm, 2, 128);
+ handle.state = WorkerState::Alive;
+ inner.workers.push(handle);
+
+ // Grow failure: a pending request far larger than any segment
+ // fails the rebuild and dispatch stops for this pump.
+ let huge_key = FrameKey {
+ sequence: 100,
+ frame: 0,
+ version: 0,
+ };
+ inner.scheduler.submit(FrameRequest {
+ key: huge_key,
+ priority: crate::scheduler::FramePriority::Seek,
+ distance: 0,
+ payload: 1,
+ slot_bytes: 1 << 50,
+ });
+ dispatcher.dispatch_to(&mut inner, 0);
+ assert_eq!(inner.scheduler.pending_len(), 1);
+ inner.scheduler.cancel_sequence(100);
+
+ // Starvation: outstanding work blocks the grow; the pending
+ // request is too large for the current slots -> claim reports
+ // None, and the debug flag logs why.
+ inner.workers[0].outstanding.insert(999, 0);
+ inner.workers[0].free_slots = (0..2).collect();
+ inner.scheduler.submit(FrameRequest {
+ key: FrameKey {
+ sequence: 101,
+ frame: 0,
+ version: 0,
+ },
+ priority: crate::scheduler::FramePriority::Seek,
+ distance: 0,
+ payload: 2,
+ slot_bytes: 4096,
+ });
+ let env = EnvRestore::set("OAK_DEBUG_DISPATCH", "1");
+ dispatcher.dispatch_to(&mut inner, 0);
+ drop(env);
+ assert_eq!(inner.scheduler.pending_len(), 1);
+ assert_eq!(inner.workers[0].outstanding.len(), 1);
+ assert_eq!(inner.workers[0].free_slots.len(), 2);
+ }
+ }
+
+ #[test]
+ fn dispatch_to_skips_claimed_request_without_ticket() {
+ let _lock = pool_test_lock();
+ let dispatcher = ProcessDispatcher::new(test_config(1, 2)).expect("dispatcher");
+ {
+ let mut inner = lock(&dispatcher.inner);
+ let shm = ShmRegionView::create(&shm_key("no-ticket"), 2, 128).expect("shm");
+ let mut handle = WorkerHandle::shell(0, shm, 2, 128);
+ handle.state = WorkerState::Alive;
+ inner.workers.push(handle);
+ inner.scheduler.submit(FrameRequest {
+ key: FrameKey {
+ sequence: 4,
+ frame: 0,
+ version: 0,
+ },
+ priority: crate::scheduler::FramePriority::Seek,
+ distance: 0,
+ payload: 12345,
+ slot_bytes: 64,
+ });
+ // The claimed request has no ticket entry: the slot stays
+ // assigned and the dispatch loop continues cleanly.
+ dispatcher.dispatch_to(&mut inner, 0);
+ assert_eq!(inner.scheduler.pending_len(), 0);
+ assert_eq!(inner.workers[0].outstanding.len(), 1);
+ assert_eq!(inner.workers[0].free_slots.len(), 1);
+ }
+ }
+
+ #[cfg(unix)]
+ #[test]
+ fn dispatch_to_video_audio_and_control_line_sends() {
+ let _lock = pool_test_lock();
+ let dispatcher = ProcessDispatcher::new(test_config(1, 2)).expect("dispatcher");
+ let mut child = std::process::Command::new("/bin/cat")
+ .stdin(Stdio::piped())
+ .stdout(Stdio::piped())
+ .spawn()
+ .expect("/bin/cat");
+ let stdin = child.stdin.take();
+ let mut fired: Vec<(Completion, TicketResult)> = Vec::new();
+ let env = EnvRestore::set("OAK_DEBUG_DISPATCH", "1");
+ {
+ let mut inner = lock(&dispatcher.inner);
+ let shm = ShmRegionView::create(&shm_key("cat-sends"), 2, 256).expect("shm");
+ let mut handle = WorkerHandle::shell(0, shm, 2, 256);
+ handle.state = WorkerState::Alive;
+ handle.stdin = stdin;
+ handle.child = Some(child);
+ inner.workers.push(handle);
+
+ // Control-plane writes succeed: handshake reply, then the
+ // hello_caps-triggered load_graph.
+ dispatcher.on_line(&mut inner, 0, r#"{"type":"handshake"}"#, &mut fired);
+ inner.config.graph_snapshot = Some("/nonexistent/graph.xml".into());
+ inner.workers[0].graph_sent = false;
+ dispatcher.on_line(
+ &mut inner,
+ 0,
+ r#"{"type":"hello_caps","protocol_version":1,"formats":[4],"max_slot_bytes":4096}"#,
+ &mut fired,
+ );
+ assert!(inner.workers[0].graph_sent);
+ assert_eq!(inner.workers[0].state, WorkerState::Alive);
+
+ // A mixed video+audio batch is written as two messages (the
+ // debug log runs with the env var set).
+ inner.tickets.insert(
+ 1,
+ PendingTicket {
+ key: FrameKey {
+ sequence: 5,
+ frame: 0,
+ version: 0,
+ },
+ params: Arc::new(video_params(0)),
+ audio: None,
+ done: None,
+ },
+ );
+ inner.tickets.insert(
+ 2,
+ PendingTicket {
+ key: FrameKey {
+ sequence: 5,
+ frame: 1,
+ version: 0,
+ },
+ params: Arc::new(video_params(1)),
+ audio: Some(Arc::new(AudioTicketParams {
+ viewer: 1,
+ range: oak_core::TimeRange::new(
+ oak_core::Rational::new(0, 1),
+ oak_core::Rational::new(1, 480),
+ ),
+ sample_rate: 48000,
+ channel_layout: 0x3,
+ montage: Vec::new(),
+ })),
+ done: None,
+ },
+ );
+ inner.scheduler.submit(FrameRequest {
+ key: FrameKey {
+ sequence: 5,
+ frame: 0,
+ version: 0,
+ },
+ priority: crate::scheduler::FramePriority::Seek,
+ distance: 0,
+ payload: 1,
+ slot_bytes: 64,
+ });
+ inner.scheduler.submit(FrameRequest {
+ key: FrameKey {
+ sequence: 5,
+ frame: 1,
+ version: 0,
+ },
+ priority: crate::scheduler::FramePriority::Seek,
+ distance: 0,
+ payload: 2,
+ slot_bytes: 64,
+ });
+ dispatcher.dispatch_to(&mut inner, 0);
+ assert_eq!(inner.workers[0].outstanding.len(), 2);
+ assert_eq!(inner.workers[0].free_slots.len(), 0);
+ // The scheduler must know about the synthetic workers below.
+ inner.scheduler.set_worker_count(3);
+
+ // Finish here: `post`/`shutdown` lock the dispatcher
+ // internally, so they run outside the guard below.
+ }
+ dispatcher.set_graph_snapshot(Some("/nonexistent/graph2.xml".into()));
+ dispatcher.set_graph_snapshot(None);
+ let results: Arc>> = Arc::new(Mutex::new(Vec::new()));
+ assert!(dispatcher.post(test_job(8, 0, None, &results)));
+ drop(env);
+ {
+ let mut inner = lock(&dispatcher.inner);
+ if let Some(handle) = inner.workers.get_mut(0) {
+ if let Some(mut c) = handle.child.take() {
+ let _ = c.kill();
+ let _ = c.wait();
+ }
+ handle.stdin = None;
+ }
+ }
+ dispatcher.shutdown();
+ }
+
+ /// A missing stdin on a dispatchable worker fails the send and marks
+ /// the worker dead (recycled).
+ #[test]
+ fn dispatch_to_missing_stdin_recycles_the_worker() {
+ let _lock = pool_test_lock();
+ let dispatcher = ProcessDispatcher::new(test_config(1, 2)).expect("dispatcher");
+ let key = FrameKey {
+ sequence: 11,
+ frame: 0,
+ version: 0,
+ };
+ {
+ let mut inner = lock(&dispatcher.inner);
+ let shm = ShmRegionView::create(&shm_key("no-stdin"), 2, 256).expect("shm");
+ let mut handle = WorkerHandle::shell(0, shm, 2, 256);
+ handle.state = WorkerState::Alive;
+ // No stdin: the send must fail and recycle the worker.
+ inner.workers.push(handle);
+ inner.tickets.insert(
+ 9,
+ PendingTicket {
+ key,
+ params: Arc::new(video_params(0)),
+ audio: None,
+ done: None,
+ },
+ );
+ inner.scheduler.submit(FrameRequest {
+ key,
+ priority: crate::scheduler::FramePriority::Seek,
+ distance: 0,
+ payload: 9,
+ slot_bytes: 64,
+ });
+ dispatcher.dispatch_to(&mut inner, 0);
+ assert_eq!(inner.workers[0].state, WorkerState::Dead);
+ }
+ }
+
+ #[test]
+ fn post_replace_inflight_oversized_and_shutdown() {
+ let _lock = pool_test_lock();
+ let dispatcher = ProcessDispatcher::new(test_config(1, 2)).expect("dispatcher");
+ let results: Arc>> = Arc::new(Mutex::new(Vec::new()));
+
+ // Replaced: a second post for the same key supersedes the first.
+ let mut first = test_job(1, 0, None, &results);
+ first.schedule.frame = Some(0);
+ assert!(dispatcher.post(first));
+ let mut second = test_job(1, 0, None, &results);
+ second.schedule.frame = Some(0);
+ assert!(dispatcher.post(second));
+ {
+ let got = results.lock().unwrap();
+ assert_eq!(got.len(), 1, "the replaced ticket fires once");
+ assert!(got[0].is_err());
+ }
+ results.lock().unwrap().clear();
+
+ // InFlight: the key is already claimed by a worker.
+ let key = FrameKey {
+ sequence: 5,
+ frame: 0,
+ version: 0,
+ };
+ {
+ let mut inner = lock(&dispatcher.inner);
+ inner.scheduler.submit(FrameRequest {
+ key,
+ priority: crate::scheduler::FramePriority::Seek,
+ distance: 0,
+ payload: 7,
+ slot_bytes: 64,
+ });
+ let claimed = inner.scheduler.claim_batch(0, 2, 64).expect("claim");
+ assert_eq!(claimed.frames.len(), 1);
+ }
+ let mut inflight = test_job(5, 0, None, &results);
+ inflight.schedule.frame = Some(0);
+ assert!(dispatcher.post(inflight));
+ {
+ let got = results.lock().unwrap();
+ assert_eq!(got.len(), 1, "the in-flight ticket is cancelled");
+ assert!(got[0].is_err());
+ }
+ results.lock().unwrap().clear();
+
+ // Oversized audio is refused (the arena falls back inline).
+ let big = Arc::new(AudioTicketParams {
+ viewer: 1,
+ range: oak_core::TimeRange::new(
+ oak_core::Rational::new(0, 1),
+ oak_core::Rational::new(175, 1),
+ ),
+ sample_rate: 48000,
+ channel_layout: 0x3,
+ montage: Vec::new(),
+ });
+ assert!(!dispatcher.post(test_job(6, 0, Some(big), &results)));
+ // An invalid (zero-length) audio range is refused too.
+ let zero = Arc::new(AudioTicketParams {
+ viewer: 1,
+ range: oak_core::TimeRange::new(
+ oak_core::Rational::new(0, 1),
+ oak_core::Rational::new(0, 1),
+ ),
+ sample_rate: 48000,
+ channel_layout: 0x3,
+ montage: Vec::new(),
+ });
+ assert!(!dispatcher.post(test_job(6, 0, Some(zero), &results)));
+
+ // Debug post logging with a synthetic (Starting) worker present.
+ {
+ let mut inner = lock(&dispatcher.inner);
+ let shm = ShmRegionView::create(&shm_key("post-debug"), 2, 64).expect("shm");
+ inner.workers.push(WorkerHandle::shell(0, shm, 2, 64));
+ }
+ let env = EnvRestore::set("OAK_DEBUG_DISPATCH", "1");
+ assert!(dispatcher.post(test_job(9, 0, None, &results)));
+ drop(env);
+
+ // After shutdown posts are refused and open tickets are cancelled.
+ dispatcher.shutdown();
+ assert!(!dispatcher.post(test_job(10, 0, None, &results)));
+ let got = results.lock().unwrap();
+ assert!(
+ !got.is_empty() && got.iter().all(|r| r.is_err()),
+ "open tickets cancelled at shutdown"
+ );
+ }
+
+ #[cfg(unix)]
+ #[test]
+ fn start_failure_restart_budget_and_accessors() {
+ let _lock = pool_test_lock();
+ let dispatcher = ProcessDispatcher::new(test_config(1, 2)).expect("dispatcher");
+ // Not started: set_target_workers is a no-op.
+ dispatcher.set_target_workers(4);
+ assert_eq!(dispatcher.worker_count(), 1);
+ assert_eq!(dispatcher.slots_per_worker(), 2);
+ assert_eq!(dispatcher.slot_bytes(), 16 * 16 * 4);
+ assert_eq!(dispatcher.slot_format(), SLOT_FORMAT_BGRA8);
+ assert!(!dispatcher.is_alive(0));
+ assert_eq!(dispatcher.restarts_of(0), 0);
+ assert_eq!(dispatcher.accepted_batches_of(0), 0);
+ assert!(dispatcher.shm_of(0).is_none());
+ // Nobody alive: the window reports the configured pool.
+ assert_eq!(dispatcher.preview_window_capacity(), 1);
+
+ // `/bin/true` exits immediately: the restart budget exhausts and
+ // start() fails permanently.
+ assert!(dispatcher.start().is_err());
+ assert!(!dispatcher.is_alive(0));
+ assert!(dispatcher.restarts_of(0) > MAX_RESTARTS);
+ assert_eq!(dispatcher.accepted_batches_of(0), 0);
+ assert_eq!(dispatcher.accepted_batches_of(99), 0);
+ assert!(dispatcher.shm_of(0).is_some());
+ assert!(dispatcher.shm_of(99).is_none());
+
+ // A second start is rejected; a same-target resize is a no-op.
+ assert!(dispatcher.start().is_err());
+ dispatcher.set_target_workers(1);
+
+ // poll() after shutdown is a no-op; shutdown is idempotent.
+ dispatcher.shutdown();
+ dispatcher.poll();
+ dispatcher.shutdown();
+ let results: Arc>> = Arc::new(Mutex::new(Vec::new()));
+ assert!(!dispatcher.post(test_job(1, 0, None, &results)));
+ assert!(results.lock().unwrap().is_empty());
+ }
+
+ #[cfg(unix)]
+ #[test]
+ fn resize_throttle_grow_failure_and_shrink_drain() {
+ let _lock = pool_test_lock();
+ let dispatcher = ProcessDispatcher::new(test_config(1, 2)).expect("dispatcher");
+ assert!(dispatcher.start().is_err(), "/bin/true never handshakes");
+ {
+ let mut inner = lock(&dispatcher.inner);
+ inner.bin = PathBuf::from("/nonexistent/oak-worker-for-resize-test");
+ inner.last_resize_at = None;
+ }
+ // Grow: the spawn failure is logged and the grow loop breaks.
+ dispatcher.set_target_workers(2);
+ assert_eq!(dispatcher.worker_count(), 2);
+ // A request inside the throttle window only stores the target.
+ {
+ let mut inner = lock(&dispatcher.inner);
+ inner.last_resize_at = Some(Instant::now());
+ }
+ dispatcher.set_target_workers(1);
+ {
+ let mut inner = lock(&dispatcher.inner);
+ assert_eq!(inner.next_target, Some(1));
+ let shm = ShmRegionView::create(&shm_key("shrink"), 2, 64).expect("shm");
+ let mut handle = WorkerHandle::shell(0, shm, 2, 64);
+ handle.state = WorkerState::Alive;
+ inner.workers.push(handle);
+ // Open the throttle window so the next pump applies the target.
+ inner.last_resize_at =
+ Some(Instant::now() - MIN_RESIZE_INTERVAL - Duration::from_secs(1));
+ }
+ dispatcher.poll();
+ assert_eq!(dispatcher.worker_count(), 1);
+ {
+ let mut inner = lock(&dispatcher.inner);
+ assert_eq!(
+ inner.workers.len(),
+ 1,
+ "the retiring worker drained and was reaped"
+ );
+ assert_eq!(inner.next_target, None);
+ // A Dead worker whose respawn fails stays Dead.
+ inner.workers[0].state = WorkerState::Dead;
+ inner.workers[0].retiring = false;
+ inner.workers[0].restarts = 0;
+ }
+ dispatcher.poll();
+ {
+ let inner = lock(&dispatcher.inner);
+ assert_eq!(inner.workers[0].state, WorkerState::Dead);
+ assert_eq!(inner.workers[0].restarts, 1);
+ }
+ // The retiring-crash path removes the worker outright.
+ {
+ let mut inner = lock(&dispatcher.inner);
+ let shm = ShmRegionView::create(&shm_key("retire-crash"), 2, 64).expect("shm");
+ let mut handle = WorkerHandle::shell(0, shm, 2, 64);
+ handle.state = WorkerState::Dead;
+ handle.retiring = true;
+ inner.workers.push(handle);
+ let index = inner.workers.len() - 1;
+ let mut fired = Vec::new();
+ dispatcher.restart_worker(&mut inner, index, &mut fired);
+ assert!(fired.is_empty());
+ assert_eq!(inner.workers.len(), index);
+ }
+ }
+
+ #[cfg(unix)]
+ #[test]
+ fn retiring_worker_hung_past_deadline_is_killed() {
+ let _lock = pool_test_lock();
+ let dispatcher = ProcessDispatcher::new(test_config(1, 1)).expect("dispatcher");
+ let mut child = std::process::Command::new("/bin/cat")
+ .stdin(Stdio::piped())
+ .stdout(Stdio::piped())
+ .spawn()
+ .expect("/bin/cat");
+ let stdin = child.stdin.take();
+ {
+ let mut inner = lock(&dispatcher.inner);
+ let shm = ShmRegionView::create(&shm_key("hung-retire"), 1, 64).expect("shm");
+ let mut handle = WorkerHandle::shell(0, shm, 1, 64);
+ handle.state = WorkerState::Starting;
+ handle.retiring = true;
+ handle.stdin = stdin;
+ handle.child = Some(child);
+ // Pretend the shutdown signal was sent 31 s ago.
+ handle.retire_sent_at = Some(Instant::now() - Duration::from_secs(31));
+ inner.workers.push(handle);
+ }
+ // `poll` locks the dispatcher internally: never call it under the
+ // guard (that deadlocks).
+ dispatcher.poll();
+ {
+ let inner = lock(&dispatcher.inner);
+ assert!(inner.workers.is_empty(), "hung retiring worker killed");
+ }
+ }
+
+ #[cfg(unix)]
+ #[test]
+ fn start_handshake_timeout_is_bounded() {
+ use std::os::unix::fs::PermissionsExt;
+ let _lock = pool_test_lock();
+ // A silent stand-in worker: it ignores the `--backend` argument and
+ // stays alive without ever speaking the protocol.
+ let script = std::env::temp_dir().join(format!(
+ "oak-procpool-silent-{}.sh",
+ std::process::id()
+ ));
+ std::fs::write(&script, "#!/bin/sh\nsleep 5\n").expect("script");
+ let mut perms = std::fs::metadata(&script).expect("meta").permissions();
+ perms.set_mode(0o755);
+ std::fs::set_permissions(&script, perms).expect("chmod");
+
+ let mut config = test_config(1, 1);
+ config.worker_bin = Some(script.clone());
+ config.handshake_timeout_ms = 60;
+ let dispatcher = ProcessDispatcher::new(config).expect("dispatcher");
+ let err = dispatcher.start().expect_err("no handshake arrives");
+ assert!(
+ format!("{err}").contains("handshake"),
+ "timeout error surfaced: {err}"
+ );
+ // Kill the silent worker (it would otherwise linger).
+ {
+ let mut inner = lock(&dispatcher.inner);
+ if let Some(handle) = inner.workers.get_mut(0) {
+ if let Some(mut c) = handle.child.take() {
+ let _ = c.kill();
+ let _ = c.wait();
+ }
+ handle.stdin = None;
+ }
+ }
+ let _ = std::fs::remove_file(&script);
+ }
+
+ #[cfg(unix)]
+ #[test]
+ fn shutdown_drains_events_and_fires_open_tickets() {
+ use std::os::unix::fs::PermissionsExt;
+ let _lock = pool_test_lock();
+ // A short-lived stand-in worker: alive for the first drain round,
+ // gone before the kill deadline.
+ let script = std::env::temp_dir().join(format!(
+ "oak-procpool-shutdown-{}.sh",
+ std::process::id()
+ ));
+ std::fs::write(&script, "#!/bin/sh\nsleep 0.1\n").expect("script");
+ let mut perms = std::fs::metadata(&script).expect("meta").permissions();
+ perms.set_mode(0o755);
+ std::fs::set_permissions(&script, perms).expect("chmod");
+
+ let mut config = test_config(1, 1);
+ config.worker_bin = Some(script.clone());
+ let dispatcher = ProcessDispatcher::new(config).expect("dispatcher");
+ let mut child = std::process::Command::new(&script)
+ .stdin(Stdio::piped())
+ .stdout(Stdio::piped())
+ .spawn()
+ .expect("stand-in worker");
+ let stdin = child.stdin.take();
+ let shm = ShmRegionView::create(&shm_key("shutdown-drain"), 2, 64).expect("shm");
+ let slot = publish_slot(&shm, 77);
+ let results: Arc>> = Arc::new(Mutex::new(Vec::new()));
+ {
+ let mut inner = lock(&dispatcher.inner);
+ let mut handle = WorkerHandle::shell(0, shm.clone(), 2, 64);
+ handle.state = WorkerState::Alive;
+ handle.stdin = stdin;
+ handle.child = Some(child);
+ handle.outstanding.insert(21, slot);
+ let sink = results.clone();
+ inner.tickets.insert(
+ 21,
+ PendingTicket {
+ key: FrameKey {
+ sequence: 1,
+ frame: 0,
+ version: 0,
+ },
+ params: Arc::new(video_params(0)),
+ audio: None,
+ done: Some(Box::new(move |result| {
+ sink.lock().unwrap_or_else(|e| e.into_inner()).push(result)
+ })),
+ },
+ );
+ // A ticket with no frame in flight must fail with Error::State.
+ let sink = results.clone();
+ inner.tickets.insert(
+ 22,
+ PendingTicket {
+ key: FrameKey {
+ sequence: 2,
+ frame: 0,
+ version: 0,
+ },
+ params: Arc::new(video_params(1)),
+ audio: None,
+ done: Some(Box::new(move |result| {
+ sink.lock().unwrap_or_else(|e| e.into_inner()).push(result)
+ })),
+ },
+ );
+ inner.workers.push(handle);
+ // Inject the frame_ready the fake worker would have sent, so the
+ // shutdown drain delivers a completion.
+ let line = format!(r#"{{"type":"frame_ready","ticket":21,"slot":{slot}}}"#);
+ assert!(inner
+ .events_tx
+ .send(WorkerEvent::Line {
+ worker: 0,
+ generation: 0,
+ line,
+ })
+ .is_ok());
+ }
+ dispatcher.shutdown();
+ {
+ let got = results.lock().unwrap();
+ assert_eq!(got.len(), 2, "one rendered frame + one cancellation");
+ assert!(got
+ .iter()
+ .any(|r| matches!(r, Ok(TicketPayload::ShmFrame(f)) if f.meta.id == 77)));
+ assert!(got.iter().any(|r| r.is_err()));
+ }
+ let _ = std::fs::remove_file(&script);
+ }
+
+ #[test]
+ fn build_audio_ticket_spec_carries_montage() {
+ let params = AudioTicketParams {
+ viewer: 3,
+ range: oak_core::TimeRange::new(
+ oak_core::Rational::new(1, 2),
+ oak_core::Rational::new(3, 2),
+ ),
+ sample_rate: 48000,
+ channel_layout: 0x3,
+ montage: vec![crate::ticket::MontageClip {
+ filename: "clip.mp4".into(),
+ stream_index: 1,
+ in_time: oak_core::Rational::new(1, 4),
+ out_time: oak_core::Rational::new(3, 4),
+ media_in: oak_core::Rational::new(0, 1),
+ gain: 0.5,
+ effects: Vec::new(),
+ }],
+ };
+ let spec = build_audio_ticket_spec(5, 2, ¶ms);
+ assert_eq!(spec.ticket, 5);
+ assert_eq!(spec.slot, 2);
+ assert_eq!(spec.time_num, 1);
+ assert_eq!(spec.time_den, 2);
+ assert_eq!(spec.duration_num, 1);
+ assert_eq!(spec.duration_den, 1);
+ assert_eq!(spec.sample_rate, 48000);
+ assert_eq!(spec.channels, 2);
+ assert_eq!(spec.montage.len(), 1);
+ assert_eq!(spec.montage[0].filename, "clip.mp4");
+ assert_eq!(spec.montage[0].stream_index, 1);
+ assert_eq!(spec.montage[0].in_num, 1);
+ assert_eq!(spec.montage[0].out_den, 4);
+ assert_eq!(spec.montage[0].media_in_num, 0);
+ assert_eq!(spec.montage[0].gain, 0.5);
+ }
+
+ #[test]
+ fn real_worker_video_audio_round_trip_and_release_paths() {
+ let _lock = pool_test_lock();
+ let Some(bin) = find_real_worker() else {
+ eprintln!("oak-worker binary not found; run `cargo build -p oak-worker`; skipping");
+ return;
+ };
+ let config = DispatcherConfig {
+ worker_bin: Some(bin),
+ workers: 1,
+ slots_per_worker: 4,
+ width: 16,
+ height: 16,
+ slot_format: SLOT_FORMAT_BGRA8,
+ batch_size: 2,
+ graph_snapshot: None,
+ handshake_timeout_ms: 60_000,
+ };
+ let dispatcher = ProcessDispatcher::new(config).expect("dispatcher");
+ dispatcher.start().expect("worker starts");
+ assert!(dispatcher.is_alive(0));
+ assert!(dispatcher.shm_of(0).is_some());
+ assert_eq!(dispatcher.slot_bytes(), 16 * 16 * 4);
+ assert!(dispatcher.preview_window_capacity() >= 1);
+
+ // Plugin-cancel broadcast to a live worker.
+ request_plugin_cancel_all();
+ // Graph snapshot push (the worker may reject the path; the send
+ // succeeds) and clear.
+ dispatcher.set_graph_snapshot(Some("/nonexistent/oak-graph.xml".into()));
+ dispatcher.poll();
+ dispatcher.set_graph_snapshot(None);
+
+ // A generated-frame video ticket renders into a BGRA8 slot.
+ let results: Arc>> = Arc::new(Mutex::new(Vec::new()));
+ assert!(dispatcher.post(test_job(1, 0, None, &results)));
+ pump_until(&dispatcher, &results, 1);
+ let result = results.lock().unwrap().pop().unwrap();
+ let Ok(TicketPayload::ShmFrame(frame)) = result else {
+ panic!("ShmFrame expected");
+ };
+ assert_eq!(frame.meta.width, 16);
+ assert_eq!(frame.meta.height, 16);
+ assert_eq!(frame.meta.format, SLOT_FORMAT_BGRA8);
+ // A stale ref (different segment) is ignored.
+ let other = ShmRegionView::create(&shm_key("stale-release"), 1, 64).expect("shm");
+ let stale = ShmFrameRef {
+ worker: 0,
+ slot: frame.slot,
+ meta: frame.meta.clone(),
+ shm: other,
+ };
+ dispatcher.release_frame(&stale);
+ dispatcher.release_frame(&frame);
+ dispatcher.release_frame(&frame); // double release: ignored
+
+ // An audio ticket: the range exceeds the slot, so the dispatcher
+ // grows the worker's segment before claiming.
+ let audio = Arc::new(AudioTicketParams {
+ viewer: 1,
+ range: oak_core::TimeRange::new(
+ oak_core::Rational::new(0, 1),
+ oak_core::Rational::new(1, 48),
+ ),
+ sample_rate: 48000,
+ channel_layout: 0x3,
+ montage: Vec::new(),
+ });
+ let audio_results: Arc>> = Arc::new(Mutex::new(Vec::new()));
+ assert!(dispatcher.post(test_job(2, 0, Some(audio), &audio_results)));
+ pump_until(&dispatcher, &audio_results, 1);
+ let result = audio_results.lock().unwrap().pop().unwrap();
+ let Ok(TicketPayload::ShmAudio(audio)) = result else {
+ panic!("ShmAudio expected");
+ };
+ assert_eq!(audio.sample_rate, 48000);
+ assert_eq!(audio.channel_count, 2);
+ JobDispatch::release_audio_frame(&*dispatcher, &audio);
+ dispatcher.shutdown();
+ }
+
+ #[test]
+ fn real_worker_crash_restarts_and_requeues() {
+ let _lock = pool_test_lock();
+ let Some(bin) = find_real_worker() else {
+ eprintln!("oak-worker binary not found; run `cargo build -p oak-worker`; skipping");
+ return;
+ };
+ let marker = std::env::temp_dir().join(format!(
+ "oak-procpool-crash-ut-{}",
+ std::process::id()
+ ));
+ let _ = std::fs::remove_file(&marker);
+ let _crash = EnvRestore::set("OAK_WORKER_CRASH_ON_TICKET", "1");
+ let _marker = EnvRestore::set(
+ "OAK_WORKER_CRASH_MARKER",
+ marker.to_string_lossy().as_ref(),
+ );
+ let config = DispatcherConfig {
+ worker_bin: Some(bin),
+ workers: 1,
+ slots_per_worker: 4,
+ width: 16,
+ height: 16,
+ slot_format: SLOT_FORMAT_BGRA8,
+ batch_size: 2,
+ graph_snapshot: None,
+ handshake_timeout_ms: 60_000,
+ };
+ let dispatcher = ProcessDispatcher::new(config).expect("dispatcher");
+ dispatcher.start().expect("worker starts");
+ let results: Arc>> = Arc::new(Mutex::new(Vec::new()));
+ assert!(dispatcher.post(test_job(1, 0, None, &results)));
+ assert!(dispatcher.post(test_job(1, 1, None, &results)));
+ pump_until(&dispatcher, &results, 2);
+ let restarts = dispatcher.restarts_of(0);
+ assert!(
+ restarts >= 1,
+ "the crashed worker restarted (restarts={restarts})"
+ );
+ let mut seen = 0usize;
+ for result in results.lock().unwrap().drain(..) {
+ let payload = result.expect("frame rendered despite the crash");
+ if let TicketPayload::ShmFrame(frame) = payload {
+ seen += 1;
+ dispatcher.release_frame(&frame);
+ }
+ }
+ assert_eq!(seen, 2);
+ assert!(marker.exists(), "the crash hook fired");
+ let _ = std::fs::remove_file(&marker);
+ dispatcher.shutdown();
+ }
+
+ #[test]
+ fn plugin_cancel_broadcast_marks_dead_and_snapshot_after_shutdown() {
+ let _lock = pool_test_lock();
+ let dispatcher = ProcessDispatcher::new(test_config(1, 1)).expect("dispatcher");
+ {
+ let mut inner = lock(&dispatcher.inner);
+ let shm = ShmRegionView::create(&shm_key("plugin-cancel"), 1, 64).expect("shm");
+ inner.workers.push(WorkerHandle::shell(0, shm, 1, 64)); // Starting, no stdin
+ }
+ // Point the process-wide slot at this dispatcher so the broadcast
+ // reaches it deterministically.
+ *dispatcher_slot().lock().unwrap_or_else(|e| e.into_inner()) = Arc::downgrade(&dispatcher);
+ request_plugin_cancel_all();
+ {
+ let inner = lock(&dispatcher.inner);
+ assert_eq!(inner.workers[0].state, WorkerState::Dead);
+ }
+ // After shutdown the graph-snapshot setter is a no-op.
+ dispatcher.shutdown();
+ dispatcher.set_graph_snapshot(Some("/nonexistent/graph.xml".into()));
+ {
+ let inner = lock(&dispatcher.inner);
+ assert!(inner.config.graph_snapshot.is_none());
+ }
+ }
+
+ #[test]
+ fn stale_generation_events_are_dropped() {
+ let _lock = pool_test_lock();
+ let dispatcher = ProcessDispatcher::new(test_config(1, 1)).expect("dispatcher");
+ {
+ let mut inner = lock(&dispatcher.inner);
+ let shm = ShmRegionView::create(&shm_key("stale-events"), 1, 64).expect("shm");
+ let mut handle = WorkerHandle::shell(5, shm, 1, 64);
+ handle.state = WorkerState::PermanentlyDead;
+ let tx = inner.events_tx.clone();
+ inner.workers.push(handle);
+ // Events from an older spawn generation are dropped.
+ assert!(tx
+ .send(WorkerEvent::Line {
+ worker: 0,
+ generation: 4,
+ line: r#"{"type":"batch_accepted","batch_id":1,"tickets":[]}"#.into(),
+ })
+ .is_ok());
+ assert!(tx
+ .send(WorkerEvent::Eof {
+ worker: 0,
+ generation: 4,
+ })
+ .is_ok());
+ // The current generation's EOF is applied (the state is kept
+ // PermanentlyDead rather than downgraded to Dead).
+ assert!(tx
+ .send(WorkerEvent::Eof {
+ worker: 0,
+ generation: 5,
+ })
+ .is_ok());
+ }
+ dispatcher.poll();
+ {
+ let inner = lock(&dispatcher.inner);
+ assert_eq!(inner.workers[0].accepted_batches, 0, "stale line dropped");
+ assert_eq!(inner.workers[0].state, WorkerState::PermanentlyDead);
+ }
+ }
+
+ // ---- Branch-coverage fill-ins: control-plane send failures and
+ // ---- environment-dependent query paths -------------------------------
+
+ /// `set_graph_snapshot(Some(..))` on a worker whose stdin is gone marks
+ /// it Dead (the send failure recycles it); clearing still only updates
+ /// the config.
+ #[test]
+ fn set_graph_snapshot_marks_dead_on_send_failure() {
+ let _lock = pool_test_lock();
+ let dispatcher = ProcessDispatcher::new(test_config(1, 1)).expect("dispatcher");
+ {
+ let mut inner = lock(&dispatcher.inner);
+ let shm = ShmRegionView::create(&shm_key("graph-send"), 1, 64).expect("shm");
+ let mut handle = WorkerHandle::shell(0, shm, 1, 64);
+ handle.state = WorkerState::Alive;
+ inner.workers.push(handle);
+ }
+ dispatcher.set_graph_snapshot(Some("/nonexistent/oak-graph.xml".into()));
+ {
+ let inner = lock(&dispatcher.inner);
+ assert!(inner.workers[0].graph_sent);
+ assert_eq!(inner.workers[0].state, WorkerState::Dead);
+ assert_eq!(
+ inner.config.graph_snapshot.as_deref(),
+ Some("/nonexistent/oak-graph.xml")
+ );
+ }
+ dispatcher.set_graph_snapshot(None);
+ assert!(lock(&dispatcher.inner).config.graph_snapshot.is_none());
+ }
+
+ /// Restart budget exhausted: the reclaimed frames of the dead worker
+ /// fail permanently and the worker stays `PermanentlyDead`.
+ #[test]
+ fn restart_budget_exhausted_fails_reclaimed_frames() {
+ let _lock = pool_test_lock();
+ let dispatcher = ProcessDispatcher::new(test_config(1, 2)).expect("dispatcher");
+ let results: Arc>> = Arc::new(Mutex::new(Vec::new()));
+ let key = FrameKey {
+ sequence: 42,
+ frame: 0,
+ version: 0,
+ };
+ let mut fired = Vec::new();
+ {
+ let mut inner = lock(&dispatcher.inner);
+ let shm = ShmRegionView::create(&shm_key("restart-budget"), 2, 64).expect("shm");
+ let mut handle = WorkerHandle::shell(0, shm, 2, 64);
+ handle.state = WorkerState::Dead;
+ // The next restart exceeds MAX_RESTARTS.
+ handle.restarts = MAX_RESTARTS + 1;
+ inner.workers.push(handle);
+ inner.scheduler.submit(FrameRequest {
+ key,
+ priority: crate::scheduler::FramePriority::Seek,
+ distance: 0,
+ payload: 1,
+ slot_bytes: 64,
+ });
+ let claimed = inner.scheduler.claim_batch(0, 2, 64).expect("claimed");
+ assert_eq!(claimed.frames.len(), 1, "the frame is in flight");
+ let sink = results.clone();
+ inner.tickets.insert(
+ 1,
+ PendingTicket {
+ key,
+ params: Arc::new(video_params(0)),
+ audio: None,
+ done: Some(Box::new(move |result| {
+ sink.lock().unwrap_or_else(|e| e.into_inner()).push(result)
+ })),
+ },
+ );
+ dispatcher.restart_worker(&mut inner, 0, &mut fired);
+ assert_eq!(inner.workers[0].state, WorkerState::PermanentlyDead);
+ assert!(inner.tickets.is_empty(), "the ticket was reaped");
+ }
+ assert_eq!(fired.len(), 1);
+ for (done, result) in fired {
+ done(result);
+ }
+ let got = results.lock().unwrap();
+ assert_eq!(got.len(), 1);
+ assert!(got[0].is_err(), "the dropped frame fails permanently");
+ }
+
+ /// An audio-only batch on a worker without stdin: the video message is
+ /// skipped and the audio send failure recycles the worker.
+ #[test]
+ fn dispatch_to_audio_only_missing_stdin_recycles_the_worker() {
+ let _lock = pool_test_lock();
+ let dispatcher = ProcessDispatcher::new(test_config(1, 1)).expect("dispatcher");
+ let key = FrameKey {
+ sequence: 12,
+ frame: 0,
+ version: 0,
+ };
+ {
+ let mut inner = lock(&dispatcher.inner);
+ let shm = ShmRegionView::create(&shm_key("audio-no-stdin"), 1, 256).expect("shm");
+ let mut handle = WorkerHandle::shell(0, shm, 1, 256);
+ handle.state = WorkerState::Alive;
+ inner.workers.push(handle);
+ inner.tickets.insert(
+ 10,
+ PendingTicket {
+ key,
+ params: Arc::new(video_params(0)),
+ audio: Some(Arc::new(AudioTicketParams {
+ viewer: 1,
+ range: oak_core::TimeRange::new(
+ oak_core::Rational::new(0, 1),
+ oak_core::Rational::new(1, 480),
+ ),
+ sample_rate: 48000,
+ channel_layout: 0x3,
+ montage: Vec::new(),
+ })),
+ done: None,
+ },
+ );
+ inner.scheduler.submit(FrameRequest {
+ key,
+ priority: crate::scheduler::FramePriority::Seek,
+ distance: 0,
+ payload: 10,
+ slot_bytes: 64,
+ });
+ dispatcher.dispatch_to(&mut inner, 0);
+ assert_eq!(inner.workers[0].state, WorkerState::Dead);
+ assert_eq!(
+ inner.workers[0].outstanding.len(),
+ 1,
+ "the slot was assigned before the failed send"
+ );
+ }
+ }
+
+ #[test]
+ fn on_frame_ready_for_unknown_worker_is_ignored() {
+ let _lock = pool_test_lock();
+ let dispatcher = ProcessDispatcher::new(test_config(1, 1)).expect("dispatcher");
+ let mut fired = Vec::new();
+ {
+ let mut inner = lock(&dispatcher.inner);
+ // No worker at index 99: the completion path returns early.
+ dispatcher.on_frame_ready(&mut inner, 99, 1, 0, &mut fired);
+ }
+ assert!(fired.is_empty());
+ }
+
+ /// The producer in `test_job` is a stub (the process backend renders
+ /// from the wire spec, never in-process); invoking it documents and
+ /// covers the never-taken inline path.
+ #[test]
+ fn test_job_produce_stub_errors() {
+ let results: Arc>> = Arc::new(Mutex::new(Vec::new()));
+ let job = test_job(1, 0, None, &results);
+ let out = (job.produce)(oak_core::Rational::new(0, 1), &job.params);
+ assert!(
+ out.is_err(),
+ "the process backend never runs the in-process producer"
+ );
+ }
+
+ #[test]
+ fn env_restore_restores_previous_value() {
+ let _lock = pool_test_lock();
+ let key = "OAK_PROCPOOL_TEST_RESTORE";
+ std::env::set_var(key, "old");
+ {
+ let _restore = EnvRestore::set(key, "new");
+ assert_eq!(std::env::var(key).as_deref(), Ok("new"));
+ }
+ assert_eq!(
+ std::env::var(key).as_deref(),
+ Ok("old"),
+ "a previously set value is restored, not removed"
+ );
+ std::env::remove_var(key);
+ }
+
+ #[test]
+ fn find_real_worker_honors_existing_env_override() {
+ let _lock = pool_test_lock();
+ let _env = EnvRestore::set("OAK_WORKER_BIN", "/bin/sh");
+ assert_eq!(
+ find_real_worker().as_deref(),
+ Some(std::path::Path::new("/bin/sh")),
+ "an existing OAK_WORKER_BIN wins over the sibling probe"
+ );
+ }
+
+ /// The vram probes are environment-dependent (NVIDIA CLI, DRM sysfs);
+ /// the query must never panic and the capacity either reports a sane
+ /// bound or falls back to the RAM policy.
+ #[test]
+ fn gpu_vram_query_smoke() {
+ let _ = nvidia_vram_bytes();
+ let _ = gpu_vram_bytes();
+ let cap = gpu_worker_capacity((1920, 1080), 30);
+ assert!(cap.is_none() || cap.unwrap() >= 1);
+ }
+
+ /// With the NVIDIA CLI unavailable the probe falls through to the
+ /// Linux DRM sysfs walk (or the `None` fallback on hosts without DRM
+ /// attrs). PATH is narrowed only for the duration of this locked test.
+ #[cfg(target_os = "linux")]
+ #[test]
+ fn gpu_vram_bytes_falls_back_to_drm_without_nvidia_cli() {
+ let _lock = pool_test_lock();
+ let _path = EnvRestore::set("PATH", "/nonexistent-oak-vram-probe");
+ assert!(
+ nvidia_vram_bytes().is_none(),
+ "nvidia-smi cannot be spawned without PATH"
+ );
+ // Exercises the sysfs arm (or the None fallback) of gpu_vram_bytes.
+ let _ = gpu_vram_bytes();
+ }
+
+ /// Every `nvidia-smi` response shape: non-zero exit, non-UTF-8 output,
+ /// a missing/malformed CSV pair, a negative free value and a zero
+ /// total are all rejected; a valid pair converts MiB to bytes.
+ #[cfg(unix)]
+ #[test]
+ fn nvidia_vram_bytes_rejects_every_bad_query() {
+ use std::os::unix::fs::PermissionsExt;
+ let _lock = pool_test_lock();
+ let dir = std::env::temp_dir().join(format!("oak-vram-fake-smi-{}", std::process::id()));
+ let _ = std::fs::remove_dir_all(&dir);
+ std::fs::create_dir_all(&dir).expect("fixture dir");
+ let script = dir.join("nvidia-smi");
+ let write = |body: &str| {
+ std::fs::write(&script, body).expect("script");
+ let mut perms = std::fs::metadata(&script).expect("meta").permissions();
+ perms.set_mode(0o755);
+ std::fs::set_permissions(&script, perms).expect("chmod");
+ };
+ let _path = EnvRestore::set("PATH", dir.to_str().expect("utf8 path"));
+
+ // Non-zero exit -> None.
+ write("#!/bin/sh\nexit 1\n");
+ assert!(nvidia_vram_bytes().is_none(), "failed query");
+ // Non-UTF-8 stdout -> None.
+ write("#!/bin/sh\nprintf '\\377\\376'\n");
+ assert!(nvidia_vram_bytes().is_none(), "non-UTF-8 query");
+ // No comma in the first line -> None.
+ write("#!/bin/sh\nprintf '16155\\n'\n");
+ assert!(nvidia_vram_bytes().is_none(), "missing separator");
+ // Unparsable numbers -> None.
+ write("#!/bin/sh\nprintf 'abc, 24576\\n'\n");
+ assert!(nvidia_vram_bytes().is_none(), "unparsable free");
+ // Negative free (driver 'unknown') -> None.
+ write("#!/bin/sh\nprintf ' -1, 24576\\n'\n");
+ assert!(nvidia_vram_bytes().is_none(), "negative free");
+ // Zero total -> None.
+ write("#!/bin/sh\nprintf '100, 0\\n'\n");
+ assert!(nvidia_vram_bytes().is_none(), "zero total");
+ // A valid pair converts MiB -> bytes.
+ write("#!/bin/sh\nprintf '100, 200\\n'\n");
+ assert_eq!(nvidia_vram_bytes(), Some((100 << 20, 200 << 20)));
+
+ drop(_path);
+ let _ = std::fs::remove_dir_all(&dir);
+ }
+
+ /// The DRM walk skips a card whose `total` is unparsable and treats an
+ /// unparsable `used` as zero, then picks the next usable card.
+ #[cfg(target_os = "linux")]
+ #[test]
+ fn linux_drm_vram_walk_skips_unparsable_total() {
+ let dir = std::env::temp_dir().join(format!(
+ "oak_vram_fixture_bad_{}_{}",
+ std::process::id(),
+ std::thread::current().name().unwrap_or("t")
+ ));
+ let _ = std::fs::remove_dir_all(&dir);
+ // card0: an unparsable total -> skipped by the walk.
+ let card0 = dir.join("card0").join("device");
+ std::fs::create_dir_all(&card0).unwrap();
+ std::fs::write(card0.join("mem_info_vram_total"), "not-a-number").unwrap();
+ // card1: a valid total, but an unparsable `used` counts as zero.
+ let card1 = dir.join("card1").join("device");
+ std::fs::create_dir_all(&card1).unwrap();
+ std::fs::write(card1.join("mem_info_vram_total"), (2u64 << 30).to_string()).unwrap();
+ std::fs::write(card1.join("mem_info_vram_used"), "garbage").unwrap();
+
+ let (free, total) = linux_drm_vram_bytes_from(&dir).expect("card1 wins");
+ assert_eq!(total, 2 << 30);
+ assert_eq!(free, 2 << 30, "an unparsable `used` counts as zero");
+ let _ = std::fs::remove_dir_all(&dir);
+ }
+
+ /// The spawn reader turns a non-UTF-8 stdout line into an EOF event
+ /// (`BufRead::read_line` errors on invalid UTF-8) instead of spinning.
+ #[cfg(unix)]
+ #[test]
+ fn spawn_worker_reader_reports_eof_on_invalid_utf8() {
+ use std::os::unix::fs::PermissionsExt;
+ let _lock = pool_test_lock();
+ let script = std::env::temp_dir().join(format!(
+ "oak-procpool-badutf8-{}.sh",
+ std::process::id()
+ ));
+ std::fs::write(&script, "#!/bin/sh\nprintf '\\377\\n'\nsleep 5\n").expect("script");
+ let mut perms = std::fs::metadata(&script).expect("meta").permissions();
+ perms.set_mode(0o755);
+ std::fs::set_permissions(&script, perms).expect("chmod");
+
+ let dispatcher = ProcessDispatcher::new(test_config(1, 1)).expect("dispatcher");
+ let mut inner = lock(&dispatcher.inner);
+ inner.bin = script.clone();
+ dispatcher.spawn_worker(&mut inner, 0).expect("spawn");
+ // Bounded wait for the reader's EOF event.
+ let deadline = Instant::now() + Duration::from_secs(10);
+ let mut eof = false;
+ while !eof && Instant::now() < deadline {
+ while let Ok(ev) = inner.events_rx.try_recv() {
+ if matches!(ev, WorkerEvent::Eof { worker: 0, .. }) {
+ eof = true;
+ }
+ }
+ if !eof {
+ std::thread::sleep(Duration::from_millis(2));
+ }
+ }
+ assert!(eof, "invalid UTF-8 must surface as an EOF event");
+ if let Some(mut child) = inner.workers[0].child.take() {
+ let _ = child.kill();
+ let _ = child.wait();
+ }
+ drop(inner);
+ let _ = std::fs::remove_file(&script);
+ }
}
diff --git a/crates/oak-render/tests/common/mod.rs b/crates/oak-render/tests/common/mod.rs
index 35f1843bd..6f4264301 100644
--- a/crates/oak-render/tests/common/mod.rs
+++ b/crates/oak-render/tests/common/mod.rs
@@ -58,6 +58,45 @@ impl Drop for ManagerGuard {
}
}
+/// A GPU context suitable for the M5 zero-copy hardware import (Vulkan on
+/// Linux/Windows, Metal on macOS), or `None` when no such adapter exists
+/// (CI's software Vulkan still qualifies — the *decoder* side decides
+/// whether there is an importable hardware surface).
+///
+/// Missing adapters follow the repo-wide `OAK_REQUIRE_GPU` policy used by
+/// `backend::gpu_or_skip`/`shared_gpu_or_skip`: on a job that promises a
+/// GPU (the CI runner has lavapipe) a missing adapter panics instead of
+/// silently dropping the import signal; elsewhere the skip prints a
+/// distinctive `SKIP:` marker so CI logs distinguish skipped from executed
+/// tests.
+pub fn gpu_context_for_import() -> Option> {
+ let created = oak_core::backend::GpuContext::create(oak_core::backend::BackendKind::Auto);
+ let Some(ctx) = created else {
+ skip_import_gpu("no GPU adapter");
+ return None;
+ };
+ if matches!(
+ ctx.kind(),
+ oak_core::backend::BackendKind::Vulkan | oak_core::backend::BackendKind::Metal
+ ) {
+ return Some(ctx);
+ }
+ skip_import_gpu("the adapter is not Vulkan/Metal");
+ None
+}
+
+/// Report (and under `OAK_REQUIRE_GPU`, fail) a missing import-capable
+/// adapter. The single `SKIP:` line keeps the skip observable in CI logs.
+fn skip_import_gpu(reason: &str) {
+ if oak_core::backend::require_gpu_adapter() {
+ panic!(
+ "no importable GPU context for the M5 hardware import ({reason}); \
+ OAK_REQUIRE_GPU is set"
+ );
+ }
+ eprintln!("SKIP: footage hardware import: {reason}");
+}
+
// ---------------------------------------------------------------------------
// Host-symbol stand-ins (oakcore_* / fb_find_best_pix_fmt_of_list)
// ---------------------------------------------------------------------------
diff --git a/crates/oak-render/tests/footage_import_test.rs b/crates/oak-render/tests/footage_import_test.rs
new file mode 100644
index 000000000..bda6367da
--- /dev/null
+++ b/crates/oak-render/tests/footage_import_test.rs
@@ -0,0 +1,394 @@
+// 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 .
+
+//! M5 acceptance: the zero-copy hardware-decode import.
+//!
+//! With a shared GPU context installed (the app's render device), a
+//! hardware-decoded frame is imported as planar GPU textures and resolved
+//! to working-space RGBA on the GPU — `HW_TRANSFERS` (the CPU download
+//! counter) must not move. Turning the import switch off must give the
+//! same pixels through the CPU staging path (within decoder/swscale
+//! rounding; the threshold is documented at the comparison).
+//!
+//! The assertions need an importable *hardware decoder* (VAAPI/NVDEC/
+//! D3D11VA/VideoToolbox), not just a GPU context: the lavapipe CI runner
+//! has software Vulkan but no `/dev/dri`, so the decoder never produces
+//! an importable surface and every test here logs a `SKIP:` line and
+//! returns. The staging fallback is covered by the existing decode tests;
+//! the real-hardware acceptance run is recorded in
+//! `docs/zh/plans/render-pipeline-threads-m5-branch-coverage.txt` (M5
+//! platform rows) and has to be repeated on a VAAPI/D3D11VA/VideoToolbox
+//! machine.
+
+use std::sync::Mutex;
+
+use oak_core::texture::Texture;
+use oak_core::{PixelFormat, Rational};
+
+mod common;
+
+/// Tests in this binary share the process-wide eval frame cache, the
+/// import switch and the shared GPU context slot; serialize them.
+static SERIAL: Mutex<()> = Mutex::new(());
+
+/// Forces the CPU staging decode (`OAK_GPU_IMPORT=0`) for the reference
+/// frame, and restores the previous value on drop: the variable is
+/// process-wide, so a mid-test panic (`expect("staging decode")`) must not
+/// leak `"0"` into later tests, and an operator-preset value must survive
+/// the test. The tests serialize on `SERIAL`, so the override is
+/// race-free here (mirrors `SoftwareDecodeGuard` in
+/// `render_threads_test.rs`).
+struct StagingDecodeGuard {
+ prev: Option,
+}
+
+impl StagingDecodeGuard {
+ fn set() -> Self {
+ let prev = std::env::var("OAK_GPU_IMPORT").ok();
+ std::env::set_var("OAK_GPU_IMPORT", "0");
+ Self { prev }
+ }
+}
+
+impl Drop for StagingDecodeGuard {
+ fn drop(&mut self) {
+ match &self.prev {
+ Some(p) => std::env::set_var("OAK_GPU_IMPORT", p),
+ None => std::env::remove_var("OAK_GPU_IMPORT"),
+ }
+ }
+}
+
+fn clip_path(tag: &str) -> std::path::PathBuf {
+ std::env::temp_dir().join(format!(
+ "oakrender_import_{tag}_{}.mp4",
+ std::process::id()
+ ))
+}
+
+fn write_clip(tag: &str) -> std::path::PathBuf {
+ let path = clip_path(tag);
+ oak_codec::testmedia::write_test_clip(&path, 64, 64, 10, 10).expect("test clip generation");
+ path
+}
+
+fn pin_legacy_working_space() {
+ oak_core::color::set_pipeline_color_settings(
+ oak_core::colormath::WorkingColorSpace::SrgbLegacy,
+ oak_core::colormath::OutputColorSpec::default(),
+ );
+}
+
+/// Sample the decoded F32 frame at a pixel.
+fn sample(frame: &oak_core::texture::Frame, x: usize, y: usize) -> [f32; 4] {
+ assert_eq!(
+ frame.format,
+ PixelFormat::F32,
+ "sample() interprets the frame bytes as f32"
+ );
+ let stride = frame.linesize_bytes();
+ let off = y * stride + x * 16;
+ let mut out = [0f32; 4];
+ for (i, channel) in out.iter_mut().enumerate() {
+ *channel =
+ f32::from_le_bytes(frame.data[off + i * 4..off + i * 4 + 4].try_into().unwrap());
+ }
+ out
+}
+
+#[test]
+fn hardware_import_is_zero_copy_and_matches_staging() {
+ let _guard = SERIAL.lock().unwrap_or_else(|e| e.into_inner());
+ pin_legacy_working_space();
+
+ // The import needs the render device the app installs; without a GPU
+ // there is nothing to test (the staging path is the fallback).
+ let Some(ctx) = common::gpu_context_for_import() else {
+ // The helper logged `SKIP:` (or panicked under OAK_REQUIRE_GPU).
+ return;
+ };
+ oak_core::backend::GpuContext::install_shared(Some(ctx.clone()));
+
+ oak_codec::gpuinterop::reset_import_counters();
+ let transfers_before = oak_codec::hwdecode::HW_TRANSFERS.load(std::sync::atomic::Ordering::Relaxed);
+
+ let path = write_clip("zero");
+ let imported = oak_render::eval::render_footage_frame(
+ &path.to_string_lossy(),
+ 0,
+ Rational::new(0, 1),
+ (0, 0), // native size: the import cannot resize
+ PixelFormat::F32,
+ )
+ .expect("decode frame 0");
+
+ if oak_codec::gpuinterop::HW_IMPORTS.load(std::sync::atomic::Ordering::Relaxed) == 0 {
+ eprintln!(
+ "SKIP: hardware import unavailable; imports={} fallbacks={} transfers={}",
+ oak_codec::gpuinterop::HW_IMPORTS.load(std::sync::atomic::Ordering::Relaxed),
+ oak_codec::gpuinterop::HW_IMPORT_FALLBACKS.load(std::sync::atomic::Ordering::Relaxed),
+ oak_codec::hwdecode::HW_TRANSFERS.load(std::sync::atomic::Ordering::Relaxed),
+ );
+ let _ = std::fs::remove_file(&path);
+ return;
+ }
+
+ eprintln!(
+ "import took the frame: imports={} transfers={} (before {transfers_before})",
+ oak_codec::gpuinterop::HW_IMPORTS.load(std::sync::atomic::Ordering::Relaxed),
+ oak_codec::hwdecode::HW_TRANSFERS.load(std::sync::atomic::Ordering::Relaxed),
+ );
+
+ // The import took the frame: the result is GPU-resident and no CPU
+ // download happened.
+ assert!(
+ matches!(imported, Texture::Gpu { .. }),
+ "imported decode must resolve to a GPU texture, got {imported:?}"
+ );
+ assert_eq!(
+ oak_codec::hwdecode::HW_TRANSFERS.load(std::sync::atomic::Ordering::Relaxed),
+ transfers_before,
+ "the zero-copy path must not call av_hwframe_transfer_data"
+ );
+ let imported_frame = imported.to_frame().expect("download resolved frame");
+ assert_eq!((imported_frame.width, imported_frame.height), (64, 64));
+
+ // Staging reference: the same media copied to a second path (a
+ // distinct eval cache key) decoded with the import switch off.
+ let staging_path = clip_path("staging");
+ std::fs::copy(&path, &staging_path).expect("copy clip");
+ let staging_guard = StagingDecodeGuard::set();
+ let staging = oak_render::eval::render_footage_frame(
+ &staging_path.to_string_lossy(),
+ 0,
+ Rational::new(0, 1),
+ (0, 0),
+ PixelFormat::F32,
+ )
+ .expect("staging decode");
+ drop(staging_guard);
+ let Texture::Cpu(staging_frame) = &staging else {
+ panic!("staging decode must stay on the CPU: {staging:?}");
+ };
+ assert_eq!((staging_frame.width, staging_frame.height), (64, 64));
+
+ // Same content (the GPU pass uses the frame's own matrix/range and
+ // bilinear chroma sampling; the CPU path uses swscale's chroma
+ // filtering, so the two differ slightly). 0.08 is the real
+ // hardware-vs-software decode precedent
+ // (`oak-codec/src/realmedia_tests.rs::hardware_decode_matches_software_decode`);
+ // the M5 reference hardware (RTX 5070 Ti + nvidia-vaapi-driver)
+ // measures 0.009 here, so the threshold has an order of magnitude of
+ // headroom.
+ let mut max_diff = 0.0f32;
+ for y in 0..64 {
+ for x in 0..64 {
+ let a = sample(&imported_frame, x, y);
+ let b = sample(staging_frame, x, y);
+ for c in 0..3 {
+ max_diff = max_diff.max((a[c] - b[c]).abs());
+ }
+ }
+ }
+ eprintln!("sRGB import vs staging: max diff {max_diff}");
+ assert!(
+ max_diff < 0.08,
+ "import and staging decodes diverge (max channel diff {max_diff})"
+ );
+
+ // The known test pattern survives the GPU path: left half red, right
+ // half blue on frame 0.
+ let [r, g, b, a] = sample(&imported_frame, 8, 32);
+ assert!(r > 0.5 && g < 0.4 && b < 0.4, "left half red: {r},{g},{b}");
+ assert!(a > 0.9, "opaque: {a}");
+ let [r, g, b, _] = sample(&imported_frame, 56, 32);
+ assert!(b > 0.5 && r < 0.4 && g < 0.4, "right half blue: {r},{g},{b}");
+
+ let _ = std::fs::remove_file(&path);
+ let _ = std::fs::remove_file(&staging_path);
+}
+
+#[test]
+fn hardware_import_applies_the_source_to_working_lut() {
+ let _guard = SERIAL.lock().unwrap_or_else(|e| e.into_inner());
+ // ACEScg working space: the import path must run the source→working
+ // transform on the GPU (the baked 3D LUT), matching the CPU path's
+ // exact per-pixel `decode_to_acescg`.
+ oak_core::color::set_pipeline_color_settings(
+ oak_core::colormath::WorkingColorSpace::AcesCg,
+ oak_core::colormath::OutputColorSpec::default(),
+ );
+
+ let Some(ctx) = common::gpu_context_for_import() else {
+ // The helper logged `SKIP:` (or panicked under OAK_REQUIRE_GPU).
+ return;
+ };
+ oak_core::backend::GpuContext::install_shared(Some(ctx.clone()));
+
+ oak_codec::gpuinterop::reset_import_counters();
+ let path = write_clip("aces");
+ let imported = oak_render::eval::render_footage_frame(
+ &path.to_string_lossy(),
+ 0,
+ Rational::new(0, 1),
+ (0, 0),
+ PixelFormat::F32,
+ )
+ .expect("decode frame 0");
+ if oak_codec::gpuinterop::HW_IMPORTS.load(std::sync::atomic::Ordering::Relaxed) == 0 {
+ eprintln!("SKIP: hardware import unavailable; skipping assertions");
+ let _ = std::fs::remove_file(&path);
+ return;
+ }
+ let imported_frame = imported.to_frame().expect("download resolved frame");
+
+ let staging_path = clip_path("aces_staging");
+ std::fs::copy(&path, &staging_path).expect("copy clip");
+ let staging_guard = StagingDecodeGuard::set();
+ let staging = oak_render::eval::render_footage_frame(
+ &staging_path.to_string_lossy(),
+ 0,
+ Rational::new(0, 1),
+ (0, 0),
+ PixelFormat::F32,
+ )
+ .expect("staging decode");
+ drop(staging_guard);
+ let Texture::Cpu(staging_frame) = &staging else {
+ panic!("staging decode must stay on the CPU: {staging:?}");
+ };
+
+ // The GPU applies the LUT (interpolated); the CPU runs the exact
+ // per-pixel transform, so a small interpolation difference is
+ // expected — far below a visible grade mismatch.
+ let mut max_diff = 0.0f32;
+ for y in 0..64 {
+ for x in 0..64 {
+ let a = sample(&imported_frame, x, y);
+ let b = sample(staging_frame, x, y);
+ for c in 0..3 {
+ max_diff = max_diff.max((a[c] - b[c]).abs());
+ }
+ }
+ }
+ eprintln!("ACEScg import vs staging: max diff {max_diff}");
+ assert!(
+ max_diff < 0.05,
+ "working-space LUT diverges from the CPU transform: {max_diff}"
+ );
+
+ let _ = std::fs::remove_file(&path);
+ let _ = std::fs::remove_file(&staging_path);
+}
+
+#[test]
+fn montage_native_size_with_host_gpu_composites_the_clip() {
+ let _guard = SERIAL.lock().unwrap_or_else(|e| e.into_inner());
+ pin_legacy_working_space();
+
+ // This is the M5 audit regression: a sequence montage at the clip's
+ // native size with a host GPU installed used to import the clip and
+ // then silently skip it in the CPU compositor, producing an
+ // all-transparent black sequence. The montage path must stage on
+ // purpose and composite the clip.
+ let Some(ctx) = common::gpu_context_for_import() else {
+ // The helper logged `SKIP:` (or panicked under OAK_REQUIRE_GPU).
+ return;
+ };
+ oak_core::backend::GpuContext::install_shared(Some(ctx));
+
+ oak_codec::gpuinterop::reset_import_counters();
+ let path = write_clip("montage_native");
+
+ // First import the frame at native size through the normal
+ // single-footage path: the planar texture is now cached under the
+ // (64, 64) key that the montage request will use.
+ let single = oak_render::eval::render_footage_frame(
+ &path.to_string_lossy(),
+ 0,
+ Rational::new(0, 1),
+ (64, 64),
+ PixelFormat::F32,
+ )
+ .expect("single-footage decode");
+ if oak_codec::gpuinterop::HW_IMPORTS.load(std::sync::atomic::Ordering::Relaxed) == 0 {
+ eprintln!("SKIP: hardware import unavailable on this machine; skipping montage assertions");
+ let _ = std::fs::remove_file(&path);
+ return;
+ }
+ assert!(
+ matches!(single, Texture::Gpu { .. }),
+ "the pre-step must produce the imported GPU texture"
+ );
+ let imports_after_single =
+ oak_codec::gpuinterop::HW_IMPORTS.load(std::sync::atomic::Ordering::Relaxed);
+ let transfers_after_single =
+ oak_codec::hwdecode::HW_TRANSFERS.load(std::sync::atomic::Ordering::Relaxed);
+
+ let params = oak_render::ticket::VideoTicketParams {
+ viewer: 1,
+ project: String::new(),
+ time: Rational::new(0, 1),
+ force_size: Some((64, 64)), // native: the import-triggering shape
+ force_format: None,
+ cache: None,
+ cache_dir: None,
+ cache_id: None,
+ cache_timebase: None,
+ footage: None,
+ montage: vec![oak_render::ticket::MontageClip {
+ filename: path.to_string_lossy().into_owned(),
+ stream_index: 0,
+ in_time: Rational::new(0, 1),
+ out_time: Rational::new(10, 1),
+ media_in: Rational::new(0, 1),
+ gain: 1.0,
+ effects: Vec::new(),
+ }],
+ adjustments: Vec::new(),
+ };
+
+ let texture = oak_render::eval::render_produced_frame(Rational::new(0, 1), ¶ms)
+ .expect("montage render");
+ let frame = texture.to_frame().expect("montage frame");
+ assert_eq!((frame.width, frame.height), (64, 64));
+ assert!(
+ !frame.data.iter().all(|&b| b == 0),
+ "montage must not be transparent black"
+ );
+ // Known pattern: left half red, right half blue.
+ let [r, g, b, a] = sample(&frame, 8, 32);
+ assert!(r > 0.5 && g < 0.4 && b < 0.4, "left half red: {r},{g},{b}");
+ assert!(a > 0.9, "opaque: {a}");
+ let [r, g, b, _] = sample(&frame, 56, 32);
+ assert!(b > 0.5 && r < 0.4 && g < 0.4, "right half blue: {r},{g},{b}");
+
+ assert_eq!(
+ oak_codec::gpuinterop::HW_IMPORTS.load(std::sync::atomic::Ordering::Relaxed),
+ imports_after_single,
+ "the CPU montage compositor must stage on purpose (no new imports)"
+ );
+ // The staged request must not have been served by the cached planar
+ // texture: it re-decoded through the CPU scaler (a hardware frame
+ // transfer happened for the staging path).
+ assert!(
+ oak_codec::hwdecode::HW_TRANSFERS.load(std::sync::atomic::Ordering::Relaxed)
+ > transfers_after_single,
+ "the staged montage decode must produce CPU pixels"
+ );
+
+ let _ = std::fs::remove_file(&path);
+}
diff --git a/crates/oak-render/tests/render_threads_test.rs b/crates/oak-render/tests/render_threads_test.rs
index a86790cba..96cef1e37 100644
--- a/crates/oak-render/tests/render_threads_test.rs
+++ b/crates/oak-render/tests/render_threads_test.rs
@@ -98,6 +98,32 @@ fn pin_legacy_working_space() {
);
}
+/// 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,
@@ -638,6 +664,7 @@ fn oak_pipeline_env_selects_the_thread_backend() {
#[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");
@@ -674,6 +701,7 @@ fn pipeline_matches_inline_pixels_across_consecutive_frames() {
#[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");
@@ -705,6 +733,7 @@ fn pipeline_seek_out_of_order_matches_inline() {
#[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));
@@ -1280,6 +1309,7 @@ fn pipeline_queue_backpressure_closes_the_prefetch_gate() {
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();
diff --git a/crates/oak-worker/src/ofx_host.rs b/crates/oak-worker/src/ofx_host.rs
index aea4b169f..2e1f7b9ba 100644
--- a/crates/oak-worker/src/ofx_host.rs
+++ b/crates/oak-worker/src/ofx_host.rs
@@ -480,9 +480,145 @@ pub fn ofx_host_main(args: &[String]) -> i32 {
#[cfg(test)]
mod tests {
use super::*;
+ use oak_core::backend::{BackendKind, GpuContextLike};
+ use oak_core::error::{Error as CoreError, Result as CoreResult};
+ use serde_json::{json, Value};
+ use std::sync::atomic::AtomicU32;
+
+ /// The process-global plugin/progress factories are shared with the
+ /// `worker.rs` tests, so both modules serialize on this one lock.
+ fn global_factory_lock() -> MutexGuard<'static, ()> {
+ crate::worker::GLOBAL_FACTORY_TEST_LOCK
+ .lock()
+ .unwrap_or_else(|e| e.into_inner())
+ }
+
+ /// A unique shm key + region holding an initialized frame slot pool.
+ fn test_region(name: &str, slots: u32, slot_bytes: usize) -> (String, SharedMemoryRegion) {
+ static COUNTER: AtomicU32 = AtomicU32::new(0);
+ let n = COUNTER.fetch_add(1, Ordering::Relaxed);
+ let key = SharedMemoryRegion::make_key(i64::from(std::process::id()), (n & 0x7FFF) as i32)
+ + &format!("-ofx-{name}");
+ let bytes = FrameSlotPool::bytes_needed(slots, slot_bytes);
+ let mut region = SharedMemoryRegion::new();
+ assert!(
+ region.open(&key, bytes, ShmMode::Create),
+ "{}",
+ region.error()
+ );
+ // SAFETY: live mapping sized by bytes_needed; the pool view is
+ // discarded — peers re-attach through the region.
+ let _ = unsafe { FrameSlotPool::create(region.data(), slots, slot_bytes) };
+ (key, region)
+ }
+
+ /// A handshake for `attach_pools`.
+ fn handshake_json(
+ out_key: &str,
+ out_slots: i32,
+ out_bytes: i64,
+ in_key: &str,
+ in_slots: i32,
+ in_bytes: i64,
+ ) -> Value {
+ json!({
+ "type": TYPE_HANDSHAKE,
+ "shm_key": out_key,
+ "output_slots": out_slots,
+ "slot_data_bytes": out_bytes,
+ "input_shm_key": in_key,
+ "input_slots": in_slots,
+ "input_slot_data_bytes": in_bytes,
+ })
+ }
+
+ /// Attach a real output pool (and input pool when `in_slots > 0`),
+ /// returning the pools plus the owner regions (kept alive by the caller).
+ fn host_pools(
+ out_slots: u32,
+ out_bytes: usize,
+ in_slots: u32,
+ in_bytes: usize,
+ ) -> (HostPools, SharedMemoryRegion, SharedMemoryRegion) {
+ let (out_key, out_region) = test_region("out", out_slots, out_bytes);
+ let (in_key, in_region) = test_region("in", in_slots, in_bytes);
+ let hs = handshake_json(
+ &out_key,
+ out_slots as i32,
+ out_bytes as i64,
+ &in_key,
+ in_slots as i32,
+ in_bytes as i64,
+ );
+ let pools = attach_pools(&hs).expect("handshake attaches the pools");
+ (pools, out_region, in_region)
+ }
+
+ /// Fill and publish `slot` from the parent (producer) side. Pops past
+ /// any other free slots (returning them to the ring) so callers do not
+ /// need to know the free-ring order left by earlier publishes.
+ unsafe fn publish_input(
+ region: &SharedMemoryRegion,
+ slot: u32,
+ width: i32,
+ height: i32,
+ data_size: i32,
+ ) {
+ // SAFETY: live region created by `test_region`.
+ let pool = unsafe { FrameSlotPool::attach(region.data()) };
+ let mut skipped: Vec = Vec::new();
+ loop {
+ let mut got = 0u32;
+ assert!(unsafe { pool.acquire(&mut got) }, "free ring is seeded");
+ if got == slot {
+ break;
+ }
+ skipped.push(got);
+ }
+ // SAFETY: `slot` was acquired above; meta and data are live.
+ unsafe {
+ let meta = &mut *pool.meta(slot);
+ *meta = Default::default();
+ meta.width = width;
+ meta.height = height;
+ meta.format = PixelFormat::F32 as i32;
+ meta.data_size = data_size;
+ std::ptr::write_bytes(pool.slot_data(slot), 0x5A, pool.slot_data_bytes());
+ for other in skipped {
+ assert!(pool.release(other), "skipped slots go back");
+ }
+ }
+ assert!(unsafe { pool.publish(slot) }, "ready ring has room");
+ }
+
+ /// A GPU context whose readback always fails, for the output-readback
+ /// error path (a real GPU texture cannot be produced headless).
+ struct FailingDownloadGpu;
+
+ impl GpuContextLike for FailingDownloadGpu {
+ fn kind(&self) -> BackendKind {
+ BackendKind::Gl
+ }
+ fn destroy_texture(&self, _token: u64) {}
+ fn upload(&self, _token: u64, _frame: &Frame) -> CoreResult<()> {
+ Err(CoreError::Failed("fake upload".to_string()))
+ }
+ fn download(&self, _token: u64) -> CoreResult {
+ Err(CoreError::Failed("fake download".to_string()))
+ }
+ fn blit(
+ &self,
+ _src: u64,
+ _dst: u64,
+ _processor: Option<&oak_core::color::ColorProcessor>,
+ ) -> CoreResult<()> {
+ Err(CoreError::Failed("fake blit".to_string()))
+ }
+ }
#[test]
fn cancel_flag_is_reset_by_progress_start() {
+ let _guard = global_factory_lock();
// Cancel semantics (protocol parity with the worker): a cancelled
// reporter tells the plugin to abort at its next progressUpdate;
// the next progressStart (the factory path below) clears the
@@ -496,9 +632,511 @@ mod tests {
// A cancel after the start makes the next update answer false.
OFX_CANCEL.store(true, Ordering::Relaxed);
assert!(!reporter.update(0.6), "a cancelled reporter answers false");
+ // progressEnd forwards completion (fraction 1.0); the emit is a
+ // no-op under test, so this just exercises the reporter path.
+ reporter.end();
OFX_CANCEL.store(false, Ordering::Relaxed);
}
+ #[test]
+ fn progress_factory_installs_a_working_reporter() {
+ let _guard = global_factory_lock();
+ OFX_CANCEL.store(false, Ordering::Relaxed);
+ install_progress_factory();
+ assert!(
+ oak_plugin::progress::has_reporter_factory(),
+ "the host factory must be installed for the plugin progress suite"
+ );
+ // Drive the installed factory through the progress suite
+ // (progressStart -> host_progress_reporter, update, end).
+ oak_plugin::suites::progress::set_current(Some(
+ oak_plugin::progress::ProgressReporter::silent(),
+ ));
+ let v2 = oak_plugin::suites::progress::suite_v2();
+ let label = std::ffi::CString::new("render").unwrap();
+ let message = std::ffi::CString::new("frame 1").unwrap();
+ // SAFETY: the suite takes the null handle by contract; the strings
+ // outlive the calls.
+ unsafe {
+ assert_eq!(
+ (v2.start)(std::ptr::null_mut(), label.as_ptr(), message.as_ptr()),
+ oak_plugin::suites::status::OK
+ );
+ assert_eq!(
+ (v2.update)(std::ptr::null_mut(), 0.5),
+ oak_plugin::suites::status::OK
+ );
+ assert_eq!(
+ (v2.end)(std::ptr::null_mut()),
+ oak_plugin::suites::status::OK
+ );
+ }
+ oak_plugin::suites::progress::set_current(None);
+ }
+
+ #[test]
+ fn lock_recovers_from_a_poisoned_mutex() {
+ // `emit` (the other user) is a no-op under cfg(test); exercise the
+ // poison-recovery helper directly so a panicking reporter can never
+ // wedge the host's stdout lock.
+ let _ = std::thread::spawn(|| {
+ let _guard = OUT_LOCK.lock().unwrap();
+ panic!("poison OUT_LOCK on purpose");
+ })
+ .join();
+ let guard = lock(&OUT_LOCK);
+ drop(guard);
+ }
+
+ #[test]
+ fn crash_hooks_maybe_crash_skips_when_marker_exists() {
+ let marker = std::env::temp_dir().join(format!(
+ "oak-ofx-host-marker-{}.txt",
+ std::process::id()
+ ));
+ std::fs::write(&marker, b"already crashed").unwrap();
+ let hooks = CrashHooks {
+ always: false,
+ once_marker: Some(marker.clone()),
+ };
+ // The marker exists: the hook must return without aborting.
+ hooks.maybe_crash();
+ let _ = std::fs::remove_file(&marker);
+
+ // A dangling --ofx-crash-once without a value leaves no marker.
+ let hooks = CrashHooks::from_args(&[
+ "oak-worker".to_string(),
+ "--ofx-host".to_string(),
+ "--ofx-crash-once".to_string(),
+ ]);
+ assert!(!hooks.always);
+ assert!(hooks.once_marker.is_none());
+ }
+
+ #[test]
+ fn attach_pools_rejects_bad_shapes_and_missing_geometry() {
+ // Wrong field types: HandshakeMsg deserialization fails.
+ let err = attach_pools(&json!({
+ "type": TYPE_HANDSHAKE,
+ "protocol_version": "one",
+ }))
+ .err().expect("wrong types are invalid");
+ assert!(err.starts_with("invalid handshake: "), "{err}");
+
+ // Empty geometry (all serde defaults).
+ let err = attach_pools(&json!({ "type": TYPE_HANDSHAKE })).err().expect("empty handshake");
+ assert_eq!(err, "handshake missing output shared-memory geometry");
+
+ // Zero and negative geometry take the same branch.
+ for (slots, bytes) in [(0, 16), (1, 0), (-2, 16), (1, -4)] {
+ let err = attach_pools(&handshake_json("k", slots, bytes, "", 0, 0))
+ .err().expect("degenerate output geometry");
+ assert_eq!(err, "handshake missing output shared-memory geometry");
+ }
+ }
+
+ #[test]
+ fn attach_pools_attaches_real_segments_and_reports_failures() {
+ // Success: both pools attach with the announced geometry.
+ let (pools, _out, _in) = host_pools(2, 256, 3, 128);
+ assert!(pools.output.is_valid());
+ assert_eq!(pools.output.slot_count(), 2);
+ assert_eq!(pools.output.slot_data_bytes(), 256);
+ assert!(pools.input.is_valid());
+ assert_eq!(pools.input.slot_count(), 3);
+ assert_eq!(pools.input.slot_data_bytes(), 128);
+
+ // Output segment missing.
+ let missing = format!("olive-rw-{}-ofx-missing-out", std::process::id());
+ let err = attach_pools(&handshake_json(&missing, 1, 16, "", 0, 0)).err().expect("no segment");
+ assert!(err.starts_with("failed to attach output shared memory: "), "{err}");
+
+ // Output segment present but not a pool (zeroed memory -> bad magic).
+ let raw_key = format!("olive-rw-{}-ofx-raw-out", std::process::id());
+ let bytes = FrameSlotPool::bytes_needed(1, 16);
+ let mut raw = SharedMemoryRegion::new();
+ assert!(raw.open(&raw_key, bytes, ShmMode::Create));
+ let err = attach_pools(&handshake_json(&raw_key, 1, 16, "", 0, 0))
+ .err().expect("zeroed output segment");
+ assert_eq!(err, "output shared memory does not contain a frame slot pool");
+
+ // Output is fine but the announced input geometry is incomplete.
+ let (out_key, _out_region) = test_region("out-for-in", 1, 16);
+ let err = attach_pools(&handshake_json(&out_key, 1, 16, "", 2, 0)).err().expect("input bytes");
+ assert_eq!(err, "handshake missing input shared-memory geometry");
+ let err = attach_pools(&handshake_json(&out_key, 1, 16, "", 2, 32))
+ .err().expect("empty input key");
+ assert_eq!(err, "handshake missing input shared-memory geometry");
+ let err = attach_pools(&handshake_json(&out_key, 1, 16, " ", 2, 32))
+ .err().expect("blank input key attaches nothing");
+ assert!(err.starts_with("failed to attach input shared memory: "), "{err}");
+
+ // Input segment missing.
+ let missing_in = format!("olive-rw-{}-ofx-missing-in", std::process::id());
+ let err = attach_pools(&handshake_json(&out_key, 1, 16, &missing_in, 2, 32))
+ .err().expect("no input segment");
+ assert!(err.starts_with("failed to attach input shared memory: "), "{err}");
+
+ // Input segment present but not a pool.
+ let raw_key = format!("olive-rw-{}-ofx-raw-in", std::process::id());
+ let mut raw_in = SharedMemoryRegion::new();
+ assert!(raw_in.open(&raw_key, FrameSlotPool::bytes_needed(2, 32), ShmMode::Create));
+ let err = attach_pools(&handshake_json(&out_key, 1, 16, &raw_key, 2, 32))
+ .err().expect("zeroed input segment");
+ assert_eq!(err, "input shared memory does not contain a frame slot pool");
+ }
+
+ #[test]
+ fn read_input_frame_reports_missing_mismatch_and_invalid_meta() {
+ let (out_key, _out_region) = test_region("read-out", 4, 64);
+ let (in_key, in_region) = test_region("read-in", 2, 64);
+ let hs = handshake_json(&out_key, 4, 64, &in_key, 2, 64);
+ let pools = attach_pools(&hs).expect("attach");
+
+ // Nothing published yet.
+ let err = read_input_frame(&pools.input, 0).expect_err("empty ready ring");
+ assert_eq!(err, "input slot missing");
+
+ // Publish slot 1 while the job asks for slot 0: a protocol
+ // violation that must release the consumed slot and fail.
+ {
+ // SAFETY: live region; parent (producer) side view.
+ let parent = unsafe { FrameSlotPool::attach(in_region.data()) };
+ let mut first = 0u32;
+ let mut second = 0u32;
+ assert!(unsafe { parent.acquire(&mut first) });
+ assert!(unsafe { parent.acquire(&mut second) });
+ assert_eq!((first, second), (0, 1));
+ assert!(unsafe { parent.release(first) });
+ unsafe {
+ let meta = &mut *parent.meta(second);
+ *meta = Default::default();
+ meta.width = 4;
+ meta.height = 4;
+ meta.data_size = 64;
+ }
+ assert!(unsafe { parent.publish(second) });
+ }
+ let err = read_input_frame(&pools.input, 0).expect_err("slot mismatch");
+ assert_eq!(err, "input slot mismatch: expected 0, got 1");
+
+ // A published frame with degenerate metadata (zero width) is
+ // rejected and released.
+ unsafe { publish_input(&in_region, 0, 0, 4, 64) };
+ let err = read_input_frame(&pools.input, 0).expect_err("zero width");
+ assert_eq!(err, "input frame has invalid metadata");
+
+ // A negative data size is equally invalid.
+ unsafe { publish_input(&in_region, 0, 4, 4, -1) };
+ let err = read_input_frame(&pools.input, 0).expect_err("negative size");
+ assert_eq!(err, "input frame has invalid metadata");
+
+ // A valid frame is copied out; an oversized `data_size` is clamped
+ // to the slot capacity.
+ unsafe { publish_input(&in_region, 0, 2, 2, 9999) };
+ let frame = read_input_frame(&pools.input, 0).expect("valid input frame");
+ assert_eq!((frame.width, frame.height), (2, 2));
+ assert_eq!(frame.format, PixelFormat::F32);
+ assert_eq!(frame.data.len(), 64, "clamped to the slot block");
+ assert!(frame.data.iter().all(|&b| b == 0x5A));
+ }
+
+ #[test]
+ fn write_output_frame_handles_full_oversize_publish_and_success() {
+ let frame = cpu_frame(1, 1, 16);
+
+ // No free slots at all: the pool view is attached directly, since
+ // `attach_pools` rightly rejects a zero-slot handshake.
+ let (_empty_key, empty_region) = test_region("write-empty", 0, 16);
+ // SAFETY: live region created by `test_region`.
+ let empty_pool = unsafe { FrameSlotPool::attach(empty_region.data()) };
+ let err = write_output_frame(&empty_pool, &frame).expect_err("full pool");
+ assert_eq!(err, "output pool is full");
+
+ // `attach_pools` always requires the input geometry too, so
+ // announce a (never used) one-slot input pool.
+ let (pools, out_region, _in_region) = host_pools(1, 16, 1, 16);
+
+ // A frame larger than the slot is rejected.
+ let oversized = cpu_frame(2, 2, 64);
+ let err = write_output_frame(&pools.output, &oversized).expect_err("oversize");
+ assert!(
+ err.starts_with("output frame is 64 bytes, larger than the output slot (16)"),
+ "{err}"
+ );
+
+ // Success: the parent consumes the published slot and sees the meta.
+ let slot = write_output_frame(&pools.output, &frame).expect("publish");
+ assert_eq!(slot, 0);
+ // SAFETY: live region; parent (drainer) side view.
+ let parent = unsafe { FrameSlotPool::attach(out_region.data()) };
+ let mut consumed = 0u32;
+ assert!(unsafe { parent.consume(&mut consumed) });
+ assert_eq!(consumed, 0);
+ // SAFETY: `consumed` was just consumed.
+ let meta = unsafe { &*parent.meta_const(consumed) };
+ assert_eq!((meta.width, meta.height), (1, 1));
+ assert_eq!(meta.format, PixelFormat::F32 as i32);
+ assert_eq!(meta.channel_count, 4);
+ assert_eq!(meta.linesize, 16);
+ assert_eq!(meta.data_size, 16);
+ unsafe { parent.release(consumed) };
+
+ // Ready ring full: a duplicate publish fills the single-slot ring,
+ // the slot is recycled through the free ring, and the next publish
+ // fails.
+ unsafe {
+ let mut slot0 = 0u32;
+ assert!(pools.output.acquire(&mut slot0));
+ assert!(pools.output.publish(slot0));
+ assert!(pools.output.release(slot0));
+ }
+ let err = write_output_frame(&pools.output, &frame).expect_err("ready ring full");
+ assert_eq!(err, "output publish failed");
+ }
+
+ fn cpu_frame(width: i32, height: i32, bytes: usize) -> Frame {
+ let pod = VideoParamsPod {
+ width,
+ height,
+ format: PixelFormat::F32 as i32,
+ ..Default::default()
+ };
+ let mut frame = Frame::new();
+ frame.set_video_params(pod);
+ frame.data = vec![0x7F; bytes];
+ frame
+ }
+
+ #[test]
+ fn handle_job_rejects_malformed_and_unbacked_jobs() {
+ let (pools, _out, _in) = host_pools(2, 64, 2, 64);
+
+ // Wrong wire types: OfxJobMsg deserialization fails.
+ let resp = handle_job(json!({ "job": "five" }), &pools);
+ assert_eq!(resp["type"], crate::ipc::TYPE_ERROR);
+ assert!(
+ resp["message"]
+ .as_str()
+ .unwrap()
+ .starts_with("invalid ofx_job: "),
+ "{resp}"
+ );
+
+ // A declared input with nothing published fails with the job id
+ // echoed and slot -1.
+ let resp = handle_job(
+ json!({
+ "job": 9,
+ "type_id": "org.oak.test",
+ "inputs": [{ "name": "Source", "slot": 0 }],
+ }),
+ &pools,
+ );
+ assert_eq!(resp["type"], crate::ipc::TYPE_OFX_RESULT);
+ assert_eq!(resp["job"], 9);
+ assert_eq!(resp["slot"], -1);
+ assert_eq!(resp["error"], "input slot missing");
+
+ // Same for a main-source slot that was never published.
+ let resp = handle_job(
+ json!({ "job": 10, "type_id": "org.oak.test", "src_slot": 0 }),
+ &pools,
+ );
+ assert_eq!(resp["job"], 10);
+ assert_eq!(resp["error"], "input slot missing");
+ }
+
+ #[test]
+ fn handle_job_reports_factory_and_executor_failures() {
+ let _guard = global_factory_lock();
+ let (pools, _out, _in) = host_pools(2, 64, 2, 64);
+ let job = json!({ "job": 11, "type_id": "org.oak.test" });
+
+ // No instance factory installed: the host cannot resolve anything.
+ eval::set_plugin_instance_factory(None);
+ eval::set_plugin_executor(None);
+ let resp = handle_job(job.clone(), &pools);
+ assert_eq!(
+ resp["error"],
+ "no plugin instance factory installed in the OFX host"
+ );
+ assert_eq!(resp["slot"], -1);
+
+ // Factory resolves nothing: unknown plugin.
+ eval::set_plugin_instance_factory(Some(Arc::new(|_type_id: &str| None)));
+ let resp = handle_job(job.clone(), &pools);
+ assert_eq!(
+ resp["error"],
+ "unknown or unavailable OFX plugin: org.oak.test"
+ );
+
+ // Instance resolved but no executor wired in.
+ eval::set_plugin_instance_factory(Some(Arc::new(|_type_id: &str| Some(7))));
+ eval::set_plugin_executor(None);
+ let resp = handle_job(job.clone(), &pools);
+ assert_eq!(
+ resp["error"],
+ "no plugin executor installed in the OFX host"
+ );
+
+ eval::set_plugin_instance_factory(None);
+ }
+
+ #[test]
+ fn handle_job_renders_through_the_executor_and_reports_errors() {
+ let _guard = global_factory_lock();
+ let (pools, _out, in_region) = host_pools(2, 64, 4, 64);
+ // SAFETY: live region created by host_pools.
+ unsafe { publish_input(&in_region, 0, 1, 1, 16) };
+
+ // The custom executor asserts the resolved spec and returns a
+ // 1x1 frame; the host publishes it into the output pool.
+ let exec: Arc = Arc::new(|req: &PluginJobRequest<'_>| {
+ match req.spec {
+ JobSpec::Plugin {
+ instance,
+ type_id,
+ time,
+ effect_input_id,
+ inputs,
+ values,
+ } => {
+ assert_eq!(*instance, 7);
+ assert_eq!(type_id, "org.oak.test");
+ assert!((*time - 0.5).abs() < 1e-9);
+ assert_eq!(effect_input_id.as_deref(), Some("Source"));
+ assert_eq!(inputs.len(), 1);
+ assert_eq!(inputs[0].0, "Source");
+ assert_eq!(values.len(), 1);
+ assert_eq!(values[0].0, "brightness");
+ assert_eq!(values[0].1, oak_node::value::NodeValue::Float(0.5));
+ }
+ other => panic!("expected a plugin spec, got {other:?}"),
+ }
+ let frame = eval::generate_frame(oak_core::Rational::new(0, 1), (1, 1), PixelFormat::F32)
+ .expect("generate");
+ Ok(Texture::wrap_frame(frame))
+ });
+ let factory: Arc = Arc::new(|_type_id: &str| Some(7));
+ eval::set_plugin_instance_factory(Some(factory));
+ eval::set_plugin_executor(Some(exec));
+
+ let resp = handle_job(
+ json!({
+ "job": 21,
+ "type_id": "org.oak.test",
+ "time": 0.5,
+ "effect_input_id": "Source",
+ "inputs": [{ "name": "Source", "slot": 0 }],
+ "values": [{ "input": "brightness", "value": { "t": "float", "v": 0.5 } }],
+ }),
+ &pools,
+ );
+ assert_eq!(resp["type"], crate::ipc::TYPE_OFX_RESULT);
+ assert_eq!(resp["job"], 21);
+ assert!(resp["slot"].as_i64().unwrap() >= 0, "{resp}");
+ assert_eq!(resp["error"], "");
+
+ // Executor failure is reported as a job failure; this one arrives
+ // through the explicit `src_slot` path.
+ // SAFETY: live region created by host_pools.
+ unsafe { publish_input(&in_region, 0, 1, 1, 16) };
+ let failing: Arc =
+ Arc::new(|_req: &PluginJobRequest<'_>| Err(oak_core::error::Error::Failed("boom".into())));
+ eval::set_plugin_executor(Some(failing));
+ let resp = handle_job(
+ json!({ "job": 22, "type_id": "org.oak.test", "src_slot": 0 }),
+ &pools,
+ );
+ assert_eq!(resp["job"], 22);
+ assert!(
+ resp["error"]
+ .as_str()
+ .unwrap()
+ .starts_with("plugin render failed: "),
+ "{resp}"
+ );
+
+ // A texture that cannot be read back fails before publishing.
+ let gpu_returning: Arc = Arc::new(|_req: &PluginJobRequest<'_>| {
+ Ok(Texture::gpu(
+ Arc::new(FailingDownloadGpu),
+ 1,
+ 1,
+ 1,
+ PixelFormat::F32,
+ ))
+ });
+ eval::set_plugin_executor(Some(gpu_returning));
+ let resp = handle_job(
+ json!({ "job": 23, "type_id": "org.oak.test" }),
+ &pools,
+ );
+ assert_eq!(resp["job"], 23);
+ assert!(
+ resp["error"]
+ .as_str()
+ .unwrap()
+ .starts_with("plugin output readback failed: "),
+ "{resp}"
+ );
+
+ eval::set_plugin_instance_factory(None);
+ eval::set_plugin_executor(None);
+ }
+
+ #[test]
+ fn handle_job_falls_back_to_first_input_then_dummy_source() {
+ let _guard = global_factory_lock();
+ let (pools, _out, in_region) = host_pools(2, 64, 4, 64);
+ // SAFETY: live region created by host_pools.
+ unsafe { publish_input(&in_region, 0, 1, 1, 16) };
+
+ // Capture the src the fallback picked. Values stay empty: the
+ // resolver runs before the executor.
+ let kinds: Arc>> = Arc::new(Mutex::new(Vec::new()));
+ let record = kinds.clone();
+ let exec: Arc = Arc::new(move |req: &PluginJobRequest<'_>| {
+ record
+ .lock()
+ .unwrap_or_else(|e| e.into_inner())
+ .push(if req.src.is_dummy() { "dummy" } else { "frame" });
+ let frame = eval::generate_frame(oak_core::Rational::new(0, 1), (1, 1), PixelFormat::F32)
+ .expect("generate");
+ Ok(Texture::wrap_frame(frame))
+ });
+ let factory: Arc = Arc::new(|_type_id: &str| Some(1));
+ eval::set_plugin_instance_factory(Some(factory));
+ eval::set_plugin_executor(Some(exec));
+
+ // The declared effect input is absent from `inputs`, so the first
+ // clip is used instead.
+ let resp = handle_job(
+ json!({
+ "job": 31,
+ "type_id": "org.oak.test",
+ "effect_input_id": "Source",
+ "inputs": [{ "name": "Extra", "slot": 0 }],
+ }),
+ &pools,
+ );
+ assert_eq!(resp["error"], "", "{resp}");
+
+ // No clips and no explicit src: the dummy texture is used.
+ let resp = handle_job(
+ json!({ "job": 32, "type_id": "org.oak.test", "effect_input_id": "Source" }),
+ &pools,
+ );
+ assert_eq!(resp["error"], "", "{resp}");
+
+ let seen = kinds.lock().unwrap_or_else(|e| e.into_inner()).clone();
+ assert_eq!(seen, vec!["frame", "dummy"]);
+
+ eval::set_plugin_instance_factory(None);
+ eval::set_plugin_executor(None);
+ }
+
#[test]
fn crash_hooks_parse_args() {
let hooks = CrashHooks::from_args(&[
@@ -513,5 +1151,66 @@ mod tests {
let hooks = CrashHooks::from_args(&["oak-worker".to_string(), "--ofx-crash-always".to_string()]);
assert!(hooks.always);
assert!(hooks.once_marker.is_none());
+
+ // Unknown flags and a missing option value are ignored.
+ let hooks = CrashHooks::from_args(&[
+ "oak-worker".to_string(),
+ "--ofx-host".to_string(),
+ "--not-a-flag".to_string(),
+ "--ofx-crash-once".to_string(),
+ ]);
+ assert!(!hooks.always);
+ assert!(hooks.once_marker.is_none());
+ }
+
+ // ---- M16 R2 coverage additions ----------------------------------------
+
+ /// Every hook of the fake failing GPU context is callable and reports
+ /// the failure (the executor readback path uses `download`; the other
+ /// hooks document the trait contract).
+ #[test]
+ fn failing_download_gpu_hooks_return_errors() {
+ let gpu = FailingDownloadGpu;
+ assert_eq!(gpu.kind(), BackendKind::Gl);
+ gpu.destroy_texture(1);
+ let frame = cpu_frame(1, 1, 4);
+ assert!(gpu.upload(1, &frame).is_err(), "fake upload always fails");
+ assert!(gpu.download(1).is_err(), "fake download always fails");
+ assert!(gpu.blit(1, 2, None).is_err(), "fake blit always fails");
+ }
+
+ /// A successful plugin render whose output pool has no free slot fails
+ /// the job with "output pool is full" (the acquired-but-unpublished
+ /// slot is not leaked into the ready ring).
+ #[test]
+ fn handle_job_reports_output_pool_full() {
+ let _guard = global_factory_lock();
+ let (pools, _out, _in) = host_pools(1, 16, 1, 16);
+ // Drain the only free output slot through the producer side, so the
+ // host's `write_output_frame` cannot acquire one.
+ let mut drained = 0u32;
+ // SAFETY: live attached pools; the test owns both sides and is
+ // single-threaded.
+ assert!(unsafe { pools.output.acquire(&mut drained) });
+ assert_eq!(drained, 0);
+
+ let exec: Arc = Arc::new(|_req: &PluginJobRequest<'_>| {
+ let frame =
+ eval::generate_frame(oak_core::Rational::new(0, 1), (1, 1), PixelFormat::F32)
+ .expect("generate");
+ Ok(Texture::wrap_frame(frame))
+ });
+ eval::set_plugin_instance_factory(Some(Arc::new(|_type_id: &str| Some(1))));
+ eval::set_plugin_executor(Some(exec));
+ let resp = handle_job(json!({ "job": 41, "type_id": "org.oak.test" }), &pools);
+ eval::set_plugin_instance_factory(None);
+ eval::set_plugin_executor(None);
+
+ assert_eq!(resp["type"], crate::ipc::TYPE_OFX_RESULT);
+ assert_eq!(resp["job"], 41);
+ assert_eq!(resp["slot"], -1);
+ assert_eq!(resp["error"], "output pool is full");
+ // Nothing was published.
+ assert!(!unsafe { pools.output.consume(&mut drained) });
}
}
diff --git a/crates/oak-worker/src/worker.rs b/crates/oak-worker/src/worker.rs
index 5b0319853..ad12ca16e 100644
--- a/crates/oak-worker/src/worker.rs
+++ b/crates/oak-worker/src/worker.rs
@@ -69,6 +69,12 @@ use crate::ipc::{
};
use crate::{log_error, PROTOCOL_VERSION};
+/// Serializes unit tests that install or drive the process-global
+/// plugin/progress factories (the `ofx_host` tests share this lock: the
+/// worker's and the host's reporter factories are process-wide).
+#[cfg(test)]
+pub(crate) static GLOBAL_FACTORY_TEST_LOCK: Mutex<()> = Mutex::new(());
+
/// A loaded graph snapshot (M15 S1): the snapshot file path plus what it
/// deserialized into — a full oaknode project, or only the copied-project
/// identity (the minimal `{"project_copy":N}` payload the
@@ -1557,10 +1563,71 @@ pub fn worker_main(backend: &str) -> i32 {
#[cfg(test)]
mod tests {
use super::*;
- use crate::ipc::{FrameSlotPool, SharedMemoryRegion, ShmMode};
+ use crate::ipc::{
+ FrameSlotPool, SharedMemoryRegion, ShmMode, WireEffectParam, WireMontageClip,
+ WireMontageEffect, WireNodeValue,
+ };
+ use oak_core::colormath::{OutputColorSpec, WorkingColorSpace};
+ use oak_node::id::NodeId;
+ use oak_node::project::Project;
+ use oak_node::sequence::SequenceBehavior;
+ use oak_node::track::TrackListBehavior;
use serde_json::json;
+ use std::collections::HashMap;
use std::ptr;
+ /// Serializes the tests that mutate the process-global pipeline color
+ /// settings (the two pre-existing adopt/sync tests included).
+ static COLOR_TEST_LOCK: Mutex<()> = Mutex::new(());
+
+ /// Saves the process-global pipeline color settings and restores them on
+ /// drop, so a panicking assertion cannot leak the override into the next
+ /// serialized test. All users hold [`COLOR_TEST_LOCK`].
+ struct ColorSettingsGuard {
+ working: oak_core::colormath::WorkingColorSpace,
+ output: oak_core::colormath::OutputColorSpec,
+ }
+
+ impl ColorSettingsGuard {
+ fn capture() -> ColorSettingsGuard {
+ ColorSettingsGuard {
+ working: oak_core::color::pipeline_working_space(),
+ output: oak_core::color::pipeline_output_spec(),
+ }
+ }
+ }
+
+ impl Drop for ColorSettingsGuard {
+ fn drop(&mut self) {
+ oak_core::color::set_pipeline_color_settings(self.working, self.output);
+ }
+ }
+
+ /// Test-only RAII environment override: restores the previous value (or
+ /// absence) on drop, so a panicking assertion cannot leak a crash-mode
+ /// override into the next serialized test.
+ struct EnvGuard {
+ name: &'static str,
+ previous: Option,
+ }
+
+ impl EnvGuard {
+ fn set(name: &'static str, value: impl AsRef) -> Self {
+ let previous = std::env::var_os(name);
+ std::env::set_var(name, value);
+ Self { name, previous }
+ }
+ }
+
+ impl Drop for EnvGuard {
+ fn drop(&mut self) {
+ match self.previous.take() {
+ Some(value) => std::env::set_var(self.name, value),
+ None => std::env::remove_var(self.name),
+ }
+ }
+ }
+
fn test_key(name: &str) -> String {
static COUNTER: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
let n = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
@@ -1579,11 +1646,9 @@ mod tests {
let out_key = test_key("out");
let out_bytes = FrameSlotPool::bytes_needed(slots as u32, slot_bytes as usize);
let mut out_region = SharedMemoryRegion::new();
- assert!(
- out_region.open(&out_key, out_bytes, ShmMode::Create),
- "{}",
- out_region.error()
- );
+ let opened = out_region.open(&out_key, out_bytes, ShmMode::Create);
+ let open_error = out_region.error();
+ assert!(opened, "{open_error}");
// SAFETY: live mapping sized by bytes_needed.
let _pool =
unsafe { FrameSlotPool::create(out_region.data(), slots as u32, slot_bytes as usize) };
@@ -1633,6 +1698,20 @@ mod tests {
assert!(s.shutdown_requested());
}
+ /// The M15 headless "cpu" backend: no renderer, but the session stays
+ /// fully operational (generated frames render through CPU eval).
+ #[test]
+ fn cpu_backend_session_is_headless_but_operational() {
+ assert!(is_cpu_backend("cpu"));
+ assert!(is_cpu_backend("CPU"));
+ assert!(!is_cpu_backend("auto"));
+ let mut s = WorkerSession::create("cpu").unwrap();
+ assert!(!s.has_renderer(), "cpu means no GPU renderer");
+ assert!(!s.shutdown_requested());
+ assert!(s.handle_line(r#"{"type":"shutdown"}"#).is_none());
+ assert!(s.shutdown_requested());
+ }
+
#[test]
fn startup_handshake_is_protocol_version_1_with_empty_geometry() {
let s = WorkerSession::create("none").unwrap();
@@ -1904,6 +1983,8 @@ mod tests {
#[test]
fn load_graph_adopts_pipeline_colors_from_snapshot() {
use oak_core::colormath::{OutputColorSpec, WorkingColorSpace};
+ let _guard = COLOR_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
+ let _colors = ColorSettingsGuard::capture();
// Reset the process global to something different from the snapshot's
// settings so the adopt step is observable.
oak_core::color::set_pipeline_color_settings(
@@ -1931,16 +2012,13 @@ mod tests {
"load_graph must adopt the snapshot's working space"
);
let _ = std::fs::remove_file(&path);
- // Restore the default global so parallel tests are not disturbed.
- oak_core::color::set_pipeline_color_settings(
- WorkingColorSpace::default(),
- OutputColorSpec::default(),
- );
}
#[test]
fn sync_pipeline_color_from_graph_restores_stale_global() {
- use oak_core::colormath::{OutputColorSpec, WorkingColorSpace};
+ use oak_core::colormath::WorkingColorSpace;
+ let _guard = COLOR_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
+ let _colors = ColorSettingsGuard::capture();
let project = oak_node::project::Project::new();
{
let mut guard = project.lock().unwrap_or_else(|e| e.into_inner());
@@ -1978,10 +2056,6 @@ mod tests {
"no change means no frame-cache invalidation"
);
let _ = std::fs::remove_file(&path);
- oak_core::color::set_pipeline_color_settings(
- WorkingColorSpace::default(),
- OutputColorSpec::default(),
- );
}
#[test]
@@ -2085,6 +2159,11 @@ mod tests {
#[test]
fn render_batch_stream_renders_generated_frames_and_reports_failures() {
+ // The crash-mode env vars are process-wide: serialize with the
+ // crash-hook test that mutates them.
+ let _guard = GLOBAL_FACTORY_TEST_LOCK
+ .lock()
+ .unwrap_or_else(|e| e.into_inner());
let mut s = WorkerSession::create("none").unwrap();
// Slots sized for 8x8 BGRA8.
let (hs, out_region, _in) = parent_side(4, 8 * 8 * 4, false);
@@ -2137,6 +2216,10 @@ mod tests {
#[test]
fn render_audio_batch_stream_mixes_silence_into_slot() {
+ // Serialize with the crash-hook env test (see render_batch_stream).
+ let _guard = GLOBAL_FACTORY_TEST_LOCK
+ .lock()
+ .unwrap_or_else(|e| e.into_inner());
// M15 S3: an empty-montage audio range pull (1/24 s at 48 kHz
// stereo = 2000 frames x 2 ch = 16000 bytes) renders total silence
// into the assigned slot and reports frame_ready with the audio
@@ -2227,6 +2310,10 @@ mod tests {
#[test]
fn render_audio_batch_rejects_oversized_range() {
+ // Serialize with the crash-hook env test (see render_batch_stream).
+ let _guard = GLOBAL_FACTORY_TEST_LOCK
+ .lock()
+ .unwrap_or_else(|e| e.into_inner());
// A range longer than the slot can hold must fail with frame_failed
// (never a buffer overflow into the next slot).
let mut s = WorkerSession::create("none").unwrap();
@@ -2263,13 +2350,10 @@ mod tests {
assert_eq!(lines[0]["type"], "batch_accepted");
assert_eq!(lines[1]["type"], "frame_failed");
assert_eq!(lines[1]["ticket"], 21);
+ let error_text = lines[1]["error"].as_str().unwrap().to_string();
assert!(
- lines[1]["error"]
- .as_str()
- .unwrap()
- .contains("needs 16000 bytes"),
- "oversized range reported: {}",
- lines[1]["error"]
+ error_text.contains("needs 16000 bytes"),
+ "oversized range reported: {error_text}"
);
// Slot 0 acquired but never published.
let parent_pool = unsafe { FrameSlotPool::attach(out_region.data()) };
@@ -2311,4 +2395,1055 @@ mod tests {
.unwrap();
assert_eq!(resp["message"], "invalid handshake message");
}
+
+ // ---- M16 R2 branch-coverage additions ---------------------------------
+
+ /// One render-batch ticket with the pipeline defaults for everything
+ /// the tests do not care about.
+ fn batch_spec(ticket: i64, slot: i32, width: i32, height: i32, format: i32) -> BatchTicketSpec {
+ BatchTicketSpec {
+ ticket,
+ slot,
+ time_num: 0,
+ time_den: 1,
+ width,
+ height,
+ format,
+ channels: 4,
+ ..Default::default()
+ }
+ }
+
+ /// A project holding one empty sequence viewer; returns the project,
+ /// the viewer's loaded id and the project uuid.
+ fn sequence_project() -> (Arc>, NodeId, String) {
+ let project = Project::new();
+ let seq;
+ {
+ let mut guard = project.lock().unwrap_or_else(|e| e.into_inner());
+ let (core, behavior) = SequenceBehavior::create();
+ seq = guard.graph.add_node(core, behavior);
+ }
+ let uuid = project.lock().unwrap_or_else(|e| e.into_inner()).uuid.clone();
+ (project, seq, uuid)
+ }
+
+ #[test]
+ fn renderer_creation_falls_back_or_succeeds_headless() {
+ // On a GPU-less host the dynamic -> direct-OpenGL fallback fails and
+ // the session continues headless (M16 S1); on a GPU host it simply
+ // initializes. Either is a valid production outcome — creation must
+ // never error out of `WorkerSession::create`, and the session's
+ // renderer flag must agree with what the factory can do on this host
+ // (the second `create` is the availability oracle; the renderer flag
+ // is not hand-rolled anywhere else).
+ let session = WorkerSession::create("auto").expect("session creation is tolerant");
+ assert_eq!(
+ session.has_renderer(),
+ Renderer::create("auto").is_ok(),
+ "the session carries a renderer exactly when the factory can create one"
+ );
+ }
+
+ #[test]
+ fn renderer_is_open_gl_reports_the_backend_kind() {
+ let gl = Renderer {
+ inner: DisplayRenderer::new(BackendKind::Gl),
+ };
+ assert!(gl.is_open_gl());
+ let vk = Renderer {
+ inner: DisplayRenderer::new(BackendKind::Vulkan),
+ };
+ assert!(!vk.is_open_gl());
+ }
+
+ #[test]
+ fn initialize_runtime_second_call_is_a_noop() {
+ let mut s = WorkerSession::create("none").unwrap();
+ s.runtime_initialized = true;
+ assert!(s.initialize_runtime(), "already initialized");
+ }
+
+ #[test]
+ fn worker_progress_events_flush_in_order_and_cancel_is_sticky() {
+ let _guard = GLOBAL_FACTORY_TEST_LOCK
+ .lock()
+ .unwrap_or_else(|e| e.into_inner());
+ WORKER_PROGRESS_EVENTS
+ .lock()
+ .unwrap_or_else(|e| e.into_inner())
+ .clear();
+ WORKER_PLUGIN_CANCEL.store(false, Ordering::Relaxed);
+ install_worker_progress_factory();
+ assert!(
+ oak_plugin::progress::has_reporter_factory(),
+ "the worker factory must be installed for the plugin progress suite"
+ );
+ // Drive progressStart -> worker factory -> update -> progressEnd
+ // through the plugin progress suite, exactly like a real render.
+ oak_plugin::suites::progress::set_current(Some(
+ oak_plugin::progress::ProgressReporter::silent(),
+ ));
+ let v2 = oak_plugin::suites::progress::suite_v2();
+ let v1 = oak_plugin::suites::progress::suite_v1();
+ let label = std::ffi::CString::new("render").unwrap();
+ let message = std::ffi::CString::new("frame 1").unwrap();
+ // SAFETY: the suite takes the null handle by contract; the C strings
+ // outlive the calls.
+ unsafe {
+ assert_eq!(
+ (v2.start)(std::ptr::null_mut(), label.as_ptr(), message.as_ptr()),
+ oak_plugin::suites::status::OK
+ );
+ assert_eq!(
+ (v2.update)(std::ptr::null_mut(), 0.25),
+ oak_plugin::suites::status::OK
+ );
+ // progressEnd is forwarded by the v1 suite (v2's end is a no-op).
+ assert_eq!(
+ (v1.end)(std::ptr::null_mut()),
+ oak_plugin::suites::status::OK
+ );
+ }
+ oak_plugin::suites::progress::set_current(None);
+
+ let mut out: Vec = Vec::new();
+ flush_worker_progress(&mut out);
+ let events: Vec = String::from_utf8(out)
+ .unwrap()
+ .lines()
+ .map(|line| serde_json::from_str(line).unwrap())
+ .collect();
+ assert_eq!(events.len(), 3, "start + update + end");
+ assert_eq!(events[0]["type"], crate::ipc::TYPE_PLUGIN_PROGRESS);
+ assert_eq!(events[0]["label"], "render");
+ assert_eq!(events[0]["message"], "frame 1");
+ assert_eq!(events[0]["fraction"], 0.0);
+ assert_eq!(events[1]["fraction"], 0.25);
+ assert_eq!(events[2]["fraction"], 1.0);
+
+ // plugin_cancel is sticky until a fresh progressStart resets it.
+ let mut s = WorkerSession::create("none").unwrap();
+ assert!(s
+ .handle_line(r#"{"type":"plugin_cancel"}"#)
+ .is_none());
+ assert!(WORKER_PLUGIN_CANCEL.load(Ordering::Relaxed));
+ WORKER_PLUGIN_CANCEL.store(false, Ordering::Relaxed);
+ }
+
+ /// A writer whose pipe is already closed.
+ struct FailingWriter;
+
+ impl Write for FailingWriter {
+ fn write(&mut self, _buf: &[u8]) -> io::Result {
+ Err(io::Error::new(io::ErrorKind::BrokenPipe, "closed"))
+ }
+ fn flush(&mut self) -> io::Result<()> {
+ Err(io::Error::new(io::ErrorKind::BrokenPipe, "closed"))
+ }
+ }
+
+ #[test]
+ fn flush_worker_progress_breaks_on_the_first_write_error() {
+ let _guard = GLOBAL_FACTORY_TEST_LOCK
+ .lock()
+ .unwrap_or_else(|e| e.into_inner());
+ WORKER_PROGRESS_EVENTS
+ .lock()
+ .unwrap_or_else(|e| e.into_inner())
+ .clear();
+ push_worker_progress(0.1, "a", "b");
+ push_worker_progress(0.2, "c", "d");
+ let mut failing = FailingWriter;
+ flush_worker_progress(&mut failing);
+ // The buffer is drained even though the pipe failed.
+ let mut out: Vec = Vec::new();
+ flush_worker_progress(&mut out);
+ assert!(out.is_empty());
+ }
+
+ #[test]
+ fn crash_hook_env_error_paths_are_inert() {
+ // The env vars are process-wide; the batch tests that call the hook
+ // take this same lock. The `EnvGuard`s restore the environment even
+ // if an assertion below fails (a leaked crash-mode override could
+ // abort a later serialized test).
+ let _guard = GLOBAL_FACTORY_TEST_LOCK
+ .lock()
+ .unwrap_or_else(|e| e.into_inner());
+ let session = WorkerSession::create("none").unwrap();
+
+ let marker = std::env::temp_dir().join(format!(
+ "oak_worker_crash_marker_{}",
+ std::process::id()
+ ));
+ let _ = std::fs::remove_file(&marker);
+ let _marker_env = EnvGuard::set("OAK_WORKER_CRASH_MARKER", &marker);
+
+ // A non-numeric ticket id is ignored (no parse, no crash, and no
+ // marker file side effect).
+ let _ticket = EnvGuard::set("OAK_WORKER_CRASH_ON_TICKET", "not-a-number");
+ session.maybe_crash_for_testing(1);
+ assert!(
+ !marker.exists(),
+ "a non-numeric ticket never reaches the marker write"
+ );
+ drop(_ticket);
+
+ // A matching ticket whose one-shot marker already exists does not
+ // crash again (the restarted worker renders for real) and leaves the
+ // marker untouched.
+ std::fs::write(&marker, b"crashed").unwrap();
+ let _ticket = EnvGuard::set("OAK_WORKER_CRASH_ON_TICKET", i64::MIN.to_string());
+ session.maybe_crash_for_testing(i64::MIN);
+ assert_eq!(
+ std::fs::read(&marker).unwrap(),
+ b"crashed".as_slice(),
+ "the existing marker is left untouched"
+ );
+ session.maybe_crash_for_testing(7); // different ticket: early return
+ assert_eq!(
+ std::fs::read(&marker).unwrap(),
+ b"crashed".as_slice(),
+ "a mismatching ticket writes nothing"
+ );
+
+ let _ = std::fs::remove_file(&marker);
+ }
+
+ #[test]
+ fn handshake_input_attach_failure_reports_error() {
+ let mut s = WorkerSession::create("none").unwrap();
+ let (mut hs, _out, _in) = parent_side(2, 256, false);
+ hs["input_slots"] = json!(2);
+ hs["input_slot_data_bytes"] = json!(256);
+ hs["input_shm_key"] = json!(format!("olive-rw-{}-missing-in", std::process::id()));
+ let resp = s.handle_line(&hs.to_string()).unwrap();
+ assert_eq!(resp["type"], "error");
+ assert!(
+ resp["message"]
+ .as_str()
+ .unwrap()
+ .starts_with("failed to attach input shared memory: "),
+ "{resp}"
+ );
+ }
+
+ #[test]
+ fn handshake_rejects_non_pool_input_segment() {
+ let mut s = WorkerSession::create("none").unwrap();
+ let (mut hs, _out, _in) = parent_side(2, 256, false);
+ let key = test_key("nopool-in");
+ let bytes = FrameSlotPool::bytes_needed(2, 256);
+ let mut region = SharedMemoryRegion::new();
+ assert!(region.open(&key, bytes, ShmMode::Create));
+ hs["input_slots"] = json!(2);
+ hs["input_slot_data_bytes"] = json!(256);
+ hs["input_shm_key"] = json!(key);
+ let resp = s.handle_line(&hs.to_string()).unwrap();
+ assert_eq!(
+ resp["message"],
+ "input shared memory does not contain a frame slot pool"
+ );
+ }
+
+ #[test]
+ fn load_graph_rejects_bad_shape_and_unreadable_files() {
+ let mut s = WorkerSession::create("none").unwrap();
+ let resp = s.handle_line(r#"{"type":"load_graph","path":42}"#).unwrap();
+ assert_eq!(resp["message"], "invalid load_graph message");
+
+ // A non-empty file that is not valid UTF-8: it passes the metadata
+ // checks but cannot be read as a project.
+ let unreadable = std::env::temp_dir().join(format!(
+ "oak_worker_unreadable_{}.ove",
+ std::process::id()
+ ));
+ std::fs::write(&unreadable, [0xFFu8, 0xFE, 0x00, 0x80]).unwrap();
+ let resp = s
+ .handle_line(
+ &json!({ "type": "load_graph", "path": unreadable.display().to_string() })
+ .to_string(),
+ )
+ .unwrap();
+ assert!(
+ resp["message"]
+ .as_str()
+ .unwrap()
+ .starts_with("graph file unreadable: "),
+ "{resp}"
+ );
+ let _ = std::fs::remove_file(&unreadable);
+
+ // The identity-only payload still lands as a graph context.
+ let ident = std::env::temp_dir().join(format!(
+ "oak_worker_identity_{}.ove",
+ std::process::id()
+ ));
+ std::fs::write(&ident, r#"{"project_copy":11}"#).unwrap();
+ assert!(s
+ .handle_line(
+ &json!({ "type": "load_graph", "path": ident.display().to_string() })
+ .to_string(),
+ )
+ .is_none());
+ let graph = s.graph.as_ref().expect("identity graph loaded");
+ assert!(graph.project.is_none());
+ assert_eq!(graph.project_copy, 11);
+ let _ = std::fs::remove_file(&ident);
+ }
+
+ #[test]
+ fn render_frame_maps_every_supported_pixel_format() {
+ let mut s = WorkerSession::create("none").unwrap();
+ // Five slots so each render can publish without draining.
+ let (hs, _out, _in) = parent_side(5, 64, false);
+ assert!(s.handle_line(&hs.to_string()).is_some());
+ for format in [
+ PixelFormat::U8,
+ PixelFormat::U10,
+ PixelFormat::U16,
+ PixelFormat::F16,
+ PixelFormat::F32,
+ ] {
+ let line = json!({
+ "type": "render_frame",
+ "ticket": 100 + format as i64,
+ "time_num": 0,
+ "time_den": 1,
+ "width": 2,
+ "height": 2,
+ "format": format as i32,
+ })
+ .to_string();
+ let resp = s.handle_line(&line).unwrap();
+ assert_eq!(resp["type"], "frame_ready", "{format:?}: {resp}");
+ }
+ }
+
+ #[test]
+ fn render_frame_rejects_invalid_message_and_unsupported_format() {
+ let mut s = WorkerSession::create("none").unwrap();
+ let resp = s
+ .handle_line(r#"{"type":"render_frame","ticket":"nope"}"#)
+ .unwrap();
+ assert_eq!(resp["message"], "invalid render_frame message");
+
+ let (hs, _out, _in) = parent_side(2, 256, false);
+ assert_eq!(
+ s.handle_line(&hs.to_string()).unwrap()["type"],
+ crate::ipc::TYPE_HELLO_CAPS
+ );
+ let resp = s
+ .handle_line(
+ r#"{"type":"render_frame","ticket":9,"time_num":0,"time_den":1,"width":4,"height":4,"format":7}"#,
+ )
+ .unwrap();
+ assert_eq!(resp["message"], "render_frame: unsupported format 7");
+ assert_eq!(resp["ticket"], 9);
+ }
+
+ #[test]
+ fn render_frame_defaults_zero_size_and_rejects_the_oversized_frame() {
+ let mut s = WorkerSession::create("none").unwrap();
+ let (hs, _out, _in) = parent_side(1, 64, false);
+ assert!(s.handle_line(&hs.to_string()).is_some());
+ // 0x0 means "pipeline default" (1920x1080), which cannot fit the
+ // 64-byte slot.
+ let resp = s
+ .handle_line(
+ r#"{"type":"render_frame","ticket":10,"time_num":0,"time_den":1,"width":0,"height":0,"format":-1}"#,
+ )
+ .unwrap();
+ assert_eq!(
+ resp["message"],
+ "render_frame: frame larger than the shm slot"
+ );
+ }
+
+ #[test]
+ fn render_frame_reports_no_free_slot_on_shutdown() {
+ let mut s = WorkerSession::create("none").unwrap();
+ let (hs, _out, _in) = parent_side(1, 256, false);
+ assert!(s.handle_line(&hs.to_string()).is_some());
+ s.shutdown_requested = true;
+ let resp = s
+ .handle_line(
+ r#"{"type":"render_frame","ticket":11,"time_num":0,"time_den":1,"width":4,"height":4,"format":-1}"#,
+ )
+ .unwrap();
+ assert_eq!(resp["message"], "render_frame: no free shm slot");
+ }
+
+ #[test]
+ fn render_frame_reports_ready_ring_full() {
+ let mut s = WorkerSession::create("none").unwrap();
+ let (hs, _out, _in) = parent_side(1, 256, false);
+ assert!(s.handle_line(&hs.to_string()).is_some());
+ // Fill the single-entry ready ring, then recycle the slot through
+ // the free ring so the render can acquire it again.
+ let pool = s.output_pool.clone().unwrap();
+ // SAFETY: live attached pool; single-threaded test.
+ unsafe {
+ let mut slot = 0u32;
+ assert!(pool.acquire(&mut slot));
+ assert!(pool.publish(slot));
+ assert!(pool.release(slot));
+ }
+ let resp = s
+ .handle_line(
+ r#"{"type":"render_frame","ticket":12,"time_num":0,"time_den":1,"width":4,"height":4,"format":-1}"#,
+ )
+ .unwrap();
+ assert_eq!(resp["message"], "render_frame: ready ring full");
+ }
+
+ #[test]
+ fn batch_and_audio_batch_reject_invalid_messages() {
+ let mut s = WorkerSession::create("none").unwrap();
+ let mut out: Vec = Vec::new();
+ s.handle_render_batch_stream(r#"{"type":"render_batch","batch_id":"x"}"#, &mut out)
+ .unwrap();
+ s.handle_render_audio_batch_stream(r#"{"type":"render_audio_batch","tickets":7}"#, &mut out)
+ .unwrap();
+ let lines: Vec = String::from_utf8(out)
+ .unwrap()
+ .lines()
+ .map(|line| serde_json::from_str(line).unwrap())
+ .collect();
+ assert_eq!(lines.len(), 2);
+ assert_eq!(lines[0]["type"], "error");
+ assert_eq!(lines[0]["message"], "invalid render_batch message");
+ assert_eq!(lines[1]["type"], "error");
+ assert_eq!(lines[1]["message"], "invalid render_audio_batch message");
+ }
+
+ #[test]
+ fn render_ticket_without_pool_and_with_wrong_slot_assignment() {
+ let mut s = WorkerSession::create("none").unwrap();
+ let spec = batch_spec(1, 0, 4, 4, PixelFormat::F32 as i32);
+ assert_eq!(
+ s.render_ticket_to_slot(&spec).unwrap_err(),
+ "no shared-memory pool attached"
+ );
+
+ let (hs, _out, _in) = parent_side(2, 256, false);
+ assert!(s.handle_line(&hs.to_string()).is_some());
+ let spec = batch_spec(1, 7, 4, 4, PixelFormat::F32 as i32);
+ assert_eq!(
+ s.render_ticket_to_slot(&spec).unwrap_err(),
+ "slot assignment mismatch: acquired 0, assigned 7"
+ );
+ }
+
+ #[test]
+ fn render_ticket_reports_a_full_ready_ring() {
+ let mut s = WorkerSession::create("none").unwrap();
+ let (hs, _out, _in) = parent_side(1, 256, false);
+ assert!(s.handle_line(&hs.to_string()).is_some());
+ let pool = s.output_pool.clone().unwrap();
+ // SAFETY: live attached pool; single-threaded test.
+ unsafe {
+ let mut slot = 0u32;
+ assert!(pool.acquire(&mut slot));
+ assert!(pool.publish(slot));
+ assert!(pool.release(slot));
+ }
+ let spec = batch_spec(2, 0, 4, 4, PixelFormat::F32 as i32);
+ assert_eq!(s.render_ticket_to_slot(&spec).unwrap_err(), "ready ring full");
+ }
+
+ #[test]
+ fn render_spec_pixels_rejects_oversized_and_rerenders_short_cache_entries() {
+ let mut s = WorkerSession::create("none").unwrap();
+ let (hs, _out, _in) = parent_side(1, 64, false);
+ assert!(s.handle_line(&hs.to_string()).is_some());
+ let pool = s.output_pool.clone().unwrap();
+
+ // 4x4 F32 needs 256 bytes; the slot holds 64.
+ let spec = batch_spec(3, 0, 4, 4, PixelFormat::F32 as i32);
+ let err = s.render_spec_pixels(&spec, &pool).unwrap_err();
+ assert!(err.starts_with("frame 4x4 needs 256 bytes"), "{err}");
+
+ // A 2x2 frame fits exactly; a stale entry smaller than this
+ // geometry is defensively discarded and re-rendered.
+ let spec = batch_spec(4, 0, 2, 2, PixelFormat::F32 as i32);
+ let key = crate::framecache::spec_cache_key(&spec);
+ s.frame_cache.insert(key.clone(), vec![0u8; 4]);
+ s.render_spec_pixels(&spec, &pool).expect("2x2 fits");
+ let cached = s.frame_cache.get(&key).expect("re-rendered and memoized");
+ assert_eq!(cached.len(), 64);
+ }
+
+ #[test]
+ fn render_spec_pixels_reports_footage_decode_failures() {
+ let mut s = WorkerSession::create("none").unwrap();
+ let (hs, _out, _in) = parent_side(2, 64, false);
+ assert!(s.handle_line(&hs.to_string()).is_some());
+ let pool = s.output_pool.clone().unwrap();
+ let missing = "/definitely/not/a/real/clip.mp4";
+
+ // F32 slot: the decode error propagates out of `render_f32_into`.
+ let spec = BatchTicketSpec {
+ footage_file: missing.to_string(),
+ ..batch_spec(5, 0, 2, 2, PixelFormat::F32 as i32)
+ };
+ let err = s.render_spec_pixels(&spec, &pool).unwrap_err();
+ assert!(err.starts_with("footage decode: "), "{err}");
+
+ // BGRA8 slot: the same failure through the scratch-buffer path.
+ let spec = BatchTicketSpec {
+ footage_file: missing.to_string(),
+ ..batch_spec(6, 1, 2, 2, SLOT_FORMAT_BGRA8)
+ };
+ let err = s.render_spec_pixels(&spec, &pool).unwrap_err();
+ assert!(err.starts_with("footage decode: "), "{err}");
+ }
+
+ #[test]
+ fn render_audio_ticket_without_pool_wrong_slot_and_full_ring() {
+ let mut s = WorkerSession::create("none").unwrap();
+ assert_eq!(
+ s.render_audio_ticket_to_slot(&AudioTicketSpec::default())
+ .unwrap_err(),
+ "no shared-memory pool attached"
+ );
+
+ let (hs, _out, _in) = parent_side(2, 64, false);
+ assert!(s.handle_line(&hs.to_string()).is_some());
+ let spec = AudioTicketSpec {
+ slot: 5,
+ ..Default::default()
+ };
+ assert_eq!(
+ s.render_audio_ticket_to_slot(&spec).unwrap_err(),
+ "slot assignment mismatch: acquired 0, assigned 5"
+ );
+
+ // One sample frame of stereo audio needs 8 bytes; fill the
+ // single-entry ready ring first so the publish fails.
+ let mut s = WorkerSession::create("none").unwrap();
+ let (hs, _out, _in) = parent_side(1, 8, false);
+ assert!(s.handle_line(&hs.to_string()).is_some());
+ let pool = s.output_pool.clone().unwrap();
+ // SAFETY: live attached pool; single-threaded test.
+ unsafe {
+ let mut slot = 0u32;
+ assert!(pool.acquire(&mut slot));
+ assert!(pool.publish(slot));
+ assert!(pool.release(slot));
+ }
+ let spec = AudioTicketSpec {
+ ticket: 2,
+ slot: 0,
+ time_num: 0,
+ time_den: 1,
+ duration_num: 1,
+ duration_den: 48000,
+ sample_rate: 48000,
+ channel_layout: 0x3,
+ channels: 2,
+ montage: Vec::new(),
+ };
+ assert_eq!(
+ s.render_audio_ticket_to_slot(&spec).unwrap_err(),
+ "ready ring full"
+ );
+ }
+
+ #[test]
+ fn audio_ticket_params_maps_montage_and_effects() {
+ let s = WorkerSession::create("none").unwrap();
+ let spec = AudioTicketSpec {
+ ticket: 1,
+ slot: 0,
+ time_num: 3,
+ time_den: 2,
+ duration_num: 1,
+ duration_den: 4,
+ sample_rate: 44100,
+ channel_layout: 0x3,
+ channels: 2,
+ montage: vec![WireMontageClip {
+ filename: "clip.mp4".to_string(),
+ stream_index: 2,
+ in_num: 1,
+ in_den: 2,
+ out_num: 3,
+ out_den: 2,
+ media_in_num: 0,
+ media_in_den: 1,
+ gain: 0.5,
+ effects: vec![WireMontageEffect {
+ type_id: "org.oak.gain".to_string(),
+ enabled: true,
+ effect_input_id: "Source".to_string(),
+ params: vec![WireEffectParam {
+ input: "gain".to_string(),
+ value: WireNodeValue::Float(2.0),
+ }],
+ }],
+ }],
+ };
+ let params = s.audio_ticket_params(&spec).expect("valid geometry");
+ assert_eq!(params.viewer, 0);
+ assert_eq!(params.range.in_(), Rational::new(3, 2));
+ assert_eq!(params.range.out(), Rational::new(7, 4));
+ assert_eq!(params.sample_rate, 44100);
+ assert_eq!(params.channel_layout, 0x3);
+ assert_eq!(params.montage.len(), 1);
+ let clip = ¶ms.montage[0];
+ assert_eq!(clip.filename, "clip.mp4");
+ assert_eq!(clip.stream_index, 2);
+ assert_eq!(clip.in_time, Rational::new(1, 2));
+ assert_eq!(clip.out_time, Rational::new(3, 2));
+ assert_eq!(clip.media_in, Rational::new(0, 1));
+ assert_eq!(clip.gain, 0.5);
+ assert_eq!(clip.effects.len(), 1);
+ assert_eq!(clip.effects[0].type_id, "org.oak.gain");
+ assert!(clip.effects[0].enabled);
+ assert_eq!(clip.effects[0].effect_input_id.as_deref(), Some("Source"));
+ assert_eq!(clip.effects[0].params.len(), 1);
+ assert_eq!(clip.effects[0].params[0].0, "gain");
+ assert_eq!(
+ clip.effects[0].params[0].1,
+ oak_node::value::NodeValue::Float(2.0)
+ );
+ }
+
+ #[test]
+ fn sync_pipeline_color_without_a_project_is_a_noop() {
+ let mut s = WorkerSession::create("none").unwrap();
+ assert!(!s.sync_pipeline_color_from_graph(), "no graph at all");
+ s.graph = Some(LoadedGraph {
+ path: "identity".to_string(),
+ project: None,
+ project_uuid: None,
+ id_map: HashMap::new(),
+ project_copy: 3,
+ });
+ assert!(
+ !s.sync_pipeline_color_from_graph(),
+ "identity-only graphs carry no color settings"
+ );
+ }
+
+ #[test]
+ fn render_spec_pixels_graph_mode_copies_the_sequence_frame() {
+ let _color = COLOR_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
+ let (project, seq, uuid) = sequence_project();
+ let mut s = WorkerSession::create("none").unwrap();
+ let (hs, _out, _in) = parent_side(1, 64, false);
+ assert!(s.handle_line(&hs.to_string()).is_some());
+ let pool = s.output_pool.clone().unwrap();
+
+ let mut graph = LoadedGraph {
+ path: "graph".to_string(),
+ project: Some(project),
+ project_uuid: Some(uuid.clone()),
+ id_map: HashMap::new(),
+ project_copy: 0,
+ };
+ graph.id_map.insert(7, seq);
+ s.graph = Some(graph);
+
+ let spec = BatchTicketSpec {
+ viewer_node: 7,
+ project_key: uuid,
+ ..batch_spec(41, 0, 2, 2, PixelFormat::F32 as i32)
+ };
+ s.render_spec_pixels(&spec, &pool)
+ .expect("an empty sequence renders");
+ // SAFETY: slot 0 of the attached pool is live and 64 bytes.
+ let dst = unsafe { std::slice::from_raw_parts(pool.slot_data_const(0), 64) };
+ assert!(
+ dst.iter().all(|&b| b == 0),
+ "an empty sequence is transparent black"
+ );
+ }
+
+ #[test]
+ fn graph_mode_falls_back_for_mismatched_or_broken_viewers() {
+ let _color = COLOR_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
+ let (project, seq, uuid) = sequence_project();
+ // A second, non-sequence node: mapping a viewer to it makes
+ // `render_graph_frame` fail and take the fallback path.
+ let track_list;
+ {
+ let mut guard = project.lock().unwrap_or_else(|e| e.into_inner());
+ let (core, behavior) = TrackListBehavior::create();
+ track_list = guard.graph.add_node(core, behavior);
+ }
+ let mut s = WorkerSession::create("none").unwrap();
+ let (hs, _out, _in) = parent_side(1, 64, false);
+ assert!(s.handle_line(&hs.to_string()).is_some());
+ let pool = s.output_pool.clone().unwrap();
+
+ let mut graph = LoadedGraph {
+ path: "graph".to_string(),
+ project: Some(project),
+ project_uuid: Some(uuid.clone()),
+ id_map: HashMap::new(),
+ project_copy: 0,
+ };
+ graph.id_map.insert(7, seq);
+ graph.id_map.insert(8, track_list);
+ s.graph = Some(graph);
+
+ // (a) A stale project key: the snapshot must not answer it.
+ let spec = BatchTicketSpec {
+ viewer_node: 7,
+ project_key: "another-project".to_string(),
+ ..batch_spec(51, 0, 2, 2, PixelFormat::F32 as i32)
+ };
+ s.render_spec_pixels(&spec, &pool)
+ .expect("stale identity falls back");
+
+ // (b) A viewer identity that is not even a valid NodeId.
+ let spec = BatchTicketSpec {
+ viewer_node: u64::MAX,
+ project_key: uuid.clone(),
+ ..spec.clone()
+ };
+ s.render_spec_pixels(&spec, &pool)
+ .expect("absent viewer falls back");
+
+ // (c) A viewer mapped to a non-sequence node: graph render errors.
+ let spec = BatchTicketSpec {
+ viewer_node: 8,
+ project_key: uuid.clone(),
+ ..spec.clone()
+ };
+ s.render_spec_pixels(&spec, &pool)
+ .expect("broken viewer falls back");
+
+ // (d) A loaded identity-only graph (uuid matches, no project).
+ s.graph = Some(LoadedGraph {
+ path: "identity".to_string(),
+ project: None,
+ project_uuid: Some(uuid.clone()),
+ id_map: HashMap::new(),
+ project_copy: 0,
+ });
+ let spec = BatchTicketSpec {
+ viewer_node: 7,
+ project_key: uuid,
+ ..spec
+ };
+ s.render_spec_pixels(&spec, &pool)
+ .expect("identity-only graph falls back");
+ }
+
+ #[test]
+ fn warn_graph_fallback_is_one_shot() {
+ // Smoke test: the only observable effect of the warning is a single
+ // stderr line, and the worker has no test-installable log sink, so
+ // the one-shot suppression itself cannot be asserted. What must hold
+ // either way: repeated and concurrent calls all return without
+ // corrupting shared state (the marker is an AtomicBool and the sink
+ // is stderr, so no call may deadlock or panic).
+ warn_graph_fallback(123, "first reason");
+ warn_graph_fallback(123, "second reason");
+
+ let completed = std::sync::atomic::AtomicUsize::new(0);
+ std::thread::scope(|scope| {
+ for viewer in 0..8u64 {
+ let completed = &completed;
+ scope.spawn(move || {
+ warn_graph_fallback(viewer, "concurrent reason");
+ completed.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
+ });
+ }
+ });
+ assert_eq!(
+ completed.load(std::sync::atomic::Ordering::Relaxed),
+ 8,
+ "every concurrent fallback warning must return"
+ );
+ }
+
+ #[test]
+ fn stale_pipeline_colors_are_refreshed_and_clear_the_frame_cache() {
+ let _color = COLOR_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
+ // Loop over both starting states so the opposite-selection branch
+ // runs both ways despite the shared process-global color state.
+ for current in [WorkingColorSpace::SrgbLegacy, WorkingColorSpace::AcesCg] {
+ oak_core::color::set_pipeline_color_settings(current, OutputColorSpec::default());
+ let opposite = if current == WorkingColorSpace::SrgbLegacy {
+ WorkingColorSpace::AcesCg
+ } else {
+ WorkingColorSpace::SrgbLegacy
+ };
+ let project = Project::new();
+ {
+ let mut guard = project.lock().unwrap_or_else(|e| e.into_inner());
+ guard.set_working_color_space(opposite);
+ }
+ let uuid = project.lock().unwrap_or_else(|e| e.into_inner()).uuid.clone();
+ let mut s = WorkerSession::create("none").unwrap();
+ s.graph = Some(LoadedGraph {
+ path: "graph".to_string(),
+ project: Some(project),
+ project_uuid: Some(uuid),
+ id_map: HashMap::new(),
+ project_copy: 0,
+ });
+ s.frame_cache.insert("stale".to_string(), vec![1u8; 8]);
+
+ let (hs, _out, _in) = parent_side(1, 64, false);
+ assert!(s.handle_line(&hs.to_string()).is_some());
+ let pool = s.output_pool.clone().unwrap();
+ let spec = batch_spec(61, 0, 2, 2, PixelFormat::F32 as i32);
+ s.render_spec_pixels(&spec, &pool)
+ .expect("renders under the refreshed colors");
+ assert_eq!(
+ oak_core::color::pipeline_working_space(),
+ opposite,
+ "the loaded graph's colors are adopted"
+ );
+ assert!(
+ s.frame_cache.get("stale").is_none(),
+ "a color change invalidates the frame cache"
+ );
+ }
+
+ oak_core::color::set_pipeline_color_settings(
+ WorkingColorSpace::default(),
+ OutputColorSpec::default(),
+ );
+ }
+
+ #[test]
+ fn apply_output_node_is_a_noop_in_the_legacy_working_space() {
+ let _color = COLOR_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
+ oak_core::color::set_pipeline_color_settings(
+ WorkingColorSpace::SrgbLegacy,
+ OutputColorSpec::default(),
+ );
+ let mut bytes: Vec = [0.25f32, 0.5, 0.75, 1.0]
+ .iter()
+ .flat_map(|value| value.to_le_bytes())
+ .collect();
+ let before = bytes.clone();
+ apply_output_node(&mut bytes, 1);
+ assert_eq!(bytes, before, "legacy sRGB is display-referred already");
+ oak_core::color::set_pipeline_color_settings(
+ WorkingColorSpace::default(),
+ OutputColorSpec::default(),
+ );
+ }
+
+ #[test]
+ fn worker_main_without_renderer_exits_one() {
+ // Mirrors oakengine_worker_main(): an explicit no-renderer backend
+ // leaves nothing to evaluate, so the process exits 1 before the
+ // control loop.
+ assert_eq!(worker_main("none"), 1);
+ }
+
+ // ---- M16 R2 branch-coverage additions (runtime + renderer) ------------
+
+ /// The full `initialize_runtime` body on a fresh session: color config,
+ /// text backends, plugin executor, OFX scan/registration and the
+ /// process-global progress factory. A plugin scan failure is tolerated,
+ /// so either outcome is a valid production result.
+ #[test]
+ fn initialize_runtime_installs_the_full_stack() {
+ // The reporter factory is process-global; serialize with every
+ // other factory test (worker and ofx_host share this lock).
+ let _guard = GLOBAL_FACTORY_TEST_LOCK
+ .lock()
+ .unwrap_or_else(|e| e.into_inner());
+ let _color = COLOR_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
+ let mut s = WorkerSession::create("none").unwrap();
+ assert!(!s.runtime_initialized);
+ assert!(s.initialize_runtime(), "the runtime always initializes");
+ assert!(s.runtime_initialized);
+ assert!(
+ oak_plugin::progress::has_reporter_factory(),
+ "the worker progress factory is installed"
+ );
+ // The second call short-circuits before re-installing anything.
+ assert!(s.initialize_runtime());
+ }
+
+ /// The dynamic / direct-OpenGL renderer factories: a GPU host
+ /// initializes one, a GPU-less host fails both — either outcome is a
+ /// valid production result, so the assertions pin the invariants that
+ /// must hold either way: a successful dynamic attempt reports the
+ /// requested backend kind (init resolves adapters internally but never
+ /// rewrites the kind), a failure names the stage that failed, and the
+ /// chained `create` succeeds exactly when one of its two stages can,
+ /// reporting OpenGL exactly when the fallback produced it.
+ #[test]
+ fn renderer_create_variants_are_tolerant() {
+ match Renderer::create_dynamic("gl") {
+ Ok(renderer) => assert!(renderer.is_open_gl(), "the gl request reports OpenGL"),
+ Err(error) => assert!(
+ error.starts_with("failed to initialize dynamic gl renderer"),
+ "the gl failure names the stage: {error}"
+ ),
+ }
+
+ let dynamic_auto = Renderer::create_dynamic("auto");
+ let dynamic_metal = Renderer::create_dynamic("metal");
+ for (label, attempt) in [("auto", &dynamic_auto), ("metal", &dynamic_metal)] {
+ match attempt {
+ Ok(renderer) => assert!(
+ !renderer.is_open_gl(),
+ "{label} is not an OpenGL request"
+ ),
+ Err(error) => assert!(
+ error.starts_with(&format!("failed to initialize dynamic {label} renderer")),
+ "the {label} failure names the stage: {error}"
+ ),
+ }
+ }
+
+ let opengl = Renderer::create_opengl();
+ match &opengl {
+ Ok(renderer) => assert!(renderer.is_open_gl(), "direct OpenGL reports OpenGL"),
+ Err(error) => assert!(
+ error.starts_with("failed to initialize direct OpenGL renderer"),
+ "the fallback failure names the stage: {error}"
+ ),
+ }
+
+ // `Renderer::create` = dynamic stage, then direct-OpenGL fallback:
+ // its outcome is fully determined by the two stages observed above.
+ for (request, dynamic) in [("bogus", &dynamic_auto), ("metal", &dynamic_metal)] {
+ match Renderer::create(request) {
+ Ok(renderer) => {
+ assert_eq!(
+ renderer.is_open_gl(),
+ dynamic.is_err(),
+ "{request}: OpenGL is reported exactly when the dynamic stage failed"
+ );
+ if dynamic.is_err() {
+ assert!(
+ opengl.is_ok(),
+ "{request}: the fallback must have initialized"
+ );
+ }
+ }
+ Err(error) => {
+ assert!(
+ dynamic.is_err() && opengl.is_err(),
+ "{request}: a chained error means both stages failed"
+ );
+ assert!(
+ error.contains(&format!("dynamic {request}"))
+ && error.contains("direct OpenGL fallback also failed"),
+ "{request}: the chained error names both stages: {error}"
+ );
+ }
+ }
+ }
+ }
+
+ /// An F32 cache hit copies the memoized bytes into the slot instead of
+ /// re-rendering (a miss would produce transparent black).
+ #[test]
+ fn render_spec_pixels_f32_cache_hit_copies_without_render() {
+ let mut s = WorkerSession::create("none").unwrap();
+ let (hs, _out, _in) = parent_side(1, 64, false);
+ assert!(s.handle_line(&hs.to_string()).is_some());
+ let pool = s.output_pool.clone().unwrap();
+ let spec = batch_spec(71, 0, 2, 2, PixelFormat::F32 as i32);
+ let key = crate::framecache::spec_cache_key(&spec);
+ let known: Vec = (0..64).map(|i| i as u8).collect();
+ s.frame_cache.insert(key.clone(), known.clone());
+ s.render_spec_pixels(&spec, &pool).expect("cache hit");
+ // SAFETY: slot 0 of the attached pool is live and 64 bytes.
+ let dst = unsafe { std::slice::from_raw_parts(pool.slot_data_const(0), 64) };
+ assert_eq!(dst, &known[..], "the cached F32 bytes land in the slot");
+ assert!(s.frame_cache.get(&key).is_some(), "the entry stays memoized");
+ }
+
+ /// A BGRA8 cache hit runs the output node + format convert on the
+ /// memoized F32 bytes (a miss would produce transparent black BGRA8).
+ #[test]
+ fn render_spec_pixels_bgra8_cache_hit_converts_the_memoized_frame() {
+ let _color = COLOR_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
+ oak_core::color::set_pipeline_color_settings(
+ WorkingColorSpace::SrgbLegacy,
+ OutputColorSpec::default(),
+ );
+ let mut s = WorkerSession::create("none").unwrap();
+ let (hs, _out, _in) = parent_side(1, 64, false);
+ assert!(s.handle_line(&hs.to_string()).is_some());
+ let pool = s.output_pool.clone().unwrap();
+ let spec = batch_spec(72, 0, 2, 2, SLOT_FORMAT_BGRA8);
+ let key = crate::framecache::spec_cache_key(&spec);
+ // Four opaque-white F32 pixels.
+ let white: Vec = std::iter::repeat_n(1.0f32.to_le_bytes(), 2 * 2 * 4)
+ .flatten()
+ .collect();
+ s.frame_cache.insert(key.clone(), white);
+ s.render_spec_pixels(&spec, &pool).expect("cache hit");
+ // SAFETY: slot 0 of the attached pool holds the 2x2 BGRA8 frame.
+ let dst = unsafe { std::slice::from_raw_parts(pool.slot_data_const(0), 2 * 2 * 4) };
+ assert!(
+ dst.iter().all(|&b| b == 255),
+ "cached white F32 converts to opaque white BGRA8: {dst:?}"
+ );
+ oak_core::color::set_pipeline_color_settings(
+ WorkingColorSpace::default(),
+ OutputColorSpec::default(),
+ );
+ }
+
+ /// The batch path's acquire failure (shutdown) and a render error both
+ /// surface as `Err` from `render_ticket_to_slot` (the slot is never
+ /// published in either case).
+ #[test]
+ fn render_ticket_to_slot_reports_shutdown_and_render_errors() {
+ let mut s = WorkerSession::create("none").unwrap();
+ let (hs, _out, _in) = parent_side(2, 256, false);
+ assert!(s.handle_line(&hs.to_string()).is_some());
+
+ // Shutdown: `acquire_slot` refuses before touching the rings.
+ s.shutdown_requested = true;
+ let spec = batch_spec(1, 0, 4, 4, PixelFormat::F32 as i32);
+ assert_eq!(
+ s.render_ticket_to_slot(&spec).unwrap_err(),
+ "no free shm slot (shutdown or timeout)"
+ );
+ s.shutdown_requested = false;
+
+ // A render failure (missing footage) propagates out of the batch
+ // path unchanged.
+ let spec = BatchTicketSpec {
+ footage_file: "/definitely/not/a/real/clip.mp4".to_string(),
+ ..batch_spec(2, 0, 2, 2, PixelFormat::F32 as i32)
+ };
+ let err = s.render_ticket_to_slot(&spec).unwrap_err();
+ assert!(err.starts_with("footage decode: "), "{err}");
+ }
+
+ /// The audio batch path's acquire failure: a shutdown session returns
+ /// "no free shm slot" before mixing anything.
+ #[test]
+ fn render_audio_ticket_to_slot_reports_shutdown() {
+ let mut s = WorkerSession::create("none").unwrap();
+ let (hs, _out, _in) = parent_side(1, 64, false);
+ assert!(s.handle_line(&hs.to_string()).is_some());
+ s.shutdown_requested = true;
+ let spec = AudioTicketSpec {
+ ticket: 5,
+ slot: 0,
+ time_num: 0,
+ time_den: 1,
+ duration_num: 1,
+ duration_den: 48000,
+ sample_rate: 48000,
+ channel_layout: 0x3,
+ channels: 2,
+ montage: Vec::new(),
+ };
+ assert_eq!(
+ s.render_audio_ticket_to_slot(&spec).unwrap_err(),
+ "no free shm slot (shutdown or timeout)"
+ );
+ }
}