diff --git a/crates/oak-storage/tests/otio_backend_test.rs b/crates/oak-storage/tests/otio_backend_test.rs
new file mode 100644
index 000000000..0402c9c07
--- /dev/null
+++ b/crates/oak-storage/tests/otio_backend_test.rs
@@ -0,0 +1,497 @@
+// 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 .
+
+//! Boundary tests for the `.otio` / `.fcpxml` storage backend
+//! (`oak_storage::backends::otio`): metadata, URI/extension validation,
+//! a rich multi-sequence round trip (collection root, gap, transition,
+//! missing media reference), the empty-project shape, and load errors.
+
+use std::sync::Arc;
+use std::sync::Mutex;
+
+use oak_core::{Rational, TimeRange};
+use oak_node::block::{ClipBlockBehavior, TransitionBlockBehavior};
+use oak_node::footage::FootageBehavior;
+use oak_node::id::NodeId;
+use oak_node::node::NodeCore;
+use oak_node::project::Project;
+use oak_node::sequence::SequenceBehavior;
+use oak_node::track::{TrackBehavior, TrackListBehavior, TrackType};
+use oak_storage::backend::{LoadResult, StorageBackend};
+use oak_storage::backends::otio::OtioBackend;
+use oak_storage::nodeutil::{make_project_owned, project_arc};
+use oak_storage::uri::StorageUri;
+
+fn work_path(tag: &str, ext: &str) -> std::path::PathBuf {
+ let path = std::env::temp_dir().join(format!(
+ "oakstorage_otio_{tag}_{}.{ext}",
+ std::process::id()
+ ));
+ let _ = std::fs::remove_file(&path);
+ path
+}
+
+fn file_uri(path: &std::path::Path) -> StorageUri {
+ StorageUri::parse(&format!("file://{}", path.display())).expect("file uri")
+}
+
+/// A synthetic media path: the OTIO backend only carries the filename, it
+/// never opens/probes the media, so no real file is needed.
+fn media_path(tag: &str) -> std::path::PathBuf {
+ work_path(tag, "mp4")
+}
+
+/// One clip covering `[in, out)` (media seconds), optionally linked to
+/// `footage` both as the domain field and as a graph edge.
+fn add_clip(
+ p: &mut Project,
+ track: NodeId,
+ footage: Option,
+ in_: Rational,
+ out: Rational,
+) -> NodeId {
+ let (core, mut behavior) = oak_node::block::clip_create();
+ let block = {
+ let clip = behavior
+ .as_any_mut()
+ .and_then(|a| a.downcast_mut::())
+ .expect("clip behavior");
+ clip.core.range = TimeRange::new(in_, out);
+ clip.core.media_in = Rational::new(0, 1);
+ clip.core.track = Some(track);
+ clip.footage = footage;
+ let id = p.graph.add_node(core, behavior);
+ if let Some(footage) = footage {
+ p.graph
+ .connect(
+ footage,
+ id,
+ oak_node::block::clip_input::TEXTURE_INPUT,
+ -1,
+ )
+ .expect("connect footage");
+ }
+ id
+ };
+ if let Some(entry) = p.graph.get_mut(track) {
+ if let Some(t) = entry
+ .behavior
+ .as_any_mut()
+ .and_then(|a| a.downcast_mut::())
+ {
+ t.blocks.push(block);
+ }
+ }
+ block
+}
+
+fn add_gap(p: &mut Project, track: NodeId, in_: Rational, out: Rational) -> NodeId {
+ let (core, mut behavior) = oak_node::block::gap_create();
+ let id = {
+ let gap = behavior
+ .as_any_mut()
+ .and_then(|a| a.downcast_mut::())
+ .expect("gap behavior");
+ gap.core.range = TimeRange::new(in_, out);
+ gap.core.track = Some(track);
+ p.graph.add_node(core, behavior)
+ };
+ if let Some(entry) = p.graph.get_mut(track) {
+ if let Some(t) = entry
+ .behavior
+ .as_any_mut()
+ .and_then(|a| a.downcast_mut::())
+ {
+ t.blocks.push(id);
+ }
+ }
+ id
+}
+
+fn add_transition(
+ p: &mut Project,
+ track: NodeId,
+ in_offset: Rational,
+ out_offset: Rational,
+) -> NodeId {
+ let (core, mut behavior) = oak_node::block::transition_create();
+ let id = {
+ let t = behavior
+ .as_any_mut()
+ .and_then(|a| a.downcast_mut::())
+ .expect("transition behavior");
+ t.core.range = TimeRange::new(Rational::new(3, 1), Rational::new(4, 1));
+ t.core.track = Some(track);
+ t.in_offset = in_offset;
+ t.out_offset = out_offset;
+ p.graph.add_node(core, behavior)
+ };
+ if let Some(entry) = p.graph.get_mut(track) {
+ if let Some(t) = entry
+ .behavior
+ .as_any_mut()
+ .and_then(|a| a.downcast_mut::())
+ {
+ t.blocks.push(id);
+ }
+ }
+ id
+}
+
+fn add_track(p: &mut Project, kind: TrackType) -> NodeId {
+ let (core, behavior) = TrackBehavior::create();
+ let track = p.graph.add_node(core, behavior);
+ if let Some(entry) = p.graph.get_mut(track) {
+ if let Some(t) = entry
+ .behavior
+ .as_any_mut()
+ .and_then(|a| a.downcast_mut::())
+ {
+ t.kind = kind;
+ }
+ }
+ track
+}
+
+fn attach_list(p: &mut Project, sequence: NodeId, kind: TrackType, tracks: Vec) -> NodeId {
+ let (core, behavior) = TrackListBehavior::create();
+ let list = p.graph.add_node(core, behavior);
+ if let Some(entry) = p.graph.get_mut(list) {
+ if let Some(l) = entry
+ .behavior
+ .as_any_mut()
+ .and_then(|a| a.downcast_mut::())
+ {
+ l.kind = kind;
+ l.tracks = tracks;
+ }
+ }
+ if let Some(entry) = p.graph.get_mut(sequence) {
+ if let Some(s) = entry
+ .behavior
+ .as_any_mut()
+ .and_then(|a| a.downcast_mut::())
+ {
+ s.track_lists.push(list);
+ }
+ }
+ list
+}
+
+/// A project with `count` sequences named "Seq0".."SeqN"; the first has a
+/// video track (clip, explicit gap, transition, footage-less clip) and an
+/// audio track.
+fn build_project(media: Option<&str>, count: usize) -> Arc> {
+ let project = Project::new();
+ {
+ let mut p = project.lock().unwrap();
+ p.initialize().expect("root folder");
+
+ let footage =
+ media.map(|media| p.graph.add_node(NodeCore::new(), Box::new(FootageBehavior::new(media))));
+
+ for index in 0..count {
+ let (core, behavior) = SequenceBehavior::create();
+ let seq = p.graph.add_node(core, behavior);
+ if let Some(entry) = p.graph.get_mut(seq) {
+ entry.core.label = format!("Seq{index}");
+ }
+ if index == 0 {
+ let video = add_track(&mut p, TrackType::Video);
+ add_clip(&mut p, video, footage, Rational::new(0, 1), Rational::new(1, 1));
+ // A discontinuity before this block exercises the implicit-gap
+ // export path (block in 2 > position 1).
+ add_gap(&mut p, video, Rational::new(2, 1), Rational::new(3, 1));
+ add_transition(
+ &mut p,
+ video,
+ Rational::new(1, 2),
+ Rational::new(1, 4),
+ );
+ // No footage and no edge: exports a MissingReference.
+ add_clip(
+ &mut p,
+ video,
+ None,
+ Rational::new(4, 1),
+ Rational::new(5, 1),
+ );
+ attach_list(&mut p, seq, TrackType::Video, vec![video]);
+
+ let audio = add_track(&mut p, TrackType::Audio);
+ add_clip(&mut p, audio, footage, Rational::new(0, 1), Rational::new(1, 1));
+ attach_list(&mut p, seq, TrackType::Audio, vec![audio]);
+ }
+ }
+ }
+ project
+}
+
+struct Loaded {
+ project: Arc>,
+ result: LoadResult,
+}
+
+impl Drop for Loaded {
+ fn drop(&mut self) {
+ if let Some(release) = self.result.project.release {
+ // SAFETY: the load result is an owned handle (refcount 1).
+ unsafe { release(self.result.project.ctx) };
+ }
+ }
+}
+
+fn load(uri: &StorageUri) -> oak_storage::error::Result {
+ let backend = OtioBackend::new();
+ let result = backend.load(uri)?;
+ let arc = unsafe { project_arc(&result.project)? };
+ Ok(Loaded { project: arc, result })
+}
+
+/// Sequence labels present in `project`.
+fn sequence_labels(project: &Arc>) -> Vec {
+ let guard = project.lock().unwrap_or_else(|e| e.into_inner());
+ let mut labels = Vec::new();
+ for id in guard.graph.node_ids() {
+ if let Some(entry) = guard.graph.get(id) {
+ if entry
+ .behavior
+ .as_any()
+ .and_then(|a| a.downcast_ref::())
+ .is_some()
+ {
+ labels.push(entry.core.label.clone());
+ }
+ }
+ }
+ labels.sort();
+ labels
+}
+
+#[test]
+fn backend_metadata_and_can_handle() {
+ let backend = OtioBackend::new();
+ assert_eq!(backend.name(), "otio");
+ assert_eq!(backend.uri_scheme(), "file");
+ assert!(backend.can_handle(&file_uri(std::path::Path::new("/tmp/a.otio"))));
+ assert!(backend.can_handle(&file_uri(std::path::Path::new("/tmp/a.fcpxml"))));
+ assert!(!backend.can_handle(&file_uri(std::path::Path::new("/tmp/a.ove"))));
+ assert!(!backend.can_handle(&file_uri(std::path::Path::new("/tmp/a"))));
+}
+
+#[test]
+fn save_rejects_bad_uri_and_extension() {
+ let project = build_project(None, 0);
+ let backend = OtioBackend::new();
+
+ // Unsupported extension.
+ let uri = file_uri(&work_path("bad", "ove"));
+ assert!(backend.save_project(&project, &uri, 0).is_err());
+ // No extension at all.
+ let uri = StorageUri::parse("file:///tmp/noext").expect("uri");
+ assert!(backend.save_project(&project, &uri, 0).is_err());
+ // Non-local URI (the otio backend only writes files).
+ let uri = StorageUri::parse("oakdb+sqlite:///tmp/x.otio").expect("uri");
+ assert!(backend.save_project(&project, &uri, 0).is_err());
+
+ // The facade `save` (handle in, path validated the same way).
+ let handle = make_project_owned(project);
+ let uri = file_uri(&work_path("bad", "ove"));
+ assert!(StorageBackend::save(&backend, handle, &uri, 0).is_err());
+ if let Some(release) = handle.release {
+ unsafe { release(handle.ctx) };
+ }
+}
+
+#[test]
+fn otio_collection_round_trip_preserves_sequences_tracks_and_blocks() {
+ let media = media_path("rich");
+ let project = build_project(Some(&media.to_string_lossy()), 2);
+ let out = work_path("rich", "otio");
+
+ let backend = OtioBackend::new();
+ let handle = make_project_owned(project.clone());
+ backend
+ .save(handle, &file_uri(&out), 0)
+ .expect("save through the facade");
+ if let Some(release) = handle.release {
+ unsafe { release(handle.ctx) };
+ }
+
+ let loaded = load(&file_uri(&out)).expect("load");
+ assert_eq!(loaded.result.version_info, oak_storage::error::OAKSTORAGE_OK);
+ assert_eq!(
+ sequence_labels(&loaded.project),
+ vec!["Seq0".to_string(), "Seq1".to_string()]
+ );
+
+ // The first sequence carries both track lists, and its video track has
+ // the four exported composables (clip, gap, transition, clip).
+ let guard = loaded.project.lock().unwrap();
+ let mut tracks = 0;
+ let mut clips = 0;
+ let mut transitions = 0;
+ let mut footage_links = 0;
+ let mut missing_links = 0;
+ for id in guard.graph.node_ids() {
+ let Some(entry) = guard.graph.get(id) else {
+ continue;
+ };
+ let Some(any) = entry.behavior.as_any() else {
+ continue;
+ };
+ if let Some(t) = any.downcast_ref::() {
+ // clip, an implicit gap for the discontinuity, the explicit gap,
+ // the transition and the footage-less clip.
+ if t.blocks.len() == 5 {
+ tracks += 1;
+ }
+ }
+ if let Some(c) = any.downcast_ref::() {
+ clips += 1;
+ if c.footage.is_some() {
+ footage_links += 1;
+ } else {
+ missing_links += 1;
+ }
+ }
+ if let Some(t) = any.downcast_ref::() {
+ transitions += 1;
+ assert_eq!(t.in_offset, Rational::new(1, 2));
+ assert_eq!(t.out_offset, Rational::new(1, 4));
+ }
+ }
+ assert_eq!(tracks, 1, "the rich video track");
+ assert_eq!(clips, 3, "two linked clips + one missing-reference clip");
+ assert_eq!(footage_links, 2);
+ assert_eq!(missing_links, 1);
+ assert_eq!(transitions, 1);
+
+ drop(guard);
+ let _ = std::fs::remove_file(&out);
+}
+
+#[test]
+fn fcpxml_round_trip_preserves_sequences_tracks_and_blocks() {
+ let media = media_path("fcpxml");
+ let project = build_project(Some(&media.to_string_lossy()), 1);
+ let out = work_path("fcpxml", "fcpxml");
+
+ let backend = OtioBackend::new();
+ backend
+ .save_project(&project, &file_uri(&out), 0)
+ .expect("save fcpxml");
+ let loaded = load(&file_uri(&out)).expect("load fcpxml");
+ assert_eq!(sequence_labels(&loaded.project), vec!["Seq0".to_string()]);
+
+ // The sequence carries both track lists, and its video track has the
+ // exported composables (clip, implicit gap, explicit gap, transition,
+ // footage-less clip) — the FCPXML writer places blocks sequentially.
+ let guard = loaded.project.lock().unwrap();
+ let mut video_tracks = 0;
+ let mut audio_tracks = 0;
+ let mut clips = 0;
+ let mut transitions = 0;
+ let mut footage_links = 0;
+ let mut missing_links = 0;
+ for id in guard.graph.node_ids() {
+ let Some(entry) = guard.graph.get(id) else {
+ continue;
+ };
+ let Some(any) = entry.behavior.as_any() else {
+ continue;
+ };
+ if let Some(t) = any.downcast_ref::() {
+ if t.kind == TrackType::Video && t.blocks.len() == 5 {
+ video_tracks += 1;
+ }
+ if t.kind == TrackType::Audio && t.blocks.len() == 1 {
+ audio_tracks += 1;
+ }
+ }
+ if let Some(c) = any.downcast_ref::() {
+ clips += 1;
+ if c.footage.is_some() {
+ footage_links += 1;
+ } else {
+ missing_links += 1;
+ }
+ }
+ if let Some(t) = any.downcast_ref::() {
+ transitions += 1;
+ // FCP X centers transitions on import: each offset is half the
+ // exported duration (1/2 + 1/4 seconds).
+ assert_eq!(t.in_offset, Rational::new(3, 8));
+ assert_eq!(t.out_offset, Rational::new(3, 8));
+ }
+ }
+ assert_eq!(video_tracks, 1, "the rich video track");
+ assert_eq!(audio_tracks, 1, "the audio track");
+ assert_eq!(clips, 3, "two linked clips + one missing-reference clip");
+ assert_eq!(footage_links, 2);
+ assert_eq!(missing_links, 1);
+ assert_eq!(transitions, 1);
+
+ drop(guard);
+ let _ = std::fs::remove_file(&out);
+}
+
+#[test]
+fn empty_project_exports_one_empty_timeline() {
+ let project = build_project(None, 0);
+ let out = work_path("empty", "otio");
+ let backend = OtioBackend::new();
+ backend
+ .save_project(&project, &file_uri(&out), 0)
+ .expect("save empty project");
+
+ let loaded = load(&file_uri(&out)).expect("load empty project");
+ // The export names the placeholder "Timeline"; the import carries it.
+ assert_eq!(sequence_labels(&loaded.project), vec!["Timeline".to_string()]);
+ let _ = std::fs::remove_file(&out);
+}
+
+#[test]
+fn load_reports_missing_corrupt_and_unrooted_documents() {
+ let backend = OtioBackend::new();
+
+ // Missing file.
+ let missing = work_path("missing", "otio");
+ assert!(backend.load(&file_uri(&missing)).is_err());
+
+ // Malformed JSON.
+ let corrupt = work_path("corrupt", "otio");
+ std::fs::write(&corrupt, b"{ not json").expect("write");
+ assert!(backend.load(&file_uri(&corrupt)).is_err());
+ let _ = std::fs::remove_file(&corrupt);
+
+ // Valid JSON whose root is neither a timeline nor a collection.
+ let raw = work_path("raw", "otio");
+ std::fs::write(&raw, br#"{"OTIO_SCHEMA":"RationalTime.1","value":1,"rate":24}"#)
+ .expect("write");
+ let err = backend.load(&file_uri(&raw)).expect_err("raw root must fail");
+ assert!(
+ format!("{err}").contains("no timeline or collection root"),
+ "{err}"
+ );
+ let _ = std::fs::remove_file(&raw);
+
+ // Missing FCPXML and no-extension URIs.
+ let missing_xml = work_path("missing", "fcpxml");
+ assert!(backend.load(&file_uri(&missing_xml)).is_err());
+ let no_ext = StorageUri::parse("file:///tmp/noext").expect("uri");
+ assert!(backend.load(&no_ext).is_err());
+}
diff --git a/crates/oak-storage/tests/writethrough_test.rs b/crates/oak-storage/tests/writethrough_test.rs
new file mode 100644
index 000000000..f034bedf5
--- /dev/null
+++ b/crates/oak-storage/tests/writethrough_test.rs
@@ -0,0 +1,289 @@
+// 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 .
+
+//! Boundary tests for the oakstorage write-through façade
+//! (`oak_storage::writethrough`): configuration gating, binding lifecycle,
+//! write-through / flush / unbind, error recording, and the null-handle
+//! contract.
+//!
+//! The config store is process-global, so all tests serialize on a local
+//! mutex and write `OAK_CONFIG_DIR` to an isolated temp directory before
+//! the first settings access (the tests never call `ConfigStore::save`, so
+//! nothing persists).
+
+use std::sync::{Arc, Mutex, MutexGuard, OnceLock};
+
+use oak_node::project::Project;
+use oak_storage::handle::CHandle;
+use oak_storage::nodeutil::make_project_owned;
+use oak_storage::uri::StorageUri;
+use oak_storage::writethrough;
+
+/// Serializes the tests (global config + global bindings + the snapshot
+/// thread).
+static CONFIG_LOCK: Mutex<()> = Mutex::new(());
+
+/// Take the lock and isolate the config store under a per-process temp
+/// directory (once) before any settings access.
+fn isolated_config() -> MutexGuard<'static, ()> {
+ static INIT: OnceLock<()> = OnceLock::new();
+ let guard = CONFIG_LOCK.lock().unwrap_or_else(|e| e.into_inner());
+ INIT.get_or_init(|| {
+ let dir = std::env::temp_dir().join(format!(
+ "oakstorage_writethrough_config_{}",
+ std::process::id()
+ ));
+ let _ = std::fs::remove_dir_all(&dir);
+ let _ = std::fs::create_dir_all(&dir);
+ std::env::set_var("OAK_CONFIG_DIR", &dir);
+ });
+ guard
+}
+
+fn set_storage_config(backend: &str, sqlite_path: Option<&std::path::Path>, pg_url: Option<&str>) {
+ let store = oak_core::configstore::ConfigStore::instance();
+ store.set(Some("Storage"), "Backend", backend);
+ store.set(
+ Some("Storage"),
+ "SqlitePath",
+ sqlite_path
+ .map(|p| p.to_string_lossy().into_owned())
+ .as_deref()
+ .unwrap_or(""),
+ );
+ store.set(Some("Storage"), "PgUrl", pg_url.unwrap_or(""));
+}
+
+fn work_dir(tag: &str) -> std::path::PathBuf {
+ let dir = std::env::temp_dir().join(format!(
+ "oakstorage_writethrough_{tag}_{}",
+ std::process::id()
+ ));
+ let _ = std::fs::remove_dir_all(&dir);
+ std::fs::create_dir_all(&dir).expect("work dir");
+ dir
+}
+
+fn project_handle() -> (CHandle, Arc>) {
+ let project = Project::new();
+ let handle = make_project_owned(project.clone());
+ (handle, project)
+}
+
+fn release(handle: CHandle) {
+ if let Some(release) = handle.release {
+ // SAFETY: the handle is an owned box (refcount 1) that this test
+ // created via `make_project_owned`.
+ unsafe { release(handle.ctx) };
+ }
+}
+
+// ---- null handles and status ------------------------------------------
+
+#[test]
+fn null_handles_are_safe_no_ops() {
+ let _guard = isolated_config();
+ assert!(!writethrough::is_bound(CHandle::null()));
+ assert_eq!(writethrough::last_error(CHandle::null()), None);
+ writethrough::bind_project(CHandle::null());
+ writethrough::unbind_project(CHandle::null());
+ // Nothing to flush is a no-op (and idempotent).
+ writethrough::flush_all();
+ writethrough::flush_all();
+}
+
+// ---- configuration gating ---------------------------------------------
+
+#[test]
+fn backend_disabled_by_config_leaves_projects_unbound() {
+ let _guard = isolated_config();
+ set_storage_config("off", None, None);
+ assert!(!writethrough::storage_enabled());
+
+ let (handle, _project) = project_handle();
+ writethrough::bind_project(handle);
+ assert!(
+ !writethrough::is_bound(handle),
+ "disabled backend must not bind"
+ );
+ writethrough::unbind_project(handle);
+ release(handle);
+}
+
+#[test]
+fn backend_enabled_by_config_binds_and_round_trips() {
+ let _guard = isolated_config();
+ let dir = work_dir("sq");
+ let db = dir.join("library.db");
+ set_storage_config("sqlite", Some(&db), None);
+ assert!(writethrough::storage_enabled());
+
+ let (handle, project) = project_handle();
+ assert!(!writethrough::is_bound(handle));
+ writethrough::bind_project(handle);
+ assert!(writethrough::is_bound(handle));
+ assert_eq!(writethrough::last_error(handle), None);
+
+ // Re-binding is idempotent (the existing binding is kept).
+ writethrough::bind_project(handle);
+ assert!(writethrough::is_bound(handle));
+
+ // Add real state so the round trip carries more than an empty row.
+ let original_uuid = {
+ let mut guard = project.lock().unwrap_or_else(|e| e.into_inner());
+ let (core, behavior) = oak_node::folder::create("Written Through");
+ guard.graph.add_node(core, behavior);
+ guard.uuid.clone()
+ };
+
+ // A command-success notification writes the project through.
+ writethrough::note_command();
+ assert_eq!(
+ writethrough::last_error(handle),
+ None,
+ "a write-through to a writable temp path succeeds"
+ );
+
+ // The write-through is real: the sqlite library file exists...
+ assert!(
+ db.is_file(),
+ "the sqlite library file was created at {}",
+ db.display()
+ );
+ assert!(
+ std::fs::metadata(&db).map(|m| m.len()).unwrap_or(0) > 0,
+ "the sqlite library file is non-empty"
+ );
+
+ // Flush drains and snapshots; it is idempotent and safe afterwards.
+ writethrough::flush_all();
+ writethrough::flush_all();
+
+ // ...and a fresh backend load reads the bound project's state back.
+ let uri = StorageUri::parse(&writethrough::library_uri().expect("library uri"))
+ .expect("parse library uri");
+ let loaded = writethrough::backend()
+ .load_project(&uri)
+ .expect("load the written-through project");
+ {
+ let guard = loaded.lock().unwrap_or_else(|e| e.into_inner());
+ assert_eq!(
+ guard.uuid, original_uuid,
+ "the loaded library row is the bound project"
+ );
+ let labels: Vec = guard
+ .graph
+ .node_ids()
+ .into_iter()
+ .filter_map(|id| guard.graph.get(id))
+ .map(|entry| entry.core.label.clone())
+ .collect();
+ assert!(
+ labels.iter().any(|label| label == "Written Through"),
+ "the loaded project carries the state added before note_command: {labels:?}"
+ );
+ }
+
+ writethrough::unbind_project(handle);
+ assert!(!writethrough::is_bound(handle), "unbind drops the binding");
+ assert_eq!(writethrough::last_error(handle), None);
+
+ release(handle);
+ let _ = std::fs::remove_dir_all(&dir);
+}
+
+#[test]
+fn write_through_failure_is_recorded_in_last_error() {
+ let _guard = isolated_config();
+ // A directory cannot be opened as a SQLite database: the write-through
+ // fails and the binding records the error instead of panicking.
+ let dir = work_dir("bad");
+ set_storage_config("sqlite", Some(&dir), None);
+
+ let (handle, _project) = project_handle();
+ writethrough::bind_project(handle);
+ assert!(writethrough::is_bound(handle));
+ writethrough::note_command();
+ assert!(
+ writethrough::last_error(handle).is_some(),
+ "a failing write-through must record last_error"
+ );
+
+ writethrough::flush_all();
+ writethrough::unbind_project(handle);
+ release(handle);
+ let _ = std::fs::remove_dir_all(&dir);
+}
+
+#[test]
+fn library_uri_resolves_sqlite_and_pg_configurations() {
+ let _guard = isolated_config();
+ let dir = work_dir("uri");
+ let db = dir.join("lib.db");
+ set_storage_config("sqlite", Some(&db), None);
+ let uri = writethrough::library_uri().expect("sqlite uri");
+ assert!(uri.starts_with("oakdb+sqlite://"), "{uri}");
+ assert!(uri.ends_with("lib.db"), "{uri}");
+
+ // PostgreSQL: the postgres:// scheme is stripped into the oakdb body.
+ set_storage_config(
+ "pg",
+ None,
+ Some("postgres://user:pass@host:5432/db"),
+ );
+ assert_eq!(
+ writethrough::library_uri().as_deref(),
+ Some("oakdb+pg://user:pass@host:5432/db")
+ );
+ // `postgresql://` is accepted too.
+ set_storage_config("pg", None, Some("postgresql://user@host/db"));
+ assert_eq!(
+ writethrough::library_uri().as_deref(),
+ Some("oakdb+pg://user@host/db")
+ );
+
+ // pg selected without a URL: no library configured.
+ set_storage_config("pg", None, None);
+ assert_eq!(writethrough::library_uri(), None);
+
+ let _ = std::fs::remove_dir_all(&dir);
+}
+
+#[test]
+fn default_library_path_honors_the_config_directory() {
+ let _guard = isolated_config();
+ let path = writethrough::default_library_path();
+ assert!(path.ends_with("library.db"), "{path}");
+ let config_dir = std::env::var("OAK_CONFIG_DIR").expect("test isolation set it");
+ assert!(
+ path.starts_with(&config_dir),
+ "{path} must live under {config_dir}"
+ );
+}
+
+#[test]
+fn note_command_without_bindings_is_a_no_op() {
+ let _guard = isolated_config();
+ set_storage_config(
+ "sqlite",
+ Some(&work_dir("none").join("lib.db")),
+ None,
+ );
+ // No project is bound in this process (the other tests' bindings are
+ // dropped before they return): the notification must not panic.
+ writethrough::note_command();
+ writethrough::flush_all();
+}
diff --git a/crates/oak-task/src/nodeops.rs b/crates/oak-task/src/nodeops.rs
index c7c6eb45a..b123f8b6f 100644
--- a/crates/oak-task/src/nodeops.rs
+++ b/crates/oak-task/src/nodeops.rs
@@ -1181,4 +1181,573 @@ pub fn add_track_command(project: ProjectRef, list: NodeId) -> UndoCommand {
kind: TrackType::Video,
})
}
- kind: TrackType::Video,
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use oak_node::track::TrackType;
+ use oak_node::value::AudioParams;
+
+ // ---- fixtures -------------------------------------------------------
+
+ fn project() -> ProjectRef {
+ Project::new()
+ }
+
+ fn sequence_with_list(project: &ProjectRef, kind: TrackType) -> (NodeId, NodeId) {
+ let seq = sequence_create(project).expect("sequence");
+ let list = sequence_track_list(project, seq, kind).expect("track list");
+ (seq, list)
+ }
+
+ /// Append a track through the public command (the only path that wires
+ /// the `index`/`track_list` fields), returning its id.
+ fn append_track(project: &ProjectRef, list: NodeId) -> NodeId {
+ let mut cmd = add_track_command(project.clone(), list);
+ cmd.redo_now();
+ let guard = lock_project(project);
+ guard
+ .graph
+ .get(list)
+ .and_then(|e| e.behavior.as_any())
+ .and_then(|a| a.downcast_ref::())
+ .and_then(|l| l.tracks.last().copied())
+ .expect("track appended")
+ }
+
+ fn add_block(project: &ProjectRef, kind: BlockKind) -> NodeId {
+ block_create(project, kind).expect("block created")
+ }
+
+ fn add_adjustment(project: &ProjectRef) -> NodeId {
+ let (core, behavior) = oak_node::block::adjustment_create();
+ lock_project(project).graph.add_node(core, behavior)
+ }
+
+ /// Give a block an exact timeline range (the public setters keep the
+ /// out-point anchored and shift the in-point, which is awkward for
+ /// length assertions).
+ fn set_block_range(project: &ProjectRef, block: NodeId, in_: Rational, out: Rational) {
+ let mut guard = lock_project(project);
+ let ok = set_block_core(&mut guard.graph, block, |core| {
+ core.range = oak_core::TimeRange::new(in_, out);
+ });
+ assert!(ok, "block {block:?} range set");
+ }
+
+ fn with_graph(project: &ProjectRef, f: impl FnOnce(&Graph) -> R) -> R {
+ let guard = lock_project(project);
+ f(&guard.graph)
+ }
+
+ fn folder_children(project: &ProjectRef, folder: NodeId) -> Vec {
+ with_graph(project, |g| {
+ g.get(folder)
+ .and_then(|e| e.behavior.as_any())
+ .and_then(|a| a.downcast_ref::())
+ .map(|f| f.children.clone())
+ .unwrap_or_default()
+ })
+ }
+
+ fn bin_folder_of(project: &ProjectRef, node: NodeId) -> Option {
+ with_graph(project, |g| g.get(node).and_then(|e| e.core.bin_folder))
+ }
+
+ fn write_clip(tag: &str) -> std::path::PathBuf {
+ let path = std::env::temp_dir().join(format!(
+ "oaktask_nodeops_{tag}_{}.mp4",
+ std::process::id()
+ ));
+ oak_codec::testmedia::write_test_clip(&path, 64, 64, 10, 10).expect("test clip");
+ path
+ }
+
+ // ---- shared block core ---------------------------------------------
+
+ #[test]
+ fn block_core_of_covers_every_block_kind_and_rejects_non_blocks() {
+ let p = project();
+ let clip = add_block(&p, BlockKind::Clip);
+ let gap = add_block(&p, BlockKind::Gap);
+ let transition = add_block(&p, BlockKind::Transition);
+ let adjustment = add_adjustment(&p);
+ let folder = folder_create(&p).expect("folder");
+ let sequence = sequence_create(&p).expect("sequence");
+
+ with_graph(&p, |g| {
+ for id in [clip, gap, transition, adjustment] {
+ assert!(block_core_of(g, id).is_some(), "block {id:?} must have a core");
+ }
+ assert!(block_core_of(g, folder).is_none(), "folder is not a block");
+ assert!(block_core_of(g, sequence).is_none(), "sequence is not a block");
+ assert!(block_core_of(g, NodeId::INVALID).is_none(), "stale id");
+ });
+ }
+
+ #[test]
+ fn block_create_rejects_the_unknown_kind() {
+ let p = project();
+ assert!(block_create(&p, BlockKind::Other).is_none());
+ assert!(block_create(&p, BlockKind::Clip).is_some());
+ assert!(block_create(&p, BlockKind::Gap).is_some());
+ assert!(block_create(&p, BlockKind::Transition).is_some());
+ }
+
+ // ---- node metadata --------------------------------------------------
+
+ #[test]
+ fn labels_round_trip_and_missing_nodes_stay_empty() {
+ let p = project();
+ let seq = sequence_create(&p).expect("sequence");
+ assert_eq!(node_label(&p, seq), "");
+ set_node_label(&p, seq, "Timeline 1");
+ assert_eq!(node_label(&p, seq), "Timeline 1");
+
+ // Missing / invalid nodes are safe no-ops.
+ set_node_label(&p, NodeId::INVALID, "nope");
+ assert_eq!(node_label(&p, NodeId::INVALID), "");
+ }
+
+ #[test]
+ fn node_type_ids_and_predicates_match_the_node_kind() {
+ let p = project();
+ let footage = footage_create(&p, None).expect("footage");
+ let sequence = sequence_create(&p).expect("sequence");
+ let folder = folder_create(&p).expect("folder");
+
+ assert_eq!(node_type_id(&p, sequence), SEQUENCE_TYPE_ID);
+ assert_eq!(node_type_id(&p, NodeId::INVALID), "");
+
+ with_graph(&p, |g| {
+ assert!(node_is_footage(g, footage));
+ assert!(!node_is_footage(g, sequence));
+ assert!(!node_is_footage(g, folder));
+ assert!(node_is_sequence(g, sequence));
+ assert!(!node_is_sequence(g, footage));
+ assert!(!node_is_sequence(g, NodeId::INVALID));
+ });
+ }
+
+ #[test]
+ fn context_position_is_a_safe_no_op_for_missing_nodes() {
+ let p = project();
+ let seq = sequence_create(&p).expect("sequence");
+ node_set_context_position(&p, seq, NodeId::INVALID, 1.0, 2.0, true);
+ node_set_context_position(&p, NodeId::INVALID, seq, 1.0, 2.0, false);
+ }
+
+ // ---- sequence / track list -----------------------------------------
+
+ #[test]
+ fn sequence_track_list_creates_once_per_kind_and_rejects_non_sequences() {
+ let p = project();
+ let seq = sequence_create(&p).expect("sequence");
+ let video = sequence_track_list(&p, seq, TrackType::Video).expect("video list");
+ let audio = sequence_track_list(&p, seq, TrackType::Audio).expect("audio list");
+ let subtitle = sequence_track_list(&p, seq, TrackType::Subtitle).expect("subtitle list");
+ assert_ne!(video, audio);
+ assert_ne!(video, subtitle);
+ // Created once: the sequence caches each list.
+ assert_eq!(sequence_track_list(&p, seq, TrackType::Video), Some(video));
+ assert_eq!(sequence_track_list(&p, seq, TrackType::Audio), Some(audio));
+ assert_eq!(sequence_track_list(&p, seq, TrackType::Subtitle), Some(subtitle));
+
+ // Non-sequences and stale ids have no track lists.
+ let folder = folder_create(&p).expect("folder");
+ assert_eq!(sequence_track_list(&p, folder, TrackType::Video), None);
+ assert_eq!(sequence_track_list(&p, NodeId::INVALID, TrackType::Video), None);
+ }
+
+ #[test]
+ fn sequence_params_default_and_read_back() {
+ let p = project();
+ let seq = sequence_create(&p).expect("sequence");
+ // The domain constructor applies the default parameters.
+ let default = sequence_video_params(&p, seq, 0).expect("default video params");
+ assert_eq!((default.width(), default.height()), (1920, 1080));
+ assert_eq!(default.channel_count(), 4);
+ assert_eq!(sequence_audio_params(&p, seq), (48000, 0x3));
+ assert!(sequence_video_params(&p, seq, 1).is_none(), "out of range");
+ assert_eq!(sequence_audio_params(&p, NodeId::INVALID), (48000, 0x3));
+ assert!(sequence_video_params(&p, NodeId::INVALID, 0).is_none());
+ assert!(sequence_video_params(&p, folder_create(&p).expect("folder"), 0).is_none());
+
+ // Replace the defaults and read them back through the oak_core type.
+ {
+ let mut guard = lock_project(&p);
+ let entry = guard.graph.get_mut(seq).expect("sequence");
+ let s = entry
+ .behavior
+ .as_any_mut()
+ .and_then(|a| a.downcast_mut::())
+ .expect("sequence behavior");
+ s.video_params = vec![oak_node::value::VideoParams {
+ width: 1280,
+ height: 720,
+ frame_rate: Rational::new(24, 1),
+ pixel_format: oak_core::ocioutils::PixelFormat::F32.code(),
+ channels: 4,
+ interlaced: true,
+ }];
+ s.audio_params = vec![AudioParams {
+ sample_rate: 44100,
+ channel_layout: 0x3,
+ format: 0,
+ }];
+ }
+ let v = sequence_video_params(&p, seq, 0).expect("video params");
+ assert_eq!((v.width(), v.height()), (1280, 720));
+ assert_eq!(v.frame_rate(), (24, 1));
+ assert_eq!(v.channel_count(), 4);
+ assert_eq!(sequence_audio_params(&p, seq), (44100, 0x3));
+ }
+
+ // ---- footage --------------------------------------------------------
+
+ #[test]
+ fn footage_accessors_reject_wrong_node_types() {
+ let p = project();
+ let folder = folder_create(&p).expect("folder");
+ assert!(!footage_set_filename(&p, folder, "x.mp4"));
+ assert!(!footage_is_valid(&p, folder));
+ assert_eq!(footage_filename(&p, folder), "");
+ assert_eq!(footage_total_stream_count(&p, folder), 0);
+ assert!(footage_video_params(&p, folder, 0).is_none());
+ assert_eq!(footage_audio_sample_rate(&p, folder), None);
+ assert_eq!(footage_filename(&p, NodeId::INVALID), "");
+ assert!(!footage_is_valid(&p, NodeId::INVALID));
+ // Cancellation on a wrong node is a safe no-op.
+ footage_set_cancelled(&p, folder, true);
+ }
+
+ #[test]
+ fn footage_probe_round_trip_with_real_media() {
+ let path = write_clip("probe");
+ let p = project();
+ let footage = footage_create(&p, None).expect("footage");
+ assert!(!footage_is_valid(&p, footage));
+ assert_eq!(footage_total_stream_count(&p, footage), 0);
+
+ // A missing file fails the probe without panicking.
+ assert!(!footage_set_filename(&p, footage, "/definitely/not/here.mp4"));
+ assert!(!footage_is_valid(&p, footage));
+
+ // A real clip probes: valid, with stream parameters.
+ assert!(footage_set_filename(&p, footage, &path.to_string_lossy()));
+ assert!(footage_is_valid(&p, footage));
+ assert_eq!(footage_filename(&p, footage), path.to_string_lossy());
+ assert!(footage_total_stream_count(&p, footage) >= 1);
+ let video = footage_video_params(&p, footage, 0).expect("video params");
+ assert_eq!((video.width(), video.height()), (64, 64));
+ assert!(footage_audio_sample_rate(&p, footage).is_some());
+ assert!(node_length(&p, footage) > Rational::new(0, 1));
+
+ // Out-of-range stream index.
+ assert!(footage_video_params(&p, footage, 99).is_none());
+
+ // Overwriting a stream parameter round-trips through the oak_core type.
+ let mut params = CommonVideoParams::new_basic(
+ 32,
+ 32,
+ oak_core::ocioutils::PixelFormat::F32,
+ 4,
+ 1,
+ 1,
+ 0,
+ 1,
+ );
+ params.set_frame_rate(30, 1);
+ footage_set_video_params(&p, footage, 0, ¶ms);
+ let back = footage_video_params(&p, footage, 0).expect("overwritten params");
+ assert_eq!((back.width(), back.height()), (32, 32));
+ assert_eq!(back.frame_rate(), (30, 1));
+ // A wrong node type is a no-op.
+ footage_set_video_params(&p, NodeId::INVALID, 0, ¶ms);
+
+ let _ = std::fs::remove_file(&path);
+ }
+
+ #[test]
+ fn node_length_covers_footage_sequence_and_other_types() {
+ let path = write_clip("length");
+ let p = project();
+ let footage = footage_create(&p, None).expect("footage");
+ assert!(footage_set_filename(&p, footage, &path.to_string_lossy()));
+ assert!(node_length(&p, footage) > Rational::new(0, 1));
+
+ let (seq, list) = sequence_with_list(&p, TrackType::Video);
+ assert_eq!(node_length(&p, seq), Rational::new(0, 1), "empty sequence");
+ let track = append_track(&p, list);
+ let a = add_block(&p, BlockKind::Clip);
+ let b = add_block(&p, BlockKind::Clip);
+ assert!(track_append_block(&p, track, a));
+ assert!(track_append_block(&p, track, b));
+ set_block_range(&p, a, Rational::new(0, 1), Rational::new(2, 1));
+ set_block_range(&p, b, Rational::new(2, 1), Rational::new(5, 1));
+ assert_eq!(node_length(&p, seq), Rational::new(5, 1), "longest track out");
+
+ let folder = folder_create(&p).expect("folder");
+ assert_eq!(node_length(&p, folder), Rational::new(0, 1));
+ assert_eq!(node_length(&p, NodeId::INVALID), Rational::new(0, 1));
+
+ let _ = std::fs::remove_file(&path);
+ }
+
+ // ---- tracks and blocks ---------------------------------------------
+
+ #[test]
+ fn tracklist_accessors_and_out_of_range() {
+ let p = project();
+ let (_, list) = sequence_with_list(&p, TrackType::Audio);
+ assert_eq!(tracklist_track_count(&p, list), 0);
+ assert!(tracklist_track_at(&p, list, 0).is_none());
+ let track = append_track(&p, list);
+ assert_eq!(tracklist_track_count(&p, list), 1);
+ assert_eq!(tracklist_track_at(&p, list, 0), Some(track));
+ assert!(tracklist_track_at(&p, list, 1).is_none());
+ assert_eq!(track_type(&p, track), Some(TrackType::Audio));
+ assert_eq!(track_type(&p, list), None);
+ assert_eq!(track_type(&p, NodeId::INVALID), None);
+ // A non-list node has no tracks.
+ assert_eq!(tracklist_track_count(&p, track), 0);
+ }
+
+ #[test]
+ fn track_blocks_append_guard_and_length() {
+ let p = project();
+ let (_, list) = sequence_with_list(&p, TrackType::Video);
+ let track = append_track(&p, list);
+ let clip = add_block(&p, BlockKind::Clip);
+ assert!(!track_append_block(&p, NodeId::INVALID, clip), "stale track");
+ assert!(track_append_block(&p, track, clip));
+ assert_eq!(track_block_count(&p, track), 1);
+ assert_eq!(track_block_at(&p, track, 0), Some(clip));
+ assert!(track_block_at(&p, track, 1).is_none());
+ assert_eq!(track_block_count(&p, NodeId::INVALID), 0);
+ set_block_range(&p, clip, Rational::new(0, 1), Rational::new(0, 1));
+ assert_eq!(track_length(&p, track), Rational::new(0, 1), "zero-length block");
+ set_block_range(&p, clip, Rational::new(1, 1), Rational::new(4, 1));
+ assert_eq!(track_length(&p, track), Rational::new(4, 1), "max out");
+ assert_eq!(track_length(&p, NodeId::INVALID), Rational::new(0, 1));
+ }
+
+ #[test]
+ fn block_getters_default_for_missing_nodes_and_setters_round_trip() {
+ let p = project();
+ let clip = add_block(&p, BlockKind::Clip);
+ assert_eq!(block_in(&p, NodeId::INVALID), Rational::new(0, 1));
+ assert_eq!(block_length(&p, NodeId::INVALID), Rational::new(0, 1));
+ assert_eq!(block_media_in(&p, NodeId::INVALID), Rational::new(0, 1));
+
+ clip_set_media_in(&p, clip, 2, 1);
+ assert_eq!(block_media_in(&p, clip), Rational::new(2, 1));
+ block_set_length_and_media_out(&p, clip, 5, 1);
+ assert_eq!(block_length(&p, clip), Rational::new(5, 1));
+ // Setters on a non-block/missing node are no-ops.
+ block_set_length_and_media_out(&p, NodeId::INVALID, 1, 1);
+ clip_set_media_in(&p, NodeId::INVALID, 9, 1);
+ }
+
+ #[test]
+ fn clip_footage_round_trip_and_non_clip_nodes() {
+ let p = project();
+ let clip = add_block(&p, BlockKind::Clip);
+ let gap = add_block(&p, BlockKind::Gap);
+ let footage = footage_create(&p, None).expect("footage");
+ assert_eq!(clip_footage(&p, clip), None);
+ clip_set_footage(&p, clip, footage);
+ assert_eq!(clip_footage(&p, clip), Some(footage));
+ assert_eq!(clip_footage(&p, gap), None);
+ assert_eq!(clip_footage(&p, NodeId::INVALID), None);
+ clip_set_footage(&p, gap, footage); // no-op on a gap
+ assert_eq!(clip_footage(&p, gap), None);
+ }
+
+ #[test]
+ fn transition_offsets_default_and_set_length() {
+ let p = project();
+ let transition = add_block(&p, BlockKind::Transition);
+ assert_eq!(transition_in_offset(&p, transition), Rational::new(0, 1));
+ assert_eq!(transition_out_offset(&p, transition), Rational::new(0, 1));
+ assert_eq!(transition_in_offset(&p, NodeId::INVALID), Rational::new(0, 1));
+
+ transition_set_offsets_and_length(&p, transition, 1, 2, 3, 4);
+ assert_eq!(transition_in_offset(&p, transition), Rational::new(1, 2));
+ assert_eq!(transition_out_offset(&p, transition), Rational::new(3, 4));
+ assert_eq!(
+ block_length(&p, transition),
+ Rational::new(5, 4),
+ "length = in + out"
+ );
+ // Wrong node types are no-ops.
+ transition_set_offsets_and_length(&p, NodeId::INVALID, 1, 1, 1, 1);
+ }
+
+ #[test]
+ fn block_kind_and_adjustment_predicates() {
+ let p = project();
+ let adjustment = add_adjustment(&p);
+ assert!(block_is_adjustment(&p, adjustment));
+ assert!(!block_is_adjustment(&p, NodeId::INVALID));
+ for (kind, expected) in [
+ (BlockKind::Clip, BlockKind::Clip),
+ (BlockKind::Gap, BlockKind::Gap),
+ (BlockKind::Transition, BlockKind::Transition),
+ ] {
+ let block = add_block(&p, kind);
+ assert_eq!(block_kind(&p, block), expected);
+ }
+ assert_eq!(block_kind(&p, adjustment), BlockKind::Other);
+ assert_eq!(block_kind(&p, NodeId::INVALID), BlockKind::Other);
+ // An adjustment block is not one of the C-ABI block kinds.
+ let folder = folder_create(&p).expect("folder");
+ assert_eq!(block_kind(&p, folder), BlockKind::Other);
+ }
+
+ // ---- graph adapters and connections --------------------------------
+
+ #[test]
+ fn graph_range_adapters_default_for_missing_nodes() {
+ let p = project();
+ let (_, list) = sequence_with_list(&p, TrackType::Video);
+ let track = append_track(&p, list);
+ let clip = add_block(&p, BlockKind::Clip);
+ assert!(track_append_block(&p, track, clip));
+ set_block_range(&p, clip, Rational::new(2, 1), Rational::new(6, 1));
+
+ with_graph(&p, |g| {
+ let blocks = GraphBlockRange { graph: g };
+ assert_eq!(blocks.in_(NodeId::INVALID), Rational::new(0, 1));
+ assert_eq!(blocks.out(NodeId::INVALID), Rational::new(0, 1));
+ assert_eq!(blocks.in_(clip), Rational::new(2, 1));
+ assert_eq!(blocks.out(clip), Rational::new(6, 1));
+
+ let tracks = GraphTrackRange { graph: g };
+ assert_eq!(tracks.length(NodeId::INVALID), Rational::new(0, 1));
+ assert_eq!(tracks.length(clip), Rational::new(0, 1), "not a track");
+ assert_eq!(tracks.length(track), Rational::new(6, 1));
+ });
+ }
+
+ #[test]
+ fn node_connect_validates_endpoints_and_inputs() {
+ let p = project();
+ let footage = footage_create(&p, None).expect("footage");
+ let clip = add_block(&p, BlockKind::Clip);
+ assert!(node_connect(&p, footage, clip, CLIP_TEXTURE_INPUT));
+ assert!(!node_connect(&p, clip, footage, CLIP_TEXTURE_INPUT), "unknown input");
+ assert!(!node_connect(&p, NodeId::INVALID, clip, CLIP_TEXTURE_INPUT));
+ assert!(!node_connect(&p, footage, NodeId::INVALID, CLIP_TEXTURE_INPUT));
+ }
+
+ #[test]
+ fn find_input_footage_direct_and_through_the_graph() {
+ let p = project();
+ let footage = footage_create(&p, None).expect("footage");
+ let clip = add_block(&p, BlockKind::Clip);
+ let folder = folder_create(&p).expect("folder");
+
+ // A footage node answers with itself.
+ with_graph(&p, |g| assert_eq!(find_input_footage(g, footage), Some(footage)));
+ // A clip answers with its recorded footage...
+ clip_set_footage(&p, clip, footage);
+ with_graph(&p, |g| assert_eq!(find_input_footage(g, clip), Some(footage)));
+ // ...but a stale recorded footage is ignored (falls through to the
+ // graph walk, which finds nothing here).
+ clip_set_footage(&p, clip, NodeId::INVALID);
+ with_graph(&p, |g| assert_eq!(find_input_footage(g, clip), None));
+ assert_eq!(node_find_input_footage(&p, clip), None);
+ // A graph edge to a footage node is found by the walk.
+ assert!(node_connect(&p, footage, clip, CLIP_TEXTURE_INPUT));
+ assert_eq!(node_find_input_footage(&p, clip), Some(footage));
+ // Non-footage, unconnected nodes have no footage.
+ assert_eq!(node_find_input_footage(&p, folder), None);
+ assert_eq!(node_find_input_footage(&p, NodeId::INVALID), None);
+ }
+
+ #[test]
+ fn pixel_format_codes_map_known_and_unknown() {
+ use oak_core::PixelFormat as P;
+ assert_eq!(pixel_format_from_code(-1), P::Invalid);
+ assert_eq!(pixel_format_from_code(0), P::U8);
+ assert_eq!(pixel_format_from_code(1), P::U10);
+ assert_eq!(pixel_format_from_code(2), P::U16);
+ assert_eq!(pixel_format_from_code(3), P::F16);
+ assert_eq!(pixel_format_from_code(4), P::F32);
+ assert_eq!(pixel_format_from_code(99), P::Invalid);
+ }
+
+ // ---- undo commands --------------------------------------------------
+
+ #[test]
+ fn folder_add_child_command_redo_undo() {
+ let p = project();
+ let folder = folder_create(&p).expect("folder");
+ let child = footage_create(&p, None).expect("footage");
+ let mut cmd =
+ folder_add_child_command((p.clone(), folder), (p.clone(), child));
+
+ assert!(folder_children(&p, folder).is_empty());
+ cmd.redo_now();
+ assert_eq!(folder_children(&p, folder), vec![child]);
+ assert_eq!(bin_folder_of(&p, child), Some(folder));
+
+ cmd.undo_now();
+ assert!(folder_children(&p, folder).is_empty());
+ assert_eq!(bin_folder_of(&p, child), None);
+ }
+
+ #[test]
+ fn remove_node_command_restores_the_entry_and_folder_slot() {
+ let p = project();
+ let folder = folder_create(&p).expect("folder");
+ let first = footage_create(&p, None).expect("first");
+ let second = footage_create(&p, None).expect("second");
+ folder_add_child_command((p.clone(), folder), (p.clone(), first)).redo_now();
+ folder_add_child_command((p.clone(), folder), (p.clone(), second)).redo_now();
+ assert_eq!(folder_children(&p, folder), vec![first, second]);
+
+ let mut cmd = remove_node_command(p.clone(), first);
+ cmd.redo_now();
+ assert!(with_graph(&p, |g| g.get(first).is_none()), "removed from graph");
+ assert_eq!(folder_children(&p, folder), vec![second]);
+
+ cmd.undo_now();
+ assert!(with_graph(&p, |g| g.get(first).is_some()), "restored");
+ assert_eq!(folder_children(&p, folder), vec![first, second], "slot restored");
+ assert_eq!(bin_folder_of(&p, first), Some(folder));
+ }
+
+ #[test]
+ fn remove_node_command_handles_nodes_without_a_folder() {
+ let p = project();
+ let node = folder_create(&p).expect("folder");
+ let mut cmd = remove_node_command(p.clone(), node);
+ cmd.redo_now();
+ assert!(with_graph(&p, |g| g.get(node).is_none()));
+ cmd.undo_now();
+ assert!(with_graph(&p, |g| g.get(node).is_some()));
+ assert_eq!(bin_folder_of(&p, node), None);
+ }
+
+ #[test]
+ fn add_track_command_appends_and_removes() {
+ let p = project();
+ let (_, list) = sequence_with_list(&p, TrackType::Subtitle);
+ assert_eq!(tracklist_track_count(&p, list), 0);
+ let mut cmd = add_track_command(p.clone(), list);
+ cmd.redo_now();
+ assert_eq!(tracklist_track_count(&p, list), 1);
+ let track = tracklist_track_at(&p, list, 0).expect("track");
+ assert_eq!(track_type(&p, track), Some(TrackType::Subtitle));
+ cmd.undo_now();
+ assert_eq!(tracklist_track_count(&p, list), 0);
+ assert!(with_graph(&p, |g| g.get(track).is_none()), "track removed");
+
+ // A stale list makes redo a no-op instead of a panic.
+ let mut missing = add_track_command(p.clone(), NodeId::INVALID);
+ missing.redo_now();
+ missing.undo_now();
+ }
+}
diff --git a/crates/oak-task/src/render.rs b/crates/oak-task/src/render.rs
index cf8f9e3dd..a1ec6ffbc 100644
--- a/crates/oak-task/src/render.rs
+++ b/crates/oak-task/src/render.rs
@@ -1241,4 +1241,709 @@ fn shm_frame_to_texture(frame: &ShmFrameRef) -> oak_core::texture::Texture {
.collect();
oak_core::texture::Texture::wrap_frame(f)
}
- .collect();
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ use oak_core::ocioutils::PixelFormat as OakPixelFormat;
+ use oak_core::PixelFormat;
+ use oak_node::block::ClipBlockBehavior;
+ use oak_node::folder;
+ use oak_node::id::NodeId;
+ use oak_node::node::NodeCore;
+ use oak_node::sequence::SequenceBehavior;
+ use oak_node::track::{TrackBehavior, TrackListBehavior, TrackType};
+ use oak_node::value::NodeValue;
+ use oak_render::ticket::AudioSamples;
+
+ /// A fresh project (the same `Arc>` shape the integration
+ /// fixtures build, but without any media I/O — these tests only touch
+ /// the pure graph-to-params marshalling).
+ fn project() -> ProjectRef {
+ oak_node::project::Project::new()
+ }
+
+ fn add_footage(project: &ProjectRef, filename: &str) -> NodeId {
+ let (core, behavior) = oak_node::footage::FootageBehavior::create();
+ let id = project.lock().unwrap().graph.add_node(core, behavior);
+ {
+ let mut guard = project.lock().unwrap();
+ if let Some(f) = guard
+ .graph
+ .get_mut(id)
+ .and_then(|e| e.behavior.as_any_mut())
+ .and_then(|a| a.downcast_mut::())
+ {
+ f.filename = filename.to_string();
+ }
+ }
+ id
+ }
+
+ fn add_folder(project: &ProjectRef) -> NodeId {
+ let (core, behavior) = folder::create("");
+ project.lock().unwrap().graph.add_node(core, behavior)
+ }
+
+ fn add_sequence(project: &ProjectRef) -> NodeId {
+ let (core, behavior) = SequenceBehavior::create();
+ project.lock().unwrap().graph.add_node(core, behavior)
+ }
+
+ fn add_clip(project: &ProjectRef, footage: Option, range: TimeRange) -> NodeId {
+ let (core, behavior) = oak_node::block::clip_create();
+ let clip = project.lock().unwrap().graph.add_node(core, behavior);
+ let mut guard = project.lock().unwrap();
+ if let Some(c) = guard
+ .graph
+ .get_mut(clip)
+ .and_then(|e| e.behavior.as_any_mut())
+ .and_then(|a| a.downcast_mut::())
+ {
+ c.core.range = range;
+ c.footage = footage;
+ }
+ clip
+ }
+
+ // ---- pure accessors -------------------------------------------------
+
+ #[test]
+ fn force_size_and_format_cover_off_and_on_paths() {
+ let project = project();
+ let make = |force: ForceParams| {
+ RenderTask::new(
+ Task::new("t", None),
+ None,
+ (project.clone(), NodeId::INVALID),
+ force,
+ None,
+ )
+ };
+
+ // All-off defaults: no forced size, no forced format.
+ let task = make(ForceParams::default());
+ assert_eq!(task.force_size(), None);
+ assert_eq!(task.force_format(), None);
+
+ // A single positive dimension is not enough for a forced size.
+ let task = make(ForceParams {
+ force_width: 32,
+ ..ForceParams::default()
+ });
+ assert_eq!(task.force_size(), None);
+ let task = make(ForceParams {
+ force_width: 0,
+ force_height: 32,
+ ..ForceParams::default()
+ });
+ assert_eq!(task.force_size(), None);
+
+ // Both positive: forced.
+ let task = make(ForceParams {
+ force_width: 32,
+ force_height: 16,
+ ..ForceParams::default()
+ });
+ assert_eq!(task.force_size(), Some((32, 16)));
+
+ // Format codes map through `pixel_format_from_code`: -1 is off,
+ // known codes map, unknown codes become Invalid.
+ let task = make(ForceParams {
+ force_format: PixelFormat::F32 as i32,
+ ..ForceParams::default()
+ });
+ assert_eq!(task.force_format(), Some(PixelFormat::F32));
+ let task = make(ForceParams {
+ force_format: 0,
+ ..ForceParams::default()
+ });
+ assert_eq!(task.force_format(), Some(PixelFormat::U8));
+ let task = make(ForceParams {
+ force_format: 99,
+ ..ForceParams::default()
+ });
+ assert_eq!(task.force_format(), Some(PixelFormat::Invalid));
+ }
+
+ #[test]
+ fn timebase_falls_back_for_missing_or_null_params() {
+ let project = project();
+ let make = |params: Option| {
+ RenderTask::new(
+ Task::new("t", None),
+ params,
+ (project.clone(), NodeId::INVALID),
+ ForceParams::default(),
+ None,
+ )
+ };
+
+ // No params at all.
+ assert_eq!(make(None).timebase(), Rational::new(1, 1));
+
+ // Null frame rate (the default `VideoParams`).
+ assert_eq!(make(Some(VideoParams::new())).timebase(), Rational::new(1, 1));
+
+ // A valid frame rate flips into the frame duration.
+ let mut params = VideoParams::new_basic(
+ 64,
+ 64,
+ OakPixelFormat::from_code(0),
+ 4,
+ 1,
+ 1,
+ 0,
+ 1,
+ );
+ params.set_frame_rate(25, 1);
+ assert_eq!(make(Some(params)).timebase(), Rational::new(1, 25));
+ }
+
+ /// The placeholder render state is a valid, empty task.
+ #[test]
+ fn placeholder_is_closed_and_empty() {
+ let task = RenderTask::placeholder();
+ assert_eq!(task.total_frames(), 0);
+ assert_eq!(task.viewer.1, NodeId::INVALID);
+ assert!(task.viewer.0.lock().is_ok());
+ }
+
+ #[test]
+ fn classify_ticket_maps_submitter_keys_only() {
+ let task = RenderTask::placeholder();
+ let mut keys = HashMap::new();
+ keys.insert(TicketId(7), (TICKET_VIDEO, 0, 1));
+ keys.insert(TicketId(9), (TICKET_AUDIO, 0, 1));
+ let mut slots = HashMap::new();
+ slots.insert((TICKET_VIDEO, 0, 1), 3);
+
+ assert_eq!(task.classify_ticket(&keys, TicketId(7), &slots), Some(3));
+ // Audio key without a slot.
+ assert_eq!(task.classify_ticket(&keys, TicketId(9), &slots), None);
+ // Unknown ticket id.
+ assert_eq!(task.classify_ticket(&keys, TicketId(99), &slots), None);
+ // Same key with a different time.
+ assert_eq!(
+ task.classify_ticket(&keys, TicketId(7), &HashMap::new()),
+ None
+ );
+ }
+
+ // ---- graph -> ticket params -----------------------------------------
+
+ /// Build a track node holding `blocks` (mutated directly: the graph is
+ /// only inspected by the montage builders, never rendered here).
+ fn track_with(project: &ProjectRef, kind: TrackType, blocks: Vec) -> NodeId {
+ let mut guard = project.lock().unwrap();
+ let track = guard
+ .graph
+ .add_node(NodeCore::new(), Box::new(TrackBehavior::new(kind)));
+ if let Some(t) = guard
+ .graph
+ .get_mut(track)
+ .and_then(|e| e.behavior.as_any_mut())
+ .and_then(|a| a.downcast_mut::())
+ {
+ t.blocks = blocks;
+ }
+ track
+ }
+
+ fn track_list_with(project: &ProjectRef, kind: TrackType, tracks: Vec) -> NodeId {
+ let mut guard = project.lock().unwrap();
+ let list = guard.graph.add_node(
+ NodeCore::new(),
+ Box::new(TrackListBehavior::new(kind)),
+ );
+ if let Some(l) = guard
+ .graph
+ .get_mut(list)
+ .and_then(|e| e.behavior.as_any_mut())
+ .and_then(|a| a.downcast_mut::())
+ {
+ l.tracks = tracks;
+ }
+ list
+ }
+
+ fn push_track_list(project: &ProjectRef, sequence: NodeId, list: NodeId) {
+ let mut guard = project.lock().unwrap();
+ if let Some(seq) = guard
+ .graph
+ .get_mut(sequence)
+ .and_then(|e| e.behavior.as_any_mut())
+ .and_then(|a| a.downcast_mut::())
+ {
+ seq.track_lists.push(list);
+ }
+ }
+
+ #[test]
+ fn build_video_ticket_rejects_missing_and_non_viewer_nodes() {
+ let project = project();
+ for viewer in [NodeId::INVALID, add_folder(&project)] {
+ let task = RenderTask::new(
+ Task::new("t", None),
+ None,
+ (project.clone(), viewer),
+ ForceParams::default(),
+ None,
+ );
+ let error = task.build_video_ticket(Rational::new(0, 1)).unwrap_err();
+ assert!(
+ error.to_string().contains("viewer output"),
+ "viewer {viewer:?}: {error}"
+ );
+ }
+ }
+
+ #[test]
+ fn build_video_ticket_marshals_footage_force_params() {
+ let project = project();
+ let footage = add_footage(&project, "/tmp/oaktask-inline.mp4");
+ let mut task = RenderTask::new(
+ Task::new("t", None),
+ Some(VideoParams::new()),
+ (project.clone(), footage),
+ ForceParams {
+ force_width: 32,
+ force_height: 16,
+ force_format: PixelFormat::F32 as i32,
+ ..ForceParams::default()
+ },
+ None,
+ );
+ task.set_render_inputs(0, false, TimeRange::default());
+
+ let params = task.build_video_ticket(Rational::new(1, 2)).unwrap();
+ assert_eq!(params.viewer, footage.identity());
+ assert_eq!(
+ params.footage,
+ Some(("/tmp/oaktask-inline.mp4".to_string(), 0))
+ );
+ assert!(params.montage.is_empty());
+ assert_eq!(params.force_size, Some((32, 16)));
+ assert_eq!(params.force_format, Some(PixelFormat::F32));
+ assert_eq!(params.cache, Some(footage.identity()));
+ assert_eq!(params.time, Rational::new(1, 2));
+ assert_eq!(params.project, task.viewer.0.lock().unwrap().uuid);
+ }
+
+ #[test]
+ fn video_montage_skips_degenerate_entries_and_keeps_valid_clips() {
+ let project = project();
+ let sequence = add_sequence(&project);
+ let footage = add_footage(&project, "valid.mp4");
+ let folder = add_folder(&project);
+
+ let ok = add_clip(
+ &project,
+ Some(footage),
+ TimeRange::new(Rational::new(0, 1), Rational::new(1, 1)),
+ );
+ let out_of_range = add_clip(
+ &project,
+ Some(footage),
+ TimeRange::new(Rational::new(10, 1), Rational::new(11, 1)),
+ );
+ let no_footage = add_clip(
+ &project,
+ None,
+ TimeRange::new(Rational::new(0, 1), Rational::new(1, 1)),
+ );
+ let wrong_footage = add_clip(
+ &project,
+ None,
+ TimeRange::new(Rational::new(0, 1), Rational::new(1, 1)),
+ );
+ crate::nodeops::clip_set_footage(&project, wrong_footage, folder);
+
+ // Track blocks: a non-block node, the valid clip, and every rejected
+ // clip kind.
+ let track = track_with(
+ &project,
+ TrackType::Video,
+ vec![folder, ok, out_of_range, no_footage, wrong_footage],
+ );
+ // A track list with bad track ids mixed in.
+ let list = track_list_with(
+ &project,
+ TrackType::Video,
+ vec![NodeId::INVALID, folder, track],
+ );
+ let audio_list = track_list_with(&project, TrackType::Audio, vec![track]);
+ // A valid id that is not a track list.
+ let not_a_list = folder;
+ // A valid id whose node was removed from the graph (the
+ // `graph.get` miss branch).
+ let removed = add_folder(&project);
+ {
+ let mut guard = project.lock().unwrap();
+ let _ = guard.graph.remove_node(removed);
+ assert!(removed.valid() && guard.graph.get(removed).is_none());
+ }
+ {
+ let mut guard = project.lock().unwrap();
+ if let Some(seq) = guard
+ .graph
+ .get_mut(sequence)
+ .and_then(|e| e.behavior.as_any_mut())
+ .and_then(|a| a.downcast_mut::())
+ {
+ seq.track_lists = vec![NodeId::INVALID, removed, not_a_list, audio_list, list];
+ }
+ }
+
+ let montage = RenderTask::video_montage(&project, sequence, Rational::new(0, 1));
+ assert_eq!(montage.len(), 1, "only the valid clip is kept");
+ assert_eq!(montage[0].filename, "valid.mp4");
+ assert_eq!(montage[0].in_time, Rational::new(0, 1));
+ assert_eq!(montage[0].out_time, Rational::new(1, 1));
+ assert_eq!(montage[0].media_in, Rational::new(0, 1));
+ assert_eq!(montage[0].gain, 1.0);
+ assert_eq!(montage[0].stream_index, 0);
+
+ // A time outside the clip's range yields an empty montage.
+ assert!(RenderTask::video_montage(&project, sequence, Rational::new(5, 1)).is_empty());
+
+ // Missing sequence and non-sequence viewers are empty, not panics.
+ assert!(RenderTask::video_montage(&project, NodeId::INVALID, Rational::new(0, 1)).is_empty());
+ assert!(RenderTask::video_montage(&project, folder, Rational::new(0, 1)).is_empty());
+ }
+
+ #[test]
+ fn audio_montage_filters_kinds_and_drops_effects() {
+ let project = project();
+ let sequence = add_sequence(&project);
+ let footage = add_footage(&project, "audio.mp4");
+ let folder = add_folder(&project);
+
+ let ok = add_clip(
+ &project,
+ Some(footage),
+ TimeRange::new(Rational::new(0, 1), Rational::new(2, 1)),
+ );
+ let out_of_range = add_clip(
+ &project,
+ Some(footage),
+ TimeRange::new(Rational::new(9, 1), Rational::new(10, 1)),
+ );
+ let no_footage = add_clip(
+ &project,
+ None,
+ TimeRange::new(Rational::new(0, 1), Rational::new(2, 1)),
+ );
+ let wrong_footage = add_clip(
+ &project,
+ None,
+ TimeRange::new(Rational::new(0, 1), Rational::new(2, 1)),
+ );
+ crate::nodeops::clip_set_footage(&project, wrong_footage, folder);
+
+ let track = track_with(
+ &project,
+ TrackType::Audio,
+ vec![NodeId::INVALID, folder, ok, out_of_range, no_footage, wrong_footage],
+ );
+ let audio_list = track_list_with(
+ &project,
+ TrackType::Audio,
+ vec![NodeId::INVALID, folder, track],
+ );
+ let video_list = track_list_with(&project, TrackType::Video, vec![track]);
+ let removed = add_folder(&project);
+ {
+ let mut guard = project.lock().unwrap();
+ let _ = guard.graph.remove_node(removed);
+ assert!(removed.valid() && guard.graph.get(removed).is_none());
+ }
+ {
+ let mut guard = project.lock().unwrap();
+ if let Some(seq) = guard
+ .graph
+ .get_mut(sequence)
+ .and_then(|e| e.behavior.as_any_mut())
+ .and_then(|a| a.downcast_mut::())
+ {
+ seq.track_lists = vec![video_list, NodeId::INVALID, removed, audio_list];
+ }
+ }
+
+ let montage = RenderTask::audio_montage(&project, sequence, Rational::new(1, 1));
+ assert_eq!(montage.len(), 1);
+ assert_eq!(montage[0].filename, "audio.mp4");
+ assert!(montage[0].effects.is_empty(), "audio carries no effects");
+
+ assert!(RenderTask::audio_montage(&project, sequence, Rational::new(5, 1)).is_empty());
+ assert!(RenderTask::audio_montage(&project, NodeId::INVALID, Rational::new(0, 1)).is_empty());
+ assert!(RenderTask::audio_montage(&project, folder, Rational::new(0, 1)).is_empty());
+ }
+
+ #[test]
+ fn build_audio_ticket_reads_sequence_params_and_montage() {
+ let project = project();
+ let sequence = add_sequence(&project);
+ let footage = add_footage(&project, "audio.mp4");
+ let clip = add_clip(
+ &project,
+ Some(footage),
+ TimeRange::new(Rational::new(0, 1), Rational::new(1, 1)),
+ );
+ let track = track_with(&project, TrackType::Audio, vec![clip]);
+ let list = track_list_with(&project, TrackType::Audio, vec![track]);
+ push_track_list(&project, sequence, list);
+
+ let task = RenderTask::new(
+ Task::new("t", None),
+ None,
+ (project.clone(), sequence),
+ ForceParams::default(),
+ None,
+ );
+ let params = task
+ .build_audio_ticket(TimeRange::new(Rational::new(0, 1), Rational::new(1, 1)))
+ .unwrap();
+ assert_eq!(params.viewer, sequence.identity());
+ assert_eq!(params.sample_rate, 48000);
+ assert_eq!(params.channel_layout, 0x3);
+ assert_eq!(params.montage.len(), 1);
+ assert_eq!(params.montage[0].filename, "audio.mp4");
+ assert_eq!(params.range.in_(), Rational::new(0, 1));
+
+ // A footage viewer still yields a ticket (with an empty montage).
+ let footage_task = RenderTask::new(
+ Task::new("t", None),
+ None,
+ (project.clone(), footage),
+ ForceParams::default(),
+ None,
+ );
+ let params = footage_task
+ .build_audio_ticket(TimeRange::new(Rational::new(0, 1), Rational::new(1, 2)))
+ .unwrap();
+ assert!(params.montage.is_empty());
+ assert_eq!(params.sample_rate, 48000);
+
+ // A stale viewer falls back to the defaults too (no panic).
+ let stale = RenderTask::new(
+ Task::new("t", None),
+ None,
+ (project, NodeId::INVALID),
+ ForceParams::default(),
+ None,
+ );
+ let params = stale
+ .build_audio_ticket(TimeRange::new(Rational::new(0, 1), Rational::new(1, 2)))
+ .unwrap();
+ assert!(params.montage.is_empty());
+ }
+
+ #[test]
+ fn clip_effects_walks_chain_params_and_enable_state() {
+ let project = project();
+ let footage = add_footage(&project, "fx.mp4");
+ let folder = add_folder(&project);
+
+ // A node without an effect input ends the walk immediately.
+ {
+ let guard = project.lock().unwrap();
+ assert!(RenderTask::clip_effects(&guard.graph, footage).is_empty());
+ assert!(RenderTask::clip_effects(&guard.graph, folder).is_empty());
+ assert!(RenderTask::clip_effects(&guard.graph, NodeId::INVALID).is_empty());
+ }
+
+ // A clip whose effect input is set but not connected: the walk's
+ // no-upstream break.
+ let bare = add_clip(
+ &project,
+ None,
+ TimeRange::new(Rational::new(0, 1), Rational::new(1, 1)),
+ );
+ {
+ let guard = project.lock().unwrap();
+ assert!(RenderTask::clip_effects(&guard.graph, bare).is_empty());
+ }
+
+ // Direct footage -> clip connection: the source node is dropped
+ // (the montage decodes the footage itself).
+ let direct = add_clip(
+ &project,
+ Some(footage),
+ TimeRange::new(Rational::new(0, 1), Rational::new(1, 1)),
+ );
+ {
+ let mut guard = project.lock().unwrap();
+ guard
+ .graph
+ .connect(footage, direct, oak_node::block::clip_input::TEXTURE_INPUT, -1)
+ .expect("connect footage");
+ }
+ {
+ let guard = project.lock().unwrap();
+ assert!(RenderTask::clip_effects(&guard.graph, direct).is_empty());
+ }
+
+ for enabled in [true, false] {
+ let clip = add_clip(&project, None, TimeRange::new(Rational::new(0, 1), Rational::new(1, 1)));
+ let (ecore, ebehavior) = oak_node::factory::Factory::global()
+ .create_any("org.olivevideoeditor.Olive.invert")
+ .expect("invert registered");
+ let mut guard = project.lock().unwrap();
+ let effect = guard.graph.add_node(ecore, ebehavior);
+ guard
+ .graph
+ .get_mut(effect)
+ .unwrap()
+ .core
+ .set_standard_value(
+ oak_node::node::ENABLED_INPUT,
+ -1,
+ NodeValue::Boolean(enabled),
+ );
+ guard
+ .graph
+ .connect(footage, effect, "tex_in", -1)
+ .expect("footage -> effect");
+ guard
+ .graph
+ .connect(
+ effect,
+ clip,
+ oak_node::block::clip_input::TEXTURE_INPUT,
+ -1,
+ )
+ .expect("effect -> clip");
+
+ let effects = RenderTask::clip_effects(&guard.graph, clip);
+ assert_eq!(effects.len(), 1);
+ assert_eq!(
+ effects[0].type_id,
+ "org.olivevideoeditor.Olive.invert"
+ );
+ assert_eq!(effects[0].enabled, enabled);
+ assert_eq!(effects[0].effect_input_id.as_deref(), Some("tex_in"));
+ assert!(
+ !effects[0].params.is_empty(),
+ "the four channel toggles are collected"
+ );
+ }
+
+ // A transform effect contributes scalars but skips its texture and
+ // matrix inputs; a blur effect additionally skips its hidden
+ // method-specific inputs; a volume effect skips its samples input.
+ for (type_name, connect_input, skipped) in [
+ ("org.olivevideoeditor.Olive.transform", "tex_in", "parent_in"),
+ (
+ "org.olivevideoeditor.Olive.blur",
+ "tex_in",
+ "directional_degrees_in",
+ ),
+ (
+ "org.olivevideoeditor.Olive.volume",
+ "samples_in",
+ "samples_in",
+ ),
+ ] {
+ let clip = add_clip(
+ &project,
+ None,
+ TimeRange::new(Rational::new(0, 1), Rational::new(1, 1)),
+ );
+ let (ecore, ebehavior) = oak_node::factory::Factory::global()
+ .create_any(type_name)
+ .expect("effect registered");
+ let mut guard = project.lock().unwrap();
+ let effect = guard.graph.add_node(ecore, ebehavior);
+ guard
+ .graph
+ .connect(footage, effect, connect_input, -1)
+ .expect("footage -> effect");
+ guard
+ .graph
+ .connect(
+ effect,
+ clip,
+ oak_node::block::clip_input::TEXTURE_INPUT,
+ -1,
+ )
+ .expect("effect -> clip");
+
+ let effects = RenderTask::clip_effects(&guard.graph, clip);
+ assert_eq!(effects.len(), 1, "{type_name}");
+ assert_eq!(effects[0].type_id, type_name);
+ assert!(
+ effects[0].params.iter().all(|(id, _)| id != skipped),
+ "{type_name}: {skipped} must be skipped"
+ );
+ assert!(
+ effects[0].params.iter().all(|(id, _)| id != "tex_in"),
+ "{type_name}: texture inputs must be skipped"
+ );
+ }
+ }
+
+ // ---- dispatch channel ------------------------------------------------
+
+ #[test]
+ fn dispatch_queue_pops_in_order_and_counts_running() {
+ let dispatch = Box::into_raw(Box::new(RenderDispatch::new()));
+ let audio = || {
+ TicketPayload::Audio(AudioSamples {
+ samples: vec![0.25, -0.25],
+ sample_rate: 48000,
+ channel_layout: 0x3,
+ channel_count: 2,
+ })
+ };
+
+ // Empty queue -> None.
+ assert!(unsafe { (*dispatch).pop_finished() }.is_none());
+
+ unsafe {
+ (*dispatch).running.store(2, Ordering::SeqCst);
+ }
+ push_finished(TicketId(1), Ok(audio()), DispatchPtr(dispatch));
+ push_finished(TicketId(2), Ok(audio()), DispatchPtr(dispatch));
+ assert_eq!(unsafe { (*dispatch).running.load(Ordering::SeqCst) }, 0);
+
+ let first = unsafe { (*dispatch).pop_finished() };
+ assert!(matches!(first, Some((TicketId(1), Ok(TicketPayload::Audio(_))))));
+ let second = unsafe { (*dispatch).pop_finished() };
+ assert!(matches!(second, Some((TicketId(2), Ok(TicketPayload::Audio(_))))));
+ assert!(unsafe { (*dispatch).pop_finished() }.is_none());
+
+ unsafe {
+ drop(Box::from_raw(dispatch));
+ }
+ }
+
+ #[test]
+ fn wait_idle_returns_when_callbacks_finish() {
+ let dispatch = RenderDispatch::new();
+ dispatch.running.store(1, Ordering::SeqCst);
+ let calls = AtomicUsize::new(0);
+ let pump = || {
+ // The first pump observes an in-flight ticket (so `wait_idle`
+ // takes its wait branch); the second one completes it. No
+ // ticket thread is involved, so the test stays deterministic.
+ if calls.fetch_add(1, Ordering::SeqCst) >= 1 {
+ dispatch.running.store(0, Ordering::SeqCst);
+ dispatch.cv.notify_all();
+ }
+ };
+ dispatch.wait_idle(&pump);
+ assert!(calls.load(Ordering::SeqCst) >= 2, "the wait branch ran");
+ }
+
+ #[test]
+ fn wait_idle_breaks_immediately_when_idle() {
+ let dispatch = RenderDispatch::new();
+ let calls = AtomicUsize::new(0);
+ dispatch.wait_idle(&|| {
+ calls.fetch_add(1, Ordering::SeqCst);
+ });
+ assert_eq!(calls.load(Ordering::SeqCst), 1, "pump once, then break");
+ }
+}
diff --git a/crates/oak-task/tests/codecbridge_test.rs b/crates/oak-task/tests/codecbridge_test.rs
new file mode 100644
index 000000000..a75bcdaba
--- /dev/null
+++ b/crates/oak-task/tests/codecbridge_test.rs
@@ -0,0 +1,306 @@
+// 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 .
+
+//! Boundary tests for the codec task submitter bridge: register/unregister
+//! state handling and the synchronous `submit_codec_task` dispatch for
+//! conform (real clip success, missing media, bad output name, failed
+//! rename) and proxy (missing source) requests.
+//!
+//! The bridge collapses inner task failures into one codec error per kind
+//! (the interim synchronous contract), so the failure-path tests assert
+//! that outer variant/message here and the distinguishing reason on the
+//! task base through `conform_directly`/`proxy_directly`.
+//!
+//! The oakcodec submit registry is process-global, so the tests in this
+//! file serialize on one mutex.
+
+use std::sync::{Mutex, MutexGuard};
+
+use oak_codec::task::{submit_task, TaskKind, TaskRequest};
+use oak_task::codecbridge::{
+ is_codec_task_submitter_registered, register_codec_task_submitter,
+ unregister_codec_task_submitter,
+};
+use oak_task::conform::ConformTask;
+use oak_task::error::Error as TaskError;
+use oak_task::proxy::{ProxyParams, ProxyTask};
+use oak_task::task::{Task, TaskBehavior};
+
+/// Tests that mutate oakcodec's global submit callback must not race.
+static REG_LOCK: Mutex<()> = Mutex::new(());
+
+fn serial() -> MutexGuard<'static, ()> {
+ REG_LOCK.lock().unwrap_or_else(|e| e.into_inner())
+}
+
+/// Run a conform request directly through [`ConformTask`], returning
+/// `(result, task error message)`.
+///
+/// The bridge collapses every inner failure into one codec error (the
+/// interim synchronous contract's "conform task failed"), so the
+/// distinguishing reason is asserted here, on the task base the behavior
+/// writes — a bad output name must fail the name contract, not the probe.
+fn conform_directly(input: &str, output: &str) -> (oak_task::error::Result<()>, Option) {
+ let req = request(TaskKind::Conform, input, output);
+ let mut inner = ConformTask::new(&req);
+ let mut driver = Task::new("conform", None);
+ let result = inner.run(&mut driver);
+ (result, driver.error().map(str::to_string))
+}
+
+/// Run a proxy request directly through [`ProxyTask`] (missing sources fail
+/// in the ffmpeg launch/run), returning the task error message.
+fn proxy_directly(input: &str, output: &str) -> Option {
+ let req = request(TaskKind::Proxy, input, output);
+ let params = ProxyParams {
+ width: 320,
+ height: 180,
+ divider: 1,
+ version: 1,
+ crf: 23,
+ include_audio: false,
+ extension: "mp4".to_string(),
+ preset: "veryfast".to_string(),
+ };
+ let mut inner = ProxyTask::new(&req, params);
+ let mut driver = Task::new("proxy", None);
+ let _ = inner.run(&mut driver);
+ driver.error().map(str::to_string)
+}
+
+/// Assert the bridge rejected a submission with the expected `Failed`
+/// message (the codec error variant is always `Failed`; the per-path
+/// reason is asserted through [`conform_directly`]/[`proxy_directly`]).
+fn assert_bridge_failed(context: &str, result: oak_codec::error::Result) -> String {
+ match result {
+ Err(oak_codec::error::Error::Failed(message)) => message,
+ other => panic!("{context}: expected a Failed submission error, got {other:?}"),
+ }
+}
+
+fn work_dir(tag: &str) -> std::path::PathBuf {
+ let path = std::env::temp_dir().join(format!("oaktask_bridge_{tag}_{}", std::process::id()));
+ let _ = std::fs::remove_dir_all(&path);
+ std::fs::create_dir_all(&path).expect("work dir");
+ path
+}
+
+fn write_clip(dir: &std::path::Path, name: &str) -> std::path::PathBuf {
+ let path = dir.join(name);
+ oak_codec::testmedia::write_test_clip(&path, 64, 64, 10, 10).expect("test clip generation");
+ path
+}
+
+fn request<'a>(kind: TaskKind, input: &'a str, output: &'a str) -> TaskRequest<'a> {
+ TaskRequest {
+ kind,
+ input_filename: input,
+ output_filename: output,
+ stream_index: 1,
+ sample_rate: 48000,
+ channel_layout: 0x3,
+ sample_format: 4,
+ proxy_width: 0,
+ proxy_height: 0,
+ }
+}
+
+// ---- registration -----------------------------------------------------
+
+/// Given no callback, `submit_task` reports "nothing submitted"; the bridge
+/// registers exactly once and unregisters idempotently.
+#[test]
+fn submitter_registration_lifecycle() {
+ let _guard = serial();
+ unregister_codec_task_submitter().expect("clean registry");
+ assert!(!is_codec_task_submitter_registered());
+
+ let req = request(TaskKind::Conform, "in.mp4", "out.0.pcm");
+ assert!(
+ !submit_task(&req).expect("no callback is not an error"),
+ "nothing is submitted without a callback"
+ );
+
+ register_codec_task_submitter().expect("first registration");
+ assert!(is_codec_task_submitter_registered());
+ assert!(matches!(
+ register_codec_task_submitter(),
+ Err(TaskError::State)
+ ));
+
+ unregister_codec_task_submitter().expect("unregister");
+ assert!(!is_codec_task_submitter_registered());
+ unregister_codec_task_submitter().expect("idempotent unregister");
+ assert!(!submit_task(&req).expect("no callback"));
+}
+
+// ---- conform ----------------------------------------------------------
+
+/// A real test clip conforms into per-channel PCM files through the bridge.
+#[test]
+fn conform_submission_writes_pcm_files() {
+ let _guard = serial();
+ unregister_codec_task_submitter().expect("clean registry");
+ register_codec_task_submitter().expect("register");
+
+ let dir = work_dir("conform_ok");
+ let clip = write_clip(&dir, "clip.mp4");
+ let out = dir.join("audio.0.pcm");
+ let clip_str = clip.to_string_lossy();
+ let out_str = out.to_string_lossy();
+ let req = request(TaskKind::Conform, &clip_str, &out_str);
+ let submitted = submit_task(&req);
+ assert!(submitted.is_ok(), "conform must be accepted: {submitted:?}");
+
+ // The final per-channel files exist (renamed from the working names).
+ for channel in 0..2 {
+ let final_name = dir.join(format!("audio.{channel}.pcm"));
+ let meta = std::fs::metadata(&final_name)
+ .unwrap_or_else(|e| panic!("{} missing: {e}", final_name.display()));
+ assert!(meta.len() > 0, "conform output is empty");
+ assert!(
+ !dir.join(format!("audio.{channel}.pcm.working")).exists(),
+ "working files are renamed away"
+ );
+ }
+
+ unregister_codec_task_submitter().expect("unregister");
+ let _ = std::fs::remove_dir_all(&dir);
+}
+
+/// A missing source file is rejected (no decoder can probe it); the
+/// path-specific reason is distinct from a bad-output-name failure.
+#[test]
+fn conform_submission_rejects_missing_media() {
+ let _guard = serial();
+ unregister_codec_task_submitter().expect("clean registry");
+ register_codec_task_submitter().expect("register");
+
+ let dir = work_dir("conform_missing");
+ let out = dir.join("audio.0.pcm");
+ let out_str = out.to_string_lossy();
+ let req = request(TaskKind::Conform, "/definitely/not/here.mp4", &out_str);
+ assert_eq!(
+ assert_bridge_failed("missing media", submit_task(&req)),
+ "conform task failed"
+ );
+
+ // The reason is the probe: no registered decoder accepts the input.
+ let (result, error) = conform_directly("/definitely/not/here.mp4", &out_str);
+ assert!(result.is_err());
+ assert_eq!(error.as_deref(), Some("Failed to create decoder"));
+
+ unregister_codec_task_submitter().expect("unregister");
+ let _ = std::fs::remove_dir_all(&dir);
+}
+
+/// An output name that does not follow the `.0.pcm` contract is rejected
+/// before any media work: the failure is the name contract, not a probe
+/// failure.
+#[test]
+fn conform_submission_rejects_a_bad_output_name() {
+ let _guard = serial();
+ unregister_codec_task_submitter().expect("clean registry");
+ register_codec_task_submitter().expect("register");
+
+ let dir = work_dir("conform_bad_name");
+ let clip = write_clip(&dir, "clip.mp4");
+ let out = dir.join("audio.pcm");
+ let clip_str = clip.to_string_lossy();
+ let out_str = out.to_string_lossy();
+ let req = request(TaskKind::Conform, &clip_str, &out_str);
+ assert_eq!(
+ assert_bridge_failed("bad output name", submit_task(&req)),
+ "conform task failed"
+ );
+
+ // Directly: the rejection names the filename contract, so a probe
+ // regression could not masquerade as this failure path.
+ let (result, error) = conform_directly(&clip_str, &out_str);
+ assert!(result.is_err());
+ assert_eq!(error.as_deref(), Some("Invalid conform output filename"));
+
+ unregister_codec_task_submitter().expect("unregister");
+ let _ = std::fs::remove_dir_all(&dir);
+}
+
+/// A rename failure while moving the working files into place fails the
+/// task after the audio was conformed — a third, distinct reason.
+#[test]
+fn conform_submission_reports_a_failed_rename() {
+ let _guard = serial();
+ unregister_codec_task_submitter().expect("clean registry");
+ register_codec_task_submitter().expect("register");
+
+ let dir = work_dir("conform_rename");
+ let clip = write_clip(&dir, "clip.mp4");
+ // The final first-channel path is an existing directory: the file
+ // rename onto it must fail.
+ std::fs::create_dir(dir.join("audio.0.pcm")).expect("blocking directory");
+ let out = dir.join("audio.0.pcm");
+ let clip_str = clip.to_string_lossy();
+ let out_str = out.to_string_lossy();
+ let req = request(TaskKind::Conform, &clip_str, &out_str);
+ assert_eq!(
+ assert_bridge_failed("failed rename", submit_task(&req)),
+ "conform task failed"
+ );
+
+ // Directly: the conform succeeded and only the rename failed.
+ let (result, error) = conform_directly(&clip_str, &out_str);
+ assert!(result.is_err());
+ assert_eq!(
+ error.as_deref(),
+ Some("Failed to move conformed audio into place")
+ );
+
+ unregister_codec_task_submitter().expect("unregister");
+ let _ = std::fs::remove_dir_all(&dir);
+}
+
+// ---- proxy ------------------------------------------------------------
+
+/// A proxy request with a missing source is rejected: the proxy params
+/// conversion runs, then the transcode (or the ffmpeg lookup) fails with
+/// an ffmpeg-side reason.
+#[test]
+fn proxy_submission_rejects_a_missing_source() {
+ let _guard = serial();
+ unregister_codec_task_submitter().expect("clean registry");
+ register_codec_task_submitter().expect("register");
+
+ let dir = work_dir("proxy_missing");
+ let out = dir.join("proxy.mp4");
+ let out_str = out.to_string_lossy();
+ let req = request(TaskKind::Proxy, "/definitely/not/here.mp4", &out_str);
+ assert_eq!(
+ assert_bridge_failed("missing source", submit_task(&req)),
+ "proxy task failed"
+ );
+
+ // Directly: the reason names the ffmpeg failure (missing binary or a
+ // non-zero transcode), never a conform-side message.
+ let error = proxy_directly("/definitely/not/here.mp4", &out_str);
+ assert!(
+ error
+ .as_deref()
+ .is_some_and(|message| message.contains("ffmpeg")),
+ "the proxy failure reason is ffmpeg-side: {error:?}"
+ );
+
+ unregister_codec_task_submitter().expect("unregister");
+ let _ = std::fs::remove_dir_all(&dir);
+}
diff --git a/crates/oak-task/tests/import_test.rs b/crates/oak-task/tests/import_test.rs
new file mode 100644
index 000000000..a1f1a51ef
--- /dev/null
+++ b/crates/oak-task/tests/import_test.rs
@@ -0,0 +1,241 @@
+// 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 .
+
+//! Boundary tests for `ProjectImportTask` (media import): valid and
+//! invalid files, directories (recursive folder creation), duplicate
+//! paths, the produced undo command, and cancellation.
+//!
+//! Media comes from `oak_codec::testmedia`, so the tests run headless.
+
+use oak_node::folder::FolderBehavior;
+use oak_node::id::NodeId;
+use oak_node::project::Project;
+use oak_task::nodeops::{self, ProjectRef};
+use oak_task::project::import::ProjectImportTask;
+use oak_task::task::{Task, TaskBehavior};
+
+fn work_dir(tag: &str) -> std::path::PathBuf {
+ let path = std::env::temp_dir().join(format!(
+ "oaktask_import_{tag}_{}",
+ std::process::id()
+ ));
+ let _ = std::fs::remove_dir_all(&path);
+ std::fs::create_dir_all(&path).expect("work dir");
+ path
+}
+
+fn write_clip(dir: &std::path::Path, name: &str) -> std::path::PathBuf {
+ let path = dir.join(name);
+ oak_codec::testmedia::write_test_clip(&path, 64, 64, 10, 10).expect("test clip generation");
+ path
+}
+
+/// A fresh project with its initialized root folder.
+fn project() -> (ProjectRef, NodeId) {
+ let project = Project::new();
+ let root = {
+ let mut p = project.lock().unwrap();
+ p.initialize().expect("root folder");
+ p.root
+ };
+ (project, root)
+}
+
+fn run_import(
+ task: &mut ProjectImportTask,
+ driver: &mut Task,
+) -> oak_task::error::Result<()> {
+ task.run(driver)
+}
+
+fn folder_children(project: &ProjectRef, folder: NodeId) -> Vec {
+ let guard = project.lock().unwrap_or_else(|e| e.into_inner());
+ guard
+ .graph
+ .get(folder)
+ .and_then(|e| e.behavior.as_any())
+ .and_then(|a| a.downcast_ref::())
+ .map(|f| f.children.clone())
+ .unwrap_or_default()
+}
+
+#[test]
+fn import_mixes_valid_and_invalid_files() {
+ let dir = work_dir("mixed");
+ let media = write_clip(&dir, "clip.mp4");
+ let missing = dir.join("missing.mp4");
+
+ let (project, root) = project();
+ let mut task = ProjectImportTask::new(
+ Task::new("Import", None),
+ (project.clone(), root),
+ project.clone(),
+ vec![
+ media.to_string_lossy().into_owned(),
+ missing.to_string_lossy().into_owned(),
+ ],
+ None,
+ 2,
+ );
+ let mut driver = Task::new("Import", None);
+ assert!(run_import(&mut task, &mut driver).is_ok());
+
+ assert_eq!(task.file_count(), 2);
+ assert_eq!(task.get_file_count(), 1, "one footage imported");
+ assert_eq!(task.get_invalid_file_count(), 1, "one file failed");
+ assert!(task.has_invalid_files());
+ assert!(!task.get_imported_footage(0).is_err());
+ assert!(task.get_imported_footage(1).is_err(), "out of range");
+
+ let (project_ref, footage) = task.get_imported_footage(0).expect("footage");
+ assert_eq!(nodeops::footage_filename(&project_ref, footage), media.to_string_lossy());
+ assert!(nodeops::footage_is_valid(&project_ref, footage));
+
+ // The task's command adds the footage to the destination folder; undo
+ // removes it again.
+ let mut cmd = task.take_command().expect("command produced");
+ assert!(folder_children(&project, root).is_empty());
+ cmd.redo_now();
+ assert_eq!(folder_children(&project, root), vec![footage]);
+ cmd.undo_now();
+ assert!(folder_children(&project, root).is_empty());
+
+ let _ = std::fs::remove_dir_all(&dir);
+}
+
+#[test]
+fn import_duplicate_paths_create_one_footage_each() {
+ let dir = work_dir("dupes");
+ let media = write_clip(&dir, "clip.mp4");
+ let path = media.to_string_lossy().into_owned();
+
+ let (project, root) = project();
+ let mut task = ProjectImportTask::new(
+ Task::new("Import", None),
+ (project.clone(), root),
+ project.clone(),
+ vec![path.clone(), path],
+ None,
+ 2,
+ );
+ let mut driver = Task::new("Import", None);
+ assert!(run_import(&mut task, &mut driver).is_ok());
+ assert_eq!(task.get_file_count(), 2, "each entry imports");
+ assert_eq!(task.get_invalid_file_count(), 0);
+
+ let _ = std::fs::remove_dir_all(&dir);
+}
+
+#[test]
+fn import_directory_creates_a_labeled_folder_recursively() {
+ let dir = work_dir("directory");
+ let sub = dir.join("Shots");
+ std::fs::create_dir_all(&sub).expect("subdir");
+ write_clip(&sub, "a.mp4");
+ write_clip(&sub, "b.mp4");
+
+ let (project, root) = project();
+ let mut task = ProjectImportTask::new(
+ Task::new("Import", None),
+ (project.clone(), root),
+ project.clone(),
+ vec![sub.to_string_lossy().into_owned()],
+ None,
+ 2,
+ );
+ let mut driver = Task::new("Import", None);
+ assert!(run_import(&mut task, &mut driver).is_ok());
+ assert_eq!(task.get_file_count(), 2, "directory children imported");
+ assert_eq!(task.get_invalid_file_count(), 0);
+
+ // The directory became a labeled sub-folder holding both footages.
+ let mut cmd = task.take_command().expect("command produced");
+ cmd.redo_now();
+ let children = folder_children(&project, root);
+ assert_eq!(children.len(), 1, "one folder added");
+ let sub_folder = children[0];
+ assert_eq!(nodeops::node_label(&project, sub_folder), "Shots");
+ assert_eq!(folder_children(&project, sub_folder).len(), 2);
+
+ cmd.undo_now();
+ assert!(folder_children(&project, root).is_empty());
+
+ let _ = std::fs::remove_dir_all(&dir);
+}
+
+#[test]
+fn import_empty_directory_imports_nothing() {
+ let dir = work_dir("empty");
+ let empty = dir.join("Nothing");
+ std::fs::create_dir_all(&empty).expect("empty dir");
+
+ let (project, root) = project();
+ let mut task = ProjectImportTask::new(
+ Task::new("Import", None),
+ (project.clone(), root),
+ project.clone(),
+ vec![empty.to_string_lossy().into_owned()],
+ None,
+ 0,
+ );
+ let mut driver = Task::new("Import", None);
+ assert!(run_import(&mut task, &mut driver).is_ok());
+ assert_eq!(task.get_file_count(), 0);
+ assert_eq!(task.get_invalid_file_count(), 0);
+ assert!(task.take_command().is_ok(), "an (empty) command is still produced");
+
+ let _ = std::fs::remove_dir_all(&dir);
+}
+
+#[test]
+fn import_cancel_reports_cancelled_and_yields_no_command() {
+ let dir = work_dir("cancel");
+ let media = write_clip(&dir, "clip.mp4");
+
+ let (project, root) = project();
+ let mut task = ProjectImportTask::new(
+ Task::new("Import", None),
+ (project.clone(), root),
+ project.clone(),
+ vec![media.to_string_lossy().into_owned()],
+ None,
+ 1,
+ );
+ let mut driver = Task::new("Import", None);
+ driver.cancel();
+ let result = run_import(&mut task, &mut driver);
+ assert!(
+ matches!(result, Err(oak_task::error::Error::Cancelled)),
+ "a cancelled import must fail with Cancelled: {result:?}"
+ );
+ assert!(task.take_command().is_err(), "no command after cancellation");
+
+ let _ = std::fs::remove_dir_all(&dir);
+}
+
+#[test]
+fn take_command_before_a_run_reports_state() {
+ let (project, root) = project();
+ let mut task = ProjectImportTask::new(
+ Task::new("Import", None),
+ (project.clone(), root),
+ project.clone(),
+ Vec::new(),
+ None,
+ 0,
+ );
+ assert!(task.take_command().is_err());
+}
diff --git a/crates/oak-task/tests/loadotio_test.rs b/crates/oak-task/tests/loadotio_test.rs
new file mode 100644
index 000000000..c6a1184e3
--- /dev/null
+++ b/crates/oak-task/tests/loadotio_test.rs
@@ -0,0 +1,483 @@
+// 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 .
+
+//! Boundary tests for the project load path:
+//!
+//! - `LoadOTIOTask` (`.otio` / `.fcpxml`) round-trips a saved project
+//! through the real interchange files.
+//! - `ProjectLoadTask` (the XML `.oakproj` path) handles valid, missing
+//! and corrupt documents.
+//! - Error contract: missing file, corrupt document, unsupported
+//! extension, `take_project` before a run, and the import-confirmation
+//! callback (accept / reject / global reset).
+//!
+//! The interchange save path is the production `SaveOTIOTask`; the media
+//! is a generated test clip, so the tests run headless with no fixtures.
+
+use std::sync::{Arc, Mutex};
+
+use oak_core::{Rational, TimeRange};
+use oak_node::block::ClipBlockBehavior;
+use oak_node::footage::FootageBehavior;
+use oak_node::id::NodeId;
+use oak_node::node::NodeCore;
+use oak_node::project::Project;
+use oak_node::sequence::SequenceBehavior;
+use oak_node::track::{TrackBehavior, TrackListBehavior, TrackType};
+use oak_task::nodeops;
+use oak_task::project::load::{ProjectLoadBaseTask, ProjectLoadTask};
+use oak_task::project::loadotio::{set_import_confirm_callback, LoadOTIOTask};
+use oak_task::project::saveotio::SaveOTIOTask;
+use oak_task::task::{Task, TaskBehavior};
+
+/// Tests that install the process-wide import-confirmation callback must
+/// not race each other.
+static CALLBACK_LOCK: Mutex<()> = Mutex::new(());
+
+fn work_path(tag: &str, ext: &str) -> std::path::PathBuf {
+ std::env::temp_dir().join(format!("oaktask_load_{tag}_{}.{ext}", std::process::id()))
+}
+
+fn write_clip(tag: &str) -> std::path::PathBuf {
+ let path = work_path(tag, "mp4");
+ oak_codec::testmedia::write_test_clip(&path, 64, 64, 10, 10).expect("test clip generation");
+ path
+}
+
+/// One video track carrying one clip of `footage` covering `[in, out)`.
+fn add_track_with_clip(
+ p: &mut Project,
+ footage: NodeId,
+ kind: TrackType,
+ in_: Rational,
+ out: Rational,
+) -> NodeId {
+ let (tcore, tbehavior) = TrackBehavior::create();
+ let track = p.graph.add_node(tcore, tbehavior);
+ p.graph
+ .get_mut(track)
+ .unwrap()
+ .behavior
+ .as_any_mut()
+ .unwrap()
+ .downcast_mut::()
+ .unwrap()
+ .kind = kind;
+
+ let (ccore, cbehavior) = oak_node::block::clip_create();
+ let clip = p.graph.add_node(ccore, cbehavior);
+ p.graph
+ .connect(footage, clip, oak_node::block::clip_input::TEXTURE_INPUT, -1)
+ .expect("connect footage to clip");
+ p.graph
+ .get_mut(clip)
+ .unwrap()
+ .behavior
+ .as_any_mut()
+ .unwrap()
+ .downcast_mut::()
+ .expect("clip block")
+ .core
+ .range = TimeRange::new(in_, out);
+ p.graph
+ .get_mut(track)
+ .unwrap()
+ .behavior
+ .as_any_mut()
+ .unwrap()
+ .downcast_mut::()
+ .unwrap()
+ .append_block(clip);
+ track
+}
+
+/// A project with one "Edited" sequence (one video track with one 1s clip,
+/// one audio track with the same clip) rooted in the bin folder.
+fn build_project(media: &str) -> Arc> {
+ let project = Project::new();
+ {
+ let mut p = project.lock().unwrap();
+ p.initialize().expect("root folder");
+
+ let footage = {
+ let mut f = FootageBehavior::new(media);
+ f.probe().expect("probe the generated clip");
+ p.graph.add_node(NodeCore::new(), Box::new(f))
+ };
+
+ let (score, sbehavior) = SequenceBehavior::create();
+ let edited = p.graph.add_node(score, sbehavior);
+ {
+ let video_track = add_track_with_clip(
+ &mut p,
+ footage,
+ TrackType::Video,
+ Rational::new(0, 1),
+ Rational::new(1, 1),
+ );
+ let audio_track = add_track_with_clip(
+ &mut p,
+ footage,
+ TrackType::Audio,
+ Rational::new(0, 1),
+ Rational::new(1, 1),
+ );
+ for (kind, tracks) in [
+ (TrackType::Video, vec![video_track]),
+ (TrackType::Audio, vec![audio_track]),
+ ] {
+ let (lcore, lbehavior) = TrackListBehavior::create();
+ let list = p.graph.add_node(lcore, lbehavior);
+ {
+ let list_b = p
+ .graph
+ .get_mut(list)
+ .unwrap()
+ .behavior
+ .as_any_mut()
+ .unwrap()
+ .downcast_mut::()
+ .unwrap();
+ list_b.kind = kind;
+ list_b.tracks = tracks;
+ }
+ p.graph
+ .get_mut(edited)
+ .unwrap()
+ .behavior
+ .as_any_mut()
+ .unwrap()
+ .downcast_mut::()
+ .unwrap()
+ .track_lists
+ .push(list);
+ }
+ p.graph.get_mut(edited).unwrap().core.label = "Edited".to_string();
+ }
+
+ let root = p.root;
+ p.graph
+ .get_mut(root)
+ .unwrap()
+ .behavior
+ .as_any_mut()
+ .unwrap()
+ .downcast_mut::()
+ .expect("root folder")
+ .children
+ .push(edited);
+ }
+ project
+}
+
+/// Save `project` through the production interchange task.
+fn save(project: &Arc>, filename: &std::path::Path) {
+ let mut driver = Task::new("Saving project...", None);
+ driver.set_behavior(Box::new(SaveOTIOTask {
+ base: Task::new("Saving project...", None),
+ project: project.clone(),
+ filename: filename.to_string_lossy().into_owned(),
+ }));
+ if let Err(e) = driver.start() {
+ panic!(
+ "save to {} failed: {e:?} / {}",
+ filename.display(),
+ driver.error().unwrap_or("unknown error")
+ );
+ }
+}
+
+/// Run the OTIO loader on `filename`; `(task error, stored project)`.
+fn load_otio(filename: &std::path::Path) -> (Option, Option>>) {
+ let mut task = Task::new("Loading project...", None);
+ let mut loader = LoadOTIOTask::new(ProjectLoadBaseTask::new(
+ Task::new("Loading project...", None),
+ filename.to_string_lossy().into_owned(),
+ ));
+ let result = loader.run(&mut task);
+ let error = if result.is_err() {
+ Some(task.error().unwrap_or("unknown error").to_string())
+ } else {
+ None
+ };
+ (error, loader.base.take_project().ok())
+}
+
+/// The labels of every sequence in `project`.
+fn sequence_labels(project: &Arc>) -> Vec {
+ let guard = project.lock().unwrap_or_else(|e| e.into_inner());
+ let mut labels = Vec::new();
+ for id in guard.graph.node_ids() {
+ if let Some(entry) = guard.graph.get(id) {
+ if entry
+ .behavior
+ .as_any()
+ .and_then(|a| a.downcast_ref::())
+ .is_some()
+ {
+ labels.push(entry.core.label.clone());
+ }
+ }
+ }
+ labels
+}
+
+// ---- OTIO / FCPXML round trip -----------------------------------------
+
+#[test]
+fn otio_round_trip_preserves_the_sequence() {
+ let _guard = CALLBACK_LOCK.lock().unwrap_or_else(|e| e.into_inner());
+ let media = write_clip("otio");
+ let project = build_project(&media.to_string_lossy());
+ let out = work_path("roundtrip", "otio");
+ let _ = std::fs::remove_file(&out);
+ save(&project, &out);
+
+ let (error, loaded) = load_otio(&out);
+ assert_eq!(error, None, "the saved document must load");
+ let loaded = loaded.expect("loaded project");
+ assert_eq!(sequence_labels(&loaded), vec!["Edited".to_string()]);
+
+ // The sequence carries the saved tracks and clips.
+ let (sequence, _) = {
+ let guard = loaded.lock().unwrap();
+ let mut found = None;
+ for id in guard.graph.node_ids() {
+ if guard
+ .graph
+ .get(id)
+ .and_then(|e| e.behavior.as_any())
+ .and_then(|a| a.downcast_ref::())
+ .is_some()
+ {
+ found = Some(id);
+ break;
+ }
+ }
+ (found.expect("sequence"), ())
+ };
+ let video_list =
+ nodeops::sequence_track_list(&loaded, sequence, TrackType::Video).expect("video list");
+ let audio_list =
+ nodeops::sequence_track_list(&loaded, sequence, TrackType::Audio).expect("audio list");
+ assert_eq!(nodeops::tracklist_track_count(&loaded, video_list), 1);
+ assert_eq!(nodeops::tracklist_track_count(&loaded, audio_list), 1);
+
+ let track = nodeops::tracklist_track_at(&loaded, video_list, 0).expect("video track");
+ assert_eq!(nodeops::track_block_count(&loaded, track), 1);
+ let clip = nodeops::track_block_at(&loaded, track, 0).expect("clip");
+ assert_eq!(nodeops::block_in(&loaded, clip), Rational::new(0, 1));
+ assert_eq!(nodeops::block_length(&loaded, clip), Rational::new(1, 1));
+ let footage = nodeops::clip_footage(&loaded, clip).expect("clip footage link");
+ assert_eq!(
+ nodeops::footage_filename(&loaded, footage),
+ media.to_string_lossy()
+ );
+
+ let _ = std::fs::remove_file(&out);
+ let _ = std::fs::remove_file(&media);
+}
+
+#[test]
+fn fcpxml_round_trip_preserves_the_sequence() {
+ let _guard = CALLBACK_LOCK.lock().unwrap_or_else(|e| e.into_inner());
+ let media = write_clip("fcpxml");
+ let project = build_project(&media.to_string_lossy());
+ let out = work_path("roundtrip", "fcpxml");
+ let _ = std::fs::remove_file(&out);
+ save(&project, &out);
+
+ let (error, loaded) = load_otio(&out);
+ assert_eq!(error, None, "the saved FCPXML must load");
+ let loaded = loaded.expect("loaded project");
+ assert_eq!(sequence_labels(&loaded), vec!["Edited".to_string()]);
+
+ // The sequence carries the saved tracks and clips (the same structure
+ // the OTIO round trip asserts, through the FCPXML mapping).
+ let sequence = {
+ let guard = loaded.lock().unwrap();
+ let mut found = None;
+ for id in guard.graph.node_ids() {
+ if guard
+ .graph
+ .get(id)
+ .and_then(|e| e.behavior.as_any())
+ .and_then(|a| a.downcast_ref::())
+ .is_some()
+ {
+ found = Some(id);
+ break;
+ }
+ }
+ found.expect("sequence")
+ };
+ let video_list =
+ nodeops::sequence_track_list(&loaded, sequence, TrackType::Video).expect("video list");
+ let audio_list =
+ nodeops::sequence_track_list(&loaded, sequence, TrackType::Audio).expect("audio list");
+ assert_eq!(nodeops::tracklist_track_count(&loaded, video_list), 1);
+ assert_eq!(nodeops::tracklist_track_count(&loaded, audio_list), 1);
+
+ let track = nodeops::tracklist_track_at(&loaded, video_list, 0).expect("video track");
+ assert_eq!(nodeops::track_block_count(&loaded, track), 1);
+ let clip = nodeops::track_block_at(&loaded, track, 0).expect("clip");
+ assert_eq!(nodeops::block_in(&loaded, clip), Rational::new(0, 1));
+ assert_eq!(nodeops::block_length(&loaded, clip), Rational::new(1, 1));
+ let footage = nodeops::clip_footage(&loaded, clip).expect("clip footage link");
+ assert_eq!(
+ nodeops::footage_filename(&loaded, footage),
+ media.to_string_lossy()
+ );
+
+ let _ = std::fs::remove_file(&out);
+ let _ = std::fs::remove_file(&media);
+}
+
+// ---- error contract ---------------------------------------------------
+
+#[test]
+fn load_otio_rejects_a_missing_file() {
+ let _guard = CALLBACK_LOCK.lock().unwrap_or_else(|e| e.into_inner());
+ let missing = work_path("missing", "otio");
+ let (error, loaded) = load_otio(&missing);
+ assert!(error.is_some(), "a missing document must fail");
+ assert!(loaded.is_none(), "no project is stored on failure");
+}
+
+#[test]
+fn load_otio_rejects_a_corrupt_document() {
+ let _guard = CALLBACK_LOCK.lock().unwrap_or_else(|e| e.into_inner());
+ let path = work_path("corrupt", "otio");
+ std::fs::write(&path, b"{ this is not json").expect("write corrupt file");
+ let (error, loaded) = load_otio(&path);
+ assert!(error.is_some(), "a corrupt document must fail");
+ assert!(loaded.is_none());
+ let _ = std::fs::remove_file(&path);
+}
+
+#[test]
+fn load_otio_rejects_an_unknown_extension() {
+ let _guard = CALLBACK_LOCK.lock().unwrap_or_else(|e| e.into_inner());
+ let path = work_path("unknown", "txt");
+ std::fs::write(&path, b"{}").expect("write file");
+ let (error, loaded) = load_otio(&path);
+ let error = error.expect("unknown extension must fail");
+ assert!(
+ error.contains("Unknown project file format"),
+ "error names the supported formats: {error}"
+ );
+ assert!(loaded.is_none());
+ let _ = std::fs::remove_file(&path);
+}
+
+#[test]
+fn take_project_before_a_run_reports_state() {
+ let mut base = ProjectLoadBaseTask::new(Task::new("load", None), "x.oakproj".into());
+ assert!(base.take_project().is_err());
+}
+
+#[test]
+fn xml_loader_rejects_missing_and_corrupt_documents() {
+ let mut task = Task::new("load", None);
+ let mut load = ProjectLoadTask {
+ base: ProjectLoadBaseTask::new(Task::new("load", None), "/definitely/not/here.oakproj".into()),
+ };
+ assert!(load.run(&mut task).is_err());
+ assert!(task
+ .error()
+ .is_some_and(|e| e.contains("Failed to read file")));
+
+ let path = work_path("corrupt", "oakproj");
+ std::fs::write(&path, b"").expect("write corrupt xml");
+ let mut task = Task::new("load", None);
+ let mut load = ProjectLoadTask {
+ base: ProjectLoadBaseTask::new(
+ Task::new("load", None),
+ path.to_string_lossy().into_owned(),
+ ),
+ };
+ assert!(load.run(&mut task).is_err());
+ assert!(task
+ .error()
+ .is_some_and(|e| e.contains("Failed to read XML document")));
+ let _ = std::fs::remove_file(&path);
+}
+
+#[test]
+fn xml_loader_round_trips_a_saved_project() {
+ let media = write_clip("oakproj");
+ let project = build_project(&media.to_string_lossy());
+ let xml = {
+ let guard = project.lock().unwrap();
+ oak_node::serializer::save(&guard).expect("serialize project")
+ };
+ let path = work_path("roundtrip", "oakproj");
+ std::fs::write(&path, xml).expect("write project");
+
+ let mut task = Task::new("load", None);
+ let mut load = ProjectLoadTask {
+ base: ProjectLoadBaseTask::new(
+ Task::new("load", None),
+ path.to_string_lossy().into_owned(),
+ ),
+ };
+ assert!(load.run(&mut task).is_ok(), "error: {:?}", task.error());
+ let loaded = load.base.take_project().expect("loaded project");
+ assert_eq!(sequence_labels(&loaded), vec!["Edited".to_string()]);
+ {
+ let guard = loaded.lock().unwrap();
+ assert_eq!(guard.filename(), path.to_string_lossy());
+ }
+
+ let _ = std::fs::remove_file(&path);
+ let _ = std::fs::remove_file(&media);
+}
+
+// ---- import-confirmation callback -------------------------------------
+
+#[test]
+fn import_confirm_callback_can_reject_and_accept() {
+ let _guard = CALLBACK_LOCK.lock().unwrap_or_else(|e| e.into_inner());
+ let media = write_clip("confirm");
+ let project = build_project(&media.to_string_lossy());
+ let out = work_path("confirm", "otio");
+ let _ = std::fs::remove_file(&out);
+ save(&project, &out);
+
+ // Rejecting: the task completes but hands no project to the caller.
+ let rejected: Arc>> = Arc::new(Mutex::new(Vec::new()));
+ let seen = rejected.clone();
+ set_import_confirm_callback(Some(Box::new(move |names| {
+ *seen.lock().unwrap() = names.to_vec();
+ false
+ })));
+ let (error, loaded) = load_otio(&out);
+ assert_eq!(error, None, "a user rejection is not a task error");
+ assert!(loaded.is_none(), "the rejected project is disposed of");
+ assert_eq!(*rejected.lock().unwrap(), vec!["Edited".to_string()]);
+
+ // Accepting: the same document loads normally.
+ set_import_confirm_callback(Some(Box::new(|_| true)));
+ let (error, loaded) = load_otio(&out);
+ assert_eq!(error, None);
+ assert!(loaded.is_some());
+
+ // Reset the global callback so other tests see the headless default.
+ set_import_confirm_callback(None);
+
+ let _ = std::fs::remove_file(&out);
+ let _ = std::fs::remove_file(&media);
+}
diff --git a/crates/oak-task/tests/manager_test.rs b/crates/oak-task/tests/manager_test.rs
new file mode 100644
index 000000000..8da97980e
--- /dev/null
+++ b/crates/oak-task/tests/manager_test.rs
@@ -0,0 +1,436 @@
+// 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 .
+
+//! Boundary tests for the `TaskManager` singleton: init/shutdown state
+//! machine, task ownership (add/cancel/drain/delete), not-found cases,
+//! pointer lookups (find/take/cancel by address) and worker joins.
+//!
+//! The manager is process-wide, so every test serializes on one static
+//! mutex and starts from a shut-down singleton. The worker behaviors use
+//! atomics and `yield_now` spins — no sleeps.
+
+use std::sync::atomic::{AtomicBool, Ordering};
+use std::sync::{Arc, Mutex, MutexGuard};
+
+use oak_task::error::Error;
+use oak_task::manager::TaskManager;
+use oak_task::task::{Task, TaskBehavior};
+
+/// Tests that touch the process-wide manager must not race each other.
+static MANAGER_LOCK: Mutex<()> = Mutex::new(());
+
+fn serial() -> MutexGuard<'static, ()> {
+ MANAGER_LOCK.lock().unwrap_or_else(|e| e.into_inner())
+}
+
+/// Shut down any leftover singleton, then create a fresh one.
+fn init_fresh() {
+ TaskManager::shutdown();
+ assert!(TaskManager::instance().is_none());
+ TaskManager::init().expect("manager init");
+}
+
+/// Bounded spin (no sleeps) until `flag` is set.
+fn spin_until(flag: &AtomicBool) {
+ for _ in 0..4_000_000 {
+ if flag.load(Ordering::SeqCst) {
+ return;
+ }
+ std::thread::yield_now();
+ }
+ panic!("flag was not set in time");
+}
+
+/// Marks that the behavior entered `run`, then returns success. The flag
+/// also makes `Task::wait_finished` race-free (it no-ops on a task whose
+/// worker has not reached `start` yet).
+struct Signal {
+ entered: Arc,
+}
+
+impl TaskBehavior for Signal {
+ fn run(&mut self, _task: &mut Task) -> Result<(), oak_task::error::Error> {
+ self.entered.store(true, Ordering::SeqCst);
+ Ok(())
+ }
+}
+
+/// Parks the worker until cancellation, recording both transitions.
+struct Park {
+ started: Arc,
+ cancelled: Arc,
+}
+
+impl TaskBehavior for Park {
+ fn run(&mut self, task: &mut Task) -> Result<(), oak_task::error::Error> {
+ self.started.store(true, Ordering::SeqCst);
+ while !task.is_cancelled() {
+ std::thread::yield_now();
+ }
+ self.cancelled.store(true, Ordering::SeqCst);
+ Err(oak_task::task::cancelled())
+ }
+}
+
+/// Parks until an external release flag is set, then succeeds.
+struct Gate {
+ started: Arc,
+ release: Arc,
+}
+
+impl TaskBehavior for Gate {
+ fn run(&mut self, _task: &mut Task) -> Result<(), oak_task::error::Error> {
+ self.started.store(true, Ordering::SeqCst);
+ while !self.release.load(Ordering::SeqCst) {
+ std::thread::yield_now();
+ }
+ Ok(())
+ }
+}
+
+/// Build a `Park` behavior task plus its observation flags.
+fn park_task(tag: &str) -> (Task, Arc, Arc) {
+ let started = Arc::new(AtomicBool::new(false));
+ let cancelled = Arc::new(AtomicBool::new(false));
+ let mut task = Task::new(tag, None);
+ task.set_behavior(Box::new(Park {
+ started: started.clone(),
+ cancelled: cancelled.clone(),
+ }));
+ (task, started, cancelled)
+}
+
+/// Add `task` and return its raw pointer after waiting for the worker to
+/// start running.
+fn add_and_wait(task: Task, started: &AtomicBool) -> *mut Task {
+ TaskManager::with_manager_mut(|m| m.add_task(Box::new(task))).expect("manager present");
+ spin_until(started);
+ TaskManager::with_manager(|m| m.task_ptr_at(0))
+ .expect("manager present")
+ .expect("task 0")
+}
+
+fn task_ptr(index: usize) -> *mut Task {
+ TaskManager::with_manager(|m| m.task_ptr_at(index))
+ .expect("manager present")
+ .unwrap_or_else(|e| panic!("task {index} missing: {e:?}"))
+}
+
+// ---- singleton state --------------------------------------------------
+
+/// Given a fresh singleton, `init` rejects duplicates and the accessors see
+/// the manager; `shutdown` is idempotent and clears the instance.
+#[test]
+fn init_is_a_singleton_and_shutdown_is_idempotent() {
+ let _guard = serial();
+ init_fresh();
+
+ assert!(TaskManager::instance().is_some());
+ assert!(matches!(TaskManager::init(), Err(Error::State)));
+
+ assert_eq!(TaskManager::with_manager(|m| m.get_task_count()), Some(0));
+ assert_eq!(
+ TaskManager::with_manager_mut(|m| {
+ m.set_codec_submitter_registered(true);
+ m.codec_submitter_registered()
+ }),
+ Some(true)
+ );
+
+ TaskManager::shutdown();
+ assert!(TaskManager::instance().is_none());
+ assert!(TaskManager::with_manager(|m| m.get_task_count()).is_none());
+ assert!(TaskManager::with_manager_mut(|m| m.get_task_count()).is_none());
+
+ TaskManager::shutdown(); // idempotent
+ assert!(TaskManager::instance().is_none());
+}
+
+/// `init` does not register the codec task submitter: the manager flag
+/// starts `false` while the oakcodec registry stays untouched, and the
+/// flag is pure bookkeeping — flipping it installs nothing.
+#[test]
+fn init_does_not_register_the_codec_submitter() {
+ let _guard = serial();
+ init_fresh();
+
+ assert!(
+ !oak_task::codecbridge::is_codec_task_submitter_registered(),
+ "init() must not install the codec submitter"
+ );
+ assert_eq!(
+ TaskManager::with_manager(|m| m.codec_submitter_registered()),
+ Some(false),
+ "a fresh manager has not registered anything"
+ );
+
+ TaskManager::with_manager_mut(|m| m.set_codec_submitter_registered(true)).unwrap();
+ assert!(
+ !oak_task::codecbridge::is_codec_task_submitter_registered(),
+ "the bookkeeping flag never installs the oakcodec callback"
+ );
+ assert_eq!(
+ TaskManager::with_manager(|m| m.codec_submitter_registered()),
+ Some(true)
+ );
+ TaskManager::with_manager_mut(|m| m.set_codec_submitter_registered(false)).unwrap();
+ assert_eq!(
+ TaskManager::with_manager(|m| m.codec_submitter_registered()),
+ Some(false)
+ );
+}
+
+// ---- add / finish / drain --------------------------------------------
+
+/// Given a task handed to the manager, the worker runs it; it can be found
+/// by address, waited on, drained (with its join handle) and removed.
+#[test]
+fn add_task_runs_and_drain_finished_returns_the_pair() {
+ let _guard = serial();
+ init_fresh();
+
+ let entered = Arc::new(AtomicBool::new(false));
+ let mut task = Task::new("signal", None);
+ task.set_behavior(Box::new(Signal {
+ entered: entered.clone(),
+ }));
+ TaskManager::with_manager_mut(|m| m.add_task(Box::new(task))).expect("manager present");
+ spin_until(&entered);
+ let ptr = task_ptr(0);
+
+ // Deterministic completion wait through the task itself.
+ unsafe { (&*ptr).wait_finished() };
+ assert!(unsafe { (&*ptr).is_finished() });
+ assert!(unsafe { (&*ptr).succeeded() });
+
+ // The list still owns the task until drained.
+ assert_eq!(TaskManager::with_manager(|m| m.get_task_count()), Some(1));
+ assert_eq!(
+ TaskManager::with_manager(|m| m.find_index(ptr)),
+ Some(Some(0))
+ );
+
+ // An unrelated task is not in the list.
+ let unrelated = Task::new("unrelated", None);
+ let unrelated_ptr: *const Task = &unrelated;
+ assert_eq!(
+ TaskManager::with_manager(|m| m.find_index(unrelated_ptr)),
+ Some(None)
+ );
+
+ let drained = TaskManager::with_manager_mut(|m| m.drain_finished()).expect("manager present");
+ assert_eq!(drained.len(), 1, "the finished task and its thread");
+ for (_task, handle) in drained {
+ handle.join().expect("worker joined");
+ }
+ assert_eq!(TaskManager::with_manager(|m| m.get_task_count()), Some(0));
+ assert!(matches!(
+ TaskManager::with_manager(|m| m.task_ptr_at(0)),
+ Some(Err(Error::NotFound))
+ ));
+}
+
+/// Ending a still-running task through `cancel_task` reports NotFound for
+/// out-of-range indices; `cancel_task_and_wait` stops and joins the worker.
+#[test]
+fn cancel_task_and_wait_stops_a_running_task() {
+ let _guard = serial();
+ init_fresh();
+
+ let (task, started, cancelled) = park_task("park");
+ let ptr = add_and_wait(task, &started);
+
+ assert!(matches!(
+ TaskManager::with_manager_mut(|m| m.cancel_task(7)),
+ Some(Err(Error::NotFound))
+ ));
+ assert!(matches!(
+ TaskManager::with_manager(|m| m.task_ptr_at(7)),
+ Some(Err(Error::NotFound))
+ ));
+
+ TaskManager::with_manager_mut(|m| m.cancel_task_and_wait(0))
+ .expect("manager present")
+ .expect("cancel index 0");
+ assert!(cancelled.load(Ordering::SeqCst));
+ assert!(unsafe { (&*ptr).is_finished() });
+ assert!(!unsafe { (&*ptr).succeeded() });
+
+ // The handle was already joined by cancel_task_and_wait, so
+ // delete_finished silently removes the finished task.
+ assert_eq!(TaskManager::with_manager(|m| m.get_task_count()), Some(1));
+ TaskManager::with_manager_mut(|m| m.delete_finished()).expect("manager present");
+ assert_eq!(TaskManager::with_manager(|m| m.get_task_count()), Some(0));
+}
+
+/// `drain_finished` skips the still-running tasks and leaves them in the
+/// list.
+#[test]
+fn drain_finished_keeps_running_tasks() {
+ let _guard = serial();
+ init_fresh();
+
+ // A finished task at index 0...
+ let entered = Arc::new(AtomicBool::new(false));
+ let mut done = Task::new("done", None);
+ done.set_behavior(Box::new(Signal {
+ entered: entered.clone(),
+ }));
+ TaskManager::with_manager_mut(|m| m.add_task(Box::new(done))).unwrap();
+ spin_until(&entered);
+ unsafe { (&*task_ptr(0)).wait_finished() };
+
+ // ...and a parked task at index 1.
+ let (parked, started, cancelled) = park_task("running");
+ TaskManager::with_manager_mut(|m| m.add_task(Box::new(parked))).unwrap();
+ spin_until(&started);
+
+ let drained = TaskManager::with_manager_mut(|m| m.drain_finished()).expect("manager present");
+ assert_eq!(drained.len(), 1, "only the finished task is drained");
+ for (_task, handle) in drained {
+ handle.join().expect("worker joined");
+ }
+ assert_eq!(
+ TaskManager::with_manager(|m| m.get_task_count()),
+ Some(1),
+ "the running task stays in the list"
+ );
+
+ TaskManager::shutdown();
+ assert!(cancelled.load(Ordering::SeqCst));
+}
+
+/// `delete_finished` also joins the handles `drain_finished` would return.
+#[test]
+fn delete_finished_removes_completed_tasks() {
+ let _guard = serial();
+ init_fresh();
+
+ for (index, tag) in ["a", "b"].into_iter().enumerate() {
+ let entered = Arc::new(AtomicBool::new(false));
+ let mut task = Task::new(tag, None);
+ task.set_behavior(Box::new(Signal {
+ entered: entered.clone(),
+ }));
+ TaskManager::with_manager_mut(|m| m.add_task(Box::new(task))).unwrap();
+ spin_until(&entered);
+ unsafe { (&*task_ptr(index)).wait_finished() };
+ }
+ assert_eq!(TaskManager::with_manager(|m| m.get_task_count()), Some(2));
+
+ TaskManager::with_manager_mut(|m| m.delete_finished()).unwrap();
+ assert_eq!(TaskManager::with_manager(|m| m.get_task_count()), Some(0));
+}
+
+// ---- pointer lookups --------------------------------------------------
+
+/// `cancel_task_by_ptr` cancels a known task and no-ops for an absent one;
+/// `take_thread_by_ptr` hands the handle out exactly once.
+#[test]
+fn cancel_and_take_by_pointer_handle_absent_targets() {
+ let _guard = serial();
+ init_fresh();
+
+ let (task, started, cancelled) = park_task("byptr");
+ let ptr = add_and_wait(task, &started);
+
+ // Known pointer: the worker thread moves out (once).
+ let handle = TaskManager::with_manager_mut(|m| m.take_thread_by_ptr(ptr))
+ .expect("manager present")
+ .expect("worker thread");
+ assert!(TaskManager::with_manager_mut(|m| m.take_thread_by_ptr(ptr))
+ .expect("manager present")
+ .is_none());
+
+ // Cancel through the pointer, then join lock-free.
+ TaskManager::with_manager_mut(|m| m.cancel_task_by_ptr(ptr)).expect("manager present");
+ handle.join().expect("worker joined");
+ assert!(cancelled.load(Ordering::SeqCst));
+ assert!(unsafe { (&*ptr).is_cancelled() });
+
+ // Absent pointer: both operations are no-ops.
+ let absent = Task::new("absent", None);
+ let absent_ptr: *const Task = &absent;
+ TaskManager::with_manager_mut(|m| m.cancel_task_by_ptr(absent_ptr)).expect("manager present");
+ assert!(
+ TaskManager::with_manager_mut(|m| m.take_thread_by_ptr(absent_ptr))
+ .expect("manager present")
+ .is_none()
+ );
+
+ assert_eq!(TaskManager::with_manager(|m| m.get_task_count()), Some(1));
+ TaskManager::with_manager_mut(|m| m.delete_finished()).unwrap();
+ assert_eq!(TaskManager::with_manager(|m| m.get_task_count()), Some(0));
+}
+
+/// `wait_finished` blocks on a live task until its behavior returns.
+#[test]
+fn wait_finished_blocks_until_the_behavior_returns() {
+ let _guard = serial();
+ init_fresh();
+
+ let started = Arc::new(AtomicBool::new(false));
+ let release = Arc::new(AtomicBool::new(false));
+ let mut task = Task::new("gate", None);
+ task.set_behavior(Box::new(Gate {
+ started: started.clone(),
+ release: release.clone(),
+ }));
+ TaskManager::with_manager_mut(|m| m.add_task(Box::new(task))).unwrap();
+ spin_until(&started);
+ let ptr = task_ptr(0);
+
+ // The waiter records whether the task was finished when
+ // `wait_finished` returned; it must not return while the gate is shut.
+ let entered = Arc::new(AtomicBool::new(false));
+ let waiter_entered = entered.clone();
+ let ptr_addr = ptr as usize;
+ let waiter = std::thread::spawn(move || {
+ let task = unsafe { &*(ptr_addr as *const Task) };
+ waiter_entered.store(true, Ordering::SeqCst);
+ task.wait_finished();
+ task.is_finished()
+ });
+ spin_until(&entered);
+ assert!(!waiter.is_finished(), "the gate is still shut");
+
+ release.store(true, Ordering::SeqCst);
+ assert!(
+ waiter.join().expect("waiter thread"),
+ "wait_finished must not return before the task finished"
+ );
+ assert!(unsafe { (&*ptr).succeeded() });
+ TaskManager::with_manager_mut(|m| m.delete_finished()).unwrap();
+}
+
+/// Dropping the manager (via `shutdown`) cancels every running task before
+/// joining its worker.
+#[test]
+fn shutdown_cancels_running_tasks_before_joining() {
+ let _guard = serial();
+ init_fresh();
+
+ let (task, started, cancelled) = park_task("drop");
+ TaskManager::with_manager_mut(|m| m.add_task(Box::new(task))).unwrap();
+ spin_until(&started);
+
+ TaskManager::shutdown();
+ assert!(
+ cancelled.load(Ordering::SeqCst),
+ "the manager destructor cancels its tasks"
+ );
+ assert!(TaskManager::instance().is_none());
+}
diff --git a/crates/oak-task/tests/precache_test.rs b/crates/oak-task/tests/precache_test.rs
new file mode 100644
index 000000000..af857b199
--- /dev/null
+++ b/crates/oak-task/tests/precache_test.rs
@@ -0,0 +1,293 @@
+// 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 .
+
+//! Boundary tests for `PreCacheTask`: construction (probed footage, missing
+//! media, invalid ids), a real one-frame render through the render loop, an
+//! empty range, cancellation before the run, and the no-op download hooks.
+//!
+//! Renders run on the process-wide manager's test-only inline (`Threads`)
+//! backend, serialized through one static mutex; the media is generated by
+//! `oak_codec::testmedia` (no network, no fixtures). Nothing here needs a
+//! GPU.
+
+use std::sync::{Arc, Mutex, MutexGuard};
+
+use oak_core::texture::{Frame, Texture};
+use oak_core::Rational;
+use oak_node::footage::FootageBehavior;
+use oak_node::id::NodeId;
+use oak_node::node::NodeCore;
+use oak_node::project::Project;
+use oak_node::sequence::SequenceBehavior;
+use oak_render::manager::{RenderBackendChoice, RenderManager};
+use oak_render::ticket::AudioSamples;
+use oak_task::nodeops::{self, ProjectRef};
+use oak_task::precache::PreCacheTask;
+use oak_task::render::RenderTaskBehavior;
+use oak_task::task::{Task, TaskBehavior, TaskEvent};
+
+/// The manager is process-wide and the inline backend is test-only.
+static RENDER_LOCK: Mutex<()> = Mutex::new(());
+
+/// Serialize render-manager use and make sure the inline backend is up.
+fn render_serial() -> MutexGuard<'static, ()> {
+ let guard = RENDER_LOCK.lock().unwrap_or_else(|e| e.into_inner());
+ if RenderManager::global().is_none() {
+ RenderManager::init_with_backend(RenderBackendChoice::Threads).expect("render manager");
+ }
+ guard
+}
+
+fn clip_path(tag: &str) -> std::path::PathBuf {
+ std::env::temp_dir().join(format!("oaktask_precache_{tag}_{}.mp4", std::process::id()))
+}
+
+/// A project with one probed footage node and one sequence whose video
+/// timebase is 1 fps (so the 1 s test clip renders as exactly one frame).
+fn project_with_footage(media: &str) -> (ProjectRef, NodeId, NodeId) {
+ let project = Project::new();
+ let mut p = project.lock().unwrap();
+
+ let mut footage = FootageBehavior::new(media);
+ footage.probe().expect("probe the generated clip");
+ let footage_id = p.graph.add_node(NodeCore::new(), Box::new(footage));
+
+ let (score, sbehavior) = SequenceBehavior::create();
+ let sequence = p.graph.add_node(score, sbehavior);
+ if let Some(seq) = p
+ .graph
+ .get_mut(sequence)
+ .and_then(|e| e.behavior.as_any_mut())
+ .and_then(|a| a.downcast_mut::())
+ {
+ seq.video_params[0].frame_rate = Rational::new(1, 1);
+ seq.video_params[0].width = 64;
+ seq.video_params[0].height = 64;
+ }
+
+ (project.clone(), footage_id, sequence)
+}
+
+/// Build a pre-cache task with a 64x64 forced render size (the sequence
+/// default is 1080p; the forced size keeps the test frame small).
+fn precache(project: &ProjectRef, footage: NodeId, sequence: NodeId, index: i32) -> PreCacheTask {
+ let mut task = PreCacheTask::new(
+ (project.clone(), footage),
+ index,
+ (project.clone(), sequence),
+ );
+ task.render.force_params.force_width = 64;
+ task.render.force_params.force_height = 64;
+ task
+}
+
+// ---- construction -----------------------------------------------------
+
+/// The task label carries the footage filename and the frame index; the
+/// public fields keep the inputs.
+#[test]
+fn new_labels_with_the_footage_filename_and_index() {
+ let media = clip_path("new");
+ oak_codec::testmedia::write_test_clip(&media, 64, 64, 10, 10).expect("test clip");
+ let (project, footage, sequence) = project_with_footage(&media.to_string_lossy());
+
+ let task = PreCacheTask::new((project.clone(), footage), 3, (project.clone(), sequence));
+ assert_eq!(
+ task.render.base.title(),
+ format!("Pre-caching {}:3", media.to_string_lossy())
+ );
+ assert_eq!(task.index, 3);
+ assert!(Arc::ptr_eq(&task.footage.0, &project));
+ assert_eq!(task.footage.1, footage);
+ assert!(Arc::ptr_eq(&task.sequence.0, &project));
+ assert_eq!(task.sequence.1, sequence);
+
+ let _ = std::fs::remove_file(&media);
+}
+
+/// Missing media (an unprobed footage node) still constructs a labeled task
+/// with a zero-length range.
+#[test]
+fn new_accepts_unprobed_and_missing_media() {
+ let (project, _, sequence) = project_with_footage_listless();
+
+ let footage =
+ nodeops::footage_create(&project, Some("/definitely/not/here.mp4")).expect("footage node");
+ let mut task = PreCacheTask::new((project.clone(), footage), 0, (project.clone(), sequence));
+ assert_eq!(
+ task.render.base.title(),
+ "Pre-caching /definitely/not/here.mp4:0"
+ );
+ assert_eq!(
+ nodeops::node_length(&project, footage),
+ Rational::new(0, 1),
+ "the unprobed footage has no length"
+ );
+
+ // Running over the empty range is a no-op success.
+ let _guard = render_serial();
+ let mut driver = Task::new("precache", None);
+ assert!(task.run(&mut driver).is_ok());
+ assert_eq!(task.render.total_frames(), 0);
+ assert!(driver.error().is_none());
+}
+
+/// A project with no footage; only the sequence is exercised here.
+fn project_with_footage_listless() -> (ProjectRef, NodeId, NodeId) {
+ let project = Project::new();
+ let sequence = {
+ let mut p = project.lock().unwrap();
+ let (score, sbehavior) = SequenceBehavior::create();
+ p.graph.add_node(score, sbehavior)
+ };
+ (project, NodeId::INVALID, sequence)
+}
+
+// ---- run --------------------------------------------------------------
+
+/// A probed 1 s clip renders exactly one frame and reports no error.
+#[test]
+fn run_renders_the_footage_frame() {
+ let media = clip_path("run");
+ oak_codec::testmedia::write_test_clip(&media, 64, 64, 10, 10).expect("test clip");
+ let (project, footage, sequence) = project_with_footage(&media.to_string_lossy());
+ let mut task = precache(&project, footage, sequence, 0);
+
+ let _guard = render_serial();
+ let mut driver = Task::new("precache", None);
+ let result = task.run(&mut driver);
+ assert!(result.is_ok(), "run failed: {:?}", driver.error());
+ assert_eq!(
+ task.render.total_frames(),
+ 1,
+ "the 1 s range at 1 fps holds one frame"
+ );
+ assert!(!driver.is_cancelled());
+ assert!(driver.error().is_none());
+
+ let _ = std::fs::remove_file(&media);
+}
+
+/// A stale footage id yields an empty range: the run succeeds without
+/// submitting any frame.
+#[test]
+fn run_with_a_stale_footage_id_renders_nothing() {
+ let (project, _, sequence) = project_with_footage_listless();
+ let mut task = PreCacheTask::new(
+ (project.clone(), NodeId::INVALID),
+ 0,
+ (project.clone(), sequence),
+ );
+
+ let _guard = render_serial();
+ let mut driver = Task::new("precache", None);
+ assert!(task.run(&mut driver).is_ok());
+ assert_eq!(task.render.total_frames(), 0);
+ assert_eq!(
+ nodeops::node_length(&project, NodeId::INVALID),
+ Rational::new(0, 1)
+ );
+}
+
+/// Cancelling before the run aborts the render with the cancellation error
+/// and delivers no frame.
+#[test]
+fn run_cancelled_before_start_reports_cancelled() {
+ let media = clip_path("cancel");
+ oak_codec::testmedia::write_test_clip(&media, 64, 64, 10, 10).expect("test clip");
+ let (project, footage, sequence) = project_with_footage(&media.to_string_lossy());
+ let mut task = precache(&project, footage, sequence, 0);
+
+ let _guard = render_serial();
+ let mut driver = Task::new("precache", None);
+ driver.cancel();
+ let result = task.run(&mut driver);
+ assert!(result.is_err(), "a cancelled render fails");
+ assert!(driver.is_cancelled());
+ assert_eq!(
+ task.render.total_frames(),
+ 1,
+ "the frame count is computed before the cancellation check"
+ );
+
+ let _ = std::fs::remove_file(&media);
+}
+
+/// The download hooks are no-ops: each accepts its payload and reports
+/// success without touching the driver.
+#[test]
+fn download_hooks_are_no_ops() {
+ let (project, _, sequence) = project_with_footage_listless();
+ let mut task = PreCacheTask::new(
+ (project.clone(), NodeId::INVALID),
+ 0,
+ (project.clone(), sequence),
+ );
+ let mut driver = Task::new("precache", None);
+
+ let frame = Texture::Cpu(Frame::dummy());
+ assert!(task.frame_downloaded(&mut driver, &frame).is_ok());
+ let audio = AudioSamples {
+ samples: vec![0.0; 8],
+ sample_rate: 48000,
+ channel_layout: 0x3,
+ channel_count: 2,
+ };
+ assert!(task.audio_downloaded(&mut driver, &audio).is_ok());
+ assert!(task.encode_subtitle(&mut driver, "a subtitle").is_ok());
+ assert!(driver.error().is_none());
+}
+
+/// `run` plans the render over the full footage length and honors the
+/// window/progress configurators: the single frame still renders with the
+/// in-flight window pinned to one ticket, and disabled native progress
+/// keeps the driver's progress silent.
+#[test]
+fn render_plan_covers_the_whole_footage() {
+ let media = clip_path("range");
+ oak_codec::testmedia::write_test_clip(&media, 64, 64, 10, 10).expect("test clip");
+ let (project, footage, sequence) = project_with_footage(&media.to_string_lossy());
+ let mut task = precache(&project, footage, sequence, 0);
+
+ let progress = Arc::new(Mutex::new(Vec::new()));
+ let sink = progress.clone();
+ task.render.set_max_inflight(1);
+ task.render.set_native_progress_signalling(false);
+ let _guard = render_serial();
+ let mut driver = Task::new("precache", None);
+ driver.set_event_listener(Box::new(move |event| {
+ if let TaskEvent::Progress(value) = event {
+ sink.lock().unwrap().push(value);
+ }
+ }));
+ let result = task.run(&mut driver);
+ assert!(result.is_ok(), "run failed: {:?}", driver.error());
+
+ // The 10-frame / 10 fps test clip is 1 s long, i.e. one frame at the
+ // sequence's 1 fps timebase; the one-ticket window still delivers it.
+ assert_eq!(
+ nodeops::node_length(&project, footage),
+ Rational::new(1, 1),
+ "the probed footage length"
+ );
+ assert_eq!(task.render.total_frames(), 1);
+ assert!(
+ progress.lock().unwrap().is_empty(),
+ "disabled native progress signalling emits nothing"
+ );
+
+ let _ = std::fs::remove_file(&media);
+}
diff --git a/crates/oak-task/tests/render_test.rs b/crates/oak-task/tests/render_test.rs
new file mode 100644
index 000000000..8520ed869
--- /dev/null
+++ b/crates/oak-task/tests/render_test.rs
@@ -0,0 +1,1225 @@
+// 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 .
+
+//! Boundary tests for the `RenderTask` base: empty ranges, stale viewer
+//! nodes, hook errors, cancellation, forced sizes/formats, footage and
+//! montage delivery (video + audio, audio first) and progress reporting.
+//!
+//! Renders run on the process-wide manager's test-only inline (`Threads`)
+//! backend, serialized through one static mutex, so no GPU, no worker
+//! processes and no sleeps are involved. Media is generated by
+//! `oak_codec::testmedia`.
+
+use std::sync::{Arc, Mutex, MutexGuard};
+
+use oak_core::texture::Texture;
+use oak_core::videoparams::VideoParams;
+use oak_core::{PixelFormat, Rational, TimeRange};
+use oak_node::block::ClipBlockBehavior;
+use oak_node::footage::FootageBehavior;
+use oak_node::id::NodeId;
+use oak_node::node::NodeCore;
+use oak_node::project::Project;
+use oak_node::sequence::SequenceBehavior;
+use oak_node::track::{TrackBehavior, TrackListBehavior, TrackType};
+use oak_render::manager::{RenderBackendChoice, RenderManager};
+use oak_render::ticket::AudioSamples;
+use oak_task::error::{Error, Result};
+use oak_task::nodeops::{self, ProjectRef};
+use oak_task::render::{ForceParams, RenderTask, RenderTaskBehavior};
+use oak_task::task::{Task, TaskEvent};
+
+/// The manager is process-wide and the inline backend is test-only.
+static RENDER_LOCK: Mutex<()> = Mutex::new(());
+
+/// Serialize render-manager use and make sure the inline backend is up.
+fn render_serial() -> MutexGuard<'static, ()> {
+ let guard = RENDER_LOCK.lock().unwrap_or_else(|e| e.into_inner());
+ if RenderManager::global().is_none() {
+ RenderManager::init_with_backend(RenderBackendChoice::Threads).expect("render manager");
+ }
+ guard
+}
+
+fn clip_path(tag: &str) -> std::path::PathBuf {
+ std::env::temp_dir().join(format!("oaktask_render_{tag}_{}.mp4", std::process::id()))
+}
+
+/// Locate an `oak-worker` binary for the private-dispatcher path: an
+/// explicit `OAK_WORKER_BIN`, a sibling of the test executable, or the
+/// workspace `target//oak-worker`. `None` means the path is
+/// unavailable in this environment (the test then skips with a reason).
+fn locate_oak_worker() -> Option {
+ if let Some(var) = std::env::var_os("OAK_WORKER_BIN") {
+ let path = std::path::PathBuf::from(var);
+ if path.is_file() {
+ return Some(path);
+ }
+ }
+ if let Some(dir) = std::env::current_exe().ok().and_then(|e| e.parent().map(|p| p.to_path_buf())) {
+ let path = dir.join("oak-worker");
+ if path.is_file() {
+ return Some(path);
+ }
+ }
+ let manifest = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
+ for profile in ["debug", "release"] {
+ let path = manifest.join("../../target").join(profile).join("oak-worker");
+ if path.is_file() {
+ return Some(path);
+ }
+ }
+ None
+}
+
+// ---- fixture ----------------------------------------------------------
+
+/// A project with one probed footage node (64x64, 1 s) and one sequence.
+fn footage_project(media: &str, fps: i64) -> (ProjectRef, NodeId, VideoParams) {
+ let project = Project::new();
+ let (footage_id, sequence) = {
+ let mut p = project.lock().unwrap();
+
+ let mut footage = FootageBehavior::new(media);
+ footage.probe().expect("probe the generated clip");
+ let footage_id = p.graph.add_node(NodeCore::new(), Box::new(footage));
+
+ let (score, sbehavior) = SequenceBehavior::create();
+ let sequence = p.graph.add_node(score, sbehavior);
+ if let Some(seq) = p
+ .graph
+ .get_mut(sequence)
+ .and_then(|e| e.behavior.as_any_mut())
+ .and_then(|a| a.downcast_mut::())
+ {
+ seq.video_params[0].frame_rate = Rational::new(fps, 1);
+ seq.video_params[0].width = 64;
+ seq.video_params[0].height = 64;
+ }
+ (footage_id, sequence)
+ };
+ let params = nodeops::sequence_video_params(&project, sequence, 0).expect("sequence params");
+ (project, footage_id, params)
+}
+
+/// A sequence with one video clip (and optionally an audio clip) of the
+/// same media, both covering `[0, 1s)`. With `effect = Some(enabled)`, an
+/// Opacity effect (factor 0) with that enable-state sits between the
+/// footage and the clip so the effect-chain walk is exercised and its
+/// effect is observable in the delivered pixels: the montage evaluator
+/// applies the built-in Opacity on the CPU (unknown built-ins pass
+/// through without a GPU, so those could not prove the chain ran).
+fn sequence_project(
+ media: &str,
+ with_audio: bool,
+ effect: Option,
+) -> (ProjectRef, NodeId, VideoParams) {
+ let project = Project::new();
+ let sequence = {
+ let mut p = project.lock().unwrap();
+
+ let mut footage = FootageBehavior::new(media);
+ footage.probe().expect("probe the generated clip");
+ let footage_id = p.graph.add_node(NodeCore::new(), Box::new(footage));
+
+ let (score, sbehavior) = SequenceBehavior::create();
+ let sequence = p.graph.add_node(score, sbehavior);
+ if let Some(seq) = p
+ .graph
+ .get_mut(sequence)
+ .and_then(|e| e.behavior.as_any_mut())
+ .and_then(|a| a.downcast_mut::())
+ {
+ seq.video_params[0].frame_rate = Rational::new(1, 1);
+ seq.video_params[0].width = 64;
+ seq.video_params[0].height = 64;
+ }
+
+ let kinds: &[TrackType] = if with_audio {
+ &[TrackType::Video, TrackType::Audio]
+ } else {
+ &[TrackType::Video]
+ };
+ for &kind in kinds {
+ let (tcore, tbehavior) = TrackBehavior::create();
+ let track = p.graph.add_node(tcore, tbehavior);
+ p.graph
+ .get_mut(track)
+ .unwrap()
+ .behavior
+ .as_any_mut()
+ .unwrap()
+ .downcast_mut::()
+ .unwrap()
+ .kind = kind;
+
+ let (ccore, cbehavior) = oak_node::block::clip_create();
+ let clip = p.graph.add_node(ccore, cbehavior);
+ if effect.is_some() && kind == TrackType::Video {
+ // The Opacity module is private; the factory is the public
+ // constructor. The value input is zeroed so an enabled
+ // effect zeroes every delivered channel (including alpha).
+ let (ecore, ebehavior) = oak_node::factory::Factory::global()
+ .create_any("org.olivevideoeditor.Olive.opacity")
+ .expect("opacity node registered");
+ let effect_node = p.graph.add_node(ecore, ebehavior);
+ p.graph
+ .get_mut(effect_node)
+ .unwrap()
+ .core
+ .set_standard_value(
+ oak_node::node::ENABLED_INPUT,
+ -1,
+ oak_node::value::NodeValue::Boolean(effect.unwrap_or(false)),
+ );
+ p.graph
+ .get_mut(effect_node)
+ .unwrap()
+ .core
+ .set_standard_value("opacity_in", -1, oak_node::value::NodeValue::Float(0.0));
+ p.graph
+ .connect(footage_id, effect_node, "tex_in", -1)
+ .expect("connect footage to effect");
+ p.graph
+ .connect(
+ effect_node,
+ clip,
+ oak_node::block::clip_input::TEXTURE_INPUT,
+ -1,
+ )
+ .expect("connect effect to clip");
+ } else {
+ p.graph
+ .connect(
+ footage_id,
+ clip,
+ oak_node::block::clip_input::TEXTURE_INPUT,
+ -1,
+ )
+ .expect("connect footage to clip");
+ }
+ p.graph
+ .get_mut(clip)
+ .unwrap()
+ .behavior
+ .as_any_mut()
+ .unwrap()
+ .downcast_mut::()
+ .expect("clip block")
+ .core
+ .range = TimeRange::new(Rational::new(0, 1), Rational::new(1, 1));
+ p.graph
+ .get_mut(track)
+ .unwrap()
+ .behavior
+ .as_any_mut()
+ .unwrap()
+ .downcast_mut::()
+ .unwrap()
+ .append_block(clip);
+
+ let (lcore, lbehavior) = TrackListBehavior::create();
+ let list = p.graph.add_node(lcore, lbehavior);
+ {
+ let list_b = p
+ .graph
+ .get_mut(list)
+ .unwrap()
+ .behavior
+ .as_any_mut()
+ .unwrap()
+ .downcast_mut::()
+ .unwrap();
+ list_b.kind = kind;
+ list_b.tracks.push(track);
+ }
+ p.graph
+ .get_mut(sequence)
+ .unwrap()
+ .behavior
+ .as_any_mut()
+ .unwrap()
+ .downcast_mut::()
+ .unwrap()
+ .track_lists
+ .push(list);
+ }
+ sequence
+ };
+
+ let params = nodeops::sequence_video_params(&project, sequence, 0).expect("sequence params");
+ (project, sequence, params)
+}
+
+/// Build a `RenderTask` for `viewer` with a 64x64 forced F32 render size.
+fn render_task(project: &ProjectRef, viewer: NodeId, params: Option) -> RenderTask {
+ let mut task = RenderTask::new(
+ Task::new("render", None),
+ params,
+ (project.clone(), viewer),
+ ForceParams {
+ force_width: 64,
+ force_height: 64,
+ // The default leaves `force_format` off (-1), so the pipeline
+ // frame stays F32.
+ ..ForceParams::default()
+ },
+ None,
+ );
+ task.set_max_inflight(2);
+ task
+}
+
+/// The first RGBA pixel of a delivered texture as f32 (downloads GPU
+/// textures; `None` when the texture cannot be read back).
+fn first_pixel(frame: &Texture) -> Option<[f32; 4]> {
+ let f = frame.to_frame().ok()?;
+ if f.width <= 0 || f.height <= 0 || f.data.len() < 16 {
+ return None;
+ }
+ let mut px = [0.0f32; 4];
+ for (i, channel) in px.iter_mut().enumerate() {
+ let start = i * 4;
+ *channel = f32::from_le_bytes(f.data[start..start + 4].try_into().ok()?);
+ }
+ Some(px)
+}
+
+/// A `RenderTaskBehavior` recording the delivered payloads in order.
+#[derive(Default)]
+struct Recorder {
+ order: Vec<&'static str>,
+ frames: Vec<(i32, i32)>,
+ /// Pixel format of each delivered frame (the forced-format contract).
+ formats: Vec,
+ /// First RGBA pixel of each delivered frame (effect and generated-frame
+ /// observability).
+ first_pixels: Vec