From 74b080f88a107e31806a176e65670faaeda9fa73 Mon Sep 17 00:00:00 2001 From: Mike Solar Date: Tue, 18 Aug 2026 20:45:01 +0800 Subject: [PATCH] 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. --- crates/oaknode/src/folder.rs | 2 + crates/oaknode/src/footage.rs | 3 + crates/oaknode/src/graph.rs | 67 ++ crates/oaknode/src/node.rs | 4 + crates/oaknode/src/nodes/mod.rs | 2 +- crates/oaknode/src/nodes/multicamnode.rs | 22 + crates/oaknode/src/sequence.rs | 3 + crates/oaknode/src/serializer.rs | 36 +- crates/oaknode/tests/serializer_test.rs | 202 ++++ crates/oaktimeline/src/lib.rs | 1 + crates/oaktimeline/src/multicam.rs | 1209 ++++++++++++++++++++++ crates/oaktimeline/src/undosplit.rs | 139 ++- 12 files changed, 1650 insertions(+), 40 deletions(-) create mode 100644 crates/oaktimeline/src/multicam.rs diff --git a/crates/oaknode/src/folder.rs b/crates/oaknode/src/folder.rs index 88f864f3a..4388eae26 100644 --- a/crates/oaknode/src/folder.rs +++ b/crates/oaknode/src/folder.rs @@ -163,6 +163,8 @@ impl NodeBehavior for FolderBehavior { /// `enabled_in` (`// CPP-PARITY: folder.cpp:26`). pub fn create(name: &str) -> (NodeCore, Box) { 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, diff --git a/crates/oaknode/src/footage.rs b/crates/oaknode/src/footage.rs index 8cede2b84..d358e73bc 100644 --- a/crates/oaknode/src/footage.rs +++ b/crates/oaknode/src/footage.rs @@ -289,6 +289,9 @@ impl FootageBehavior { /// behavior (`// CPP-PARITY: footage.cpp:83`, `viewer.cpp:84`). pub fn create() -> (NodeCore, Box) { 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, diff --git a/crates/oaknode/src/graph.rs b/crates/oaknode/src/graph.rs index fa622fc3e..e662ba356 100644 --- a/crates/oaknode/src/graph.rs +++ b/crates/oaknode/src/graph.rs @@ -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)> { + let mut created: HashMap = 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, + ) -> Option { + 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 = self diff --git a/crates/oaknode/src/node.rs b/crates/oaknode/src/node.rs index 58e5b3f4f..1d3fda80d 100644 --- a/crates/oaknode/src/node.rs +++ b/crates/oaknode/src/node.rs @@ -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 { diff --git a/crates/oaknode/src/nodes/mod.rs b/crates/oaknode/src/nodes/mod.rs index eada687a6..a1e277b36 100644 --- a/crates/oaknode/src/nodes/mod.rs +++ b/crates/oaknode/src/nodes/mod.rs @@ -36,7 +36,7 @@ mod mathbase; mod matrix; mod merge; mod mosaicfilternode; -mod multicamnode; +pub mod multicamnode; mod noise; mod ociobase; mod ociogradingtransformlinear; diff --git a/crates/oaknode/src/nodes/multicamnode.rs b/crates/oaknode/src/nodes/multicamnode.rs index 97de77d4c..56c6c6046 100644 --- a/crates/oaknode/src/nodes/multicamnode.rs +++ b/crates/oaknode/src/nodes/multicamnode.rs @@ -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 { + 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) { + 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. diff --git a/crates/oaknode/src/sequence.rs b/crates/oaknode/src/sequence.rs index 6e23fa0c6..902d7bf4e 100644 --- a/crates/oaknode/src/sequence.rs +++ b/crates/oaknode/src/sequence.rs @@ -77,6 +77,9 @@ impl SequenceBehavior { /// from a file — the track lists arrive as separate nodes. pub fn create() -> (NodeCore, Box) { 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 [ diff --git a/crates/oaknode/src/serializer.rs b/crates/oaknode/src/serializer.rs index 64a599053..5c6f9ad13 100644 --- a/crates/oaknode/src/serializer.rs +++ b/crates/oaknode/src/serializer.rs @@ -624,10 +624,11 @@ fn load_node( ) -> crate::error::Result { use crate::error::Error; let type_id = reader.attribute("id").unwrap_or_default(); - let ptr = reader - .attribute("ptr") - .and_then(|p| p.parse::().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::().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()) + { + mc.set_sequence(&mut entry.core, seq); + } + } + } } /// Fold C++ `child_in` connections into the folder children (the Rust diff --git a/crates/oaknode/tests/serializer_test.rs b/crates/oaknode/tests/serializer_test.rs index c4798c9b9..5516b86a5 100644 --- a/crates/oaknode/tests/serializer_test.rs +++ b/crates/oaknode/tests/serializer_test.rs @@ -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::() + .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::() + .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::() + .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::() + .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::() + .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::()) + .expect("loaded node is a MultiCamNode"); + assert_eq!(mc_node.sequence(), Some(seq_src)); +} diff --git a/crates/oaktimeline/src/lib.rs b/crates/oaktimeline/src/lib.rs index 7fd45ac8d..37f38e940 100644 --- a/crates/oaktimeline/src/lib.rs +++ b/crates/oaktimeline/src/lib.rs @@ -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; diff --git a/crates/oaktimeline/src/multicam.rs b/crates/oaktimeline/src/multicam.rs new file mode 100644 index 000000000..20409ef51 --- /dev/null +++ b/crates/oaktimeline/src/multicam.rs @@ -0,0 +1,1209 @@ +// 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 . + +//! Multi-camera editing commands (C++ +//! `app/widget/multicam/multicamwidget.cpp::Switch`, +//! `app/widget/timelinewidget/timelinewidget.cpp::multicam_enabled_triggered`). +//! +//! A multi-cam clip is a clip whose effect input (`tex_in`, C++ +//! `buffer_in`) is fed by a [`MultiCamNode`] (`oaknode::nodes::multicamnode`). +//! The node's `sequence_in` connects to the sequence whose track list +//! supplies the angles; `sources_in` carries no real edges — the render +//! resolves element `i` to track `i` of the selected track list +//! (`sequence_type_in`). +//! +//! The three operations: +//! +//! * [`multicam_enable`] inserts a fresh `MultiCamNode` between the +//! sequence and each clip: every input the sequence fed along the clip's +//! dependency chain is re-routed through the node, `sequence_in` is +//! connected, and `sequence_type_in` mirrors the clip's track type. +//! * [`multicam_disable`] is the reverse: the node's outputs are re-wired +//! straight to the sequence and the node is removed. +//! * [`multicam_switch`] changes `current_in`. With `split_clip` and the +//! playhead strictly inside the clip, the clip (and its linked blocks) +//! is first split preserving links — each new half owns an independent +//! copy of the clip's dependency graph (incl. its own `MultiCamNode`), +//! so the halves after the playhead switch source while the halves +//! before keep theirs. +//! +//! Every operation is exposed as a single undo command ([`UndoCommand`]); +//! the caller pushes it with the labels +//! [`ENABLE_LABEL`]/[`DISABLE_LABEL`]/[`SWITCH_LABEL`] (or the +//! `enable_label`/`disable_label` helpers), matching the C++ undo names. + +use std::collections::HashSet; +use std::sync::{Arc, Mutex}; + +use oakcore_rs::Rational; +use oaknode::block::clip_input; +use oaknode::graph::{Graph, NodeEntry}; +use oaknode::id::NodeId; +use oaknode::nodes::multicamnode::{ + MultiCamNode, CURRENT_INPUT, SEQUENCE_INPUT, SEQUENCE_TYPE_INPUT, +}; +use oaknode::project::Project; +use oaknode::track::TrackType; +use oaknode::value::NodeValue; +use oakundo::undocommand::UndoCommand; + +use crate::util::{ + block_in, block_kind, block_out, block_track, BlockKind, NodeRef, +}; +use crate::undosplit::BlockSplitPreservingLinksCommand; + +/// Undo label for [`multicam_enable`] (C++ +/// `tr("Multi-Cam Enabled On %1 Clip(s)")`). +pub const ENABLE_LABEL: &str = "Multi-Cam Enabled On %1 Clip(s)"; +/// Undo label for [`multicam_disable`] (C++ +/// `tr("Multi-Cam Disabled On %1 Clip(s)")`). +pub const DISABLE_LABEL: &str = "Multi-Cam Disabled On %1 Clip(s)"; +/// Undo label for [`multicam_switch`] (C++ +/// `tr("Switched Multi-Camera Source")`). +pub const SWITCH_LABEL: &str = "Switched Multi-Camera Source"; + +/// Format [`ENABLE_LABEL`] with a clip count. +pub fn enable_label(clip_count: usize) -> String { + ENABLE_LABEL.replace("%1", &clip_count.to_string()) +} + +/// Format [`DISABLE_LABEL`] with a clip count. +pub fn disable_label(clip_count: usize) -> String { + DISABLE_LABEL.replace("%1", &clip_count.to_string()) +} + +// --------------------------------------------------------------------------- +// Lookup helpers +// --------------------------------------------------------------------------- + +/// `ClipBlock::find_multicam()` — the `MultiCamNode` feeding the clip's +/// effect input (`tex_in`, C++ `buffer_in`), searched at depth 1 and then +/// along the dependency chain exactly like the C++ +/// `find_input_nodes_connected_to_input(input, 1)` +/// (`// CPP-PARITY: clip.cpp:657-665`). +pub fn clip_find_multicam(clip: &NodeRef) -> Option { + let project = clip.project.clone(); + { + let p = project.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); + let mut list = Vec::new(); + find_input_nodes_connected_to_input_internal( + &p.graph, + clip.id, + clip_input::TEXTURE_INPUT, + -1, + 1, + &mut list, + ); + list.first().map(|id| *id) + } + .map(|id| NodeRef::new(project, id)) +} + +/// One step of the C++ `find_input_nodes_connected_to_input`: the node +/// feeding `node.input[element]`, then a recursive walk of its inputs, +/// stopping after `maximum` matches (`maximum == 0` = unlimited). +fn find_input_nodes_connected_to_input_internal( + graph: &Graph, + node: NodeId, + input: &str, + element: i32, + maximum: usize, + list: &mut Vec, +) { + let Some(source) = graph.connected_output(node, input, element) else { + return; + }; + if is_multicam(graph, source) { + list.push(source); + if maximum != 0 && list.len() == maximum { + return; + } + } + find_input_node_internal(graph, source, maximum, list); +} + +/// C++ `find_input_node_internal` — walk `node`'s input connections, +/// checking each source and recursing. +fn find_input_node_internal(graph: &Graph, node: NodeId, maximum: usize, list: &mut Vec) { + for (from, _input, _element) in graph.input_connections(node) { + if is_multicam(graph, from) { + list.push(from); + if maximum != 0 && list.len() == maximum { + return; + } + } + find_input_node_internal(graph, from, maximum, list); + if maximum != 0 && list.len() == maximum { + return; + } + } +} + +/// Whether `id` names a `MultiCamNode`. +fn is_multicam(graph: &Graph, id: NodeId) -> bool { + graph + .get(id) + .and_then(|e| e.behavior.as_any()) + .and_then(|a| a.downcast_ref::()) + .is_some() +} + +/// Set/clear the `MultiCamNode` sequence state after a `sequence_in` edge +/// edit (the graph arena fires no behavior events, so the command keeps the +/// behavior in sync — C++ `InputConnectedEvent`/`InputDisconnectedEvent`). +fn sync_multicam_sequence(graph: &mut Graph, mc: NodeId, sequence: Option) { + let Some(entry) = graph.get_mut(mc) else { + return; + }; + if let Some(mc_node) = entry + .behavior + .as_any_mut() + .and_then(|a| a.downcast_mut::()) + { + mc_node.set_sequence(&mut entry.core, sequence); + } +} + +/// C++ `Node::find_ways_node_arrives_here()` — every input slot along +/// `node`'s dependency chain that is fed (directly or transitively) by +/// `output`. Returns `(target, input_id, element)` where the target's +/// input is directly fed by `output`. +fn find_ways_node_arrives_here( + graph: &Graph, + output: NodeId, + node: NodeId, + v: &mut Vec<(NodeId, String, i32)>, +) { + for (from, input, element) in graph.input_connections(node) { + if from == output { + v.push((node, input, element)); + } else { + find_ways_node_arrives_here(graph, output, from, v); + } + } +} + +/// The `current_in` value of a multicam node (its currently selected +/// source; `-1` when the node is stale). +fn multicam_current_source(graph: &Graph, mc: NodeId) -> i32 { + graph + .get(mc) + .map(|e| e.core.standard_value(CURRENT_INPUT, -1).to_double() as i32) + .unwrap_or(-1) +} + +/// Set the `current_in` standard value of a multicam node. +fn multicam_set_current(graph: &mut Graph, mc: NodeId, source: i32) { + if let Some(entry) = graph.get_mut(mc) { + entry + .core + .set_standard_value(CURRENT_INPUT, -1, NodeValue::Combo(source as i64)); + } +} + +/// The clip's track media type (C++ `track_type_of(block_track(c))`); +/// defaults to video when the clip is trackless. +fn clip_track_type(clip: &NodeRef) -> TrackType { + block_track(clip) + .and_then(|track| { + let p = track.project.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); + p.graph + .get(track.id) + .and_then(|e| e.behavior.as_any()) + .and_then(|a| a.downcast_ref::()) + .map(|t| t.kind) + }) + .unwrap_or(TrackType::Video) +} + +/// Linked block ids of `block` in the project graph (C++ +/// `Block::block_links()`). +fn block_links(clip: &NodeRef) -> Vec { + let project = clip.project.clone(); + let p = project.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); + p.graph + .links_of(clip.id) + .into_iter() + .map(|id| NodeRef::new(project.clone(), id)) + .collect() +} + +/// For every clip in `clips`, the `(clip, multicam, old_current)` tuple for +/// each multicam found on the clip or its clip links (C++ Switch: set the +/// source on the new clip's multicam and every linked clip's multicam). +fn collect_switch_targets( + clip: &NodeRef, +) -> Vec<(NodeRef, NodeId, i32)> { + let project = clip.project.clone(); + let mut targets = Vec::new(); + let mut seen: HashSet = HashSet::new(); + for c in std::iter::once(clip.clone()).chain(block_links(clip)) { + if !seen.insert(c.id) { + continue; + } + if block_kind(&c) != BlockKind::Clip { + continue; + } + if let Some(mc) = clip_find_multicam(&c) { + let p = project.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); + let old = multicam_current_source(&p.graph, mc.id); + targets.push((c, mc.id, old)); + } + } + targets +} + +// --------------------------------------------------------------------------- +// MultiCamEnableCommand +// --------------------------------------------------------------------------- + +/// Per-clip state of [`MultiCamEnableCommand`]. +struct EnableState { + /// The multicam node created for the clip (valid after the first redo). + multicam: Option, + /// Detached arena entry while the node is out of the graph between + /// `undo` and the next `redo`. + entry: Option, + /// `sequence_type_in` value (the clip's track type ordinal). + sequence_type: i32, + /// Inputs the sequence fed along the clip's chain before the enable + /// (`(target, input_id, element)`), re-routed through the multicam. + rerouted: Vec<(NodeId, String, i32)>, +} + +/// `MultiCamEnableCommand` — wrap each clip's source through a fresh +/// `MultiCamNode` (C++ `multicam_enabled_triggered(true)`). One undo +/// command covering every clip; push with [`enable_label`]. +pub struct MultiCamEnableCommand { + /// Clips to enable multicam on. + clips: Vec, + /// The sequence whose track list supplies the angles. + sequence: NodeRef, + /// Per-clip state, built on the first redo. + state: Vec, +} + +impl MultiCamEnableCommand { + /// Construct from clips + sequence. + pub fn new(clips: Vec, sequence: NodeRef) -> Self { + Self { + clips, + sequence, + state: Vec::new(), + } + } + + /// `prepare`: plan each clip — create its multicam node and record the + /// sequence-fed inputs to re-route. + fn prepare(&mut self) { + if !self.state.is_empty() { + return; + } + let project = self.sequence.project.clone(); + for clip in &self.clips { + let mc = { + let (core, behavior) = oaknode::nodes::multicamnode::create(); + let mut p = project.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); + p.graph.add_node(core, behavior) + }; + let sequence_type = clip_track_type(clip).to_c(); + let rerouted = { + let p = project.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); + let mut v = Vec::new(); + find_ways_node_arrives_here(&p.graph, self.sequence.id, clip.id, &mut v); + v + }; + self.state.push(EnableState { + multicam: Some(mc), + entry: None, + sequence_type, + rerouted, + }); + } + } + + /// `redo`: (re-)insert the multicam nodes and re-route the sequence + /// edges through them. + pub fn redo(&mut self) { + self.prepare(); + let project = self.sequence.project.clone(); + for st in self.state.iter_mut() { + let Some(mc) = st.multicam else { + continue; + }; + let mut p = project.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); + // Re-attach the node if a previous undo detached it. + if let Some(entry) = st.entry.take() { + p.graph.add_entry(entry, mc); + } + // Set the sequence type selector (the clip's track type). + if let Some(entry) = p.graph.get_mut(mc) { + entry.core.set_standard_value( + SEQUENCE_TYPE_INPUT, + -1, + NodeValue::Combo(st.sequence_type as i64), + ); + } + // Disconnect the sequence from each input it fed and connect + // the multicam in its place. + for (target, input, element) in &st.rerouted { + p.graph.disconnect(self.sequence.id, *target, input, *element); + p.graph.connect(mc, *target, input, *element).ok(); + } + // Connect the sequence to the multicam's sequence_in. + p.graph + .disconnect(self.sequence.id, mc, SEQUENCE_INPUT, -1); + p.graph + .connect(self.sequence.id, mc, SEQUENCE_INPUT, -1) + .ok(); + // Keep the behavior's cached sequence state in sync. + sync_multicam_sequence(&mut p.graph, mc, Some(self.sequence.id)); + } + } + + /// `undo`: disconnect the multicam, re-connect the sequence straight to + /// each original input, and detach the multicam node. + pub fn undo(&mut self) { + let project = self.sequence.project.clone(); + for st in self.state.iter_mut() { + let Some(mc) = st.multicam else { + continue; + }; + let mut p = project.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); + // The multicam fed every re-routed input; wire the sequence + // back directly. + for (target, input, element) in &st.rerouted { + p.graph.disconnect(mc, *target, input, *element); + p.graph.connect(self.sequence.id, *target, input, *element).ok(); + } + // Drop the sequence_in edge. + p.graph.disconnect(self.sequence.id, mc, SEQUENCE_INPUT, -1); + // Detach the multicam node (identity preserved for the next + // redo). + if st.entry.is_none() { + st.entry = p.graph.take_node(mc); + } + } + } + + /// Wrap as an oakundo vtable command value. + pub fn to_command(self) -> UndoCommand { + crate::undocommon::box_command(self) + } +} + +impl crate::undocommon::Command for MultiCamEnableCommand { + fn redo(&mut self) { + self.redo(); + } + + fn undo(&mut self) { + self.undo(); + } +} + +/// Build the enable command (C++ `multicam_enabled_triggered(true)`). +pub fn multicam_enable(clips: Vec, sequence: NodeRef) -> UndoCommand { + MultiCamEnableCommand::new(clips, sequence).to_command() +} + +// --------------------------------------------------------------------------- +// MultiCamDisableCommand +// --------------------------------------------------------------------------- + +/// Per-clip state of [`MultiCamDisableCommand`]. +struct DisableState { + /// The multicam node currently feeding the clip. + multicam: NodeId, + /// The sequence the multicam pulled angles from (from its + /// `sequence_in` edge). + sequence: NodeId, + /// The multicam's output edges `(target, input_id, element)` that + /// must be re-wired straight to the sequence. + outputs: Vec<(NodeId, String, i32)>, + /// Detached arena entry while the node is out of the graph between + /// `undo` and the next `redo`. + entry: Option, +} + +/// `MultiCamDisableCommand` — bypass a clip's `MultiCamNode` and remove it +/// (C++ `multicam_enabled_triggered(false)`). One undo command covering +/// every clip; push with [`disable_label`]. +pub struct MultiCamDisableCommand { + /// Clips to disable multicam on. + clips: Vec, + /// Per-clip state, built on the first redo. + state: Vec, +} + +impl MultiCamDisableCommand { + /// Construct from clips. + pub fn new(clips: Vec) -> Self { + Self { + clips, + state: Vec::new(), + } + } + + /// `prepare`: locate each clip's multicam and the outputs to re-wire. + fn prepare(&mut self) { + if !self.state.is_empty() { + return; + } + let clips = self.clips.clone(); + for clip in clips { + let Some(mc) = clip_find_multicam(&clip) else { + continue; + }; + let (sequence, outputs) = { + let p = clip.project.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); + let sequence = p + .graph + .connected_output(mc.id, SEQUENCE_INPUT, -1) + .unwrap_or(NodeId::INVALID); + let outputs = p.graph.output_connections(mc.id); + (sequence, outputs) + }; + if sequence == NodeId::INVALID { + // No connected sequence: nothing to bypass back to; leave + // the node untouched. + continue; + } + self.state.push(DisableState { + multicam: mc.id, + sequence, + outputs, + entry: None, + }); + } + } + + /// `redo`: re-wire the multicam's outputs straight to the sequence and + /// detach the multicam node. + pub fn redo(&mut self) { + self.prepare(); + let mut project: Option>> = None; + for st in self.state.iter_mut() { + if project.is_none() { + if let Some(clip) = self.clips.first() { + project = Some(clip.project.clone()); + } else { + return; + } + } + let project = project.as_ref().expect("set above"); + let mut p = project.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); + // Re-attach the node if a previous undo detached it. + if let Some(entry) = st.entry.take() { + p.graph.add_entry(entry, st.multicam); + } + // Re-wire outputs to the sequence. + for (target, input, element) in &st.outputs { + p.graph.disconnect(st.multicam, *target, input, *element); + p.graph.connect(st.sequence, *target, input, *element).ok(); + } + // Detach the multicam (the sequence_in edge goes with it). + if st.entry.is_none() { + st.entry = p.graph.take_node(st.multicam); + } + } + } + + /// `undo`: re-insert the multicam node, re-route the outputs through it + /// (disconnecting the sequence), and re-connect the sequence_in edge. + pub fn undo(&mut self) { + // `undo` only runs after `redo`, which returned early when `clips` + // was empty, so a project is always available here. + let project = self + .clips + .first() + .map(|c| c.project.clone()) + .unwrap_or_else(Project::new); + for st in self.state.iter_mut() { + let mut p = project.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); + if let Some(entry) = st.entry.take() { + p.graph.add_entry(entry, st.multicam); + } + // The sequence now feeds every output directly; re-route them + // through the multicam and restore the sequence_in edge. + for (target, input, element) in &st.outputs { + p.graph.disconnect(st.sequence, *target, input, *element); + p.graph.connect(st.multicam, *target, input, *element).ok(); + } + p.graph + .connect(st.sequence, st.multicam, SEQUENCE_INPUT, -1) + .ok(); + sync_multicam_sequence(&mut p.graph, st.multicam, Some(st.sequence)); + } + } + + /// Wrap as an oakundo vtable command value. + pub fn to_command(self) -> UndoCommand { + crate::undocommon::box_command(self) + } +} + +impl crate::undocommon::Command for MultiCamDisableCommand { + fn redo(&mut self) { + self.redo(); + } + + fn undo(&mut self) { + self.undo(); + } +} + +/// Build the disable command (C++ `multicam_enabled_triggered(false)`). +pub fn multicam_disable(clips: Vec) -> UndoCommand { + MultiCamDisableCommand::new(clips).to_command() +} + +// --------------------------------------------------------------------------- +// MultiCamSwitchCommand +// --------------------------------------------------------------------------- + +/// `MultiCamSwitchCommand` — change the multicam source, optionally +/// splitting the clip at the playhead first (C++ `MulticamWidget::Switch`). +/// One undo command for the whole operation; push with [`SWITCH_LABEL`]. +pub struct MultiCamSwitchCommand { + /// The clip whose multicam source changes. + clip: NodeRef, + /// The new source index. + source: i32, + /// Whether to split the clip at the playhead (source change applies + /// from the playhead forward). + split_clip: bool, + /// The playhead time (split point). + playhead: Rational, + /// The split command, when `split_clip` and the playhead lies strictly + /// inside the clip. + split: Option, + /// Whether the clip was actually split on the first redo. + did_split: bool, + /// `(clip, multicam, old_source)` targets captured on the first redo; + /// the ids stay stable across undo/redo (the split re-attaches its + /// copies identity-preserving). + targets: Vec<(NodeRef, NodeId, i32)>, + /// True when the clip has no multicam: the command is a no-op (the C++ + /// `if (!node_) return;` guard). + noop: bool, +} + +impl MultiCamSwitchCommand { + /// Construct from clip + source + split flag + playhead. + pub fn new(clip: NodeRef, source: i32, split_clip: bool, playhead: Rational) -> Self { + Self { + clip, + source, + split_clip, + playhead, + split: None, + did_split: false, + targets: Vec::new(), + noop: false, + } + } + + /// `redo`: split (if requested and the playhead is strictly inside), + /// then write `current_in` on the affected multicam copies. + pub fn redo(&mut self) { + if self.split.is_none() && !self.noop { + if clip_find_multicam(&self.clip).is_none() { + // C++ `if (!node_) return;` — nothing to switch. + self.noop = true; + return; + } + if self.split_clip { + let clip_in = block_in(&self.clip); + let clip_out = block_out(&self.clip); + if clip_in < self.playhead && self.playhead < clip_out { + // Split the clip and every linked block, preserving + // links. Each half keeps its own multicam copy. + let mut blocks = vec![self.clip.clone()]; + blocks.extend(block_links(&self.clip)); + let times = vec![self.playhead]; + let mut split = + BlockSplitPreservingLinksCommand::new(blocks, times); + split.redo(); + let new_clip = split.get_split(&self.clip, 0); + self.did_split = true; + // Targets come from the new (post-playhead) halves. + if let Some(new_clip) = new_clip { + self.targets = collect_switch_targets(&new_clip); + } + self.split = Some(split); + } + } + if !self.did_split { + // No split (split disabled or playhead outside the clip): + // the whole clip (and its links) switch source. + self.targets = collect_switch_targets(&self.clip); + } + } else if self.did_split { + // Redo after undo: re-run the split (the halves re-attach + // identity-preserving) and re-apply the stored targets. + if let Some(split) = self.split.as_mut() { + split.redo(); + } + } + + // Write the new source on every captured multicam. + let project = self.clip.project.clone(); + let mut p = project.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); + for (_clip, mc, _old) in &self.targets { + multicam_set_current(&mut p.graph, *mc, self.source); + } + } + + /// `undo`: restore the old sources and undo the split. + pub fn undo(&mut self) { + // Restore the previous sources first (the targets are still + // attached); undoing the split afterwards discards the copies. + let project = self.clip.project.clone(); + let mut p = project.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); + for (_clip, mc, old) in &self.targets { + multicam_set_current(&mut p.graph, *mc, *old); + } + drop(p); + if self.did_split { + if let Some(split) = self.split.as_mut() { + split.undo(); + } + } + } + + /// Wrap as an oakundo vtable command value. + pub fn to_command(self) -> UndoCommand { + crate::undocommon::box_command(self) + } +} + +impl crate::undocommon::Command for MultiCamSwitchCommand { + fn redo(&mut self) { + self.redo(); + } + + fn undo(&mut self) { + self.undo(); + } +} + +/// Build the switch command (C++ `MulticamWidget::Switch`). +pub fn multicam_switch( + clip: NodeRef, + source: i32, + split_clip: bool, + playhead: Rational, +) -> UndoCommand { + MultiCamSwitchCommand::new(clip, source, split_clip, playhead).to_command() +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use crate::util::{ + block_clip_create, block_in, block_length, block_out, same_block, + track_append_block, track_block_at, track_block_count, NodeRef, + }; + use oakcore_rs::TimeRange; + use oaknode::block::ClipBlockBehavior; + use oaknode::node::NodeCore; + use oaknode::sequence::SequenceBehavior; + use oaknode::track::{TrackBehavior, TrackListBehavior}; + + /// Project fixture: a sequence owning one video track list with one + /// video track. + struct Fixture { + project: Arc>, + seq: NodeRef, + track: NodeRef, + } + + fn fixture() -> Fixture { + let project = Project::new(); + let (seq_id, list_id, track_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::() + .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::() + .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::() + .unwrap(); + t.kind = TrackType::Video; + t.track_list = Some(list_id); + (seq_id, list_id, track_id) + }; + let _ = list_id; + Fixture { + project: project.clone(), + seq: NodeRef::new(project.clone(), seq_id), + track: NodeRef::new(project, track_id), + } + } + + /// Add a clip spanning `[in, out)` on the fixture's video track. + fn add_clip(fx: &Fixture, in_: Rational, out: Rational) -> NodeRef { + let clip = block_clip_create(&fx.project); + { + let mut p = fx.project.lock().unwrap(); + let e = p.graph.get_mut(clip.id).unwrap(); + let c = e + .behavior + .as_any_mut() + .unwrap() + .downcast_mut::() + .unwrap(); + c.core.range = TimeRange::new(in_, out); + } + track_append_block(&fx.track, &clip); + clip + } + + /// The pre-enable topology: the sequence feeds the clip's effect input. + fn connect_sequence_to_clip(fx: &Fixture, clip: &NodeRef) { + let mut p = fx.project.lock().unwrap(); + p.graph + .connect(fx.seq.id, clip.id, clip_input::TEXTURE_INPUT, -1) + .unwrap(); + } + + /// The multicam node currently feeding `clip` (via `clip_find_multicam`). + #[allow(unused_variables)] + fn multicam_of(_fx: &Fixture, clip: &NodeRef) -> Option { + clip_find_multicam(clip) + } + + /// The multicam node feeding `clip`, asserted present. + fn multicam_of_expect(fx: &Fixture, clip: &NodeRef) -> NodeRef { + multicam_of(fx, clip).expect("clip has a multicam") + } + + /// The `current_in` value of a multicam node. + fn current_of(fx: &Fixture, mc: &NodeRef) -> i32 { + let p = fx.project.lock().unwrap(); + multicam_current_source(&p.graph, mc.id) + } + + /// Set the `current_in` of a multicam node (test setup for non-default + /// initial source). + fn set_current(fx: &Fixture, mc: &NodeRef, source: i32) { + let mut p = fx.project.lock().unwrap(); + multicam_set_current(&mut p.graph, mc.id, source); + } + + /// The `sequence_type_in` value of a multicam node. + fn sequence_type_of(fx: &Fixture, mc: &NodeRef) -> i64 { + let p = fx.project.lock().unwrap(); + let v = p.graph.get(mc.id).unwrap().core.standard_value(SEQUENCE_TYPE_INPUT, -1); + match v { + NodeValue::Combo(i) => i, + other => panic!("sequence_type_in is {other:?}, expected Combo"), + } + } + + #[test] + fn labels_format_count() { + assert_eq!(enable_label(3), "Multi-Cam Enabled On 3 Clip(s)"); + assert_eq!(disable_label(2), "Multi-Cam Disabled On 2 Clip(s)"); + assert_eq!(SWITCH_LABEL, "Switched Multi-Camera Source"); + } + + /// `clip_find_multicam` is None for a plain clip and Some after the + /// sequence is routed through a multicam (the C++ + /// `ClipBlock::find_multicam` parity). + #[test] + fn find_multicam_positive_and_negative() { + let fx = fixture(); + let clip = add_clip(&fx, Rational::new(0, 1), Rational::new(50, 1)); + + // No multicam anywhere: nothing found. + assert!(multicam_of(&fx, &clip).is_none()); + + // A non-multicam source on the effect input is not reported either. + connect_sequence_to_clip(&fx, &clip); + assert!(multicam_of(&fx, &clip).is_none()); + + // After the enable command, the multicam feeds the clip. + let mut cmd = MultiCamEnableCommand::new(vec![clip.clone()], fx.seq.clone()); + cmd.redo(); + let mc = multicam_of_expect(&fx, &clip); + { + let p = fx.project.lock().unwrap(); + assert_eq!( + p.graph.get(mc.id).unwrap().behavior.type_id(), + "org.olivevideoeditor.Olive.multicam" + ); + } + + // After undo the multicam is gone again. + cmd.undo(); + assert!(multicam_of(&fx, &clip).is_none()); + } + + /// `multicam_enable` re-routes the sequence→clip edges through a fresh + /// multicam node and sets `sequence_type_in`; undo restores the exact + /// pre-enable edges. + #[test] + fn enable_reroutes_through_multicam_and_undo_restores() { + let fx = fixture(); + let clip = add_clip(&fx, Rational::new(0, 1), Rational::new(50, 1)); + connect_sequence_to_clip(&fx, &clip); + + let mut cmd = MultiCamEnableCommand::new(vec![clip.clone()], fx.seq.clone()); + cmd.redo(); + + let mc = multicam_of_expect(&fx, &clip); + { + let p = fx.project.lock().unwrap(); + // The sequence no longer feeds the clip; the multicam does. + assert_eq!( + p.graph.connected_output(clip.id, clip_input::TEXTURE_INPUT, -1), + Some(mc.id) + ); + // The sequence feeds the multicam's sequence_in. + assert_eq!( + p.graph.connected_output(mc.id, SEQUENCE_INPUT, -1), + Some(fx.seq.id) + ); + } + // The type selector mirrors the video track type. + assert_eq!(sequence_type_of(&fx, &mc), 0); + + cmd.undo(); + // Back to the pre-enable edge; the multicam node is gone. + assert!(multicam_of(&fx, &clip).is_none()); + { + let p = fx.project.lock().unwrap(); + assert_eq!( + p.graph.connected_output(clip.id, clip_input::TEXTURE_INPUT, -1), + Some(fx.seq.id) + ); + assert!(!p.graph.is_valid(mc.id)); + } + + // Redo re-creates the multicam (fresh node id) and re-routes again. + cmd.redo(); + let mc2 = multicam_of_expect(&fx, &clip); + { + let p = fx.project.lock().unwrap(); + assert_eq!( + p.graph.connected_output(clip.id, clip_input::TEXTURE_INPUT, -1), + Some(mc2.id) + ); + } + } + + /// `multicam_disable` re-wires the multicam's outputs back to the + /// sequence and removes the node; undo restores the enabled state. + #[test] + fn disable_round_trip() { + let fx = fixture(); + let clip = add_clip(&fx, Rational::new(0, 1), Rational::new(50, 1)); + connect_sequence_to_clip(&fx, &clip); + + let mut enable = MultiCamEnableCommand::new(vec![clip.clone()], fx.seq.clone()); + enable.redo(); + let mc = multicam_of_expect(&fx, &clip); + + let mut disable = MultiCamDisableCommand::new(vec![clip.clone()]); + disable.redo(); + { + let p = fx.project.lock().unwrap(); + assert_eq!( + p.graph.connected_output(clip.id, clip_input::TEXTURE_INPUT, -1), + Some(fx.seq.id) + ); + assert!(!p.graph.is_valid(mc.id)); + } + + disable.undo(); + let restored_mc = multicam_of_expect(&fx, &clip); + { + let p = fx.project.lock().unwrap(); + assert_eq!( + p.graph.connected_output(clip.id, clip_input::TEXTURE_INPUT, -1), + Some(restored_mc.id) + ); + assert_eq!( + p.graph.connected_output(restored_mc.id, SEQUENCE_INPUT, -1), + Some(fx.seq.id) + ); + } + } + + /// `multicam_switch` without splitting sets the source on the clip and + /// its linked clips' multicams; a single undo restores every source. + #[test] + fn switch_no_split_updates_clip_and_links() { + let fx = fixture(); + let clip_a = add_clip(&fx, Rational::new(0, 1), Rational::new(50, 1)); + let clip_b = add_clip(&fx, Rational::new(50, 1), Rational::new(100, 1)); + connect_sequence_to_clip(&fx, &clip_a); + connect_sequence_to_clip(&fx, &clip_b); + { + let mut p = fx.project.lock().unwrap(); + p.graph.link(clip_a.id, clip_b.id); + } + let mut enable = + MultiCamEnableCommand::new(vec![clip_a.clone(), clip_b.clone()], fx.seq.clone()); + enable.redo(); + let mc_a = multicam_of_expect(&fx, &clip_a); + let mc_b = multicam_of_expect(&fx, &clip_b); + set_current(&fx, &mc_a, 1); + set_current(&fx, &mc_b, 1); + + // Split disabled: the switch writes the source directly. + let mut cmd = MultiCamSwitchCommand::new( + clip_a.clone(), + 2, + false, + Rational::new(30, 1), + ); + cmd.redo(); + assert_eq!(current_of(&fx, &mc_a), 2); + assert_eq!(current_of(&fx, &mc_b), 2); + // No split happened: still one block per track slot. + assert_eq!(track_block_count(&fx.track), 2); + + cmd.undo(); + assert_eq!(current_of(&fx, &mc_a), 1); + assert_eq!(current_of(&fx, &mc_b), 1); + assert_eq!(track_block_count(&fx.track), 2); + } + + /// `multicam_switch` with a playhead strictly inside the clip splits it + /// preserving links: the halves each own an independent multicam copy, + /// the post-playhead half switches source and its linked half follows, + /// the pre-playhead half keeps the old source. Undo restores the single + /// clip and its original source. + #[test] + fn switch_splits_and_copies_multicam() { + let fx = fixture(); + let clip_a = add_clip(&fx, Rational::new(0, 1), Rational::new(100, 1)); + let clip_b = add_clip(&fx, Rational::new(0, 1), Rational::new(100, 1)); + // Same track layout: clip_b on a second track slot is created after; + // link the two clips so the split is link-preserving. + { + let mut p = fx.project.lock().unwrap(); + p.graph.link(clip_a.id, clip_b.id); + } + connect_sequence_to_clip(&fx, &clip_a); + connect_sequence_to_clip(&fx, &clip_b); + let mut enable = + MultiCamEnableCommand::new(vec![clip_a.clone(), clip_b.clone()], fx.seq.clone()); + enable.redo(); + let mc_a = multicam_of_expect(&fx, &clip_a); + let mc_b = multicam_of_expect(&fx, &clip_b); + set_current(&fx, &mc_a, 1); + set_current(&fx, &mc_b, 1); + + // Switch at t=40 (strictly inside [0,100)) with split. + let mut cmd = MultiCamSwitchCommand::new( + clip_a.clone(), + 3, + true, + Rational::new(40, 1), + ); + cmd.redo(); + + // Two halves per clip (both linked clips split): 4 blocks total. + assert_eq!(track_block_count(&fx.track), 4); + let first = track_block_at(&fx.track, 0).unwrap(); + let second = track_block_at(&fx.track, 1).unwrap(); + assert!(same_block(&first, &clip_a), "original clip keeps the in half"); + assert_eq!(block_in(&first), Rational::new(0, 1)); + assert_eq!(block_out(&first), Rational::new(40, 1)); + assert_eq!(block_in(&second), Rational::new(40, 1)); + assert_eq!(block_out(&second), Rational::new(100, 1)); + + // The pre-playhead half keeps its own multicam and old source; the + // post-playhead half has a distinct multicam copy with the new + // source. + let mc_first = multicam_of_expect(&fx, &first); + let mc_second = multicam_of_expect(&fx, &second); + assert_ne!(mc_first.id, mc_second.id, "independent multicam copies"); + assert_eq!(current_of(&fx, &mc_first), 1); + assert_eq!(current_of(&fx, &mc_second), 3); + + // The linked clip's post-playhead half switched too. + let linked_second = { + let p = fx.project.lock().unwrap(); + // The second half of clip_a links to the second half of clip_b. + let links = p.graph.links_of(second.id); + links.iter().find(|id| **id != second.id).copied().unwrap() + }; + assert_ne!(linked_second, clip_a.id); + let mc_linked = multicam_of_expect(&fx, &NodeRef::new(fx.project.clone(), linked_second)); + assert_eq!(current_of(&fx, &mc_linked), 3); + + // A single undo restores the two original clips and the old sources. + cmd.undo(); + assert_eq!(track_block_count(&fx.track), 2); + assert_eq!(block_length(&clip_a), Rational::new(100, 1)); + assert_eq!(current_of(&fx, &multicam_of_expect(&fx, &clip_a)), 1); + assert_eq!(current_of(&fx, &multicam_of_expect(&fx, &clip_b)), 1); + + // Redo re-splits (the copies re-attach identity-preserving) and + // re-applies the new source. + cmd.redo(); + assert_eq!(track_block_count(&fx.track), 4); + let second_again = track_block_at(&fx.track, 1).unwrap(); + let mc_second_again = multicam_of_expect(&fx, &second_again); + assert_eq!(current_of(&fx, &mc_second_again), 3); + let mc_first_again = multicam_of_expect(&fx, &track_block_at(&fx.track, 0).unwrap()); + assert_eq!(current_of(&fx, &mc_first_again), 1); + } + + /// The split's dependency-graph copy gives the two halves independent + /// multicam nodes: mutating one half's multicam does not affect the + /// other's. + #[test] + fn split_copies_dependency_graph_independently() { + let fx = fixture(); + let clip = add_clip(&fx, Rational::new(0, 1), Rational::new(100, 1)); + connect_sequence_to_clip(&fx, &clip); + let mut enable = MultiCamEnableCommand::new(vec![clip.clone()], fx.seq.clone()); + enable.redo(); + let mc_orig = multicam_of_expect(&fx, &clip); + + // Split manually through the split command. + let mut split = crate::undosplit::BlockSplitCommand::new( + clip.clone(), + Rational::new(40, 1), + ); + split.prepare(); + split.redo(); + let second = split.new_block().unwrap(); + + let mc_first = multicam_of_expect(&fx, &clip); + let mc_second = multicam_of_expect(&fx, &second); + assert_ne!(mc_first.id, mc_second.id); + assert_ne!(mc_orig.id, mc_second.id); + + // Both copies kept the original source value. + assert_eq!(current_of(&fx, &mc_first), 0); + assert_eq!(current_of(&fx, &mc_second), 0); + + // Changing the second half's source leaves the first half's copy + // untouched. + let mut p = fx.project.lock().unwrap(); + multicam_set_current(&mut p.graph, mc_second.id, 5); + drop(p); + assert_eq!(current_of(&fx, &mc_first), 0); + assert_eq!(current_of(&fx, &mc_second), 5); + } + + /// A switch with `split_clip` but a playhead outside the clip does not + /// split; it writes the source directly (C++ only splits when the + /// playhead lies strictly inside). + #[test] + fn switch_outside_playhead_does_not_split() { + let fx = fixture(); + let clip = add_clip(&fx, Rational::new(0, 1), Rational::new(50, 1)); + connect_sequence_to_clip(&fx, &clip); + let mut enable = MultiCamEnableCommand::new(vec![clip.clone()], fx.seq.clone()); + enable.redo(); + let mc = multicam_of_expect(&fx, &clip); + + let mut cmd = MultiCamSwitchCommand::new( + clip.clone(), + 2, + true, + Rational::new(70, 1), // past the out point + ); + cmd.redo(); + assert_eq!(track_block_count(&fx.track), 1); + assert_eq!(current_of(&fx, &mc), 2); + + cmd.undo(); + assert_eq!(current_of(&fx, &mc), 0); + } + + /// A switch on a clip without any multicam is a no-op (the C++ + /// `if (!node_) return;` guard) — no split, no writes. + #[test] + fn switch_without_multicam_is_noop() { + let fx = fixture(); + let clip = add_clip(&fx, Rational::new(0, 1), Rational::new(100, 1)); + + let mut cmd = MultiCamSwitchCommand::new( + clip.clone(), + 2, + true, + Rational::new(40, 1), + ); + cmd.redo(); + // No split happened. + assert_eq!(track_block_count(&fx.track), 1); + cmd.undo(); + assert_eq!(track_block_count(&fx.track), 1); + } + + #[test] + fn disable_skips_clips_without_sequence_connection() { + let fx = fixture(); + let clip = add_clip(&fx, Rational::new(0, 1), Rational::new(50, 1)); + connect_sequence_to_clip(&fx, &clip); + let mut enable = MultiCamEnableCommand::new(vec![clip.clone()], fx.seq.clone()); + enable.redo(); + let mc = multicam_of_expect(&fx, &clip); + + // A second clip without any multicam is a no-op for the disable. + let bare = add_clip(&fx, Rational::new(50, 1), Rational::new(100, 1)); + let mut disable = MultiCamDisableCommand::new(vec![clip.clone(), bare.clone()]); + disable.redo(); + { + let p = fx.project.lock().unwrap(); + assert_eq!( + p.graph.connected_output(clip.id, clip_input::TEXTURE_INPUT, -1), + Some(fx.seq.id) + ); + assert!(!p.graph.is_valid(mc.id)); + } + // The bare clip is untouched. + assert!(multicam_of(&fx, &bare).is_none()); + } +} diff --git a/crates/oaktimeline/src/undosplit.rs b/crates/oaktimeline/src/undosplit.rs index 23980f7fd..32384938c 100644 --- a/crates/oaktimeline/src/undosplit.rs +++ b/crates/oaktimeline/src/undosplit.rs @@ -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, - /// Arena entry of the second block while it is detached from the - /// graph (between `undo` and the next `redo`). - new_block_entry: Option, + /// 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, + /// 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>, /// 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