The two BlockCore length setters swapped their anchors relative to the C++ semantics they document, so the ported edit commands produced wrong geometry on the live UI paths: roll edits kept the seam still, slides left negative in-points, and trims wrote the timeline in-point into media_in (playing the wrong media content). Adopt three stored-range primitives in block.rs: - set_length_and_media_out: in fixed, out moves, media untouched (resize, trim-out, gaps growing rightward). - set_length_and_media_in: in fixed, out moves, media_in += old-new (resize-with-media-in, splice right half, ripple trim-in). - set_length_keeping_out (new): out fixed, in moves, media_in += old-new (trim-in body and out-neighbour, slide out-neighbour, ripple trim-in of the trailing block). Point every command at the primitive matching its intent (undopointer, undogeneral, undoripple, undosplit, graphops, cli, nodeops) and fix the two real defects the swap hid: - TrackReplaceBlockWithGapCommand grew a following gap rightward, swallowing whatever followed it: the "dragging one clip moves unrelated clips" regression. The gap now grows leftward over the removed block's span; regression test in domain_test. - The ripple/splice trims now advance media_in instead of rewriting it, and BlockSplitCommand writes both halves' ranges and media explicitly (the second half continues from the split point). Rewrite the KNOWN-SWAP expectations to the correct geometry (roll moves the seam, slide has no negative in-point, insert-gaps grows rightward, resize-with-media-in yields media_in = 20) and add the missing media assertions. TrackSlideCommand documents that the caller positions the sliding blocks (the stored model has no track layout).
1755 lines
56 KiB
Rust
1755 lines
56 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/>.
|
|
|
|
//! Direct oaknode domain operations backing the task module (single-lib
|
|
//! unification).
|
|
//!
|
|
//! The old `src/stubs.rs` stood in for the deleted oaknode / oaktimeline /
|
|
//! oakrender C ABIs with `CHandle`-typed failure paths. Every one of those
|
|
//! stubs is replaced here by real operations over `oaknode` domain types:
|
|
//! a project is `Arc<Mutex<oak_node::project::Project>>` and every node is
|
|
//! an `oak_node::id::NodeId` in that project's `oak_node::graph::Graph`.
|
|
//!
|
|
//! Undo commands follow the oaktimeline `undocommon` pattern: a command
|
|
//! struct is boxed into an `oak_undo::undocommand::UndoCommand` value
|
|
//! ([`box_command`]); `redo_now`/`undo_now` dispatch to it.
|
|
|
|
use std::sync::{Arc, Mutex, MutexGuard};
|
|
|
|
use oak_core::videoparams::VideoParams as CommonVideoParams;
|
|
use oak_core::Rational;
|
|
use oak_node::block::{
|
|
AdjustmentBlockBehavior, BlockCore, ClipBlockBehavior, GapBlockBehavior,
|
|
TransitionBlockBehavior,
|
|
};
|
|
use oak_node::folder::FolderBehavior;
|
|
use oak_node::footage::{FootageBehavior, StreamInfo};
|
|
use oak_node::graph::Graph;
|
|
use oak_node::id::NodeId;
|
|
use oak_node::node::{NodeBehavior, NodeCore};
|
|
use oak_node::project::Project;
|
|
use oak_node::sequence::SequenceBehavior;
|
|
use oak_node::track::{BlockRange, TrackBehavior, TrackListBehavior, TrackRange, TrackType};
|
|
use oak_undo::undocommand::UndoCommand;
|
|
|
|
/// A project, shared like the C++ `olive::Project` smart pointer.
|
|
pub type ProjectRef = Arc<Mutex<Project>>;
|
|
|
|
/// A reference to one node: its owning project and its id there. Replaces
|
|
/// the borrowed `CHandle` node handles of the deleted C ABI.
|
|
pub type NodeRef = (ProjectRef, NodeId);
|
|
|
|
/// `OAKNODE_TRANSITION_CROSS_DISSOLVE` (`include/node/transition.h`);
|
|
/// transition kinds other than cross-dissolve are not modelled.
|
|
pub const TRANSITION_CROSS_DISSOLVE: i32 = 0;
|
|
|
|
/// `OAKNODE_TYPE_TRANSFORM` (`include/node/factory.h`): the transform node
|
|
/// type id used by the OTIO loader.
|
|
pub const TRANSFORM_TYPE_ID: &str = "org.olivevideoeditor.Olive.transform";
|
|
|
|
/// `OAKNODE_TYPE_VOLUME` (`include/node/factory.h`): the volume node type id
|
|
/// used by the OTIO loader.
|
|
pub const VOLUME_TYPE_ID: &str = "org.olivevideoeditor.Olive.volume";
|
|
|
|
/// `OAKNODE_TYPE_SEQUENCE` (`include/node/factory.h`): the sequence type id
|
|
/// (matches `SequenceBehavior::type_id`).
|
|
pub const SEQUENCE_TYPE_ID: &str = "org.olivevideoeditor.Olive.sequence";
|
|
|
|
/// Clip texture/effect input id (oaknode `clip_input::TEXTURE_INPUT`; the
|
|
/// old `OAKNODE_SEQUENCE_TEXTURE_INPUT` value).
|
|
pub const CLIP_TEXTURE_INPUT: &str = "tex_in";
|
|
|
|
/// Transition connection inputs (oaknode `transition_input`).
|
|
pub const TRANSITION_OUT_BLOCK_INPUT: &str = "out_block_in";
|
|
/// Transition connection input (incoming side).
|
|
pub const TRANSITION_IN_BLOCK_INPUT: &str = "in_block_in";
|
|
|
|
/// Block kinds, mirroring the `OAKNODE_BLOCK_*` constants of the deleted
|
|
/// C ABI.
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub enum BlockKind {
|
|
/// Unknown block type (`OAKNODE_BLOCK_OTHER`).
|
|
Other,
|
|
/// Media-bearing clip (`OAKNODE_BLOCK_CLIP`).
|
|
Clip,
|
|
/// Empty span (`OAKNODE_BLOCK_GAP`).
|
|
Gap,
|
|
/// Transition (`OAKNODE_BLOCK_TRANSITION`).
|
|
Transition,
|
|
}
|
|
|
|
/// Lock the project mutex (poisoned locks recover their guard — task code
|
|
/// has no invariants that survive a panic).
|
|
fn lock_project(project: &ProjectRef) -> MutexGuard<'_, Project> {
|
|
project.lock().unwrap_or_else(|e| e.into_inner())
|
|
}
|
|
|
|
/// The shared block core of any block-typed node, or `None` for non-block
|
|
/// nodes / stale ids.
|
|
pub fn block_core_of(graph: &Graph, block: NodeId) -> Option<BlockCore> {
|
|
let entry = graph.get(block)?;
|
|
let any = entry.behavior.as_any()?;
|
|
if let Some(b) = any.downcast_ref::<ClipBlockBehavior>() {
|
|
return Some(b.core.clone());
|
|
}
|
|
if let Some(b) = any.downcast_ref::<GapBlockBehavior>() {
|
|
return Some(b.core.clone());
|
|
}
|
|
if let Some(b) = any.downcast_ref::<TransitionBlockBehavior>() {
|
|
return Some(b.core.clone());
|
|
}
|
|
if let Some(b) = any.downcast_ref::<AdjustmentBlockBehavior>() {
|
|
return Some(b.core.clone());
|
|
}
|
|
None
|
|
}
|
|
|
|
/// Set the shared block core fields of any block-typed node (`track`
|
|
/// ownership aside); false for non-block nodes / stale ids.
|
|
fn set_block_core(graph: &mut Graph, block: NodeId, f: impl FnOnce(&mut BlockCore)) -> bool {
|
|
let Some(entry) = graph.get_mut(block) else {
|
|
return false;
|
|
};
|
|
let Some(any) = entry.behavior.as_any_mut() else {
|
|
return false;
|
|
};
|
|
if let Some(b) = any.downcast_mut::<ClipBlockBehavior>() {
|
|
f(&mut b.core);
|
|
return true;
|
|
}
|
|
if let Some(b) = any.downcast_mut::<GapBlockBehavior>() {
|
|
f(&mut b.core);
|
|
return true;
|
|
}
|
|
if let Some(b) = any.downcast_mut::<TransitionBlockBehavior>() {
|
|
f(&mut b.core);
|
|
return true;
|
|
}
|
|
if let Some(b) = any.downcast_mut::<AdjustmentBlockBehavior>() {
|
|
f(&mut b.core);
|
|
return true;
|
|
}
|
|
false
|
|
}
|
|
|
|
/// The user label of a node (C++ `Node::label`); empty for missing nodes.
|
|
pub fn node_label(project: &ProjectRef, node: NodeId) -> String {
|
|
let guard = lock_project(project);
|
|
match guard.graph.get(node) {
|
|
Some(entry) => entry.core.label.clone(),
|
|
None => String::new(),
|
|
}
|
|
}
|
|
|
|
/// Set the user label of a node (`oaknode_node_set_label`).
|
|
pub fn set_node_label(project: &ProjectRef, node: NodeId, label: &str) {
|
|
let mut guard = lock_project(project);
|
|
if let Some(entry) = guard.graph.get_mut(node) {
|
|
entry.core.label = label.to_string();
|
|
}
|
|
}
|
|
|
|
/// The node's stable type id (`oaknode_node_get_id`; C++ `Node::id()`).
|
|
pub fn node_type_id(project: &ProjectRef, node: NodeId) -> String {
|
|
let guard = lock_project(project);
|
|
match guard.graph.get(node) {
|
|
Some(entry) => entry.behavior.type_id().to_string(),
|
|
None => String::new(),
|
|
}
|
|
}
|
|
|
|
/// True when the node's behavior is the footage type.
|
|
pub fn node_is_footage(graph: &Graph, node: NodeId) -> bool {
|
|
graph
|
|
.get(node)
|
|
.and_then(|e| e.behavior.as_any())
|
|
.and_then(|a| a.downcast_ref::<FootageBehavior>())
|
|
.is_some()
|
|
}
|
|
|
|
/// True when the node's behavior is the sequence type.
|
|
pub fn node_is_sequence(graph: &Graph, node: NodeId) -> bool {
|
|
graph
|
|
.get(node)
|
|
.and_then(|e| e.behavior.as_any())
|
|
.and_then(|a| a.downcast_ref::<SequenceBehavior>())
|
|
.is_some()
|
|
}
|
|
|
|
/// Set a node's position inside a context (the C++
|
|
/// `oaknode_node_set_context_position`; `expanded` replaces the old
|
|
/// `force != 0` flag).
|
|
pub fn node_set_context_position(
|
|
project: &ProjectRef,
|
|
node: NodeId,
|
|
context: NodeId,
|
|
x: f64,
|
|
y: f64,
|
|
expanded: bool,
|
|
) {
|
|
let mut guard = lock_project(project);
|
|
if let Some(entry) = guard.graph.get_mut(node) {
|
|
entry.core.set_context_position(context, x, y, expanded);
|
|
}
|
|
}
|
|
|
|
/// The total length of a footage or sequence node (footage: longest video
|
|
/// stream; sequence: longest track list; `0/1` otherwise) — the domain
|
|
/// replacement for `oaknode_footage_get_video_length` /
|
|
/// `oaknode_sequence_get_length`.
|
|
pub fn node_length(project: &ProjectRef, node: NodeId) -> Rational {
|
|
let guard = lock_project(project);
|
|
let Some(entry) = guard.graph.get(node) else {
|
|
return Rational::new(0, 1);
|
|
};
|
|
let Some(any) = entry.behavior.as_any() else {
|
|
return Rational::new(0, 1);
|
|
};
|
|
if let Some(f) = any.downcast_ref::<FootageBehavior>() {
|
|
return f.video_length();
|
|
}
|
|
if let Some(seq) = any.downcast_ref::<SequenceBehavior>() {
|
|
let blocks = GraphBlockRange {
|
|
graph: &guard.graph,
|
|
};
|
|
let tracks = GraphTrackRange {
|
|
graph: &guard.graph,
|
|
};
|
|
let mut longest = Rational::new(0, 1);
|
|
for list_id in seq.track_lists.iter().filter(|i| i.valid()) {
|
|
let Some(le) = guard.graph.get(*list_id) else {
|
|
continue;
|
|
};
|
|
let Some(list) = le
|
|
.behavior
|
|
.as_any()
|
|
.and_then(|a| a.downcast_ref::<TrackListBehavior>())
|
|
else {
|
|
continue;
|
|
};
|
|
let len = list.total_length(&tracks);
|
|
if len > longest {
|
|
longest = len;
|
|
}
|
|
}
|
|
let _ = blocks;
|
|
if longest > Rational::new(0, 1) {
|
|
return longest;
|
|
}
|
|
return seq.last_length;
|
|
}
|
|
Rational::new(0, 1)
|
|
}
|
|
|
|
/// Block-range access over the project graph (the adapter [`TrackBehavior`]
|
|
/// queries need; `// CPP-PARITY: src/task/...` uses the node graph directly).
|
|
pub struct GraphBlockRange<'a> {
|
|
/// The graph the blocks live in.
|
|
pub graph: &'a Graph,
|
|
}
|
|
|
|
impl BlockRange for GraphBlockRange<'_> {
|
|
fn in_(&self, block: NodeId) -> Rational {
|
|
block_core_of(self.graph, block)
|
|
.map(|c| c.in_())
|
|
.unwrap_or_else(|| Rational::new(0, 1))
|
|
}
|
|
|
|
fn out(&self, block: NodeId) -> Rational {
|
|
block_core_of(self.graph, block)
|
|
.map(|c| c.out())
|
|
.unwrap_or_else(|| Rational::new(0, 1))
|
|
}
|
|
}
|
|
|
|
/// Track-range access over the project graph (the adapter
|
|
/// [`TrackListBehavior::total_length`] needs).
|
|
pub struct GraphTrackRange<'a> {
|
|
/// The graph the tracks live in.
|
|
pub graph: &'a Graph,
|
|
}
|
|
|
|
impl TrackRange for GraphTrackRange<'_> {
|
|
fn length(&self, track: NodeId) -> Rational {
|
|
let Some(entry) = self.graph.get(track) else {
|
|
return Rational::new(0, 1);
|
|
};
|
|
let Some(t) = entry
|
|
.behavior
|
|
.as_any()
|
|
.and_then(|a| a.downcast_ref::<TrackBehavior>())
|
|
else {
|
|
return Rational::new(0, 1);
|
|
};
|
|
t.length(&GraphBlockRange { graph: self.graph })
|
|
}
|
|
}
|
|
|
|
/// Convert an oaknode video-params value to the oak_core `VideoParams`
|
|
/// value type the task code works with (the C ABI marshalled the same
|
|
/// fields).
|
|
fn common_from_node_video(v: oak_node::value::VideoParams) -> CommonVideoParams {
|
|
let mut p = CommonVideoParams::new_basic(
|
|
v.width,
|
|
v.height,
|
|
oak_core::ocioutils::PixelFormat::from_code(v.pixel_format),
|
|
v.channels,
|
|
1,
|
|
1,
|
|
0,
|
|
1,
|
|
);
|
|
p.set_frame_rate(v.frame_rate.numerator() as i32, v.frame_rate.denominator() as i32);
|
|
p
|
|
}
|
|
|
|
/// Convert an oak_core video-params value back to the oaknode value type
|
|
/// (the fields that survive the C ABI marshalling; `video_type`,
|
|
/// `start_time`/`duration` and color fields have no oaknode counterpart).
|
|
fn node_video_from_common(v: &CommonVideoParams) -> oak_node::value::VideoParams {
|
|
oak_node::value::VideoParams {
|
|
width: v.width(),
|
|
height: v.height(),
|
|
frame_rate: Rational::new(v.frame_rate().0 as i64, v.frame_rate().1 as i64),
|
|
pixel_format: v.format().code(),
|
|
channels: v.channel_count(),
|
|
interlaced: false,
|
|
}
|
|
}
|
|
|
|
/// The sequence's `index`th video parameter stream as the oak_core value
|
|
/// type (`oaknode_sequence_get_video_params`).
|
|
pub fn sequence_video_params(
|
|
project: &ProjectRef,
|
|
sequence: NodeId,
|
|
index: usize,
|
|
) -> Option<CommonVideoParams> {
|
|
let guard = lock_project(project);
|
|
let entry = guard.graph.get(sequence)?;
|
|
let seq = entry
|
|
.behavior
|
|
.as_any()?
|
|
.downcast_ref::<SequenceBehavior>()?;
|
|
seq.video_params.get(index).copied().map(common_from_node_video)
|
|
}
|
|
|
|
/// The sequence's audio output parameters `(sample_rate, channel_layout)`
|
|
/// (defaults to 48000 Hz stereo, the sequence defaults).
|
|
pub fn sequence_audio_params(project: &ProjectRef, sequence: NodeId) -> (i32, u64) {
|
|
let guard = lock_project(project);
|
|
let Some(entry) = guard.graph.get(sequence) else {
|
|
return (48000, 0x3);
|
|
};
|
|
let Some(seq) = entry
|
|
.behavior
|
|
.as_any()
|
|
.and_then(|a| a.downcast_ref::<SequenceBehavior>())
|
|
else {
|
|
return (48000, 0x3);
|
|
};
|
|
match seq.audio_params.first() {
|
|
Some(a) => (a.sample_rate, a.channel_layout),
|
|
None => (48000, 0x3),
|
|
}
|
|
}
|
|
|
|
/// Create a new footage node in `project` (optionally pre-set with a
|
|
/// filename; unprobed — call [`footage_set_filename`] to probe). `None` is
|
|
/// no longer reachable (the old null-handle allocation path); the `Option`
|
|
/// keeps the call sites' shape.
|
|
pub fn footage_create(project: &ProjectRef, filename: Option<&str>) -> Option<NodeId> {
|
|
let mut guard = lock_project(project);
|
|
let (core, behavior) = oak_node::footage::FootageBehavior::create();
|
|
let id = guard.graph.add_node(core, behavior);
|
|
if let Some(filename) = filename {
|
|
if let Some(entry) = guard.graph.get_mut(id) {
|
|
if let Some(f) = entry
|
|
.behavior
|
|
.as_any_mut()
|
|
.and_then(|a| a.downcast_mut::<FootageBehavior>())
|
|
{
|
|
f.filename = filename.to_string();
|
|
}
|
|
}
|
|
}
|
|
Some(id)
|
|
}
|
|
|
|
/// Set the footage cancellation flag (the C++ footage cancel atom; the
|
|
/// footage behavior owns its own atom, mirrored from the task's).
|
|
pub fn footage_set_cancelled(project: &ProjectRef, footage: NodeId, cancelled: bool) {
|
|
let mut guard = lock_project(project);
|
|
if let Some(entry) = guard.graph.get_mut(footage) {
|
|
if let Some(f) = entry
|
|
.behavior
|
|
.as_any_mut()
|
|
.and_then(|a| a.downcast_mut::<FootageBehavior>())
|
|
{
|
|
f.set_cancel(cancelled);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Set the footage's filename and re-probe it through the oakcodec decoder
|
|
/// registry (`oaknode_footage_set_filename`); returns the post-probe
|
|
/// validity (the C++ set_filename → Probe() → is_valid chain).
|
|
pub fn footage_set_filename(project: &ProjectRef, footage: NodeId, filename: &str) -> bool {
|
|
let mut guard = lock_project(project);
|
|
let Some(entry) = guard.graph.get_mut(footage) else {
|
|
return false;
|
|
};
|
|
let Some(f) = entry
|
|
.behavior
|
|
.as_any_mut()
|
|
.and_then(|a| a.downcast_mut::<FootageBehavior>())
|
|
else {
|
|
return false;
|
|
};
|
|
f.filename = filename.to_string();
|
|
f.valid = false;
|
|
entry
|
|
.core
|
|
.set_standard_value("file_in", -1, oak_node::value::NodeValue::Text(filename.to_string()));
|
|
if f.probe().is_err() {
|
|
return false;
|
|
}
|
|
f.valid
|
|
}
|
|
|
|
/// Whether the footage's last probe succeeded (`oaknode_footage_is_valid`).
|
|
pub fn footage_is_valid(project: &ProjectRef, footage: NodeId) -> bool {
|
|
let guard = lock_project(project);
|
|
guard
|
|
.graph
|
|
.get(footage)
|
|
.and_then(|e| e.behavior.as_any())
|
|
.and_then(|a| a.downcast_ref::<FootageBehavior>())
|
|
.map(|f| f.valid)
|
|
.unwrap_or(false)
|
|
}
|
|
|
|
/// The footage's absolute filename (`oaknode_footage_filename`).
|
|
pub fn footage_filename(project: &ProjectRef, footage: NodeId) -> String {
|
|
let guard = lock_project(project);
|
|
guard
|
|
.graph
|
|
.get(footage)
|
|
.and_then(|e| e.behavior.as_any())
|
|
.and_then(|a| a.downcast_ref::<FootageBehavior>())
|
|
.map(|f| f.filename.clone())
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
/// Total stream count of the footage (`oaknode_footage_total_stream_count`).
|
|
pub fn footage_total_stream_count(project: &ProjectRef, footage: NodeId) -> usize {
|
|
let guard = lock_project(project);
|
|
guard
|
|
.graph
|
|
.get(footage)
|
|
.and_then(|e| e.behavior.as_any())
|
|
.and_then(|a| a.downcast_ref::<FootageBehavior>())
|
|
.map(|f| f.total_stream_count())
|
|
.unwrap_or(0)
|
|
}
|
|
|
|
/// The footage's `index`th video parameter stream as the oak_core value
|
|
/// type (`oaknode_footage_get_video_params`).
|
|
pub fn footage_video_params(
|
|
project: &ProjectRef,
|
|
footage: NodeId,
|
|
index: usize,
|
|
) -> Option<CommonVideoParams> {
|
|
let guard = lock_project(project);
|
|
let f = guard
|
|
.graph
|
|
.get(footage)?
|
|
.behavior
|
|
.as_any()?
|
|
.downcast_ref::<FootageBehavior>()?;
|
|
f.video_params(index).map(common_from_node_video)
|
|
}
|
|
|
|
/// The footage's first audio stream's sample rate (`oaknode_footage_get_
|
|
/// audio_params`), `None` when unprobed or the file has no audio stream.
|
|
pub fn footage_audio_sample_rate(project: &ProjectRef, footage: NodeId) -> Option<i32> {
|
|
let guard = lock_project(project);
|
|
let f = guard
|
|
.graph
|
|
.get(footage)?
|
|
.behavior
|
|
.as_any()?
|
|
.downcast_ref::<FootageBehavior>()?;
|
|
f.audio_params(0).map(|a| a.sample_rate)
|
|
}
|
|
|
|
/// Overwrite (or create) the footage's `index`th video stream parameters
|
|
/// (`oaknode_footage_set_video_params`).
|
|
pub fn footage_set_video_params(
|
|
project: &ProjectRef,
|
|
footage: NodeId,
|
|
index: usize,
|
|
params: &CommonVideoParams,
|
|
) {
|
|
let mut guard = lock_project(project);
|
|
let Some(entry) = guard.graph.get_mut(footage) else {
|
|
return;
|
|
};
|
|
let Some(f) = entry
|
|
.behavior
|
|
.as_any_mut()
|
|
.and_then(|a| a.downcast_mut::<FootageBehavior>())
|
|
else {
|
|
return;
|
|
};
|
|
let node_params = node_video_from_common(params);
|
|
let mut seen = 0usize;
|
|
let mut found = false;
|
|
for s in f.streams.iter_mut().filter(|s| s.is_video) {
|
|
if seen == index {
|
|
s.video = Some(node_params);
|
|
found = true;
|
|
break;
|
|
}
|
|
seen += 1;
|
|
}
|
|
if !found {
|
|
for _ in seen..=index {
|
|
f.streams.push(StreamInfo {
|
|
index: f.streams.len() as i32,
|
|
is_video: true,
|
|
video: None,
|
|
audio: None,
|
|
duration: Rational::new(0, 1),
|
|
});
|
|
}
|
|
if let Some(s) = f.streams.iter_mut().filter(|s| s.is_video).nth(index) {
|
|
s.video = Some(node_params);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Create a new bin folder node (`oaknode_folder_create`; `None` keeps the
|
|
/// call-site shape of the old null-handle path).
|
|
pub fn folder_create(project: &ProjectRef) -> Option<NodeId> {
|
|
let mut guard = lock_project(project);
|
|
let (core, behavior) = oak_node::folder::create("");
|
|
Some(guard.graph.add_node(core, behavior))
|
|
}
|
|
|
|
/// Create a new sequence node with default parameters
|
|
/// (`oaknode_sequence_create` + `oaknode_sequence_set_default_parameters`;
|
|
/// the domain constructor applies the defaults itself).
|
|
pub fn sequence_create(project: &ProjectRef) -> Option<NodeId> {
|
|
let mut guard = lock_project(project);
|
|
let (core, behavior) = oak_node::sequence::SequenceBehavior::create();
|
|
Some(guard.graph.add_node(core, behavior))
|
|
}
|
|
|
|
/// The sequence's track list for `kind`, creating it on first use (the C++
|
|
/// `Sequence::get_track_list`).
|
|
pub fn sequence_track_list(
|
|
project: &ProjectRef,
|
|
sequence: NodeId,
|
|
kind: TrackType,
|
|
) -> Option<NodeId> {
|
|
let base = match kind {
|
|
TrackType::Video => 0usize,
|
|
TrackType::Audio => 1usize,
|
|
TrackType::Subtitle => 2usize,
|
|
};
|
|
let mut guard = lock_project(project);
|
|
{
|
|
let entry = guard.graph.get(sequence)?;
|
|
let seq = entry
|
|
.behavior
|
|
.as_any()?
|
|
.downcast_ref::<SequenceBehavior>()?;
|
|
if let Some(id) = seq.track_lists.get(base).filter(|i| i.valid()) {
|
|
return Some(*id);
|
|
}
|
|
}
|
|
let list_id = guard.graph.add_node(
|
|
NodeCore::new(),
|
|
Box::new(TrackListBehavior::new(kind)),
|
|
);
|
|
if let Some(le) = guard.graph.get_mut(list_id) {
|
|
if let Some(l) = le
|
|
.behavior
|
|
.as_any_mut()
|
|
.and_then(|a| a.downcast_mut::<TrackListBehavior>())
|
|
{
|
|
l.sequence = Some(sequence);
|
|
l.array_base = base as i32;
|
|
}
|
|
}
|
|
if let Some(entry) = guard.graph.get_mut(sequence) {
|
|
if let Some(seq) = entry
|
|
.behavior
|
|
.as_any_mut()
|
|
.and_then(|a| a.downcast_mut::<SequenceBehavior>())
|
|
{
|
|
while seq.track_lists.len() <= base {
|
|
seq.track_lists.push(NodeId::INVALID);
|
|
}
|
|
seq.track_lists[base] = list_id;
|
|
}
|
|
}
|
|
Some(list_id)
|
|
}
|
|
|
|
/// Number of tracks on the list (`oaknode_tracklist_get_track_count`).
|
|
pub fn tracklist_track_count(project: &ProjectRef, list: NodeId) -> usize {
|
|
let guard = lock_project(project);
|
|
guard
|
|
.graph
|
|
.get(list)
|
|
.and_then(|e| e.behavior.as_any())
|
|
.and_then(|a| a.downcast_ref::<TrackListBehavior>())
|
|
.map(|l| l.tracks.len())
|
|
.unwrap_or(0)
|
|
}
|
|
|
|
/// Track at `index` on the list (`oaknode_tracklist_get_track_at`).
|
|
pub fn tracklist_track_at(project: &ProjectRef, list: NodeId, index: usize) -> Option<NodeId> {
|
|
let guard = lock_project(project);
|
|
guard
|
|
.graph
|
|
.get(list)?
|
|
.behavior
|
|
.as_any()?
|
|
.downcast_ref::<TrackListBehavior>()?
|
|
.track_at(index)
|
|
}
|
|
|
|
/// The track's media type (`oaknode_track_get_type`).
|
|
pub fn track_type(project: &ProjectRef, track: NodeId) -> Option<TrackType> {
|
|
let guard = lock_project(project);
|
|
guard
|
|
.graph
|
|
.get(track)
|
|
.and_then(|e| e.behavior.as_any())
|
|
.and_then(|a| a.downcast_ref::<TrackBehavior>())
|
|
.map(|t| t.kind)
|
|
}
|
|
|
|
/// Number of blocks on the track (`oaknode_track_get_block_count`).
|
|
pub fn track_block_count(project: &ProjectRef, track: NodeId) -> usize {
|
|
let guard = lock_project(project);
|
|
guard
|
|
.graph
|
|
.get(track)
|
|
.and_then(|e| e.behavior.as_any())
|
|
.and_then(|a| a.downcast_ref::<TrackBehavior>())
|
|
.map(|t| t.blocks.len())
|
|
.unwrap_or(0)
|
|
}
|
|
|
|
/// Block at `index` on the track (`oaknode_track_get_block_at`).
|
|
pub fn track_block_at(project: &ProjectRef, track: NodeId, index: usize) -> Option<NodeId> {
|
|
let guard = lock_project(project);
|
|
guard
|
|
.graph
|
|
.get(track)?
|
|
.behavior
|
|
.as_any()?
|
|
.downcast_ref::<TrackBehavior>()?
|
|
.block_at(index)
|
|
}
|
|
|
|
/// The track's total length (end of its last block; `oaknode_track_get_length`).
|
|
pub fn track_length(project: &ProjectRef, track: NodeId) -> Rational {
|
|
let guard = lock_project(project);
|
|
let Some(entry) = guard.graph.get(track) else {
|
|
return Rational::new(0, 1);
|
|
};
|
|
let Some(t) = entry
|
|
.behavior
|
|
.as_any()
|
|
.and_then(|a| a.downcast_ref::<TrackBehavior>())
|
|
else {
|
|
return Rational::new(0, 1);
|
|
};
|
|
t.length(&GraphBlockRange { graph: &guard.graph })
|
|
}
|
|
|
|
/// Append a block to a track and record the track ownership on the block
|
|
/// (`oaknode_track_append_block`); false for stale ids.
|
|
pub fn track_append_block(project: &ProjectRef, track: NodeId, block: NodeId) -> bool {
|
|
let mut guard = lock_project(project);
|
|
let _ = set_block_core(&mut guard.graph, block, |core| core.track = Some(track));
|
|
let Some(entry) = guard.graph.get_mut(track) else {
|
|
return false;
|
|
};
|
|
let Some(t) = entry
|
|
.behavior
|
|
.as_any_mut()
|
|
.and_then(|a| a.downcast_mut::<TrackBehavior>())
|
|
else {
|
|
return false;
|
|
};
|
|
t.append_block(block);
|
|
true
|
|
}
|
|
|
|
/// Create a block node of the given kind (`oaknode_block_clip_create` /
|
|
/// `oaknode_block_gap_create` / `oaknode_block_transition_create`; the
|
|
/// transition kind argument is ignored — only cross-dissolve is modelled).
|
|
pub fn block_create(project: &ProjectRef, kind: BlockKind) -> Option<NodeId> {
|
|
let mut guard = lock_project(project);
|
|
let (core, behavior): (NodeCore, Box<dyn NodeBehavior>) = match kind {
|
|
BlockKind::Clip => oak_node::block::clip_create(),
|
|
BlockKind::Gap => oak_node::block::gap_create(),
|
|
BlockKind::Transition => oak_node::block::transition_create(),
|
|
BlockKind::Other => return None,
|
|
};
|
|
Some(guard.graph.add_node(core, behavior))
|
|
}
|
|
|
|
/// The block's kind (`oaknode_block_get_kind`).
|
|
pub fn block_kind(project: &ProjectRef, block: NodeId) -> BlockKind {
|
|
let guard = lock_project(project);
|
|
let Some(entry) = guard.graph.get(block) else {
|
|
return BlockKind::Other;
|
|
};
|
|
let Some(any) = entry.behavior.as_any() else {
|
|
return BlockKind::Other;
|
|
};
|
|
if any.downcast_ref::<ClipBlockBehavior>().is_some() {
|
|
BlockKind::Clip
|
|
} else if any.downcast_ref::<GapBlockBehavior>().is_some() {
|
|
BlockKind::Gap
|
|
} else if any.downcast_ref::<TransitionBlockBehavior>().is_some() {
|
|
BlockKind::Transition
|
|
} else {
|
|
BlockKind::Other
|
|
}
|
|
}
|
|
|
|
/// Whether the block is an adjustment layer (neither `BlockKind` covers
|
|
/// it: the enum mirrors the deleted C ABI's block types).
|
|
pub fn block_is_adjustment(project: &ProjectRef, block: NodeId) -> bool {
|
|
let guard = lock_project(project);
|
|
guard
|
|
.graph
|
|
.get(block)
|
|
.and_then(|e| e.behavior.as_any())
|
|
.map(|a| a.downcast_ref::<AdjustmentBlockBehavior>().is_some())
|
|
.unwrap_or(false)
|
|
}
|
|
|
|
/// The block's timeline in point (`oaknode_block_get_in`).
|
|
pub fn block_in(project: &ProjectRef, block: NodeId) -> Rational {
|
|
let guard = lock_project(project);
|
|
block_core_of(&guard.graph, block)
|
|
.map(|c| c.in_())
|
|
.unwrap_or_else(|| Rational::new(0, 1))
|
|
}
|
|
|
|
/// The block's timeline length (`oaknode_block_get_length`).
|
|
pub fn block_length(project: &ProjectRef, block: NodeId) -> Rational {
|
|
let guard = lock_project(project);
|
|
block_core_of(&guard.graph, block)
|
|
.map(|c| c.length())
|
|
.unwrap_or_else(|| Rational::new(0, 1))
|
|
}
|
|
|
|
/// The block's media in point (C++ `Block::media_in`).
|
|
pub fn block_media_in(project: &ProjectRef, block: NodeId) -> Rational {
|
|
let guard = lock_project(project);
|
|
block_core_of(&guard.graph, block)
|
|
.map(|c| c.media_in)
|
|
.unwrap_or_else(|| Rational::new(0, 1))
|
|
}
|
|
|
|
/// Set the block's length keeping the in point and the media in point
|
|
/// anchored — the out point (and the media out) shift
|
|
/// (`oaknode_block_set_length_and_media_out`).
|
|
pub fn block_set_length_and_media_out(project: &ProjectRef, block: NodeId, n: i64, d: i64) {
|
|
let mut guard = lock_project(project);
|
|
let _ = set_block_core(&mut guard.graph, block, |core| {
|
|
core.set_length_and_media_out(Rational::new(n, d))
|
|
});
|
|
}
|
|
|
|
/// Set a clip's media in point (`oaknode_clip_set_media_in`).
|
|
pub fn clip_set_media_in(project: &ProjectRef, block: NodeId, n: i64, d: i64) {
|
|
let mut guard = lock_project(project);
|
|
if let Some(entry) = guard.graph.get_mut(block) {
|
|
if let Some(c) = entry
|
|
.behavior
|
|
.as_any_mut()
|
|
.and_then(|a| a.downcast_mut::<ClipBlockBehavior>())
|
|
{
|
|
c.core.media_in = Rational::new(n, d);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Record the footage connected to a clip (the domain model keeps the
|
|
/// clip-footage link in [`ClipBlockBehavior::footage`]).
|
|
pub fn clip_set_footage(project: &ProjectRef, block: NodeId, footage: NodeId) {
|
|
let mut guard = lock_project(project);
|
|
if let Some(entry) = guard.graph.get_mut(block) {
|
|
if let Some(c) = entry
|
|
.behavior
|
|
.as_any_mut()
|
|
.and_then(|a| a.downcast_mut::<ClipBlockBehavior>())
|
|
{
|
|
c.footage = Some(footage);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The clip footage recorded on a clip block, if any.
|
|
pub fn clip_footage(project: &ProjectRef, block: NodeId) -> Option<NodeId> {
|
|
let guard = lock_project(project);
|
|
guard
|
|
.graph
|
|
.get(block)?
|
|
.behavior
|
|
.as_any()?
|
|
.downcast_ref::<ClipBlockBehavior>()?
|
|
.footage
|
|
}
|
|
|
|
/// A transition's in offset (`oaknode_transition_get_in_offset`).
|
|
pub fn transition_in_offset(project: &ProjectRef, block: NodeId) -> Rational {
|
|
let guard = lock_project(project);
|
|
guard
|
|
.graph
|
|
.get(block)
|
|
.and_then(|e| e.behavior.as_any())
|
|
.and_then(|a| a.downcast_ref::<TransitionBlockBehavior>())
|
|
.map(|t| t.in_offset)
|
|
.unwrap_or_else(|| Rational::new(0, 1))
|
|
}
|
|
|
|
/// A transition's out offset (`oaknode_transition_get_out_offset`).
|
|
pub fn transition_out_offset(project: &ProjectRef, block: NodeId) -> Rational {
|
|
let guard = lock_project(project);
|
|
guard
|
|
.graph
|
|
.get(block)
|
|
.and_then(|e| e.behavior.as_any())
|
|
.and_then(|a| a.downcast_ref::<TransitionBlockBehavior>())
|
|
.map(|t| t.out_offset)
|
|
.unwrap_or_else(|| Rational::new(0, 1))
|
|
}
|
|
|
|
/// Set a transition's offsets and derive its length from them
|
|
/// (`oaknode_transition_set_offsets_and_length`).
|
|
pub fn transition_set_offsets_and_length(
|
|
project: &ProjectRef,
|
|
block: NodeId,
|
|
in_n: i64,
|
|
in_d: i64,
|
|
out_n: i64,
|
|
out_d: i64,
|
|
) {
|
|
let mut guard = lock_project(project);
|
|
if let Some(entry) = guard.graph.get_mut(block) {
|
|
if let Some(t) = entry
|
|
.behavior
|
|
.as_any_mut()
|
|
.and_then(|a| a.downcast_mut::<TransitionBlockBehavior>())
|
|
{
|
|
t.in_offset = Rational::new(in_n, in_d);
|
|
t.out_offset = Rational::new(out_n, out_d);
|
|
let length = t.in_offset + t.out_offset;
|
|
t.core.set_length_and_media_out(length);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Connect `from`'s output to `to`'s `input_id` (`oaknode_node_connect`);
|
|
/// false on invalid endpoints/inputs or cycles.
|
|
pub fn node_connect(project: &ProjectRef, from: NodeId, to: NodeId, input_id: &str) -> bool {
|
|
let mut guard = lock_project(project);
|
|
guard.graph.connect(from, to, input_id, -1).is_ok()
|
|
}
|
|
|
|
/// Walk the node's input chain to its footage source (C++
|
|
/// `node_find_input_footage`): footage nodes answer with themselves, clip
|
|
/// blocks answer with their recorded footage, other nodes are walked
|
|
/// upstream along graph edges. Pure graph query — callers already holding
|
|
/// the project lock use this variant.
|
|
pub fn find_input_footage(graph: &Graph, node: NodeId) -> Option<NodeId> {
|
|
if node_is_footage(graph, node) {
|
|
return Some(node);
|
|
}
|
|
if let Some(clip) = graph
|
|
.get(node)
|
|
.and_then(|e| e.behavior.as_any())
|
|
.and_then(|a| a.downcast_ref::<ClipBlockBehavior>())
|
|
{
|
|
if let Some(f) = clip.footage {
|
|
if graph.is_valid(f) {
|
|
return Some(f);
|
|
}
|
|
}
|
|
}
|
|
let mut stack = vec![node];
|
|
let mut seen = std::collections::HashSet::new();
|
|
while let Some(n) = stack.pop() {
|
|
if !seen.insert(n) {
|
|
continue;
|
|
}
|
|
for (from, _, _) in graph.input_connections(n) {
|
|
if node_is_footage(graph, from) {
|
|
return Some(from);
|
|
}
|
|
stack.push(from);
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
/// Locking variant of [`find_input_footage`]
|
|
/// (`oaknode_node_find_input_footage`).
|
|
pub fn node_find_input_footage(project: &ProjectRef, node: NodeId) -> Option<NodeId> {
|
|
let guard = lock_project(project);
|
|
find_input_footage(&guard.graph, node)
|
|
}
|
|
|
|
/// Map an `olive::core::PixelFormat` code to `oak_core::PixelFormat`
|
|
/// (unknown codes map to `Invalid`).
|
|
pub fn pixel_format_from_code(code: i32) -> oak_core::PixelFormat {
|
|
match code {
|
|
-1 => oak_core::PixelFormat::Invalid,
|
|
0 => oak_core::PixelFormat::U8,
|
|
1 => oak_core::PixelFormat::U10,
|
|
2 => oak_core::PixelFormat::U16,
|
|
3 => oak_core::PixelFormat::F16,
|
|
4 => oak_core::PixelFormat::F32,
|
|
_ => oak_core::PixelFormat::Invalid,
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Undo commands
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Command trait for the task-local undo commands (the oakundo command
|
|
/// trait; mirrors the oaktimeline `undocommon::Command` pattern).
|
|
pub use oak_undo::undocommand::Command as TaskCommand;
|
|
|
|
/// Box a task command into an oakundo command value.
|
|
fn box_command<T: TaskCommand + 'static>(cmd: T) -> UndoCommand {
|
|
UndoCommand::new(cmd)
|
|
}
|
|
|
|
/// `FolderAddChildCommand` — add an item to a bin folder
|
|
/// (`oaknode_command_create_folder_add_child`).
|
|
struct FolderAddChildCommand {
|
|
/// The destination folder.
|
|
folder: NodeRef,
|
|
/// The item (footage/folder/sequence) to add.
|
|
child: NodeRef,
|
|
}
|
|
|
|
impl TaskCommand for FolderAddChildCommand {
|
|
fn redo(&mut self) {
|
|
let mut guard = lock_project(&self.folder.0);
|
|
if let Some(entry) = guard.graph.get_mut(self.folder.1) {
|
|
if let Some(f) = entry
|
|
.behavior
|
|
.as_any_mut()
|
|
.and_then(|a| a.downcast_mut::<FolderBehavior>())
|
|
{
|
|
f.add_child(self.child.1);
|
|
}
|
|
}
|
|
if let Some(entry) = guard.graph.get_mut(self.child.1) {
|
|
entry.core.bin_folder = Some(self.folder.1);
|
|
}
|
|
}
|
|
|
|
fn undo(&mut self) {
|
|
let mut guard = lock_project(&self.folder.0);
|
|
if let Some(entry) = guard.graph.get_mut(self.folder.1) {
|
|
if let Some(f) = entry
|
|
.behavior
|
|
.as_any_mut()
|
|
.and_then(|a| a.downcast_mut::<FolderBehavior>())
|
|
{
|
|
f.remove_child(self.child.1);
|
|
}
|
|
}
|
|
if let Some(entry) = guard.graph.get_mut(self.child.1) {
|
|
entry.core.bin_folder = None;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Build the undo command that adds `child` to `folder`
|
|
/// (`oaknode_command_create_folder_add_child`).
|
|
pub fn folder_add_child_command(folder: NodeRef, child: NodeRef) -> UndoCommand {
|
|
box_command(FolderAddChildCommand { folder, child })
|
|
}
|
|
|
|
/// `RemoveNodeCommand` — detach a node from the project graph
|
|
/// (`oaknode_command_create_remove_node`). The node's entry (core +
|
|
/// behavior + generation) is kept between redo and undo; undo re-inserts
|
|
/// it into its original slot. Edges are dropped on redo and not restored
|
|
/// on undo (the C++ command's connections survive; this task module only
|
|
/// removes unconnected nodes).
|
|
struct RemoveNodeCommand {
|
|
/// The owning project.
|
|
project: ProjectRef,
|
|
/// The node to remove.
|
|
node: NodeId,
|
|
/// The detached entry, captured at redo.
|
|
entry: Option<oak_node::graph::NodeEntry>,
|
|
/// The node's bin folder at redo time.
|
|
folder: Option<NodeId>,
|
|
/// The node's child index in that folder.
|
|
folder_index: Option<usize>,
|
|
}
|
|
|
|
impl TaskCommand for RemoveNodeCommand {
|
|
fn redo(&mut self) {
|
|
let mut guard = lock_project(&self.project);
|
|
let bin_folder = guard
|
|
.graph
|
|
.get(self.node)
|
|
.and_then(|e| e.core.bin_folder);
|
|
let index = bin_folder.and_then(|fid| {
|
|
guard
|
|
.graph
|
|
.get(fid)
|
|
.and_then(|e| e.behavior.as_any())
|
|
.and_then(|a| a.downcast_ref::<FolderBehavior>())
|
|
.and_then(|f| f.index_of_child(self.node))
|
|
});
|
|
if let (Some(fid), Some(_)) = (bin_folder, index) {
|
|
if let Some(entry) = guard.graph.get_mut(fid) {
|
|
if let Some(f) = entry
|
|
.behavior
|
|
.as_any_mut()
|
|
.and_then(|a| a.downcast_mut::<FolderBehavior>())
|
|
{
|
|
f.remove_child(self.node);
|
|
}
|
|
}
|
|
}
|
|
let entry = guard.graph.take_node(self.node);
|
|
self.entry = entry;
|
|
self.folder = bin_folder;
|
|
self.folder_index = index;
|
|
}
|
|
|
|
fn undo(&mut self) {
|
|
let Some(entry) = self.entry.take() else {
|
|
return;
|
|
};
|
|
let mut guard = lock_project(&self.project);
|
|
guard.graph.add_entry(entry, self.node);
|
|
if let (Some(fid), Some(index)) = (self.folder, self.folder_index) {
|
|
if let Some(entry) = guard.graph.get_mut(fid) {
|
|
if let Some(f) = entry
|
|
.behavior
|
|
.as_any_mut()
|
|
.and_then(|a| a.downcast_mut::<FolderBehavior>())
|
|
{
|
|
let index = index.min(f.children.len());
|
|
f.children.insert(index, self.node);
|
|
}
|
|
}
|
|
if let Some(entry) = guard.graph.get_mut(self.node) {
|
|
entry.core.bin_folder = Some(fid);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Build the undo command that removes `node` from its project
|
|
/// (`oaknode_command_create_remove_node`).
|
|
pub fn remove_node_command(project: ProjectRef, node: NodeId) -> UndoCommand {
|
|
box_command(RemoveNodeCommand {
|
|
project,
|
|
node,
|
|
entry: None,
|
|
folder: None,
|
|
folder_index: None,
|
|
})
|
|
}
|
|
|
|
/// `TimelineAddTrackCommand` — append a track to a track list
|
|
/// (`TimelineAddTrackCommand::run_immediately` semantics).
|
|
///
|
|
/// Domain replacement for oaktimeline's `TimelineAddTrackCommand`: that
|
|
/// crate is mid-migration to `Arc<Mutex<Project>>` + `NodeId` and still
|
|
/// only exposes `CHandle`-based constructors (whose internals are local
|
|
/// stubs), so oaktask cannot wire domain objects through it yet. This
|
|
/// command performs the same work directly over the oaknode domain model;
|
|
/// once oaktimeline's domain constructor lands, oaktask should switch to
|
|
/// it. The C++ merge-node/automerge branch is not modelled (automerge is
|
|
/// disabled by default and the sequence-input connection is implicit in
|
|
/// the Rust track-list model).
|
|
struct TimelineAddTrackCommand {
|
|
/// The owning project.
|
|
project: ProjectRef,
|
|
/// The target track list.
|
|
list: NodeId,
|
|
/// The created track (set by redo).
|
|
track: Option<NodeId>,
|
|
/// The list's media type (copied at redo).
|
|
kind: TrackType,
|
|
}
|
|
|
|
impl TaskCommand for TimelineAddTrackCommand {
|
|
fn redo(&mut self) {
|
|
let mut guard = lock_project(&self.project);
|
|
let track_index = {
|
|
let Some(entry) = guard.graph.get(self.list) else {
|
|
return;
|
|
};
|
|
let Some(list) = entry
|
|
.behavior
|
|
.as_any()
|
|
.and_then(|a| a.downcast_ref::<TrackListBehavior>())
|
|
else {
|
|
return;
|
|
};
|
|
self.kind = list.kind;
|
|
list.tracks.len() as i32
|
|
};
|
|
let track_id = guard.graph.add_node(
|
|
NodeCore::new(),
|
|
Box::new(TrackBehavior::new(self.kind)),
|
|
);
|
|
if let Some(te) = guard.graph.get_mut(track_id) {
|
|
if let Some(t) = te
|
|
.behavior
|
|
.as_any_mut()
|
|
.and_then(|a| a.downcast_mut::<TrackBehavior>())
|
|
{
|
|
t.index = track_index;
|
|
t.track_list = Some(self.list);
|
|
}
|
|
}
|
|
if let Some(le) = guard.graph.get_mut(self.list) {
|
|
if let Some(l) = le
|
|
.behavior
|
|
.as_any_mut()
|
|
.and_then(|a| a.downcast_mut::<TrackListBehavior>())
|
|
{
|
|
l.tracks.push(track_id);
|
|
}
|
|
}
|
|
self.track = Some(track_id);
|
|
}
|
|
|
|
fn undo(&mut self) {
|
|
let Some(track_id) = self.track.take() else {
|
|
return;
|
|
};
|
|
let mut guard = lock_project(&self.project);
|
|
if let Some(le) = guard.graph.get_mut(self.list) {
|
|
if let Some(l) = le
|
|
.behavior
|
|
.as_any_mut()
|
|
.and_then(|a| a.downcast_mut::<TrackListBehavior>())
|
|
{
|
|
l.tracks.retain(|t| *t != track_id);
|
|
}
|
|
}
|
|
guard.graph.remove_node(track_id);
|
|
}
|
|
}
|
|
|
|
/// Build the undo command that appends a track to `list`
|
|
/// (`oaktimeline_add_track_command`; see [`TimelineAddTrackCommand`] for
|
|
/// the oaktimeline-migration note).
|
|
pub fn add_track_command(project: ProjectRef, list: NodeId) -> UndoCommand {
|
|
box_command(TimelineAddTrackCommand {
|
|
project,
|
|
list,
|
|
track: None,
|
|
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();
|
|
}
|
|
}
|