Module-internal object references are Rust types now (values, Arc, Mutex); CHandle remains only at the oakengine C-ABI boundary: - oakundo: the global stack holds UndoStack/UndoCommand values directly (stack token is the static's address) - oaktimeline: marker/workarea boxes carry Arc<Mutex<T>>; commands share the same allocation through Arc clones (readers in oakengine stubs and the app's graphops updated to lock) - oaktask/oakstorage: sessions, write-through bindings and the database backend pass ProjectArc; the Session drops its manual release bookkeeping; nodeutil keeps the CHandle<->Arc boundary conversion (release_project restored for the app) - oakcodec: handle.rs deleted outright (no facade entry needed it); texture/block placeholders are unit structs - oakrender: copier's project handle is an identity u64; alive-count machinery removed; handle.rs is make_owned/get/get_mut only - oakplugin: the instance registry is gone (its unregister key never matched, leaking weak entries); handle.rs is the RefBox boundary type - oaknode/oakcommon: only dead guard/borrow helpers removed; external payload handles (texture/processor) documented as the boundary Flake hunts landed along the way: the audio recording test serializes on the shared manager lock with a normalized state; the autocacher cancel test uses a slow producer so cancellation is deterministic.
947 lines
28 KiB
Rust
947 lines
28 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/>.
|
|
|
|
//! oakstorage contract tests (M10 §4 mapping), calling the public Rust
|
|
//! API: the backend registry, the `StorageBackend` trait, `StorageUri`
|
|
//! and the `Session` shell. The former `ffi.rs`/`bridge::node.rs` C ABI
|
|
//! is gone; save/load now go through `Registry::global().resolve(uri)`.
|
|
|
|
use std::path::{Path, PathBuf};
|
|
use std::sync::{Arc, Mutex};
|
|
|
|
use oakcore_rs::Rational;
|
|
use oaknode::block::ClipBlockBehavior;
|
|
use oaknode::footage::FootageBehavior;
|
|
use oaknode::id::NodeId;
|
|
use oaknode::keyframe::{Interpolation, Keyframe};
|
|
use oaknode::node::NodeCore;
|
|
use oaknode::project::Project;
|
|
use oaknode::sequence::SequenceBehavior;
|
|
use oaknode::track::{TrackBehavior, TrackListBehavior, TrackType};
|
|
use oaknode::value::NodeValue;
|
|
use oakstorage::backend::{LoadResult, StorageBackend};
|
|
use oakstorage::error::{
|
|
OAKSTORAGE_E_FORMAT, OAKSTORAGE_E_INVALID, OAKSTORAGE_E_IO, OAKSTORAGE_E_NO_BACKEND,
|
|
OAKSTORAGE_E_STATE, OAKSTORAGE_OK, OAKSTORAGE_TOO_NEW, OAKSTORAGE_TOO_OLD,
|
|
OAKSTORAGE_UNKNOWN_VERSION,
|
|
};
|
|
use oakstorage::handle::CHandle;
|
|
use oakstorage::nodeutil::{make_project_owned, project_arc};
|
|
use oakstorage::registry::Registry;
|
|
use oakstorage::session::Session;
|
|
use oakstorage::uri::StorageUri;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
fn file_uri(path: &Path) -> String {
|
|
format!("file://{}", path.display())
|
|
}
|
|
|
|
/// A fresh, unique temp directory for one test.
|
|
fn temp_dir(tag: &str) -> PathBuf {
|
|
let dir = std::env::temp_dir().join(format!(
|
|
"oakstorage_it_{}_{}",
|
|
std::process::id(),
|
|
tag
|
|
));
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
dir
|
|
}
|
|
|
|
/// Release an owned handle (refcount 1).
|
|
fn release(h: CHandle) {
|
|
if let Some(release) = h.release {
|
|
unsafe { release(h.ctx) };
|
|
}
|
|
}
|
|
|
|
/// The backend claiming `uri` (Err for none / invalid URI).
|
|
fn backend_for(uri: &str) -> Result<Arc<dyn StorageBackend>, oakstorage::error::Error> {
|
|
let parsed = StorageUri::parse(uri)?;
|
|
Registry::global().resolve(&parsed)
|
|
}
|
|
|
|
/// Save `handle` to `uri` with `options`.
|
|
fn save_handle(handle: CHandle, uri: &str, options: u32) -> oakstorage::error::Result<()> {
|
|
let parsed = StorageUri::parse(uri)?;
|
|
let backend = Registry::global().resolve(&parsed)?;
|
|
backend.save(handle, &parsed, options)
|
|
}
|
|
|
|
/// Save an `Arc<Mutex<Project>>` to `uri`, releasing the wrapper handle.
|
|
fn save_project(
|
|
project: &Arc<Mutex<Project>>,
|
|
uri: &str,
|
|
options: u32,
|
|
) -> oakstorage::error::Result<()> {
|
|
let handle = make_project_owned(project.clone());
|
|
let result = save_handle(handle, uri, options);
|
|
release(handle);
|
|
result
|
|
}
|
|
|
|
/// Open `uri`: resolve the backend, load, wrap the result in a [`Session`].
|
|
/// `Err` is a hard failure (I/O, format, no backend, invalid URI); `Ok`
|
|
/// carries the session plus the version info code (TOO_OLD / TOO_NEW /
|
|
/// UNKNOWN_VERSION) the backend reported.
|
|
fn open(uri: &str) -> oakstorage::error::Result<(Session, i32)> {
|
|
let parsed = StorageUri::parse(uri)?;
|
|
let backend = Registry::global().resolve(&parsed)?;
|
|
let result = backend.load(&parsed)?;
|
|
// The backend hands the project back as a handle (the facade-facing
|
|
// form); the session stores the boxed project directly.
|
|
let project = if result.project.is_null() {
|
|
None
|
|
} else {
|
|
Some(unsafe { project_arc(&result.project) }.unwrap())
|
|
};
|
|
let session = Session::new(parsed, project);
|
|
Ok((session, result.version_info))
|
|
}
|
|
|
|
fn r_to_f(r: Rational) -> f64 {
|
|
r.numerator() as f64 / r.denominator() as f64
|
|
}
|
|
|
|
fn assert_close(a: f64, b: f64) {
|
|
assert!((a - b).abs() < 1e-6, "expected {a} close to {b}");
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// .ove round-trip
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const MATH: &str = "org.olivevideoeditor.Olive.math";
|
|
|
|
/// Build the round-trip fixture: root folder + two math nodes with
|
|
/// values/keyframes/label/color/link/connection + settings.
|
|
fn build_roundtrip_project() -> Arc<Mutex<Project>> {
|
|
let project = Project::new();
|
|
let mut p = project.lock().unwrap();
|
|
p.initialize().unwrap();
|
|
|
|
let (core, behavior) = (oaknode::factory::Factory::global().find(MATH).unwrap().create)();
|
|
let a = p.graph.add_node(core, behavior);
|
|
{
|
|
let e = p.graph.get_mut(a).unwrap();
|
|
e.core.label = "Math A".to_string();
|
|
e.core.override_color = 2;
|
|
e.core.set_standard_value("param_a_in", -1, NodeValue::Float(2.5));
|
|
e.core
|
|
.keyframe_track_mut("param_a_in", -1)
|
|
.set_key(Keyframe {
|
|
time: Rational::new(0, 1),
|
|
value: NodeValue::Float(1.0),
|
|
interpolation: Interpolation::Linear,
|
|
bezier_in: (0.0, 0.0),
|
|
bezier_out: (0.0, 0.0),
|
|
});
|
|
e.core
|
|
.keyframe_track_mut("param_a_in", -1)
|
|
.set_key(Keyframe {
|
|
time: Rational::new(1, 1),
|
|
value: NodeValue::Float(3.0),
|
|
interpolation: Interpolation::Bezier,
|
|
bezier_in: (0.1, 0.2),
|
|
bezier_out: (0.3, 0.4),
|
|
});
|
|
}
|
|
let (core, behavior) = (oaknode::factory::Factory::global().find(MATH).unwrap().create)();
|
|
let b = p.graph.add_node(core, behavior);
|
|
p.graph
|
|
.get_mut(b)
|
|
.unwrap()
|
|
.core
|
|
.set_standard_value("param_a_in", -1, NodeValue::Float(4.0));
|
|
|
|
p.graph.connect(a, b, "param_b_in", -1).unwrap();
|
|
p.graph.link(a, b);
|
|
|
|
p.settings
|
|
.insert("projectname".to_string(), "roundtrip-fixture".to_string());
|
|
drop(p);
|
|
project
|
|
}
|
|
|
|
/// Compare the round-trip-able surface field by field.
|
|
fn assert_roundtrip_fields(orig: &Project, loaded: &Project) {
|
|
assert_eq!(loaded.uuid, orig.uuid, "uuid");
|
|
assert_eq!(loaded.settings, orig.settings, "settings");
|
|
|
|
let o_ids = orig.graph.node_ids();
|
|
let l_ids = loaded.graph.node_ids();
|
|
assert_eq!(l_ids.len(), o_ids.len(), "node count");
|
|
// Slot order is preserved by the writer, so the ids line up.
|
|
let o_types: Vec<&str> = o_ids
|
|
.iter()
|
|
.map(|id| orig.graph.get(*id).unwrap().behavior.type_id())
|
|
.collect();
|
|
let l_types: Vec<&str> = l_ids
|
|
.iter()
|
|
.map(|id| loaded.graph.get(*id).unwrap().behavior.type_id())
|
|
.collect();
|
|
assert_eq!(l_types, o_types, "node types");
|
|
|
|
let a_o = o_ids[1];
|
|
let a_l = l_ids[1];
|
|
let b_o = o_ids[2];
|
|
let b_l = l_ids[2];
|
|
|
|
// Label + color.
|
|
assert_eq!(
|
|
loaded.graph.get(a_l).unwrap().core.label,
|
|
orig.graph.get(a_o).unwrap().core.label,
|
|
"label"
|
|
);
|
|
assert_eq!(
|
|
loaded.graph.get(a_l).unwrap().core.override_color,
|
|
orig.graph.get(a_o).unwrap().core.override_color,
|
|
"color"
|
|
);
|
|
|
|
// Standard values.
|
|
assert_eq!(
|
|
loaded.graph.get(a_l).unwrap().core.standard_value("param_a_in", -1),
|
|
orig.graph.get(a_o).unwrap().core.standard_value("param_a_in", -1),
|
|
"value a"
|
|
);
|
|
assert_eq!(
|
|
loaded.graph.get(b_l).unwrap().core.standard_value("param_a_in", -1),
|
|
orig.graph.get(b_o).unwrap().core.standard_value("param_a_in", -1),
|
|
"value b"
|
|
);
|
|
|
|
// Keyframes: count, times, values, interpolation, bezier handles.
|
|
let keys_o = orig
|
|
.graph
|
|
.get(a_o)
|
|
.unwrap()
|
|
.core
|
|
.keyframe_track("param_a_in", -1)
|
|
.unwrap()
|
|
.keys()
|
|
.to_vec();
|
|
let keys_l = loaded
|
|
.graph
|
|
.get(a_l)
|
|
.unwrap()
|
|
.core
|
|
.keyframe_track("param_a_in", -1)
|
|
.unwrap()
|
|
.keys()
|
|
.to_vec();
|
|
assert_eq!(keys_l.len(), keys_o.len(), "keyframe count");
|
|
for (ko, kl) in keys_o.iter().zip(&keys_l) {
|
|
assert_eq!(kl.time, ko.time, "key time");
|
|
assert_eq!(kl.value.to_double(), ko.value.to_double(), "key value");
|
|
assert_eq!(kl.interpolation, ko.interpolation, "key interpolation");
|
|
assert_eq!(kl.bezier_in, ko.bezier_in, "key bezier in");
|
|
assert_eq!(kl.bezier_out, ko.bezier_out, "key bezier out");
|
|
}
|
|
|
|
// Connection a -> b.param_b_in and the link.
|
|
assert_eq!(
|
|
loaded.graph.connected_output(b_l, "param_b_in", -1),
|
|
Some(a_l),
|
|
"connection"
|
|
);
|
|
assert!(loaded.graph.are_linked(a_l, b_l), "link");
|
|
}
|
|
|
|
#[test]
|
|
fn ove_xml_roundtrip_field_by_field() {
|
|
let dir = temp_dir("ove_roundtrip");
|
|
let path = dir.join("roundtrip.ove");
|
|
let uri = file_uri(&path);
|
|
|
|
let project = build_roundtrip_project();
|
|
save_project(&project, &uri, 0).unwrap();
|
|
|
|
// The file exists and is plain XML.
|
|
let text = std::fs::read_to_string(&path).unwrap();
|
|
assert!(text.starts_with("<project version=\"1\">"), "{text}");
|
|
|
|
let (session, rc) = open(&uri).unwrap();
|
|
assert!(session.project().is_some(), "open failed rc={rc}");
|
|
assert_eq!(rc, OAKSTORAGE_OK);
|
|
assert_eq!(session.uri().to_uri_string(), uri);
|
|
|
|
let loaded = session.project().cloned().unwrap();
|
|
{
|
|
let o = project.lock().unwrap();
|
|
let l = loaded.lock().unwrap();
|
|
assert_roundtrip_fields(&o, &l);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn ove_xml_compress_flag_still_round_trips() {
|
|
// OAKSTORAGE_SAVE_COMPRESS (bit 0) is accepted but not implemented (the
|
|
// oaknode serializer emits plain XML); the file still round-trips.
|
|
let dir = temp_dir("ove_compress");
|
|
let path = dir.join("compressed.ove");
|
|
let uri = file_uri(&path);
|
|
|
|
let project = build_roundtrip_project();
|
|
save_project(&project, &uri, 1).unwrap();
|
|
|
|
let text = std::fs::read_to_string(&path).unwrap();
|
|
assert!(text.starts_with("<project"), "compression must not corrupt the file");
|
|
|
|
let (session, rc) = open(&uri).unwrap();
|
|
assert!(session.project().is_some(), "open failed rc={rc}");
|
|
assert_eq!(rc, OAKSTORAGE_OK);
|
|
let loaded = session.project().cloned().unwrap();
|
|
{
|
|
let o = project.lock().unwrap();
|
|
let l = loaded.lock().unwrap();
|
|
assert_eq!(l.uuid, o.uuid);
|
|
assert_eq!(l.graph.node_count(), o.graph.node_count());
|
|
assert_eq!(l.settings, o.settings);
|
|
}
|
|
}
|
|
|
|
/// The .ove backend preserves the full timeline structure: the
|
|
/// sequence, its track lists, the track's block order, each block's
|
|
/// span, and the clip -> footage references (this surface did not
|
|
/// round-trip before the timeline serialization landed).
|
|
#[test]
|
|
fn ove_xml_timeline_roundtrip() {
|
|
use oaknode::block::ClipBlockBehavior;
|
|
use oaknode::footage::FootageBehavior;
|
|
use oaknode::sequence::SequenceBehavior;
|
|
use oaknode::track::{TrackBehavior, TrackListBehavior, TrackType};
|
|
|
|
let dir = temp_dir("ove_timeline");
|
|
let path = dir.join("timeline.ove");
|
|
let uri = file_uri(&path);
|
|
|
|
let project = build_timeline_project();
|
|
save_project(&project, &uri, 0).unwrap();
|
|
|
|
let (session, rc) = open(&uri).unwrap();
|
|
assert!(session.project().is_some(), "open failed rc={rc}");
|
|
assert_eq!(rc, OAKSTORAGE_OK);
|
|
let loaded = session.project().cloned().unwrap();
|
|
{
|
|
let l = loaded.lock().unwrap();
|
|
assert_imported_timeline(&l);
|
|
}
|
|
|
|
// The bin-level detail too: the sequence keeps its three track
|
|
// lists (video/audio/subtitle) with the right kinds and bases.
|
|
{
|
|
let l = loaded.lock().unwrap();
|
|
let mut seq: Option<(NodeId, Vec<NodeId>)> = None;
|
|
for id in l.graph.node_ids() {
|
|
let entry = l.graph.get(id).unwrap();
|
|
if entry.behavior.type_id() == "org.olivevideoeditor.Olive.sequence" {
|
|
let s = entry
|
|
.behavior
|
|
.as_any()
|
|
.and_then(|a| a.downcast_ref::<SequenceBehavior>())
|
|
.unwrap();
|
|
seq = Some((id, s.track_lists.clone()));
|
|
break;
|
|
}
|
|
}
|
|
let (_seq_id, lists) = seq.expect("sequence present");
|
|
assert_eq!(lists.len(), 3, "video/audio/subtitle lists");
|
|
let vlist = lists[0];
|
|
let list = l
|
|
.graph
|
|
.get(vlist)
|
|
.unwrap()
|
|
.behavior
|
|
.as_any()
|
|
.and_then(|a| a.downcast_ref::<TrackListBehavior>())
|
|
.unwrap();
|
|
assert_eq!(list.kind, TrackType::Video);
|
|
assert_eq!(list.array_base, 0);
|
|
assert_eq!(list.tracks.len(), 1);
|
|
let track = l
|
|
.graph
|
|
.get(list.tracks[0])
|
|
.unwrap()
|
|
.behavior
|
|
.as_any()
|
|
.and_then(|a| a.downcast_ref::<TrackBehavior>())
|
|
.unwrap();
|
|
assert_eq!(track.kind, TrackType::Video);
|
|
assert_eq!(track.blocks.len(), 2);
|
|
let c1 = l
|
|
.graph
|
|
.get(track.blocks[0])
|
|
.unwrap()
|
|
.behavior
|
|
.as_any()
|
|
.and_then(|a| a.downcast_ref::<ClipBlockBehavior>())
|
|
.unwrap();
|
|
assert_close(r_to_f(c1.core.length()), 4.0);
|
|
assert_close(r_to_f(c1.core.media_in), 0.0);
|
|
let f1 = l
|
|
.graph
|
|
.get(c1.footage.unwrap())
|
|
.unwrap()
|
|
.behavior
|
|
.as_any()
|
|
.and_then(|a| a.downcast_ref::<FootageBehavior>())
|
|
.unwrap();
|
|
assert_eq!(f1.filename, "/a/b.mp4");
|
|
let c2 = l
|
|
.graph
|
|
.get(track.blocks[1])
|
|
.unwrap()
|
|
.behavior
|
|
.as_any()
|
|
.and_then(|a| a.downcast_ref::<ClipBlockBehavior>())
|
|
.unwrap();
|
|
assert_close(r_to_f(c2.core.length()), 2.0);
|
|
assert_close(r_to_f(c2.core.media_in), 0.4);
|
|
let f2 = l
|
|
.graph
|
|
.get(c2.footage.unwrap())
|
|
.unwrap()
|
|
.behavior
|
|
.as_any()
|
|
.and_then(|a| a.downcast_ref::<FootageBehavior>())
|
|
.unwrap();
|
|
assert_eq!(f2.filename, "/a/c.mp4");
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Probe dispatch
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn probe_dispatch() {
|
|
assert_eq!(backend_for("file:///tmp/x.ove").unwrap().name(), "ove-xml");
|
|
assert_eq!(
|
|
backend_for("file:///tmp/x.OTIO").unwrap().name(),
|
|
"otio",
|
|
"case-insensitive"
|
|
);
|
|
assert_eq!(backend_for("file:///tmp/x.fcpxml").unwrap().name(), "otio");
|
|
assert_eq!(backend_for("/tmp/x.ove").unwrap().name(), "ove-xml", "bare path");
|
|
|
|
// No backend claims these.
|
|
assert_eq!(
|
|
backend_for("oakdb://user@host/db").err().unwrap().code(),
|
|
OAKSTORAGE_E_NO_BACKEND
|
|
);
|
|
assert_eq!(
|
|
backend_for("file:///tmp/x.txt").err().unwrap().code(),
|
|
OAKSTORAGE_E_NO_BACKEND
|
|
);
|
|
|
|
// Empty URI -> E_INVALID.
|
|
assert_eq!(backend_for("").err().unwrap().code(), OAKSTORAGE_E_INVALID);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Open error paths and version info codes
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn open_error_paths() {
|
|
// Nonexistent file: Err(Io).
|
|
let missing = file_uri(&temp_dir("ove_missing").join("nope.ove"));
|
|
assert_eq!(open(&missing).err().unwrap().code(), OAKSTORAGE_E_IO);
|
|
|
|
// Too-new version header -> TOO_NEW info code, no project.
|
|
let dir = temp_dir("ove_versions");
|
|
let future = dir.join("future.ove");
|
|
std::fs::write(&future, "<olive version=\"999999\"></olive>").unwrap();
|
|
let (session, rc) = open(&file_uri(&future)).unwrap();
|
|
assert!(session.project().is_none());
|
|
assert_eq!(rc, OAKSTORAGE_TOO_NEW);
|
|
|
|
// Corrupt XML -> E_FORMAT.
|
|
let corrupt = dir.join("corrupt.ove");
|
|
std::fs::write(&corrupt, "<project><nodes><node></project>").unwrap();
|
|
let err = open(&file_uri(&corrupt)).err().unwrap();
|
|
assert_eq!(err.code(), OAKSTORAGE_E_FORMAT);
|
|
|
|
// Unparseable garbage -> E_FORMAT (not a version info code).
|
|
let garbage = dir.join("garbage.ove");
|
|
std::fs::write(&garbage, "not xml at all").unwrap();
|
|
let err = open(&file_uri(&garbage)).err().unwrap();
|
|
assert_eq!(err.code(), OAKSTORAGE_E_FORMAT);
|
|
|
|
// Recognized olive root without a version -> UNKNOWN_VERSION.
|
|
let unversioned = dir.join("unversioned.ove");
|
|
std::fs::write(&unversioned, "<olive></olive>").unwrap();
|
|
let (session, rc) = open(&file_uri(&unversioned)).unwrap();
|
|
assert!(session.project().is_none());
|
|
assert_eq!(rc, OAKSTORAGE_UNKNOWN_VERSION);
|
|
|
|
// A known older version loads, reporting TOO_OLD.
|
|
let old = dir.join("old.ove");
|
|
std::fs::write(
|
|
&old,
|
|
"<olive version=\"210528\"><project version=\"1\"><uuid>{old}</uuid><nodes></nodes><settings></settings></project></olive>",
|
|
)
|
|
.unwrap();
|
|
let (session, rc) = open(&file_uri(&old)).unwrap();
|
|
assert!(session.project().is_some(), "old version must load");
|
|
assert_eq!(rc, OAKSTORAGE_TOO_OLD);
|
|
|
|
// Empty URI -> E_INVALID.
|
|
assert_eq!(open("").err().unwrap().code(), OAKSTORAGE_E_INVALID);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Backend pluggability (the database-swap interface proof)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Record of the mock backend's calls.
|
|
static MOCK_SAVED: std::sync::Mutex<Vec<String>> = std::sync::Mutex::new(Vec::new());
|
|
|
|
/// A mock backend claiming `mem://` (the Rust trait shape of the former
|
|
/// C vtable).
|
|
struct MemBackend;
|
|
|
|
impl StorageBackend for MemBackend {
|
|
fn name(&self) -> &'static str {
|
|
"mem-test"
|
|
}
|
|
|
|
fn uri_scheme(&self) -> &'static str {
|
|
"mem"
|
|
}
|
|
|
|
fn can_handle(&self, uri: &StorageUri) -> bool {
|
|
uri.scheme == "mem"
|
|
}
|
|
|
|
fn load(&self, uri: &StorageUri) -> oakstorage::error::Result<LoadResult> {
|
|
MOCK_SAVED
|
|
.lock()
|
|
.unwrap()
|
|
.push(format!("load:{}", uri.to_uri_string()));
|
|
// A real project handle is the "loaded project".
|
|
let project = Project::new();
|
|
Ok(LoadResult::success(make_project_owned(project)))
|
|
}
|
|
|
|
fn save(
|
|
&self,
|
|
_project: CHandle,
|
|
uri: &StorageUri,
|
|
options: u32,
|
|
) -> oakstorage::error::Result<()> {
|
|
MOCK_SAVED
|
|
.lock()
|
|
.unwrap()
|
|
.push(format!("save:{}:options={options}", uri.to_uri_string()));
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn backend_pluggability() {
|
|
// The process-global registry already holds the built-ins; the mock
|
|
// is registered under its own name and unregistered at the end.
|
|
Registry::global().register(Arc::new(MemBackend)).unwrap();
|
|
|
|
// Probe routes through the registered backend.
|
|
assert_eq!(backend_for("mem://x").unwrap().name(), "mem-test");
|
|
|
|
// Open routes through the backend's load.
|
|
let (session, rc) = open("mem://in").unwrap();
|
|
assert!(session.project().is_some(), "open failed rc={rc}");
|
|
assert_eq!(rc, OAKSTORAGE_OK);
|
|
assert!(session.uri().to_uri_string() == "mem://in");
|
|
|
|
// Save routes through the backend's save, options passed through.
|
|
let project = Project::new();
|
|
let handle = make_project_owned(project);
|
|
save_handle(handle, "mem://out", 1).unwrap();
|
|
release(handle);
|
|
{
|
|
let saved = MOCK_SAVED.lock().unwrap();
|
|
assert!(saved.iter().any(|s| s == "load:mem://in"), "{saved:?}");
|
|
assert!(
|
|
saved.iter().any(|s| s == "save:mem://out:options=1"),
|
|
"{saved:?}"
|
|
);
|
|
}
|
|
|
|
// Duplicate registration is rejected.
|
|
assert_eq!(
|
|
Registry::global().register(Arc::new(MemBackend)).err().unwrap().code(),
|
|
OAKSTORAGE_E_STATE
|
|
);
|
|
|
|
// Unregister: probe reports E_NO_BACKEND again.
|
|
assert!(Registry::global().unregister("mem-test").is_ok());
|
|
assert_eq!(
|
|
backend_for("mem://x").err().unwrap().code(),
|
|
OAKSTORAGE_E_NO_BACKEND
|
|
);
|
|
|
|
// Unregister of an unknown name -> E_NOT_FOUND.
|
|
assert_eq!(
|
|
Registry::global().unregister("nope").err().unwrap().code(),
|
|
oakstorage::error::OAKSTORAGE_E_NOT_FOUND
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// otio / fcpxml interchange round-trip
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Build the timeline fixture: a sequence "My Seq" with one video track
|
|
/// carrying two clips (footage /a/b.mp4 and /a/c.mp4).
|
|
fn build_timeline_project() -> Arc<Mutex<Project>> {
|
|
let project = Project::new();
|
|
let mut p = project.lock().unwrap();
|
|
p.initialize().unwrap();
|
|
|
|
let (seq_id, lists) = oakstorage::nodeutil::create_sequence(&mut p.graph);
|
|
p.graph.get_mut(seq_id).unwrap().core.label = "My Seq".to_string();
|
|
|
|
let mut tb = TrackBehavior::new(TrackType::Video);
|
|
tb.track_list = Some(lists[0]);
|
|
let track_id = p.graph.add_node(NodeCore::new(), Box::new(tb));
|
|
|
|
// Clip 1: 100/25 long, media in 0/1, footage /a/b.mp4.
|
|
let foot1 = p
|
|
.graph
|
|
.add_node(NodeCore::new(), Box::new(FootageBehavior::new("/a/b.mp4")));
|
|
let clip1 = {
|
|
let (core, mut behavior) = oaknode::block::clip_create();
|
|
let clip = behavior
|
|
.as_any_mut()
|
|
.and_then(|a| a.downcast_mut::<ClipBlockBehavior>())
|
|
.unwrap();
|
|
clip.core.range = oakcore_rs::TimeRange::new(Rational::new(0, 1), Rational::new(100, 25));
|
|
clip.core.media_in = Rational::new(0, 1);
|
|
clip.core.track = Some(track_id);
|
|
clip.footage = Some(foot1);
|
|
p.graph.add_node(core, behavior)
|
|
};
|
|
|
|
// Clip 2: 50/25 long, media in 10/25, footage /a/c.mp4.
|
|
let foot2 = p
|
|
.graph
|
|
.add_node(NodeCore::new(), Box::new(FootageBehavior::new("/a/c.mp4")));
|
|
let clip2 = {
|
|
let (core, mut behavior) = oaknode::block::clip_create();
|
|
let clip = behavior
|
|
.as_any_mut()
|
|
.and_then(|a| a.downcast_mut::<ClipBlockBehavior>())
|
|
.unwrap();
|
|
clip.core.range = oakcore_rs::TimeRange::new(Rational::new(100, 25), Rational::new(150, 25));
|
|
clip.core.media_in = Rational::new(10, 25);
|
|
clip.core.track = Some(track_id);
|
|
clip.footage = Some(foot2);
|
|
p.graph.add_node(core, behavior)
|
|
};
|
|
|
|
if let Some(entry) = p.graph.get_mut(track_id) {
|
|
entry
|
|
.behavior
|
|
.as_any_mut()
|
|
.and_then(|a| a.downcast_mut::<TrackBehavior>())
|
|
.unwrap()
|
|
.blocks = vec![clip1, clip2];
|
|
}
|
|
if let Some(entry) = p.graph.get_mut(lists[0]) {
|
|
entry
|
|
.behavior
|
|
.as_any_mut()
|
|
.and_then(|a| a.downcast_mut::<TrackListBehavior>())
|
|
.unwrap()
|
|
.tracks = vec![track_id];
|
|
}
|
|
drop(p);
|
|
project
|
|
}
|
|
|
|
/// Assertions over the otio model of a written interchange file.
|
|
fn assert_otio_model(timelines: &[oakotio::Timeline]) {
|
|
assert_eq!(timelines.len(), 1);
|
|
let timeline = &timelines[0];
|
|
assert_eq!(timeline.name(), "My Seq");
|
|
let tracks: Vec<&oakotio::Track> = timeline
|
|
.tracks()
|
|
.children()
|
|
.iter()
|
|
.filter_map(|c| c.as_track())
|
|
.collect();
|
|
assert_eq!(tracks.len(), 1, "one video track");
|
|
let track = tracks[0];
|
|
assert_eq!(track.kind(), "Video");
|
|
let clips: Vec<&oakotio::Clip> = track
|
|
.children()
|
|
.iter()
|
|
.filter_map(|c| c.as_clip())
|
|
.collect();
|
|
assert_eq!(clips.len(), 2, "two clips");
|
|
|
|
assert_eq!(clips[0].name(), "b");
|
|
assert_eq!(
|
|
clips[0]
|
|
.media_reference()
|
|
.unwrap()
|
|
.as_external_reference()
|
|
.unwrap()
|
|
.target_url(),
|
|
"file:///a/b.mp4"
|
|
);
|
|
let r0 = clips[0].source_range().unwrap();
|
|
assert_close(r0.duration().to_seconds(), 4.0);
|
|
assert_close(r0.start_time().to_seconds(), 0.0);
|
|
|
|
assert_eq!(clips[1].name(), "c");
|
|
assert_eq!(
|
|
clips[1]
|
|
.media_reference()
|
|
.unwrap()
|
|
.as_external_reference()
|
|
.unwrap()
|
|
.target_url(),
|
|
"file:///a/c.mp4"
|
|
);
|
|
let r1 = clips[1].source_range().unwrap();
|
|
assert_close(r1.duration().to_seconds(), 2.0);
|
|
assert_close(r1.start_time().to_seconds(), 0.4);
|
|
}
|
|
|
|
/// Assertions over the oaknode project imported from an interchange
|
|
/// file: one sequence, one video track, two clips with footage.
|
|
fn assert_imported_timeline(project: &Project) {
|
|
// Find the sequence and its track lists.
|
|
let mut seq: Option<(NodeId, Vec<NodeId>)> = None;
|
|
for id in project.graph.node_ids() {
|
|
let entry = project.graph.get(id).unwrap();
|
|
if entry.behavior.type_id() == "org.olivevideoeditor.Olive.sequence" {
|
|
let s = entry
|
|
.behavior
|
|
.as_any()
|
|
.and_then(|a| a.downcast_ref::<SequenceBehavior>())
|
|
.unwrap();
|
|
seq = Some((id, s.track_lists.clone()));
|
|
break;
|
|
}
|
|
}
|
|
let (seq_id, lists) = seq.expect("imported project has a sequence");
|
|
assert_eq!(project.graph.get(seq_id).unwrap().core.label, "My Seq");
|
|
|
|
// Video list -> one track.
|
|
let list_entry = project.graph.get(lists[0]).unwrap();
|
|
let list = list_entry
|
|
.behavior
|
|
.as_any()
|
|
.and_then(|a| a.downcast_ref::<TrackListBehavior>())
|
|
.unwrap();
|
|
assert_eq!(list.tracks.len(), 1);
|
|
let track_id = list.tracks[0];
|
|
let track = project
|
|
.graph
|
|
.get(track_id)
|
|
.unwrap()
|
|
.behavior
|
|
.as_any()
|
|
.and_then(|a| a.downcast_ref::<TrackBehavior>())
|
|
.unwrap();
|
|
assert_eq!(track.kind, TrackType::Video);
|
|
assert_eq!(track.blocks.len(), 2);
|
|
|
|
// Clip 1.
|
|
let c1 = project
|
|
.graph
|
|
.get(track.blocks[0])
|
|
.unwrap()
|
|
.behavior
|
|
.as_any()
|
|
.and_then(|a| a.downcast_ref::<ClipBlockBehavior>())
|
|
.expect("block 0 is a clip");
|
|
assert_close(r_to_f(c1.core.length()), 4.0);
|
|
assert_close(r_to_f(c1.core.media_in), 0.0);
|
|
let f1 = c1.footage.expect("clip 1 has footage");
|
|
let filename1 = project
|
|
.graph
|
|
.get(f1)
|
|
.unwrap()
|
|
.behavior
|
|
.as_any()
|
|
.and_then(|a| a.downcast_ref::<FootageBehavior>())
|
|
.unwrap()
|
|
.filename
|
|
.clone();
|
|
assert_eq!(filename1, "/a/b.mp4");
|
|
|
|
// Clip 2.
|
|
let c2 = project
|
|
.graph
|
|
.get(track.blocks[1])
|
|
.unwrap()
|
|
.behavior
|
|
.as_any()
|
|
.and_then(|a| a.downcast_ref::<ClipBlockBehavior>())
|
|
.expect("block 1 is a clip");
|
|
assert_close(r_to_f(c2.core.length()), 2.0);
|
|
assert_close(r_to_f(c2.core.media_in), 0.4);
|
|
let f2 = c2.footage.expect("clip 2 has footage");
|
|
let filename2 = project
|
|
.graph
|
|
.get(f2)
|
|
.unwrap()
|
|
.behavior
|
|
.as_any()
|
|
.and_then(|a| a.downcast_ref::<FootageBehavior>())
|
|
.unwrap()
|
|
.filename
|
|
.clone();
|
|
assert_eq!(filename2, "/a/c.mp4");
|
|
}
|
|
|
|
fn assert_interchange_roundtrip(ext: &str) {
|
|
let dir = temp_dir(&format!("interchange_{ext}"));
|
|
let path = dir.join(format!("timeline.{ext}"));
|
|
let uri = file_uri(&path);
|
|
|
|
let project = build_timeline_project();
|
|
save_project(&project, &uri, 0).unwrap();
|
|
|
|
// The written file parses back through oakotio with clips/tracks.
|
|
match ext {
|
|
"otio" => {
|
|
let text = std::fs::read_to_string(&path).unwrap();
|
|
let root = oakotio::from_json_string(&text).unwrap();
|
|
let timelines: Vec<oakotio::Timeline> = match root {
|
|
oakotio::Serializable::Timeline(t) => vec![t],
|
|
other => panic!("expected a timeline root, got {}", other.schema_name()),
|
|
};
|
|
assert_otio_model(&timelines);
|
|
}
|
|
"fcpxml" => {
|
|
let text = std::fs::read_to_string(&path).unwrap();
|
|
let timelines = oakotio::from_fcpxml_string(&text).unwrap();
|
|
assert_otio_model(&timelines);
|
|
}
|
|
_ => unreachable!(),
|
|
}
|
|
|
|
// And imports back into an equivalent project.
|
|
let (session, rc) = open(&uri).unwrap();
|
|
assert!(session.project().is_some(), "open failed rc={rc}");
|
|
assert_eq!(rc, OAKSTORAGE_OK);
|
|
let loaded = session.project().cloned().unwrap();
|
|
{
|
|
let l = loaded.lock().unwrap();
|
|
assert_imported_timeline(&l);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn otio_roundtrip() {
|
|
assert_interchange_roundtrip("otio");
|
|
}
|
|
|
|
#[test]
|
|
fn fcpxml_roundtrip() {
|
|
assert_interchange_roundtrip("fcpxml");
|
|
}
|
|
|
|
/// A file whose root is neither a timeline nor a collection fails with
|
|
/// E_FORMAT on open.
|
|
#[test]
|
|
fn otio_bad_root_rejected() {
|
|
let dir = temp_dir("otio_badroot");
|
|
let path = dir.join("bad.otio");
|
|
std::fs::write(&path, "{\"OTIO_SCHEMA\": \"NotATimeline.1\", \"x\": 1}").unwrap();
|
|
let err = open(&file_uri(&path)).err().unwrap();
|
|
assert_eq!(err.code(), OAKSTORAGE_E_FORMAT);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// NULL / invalid-handle matrix
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn null_and_invalid_handles() {
|
|
// open with an empty URI -> E_INVALID.
|
|
assert_eq!(open("").err().unwrap().code(), OAKSTORAGE_E_INVALID);
|
|
|
|
// save with a null project handle -> E_INVALID (the project cannot be
|
|
// read out of an empty handle).
|
|
let uri = "file:///tmp/x.ove";
|
|
let backend = backend_for(uri).unwrap();
|
|
let err = backend
|
|
.save(CHandle::null(), &StorageUri::parse(uri).unwrap(), 0)
|
|
.err().unwrap();
|
|
assert_eq!(err.code(), OAKSTORAGE_E_INVALID);
|
|
|
|
// save to an unknown-scheme URI -> E_NO_BACKEND (never reaches a
|
|
// backend).
|
|
let project = Project::new();
|
|
let handle = make_project_owned(project);
|
|
let err = save_handle(handle, "oakdb://x", 0).err().unwrap();
|
|
assert_eq!(err.code(), OAKSTORAGE_E_NO_BACKEND);
|
|
release(handle);
|
|
|
|
// take transfers the project; the second take is empty.
|
|
let dir = temp_dir("null_matrix");
|
|
let path = dir.join("x.ove");
|
|
let uri = file_uri(&path);
|
|
let project = build_roundtrip_project();
|
|
save_project(&project, &uri, 0).unwrap();
|
|
|
|
let (mut session, _) = open(&uri).unwrap();
|
|
let taken = session.take().expect("take transfers the project");
|
|
assert!(session.project().is_none(), "empty shell after take");
|
|
drop(taken);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Session shell lifecycle
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// open -> Session owns the project handle; take hands it out and the
|
|
/// shell stays usable (drop-safe).
|
|
#[test]
|
|
fn session_take_transfers_project() {
|
|
let dir = temp_dir("session");
|
|
let path = dir.join("x.ove");
|
|
let uri = file_uri(&path);
|
|
let project = build_roundtrip_project();
|
|
save_project(&project, &uri, 0).unwrap();
|
|
|
|
let (mut session, rc) = open(&uri).unwrap();
|
|
assert_eq!(rc, OAKSTORAGE_OK);
|
|
assert!(session.project().is_some(), "open counts one project");
|
|
|
|
// Take transfers the project; the session shell stays empty.
|
|
let taken = session.take().unwrap();
|
|
assert!(session.project().is_none(), "take empties the shell");
|
|
drop(taken);
|
|
// Dropping the shell (with the project already taken) is a no-op.
|
|
drop(session);
|
|
|
|
// A second open/take pairing works the same.
|
|
let (mut session, _) = open(&uri).unwrap();
|
|
let taken = session.take().unwrap();
|
|
assert!(session.project().is_none(), "take empties the shell");
|
|
drop(taken);
|
|
}
|