Files
oak-editor/crates/oaktimeline/src/multicam.rs
T
Mike-Solar 194d761ade refactor(oakundo): replace the CHandle vtable layer with owned trait objects
With the C ABI facade (oakengine) retired, the frozen-ABI rationale is
gone. UndoCommand now boxes a Send Command trait (new/from_closures/
multi), dropping OakUndoCommandVtable, the userdata trampolines, the
refcount shell, the handle module, and all undostack_* handle exports.
The global facade loses its raw-pointer out-params (can_undo/can_redo
return bool, command_name returns String). oaktimeline/oaknode/
oakplugin/oaktask construct commands directly via UndoCommand::new.
oakundo src is now free of unsafe; behavior (ordering, idempotence,
done flags, groups, observers, 200-row cap) is unchanged and pinned by
the rewritten tests.
2026-08-19 00:41:49 +08:00

1210 lines
45 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/>.
//! 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<MultiCamNode>(input, 1)`
/// (`// CPP-PARITY: clip.cpp:657-665`).
pub fn clip_find_multicam(clip: &NodeRef) -> Option<NodeRef> {
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<NodeId>,
) {
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<NodeId>) {
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::<MultiCamNode>())
.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<NodeId>) {
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::<MultiCamNode>())
{
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::<oaknode::track::TrackBehavior>())
.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<NodeRef> {
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<NodeId> = 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<NodeId>,
/// Detached arena entry while the node is out of the graph between
/// `undo` and the next `redo`.
entry: Option<NodeEntry>,
/// `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<NodeRef>,
/// The sequence whose track list supplies the angles.
sequence: NodeRef,
/// Per-clip state, built on the first redo.
state: Vec<EnableState>,
}
impl MultiCamEnableCommand {
/// Construct from clips + sequence.
pub fn new(clips: Vec<NodeRef>, 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 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<NodeRef>, 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<NodeEntry>,
}
/// `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<NodeRef>,
/// Per-clip state, built on the first redo.
state: Vec<DisableState>,
}
impl MultiCamDisableCommand {
/// Construct from clips.
pub fn new(clips: Vec<NodeRef>) -> 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<Arc<Mutex<Project>>> = 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 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<NodeRef>) -> 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<BlockSplitPreservingLinksCommand>,
/// 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 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<Mutex<Project>>,
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::<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);
(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::<ClipBlockBehavior>()
.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<NodeRef> {
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());
}
}