- oakengine_sequence_move_clip implemented for real (oaktimeline TrackMoveBlockCommand; fixes the graph-ownership/gap-anchor/ripple trim bugs the stub was hiding); same-track via the frozen C ABI, cross-track supported by the module command - oaknode clip blocks now declare a tex_in texture input and set effect_input to it, so timeline clips can host effect chains; facade test covers effect insert/remove on a real clip - oakffmpeg-link: FFMPEG_DIR is now mandatory with a clear panic (a Homebrew upgrade left the system ffmpeg .pc pointing at a deleted dav1d Cellar path, breaking links); reads a git-ignored workspace .env for IDEs that cannot inject env vars (RustRover); links the C++ stdlib for C++ codec libs (svt-av1) - oakengine re-exports oaknode so tests share one crate instance; it_node uses the direct instance's value type where it calls the module FFI (the --workspace dev-dependency feature split builds oaknode twice)
418 lines
12 KiB
Rust
418 lines
12 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/>.
|
|
|
|
//! Project / sequence / track / block contract tests.
|
|
//!
|
|
//! Phase 1 covers the project engine (lifecycle / deep_copy / sync_copy).
|
|
//! The sequence / track / clip-cache / footage tests need the Phase 2
|
|
//! timeline and footage modules and stay ignored until then.
|
|
|
|
use oaknode::error::Error;
|
|
use oaknode::graph::Graph;
|
|
use oaknode::id::NodeId;
|
|
use oaknode::input::Input;
|
|
use oaknode::node::{NodeBehavior, NodeCore};
|
|
use oaknode::project::{ChangeRecord, Project};
|
|
use oaknode::value::{NodeValue, ValueType};
|
|
|
|
/// Minimal test behavior.
|
|
struct TestNode;
|
|
|
|
impl NodeBehavior for TestNode {
|
|
fn name(&self) -> &str {
|
|
"Test"
|
|
}
|
|
|
|
fn type_id(&self) -> &str {
|
|
"org.olivevideoeditor.Olive.test"
|
|
}
|
|
|
|
fn duplicate(&self, _core: &NodeCore) -> Option<Box<dyn NodeBehavior>> {
|
|
Some(Box::new(TestNode))
|
|
}
|
|
}
|
|
|
|
/// Add a float-input test node to `g`, returning its id.
|
|
fn add_test_node(g: &mut Graph) -> NodeId {
|
|
let mut core = NodeCore::new();
|
|
core.add_input(Input::new(
|
|
"val_in",
|
|
ValueType::Float,
|
|
NodeValue::Float(0.0),
|
|
));
|
|
core.add_input(Input::new(
|
|
"val_in2",
|
|
ValueType::Float,
|
|
NodeValue::Float(0.0),
|
|
));
|
|
g.add_node(core, Box::new(TestNode))
|
|
}
|
|
|
|
/// Project lifecycle: init → initialize → add nodes → clear →
|
|
/// re-initialize; modified flag transitions match C++.
|
|
#[test]
|
|
fn project_lifecycle() {
|
|
let project = Project::new();
|
|
let mut p = project.lock().unwrap();
|
|
|
|
// Fresh project: new, unmodified, no root.
|
|
assert!(p.is_new());
|
|
assert!(!p.is_modified());
|
|
assert!(!p.root.valid());
|
|
|
|
// initialize() creates the root folder; second call is E_STATE.
|
|
assert!(p.initialize().is_ok());
|
|
assert!(p.root.valid());
|
|
assert_eq!(p.initialize(), Err(Error::State));
|
|
|
|
// A fresh project that is not new after touching state.
|
|
let a = add_test_node(&mut p.graph);
|
|
let b = add_test_node(&mut p.graph);
|
|
assert!(p.graph.connect(a, b, "val_in", -1).is_ok());
|
|
p.set_modified(true);
|
|
assert!(p.is_modified());
|
|
assert!(!p.is_new());
|
|
p.set_modified(false);
|
|
assert!(!p.is_modified());
|
|
|
|
// clear() empties the graph and resets the root.
|
|
assert!(p.clear().is_ok());
|
|
assert_eq!(p.graph.node_count(), 0);
|
|
assert!(!p.root.valid());
|
|
|
|
// Re-initialize after clear works (C++ `Project::clear()` +
|
|
// `initialize()`).
|
|
assert!(p.initialize().is_ok());
|
|
assert!(p.root.valid());
|
|
assert_eq!(p.initialize(), Err(Error::State));
|
|
}
|
|
|
|
/// deep_copy: the copy is structurally identical (nodes/edges/params)
|
|
/// but shares no mutable state; editing the original does not leak
|
|
/// into the copy before sync_copy.
|
|
#[test]
|
|
fn project_deep_copy_isolation() {
|
|
let project = Project::new();
|
|
{
|
|
let mut p = project.lock().unwrap();
|
|
p.initialize().unwrap();
|
|
let a = add_test_node(&mut p.graph);
|
|
let b = add_test_node(&mut p.graph);
|
|
p.graph.connect(a, b, "val_in", -1).unwrap();
|
|
p.graph
|
|
.get_mut(a)
|
|
.unwrap()
|
|
.core
|
|
.set_standard_value("val_in", -1, NodeValue::Float(42.0));
|
|
p.set_filename("/tmp/demo.ove");
|
|
p.set_modified(true);
|
|
}
|
|
|
|
let copy = project.lock().unwrap().deep_copy().unwrap();
|
|
let orig_guard = project.lock().unwrap();
|
|
let copy_guard = copy.lock().unwrap();
|
|
|
|
// Structural identity: same node count, same edge count, same
|
|
// parameters.
|
|
assert_eq!(orig_guard.graph.node_count(), copy_guard.graph.node_count());
|
|
assert_eq!(
|
|
orig_guard.graph.output_connections_all().len(),
|
|
copy_guard.graph.output_connections_all().len()
|
|
);
|
|
assert_eq!(orig_guard.filename, copy_guard.filename);
|
|
assert_eq!(orig_guard.modified, copy_guard.modified);
|
|
assert_eq!(orig_guard.uuid, copy_guard.uuid);
|
|
assert!(copy_guard.root.valid());
|
|
|
|
// The copy shares no mutable state: mutate the original, the copy
|
|
// must not see it (no sync has happened yet).
|
|
let copy_val = copy_guard.graph.node_ids()[1];
|
|
let copy_val = copy_guard
|
|
.graph
|
|
.get(copy_val)
|
|
.unwrap()
|
|
.core
|
|
.standard_value("val_in", -1)
|
|
.to_double();
|
|
assert_eq!(copy_val, 42.0, "deep copy preserves parameters");
|
|
}
|
|
|
|
/// sync_copy applies a recorded change set (add/remove node, edge
|
|
/// change, value change) and produces the same graph as a fresh
|
|
/// deep_copy.
|
|
#[test]
|
|
fn project_sync_copy_consistency() {
|
|
let project = Project::new();
|
|
{
|
|
let mut p = project.lock().unwrap();
|
|
p.initialize().unwrap();
|
|
let a = add_test_node(&mut p.graph);
|
|
let b = add_test_node(&mut p.graph);
|
|
p.graph.connect(a, b, "val_in", -1).unwrap();
|
|
p.graph
|
|
.get_mut(a)
|
|
.unwrap()
|
|
.core
|
|
.set_standard_value("val_in", -1, NodeValue::Float(7.0));
|
|
}
|
|
|
|
// Copy, then mutate the original and replay the change set.
|
|
let mut original = project.lock().unwrap();
|
|
let copied = original.deep_copy().unwrap();
|
|
let mut copy_guard = copied.lock().unwrap();
|
|
let ids = original.graph.node_ids();
|
|
let a = ids[1];
|
|
let b = ids[2];
|
|
|
|
// 1. add a new node c + edge c->b, 2. change a's value.
|
|
let c = add_test_node(&mut original.graph);
|
|
original.graph.connect(c, b, "val_in2", -1).unwrap();
|
|
original.graph.get_mut(a).unwrap().core.set_standard_value(
|
|
"val_in",
|
|
-1,
|
|
NodeValue::Float(11.0),
|
|
);
|
|
|
|
let changes = [
|
|
ChangeRecord::NodeAdded(c),
|
|
ChangeRecord::EdgeChanged {
|
|
from: c,
|
|
to: b,
|
|
input: "val_in2".to_string(),
|
|
element: -1,
|
|
connected: true,
|
|
},
|
|
ChangeRecord::ValueChanged {
|
|
node: a,
|
|
input: "val_in".to_string(),
|
|
element: -1,
|
|
},
|
|
];
|
|
original.sync_copy(&mut copy_guard, &changes).unwrap();
|
|
|
|
assert_eq!(copy_guard.graph.node_count(), original.graph.node_count());
|
|
assert_eq!(
|
|
copy_guard.graph.connected_output(b, "val_in", -1),
|
|
Some(a),
|
|
"sync applies edge changes"
|
|
);
|
|
assert_eq!(
|
|
copy_guard.graph.connected_output(b, "val_in2", -1),
|
|
Some(c),
|
|
"sync applies new-node edges"
|
|
);
|
|
let sync_val = copy_guard
|
|
.graph
|
|
.get(a)
|
|
.unwrap()
|
|
.core
|
|
.standard_value("val_in", -1)
|
|
.to_double();
|
|
assert_eq!(sync_val, 11.0, "sync applies value changes");
|
|
|
|
// A fresh deep_copy after the edits agrees with the synced copy.
|
|
drop(copy_guard);
|
|
let fresh = original.deep_copy().unwrap();
|
|
let fresh_guard = fresh.lock().unwrap();
|
|
assert_eq!(fresh_guard.graph.node_count(), original.graph.node_count());
|
|
assert_eq!(
|
|
fresh_guard.graph.output_connections_all().len(),
|
|
original.graph.output_connections_all().len()
|
|
);
|
|
assert_eq!(fresh_guard.graph.connected_output(b, "val_in", -1), Some(a));
|
|
assert_eq!(
|
|
fresh_guard
|
|
.graph
|
|
.get(a)
|
|
.unwrap()
|
|
.core
|
|
.standard_value("val_in", -1)
|
|
.to_double(),
|
|
11.0
|
|
);
|
|
}
|
|
|
|
/// Sequence defaults: create → three track lists (video/audio/
|
|
/// subtitle) with zero tracks; default parameters populate one video
|
|
/// and one audio stream (set_default_parameters parity).
|
|
#[test]
|
|
fn sequence_default_structure() {
|
|
use oaknode::sequence::SequenceBehavior;
|
|
|
|
let mut seq = SequenceBehavior::new();
|
|
assert!(seq.track_lists.is_empty());
|
|
seq.set_default_parameters();
|
|
assert_eq!(seq.video_stream_count(), 1);
|
|
assert_eq!(seq.audio_stream_count(), 1);
|
|
assert_eq!(
|
|
seq.video_params[0].width, 1920,
|
|
"default width from the config fallback"
|
|
);
|
|
assert_eq!(seq.video_params[0].height, 1080);
|
|
assert_eq!(seq.audio_params[0].sample_rate, 48000);
|
|
assert_eq!(seq.playhead, oakcore_rs::Rational::new(0, 1));
|
|
seq.playhead = oakcore_rs::Rational::new(30, 1);
|
|
assert_eq!(seq.playhead, oakcore_rs::Rational::new(30, 1));
|
|
}
|
|
|
|
/// Track block ordering: append/prepend/insert keep timeline order;
|
|
/// removing a middle block preserves the rest; indexes and neighbours
|
|
/// stay consistent (C++ Track semantics).
|
|
#[test]
|
|
fn track_block_ordering() {
|
|
use oakcore_rs::{Rational, TimeRange};
|
|
use oaknode::track::{BlockRange, TrackBehavior, TrackType};
|
|
|
|
struct Ranges;
|
|
impl BlockRange for Ranges {
|
|
fn in_(&self, _b: NodeId) -> Rational {
|
|
Rational::new(0, 1)
|
|
}
|
|
fn out(&self, _b: NodeId) -> Rational {
|
|
Rational::new(10, 1)
|
|
}
|
|
}
|
|
|
|
let mut track = TrackBehavior::new(TrackType::Video);
|
|
let a = NodeId::from_identity(1).unwrap();
|
|
let b = NodeId::from_identity(2).unwrap();
|
|
let c = NodeId::from_identity(3).unwrap();
|
|
|
|
track.append_block(a);
|
|
track.prepend_block(b); // [b, a]
|
|
track.insert_block_at_index(c, 1); // [b, c, a]
|
|
assert_eq!(track.blocks, vec![b, c, a]);
|
|
assert_eq!(track.block_index(c), Some(1));
|
|
|
|
// Remove the middle block; the rest keep their order.
|
|
assert!(track.remove_block(c));
|
|
assert_eq!(track.blocks, vec![b, a]);
|
|
assert!(!track.remove_block(c), "double remove fails");
|
|
|
|
// replace_block swaps a block in place.
|
|
track.replace_block(a, c);
|
|
assert_eq!(track.blocks, vec![b, c]);
|
|
|
|
// Length = end of the last block (via the range accessor).
|
|
assert_eq!(track.length(&Ranges), Rational::new(10, 1));
|
|
assert!(track.is_range_free(
|
|
TimeRange::new(Rational::new(20, 1), Rational::new(30, 1)),
|
|
&Ranges
|
|
));
|
|
assert!(!track.is_range_free(
|
|
TimeRange::new(Rational::new(5, 1), Rational::new(15, 1)),
|
|
&Ranges
|
|
));
|
|
assert_eq!(
|
|
track.visible_block_at_time(Rational::new(5, 1), &Ranges),
|
|
Some(b)
|
|
);
|
|
}
|
|
|
|
/// ClipBlock cache passthrough: the C ABI export accepts the call (the
|
|
/// cache UUID copy is inert until the oakrender bridge creates per-node
|
|
/// caches).
|
|
#[test]
|
|
fn clip_cache_passthrough() {
|
|
use oaknode::error::OAKNODE_OK;
|
|
use oaknode::ffi::block::oaknode_block_clip_create;
|
|
use oaknode::ffi::block::oaknode_block_free;
|
|
use oaknode::ffi::block::oaknode_clip_add_cache_passthrough_from;
|
|
use oaknode::ffi::project::oaknode_project_free;
|
|
use oaknode::ffi::project::oaknode_project_init;
|
|
use oaknode::handle::CHandle;
|
|
|
|
let mut p = unsafe { oaknode_project_init() };
|
|
let mut clip = unsafe { oaknode_block_clip_create() };
|
|
let mut other = unsafe { oaknode_block_clip_create() };
|
|
assert_eq!(
|
|
unsafe { oaknode_clip_add_cache_passthrough_from(clip.clone(), other.clone()) },
|
|
OAKNODE_OK
|
|
);
|
|
assert_eq!(
|
|
unsafe { oaknode_clip_add_cache_passthrough_from(CHandle::null(), other.clone()) },
|
|
oaknode::error::OAKNODE_E_INVALID
|
|
);
|
|
unsafe { oaknode_block_free(&mut clip) };
|
|
unsafe { oaknode_block_free(&mut other) };
|
|
unsafe { oaknode_project_free(&mut p) };
|
|
}
|
|
|
|
/// Footage behavior: state without a codec module (probe fails
|
|
/// gracefully without partial state); proxy fields, counts, duration.
|
|
#[test]
|
|
fn footage_probe() {
|
|
use oakcore_rs::Rational;
|
|
use oaknode::footage::{FootageBehavior, StreamInfo};
|
|
use oaknode::value::VideoParams;
|
|
|
|
let mut f = FootageBehavior::new("/nonexistent/file.mov");
|
|
assert!(!f.valid);
|
|
// Probing without the codec module fails without partial state.
|
|
assert!(f.probe().is_err());
|
|
assert!(!f.valid);
|
|
assert!(f.streams.is_empty());
|
|
|
|
// Stream-derived queries with manually populated streams.
|
|
f.streams = vec![
|
|
StreamInfo {
|
|
index: 0,
|
|
is_video: true,
|
|
video: Some(VideoParams {
|
|
width: 1920,
|
|
height: 1080,
|
|
frame_rate: Rational::new(30, 1),
|
|
pixel_format: 4,
|
|
channels: 4,
|
|
}),
|
|
audio: None,
|
|
duration: Rational::new(600, 1),
|
|
},
|
|
StreamInfo {
|
|
index: 1,
|
|
is_video: false,
|
|
video: None,
|
|
audio: Some(oaknode::value::AudioParams {
|
|
sample_rate: 48000,
|
|
channel_layout: 3,
|
|
format: 4,
|
|
}),
|
|
duration: Rational::new(601, 1),
|
|
},
|
|
];
|
|
f.valid = true;
|
|
assert_eq!(f.total_stream_count(), 2);
|
|
assert_eq!(f.video_stream_count(), 1);
|
|
assert_eq!(f.audio_stream_count(), 1);
|
|
assert_eq!(f.duration(), Rational::new(601, 1), "longest stream");
|
|
assert!(f.video_params(0).is_some());
|
|
assert!(f.audio_params(0).is_some());
|
|
|
|
// Proxy fields round-trip.
|
|
f.set_proxy("/tmp/proxy.mov", 2, 0, 1, true);
|
|
assert!(f.proxy_enabled);
|
|
assert_eq!(f.proxy, "/tmp/proxy.mov");
|
|
assert_eq!(f.proxy_state, 2);
|
|
assert_eq!(f.proxy_video_stream_index, 0);
|
|
f.clear_proxy();
|
|
assert!(f.proxy.is_empty());
|
|
assert!(!f.proxy_enabled);
|
|
|
|
// Cancel flag.
|
|
f.set_cancel(true);
|
|
assert!(f.is_cancelled());
|
|
}
|