Files
oak-editor/crates/oak-task/tests/loadotio_test.rs
T
Mike-Solar e04ce03058 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.
2026-09-22 20:54:04 +08:00

484 lines
15 KiB
Rust

// Oak Video Editor - Non-Linear Video Editor
// Copyright (C) 2026 Oak Team
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//! 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);
}