feat(oaktimeline): multicam enable/disable/switch commands, split copies the dependency graph

- oaktimeline::multicam: clip_find_multicam (buffer/tex_in depth-1
  lookup), multicam_enable/disable (rewire sequence<->clip through a
  MultiCamNode), multicam_switch (split-preserving-links at the
  playhead, each half owns an independent multicam copy, linked clips
  switched together) as single undo commands with C++ labels.
- BlockSplitCommand now duplicates the clip's whole dependency graph
  (copy_node_and_dependency_graph_minus_items) instead of just the
  block core, matching the C++ BlockSplitCommand::prepare semantics;
  undo detaches the copied subgraph, redo re-attaches identity-
  preserving.
- oaknode: fix serializer dropping edges from the first-created node
  (ptr=0 was not registered in id_map), restoring sequence_in edge
  round-trips; multicam node and clip wiring serializer round-trip
  tests.
This commit is contained in:
2026-08-18 20:45:01 +08:00
parent 431b9ed2b1
commit 74b080f88a
12 changed files with 1650 additions and 40 deletions
+2
View File
@@ -163,6 +163,8 @@ impl NodeBehavior for FolderBehavior {
/// `enabled_in` (`// CPP-PARITY: folder.cpp:26`).
pub fn create(name: &str) -> (NodeCore, Box<dyn NodeBehavior>) {
let mut core = NodeCore::empty();
// Bin item (C++ `folder.cpp:38` `set_flag(k_is_item)`).
core.flags |= crate::node::flags::IS_ITEM;
let mut child = crate::input::Input::new(
"child_in",
crate::value::ValueType::None,
+3
View File
@@ -289,6 +289,9 @@ impl FootageBehavior {
/// behavior (`// CPP-PARITY: footage.cpp:83`, `viewer.cpp:84`).
pub fn create() -> (NodeCore, Box<dyn NodeBehavior>) {
let mut core = NodeCore::new();
// Bin item (C++ `footage.cpp:61` `set_flag(k_is_item)`): shared,
// never cloned, by dependency-graph copies.
core.flags |= crate::node::flags::IS_ITEM;
let mut file = Input::new(
"file_in",
ValueType::Text,
+67
View File
@@ -617,6 +617,73 @@ impl Graph {
map
}
/// Copy `root` and its upstream dependency graph into fresh nodes (C++
/// `Node::copy_node_and_dependency_graph_minus_items`,
/// `// CPP-PARITY: node.cpp:1141-1229`). "Minus items": nodes carrying
/// [`crate::node::flags::IS_ITEM`] (folders, footage, sequences) are
/// shared, not cloned — copied nodes connect straight to them. Each
/// copy inherits the original's core data and behavior state but NO
/// links (the C++ `Node::copy()` leaves `links_` empty; the split/link
/// commands re-link explicitly), and context positions whose context is
/// itself copied are remapped to the copy. Returns the copy of `root`
/// plus the old -> new id map (items map to themselves), or `None`
/// when `root` is stale or a behavior refuses to duplicate.
pub fn copy_node_and_dependency_graph_minus_items(
&mut self,
root: NodeId,
) -> Option<(NodeId, HashMap<NodeId, NodeId>)> {
let mut created: HashMap<NodeId, NodeId> = HashMap::new();
let copy = self.copy_dependency_graph_internal(root, &mut created)?;
Some((copy, created))
}
/// Recursive worker of
/// [`Graph::copy_node_and_dependency_graph_minus_items`].
fn copy_dependency_graph_internal(
&mut self,
node: NodeId,
created: &mut HashMap<NodeId, NodeId>,
) -> Option<NodeId> {
if let Some(&existing) = created.get(&node) {
return Some(existing);
}
// Clone core + behavior up front (the recursive adds below would
// invalidate any borrow of the entry).
let (mut core, behavior, is_item) = {
let entry = self.get(node)?;
(
entry.core.clone(),
entry.behavior.duplicate(&entry.core)?,
entry.core.flags & crate::node::flags::IS_ITEM != 0,
)
};
if is_item {
// Items are shared: upstream edges connect to the original.
created.insert(node, node);
return Some(node);
}
// The C++ copy carries no links (`Node::copy()` leaves `links_`
// empty).
core.links.clear();
// Context positions: a context that is itself copied points at the
// copy (the C++ maps context children through the created table).
for (context, _, _) in core.context_positions.iter_mut() {
if let Some(&mapped) = created.get(context) {
*context = mapped;
}
}
let copy = self.add_node(core, behavior);
created.insert(node, copy);
// Copy the upstream edges, recursing into each source.
for (from, input, element) in self.input_connections(node) {
let from_copy = self.copy_dependency_graph_internal(from, created)?;
// The C++ asserts every reconnect succeeds; a rejected edge
// (duplicate input, cycle) is skipped here.
self.connect(from_copy, copy, &input, element).ok();
}
Some(copy)
}
/// Drop every edge touching `id` (C++ `disconnect_all`).
fn drop_edges_touching(&mut self, id: NodeId) {
let doomed: Vec<Edge> = self
+4
View File
@@ -120,6 +120,10 @@ pub mod flags {
pub const AUDIO_EFFECT: u64 = 0x4;
/// `k_dont_show_in_create_menu`.
pub const DONT_SHOW_IN_CREATE_MENU: u64 = 0x8;
/// `k_is_item` (C++ `node.h:119`): bin items (folders, footage,
/// sequences) — dependency-graph copies share these instead of
/// cloning them (`// CPP-PARITY: node.cpp:1159,1199`).
pub const IS_ITEM: u64 = 0x10;
}
impl NodeCore {
+1 -1
View File
@@ -36,7 +36,7 @@ mod mathbase;
mod matrix;
mod merge;
mod mosaicfilternode;
mod multicamnode;
pub mod multicamnode;
mod noise;
mod ociobase;
mod ociogradingtransformlinear;
+22
View File
@@ -69,6 +69,28 @@ impl MultiCamNode {
core.standard_value(CURRENT_INPUT, -1).to_double() as i32
}
/// The connected sequence node (C++ `sequence_`).
pub fn sequence(&self) -> Option<NodeId> {
self.sequence
}
/// Set/clear the connected-sequence state (the effects of the C++
/// `InputConnectedEvent`/`InputDisconnectedEvent` on `sequence_in`:
/// store the sequence and toggle the `sequence_type_in` hidden flag).
/// The graph arena does not dispatch behavior events on edge edits, so
/// the commands that edit the `sequence_in` edge call this to keep the
/// behavior state in sync with the graph.
pub fn set_sequence(&mut self, core: &mut NodeCore, sequence: Option<NodeId>) {
if let Some(slot) = core.get_input_mut(SEQUENCE_TYPE_INPUT) {
if sequence.is_some() {
slot.flags &= !crate::input::flags::HIDDEN;
} else {
slot.flags |= crate::input::flags::HIDDEN;
}
}
self.sequence = sequence;
}
/// Number of available sources (C++ `get_source_count()`): the
/// connected sequence's track count when a sequence is set,
/// otherwise the `sources_in` array size.
+3
View File
@@ -77,6 +77,9 @@ impl SequenceBehavior {
/// from a file — the track lists arrive as separate nodes.
pub fn create() -> (NodeCore, Box<dyn NodeBehavior>) {
let mut core = NodeCore::new();
// Bin item (C++ `sequence.cpp:37` `set_flag(k_is_item)`): nested
// sequences are shared, never cloned, by dependency-graph copies.
core.flags |= crate::node::flags::IS_ITEM;
// Viewer parameter streams (C++ ViewerOutput::kVideoParamsInput /
// kAudioParamsInput / kSubtitleParamsInput arrays).
for (id, ty) in [
+31 -5
View File
@@ -624,10 +624,11 @@ fn load_node(
) -> crate::error::Result<NodeId> {
use crate::error::Error;
let type_id = reader.attribute("id").unwrap_or_default();
let ptr = reader
.attribute("ptr")
.and_then(|p| p.parse::<u64>().ok())
.unwrap_or(0);
// The packed identity the file assigns this node (`ptr`). `None` when
// the attribute is absent (foreign/old files); only present `ptr`s are
// registered so an explicit identity of `0` (the first-created node)
// still resolves its outgoing connections.
let ptr = reader.attribute("ptr").and_then(|p| p.parse::<u64>().ok());
// Instantiate the node type; timeline structural types (which are
// not in the factory menu) are reconstructed directly, unknown
@@ -648,7 +649,7 @@ fn load_node(
// The node enters the graph before its body is parsed so deferred
// connections/links can reference it by id.
let id = graph.add_node(core, behavior);
if ptr != 0 {
if let Some(ptr) = ptr {
id_map.insert(ptr, id);
}
@@ -985,6 +986,31 @@ fn resolve_timeline_refs(graph: &mut Graph, id_map: &std::collections::HashMap<u
}
}
}
// MultiCamNode: rebuild the cached sequence reference (C++
// `sequence_`) from the restored `sequence_in` edge — the graph arena
// fires no connect events at load, so the behavior state is synced
// here (also re-applies the `sequence_type_in` unhide of the C++
// `InputConnectedEvent`).
for id in graph.node_ids() {
let seq = graph.connected_output(
id,
crate::nodes::multicamnode::SEQUENCE_INPUT,
-1,
);
if seq.is_none() {
continue;
}
if let Some(entry) = graph.get_mut(id) {
if let Some(mc) = entry
.behavior
.as_any_mut()
.and_then(|a| a.downcast_mut::<crate::nodes::multicamnode::MultiCamNode>())
{
mc.set_sequence(&mut entry.core, seq);
}
}
}
}
/// Fold C++ `child_in` connections into the folder children (the Rust
+202
View File
@@ -869,3 +869,205 @@ fn roundtrip_full_timeline() {
};
assert_eq!(xml, xml2, "re-save is idempotent");
}
/// Standalone `MultiCamNode` round-trip: the `current_in` standard value
/// and the node's type survive save/load (C++
/// `ProjectSerializer::FileRoundTripPreservesMultiCamNode`).
#[test]
fn multicam_node_round_trip_preserves_current_in() {
use oaknode::nodes::multicamnode::{create, CURRENT_INPUT};
use oaknode::project::Project;
use oaknode::value::NodeValue;
let project = Project::new();
let mc_id = {
let mut p = project.lock().unwrap();
let (core, behavior) = create();
let id = p.graph.add_node(core, behavior);
p.graph.get_mut(id).unwrap().core.set_standard_value(
CURRENT_INPUT,
-1,
NodeValue::Combo(2),
);
id
};
let _ = mc_id;
let xml = {
let p = project.lock().unwrap();
oaknode::serializer::save(&p).unwrap()
};
let loaded = oaknode::serializer::load(&xml).unwrap();
let l = loaded.lock().unwrap();
let loaded_mc = l
.graph
.node_ids()
.into_iter()
.find(|id| {
l.graph.get(*id).map(|e| e.behavior.type_id())
== Some("org.olivevideoeditor.Olive.multicam")
})
.expect("loaded project has a MultiCamNode");
// The combo's numeric value survives the round-trip (the value codec
// serializes combo indices as Int — `string_to_value` maps
// `ValueType::Combo` to `NodeValue::Int`).
assert_eq!(
l.graph.get(loaded_mc).unwrap().core.standard_value(CURRENT_INPUT, -1),
NodeValue::Int(2),
"current_in survives the round-trip"
);
}
/// A clip with multicam enabled round-trips: the `current_in` value, the
/// `sequence_in` edge, and the `sequence_type_in` selector all survive.
#[test]
fn multicam_clip_round_trip_preserves_wiring() {
use oakcore_rs::Rational;
use oaknode::block::{clip_create, ClipBlockBehavior};
use oaknode::node::NodeCore;
use oaknode::nodes::multicamnode::{
create, CURRENT_INPUT, SEQUENCE_INPUT, SEQUENCE_TYPE_INPUT,
};
use oaknode::project::Project;
use oaknode::sequence::SequenceBehavior;
use oaknode::track::{TrackBehavior, TrackListBehavior, TrackType};
use oaknode::value::NodeValue;
let project = Project::new();
let (seq_id, clip_id, mc_id) = {
let mut p = project.lock().unwrap();
let (core, behavior) = SequenceBehavior::create();
let seq_id = p.graph.add_node(core, behavior);
let (core, behavior) = TrackListBehavior::create();
let list_id = p.graph.add_node(core, behavior);
let (core, behavior) = (NodeCore::new(), Box::new(TrackBehavior::new(TrackType::Video)));
let track_id = p.graph.add_node(core, behavior);
{
let seq = p.graph.get_mut(seq_id).unwrap();
let s = seq
.behavior
.as_any_mut()
.unwrap()
.downcast_mut::<SequenceBehavior>()
.unwrap();
s.track_lists.push(list_id);
}
let list = p.graph.get_mut(list_id).unwrap();
let l = list
.behavior
.as_any_mut()
.unwrap()
.downcast_mut::<TrackListBehavior>()
.unwrap();
l.sequence = Some(seq_id);
l.tracks.push(track_id);
let track = p.graph.get_mut(track_id).unwrap();
let t = track
.behavior
.as_any_mut()
.unwrap()
.downcast_mut::<TrackBehavior>()
.unwrap();
t.kind = TrackType::Video;
t.track_list = Some(list_id);
// A clip on the track.
let (core, behavior) = clip_create();
let clip_id = p.graph.add_node(core, behavior);
let clip = p.graph.get_mut(clip_id).unwrap();
let c = clip
.behavior
.as_any_mut()
.unwrap()
.downcast_mut::<ClipBlockBehavior>()
.unwrap();
c.core.range = oakcore_rs::TimeRange::new(Rational::new(0, 1), Rational::new(100, 1));
c.core.track = Some(track_id);
let track = p.graph.get_mut(track_id).unwrap();
let t = track
.behavior
.as_any_mut()
.unwrap()
.downcast_mut::<TrackBehavior>()
.unwrap();
t.blocks.push(clip_id);
// The multicam node routed between the sequence and the clip.
let (core, behavior) = create();
let mc_id = p.graph.add_node(core, behavior);
p.graph
.connect(mc_id, clip_id, "tex_in", -1)
.unwrap();
p.graph.connect(seq_id, mc_id, SEQUENCE_INPUT, -1).unwrap();
let mc = p.graph.get_mut(mc_id).unwrap();
mc.core
.set_standard_value(CURRENT_INPUT, -1, NodeValue::Combo(2));
mc.core.set_standard_value(
SEQUENCE_TYPE_INPUT,
-1,
NodeValue::Combo(0),
);
(seq_id, clip_id, mc_id)
};
let _ = (seq_id, clip_id, mc_id);
let xml = {
let p = project.lock().unwrap();
oaknode::serializer::save(&p).unwrap()
};
let loaded = oaknode::serializer::load(&xml).unwrap();
let l = loaded.lock().unwrap();
// The multicam node round-tripped with its current_in and type selector.
let loaded_mc = l
.graph
.node_ids()
.into_iter()
.find(|id| {
l.graph.get(*id).map(|e| e.behavior.type_id())
== Some("org.olivevideoeditor.Olive.multicam")
})
.expect("loaded project has a MultiCamNode");
assert_eq!(
l.graph.get(loaded_mc).unwrap().core.standard_value(CURRENT_INPUT, -1),
NodeValue::Int(2),
"current_in survives"
);
assert_eq!(
l.graph.get(loaded_mc).unwrap().core.standard_value(SEQUENCE_TYPE_INPUT, -1),
NodeValue::Int(0),
"sequence_type_in survives"
);
// The wiring survives: a sequence feeds the multicam's sequence_in and
// the multicam feeds a clip's tex_in.
let seq_src = l
.graph
.connected_output(loaded_mc, SEQUENCE_INPUT, -1)
.expect("sequence_in edge restored");
assert_eq!(
l.graph.get(seq_src).unwrap().behavior.type_id(),
"org.olivevideoeditor.Olive.sequence"
);
let clip_dst = l
.graph
.output_connections(loaded_mc)
.into_iter()
.find(|(_, input, _)| input == "tex_in")
.map(|(to, _, _)| to)
.expect("multicam still feeds a clip's tex_in");
assert_eq!(
l.graph.get(clip_dst).unwrap().behavior.type_id(),
"org.olivevideoeditor.Olive.clipblock"
);
// The multicam behavior's cached sequence reference was rebuilt from
// the restored edge.
let behavior = l.graph.get(loaded_mc).unwrap();
let mc_node = behavior
.behavior
.as_any()
.and_then(|a| a.downcast_ref::<oaknode::nodes::multicamnode::MultiCamNode>())
.expect("loaded node is a MultiCamNode");
assert_eq!(mc_node.sequence(), Some(seq_src));
}
+1
View File
@@ -37,6 +37,7 @@ pub mod common;
pub mod error;
pub mod handle;
pub mod marker;
pub mod multicam;
pub mod undocommon;
pub mod undogeneral;
pub mod undopointer;
File diff suppressed because it is too large Load Diff
+105 -34
View File
@@ -31,12 +31,13 @@
use oakcore_rs::Rational;
use oaknode::graph::NodeEntry;
use oaknode::id::NodeId;
use oakundo::undocommand::UndoCommand;
use crate::util::{
block_add_to_graph, block_in, block_length, block_out, block_remove_from_graph,
block_set_length_and_media_in, block_set_length_and_media_out, block_track,
track_insert_block_after, track_ripple_remove_block, GraphBlockRange, NodeRef,
block_in, block_length, block_out, block_set_length_and_media_in,
block_set_length_and_media_out, block_track, track_insert_block_after,
track_ripple_remove_block, GraphBlockRange, NodeRef,
};
/// `BlockSplitCommand` — split one block at a point
@@ -46,6 +47,15 @@ use crate::util::{
/// the first half `[in, point)`) and anchors the cloned `new_block` at its
/// out-point (it becomes the second half `[point, out)`), inserted right
/// after the original so the track order mirrors the timeline order.
///
/// The second half is a copy of the original block's whole dependency
/// graph (C++ `BlockSplitCommand::prepare` calls `Node::copy_node_in_graph`
/// — `// CPP-PARITY: timelineundosplit.cpp:34-39`): the block's upstream
/// nodes (effects, the MultiCamNode, ...) are cloned too, so the two halves
/// own independent copies. Bin items (footage, sequences — `IS_ITEM`) are
/// shared, exactly as `Graph::copy_node_and_dependency_graph_minus_items`
/// defines. `undo` detaches the whole copied subgraph (not just the second
/// block) and the next `redo` re-attaches it identity-preserving.
pub struct BlockSplitCommand {
/// Block to split.
block: NodeRef,
@@ -53,9 +63,17 @@ pub struct BlockSplitCommand {
point: Rational,
/// Second block created by the split (valid after `redo`).
new_block: Option<NodeRef>,
/// Arena entry of the second block while it is detached from the
/// graph (between `undo` and the next `redo`).
new_block_entry: Option<NodeEntry>,
/// Copy of `block`'s dependency graph created at `prepare`: the node
/// ids of every node the copy introduced (the second block first,
/// then its copied upstream nodes), in copy order.
copied: Vec<NodeId>,
/// Input edges of the copied nodes captured at `prepare`
/// `(from, to, input, element)` — the endpoints are copied ids or
/// shared item ids. Recreated after a re-attach.
copied_edges: Vec<(NodeId, NodeId, String, i32)>,
/// Detached arena entries for [`Self::copied`] (index-parallel), owned
/// by this command between `undo` and the next `redo`.
detached: Vec<Option<NodeEntry>>,
/// Length of `block` before the split, restored on `undo`.
old_length: Rational,
}
@@ -69,34 +87,90 @@ impl BlockSplitCommand {
block,
point,
new_block: None,
new_block_entry: None,
copied: Vec::new(),
copied_edges: Vec::new(),
detached: Vec::new(),
old_length: Rational::new(0, 1),
}
}
/// `prepare`: create the second half by cloning the original block in
/// the project graph (the Rust equivalent of the C++
/// `oaknode_node_copy_in_graph`; the clone happens through the
/// behavior's `duplicate`, which copies the block core too).
/// `prepare`: create the second half by copying the original block's
/// dependency graph in the project graph (the Rust equivalent of the
/// C++ `Node::copy_node_in_graph` +
/// `copy_node_and_dependency_graph_minus_items` — see the module doc).
pub fn prepare(&mut self) {
if self.new_block.is_some() {
return;
}
let id = {
let project = self.block.project.clone();
let project = self.block.project.clone();
let (id, map) = {
let mut p = project.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
let entry = match p.graph.get(self.block.id) {
Some(e) => e,
match p.graph.copy_node_and_dependency_graph_minus_items(self.block.id) {
Some(r) => r,
None => return,
};
let new_core = entry.core.clone();
let new_behavior = match entry.behavior.duplicate(&entry.core) {
Some(b) => b,
None => return,
};
p.graph.add_node(new_core, new_behavior)
}
};
self.new_block = Some(NodeRef::new(self.block.project.clone(), id));
// The copied nodes: the new block plus every fresh copy in the
// map (items map to themselves and are shared, not copied).
let mut copied = vec![id];
for (old, new) in &map {
if old != new && *new != id {
copied.push(*new);
}
}
// Capture the copied nodes' input edges (endpoints are copies or
// shared items) so a re-attach after undo can rewire them.
let copied_edges = {
let p = project.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
let mut edges = Vec::new();
for &node_id in &copied {
for (from, input, element) in p.graph.input_connections(node_id) {
edges.push((from, node_id, input, element));
}
}
edges
};
self.new_block = Some(NodeRef::new(project, id));
self.copied = copied;
self.copied_edges = copied_edges;
self.detached = (0..self.copied.len()).map(|_| None).collect();
}
/// Re-insert the copied subgraph detached by a previous `undo` and
/// rewire its edges. No-op on the first `redo` (the copies are still in
/// the graph from `prepare`).
fn re_attach_subgraph(&mut self) {
if self.detached.iter().all(Option::is_none) {
return;
}
let mut p = self
.block
.project
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
for (i, id) in self.copied.iter().enumerate() {
if let Some(entry) = self.detached[i].take() {
p.graph.add_entry(entry, *id);
}
}
for (from, to, input, element) in &self.copied_edges {
p.graph.connect(*from, *to, input, *element).ok();
}
}
/// Detach the whole copied subgraph (the second block and every node
/// the copy introduced), preserving the entries for the next `redo`.
fn detach_subgraph(&mut self) {
let mut p = self
.block
.project
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
for (i, id) in self.copied.iter().enumerate() {
if self.detached[i].is_none() {
self.detached[i] = p.graph.take_node(*id);
}
}
}
/// `redo`: shrink `block` to the first half, grow `new_block` to the
@@ -111,6 +185,8 @@ impl BlockSplitCommand {
// Create the second half if redo is invoked without a preceding
// prepare() (the vtable command path may call redo directly).
self.prepare();
// Re-attach the copied subgraph if a previous undo detached it.
self.re_attach_subgraph();
// The C++ asserts `point_` lies strictly inside the block; that
// would panic across the FFI boundary, so it is intentionally not
@@ -125,10 +201,6 @@ impl BlockSplitCommand {
let second_half_length = block_out - self.point;
if let Some(new_block) = &self.new_block {
// Re-attach the second half if a previous undo detached it.
if self.new_block_entry.is_some() {
block_add_to_graph(new_block, self.new_block_entry.take());
}
// In-anchored length for the first half keeps the original's in
// point (the C++ `set_length_and_media_in`); the out-anchored
// length for the second half keeps the copy's out point (the
@@ -147,7 +219,8 @@ impl BlockSplitCommand {
// has no transition edges, so both are omitted here.
}
/// `undo`: restore `block`'s original length and remove the second half.
/// `undo`: restore `block`'s original length, remove the second half
/// and detach the whole copied subgraph from the project graph.
pub fn undo(&mut self) {
if let Some(track) = block_track(&self.block) {
// The redo shrank the original from its in point (it became the
@@ -158,14 +231,12 @@ impl BlockSplitCommand {
block_set_length_and_media_in(&self.block, self.old_length);
if let Some(new_block) = &self.new_block {
track_ripple_remove_block(&track, new_block);
// Detach the second half from the graph (the C++ re-parents
// it to the scratch memory manager); the entry is owned by
// this command until the next redo.
if self.new_block_entry.is_none() {
self.new_block_entry = block_remove_from_graph(new_block);
}
}
}
// Detach the copied subgraph (the C++ re-parents it to the scratch
// memory manager); the entries are owned by this command until the
// next redo.
self.detach_subgraph();
// The C++ first moves a previously-moved out transition back onto
// `block` and runs `reconnect_tree_command_`'s undo; the Rust block