Files
oak-editor/crates/oaknode/src/node.rs
T
Mike-Solar 9003b78176 feat: built-in effect params, clip click-select, effect drag-and-drop, project load with plugins
- serializer resolves node types through the factory's dynamic
  (runtime-registered OpenFX) entries, so a project carrying plugin
  nodes loads again (was: "unknown node type"); covered by a new
  CI-gated round-trip test driving the real fixture plugin
- built-in effect nodes expose their inputs as inspector parameters
  like the C++ parameter editor: localized input names from the
  behavior, combo option tables via the new
  NodeBehavior::input_combo_strings (16 nodes, string-for-string from
  the C++ set_combo_box_strings), connection/data inputs excluded
- effect library: live drag-and-drop — onto the inspector's effect
  stack (lands at the indicator position) and onto the node editor
  canvas (creates the node at the drop point); double-click still
  appends to the selected clip
- inspector parameter controls are no longer recreated per render
  (gpui stack view caches them per effect), so sliders drag and
  checkboxes click; the view observes the engine and silently re-syncs
  values (undo/redo land on the widgets)
- timeline: left-press selects clips (plain/keep-multi/Ctrl-Cmd
  toggle); clip moves clamp the shared delta so no clip of a linked
  group lands before frame 0 instead of failing with "invalid move
  target"
- oakplugin: createInstance-rejected instances skip the destroyInstance
  notification (the plugin never owned them); vendor-suite fetchSuite
  misses moved behind OAK_OFX_TRACE; the worker logs the discovered/
  registered plugin counts
- CI: the OFX probe step also runs the serialization round-trip test
- gpui submodule: params view caching, clip click-select, library
  drag payload, graph_position_at
2026-08-20 23:46:40 +08:00

734 lines
21 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/>.
//! Node core data and the behavior trait — the C++ `Node` class,
//! restructured for Rust. COVERAGE.md maps every C++ method to its
//! Rust home; this file carries the virtual surface ([`NodeBehavior`])
//! and the shared data ([`NodeCore`]).
use oakcore_rs::{Rational, TimeRange};
use crate::id::NodeId;
use crate::input::{Input, ValueHint};
use crate::keyframe::KeyframeTrack;
use crate::value::{NodeValue, NodeValueRow, NodeValueTable};
/// Node category (mirrors C++ `Node::CategoryID` order).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Category {
/// Output nodes.
Output,
/// Effects.
Effect,
/// Generators.
Generator,
/// Inputs (footage).
Input,
/// Math/combine.
Math,
/// Color.
Color,
/// Distort.
Distort,
/// Filter.
Filter,
/// Keying.
Keying,
/// OpenFX plugins.
OpenFx,
/// Timeline structural (tracks/blocks).
Timeline,
/// Groups.
Group,
}
/// A gizmo: viewport-interaction data object (C++ `NodeGizmo`). Data
/// only — drawing and mouse handling live in facade/app.
#[derive(Clone)]
pub struct Gizmo {
/// Keyframed position inputs (track references).
pub position_inputs: Vec<(String, i32, i32)>,
/// Current drag position.
pub drag_point: (f64, f64),
}
/// Shared per-node data (the C++ `Node` member fields). Behavior lives
/// in [`NodeBehavior`].
#[derive(Clone)]
pub struct NodeCore {
/// Inputs by id (array inputs hold multiple elements).
pub inputs: Vec<Input>,
/// Keyframe tracks per (input, element).
pub keyframes: Vec<(String, i32, KeyframeTrack)>,
/// Caches as opaque oakrender handles.
pub caches: NodeCaches,
/// Node flags bitmask (hidden, dont-show-in-param-view, ...).
pub flags: u64,
/// Editor position (serialization only).
pub position: (f64, f64),
/// User label (C++ label_).
pub label: String,
/// Color override index (-1 = none).
pub override_color: i32,
/// Effect input id (C++ effect_input_).
pub effect_input: String,
/// Value hints per (input, element).
pub hints: Vec<((String, i32), ValueHint)>,
/// Group-context positions (C++ context_positions_).
pub context_positions: Vec<(NodeId, (f64, f64), bool)>,
/// Linked nodes (C++ links_).
pub links: Vec<NodeId>,
/// Bin folder membership (None = not in the bin).
pub bin_folder: Option<NodeId>,
/// Caches master toggle (C++ caches_enabled_).
pub caches_enabled: bool,
/// Gizmos owned by this node.
pub gizmos: Vec<Gizmo>,
/// Currently dragged gizmo index.
pub current_gizmo: Option<usize>,
/// Standard (non-keyframed) values keyed by (input id, element);
/// falls back to [`Input::default`] (the C++ `NodeInputImmediate`
/// `standard_value_` map — `// CPP-PARITY: inputimmediate.h`).
pub standard_values: std::collections::HashMap<(String, i32), crate::value::NodeValue>,
}
/// The always-present "enabled" input id (C++
/// `Node::k_enabled_input`, `"enabled_in"`).
pub const ENABLED_INPUT: &str = "enabled_in";
/// Node flag bits (C++ `Node::Flag` enum; values cross the C ABI and
/// project XML as ints — `// CPP-PARITY: node.h:109`).
pub mod flags {
/// `k_dont_show_in_param_view`.
pub const DONT_SHOW_IN_PARAM_VIEW: u64 = 0x1;
/// `k_video_effect`.
pub const VIDEO_EFFECT: u64 = 0x2;
/// `k_audio_effect`.
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 {
/// Bare core with no inputs (vacant arena slots).
pub fn empty() -> NodeCore {
NodeCore {
inputs: Vec::new(),
keyframes: Vec::new(),
caches: NodeCaches::default(),
flags: 0,
position: (0.0, 0.0),
label: String::new(),
override_color: -1,
effect_input: String::new(),
hints: Vec::new(),
context_positions: Vec::new(),
links: Vec::new(),
bin_folder: None,
caches_enabled: true,
gizmos: Vec::new(),
current_gizmo: None,
standard_values: std::collections::HashMap::new(),
}
}
/// Fresh core for a node constructor: adds the standard `enabled_in`
/// boolean input (default true) first, exactly like the C++ `Node`
/// constructor (`// CPP-PARITY: node.cpp:91`).
pub fn new() -> NodeCore {
let mut core = NodeCore::empty();
core.add_input(Input::new(
ENABLED_INPUT,
crate::value::ValueType::Boolean,
crate::value::NodeValue::Boolean(true),
));
core
}
/// Append an input descriptor.
pub fn add_input(&mut self, input: Input) {
self.inputs.push(input);
}
/// Look up an input by id.
pub fn get_input(&self, id: &str) -> Option<&Input> {
self.inputs.iter().find(|i| i.id == id)
}
/// Mutable input lookup by id.
pub fn get_input_mut(&mut self, id: &str) -> Option<&mut Input> {
self.inputs.iter_mut().find(|i| i.id == id)
}
/// Index of the input in declaration order (C++ `inputs()`).
pub fn input_index(&self, id: &str) -> Option<usize> {
self.inputs.iter().position(|i| i.id == id)
}
/// True when the node declares `id` (C++ `has_input_with_id`).
pub fn has_input(&self, id: &str) -> bool {
self.inputs.iter().any(|i| i.id == id)
}
/// Remove the input `id`, if present (C++ `Node::remove_input`).
pub fn remove_input(&mut self, id: &str) -> bool {
let before = self.inputs.len();
self.inputs.retain(|i| i.id != id);
self.inputs.len() != before
}
/// Declared value type of `id` (C++ `get_input_data_type`).
pub fn input_data_type(&self, id: &str) -> Option<crate::value::ValueType> {
self.get_input(id).map(|i| i.value_type)
}
/// Flag bits of `id` (C++ `get_input_flags`).
pub fn input_flags(&self, id: &str) -> u32 {
self.get_input(id).map(|i| i.flags).unwrap_or(0)
}
/// Display name of `id` (C++ `get_input_name`, non-virtual part).
pub fn input_display_name(&self, id: &str) -> String {
self.get_input(id)
.map(|i| i.display_name.clone())
.unwrap_or_else(|| id.to_string())
}
/// Array size of `id` (0 for non-array inputs; C++ `input_array_size`).
pub fn input_array_size(&self, id: &str) -> usize {
self.get_input(id).map(|i| i.array_size).unwrap_or(0)
}
/// Grow/shrink an array input's element count, inserting/removing the
/// given element index. Per-element standard values and keyframe
/// tracks shift to keep their element mapping (C++
/// `Node::input_array_insert`/`input_array_remove`, values half).
pub fn input_array_insert(&mut self, id: &str, index: usize) {
if let Some(input) = self.get_input_mut(id) {
input.array_size += 1;
}
let size = self.input_array_size(id);
// Shift standard values and keyframe tracks up one element.
for e in (index + 1..size).rev() {
self.move_element_value(id, e - 1, e);
self.move_element_keyframes(id, e - 1, e);
}
// The freshly inserted slot carries no value or track.
self.standard_values.remove(&(id.to_string(), index as i32));
self.remove_element_keyframes(id, index);
}
/// See [`NodeCore::input_array_insert`].
pub fn input_array_remove(&mut self, id: &str, index: usize) {
let size = self.input_array_size(id);
if index >= size {
return;
}
// Drop the removed element's own value and track first (the
// shift below only overwrites targets whose source has data).
self.standard_values.remove(&(id.to_string(), index as i32));
self.remove_element_keyframes(id, index);
// Shift values/keyframes down one element, then drop the tail.
for e in index..size.saturating_sub(1) {
self.move_element_value(id, e + 1, e);
self.move_element_keyframes(id, e + 1, e);
}
self.standard_values
.remove(&(id.to_string(), (size - 1) as i32));
self.remove_element_keyframes(id, size - 1);
if let Some(input) = self.get_input_mut(id) {
input.array_size = input.array_size.saturating_sub(1);
}
}
fn move_element_value(&mut self, id: &str, from: usize, to: usize) {
let key = |e: usize| (id.to_string(), e as i32);
if let Some(v) = self.standard_values.remove(&key(from)) {
self.standard_values.insert(key(to), v);
}
}
fn move_element_keyframes(&mut self, id: &str, from: usize, to: usize) {
let track = match self
.keyframes
.iter()
.position(|(i, e, _)| i == id && *e == from as i32)
{
Some(i) => self.keyframes.remove(i).2,
None => return,
};
// Replace or insert at the target element.
if let Some(slot) = self
.keyframes
.iter_mut()
.find(|(i, e, _)| i == id && *e == to as i32)
{
slot.2 = track;
} else {
self.keyframes.push((id.to_string(), to as i32, track));
}
}
fn remove_element_keyframes(&mut self, id: &str, element: usize) {
self.keyframes
.retain(|(i, e, _)| !(i == id && *e == element as i32));
}
/// The keyframe track for (input, element), if any.
pub fn keyframe_track(&self, id: &str, element: i32) -> Option<&KeyframeTrack> {
self.keyframes
.iter()
.find(|(i, e, _)| i == id && *e == element)
.map(|(_, _, t)| t)
}
/// Mutable keyframe track access, creating one on demand.
pub fn keyframe_track_mut(&mut self, id: &str, element: i32) -> &mut KeyframeTrack {
if let Some(i) = self
.keyframes
.iter()
.position(|(i, e, _)| i == id && *e == element)
{
return &mut self.keyframes[i].2;
}
self.keyframes
.push((id.to_string(), element, KeyframeTrack::default()));
let last = self.keyframes.len() - 1;
&mut self.keyframes[last].2
}
/// Standard (non-keyframed) value of (input, element): the per-element
/// override or the input's default (C++ `get_standard_value`).
pub fn standard_value(&self, id: &str, element: i32) -> crate::value::NodeValue {
self.standard_values
.get(&(id.to_string(), element))
.cloned()
.or_else(|| self.get_input(id).map(|i| i.default.clone()))
.unwrap_or(crate::value::NodeValue::None)
}
/// Set the standard value of (input, element) (C++ `set_standard_value`).
pub fn set_standard_value(&mut self, id: &str, element: i32, value: crate::value::NodeValue) {
self.standard_values
.insert((id.to_string(), element), value);
}
/// Value of `input` at `time`: keyframes when the (input, element)
/// track is non-empty, else the standard value (C++
/// `get_value_at_time`; `// CPP-PARITY: node.cpp:465`).
pub fn value_at_time(
&self,
id: &str,
element: i32,
time: oakcore_rs::Rational,
) -> crate::value::NodeValue {
match self.keyframe_track(id, element) {
Some(track) if !track.keys().is_empty() => track
.value_at(time)
.unwrap_or_else(|| self.standard_value(id, element)),
_ => self.standard_value(id, element),
}
}
/// Whether (input, element) is being keyframed (C++
/// `Node::is_input_keyframing`): the keyframe track exists and is
/// non-empty.
pub fn is_input_keyframing(&self, id: &str, element: i32) -> bool {
self.keyframe_track(id, element)
.map(|t| !t.keys().is_empty())
.unwrap_or(false)
}
/// Whether (input, element) is static at evaluation time (C++
/// `Node::is_input_static`): neither connected nor keyframed.
/// `inputs` is the render-time input row — a connected input appears
/// in the row under its id.
pub fn is_input_static(
&self,
inputs: &crate::value::NodeValueRow,
id: &str,
element: i32,
) -> bool {
!inputs.contains_key(id) && !self.is_input_keyframing(id, element)
}
/// Set the value hint for (input, element) (C++ `set_value_hint_for_input`).
pub fn set_value_hint(&mut self, id: &str, element: i32, hint: ValueHint) {
if let Some(slot) = self
.hints
.iter_mut()
.find(|((i, e), _)| i == id && *e == element)
{
slot.1 = hint;
} else {
self.hints.push(((id.to_string(), element), hint));
}
}
/// The value hint for (input, element), if any.
pub fn value_hint(&self, id: &str, element: i32) -> Option<&ValueHint> {
self.hints
.iter()
.find(|((i, e), _)| i == id && *e == element)
.map(|(_, h)| h)
}
/// True when `context` appears in this node's context-position map
/// (C++ `context_contains_node`).
pub fn context_contains(&self, context: NodeId) -> bool {
self.context_positions.iter().any(|(c, _, _)| *c == context)
}
/// Set this node's position in `context` (C++
/// `set_node_position_in_context`). Returns true when newly added.
pub fn set_context_position(
&mut self,
context: NodeId,
x: f64,
y: f64,
expanded: bool,
) -> bool {
let added = !self.context_contains(context);
if let Some(slot) = self
.context_positions
.iter_mut()
.find(|(c, _, _)| *c == context)
{
slot.1 = (x, y);
slot.2 = expanded;
} else {
self.context_positions.push((context, (x, y), expanded));
}
added
}
/// Remove this node from `context`; false when absent (C++
/// `remove_node_from_context`).
pub fn remove_from_context(&mut self, context: NodeId) -> bool {
let before = self.context_positions.len();
self.context_positions.retain(|(c, _, _)| *c != context);
self.context_positions.len() != before
}
}
/// The node's oakrender caches (frame/thumbnail/audio/waveform),
/// owned handles released with the node.
///
/// Cross-module payloads: the cache objects live behind opaque
/// oakrender handles created lazily by the facade (oakengine reads
/// `caches.video` directly through the C ABI), and oakrender depends on
/// oaknode, so no Rust type is nameable here — the handle is the
/// boundary representation.
#[derive(Clone)]
pub struct NodeCaches {
/// Video frame hash cache.
pub video: crate::handle::CHandle,
/// Thumbnail cache.
pub thumbnail: crate::handle::CHandle,
/// Audio playback cache.
pub audio: crate::handle::CHandle,
/// Waveform cache.
pub waveform: crate::handle::CHandle,
}
impl Default for NodeCaches {
/// All empty handles (caches are created lazily when a node
/// enters a project; `// CPP-PARITY: node.cpp:102`).
fn default() -> Self {
NodeCaches {
video: crate::handle::CHandle::null(),
thumbnail: crate::handle::CHandle::null(),
audio: crate::handle::CHandle::null(),
waveform: crate::handle::CHandle::null(),
}
}
}
/// The polymorphic surface of a node — every C++ virtual on `Node`
/// becomes a method here (see COVERAGE.md §1/§7/§8/§10).
pub trait NodeBehavior: Send {
/// Human-readable name (C++ `name()`).
fn name(&self) -> &str;
/// Short menu name (C++ `short_name()`; defaults to [`Self::name`]).
fn short_name(&self) -> &str {
self.name()
}
/// Stable type id (C++ `id()`).
fn type_id(&self) -> &str;
/// Categories (C++ `category()`).
fn categories(&self) -> &[Category] {
&[]
}
/// Sub-category (C++ `sub_category()`).
fn sub_category(&self) -> &str {
""
}
/// Description (C++ `description()`).
fn description(&self) -> &str {
""
}
/// Localized input name (C++ `get_input_name()` virtual).
fn input_name<'a>(&self, id: &'a str) -> &'a str {
// The standard enabled input displays as "Enabled" on every node
// (`// CPP-PARITY: node.cpp:155` retranslate).
if id == crate::node::ENABLED_INPUT {
"Enabled"
} else {
id
}
}
/// The option labels of a combo input (C++ `set_combo_box_strings`,
/// called from each node's `retranslate`). Empty for non-combo inputs;
/// the default is no options.
fn input_combo_strings(&self, id: &str) -> Vec<&'static str> {
let _ = id;
Vec::new()
}
/// Inputs excluded from rendering (C++ `ignore_inputs_for_rendering()`).
fn ignore_inputs_for_rendering(&self) -> &[String] {
&[]
}
/// Array elements active at `time` (C++ `get_active_elements_at_time()`).
fn active_elements_at_time(&self, input: &str, time: Rational) -> Vec<i32> {
let _ = (input, time);
Vec::new()
}
/// Cache ranges (C++ `get_video_cache_range()` /
/// `get_audio_cache_range()`).
fn video_cache_range(&self, core: &NodeCore) -> TimeRange {
let _ = core;
TimeRange::default()
}
/// Audio cache range.
fn audio_cache_range(&self, core: &NodeCore) -> TimeRange {
let _ = core;
TimeRange::default()
}
/// Value hint for an input (C++ `get_value_hint_for_input()` virtual).
fn value_hint_for_input(&self, input: &str) -> Option<ValueHint> {
let _ = input;
None
}
/// Render-time connection resolution (C++
/// `get_connected_render_output()`; Group overrides).
fn connected_render_output(
&self,
core: &NodeCore,
input: &str,
element: i32,
) -> Option<NodeId> {
let _ = (core, input, element);
None
}
/// Time adjustment through this node (C++
/// `input_time_adjustment()`/`output_time_adjustment()`; clips
/// override for speed/reverse).
fn input_time_adjustment(
&self,
input: &str,
element: i32,
time: TimeRange,
traverse: bool,
) -> TimeRange {
let _ = (input, element, traverse);
time
}
/// Output-side time adjustment.
fn output_time_adjustment(
&self,
input: &str,
element: i32,
time: TimeRange,
traverse: bool,
) -> TimeRange {
let _ = (input, element, traverse);
time
}
/// Evaluate outputs (C++ `value()`).
fn value(
&self,
core: &NodeCore,
inputs: &NodeValueRow,
time: Rational,
table: &mut NodeValueTable,
) {
let _ = (core, inputs, time, table);
}
/// Process a span of samples (C++ `process_samples()`).
fn process_samples(
&self,
core: &NodeCore,
inputs: &NodeValueRow,
range: TimeRange,
output: &mut crate::value::SampleBuffer,
) {
let _ = (core, inputs, range, output);
}
/// Direct frame generation (C++ `generate_frame()`; CPU-render
/// nodes).
fn generate_frame(
&self,
core: &NodeCore,
frame: &mut crate::handle::CHandle,
time: Rational,
) {
let _ = (core, frame, time);
}
/// Shader code request (C++ `get_shader_code()`; GPU nodes).
fn shader_code(&self, request: &str) -> Option<String> {
let _ = request;
None
}
/// Gizmo transform/positions (C++ `gizmo_transformation()` /
/// `update_gizmo_positions()`).
fn gizmo_update(&self, core: &NodeCore, row: &NodeValueRow) {
let _ = (core, row);
}
/// Gizmo drag callbacks (C++ `gizmo_drag_start/move`).
fn gizmo_drag(&mut self, core: &mut NodeCore, start: bool, x: f64, y: f64, modifiers: u32) {
let _ = (core, start, x, y, modifiers);
}
/// Input value changed (C++ `InputValueChangedEvent`).
fn input_value_changed(&mut self, core: &mut NodeCore, input: &str, element: i32) {
let _ = (core, input, element);
}
/// Edge connected to an input (C++ `InputConnectedEvent`).
fn input_connected(&mut self, core: &mut NodeCore, input: &str, element: i32, source: NodeId) {
let _ = (core, input, element, source);
}
/// Edge disconnected from an input (C++ `InputDisconnectedEvent`).
fn input_disconnected(
&mut self,
core: &mut NodeCore,
input: &str,
element: i32,
source: NodeId,
) {
let _ = (core, input, element, source);
}
/// Someone connected to this node's output (C++
/// `OutputConnectedEvent`).
fn output_connected(&mut self, core: &mut NodeCore, target: NodeId, input: &str, element: i32) {
let _ = (core, target, input, element);
}
/// Output disconnected (C++ `OutputDisconnectedEvent`).
fn output_disconnected(
&mut self,
core: &mut NodeCore,
target: NodeId,
input: &str,
element: i32,
) {
let _ = (core, target, input, element);
}
/// Attached to a preview/viewer (C++ `ConnectedToPreviewEvent`).
fn connected_to_preview(&mut self, core: &mut NodeCore) {
let _ = core;
}
/// Inserted into / removed from a project graph (C++
/// `AddedToGraphEvent` / `RemovedFromGraphEvent`).
fn added_to_graph(&mut self, core: &mut NodeCore) {
let _ = core;
}
/// See [`NodeBehavior::added_to_graph`].
fn removed_from_graph(&mut self, core: &mut NodeCore) {
let _ = core;
}
/// Node links changed (C++ `LinkChangeEvent`).
fn link_changed(&mut self, core: &mut NodeCore) {
let _ = core;
}
/// Deep copy (C++ `copy()`); None = not copiable.
fn duplicate(&self, core: &NodeCore) -> Option<Box<dyn NodeBehavior>>;
/// Custom load/save (C++ `load_custom()`/`save_custom()`).
fn load_custom(
&mut self,
core: &mut NodeCore,
reader: &mut dyn crate::serializer::XmlRead,
) -> bool {
let _ = core;
// The default has no custom state; consume the segment so the
// node-body parser continues at the correct depth (the reader is
// positioned on the `<custom>` start element).
reader.skip_current_element();
true
}
/// See [`NodeBehavior::load_custom`].
fn save_custom(&self, core: &NodeCore, writer: &mut dyn crate::serializer::XmlWrite) {
let _ = (core, writer);
}
/// Post-load fixups (C++ `PostLoadEvent` / `LoadFinishedEvent`).
fn post_load(&mut self, core: &mut NodeCore) {
let _ = core;
}
/// Legacy input id mapping for old project versions (C++
/// `get_input_id_for_legacy_id()`; default identity).
fn map_legacy_input_id<'a>(&self, id: &'a str) -> &'a str {
id
}
/// Downcast to the concrete behavior (used by the timeline families
/// to reach `FolderBehavior`/`TrackBehavior`/`SequenceBehavior`
/// state). Default `None`; concrete behaviors override.
fn as_any(&self) -> Option<&dyn std::any::Any> {
None
}
/// Mutable downcast (see [`NodeBehavior::as_any`]).
fn as_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
None
}
}