Files
oak-editor/crates/oak-storage/tests/otio_backend_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

498 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 `.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());
}