Files
oak-editor/crates/oak-node/src/nodes/multicamnode.rs
T
Mike-Solar 244d5e860f
CI / Build & test (Windows) (push) Failing after 7s
workspace: kebab-case crates, app under crates/oak-app, shared versions
All crates take the oak-* kebab-case naming (oak-audio, oak-codec,
oak-common, oak-core, oak-ffmpeg-link, oak-node, oak-otio, oak-plugin,
oak-render, oak-storage, oak-task, oak-timeline, oak-undo), with the
lib identifiers rewritten (oakrender:: -> oak_render::, oakcore_rs:: ->
oak_core::, ...) across all 226 referencing files.

The GUI application moves from the workspace root into
crates/oak-app/: src/, build.rs (paths fixed for the new location) and
tests/ travel with it, the root Cargo.toml becomes workspace-only
([workspace] + workspace.package + profiles), and the app package
inherits the workspace version. The screenshots example becomes a
standalone crate examples/simple_player/ with its own Cargo.toml.

Every crate now inherits the single workspace version
(version.workspace = true), and the workflows' crate paths and the
build docs follow the renames.

Validated with a clean cargo check --workspace.
2026-08-22 16:58:37 +08:00

571 lines
19 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-cam source switcher node (C++
//! `src/node/src/input/multicam/multicamnode.{h,cpp}`,
//! `olive::MultiCamNode`).
use crate::factory::NodeMeta;
use crate::id::NodeId;
use crate::node::{Category, NodeBehavior, NodeCore};
use crate::value::{NodeValue, NodeValueRow, NodeValueTable};
use oak_core::Rational;
/// Current source selector input id (C++ `k_current_input`). Type:
/// combo; default `0`; flags: static (not-connectable +
/// not-keyframable). Its combo-box strings are built in `retranslate()`
/// as `"<index + 1>: <source name>"` entries.
pub const CURRENT_INPUT: &str = "current_in";
/// Sources array input id (C++ `k_sources_input`). Type: none (any);
/// flags: not-keyframable, array; properties: `arraystart = 1`.
pub const SOURCES_INPUT: &str = "sources_in";
/// Sequence input id (C++ `k_sequence_input`). Type: none (node
/// reference to a `Sequence`); flags: not-keyframable. Excluded from
/// rendering via `ignore_inputs_for_rendering()`.
pub const SEQUENCE_INPUT: &str = "sequence_in";
/// Sequence track-type selector input id (C++ `k_sequence_type_input`).
/// Type: combo; default `0` (video); flags: static, hidden (unhidden
/// while a sequence is connected). Combo strings: `"Video"`, `"Audio"`
/// (C++ `Track::Type` values).
pub const SEQUENCE_TYPE_INPUT: &str = "sequence_type_in";
/// Multi-cam node. Switches between multiple sources either from the
/// `sources_in` array or, when a `Sequence` is connected to
/// `sequence_in`, from that sequence's track list.
pub struct MultiCamNode {
/// Connected sequence whose track list supplies the sources (C++
/// `sequence_`, a raw `Sequence *` set/cleared by the
/// `sequence_in` connect/disconnect events). Stored here as the
/// sequence node's id; `None` = no sequence connected.
sequence: Option<NodeId>,
}
/// The C++ `k_input_flag_static` mask: not-connectable +
/// not-keyframable.
const STATIC_FLAGS: u32 =
crate::input::flags::NOT_CONNECTABLE | crate::input::flags::NOT_KEYFRAMABLE;
impl MultiCamNode {
/// Index of the currently selected source (C++
/// `get_current_source()` — the standard value of
/// [`CURRENT_INPUT`] as int).
pub fn current_source(&self, core: &NodeCore) -> i32 {
core.standard_value(CURRENT_INPUT, -1).to_double() as i32
}
/// The connected sequence node (C++ `sequence_`).
pub fn sequence(&self) -> Option<NodeId> {
self.sequence
}
/// Set/clear the connected-sequence state (the effects of the C++
/// `InputConnectedEvent`/`InputDisconnectedEvent` on `sequence_in`:
/// store the sequence and toggle the `sequence_type_in` hidden flag).
/// The graph arena does not dispatch behavior events on edge edits, so
/// the commands that edit the `sequence_in` edge call this to keep the
/// behavior state in sync with the graph.
pub fn set_sequence(&mut self, core: &mut NodeCore, sequence: Option<NodeId>) {
if let Some(slot) = core.get_input_mut(SEQUENCE_TYPE_INPUT) {
if sequence.is_some() {
slot.flags &= !crate::input::flags::HIDDEN;
} else {
slot.flags |= crate::input::flags::HIDDEN;
}
}
self.sequence = sequence;
}
/// Number of available sources (C++ `get_source_count()`): the
/// connected sequence's track count when a sequence is set,
/// otherwise the `sources_in` array size.
///
/// The sequence's track list lives in the sequence node's behavior
/// (`crate::sequence::SequenceBehavior`) reachable only through the
/// graph, which this signature does not carry; with a sequence set
/// the count therefore falls back to the `sources_in` array size
/// (`// CPP-PARITY: multicamnode.cpp` `get_source_count`).
pub fn source_count(&self, core: &NodeCore) -> i32 {
let _ = self;
core.input_array_size(SOURCES_INPUT) as i32
}
/// Grid layout for `sources` sources (C++ static
/// `get_rows_and_columns(sources, rows, cols)`): starts at 1x1 and
/// grows the smaller dimension until rows*cols >= sources.
pub fn rows_and_columns(sources: i32) -> (i32, i32) {
let mut rows = 1;
let mut cols = 1;
while rows * cols < sources {
if rows < cols {
rows += 1;
} else {
cols += 1;
}
}
(rows, cols)
}
/// Grid position of a source index (C++ static
/// `index_to_row_cols()`): `col = index % total_cols`,
/// `row = index / total_cols` (`total_rows` is unused in C++).
pub fn index_to_row_cols(index: i32, total_rows: i32, total_cols: i32) -> (i32, i32) {
let _ = total_rows;
(index / total_cols, index % total_cols)
}
/// Inverse of [`Self::index_to_row_cols`] (C++ static
/// `rows_cols_to_index()`): `col + row * total_cols`.
pub fn rows_cols_to_index(row: i32, col: i32, total_rows: i32, total_cols: i32) -> i32 {
let _ = total_rows;
col + row * total_cols
}
/// Active `sources_in` element set (C++
/// `get_active_elements_at_time()` for `sources_in`): only the
/// element at the current source index, or no elements when the
/// index is out of range. Core-carrying variant of
/// [`NodeBehavior::active_elements_at_time`], which receives no
/// `NodeCore`.
pub fn active_elements(&self, core: &NodeCore) -> Vec<i32> {
let src = self.current_source(core);
if src >= 0 && src < self.source_count(core) {
vec![src]
} else {
Vec::new()
}
}
}
impl NodeBehavior for MultiCamNode {
/// Human-readable name (C++ `name()`).
fn name(&self) -> &str {
"Multi-Cam"
}
/// Stable type id (C++ `id()`).
fn type_id(&self) -> &str {
"org.olivevideoeditor.Olive.multicam"
}
/// Categories (C++ `category()` returns `{ k_category_timeline }`).
fn categories(&self) -> &[Category] {
&[Category::Timeline]
}
/// Description (C++ `description()`).
fn description(&self) -> &str {
"Allows easy switching between multiple sources."
}
/// Localized input names (C++ `retranslate()`): `current_in` ->
/// "Current", `sources_in` -> "Sources", `sequence_in` ->
/// "Sequence", `sequence_type_in` -> "Sequence Type". The C++
/// override also refreshes the combo strings: `sequence_type_in`
/// gets {"Video", "Audio"} and `current_in` gets per-source
/// `"<i + 1>: <connected source name>"` entries — that part has no
/// trait surface and is noted here only.
fn input_name<'a>(&self, id: &'a str) -> &'a str {
match id {
CURRENT_INPUT => "Current",
SOURCES_INPUT => "Sources",
SEQUENCE_INPUT => "Sequence",
SEQUENCE_TYPE_INPUT => "Sequence Type",
_ => id,
}
}
/// Combo input option labels (C++ `retranslate()` /
/// `set_combo_box_strings`): `sequence_type_in` -> "Video", "Audio".
/// `current_in`'s strings are built dynamically per connected source
/// (`"<i + 1>: <name>"`) and cannot be expressed statically.
fn input_combo_strings(&self, id: &str) -> Vec<&'static str> {
match id {
SEQUENCE_TYPE_INPUT => vec!["Video", "Audio"],
_ => Vec::new(),
}
}
/// Inputs excluded from rendering (C++
/// `ignore_inputs_for_rendering()`): always
/// `{ k_sequence_input }`.
fn ignore_inputs_for_rendering(&self) -> &[String] {
static IGNORED: std::sync::OnceLock<String> = std::sync::OnceLock::new();
std::slice::from_ref(IGNORED.get_or_init(|| SEQUENCE_INPUT.to_string()))
}
/// Active array elements (C++ `get_active_elements_at_time()`): for
/// `sources_in`, only the element at the current source index (or
/// no elements if the index is out of range); any other input
/// defers to the base-class behavior.
///
/// The C++ reads the `current_in` standard value
/// (`get_current_source()`), which requires the node's data
/// ([`NodeCore`]) — not carried by this trait signature. The
/// core-carrying equivalent is [`Self::active_elements`] (tested
/// there); until the API gains core access, the base-class (empty)
/// set is returned (`// CPP-PARITY: multicamnode.cpp`
/// `get_active_elements_at_time`).
fn active_elements_at_time(&self, input: &str, time: Rational) -> Vec<i32> {
let _ = (input, time);
Vec::new()
}
/// Render-time connection resolution (C++
/// `get_connected_render_output()`): with a sequence connected,
/// `sources_in` element `i` (in range) resolves to the track at
/// index `i` of the selected track list instead of any connected
/// edge; otherwise defers to the base-class behavior (no virtual
/// output — the base returns `None`).
///
/// The track-at-index lookup needs the sequence's track list
/// (graph-owned, unreachable from this signature), so the
/// sequence-connected branch resolves to nothing here
/// (`// CPP-PARITY: multicamnode.cpp` `get_connected_render_output`).
///
/// NOTE: the C++ class also overrides
/// `is_input_connected_for_render()` (reports `sources_in` elements
/// as connected whenever a sequence is set); the trait has no such
/// method, so that behavior folds into this one.
fn connected_render_output(
&self,
core: &NodeCore,
input: &str,
element: i32,
) -> Option<NodeId> {
if self.sequence.is_some() && input == SOURCES_INPUT && element >= 0 {
let _ = (core, element);
None
} else {
None
}
}
/// Evaluate outputs (C++ `value()`): pushes the first value of the
/// `sources_in` value array (which, per
/// `active_elements_at_time`, is the currently selected source);
/// pushes nothing when the array is empty.
///
/// The Rust row carries no array payload (the `sources_in` value is
/// the single active element), so the connected value is pushed
/// through as-is; when the input is absent nothing is pushed.
fn value(
&self,
core: &NodeCore,
inputs: &NodeValueRow,
time: Rational,
table: &mut NodeValueTable,
) {
let _ = (core, time);
if let Some(v) = inputs.get(SOURCES_INPUT) {
table.push(v.value_type(), v.clone(), None);
}
}
/// Edge connected (C++ `InputConnectedEvent()`): when a `Sequence`
/// connects to `sequence_in`, stores it and unhides
/// `sequence_type_in`.
fn input_connected(&mut self, core: &mut NodeCore, input: &str, element: i32, source: NodeId) {
let _ = element;
if input == SEQUENCE_INPUT {
// C++ additionally dynamic_casts the source to `Sequence`;
// the graph type-checks connections at edit time, so the
// identity is trusted here.
if let Some(slot) = core.get_input_mut(SEQUENCE_TYPE_INPUT) {
slot.flags &= !crate::input::flags::HIDDEN;
}
self.sequence = Some(source);
}
}
/// Edge disconnected (C++ `InputDisconnectedEvent()`): on
/// `sequence_in` disconnect, clears the stored sequence and re-hides
/// `sequence_type_in`.
fn input_disconnected(
&mut self,
core: &mut NodeCore,
input: &str,
element: i32,
source: NodeId,
) {
let _ = (element, source);
if input == SEQUENCE_INPUT {
if let Some(slot) = core.get_input_mut(SEQUENCE_TYPE_INPUT) {
slot.flags |= crate::input::flags::HIDDEN;
}
self.sequence = None;
}
}
/// Deep copy (C++ `copy()` via `NODE_DEFAULT_FUNCTIONS`).
fn duplicate(&self, _core: &NodeCore) -> Option<Box<dyn NodeBehavior>> {
Some(Box::new(MultiCamNode {
sequence: self.sequence,
}))
}
/// Downcast to [`Self`] (sequence state access).
fn as_any(&self) -> Option<&dyn std::any::Any> {
Some(self)
}
/// Mutable downcast (see [`NodeBehavior::as_any`]).
fn as_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
Some(self)
}
}
/// Constructor (C++ `MultiCamNode::MultiCamNode()`): adds
/// `current_in`/`sources_in`/`sequence_in`/`sequence_type_in` with the
/// defaults, flags and properties documented on the constants, and
/// initializes `sequence_` to null.
pub fn create() -> (NodeCore, Box<dyn NodeBehavior>) {
let mut core = NodeCore::new();
let mut current = crate::input::Input::new(
CURRENT_INPUT,
crate::value::ValueType::Combo,
NodeValue::Combo(0),
);
current.flags |= STATIC_FLAGS;
core.add_input(current);
let mut sources = crate::input::Input::new(
SOURCES_INPUT,
crate::value::ValueType::None,
NodeValue::None,
);
sources.flags |= crate::input::flags::NOT_KEYFRAMABLE | crate::input::flags::ARRAY;
sources.properties = vec![("arraystart".to_string(), NodeValue::Int(1))];
core.add_input(sources);
let mut sequence = crate::input::Input::new(
SEQUENCE_INPUT,
crate::value::ValueType::None,
NodeValue::None,
);
sequence.flags |= crate::input::flags::NOT_KEYFRAMABLE;
core.add_input(sequence);
let mut sequence_type = crate::input::Input::new(
SEQUENCE_TYPE_INPUT,
crate::value::ValueType::Combo,
NodeValue::Combo(0),
);
sequence_type.flags |= STATIC_FLAGS | crate::input::flags::HIDDEN;
core.add_input(sequence_type);
(core, Box::new(MultiCamNode { sequence: None }))
}
/// Register this node type (C++ `k_multicam_node` in
/// `factory.cpp::create_from_factory_index`).
pub fn register(meta: &mut Vec<NodeMeta>) {
meta.push(NodeMeta {
type_id: "org.olivevideoeditor.Olive.multicam",
name: "Multi-Cam",
categories: &[Category::Timeline],
create,
});
}
#[cfg(test)]
mod tests {
use super::*;
use crate::id::NodeId;
use crate::node::NodeBehavior;
use crate::value::{NodeValueTable, ValueType};
use oak_core::Rational;
/// A distinct, valid-looking node id for sequence connect tests.
fn fake_id(n: u32) -> NodeId {
NodeId::from_identity(n as u64).unwrap()
}
#[test]
fn input_names() {
let n = MultiCamNode { sequence: None };
assert_eq!(n.input_name(CURRENT_INPUT), "Current");
assert_eq!(n.input_name(SOURCES_INPUT), "Sources");
assert_eq!(n.input_name(SEQUENCE_INPUT), "Sequence");
assert_eq!(n.input_name(SEQUENCE_TYPE_INPUT), "Sequence Type");
assert_eq!(n.input_name("other_in"), "other_in");
}
#[test]
fn create_wires_inputs() {
let (core, behavior) = create();
assert_eq!(behavior.type_id(), "org.olivevideoeditor.Olive.multicam");
assert_eq!(
core.get_input(CURRENT_INPUT).unwrap().value_type,
ValueType::Combo
);
assert_eq!(
core.get_input(CURRENT_INPUT).unwrap().default,
NodeValue::Combo(0)
);
let sources = core.get_input(SOURCES_INPUT).unwrap();
assert_ne!(sources.flags & crate::input::flags::ARRAY, 0);
assert_ne!(sources.flags & crate::input::flags::NOT_KEYFRAMABLE, 0);
assert!(sources
.properties
.iter()
.any(|(k, v)| k == "arraystart" && v == &NodeValue::Int(1)));
assert_ne!(
core.get_input(SEQUENCE_INPUT).unwrap().flags & crate::input::flags::NOT_KEYFRAMABLE,
0
);
let seq_type = core.get_input(SEQUENCE_TYPE_INPUT).unwrap();
assert_ne!(seq_type.flags & crate::input::flags::HIDDEN, 0);
assert_ne!(seq_type.flags & crate::input::flags::NOT_CONNECTABLE, 0);
assert_ne!(seq_type.flags & crate::input::flags::NOT_KEYFRAMABLE, 0);
}
#[test]
fn current_source_reads_standard_value() {
let (mut core, _) = create();
let node = MultiCamNode { sequence: None };
assert_eq!(node.current_source(&core), 0);
core.set_standard_value(CURRENT_INPUT, -1, NodeValue::Combo(2));
assert_eq!(node.current_source(&core), 2);
}
#[test]
fn source_count_is_array_size() {
let (mut core, _) = create();
// Grow the sources array to 3 elements.
core.input_array_insert(SOURCES_INPUT, 0);
core.input_array_insert(SOURCES_INPUT, 1);
core.input_array_insert(SOURCES_INPUT, 2);
let node = MultiCamNode { sequence: None };
assert_eq!(node.source_count(&core), 3);
}
#[test]
fn rows_and_columns_square_growth() {
assert_eq!(MultiCamNode::rows_and_columns(0), (1, 1));
assert_eq!(MultiCamNode::rows_and_columns(1), (1, 1));
assert_eq!(MultiCamNode::rows_and_columns(2), (1, 2));
assert_eq!(MultiCamNode::rows_and_columns(3), (2, 2));
assert_eq!(MultiCamNode::rows_and_columns(4), (2, 2));
// The smaller dimension grows first: 1x1 -> 1x2 -> 2x2 -> 2x3.
assert_eq!(MultiCamNode::rows_and_columns(5), (2, 3));
assert_eq!(MultiCamNode::rows_and_columns(6), (2, 3));
assert_eq!(MultiCamNode::rows_and_columns(9), (3, 3));
}
#[test]
fn index_row_cols_round_trip() {
for (i, rows, cols) in [
(0, 3, 3),
(1, 3, 3),
(2, 3, 3),
(3, 3, 3),
(8, 3, 3),
(5, 2, 3),
] {
let (r, c) = MultiCamNode::index_to_row_cols(i, rows, cols);
assert_eq!(r, i / cols);
assert_eq!(c, i % cols);
assert_eq!(MultiCamNode::rows_cols_to_index(r, c, rows, cols), i);
}
}
#[test]
fn active_elements_selects_current_source() {
let (mut core, _) = create();
core.input_array_insert(SOURCES_INPUT, 0);
core.input_array_insert(SOURCES_INPUT, 1);
core.input_array_insert(SOURCES_INPUT, 2);
let node = MultiCamNode { sequence: None };
core.set_standard_value(CURRENT_INPUT, -1, NodeValue::Combo(1));
assert_eq!(node.active_elements(&core), vec![1]);
// Out of range -> no active elements.
core.set_standard_value(CURRENT_INPUT, -1, NodeValue::Combo(9));
assert!(node.active_elements(&core).is_empty());
}
#[test]
fn ignore_inputs_always_sequence() {
let node = MultiCamNode { sequence: None };
assert_eq!(
node.ignore_inputs_for_rendering(),
&[SEQUENCE_INPUT.to_string()]
);
}
#[test]
fn value_pushes_connected_source_value() {
let (core, behavior) = create();
let mut row = NodeValueRow::default();
row.insert(SOURCES_INPUT.to_string(), NodeValue::Float(7.0));
let mut table = NodeValueTable::default();
behavior.value(&core, &row, Rational::new(0, 1), &mut table);
assert_eq!(table.get(ValueType::Float), Some(&NodeValue::Float(7.0)));
}
#[test]
fn value_pushes_nothing_when_sources_empty() {
let (core, behavior) = create();
let mut table = NodeValueTable::default();
behavior.value(
&core,
&NodeValueRow::default(),
Rational::new(0, 1),
&mut table,
);
assert!(table.is_empty());
}
#[test]
fn sequence_connect_toggles_hidden_flag() {
let (mut core, mut behavior) = create();
assert_ne!(
core.get_input(SEQUENCE_TYPE_INPUT).unwrap().flags & crate::input::flags::HIDDEN,
0
);
let seq = fake_id(7);
behavior.input_connected(&mut core, SEQUENCE_INPUT, -1, seq);
assert_eq!(
core.get_input(SEQUENCE_TYPE_INPUT).unwrap().flags & crate::input::flags::HIDDEN,
0
);
behavior.input_disconnected(&mut core, SEQUENCE_INPUT, -1, seq);
assert_ne!(
core.get_input(SEQUENCE_TYPE_INPUT).unwrap().flags & crate::input::flags::HIDDEN,
0
);
}
#[test]
fn duplicate_copies_sequence() {
let seq = fake_id(7);
let node = MultiCamNode {
sequence: Some(seq),
};
let copy = node.duplicate(&NodeCore::new()).unwrap();
assert_eq!(copy.type_id(), "org.olivevideoeditor.Olive.multicam");
let down = copy
.as_any()
.unwrap()
.downcast_ref::<MultiCamNode>()
.unwrap();
assert_eq!(down.sequence, Some(seq));
}
}