test(oak-task, oak-storage): task managers, codec bridge, storage
Task/manager lifecycles, precache and render boundaries, OTIO/FCPXML round trips, and the write-through/library contract tests.
This commit is contained in:
@@ -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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! 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<NodeId>,
|
||||
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::<ClipBlockBehavior>())
|
||||
.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::<TrackBehavior>())
|
||||
{
|
||||
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::<oak_node::block::GapBlockBehavior>())
|
||||
.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::<TrackBehavior>())
|
||||
{
|
||||
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::<TransitionBlockBehavior>())
|
||||
.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::<TrackBehavior>())
|
||||
{
|
||||
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::<TrackBehavior>())
|
||||
{
|
||||
t.kind = kind;
|
||||
}
|
||||
}
|
||||
track
|
||||
}
|
||||
|
||||
fn attach_list(p: &mut Project, sequence: NodeId, kind: TrackType, tracks: Vec<NodeId>) -> 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::<TrackListBehavior>())
|
||||
{
|
||||
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::<SequenceBehavior>())
|
||||
{
|
||||
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<Mutex<Project>> {
|
||||
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<Mutex<Project>>,
|
||||
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<Loaded> {
|
||||
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<Mutex<Project>>) -> Vec<String> {
|
||||
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::<SequenceBehavior>())
|
||||
.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::<TrackBehavior>() {
|
||||
// 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::<ClipBlockBehavior>() {
|
||||
clips += 1;
|
||||
if c.footage.is_some() {
|
||||
footage_links += 1;
|
||||
} else {
|
||||
missing_links += 1;
|
||||
}
|
||||
}
|
||||
if let Some(t) = any.downcast_ref::<TransitionBlockBehavior>() {
|
||||
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::<TrackBehavior>() {
|
||||
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::<ClipBlockBehavior>() {
|
||||
clips += 1;
|
||||
if c.footage.is_some() {
|
||||
footage_links += 1;
|
||||
} else {
|
||||
missing_links += 1;
|
||||
}
|
||||
}
|
||||
if let Some(t) = any.downcast_ref::<TransitionBlockBehavior>() {
|
||||
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());
|
||||
}
|
||||
@@ -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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! 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<Mutex<Project>>) {
|
||||
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<String> = 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();
|
||||
}
|
||||
@@ -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::<TrackListBehavior>())
|
||||
.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<R>(project: &ProjectRef, f: impl FnOnce(&Graph) -> R) -> R {
|
||||
let guard = lock_project(project);
|
||||
f(&guard.graph)
|
||||
}
|
||||
|
||||
fn folder_children(project: &ProjectRef, folder: NodeId) -> Vec<NodeId> {
|
||||
with_graph(project, |g| {
|
||||
g.get(folder)
|
||||
.and_then(|e| e.behavior.as_any())
|
||||
.and_then(|a| a.downcast_ref::<FolderBehavior>())
|
||||
.map(|f| f.children.clone())
|
||||
.unwrap_or_default()
|
||||
})
|
||||
}
|
||||
|
||||
fn bin_folder_of(project: &ProjectRef, node: NodeId) -> Option<NodeId> {
|
||||
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::<SequenceBehavior>())
|
||||
.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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Mutex<Project>>` 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::<oak_node::footage::FootageBehavior>())
|
||||
{
|
||||
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<NodeId>, 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::<ClipBlockBehavior>())
|
||||
{
|
||||
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<VideoParams>| {
|
||||
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>) -> 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::<TrackBehavior>())
|
||||
{
|
||||
t.blocks = blocks;
|
||||
}
|
||||
track
|
||||
}
|
||||
|
||||
fn track_list_with(project: &ProjectRef, kind: TrackType, tracks: Vec<NodeId>) -> 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::<TrackListBehavior>())
|
||||
{
|
||||
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::<SequenceBehavior>())
|
||||
{
|
||||
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::<SequenceBehavior>())
|
||||
{
|
||||
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::<SequenceBehavior>())
|
||||
{
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! 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<String>) {
|
||||
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<String> {
|
||||
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<bool>) -> 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);
|
||||
}
|
||||
@@ -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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! 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<NodeId> {
|
||||
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::<FolderBehavior>())
|
||||
.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());
|
||||
}
|
||||
@@ -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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! 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::<TrackBehavior>()
|
||||
.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::<ClipBlockBehavior>()
|
||||
.expect("clip block")
|
||||
.core
|
||||
.range = TimeRange::new(in_, out);
|
||||
p.graph
|
||||
.get_mut(track)
|
||||
.unwrap()
|
||||
.behavior
|
||||
.as_any_mut()
|
||||
.unwrap()
|
||||
.downcast_mut::<TrackBehavior>()
|
||||
.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<Mutex<Project>> {
|
||||
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::<TrackListBehavior>()
|
||||
.unwrap();
|
||||
list_b.kind = kind;
|
||||
list_b.tracks = tracks;
|
||||
}
|
||||
p.graph
|
||||
.get_mut(edited)
|
||||
.unwrap()
|
||||
.behavior
|
||||
.as_any_mut()
|
||||
.unwrap()
|
||||
.downcast_mut::<SequenceBehavior>()
|
||||
.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::<oak_node::folder::FolderBehavior>()
|
||||
.expect("root folder")
|
||||
.children
|
||||
.push(edited);
|
||||
}
|
||||
project
|
||||
}
|
||||
|
||||
/// Save `project` through the production interchange task.
|
||||
fn save(project: &Arc<Mutex<Project>>, 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<String>, Option<Arc<Mutex<Project>>>) {
|
||||
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<Mutex<Project>>) -> Vec<String> {
|
||||
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::<SequenceBehavior>())
|
||||
.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::<SequenceBehavior>())
|
||||
.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::<SequenceBehavior>())
|
||||
.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"<not-oak>").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<Mutex<Vec<String>>> = 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);
|
||||
}
|
||||
@@ -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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! 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<AtomicBool>,
|
||||
}
|
||||
|
||||
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<AtomicBool>,
|
||||
cancelled: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
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<AtomicBool>,
|
||||
release: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
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<AtomicBool>, Arc<AtomicBool>) {
|
||||
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());
|
||||
}
|
||||
@@ -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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! 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::<SequenceBehavior>())
|
||||
{
|
||||
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);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,324 @@
|
||||
// Oak Video Editor - Non-Linear Video Editor
|
||||
// Copyright (C) 2026 Oak Team
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! Boundary tests for the `Task` base lifecycle: `start` with/without a
|
||||
//! behavior, cancellation before/after the run, error propagation,
|
||||
//! progress/event emission (including the one-shot listener), `reset`,
|
||||
//! `wait_finished`, shared cancel atoms and subscriber state.
|
||||
//!
|
||||
//! Everything is synchronous and deterministic (no sleeps, no fixtures).
|
||||
|
||||
use std::sync::atomic::{AtomicI64, AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use oak_core::cancelatom::CancelAtom;
|
||||
use oak_task::error::{Error, Result};
|
||||
use oak_task::task::{cancelled, system_time_ms, SubscriberState, Task, TaskBehavior, TaskEvent};
|
||||
|
||||
// ---- behaviors --------------------------------------------------------
|
||||
|
||||
/// Returns success without doing anything.
|
||||
struct Noop;
|
||||
|
||||
impl TaskBehavior for Noop {
|
||||
fn run(&mut self, _task: &mut Task) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Records an error on the task and fails.
|
||||
struct Fail;
|
||||
|
||||
impl TaskBehavior for Fail {
|
||||
fn run(&mut self, task: &mut Task) -> Result<()> {
|
||||
task.set_error("disk full");
|
||||
Err(Error::Failed("disk full".to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Asserts cancellation was requested before the run and returns the
|
||||
/// cancellation error.
|
||||
struct CancelledBehavior;
|
||||
|
||||
impl TaskBehavior for CancelledBehavior {
|
||||
fn run(&mut self, task: &mut Task) -> Result<()> {
|
||||
assert!(
|
||||
task.is_cancelled(),
|
||||
"cancel must be visible to the behavior"
|
||||
);
|
||||
Err(cancelled())
|
||||
}
|
||||
}
|
||||
|
||||
/// Cancels itself mid-run and succeeds.
|
||||
struct SelfCancel;
|
||||
|
||||
impl TaskBehavior for SelfCancel {
|
||||
fn run(&mut self, task: &mut Task) -> Result<()> {
|
||||
assert!(!task.is_cancelled());
|
||||
task.cancel();
|
||||
assert!(task.is_cancelled());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Emits out-of-range progress values so the clamping is observable.
|
||||
struct Progressor;
|
||||
|
||||
impl TaskBehavior for Progressor {
|
||||
fn run(&mut self, task: &mut Task) -> Result<()> {
|
||||
task.emit_progress(0.25);
|
||||
task.emit_progress(2.0);
|
||||
task.emit_progress(-1.0);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn task_with(behavior: impl TaskBehavior + Send + 'static) -> Task {
|
||||
let mut task = Task::new("unit", None);
|
||||
task.set_behavior(Box::new(behavior));
|
||||
task
|
||||
}
|
||||
|
||||
// ---- lifecycle --------------------------------------------------------
|
||||
|
||||
/// Given no behavior, `start` succeeds, marks the task finished/succeeded
|
||||
/// and exposes an elapsed duration; the error and cancel state stay clean.
|
||||
#[test]
|
||||
fn start_without_a_behavior_succeeds() {
|
||||
let mut task = Task::new("unit", None);
|
||||
assert_eq!(task.title(), "unit");
|
||||
assert!(!task.is_finished());
|
||||
assert!(!task.succeeded());
|
||||
assert!(!task.is_cancelled());
|
||||
assert!(task.error().is_none());
|
||||
assert!(task.elapsed().is_none(), "not started yet");
|
||||
|
||||
assert!(task.start().is_ok());
|
||||
assert!(task.is_finished());
|
||||
assert!(task.succeeded());
|
||||
assert!(task.elapsed().is_some());
|
||||
assert!(task.error().is_none());
|
||||
}
|
||||
|
||||
/// Given a failing behavior, `start` returns the same error, the task is
|
||||
/// finished but not succeeded, and the recorded error survives.
|
||||
#[test]
|
||||
fn failing_behavior_marks_failed_and_records_the_error() {
|
||||
let mut task = task_with(Fail);
|
||||
let result = task.start();
|
||||
assert!(result.is_err());
|
||||
assert!(task.is_finished());
|
||||
assert!(!task.succeeded());
|
||||
assert_eq!(task.error(), Some("disk full"));
|
||||
}
|
||||
|
||||
/// Given a cancel before `start`, the behavior observes it and the task
|
||||
/// fails with the cancellation error.
|
||||
#[test]
|
||||
fn cancel_before_start_is_visible_to_the_behavior() {
|
||||
let mut task = task_with(CancelledBehavior);
|
||||
task.cancel();
|
||||
assert!(task.is_cancelled());
|
||||
let result = task.start();
|
||||
assert!(matches!(result, Err(Error::Cancelled)));
|
||||
assert!(task.is_finished());
|
||||
assert!(!task.succeeded());
|
||||
}
|
||||
|
||||
/// Given a cancel event hook, cancelling fires it exactly once per call
|
||||
/// (and the atom stays cancelled).
|
||||
#[test]
|
||||
fn cancel_fires_the_event_hook_and_is_idempotent() {
|
||||
let fired = Arc::new(AtomicUsize::new(0));
|
||||
let hook_flag = fired.clone();
|
||||
let mut task = task_with(SelfCancel);
|
||||
task.set_cancel_event(Box::new(move || {
|
||||
hook_flag.fetch_add(1, Ordering::SeqCst);
|
||||
}));
|
||||
|
||||
assert!(task.start().is_ok(), "the behavior itself succeeds");
|
||||
assert_eq!(fired.load(Ordering::SeqCst), 1, "self-cancel fires once");
|
||||
assert!(task.is_cancelled());
|
||||
|
||||
// A later cancel keeps firing the hook (the C++ cancel() contract).
|
||||
task.cancel();
|
||||
assert_eq!(fired.load(Ordering::SeqCst), 2);
|
||||
}
|
||||
|
||||
/// Given a listener, the events arrive as Started, the clamped progress
|
||||
/// values, then Finished — and the listener is dropped after the run
|
||||
/// (one-shot subscription).
|
||||
#[test]
|
||||
fn events_are_ordered_clamped_and_one_shot() {
|
||||
let events: Arc<Mutex<Vec<TaskEvent>>> = Arc::new(Mutex::new(Vec::new()));
|
||||
let sink = events.clone();
|
||||
let mut task = task_with(Progressor);
|
||||
task.set_event_listener(Box::new(move |event| {
|
||||
sink.lock().unwrap_or_else(|e| e.into_inner()).push(event);
|
||||
}));
|
||||
|
||||
assert!(task.start().is_ok());
|
||||
assert_eq!(
|
||||
*events.lock().unwrap_or_else(|e| e.into_inner()),
|
||||
vec![
|
||||
TaskEvent::Started,
|
||||
TaskEvent::Progress(0.25),
|
||||
TaskEvent::Progress(1.0),
|
||||
TaskEvent::Progress(0.0),
|
||||
TaskEvent::Finished,
|
||||
]
|
||||
);
|
||||
|
||||
// One-shot: after `start` the listener is gone.
|
||||
task.emit_progress(0.5);
|
||||
assert_eq!(
|
||||
events.lock().unwrap_or_else(|e| e.into_inner()).len(),
|
||||
5,
|
||||
"a finished task emits nothing to the dropped listener"
|
||||
);
|
||||
}
|
||||
|
||||
/// Given a failed run, `reset` clears every lifecycle flag and error so the
|
||||
/// task can run again.
|
||||
#[test]
|
||||
fn reset_clears_state_and_allows_a_second_run() {
|
||||
let mut task = task_with(Fail);
|
||||
assert!(task.start().is_err());
|
||||
assert!(task.error().is_some());
|
||||
|
||||
task.reset();
|
||||
assert!(!task.is_finished());
|
||||
assert!(!task.succeeded());
|
||||
assert!(task.error().is_none());
|
||||
assert!(task.elapsed().is_none());
|
||||
|
||||
task.set_behavior(Box::new(Noop));
|
||||
assert!(task.start().is_ok());
|
||||
assert!(task.succeeded());
|
||||
}
|
||||
|
||||
/// `wait_finished` returns immediately for a task that was never started or
|
||||
/// is already finished.
|
||||
#[test]
|
||||
fn wait_finished_returns_for_unstarted_and_finished_tasks() {
|
||||
let never = Task::new("never", None);
|
||||
never.wait_finished();
|
||||
assert!(!never.is_finished());
|
||||
|
||||
let mut task = task_with(Noop);
|
||||
assert!(task.start().is_ok());
|
||||
task.wait_finished();
|
||||
assert!(task.is_finished());
|
||||
}
|
||||
|
||||
/// Given a subscriber state, `start` publishes the wall-clock start and
|
||||
/// 1.0/0.0 for success/failure.
|
||||
#[test]
|
||||
fn subscriber_state_tracks_start_time_and_result() {
|
||||
let state = Arc::new(SubscriberState::default());
|
||||
let mut task = task_with(Noop);
|
||||
task.set_subscriber(state.clone());
|
||||
let before = system_time_ms();
|
||||
assert!(task.start().is_ok());
|
||||
assert!(
|
||||
state.start_ms.load(Ordering::SeqCst) >= before,
|
||||
"start timestamp is published"
|
||||
);
|
||||
assert_eq!(state.finished_value.load(Ordering::SeqCst), 1);
|
||||
|
||||
let state = Arc::new(SubscriberState::default());
|
||||
let mut failed = task_with(Fail);
|
||||
failed.set_subscriber(state.clone());
|
||||
assert!(failed.start().is_err());
|
||||
assert_eq!(state.finished_value.load(Ordering::SeqCst), 0);
|
||||
}
|
||||
|
||||
/// A shared cancel atom links an outer task and its inner base: cancelling
|
||||
/// either side is visible from the other.
|
||||
#[test]
|
||||
fn shared_cancel_atom_links_inner_and_outer_tasks() {
|
||||
let atom = Arc::new(CancelAtom::new());
|
||||
let mut outer = Task::new("outer", Some(atom.clone()));
|
||||
assert!(Arc::ptr_eq(&outer.get_cancel_atom(), &atom));
|
||||
outer.set_cancel_atom(atom.clone());
|
||||
|
||||
let mut inner = Task::new("inner", Some(atom));
|
||||
inner.cancel();
|
||||
assert!(outer.is_cancelled());
|
||||
}
|
||||
|
||||
/// Titles and error messages round-trip through their setters.
|
||||
#[test]
|
||||
fn title_and_error_are_settable() {
|
||||
let mut task = Task::new("old", None);
|
||||
task.set_title("new");
|
||||
assert_eq!(task.title(), "new");
|
||||
assert!(task.error().is_none());
|
||||
task.set_error("boom");
|
||||
assert_eq!(task.error(), Some("boom"));
|
||||
}
|
||||
|
||||
/// The module helpers: `cancelled()` is the cancellation error and
|
||||
/// `system_time_ms()` reports a plausible epoch timestamp.
|
||||
#[test]
|
||||
fn helpers_report_cancellation_and_wall_clock() {
|
||||
assert!(matches!(cancelled(), Error::Cancelled));
|
||||
assert!(system_time_ms() > 0);
|
||||
}
|
||||
|
||||
/// `emit_progress` without a listener must not panic or resurrect state;
|
||||
/// a listener attached afterwards observes the clamped value (`2.0` is
|
||||
/// reported as `1.0`).
|
||||
#[test]
|
||||
fn progress_without_a_listener_is_a_no_op() {
|
||||
let mut task = task_with(Progressor);
|
||||
task.emit_progress(0.5);
|
||||
assert!(!task.is_finished(), "emitting progress does not finish");
|
||||
assert!(task.start().is_ok());
|
||||
assert!(task.is_finished());
|
||||
|
||||
// The one-shot listener is gone after the run; a fresh one still
|
||||
// receives the clamped value.
|
||||
let events: Arc<Mutex<Vec<TaskEvent>>> = Arc::new(Mutex::new(Vec::new()));
|
||||
let sink = events.clone();
|
||||
task.set_event_listener(Box::new(move |event| {
|
||||
sink.lock().unwrap_or_else(|e| e.into_inner()).push(event);
|
||||
}));
|
||||
task.emit_progress(2.0);
|
||||
assert_eq!(
|
||||
*events.lock().unwrap_or_else(|e| e.into_inner()),
|
||||
vec![TaskEvent::Progress(1.0)],
|
||||
"the final progress value is clamped"
|
||||
);
|
||||
}
|
||||
|
||||
/// A subscriber is not required for the lifecycle; setting one after a
|
||||
/// reset still receives the next run's values.
|
||||
#[test]
|
||||
fn subscriber_is_optional_and_reusable_after_reset() {
|
||||
let mut task = task_with(Noop);
|
||||
assert!(task.start().is_ok());
|
||||
task.reset();
|
||||
let state = Arc::new(SubscriberState {
|
||||
start_ms: AtomicI64::new(0),
|
||||
finished_value: AtomicI64::new(-1),
|
||||
});
|
||||
task.set_subscriber(state.clone());
|
||||
assert!(task.start().is_ok());
|
||||
assert!(state.start_ms.load(Ordering::SeqCst) > 0);
|
||||
assert_eq!(state.finished_value.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
Reference in New Issue
Block a user