refactor: workspace layout — crates/, app at root, legacy C++ removed

Single mechanical restructure commit:
- root Cargo.toml = oakapp bin + workspace; one cargo build produces
  oakapp, oak-cli, oak-worker, liboakengine.dylib
- app/rust/src -> src/ (app at repo root, no rust/ nesting)
- src/<mod>/rust -> crates/oak<mod>; src/oakcore-rs -> crates/oakcore;
  src/bindings/oakotio -> crates/oakotio; src/engine/rust ->
  crates/oakengine (keeps cdylib+staticlib+rlib)
- public C headers include/<mod>/ -> crates/oakengine/include/<mod>/
- OFX SDK headers vendored into crates/oakplugin/ofx/ (HostSupport gone)
- legacy deleted: old src/ C++ modules, engine/, core/, ffmpeg_bridge/,
  app/ (Qt), cli/worker C++, root CMakeLists, third_party/KDDockWidgets
  submodule, otio-install, all build-* output (~40GB)
- oakstorage kept but excluded from the workspace (skeleton w/ todos);
  gpui excluded (own workspace)
- verified: cargo build green, cargo test --workspace 1845/0
  (with the documented OCIO_RS_* env override for the homebrew OCIO)
This commit is contained in:
2026-08-10 20:24:25 +08:00
parent f8540e3892
commit 013a175707
4212 changed files with 8331 additions and 2274987 deletions
+374
View File
@@ -0,0 +1,374 @@
// 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/>.
//! Blocks (C++ `Block`, `ClipBlock`, `GapBlock`, `TransitionBlock`).
//! `// CPP-PARITY: src/node/src/block/*`.
use oakcore_rs::{Rational, TimeRange};
use crate::id::NodeId;
use crate::input::Input;
use crate::node::{Category, NodeBehavior, NodeCore};
use crate::value::{NodeValue, ValueType};
/// Block core data (C++ `Block` members): timeline span + media range.
#[derive(Clone)]
pub struct BlockCore {
/// Position and length on the timeline.
pub range: TimeRange,
/// Media in-point.
pub media_in: Rational,
/// Speed (1.0 = normal).
pub speed: f64,
/// Reversed flag.
pub reversed: bool,
/// Linked blocks (C++ block_links_).
pub links: Vec<NodeId>,
/// Enabled flag (C++ `Block::enabled_`).
pub enabled: bool,
/// Maintain audio pitch (ClipBlock `maintain_audio_pitch_in`).
pub maintain_audio_pitch: bool,
/// Loop mode (ClipBlock `loop_in`).
pub loop_mode: i32,
/// Owning track id (None when trackless).
pub track: Option<NodeId>,
}
impl Default for BlockCore {
fn default() -> Self {
BlockCore {
range: TimeRange::new(Rational::new(0, 1), Rational::new(1, 1)),
media_in: Rational::new(0, 1),
speed: 1.0,
reversed: false,
links: Vec::new(),
enabled: true,
maintain_audio_pitch: false,
loop_mode: 0,
track: None,
}
}
}
impl BlockCore {
/// The timeline in-point (C++ `Block::in()`).
pub fn in_(&self) -> Rational {
self.range.in_()
}
/// The timeline out-point (C++ `Block::out()`).
pub fn out(&self) -> Rational {
self.range.out()
}
/// The timeline length (C++ `Block::length()`).
pub fn length(&self) -> Rational {
self.range.length()
}
/// Set the in-point, keeping the length (C++ `Block::set_in`).
pub fn set_in(&mut self, in_: Rational) {
let length = self.length();
self.range = TimeRange::new(in_, in_ + length);
}
/// Set the out-point, keeping the in-point (C++ `Block::set_out`).
pub fn set_out(&mut self, out: Rational) {
self.range = TimeRange::new(self.in_(), out);
}
/// Set the length, keeping the media out anchored (C++
/// `Block::set_length_and_media_out`): the timeline in-point shifts
/// so the out-point stays put, and the media in follows it.
pub fn set_length_and_media_out(&mut self, length: Rational) {
let out = self.in_() + self.length();
self.range = TimeRange::new(out - length, out);
self.media_in = self.range.in_();
}
/// Set the length, keeping the media in anchored (C++
/// `Block::set_length_and_media_in`): the in-point stays, the
/// out-point shifts.
pub fn set_length_and_media_in(&mut self, length: Rational) {
self.range = TimeRange::new(self.in_(), self.in_() + length);
}
/// Media out (in + length; C++ `Block::media_out`).
pub fn media_out(&self) -> Rational {
self.media_in + self.length()
}
}
/// Clip block behavior (media-bearing block; C++ `ClipBlock`).
pub struct ClipBlockBehavior {
/// Block core.
pub core: BlockCore,
/// Connected footage (via the footage input edge).
pub footage: Option<NodeId>,
}
/// Gap block behavior (empty span; C++ `GapBlock`).
pub struct GapBlockBehavior {
/// Block core.
pub core: BlockCore,
}
/// Transition block behavior (C++ `TransitionBlock`).
pub struct TransitionBlockBehavior {
/// Block core.
pub core: BlockCore,
/// In offset (C++ in_offset).
pub in_offset: Rational,
/// Out offset.
pub out_offset: Rational,
}
/// ClipBlock input ids (C++ `clip.cpp`).
pub mod clip_input {
/// `media_in_in` (rational, static).
pub const MEDIA_IN: &str = "media_in_in";
/// `speed_in` (float, static).
pub const SPEED: &str = "speed_in";
/// `reverse_in` (boolean, static).
pub const REVERSE: &str = "reverse_in";
/// `maintain_audio_pitch_in` (boolean, static).
pub const MAINTAIN_AUDIO_PITCH: &str = "maintain_audio_pitch_in";
/// `loop_in` (combo, static).
pub const LOOP_MODE: &str = "loop_in";
}
/// TransitionBlock connection inputs (C++ `transition.cpp`).
pub mod transition_input {
/// `out_block_in` (the outgoing side).
pub const OUT_BLOCK: &str = "out_block_in";
/// `in_block_in` (the incoming side).
pub const IN_BLOCK: &str = "in_block_in";
}
impl ClipBlockBehavior {
/// New clip with a default length of one second.
pub fn new() -> Self {
ClipBlockBehavior {
core: BlockCore::default(),
footage: None,
}
}
}
impl GapBlockBehavior {
/// New gap with a default length of one second.
pub fn new() -> Self {
GapBlockBehavior {
core: BlockCore::default(),
}
}
}
impl TransitionBlockBehavior {
/// New transition with zero offsets (C++ `TransitionBlock`).
pub fn new() -> Self {
TransitionBlockBehavior {
core: BlockCore::default(),
in_offset: Rational::new(0, 1),
out_offset: Rational::new(0, 1),
}
}
/// Whether both sides are connected to clips (C++
/// `TransitionBlock::is_dual`, graph-side query; the ffi checks the
/// edges).
pub fn is_dual(&self) -> bool {
false
}
}
fn block_categories() -> &'static [Category] {
&[Category::Timeline]
}
impl NodeBehavior for ClipBlockBehavior {
fn name(&self) -> &str {
"Clip"
}
fn type_id(&self) -> &str {
"org.olivevideoeditor.Olive.clipblock"
}
fn categories(&self) -> &[Category] {
block_categories()
}
fn as_any(&self) -> Option<&dyn std::any::Any> {
Some(self)
}
fn as_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
Some(self)
}
fn duplicate(&self, _core: &NodeCore) -> Option<Box<dyn NodeBehavior>> {
Some(Box::new(ClipBlockBehavior {
core: self.core.clone(),
footage: self.footage,
}))
}
}
impl NodeBehavior for GapBlockBehavior {
fn name(&self) -> &str {
"Gap"
}
fn type_id(&self) -> &str {
"org.olivevideoeditor.Olive.gapblock"
}
fn categories(&self) -> &[Category] {
block_categories()
}
fn as_any(&self) -> Option<&dyn std::any::Any> {
Some(self)
}
fn as_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
Some(self)
}
fn duplicate(&self, _core: &NodeCore) -> Option<Box<dyn NodeBehavior>> {
Some(Box::new(GapBlockBehavior {
core: self.core.clone(),
}))
}
}
impl NodeBehavior for TransitionBlockBehavior {
fn name(&self) -> &str {
"Transition"
}
fn type_id(&self) -> &str {
"org.olivevideoeditor.Olive.transitionblock"
}
fn categories(&self) -> &[Category] {
block_categories()
}
fn as_any(&self) -> Option<&dyn std::any::Any> {
Some(self)
}
fn as_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
Some(self)
}
fn duplicate(&self, _core: &NodeCore) -> Option<Box<dyn NodeBehavior>> {
Some(Box::new(TransitionBlockBehavior {
core: self.core.clone(),
in_offset: self.in_offset,
out_offset: self.out_offset,
}))
}
}
/// Constructor for a clip block (C++ `ClipBlock::ClipBlock()`): adds the
/// static clip inputs (`media_in_in`, `speed_in`, `reverse_in`,
/// `maintain_audio_pitch_in`, `autocache_in`, `loop_in`).
pub fn clip_create() -> (NodeCore, Box<dyn NodeBehavior>) {
let mut core = NodeCore::new();
let mut media_in = Input::new(
clip_input::MEDIA_IN,
ValueType::Rational,
NodeValue::Rational(Rational::new(0, 1)),
);
media_in.flags |= crate::input::flags::NOT_CONNECTABLE | crate::input::flags::NOT_KEYFRAMABLE;
core.add_input(media_in);
let mut speed = Input::new(
clip_input::SPEED,
ValueType::Float,
NodeValue::Float(1.0),
);
speed.flags |= crate::input::flags::NOT_CONNECTABLE | crate::input::flags::NOT_KEYFRAMABLE;
speed.properties = vec![
("min".to_string(), NodeValue::Float(0.0)),
("max".to_string(), NodeValue::Float(4.0)),
];
core.add_input(speed);
let mut reverse = Input::new(
clip_input::REVERSE,
ValueType::Boolean,
NodeValue::Boolean(false),
);
reverse.flags |= crate::input::flags::NOT_CONNECTABLE | crate::input::flags::NOT_KEYFRAMABLE;
core.add_input(reverse);
let mut pitch = Input::new(
clip_input::MAINTAIN_AUDIO_PITCH,
ValueType::Boolean,
NodeValue::Boolean(false),
);
pitch.flags |= crate::input::flags::NOT_CONNECTABLE | crate::input::flags::NOT_KEYFRAMABLE;
core.add_input(pitch);
let mut loop_mode = Input::new(
clip_input::LOOP_MODE,
ValueType::Combo,
NodeValue::Combo(0),
);
loop_mode.flags |= crate::input::flags::NOT_CONNECTABLE | crate::input::flags::NOT_KEYFRAMABLE;
loop_mode.properties = vec![(
"combobox_strings".to_string(),
NodeValue::Binary("No Loop,Loop Clips,Loop Section".as_bytes().to_vec()),
)];
core.add_input(loop_mode);
(core, Box::new(ClipBlockBehavior::new()))
}
/// Constructor for a gap block (C++ `GapBlock::GapBlock()`): no own
/// inputs.
pub fn gap_create() -> (NodeCore, Box<dyn NodeBehavior>) {
(NodeCore::new(), Box::new(GapBlockBehavior::new()))
}
/// Constructor for a transition block (C++ `TransitionBlock`): adds the
/// `out_block_in`/`in_block_in` node-typed connection inputs.
pub fn transition_create() -> (NodeCore, Box<dyn NodeBehavior>) {
let mut core = NodeCore::new();
let mut out = Input::new(
transition_input::OUT_BLOCK,
ValueType::NodeRef,
NodeValue::None,
);
out.flags |= crate::input::flags::NOT_KEYFRAMABLE;
out.display_name = "From".to_string();
core.add_input(out);
let mut inn = Input::new(
transition_input::IN_BLOCK,
ValueType::NodeRef,
NodeValue::None,
);
inn.flags |= crate::input::flags::NOT_KEYFRAMABLE;
inn.display_name = "To".to_string();
core.add_input(inn);
(core, Box::new(TransitionBlockBehavior::new()))
}
+32
View File
@@ -0,0 +1,32 @@
// 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/>.
//! oakcodec C ABI calls (footage probing) — now direct Rust calls into
//! the oakcodec crate (single-lib unification, see
//! `docs/zh/plans/riir/single-lib.md`).
use std::ffi::c_char;
use crate::handle::CHandle;
/// `oakcodec_decoder_probe` — probe a media file, returning the
/// stream-list handle (`oakcodec_decoder_probe(filename)`). The caller
/// owns the returned handle.
pub fn decoder_probe(path: &str) -> Option<CHandle> {
use std::ffi::CString;
let c = CString::new(path).ok()?;
Some(unsafe { oakcodec::ffi::decoder::oakcodec_decoder_probe(c.as_ptr()) })
}
+375
View File
@@ -0,0 +1,375 @@
// 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/>.
//! oakcommon C ABI calls — now direct Rust calls into the oakcommon crate
//! (single-lib unification, see `docs/zh/plans/riir/single-lib.md`).
//! The XML surface mirrors `include/common/xmlutils.h` and backs the
//! serializer's reader/writer traits. Function names and signatures are
//! unchanged (callers in `src/` and `tests/` are untouched); the
//! `test-stubs` feature and its in-crate mocks were removed because the
//! real oakcommon rlib is now always linked (the mocks would collide with
//! its `#[no_mangle]` exports).
use std::ffi::{c_char, c_int};
use crate::handle::CHandle;
/// `oakcommon_xml_reader_init`.
pub fn xml_reader_init(data: *const c_char) -> Option<CHandle> {
Some(unsafe { oakcommon::ffi::xmlutils::oakcommon_xml_reader_init(data) })
}
/// `oakcommon_xml_reader_free`.
pub fn xml_reader_free(reader: *mut CHandle) {
unsafe { oakcommon::ffi::xmlutils::oakcommon_xml_reader_free(reader) }
}
/// `oakcommon_xml_reader_read_next_start_element` (advance to the next
/// start element; `found` receives 1/0).
pub fn xml_reader_next_start_element(reader: CHandle) -> Option<bool> {
let mut found = 0;
unsafe {
oakcommon::ffi::xmlutils::oakcommon_xml_reader_read_next_start_element(reader, &mut found);
}
Some(found != 0)
}
/// `oakcommon_xml_reader_name` (two-stage).
pub fn xml_reader_name(reader: CHandle) -> Option<String> {
two_stage_string("oakcommon_xml_reader_name", |buf, size| unsafe {
Some(oakcommon::ffi::xmlutils::oakcommon_xml_reader_name(reader.clone(), buf, size))
})
}
/// `oakcommon_xml_reader_read_element_text` (two-stage).
pub fn xml_reader_read_element_text(reader: CHandle) -> Option<String> {
two_stage_string("oakcommon_xml_reader_read_element_text", |buf, size| unsafe {
Some(oakcommon::ffi::xmlutils::oakcommon_xml_reader_read_element_text(
reader.clone(),
buf,
size,
))
})
}
/// `oakcommon_xml_reader_skip_current_element`.
pub fn xml_reader_skip_current_element(reader: CHandle) -> Option<c_int> {
Some(unsafe { oakcommon::ffi::xmlutils::oakcommon_xml_reader_skip_current_element(reader) })
}
/// `oakcommon_xml_reader_attribute_count`.
pub fn xml_reader_attribute_count(reader: CHandle) -> Option<c_int> {
let mut count = 0;
unsafe { oakcommon::ffi::xmlutils::oakcommon_xml_reader_attribute_count(reader, &mut count) };
Some(count)
}
/// `oakcommon_xml_reader_attribute_name` (two-stage).
pub fn xml_reader_attribute_name(reader: CHandle, index: c_int) -> Option<String> {
two_stage_string("oakcommon_xml_reader_attribute_name", |buf, size| unsafe {
Some(oakcommon::ffi::xmlutils::oakcommon_xml_reader_attribute_name(
reader.clone(),
index,
buf,
size,
))
})
}
/// `oakcommon_xml_reader_attribute_value` (two-stage).
pub fn xml_reader_attribute_value(reader: CHandle, index: c_int) -> Option<String> {
two_stage_string("oakcommon_xml_reader_attribute_value", |buf, size| unsafe {
Some(oakcommon::ffi::xmlutils::oakcommon_xml_reader_attribute_value(
reader.clone(),
index,
buf,
size,
))
})
}
/// `oakcommon_xml_reader_has_error`.
pub fn xml_reader_has_error(reader: CHandle) -> Option<bool> {
let mut err = 0;
unsafe { oakcommon::ffi::xmlutils::oakcommon_xml_reader_has_error(reader, &mut err) };
Some(err != 0)
}
/// `oakcommon_xml_writer_init`.
pub fn xml_writer_init() -> Option<CHandle> {
Some(unsafe { oakcommon::ffi::xmlutils::oakcommon_xml_writer_init() })
}
/// `oakcommon_xml_writer_free`.
pub fn xml_writer_free(writer: *mut CHandle) {
unsafe { oakcommon::ffi::xmlutils::oakcommon_xml_writer_free(writer) }
}
/// `oakcommon_xml_writer_write_start_element`.
pub fn xml_writer_start_element(writer: CHandle, name: &str) -> Option<c_int> {
use std::ffi::CString;
let n = CString::new(name).ok()?;
Some(unsafe {
oakcommon::ffi::xmlutils::oakcommon_xml_writer_write_start_element(writer, n.as_ptr())
})
}
/// `oakcommon_xml_writer_write_attribute`.
pub fn xml_writer_attribute(writer: CHandle, name: &str, value: &str) -> Option<c_int> {
use std::ffi::CString;
let n = CString::new(name).ok()?;
let v = CString::new(value).ok()?;
Some(unsafe {
oakcommon::ffi::xmlutils::oakcommon_xml_writer_write_attribute(writer, n.as_ptr(), v.as_ptr())
})
}
/// `oakcommon_xml_writer_write_characters`.
pub fn xml_writer_characters(writer: CHandle, text: &str) -> Option<c_int> {
use std::ffi::CString;
let t = CString::new(text).ok()?;
Some(unsafe {
oakcommon::ffi::xmlutils::oakcommon_xml_writer_write_characters(writer, t.as_ptr())
})
}
/// `oakcommon_xml_writer_write_text_element`.
pub fn xml_writer_text_element(writer: CHandle, name: &str, text: &str) -> Option<c_int> {
use std::ffi::CString;
let n = CString::new(name).ok()?;
let t = CString::new(text).ok()?;
Some(unsafe {
oakcommon::ffi::xmlutils::oakcommon_xml_writer_write_text_element(writer, n.as_ptr(), t.as_ptr())
})
}
/// `oakcommon_xml_writer_write_end_element`.
pub fn xml_writer_end_element(writer: CHandle) -> Option<c_int> {
Some(unsafe { oakcommon::ffi::xmlutils::oakcommon_xml_writer_write_end_element(writer) })
}
/// `oakcommon_xml_writer_write_end_document`.
pub fn xml_writer_end_document(writer: CHandle) -> Option<c_int> {
Some(unsafe { oakcommon::ffi::xmlutils::oakcommon_xml_writer_write_end_document(writer) })
}
/// `oakcommon_xml_writer_output` (two-stage).
pub fn xml_writer_output(writer: CHandle) -> Option<String> {
two_stage_string("oakcommon_xml_writer_output", |buf, size| unsafe {
Some(oakcommon::ffi::xmlutils::oakcommon_xml_writer_output(writer.clone(), buf, size))
})
}
/// `oakcommon_config_get_int` (config access for node defaults).
pub fn config_get_int(group: &str, key: &str, default: c_int) -> Option<c_int> {
use std::ffi::CString;
let g = CString::new(group).ok()?;
let k = CString::new(key).ok()?;
Some(unsafe {
oakcommon::ffi::config::oakcommon_config_get_int(g.as_ptr(), k.as_ptr(), default)
})
}
// ---------------------------------------------------------------------
// oakcommon videoparams C ABI (sequence/footage stream params)
// ---------------------------------------------------------------------
/// `oakcommon_videoparams_init_basic`: new owned handle (count 1).
pub fn videoparams_init_basic(
width: c_int,
height: c_int,
pixel_format: c_int,
channels: c_int,
par_num: c_int,
par_den: c_int,
interlacing: c_int,
divider: c_int,
) -> Option<CHandle> {
Some(unsafe {
oakcommon::ffi::videoparams::oakcommon_videoparams_init_basic(
width, height, pixel_format, channels, par_num, par_den, interlacing, divider,
)
})
}
/// `oakcommon_videoparams_set_frame_rate`.
pub fn videoparams_set_frame_rate(params: CHandle, num: c_int, den: c_int) -> Option<c_int> {
Some(unsafe {
oakcommon::ffi::videoparams::oakcommon_videoparams_set_frame_rate(params, num, den)
})
}
/// `oakcommon_videoparams_free` — releases the handle locally (the
/// handle's `release` fn points into the oakcommon box machinery).
pub fn videoparams_free(params: *mut CHandle) {
if params.is_null() || unsafe { (*params).ctx.is_null() } {
return;
}
let h = unsafe { (*params).clone() };
if let Some(f) = h.release {
unsafe { f(h.ctx) };
}
unsafe { (*params).ctx = std::ptr::null_mut() };
}
/// `oakcommon_videoparams_get_width` — the value, or the default on
/// error (the caller decides whether the handle is real).
pub fn videoparams_get_width(params: CHandle) -> Option<c_int> {
let mut v = 0;
let rc = unsafe {
oakcommon::ffi::videoparams::oakcommon_videoparams_get_width(params.clone(), &mut v)
};
Some(if rc < 0 { 0 } else { v })
}
/// `oakcommon_videoparams_get_height`.
pub fn videoparams_get_height(params: CHandle) -> Option<c_int> {
let mut v = 0;
let rc = unsafe {
oakcommon::ffi::videoparams::oakcommon_videoparams_get_height(params.clone(), &mut v)
};
Some(if rc < 0 { 0 } else { v })
}
/// `oakcommon_videoparams_get_format`.
pub fn videoparams_get_format(params: CHandle) -> Option<c_int> {
let mut v = 0;
let rc = unsafe {
oakcommon::ffi::videoparams::oakcommon_videoparams_get_format(params.clone(), &mut v)
};
Some(if rc < 0 { 0 } else { v })
}
/// `oakcommon_videoparams_get_channel_count`.
pub fn videoparams_get_channel_count(params: CHandle) -> Option<c_int> {
let mut v = 0;
let rc = unsafe {
oakcommon::ffi::videoparams::oakcommon_videoparams_get_channel_count(params.clone(), &mut v)
};
Some(if rc < 0 { 0 } else { v })
}
/// `oakcommon_videoparams_get_frame_rate` — (num, den).
pub fn videoparams_get_frame_rate(params: CHandle) -> Option<(c_int, c_int)> {
let mut n = 0;
let mut d = 0;
let rc = unsafe {
oakcommon::ffi::videoparams::oakcommon_videoparams_get_frame_rate(params.clone(), &mut n, &mut d)
};
Some(if rc < 0 { (0, 0) } else { (n, d) })
}
// ---------------------------------------------------------------------
// oakcommon colortransform C ABI (color manager compliance)
// ---------------------------------------------------------------------
/// `oakcommon_colortransform_init_display`: new owned display transform.
pub fn colortransform_init_display(display: &str, view: &str, look: &str) -> Option<CHandle> {
use std::ffi::CString;
let d = CString::new(display).ok()?;
let v = CString::new(view).ok()?;
let l = CString::new(look).ok()?;
Some(unsafe {
oakcommon::ffi::colortransform::oakcommon_colortransform_init_display(
d.as_ptr(),
v.as_ptr(),
l.as_ptr(),
)
})
}
/// `oakcommon_colortransform_init_output`: new owned output transform.
pub fn colortransform_init_output(output: &str) -> Option<CHandle> {
use std::ffi::CString;
let o = CString::new(output).ok()?;
Some(unsafe { oakcommon::ffi::colortransform::oakcommon_colortransform_init_output(o.as_ptr()) })
}
/// `oakcommon_colortransform_free` — releases the handle locally.
pub fn colortransform_free(transform: *mut CHandle) {
if transform.is_null() || unsafe { (*transform).ctx.is_null() } {
return;
}
let h = unsafe { (*transform).clone() };
if let Some(f) = h.release {
unsafe { f(h.ctx) };
}
unsafe { (*transform).ctx = std::ptr::null_mut() };
}
/// `oakcommon_colortransform_is_display`.
pub fn colortransform_is_display(transform: CHandle) -> Option<bool> {
Some(unsafe {
oakcommon::ffi::colortransform::oakcommon_colortransform_is_display(transform) != 0
})
}
/// `oakcommon_colortransform_get_display` (two-stage).
pub fn colortransform_get_display(transform: CHandle) -> Option<String> {
two_stage_string("oakcommon_colortransform_get_display", |buf, size| unsafe {
Some(oakcommon::ffi::colortransform::oakcommon_colortransform_get_display(
transform.clone(),
buf,
size,
))
})
}
/// `oakcommon_colortransform_get_output` (two-stage).
pub fn colortransform_get_output(transform: CHandle) -> Option<String> {
two_stage_string("oakcommon_colortransform_get_output", |buf, size| unsafe {
Some(oakcommon::ffi::colortransform::oakcommon_colortransform_get_output(
transform.clone(),
buf,
size,
))
})
}
/// `oakcommon_colortransform_get_view` (two-stage).
pub fn colortransform_get_view(transform: CHandle) -> Option<String> {
two_stage_string("oakcommon_colortransform_get_view", |buf, size| unsafe {
Some(oakcommon::ffi::colortransform::oakcommon_colortransform_get_view(
transform.clone(),
buf,
size,
))
})
}
/// `oakcommon_colortransform_get_look` (two-stage).
pub fn colortransform_get_look(transform: CHandle) -> Option<String> {
two_stage_string("oakcommon_colortransform_get_look", |buf, size| unsafe {
Some(oakcommon::ffi::colortransform::oakcommon_colortransform_get_look(
transform.clone(),
buf,
size,
))
})
}
/// Shared two-stage string fetch: query the required size, then read
/// into an owned buffer. `None` when the query returns an error.
fn two_stage_string<F: Fn(*mut c_char, c_int) -> Option<c_int>>(_sym: &str, call: F) -> Option<String> {
let needed = call(std::ptr::null_mut(), 0)?;
if needed <= 0 {
return Some(String::new());
}
let mut buf = vec![0u8; needed as usize];
call(buf.as_mut_ptr() as *mut c_char, needed)?;
buf.pop(); // trailing NUL
String::from_utf8(buf).ok()
}
+161
View File
@@ -0,0 +1,161 @@
// 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/>.
//! oakcore C ABI imports (audio stream parameters). dlsym-resolved (see
//! [`super`]). The `OakAudioParams` object is an opaque raw pointer owned
//! by the caller (`oakcore_audioparams_free`), not a [`crate::handle::CHandle`].
use std::ffi::{c_int, c_void};
#[cfg(not(feature = "test-stubs"))]
/// `oakcore_audioparams_create` — new owned params (release with
/// [`audioparams_free`]).
pub fn audioparams_create(sample_rate: c_int, channel_layout: u64, format: c_int) -> Option<*mut c_void> {
use crate::bridge::dlsym;
type F = unsafe extern "C" fn(c_int, u64, c_int) -> *mut c_void;
dlsym::call::<F, *mut c_void>("oakcore_audioparams_create", |f| unsafe {
f(sample_rate, channel_layout, format)
})
}
/// Test-stub path.
#[cfg(feature = "test-stubs")]
pub fn audioparams_create(sample_rate: c_int, channel_layout: u64, format: c_int) -> Option<*mut c_void> {
Some(unsafe { stub::oakcore_audioparams_create(sample_rate, channel_layout, format) })
}
/// `oakcore_audioparams_free`.
pub fn audioparams_free(params: *mut c_void) {
if params.is_null() {
return;
}
#[cfg(feature = "test-stubs")]
unsafe {
stub::oakcore_audioparams_free(params);
}
#[cfg(not(feature = "test-stubs"))]
{
use crate::bridge::dlsym;
type F = unsafe extern "C" fn(*mut c_void);
let _ = dlsym::call::<F, ()>("oakcore_audioparams_free", |f| unsafe { f(params) });
}
}
#[cfg(not(feature = "test-stubs"))]
/// `oakcore_audioparams_sample_rate`.
pub fn audioparams_sample_rate(params: *const c_void) -> Option<c_int> {
use crate::bridge::dlsym;
type F = unsafe extern "C" fn(*const c_void) -> c_int;
dlsym::call::<F, c_int>("oakcore_audioparams_sample_rate", |f| unsafe { f(params) })
}
/// Test-stub path.
#[cfg(feature = "test-stubs")]
pub fn audioparams_sample_rate(params: *const c_void) -> Option<c_int> {
Some(unsafe { stub::oakcore_audioparams_sample_rate(params) })
}
#[cfg(not(feature = "test-stubs"))]
/// `oakcore_audioparams_channel_layout`.
pub fn audioparams_channel_layout(params: *const c_void) -> Option<u64> {
use crate::bridge::dlsym;
type F = unsafe extern "C" fn(*const c_void) -> u64;
dlsym::call::<F, u64>("oakcore_audioparams_channel_layout", |f| unsafe { f(params) })
}
/// Test-stub path.
#[cfg(feature = "test-stubs")]
pub fn audioparams_channel_layout(params: *const c_void) -> Option<u64> {
Some(unsafe { stub::oakcore_audioparams_channel_layout(params) })
}
#[cfg(not(feature = "test-stubs"))]
/// `oakcore_audioparams_format`.
pub fn audioparams_format(params: *const c_void) -> Option<c_int> {
use crate::bridge::dlsym;
type F = unsafe extern "C" fn(*const c_void) -> c_int;
dlsym::call::<F, c_int>("oakcore_audioparams_format", |f| unsafe { f(params) })
}
/// Test-stub path.
#[cfg(feature = "test-stubs")]
pub fn audioparams_format(params: *const c_void) -> Option<c_int> {
Some(unsafe { stub::oakcore_audioparams_format(params) })
}
/// In-crate implementations of the oakcore audioparams C ABI for
/// `cargo test` (`--features test-stubs`). Mirrors the real object: a
/// plain struct behind the caller-owned pointer.
#[cfg(feature = "test-stubs")]
pub(crate) mod stub {
use super::*;
/// `oakcore_audioparams_create`.
#[no_mangle]
pub unsafe extern "C" fn oakcore_audioparams_create(
sample_rate: c_int,
channel_layout: u64,
format: c_int,
) -> *mut c_void {
Box::into_raw(Box::new(StubAudioParams {
sample_rate,
channel_layout,
format,
})) as *mut c_void
}
/// `oakcore_audioparams_free`.
#[no_mangle]
pub unsafe extern "C" fn oakcore_audioparams_free(params: *mut c_void) {
if !params.is_null() {
unsafe { drop(Box::from_raw(params as *mut StubAudioParams)) };
}
}
/// `oakcore_audioparams_sample_rate`.
#[no_mangle]
pub unsafe extern "C" fn oakcore_audioparams_sample_rate(params: *const c_void) -> c_int {
if params.is_null() {
return 0;
}
unsafe { (*(params as *const StubAudioParams)).sample_rate }
}
/// `oakcore_audioparams_channel_layout`.
#[no_mangle]
pub unsafe extern "C" fn oakcore_audioparams_channel_layout(params: *const c_void) -> u64 {
if params.is_null() {
return 0;
}
unsafe { (*(params as *const StubAudioParams)).channel_layout }
}
/// `oakcore_audioparams_format`.
#[no_mangle]
pub unsafe extern "C" fn oakcore_audioparams_format(params: *const c_void) -> c_int {
if params.is_null() {
return 0;
}
unsafe { (*(params as *const StubAudioParams)).format }
}
/// Boxed audioparams stub payload.
pub(crate) struct StubAudioParams {
pub sample_rate: c_int,
pub channel_layout: u64,
pub format: c_int,
}
}
+76
View File
@@ -0,0 +1,76 @@
// 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/>.
//! C ABI imports from other oak modules.
//!
//! ## Resolution model
//!
//! Symbols are resolved at runtime with `dlsym(RTLD_DEFAULT)` (the
//! module is force-loaded into the host process, so the real module
//! libraries' symbols are in the global scope). `cargo test` builds
//! without those libraries: a missing symbol surfaces as `None` from
//! the wrapper and the caller maps it to a graceful error. This follows
//! the oakplugin crate template (`src/plugin/rust/src/bridge/mod.rs`).
//!
//! Real linkage for the module dylib is provided by the C++ side's
//! force_load of liboaknode (the staticlib); nothing here is linked
//! directly at compile time.
pub mod codec;
pub mod common;
pub mod core;
pub mod render;
pub mod timeline;
pub mod undo;
/// Shared dlsym runtime resolution (pub for crate tests).
pub mod dlsym {
use std::ffi::{c_char, c_void};
/// RTLD_DEFAULT (macOS: -2; Linux: 0).
#[cfg(target_os = "macos")]
pub(crate) const RTLD_DEFAULT: *mut c_void = -2isize as *mut c_void;
#[cfg(target_os = "linux")]
pub(crate) const RTLD_DEFAULT: *mut c_void = 0isize as *mut c_void;
extern "C" {
fn dlsym(handle: *mut c_void, symbol: *const c_char) -> *mut c_void;
}
/// Resolve a global-scope symbol; `None` when missing.
pub fn resolve(name: &str) -> Option<*mut c_void> {
let c = std::ffi::CString::new(name).ok()?;
let p = unsafe { dlsym(RTLD_DEFAULT, c.as_ptr()) };
if p.is_null() {
None
} else {
Some(p)
}
}
/// Resolve and call by signature; `None` when the symbol is missing.
///
/// # Safety
/// The caller guarantees `T` matches the symbol's real function type.
pub(crate) fn call<T, R>(name: &str, f: impl FnOnce(T) -> R) -> Option<R>
where
T: Copy,
{
let p = resolve(name)?;
let f_ptr: T = unsafe { std::mem::transmute_copy(&p) };
Some(f(f_ptr))
}
}
+159
View File
@@ -0,0 +1,159 @@
// 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/>.
//! oakrender C ABI imports (caches, textures, color processors).
//!
//! Symbols resolved via `dlsym(RTLD_DEFAULT)` (see [`super::dlsym`]);
//! every wrapper returns `None`/a neutral value when the symbol is
//! absent (cargo test without liboakrender).
use std::ffi::c_int;
use crate::handle::CHandle;
/// oakrender cache handle (value type).
pub type CacheHandle = CHandle;
/// oakrender texture handle (value type).
pub type TextureHandle = CHandle;
/// oakrender color processor handle (value type).
pub type ColorProcessorHandle = CHandle;
/// Cache kind constants (oakrender `OAKRENDER_CACHE_*`).
pub mod cache_kind {
/// `OAKRENDER_CACHE_VIDEO_FRAME`.
pub const VIDEO_FRAME: i32 = 0;
/// `OAKRENDER_CACHE_THUMBNAIL`.
pub const THUMBNAIL: i32 = 1;
/// `OAKRENDER_CACHE_AUDIO_PLAYBACK`.
pub const AUDIO_PLAYBACK: i32 = 2;
/// `OAKRENDER_CACHE_AUDIO_WAVEFORM`.
pub const AUDIO_WAVEFORM: i32 = 3;
}
/// `oakrender_cache_create_for_node`.
pub fn cache_create_for_node(parent: CHandle, kind: i32) -> Option<CHandle> {
use crate::bridge::dlsym;
type F = unsafe extern "C" fn(CHandle, i32) -> CHandle;
dlsym::call::<F, CHandle>("oakrender_cache_create_for_node", |f| unsafe { f(parent, kind) })
}
/// `oakrender_cache_free`.
pub fn cache_free(cache: *mut CHandle) {
use crate::bridge::dlsym;
type F = unsafe extern "C" fn(*mut CHandle);
if let Some(f) = dlsym::call::<F, ()>("oakrender_cache_free", |f| unsafe { f(cache) }) {
let _ = f;
}
}
/// `oakrender_cache_invalidate_range`.
pub fn cache_invalidate_range(
cache: CHandle,
in_num: i64,
in_den: i64,
out_num: i64,
out_den: i64,
) {
use crate::bridge::dlsym;
type F = unsafe extern "C" fn(CHandle, i64, i64, i64, i64);
if let Some(f) = dlsym::call::<F, ()>("oakrender_cache_invalidate_range", |f| unsafe {
f(cache, in_num, in_den, out_num, out_den)
}) {
let _ = f;
}
}
/// `oakrender_cache_set_uuid`.
pub fn cache_set_uuid(cache: CHandle, uuid: &str) -> Option<i32> {
use crate::bridge::dlsym;
use std::ffi::CString;
type F = unsafe extern "C" fn(CHandle, *const std::ffi::c_char) -> i32;
let c = CString::new(uuid).ok()?;
dlsym::call::<F, i32>("oakrender_cache_set_uuid", |f| unsafe {
f(cache, c.as_ptr())
})
}
/// `oakrender_cache_get_uuid` (two-stage).
pub fn cache_get_uuid(cache: CHandle) -> Option<String> {
use crate::bridge::dlsym;
use std::ffi::c_char;
type F = unsafe extern "C" fn(CHandle, *mut c_char, i32) -> i32;
let needed = dlsym::call::<F, i32>("oakrender_cache_get_uuid", |f| unsafe {
f(cache.clone(), std::ptr::null_mut(), 0)
})?;
if needed <= 0 {
return None;
}
let mut buf = vec![0u8; needed as usize];
dlsym::call::<F, i32>("oakrender_cache_get_uuid", |f| unsafe {
f(cache.clone(), buf.as_mut_ptr() as *mut c_char, needed)
})?;
buf.pop(); // trailing NUL
String::from_utf8(buf).ok()
}
/// `oakrender_disk_cache_path` (two-stage): the default cache directory.
pub fn disk_cache_path() -> Option<String> {
use crate::bridge::dlsym;
use std::ffi::c_char;
type F = unsafe extern "C" fn(*mut c_char, i32) -> i32;
let needed = dlsym::call::<F, i32>("oakrender_disk_cache_path", |f| unsafe {
f(std::ptr::null_mut(), 0)
})?;
if needed <= 0 {
return None;
}
let mut buf = vec![0u8; needed as usize];
dlsym::call::<F, i32>("oakrender_disk_cache_path", |f| unsafe {
f(buf.as_mut_ptr() as *mut c_char, needed)
})?;
buf.pop(); // trailing NUL
String::from_utf8(buf).ok()
}
/// `oakrender_color_config_create_default`: load the bundled OCIO
/// config. `None` = symbol absent (cargo test); `Some(Ok(())` =
/// success; `Some(Err(()))` = OCIO error.
pub fn color_config_create_default() -> Option<Result<(), ()>> {
use crate::bridge::dlsym;
type F = unsafe extern "C" fn() -> i32;
let rc = dlsym::call::<F, i32>("oakrender_color_config_create_default", |f| unsafe {
f()
})?;
if rc == 0 {
Some(Ok(()))
} else {
Some(Err(()))
}
}
/// `oakrender_color_config_load_from_filename`: load a config file.
/// Same tri-state as [`color_config_create_default`].
pub fn color_config_load(filename: &str) -> Option<Result<(), ()>> {
use crate::bridge::dlsym;
use std::ffi::CString;
type F = unsafe extern "C" fn(*const std::ffi::c_char) -> i32;
let c = CString::new(filename).ok()?;
let rc = dlsym::call::<F, i32>("oakrender_color_config_load_from_filename", |f| unsafe {
f(c.as_ptr())
})?;
if rc == 0 {
Some(Ok(()))
} else {
Some(Err(()))
}
}
+61
View File
@@ -0,0 +1,61 @@
// 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/>.
//! oaktimeline C ABI imports (sequence markers/work area, edit
//! commands used by sequence setup). dlsym-resolved (see [`super`]).
use crate::handle::CHandle;
/// `oaktimeline_marker_list_create`.
pub fn marker_list_create() -> Option<CHandle> {
use crate::bridge::dlsym;
type F = unsafe extern "C" fn() -> CHandle;
dlsym::call::<F, CHandle>("oaktimeline_marker_list_create", |f| unsafe { f() })
}
/// `oaktimeline_marker_list_free`.
pub fn marker_list_free(list: *mut CHandle) {
use crate::bridge::dlsym;
type F = unsafe extern "C" fn(*mut CHandle);
if let Some(f) = dlsym::call::<F, ()>("oaktimeline_marker_list_free", |f| unsafe {
f(list)
}) {
let _ = f;
}
}
/// `oaktimeline_workarea_create`.
pub fn workarea_create() -> Option<CHandle> {
use crate::bridge::dlsym;
type F = unsafe extern "C" fn() -> CHandle;
dlsym::call::<F, CHandle>("oaktimeline_workarea_create", |f| unsafe { f() })
}
/// `oaktimeline_workarea_free`.
pub fn workarea_free(w: *mut CHandle) {
use crate::bridge::dlsym;
type F = unsafe extern "C" fn(*mut CHandle);
if let Some(f) = dlsym::call::<F, ()>("oaktimeline_workarea_free", |f| unsafe { f(w) }) {
let _ = f;
}
}
/// `oaktimeline_add_track_command`.
pub fn add_track_command(list: CHandle) -> Option<CHandle> {
use crate::bridge::dlsym;
type F = unsafe extern "C" fn(CHandle) -> CHandle;
dlsym::call::<F, CHandle>("oaktimeline_add_track_command", |f| unsafe { f(list) })
}
+399
View File
@@ -0,0 +1,399 @@
// 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/>.
//! oakundo C ABI calls — now direct Rust calls into the oakundo crate
//! (single-lib unification, see `docs/zh/plans/riir/single-lib.md`).
//! Undo commands are created through the C ABI vtable
//! (`oakundo_command_init` with Rust closures as userdata) — no C++
//! UndoCommand subclassing exists on this side.
//!
//! ## Test stubs (`--features test-stubs`)
//!
//! With the direct dependency, the real oakundo rlib is linked into
//! every build and test; the `test-stubs` feature still compiles
//! in-crate `#[no_mangle]` implementations of the undo C ABI (see
//! [`stub`]) for environments that link without oakundo. Do not enable
//! the feature in a binary that also links oakundo (duplicate symbols).
use std::ffi::c_int;
use std::sync::atomic::{AtomicBool, Ordering};
use crate::handle::CHandle;
/// `OakUndoCommandVtable` (include/undo/undocommand.h) — the callback
/// table backing a caller-defined undo command. Single-lib unification:
/// aliases the oakundo crate's vtable POD (identical layout).
pub type Vtable = oakundo::undocommand::OakUndoCommandVtable;
/// Rust closure state behind a vtable command's `userdata` pointer.
///
/// The box is handed to [`command_init`] (which takes ownership); the
/// vtable trampolines below route `redo`/`undo`/destruction back into
/// the closures.
pub struct CommandState {
/// Whether the command has been executed (redo_now no-ops when done).
pub done: AtomicBool,
/// Redo closure.
pub redo: Box<dyn FnMut() + Send>,
/// Undo closure.
pub undo: Box<dyn FnMut() + Send>,
}
impl CommandState {
/// New state with both directions.
pub fn new(
redo: impl FnMut() + Send + 'static,
undo: impl FnMut() + Send + 'static,
) -> CommandState {
CommandState {
done: AtomicBool::new(false),
redo: Box::new(redo),
undo: Box::new(undo),
}
}
}
/// Trampoline: run the redo closure behind `userdata`. Panics are
/// swallowed at the C boundary (`// CPP-PARITY: undocommand.cpp` — the
/// C++ side has no panic concept; a panic here must never unwind across
/// the extern "C" frame).
unsafe extern "C" fn redo_trampoline(userdata: *mut std::ffi::c_void) {
let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
if !userdata.is_null() {
let state = unsafe { &mut *(userdata as *mut CommandState) };
(state.redo)();
}
}));
}
/// Trampoline: run the undo closure behind `userdata`.
unsafe extern "C" fn undo_trampoline(userdata: *mut std::ffi::c_void) {
let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
if !userdata.is_null() {
let state = unsafe { &mut *(userdata as *mut CommandState) };
(state.undo)();
}
}));
}
/// Trampoline: free the `CommandState` box.
unsafe extern "C" fn free_trampoline(userdata: *mut std::ffi::c_void) {
if !userdata.is_null() {
unsafe { drop(Box::from_raw(userdata as *mut CommandState)) };
}
}
/// Create a vtable-backed undo command whose redo/undo run the given
/// closures (`oakundo_command_init`). The returned handle is owned by
/// the caller; `None` when oakundo is unavailable (or the stub returns
/// an empty handle).
pub fn command_from_closures(
redo: impl FnMut() + Send + 'static,
undo: impl FnMut() + Send + 'static,
) -> Option<CHandle> {
let state = Box::new(CommandState::new(redo, undo));
let vtable = Vtable {
redo: Some(redo_trampoline),
undo: Some(undo_trampoline),
free_fn: Some(free_trampoline),
};
command_init(&vtable, Box::into_raw(state) as *mut std::ffi::c_void)
}
/// `oakundo_command_init` (vtable command).
#[cfg(feature = "test-stubs")]
pub fn command_init(vtable: &Vtable, userdata: *mut std::ffi::c_void) -> Option<CHandle> {
Some(unsafe { stub::oakundo_command_init(vtable as *const Vtable, userdata) })
}
/// `oakundo_command_init` (vtable command).
#[cfg(not(feature = "test-stubs"))]
pub fn command_init(vtable: &Vtable, userdata: *mut std::ffi::c_void) -> Option<CHandle> {
// Direct call into the oakundo crate (single-lib unification).
let h = unsafe { oakundo::ffi::command::oakundo_command_init(vtable, userdata) };
if h.is_null() {
None
} else {
Some(h)
}
}
/// `oakundo_command_init_multi`.
#[cfg(feature = "test-stubs")]
pub fn command_init_multi() -> Option<CHandle> {
Some(unsafe { stub::oakundo_command_init_multi() })
}
/// `oakundo_command_init_multi`.
#[cfg(not(feature = "test-stubs"))]
pub fn command_init_multi() -> Option<CHandle> {
// Direct call into the oakundo crate (single-lib unification).
let h = unsafe { oakundo::ffi::command::oakundo_command_init_multi() };
if h.is_null() {
None
} else {
Some(h)
}
}
/// `oakundo_command_multi_add_child`.
#[cfg(feature = "test-stubs")]
pub fn command_multi_add_child(multi: CHandle, child: CHandle) -> Option<c_int> {
Some(unsafe { stub::oakundo_command_multi_add_child(multi, child) })
}
/// `oakundo_command_multi_add_child`.
#[cfg(not(feature = "test-stubs"))]
pub fn command_multi_add_child(multi: CHandle, child: CHandle) -> Option<c_int> {
// Direct call into the oakundo crate (single-lib unification).
Some(unsafe { oakundo::ffi::command::oakundo_command_multi_add_child(multi, child) })
}
/// `oakundo_command_redo_now`.
#[cfg(feature = "test-stubs")]
pub fn command_redo_now(command: CHandle) -> Option<c_int> {
Some(unsafe { stub::oakundo_command_redo_now(command) })
}
/// `oakundo_command_redo_now`.
#[cfg(not(feature = "test-stubs"))]
pub fn command_redo_now(command: CHandle) -> Option<c_int> {
// Direct call into the oakundo crate (single-lib unification).
Some(unsafe { oakundo::ffi::command::oakundo_command_redo_now(command) })
}
/// `oakundo_command_undo_now`.
#[cfg(feature = "test-stubs")]
pub fn command_undo_now(command: CHandle) -> Option<c_int> {
Some(unsafe { stub::oakundo_command_undo_now(command) })
}
/// `oakundo_command_undo_now`.
#[cfg(not(feature = "test-stubs"))]
pub fn command_undo_now(command: CHandle) -> Option<c_int> {
// Direct call into the oakundo crate (single-lib unification).
Some(unsafe { oakundo::ffi::command::oakundo_command_undo_now(command) })
}
/// `oakundo_command_free`.
#[cfg(feature = "test-stubs")]
pub fn command_free(command: *mut CHandle) {
unsafe { stub::oakundo_command_free(command) };
}
/// `oakundo_command_free`.
#[cfg(not(feature = "test-stubs"))]
pub fn command_free(command: *mut CHandle) {
// Direct call into the oakundo crate (single-lib unification).
unsafe { oakundo::ffi::command::oakundo_command_free(command) }
}
/// `oakundo_stack_push` (facade-owned stack).
pub fn stack_push(stack: CHandle, command: CHandle, text: *const std::ffi::c_char) -> Option<c_int> {
// Direct call into the oakundo crate (single-lib unification).
Some(unsafe { oakundo::ffi::undostack::oakundo_undostack_push(stack, command, text) })
}
/// In-crate implementations of the undo C ABI for `cargo test`
/// (`--features test-stubs`). Mirrors the C++ `CallbackUndoCommand`
/// (`src/undo/c_api/undocommand.cpp`) semantics: the command holds the
/// vtable + userdata, calls `free_fn` on destruction, and `redo_now`/
/// `undo_now` are no-ops when already executed. Multi commands hold one
/// reference per child.
#[cfg(feature = "test-stubs")]
pub(crate) mod stub {
use super::*;
/// A command box behind an OakUndoCommand handle's `ctx`.
pub(crate) enum StubCommand {
/// Vtable command.
Callback {
/// Executed state (redo_now no-ops when true).
done: AtomicBool,
/// Redo callback.
redo: Option<unsafe extern "C" fn(*mut std::ffi::c_void)>,
/// Undo callback.
undo: Option<unsafe extern "C" fn(*mut std::ffi::c_void)>,
/// userdata release.
free_fn: Option<unsafe extern "C" fn(*mut std::ffi::c_void)>,
/// Opaque userdata (owned by the command).
userdata: *mut std::ffi::c_void,
},
/// Multi command.
Multi {
/// Executed state.
done: AtomicBool,
/// Child commands (each holds one reference).
children: Vec<CHandle>,
},
}
impl Drop for StubCommand {
fn drop(&mut self) {
match self {
StubCommand::Callback {
free_fn, userdata, ..
} => {
if let Some(f) = free_fn {
if !userdata.is_null() {
unsafe { f(*userdata) };
}
}
}
StubCommand::Multi { children, .. } => {
for child in children {
if let Some(f) = child.release {
unsafe { f(child.ctx) };
}
}
}
}
}
}
/// `oakundo_command_init`.
#[no_mangle]
pub unsafe extern "C" fn oakundo_command_init(
vtable: *const Vtable,
userdata: *mut std::ffi::c_void,
) -> CHandle {
if vtable.is_null() {
return CHandle::null();
}
let vt = unsafe { &*vtable };
let cmd = StubCommand::Callback {
done: AtomicBool::new(false),
redo: vt.redo,
undo: vt.undo,
free_fn: vt.free_fn,
userdata,
};
crate::handle::make_owned(SendStub(cmd))
}
/// `oakundo_command_init_multi`.
#[no_mangle]
pub unsafe extern "C" fn oakundo_command_init_multi() -> CHandle {
crate::handle::make_owned(SendStub(StubCommand::Multi {
done: AtomicBool::new(false),
children: Vec::new(),
}))
}
/// `oakundo_command_multi_add_child`.
#[no_mangle]
pub unsafe extern "C" fn oakundo_command_multi_add_child(
multi: CHandle,
child: CHandle,
) -> c_int {
if multi.ctx.is_null() || child.ctx.is_null() {
return crate::error::OAKNODE_E_INVALID;
}
let boxed = multi.ctx as *mut crate::handle::RefBox<SendStub>;
// Take one reference for the multi.
if let Some(f) = child.addref {
unsafe { f(child.ctx) };
}
let state = unsafe { &mut (*boxed).value };
match &mut state.0 {
StubCommand::Multi { children, .. } => {
children.push(child);
crate::error::OAKNODE_OK
}
_ => crate::error::OAKNODE_E_INVALID,
}
}
/// `oakundo_command_redo_now`.
#[no_mangle]
pub unsafe extern "C" fn oakundo_command_redo_now(command: CHandle) -> c_int {
if command.ctx.is_null() {
return crate::error::OAKNODE_E_INVALID;
}
let boxed = command.ctx as *mut crate::handle::RefBox<SendStub>;
let state = unsafe { &mut (*boxed).value };
match &mut state.0 {
StubCommand::Callback {
done, redo, userdata, ..
} => {
if !done.swap(true, Ordering::AcqRel) {
if let Some(f) = redo {
unsafe { f(*userdata) };
}
}
crate::error::OAKNODE_OK
}
StubCommand::Multi { done, children } => {
if !done.swap(true, Ordering::AcqRel) {
for child in children.iter() {
let _ = unsafe { oakundo_command_redo_now(child.clone()) };
}
}
crate::error::OAKNODE_OK
}
}
}
/// `oakundo_command_undo_now`.
#[no_mangle]
pub unsafe extern "C" fn oakundo_command_undo_now(command: CHandle) -> c_int {
if command.ctx.is_null() {
return crate::error::OAKNODE_E_INVALID;
}
let boxed = command.ctx as *mut crate::handle::RefBox<SendStub>;
let state = unsafe { &mut (*boxed).value };
match &mut state.0 {
StubCommand::Callback {
done, undo, userdata, ..
} => {
if done.swap(false, Ordering::AcqRel) {
if let Some(f) = undo {
unsafe { f(*userdata) };
}
}
crate::error::OAKNODE_OK
}
StubCommand::Multi { done, children } => {
if done.swap(false, Ordering::AcqRel) {
for child in children.iter().rev() {
let _ = unsafe { oakundo_command_undo_now(child.clone()) };
}
}
crate::error::OAKNODE_OK
}
}
}
/// `oakundo_command_free`.
#[no_mangle]
pub unsafe extern "C" fn oakundo_command_free(command: *mut CHandle) {
if command.is_null() || unsafe { (*command).ctx.is_null() } {
return;
}
let h = unsafe { (*command).clone() };
if let Some(f) = h.release {
unsafe { f(h.ctx) };
}
unsafe { (*command).ctx = std::ptr::null_mut() };
}
/// Send-marker for the command box (the raw `userdata` pointer is
/// only dereferenced on the thread that created the command — the
/// test thread — so the box never actually crosses threads).
struct SendStub(StubCommand);
unsafe impl Send for SendStub {}
}
+132
View File
@@ -0,0 +1,132 @@
// 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/>.
//! Color manager (C++ `olive::ColorManager`): the per-project OCIO
//! config handle. OCIO itself stays behind the oakrender C ABI until
//! oakrender is rewritten; this module is the state owner and query
//! facade. `// CPP-PARITY: src/node/src/color/colormanager/colormanager.{h,cpp}`.
/// Per-project color manager.
pub struct ColorManager {
/// OCIO config filename (empty = bundled default).
pub config_filename: String,
/// Default input colorspace.
pub default_input_space: String,
/// Default display.
pub default_display: String,
/// Default view.
pub default_view: String,
/// Reference colorspace.
pub reference_space: String,
/// Whether a config is loaded (C++ `config_` non-null). Without
/// liboakrender the config stays unloaded and the config-dependent
/// queries report E_STATE.
pub config_loaded: bool,
}
impl ColorManager {
/// New with the built-in default config selected (C++ `init()`).
pub fn new() -> Self {
// The bundled default config is "reference space" — treat it as
// loaded with the conventional defaults (the C++ constructor
// leaves the config null until `init()`; `new()` is the
// un-initialized state for `oaknode_colormanager_init`).
ColorManager {
config_filename: String::new(),
default_input_space: "linear".to_string(),
default_display: "sRGB".to_string(),
default_view: "Standard".to_string(),
reference_space: "linear".to_string(),
config_loaded: false,
}
}
/// Load the built-in default config (C++ `ColorManager::init()`).
/// The oakrender color C ABI never exported the config-loading
/// symbols, so this always took the "mark loaded with the built-in
/// defaults" path (single-lib: the render call is removed and the
/// deterministic equivalent kept).
pub fn initialize(&mut self) -> crate::error::Result<()> {
self.config_loaded = true;
Ok(())
}
/// (Re)build the process-wide default config (C++
/// `set_up_default_config()`).
pub fn set_up_default_config(&mut self) -> crate::error::Result<()> {
self.config_loaded = true;
Ok(())
}
/// (Re)load the config from `config_filename`; E_FAILED on OCIO
/// errors. Missing/invalid files keep the previous config (C++
/// `update_config_from_filename()`). The oakrender color C ABI never
/// implemented the load, so the file-loaded path always kept the
/// previous state (single-lib: the render call is removed).
pub fn update_config_from_filename(&mut self) -> crate::error::Result<()> {
if self.config_filename.is_empty() {
// Empty filename selects the bundled default.
self.config_loaded = true;
return Ok(());
}
Ok(())
}
/// Enumerate colorspaces of the active config (two-stage lists are
/// flattened here into owned Strings). Without a real OCIO config the
/// list holds the reference space only.
pub fn list_colorspaces(&self) -> Vec<String> {
if !self.config_loaded {
return Vec::new();
}
vec![self.reference_space.clone()]
}
/// Displays of the active config (default display when unloaded).
pub fn list_displays(&self) -> Vec<String> {
if !self.config_loaded {
return Vec::new();
}
vec![self.default_display.clone()]
}
/// Views of the active config for `display` (default view).
pub fn list_views(&self, _display: &str) -> Vec<String> {
if !self.config_loaded {
return Vec::new();
}
vec![self.default_view.clone()]
}
/// Looks of the active config (none by default).
pub fn list_looks(&self) -> Vec<String> {
if !self.config_loaded {
return Vec::new();
}
Vec::new()
}
/// True when a config is loaded.
pub fn is_loaded(&self) -> bool {
self.config_loaded
}
}
impl Default for ColorManager {
fn default() -> Self {
ColorManager::new()
}
}
+62
View File
@@ -0,0 +1,62 @@
// 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/>.
//! Error codes, mirroring `include/node/error.h` verbatim; project-wide
//! -MMCCCC scheme (module registry in include/common/error.h), pass-through untranslated.
/// Success.
pub const OAKNODE_OK: i32 = 0;
/// Null handle or invalid argument.
pub const OAKNODE_E_INVALID: i32 = -30001;
/// Call not valid in the current state.
pub const OAKNODE_E_STATE: i32 = -30002;
/// The underlying operation failed.
pub const OAKNODE_E_FAILED: i32 = -30003;
/// Index out of range / entry not found.
pub const OAKNODE_E_NOT_FOUND: i32 = -30004;
/// Allocation failed.
pub const OAKNODE_E_NOMEM: i32 = -30005;
/// Crate-internal result type; the FFI layer maps it to the codes.
pub type Result<T> = std::result::Result<T, Error>;
/// Crate-internal error.
#[derive(Debug, PartialEq)]
pub enum Error {
/// Null handle or invalid argument.
Invalid,
/// Wrong state.
State,
/// Operation failed (context string is log-only).
Failed(String),
/// Not found.
NotFound,
/// Out of memory.
NoMem,
}
impl Error {
/// Map to the public error code.
pub fn code(&self) -> i32 {
match self {
Error::Invalid => OAKNODE_E_INVALID,
Error::State => OAKNODE_E_STATE,
Error::Failed(_) => OAKNODE_E_FAILED,
Error::NotFound => OAKNODE_E_NOT_FOUND,
Error::NoMem => OAKNODE_E_NOMEM,
}
}
}
+81
View File
@@ -0,0 +1,81 @@
// 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/>.
//! The node type registry (C++ `NodeFactory` / `node/factory`):
//! type id -> constructor, plus menu metadata.
use std::sync::OnceLock;
use crate::node::{Category, NodeBehavior, NodeCore};
/// Constructor for a node type: behavior + default core inputs.
pub type NodeConstructor = fn() -> (NodeCore, Box<dyn NodeBehavior>);
/// Static metadata for the node menu (C++ factory listing).
#[derive(Clone)]
pub struct NodeMeta {
/// Type id (matches [`NodeBehavior::type_id`]).
pub type_id: &'static str,
/// Display name.
pub name: &'static str,
/// Categories.
pub categories: &'static [Category],
/// Constructor.
pub create: NodeConstructor,
}
/// The registry (built at crate init by `nodes::register_all`).
pub struct Factory {
entries: Vec<NodeMeta>,
}
impl Factory {
/// Global registry. Registration (`nodes::register_all`) runs on
/// first access, so the menu order is deterministic without an
/// explicit init call.
pub fn global() -> &'static Factory {
GLOBAL.get_or_init(|| {
crate::nodes::register_all();
let entries = ENTRIES
.get()
.expect("nodes::register_all installs the entry table")
.clone();
Factory { entries }
})
}
/// Look up by type id.
pub fn find(&self, type_id: &str) -> Option<&NodeMeta> {
self.entries.iter().find(|e| e.type_id == type_id)
}
/// All entries (menu order = registration order, C++ parity).
pub fn entries(&self) -> &[NodeMeta] {
&self.entries
}
}
/// Entries installed by [`crate::nodes::register_all`].
static ENTRIES: OnceLock<Vec<NodeMeta>> = OnceLock::new();
/// The lazily built global [`Factory`].
static GLOBAL: OnceLock<Factory> = OnceLock::new();
/// Install the registered node entries (called by
/// [`crate::nodes::register_all`]; a no-op if already installed).
pub(crate) fn install_entries(entries: Vec<NodeMeta>) {
let _ = ENTRIES.set(entries);
}
File diff suppressed because it is too large Load Diff
+131
View File
@@ -0,0 +1,131 @@
// 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/>.
//! Bin folders (C++ `olive::Folder`): tree structure over `NodeId`s.
use crate::factory::NodeMeta;
use crate::id::NodeId;
use crate::node::{Category, NodeBehavior, NodeCore};
/// A folder node behavior: children are folder-tree members (folders
/// and footage), not graph edges.
pub struct FolderBehavior {
/// Display name.
pub name: String,
/// Child node ids (folders/footage) in bin order.
pub children: Vec<NodeId>,
}
impl FolderBehavior {
/// New empty folder.
pub fn new(name: &str) -> Self {
FolderBehavior {
name: name.to_string(),
children: Vec::new(),
}
}
/// Add a child (C++ `Folder::add_child`).
pub fn add_child(&mut self, child: NodeId) {
if !self.children.contains(&child) {
self.children.push(child);
}
}
/// Remove a child; false when absent.
pub fn remove_child(&mut self, child: NodeId) -> bool {
let before = self.children.len();
self.children.retain(|c| *c != child);
self.children.len() != before
}
/// Index of `child` in the direct children.
pub fn index_of_child(&self, child: NodeId) -> Option<usize> {
self.children.iter().position(|c| *c == child)
}
/// True when `child` is contained recursively (C++
/// `Folder::has_child_recursive`).
pub fn has_child_recursive(&self, child: NodeId, graph: &crate::graph::Graph) -> bool {
for c in &self.children {
if *c == child {
return true;
}
if let Some(entry) = graph.get(*c) {
if let Some(f) = entry
.behavior
.as_any()
.and_then(|a| a.downcast_ref::<FolderBehavior>())
{
if f.has_child_recursive(child, graph) {
return true;
}
}
}
}
false
}
}
impl NodeBehavior for FolderBehavior {
/// Human-readable name (C++ `name()`): the folder's display name.
fn name(&self) -> &str {
&self.name
}
/// Concrete downcast for the folder family.
fn as_any(&self) -> Option<&dyn std::any::Any> {
Some(self)
}
/// Concrete downcast for the folder family.
fn as_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
Some(self)
}
/// Stable type id (C++ `id()`).
fn type_id(&self) -> &str {
"org.olivevideoeditor.Olive.folder"
}
/// Categories (C++ `category()`): items live in the bin, not the
/// render graph.
fn categories(&self) -> &[Category] {
&[Category::Timeline]
}
/// Deep copy (C++ `copy()`).
fn duplicate(&self, _core: &NodeCore) -> Option<Box<dyn NodeBehavior>> {
Some(Box::new(FolderBehavior::new(&self.name)))
}
}
/// Constructor (C++ `Folder::Folder()`): a folder node has no inputs.
pub fn create(name: &str) -> (NodeCore, Box<dyn NodeBehavior>) {
// Folders carry no `enabled_in` in C++; keep the bare core.
(NodeCore::empty(), Box::new(FolderBehavior::new(name)))
}
/// Register a folder-typed node (used by the serializer for bin folders;
/// folders are not in the factory menu).
pub fn register(meta: &mut Vec<NodeMeta>) {
meta.push(NodeMeta {
type_id: "org.olivevideoeditor.Olive.folder",
name: "Folder",
categories: &[Category::Timeline],
create: || create("Folder"),
});
}
+249
View File
@@ -0,0 +1,249 @@
// 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/>.
//! Footage nodes (C++ `olive::Footage`): media file references.
//! Probing goes through the oakcodec C ABI (`bridge::codec`) — the C++
//! transition-stub probe path does not exist here.
//! `// CPP-PARITY: src/node/src/project/footage/footage.{h,cpp}`.
use std::sync::atomic::{AtomicBool, Ordering};
use crate::node::{Category, NodeBehavior, NodeCore};
use crate::value::{AudioParams, VideoParams};
/// One media stream inside a footage file.
#[derive(Clone, Debug)]
pub struct StreamInfo {
/// Stream index in the container.
pub index: i32,
/// True for video streams.
pub is_video: bool,
/// Video parameters (when `is_video`).
pub video: Option<VideoParams>,
/// Audio parameters (when not video).
pub audio: Option<AudioParams>,
/// Duration in stream timebase.
pub duration: oakcore_rs::Rational,
}
/// Footage behavior.
pub struct FootageBehavior {
/// Absolute file path.
pub filename: String,
/// Probed streams (empty until [`FootageBehavior::probe`]).
pub streams: Vec<StreamInfo>,
/// Proxy path (C++ set_proxy; empty = none).
pub proxy: String,
/// Proxy playback enabled.
pub proxy_enabled: bool,
/// Proxy state (`ProxyManager::ProxyState`).
pub proxy_state: i32,
/// Proxy's video stream index (-1 when none).
pub proxy_video_stream_index: i32,
/// Proxy preset version.
pub proxy_preset_version: i32,
/// File last-modified timestamp (ms since epoch).
pub timestamp: i64,
/// Decoder id recorded at probe time.
pub decoder: String,
/// True after a successful probe (C++ `is_valid`).
pub valid: bool,
/// Shared cancellation flag (C++ cancel atom).
pub cancel: std::sync::Arc<AtomicBool>,
}
impl FootageBehavior {
/// Create for `filename` (unprobed).
pub fn new(filename: &str) -> Self {
FootageBehavior {
filename: filename.to_string(),
streams: Vec::new(),
proxy: String::new(),
proxy_enabled: false,
proxy_state: 0,
proxy_video_stream_index: -1,
proxy_preset_version: 0,
timestamp: 0,
decoder: String::new(),
valid: false,
cancel: std::sync::Arc::new(AtomicBool::new(false)),
}
}
/// Probe the file through oakcodec (`oakcodec_decoder_probe`),
/// filling `streams`. Error on unreadable/corrupt media or when the
/// codec module is unavailable; the prior `streams`/`valid` state is
/// preserved on failure (no partial state).
pub fn probe(&mut self) -> crate::error::Result<()> {
use crate::error::Error;
// Direct call into the oakcodec crate (single-lib unification):
// `oakcodec_decoder_probe(filename)` returns the stream-list
// handle (owned by the caller).
let out = match crate::bridge::codec::decoder_probe(&self.filename) {
Some(out) => out,
None => {
return Err(Error::Failed(
"oakcodec unavailable (not linked)".to_string(),
));
}
};
if out.is_null() {
return Err(Error::Failed(
"oakcodec probe returned no streams".to_string(),
));
}
// The probe result handle is an oakcodec stream-list. Reading
// stream entries into `streams` is a Phase-2 follow-up (the
// exact accessor symbols are pinned when the codec module C ABI
// is finalized).
let _ = out;
self.valid = true;
Ok(())
}
/// Set the cancellation flag (C++ `set_cancel_pointer`).
pub fn set_cancel(&mut self, cancelled: bool) {
self.cancel.store(cancelled, Ordering::Relaxed);
}
/// True when the cancellation atom is set.
pub fn is_cancelled(&self) -> bool {
self.cancel.load(Ordering::Relaxed)
}
/// Total stream count (C++ `get_total_stream_count`).
pub fn total_stream_count(&self) -> usize {
self.streams.len()
}
/// Video stream count.
pub fn video_stream_count(&self) -> usize {
self.streams.iter().filter(|s| s.is_video).count()
}
/// Audio stream count.
pub fn audio_stream_count(&self) -> usize {
self.streams.iter().filter(|s| !s.is_video).count()
}
/// Subtitle stream count (none without a subtitle codec).
pub fn subtitle_stream_count(&self) -> usize {
0
}
/// Duration of the longest stream (C++ `ViewerOutput::get_length`).
pub fn duration(&self) -> oakcore_rs::Rational {
let mut longest = oakcore_rs::Rational::new(0, 1);
for s in &self.streams {
if s.duration > longest {
longest = s.duration;
}
}
longest
}
/// Video length (duration of the longest video stream).
pub fn video_length(&self) -> oakcore_rs::Rational {
let mut longest = oakcore_rs::Rational::new(0, 1);
for s in self.streams.iter().filter(|s| s.is_video) {
if s.duration > longest {
longest = s.duration;
}
}
longest
}
/// Video params of the `index`th video stream.
pub fn video_params(&self, index: usize) -> Option<VideoParams> {
self.streams
.iter()
.filter(|s| s.is_video)
.nth(index)
.and_then(|s| s.video)
}
/// Audio params of the `index`th audio stream.
pub fn audio_params(&self, index: usize) -> Option<AudioParams> {
self.streams
.iter()
.filter(|s| !s.is_video)
.nth(index)
.and_then(|s| s.audio)
}
/// Set all proxy fields at once (C++ `set_proxy`).
pub fn set_proxy(
&mut self,
path: &str,
state: i32,
video_stream_index: i32,
preset_version: i32,
enabled: bool,
) {
self.proxy = path.to_string();
self.proxy_state = state;
self.proxy_video_stream_index = video_stream_index;
self.proxy_preset_version = preset_version;
self.proxy_enabled = enabled;
}
/// Clear all proxy fields (C++ `clear_proxy`).
pub fn clear_proxy(&mut self) {
self.proxy.clear();
self.proxy_state = 0;
self.proxy_video_stream_index = -1;
self.proxy_preset_version = 0;
self.proxy_enabled = false;
}
}
impl NodeBehavior for FootageBehavior {
fn name(&self) -> &str {
"Footage"
}
fn type_id(&self) -> &str {
"org.olivevideoeditor.Olive.footage"
}
fn categories(&self) -> &[Category] {
&[Category::Input]
}
fn duplicate(&self, _core: &NodeCore) -> Option<Box<dyn NodeBehavior>> {
Some(Box::new(FootageBehavior {
filename: self.filename.clone(),
streams: self.streams.clone(),
proxy: self.proxy.clone(),
proxy_enabled: self.proxy_enabled,
proxy_state: self.proxy_state,
proxy_video_stream_index: self.proxy_video_stream_index,
proxy_preset_version: self.proxy_preset_version,
timestamp: self.timestamp,
decoder: self.decoder.clone(),
valid: self.valid,
cancel: self.cancel.clone(),
}))
}
fn as_any(&self) -> Option<&dyn std::any::Any> {
Some(self)
}
fn as_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
Some(self)
}
}
+336
View File
@@ -0,0 +1,336 @@
// 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/>.
//! Gizmo data types (C++ `src/node/src/gizmo/{gizmo,draggable,point,line,
//! path,polygon,screen,text}.h`; the C++ classes are `olive::NodeGizmo`,
//! `DraggableGizmo`, `PointGizmo`, `LineGizmo`, `PathGizmo`,
//! `PolygonGizmo`, `ScreenGizmo`, `TextGizmo`, plus the `PointF`/`LineF`/
//! `RectF` carriers from `mathtypes.h`/`line.h`/`text.h`).
//!
//! Data only — drawing, hit testing, and mouse handling live in the
//! facade/app layer, so the C++ drag callbacks (`drag_start`/`drag_move`/
//! `drag_end`) and `TextGizmo::update_input_html` have no Rust counterpart
//! here.
//!
//! Note: the rough `crate::node::Gizmo` sketch in `node.rs` should
//! eventually be replaced by the types in this module.
//!
//! C++ uses single inheritance (`NodeGizmo` <- `DraggableGizmo` <-
//! `PointGizmo`/`PathGizmo`/`PolygonGizmo`/`ScreenGizmo`); Rust models
//! this by flattening the base-class fields into each concrete struct
//! (the base fields are documented as such on each).
use oakcore_rs::{Rational, TimeRange};
use crate::value::{AudioParams, NodeValue, VideoParams};
/// 2D point (C++ `olive::PointF` in `src/node/src/mathtypes.h`, a de-Qt
/// replacement for `QPointF`).
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct PointF {
/// X coordinate.
pub x: f64,
/// Y coordinate.
pub y: f64,
}
impl PointF {
/// Construct a point (C++ `PointF(x, y)`).
pub fn new(x: f64, y: f64) -> Self {
Self { x, y }
}
/// Whether both coordinates are zero (C++ `is_null()`).
pub fn is_null(&self) -> bool {
self.x == 0.0 && self.y == 0.0
}
/// `|x| + |y|` (C++ `manhattan_length()`).
pub fn manhattan_length(&self) -> f64 {
self.x.abs() + self.y.abs()
}
}
/// Line segment (C++ `olive::LineF` in `src/node/src/gizmo/line.h`, a
/// de-Qt replacement for `QLineF`; data carrier only).
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct LineF {
/// Start point (C++ `p1_`).
pub p1: PointF,
/// End point (C++ `p2_`).
pub p2: PointF,
}
/// Rectangle (C++ `olive::RectF` in `src/node/src/gizmo/text.h`, a de-Qt
/// replacement for `QRectF`; text gizmo rect).
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct RectF {
/// Left edge (C++ `x_`).
pub x: f64,
/// Top edge (C++ `y_`).
pub y: f64,
/// Width (C++ `width_`).
pub width: f64,
/// Height (C++ `height_`).
pub height: f64,
}
/// Loop mode (C++ `enum class LoopMode` in
/// `src/common/src/loopmode.h`).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum LoopMode {
/// Play once (C++ `k_loop_mode_off`).
#[default]
Off,
/// Repeat the clip (C++ `k_loop_mode_loop`).
Loop,
/// Hold first/last frame (C++ `k_loop_mode_clamp`).
Clamp,
}
/// Sequence/render globals a gizmo draws against (C++ `NodeGlobals` in
/// `src/node/src/globals.h`).
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct NodeGlobals {
/// Video parameters (C++ `video_params_`).
pub video_params: VideoParams,
/// Audio parameters (C++ `audio_params_`).
pub audio_params: AudioParams,
/// Current time range (C++ `time_`).
pub time: TimeRange,
/// Loop mode (C++ `loop_mode_`).
pub loop_mode: LoopMode,
}
/// Reference to one keyframe track of a node input (C++
/// `NodeKeyframeTrackReference` in `src/node/src/param.h`). Invalid when
/// `track` < 0 (C++ default constructs `track_ = -1`).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct KeyframeTrackReference {
/// Input id (C++ `NodeInput`'s input string).
pub input_id: String,
/// Array element (C++ `NodeInput`'s element; -1 = none).
pub element: i32,
/// Track index (C++ `track_`; -1 = invalid).
pub track: i32,
}
impl Default for KeyframeTrackReference {
/// C++ default constructor: empty input, `track = -1`.
fn default() -> Self {
Self {
input_id: String::new(),
element: -1,
track: -1,
}
}
}
impl KeyframeTrackReference {
/// Whether the reference is usable (C++ `is_valid()`).
pub fn is_valid(&self) -> bool {
self.track >= 0
}
}
/// Drag state for one input (C++ `NodeInputDragger` in
/// `src/node/src/inputdragger.h`). Data only: the C++ `start`/`drag`/
/// `end` methods write keyframes and push undo commands — that behavior
/// belongs to the facade/app layer; the C++ static
/// `input_being_dragged` flag is global UI state and is not modeled
/// here.
#[derive(Clone, Debug)]
pub struct InputDragger {
/// Target input/track (C++ `input_`).
pub input: KeyframeTrackReference,
/// Time at drag start (C++ `time_`).
pub time: Rational,
/// Value at drag start (C++ `start_value_`).
pub start_value: NodeValue,
/// Current/last dragged value (C++ `end_value_`).
pub end_value: NodeValue,
/// Whether a drag is in progress (C++ `is_started()`).
pub started: bool,
}
impl Default for InputDragger {
/// C++ default constructor: no drag in progress, values unset.
fn default() -> Self {
Self {
input: KeyframeTrackReference::default(),
time: Rational::default(),
start_value: NodeValue::None,
end_value: NodeValue::None,
started: false,
}
}
}
/// What the X/Y coordinates emitted during a drag mean (C++
/// `DraggableGizmo::DragValueBehavior` in
/// `src/node/src/gizmo/draggable.h`).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum DragValueBehavior {
/// Exact mouse coordinates in sequence pixels (C++ `k_absolute`;
/// the C++ default).
#[default]
Absolute,
/// Movement since the last move event (C++
/// `k_delta_from_previous`).
DeltaFromPrevious,
/// Movement from the start of the drag (C++ `k_delta_from_start`).
DeltaFromStart,
}
/// Base gizmo data (C++ `olive::NodeGizmo` private members in
/// `src/node/src/gizmo/gizmo.h`).
///
/// The C++ `parent_` node pointer is omitted: ownership and
/// registration run through `NodeCore::gizmos` (mirroring
/// `Node::add_gizmo()`/`remove_gizmo()`), so a back-pointer has no Rust
/// equivalent here.
#[derive(Clone, Debug, Default)]
pub struct GizmoBase {
/// Sequence globals the gizmo draws against (C++ `globals_`).
pub globals: NodeGlobals,
/// Visibility flag (C++ `visible_`).
pub visible: bool,
}
/// Draggable base data (C++ `olive::DraggableGizmo` in
/// `src/node/src/gizmo/draggable.h`).
#[derive(Clone, Debug, Default)]
pub struct DraggableGizmo {
/// Base gizmo data (C++ `NodeGizmo` base-class members).
pub base: GizmoBase,
/// Keyframed inputs this gizmo drags (C++ `inputs_`; appended by
/// `add_input()`).
pub inputs: Vec<KeyframeTrackReference>,
/// Per-input drag state, parallel to `inputs` (C++ `draggers_`).
pub draggers: Vec<InputDragger>,
/// Drag coordinate semantics (C++ `drag_value_behavior_`).
pub drag_value_behavior: DragValueBehavior,
}
/// Point gizmo shape (C++ `PointGizmo::Shape` in
/// `src/node/src/gizmo/point.h`).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum PointShape {
/// Square handle (C++ `k_square`; the C++ default constructor's
/// shape).
#[default]
Square,
/// Circle handle (C++ `k_circle`).
Circle,
/// Anchor-point handle (C++ `k_anchor_point`).
AnchorPoint,
}
/// Point gizmo (C++ `olive::PointGizmo` in
/// `src/node/src/gizmo/point.h`; base `DraggableGizmo`).
#[derive(Clone, Debug, Default)]
pub struct PointGizmo {
/// Draggable base data (C++ `DraggableGizmo` base-class members).
pub draggable: DraggableGizmo,
/// Handle shape (C++ `shape_`).
pub shape: PointShape,
/// Handle position in sequence pixels (C++ `point_`).
pub point: PointF,
/// Render the handle smaller (C++ `smaller_`).
pub smaller: bool,
}
/// Line gizmo (C++ `olive::LineGizmo` in `src/node/src/gizmo/line.h`;
/// base `NodeGizmo`, not draggable).
#[derive(Clone, Debug, Default)]
pub struct LineGizmo {
/// Base gizmo data (C++ `NodeGizmo` base-class members).
pub base: GizmoBase,
/// The line in sequence pixels (C++ `line_`).
pub line: LineF,
}
/// Path gizmo (C++ `olive::PathGizmo` in `src/node/src/gizmo/path.h`;
/// base `DraggableGizmo`).
///
/// The C++ class has no own members — its former `QPainterPath` (a
/// drawing/hit-test primitive) was removed; path storage and drawing
/// belong to the app layer. The type is kept so the gizmo hierarchy
/// remains distinguishable.
#[derive(Clone, Debug, Default)]
pub struct PathGizmo {
/// Draggable base data (C++ `DraggableGizmo` base-class members).
pub draggable: DraggableGizmo,
}
/// Polygon gizmo (C++ `olive::PolygonGizmo` in
/// `src/node/src/gizmo/polygon.h`; base `DraggableGizmo`).
///
/// Point-in-polygon testing (formerly `QPolygonF::containsPoint`)
/// belongs to the app layer.
#[derive(Clone, Debug, Default)]
pub struct PolygonGizmo {
/// Draggable base data (C++ `DraggableGizmo` base-class members).
pub draggable: DraggableGizmo,
/// Polygon vertices (C++ `polygon_`, de-Qt'd from `QPolygonF` to a
/// plain vector of points).
pub polygon: Vec<PointF>,
}
/// Screen gizmo (C++ `olive::ScreenGizmo` in
/// `src/node/src/gizmo/screen.h`; base `DraggableGizmo`).
///
/// The C++ class has no own members; it exists only to distinguish the
/// whole-screen drag target in the gizmo hierarchy.
#[derive(Clone, Debug, Default)]
pub struct ScreenGizmo {
/// Draggable base data (C++ `DraggableGizmo` base-class members).
pub draggable: DraggableGizmo,
}
/// Text gizmo vertical alignment (C++ `TextGizmo::VerticalAlignment` in
/// `src/node/src/gizmo/text.h`, formerly `Qt::Alignment`; values match
/// the oakengine facade).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum VerticalAlignment {
/// Align to the top (C++ `k_align_top` = 0).
#[default]
Top,
/// Align to the bottom (C++ `k_align_bottom` = 1).
Bottom,
/// Align vertically centered (C++ `k_align_vcenter` = 2).
VCenter,
}
/// Text gizmo (C++ `olive::TextGizmo` in `src/node/src/gizmo/text.h`;
/// base `NodeGizmo`, not draggable).
///
/// `TextGizmo::update_input_html()` (pushing edited HTML back into the
/// connected input at a time) is behavioral and belongs to the
/// facade/app layer, so it is not declared here.
#[derive(Clone, Debug, Default)]
pub struct TextGizmo {
/// Base gizmo data (C++ `NodeGizmo` base-class members).
pub base: GizmoBase,
/// Text bounds in sequence pixels (C++ `rect_`).
pub rect: RectF,
/// HTML content (C++ `text_`).
pub html: String,
/// Input track this gizmo edits (C++ `input_`).
pub input: KeyframeTrackReference,
/// Vertical alignment (C++ `valign_`).
pub vertical_alignment: VerticalAlignment,
}
+640
View File
@@ -0,0 +1,640 @@
// 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/>.
//! The graph arena: node storage, edges, traversal order.
//!
//! Replaces the C++ pointer web (`Node::parent_`, output_connections_)
//! with a slab arena + edge set. All structural mutation goes through
//! `&mut Graph` methods; evaluation takes `&Graph`.
use std::collections::{BTreeSet, HashMap, HashSet};
use crate::id::NodeId;
use crate::node::{NodeBehavior, NodeCore};
/// One arena slot.
pub struct NodeEntry {
/// Shared data.
pub core: NodeCore,
/// Polymorphic behavior.
pub behavior: Box<dyn NodeBehavior>,
/// Generation for stale-id detection.
pub generation: u32,
/// True when the slot is free.
pub vacant: bool,
}
/// A directed edge: `from` node's output feeds `to` node's `input`
/// (element for array inputs).
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub struct Edge {
/// Source node.
pub from: NodeId,
/// Destination node.
pub to: NodeId,
/// Destination input id hash + element (string stored arena-side).
pub input_key: u64,
/// Array element (-1 = scalar input).
pub element: i32,
}
/// The node graph. Nodes are owned here, exclusively.
pub struct Graph {
entries: Vec<NodeEntry>,
free_list: Vec<u32>,
edges: BTreeSet<Edge>,
/// Arena-side storage for edge input ids (key = hash into this map).
input_names: HashMap<u64, String>,
}
impl Graph {
/// Empty graph.
pub fn new() -> Self {
Graph {
entries: Vec::new(),
free_list: Vec::new(),
edges: BTreeSet::new(),
input_names: HashMap::new(),
}
}
/// Insert a node; returns its id.
pub fn add_node(&mut self, core: NodeCore, behavior: Box<dyn NodeBehavior>) -> NodeId {
let (index, generation) = match self.free_list.pop() {
Some(i) => {
// Reuse the slot with a fresh generation (the slot's
// previous occupant is gone; its generation counter
// advances so stale ids fail).
let gen = self.entries[i as usize].generation.wrapping_add(1);
self.entries[i as usize] = NodeEntry {
core,
behavior,
generation: gen,
vacant: false,
};
(i, gen)
}
None => {
let index = self.entries.len() as u32;
let entry = NodeEntry {
core,
behavior,
generation: 0,
vacant: false,
};
self.entries.push(entry);
(index, 0)
}
};
NodeId::new(index, generation)
}
/// Insert a previously detached [`NodeEntry`] (from
/// [`Graph::take_node`]) back into this graph, preserving its identity
/// when the original slot is free here. `id` is the node's identity
/// before the detach; it is reused when its slot is vacant. Used by
/// the project node-transfer paths so a node's stable identity
/// survives a move.
pub fn add_entry(&mut self, entry: NodeEntry, id: NodeId) -> NodeId {
let index = id.index();
if (index as usize) < self.entries.len() && self.entries[index as usize].vacant {
// Original slot free: reuse (index, generation) unchanged.
let generation = entry.generation;
self.entries[index as usize] = entry;
return NodeId::new(index, generation);
}
// Slot occupied (or out of range): allocate fresh.
match self.free_list.pop() {
Some(i) => {
let gen = self.entries[i as usize].generation.wrapping_add(1);
let new_id = NodeId::new(i, gen);
self.entries[i as usize] = NodeEntry {
core: entry.core,
behavior: entry.behavior,
generation: gen,
vacant: false,
};
new_id
}
None => {
let i = self.entries.len() as u32;
self.entries.push(NodeEntry {
core: entry.core,
behavior: entry.behavior,
generation: 0,
vacant: false,
});
NodeId::new(i, 0)
}
}
}
/// Detach a node from the arena, returning its full entry (core +
/// behavior + generation) and removing all its edges. `None` for a
/// stale id. Unlike [`Graph::remove_node`] the entry is preserved for
/// re-insertion elsewhere (project detach/attach moves).
pub fn take_node(&mut self, id: NodeId) -> Option<NodeEntry> {
if !self.is_valid(id) {
return None;
}
let idx = id.index() as usize;
let vacant_entry = NodeEntry {
core: NodeCore::empty(),
behavior: Box::new(crate::nodes::EmptyBehavior),
generation: self.entries[idx].generation,
vacant: true,
};
// Move the real entry out.
let taken = std::mem::replace(&mut self.entries[idx], vacant_entry);
self.free_list.push(id.index());
self.drop_edges_touching(id);
Some(taken)
}
/// Remove a node and all its edges (C++: ~Node + set_parent(null)
/// + disconnect_all side effects — see `// CPP-PARITY: node.cpp`).
pub fn remove_node(&mut self, id: NodeId) -> Option<Box<dyn NodeBehavior>> {
let entry = self.take_node(id)?;
Some(entry.behavior)
}
/// Validated access; `None` for stale ids.
pub fn get(&self, id: NodeId) -> Option<&NodeEntry> {
if !self.is_valid(id) {
return None;
}
Some(&self.entries[id.index() as usize])
}
/// Mutable access; `None` for stale ids.
pub fn get_mut(&mut self, id: NodeId) -> Option<&mut NodeEntry> {
if !self.is_valid(id) {
return None;
}
Some(&mut self.entries[id.index() as usize])
}
/// True when `id` names a live slot.
pub fn is_valid(&self, id: NodeId) -> bool {
id.valid()
&& (id.index() as usize) < self.entries.len()
&& !self.entries[id.index() as usize].vacant
&& self.entries[id.index() as usize].generation == id.generation()
}
/// Number of live nodes (used by the project node_count family).
pub fn node_count(&self) -> usize {
self.entries.len() - self.free_list.len()
}
/// Live node ids in slot order (stable within a session).
pub fn node_ids(&self) -> Vec<NodeId> {
let mut ids = Vec::with_capacity(self.node_count());
for (i, e) in self.entries.iter().enumerate() {
if !e.vacant {
ids.push(NodeId::new(i as u32, e.generation));
}
}
ids
}
/// Connect `from`'s output to `to.input[element]`
/// (C++ `Node::connect_edge` incl. cycle rejection).
///
/// Errors: `NotFound` for a stale endpoint or unknown input id;
/// `Invalid` when the input is not connectable; `State` when the
/// input is already connected, `from == to`, or the edge would create
/// a cycle (the Rust design rejects cycles at connect time so the
/// arena never contains them — `// CPP-PARITY: node.cpp:210`).
pub fn connect(&mut self, from: NodeId, to: NodeId, input: &str, element: i32) -> crate::error::Result<()> {
use crate::error::Error;
if !self.is_valid(from) || !self.is_valid(to) {
return Err(Error::NotFound);
}
// Input must exist and be connectable (checked before the
// state-level rejections, matching the C++ c_api precedence:
// NOT_FOUND > INVALID(not connectable) > STATE).
let input_flags = {
let entry = self.get(to).expect("validated above");
let input = entry
.core
.get_input(input)
.ok_or(Error::NotFound)?;
input.flags
};
if input_flags & crate::input::flags::NOT_CONNECTABLE != 0 {
return Err(Error::Invalid);
}
// Array inputs address elements >= 0; scalar inputs only -1.
let is_array = input_flags & crate::input::flags::ARRAY != 0;
if !is_array && element != -1 {
return Err(Error::Invalid);
}
if from == to {
// Self-connection is a trivial cycle.
return Err(Error::State);
}
// Already connected on this input.
if self.connected_output(to, input, element).is_some() {
return Err(Error::State);
}
// Cycle rejection: adding from->to must not let `to` reach `from`.
if self.reaches(to, from) {
return Err(Error::State);
}
let key = self.intern_input(input);
let edge = Edge {
from,
to,
input_key: key,
element,
};
self.edges.insert(edge);
Ok(())
}
/// Disconnect one edge (no-op when absent).
pub fn disconnect(&mut self, from: NodeId, to: NodeId, input: &str, element: i32) {
let key = self.input_key(input);
let edge = Edge {
from,
to,
input_key: key,
element,
};
self.edges.remove(&edge);
}
/// Remove the edge feeding `to.input[element]` (returns the source
/// node id, or `None` when absent).
pub fn disconnect_input(&mut self, to: NodeId, input: &str, element: i32) -> Option<NodeId> {
let from = self.connected_output(to, input, element)?;
self.disconnect(from, to, input, element);
Some(from)
}
/// The node feeding `to.input[element]`, if any.
pub fn connected_output(&self, to: NodeId, input: &str, element: i32) -> Option<NodeId> {
let key = self.input_key(input);
self.edges
.iter()
.find(|e| e.to == to && e.input_key == key && e.element == element)
.map(|e| e.from)
}
/// True when `to.input[element]` has a connected edge.
pub fn is_input_connected(&self, to: NodeId, input: &str, element: i32) -> bool {
self.connected_output(to, input, element).is_some()
}
/// Incoming edges of `id`: `(source, input_id, element)` — the
/// edges feeding this node's inputs.
pub fn input_connections(&self, id: NodeId) -> Vec<(NodeId, String, i32)> {
self.edges
.iter()
.filter(|e| e.to == id)
.map(|e| {
(
e.from,
self.input_names
.get(&e.input_key)
.cloned()
.unwrap_or_default(),
e.element,
)
})
.collect()
}
/// Outgoing edges of `id`, in stable (edge) order:
/// `(target, input_id, element)`.
pub fn output_connections(&self, id: NodeId) -> Vec<(NodeId, String, i32)> {
self.edges
.iter()
.filter(|e| e.from == id)
.map(|e| {
(
e.to,
self.input_names
.get(&e.input_key)
.cloned()
.unwrap_or_default(),
e.element,
)
})
.collect()
}
/// Every edge in the graph as `(from, to, input_id, element)` (used
/// by the deep-copy paths).
pub fn output_connections_all(&self) -> Vec<(NodeId, NodeId, String, i32)> {
self.edges
.iter()
.map(|e| {
(
e.from,
e.to,
self.input_names
.get(&e.input_key)
.cloned()
.unwrap_or_default(),
e.element,
)
})
.collect()
}
/// Nodes directly upstream of `id` (evaluation order helper).
pub fn upstream(&self, id: NodeId) -> Vec<NodeId> {
let mut v: Vec<NodeId> = self
.edges
.iter()
.filter(|e| e.to == id)
.map(|e| e.from)
.collect();
v.sort_unstable();
v
}
/// Nodes directly downstream of `id` (invalidation fan-out).
pub fn downstream(&self, id: NodeId) -> Vec<NodeId> {
let mut v: Vec<NodeId> = self
.edges
.iter()
.filter(|e| e.from == id)
.map(|e| e.to)
.collect();
v.sort_unstable();
v.dedup();
v
}
/// True when `from` can reach `to` through existing edges (DFS on
/// the BTreeSet adjacency).
fn reaches(&self, from: NodeId, to: NodeId) -> bool {
let mut stack = vec![from];
let mut seen: HashSet<NodeId> = HashSet::new();
while let Some(n) = stack.pop() {
if n == to {
return true;
}
if !seen.insert(n) {
continue;
}
stack.extend(
self.edges
.iter()
.filter(|e| e.from == n)
.map(|e| e.to),
);
}
false
}
/// Topological order from sources to sinks (Kahn; cycles are
/// rejected at connect time so this cannot fail). Deterministic:
/// ready nodes are taken in ascending [`NodeId`] order.
pub fn topological_order(&self) -> Vec<NodeId> {
// In-degree per live node.
let mut indegree: HashMap<NodeId, usize> = HashMap::new();
for e in &self.edges {
*indegree.entry(e.to).or_insert(0) += 1;
indegree.entry(e.from).or_insert(0);
}
let mut ready: BTreeSet<NodeId> = indegree
.iter()
.filter(|(_, d)| **d == 0)
.map(|(n, _)| *n)
.collect();
let mut order = Vec::with_capacity(indegree.len());
while let Some(n) = ready.iter().next().copied() {
ready.remove(&n);
order.push(n);
for e in self.edges.iter().filter(|e| e.from == n) {
let d = indegree.get_mut(&e.to).expect("every edge endpoint is counted");
*d -= 1;
if *d == 0 {
ready.insert(e.to);
}
}
}
// Isolated nodes (no edges) are absent from `indegree`; append them.
let mut isolated: Vec<NodeId> = self
.node_ids()
.into_iter()
.filter(|n| !indegree.contains_key(n))
.collect();
order.append(&mut isolated);
order
}
/// Intern an input id string, returning its stable key.
fn intern_input(&mut self, input: &str) -> u64 {
let key = hash_str(input);
self.input_names.entry(key).or_insert_with(|| input.to_string());
key
}
/// Key for an already-interned (or new) input id; never inserts.
fn input_key(&self, input: &str) -> u64 {
hash_str(input)
}
/// Link two nodes bidirectionally (C++ `Node::link`); false when
/// already linked or `a == b`.
pub fn link(&mut self, a: NodeId, b: NodeId) -> bool {
if a == b || !self.is_valid(a) || !self.is_valid(b) || self.are_linked(a, b) {
return false;
}
let (a_idx, b_idx) = (a.index() as usize, b.index() as usize);
let a_placeholder = vacant_entry(self.entries[a_idx].generation);
let b_placeholder = vacant_entry(self.entries[b_idx].generation);
let mut a_entry = std::mem::replace(&mut self.entries[a_idx], a_placeholder);
let mut b_entry = std::mem::replace(&mut self.entries[b_idx], b_placeholder);
a_entry.core.links.push(b);
b_entry.core.links.push(a);
self.entries[a_idx] = a_entry;
self.entries[b_idx] = b_entry;
true
}
/// Unlink two nodes (C++ `Node::unlink`); false when not linked.
pub fn unlink(&mut self, a: NodeId, b: NodeId) -> bool {
if !self.are_linked(a, b) {
return false;
}
let (a_idx, b_idx) = (a.index() as usize, b.index() as usize);
let a_placeholder = vacant_entry(self.entries[a_idx].generation);
let b_placeholder = vacant_entry(self.entries[b_idx].generation);
let mut a_entry = std::mem::replace(&mut self.entries[a_idx], a_placeholder);
let mut b_entry = std::mem::replace(&mut self.entries[b_idx], b_placeholder);
a_entry.core.links.retain(|n| *n != b);
b_entry.core.links.retain(|n| *n != a);
self.entries[a_idx] = a_entry;
self.entries[b_idx] = b_entry;
true
}
/// True when `a` and `b` are linked (C++ `Node::are_linked`).
pub fn are_linked(&self, a: NodeId, b: NodeId) -> bool {
self.get(a)
.map(|e| e.core.links.contains(&b))
.unwrap_or(false)
}
/// Linked node ids of `id` (C++ `Node::links`).
pub fn links_of(&self, id: NodeId) -> Vec<NodeId> {
self.get(id)
.map(|e| e.core.links.clone())
.unwrap_or_default()
}
/// Insert an element into an array input, shifting per-element values,
/// keyframes and edges (C++ `Node::input_array_insert`).
pub fn input_array_insert(&mut self, id: NodeId, input: &str, index: i32) -> crate::error::Result<()> {
use crate::error::Error;
let entry = self.get_mut(id).ok_or(Error::NotFound)?;
let input_ = entry.core.get_input(input).ok_or(Error::NotFound)?;
if !input_.is_array() {
return Err(Error::Invalid);
}
let size = input_.array_size;
if index < 0 || index > size as i32 {
return Err(Error::Invalid);
}
drop(input_);
let entry = self.get_mut(id).ok_or(Error::NotFound)?;
entry.core.input_array_insert(input, index as usize);
// Move connections down one element.
let key = self.input_key(input);
let moves: Vec<Edge> = self
.edges
.iter()
.filter(|e| e.to == id && e.input_key == key && e.element >= index)
.copied()
.collect();
for e in moves {
self.edges.remove(&e);
let mut shifted = e;
shifted.element += 1;
self.edges.insert(shifted);
}
Ok(())
}
/// Remove an array element, shifting per-element values, keyframes
/// and edges up (C++ `Node::input_array_remove`).
pub fn input_array_remove(&mut self, id: NodeId, input: &str, index: i32) -> crate::error::Result<()> {
use crate::error::Error;
let entry = self.get_mut(id).ok_or(Error::NotFound)?;
let input_ = entry.core.get_input(input).ok_or(Error::NotFound)?;
if !input_.is_array() {
return Err(Error::Invalid);
}
let size = input_.array_size;
if index < 0 || index >= size as i32 {
return Err(Error::Invalid);
}
drop(input_);
let entry = self.get_mut(id).ok_or(Error::NotFound)?;
entry.core.input_array_remove(input, index as usize);
// Move connections up one element; drop the connection on the
// removed element.
let key = self.input_key(input);
let moves: Vec<Edge> = self
.edges
.iter()
.filter(|e| e.to == id && e.input_key == key && e.element >= index)
.copied()
.collect();
for e in moves {
self.edges.remove(&e);
if e.element > index {
let mut shifted = e;
shifted.element -= 1;
self.edges.insert(shifted);
}
}
Ok(())
}
/// Transfer every live node and edge from `other` into `self`,
/// preserving identities where the slots are free. Returns the
/// original-id -> new-id mapping. Used when a project adopts a
/// self-contained subgraph (e.g. a sequence with its track lists).
pub fn transfer_all(&mut self, other: &mut Graph) -> std::collections::HashMap<NodeId, NodeId> {
let mut map = std::collections::HashMap::new();
let ids = other.node_ids();
for id in ids {
let entry = match other.take_node(id) {
Some(e) => e,
None => continue,
};
let new_id = self.add_entry(entry, id);
map.insert(id, new_id);
}
// Re-create edges with remapped endpoints.
let edges = other.output_connections_all();
for (from, to, input, element) in edges {
let from = *map.get(&from).unwrap_or(&from);
let to = *map.get(&to).unwrap_or(&to);
self.connect(from, to, &input, element).ok();
}
map
}
/// Drop every edge touching `id` (C++ `disconnect_all`).
fn drop_edges_touching(&mut self, id: NodeId) {
let doomed: Vec<Edge> = self
.edges
.iter()
.filter(|e| e.from == id || e.to == id)
.copied()
.collect();
for e in doomed {
self.edges.remove(&e);
}
}
}
/// A vacant-slot placeholder entry (used by the two-at-a-time link
/// edits, which cannot borrow two slots mutably at once).
fn vacant_entry(generation: u32) -> NodeEntry {
NodeEntry {
core: NodeCore::empty(),
behavior: Box::new(crate::nodes::EmptyBehavior),
generation,
vacant: true,
}
}
/// Deterministic string hash for edge input-id keys (stable across runs;
/// `DefaultHasher::new()` uses fixed SipHash keys).
fn hash_str(input: &str) -> u64 {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
input.hash(&mut h);
h.finish()
}
+174
View File
@@ -0,0 +1,174 @@
// 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/>.
//! Refcounted-handle scaffolding. Same pattern as the oakplugin crate
//! (`src/plugin/rust/src/handle.rs`); intentionally duplicated rather
//! than shared — each module DLL must run its own addref/release code
//! (the function pointers in a handle always point into the DLL that
//! created the object).
use std::any::Any;
use std::panic::{catch_unwind, AssertUnwindSafe};
use std::sync::atomic::{AtomicU32, Ordering};
use crate::error::OAKNODE_E_FAILED;
/// ABI version stamped into every handle.
pub const OAKNODE_ABI_VERSION: u32 = 1;
/// Heap box behind a handle's `ctx`.
pub struct RefBox<T: ?Sized> {
/// Atomic reference count.
pub refs: AtomicU32,
/// Boxed value.
pub value: T,
}
/// The shared ABI value-handle type (single-lib unification, see
/// `docs/zh/plans/riir/single-lib.md`): one canonical
/// `{ctx, addref, release, abi_version}` type in `oakcore-rs`, re-exported
/// here so the crate's `ffi.rs` signatures and handle scaffolding stay
/// source-compatible.
pub use oakcore_rs::handle::CHandle;
/// addref 的实现:原子 +1。拥有型与借用型共用——借用型只延长盒子
/// 的寿命,不延长被借用对象。
unsafe extern "C" fn refbox_addref<T: Any + Send>(ctx: *mut std::ffi::c_void) {
unsafe {
let rb = ctx as *const RefBox<T>;
// 调用方保证句柄在借用期内有效(ctx 非空且未被释放)。
(*rb).refs.fetch_add(1, Ordering::Relaxed);
}
}
/// release 的实现(拥有型):原子 -1,归零时回收盒子并销毁内含对象。
unsafe extern "C" fn refbox_release_owned<T: Any + Send>(ctx: *mut std::ffi::c_void) {
unsafe {
let rb = ctx as *mut RefBox<T>;
// AcqRel:归零这一侧要能看见最后一次引用前的全部写(含对象
// 析构所需的内部状态)。
if (*rb).refs.fetch_sub(1, Ordering::AcqRel) == 1 {
drop(Box::from_raw(rb));
}
}
}
/// release 的实现(借用型,[`make_borrowed`] 的产物):归零时只回收
/// 盒子内存,把内含对象原样忘掉——其所有权仍在借用方手里。
unsafe extern "C" fn refbox_release_borrowed<T: Any + Send>(ctx: *mut std::ffi::c_void) {
unsafe {
let rb = ctx as *mut RefBox<T>;
if (*rb).refs.fetch_sub(1, Ordering::AcqRel) == 1 {
// 部分 move:把 value 移出临时 Box,Box 析构只释放分配;
// value 用 forget 放弃析构(double-free 防线)。
std::mem::forget((Box::from_raw(rb)).value);
}
}
}
/// Owned handle with count 1; empty on allocation failure.
pub fn make_owned<T: Any + Send>(value: T) -> CHandle {
let rb = Box::into_raw(Box::new(RefBox {
refs: AtomicU32::new(1),
value,
}));
CHandle {
ctx: rb as *mut std::ffi::c_void,
addref: Some(refbox_addref::<T>),
release: Some(refbox_release_owned::<T>),
abi_version: OAKNODE_ABI_VERSION,
}
}
/// Owned handle with count 1 and a caller-provided release routine
/// (used by the ffi layer's alive-counted node/project boxes, where the
/// release must also update the debug counter).
pub fn make_owned_with<T: Any + Send>(
value: T,
release: unsafe extern "C" fn(*mut std::ffi::c_void),
) -> CHandle {
let rb = Box::into_raw(Box::new(RefBox {
refs: AtomicU32::new(1),
value,
}));
CHandle {
ctx: rb as *mut std::ffi::c_void,
addref: Some(refbox_addref::<T>),
release: Some(release),
abi_version: OAKNODE_ABI_VERSION,
}
}
/// Borrowed handle for an object owned elsewhere (release frees only
/// the box).
///
/// Semantics: bitwise copy ("borrowed copy"); the borrowed object's
/// destructor is entirely the caller's responsibility — the box never
/// touches it.
///
/// # Safety
/// Caller guarantees `ptr` outlives every derived handle, and that its
/// value is not moved or destroyed for the borrow's lifetime.
pub unsafe fn make_borrowed<T: Any + Send>(ptr: *mut T) -> CHandle {
if ptr.is_null() {
return CHandle::null();
}
let rb = Box::into_raw(Box::new(RefBox {
refs: AtomicU32::new(1),
value: unsafe { std::ptr::read(ptr) },
}));
CHandle {
ctx: rb as *mut std::ffi::c_void,
addref: Some(refbox_addref::<T>),
release: Some(refbox_release_borrowed::<T>),
abi_version: OAKNODE_ABI_VERSION,
}
}
/// Typed view into a handle; `None` for empty handles.
///
/// # Safety
/// `T` must be the boxed type.
pub unsafe fn get<T: Any>(h: &CHandle) -> Option<&T> {
if h.ctx.is_null() {
return None;
}
unsafe { Some(&(*(h.ctx as *const RefBox<T>)).value) }
}
/// Panic-catching FFI wrapper for i32-returning exports.
///
/// Panics map to [`OAKNODE_E_FAILED`].
pub fn guard<F: FnOnce() -> crate::error::Result<()>>(f: F) -> i32 {
match catch_unwind(AssertUnwindSafe(f)) {
Ok(Ok(())) => crate::error::OAKNODE_OK,
Ok(Err(e)) => e.code(),
Err(_) => OAKNODE_E_FAILED,
}
}
/// Panic-catching FFI wrapper for handle-returning exports.
pub fn guard_handle<F: FnOnce() -> crate::error::Result<CHandle>>(f: F) -> CHandle {
match catch_unwind(AssertUnwindSafe(f)) {
Ok(Ok(h)) => h,
Ok(Err(_)) | Err(_) => CHandle::null(),
}
}
/// Panic-catching FFI wrapper for void exports.
pub fn guard_void<F: FnOnce()>(f: F) {
let _ = catch_unwind(AssertUnwindSafe(f));
}
+78
View File
@@ -0,0 +1,78 @@
// 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/>.
//! Generational identifiers for arena-stored graph objects.
//!
//! The public C ABI hands out opaque refcounted handle boxes; inside
//! the crate, a handle boxes `(Arc<Mutex<Project>>, NodeId)`. The
//! generation counter makes a stale `NodeId` (whose slot was reused)
//! fail loudly instead of aliasing a different node — this replaces
//! the C++ design's dangling-pointer failure mode with a checked one.
/// Id of a node inside a [`crate::graph::Graph`] arena.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct NodeId {
index: u32,
generation: u32,
}
impl NodeId {
/// Sentinel marking "no node" (project without a root folder, empty
/// references). Not a valid arena slot: the arena never produces this
/// value (an index of `u32::MAX` is rejected by [`NodeId::valid`]).
pub const INVALID: NodeId = NodeId {
index: u32::MAX,
generation: 0,
};
/// Construct from raw parts (arena-internal use).
pub(crate) fn new(index: u32, generation: u32) -> NodeId {
NodeId { index, generation }
}
/// True when this id names a real arena slot (not [`NodeId::INVALID`]).
pub fn valid(self) -> bool {
self.index != u32::MAX
}
/// Slot index.
pub fn index(self) -> u32 {
self.index
}
/// Generation counter.
pub fn generation(self) -> u32 {
self.generation
}
/// Stable identity integer for registry keys and XML cross-
/// references (replaces the C++ raw-pointer `uintptr_t` identity;
/// NOT an address, safe to persist within a session).
pub fn identity(self) -> u64 {
((self.generation as u64) << 32) | self.index as u64
}
/// Inverse of [`NodeId::identity`]: rebuild an id from its packed
/// form. `None` for the invalid sentinel.
pub fn from_identity(id: u64) -> Option<NodeId> {
let index = (id & 0xffff_ffff) as u32;
let generation = (id >> 32) as u32;
if index == u32::MAX {
return None;
}
Some(NodeId { index, generation })
}
}
+96
View File
@@ -0,0 +1,96 @@
// 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/>.
//! Input descriptors: the C++ `Node::Input` record, flags, array
//! inputs, and value hints.
use crate::value::{NodeValue, ValueType};
/// Input flag bits (values match the C++ `InputFlag` enum — they
/// cross the C ABI and project XML as ints).
pub mod flags {
/// Not connectable to other nodes.
pub const NOT_CONNECTABLE: u32 = 1 << 0;
/// Not keyframable.
pub const NOT_KEYFRAMABLE: u32 = 1 << 1;
/// Array input (elements addressable).
pub const ARRAY: u32 = 1 << 2;
/// Hidden from the parameter UI.
pub const HIDDEN: u32 = 1 << 3;
/// Does not trigger invalidation on change.
pub const IGNORE_INVALIDATIONS: u32 = 1 << 4;
}
/// One input (scalar) or one array element slot's descriptor.
#[derive(Clone)]
pub struct Input {
/// Input id (e.g. "tex_in").
pub id: String,
/// Accepted value type.
pub value_type: ValueType,
/// Default value (C++ default parameter).
pub default: NodeValue,
/// Flag bits (`flags::*`).
pub flags: u32,
/// Display name (C++ `set_input_name`).
pub display_name: String,
/// Arbitrary properties (C++ `set_input_property` map).
pub properties: Vec<(String, NodeValue)>,
/// Array size for ARRAY inputs (0 otherwise).
pub array_size: usize,
}
impl Input {
/// New input with the given id/type/default and normal flags.
pub fn new(id: &str, value_type: ValueType, default: NodeValue) -> Input {
Input {
id: id.to_string(),
value_type,
default,
flags: 0,
display_name: id.to_string(),
properties: Vec::new(),
array_size: 0,
}
}
/// Convenience accessor for the flag bits.
pub fn flags(&self) -> u32 {
self.flags
}
/// True when this is an array input (elements addressable).
pub fn is_array(&self) -> bool {
self.flags & flags::ARRAY != 0
}
/// True when the input accepts connections.
pub fn is_connectable(&self) -> bool {
self.flags & flags::NOT_CONNECTABLE == 0
}
}
/// Value hint (C++ `Node::ValueHint`): accepted type set per input,
/// used to convert values on connect.
#[derive(Clone, Debug, Default)]
pub struct ValueHint {
/// Accepted types in preference order.
pub types: Vec<ValueType>,
/// Optional index hint.
pub index: i32,
/// Optional tag (e.g. track reference).
pub tag: String,
}
+341
View File
@@ -0,0 +1,341 @@
// 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/>.
//! Keyframes and interpolation (C++ `NodeKeyframe` + track logic).
//!
//! Interpolation parity: [`KeyframeTrack::value_at`] ports C++
//! `Node::get_split_value_at_time_on_track` (`src/node/src/node.cpp`)
//! with the cubic/quadratic bezier solvers from
//! `core/src/oliveimpl/util/bezier.h`. Unlike C++ (one track per
//! component), a Rust track holds whole values; vector/color values
//! interpolate component-wise.
use oakcore_rs::Rational;
use crate::value::{NodeValue, ValueType};
/// Interpolation mode (values match the C++ `NodeKeyframe::Type`).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Interpolation {
/// Constant hold.
Hold,
/// Linear.
Linear,
/// Cubic bezier (control points on the keyframe).
Bezier,
}
/// A single keyframe.
#[derive(Clone, Debug)]
pub struct Keyframe {
/// Time.
pub time: Rational,
/// Value.
pub value: NodeValue,
/// Interpolation to the next keyframe.
pub interpolation: Interpolation,
/// Bezier control points (used when `interpolation == Bezier`).
pub bezier_in: (f64, f64),
/// Bezier out control point.
pub bezier_out: (f64, f64),
}
/// Sorted keyframe track for one (input, element).
#[derive(Default, Debug, Clone)]
pub struct KeyframeTrack {
keys: Vec<Keyframe>,
}
impl KeyframeTrack {
/// Insert or replace the keyframe at `time` (keeps sort order).
pub fn set_key(&mut self, key: Keyframe) {
match self.keys.binary_search_by(|k| k.time.cmp(&key.time)) {
Ok(i) => self.keys[i] = key,
Err(i) => self.keys.insert(i, key),
}
}
/// Replace the value of the key at `time` (preserving its
/// interpolation and bezier handles); false when absent.
pub fn set_key_value(&mut self, time: Rational, value: NodeValue) -> bool {
if let Some(k) = self.keys.iter_mut().find(|k| k.time == time) {
k.value = value;
true
} else {
false
}
}
/// Remove the keyframe at `time`; false when absent.
pub fn remove_key(&mut self, time: Rational) -> bool {
match self.keys.binary_search_by(|k| k.time.cmp(&time)) {
Ok(i) => {
self.keys.remove(i);
true
}
Err(_) => false,
}
}
/// Interpolated value at `time`; `None` on an empty track.
/// Interpolation math must match the C++ lerp/bezier exactly
/// (`// CPP-PARITY: node.cpp:465` `get_split_value_at_time_on_track`,
/// `// CPP-PARITY: core/src/oliveimpl/util/bezier.h`).
pub fn value_at(&self, time: Rational) -> Option<NodeValue> {
let keys = &self.keys;
if keys.is_empty() {
return None;
}
// This time precedes any keyframe -> first value.
if keys[0].time >= time {
return Some(keys[0].value.clone());
}
// This time is after any keyframes -> last value.
if keys[keys.len() - 1].time <= time {
return Some(keys[keys.len() - 1].value.clone());
}
// The time must be somewhere in between: binary search for the
// bracketing pair (C++ low/high loop).
let mut low = 0usize;
let mut high = keys.len() - 1;
let mut before: Option<(usize, usize)> = None;
while low <= high {
let mid = low + (high - low) / 2;
let mid_key = &keys[mid];
let next_key = &keys[mid + 1];
if mid_key.time <= time && next_key.time > time {
before = Some((mid, mid + 1));
break;
} else if mid_key.time < time {
low = mid + 1;
} else {
high = mid - 1;
}
}
let (b, a) = match before {
Some(pair) => pair,
// Unreachable given the front/back guards; C++ logs and falls
// through to the standard value.
None => return None,
};
let before_key = &keys[b];
let after_key = &keys[a];
if before_key.time == time {
return Some(before_key.value.clone());
}
if !before_key.value.can_interpolate() || before_key.interpolation == Interpolation::Hold {
// Non-interpolable or hold: the value stays at `before` until
// the next keyframe (C++: `after->time() > time` guaranteed here).
return Some(before_key.value.clone());
}
if after_key.time == time {
return Some(after_key.value.clone());
}
if before_key.time < time && after_key.time > time {
return Some(interpolate(before_key, after_key, time));
}
None
}
/// Sorted keyframes view.
pub fn keys(&self) -> &[Keyframe] {
&self.keys
}
}
/// Interpolate between two keyframes at `time` (strictly between their
/// times). Ports the C++ three-way branch: cubic bezier (both bezier),
/// quadratic bezier (one bezier), linear (both linear).
///
/// The C++ path interpolates per component track along a shared
/// parametric `t` derived from the time axis; the Rust track holds whole
/// values, so the components are split, evaluated at `t` with the shared
/// handle y-offsets, and recombined.
fn interpolate(before: &Keyframe, after: &Keyframe, time: Rational) -> NodeValue {
let declared = before.value.value_type();
let before_val = before.value.to_double();
let after_val = after.value.to_double();
let both_bezier =
before.interpolation == Interpolation::Bezier && after.interpolation == Interpolation::Bezier;
let one_bezier =
before.interpolation == Interpolation::Bezier || after.interpolation == Interpolation::Bezier;
if !both_bezier && !one_bezier {
// Both linear.
let period_progress =
(time.to_f64() - before.time.to_f64()) / (after.time.to_f64() - before.time.to_f64());
return before.value.lerp(&after.value, period_progress);
}
// Shared parametric t from the time axis, plus the handle y-offsets.
let (t, bcp_y, acp_y, quad_before) = if both_bezier {
let (cp1_x, cp1_y) = valid_bezier_out(before, after.time);
let (cp2_x, cp2_y) = valid_bezier_in(after, before.time);
let t = cubic_xto_t(
time.to_f64(),
before.time.to_f64(),
before.time.to_f64() + cp1_x,
after.time.to_f64() + cp2_x,
after.time.to_f64(),
);
(t, cp1_y, cp2_y, false)
} else if before.interpolation == Interpolation::Bezier {
let (x, y) = valid_bezier_out(before, after.time);
let t = quadratic_xto_t(
time.to_f64(),
before.time.to_f64(),
before.time.to_f64() + x,
after.time.to_f64(),
);
(t, y, 0.0, true)
} else {
let (x, y) = valid_bezier_in(after, before.time);
let t = quadratic_xto_t(
time.to_f64(),
before.time.to_f64(),
after.time.to_f64() + x,
after.time.to_f64(),
);
(t, 0.0, y, true)
};
let eval = |bv: f64, av: f64| -> f64 {
if both_bezier {
cubic_tto_y(bv, bv + bcp_y, av + acp_y, av, t)
} else if quad_before {
quadratic_tto_y(bv, bv + bcp_y, av, t)
} else {
quadratic_tto_y(bv, av + acp_y, av, t)
}
};
if declared == ValueType::Rational {
// Rational inputs re-quantize through from_double (C++ k_rational
// path).
let y = eval(before_val, after_val);
return NodeValue::Rational(Rational::from_double(y));
}
// Split into per-component tracks, evaluate each, recombine.
let b_tracks = before.value.split_into_tracks(declared);
let a_tracks = after.value.split_into_tracks(declared);
let out: Vec<NodeValue> = b_tracks
.iter()
.zip(a_tracks.iter())
.map(|(bv, av)| {
if declared == ValueType::Color
|| declared == ValueType::Vec2
|| declared == ValueType::Vec3
|| declared == ValueType::Vec4
{
NodeValue::Float(eval(bv.to_double(), av.to_double()))
} else {
// Scalar types: the whole value is the single component.
before
.value
.lerp(&after.value, 0.0)
.with_scalar(declared, eval(bv.to_double(), av.to_double()))
}
})
.collect();
NodeValue::combine_tracks(&out, declared)
}
/// The keyframe's out-handle clamped so the curve never overlaps the
/// next keyframe's time (C++
/// `NodeKeyframe::valid_bezier_control_out`).
fn valid_bezier_out(key: &Keyframe, next_time: Rational) -> (f64, f64) {
let t = key.time.to_f64();
let adjusted_x = (t + key.bezier_out.0).min(next_time.to_f64());
(adjusted_x - t, key.bezier_out.1)
}
/// The keyframe's in-handle clamped so the curve never overlaps the
/// previous keyframe's time (C++
/// `NodeKeyframe::valid_bezier_control_in`).
fn valid_bezier_in(key: &Keyframe, prev_time: Rational) -> (f64, f64) {
let t = key.time.to_f64();
let adjusted_x = (t + key.bezier_in.0).max(prev_time.to_f64());
(adjusted_x - t, key.bezier_in.1)
}
/// Bezier solver helpers (ported verbatim from
/// `core/src/oliveimpl/util/bezier.h` / `core/src/util/bezier.cpp`).
/// Cubic `x(t)` -> `t` by binary search (`calculate_t_from_x`, cubic).
fn cubic_xto_t(x: f64, a: f64, b: f64, c: f64, d: f64) -> f64 {
// Clamp to prevent infinite loop.
let x = x.clamp(a.min(d), a.max(d));
calculate_t_from_x(true, x, a, b, c, d)
}
/// Quadratic `x(t)` -> `t` by binary search (`calculate_t_from_x`,
/// quadratic).
fn quadratic_xto_t(x: f64, a: f64, b: f64, c: f64) -> f64 {
let x = x.clamp(a.min(c), a.max(c));
calculate_t_from_x(false, x, a, b, c, 0.0)
}
/// The C++ `Bezier::calculate_t_from_x` bisection loop.
fn calculate_t_from_x(cubic: bool, x: f64, a: f64, b: f64, c: f64, d: f64) -> f64 {
let mut bottom = 0.0;
let mut top = 1.0;
loop {
if bottom == top {
return bottom;
}
let mid = (bottom + top) * 0.5;
let test = if cubic {
cubic_tto_y(a, b, c, d, mid)
} else {
quadratic_tto_y(a, b, c, mid)
};
if (test - x).abs() < 0.000001 {
return mid;
} else if x > test {
bottom = mid;
} else {
top = mid;
}
}
}
/// `(1-t)^2*a + 2*(1-t)*t*b + t^2*c`.
fn quadratic_tto_y(a: f64, b: f64, c: f64, t: f64) -> f64 {
(1.0 - t).powi(2) * a + 2.0 * (1.0 - t) * t * b + t.powi(2) * c
}
/// `(1-t)^3*a + 3*(1-t)^2*t*b + 3*(1-t)*t^2*c + t^3*d`.
fn cubic_tto_y(a: f64, b: f64, c: f64, d: f64, t: f64) -> f64 {
(1.0 - t).powi(3) * a
+ 3.0 * (1.0 - t).powi(2) * t * b
+ 3.0 * (1.0 - t) * t.powi(2) * c
+ t.powi(3) * d
}
+53
View File
@@ -0,0 +1,53 @@
// 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/>.
//! # oaknode — the node graph engine (Rust)
//!
//! Reimplements the C++ oaknode module behind its frozen C ABI
//! (`include/node/*.h`). See README.md for the architectural mapping
//! (inheritance → arena + trait objects, etc.).
//!
//! ## FFI discipline
//!
//! Identical to the oakplugin crate: every export goes through
//! [`handle::guard*`], handles are opaque refcounted boxes, shared
//! state behind `Mutex`.
#![deny(unsafe_op_in_unsafe_fn)]
#![warn(missing_docs)]
pub mod block;
pub mod bridge;
pub mod colormanager;
pub mod error;
pub mod factory;
pub mod ffi;
pub mod folder;
pub mod footage;
pub mod graph;
pub mod handle;
pub mod id;
pub mod input;
pub mod keyframe;
pub mod node;
pub mod nodes;
pub mod ops;
pub mod project;
pub mod sequence;
pub mod serializer;
pub mod track;
pub mod traverser;
pub mod value;
+645
View File
@@ -0,0 +1,645 @@
// 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 oakrender handles (created via bridge::render).
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;
}
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.
#[derive(Clone)]
pub struct NodeCaches {
/// Video frame hash cache.
pub video: crate::bridge::render::CacheHandle,
/// Thumbnail cache.
pub thumbnail: crate::bridge::render::CacheHandle,
/// Audio playback cache.
pub audio: crate::bridge::render::CacheHandle,
/// Waveform cache.
pub waveform: crate::bridge::render::CacheHandle,
}
impl Default for NodeCaches {
/// All empty handles (caches are created lazily through bridge::render
/// 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
}
}
/// 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::bridge::render::TextureHandle, 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, reader);
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
}
}
+686
View File
@@ -0,0 +1,686 @@
// 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/>.
//! Blur filter (C++ `src/node/src/filter/blur/blur.{h,cpp}`,
//! `olive::BlurFilterNode`).
use crate::factory::NodeMeta;
use crate::node::{Category, Gizmo, NodeBehavior, NodeCore};
/// Texture input id (C++ `k_texture_input`). Type: texture; flags:
/// not-keyframable; this is the node's effect input.
pub const TEXTURE_INPUT: &str = "tex_in";
/// Blur method input id (C++ `k_method_input`). Type: combo; default
/// `k_gaussian` (1); flags: not-keyframable, not-connectable; combo box
/// strings: "Box", "Gaussian", "Directional", "Radial".
pub const METHOD_INPUT: &str = "method_in";
/// Blur radius input id (C++ `k_radius_input`). Type: float; default
/// `10.0`; properties: `min = 0.0`.
pub const RADIUS_INPUT: &str = "radius_in";
/// Horizontal blur toggle input id (C++ `k_horiz_input`). Type: bool;
/// default `true`; hidden unless the method is box or gaussian.
pub const HORIZ_INPUT: &str = "horiz_in";
/// Vertical blur toggle input id (C++ `k_vert_input`). Type: bool;
/// default `true`; hidden unless the method is box or gaussian.
pub const VERT_INPUT: &str = "vert_in";
/// Repeat-edge-pixels input id (C++ `k_repeat_edge_pixels_input`). Type:
/// bool; default `true`.
pub const REPEAT_EDGE_PIXELS_INPUT: &str = "repeat_edge_pixels_in";
/// Directional angle input id (C++ `k_directional_degrees_input`). Type:
/// float; default `0.0`; hidden unless the method is directional.
pub const DIRECTIONAL_DEGREES_INPUT: &str = "directional_degrees_in";
/// Radial center input id (C++ `k_radial_center_input`). Type: vec2;
/// default `(0.0, 0.0)`; hidden unless the method is radial; property
/// `offset` is set to half the texture resolution at gizmo-update time.
pub const RADIAL_CENTER_INPUT: &str = "radial_center_in";
/// Blur method enum (C++ `BlurFilterNode::Method`).
pub enum Method {
/// Box blur.
Box,
/// Gaussian blur (the default).
Gaussian,
/// Directional blur.
Directional,
/// Radial blur.
Radial,
}
/// Blur filter node. Box/gaussian/directional/radial blur, implemented
/// as one iterative shader.
///
/// C++ member `radial_center_gizmo_` is a Qt `PointGizmo` (GUI type);
/// gizmos live in `NodeCore::gizmos`, so no field is kept here.
pub struct BlurFilterNode;
/// Fragment shader (C++ `get_shader_code()` loads
/// `:/shaders/blur.frag` via FileFunctions for any request). Text
/// copied verbatim from `engine/shaders/blur.frag`.
const SHADER_FRAG: &str = r#"uniform sampler2D tex_in;
uniform int method_in;
uniform float radius_in;
uniform bool horiz_in;
uniform bool vert_in;
uniform bool repeat_edge_pixels_in;
uniform vec2 resolution_in;
// Directional
uniform float directional_degrees_in;
// Radial
uniform vec2 radial_center_in;
uniform int ove_iteration;
in vec2 ove_texcoord;
out vec4 frag_color;
// Gaussian function uses PI
#define M_PI 3.1415926535897932384626433832795
// Methods
#define METHOD_BOX_BLUR 0
#define METHOD_GAUSSIAN_BLUR 1
#define METHOD_DIRECTIONAL_BLUR 2
#define METHOD_RADIAL_BLUR 3
// Mode
#define MODE_NONE 0
#define MODE_HORIZONTAL 1
#define MODE_VERTICAL 2
// Single gaussian formula (unused, mainly here for documentation/just in case)
//float gaussian(float x, float sigma) {
// return (1.0/(sigma*sqrt(2.0*M_PI)))*exp(-0.5*pow(x/sigma, 2.0));
//}
// Double gaussian formula, actually used in the code below
// Should be faster than the single gaussian above since it doesn't need sqrt()
float gaussian2(float x, float y, float sigma) {
return (1.0/((sigma*sigma)*2.0*M_PI))*exp(-0.5*(((x*x) + (y*y))/(sigma*sigma)));
}
int determine_mode() {
if (radius_in == 0.0) {
return MODE_NONE;
}
if (!horiz_in && !vert_in) {
return MODE_NONE;
}
if (horiz_in && !vert_in) {
return MODE_HORIZONTAL;
}
if (vert_in && !horiz_in) {
return MODE_VERTICAL;
}
if (ove_iteration == 0) {
return MODE_HORIZONTAL;
}
if (ove_iteration == 1) {
return MODE_VERTICAL;
}
}
vec4 add_to_composite(vec4 composite, vec2 pixel_coord, float weight)
{
if (repeat_edge_pixels_in
|| (pixel_coord.x >= 0.0
&& pixel_coord.x < 1.0
&& pixel_coord.y >= 0.0
&& pixel_coord.y < 1.0)) {
composite += texture(tex_in, pixel_coord) * weight;
}
return composite;
}
void main(void) {
int mode = determine_mode();
if (mode == MODE_NONE) {
frag_color = texture(tex_in, ove_texcoord);
return;
}
// We only sample on hard pixels, so we don't accept decimal radii
float real_radius = ceil(radius_in);
vec4 composite = vec4(0.0);
float divider, sigma;
if (method_in == METHOD_DIRECTIONAL_BLUR || method_in == METHOD_RADIAL_BLUR) {
// Despite similar math, these are lighter methods perceptually, so we double the radius to
// better match box/gaussian
real_radius *= 2.0;
}
if (method_in == METHOD_BOX_BLUR || method_in == METHOD_DIRECTIONAL_BLUR) {
// Calculate the weight of each pixel based on the radius
divider = 1.0 / real_radius;
} else if (method_in == METHOD_GAUSSIAN_BLUR) {
// Using (radius = 3 * sigma) because 3 standard deviations covers 97% of the blur according to this document:
// http://chemaguerra.com/gaussian-filter-radius/
sigma = real_radius;
real_radius *= 3.0;
// Use gaussian formula to calculate the weight of all pixels
divider = 0.0;
for (float i = -real_radius + 0.5; i <= real_radius; i += 2.0) {
divider += gaussian2(i, 0.0, sigma);
}
}
if (method_in == METHOD_BOX_BLUR || method_in == METHOD_GAUSSIAN_BLUR) {
for (float i = -real_radius + 0.5; i <= real_radius; i += 2.0) {
float weight;
if (method_in == METHOD_BOX_BLUR) {
weight = divider;
} else if (method_in == METHOD_GAUSSIAN_BLUR) {
weight = gaussian2(i, 0.0, sigma) / divider;
}
vec2 pixel_coord = ove_texcoord;
if (mode == MODE_HORIZONTAL) {
pixel_coord.x += i / resolution_in.x;
} else if (mode == MODE_VERTICAL) {
pixel_coord.y += i / resolution_in.y;
}
composite = add_to_composite(composite, pixel_coord, weight);
}
} else if (method_in == METHOD_DIRECTIONAL_BLUR || method_in == METHOD_RADIAL_BLUR) {
float angle;
if (method_in == METHOD_DIRECTIONAL_BLUR) {
// Convert directional degrees to radians
angle = (directional_degrees_in*M_PI)/180.0;
} else {
// Calculate angle from distance of center to current coordinate
vec2 distance = (ove_texcoord - 0.5) * (resolution_in) - radial_center_in;
angle = atan(distance.y/distance.x);
float multiplier = length(distance) / resolution_in.y * 2.0;
real_radius = ceil(radius_in * multiplier);
divider = 1.0 / real_radius;
}
// Get angles
float sin_angle = sin(angle);
float cos_angle = cos(angle);
for (float i = -real_radius + 0.5; i <= real_radius; i += 2.0) {
vec2 pixel_coord = ove_texcoord;
pixel_coord.y += sin_angle * i / resolution_in.y;
pixel_coord.x += cos_angle * i / resolution_in.x;
composite = add_to_composite(composite, pixel_coord, divider);
}
}
frag_color = composite;
}
"#;
impl BlurFilterNode {
/// Fragment shader for any request (C++ `get_shader_code()` ignores
/// the request id and always returns `blur.frag`).
fn shader_frag() -> &'static str {
SHADER_FRAG
}
}
impl NodeBehavior for BlurFilterNode {
/// Human-readable name (C++ `name()`).
fn name(&self) -> &str {
"Blur"
}
/// Stable type id (C++ `id()`).
fn type_id(&self) -> &str {
"org.olivevideoeditor.Olive.blur"
}
/// Categories (C++ `category()`).
fn categories(&self) -> &[Category] {
&[Category::Filter]
}
/// Description (C++ `description()`).
fn description(&self) -> &str {
"Blurs an image."
}
/// Localized input names (C++ `retranslate()`): `tex_in` -> "Input",
/// `method_in` -> "Method" (combo strings "Box", "Gaussian",
/// "Directional", "Radial"), `radius_in` -> "Radius", `horiz_in` ->
/// "Horizontal", `vert_in` -> "Vertical", `repeat_edge_pixels_in` ->
/// "Repeat Edge Pixels", `directional_degrees_in` -> "Direction",
/// `radial_center_in` -> "Center".
fn input_name<'a>(&self, id: &'a str) -> &'a str {
match id {
TEXTURE_INPUT => "Input",
// The `method_in` combo strings "Box"/"Gaussian"/"Directional"/
// "Radial" are a UI-level property (C++ `set_combo_box_strings`).
METHOD_INPUT => "Method",
RADIUS_INPUT => "Radius",
HORIZ_INPUT => "Horizontal",
VERT_INPUT => "Vertical",
REPEAT_EDGE_PIXELS_INPUT => "Repeat Edge Pixels",
DIRECTIONAL_DEGREES_INPUT => "Direction",
RADIAL_CENTER_INPUT => "Center",
_ => id,
}
}
/// Evaluate outputs (C++ `value()`): no texture -> push nothing;
/// radius <= 0.0, or box/gaussian with both horiz and vert unchecked
/// -> pass-through push of the input texture; otherwise push a shader
/// job with `resolution_in` set to the texture's virtual resolution,
/// running 2 iterations for box/gaussian when both horiz and vert are
/// checked (1 otherwise).
///
/// The Rust model has no shader-job payload: the job (including the
/// `resolution_in` value and the iteration count) is deferred to the
/// renderer seam (`// CPP-PARITY: blur.cpp` value()).
fn value(
&self,
core: &NodeCore,
inputs: &crate::value::NodeValueRow,
time: oakcore_rs::Rational,
table: &mut crate::value::NodeValueTable,
) {
let tex = match inputs.get(TEXTURE_INPUT) {
Some(tex @ crate::value::NodeValue::Texture(_)) => tex.clone(),
_ => return,
};
let method = match inputs.get(METHOD_INPUT) {
Some(v) => v.to_double() as i64,
None => core.value_at_time(METHOD_INPUT, -1, time).to_double() as i64,
};
let radius = match inputs.get(RADIUS_INPUT) {
Some(v) => v.to_double(),
None => core.value_at_time(RADIUS_INPUT, -1, time).to_double(),
};
let horiz = match inputs.get(HORIZ_INPUT) {
Some(v) => v.to_double() != 0.0,
None => core.value_at_time(HORIZ_INPUT, -1, time).to_double() != 0.0,
};
let vert = match inputs.get(VERT_INPUT) {
Some(v) => v.to_double() != 0.0,
None => core.value_at_time(VERT_INPUT, -1, time).to_double() != 0.0,
};
let mut can_push_job = true;
if radius > 0.0 {
// Method-specific considerations.
if method == Method::Box as i64 || method == Method::Gaussian as i64 {
if !horiz && !vert {
// Disable the job if both directions are unchecked.
can_push_job = false;
}
}
} else {
can_push_job = false;
}
if can_push_job {
table.push(
crate::value::ValueType::Texture,
crate::value::NodeValue::Texture(crate::handle::CHandle::null()),
None,
);
} else {
table.push(crate::value::ValueType::Texture, tex, None);
}
}
/// Shader code request (C++ `get_shader_code()`): the request id is
/// ignored; always returns the blur fragment shader.
fn shader_code(&self, _request: &str) -> Option<String> {
Some(Self::shader_frag().to_string())
}
/// Gizmo positions (C++ `update_gizmo_positions()`): when the method
/// is radial and a texture is present, show the radial-center gizmo
/// at half the texture resolution plus the center input, and set the
/// input's `offset` property to half the resolution; otherwise hide
/// the gizmo.
///
/// The placement and the `offset` property need the texture's virtual
/// resolution (the Rust texture handle carries no params), and the
/// gizmo visibility has no storage in [`Gizmo`] — not representable
/// here (`// CPP-PARITY: blur.cpp` `update_gizmo_positions`).
fn gizmo_update(&self, core: &NodeCore, row: &crate::value::NodeValueRow) {
let _ = (core, row);
}
/// Gizmo drag (C++ `gizmo_drag_move()`): when the current gizmo is
/// the radial-center gizmo, drag its x/y input draggers by the drag
/// delta.
///
/// The draggers hold per-drag start values and write keyframe tracks,
/// neither of which the Rust data model carries — not representable
/// here (`// CPP-PARITY: blur.cpp` `gizmo_drag_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()`): on
/// `method_in` changes, re-run the hidden-flag update (`horiz_in` /
/// `vert_in` shown only for box/gaussian, `directional_degrees_in`
/// only for directional, `radial_center_in` only for radial), then
/// defer to the base implementation.
fn input_value_changed(&mut self, core: &mut NodeCore, input: &str, element: i32) {
if input == METHOD_INPUT && element == -1 {
let method = core.standard_value(METHOD_INPUT, -1).to_double() as i64;
Self::update_inputs(core, method);
}
}
/// Deep copy (C++ `copy()`).
fn duplicate(&self, _core: &NodeCore) -> Option<Box<dyn NodeBehavior>> {
Some(Box::new(BlurFilterNode))
}
}
impl BlurFilterNode {
/// Hidden-flag update for the method-specific inputs (C++
/// `update_inputs(Method)`): `horiz_in`/`vert_in` are shown only for
/// box/gaussian, `directional_degrees_in` only for directional, and
/// `radial_center_in` only for radial.
fn update_inputs(core: &mut NodeCore, method: i64) {
set_hidden(core, HORIZ_INPUT, !(method == Method::Box as i64 || method == Method::Gaussian as i64));
set_hidden(core, VERT_INPUT, !(method == Method::Box as i64 || method == Method::Gaussian as i64));
set_hidden(core, DIRECTIONAL_DEGREES_INPUT, method != Method::Directional as i64);
set_hidden(core, RADIAL_CENTER_INPUT, method != Method::Radial as i64);
}
}
/// Set or clear the hidden input flag on `id` (C++
/// `set_input_flag(id, k_input_flag_hidden, hidden)`).
fn set_hidden(core: &mut NodeCore, id: &str, hidden: bool) {
if let Some(input) = core.get_input_mut(id) {
if hidden {
input.flags |= crate::input::flags::HIDDEN;
} else {
input.flags &= !crate::input::flags::HIDDEN;
}
}
}
/// Constructor (C++ `BlurFilterNode::BlurFilterNode()`): adds `tex_in`,
/// `method_in` (default gaussian), `radius_in`, `horiz_in`/`vert_in`,
/// `directional_degrees_in`, `radial_center_in` with the defaults and
/// properties documented on the constants, hides the method-specific
/// inputs for the default method, adds `repeat_edge_pixels_in`, sets
/// the video-effect flag and the effect input, and adds a draggable
/// anchor-point gizmo bound to both tracks of `radial_center_in`.
pub fn create() -> (NodeCore, Box<dyn NodeBehavior>) {
let mut core = NodeCore::new();
let mut tex = crate::input::Input::new(
TEXTURE_INPUT,
crate::value::ValueType::Texture,
crate::value::NodeValue::None,
);
tex.flags |= crate::input::flags::NOT_KEYFRAMABLE;
core.add_input(tex);
let mut method = crate::input::Input::new(
METHOD_INPUT,
crate::value::ValueType::Combo,
crate::value::NodeValue::Combo(Method::Gaussian as i64),
);
method.flags |= crate::input::flags::NOT_KEYFRAMABLE | crate::input::flags::NOT_CONNECTABLE;
core.add_input(method);
let mut radius = crate::input::Input::new(
RADIUS_INPUT,
crate::value::ValueType::Float,
crate::value::NodeValue::Float(10.0),
);
radius.properties = vec![("min".to_string(), crate::value::NodeValue::Float(0.0))];
core.add_input(radius);
// Box and gaussian only.
core.add_input(crate::input::Input::new(
HORIZ_INPUT,
crate::value::ValueType::Boolean,
crate::value::NodeValue::Boolean(true),
));
core.add_input(crate::input::Input::new(
VERT_INPUT,
crate::value::ValueType::Boolean,
crate::value::NodeValue::Boolean(true),
));
// Directional only.
core.add_input(crate::input::Input::new(
DIRECTIONAL_DEGREES_INPUT,
crate::value::ValueType::Float,
crate::value::NodeValue::Float(0.0),
));
// Radial only.
core.add_input(crate::input::Input::new(
RADIAL_CENTER_INPUT,
crate::value::ValueType::Vec2,
crate::value::NodeValue::Vec2([0.0, 0.0]),
));
// Hide the method-specific inputs for the default (gaussian) method.
BlurFilterNode::update_inputs(&mut core, Method::Gaussian as i64);
core.add_input(crate::input::Input::new(
REPEAT_EDGE_PIXELS_INPUT,
crate::value::ValueType::Boolean,
crate::value::NodeValue::Boolean(true),
));
core.flags |= crate::node::flags::VIDEO_EFFECT;
core.effect_input = TEXTURE_INPUT.to_string();
// Anchor-shaped radial-center point gizmo dragging both tracks.
let gizmo = Gizmo {
position_inputs: vec![
(RADIAL_CENTER_INPUT.to_string(), -1, 0),
(RADIAL_CENTER_INPUT.to_string(), -1, 1),
],
drag_point: (0.0, 0.0),
};
core.gizmos = vec![gizmo];
(core, Box::new(BlurFilterNode))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::node::NodeBehavior;
use crate::value::{NodeValue, NodeValueTable, ValueType};
use oakcore_rs::Rational;
fn tex() -> NodeValue {
NodeValue::Texture(crate::handle::CHandle::null())
}
fn is_hidden(core: &NodeCore, id: &str) -> bool {
core.get_input(id).unwrap().flags & crate::input::flags::HIDDEN != 0
}
#[test]
fn input_names() {
let n = BlurFilterNode;
assert_eq!(n.input_name(TEXTURE_INPUT), "Input");
assert_eq!(n.input_name(METHOD_INPUT), "Method");
assert_eq!(n.input_name(RADIUS_INPUT), "Radius");
assert_eq!(n.input_name(HORIZ_INPUT), "Horizontal");
assert_eq!(n.input_name(VERT_INPUT), "Vertical");
assert_eq!(n.input_name(REPEAT_EDGE_PIXELS_INPUT), "Repeat Edge Pixels");
assert_eq!(n.input_name(DIRECTIONAL_DEGREES_INPUT), "Direction");
assert_eq!(n.input_name(RADIAL_CENTER_INPUT), "Center");
}
#[test]
fn create_wires_inputs_and_flags() {
let (core, behavior) = create();
assert_eq!(behavior.type_id(), "org.olivevideoeditor.Olive.blur");
assert_eq!(
core.get_input(METHOD_INPUT).unwrap().default,
NodeValue::Combo(1)
);
assert_eq!(
core.get_input(RADIUS_INPUT).unwrap().default,
NodeValue::Float(10.0)
);
assert_eq!(
core.get_input(REPEAT_EDGE_PIXELS_INPUT).unwrap().default,
NodeValue::Boolean(true)
);
// Default method (gaussian): directional/radial inputs hidden.
assert!(!is_hidden(&core, HORIZ_INPUT));
assert!(!is_hidden(&core, VERT_INPUT));
assert!(is_hidden(&core, DIRECTIONAL_DEGREES_INPUT));
assert!(is_hidden(&core, RADIAL_CENTER_INPUT));
// One radial-center gizmo bound to both tracks.
assert_eq!(core.gizmos.len(), 1);
assert_eq!(core.gizmos[0].position_inputs.len(), 2);
assert_eq!(core.effect_input, TEXTURE_INPUT);
assert_ne!(core.flags & crate::node::flags::VIDEO_EFFECT, 0);
}
#[test]
fn value_no_texture_pushes_nothing() {
let (core, behavior) = create();
let mut table = NodeValueTable::default();
behavior.value(
&core,
&crate::value::NodeValueRow::default(),
Rational::new(0, 1),
&mut table,
);
assert!(table.is_empty());
}
#[test]
fn value_zero_radius_passes_texture_through() {
let (mut core, behavior) = create();
core.set_standard_value(RADIUS_INPUT, -1, NodeValue::Float(0.0));
let tex = tex();
let inputs = crate::value::NodeValueRow::from([(TEXTURE_INPUT.to_string(), tex.clone())]);
let mut table = NodeValueTable::default();
behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
assert_eq!(table.get(ValueType::Texture), Some(&tex));
}
#[test]
fn value_box_no_directions_passes_texture_through() {
let (mut core, behavior) = create();
core.set_standard_value(METHOD_INPUT, -1, NodeValue::Combo(0));
core.set_standard_value(HORIZ_INPUT, -1, NodeValue::Boolean(false));
core.set_standard_value(VERT_INPUT, -1, NodeValue::Boolean(false));
core.set_standard_value(RADIUS_INPUT, -1, NodeValue::Float(10.0));
let tex = tex();
let inputs = crate::value::NodeValueRow::from([(TEXTURE_INPUT.to_string(), tex.clone())]);
let mut table = NodeValueTable::default();
behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
assert_eq!(table.get(ValueType::Texture), Some(&tex));
}
#[test]
fn value_gaussian_pushes_deferred_job() {
let (mut core, behavior) = create();
core.set_standard_value(RADIUS_INPUT, -1, NodeValue::Float(10.0));
let inputs = crate::value::NodeValueRow::from([(TEXTURE_INPUT.to_string(), tex())]);
let mut table = NodeValueTable::default();
behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
assert!(table.get(ValueType::Texture).is_some());
}
#[test]
fn input_value_changed_toggles_method_inputs() {
let (mut core, behavior) = create();
let mut b = behavior;
// Directional: horiz/vert hidden, directional shown.
core.set_standard_value(METHOD_INPUT, -1, NodeValue::Combo(2));
b.input_value_changed(&mut core, METHOD_INPUT, -1);
assert!(is_hidden(&core, HORIZ_INPUT));
assert!(is_hidden(&core, VERT_INPUT));
assert!(!is_hidden(&core, DIRECTIONAL_DEGREES_INPUT));
assert!(is_hidden(&core, RADIAL_CENTER_INPUT));
// Radial: radial shown.
core.set_standard_value(METHOD_INPUT, -1, NodeValue::Combo(3));
b.input_value_changed(&mut core, METHOD_INPUT, -1);
assert!(!is_hidden(&core, RADIAL_CENTER_INPUT));
// Back to box: horiz/vert shown again.
core.set_standard_value(METHOD_INPUT, -1, NodeValue::Combo(0));
b.input_value_changed(&mut core, METHOD_INPUT, -1);
assert!(!is_hidden(&core, HORIZ_INPUT));
assert!(!is_hidden(&core, VERT_INPUT));
assert!(is_hidden(&core, DIRECTIONAL_DEGREES_INPUT));
assert!(is_hidden(&core, RADIAL_CENTER_INPUT));
}
#[test]
fn shader_code_returns_blur_shader() {
let n = BlurFilterNode;
let code = n.shader_code("anything").unwrap();
assert!(code.contains("uniform int method_in;"));
assert!(code.contains("METHOD_GAUSSIAN_BLUR 1"));
}
#[test]
fn duplicate_clones() {
let (core, behavior) = create();
let dup = behavior.duplicate(&core).unwrap();
assert_eq!(dup.name(), "Blur");
}
}
/// Register this node type (C++ `k_blur_filter` in
/// `factory.cpp::create_from_factory_index`).
pub fn register(meta: &mut Vec<NodeMeta>) {
meta.push(NodeMeta {
type_id: "org.olivevideoeditor.Olive.blur",
name: "Blur",
categories: &[Category::Filter],
create,
});
}
+576
View File
@@ -0,0 +1,576 @@
// 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/>.
//! Chroma Key effect (C++ `src/node/src/keying/chromakey/chromakey.{h,cpp}`,
//! `olive::ChromaKeyNode`, derived from `olive::OCIOBaseNode`).
use crate::factory::NodeMeta;
use crate::node::{Category, NodeBehavior, NodeCore};
/// Key color input id (C++ `k_color_input`). Type: color; default
/// `Color(0.0, 1.0, 0.0, 1.0)` (opaque green).
pub const COLOR_INPUT: &str = "color_key";
/// Show-mask-only toggle input id (C++ `k_mask_only_input`). Type:
/// boolean; default `false`.
pub const MASK_ONLY_INPUT: &str = "mask_only_in";
/// Invert-mask toggle input id (C++ `k_invert_input`). Type: boolean;
/// default `false`.
pub const INVERT_INPUT: &str = "invert_in";
/// Upper tolerance input id (C++ `k_upper_tolerance_input`). Type:
/// float; default `25.0`; properties: `base = 0.1` (the `min` property
/// tracking the lower tolerance is present but disabled in C++ — see
/// the FIXME in the constructor).
pub const UPPER_TOLERANCE_INPUT: &str = "upper_tolerance_in";
/// Lower tolerance input id (C++ `k_lower_tolerance_input`). Type:
/// float; default `5.0`; properties: `min = 0.0`, `base = 0.1`.
pub const LOWER_TOLERANCE_INPUT: &str = "lower_tolerance_in";
/// Garbage matte texture input id (C++ `k_garbage_matte_input`). Type:
/// texture; flags: not-keyframable.
pub const GARBAGE_MATTE_INPUT: &str = "garbage_in";
/// Core matte texture input id (C++ `k_core_matte_input`). Type:
/// texture; flags: not-keyframable.
pub const CORE_MATTE_INPUT: &str = "core_in";
/// Shadows input id (C++ `k_shadows_input`). Type: float; default
/// `100.0`; properties: `min = 0.0`, `base = 0.1`.
pub const SHADOWS_INPUT: &str = "shadows_in";
/// Highlights input id (C++ `k_highlights_input`). Type: float;
/// default `100.0`; properties: `min = 0.0`, `base = 0.1`.
pub const HIGHLIGHTS_INPUT: &str = "highlights_in";
/// Chroma key node: keys on the CIE Lab distance from a selected
/// color, with optional garbage/core mattes.
///
/// The C++ class derives from `OCIOBaseNode`, which owns the `tex_in`
/// texture input (C++ `OCIOBaseNode::k_texture_input = "tex_in"`, the
/// effect input), the color manager pointer, and the OCIO color
/// processor handle; that state is held here via the shared
/// `crate::nodes::ociobase` helper. The class has no other own members (the
/// private `generate_processor()` is a method, not state).
pub struct ChromaKeyNode {
/// OCIO base state (C++ base class `OCIOBaseNode`: `manager_` and
/// `processor_`).
base: crate::nodes::ociobase::OcioBase,
}
/// Fragment shader (C++ loads the `:/shaders/chromakey.frag` resource
/// in `get_shader_code`). Text copied verbatim from
/// `engine/shaders/chromakey.frag`. The `%1` marker is replaced with
/// the OCIO-generated shader stub (`request.stub`) at request time;
/// the shader calls `SceneLinearToCIEXYZ_d65`, which the stub must
/// define. Note the shader still uses the legacy misspelled uniform
/// names `upper_tolerence_in`/`lower_tolerence_in`, matching the old
/// input ids remapped by `map_legacy_input_id`.
const SHADER_FRAG: &str = r#"// Main texture input
uniform sampler2D tex_in;
uniform vec4 color_key;
uniform bool mask_only_in;
uniform float upper_tolerence_in;
uniform float lower_tolerence_in;
uniform sampler2D garbage_in;
uniform sampler2D core_in;
uniform bool garbage_in_enabled;
uniform bool core_in_enabled;
uniform bool invert_in;
uniform float highlights_in;
uniform float shadows_in;
// Main texture coordinate
in vec2 ove_texcoord;
out vec4 frag_color;
// Program will replace this with OCIO's auto-generated shader code
%1
// Assume D65 white point
float Xn = 95.0489;
float Yn = 100.0;
float Zn = 108.8840;
float delta = 0.20689655172; // 6/29
float func(float t) {
if (t > pow(delta, 3.0)){
return pow(t, 1.0/3.0);
} else{
return (t / (3.0 * pow(delta, 2))) + 4.0/29.0;
}
}
vec4 CIExyz_to_Lab(vec4 CIE) {
vec4 lab;
lab.r = 116.0 * func(CIE.g / Yn) - 16.0;
lab.g = 500.0 * (func(CIE.r / Xn) - func(CIE.g / Yn));
lab.b = 200.0 * (func(CIE.g / Yn) - func(CIE.b / Zn));
lab.w = CIE.w;
return lab;
}
float colorclose(vec4 col, vec4 key, float tola,float tolb) {
// Decides if a color is close to the specified hue
float temp = sqrt(((key.g-col.g)*(key.g-col.g))+((key.b-col.b)*(key.b-col.b))+((key.r-col.r)*(key.r-col.r)));
if (temp < tola) {return (0.0);}
if (temp < tolb) {return ((temp-tola)/(tolb-tola));}
return (1.0);
}
void main() {
vec4 col = texture(tex_in, ove_texcoord);
vec4 unassoc = col;
if (unassoc.a > 0) {
unassoc.rgb /= unassoc.a;
}
// Perform color conversion
vec4 cie_xyz = SceneLinearToCIEXYZ_d65(unassoc);
vec4 lab = CIExyz_to_Lab(cie_xyz);
vec4 cie_xyz_key = SceneLinearToCIEXYZ_d65(color_key);
vec4 lab_key = CIExyz_to_Lab(cie_xyz_key);
float mask = colorclose(lab, lab_key, lower_tolerence_in, upper_tolerence_in);
mask = clamp(mask, 0.0, 1.0);
if (garbage_in_enabled) {
// Force anything we want to remove to be 0.0
vec4 garbage = texture(garbage_in, ove_texcoord);
// Assumes garbage is achromatic
mask -= garbage.r;
mask = clamp(mask, 0.0, 1.0);
}
if (core_in_enabled) {
// Force anything we want to keep to be 1.0
vec3 core = texture(core_in, ove_texcoord).rgb;
// Assumes core is achromatic
mask += core.r;
mask = clamp(mask, 0.0, 1.0);
}
// Crush blacks and push whites
mask = shadows_in * 0.01 * (highlights_in * 0.01 * mask - 1.0) + 1.0;
mask = clamp(mask, 0.0, 1.0);
// Invert
if (invert_in) {
mask = 1.0 - mask;
}
col *= mask;
if (!mask_only_in) {
frag_color = col;
} else {
frag_color = vec4(vec3(mask), 1.0);
}
}
"#;
impl ChromaKeyNode {
/// Fragment shader with the `%1` OCIO stub marker still in place
/// (C++ `get_shader_code()` before the stub substitution).
fn shader_frag() -> &'static str {
SHADER_FRAG
}
/// (Re)build the OCIO color processor (C++ `generate_processor()`):
/// converts to the `cie_xyz_d65_interchange` output color space via
/// `oakrender_color_processor_create_transform` and stores the result
/// with [`OcioBase::set_processor`] when creation succeeds.
fn generate_processor(&mut self, _core: &mut NodeCore) {
// The C++ wraps the color manager, builds a transform to the
// "cie_xyz_d65_interchange" output color space and creates the
// processor through `oakrender_color_processor_create_transform`,
// storing it with OcioBase::set_processor when `processor.ctx` is
// non-null. Without a manager (the Rust model reaches the manager
// through the oakrender bridge, absent here) the C++ guard
// `if (manager())` fails, so this is a no-op and the processor
// stays empty — `value()` then pushes nothing.
// `// CPP-PARITY: chromakey.cpp` generate_processor.
}
}
impl NodeBehavior for ChromaKeyNode {
/// Human-readable name (C++ `name()`).
fn name(&self) -> &str {
"Chroma Key"
}
/// Stable type id (C++ `id()`).
fn type_id(&self) -> &str {
"org.olivevideoeditor.Olive.chromakey"
}
/// Categories (C++ `category()`).
fn categories(&self) -> &[Category] {
&[Category::Keying]
}
/// Description (C++ `description()`).
fn description(&self) -> &str {
"A simple color key based on the distance from the chroma of a selected color."
}
/// Localized input names (C++ `retranslate()`): `tex_in` ->
/// "Input", `garbage_in` -> "Garbage Matte", `core_in` ->
/// "Core Matte", `color_key` -> "Key Color", `shadows_in` ->
/// "Shadows", `highlights_in` -> "Highlights",
/// `upper_tolerance_in` -> "Upper Tolerance",
/// `lower_tolerance_in` -> "Lower Tolerance", `invert_in` ->
/// "Invert Mask", `mask_only_in` -> "Show Mask Only".
fn input_name<'a>(&self, id: &'a str) -> &'a str {
match id {
crate::nodes::ociobase::TEXTURE_INPUT => "Input",
GARBAGE_MATTE_INPUT => "Garbage Matte",
CORE_MATTE_INPUT => "Core Matte",
COLOR_INPUT => "Key Color",
SHADOWS_INPUT => "Shadows",
HIGHLIGHTS_INPUT => "Highlights",
UPPER_TOLERANCE_INPUT => "Upper Tolerance",
LOWER_TOLERANCE_INPUT => "Lower Tolerance",
INVERT_INPUT => "Invert Mask",
MASK_ONLY_INPUT => "Show Mask Only",
_ => id,
}
}
/// Input value changed (C++ `InputValueChangedEvent`): the lower
/// tolerance branch that would update the upper tolerance's `min`
/// property is disabled in C++ (FIXME); unconditionally
/// regenerates the OCIO color processor
/// (`generate_processor()`).
fn input_value_changed(&mut self, core: &mut NodeCore, input: &str, element: i32) {
let _ = (input, element);
// The C++ lower-tolerance branch that mirrors the lower tolerance
// into the upper tolerance's `min` property is commented out
// (FIXME: breaks when the lower tolerance is keyframed/connected),
// so only the processor regeneration remains.
self.generate_processor(core);
}
/// Evaluate outputs (C++ `value()`): no texture on `tex_in` ->
/// push nothing; texture present and a valid OCIO processor ->
/// push a `ColorTransformJob` wired with the processor, the input
/// texture, this node as the custom-shader provider, and the
/// function name `SceneLinearToCIEXYZ_d65`.
///
/// The C++ class also overrides `config_changed()` (pure virtual
/// on `OCIOBaseNode`) to regenerate the processor when the OCIO
/// config changes; `NodeBehavior` has no equivalent hook — that
/// wiring belongs to the facade/event layer.
fn value(
&self,
core: &NodeCore,
inputs: &crate::value::NodeValueRow,
time: oakcore_rs::Rational,
table: &mut crate::value::NodeValueTable,
) {
let _ = (core, time);
match inputs.get(crate::nodes::ociobase::TEXTURE_INPUT) {
Some(crate::value::NodeValue::Texture(_)) => {
if self.base.processor().is_some() {
// `// CPP-PARITY: chromakey.cpp` `value()` — the C++
// builds a ColorTransformJob with the processor, the
// input texture, this node as the custom-shader
// provider and the function name
// `SceneLinearToCIEXYZ_d65`. The Rust model has no
// color-transform job payload: the renderer seam
// resolves the deferred job from this null handle.
table.push(
crate::value::ValueType::Texture,
crate::value::NodeValue::Texture(crate::handle::CHandle::null()),
None,
);
}
// Texture present but no processor: the C++ pushes
// nothing (unlike the base class, which would pass the
// texture through).
}
_ => {}
}
}
/// Shader code request (C++ `get_shader_code()`): reads the
/// fragment shader and replaces every `%1` marker with
/// `request.stub` (the OCIO auto-generated shader code).
fn shader_code(&self, request: &str) -> Option<String> {
Some(SHADER_FRAG.replace("%1", request))
}
/// Legacy input id mapping (C++ `get_input_id_for_legacy_id()`):
/// maps the misspelled `upper_tolerence_in` /
/// `lower_tolerence_in` from old project files onto
/// [`UPPER_TOLERANCE_INPUT`] / [`LOWER_TOLERANCE_INPUT`];
/// anything else defers to the default (identity) mapping.
fn map_legacy_input_id<'a>(&self, id: &'a str) -> &'a str {
match id {
"upper_tolerence_in" => UPPER_TOLERANCE_INPUT,
"lower_tolerence_in" => LOWER_TOLERANCE_INPUT,
_ => id,
}
}
/// Deep copy (C++ `copy()` via `NODE_DEFAULT_FUNCTIONS`).
fn duplicate(&self, _core: &NodeCore) -> Option<Box<dyn NodeBehavior>> {
// The C++ copy constructor copies the embedded OCIO base state;
// a fresh base with no processor is the safe Rust port (the
// processor is never populated without the render bridge).
Some(Box::new(ChromaKeyNode {
base: crate::nodes::ociobase::OcioBase::new(),
}))
}
}
/// Constructor (C++ `ChromaKeyNode::ChromaKeyNode()`): the
/// `OCIOBaseNode` base adds `tex_in` (texture, not-keyframable), sets
/// the video-effect flag and the effect input; this class then adds
/// `color_key`, `lower_tolerance_in`, `upper_tolerance_in`,
/// `garbage_in`, `core_in`, `highlights_in`, `shadows_in`,
/// `invert_in`, and `mask_only_in` with the defaults and properties
/// documented on the constants.
pub fn create() -> (NodeCore, Box<dyn NodeBehavior>) {
let mut core = NodeCore::new();
// OCIOBaseNode base constructor.
let mut tex = crate::input::Input::new(
crate::nodes::ociobase::TEXTURE_INPUT,
crate::value::ValueType::Texture,
crate::value::NodeValue::None,
);
tex.flags |= crate::input::flags::NOT_KEYFRAMABLE;
core.add_input(tex);
core.effect_input = crate::nodes::ociobase::TEXTURE_INPUT.to_string();
core.flags |= crate::node::flags::VIDEO_EFFECT;
core.add_input(crate::input::Input::new(
COLOR_INPUT,
crate::value::ValueType::Color,
crate::value::NodeValue::Color([0.0, 1.0, 0.0, 1.0]),
));
let mut lower = crate::input::Input::new(
LOWER_TOLERANCE_INPUT,
crate::value::ValueType::Float,
crate::value::NodeValue::Float(5.0),
);
lower.properties = vec![
("min".to_string(), crate::value::NodeValue::Float(0.0)),
("base".to_string(), crate::value::NodeValue::Float(0.1)),
];
core.add_input(lower);
let mut upper = crate::input::Input::new(
UPPER_TOLERANCE_INPUT,
crate::value::ValueType::Float,
crate::value::NodeValue::Float(25.0),
);
upper.properties = vec![("base".to_string(), crate::value::NodeValue::Float(0.1))];
core.add_input(upper);
let mut garbage = crate::input::Input::new(
GARBAGE_MATTE_INPUT,
crate::value::ValueType::Texture,
crate::value::NodeValue::None,
);
garbage.flags |= crate::input::flags::NOT_KEYFRAMABLE;
core.add_input(garbage);
let mut core_matte = crate::input::Input::new(
CORE_MATTE_INPUT,
crate::value::ValueType::Texture,
crate::value::NodeValue::None,
);
core_matte.flags |= crate::input::flags::NOT_KEYFRAMABLE;
core.add_input(core_matte);
let mut highlights = crate::input::Input::new(
HIGHLIGHTS_INPUT,
crate::value::ValueType::Float,
crate::value::NodeValue::Float(100.0),
);
highlights.properties = vec![
("min".to_string(), crate::value::NodeValue::Float(0.0)),
("base".to_string(), crate::value::NodeValue::Float(0.1)),
];
core.add_input(highlights);
let mut shadows = crate::input::Input::new(
SHADOWS_INPUT,
crate::value::ValueType::Float,
crate::value::NodeValue::Float(100.0),
);
shadows.properties = vec![
("min".to_string(), crate::value::NodeValue::Float(0.0)),
("base".to_string(), crate::value::NodeValue::Float(0.1)),
];
core.add_input(shadows);
core.add_input(crate::input::Input::new(
INVERT_INPUT,
crate::value::ValueType::Boolean,
crate::value::NodeValue::Boolean(false),
));
core.add_input(crate::input::Input::new(
MASK_ONLY_INPUT,
crate::value::ValueType::Boolean,
crate::value::NodeValue::Boolean(false),
));
(core, Box::new(ChromaKeyNode {
base: crate::nodes::ociobase::OcioBase::new(),
}))
}
/// Register this node type (C++ factory entry for
/// `org.olivevideoeditor.Olive.chromakey`).
pub fn register(meta: &mut Vec<NodeMeta>) {
meta.push(NodeMeta {
type_id: "org.olivevideoeditor.Olive.chromakey",
name: "Chroma Key",
categories: &[Category::Keying],
create,
});
}
#[cfg(test)]
mod tests {
use super::*;
use crate::value::{NodeValue, NodeValueTable, ValueType};
use oakcore_rs::Rational;
#[test]
fn input_names() {
let n = ChromaKeyNode {
base: crate::nodes::ociobase::OcioBase::new(),
};
assert_eq!(n.input_name(crate::nodes::ociobase::TEXTURE_INPUT), "Input");
assert_eq!(n.input_name(GARBAGE_MATTE_INPUT), "Garbage Matte");
assert_eq!(n.input_name(CORE_MATTE_INPUT), "Core Matte");
assert_eq!(n.input_name(COLOR_INPUT), "Key Color");
assert_eq!(n.input_name(SHADOWS_INPUT), "Shadows");
assert_eq!(n.input_name(HIGHLIGHTS_INPUT), "Highlights");
assert_eq!(n.input_name(UPPER_TOLERANCE_INPUT), "Upper Tolerance");
assert_eq!(n.input_name(LOWER_TOLERANCE_INPUT), "Lower Tolerance");
assert_eq!(n.input_name(INVERT_INPUT), "Invert Mask");
assert_eq!(n.input_name(MASK_ONLY_INPUT), "Show Mask Only");
assert_eq!(n.input_name("other_in"), "other_in");
}
#[test]
fn create_wires_inputs_flags_and_properties() {
let (core, behavior) = create();
assert_eq!(behavior.type_id(), "org.olivevideoeditor.Olive.chromakey");
assert_ne!(
core.get_input(crate::nodes::ociobase::TEXTURE_INPUT).unwrap().flags & crate::input::flags::NOT_KEYFRAMABLE,
0
);
assert_eq!(
core.get_input(COLOR_INPUT).unwrap().default,
NodeValue::Color([0.0, 1.0, 0.0, 1.0])
);
assert_eq!(core.get_input(LOWER_TOLERANCE_INPUT).unwrap().default, NodeValue::Float(5.0));
assert_eq!(core.get_input(UPPER_TOLERANCE_INPUT).unwrap().default, NodeValue::Float(25.0));
assert_eq!(core.get_input(HIGHLIGHTS_INPUT).unwrap().default, NodeValue::Float(100.0));
assert_eq!(core.get_input(SHADOWS_INPUT).unwrap().default, NodeValue::Float(100.0));
assert_eq!(core.get_input(INVERT_INPUT).unwrap().default, NodeValue::Boolean(false));
assert_eq!(core.get_input(MASK_ONLY_INPUT).unwrap().default, NodeValue::Boolean(false));
for id in [GARBAGE_MATTE_INPUT, CORE_MATTE_INPUT] {
assert_ne!(core.get_input(id).unwrap().flags & crate::input::flags::NOT_KEYFRAMABLE, 0);
}
assert_eq!(core.effect_input, crate::nodes::ociobase::TEXTURE_INPUT);
assert_ne!(core.flags & crate::node::flags::VIDEO_EFFECT, 0);
}
#[test]
fn shader_code_replaces_stub_marker() {
let n = ChromaKeyNode {
base: crate::nodes::ociobase::OcioBase::new(),
};
let stub = "float SceneLinearToCIEXYZ_d65(vec4 c){ return 0.0; }";
let code = n.shader_code(stub).unwrap();
assert!(!code.contains("%1"));
assert!(code.contains(stub));
}
#[test]
fn legacy_input_ids_remap_misspellings() {
let n = ChromaKeyNode {
base: crate::nodes::ociobase::OcioBase::new(),
};
assert_eq!(n.map_legacy_input_id("upper_tolerence_in"), UPPER_TOLERANCE_INPUT);
assert_eq!(n.map_legacy_input_id("lower_tolerence_in"), LOWER_TOLERANCE_INPUT);
assert_eq!(n.map_legacy_input_id("anything_else_in"), "anything_else_in");
}
#[test]
fn value_no_texture_pushes_nothing() {
let (core, behavior) = create();
let mut table = NodeValueTable::default();
behavior.value(&core, &crate::value::NodeValueRow::default(), Rational::new(0, 1), &mut table);
assert!(table.is_empty());
}
#[test]
fn value_texture_without_processor_pushes_nothing() {
// Unlike the OCIO base, chroma key has no pass-through branch:
// without a processor the C++ pushes nothing.
let (core, behavior) = create();
let inputs = crate::value::NodeValueRow::from([(
crate::nodes::ociobase::TEXTURE_INPUT.to_string(),
NodeValue::Texture(crate::handle::CHandle::null()),
)]);
let mut table = NodeValueTable::default();
behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
assert!(table.is_empty());
}
#[test]
fn value_texture_with_processor_pushes_deferred_job() {
let core = NodeCore::new();
let mut node = ChromaKeyNode {
base: crate::nodes::ociobase::OcioBase::new(),
};
node.base.set_processor(Some(crate::handle::CHandle::null()));
let inputs = crate::value::NodeValueRow::from([(
crate::nodes::ociobase::TEXTURE_INPUT.to_string(),
NodeValue::Texture(crate::handle::CHandle::null()),
)]);
let mut table = NodeValueTable::default();
node.value(&core, &inputs, Rational::new(0, 1), &mut table);
assert!(table.get(ValueType::Texture).is_some());
}
#[test]
fn duplicate_clones() {
let (core, behavior) = create();
let dup = behavior.duplicate(&core).unwrap();
assert_eq!(dup.name(), "Chroma Key");
}
}
@@ -0,0 +1,359 @@
// 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/>.
//! Color Difference Key effect (C++
//! `src/node/src/keying/colordifferencekey/colordifferencekey.{h,cpp}`,
//! `olive::ColorDifferenceKeyNode`).
use crate::factory::NodeMeta;
use crate::node::{Category, NodeBehavior, NodeCore};
/// Texture input id (C++ `k_texture_input`). Type: texture; flags:
/// not-keyframable; this is the node's effect input.
pub const TEXTURE_INPUT: &str = "tex_in";
/// Garbage matte texture input id (C++ `k_garbage_matte_input`). Type:
/// texture; flags: not-keyframable.
pub const GARBAGE_MATTE_INPUT: &str = "garbage_in";
/// Core matte texture input id (C++ `k_core_matte_input`). Type:
/// texture; flags: not-keyframable.
pub const CORE_MATTE_INPUT: &str = "core_in";
/// Key color input id (C++ `k_color_input`). Type: combo; default `0`;
/// combo strings: "Green", "Blue" (set in `retranslate()`).
pub const COLOR_INPUT: &str = "color_in";
/// Shadows input id (C++ `k_shadows_input`). Type: float; default
/// `1.0`; properties: `min = 0.0`, `base = 0.01`.
pub const SHADOWS_INPUT: &str = "shadows_in";
/// Highlights input id (C++ `k_highlights_input`). Type: float;
/// default `1.0`; properties: `min = 0.0`, `base = 0.01`.
pub const HIGHLIGHTS_INPUT: &str = "highlights_in";
/// Show-mask-only toggle input id (C++ `k_mask_only_input`). Type:
/// boolean; default `false`.
pub const MASK_ONLY_INPUT: &str = "mask_only_in";
/// Color difference key node: keys on how far one channel (green or
/// blue) stands out from the other two, with optional garbage/core
/// mattes. The C++ class has no own members.
pub struct ColorDifferenceKeyNode;
/// Fragment shader (C++ loads the `:/shaders/colordifferencekey.frag`
/// resource in `get_shader_code`). Text copied verbatim from
/// `engine/shaders/colordifferencekey.frag`.
const SHADER_FRAG: &str = r#"uniform sampler2D tex_in;
uniform sampler2D garbage_in;
uniform sampler2D core_in;
uniform int color_in;
uniform bool garbage_in_enabled;
uniform bool core_in_enabled;
uniform float highlights_in;
uniform float shadows_in;
uniform bool mask_only_in;
in vec2 ove_texcoord;
out vec4 frag_color;
#define SCREEN_COLOR_GREEN 0
#define SCREEN_COLOR_BLUE 1
void main(void) {
vec4 tex_col = texture(tex_in, ove_texcoord);
// Unassociate RGB before calculating values
vec4 unassoc = tex_col;
if (unassoc.a > 0) {
unassoc.rgb /= unassoc.a;
}
// Simple keyer, generates a inverted mask (background is white, foreground black)
float mask;
if (color_in == SCREEN_COLOR_GREEN) {
mask = (unassoc.g - max(unassoc.r, unassoc.b));
} else{ // Assume SCREEN_COLOR_BLUE
mask = (unassoc.b - max(unassoc.r, unassoc.g));
}
mask = clamp(mask, 0.0, 1.0);
if (garbage_in_enabled) {
// Force anything we want to remove to be 1.0
vec4 garbage = texture(garbage_in, ove_texcoord);
// Assumes garbage is achromatic
mask += garbage.r;
mask = clamp(mask, 0.0, 1.0);
}
if (core_in_enabled) {
// Force anything we want to keep to be 0.1
vec3 core = texture(core_in, ove_texcoord).rgb;
vec3 core_invert = 1.0 - core.rgb;
// Assumes core is achromatic
mask *= core_invert.r;
mask = clamp(mask, 0.0, 1.0);
}
// Crush blacks and push whites
mask = highlights_in * (shadows_in * mask - 1.0) + 1.0;
mask = clamp(mask, 0.0, 1.0);
// Invert mask
mask = 1.0 - mask;
// Multiply color by mask
tex_col *= mask;
if (!mask_only_in) {
frag_color = tex_col;
} else {
frag_color = vec4(vec3(mask), 1.0);
}
}
"#;
impl ColorDifferenceKeyNode {
/// Fragment shader (C++ `get_shader_code()`; the request is
/// ignored — there is a single shader).
fn shader_frag() -> &'static str {
SHADER_FRAG
}
}
impl NodeBehavior for ColorDifferenceKeyNode {
/// Human-readable name (C++ `name()`).
fn name(&self) -> &str {
"Color Difference Key"
}
/// Stable type id (C++ `id()`).
fn type_id(&self) -> &str {
"org.olivevideoeditor.Olive.colordifferencekey"
}
/// Categories (C++ `category()`).
fn categories(&self) -> &[Category] {
&[Category::Keying]
}
/// Description (C++ `description()`).
fn description(&self) -> &str {
"A simple color key based on the distance of one color from other colors."
}
/// Localized input names (C++ `retranslate()`): `tex_in` ->
/// "Input", `garbage_in` -> "Garbage Matte", `core_in` ->
/// "Core Matte", `color_in` -> "Key Color" (combo strings
/// "Green"/"Blue"), `shadows_in` -> "Shadows", `highlights_in`
/// -> "Highlights", `mask_only_in` -> "Show Mask Only".
fn input_name<'a>(&self, id: &'a str) -> &'a str {
match id {
TEXTURE_INPUT => "Input",
GARBAGE_MATTE_INPUT => "Garbage Matte",
CORE_MATTE_INPUT => "Core Matte",
COLOR_INPUT => "Key Color",
SHADOWS_INPUT => "Shadows",
HIGHLIGHTS_INPUT => "Highlights",
MASK_ONLY_INPUT => "Show Mask Only",
_ => id,
}
}
/// Evaluate outputs (C++ `value()`): no texture on `tex_in` ->
/// push nothing; texture present -> push a `ShaderJob` with the
/// whole input row inserted.
fn value(
&self,
core: &NodeCore,
inputs: &crate::value::NodeValueRow,
time: oakcore_rs::Rational,
table: &mut crate::value::NodeValueTable,
) {
let _ = (core, time);
match inputs.get(TEXTURE_INPUT) {
Some(crate::value::NodeValue::Texture(_)) => {}
_ => return,
}
// `// CPP-PARITY: colordifferencekey.cpp` `value()` — the C++
// builds a ShaderJob over the whole input row and pushes
// `tex->to_job(job)`. The Rust model has no shader-job payload:
// the renderer seam resolves the deferred job (and the
// `garbage_in_enabled`/`core_in_enabled` uniforms derived from
// input presence) from this null handle.
table.push(
crate::value::ValueType::Texture,
crate::value::NodeValue::Texture(crate::handle::CHandle::null()),
None,
);
}
/// Shader code request (C++ `get_shader_code()`): returns the
/// single fragment shader regardless of the request id.
fn shader_code(&self, _request: &str) -> Option<String> {
Some(SHADER_FRAG.to_string())
}
/// Deep copy (C++ `copy()` via `NODE_DEFAULT_FUNCTIONS`).
fn duplicate(&self, _core: &NodeCore) -> Option<Box<dyn NodeBehavior>> {
Some(Box::new(ColorDifferenceKeyNode))
}
}
/// Constructor (C++ `ColorDifferenceKeyNode::ColorDifferenceKeyNode()`):
/// adds `tex_in`, `garbage_in`, `core_in`, `color_in`,
/// `highlights_in`, `shadows_in`, and `mask_only_in` with the defaults
/// and properties documented on the constants, sets the video-effect
/// flag, and makes `tex_in` the effect input.
pub fn create() -> (NodeCore, Box<dyn NodeBehavior>) {
let mut core = NodeCore::new();
let mut texture_input = |id: &str| {
let mut input = crate::input::Input::new(
id,
crate::value::ValueType::Texture,
crate::value::NodeValue::None,
);
input.flags |= crate::input::flags::NOT_KEYFRAMABLE;
core.add_input(input);
};
texture_input(TEXTURE_INPUT);
texture_input(GARBAGE_MATTE_INPUT);
texture_input(CORE_MATTE_INPUT);
core.add_input(crate::input::Input::new(
COLOR_INPUT,
crate::value::ValueType::Combo,
crate::value::NodeValue::Combo(0),
));
let mut highlights = crate::input::Input::new(
HIGHLIGHTS_INPUT,
crate::value::ValueType::Float,
crate::value::NodeValue::Float(1.0),
);
highlights.properties = vec![
("min".to_string(), crate::value::NodeValue::Float(0.0)),
("base".to_string(), crate::value::NodeValue::Float(0.01)),
];
core.add_input(highlights);
let mut shadows = crate::input::Input::new(
SHADOWS_INPUT,
crate::value::ValueType::Float,
crate::value::NodeValue::Float(1.0),
);
shadows.properties = vec![
("min".to_string(), crate::value::NodeValue::Float(0.0)),
("base".to_string(), crate::value::NodeValue::Float(0.01)),
];
core.add_input(shadows);
core.add_input(crate::input::Input::new(
MASK_ONLY_INPUT,
crate::value::ValueType::Boolean,
crate::value::NodeValue::Boolean(false),
));
core.flags |= crate::node::flags::VIDEO_EFFECT;
core.effect_input = TEXTURE_INPUT.to_string();
(core, Box::new(ColorDifferenceKeyNode))
}
/// Register this node type (C++ factory entry for
/// `org.olivevideoeditor.Olive.colordifferencekey`).
pub fn register(meta: &mut Vec<NodeMeta>) {
meta.push(NodeMeta {
type_id: "org.olivevideoeditor.Olive.colordifferencekey",
name: "Color Difference Key",
categories: &[Category::Keying],
create,
});
}
#[cfg(test)]
mod tests {
use super::*;
use crate::value::{NodeValue, NodeValueTable, ValueType};
use oakcore_rs::Rational;
#[test]
fn input_names() {
let n = ColorDifferenceKeyNode;
assert_eq!(n.input_name(TEXTURE_INPUT), "Input");
assert_eq!(n.input_name(GARBAGE_MATTE_INPUT), "Garbage Matte");
assert_eq!(n.input_name(CORE_MATTE_INPUT), "Core Matte");
assert_eq!(n.input_name(COLOR_INPUT), "Key Color");
assert_eq!(n.input_name(SHADOWS_INPUT), "Shadows");
assert_eq!(n.input_name(HIGHLIGHTS_INPUT), "Highlights");
assert_eq!(n.input_name(MASK_ONLY_INPUT), "Show Mask Only");
assert_eq!(n.input_name("other_in"), "other_in");
}
#[test]
fn create_wires_inputs_flags_and_properties() {
let (core, behavior) = create();
assert_eq!(behavior.type_id(), "org.olivevideoeditor.Olive.colordifferencekey");
for id in [TEXTURE_INPUT, GARBAGE_MATTE_INPUT, CORE_MATTE_INPUT] {
assert_ne!(core.get_input(id).unwrap().flags & crate::input::flags::NOT_KEYFRAMABLE, 0);
}
assert_eq!(core.get_input(COLOR_INPUT).unwrap().default, NodeValue::Combo(0));
assert_eq!(core.get_input(SHADOWS_INPUT).unwrap().default, NodeValue::Float(1.0));
assert_eq!(core.get_input(HIGHLIGHTS_INPUT).unwrap().default, NodeValue::Float(1.0));
assert_eq!(
core.get_input(MASK_ONLY_INPUT).unwrap().default,
NodeValue::Boolean(false)
);
assert_eq!(core.effect_input, TEXTURE_INPUT);
assert_ne!(core.flags & crate::node::flags::VIDEO_EFFECT, 0);
}
#[test]
fn shader_code_returns_colordifferencekey_frag() {
let code = ColorDifferenceKeyNode.shader_code("anything").unwrap();
assert!(code.contains("mask = (unassoc.g - max(unassoc.r, unassoc.b));"));
}
#[test]
fn value_no_texture_pushes_nothing() {
let (core, behavior) = create();
let mut table = NodeValueTable::default();
behavior.value(&core, &crate::value::NodeValueRow::default(), Rational::new(0, 1), &mut table);
assert!(table.is_empty());
}
#[test]
fn value_with_texture_pushes_deferred_shader_job() {
let (core, behavior) = create();
let inputs = crate::value::NodeValueRow::from([(
TEXTURE_INPUT.to_string(),
NodeValue::Texture(crate::handle::CHandle::null()),
)]);
let mut table = NodeValueTable::default();
behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
assert!(table.get(ValueType::Texture).is_some());
}
#[test]
fn duplicate_clones() {
let (core, behavior) = create();
let dup = behavior.duplicate(&core).unwrap();
assert_eq!(dup.name(), "Color Difference Key");
}
}
@@ -0,0 +1,641 @@
// 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/>.
//! Corner pin distort effect (C++
//! `src/node/src/distort/cornerpin/cornerpindistortnode.{h,cpp}`,
//! `olive::CornerPinDistortNode`).
use crate::factory::NodeMeta;
use crate::node::{Category, Gizmo, NodeBehavior, NodeCore};
/// Texture input id (C++ `k_texture_input`). Type: texture; flags:
/// not-keyframable; this is the node's effect input.
pub const TEXTURE_INPUT: &str = "tex_in";
/// Perspective-correct interpolation input id (C++
/// `k_perspective_input`). Type: bool; default `true`.
pub const PERSPECTIVE_INPUT: &str = "perspective_in";
/// Top-left corner offset input id (C++ `k_top_left_input`). Type:
/// vec2; default `(0.0, 0.0)`; property `offset` is set per-frame by
/// `update_gizmo_positions()` to the corner's pixel origin.
pub const TOP_LEFT_INPUT: &str = "top_left_in";
/// Top-right corner offset input id (C++ `k_top_right_input`). Type:
/// vec2; default `(0.0, 0.0)`; property `offset` is set per-frame to
/// `(resolution.x, 0)`.
pub const TOP_RIGHT_INPUT: &str = "top_right_in";
/// Bottom-right corner offset input id (C++ `k_bottom_right_input`).
/// Type: vec2; default `(0.0, 0.0)`; property `offset` is set per-frame
/// to the full resolution.
pub const BOTTOM_RIGHT_INPUT: &str = "bottom_right_in";
/// Bottom-left corner offset input id (C++ `k_bottom_left_input`).
/// Type: vec2; default `(0.0, 0.0)`; property `offset` is set per-frame
/// to `(0, resolution.y)`.
pub const BOTTOM_LEFT_INPUT: &str = "bottom_left_in";
/// Number of corner point gizmos (C++ `k_gizmo_corner_count`).
pub const GIZMO_CORNER_COUNT: usize = 4;
/// Corner pin distort node. Warps the image by dragging its four
/// corners, optionally with perspective-correct interpolation.
pub struct CornerPinDistortNode {
/// Corner drag handles, one per corner in top-left, top-right,
/// bottom-right, bottom-left order (C++
/// `PointGizmo *gizmo_resize_handle_[k_gizmo_corner_count]`; each
/// drags both tracks of its corner input).
gizmo_resize_handle: [Gizmo; GIZMO_CORNER_COUNT],
/// Whole-quad outline gizmo (C++ `PolygonGizmo *gizmo_whole_rect_`;
/// draggable but ignored by `gizmo_drag_move`, so dragging it does
/// nothing).
gizmo_whole_rect: Gizmo,
}
/// Fragment shader (C++ loads the `:/shaders/cornerpin.frag` resource
/// in `get_shader_code`). Text copied verbatim from
/// `engine/shaders/cornerpin.frag`.
const SHADER_FRAG: &str = r#"// Input texture
uniform sampler2D ove_maintex;
uniform sampler2D tex_in;
uniform bool perspective_in;
// Input texture coordinate
in vec2 ove_texcoord;
out vec4 frag_color;
in vec2 q;
in vec2 b1;
in vec2 b2;
in vec2 b3;
float Wedge2D(vec2 v, vec2 w) {
return (v.x*w.y) - (v.y*w.x);
}
void main() {
if(perspective_in){
frag_color = texture(tex_in, ove_texcoord);
} else {
float A = Wedge2D(b2, b3);
float B = Wedge2D(b3, q) - Wedge2D(b1, b2);
float C = Wedge2D(b1, q);
vec2 uv;
// solve for v
if (abs(A) < 0.001) {
uv.y = -C/B;
} else {
float discrim = B*B - 4.0*A*C;
uv.y = 0.5 * (-B + sqrt(discrim)) / A;
}
// solve for u
vec2 denom = b1 + uv.y * b3;
if (abs(denom.x) > abs(denom.y)) {
uv.x = (q.x - b2.x * uv.y) / denom.x;
} else {
uv.x = (q.y - b2.y * uv.y) / denom.y;
}
uv.y = 1.0 - uv.y;
frag_color = texture(tex_in, uv);
}
}
"#;
/// Vertex shader (C++ loads the `:/shaders/cornerpin.vert` resource in
/// `get_shader_code`). Text copied verbatim from
/// `engine/shaders/cornerpin.vert`.
const SHADER_VERT: &str = r#"uniform bool perspective_in;
uniform vec2 top_left_in;
uniform vec2 top_right_in;
uniform vec2 bottom_left_in;
uniform vec2 bottom_right_in;
uniform vec2 resolution_in;
uniform mat4 ove_mvpmat;
in vec4 a_position;
in vec2 a_texcoord;
out vec2 ove_texcoord;
out vec2 q;
out vec2 b1;
out vec2 b2;
out vec2 b3;
void main() {
// The slider inputs only contain the amount they have changed rather than
// their pixel locations so we adjust them here.
vec2 t_l = top_left_in;
vec2 t_r = top_right_in + vec2(resolution_in.x, 0.0);
vec2 b_r = bottom_right_in + resolution_in;
vec2 b_l = bottom_left_in + vec2(0.0, resolution_in.y);
gl_Position = ove_mvpmat * a_position;
if (perspective_in){
// Find the center of the quadrilateral by finding where the two diagonals intersect.
// https://www.reedbeta.com/blog/quadrilateral-interpolation-part-1/
// Here we calculate the gradient and constant (y = mx + c) for each diagonal.
float m1 = (t_r.y - b_l.y)/(t_r.x - b_l.x);
float c1 = b_l.y - m1 * b_l.x;
float m2 = (b_r.y - t_l.y)/(b_r.x - t_l.x);
float c2 = t_l.y - m2 * t_l.x;
// Find the intersection by setting the two line equations equal and rearrange.
float mid_x = (c2 - c1) / (m1 - m2);
float mid_y = m1 * mid_x + c1;
// Find the distance from each corner to our center point
float d0 = length(vec2(mid_x - b_l.x, mid_y - b_l.y));
float d1 = length(vec2(b_r.x - mid_x, mid_y - b_r.y));
float d2 = length(vec2(t_r.x - mid_x, t_r.y - mid_y));
float d3 = length(vec2(mid_x - t_l.x, t_l.y - mid_y));
float q = 1.0;
/*
Vertex IDs (aspect ratio irrelevant):
0_____1
3|\ |
| \ |
| \ |
| \ |
|____\|2
4 5
*/
if (gl_VertexID == 0 || gl_VertexID == 3) {
q = (d1+d3)/d3;
} else if (gl_VertexID == 1) {
q = (d0+d2)/d2;
} else if (gl_VertexID == 2 || gl_VertexID == 5) {
q = (d3+d1)/d1;
} else {
q = (d2+d0)/d0;
}
gl_Position[0] *= q;
gl_Position[1] *= q;
gl_Position[3] = q;
} else{
// https://www.reedbeta.com/blog/quadrilateral-interpolation-part-2/
vec2 pos;
if (gl_VertexID == 0 || gl_VertexID == 3) { // top left
pos = t_l;
} else if (gl_VertexID == 1) { // top right
pos = t_r;
} else if (gl_VertexID == 2 || gl_VertexID == 5) { // bottom right
pos = b_r;
} else if (gl_VertexID == 4) { // bottom left
pos = b_l;
}
q = pos - b_l;
b1 = b_r - b_l;
b2 = t_l - b_l;
b3 = b_l - b_r - t_l + t_r;
}
ove_texcoord = a_texcoord;
}
"#;
impl CornerPinDistortNode {
/// Fragment shader (C++ `get_shader_code()` frag half; the request
/// id is ignored).
fn shader_frag() -> &'static str {
SHADER_FRAG
}
/// Vertex shader (C++ `get_shader_code()` vert half; the request id
/// is ignored).
fn shader_vert() -> &'static str {
SHADER_VERT
}
}
impl NodeBehavior for CornerPinDistortNode {
/// Human-readable name (C++ `name()`).
fn name(&self) -> &str {
"Corner Pin"
}
/// Stable type id (C++ `id()`).
fn type_id(&self) -> &str {
"org.olivevideoeditor.Olive.cornerpin"
}
/// Categories (C++ `category()`).
fn categories(&self) -> &[Category] {
&[Category::Distort]
}
/// Description (C++ `description()`).
fn description(&self) -> &str {
"Distort the image by dragging the corners."
}
/// Localized input names (C++ `retranslate()`): `tex_in` ->
/// "Texture", `perspective_in` -> "Perspective", `top_left_in` ->
/// "Top Left", `top_right_in` -> "Top Right", `bottom_right_in` ->
/// "Bottom Right", `bottom_left_in` -> "Bottom Left".
fn input_name<'a>(&self, id: &'a str) -> &'a str {
match id {
TEXTURE_INPUT => "Texture",
PERSPECTIVE_INPUT => "Perspective",
TOP_LEFT_INPUT => "Top Left",
TOP_RIGHT_INPUT => "Top Right",
BOTTOM_RIGHT_INPUT => "Bottom Right",
BOTTOM_LEFT_INPUT => "Bottom Left",
_ => id,
}
}
/// Evaluate outputs (C++ `value()`): no texture -> push nothing;
/// all four corner sliders at their `(0, 0)` default -> pass-through
/// push of the input texture unchanged; otherwise build a shader job
/// with `resolution_in` inserted and custom vertex coordinates: each
/// corner offset is converted to pixels via `value_to_pixel` and then
/// to clip space (`/ half_resolution - 1.0`) and pushed as two
/// triangles (TL, TR, BR / TL, BL, BR).
///
/// The Rust model has no shader-job payload: the job (including the
/// `resolution_in` value and the adjusted vertex coordinates) is
/// deferred to the renderer seam (`// CPP-PARITY:
/// cornerpindistortnode.cpp` value()).
fn value(
&self,
core: &NodeCore,
inputs: &crate::value::NodeValueRow,
time: oakcore_rs::Rational,
table: &mut crate::value::NodeValueTable,
) {
let tex = match inputs.get(TEXTURE_INPUT) {
Some(tex @ crate::value::NodeValue::Texture(_)) => tex.clone(),
_ => return,
};
// C++ `to_vec2().is_null()`: a corner is at its default only when
// both components are zero.
let corner_is_null = |id: &str| {
let v = match inputs.get(id) {
Some(crate::value::NodeValue::Vec2(v)) => *v,
_ => match core.value_at_time(id, -1, time) {
crate::value::NodeValue::Vec2(v) => v,
v => [v.to_double(), v.to_double()],
},
};
v[0] == 0.0 && v[1] == 0.0
};
if !(corner_is_null(TOP_LEFT_INPUT)
&& corner_is_null(TOP_RIGHT_INPUT)
&& corner_is_null(BOTTOM_RIGHT_INPUT)
&& corner_is_null(BOTTOM_LEFT_INPUT))
{
table.push(
crate::value::ValueType::Texture,
crate::value::NodeValue::Texture(crate::handle::CHandle::null()),
None,
);
} else {
table.push(crate::value::ValueType::Texture, tex, None);
}
}
/// Shader code request (C++ `get_shader_code()`): ignores the
/// request id and always returns the corner pin fragment and vertex
/// shaders together.
///
/// The trait returns only the fragment shader, so the vertex shader
/// (see [`SHADER_VERT`]) is not representable in the return value.
fn shader_code(&self, _request: &str) -> Option<String> {
Some(Self::shader_frag().to_string())
}
/// Gizmo positions (C++ `update_gizmo_positions()`): with a texture,
/// converts the four corner offsets to pixels (`value_to_pixel`,
/// which adds the corner's resolution-based origin), sets each corner
/// input's `offset` property to that origin, sets the polygon gizmo
/// to the quad TL->TR->BR->BL->TL and each point gizmo to its
/// corner. Also covers C++ `value_to_pixel()`.
///
/// The pixel conversions and `offset` property writes need the
/// texture's virtual resolution (the Rust texture handle carries no
/// params), and the gizmo point positions have no storage in
/// [`Gizmo`] — so the update is not representable here
/// (`// CPP-PARITY: cornerpindistortnode.cpp`
/// `update_gizmo_positions`). [`Self::value_to_pixel`] below ports the
/// pure math for tests.
fn gizmo_update(&self, core: &NodeCore, row: &crate::value::NodeValueRow) {
let _ = (core, row);
}
/// Gizmo drag (C++ `gizmo_drag_move()`): for a corner handle, drags
/// both its X and Y track draggers by the mouse delta added to their
/// drag-start values; dragging the whole-rect polygon gizmo is a
/// no-op.
///
/// The draggers hold per-drag start values and write keyframe tracks,
/// neither of which the Rust data model carries — not representable
/// here (`// CPP-PARITY: cornerpindistortnode.cpp` `gizmo_drag_move`).
fn gizmo_drag(&mut self, core: &mut NodeCore, start: bool, x: f64, y: f64, modifiers: u32) {
let _ = (core, start, x, y, modifiers);
}
/// Deep copy (C++ `copy()`).
fn duplicate(&self, _core: &NodeCore) -> Option<Box<dyn NodeBehavior>> {
Some(Box::new(CornerPinDistortNode {
gizmo_resize_handle: self.gizmo_resize_handle.clone(),
gizmo_whole_rect: self.gizmo_whole_rect.clone(),
}))
}
}
impl CornerPinDistortNode {
/// Corner offset to pixel position (C++ `value_to_pixel()`): the
/// four corner inputs hold offsets from their resolution-based
/// origin, so corner 0 (top-left) maps straight, corner 1 (top-right)
/// adds `(resolution.x, 0)`, corner 2 (bottom-right) adds the full
/// resolution, and corner 3 (bottom-left) adds `(0, resolution.y)`.
fn value_to_pixel(value: i32, row: &crate::value::NodeValueRow, resolution: (f64, f64)) -> (f64, f64) {
let vec_at = |id: &str| match row.get(id) {
Some(crate::value::NodeValue::Vec2(v)) => *v,
Some(v) => [v.to_double(), 0.0],
None => [0.0, 0.0],
};
match value {
0 => {
let v = vec_at(TOP_LEFT_INPUT);
(v[0], v[1])
}
1 => {
let v = vec_at(TOP_RIGHT_INPUT);
(resolution.0 + v[0], v[1])
}
2 => {
let v = vec_at(BOTTOM_RIGHT_INPUT);
(resolution.0 + v[0], resolution.1 + v[1])
}
3 => {
let v = vec_at(BOTTOM_LEFT_INPUT);
(v[0], resolution.1 + v[1])
}
_ => (0.0, 0.0),
}
}
}
/// Constructor (C++ `CornerPinDistortNode::CornerPinDistortNode()`):
/// adds `tex_in`, `perspective_in` and the four corner inputs with the
/// defaults and flags documented on the constants; creates the polygon
/// gizmo and the four corner point gizmos (each bound to both tracks of
/// its corner input); sets the video-effect flag and the effect input.
pub fn create() -> (NodeCore, Box<dyn NodeBehavior>) {
let mut core = NodeCore::new();
let mut tex = crate::input::Input::new(
TEXTURE_INPUT,
crate::value::ValueType::Texture,
crate::value::NodeValue::None,
);
tex.flags |= crate::input::flags::NOT_KEYFRAMABLE;
core.add_input(tex);
core.add_input(crate::input::Input::new(
PERSPECTIVE_INPUT,
crate::value::ValueType::Boolean,
crate::value::NodeValue::Boolean(true),
));
core.add_input(crate::input::Input::new(
TOP_LEFT_INPUT,
crate::value::ValueType::Vec2,
crate::value::NodeValue::Vec2([0.0, 0.0]),
));
core.add_input(crate::input::Input::new(
TOP_RIGHT_INPUT,
crate::value::ValueType::Vec2,
crate::value::NodeValue::Vec2([0.0, 0.0]),
));
core.add_input(crate::input::Input::new(
BOTTOM_RIGHT_INPUT,
crate::value::ValueType::Vec2,
crate::value::NodeValue::Vec2([0.0, 0.0]),
));
core.add_input(crate::input::Input::new(
BOTTOM_LEFT_INPUT,
crate::value::ValueType::Vec2,
crate::value::NodeValue::Vec2([0.0, 0.0]),
));
// Gizmos, in C++ add order: the whole-quad polygon gizmo first, then
// the four corner point gizmos (each dragging both tracks of its
// corner input) in TL, TR, BR, BL order.
let corner_gizmo = |id: &str| Gizmo {
position_inputs: vec![(id.to_string(), -1, 0), (id.to_string(), -1, 1)],
drag_point: (0.0, 0.0),
};
let gizmo_whole_rect = Gizmo {
position_inputs: vec![],
drag_point: (0.0, 0.0),
};
let tl = corner_gizmo(TOP_LEFT_INPUT);
let tr = corner_gizmo(TOP_RIGHT_INPUT);
let br = corner_gizmo(BOTTOM_RIGHT_INPUT);
let bl = corner_gizmo(BOTTOM_LEFT_INPUT);
core.gizmos = vec![
gizmo_whole_rect.clone(),
tl.clone(),
tr.clone(),
br.clone(),
bl.clone(),
];
core.flags |= crate::node::flags::VIDEO_EFFECT;
core.effect_input = TEXTURE_INPUT.to_string();
(
core,
Box::new(CornerPinDistortNode {
gizmo_resize_handle: [tl, tr, br, bl],
gizmo_whole_rect,
}),
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::node::NodeBehavior;
use crate::value::{NodeValue, NodeValueTable, ValueType};
use oakcore_rs::Rational;
fn tex() -> NodeValue {
NodeValue::Texture(crate::handle::CHandle::null())
}
fn empty_gizmo() -> Gizmo {
Gizmo {
position_inputs: vec![],
drag_point: (0.0, 0.0),
}
}
#[test]
fn input_names() {
let n = CornerPinDistortNode {
gizmo_resize_handle: std::array::from_fn(|_| empty_gizmo()),
gizmo_whole_rect: empty_gizmo(),
};
assert_eq!(n.input_name(TEXTURE_INPUT), "Texture");
assert_eq!(n.input_name(PERSPECTIVE_INPUT), "Perspective");
assert_eq!(n.input_name(TOP_LEFT_INPUT), "Top Left");
assert_eq!(n.input_name(TOP_RIGHT_INPUT), "Top Right");
assert_eq!(n.input_name(BOTTOM_RIGHT_INPUT), "Bottom Right");
assert_eq!(n.input_name(BOTTOM_LEFT_INPUT), "Bottom Left");
}
#[test]
fn create_wires_inputs_and_flags() {
let (core, behavior) = create();
assert_eq!(behavior.type_id(), "org.olivevideoeditor.Olive.cornerpin");
assert_eq!(
core.get_input(PERSPECTIVE_INPUT).unwrap().default,
NodeValue::Boolean(true)
);
assert_eq!(
core.get_input(TOP_LEFT_INPUT).unwrap().default,
NodeValue::Vec2([0.0, 0.0])
);
// Five gizmos: one polygon + four corner points.
assert_eq!(core.gizmos.len(), 5);
assert_eq!(core.effect_input, TEXTURE_INPUT);
assert_ne!(core.flags & crate::node::flags::VIDEO_EFFECT, 0);
}
#[test]
fn value_no_texture_pushes_nothing() {
let (core, behavior) = create();
let mut table = NodeValueTable::default();
behavior.value(
&core,
&crate::value::NodeValueRow::default(),
Rational::new(0, 1),
&mut table,
);
assert!(table.is_empty());
}
#[test]
fn value_default_corners_passes_texture_through() {
let (mut core, behavior) = create();
core.set_standard_value(TOP_LEFT_INPUT, -1, NodeValue::Vec2([0.0, 0.0]));
core.set_standard_value(TOP_RIGHT_INPUT, -1, NodeValue::Vec2([0.0, 0.0]));
core.set_standard_value(BOTTOM_RIGHT_INPUT, -1, NodeValue::Vec2([0.0, 0.0]));
core.set_standard_value(BOTTOM_LEFT_INPUT, -1, NodeValue::Vec2([0.0, 0.0]));
let tex = tex();
let inputs = crate::value::NodeValueRow::from([(TEXTURE_INPUT.to_string(), tex.clone())]);
let mut table = NodeValueTable::default();
behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
assert_eq!(table.get(ValueType::Texture), Some(&tex));
}
#[test]
fn value_moved_corner_pushes_deferred_job() {
let (mut core, behavior) = create();
core.set_standard_value(TOP_RIGHT_INPUT, -1, NodeValue::Vec2([10.0, 5.0]));
let inputs = crate::value::NodeValueRow::from([(TEXTURE_INPUT.to_string(), tex())]);
let mut table = NodeValueTable::default();
behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
assert!(table.get(ValueType::Texture).is_some());
}
#[test]
fn value_corner_moved_on_y_only_pushes_deferred_job() {
// C++ `is_null()` requires both components zero: a corner at
// (0, 5) is not at its default.
let (mut core, behavior) = create();
core.set_standard_value(BOTTOM_LEFT_INPUT, -1, NodeValue::Vec2([0.0, 5.0]));
let inputs = crate::value::NodeValueRow::from([(TEXTURE_INPUT.to_string(), tex())]);
let mut table = NodeValueTable::default();
behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
assert!(table.get(ValueType::Texture).is_some());
}
#[test]
fn value_to_pixel_adds_resolution_origins() {
let res = (1920.0, 1080.0);
let mut row = crate::value::NodeValueRow::new();
row.insert(
TOP_LEFT_INPUT.to_string(),
NodeValue::Vec2([5.0, 6.0]),
);
row.insert(
TOP_RIGHT_INPUT.to_string(),
NodeValue::Vec2([7.0, 8.0]),
);
row.insert(
BOTTOM_RIGHT_INPUT.to_string(),
NodeValue::Vec2([9.0, 10.0]),
);
row.insert(
BOTTOM_LEFT_INPUT.to_string(),
NodeValue::Vec2([11.0, 12.0]),
);
assert_eq!(CornerPinDistortNode::value_to_pixel(0, &row, res), (5.0, 6.0));
assert_eq!(CornerPinDistortNode::value_to_pixel(1, &row, res), (1927.0, 8.0));
assert_eq!(CornerPinDistortNode::value_to_pixel(2, &row, res), (1929.0, 1090.0));
assert_eq!(CornerPinDistortNode::value_to_pixel(3, &row, res), (11.0, 1092.0));
}
#[test]
fn shader_code_returns_cornerpin_fragment() {
let (_, behavior) = create();
let code = behavior.shader_code("anything").unwrap();
assert!(code.contains("uniform sampler2D ove_maintex;"));
assert!(code.contains("Wedge2D"));
}
#[test]
fn duplicate_clones() {
let (core, behavior) = create();
let dup = behavior.duplicate(&core).unwrap();
assert_eq!(dup.name(), "Corner Pin");
}
}
/// Register this node type (C++ factory entry for
/// `org.olivevideoeditor.Olive.cornerpin`).
pub fn register(meta: &mut Vec<NodeMeta>) {
meta.push(NodeMeta {
type_id: "org.olivevideoeditor.Olive.cornerpin",
name: "Corner Pin",
categories: &[Category::Distort],
create,
});
}
+499
View File
@@ -0,0 +1,499 @@
// 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/>.
//! Crop distort effect (C++
//! `src/node/src/distort/crop/cropdistortnode.{h,cpp}`,
//! `olive::CropDistortNode`).
use crate::factory::NodeMeta;
use crate::node::{Category, Gizmo, NodeBehavior, NodeCore};
/// Texture input id (C++ `k_texture_input`). Type: texture; flags:
/// not-keyframable; this is the node's effect input.
pub const TEXTURE_INPUT: &str = "tex_in";
/// Left crop input id (C++ `k_left_input`). Type: float; default `0.0`;
/// properties: `min = 0.0`, `max = 1.0`, `view = percentage` (created by
/// C++ `create_crop_side_input()`).
pub const LEFT_INPUT: &str = "left_in";
/// Top crop input id (C++ `k_top_input`). Type: float; default `0.0`;
/// properties: `min = 0.0`, `max = 1.0`, `view = percentage`.
pub const TOP_INPUT: &str = "top_in";
/// Right crop input id (C++ `k_right_input`). Type: float; default
/// `0.0`; properties: `min = 0.0`, `max = 1.0`, `view = percentage`.
pub const RIGHT_INPUT: &str = "right_in";
/// Bottom crop input id (C++ `k_bottom_input`). Type: float; default
/// `0.0`; properties: `min = 0.0`, `max = 1.0`, `view = percentage`.
pub const BOTTOM_INPUT: &str = "bottom_in";
/// Feather input id (C++ `k_feather_input`). Type: float; default
/// `0.0`; properties: `min = 0.0`.
pub const FEATHER_INPUT: &str = "feather_in";
/// Number of crop point gizmos (C++ `k_gizmo_scale_count` from
/// `node.h`: top-left, top-center, top-right, bottom-left,
/// bottom-center, bottom-right, center-left, center-right).
pub const GIZMO_SCALE_COUNT: usize = 8;
/// Crop distort node. Crops the edges of an image with an optional
/// feather.
pub struct CropDistortNode {
/// Edge/corner drag handles in `k_gizmo_scale_*` order (C++
/// `PointGizmo *point_gizmo_[k_gizmo_scale_count]`; each handle drags
/// the one or two crop inputs it touches).
point_gizmo: [Gizmo; GIZMO_SCALE_COUNT],
/// Crop rectangle outline gizmo (C++ `PolygonGizmo *poly_gizmo_`;
/// drags all four crop inputs).
poly_gizmo: Gizmo,
/// Resolution captured by the last `update_gizmo_positions()` call,
/// used to normalize drag deltas (C++ `Vector2D temp_resolution_`).
temp_resolution: (f64, f64),
}
/// Fragment shader (C++ loads the `:/shaders/crop.frag` resource in
/// `get_shader_code`). Text copied verbatim from
/// `engine/shaders/crop.frag`.
const SHADER_FRAG: &str = r#"// Input variables
uniform sampler2D tex_in;
uniform float left_in;
uniform float top_in;
uniform float right_in;
uniform float bottom_in;
uniform float feather_in;
uniform vec2 resolution_in;
// Input texture coordinate
in vec2 ove_texcoord;
out vec4 frag_color;
void main() {
float multiplier = 1.0;
vec2 feather_normalized = vec2(feather_in / resolution_in.x, feather_in / resolution_in.y);
vec2 feather_normalized_half = feather_normalized * 0.5;
// Calculate left cropping
float left_adjustment;
float right_adjustment;
float top_adjustment;
float bottom_adjustment;
if (feather_in == 0.0) {
if (ove_texcoord.x < left_in
|| ove_texcoord.x > (1.0-right_in)
|| ove_texcoord.y < (top_in)
|| ove_texcoord.y > (1.0-bottom_in)) {
multiplier = 0.0;
}
} else {
float left_adjustment = clamp((ove_texcoord.x - (left_in - feather_normalized.x*(1.0-left_in))) / feather_normalized.x, 0.0, 1.0);
multiplier *= left_adjustment;
float right_adjustment = 1.0-clamp((ove_texcoord.x - ((1.0-right_in) - feather_normalized.x*(right_in))) / feather_normalized.x, 0.0, 1.0);
multiplier *= right_adjustment;
float top_adjustment = clamp((ove_texcoord.y - (top_in - feather_normalized.y*(1.0-top_in))) / feather_normalized.y, 0.0, 1.0);
multiplier *= top_adjustment;
float bottom_adjustment = 1.0-clamp((ove_texcoord.y - ((1.0-bottom_in) - feather_normalized.y*(bottom_in))) / feather_normalized.y, 0.0, 1.0);
multiplier *= bottom_adjustment;
}
if (multiplier > 0.0) {
vec4 color = texture(tex_in, ove_texcoord) * multiplier;
frag_color = color;
} else {
frag_color = vec4(0.0);
}
}
"#;
impl CropDistortNode {
/// Fragment shader (C++ `get_shader_code()`; the request id is
/// ignored, this is the only shader).
fn shader_frag() -> &'static str {
SHADER_FRAG
}
}
impl NodeBehavior for CropDistortNode {
/// Human-readable name (C++ `name()`).
fn name(&self) -> &str {
"Crop"
}
/// Stable type id (C++ `id()`).
fn type_id(&self) -> &str {
"org.olivevideoeditor.Olive.crop"
}
/// Categories (C++ `category()`).
fn categories(&self) -> &[Category] {
&[Category::Distort]
}
/// Description (C++ `description()`).
fn description(&self) -> &str {
"Crop the edges of an image."
}
/// Localized input names (C++ `retranslate()`): `tex_in` ->
/// "Texture", `left_in` -> "Left", `top_in` -> "Top", `right_in` ->
/// "Right", `bottom_in` -> "Bottom", `feather_in` -> "Feather".
fn input_name<'a>(&self, id: &'a str) -> &'a str {
match id {
TEXTURE_INPUT => "Texture",
LEFT_INPUT => "Left",
TOP_INPUT => "Top",
RIGHT_INPUT => "Right",
BOTTOM_INPUT => "Bottom",
FEATHER_INPUT => "Feather",
_ => id,
}
}
/// Evaluate outputs (C++ `value()`): copies the whole value row into
/// a shader job and inserts `resolution_in` from the texture params;
/// no texture -> push nothing; any of left/right/top/bottom != 0.0 ->
/// shader job; all zero -> pass-through push of the input texture
/// unchanged.
///
/// The Rust model has no shader-job payload: the job (including the
/// `resolution_in` value) is deferred to the renderer seam
/// (`// CPP-PARITY: cropdistortnode.cpp` value()).
fn value(
&self,
core: &NodeCore,
inputs: &crate::value::NodeValueRow,
time: oakcore_rs::Rational,
table: &mut crate::value::NodeValueTable,
) {
let tex = match inputs.get(TEXTURE_INPUT) {
Some(tex @ crate::value::NodeValue::Texture(_)) => tex.clone(),
_ => return,
};
let left = match inputs.get(LEFT_INPUT) {
Some(v) => v.to_double(),
None => core.value_at_time(LEFT_INPUT, -1, time).to_double(),
};
let right = match inputs.get(RIGHT_INPUT) {
Some(v) => v.to_double(),
None => core.value_at_time(RIGHT_INPUT, -1, time).to_double(),
};
let top = match inputs.get(TOP_INPUT) {
Some(v) => v.to_double(),
None => core.value_at_time(TOP_INPUT, -1, time).to_double(),
};
let bottom = match inputs.get(BOTTOM_INPUT) {
Some(v) => v.to_double(),
None => core.value_at_time(BOTTOM_INPUT, -1, time).to_double(),
};
if left != 0.0 || right != 0.0 || top != 0.0 || bottom != 0.0 {
table.push(
crate::value::ValueType::Texture,
crate::value::NodeValue::Texture(crate::handle::CHandle::null()),
None,
);
} else {
table.push(crate::value::ValueType::Texture, tex, None);
}
}
/// Shader code request (C++ `get_shader_code()`): ignores the
/// request id and always returns the crop fragment shader.
fn shader_code(&self, _request: &str) -> Option<String> {
Some(Self::shader_frag().to_string())
}
/// Gizmo positions (C++ `update_gizmo_positions()`): with a texture,
/// caches the resolution in `temp_resolution`, converts the four 0..1
/// crop values to pixel points (left/top straight, right/bottom as
/// `1.0 - value`), places the eight edge/corner point gizmos (center
/// handles at the midpoints) and the rectangle polygon gizmo.
///
/// The pixel points need the texture's virtual resolution (the Rust
/// texture handle carries no params), the `temp_resolution` cache
/// needs `&mut self` (the trait hands out `&self`), and the gizmo
/// point positions have no storage in [`Gizmo`] — so the update is
/// not representable here (`// CPP-PARITY: cropdistortnode.cpp`
/// `update_gizmo_positions`).
fn gizmo_update(&self, core: &NodeCore, row: &crate::value::NodeValueRow) {
let _ = (core, row);
}
/// Gizmo drag (C++ `gizmo_drag_move()`): normalizes the mouse delta
/// by `temp_resolution`, then for each dragger of the current gizmo
/// adds the delta to its drag-start value with a per-input sign
/// (left `+x`, top `+y`, right `-x`, bottom `-y`).
///
/// The draggers hold per-drag start values and write keyframe tracks,
/// neither of which the Rust data model carries — not representable
/// here (`// CPP-PARITY: cropdistortnode.cpp` `gizmo_drag_move`).
fn gizmo_drag(&mut self, core: &mut NodeCore, start: bool, x: f64, y: f64, modifiers: u32) {
let _ = (core, start, x, y, modifiers);
}
/// Deep copy (C++ `copy()`).
fn duplicate(&self, _core: &NodeCore) -> Option<Box<dyn NodeBehavior>> {
Some(Box::new(CropDistortNode {
point_gizmo: self.point_gizmo.clone(),
poly_gizmo: self.poly_gizmo.clone(),
temp_resolution: self.temp_resolution,
}))
}
}
/// Constructor (C++ `CropDistortNode::CropDistortNode()`): adds
/// `tex_in`; adds the four crop side inputs via
/// `create_crop_side_input()` (float, default 0.0, min 0.0, max 1.0,
/// percentage view); adds `feather_in` (float, default 0.0, min 0.0);
/// creates the rectangle polygon gizmo and the eight point gizmos bound
/// to their crop inputs; sets the video-effect flag and the effect
/// input.
pub fn create() -> (NodeCore, Box<dyn NodeBehavior>) {
let mut core = NodeCore::new();
let mut tex = crate::input::Input::new(
TEXTURE_INPUT,
crate::value::ValueType::Texture,
crate::value::NodeValue::None,
);
tex.flags |= crate::input::flags::NOT_KEYFRAMABLE;
core.add_input(tex);
create_crop_side_input(&mut core, LEFT_INPUT);
create_crop_side_input(&mut core, TOP_INPUT);
create_crop_side_input(&mut core, RIGHT_INPUT);
create_crop_side_input(&mut core, BOTTOM_INPUT);
let mut feather = crate::input::Input::new(
FEATHER_INPUT,
crate::value::ValueType::Float,
crate::value::NodeValue::Float(0.0),
);
feather.properties = vec![("min".to_string(), crate::value::NodeValue::Float(0.0))];
core.add_input(feather);
// Gizmos, in C++ add order: the rectangle polygon gizmo first, then
// the eight edge/corner point gizmos in `k_gizmo_scale_*` order. Each
// gizmo records the crop inputs it drags (float inputs, one track).
let poly_gizmo = Gizmo {
position_inputs: vec![
(LEFT_INPUT.to_string(), -1, 0),
(TOP_INPUT.to_string(), -1, 0),
(RIGHT_INPUT.to_string(), -1, 0),
(BOTTOM_INPUT.to_string(), -1, 0),
],
drag_point: (0.0, 0.0),
};
let tl = Gizmo {
position_inputs: vec![(LEFT_INPUT.to_string(), -1, 0), (TOP_INPUT.to_string(), -1, 0)],
drag_point: (0.0, 0.0),
};
let tc = Gizmo {
position_inputs: vec![(TOP_INPUT.to_string(), -1, 0)],
drag_point: (0.0, 0.0),
};
let tr = Gizmo {
position_inputs: vec![(RIGHT_INPUT.to_string(), -1, 0), (TOP_INPUT.to_string(), -1, 0)],
drag_point: (0.0, 0.0),
};
let bl = Gizmo {
position_inputs: vec![(LEFT_INPUT.to_string(), -1, 0), (BOTTOM_INPUT.to_string(), -1, 0)],
drag_point: (0.0, 0.0),
};
let bc = Gizmo {
position_inputs: vec![(BOTTOM_INPUT.to_string(), -1, 0)],
drag_point: (0.0, 0.0),
};
let br = Gizmo {
position_inputs: vec![(RIGHT_INPUT.to_string(), -1, 0), (BOTTOM_INPUT.to_string(), -1, 0)],
drag_point: (0.0, 0.0),
};
let cl = Gizmo {
position_inputs: vec![(LEFT_INPUT.to_string(), -1, 0)],
drag_point: (0.0, 0.0),
};
let cr = Gizmo {
position_inputs: vec![(RIGHT_INPUT.to_string(), -1, 0)],
drag_point: (0.0, 0.0),
};
core.gizmos = vec![
poly_gizmo.clone(),
tl.clone(),
tc.clone(),
tr.clone(),
bl.clone(),
bc.clone(),
br.clone(),
cl.clone(),
cr.clone(),
];
core.flags |= crate::node::flags::VIDEO_EFFECT;
core.effect_input = TEXTURE_INPUT.to_string();
(
core,
Box::new(CropDistortNode {
point_gizmo: [tl, tc, tr, bl, bc, br, cl, cr],
poly_gizmo,
temp_resolution: (0.0, 0.0),
}),
)
}
/// Helper mirroring the C++ `create_crop_side_input()`: a float input
/// with default 0.0 and `min = 0.0`, `max = 1.0`, `view = percentage`
/// properties.
fn create_crop_side_input(core: &mut NodeCore, id: &str) {
let mut input = crate::input::Input::new(
id,
crate::value::ValueType::Float,
crate::value::NodeValue::Float(0.0),
);
input.properties = vec![
("min".to_string(), crate::value::NodeValue::Float(0.0)),
("max".to_string(), crate::value::NodeValue::Float(1.0)),
("view".to_string(), crate::value::NodeValue::Text("percentage".into())),
];
core.add_input(input);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::node::NodeBehavior;
use crate::value::{NodeValue, NodeValueTable, ValueType};
use oakcore_rs::Rational;
fn tex() -> NodeValue {
NodeValue::Texture(crate::handle::CHandle::null())
}
fn empty_gizmo() -> Gizmo {
Gizmo {
position_inputs: vec![],
drag_point: (0.0, 0.0),
}
}
#[test]
fn input_names() {
let n = CropDistortNode {
point_gizmo: std::array::from_fn(|_| empty_gizmo()),
poly_gizmo: empty_gizmo(),
temp_resolution: (0.0, 0.0),
};
assert_eq!(n.input_name(TEXTURE_INPUT), "Texture");
assert_eq!(n.input_name(LEFT_INPUT), "Left");
assert_eq!(n.input_name(TOP_INPUT), "Top");
assert_eq!(n.input_name(RIGHT_INPUT), "Right");
assert_eq!(n.input_name(BOTTOM_INPUT), "Bottom");
assert_eq!(n.input_name(FEATHER_INPUT), "Feather");
}
#[test]
fn create_wires_inputs_and_flags() {
let (core, behavior) = create();
assert_eq!(behavior.type_id(), "org.olivevideoeditor.Olive.crop");
assert_eq!(
core.get_input(LEFT_INPUT).unwrap().default,
NodeValue::Float(0.0)
);
assert_eq!(
core.get_input(BOTTOM_INPUT).unwrap().default,
NodeValue::Float(0.0)
);
assert_eq!(
core.get_input(FEATHER_INPUT).unwrap().default,
NodeValue::Float(0.0)
);
// Nine gizmos: one polygon + eight points.
assert_eq!(core.gizmos.len(), 9);
assert_eq!(core.effect_input, TEXTURE_INPUT);
assert_ne!(core.flags & crate::node::flags::VIDEO_EFFECT, 0);
}
#[test]
fn value_no_texture_pushes_nothing() {
let (core, behavior) = create();
let mut table = NodeValueTable::default();
behavior.value(
&core,
&crate::value::NodeValueRow::default(),
Rational::new(0, 1),
&mut table,
);
assert!(table.is_empty());
}
#[test]
fn value_all_zero_crops_passes_texture_through() {
let (mut core, behavior) = create();
core.set_standard_value(LEFT_INPUT, -1, NodeValue::Float(0.0));
core.set_standard_value(TOP_INPUT, -1, NodeValue::Float(0.0));
core.set_standard_value(RIGHT_INPUT, -1, NodeValue::Float(0.0));
core.set_standard_value(BOTTOM_INPUT, -1, NodeValue::Float(0.0));
let tex = tex();
let inputs = crate::value::NodeValueRow::from([(TEXTURE_INPUT.to_string(), tex.clone())]);
let mut table = NodeValueTable::default();
behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
assert_eq!(table.get(ValueType::Texture), Some(&tex));
}
#[test]
fn value_any_crop_pushes_deferred_job() {
let (mut core, behavior) = create();
core.set_standard_value(LEFT_INPUT, -1, NodeValue::Float(0.25));
let inputs = crate::value::NodeValueRow::from([(TEXTURE_INPUT.to_string(), tex())]);
let mut table = NodeValueTable::default();
behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
assert!(table.get(ValueType::Texture).is_some());
}
#[test]
fn shader_code_returns_crop_shader() {
let (_, behavior) = create();
let code = behavior.shader_code("anything").unwrap();
assert!(code.contains("uniform float feather_in;"));
assert!(code.contains("multiplier *= left_adjustment;"));
}
#[test]
fn duplicate_clones() {
let (core, behavior) = create();
let dup = behavior.duplicate(&core).unwrap();
assert_eq!(dup.name(), "Crop");
}
}
/// Register this node type (C++ factory entry for
/// `org.olivevideoeditor.Olive.crop`).
pub fn register(meta: &mut Vec<NodeMeta>) {
meta.push(NodeMeta {
type_id: "org.olivevideoeditor.Olive.crop",
name: "Crop",
categories: &[Category::Distort],
create,
});
}
+318
View File
@@ -0,0 +1,318 @@
// 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/>.
//! Despill effect (C++ `src/node/src/keying/despill/despill.{h,cpp}`,
//! `olive::DespillNode`).
use crate::factory::NodeMeta;
use crate::node::{Category, NodeBehavior, NodeCore};
/// Texture input id (C++ `k_texture_input`). Type: texture; flags:
/// not-keyframable; this is the node's effect input.
pub const TEXTURE_INPUT: &str = "tex_in";
/// Key color input id (C++ `k_color_input`). Type: combo; default `0`;
/// combo strings: "Green", "Blue" (set in `retranslate()`).
pub const COLOR_INPUT: &str = "color_in";
/// Despill method input id (C++ `k_method_input`). Type: combo;
/// default `0`; combo strings: "Average", "Double Red Average",
/// "Double Average", "Limit" (set in `retranslate()`).
pub const METHOD_INPUT: &str = "method_in";
/// Preserve-luminance toggle input id (C++
/// `k_preserve_luminance_input`; note the value ends in `_input`, not
/// `_in`). Type: boolean; default `false`.
pub const PRESERVE_LUMINANCE_INPUT: &str = "preserve_luminance_input";
/// Despill node: removes green/blue screen spill from the keyed
/// foreground using one of several channel-averaging methods. The C++
/// class has no own members.
pub struct DespillNode;
/// Fragment shader (C++ loads the `:/shaders/despill.frag` resource in
/// `get_shader_code`). Text copied verbatim from
/// `engine/shaders/despill.frag`. The `luma_coeffs` uniform is not a
/// node input — it is injected into the shader job by `value()`.
const SHADER_FRAG: &str = r#"uniform sampler2D tex_in;
uniform int color_in;
uniform int method_in;
uniform bool preserve_luminance_input;
uniform vec3 luma_coeffs;
in vec2 ove_texcoord;
out vec4 frag_color;
#define AVERAGE 0
#define DOUBLE_RED_AVERAGE 1
#define DOUBLE_AVERAGE 2
#define BLUE_LIMIT 3
void main(void) {
vec4 original_col = texture(tex_in, ove_texcoord);
vec4 tex_col = original_col;
float color_average = 0.0;
if(color_in == 0) { // Green screen
switch (method_in) {
case AVERAGE:
color_average = dot(tex_col.rb, vec2(0.5)); // (tex_col.r + tex_col.b) / 2.0
tex_col.g = tex_col.g > color_average ? color_average: tex_col.g;
break;
case DOUBLE_RED_AVERAGE:
color_average = dot(tex_col.rb, vec2(2.0, 1.0) / 3.0); // (2.0 * tex_col.r + tex_col.b) / 3.0
tex_col.g = tex_col.g > color_average ? color_average : tex_col.g;
break;
case DOUBLE_AVERAGE:
color_average = dot(tex_col.br, vec2(2.0, 1.0) / 3.0); // (2.0 * tex_col.b + tex_col.r) / 3.0
tex_col.g = tex_col.g > color_average ? color_average : tex_col.g;
break;
case BLUE_LIMIT:
tex_col.g = tex_col.g > tex_col.b ? tex_col.b : tex_col.g;
break;
}
} else { // Blue screen
switch (method_in) {
case AVERAGE:
color_average = dot(tex_col.rg, vec2(0.5)); // (tex_col.r + tex_col.g) / 2.0
tex_col.b = tex_col.b > color_average ? color_average : tex_col.b;
break;
case DOUBLE_RED_AVERAGE:
color_average = dot(tex_col.rg, vec2(2.0, 1.0) / 3.0); // (2.0 * tex_col.r + tex_col.g) / 3.0
tex_col.b = tex_col.b > color_average ? color_average : tex_col.b;
break;
case DOUBLE_AVERAGE:
color_average = dot(tex_col.gr, vec2(2.0, 1.0) / 3.0); // (2.0 * tex_col.g+ tex_col.r) / 3.0
tex_col.b = tex_col.b > color_average ? color_average : tex_col.b;
break;
case BLUE_LIMIT:
tex_col.b = tex_col.b > tex_col.g ? tex_col.g : tex_col.b;
break;
}
}
if (preserve_luminance_input) {
vec4 diff = original_col - tex_col;
float luma = dot(abs(diff.rgb), luma_coeffs);
tex_col.rgb += vec3(luma);
}
frag_color = tex_col;
}
"#;
impl DespillNode {
/// Fragment shader (C++ `get_shader_code()`; the request is
/// ignored — there is a single shader).
fn shader_frag() -> &'static str {
SHADER_FRAG
}
}
impl NodeBehavior for DespillNode {
/// Human-readable name (C++ `name()`).
fn name(&self) -> &str {
"Despill"
}
/// Stable type id (C++ `id()`).
fn type_id(&self) -> &str {
"org.olivevideoeditor.Olive.despill"
}
/// Categories (C++ `category()`).
fn categories(&self) -> &[Category] {
&[Category::Keying]
}
/// Description (C++ `description()`).
fn description(&self) -> &str {
"Selection of simple despill operations"
}
/// Localized input names (C++ `retranslate()`): `tex_in` ->
/// "Input", `color_in` -> "Key Color" (combo strings
/// "Green"/"Blue"), `method_in` -> "Method" (combo strings
/// "Average"/"Double Red Average"/"Double Average"/"Limit"),
/// `preserve_luminance_input` -> "Preserve Luminance".
fn input_name<'a>(&self, id: &'a str) -> &'a str {
match id {
TEXTURE_INPUT => "Input",
COLOR_INPUT => "Key Color",
METHOD_INPUT => "Method",
PRESERVE_LUMINANCE_INPUT => "Preserve Luminance",
_ => id,
}
}
/// Evaluate outputs (C++ `value()`): builds a `ShaderJob` from
/// the whole input row, then inserts a `luma_coeffs` vec3 taken
/// from the project's color manager default luma coefficients
/// (falling back to Rec.709 `0.2126/0.7152/0.0722` when there is
/// no project or color manager); pushes the job only when
/// `tex_in` holds a texture.
fn value(
&self,
core: &NodeCore,
inputs: &crate::value::NodeValueRow,
time: oakcore_rs::Rational,
table: &mut crate::value::NodeValueTable,
) {
let _ = (core, time);
match inputs.get(TEXTURE_INPUT) {
Some(crate::value::NodeValue::Texture(_)) => {}
_ => return,
}
// `// CPP-PARITY: despill.cpp` `value()` — the C++ inserts
// `luma_coeffs` (Rec. 709 {0.2126, 0.7152, 0.0722}, or the project
// color manager's default luma coefficients when one is attached —
// the Rust model has no project/manager access, so the fallback
// always applies) into a ShaderJob over the whole input row and
// pushes `tex->to_job(job)`. The Rust model has no shader-job
// payload: the renderer seam resolves the deferred job from this
// null handle.
table.push(
crate::value::ValueType::Texture,
crate::value::NodeValue::Texture(crate::handle::CHandle::null()),
None,
);
}
/// Shader code request (C++ `get_shader_code()`): returns the
/// single fragment shader regardless of the request id.
fn shader_code(&self, _request: &str) -> Option<String> {
Some(SHADER_FRAG.to_string())
}
/// Deep copy (C++ `copy()` via `NODE_DEFAULT_FUNCTIONS`).
fn duplicate(&self, _core: &NodeCore) -> Option<Box<dyn NodeBehavior>> {
Some(Box::new(DespillNode))
}
}
/// Constructor (C++ `DespillNode::DespillNode()`): adds `tex_in`,
/// `color_in`, `method_in`, and `preserve_luminance_input` with the
/// defaults documented on the constants, sets the video-effect flag,
/// and makes `tex_in` the effect input.
pub fn create() -> (NodeCore, Box<dyn NodeBehavior>) {
let mut core = NodeCore::new();
let mut tex = crate::input::Input::new(
TEXTURE_INPUT,
crate::value::ValueType::Texture,
crate::value::NodeValue::None,
);
tex.flags |= crate::input::flags::NOT_KEYFRAMABLE;
core.add_input(tex);
core.add_input(crate::input::Input::new(
COLOR_INPUT,
crate::value::ValueType::Combo,
crate::value::NodeValue::Combo(0),
));
core.add_input(crate::input::Input::new(
METHOD_INPUT,
crate::value::ValueType::Combo,
crate::value::NodeValue::Combo(0),
));
core.add_input(crate::input::Input::new(
PRESERVE_LUMINANCE_INPUT,
crate::value::ValueType::Boolean,
crate::value::NodeValue::Boolean(false),
));
core.flags |= crate::node::flags::VIDEO_EFFECT;
core.effect_input = TEXTURE_INPUT.to_string();
(core, Box::new(DespillNode))
}
/// Register this node type (C++ factory entry for
/// `org.olivevideoeditor.Olive.despill`).
pub fn register(meta: &mut Vec<NodeMeta>) {
meta.push(NodeMeta {
type_id: "org.olivevideoeditor.Olive.despill",
name: "Despill",
categories: &[Category::Keying],
create,
});
}
#[cfg(test)]
mod tests {
use super::*;
use crate::value::{NodeValue, NodeValueTable, ValueType};
use oakcore_rs::Rational;
#[test]
fn input_names() {
let n = DespillNode;
assert_eq!(n.input_name(TEXTURE_INPUT), "Input");
assert_eq!(n.input_name(COLOR_INPUT), "Key Color");
assert_eq!(n.input_name(METHOD_INPUT), "Method");
assert_eq!(n.input_name(PRESERVE_LUMINANCE_INPUT), "Preserve Luminance");
assert_eq!(n.input_name("other_in"), "other_in");
}
#[test]
fn create_wires_inputs_flags_and_defaults() {
let (core, behavior) = create();
assert_eq!(behavior.type_id(), "org.olivevideoeditor.Olive.despill");
assert_ne!(core.get_input(TEXTURE_INPUT).unwrap().flags & crate::input::flags::NOT_KEYFRAMABLE, 0);
assert_eq!(core.get_input(COLOR_INPUT).unwrap().default, NodeValue::Combo(0));
assert_eq!(core.get_input(METHOD_INPUT).unwrap().default, NodeValue::Combo(0));
assert_eq!(
core.get_input(PRESERVE_LUMINANCE_INPUT).unwrap().default,
NodeValue::Boolean(false)
);
assert_eq!(core.effect_input, TEXTURE_INPUT);
assert_ne!(core.flags & crate::node::flags::VIDEO_EFFECT, 0);
}
#[test]
fn shader_code_returns_despill_frag() {
let code = DespillNode.shader_code("anything").unwrap();
assert!(code.contains("color_average = dot(tex_col.rb, vec2(0.5));"));
}
#[test]
fn value_no_texture_pushes_nothing() {
let (core, behavior) = create();
let mut table = NodeValueTable::default();
behavior.value(&core, &crate::value::NodeValueRow::default(), Rational::new(0, 1), &mut table);
assert!(table.is_empty());
}
#[test]
fn value_with_texture_pushes_deferred_shader_job() {
let (core, behavior) = create();
let inputs = crate::value::NodeValueRow::from([(
TEXTURE_INPUT.to_string(),
NodeValue::Texture(crate::handle::CHandle::null()),
)]);
let mut table = NodeValueTable::default();
behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
assert!(table.get(ValueType::Texture).is_some());
}
#[test]
fn duplicate_clones() {
let (core, behavior) = create();
let dup = behavior.duplicate(&core).unwrap();
assert_eq!(dup.name(), "Despill");
}
}
@@ -0,0 +1,395 @@
// 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/>.
//! OCIO display transform node (C++
//! `src/node/src/color/displaytransform/displaytransform.{h,cpp}`,
//! `olive::DisplayTransformNode`).
//!
//! Note: OpenColorIO itself is never linked here; it is reached through
//! the color manager (`crate::colormanager`) and the oakrender bridge
//! (`crate::bridge::render`), like the C++ node's
//! `oaknode_colormanager_*` / `oakrender_color_processor_*` calls.
use crate::factory::NodeMeta;
use crate::node::{Category, NodeBehavior, NodeCore};
use crate::nodes::ociobase::OcioBase;
/// Display combo input id (C++ `k_display_input`). Type: combo; default
/// `0`; flags: not-keyframable, not-connectable. Combo strings are the
/// color manager's available displays (refreshed on config change).
pub const DISPLAY_INPUT: &str = "display_in";
/// View combo input id (C++ `k_view_input`). Type: combo; default `0`;
/// flags: not-keyframable, not-connectable. Combo strings are the views
/// available for the selected display.
pub const VIEW_INPUT: &str = "view_in";
/// Direction combo input id (C++ `k_direction_input`). Type: combo;
/// default `0` (forward); flags: not-keyframable, not-connectable.
/// Combo strings (set in `retranslate`): "Forward", "Inverse".
pub const DIRECTION_INPUT: &str = "dir_in";
/// Display transform node. Converts an image to or from a display color
/// space via an OCIO display/view transform. Owns no members beyond the
/// embedded OCIO base state (C++ has no own private members).
pub struct DisplayTransformNode {
/// Shared OCIO base state (C++ base class `OCIOBaseNode`).
base: OcioBase,
}
impl DisplayTransformNode {
/// Selected display name (C++ `get_display()`): the display combo
/// index mapped through the color manager's display list; empty
/// string when no manager is attached or the index is out of range.
fn get_display(&self, core: &NodeCore) -> String {
// The C++ reads the display combo index through
// `manager()->list_available_displays()`. The Rust model has no
// attached color manager (the manager lives per-project behind
// the oakrender bridge, absent here), so the C++ guard
// `if (manager())` fails and the empty string is returned.
// `// CPP-PARITY: displaytransform.cpp` get_display.
let _ = core;
String::new()
}
/// Selected view name (C++ `get_view()`): the view combo index
/// mapped through the manager's views for [`Self::get_display`];
/// empty when unavailable.
fn get_view(&self, core: &NodeCore) -> String {
// See [`Self::get_display`]: no manager is ever attached in the
// Rust model, so the empty string is returned.
// `// CPP-PARITY: displaytransform.cpp` get_view.
let _ = core;
String::new()
}
/// Transform direction (C++ `get_direction()`): the direction combo
/// value cast to `ColorProcessor::Direction` (`0` = normal/forward,
/// `1` = inverse).
fn get_direction(&self, core: &NodeCore) -> i64 {
core.standard_value(DIRECTION_INPUT, -1).to_double() as i64
}
/// Refresh the display combo strings from the manager (C++
/// `update_displays()`); no-op without a manager.
fn update_displays(&mut self, core: &mut NodeCore) {
let _ = (self, core);
// The C++ sets the display combo strings from
// `manager()->list_available_displays()`; without a manager
// (Rust model: no bridge) this is a no-op.
// `// CPP-PARITY: displaytransform.cpp` update_displays.
}
/// Refresh the view combo strings for the current display (C++
/// `update_views()`); no-op without a manager.
fn update_views(&mut self, core: &mut NodeCore) {
let _ = (self, core);
// See [`Self::update_displays`]: no-op without a manager.
// `// CPP-PARITY: displaytransform.cpp` update_views.
}
/// (Re)build the color processor (C++ `generate_processor()`): wraps
/// the manager, builds a display transform for
/// display/view/reference-space with the selected direction via
/// `oakrender_color_processor_create_transform`, and stores it with
/// [`OcioBase::set_processor`].
fn generate_processor(&mut self, core: &mut NodeCore) {
let _ = core;
// The C++ wraps the color manager, builds a display transform
// (`oakcommon_colortransform_init_display`) for the selected
// display/view, resolves the reference color space and creates
// the processor via `oakrender_color_processor_create_transform`,
// storing it with OcioBase::set_processor. Without a manager (the
// Rust model reaches the manager through the oakrender bridge,
// absent here) the C++ guard `if (manager())` fails, so this is a
// no-op and the processor stays empty — [`OcioBase::value`] then
// passes the input texture through unchanged.
// `// CPP-PARITY: displaytransform.cpp` generate_processor.
}
/// OCIO config change hook (C++ `config_changed()` override):
/// refreshes displays and views, then regenerates the processor.
fn config_changed(&mut self, core: &mut NodeCore) {
self.update_displays(core);
self.update_views(core);
self.generate_processor(core);
}
}
impl NodeBehavior for DisplayTransformNode {
/// Human-readable name (C++ `name()`).
fn name(&self) -> &str {
"Display Transform"
}
/// Stable type id (C++ `id()`).
fn type_id(&self) -> &str {
"org.olivevideoeditor.Olive.displaytransform"
}
/// Categories (C++ `category()`).
fn categories(&self) -> &[Category] {
&[Category::Color]
}
/// Description (C++ `description()`).
fn description(&self) -> &str {
"Converts an image to or from a display color space."
}
/// Localized input names (C++ `retranslate()`): `tex_in` -> "Input",
/// `display_in` -> "Display", `view_in` -> "View", `dir_in` ->
/// "Direction" (also sets the direction combo strings
/// "Forward"/"Inverse").
fn input_name<'a>(&self, id: &'a str) -> &'a str {
match id {
crate::nodes::ociobase::TEXTURE_INPUT => "Input",
DISPLAY_INPUT => "Display",
VIEW_INPUT => "View",
DIRECTION_INPUT => "Direction",
_ => id,
}
}
/// Input value changed (C++ `InputValueChangedEvent`): for
/// `display_in`, `view_in` or `dir_in` regenerates the processor;
/// a `display_in` change additionally refreshes the view combo.
fn input_value_changed(&mut self, core: &mut NodeCore, input: &str, element: i32) {
let _ = element;
if input == DISPLAY_INPUT || input == VIEW_INPUT || input == DIRECTION_INPUT {
if input == DISPLAY_INPUT {
self.update_views(core);
}
self.generate_processor(core);
}
}
/// Evaluate outputs: inherited from the C++ base
/// (`OCIOBaseNode::value()`), i.e. delegates to
/// [`OcioBase::value`] — color-transform job when the processor is
/// ready, pass-through otherwise.
fn value(
&self,
core: &NodeCore,
inputs: &crate::value::NodeValueRow,
time: oakcore_rs::Rational,
table: &mut crate::value::NodeValueTable,
) {
self.base.value(core, inputs, time, table);
}
/// Added to a graph (C++ base `AddedToGraphEvent`): captures the
/// project's color manager and runs `config_changed()` via
/// [`OcioBase::added_to_graph`].
fn added_to_graph(&mut self, core: &mut NodeCore) {
self.base.added_to_graph(core);
self.config_changed(core);
}
/// Removed from a graph (C++ base `RemovedFromGraphEvent`): clears
/// the color manager pointer via [`OcioBase::removed_from_graph`].
fn removed_from_graph(&mut self, core: &mut NodeCore) {
self.base.removed_from_graph(core);
}
/// Deep copy (C++ `copy()` via `NODE_DEFAULT_FUNCTIONS`).
fn duplicate(&self, _core: &NodeCore) -> Option<Box<dyn NodeBehavior>> {
// The C++ copy constructor copies the embedded OCIO base state;
// a fresh base with no processor is the safe Rust port (the
// processor is never populated without the render bridge).
Some(Box::new(DisplayTransformNode {
base: OcioBase::new(),
}))
}
}
/// Constructor (C++ `DisplayTransformNode::DisplayTransformNode()`):
/// builds the base (`tex_in` texture input, effect input, video-effect
/// flag) and adds the `display_in`/`view_in`/`dir_in` combo inputs with
/// the defaults and flags documented on the constants.
pub fn create() -> (NodeCore, Box<dyn NodeBehavior>) {
let mut core = NodeCore::new();
// OCIOBaseNode base constructor.
let mut tex = crate::input::Input::new(
crate::nodes::ociobase::TEXTURE_INPUT,
crate::value::ValueType::Texture,
crate::value::NodeValue::None,
);
tex.flags |= crate::input::flags::NOT_KEYFRAMABLE;
core.add_input(tex);
core.effect_input = crate::nodes::ociobase::TEXTURE_INPUT.to_string();
core.flags |= crate::node::flags::VIDEO_EFFECT;
for id in [DISPLAY_INPUT, VIEW_INPUT, DIRECTION_INPUT] {
let mut combo = crate::input::Input::new(
id,
crate::value::ValueType::Combo,
crate::value::NodeValue::Combo(0),
);
combo.flags |= crate::input::flags::NOT_KEYFRAMABLE | crate::input::flags::NOT_CONNECTABLE;
core.add_input(combo);
}
(core, Box::new(DisplayTransformNode {
base: OcioBase::new(),
}))
}
/// Register this node type (C++ factory entry for
/// `org.olivevideoeditor.Olive.displaytransform`).
pub fn register(meta: &mut Vec<NodeMeta>) {
meta.push(NodeMeta {
type_id: "org.olivevideoeditor.Olive.displaytransform",
name: "Display Transform",
categories: &[Category::Color],
create,
});
}
#[cfg(test)]
mod tests {
use super::*;
use crate::value::{NodeValue, NodeValueTable, ValueType};
use oakcore_rs::Rational;
fn node() -> DisplayTransformNode {
DisplayTransformNode {
base: OcioBase::new(),
}
}
#[test]
fn input_names() {
let n = node();
assert_eq!(n.input_name(crate::nodes::ociobase::TEXTURE_INPUT), "Input");
assert_eq!(n.input_name(DISPLAY_INPUT), "Display");
assert_eq!(n.input_name(VIEW_INPUT), "View");
assert_eq!(n.input_name(DIRECTION_INPUT), "Direction");
assert_eq!(n.input_name("other_in"), "other_in");
}
#[test]
fn create_wires_inputs_flags_and_properties() {
let (core, behavior) = create();
assert_eq!(behavior.type_id(), "org.olivevideoeditor.Olive.displaytransform");
assert_ne!(
core.get_input(crate::nodes::ociobase::TEXTURE_INPUT).unwrap().flags & crate::input::flags::NOT_KEYFRAMABLE,
0
);
for id in [DISPLAY_INPUT, VIEW_INPUT, DIRECTION_INPUT] {
let input = core.get_input(id).unwrap();
assert_eq!(input.default, NodeValue::Combo(0));
assert_ne!(input.flags & crate::input::flags::NOT_KEYFRAMABLE, 0);
assert_ne!(input.flags & crate::input::flags::NOT_CONNECTABLE, 0);
}
assert_eq!(core.effect_input, crate::nodes::ociobase::TEXTURE_INPUT);
assert_ne!(core.flags & crate::node::flags::VIDEO_EFFECT, 0);
}
#[test]
fn get_direction_reads_combo_value() {
let mut core = NodeCore::new();
let n = node();
assert_eq!(n.get_direction(&core), 0);
core.set_standard_value(DIRECTION_INPUT, -1, NodeValue::Combo(1));
assert_eq!(n.get_direction(&core), 1);
}
#[test]
fn get_display_and_view_empty_without_manager() {
// No color manager is ever attached in the Rust model, so the
// C++ `if (manager())` guard fails and both return empty strings.
let core = NodeCore::new();
let n = node();
assert_eq!(n.get_display(&core), "");
assert_eq!(n.get_view(&core), "");
}
#[test]
fn value_no_texture_pushes_nothing() {
let (core, behavior) = create();
let mut table = NodeValueTable::default();
behavior.value(&core, &crate::value::NodeValueRow::default(), Rational::new(0, 1), &mut table);
assert!(table.is_empty());
}
#[test]
fn value_passes_texture_through_without_processor() {
let core = NodeCore::new();
let n = node();
let tex = NodeValue::Texture(crate::handle::CHandle::null());
let inputs = crate::value::NodeValueRow::from([(
crate::nodes::ociobase::TEXTURE_INPUT.to_string(),
tex.clone(),
)]);
let mut table = NodeValueTable::default();
n.value(&core, &inputs, Rational::new(0, 1), &mut table);
assert_eq!(table.get(ValueType::Texture), Some(&tex));
}
#[test]
fn value_pushes_deferred_job_with_processor() {
let core = NodeCore::new();
let mut n = node();
n.base.set_processor(Some(crate::handle::CHandle::null()));
let inputs = crate::value::NodeValueRow::from([(
crate::nodes::ociobase::TEXTURE_INPUT.to_string(),
NodeValue::Texture(crate::handle::CHandle::null()),
)]);
let mut table = NodeValueTable::default();
n.value(&core, &inputs, Rational::new(0, 1), &mut table);
assert!(table.get(ValueType::Texture).is_some());
}
#[test]
fn input_value_changed_regenerates_processor_and_views() {
let mut core = NodeCore::new();
let mut n = node();
// display_in change refreshes views + regenerates (all no-ops
// without a manager; must not panic).
n.input_value_changed(&mut core, DISPLAY_INPUT, 0);
n.input_value_changed(&mut core, VIEW_INPUT, 0);
n.input_value_changed(&mut core, DIRECTION_INPUT, 0);
// Other inputs are ignored.
n.input_value_changed(&mut core, crate::nodes::ociobase::TEXTURE_INPUT, 0);
assert!(n.base.processor().is_none());
}
#[test]
fn duplicate_clones() {
let (core, behavior) = create();
let dup = behavior.duplicate(&core).unwrap();
assert_eq!(dup.name(), "Display Transform");
}
#[test]
fn config_and_graph_hooks_are_safe_noops() {
let mut core = NodeCore::new();
let mut n = node();
// Without a manager all refresh/regenerate helpers are no-ops;
// they must run without panicking and leave the processor empty.
n.update_displays(&mut core);
n.update_views(&mut core);
n.generate_processor(&mut core);
n.config_changed(&mut core);
assert!(n.base.processor().is_none());
n.added_to_graph(&mut core);
n.removed_from_graph(&mut core);
assert!(n.base.processor().is_none());
}
}
@@ -0,0 +1,414 @@
// 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/>.
//! Drop shadow filter (C++
//! `src/node/src/filter/dropshadow/dropshadowfilter.{h,cpp}`,
//! `olive::DropShadowFilter`).
use crate::factory::NodeMeta;
use crate::node::{Category, NodeBehavior, NodeCore};
/// Texture input id (C++ `k_texture_input`). Type: texture; flags:
/// not-keyframable; this is the node's effect input.
pub const TEXTURE_INPUT: &str = "tex_in";
/// Shadow color input id (C++ `k_color_input`). Type: color; default
/// black `(0.0, 0.0, 0.0)`.
pub const COLOR_INPUT: &str = "color_in";
/// Shadow distance input id (C++ `k_distance_input`). Type: float;
/// default `10.0`.
pub const DISTANCE_INPUT: &str = "distance_in";
/// Shadow angle input id (C++ `k_angle_input`). Type: float; default
/// `135.0`.
pub const ANGLE_INPUT: &str = "angle_in";
/// Shadow softness input id (C++ `k_softness_input`). Type: float;
/// default `10.0`; properties: `min = 0.0`.
pub const SOFTNESS_INPUT: &str = "radius_in";
/// Shadow opacity input id (C++ `k_opacity_input`). Type: float;
/// default `1.0`; properties: `min = 0.0`, `view = percentage`.
pub const OPACITY_INPUT: &str = "opacity_in";
/// Fast/low-quality toggle input id (C++ `k_fast_input`). Type: bool;
/// default `false`.
pub const FAST_INPUT: &str = "fast_in";
/// Drop shadow filter node. Adds a colored, blurred, offset copy of the
/// input's alpha behind the image. The C++ class declares no own member
/// fields.
pub struct DropShadowFilter;
/// Fragment shader (C++ `get_shader_code()` loads
/// `:/shaders/dropshadow.frag` via FileFunctions for any request). Text
/// copied verbatim from `engine/shaders/dropshadow.frag`.
const SHADER_FRAG: &str = r#"uniform sampler2D tex_in;
uniform vec4 color_in;
uniform float distance_in;
uniform float angle_in;
uniform float radius_in;
uniform float opacity_in;
uniform vec2 resolution_in;
uniform sampler2D previous_iteration_in;
uniform bool fast_in;
uniform int ove_iteration;
in vec2 ove_texcoord;
out vec4 frag_color;
// Gaussian function uses PI
#define M_PI 3.1415926535897932384626433832795
// Single gaussian formula (unused, mainly here for documentation/just in case)
//float gaussian(float x, float sigma) {
// return (1.0/(sigma*sqrt(2.0*M_PI)))*exp(-0.5*pow(x/sigma, 2.0));
//}
// Double gaussian formula, actually used in the code below
// Should be faster than the single gaussian above since it doesn't need sqrt()
float gaussian2(float x, float y, float sigma) {
return (1.0/((sigma*sigma)*2.0*M_PI))*exp(-0.5*(((x*x) + (y*y))/(sigma*sigma)));
}
void main(void) {
if (ove_iteration == 2 || radius_in == 0.0) {
// Merge step
vec4 composite = texture(tex_in, ove_texcoord);
if (composite.a < 1.0) {
// Convert degrees to radians
float shadow_angle = ((angle_in + 90.0)*M_PI)/180.0;
vec2 shadow_offset = vec2(cos(shadow_angle) * distance_in, sin(shadow_angle) * distance_in);
shadow_offset /= resolution_in;
shadow_offset += ove_texcoord;
vec4 shadow_color = texture(previous_iteration_in, shadow_offset);
shadow_color.rgb = color_in.rgb * shadow_color.a;
shadow_color *= 1.0 - composite.a;
shadow_color *= opacity_in;
composite += shadow_color;
}
frag_color = composite;
} else {
// We only sample on hard pixels, so we don't accept decimal radii
float real_radius = ceil(radius_in);
vec4 composite = vec4(0.0);
float divider, sigma;
if (fast_in) {
// Calculate the weight of each pixel based on the radius
divider = 1.0 / real_radius;
} else {
// Using (radius = 3 * sigma) because 3 standard deviations covers 97% of the blur according to this document:
// http://chemaguerra.com/gaussian-filter-radius/
sigma = real_radius;
real_radius *= 3.0;
// Use gaussian formula to calculate the weight of all pixels
divider = 0.0;
for (float i = -real_radius + 0.5; i <= real_radius; i += 2.0) {
divider += gaussian2(i, 0.0, sigma);
}
}
for (float i = -real_radius + 0.5; i <= real_radius; i += 2.0) {
float weight;
if (fast_in) {
weight = divider;
} else {
weight = gaussian2(i, 0.0, sigma) / divider;
}
vec2 pixel_coord = ove_texcoord;
vec4 tex_col;
if (ove_iteration == 0) {
pixel_coord.x += i / resolution_in.x;
// Pull from main texture
tex_col = texture(tex_in, pixel_coord);
} else if (ove_iteration == 1) {
pixel_coord.y += i / resolution_in.y;
// Pull from previous iteration
tex_col = texture(previous_iteration_in, pixel_coord);
}
composite += tex_col * weight;
}
frag_color = composite;
}
}
"#;
impl DropShadowFilter {
/// Fragment shader for any request (C++ `get_shader_code()` ignores
/// the request id and always returns `dropshadow.frag`).
fn shader_frag() -> &'static str {
SHADER_FRAG
}
}
impl NodeBehavior for DropShadowFilter {
/// Human-readable name (C++ `name()`).
fn name(&self) -> &str {
"Drop Shadow"
}
/// Stable type id (C++ `id()`).
fn type_id(&self) -> &str {
"org.olivevideoeditor.Olive.dropshadow"
}
/// Categories (C++ `category()`).
fn categories(&self) -> &[Category] {
&[Category::Filter]
}
/// Description (C++ `description()`).
fn description(&self) -> &str {
"Adds a drop shadow to an image."
}
/// Localized input names (C++ `retranslate()`): `tex_in` ->
/// "Texture", `color_in` -> "Color", `distance_in` -> "Distance",
/// `angle_in` -> "Angle", `radius_in` -> "Softness", `opacity_in` ->
/// "Opacity", `fast_in` -> "Faster (Lower Quality)".
fn input_name<'a>(&self, id: &'a str) -> &'a str {
match id {
TEXTURE_INPUT => "Texture",
COLOR_INPUT => "Color",
DISTANCE_INPUT => "Distance",
ANGLE_INPUT => "Angle",
// The C++ softness input id is `radius_in` (k_softness_input).
SOFTNESS_INPUT => "Softness",
OPACITY_INPUT => "Opacity",
FAST_INPUT => "Faster (Lower Quality)",
_ => id,
}
}
/// Evaluate outputs (C++ `value()`): no texture -> push nothing;
/// otherwise push a shader job with `resolution_in` set to the
/// texture's virtual resolution and `previous_iteration_in` bound to
/// the input texture; when softness is non-zero the job runs 3
/// iterations feeding back through `previous_iteration_in`.
///
/// The Rust model has no shader-job payload: the job (including the
/// `resolution_in` / `previous_iteration_in` bindings and the 3-iteration
/// feedback when softness != 0) is deferred to the renderer seam
/// (`// CPP-PARITY: dropshadowfilter.cpp` value()).
fn value(
&self,
core: &NodeCore,
inputs: &crate::value::NodeValueRow,
time: oakcore_rs::Rational,
table: &mut crate::value::NodeValueTable,
) {
if !matches!(inputs.get(TEXTURE_INPUT), Some(crate::value::NodeValue::Texture(_))) {
return;
}
let _ = (core, time, inputs);
table.push(
crate::value::ValueType::Texture,
crate::value::NodeValue::Texture(crate::handle::CHandle::null()),
None,
);
}
/// Shader code request (C++ `get_shader_code()`): the request id is
/// ignored; always returns the dropshadow fragment shader.
fn shader_code(&self, _request: &str) -> Option<String> {
Some(Self::shader_frag().to_string())
}
/// Deep copy (C++ `copy()`).
fn duplicate(&self, _core: &NodeCore) -> Option<Box<dyn NodeBehavior>> {
Some(Box::new(DropShadowFilter))
}
}
/// Constructor (C++ `DropShadowFilter::DropShadowFilter()`): adds
/// `tex_in`, `color_in`, `distance_in`, `angle_in`, `radius_in`
/// (softness), `opacity_in`, `fast_in` with the defaults and properties
/// documented on the constants, then sets the effect input and the
/// video-effect flag.
pub fn create() -> (NodeCore, Box<dyn NodeBehavior>) {
let mut core = NodeCore::new();
let mut tex = crate::input::Input::new(
TEXTURE_INPUT,
crate::value::ValueType::Texture,
crate::value::NodeValue::None,
);
tex.flags |= crate::input::flags::NOT_KEYFRAMABLE;
core.add_input(tex);
let color = crate::input::Input::new(
COLOR_INPUT,
crate::value::ValueType::Color,
crate::value::NodeValue::Color([0.0, 0.0, 0.0, 1.0]),
);
core.add_input(color);
core.add_input(crate::input::Input::new(
DISTANCE_INPUT,
crate::value::ValueType::Float,
crate::value::NodeValue::Float(10.0),
));
core.add_input(crate::input::Input::new(
ANGLE_INPUT,
crate::value::ValueType::Float,
crate::value::NodeValue::Float(135.0),
));
let mut softness = crate::input::Input::new(
SOFTNESS_INPUT,
crate::value::ValueType::Float,
crate::value::NodeValue::Float(10.0),
);
softness.properties = vec![("min".to_string(), crate::value::NodeValue::Float(0.0))];
core.add_input(softness);
let mut opacity = crate::input::Input::new(
OPACITY_INPUT,
crate::value::ValueType::Float,
crate::value::NodeValue::Float(1.0),
);
opacity.properties = vec![
("min".to_string(), crate::value::NodeValue::Float(0.0)),
("view".to_string(), crate::value::NodeValue::Text("percentage".into())),
];
core.add_input(opacity);
core.add_input(crate::input::Input::new(
FAST_INPUT,
crate::value::ValueType::Boolean,
crate::value::NodeValue::Boolean(false),
));
core.flags |= crate::node::flags::VIDEO_EFFECT;
core.effect_input = TEXTURE_INPUT.to_string();
(core, Box::new(DropShadowFilter))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::node::NodeBehavior;
use crate::value::{NodeValue, NodeValueTable, ValueType};
use oakcore_rs::Rational;
fn tex() -> NodeValue {
NodeValue::Texture(crate::handle::CHandle::null())
}
#[test]
fn input_names() {
let n = DropShadowFilter;
assert_eq!(n.input_name(TEXTURE_INPUT), "Texture");
assert_eq!(n.input_name(COLOR_INPUT), "Color");
assert_eq!(n.input_name(DISTANCE_INPUT), "Distance");
assert_eq!(n.input_name(ANGLE_INPUT), "Angle");
assert_eq!(n.input_name(SOFTNESS_INPUT), "Softness");
assert_eq!(n.input_name(OPACITY_INPUT), "Opacity");
assert_eq!(n.input_name(FAST_INPUT), "Faster (Lower Quality)");
}
#[test]
fn create_wires_inputs_and_flags() {
let (core, behavior) = create();
assert_eq!(behavior.type_id(), "org.olivevideoeditor.Olive.dropshadow");
assert_eq!(
core.get_input(COLOR_INPUT).unwrap().default,
NodeValue::Color([0.0, 0.0, 0.0, 1.0])
);
assert_eq!(
core.get_input(DISTANCE_INPUT).unwrap().default,
NodeValue::Float(10.0)
);
assert_eq!(
core.get_input(ANGLE_INPUT).unwrap().default,
NodeValue::Float(135.0)
);
assert_eq!(core.effect_input, TEXTURE_INPUT);
assert_ne!(core.flags & crate::node::flags::VIDEO_EFFECT, 0);
}
#[test]
fn value_no_texture_pushes_nothing() {
let (core, behavior) = create();
let mut table = NodeValueTable::default();
behavior.value(
&core,
&crate::value::NodeValueRow::default(),
Rational::new(0, 1),
&mut table,
);
assert!(table.is_empty());
}
#[test]
fn value_with_texture_pushes_deferred_job() {
let (core, behavior) = create();
let inputs = crate::value::NodeValueRow::from([(TEXTURE_INPUT.to_string(), tex())]);
let mut table = NodeValueTable::default();
behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
assert!(table.get(ValueType::Texture).is_some());
}
#[test]
fn shader_code_returns_dropshadow_shader() {
let n = DropShadowFilter;
let code = n.shader_code("anything").unwrap();
assert!(code.contains("uniform float distance_in;"));
assert!(code.contains("shadow_color.rgb = color_in.rgb * shadow_color.a;"));
}
#[test]
fn duplicate_clones() {
let (core, behavior) = create();
let dup = behavior.duplicate(&core).unwrap();
assert_eq!(dup.name(), "Drop Shadow");
}
}
/// Register this node type (C++ `k_drop_shadow_filter` in
/// `factory.cpp::create_from_factory_index`).
pub fn register(meta: &mut Vec<NodeMeta>) {
meta.push(NodeMeta {
type_id: "org.olivevideoeditor.Olive.dropshadow",
name: "Drop Shadow",
categories: &[Category::Filter],
create,
});
}
+296
View File
@@ -0,0 +1,296 @@
// 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/>.
//! Flip distort effect (C++
//! `src/node/src/distort/flip/flipdistortnode.{h,cpp}`,
//! `olive::FlipDistortNode`).
use crate::factory::NodeMeta;
use crate::node::{Category, NodeBehavior, NodeCore};
/// Texture input id (C++ `k_texture_input`). Type: texture; flags:
/// not-keyframable; this is the node's effect input.
pub const TEXTURE_INPUT: &str = "tex_in";
/// Horizontal flip input id (C++ `k_horizontal_input`). Type: bool;
/// default `false`.
pub const HORIZONTAL_INPUT: &str = "horiz_in";
/// Vertical flip input id (C++ `k_vertical_input`). Type: bool;
/// default `false`.
pub const VERTICAL_INPUT: &str = "vert_in";
/// Flip distort node. Mirrors the image horizontally and/or vertically.
/// Has no own member fields in C++ (state lives in the `Node` inputs).
pub struct FlipDistortNode;
/// Fragment shader (C++ loads the `:/shaders/flip.frag` resource in
/// `get_shader_code`). Text copied verbatim from
/// `engine/shaders/flip.frag`.
const SHADER_FRAG: &str = r#"uniform sampler2D tex_in;
uniform bool horiz_in;
uniform bool vert_in;
in vec2 ove_texcoord;
out vec4 frag_color;
void main(void) {
if (!horiz_in && !vert_in) {
frag_color = texture(tex_in, ove_texcoord);
return;
}
vec2 new_coord = ove_texcoord;
if (horiz_in) new_coord.x = 1.0 - new_coord.x;
if (vert_in) new_coord.y = 1.0 - new_coord.y;
frag_color = texture(tex_in, new_coord);
}
"#;
impl FlipDistortNode {
/// Fragment shader (C++ `get_shader_code()`; the request id is
/// ignored, this is the only shader).
fn shader_frag() -> &'static str {
SHADER_FRAG
}
}
impl NodeBehavior for FlipDistortNode {
/// Human-readable name (C++ `name()`).
fn name(&self) -> &str {
"Flip"
}
/// Stable type id (C++ `id()`).
fn type_id(&self) -> &str {
"org.olivevideoeditor.Olive.flip"
}
/// Categories (C++ `category()`).
fn categories(&self) -> &[Category] {
&[Category::Distort]
}
/// Description (C++ `description()`).
fn description(&self) -> &str {
"Flips an image horizontally or vertically"
}
/// Localized input names (C++ `retranslate()`): `tex_in` ->
/// "Input", `horiz_in` -> "Horizontal", `vert_in` -> "Vertical".
fn input_name<'a>(&self, id: &'a str) -> &'a str {
match id {
TEXTURE_INPUT => "Input",
HORIZONTAL_INPUT => "Horizontal",
VERTICAL_INPUT => "Vertical",
_ => id,
}
}
/// Evaluate outputs (C++ `value()`): no texture -> push nothing;
/// either flip flag set -> shader job over the whole value row;
/// neither set -> pass-through push of the input texture unchanged.
fn value(
&self,
core: &NodeCore,
inputs: &crate::value::NodeValueRow,
time: oakcore_rs::Rational,
table: &mut crate::value::NodeValueTable,
) {
let tex = match inputs.get(TEXTURE_INPUT) {
Some(tex @ crate::value::NodeValue::Texture(_)) => tex.clone(),
_ => return,
};
let horiz = match inputs.get(HORIZONTAL_INPUT) {
Some(v) => v.to_double() != 0.0,
None => core.value_at_time(HORIZONTAL_INPUT, -1, time).to_double() != 0.0,
};
let vert = match inputs.get(VERTICAL_INPUT) {
Some(v) => v.to_double() != 0.0,
None => core.value_at_time(VERTICAL_INPUT, -1, time).to_double() != 0.0,
};
if horiz || vert {
// C++ pushes `tex->to_job(ShaderJob(value))` (the whole row as
// job values); the Rust model defers the job to the renderer
// seam, so a null handle marks "renderer must produce this
// texture" (`// CPP-PARITY: flipdistortnode.cpp` value()).
table.push(
crate::value::ValueType::Texture,
crate::value::NodeValue::Texture(crate::handle::CHandle::null()),
None,
);
} else {
table.push(crate::value::ValueType::Texture, tex, None);
}
}
/// Shader code request (C++ `get_shader_code()`): ignores the
/// request id and always returns the flip fragment shader.
fn shader_code(&self, _request: &str) -> Option<String> {
Some(Self::shader_frag().to_string())
}
/// Deep copy (C++ `copy()`).
fn duplicate(&self, _core: &NodeCore) -> Option<Box<dyn NodeBehavior>> {
Some(Box::new(FlipDistortNode))
}
}
/// Constructor (C++ `FlipDistortNode::FlipDistortNode()`): adds
/// `tex_in`, `horiz_in` and `vert_in` with the defaults and flags
/// documented on the constants, sets the video-effect flag and the
/// effect input.
pub fn create() -> (NodeCore, Box<dyn NodeBehavior>) {
let mut core = NodeCore::new();
let mut tex = crate::input::Input::new(
TEXTURE_INPUT,
crate::value::ValueType::Texture,
crate::value::NodeValue::None,
);
tex.flags |= crate::input::flags::NOT_KEYFRAMABLE;
core.add_input(tex);
core.add_input(crate::input::Input::new(
HORIZONTAL_INPUT,
crate::value::ValueType::Boolean,
crate::value::NodeValue::Boolean(false),
));
core.add_input(crate::input::Input::new(
VERTICAL_INPUT,
crate::value::ValueType::Boolean,
crate::value::NodeValue::Boolean(false),
));
core.flags |= crate::node::flags::VIDEO_EFFECT;
core.effect_input = TEXTURE_INPUT.to_string();
(core, Box::new(FlipDistortNode))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::node::NodeBehavior;
use crate::value::{NodeValue, NodeValueTable, ValueType};
use oakcore_rs::Rational;
fn tex() -> NodeValue {
NodeValue::Texture(crate::handle::CHandle::null())
}
#[test]
fn input_names() {
let n = FlipDistortNode;
assert_eq!(n.input_name(TEXTURE_INPUT), "Input");
assert_eq!(n.input_name(HORIZONTAL_INPUT), "Horizontal");
assert_eq!(n.input_name(VERTICAL_INPUT), "Vertical");
assert_eq!(n.input_name("other_in"), "other_in");
}
#[test]
fn create_wires_inputs_and_flags() {
let (core, behavior) = create();
assert_eq!(behavior.type_id(), "org.olivevideoeditor.Olive.flip");
assert_eq!(
core.get_input(HORIZONTAL_INPUT).unwrap().default,
NodeValue::Boolean(false)
);
assert_eq!(
core.get_input(VERTICAL_INPUT).unwrap().default,
NodeValue::Boolean(false)
);
assert_eq!(core.effect_input, TEXTURE_INPUT);
assert_ne!(core.flags & crate::node::flags::VIDEO_EFFECT, 0);
}
#[test]
fn value_no_texture_pushes_nothing() {
let (core, behavior) = create();
let mut table = NodeValueTable::default();
behavior.value(
&core,
&crate::value::NodeValueRow::default(),
Rational::new(0, 1),
&mut table,
);
assert!(table.is_empty());
}
#[test]
fn value_no_flip_passes_texture_through() {
let (mut core, behavior) = create();
core.set_standard_value(HORIZONTAL_INPUT, -1, NodeValue::Boolean(false));
core.set_standard_value(VERTICAL_INPUT, -1, NodeValue::Boolean(false));
let tex = tex();
let inputs = crate::value::NodeValueRow::from([(TEXTURE_INPUT.to_string(), tex.clone())]);
let mut table = NodeValueTable::default();
behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
assert_eq!(table.get(ValueType::Texture), Some(&tex));
}
#[test]
fn value_flip_pushes_deferred_job() {
let (mut core, behavior) = create();
core.set_standard_value(VERTICAL_INPUT, -1, NodeValue::Boolean(true));
let inputs = crate::value::NodeValueRow::from([(TEXTURE_INPUT.to_string(), tex())]);
let mut table = NodeValueTable::default();
behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
assert!(table.get(ValueType::Texture).is_some());
}
#[test]
fn value_connected_horizontal_flip_pushes_deferred_job() {
let (core, behavior) = create();
let inputs = crate::value::NodeValueRow::from([
(TEXTURE_INPUT.to_string(), tex()),
(HORIZONTAL_INPUT.to_string(), NodeValue::Boolean(true)),
]);
let mut table = NodeValueTable::default();
behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
assert!(table.get(ValueType::Texture).is_some());
}
#[test]
fn shader_code_returns_flip_shader() {
let n = FlipDistortNode;
let code = n.shader_code("anything").unwrap();
assert!(code.contains("uniform sampler2D tex_in;"));
assert!(code.contains("if (horiz_in) new_coord.x = 1.0 - new_coord.x;"));
}
#[test]
fn duplicate_clones() {
let (core, behavior) = create();
let dup = behavior.duplicate(&core).unwrap();
assert_eq!(dup.name(), "Flip");
}
}
/// Register this node type (C++ factory entry for
/// `org.olivevideoeditor.Olive.flip`).
pub fn register(meta: &mut Vec<NodeMeta>) {
meta.push(NodeMeta {
type_id: "org.olivevideoeditor.Olive.flip",
name: "Flip",
categories: &[Category::Distort],
create,
});
}
@@ -0,0 +1,152 @@
// 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/>.
//! Shared generator-with-merge base (C++
//! `src/node/src/generator/shape/generatorwithmerge.{h,cpp}`,
//! `olive::GeneratorWithMerge`).
//!
//! Abstract C++ base for generators that can merge their output over a
//! base texture. Not instantiable, so this is a helper module, not a
//! [`NodeBehavior`] implementation.
use crate::value::NodeValue;
/// Base texture input id (C++ `k_base_input`). Type: texture; flags:
/// not-keyframable; the base constructor makes it the effect input and
/// sets the video-effect flag.
pub const BASE_INPUT: &str = "base_in";
/// Merge fragment shader (C++ `get_shader_code()` `"mrg"` branch loads
/// `:/shaders/alphaover.frag`). Text copied verbatim from
/// `engine/shaders/alphaover.frag`.
const MERGE_SHADER_FRAG: &str = r#"uniform sampler2D base_in;
uniform sampler2D blend_in;
uniform bool base_in_enabled;
uniform bool blend_in_enabled;
in vec2 ove_texcoord;
out vec4 frag_color;
void main(void) {
vec4 base_col = texture(base_in, ove_texcoord);
vec4 blend_col = texture(blend_in, ove_texcoord);
if (!base_in_enabled && !blend_in_enabled) {
frag_color = vec4(0.0);
return;
}
if (!base_in_enabled) {
frag_color = blend_col;
return;
}
if (!blend_in_enabled) {
frag_color = base_col;
return;
}
base_col *= 1.0 - blend_col.a;
base_col += blend_col;
frag_color = base_col;
}
"#;
/// Merge fragment shader for the `"mrg"` shader request (C++
/// `GeneratorWithMerge::get_shader_code()` `"mrg"` branch; any other
/// request returns an empty `ShaderCode`).
pub fn merge_shader_frag() -> &'static str {
MERGE_SHADER_FRAG
}
/// Helper mirroring the C++ `GeneratorWithMerge` base. The base has no
/// own member fields; its constructor adds [`BASE_INPUT`], makes it the
/// effect input and sets the video-effect flag, and its `retranslate()`
/// names `base_in` "Base".
pub struct GeneratorWithMerge;
impl GeneratorWithMerge {
/// Push a generated texture job, merged over the base input when
/// one is connected (C++ `push_mergable_job()`): with a base
/// texture, builds a `"mrg"` shader job with the base input as
/// `MergeNode::k_base_in` and the generated job as
/// `MergeNode::k_blend_in`, pushing `base->to_job(merge)`; without
/// a base, pushes the generated job unchanged.
///
/// The Rust model has no shader-job payload (see
/// [`crate::nodes::mathbase`]): the merged case pushes a null
/// texture handle marking a renderer-deferred `"mrg"` shader job,
/// and the un-merged case pushes `job` itself.
pub fn push_mergable_job(
inputs: &crate::value::NodeValueRow,
job: crate::bridge::render::TextureHandle,
table: &mut crate::value::NodeValueTable,
) {
match inputs.get(BASE_INPUT) {
Some(NodeValue::Texture(_)) => {
// A base is connected: the C++ pushes
// `base->to_job(ShaderJob("mrg"))` — a deferred alpha-over
// merge of the generated texture over the base.
// `// CPP-PARITY: generatorwithmerge.cpp` push_mergable_job.
table.push(
crate::value::ValueType::Texture,
NodeValue::Texture(crate::handle::CHandle::null()),
None,
);
}
_ => {
table.push(crate::value::ValueType::Texture, NodeValue::Texture(job), None);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::value::{NodeValue, NodeValueTable, ValueType};
#[test]
fn push_job_without_base_pushes_job() {
let job = crate::handle::CHandle::null();
let mut table = NodeValueTable::default();
GeneratorWithMerge::push_mergable_job(
&crate::value::NodeValueRow::default(),
job,
&mut table,
);
match table.get(ValueType::Texture) {
Some(NodeValue::Texture(h)) => assert!(h.is_null()),
_ => panic!("texture expected"),
}
}
#[test]
fn push_job_with_base_pushes_deferred_merge() {
let job = crate::handle::CHandle::null();
let inputs = crate::value::NodeValueRow::from([(
BASE_INPUT.to_string(),
NodeValue::Texture(crate::handle::CHandle::null()),
)]);
let mut table = NodeValueTable::default();
GeneratorWithMerge::push_mergable_job(&inputs, job, &mut table);
assert!(
table.get(ValueType::Texture).is_some(),
"merge job placeholder pushed"
);
}
}
+339
View File
@@ -0,0 +1,339 @@
// 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 group (C++ `src/node/src/group/group.{h,cpp}`,
//! `olive::NodeGroup`).
//!
//! A group wraps an inner node context and exposes selected inner
//! inputs as its own ("passthroughs"). The C++ class also declares the
//! undo commands `NodeGroupAddInputPassthrough` /
//! `NodeGroupSetOutputPassthrough`; the C ABI's undoable variants
//! (`include/node/group.h`) build equivalent vtable commands through
//! `bridge::undo` in the ffi layer.
use crate::factory::NodeMeta;
use crate::graph::Graph;
use crate::id::NodeId;
use crate::node::{Category, NodeBehavior, NodeCore};
/// Reference to an input of a node inside the group's context
/// (C++ `NodeInput`: node pointer + input id + array element).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct InnerInput {
/// The inner node this input belongs to.
pub node: NodeId,
/// Input id on the inner node.
pub input: String,
/// Array element index (-1/0 for non-array inputs).
pub element: i32,
}
/// One passthrough entry (C++ `NodeGroup::InputPassthrough`):
/// the group's own input id mapped to the inner input it mirrors.
pub type InputPassthrough = (String, InnerInput);
/// Node group node. Exposes inner-node inputs as its own inputs and
/// forwards its output from a designated inner node.
///
/// The C++ `output_passthrough_` is a raw `Node*`; here it is the
/// inner node's [`NodeId`] (`None` = unset). Note: the brief for this
/// port mentioned `connected_render_output`, but the actual C++
/// `NodeGroup` does not override it — only the methods below are
/// overridden.
#[derive(Clone)]
pub struct NodeGroup {
/// Passthrough table: group input id -> inner input
/// (C++ `input_passthroughs_`).
input_passthroughs: Vec<InputPassthrough>,
/// Inner node whose output this group forwards
/// (C++ `output_passthrough_`).
output_passthrough: Option<NodeId>,
}
impl NodeGroup {
/// Add (or find) a passthrough for an inner input (C++
/// `add_input_passthrough()`): returns the existing id if the
/// input is already passed through; otherwise mints a fresh id
/// (`input.input`, suffixed `_2`, `_3`, ... until unique, or
/// `force_id` when given), adds a group input with the inner
/// input's type/default/flags, and records the mapping.
///
/// `descriptor` is the inner input's [`crate::input::Input`] (the
/// C++ reads it off the inner node directly; the caller resolves
/// it so the group input can be added while the group entry is
/// mutably borrowed).
pub fn add_input_passthrough(
&mut self,
core: &mut NodeCore,
input: InnerInput,
force_id: &str,
descriptor: &crate::input::Input,
) -> String {
for (id, inner) in &self.input_passthroughs {
if inner == &input {
return id.clone();
}
}
// Mint the passthrough id.
let id = if force_id.is_empty() {
let mut id = input.input.clone();
let mut i = 2;
while core.has_input(&id) {
id = format!("{}_{}", input.input, i);
i += 1;
}
id
} else {
force_id.to_string()
};
core.add_input(crate::input::Input {
id: id.clone(),
value_type: descriptor.value_type,
default: descriptor.default.clone(),
flags: descriptor.flags,
display_name: id.clone(),
properties: Vec::new(),
array_size: 0,
});
self.input_passthroughs.push((id.clone(), input));
id
}
/// Remove the passthrough (and the group input) for an inner
/// input (C++ `remove_input_passthrough()`); no-op if absent.
pub fn remove_input_passthrough(&mut self, core: &mut NodeCore, input: &InnerInput) {
if let Some(i) = self.input_passthroughs.iter().position(|(_, inner)| inner == input) {
let id = self.input_passthroughs.remove(i).0;
core.remove_input(&id);
}
}
/// The inner output node (C++ `get_output_passthrough()`).
pub fn output_passthrough(&self) -> Option<NodeId> {
self.output_passthrough
}
/// Set the inner output node (C++ `set_output_passthrough()`); the
/// C++ asserts the node belongs to the group's context (debug-only,
/// not enforced here).
pub fn set_output_passthrough(&mut self, node: Option<NodeId>) {
self.output_passthrough = node;
}
/// Whether an inner input is already passed through (C++
/// `contains_input_passthrough()`).
pub fn contains_input_passthrough(&self, input: &InnerInput) -> bool {
self.input_passthroughs.iter().any(|(_, inner)| inner == input)
}
/// Group input id for an inner input, or empty (C++
/// `get_id_of_passthrough()`).
pub fn id_of_passthrough(&self, input: &InnerInput) -> &str {
for (id, inner) in &self.input_passthroughs {
if inner == input {
return id;
}
}
""
}
/// Inner input behind a group input id, if any (C++
/// `get_input_from_id()`).
pub fn input_from_id(&self, id: &str) -> Option<&InnerInput> {
self.input_passthroughs
.iter()
.find(|(pid, _)| pid == id)
.map(|(_, inner)| inner)
}
/// The passthrough entries (C++ `get_input_passthroughs()`).
pub fn passthroughs(&self) -> &[InputPassthrough] {
&self.input_passthroughs
}
/// Fully resolve a group input to the innermost non-group input
/// (C++ `resolve_input()`): loops [`Self::get_inner`] until the
/// input no longer refers to a group passthrough.
pub fn resolve_input(graph: &Graph, input: InnerInput) -> InnerInput {
let mut input = input;
while Self::get_inner(graph, &mut input) {}
input
}
/// One resolution step (C++ `get_inner()`): if `input` points at a
/// group node's passthrough input, rewrite it to the mapped inner
/// input and return true; false otherwise.
pub fn get_inner(graph: &Graph, input: &mut InnerInput) -> bool {
let inner = graph
.get(input.node)
.and_then(|e| e.behavior.as_any())
.and_then(|a| a.downcast_ref::<NodeGroup>())
.and_then(|g| g.input_from_id(&input.input));
match inner {
Some(inner) => {
input.node = inner.node;
input.input = inner.input.clone();
true
}
None => false,
}
}
}
impl NodeBehavior for NodeGroup {
/// Human-readable name (C++ `name()`).
fn name(&self) -> &str {
"Group"
}
/// Stable type id (C++ `id()`).
fn type_id(&self) -> &str {
"org.olivevideoeditor.Olive.group"
}
/// Categories (C++ `category()` returns `{k_category_unknown}` =
/// -1, which has no `Category` enum counterpart; modeled as an
/// empty slice — the group is hidden from the create menu via
/// the dont-show-in-create-menu flag set in [`create`]).
fn categories(&self) -> &[Category] {
&[]
}
/// Description (C++ `description()`).
fn description(&self) -> &str {
"A group of nodes that is represented as a single node."
}
/// Localized input name (C++ `get_input_name()` override): forwards
/// to the passed-through inner node's input name. The trait's
/// borrowed return cannot carry a graph-resolved name, so the
/// override relies on the default (the plain id / "Enabled"); the
/// C++ forwarding needs a future graph-capable signature.
fn input_name<'a>(&self, id: &'a str) -> &'a str {
id
}
/// Custom project save (C++ `save_custom()`). The node-reference
/// half of the C++ format (inner-node ids, output-passthrough
/// target, per-input name/flags/properties) requires a graph the
/// trait signature does not carry, so only the core-visible
/// passthrough data is written: id, input and element.
fn save_custom(&self, core: &NodeCore, writer: &mut dyn crate::serializer::XmlWrite) {
let _ = core;
writer.start_element("inputpassthroughs");
for (id, inner) in &self.input_passthroughs {
writer.start_element("inputpassthrough");
writer.attribute("input", &inner.input);
writer.attribute("element", &inner.element.to_string());
writer.attribute("id", id);
writer.end_element();
}
writer.end_element();
}
/// Custom project load (C++ `load_custom()`). Parses the
/// `inputpassthroughs` entries written by [`Self::save_custom`];
/// the C++ defers the node references into `SerializedData` and
/// resolves them in `PostLoadEvent`, a channel this crate's
/// serializer does not drive — `post_load` is therefore a no-op.
fn load_custom(&mut self, _core: &mut NodeCore, reader: &mut dyn crate::serializer::XmlRead) -> bool {
while reader.next_start_element() {
match reader.name() {
"inputpassthroughs" => {
while reader.next_start_element() {
if reader.name() == "inputpassthrough" {
let id = reader.attribute("id").unwrap_or_default();
let input = reader.attribute("input").unwrap_or_default();
let element = reader
.attribute("element")
.and_then(|e| e.parse().ok())
.unwrap_or(0);
// The inner node id is not resolvable without
// the graph channel; keep the entry with a
// placeholder node so a later graph pass can
// re-map it.
self.input_passthroughs.push((
id,
InnerInput {
node: crate::id::NodeId::INVALID,
input,
element,
},
));
}
reader.skip_current_element();
}
}
_ => reader.skip_current_element(),
}
}
true
}
/// Post-load fixup (C++ `PostLoadEvent()`). The C++ re-resolves the
/// deferred passthrough links against the now-live inner nodes and
/// restores each group's output passthrough; this crate's serializer
/// does not drive the load_custom/save_custom channel, so there is
/// nothing to fix up here yet.
fn post_load(&mut self, _core: &mut NodeCore) {}
/// Deep copy (C++ `copy()` via `NODE_DEFAULT_FUNCTIONS`); clones
/// the passthrough table and output reference too.
fn duplicate(&self, _core: &NodeCore) -> Option<Box<dyn NodeBehavior>> {
Some(Box::new(self.clone()))
}
/// Downcast (C++ `dynamic_cast<NodeGroup *>`).
fn as_any(&self) -> Option<&dyn std::any::Any> {
Some(self)
}
/// See [`NodeBehavior::as_any`].
fn as_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
Some(self)
}
}
/// Constructor (C++ `NodeGroup::NodeGroup()`): no inputs of its own
/// (passthroughs are added dynamically); initializes the output
/// passthrough to unset and sets the dont-show-in-create-menu flag
/// (`// CPP-PARITY: src/node/src/group/group.cpp:35`, flag value
/// `k_dont_show_in_create_menu = 0x8` = [`crate::input::flags::HIDDEN`]).
pub fn create() -> (NodeCore, Box<dyn NodeBehavior>) {
let mut core = NodeCore::new();
core.flags |= crate::input::flags::HIDDEN as u64;
(
core,
Box::new(NodeGroup {
input_passthroughs: Vec::new(),
output_passthrough: None,
}),
)
}
/// Register this node type (C++ factory entry for
/// `org.olivevideoeditor.Olive.group`; note C++ files it under
/// `k_category_unknown`, which is unrepresentable in [`Category`]).
pub fn register(meta: &mut Vec<NodeMeta>) {
meta.push(NodeMeta {
type_id: "org.olivevideoeditor.Olive.group",
name: "Group",
categories: &[],
create,
});
}
+553
View File
@@ -0,0 +1,553 @@
// 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/>.
//! Mask distort effect (C++ `src/node/src/distort/mask/mask.{h,cpp}`,
//! `olive::MaskDistortNode`). In C++ this derives from
//! `PolygonGenerator` (which derives from `GeneratorWithMerge`), so the
//! polygon point editing, the `base_in` merge input and the
//! `points_in`/`color_in` inputs are inherited; the base's Rust home is
//! `crate::nodes::polygon`.
use crate::factory::NodeMeta;
use crate::node::{Category, NodeBehavior, NodeCore};
/// Invert input id (C++ `k_invert_input`). Type: bool; default `false`.
pub const INVERT_INPUT: &str = "invert_in";
/// Feather input id (C++ `k_feather_input`). Type: float; default
/// `0.0`; properties: `min = 0.0`.
pub const FEATHER_INPUT: &str = "feather_in";
/// Mask distort node. Renders the inherited polygon as a white mask and
/// multiplies it over the base texture, with optional invert and
/// feather (gaussian blur of the matte). Has no own member fields in
/// C++ beyond the inherited `PolygonGenerator` state.
pub struct MaskDistortNode {
/// Inherited polygon-generator state (C++ base class
/// `PolygonGenerator`; provides `points_in`, `color_in`, the base
/// merge input `base_in` and the polygon shape shader).
pub polygon: crate::nodes::polygon::PolygonGenerator,
}
/// Merge fragment shader for the `"mrg"` shader id (C++ loads
/// `:/shaders/multiply.frag`). Text copied verbatim from
/// `engine/shaders/multiply.frag`.
const SHADER_MRG_FRAG: &str = r#"// Input texture
uniform sampler2D tex_a;
uniform sampler2D tex_b;
// Input texture coordinate
in vec2 ove_texcoord;
out vec4 frag_color;
void main() {
frag_color = texture(tex_a, ove_texcoord) * texture(tex_b, ove_texcoord);
}
"#;
/// Invert fragment shader for the `"invert"` shader id (C++ loads
/// `:/shaders/invertrgba.frag`). Text copied verbatim from
/// `engine/shaders/invertrgba.frag`.
const SHADER_INVERT_FRAG: &str = r#"// Input texture
uniform sampler2D tex_in;
// Input texture coordinate
in vec2 ove_texcoord;
out vec4 frag_color;
void main() {
vec4 color = texture(tex_in, ove_texcoord);
color = 1.0 - color;
frag_color = color;
}
"#;
/// Feather (gaussian blur) fragment shader for the `"feather"` shader
/// id (C++ loads `:/shaders/blur.frag`). Text copied verbatim from
/// `engine/shaders/blur.frag`.
const SHADER_FEATHER_FRAG: &str = r#"uniform sampler2D tex_in;
uniform int method_in;
uniform float radius_in;
uniform bool horiz_in;
uniform bool vert_in;
uniform bool repeat_edge_pixels_in;
uniform vec2 resolution_in;
// Directional
uniform float directional_degrees_in;
// Radial
uniform vec2 radial_center_in;
uniform int ove_iteration;
in vec2 ove_texcoord;
out vec4 frag_color;
// Gaussian function uses PI
#define M_PI 3.1415926535897932384626433832795
// Methods
#define METHOD_BOX_BLUR 0
#define METHOD_GAUSSIAN_BLUR 1
#define METHOD_DIRECTIONAL_BLUR 2
#define METHOD_RADIAL_BLUR 3
// Mode
#define MODE_NONE 0
#define MODE_HORIZONTAL 1
#define MODE_VERTICAL 2
// Single gaussian formula (unused, mainly here for documentation/just in case)
//float gaussian(float x, float sigma) {
// return (1.0/(sigma*sqrt(2.0*M_PI)))*exp(-0.5*pow(x/sigma, 2.0));
//}
// Double gaussian formula, actually used in the code below
// Should be faster than the single gaussian above since it doesn't need sqrt()
float gaussian2(float x, float y, float sigma) {
return (1.0/((sigma*sigma)*2.0*M_PI))*exp(-0.5*(((x*x) + (y*y))/(sigma*sigma)));
}
int determine_mode() {
if (radius_in == 0.0) {
return MODE_NONE;
}
if (!horiz_in && !vert_in) {
return MODE_NONE;
}
if (horiz_in && !vert_in) {
return MODE_HORIZONTAL;
}
if (vert_in && !horiz_in) {
return MODE_VERTICAL;
}
if (ove_iteration == 0) {
return MODE_HORIZONTAL;
}
if (ove_iteration == 1) {
return MODE_VERTICAL;
}
}
vec4 add_to_composite(vec4 composite, vec2 pixel_coord, float weight)
{
if (repeat_edge_pixels_in
|| (pixel_coord.x >= 0.0
&& pixel_coord.x < 1.0
&& pixel_coord.y >= 0.0
&& pixel_coord.y < 1.0)) {
composite += texture(tex_in, pixel_coord) * weight;
}
return composite;
}
void main(void) {
int mode = determine_mode();
if (mode == MODE_NONE) {
frag_color = texture(tex_in, ove_texcoord);
return;
}
// We only sample on hard pixels, so we don't accept decimal radii
float real_radius = ceil(radius_in);
vec4 composite = vec4(0.0);
float divider, sigma;
if (method_in == METHOD_DIRECTIONAL_BLUR || method_in == METHOD_RADIAL_BLUR) {
// Despite similar math, these are lighter methods perceptually, so we double the radius to
// better match box/gaussian
real_radius *= 2.0;
}
if (method_in == METHOD_BOX_BLUR || method_in == METHOD_DIRECTIONAL_BLUR) {
// Calculate the weight of each pixel based on the radius
divider = 1.0 / real_radius;
} else if (method_in == METHOD_GAUSSIAN_BLUR) {
// Using (radius = 3 * sigma) because 3 standard deviations covers 97% of the blur according to this document:
// http://chemaguerra.com/gaussian-filter-radius/
sigma = real_radius;
real_radius *= 3.0;
// Use gaussian formula to calculate the weight of all pixels
divider = 0.0;
for (float i = -real_radius + 0.5; i <= real_radius; i += 2.0) {
divider += gaussian2(i, 0.0, sigma);
}
}
if (method_in == METHOD_BOX_BLUR || method_in == METHOD_GAUSSIAN_BLUR) {
for (float i = -real_radius + 0.5; i <= real_radius; i += 2.0) {
float weight;
if (method_in == METHOD_BOX_BLUR) {
weight = divider;
} else if (method_in == METHOD_GAUSSIAN_BLUR) {
weight = gaussian2(i, 0.0, sigma) / divider;
}
vec2 pixel_coord = ove_texcoord;
if (mode == MODE_HORIZONTAL) {
pixel_coord.x += i / resolution_in.x;
} else if (mode == MODE_VERTICAL) {
pixel_coord.y += i / resolution_in.y;
}
composite = add_to_composite(composite, pixel_coord, weight);
}
} else if (method_in == METHOD_DIRECTIONAL_BLUR || method_in == METHOD_RADIAL_BLUR) {
float angle;
if (method_in == METHOD_DIRECTIONAL_BLUR) {
// Convert directional degrees to radians
angle = (directional_degrees_in*M_PI)/180.0;
} else {
// Calculate angle from distance of center to current coordinate
vec2 distance = (ove_texcoord - 0.5) * (resolution_in) - radial_center_in;
angle = atan(distance.y/distance.x);
float multiplier = length(distance) / resolution_in.y * 2.0;
real_radius = ceil(radius_in * multiplier);
divider = 1.0 / real_radius;
}
// Get angles
float sin_angle = sin(angle);
float cos_angle = cos(angle);
for (float i = -real_radius + 0.5; i <= real_radius; i += 2.0) {
vec2 pixel_coord = ove_texcoord;
pixel_coord.y += sin_angle * i / resolution_in.y;
pixel_coord.x += cos_angle * i / resolution_in.x;
composite = add_to_composite(composite, pixel_coord, divider);
}
}
frag_color = composite;
}
"#;
impl MaskDistortNode {
/// Merge fragment shader (C++ `get_shader_code()` `"mrg"` branch).
fn shader_mrg_frag() -> &'static str {
SHADER_MRG_FRAG
}
/// Invert fragment shader (C++ `get_shader_code()` `"invert"`
/// branch).
fn shader_invert_frag() -> &'static str {
SHADER_INVERT_FRAG
}
/// Feather blur fragment shader (C++ `get_shader_code()`
/// `"feather"` branch).
fn shader_feather_frag() -> &'static str {
SHADER_FEATHER_FRAG
}
}
impl NodeBehavior for MaskDistortNode {
/// Human-readable name (C++ `name()`).
fn name(&self) -> &str {
"Mask"
}
/// Stable type id (C++ `id()`).
fn type_id(&self) -> &str {
"org.olivevideoeditor.Olive.mask"
}
/// Categories (C++ `category()`; note: C++ files Mask under
/// distort even though it derives from a generator base).
fn categories(&self) -> &[Category] {
&[Category::Distort]
}
/// Description (C++ `description()`).
fn description(&self) -> &str {
"Apply a polygonal mask."
}
/// Localized input names (C++ `retranslate()`): the inherited
/// `base_in` -> "Texture", `invert_in` -> "Invert", `feather_in` ->
/// "Feather" (plus the `PolygonGenerator` names via its own
/// retranslate).
fn input_name<'a>(&self, id: &'a str) -> &'a str {
match id {
crate::nodes::generatorwithmerge::BASE_INPUT => "Texture",
INVERT_INPUT => "Invert",
FEATHER_INPUT => "Feather",
crate::nodes::polygon::POINTS_INPUT => "Points",
crate::nodes::polygon::COLOR_INPUT => "Color",
_ => id,
}
}
/// Evaluate outputs (C++ `value()`): generates the polygon matte
/// (via the inherited `get_generate_job`) at the base texture's
/// params or the global video params when there is no base; if
/// `invert_in` is set wraps the matte in an `"invert"` shader job;
/// with a base texture pushes an `"mrg"` multiply merge of base
/// (`tex_a`) and matte (`tex_b`) — where `feather_in` > 0.0 the
/// matte is first nested in a two-iteration gaussian `"feather"`
/// blur job (method gaussian, horiz/vert/repeat-edge true, radius =
/// feather value, `resolution_in` from the texture or the global
/// square resolution); without a base texture pushes the matte
/// itself.
fn value(
&self,
core: &NodeCore,
inputs: &crate::value::NodeValueRow,
time: oakcore_rs::Rational,
table: &mut crate::value::NodeValueTable,
) {
let _ = (core, time);
let _ = inputs;
// `// CPP-PARITY: mask.cpp` `value()` — the C++ rasterizes the
// polygon matte via the inherited `get_generate_job`, optionally
// wraps it in an `"invert"` shader job, then pushes an `"mrg"`
// multiply merge over `base_in` (nesting a two-iteration
// gaussian `"feather"` blur job when `feather_in` > 0.0) — or
// the bare matte job when there is no base texture. Every
// outcome is a renderer-deferred job in the Rust model (the
// Rust polygon base provides no generate job either), so a
// single null texture handle marks the result.
table.push(
crate::value::ValueType::Texture,
crate::value::NodeValue::Texture(crate::handle::CHandle::null()),
None,
);
}
/// Shader code request (C++ `get_shader_code()`): dispatches on the
/// request id — `"mrg"` -> multiply merge shader, `"feather"` ->
/// blur shader, `"invert"` -> invert RGBA shader, anything else ->
/// the inherited `PolygonGenerator` shader.
fn shader_code(&self, request: &str) -> Option<String> {
match request {
"mrg" => Some(SHADER_MRG_FRAG.to_string()),
"feather" => Some(SHADER_FEATHER_FRAG.to_string()),
"invert" => Some(SHADER_INVERT_FRAG.to_string()),
_ => self.polygon.shader_code(request),
}
}
/// Deep copy (C++ `copy()`).
fn duplicate(&self, _core: &NodeCore) -> Option<Box<dyn NodeBehavior>> {
Some(Box::new(MaskDistortNode {
polygon: crate::nodes::polygon::PolygonGenerator,
}))
}
}
/// Constructor (C++ `MaskDistortNode::MaskDistortNode()`): hides the
/// inherited `color_in` input (the mask is always white so the multiply
/// works), then adds `invert_in` and `feather_in` with the defaults and
/// properties documented on the constants. Note: unlike the other
/// distort nodes, the C++ constructor does NOT set the video-effect
/// flag or an effect input here — that state comes from the
/// `PolygonGenerator`/`GeneratorWithMerge` base.
pub fn create() -> (NodeCore, Box<dyn NodeBehavior>) {
let mut core = NodeCore::new();
// Inherited from the `GeneratorWithMerge` / `PolygonGenerator` base
// chain, mirrored here because the base constructors are not callable
// in the Rust model (`// CPP-PARITY: generatorwithmerge.cpp` /
// `polygon.cpp` constructors). `base_in` is the effect input and sets
// the video-effect flag.
let mut base = crate::input::Input::new(
crate::nodes::generatorwithmerge::BASE_INPUT,
crate::value::ValueType::Texture,
crate::value::NodeValue::None,
);
base.flags |= crate::input::flags::NOT_KEYFRAMABLE;
core.add_input(base);
core.effect_input = crate::nodes::generatorwithmerge::BASE_INPUT.to_string();
core.flags |= crate::node::flags::VIDEO_EFFECT;
// `points_in` is a bezier array in C++; the Rust value model has no
// bezier type, so it is declared as a Vec2 array with the default
// pentagon positions (the bezier control handles are not
// representable). The array element count matches the C++ default of
// 5 (`// CPP-PARITY: polygon.cpp` constructor).
let mut points = crate::input::Input::new(
crate::nodes::polygon::POINTS_INPUT,
crate::value::ValueType::Vec2,
crate::value::NodeValue::Vec2([0.0, 0.0]),
);
points.flags |= crate::input::flags::ARRAY;
points.array_size = 5;
core.add_input(points);
for (i, (x, y)) in [(0.0, -135.0), (135.0, -45.0), (90.0, 120.0), (-90.0, 120.0), (-135.0, -45.0)]
.iter()
.enumerate()
{
core.set_standard_value(
crate::nodes::polygon::POINTS_INPUT,
i as i32,
crate::value::NodeValue::Vec2([*x, *y]),
);
}
// Mask should always be (1.0, 1.0, 1.0) for multiply to work correctly
let mut color = crate::input::Input::new(
crate::nodes::polygon::COLOR_INPUT,
crate::value::ValueType::Color,
crate::value::NodeValue::Color([1.0, 1.0, 1.0, 1.0]),
);
color.flags |= crate::input::flags::HIDDEN;
core.add_input(color);
core.add_input(crate::input::Input::new(
INVERT_INPUT,
crate::value::ValueType::Boolean,
crate::value::NodeValue::Boolean(false),
));
let mut feather = crate::input::Input::new(
FEATHER_INPUT,
crate::value::ValueType::Float,
crate::value::NodeValue::Float(0.0),
);
feather.properties = vec![("min".to_string(), crate::value::NodeValue::Float(0.0))];
core.add_input(feather);
(core, Box::new(MaskDistortNode {
polygon: crate::nodes::polygon::PolygonGenerator,
}))
}
/// Register this node type (C++ factory entry for
/// `org.olivevideoeditor.Olive.mask`).
pub fn register(meta: &mut Vec<NodeMeta>) {
meta.push(NodeMeta {
type_id: "org.olivevideoeditor.Olive.mask",
name: "Mask",
categories: &[Category::Distort],
create,
});
}
#[cfg(test)]
mod tests {
use super::*;
use crate::value::{NodeValue, NodeValueTable, ValueType};
use oakcore_rs::Rational;
#[test]
fn input_names() {
let n = MaskDistortNode {
polygon: crate::nodes::polygon::PolygonGenerator,
};
assert_eq!(n.input_name(crate::nodes::generatorwithmerge::BASE_INPUT), "Texture");
assert_eq!(n.input_name(INVERT_INPUT), "Invert");
assert_eq!(n.input_name(FEATHER_INPUT), "Feather");
assert_eq!(n.input_name(crate::nodes::polygon::POINTS_INPUT), "Points");
assert_eq!(n.input_name(crate::nodes::polygon::COLOR_INPUT), "Color");
assert_eq!(n.input_name("other_in"), "other_in");
}
#[test]
fn create_wires_inputs_flags_and_properties() {
let (core, behavior) = create();
assert_eq!(behavior.type_id(), "org.olivevideoeditor.Olive.mask");
// Inherited base wiring.
assert_ne!(
core.get_input(crate::nodes::generatorwithmerge::BASE_INPUT).unwrap().flags & crate::input::flags::NOT_KEYFRAMABLE,
0
);
assert_eq!(core.effect_input, crate::nodes::generatorwithmerge::BASE_INPUT);
assert_ne!(core.flags & crate::node::flags::VIDEO_EFFECT, 0);
// The inherited color input is hidden (mask is always white).
let color = core.get_input(crate::nodes::polygon::COLOR_INPUT).unwrap();
assert_ne!(color.flags & crate::input::flags::HIDDEN, 0);
assert_eq!(color.default, NodeValue::Color([1.0, 1.0, 1.0, 1.0]));
// points_in: 5-element array with the default pentagon positions.
let points = core.get_input(crate::nodes::polygon::POINTS_INPUT).unwrap();
assert_ne!(points.flags & crate::input::flags::ARRAY, 0);
assert_eq!(points.array_size, 5);
assert_eq!(
core.standard_value(crate::nodes::polygon::POINTS_INPUT, 0),
NodeValue::Vec2([0.0, -135.0])
);
assert_eq!(
core.standard_value(crate::nodes::polygon::POINTS_INPUT, 4),
NodeValue::Vec2([-135.0, -45.0])
);
// Mask-specific inputs.
assert_eq!(core.get_input(INVERT_INPUT).unwrap().default, NodeValue::Boolean(false));
let feather = core.get_input(FEATHER_INPUT).unwrap();
assert_eq!(feather.default, NodeValue::Float(0.0));
assert!(feather
.properties
.iter()
.any(|(k, v)| k == "min" && *v == NodeValue::Float(0.0)));
}
#[test]
fn value_always_pushes_deferred_matte_or_merge() {
let (core, behavior) = create();
// No inputs at all: the matte is generated and pushed.
let mut table = NodeValueTable::default();
behavior.value(&core, &crate::value::NodeValueRow::default(), Rational::new(0, 1), &mut table);
assert!(table.get(ValueType::Texture).is_some());
// With a base texture: an "mrg" merge job is pushed instead.
let inputs = crate::value::NodeValueRow::from([(
crate::nodes::generatorwithmerge::BASE_INPUT.to_string(),
NodeValue::Texture(crate::handle::CHandle::null()),
)]);
let mut table = NodeValueTable::default();
behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
assert!(table.get(ValueType::Texture).is_some());
}
#[test]
fn shader_code_dispatches_on_request() {
let n = MaskDistortNode {
polygon: crate::nodes::polygon::PolygonGenerator,
};
let mrg = n.shader_code("mrg").unwrap();
assert!(mrg.contains("texture(tex_a, ove_texcoord) * texture(tex_b, ove_texcoord)"));
let feather = n.shader_code("feather").unwrap();
assert!(feather.contains("gaussian2"));
let invert = n.shader_code("invert").unwrap();
assert!(invert.contains("color = 1.0 - color;"));
}
#[test]
fn duplicate_clones() {
let (core, behavior) = create();
let dup = behavior.duplicate(&core).unwrap();
assert_eq!(dup.name(), "Mask");
}
}
+386
View File
@@ -0,0 +1,386 @@
// 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/>.
//! Math node (C++ `src/node/src/math/math/math.{h,cpp}`,
//! `olive::MathNode`); behavior shared with other binary math nodes
//! lives in [`super::mathbase`] (C++ `MathNodeBase`).
use crate::factory::NodeMeta;
use crate::node::{Category, NodeBehavior, NodeCore};
/// Operation/method input id (C++ `k_method_in`). Type: combo;
/// flags: not-connectable, not-keyframable; combo strings are the
/// operation names Add/Subtract/Multiply/Divide/Power.
pub const METHOD_INPUT: &str = "method_in";
/// Operand A input id (C++ `k_param_a_in`). Type: float; default
/// `0.0`; properties: `decimalplaces = 8`, `autotrim = true`.
pub const PARAM_A_INPUT: &str = "param_a_in";
/// Operand B input id (C++ `k_param_b_in`). Type: float; default
/// `0.0`; properties: `decimalplaces = 8`, `autotrim = true`.
pub const PARAM_B_INPUT: &str = "param_b_in";
/// Operand C input id (C++ `k_param_c_in`). Declared as a static but
/// never added in the constructor — reserved/unused upstream.
pub const PARAM_C_INPUT: &str = "param_c_in";
/// Math node: applies a binary arithmetic operation to two values.
/// Unit-like — the C++ class has no own data members (inputs live in
/// [`NodeCore`], shared logic in [`super::mathbase`]).
pub struct MathNode;
impl MathNode {
/// Construct the behavior struct (C++ `MathNode()`; the node's
/// inputs are wired in [`create`]). Exposed so other nodes can own
/// a child math node (e.g. `super::opacity` multiplies via one).
pub fn new() -> Box<MathNode> {
Box::new(MathNode)
}
/// The operation selected by the `method_in` combo (C++
/// `MathNode::get_operation()`).
pub fn operation(&self, core: &NodeCore) -> super::mathbase::Operation {
let idx = core.standard_value(METHOD_INPUT, -1).to_double() as usize;
match idx {
0 => super::mathbase::Operation::Add,
1 => super::mathbase::Operation::Subtract,
2 => super::mathbase::Operation::Multiply,
3 => super::mathbase::Operation::Divide,
_ => super::mathbase::Operation::Power,
}
}
}
impl NodeBehavior for MathNode {
/// Human-readable name (C++ `name()`): if the node is parented
/// (i.e. owned as a child of another node) and the current
/// operation has a name, returns that operation name
/// (Add/Subtract/Multiply/Divide/Power); otherwise "Math".
fn name(&self) -> &str {
"Math"
}
/// Stable type id (C++ `id()`).
fn type_id(&self) -> &str {
"org.olivevideoeditor.Olive.math"
}
/// Categories (C++ `category()`).
fn categories(&self) -> &[Category] {
&[Category::Math]
}
/// Description (C++ `description()`).
fn description(&self) -> &str {
"Perform a mathematical operation between two values."
}
/// Localized input names (C++ `retranslate()`): `method_in` ->
/// "Method", `param_a_in`/`param_b_in` -> "Value"; also sets the
/// combo strings on `method_in` to the five operation names.
fn input_name<'a>(&self, id: &'a str) -> &'a str {
match id {
METHOD_INPUT => "Method",
PARAM_A_INPUT | PARAM_B_INPUT => "Value",
_ => id,
}
}
/// Evaluate outputs (C++ `value()`): pushes both operands into
/// single-value tables, runs the [`super::mathbase::PairingCalculator`]
/// heuristic, and if a pairing was found delegates to
/// [`super::mathbase::MathNodeBase::value_internal`] with the
/// current operation; otherwise pushes nothing.
fn value(
&self,
core: &NodeCore,
inputs: &crate::value::NodeValueRow,
time: oakcore_rs::Rational,
table: &mut crate::value::NodeValueTable,
) {
let a = inputs
.get(PARAM_A_INPUT)
.cloned()
.unwrap_or_else(|| core.value_at_time(PARAM_A_INPUT, -1, time));
let b = inputs
.get(PARAM_B_INPUT)
.cloned()
.unwrap_or_else(|| core.value_at_time(PARAM_B_INPUT, -1, time));
let mut at = crate::value::NodeValueTable::default();
at.push(a.value_type(), a, None);
let mut bt = crate::value::NodeValueTable::default();
bt.push(b.value_type(), b, None);
let calc = super::mathbase::PairingCalculator::new(&at, &bt);
if calc.found_most_likely_pairing() {
super::mathbase::MathNodeBase::value_internal(
self.operation(core),
calc.most_likely_pairing,
PARAM_A_INPUT,
&calc.most_likely_value_a,
PARAM_B_INPUT,
&calc.most_likely_value_b,
core,
inputs,
table,
);
}
}
/// Process a span of samples (C++ `process_samples()`): delegates to
/// [`super::mathbase::MathNodeBase::process_samples_internal`]
/// with the current operation and the `param_a_in`/`param_b_in`
/// ids (only used for the sample*number pairing). The C++ signature
/// receives the input buffer and a sample index; the Rust trait
/// instead hands over a time `range` and the destination buffer, so
/// the samples operand is located in the row and every index of the
/// output span is filled.
fn process_samples(
&self,
core: &NodeCore,
inputs: &crate::value::NodeValueRow,
range: oakcore_rs::TimeRange,
output: &mut crate::value::SampleBuffer,
) {
let _ = range;
let input = match inputs.get(PARAM_A_INPUT) {
Some(crate::value::NodeValue::Samples(b)) => b.clone(),
_ => match inputs.get(PARAM_B_INPUT) {
Some(crate::value::NodeValue::Samples(b)) => b.clone(),
_ => return,
},
};
for index in 0..output.sample_count {
super::mathbase::MathNodeBase::process_samples_internal(
inputs,
self.operation(core),
PARAM_A_INPUT,
PARAM_B_INPUT,
&input,
output,
index,
);
}
}
/// Shader code request (C++ `get_shader_code()`): delegates to
/// [`super::mathbase::MathNodeBase::shader_code_internal`] with the
/// request id (expected `"<op>.<pairing>.<type_a>.<type_b>"`) and
/// the `param_a_in`/`param_b_in` uniform names. The C++ `ShaderCode`
/// carries a vertex shader for the texture*matrix case; the trait's
/// single-string return carries only the fragment shader
/// (`// CPP-PARITY: math.cpp` `get_shader_code`).
fn shader_code(&self, request: &str) -> Option<String> {
let (frag, _vert) =
super::mathbase::MathNodeBase::shader_code_internal(request, PARAM_A_INPUT, PARAM_B_INPUT);
if frag.is_empty() {
None
} else {
Some(frag)
}
}
/// Deep copy (C++ `copy()`).
fn duplicate(&self, _core: &NodeCore) -> Option<Box<dyn NodeBehavior>> {
Some(Box::new(MathNode))
}
}
/// Operation combo strings (C++ `MathNodeBase::retranslate` order).
pub const OPERATION_NAMES: [&str; 5] = ["Add", "Subtract", "Multiply", "Divide", "Power"];
/// Constructor (C++ `MathNode::MathNode()`): adds `method_in` as a
/// not-connectable/not-keyframable combo, and `param_a_in`/
/// `param_b_in` as float inputs defaulting to 0.0 with
/// `decimalplaces = 8` and `autotrim = true`.
pub fn create() -> (NodeCore, Box<dyn NodeBehavior>) {
let mut core = NodeCore::new();
let mut method = crate::input::Input::new(
METHOD_INPUT,
crate::value::ValueType::Combo,
crate::value::NodeValue::Combo(0),
);
method.flags |= crate::input::flags::NOT_CONNECTABLE | crate::input::flags::NOT_KEYFRAMABLE;
method.properties = vec![
(
"combobox_strings".to_string(),
crate::value::NodeValue::Binary(OPERATION_NAMES.concat().into_bytes()),
),
];
core.add_input(method);
let mut a = crate::input::Input::new(
PARAM_A_INPUT,
crate::value::ValueType::Float,
crate::value::NodeValue::Float(0.0),
);
a.properties = vec![
(
"decimalplaces".to_string(),
crate::value::NodeValue::Int(8),
),
("autotrim".to_string(), crate::value::NodeValue::Boolean(true)),
];
core.add_input(a);
let mut b = crate::input::Input::new(
PARAM_B_INPUT,
crate::value::ValueType::Float,
crate::value::NodeValue::Float(0.0),
);
b.properties = vec![
(
"decimalplaces".to_string(),
crate::value::NodeValue::Int(8),
),
("autotrim".to_string(), crate::value::NodeValue::Boolean(true)),
];
core.add_input(b);
(core, MathNode::new())
}
/// Register this node type (C++ `k_math_node` in
/// `factory.cpp::create_from_factory_index`).
pub fn register(meta: &mut Vec<NodeMeta>) {
meta.push(NodeMeta {
type_id: "org.olivevideoeditor.Olive.math",
name: "Math",
categories: &[Category::Math],
create,
});
}
#[cfg(test)]
mod tests {
use super::*;
use crate::node::NodeBehavior;
use crate::value::{NodeValue, NodeValueTable, ValueType};
use oakcore_rs::Rational;
fn row(values: &[(&str, NodeValue)]) -> crate::value::NodeValueRow {
values
.iter()
.map(|(k, v)| (k.to_string(), v.clone()))
.collect()
}
#[test]
fn value_adds_numbers() {
let (core, behavior) = create();
let inputs = row(&[
(PARAM_A_INPUT, NodeValue::Float(2.0)),
(PARAM_B_INPUT, NodeValue::Float(3.0)),
]);
let mut table = NodeValueTable::default();
behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
assert_eq!(table.get(ValueType::Float), Some(&NodeValue::Float(5.0)));
}
#[test]
fn value_uses_standard_operands_when_unconnected() {
let (mut core, behavior) = create();
core.set_standard_value(PARAM_A_INPUT, -1, NodeValue::Float(10.0));
core.set_standard_value(PARAM_B_INPUT, -1, NodeValue::Float(4.0));
let inputs = crate::value::NodeValueRow::default();
let mut table = NodeValueTable::default();
behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
assert_eq!(table.get(ValueType::Float), Some(&NodeValue::Float(14.0)));
}
#[test]
fn value_multiply_color_by_number() {
let (core, behavior) = create();
let mut core = core;
core.set_standard_value(METHOD_INPUT, -1, NodeValue::Combo(2)); // Multiply
let inputs = row(&[
(PARAM_A_INPUT, NodeValue::Color([1.0, 1.0, 1.0, 1.0])),
(PARAM_B_INPUT, NodeValue::Float(0.5)),
]);
let mut table = NodeValueTable::default();
behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
assert_eq!(
table.get(ValueType::Color),
Some(&NodeValue::Color([0.5, 0.5, 0.5, 0.5]))
);
}
#[test]
fn value_empty_operands_push_nothing() {
let (core, behavior) = create();
let inputs = row(&[
(PARAM_A_INPUT, NodeValue::None),
(PARAM_B_INPUT, NodeValue::None),
]);
let mut table = NodeValueTable::default();
behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
assert!(table.is_empty());
}
#[test]
fn operation_from_combo() {
let (mut core, _) = create();
let n = MathNode;
core.set_standard_value(METHOD_INPUT, -1, NodeValue::Combo(0));
assert_eq!(n.operation(&core), super::super::mathbase::Operation::Add);
core.set_standard_value(METHOD_INPUT, -1, NodeValue::Combo(4));
assert_eq!(n.operation(&core), super::super::mathbase::Operation::Power);
core.set_standard_value(METHOD_INPUT, -1, NodeValue::Combo(99));
assert_eq!(n.operation(&core), super::super::mathbase::Operation::Power, "clamped");
}
#[test]
fn shader_code_delegates_to_mathbase() {
let n = MathNode;
let code = n.shader_code("0.0.2.2").unwrap();
assert!(code.contains("param_a_in + param_b_in"));
assert_eq!(n.shader_code("garbage"), None);
}
#[test]
fn process_samples_multiplies_number() {
let (mut core, behavior) = create();
core.set_standard_value(METHOD_INPUT, -1, NodeValue::Combo(2)); // Multiply
let mut buf = crate::value::SampleBuffer {
format: oakcore_rs::SampleFormat::F32Planar,
channels: 1,
sample_count: 2,
data: vec![0u8; 8],
};
buf.set_sample_value(0, 0, 1.0);
buf.set_sample_value(0, 1, 2.0);
let inputs = row(&[
(PARAM_A_INPUT, NodeValue::Samples(buf.clone())),
(PARAM_B_INPUT, NodeValue::Float(3.0)),
]);
let mut out = crate::value::SampleBuffer {
format: oakcore_rs::SampleFormat::F32Planar,
channels: 1,
sample_count: 2,
data: vec![0u8; 8],
};
behavior.process_samples(
&core,
&inputs,
oakcore_rs::TimeRange::new(Rational::new(0, 1), Rational::new(2, 1)),
&mut out,
);
assert_eq!(out.sample_value(0, 0), 3.0);
assert_eq!(out.sample_value(0, 1), 6.0);
}
}
File diff suppressed because it is too large Load Diff
+553
View File
@@ -0,0 +1,553 @@
// 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/>.
//! Orthographic matrix generator (C++
//! `src/node/src/generator/matrix/matrix.{h,cpp}`,
//! `olive::MatrixGenerator`).
use crate::factory::NodeMeta;
use crate::node::{Category, NodeBehavior, NodeCore};
/// Position input id (C++ `k_position_input`). Type: vec2; default
/// `(0.0, 0.0)`.
pub const POSITION_INPUT: &str = "pos_in";
/// Rotation input id (C++ `k_rotation_input`). Type: float; default
/// `0.0`.
pub const ROTATION_INPUT: &str = "rot_in";
/// Scale input id (C++ `k_scale_input`). Type: vec2; default
/// `(1.0, 1.0)`; properties: `min = (0, 0)`, `view = percentage`,
/// `disable1 = true` (the second component starts disabled because
/// uniform scale defaults to on).
pub const SCALE_INPUT: &str = "scale_in";
/// Uniform scale input id (C++ `k_uniform_scale_input`). Type: bool;
/// default `true`; flags: not-connectable, not-keyframable.
pub const UNIFORM_SCALE_INPUT: &str = "uniform_scale_in";
/// Anchor point input id (C++ `k_anchor_input`). Type: vec2; default
/// `(0.0, 0.0)`.
pub const ANCHOR_INPUT: &str = "anchor_in";
/// Orthographic matrix generator node. Builds a 2D transform matrix
/// from position, rotation, scale and anchor inputs. Has no own member
/// fields in C++ (state lives in the `Node` inputs).
pub struct MatrixGenerator;
impl NodeBehavior for MatrixGenerator {
/// Human-readable name (C++ `name()`).
fn name(&self) -> &str {
"Orthographic Matrix"
}
/// Short menu name (C++ `short_name()`).
fn short_name(&self) -> &str {
"Ortho"
}
/// Stable type id (C++ `id()`).
fn type_id(&self) -> &str {
"org.olivevideoeditor.Olive.ortho"
}
/// Categories (C++ `category()`): generator and math.
fn categories(&self) -> &[Category] {
&[Category::Generator, Category::Math]
}
/// Description (C++ `description()`).
fn description(&self) -> &str {
"Generate an orthographic matrix using position, rotation, and scale."
}
/// Localized input names (C++ `retranslate()`): `pos_in` ->
/// "Position", `rot_in` -> "Rotation", `scale_in` -> "Scale",
/// `uniform_scale_in` -> "Uniform Scale", `anchor_in` ->
/// "Anchor Point".
fn input_name<'a>(&self, id: &'a str) -> &'a str {
match id {
POSITION_INPUT => "Position",
ROTATION_INPUT => "Rotation",
SCALE_INPUT => "Scale",
UNIFORM_SCALE_INPUT => "Uniform Scale",
ANCHOR_INPUT => "Anchor Point",
_ => id,
}
}
/// Evaluate outputs (C++ `value()`): builds the matrix via
/// `generate_matrix(value, false, false, false, Matrix4x4())` and
/// pushes it as a `k_matrix` value. The C++ helper composes, in
/// order: translate(position), rotate(rotation around Z),
/// scale(scale.x, uniform ? scale.x : scale.y, 1), then
/// translate(-anchor).
fn value(
&self,
core: &NodeCore,
inputs: &crate::value::NodeValueRow,
time: oakcore_rs::Rational,
table: &mut crate::value::NodeValueTable,
) {
let mat = MatrixGenerator::generate_matrix(
inputs,
core,
time,
false,
false,
false,
super::mathbase::identity_matrix(),
);
table.push(
crate::value::ValueType::Matrix,
crate::value::NodeValue::Matrix(mat),
None,
);
}
/// Input value changed (C++ `InputValueChangedEvent`): when
/// `uniform_scale_in` changes, sets the scale input's `disable1`
/// property to the uniform-scale value (disabling the Y component
/// while uniform scale is on).
fn input_value_changed(&mut self, core: &mut NodeCore, input: &str, element: i32) {
if input == UNIFORM_SCALE_INPUT && element == -1 {
let uniform = core.standard_value(UNIFORM_SCALE_INPUT, -1).to_double() != 0.0;
if let Some(scale) = core.get_input_mut(SCALE_INPUT) {
scale.properties.retain(|(k, _)| k != "disable1");
scale
.properties
.push(("disable1".to_string(), crate::value::NodeValue::Boolean(uniform)));
}
}
}
/// Deep copy (C++ `copy()`).
fn duplicate(&self, _core: &NodeCore) -> Option<Box<dyn NodeBehavior>> {
Some(Box::new(MatrixGenerator))
}
}
impl MatrixGenerator {
/// C++ `generate_matrix(const NodeValueRow&, bool, bool, bool,
/// const Matrix4x4&)` — reads the transform inputs from the render
/// row (falling back to the keyframed/standard values via `core`),
/// honoring the ignore flags, and composes the result onto `mat`
/// (used by [`MatrixGenerator::value`] and `TransformDistortNode`).
pub fn generate_matrix(
row: &crate::value::NodeValueRow,
core: &NodeCore,
time: oakcore_rs::Rational,
ignore_anchor: bool,
ignore_position: bool,
ignore_scale: bool,
mat: [f64; 16],
) -> [f64; 16] {
let anchor = if ignore_anchor {
[0.0, 0.0]
} else {
row_vec2(row, core, time, ANCHOR_INPUT)
};
let scale = if ignore_scale {
[1.0, 1.0]
} else {
row_vec2(row, core, time, SCALE_INPUT)
};
let position = if ignore_position {
[0.0, 0.0]
} else {
row_vec2(row, core, time, POSITION_INPUT)
};
let rotation = row_float(row, core, time, ROTATION_INPUT);
let uniform_scale = row_bool(row, core, time, UNIFORM_SCALE_INPUT);
Self::compose_matrix(position, rotation, scale, uniform_scale, anchor, mat)
}
/// Static matrix composition (C++
/// `generate_matrix(const Vector2D&, const float&, const Vector2D&,
/// bool, const Vector2D&, Matrix4x4)`): `mat` post-multiplied by
/// translate(position), rotate(rotation degrees around Z),
/// scale(scale.x, uniform ? scale.x : scale.y, 1), then
/// translate(-anchor). Row-major 16-element storage; every
/// post-multiply matches the C++ `Matrix4x4` member operations
/// (`// CPP-PARITY: matrix.cpp` `generate_matrix`).
pub fn compose_matrix(
pos: [f64; 2],
rot: f64,
scale: [f64; 2],
uniform_scale: bool,
anchor: [f64; 2],
mat: [f64; 16],
) -> [f64; 16] {
// Position
let mut m = matrix_translate(mat, pos[0], pos[1]);
// Rotation (2D rotation around the Z axis).
m = matrix_rotate_z(m, rot);
// Scale (uniform scale replicates the X component).
let full_scale = if uniform_scale {
(scale[0], scale[0])
} else {
(scale[0], scale[1])
};
m = matrix_scale(m, full_scale.0, full_scale.1, 1.0);
// Anchor point
matrix_translate(m, -anchor[0], -anchor[1])
}
}
/// Post-multiply `a` by `b` (C++ `Matrix4x4::operator*`; row-major
/// `m[r*4+c]` storage).
pub fn matrix_mul(a: [f64; 16], b: [f64; 16]) -> [f64; 16] {
let mut out = [0.0f64; 16];
for r in 0..4 {
for c in 0..4 {
let mut acc = 0.0;
for k in 0..4 {
acc += a[r * 4 + k] * b[k * 4 + c];
}
out[r * 4 + c] = acc;
}
}
out
}
/// Post-multiply a 2D translation (C++ `Matrix4x4::translate(x, y)`).
pub fn matrix_translate(m: [f64; 16], x: f64, y: f64) -> [f64; 16] {
let mut t = super::mathbase::identity_matrix();
t[3] = x;
t[7] = y;
matrix_mul(m, t)
}
/// Post-multiply a scale (C++ `Matrix4x4::scale(x, y, z)`).
pub fn matrix_scale(m: [f64; 16], x: f64, y: f64, z: f64) -> [f64; 16] {
let mut s = super::mathbase::identity_matrix();
s[0] = x;
s[5] = y;
s[10] = z;
matrix_mul(m, s)
}
/// Post-multiply a Z-axis rotation in degrees (C++
/// `Matrix4x4::rotate(degrees)`).
fn matrix_rotate_z(m: [f64; 16], degrees: f64) -> [f64; 16] {
let radians = degrees * std::f64::consts::PI / 180.0;
let (c, s) = (radians.cos(), radians.sin());
let mut r = super::mathbase::identity_matrix();
r[0] = c;
r[1] = -s;
r[4] = s;
r[5] = c;
matrix_mul(m, r)
}
/// Resolve a vec2 input from the render row or the keyframed/standard
/// value (C++ `value.at(id).to_vec2()`; missing values read as
/// `(0, 0)`).
fn row_vec2(
row: &crate::value::NodeValueRow,
core: &NodeCore,
time: oakcore_rs::Rational,
id: &str,
) -> [f64; 2] {
match row.get(id) {
Some(crate::value::NodeValue::Vec2(v)) => *v,
Some(v) => [v.to_double(), 0.0],
None => match core.value_at_time(id, -1, time) {
crate::value::NodeValue::Vec2(v) => v,
v => [v.to_double(), 0.0],
},
}
}
/// Resolve a float input from the render row or the keyframed/standard
/// value (C++ `value.at(id).to_double()`).
fn row_float(
row: &crate::value::NodeValueRow,
core: &NodeCore,
time: oakcore_rs::Rational,
id: &str,
) -> f64 {
match row.get(id) {
Some(v) => v.to_double(),
None => core.value_at_time(id, -1, time).to_double(),
}
}
/// Resolve a boolean input from the render row or the keyframed/standard
/// value (C++ `value.at(id).to_bool()`).
fn row_bool(
row: &crate::value::NodeValueRow,
core: &NodeCore,
time: oakcore_rs::Rational,
id: &str,
) -> bool {
match row.get(id) {
Some(v) => v.to_double() != 0.0,
None => core.value_at_time(id, -1, time).to_double() != 0.0,
}
}
/// Constructor (C++ `MatrixGenerator::MatrixGenerator()`): adds
/// `pos_in`, `rot_in`, `scale_in`, `uniform_scale_in` and `anchor_in`
/// with the defaults, flags and properties documented on the constants.
pub fn create() -> (NodeCore, Box<dyn NodeBehavior>) {
let mut core = NodeCore::new();
let mut pos = crate::input::Input::new(
POSITION_INPUT,
crate::value::ValueType::Vec2,
crate::value::NodeValue::Vec2([0.0, 0.0]),
);
pos.properties = vec![("view".to_string(), crate::value::NodeValue::Text("percentage".into()))];
core.add_input(pos);
let mut rot = crate::input::Input::new(
ROTATION_INPUT,
crate::value::ValueType::Float,
crate::value::NodeValue::Float(0.0),
);
rot.properties = vec![
("view".to_string(), crate::value::NodeValue::Text("percentage".into())),
("min".to_string(), crate::value::NodeValue::Float(-360.0)),
("max".to_string(), crate::value::NodeValue::Float(360.0)),
];
core.add_input(rot);
let mut scale = crate::input::Input::new(
SCALE_INPUT,
crate::value::ValueType::Vec2,
crate::value::NodeValue::Vec2([1.0, 1.0]),
);
scale.properties = vec![
("min".to_string(), crate::value::NodeValue::Vec2([0.0, 0.0])),
("view".to_string(), crate::value::NodeValue::Text("percentage".into())),
("disable1".to_string(), crate::value::NodeValue::Boolean(true)),
];
core.add_input(scale);
let mut uniform = crate::input::Input::new(
UNIFORM_SCALE_INPUT,
crate::value::ValueType::Boolean,
crate::value::NodeValue::Boolean(true),
);
uniform.flags |= crate::input::flags::NOT_CONNECTABLE | crate::input::flags::NOT_KEYFRAMABLE;
core.add_input(uniform);
let mut anchor = crate::input::Input::new(
ANCHOR_INPUT,
crate::value::ValueType::Vec2,
crate::value::NodeValue::Vec2([0.0, 0.0]),
);
anchor.properties = vec![("view".to_string(), crate::value::NodeValue::Text("percentage".into()))];
core.add_input(anchor);
(core, Box::new(MatrixGenerator))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::node::NodeBehavior;
use crate::value::{NodeValue, NodeValueTable, ValueType};
use oakcore_rs::Rational;
#[test]
fn input_names() {
let n = MatrixGenerator;
assert_eq!(n.input_name(POSITION_INPUT), "Position");
assert_eq!(n.input_name(ROTATION_INPUT), "Rotation");
assert_eq!(n.input_name(SCALE_INPUT), "Scale");
assert_eq!(n.input_name(UNIFORM_SCALE_INPUT), "Uniform Scale");
assert_eq!(n.input_name(ANCHOR_INPUT), "Anchor Point");
}
#[test]
fn create_wires_inputs() {
let (core, behavior) = create();
assert_eq!(behavior.type_id(), "org.olivevideoeditor.Olive.ortho");
assert_eq!(
core.get_input(POSITION_INPUT).unwrap().default,
NodeValue::Vec2([0.0, 0.0])
);
assert_eq!(
core.get_input(SCALE_INPUT).unwrap().default,
NodeValue::Vec2([1.0, 1.0])
);
assert_eq!(
core.get_input(UNIFORM_SCALE_INPUT).unwrap().default,
NodeValue::Boolean(true)
);
}
#[test]
fn value_defaults_produce_identity() {
let (core, behavior) = create();
let mut table = NodeValueTable::default();
behavior.value(
&core,
&crate::value::NodeValueRow::default(),
Rational::new(0, 1),
&mut table,
);
assert_eq!(
table.get(ValueType::Matrix),
Some(&NodeValue::Matrix(super::super::mathbase::identity_matrix()))
);
}
#[test]
fn value_translate_position() {
let (mut core, behavior) = create();
core.set_standard_value(POSITION_INPUT, -1, NodeValue::Vec2([10.0, 20.0]));
let mut table = NodeValueTable::default();
behavior.value(
&core,
&crate::value::NodeValueRow::default(),
Rational::new(0, 1),
&mut table,
);
// translate(10, 20) is stored at m[0][3] / m[1][3].
let m = match table.get(ValueType::Matrix).unwrap() {
NodeValue::Matrix(m) => *m,
_ => panic!("matrix expected"),
};
assert_eq!(m[3], 10.0);
assert_eq!(m[7], 20.0);
}
#[test]
fn value_non_uniform_scale() {
let (mut core, behavior) = create();
core.set_standard_value(UNIFORM_SCALE_INPUT, -1, NodeValue::Boolean(false));
core.set_standard_value(SCALE_INPUT, -1, NodeValue::Vec2([2.0, 3.0]));
let mut table = NodeValueTable::default();
behavior.value(
&core,
&crate::value::NodeValueRow::default(),
Rational::new(0, 1),
&mut table,
);
let m = match table.get(ValueType::Matrix).unwrap() {
NodeValue::Matrix(m) => *m,
_ => panic!("matrix expected"),
};
assert_eq!(m[0], 2.0);
assert_eq!(m[5], 3.0);
}
#[test]
fn value_uniform_scale_replicates_x() {
let (mut core, behavior) = create();
// uniform_scale defaults to true.
core.set_standard_value(SCALE_INPUT, -1, NodeValue::Vec2([2.0, 9.0]));
let mut table = NodeValueTable::default();
behavior.value(
&core,
&crate::value::NodeValueRow::default(),
Rational::new(0, 1),
&mut table,
);
let m = match table.get(ValueType::Matrix).unwrap() {
NodeValue::Matrix(m) => *m,
_ => panic!("matrix expected"),
};
assert_eq!(m[0], 2.0);
assert_eq!(m[5], 2.0, "uniform scale replicates X");
}
#[test]
fn compose_matrix_rotation_translate_order() {
// rotate(90) post-multiplied by translate(-10, 0): a pure
// translation (10, 0) under a 90-degree rotation lands in the
// negative Y translation slot.
let m = MatrixGenerator::compose_matrix(
[0.0, 0.0],
90.0,
[1.0, 1.0],
false,
[-10.0, 0.0],
super::super::mathbase::identity_matrix(),
);
assert!((m[0] - 0.0).abs() < 1e-9);
assert!((m[5] - 0.0).abs() < 1e-9);
assert!((m[7] - 10.0).abs() < 1e-9, "m[1][3] = +10 after rotate(90)");
assert!((m[3] - 0.0).abs() < 1e-9);
}
#[test]
fn compose_matrix_identity_inputs() {
let m = MatrixGenerator::compose_matrix(
[0.0, 0.0],
0.0,
[1.0, 1.0],
false,
[0.0, 0.0],
super::super::mathbase::identity_matrix(),
);
assert!(super::super::mathbase::matrix_is_identity(m));
}
#[test]
fn matrix_helpers_are_row_major() {
let mut a = super::super::mathbase::identity_matrix();
a[3] = 100.0; // translate x
let mut b = super::super::mathbase::identity_matrix();
b[5] = 2.0; // scale y
let prod = matrix_mul(a, b);
assert_eq!(prod[5], 2.0);
assert_eq!(prod[3], 100.0, "post-multiplied translate survives");
}
#[test]
fn input_value_changed_toggles_disable1() {
let (mut core, behavior) = create();
let mut b = behavior;
b.input_value_changed(&mut core, UNIFORM_SCALE_INPUT, -1);
let scale = core.get_input(SCALE_INPUT).unwrap();
assert!(scale.properties.iter().any(|(k, v)| {
k == "disable1" && *v == NodeValue::Boolean(true)
}));
core.set_standard_value(UNIFORM_SCALE_INPUT, -1, NodeValue::Boolean(false));
b.input_value_changed(&mut core, UNIFORM_SCALE_INPUT, -1);
let scale = core.get_input(SCALE_INPUT).unwrap();
assert!(scale.properties.iter().any(|(k, v)| {
k == "disable1" && *v == NodeValue::Boolean(false)
}));
}
#[test]
fn duplicate_clones() {
let (core, behavior) = create();
let dup = behavior.duplicate(&core).unwrap();
assert_eq!(dup.name(), "Orthographic Matrix");
}
}
/// Register this node type (C++ factory entry for
/// `org.olivevideoeditor.Olive.ortho`).
pub fn register(meta: &mut Vec<NodeMeta>) {
meta.push(NodeMeta {
type_id: "org.olivevideoeditor.Olive.ortho",
name: "Orthographic Matrix",
categories: &[Category::Generator, Category::Math],
create,
});
}
+292
View File
@@ -0,0 +1,292 @@
// 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/>.
//! Merge node (C++ `src/node/src/math/merge/merge.{h,cpp}`,
//! `olive::MergeNode`): alpha-over composites two textures.
use crate::factory::NodeMeta;
use crate::node::{Category, NodeBehavior, NodeCore};
/// Base (background) texture input id (C++ `k_base_in`). Type:
/// texture; flags: not-keyframable.
pub const BASE_INPUT: &str = "base_in";
/// Blend (foreground) texture input id (C++ `k_blend_in`). Type:
/// texture; flags: not-keyframable.
pub const BLEND_INPUT: &str = "blend_in";
/// Merge node. Unit-like: the C++ class's only members are
/// `base_in_`/`blend_in_` `NodeInput*` back-pointers, which live in
/// [`NodeCore`] here, so there is nothing to duplicate.
pub struct MergeNode;
/// Fragment shader (C++ `get_shader_code()` loads the
/// `:/shaders/alphaover.frag` resource). Text copied verbatim from
/// `engine/shaders/alphaover.frag`.
const SHADER_FRAG: &str = r#"uniform sampler2D base_in;
uniform sampler2D blend_in;
uniform bool base_in_enabled;
uniform bool blend_in_enabled;
in vec2 ove_texcoord;
out vec4 frag_color;
void main(void) {
vec4 base_col = texture(base_in, ove_texcoord);
vec4 blend_col = texture(blend_in, ove_texcoord);
if (!base_in_enabled && !blend_in_enabled) {
frag_color = vec4(0.0);
return;
}
if (!base_in_enabled) {
frag_color = blend_col;
return;
}
if (!blend_in_enabled) {
frag_color = base_col;
return;
}
base_col *= 1.0 - blend_col.a;
base_col += blend_col;
frag_color = base_col;
}
"#;
impl MergeNode {
/// Fragment shader (C++ `get_shader_code()`; the request id is
/// ignored — the same alpha-over shader serves every request).
fn shader_frag() -> &'static str {
SHADER_FRAG
}
}
impl NodeBehavior for MergeNode {
/// Human-readable name (C++ `name()`).
fn name(&self) -> &str {
"Merge"
}
/// Stable type id (C++ `id()`).
fn type_id(&self) -> &str {
"org.olivevideoeditor.Olive.merge"
}
/// Categories (C++ `category()`; filed under math even though it
/// composites textures).
fn categories(&self) -> &[Category] {
&[Category::Math]
}
/// Description (C++ `description()`).
fn description(&self) -> &str {
"Merge two textures together."
}
/// Localized input names (C++ `retranslate()`): `base_in` ->
/// "Base", `blend_in` -> "Blend".
fn input_name<'a>(&self, id: &'a str) -> &'a str {
match id {
BASE_INPUT => "Base",
BLEND_INPUT => "Blend",
_ => id,
}
}
/// Evaluate outputs (C++ `value()`): if only the blend texture is
/// present, or the blend texture has fewer than RGBA channels (no
/// alpha to over with), push the blend input as-is; if only the
/// base texture is present, push the base input as-is; if both are
/// present, push a shader job over the base texture with the whole
/// input row as job values; if neither, push nothing.
///
/// The Rust model has no shader-job payload: the both-present case
/// pushes a null texture handle marking a renderer-deferred
/// alpha-over job resolved via [`Self::shader_code`]
/// (`// CPP-PARITY: merge.cpp` `value()`). The "blend has fewer
/// than 4 channels" check needs the texture's channel count, which
/// the Rust texture handle does not carry, so the alpha-less blend
/// case is only distinguishable by presence here.
fn value(
&self,
core: &NodeCore,
inputs: &crate::value::NodeValueRow,
time: oakcore_rs::Rational,
table: &mut crate::value::NodeValueTable,
) {
let _ = (core, time);
let base = inputs.get(BASE_INPUT);
let blend = inputs.get(BLEND_INPUT);
match (base, blend) {
(Some(b @ crate::value::NodeValue::Texture(_)), Some(bl @ crate::value::NodeValue::Texture(_))) => {
// Both present: alpha-over shader job. The C++ checks
// the blend channel count here (RGBA required for an
// alpha to over with) and pushes the blend as-is when it
// has no alpha channel — not representable without the
// texture params (`// CPP-PARITY: merge.cpp`).
let _ = (b, bl);
table.push(
crate::value::ValueType::Texture,
crate::value::NodeValue::Texture(crate::handle::CHandle::null()),
None,
);
}
(Some(b @ crate::value::NodeValue::Texture(_)), None) => {
table.push(crate::value::ValueType::Texture, b.clone(), None);
}
(None, Some(bl @ crate::value::NodeValue::Texture(_))) => {
table.push(crate::value::ValueType::Texture, bl.clone(), None);
}
_ => {}
}
}
/// Shader code request (C++ `get_shader_code()`): always returns
/// the alpha-over fragment shader regardless of the request id.
fn shader_code(&self, _request: &str) -> Option<String> {
Some(SHADER_FRAG.to_string())
}
/// Deep copy (C++ `copy()`).
fn duplicate(&self, _core: &NodeCore) -> Option<Box<dyn NodeBehavior>> {
Some(Box::new(MergeNode))
}
}
/// Constructor (C++ `MergeNode::MergeNode()`): adds `base_in` and
/// `blend_in` as not-keyframable texture inputs and sets the
/// dont-show-in-param-view flag.
pub fn create() -> (NodeCore, Box<dyn NodeBehavior>) {
let mut core = NodeCore::new();
let mut base = crate::input::Input::new(
BASE_INPUT,
crate::value::ValueType::Texture,
crate::value::NodeValue::None,
);
base.flags |= crate::input::flags::NOT_KEYFRAMABLE;
core.add_input(base);
let mut blend = crate::input::Input::new(
BLEND_INPUT,
crate::value::ValueType::Texture,
crate::value::NodeValue::None,
);
blend.flags |= crate::input::flags::NOT_KEYFRAMABLE;
core.add_input(blend);
core.flags |= crate::node::flags::DONT_SHOW_IN_PARAM_VIEW;
(core, Box::new(MergeNode))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::node::NodeBehavior;
use crate::value::{NodeValue, NodeValueTable, ValueType};
use oakcore_rs::Rational;
fn tex() -> NodeValue {
NodeValue::Texture(crate::handle::CHandle::null())
}
#[test]
fn input_names() {
let n = MergeNode;
assert_eq!(n.input_name(BASE_INPUT), "Base");
assert_eq!(n.input_name(BLEND_INPUT), "Blend");
}
#[test]
fn create_wires_inputs_and_flag() {
let (core, behavior) = create();
assert_eq!(behavior.type_id(), "org.olivevideoeditor.Olive.merge");
assert!(core.get_input(BASE_INPUT).is_some());
assert!(core.get_input(BLEND_INPUT).is_some());
assert_ne!(core.flags & crate::node::flags::DONT_SHOW_IN_PARAM_VIEW, 0);
}
#[test]
fn value_neither_pushes_nothing() {
let (core, behavior) = create();
let mut table = NodeValueTable::default();
behavior.value(&core, &crate::value::NodeValueRow::default(), Rational::new(0, 1), &mut table);
assert!(table.is_empty());
}
#[test]
fn value_base_only_pushes_base() {
let (core, behavior) = create();
let base = tex();
let inputs = crate::value::NodeValueRow::from([(BASE_INPUT.to_string(), base.clone())]);
let mut table = NodeValueTable::default();
behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
assert_eq!(table.get(ValueType::Texture), Some(&base));
}
#[test]
fn value_blend_only_pushes_blend() {
let (core, behavior) = create();
let blend = tex();
let inputs = crate::value::NodeValueRow::from([(BLEND_INPUT.to_string(), blend.clone())]);
let mut table = NodeValueTable::default();
behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
assert_eq!(table.get(ValueType::Texture), Some(&blend));
}
#[test]
fn value_both_pushes_deferred_job() {
let (core, behavior) = create();
let inputs = crate::value::NodeValueRow::from([
(BASE_INPUT.to_string(), tex()),
(BLEND_INPUT.to_string(), tex()),
]);
let mut table = NodeValueTable::default();
behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
assert!(table.get(ValueType::Texture).is_some());
}
#[test]
fn shader_code_always_alphaover() {
let n = MergeNode;
let code = n.shader_code("anything").unwrap();
assert!(code.contains("base_col *= 1.0 - blend_col.a;"));
}
#[test]
fn duplicate_clones() {
let (core, behavior) = create();
let dup = behavior.duplicate(&core).unwrap();
assert_eq!(dup.name(), "Merge");
}
}
/// Register this node type (C++ `k_merge_node` in
/// `factory.cpp::create_from_factory_index`).
pub fn register(meta: &mut Vec<NodeMeta>) {
meta.push(NodeMeta {
type_id: "org.olivevideoeditor.Olive.merge",
name: "Merge",
categories: &[Category::Math],
create,
});
}
+150
View File
@@ -0,0 +1,150 @@
// 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/>.
//! Built-in node types. One file per C++ node class, named after it
//! (e.g. `transformdistortnode.rs`, `viewer` stays in
//! [`crate::sequence`]). Each registers with the factory via
//! [`register_all`].
mod blur;
mod chromakey;
mod colordifferencekey;
mod cornerpindistortnode;
mod cropdistortnode;
mod despill;
mod displaytransform;
mod dropshadowfilter;
mod flipdistortnode;
mod generatorwithmerge;
pub mod group;
mod mask;
mod math;
mod mathbase;
mod matrix;
mod merge;
mod mosaicfilternode;
mod multicamnode;
mod noise;
mod ociobase;
mod ociogradingtransformlinear;
mod ociogradingtransformlog;
mod ociolut;
mod opacity;
mod pan;
mod plugin;
mod polygon;
mod rippledistortnode;
mod shapenode;
mod shapenodebase;
mod solid;
mod stroke;
mod swirldistortnode;
mod textbackend;
mod textv1;
mod textv2;
mod textv3;
mod threewaycolor;
mod tiledistortnode;
mod timeformat;
mod timeinput;
mod timeoffsetnode;
mod timeremap;
mod transformdistortnode;
mod trigonometry;
mod valuenode;
mod volume;
mod wavedistortnode;
mod whitebalance;
use crate::factory::NodeMeta;
use crate::node::{NodeBehavior, NodeCore};
/// A no-op behavior for vacant arena slots (graph internal; never
/// observable through the public API — a vacant slot is only reachable
/// by a stale id, which `Graph::get` rejects).
pub struct EmptyBehavior;
impl NodeBehavior for EmptyBehavior {
fn name(&self) -> &str {
""
}
fn type_id(&self) -> &str {
""
}
fn duplicate(&self, _core: &NodeCore) -> Option<Box<dyn NodeBehavior>> {
Some(Box::new(EmptyBehavior))
}
}
/// Register every built-in node type with the global factory.
/// Registration order matches the C++ menu order
/// (`// CPP-PARITY: factory.cpp`).
pub fn register_all() {
let mut meta = Vec::new();
polygon::register(&mut meta);
matrix::register(&mut meta);
transformdistortnode::register(&mut meta);
volume::register(&mut meta);
pan::register(&mut meta);
math::register(&mut meta);
timeinput::register(&mut meta);
trigonometry::register(&mut meta);
blur::register(&mut meta);
solid::register(&mut meta);
merge::register(&mut meta);
stroke::register(&mut meta);
textv1::register(&mut meta);
textv2::register(&mut meta);
textv3::register(&mut meta);
mosaicfilternode::register(&mut meta);
cropdistortnode::register(&mut meta);
valuenode::register(&mut meta);
timeremap::register(&mut meta);
shapenode::register(&mut meta);
colordifferencekey::register(&mut meta);
despill::register(&mut meta);
group::register(&mut meta);
opacity::register(&mut meta);
flipdistortnode::register(&mut meta);
noise::register(&mut meta);
timeoffsetnode::register(&mut meta);
cornerpindistortnode::register(&mut meta);
displaytransform::register(&mut meta);
ociogradingtransformlinear::register(&mut meta);
ociogradingtransformlog::register(&mut meta);
whitebalance::register(&mut meta);
ociolut::register(&mut meta);
threewaycolor::register(&mut meta);
chromakey::register(&mut meta);
mask::register(&mut meta);
dropshadowfilter::register(&mut meta);
timeformat::register(&mut meta);
wavedistortnode::register(&mut meta);
tiledistortnode::register(&mut meta);
swirldistortnode::register(&mut meta);
rippledistortnode::register(&mut meta);
multicamnode::register(&mut meta);
// OpenFX plugins have no static type ids (C++
// `factory.cpp::register_plugin_nodes`); the registration call is a
// no-op placeholder for the oakplugin bridge's runtime discovery.
plugin::register(&mut meta);
crate::factory::install_entries(meta);
}
@@ -0,0 +1,274 @@
// 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/>.
//! Mosaic filter (C++
//! `src/node/src/filter/mosaic/mosaicfilternode.{h,cpp}`,
//! `olive::MosaicFilterNode`).
use crate::factory::NodeMeta;
use crate::node::{Category, NodeBehavior, NodeCore};
/// Texture input id (C++ `k_texture_input`). Type: texture; flags:
/// not-keyframable; this is the node's effect input.
pub const TEXTURE_INPUT: &str = "tex_in";
/// Horizontal block count input id (C++ `k_horiz_input`). Type: float;
/// default `32.0`; properties: `min = 1.0`.
pub const HORIZ_INPUT: &str = "horiz_in";
/// Vertical block count input id (C++ `k_vert_input`). Type: float;
/// default `18.0`; properties: `min = 1.0`.
pub const VERT_INPUT: &str = "vert_in";
/// Mosaic filter node. Pixelates the image into a grid of blocks. The
/// C++ class declares no own member fields.
pub struct MosaicFilterNode;
/// Fragment shader (C++ `get_shader_code()` loads
/// `:/shaders/mosaic.frag` via FileFunctions for any request). Text
/// copied verbatim from `engine/shaders/mosaic.frag`.
const SHADER_FRAG: &str = r#"// Input texture
uniform sampler2D tex_in;
uniform float horiz_in;
uniform float vert_in;
// Input texture coordinate
in vec2 ove_texcoord;
out vec4 frag_color;
void main() {
float x;
float y;
if (horiz_in > 0.0) {
x = floor(ove_texcoord.x * horiz_in) / horiz_in;
} else {
x = ove_texcoord.x;
}
if (vert_in > 0.0) {
y = floor(ove_texcoord.y * vert_in) / vert_in;
} else {
y = ove_texcoord.y;
}
vec4 color = texture(tex_in, vec2(x, y));
frag_color = color;
}
"#;
impl MosaicFilterNode {
/// Fragment shader for any request (C++ `get_shader_code()` ignores
/// the request id and always returns `mosaic.frag`).
fn shader_frag() -> &'static str {
SHADER_FRAG
}
}
impl NodeBehavior for MosaicFilterNode {
/// Human-readable name (C++ `name()`).
fn name(&self) -> &str {
"Mosaic"
}
/// Stable type id (C++ `id()`).
fn type_id(&self) -> &str {
"org.olivevideoeditor.Olive.mosaicfilter"
}
/// Categories (C++ `category()`).
fn categories(&self) -> &[Category] {
&[Category::Filter]
}
/// Description (C++ `description()`).
fn description(&self) -> &str {
"Apply a pixelated mosaic filter to video."
}
/// Localized input names (C++ `retranslate()`): `tex_in` ->
/// "Texture", `horiz_in` -> "Horizontal", `vert_in` -> "Vertical".
fn input_name<'a>(&self, id: &'a str) -> &'a str {
match id {
TEXTURE_INPUT => "Texture",
HORIZ_INPUT => "Horizontal",
VERT_INPUT => "Vertical",
_ => id,
}
}
/// Evaluate outputs (C++ `value()`): no texture -> push nothing; if
/// the block counts already equal the texture's pixel dimensions ->
/// pass-through push of the input texture; otherwise push a shader
/// job with bilinear interpolation forced on `tex_in` (mipmapping
/// makes block colors look wrong).
///
/// The "block counts equal the pixel dimensions" check compares the
/// input values against the texture's width/height, which the Rust
/// texture handle does not carry — so the pass-through optimization
/// is not representable and a shader job is always queued when a
/// texture is present (`// CPP-PARITY: mosaicfilternode.cpp` value()).
fn value(
&self,
core: &NodeCore,
inputs: &crate::value::NodeValueRow,
time: oakcore_rs::Rational,
table: &mut crate::value::NodeValueTable,
) {
if !matches!(inputs.get(TEXTURE_INPUT), Some(crate::value::NodeValue::Texture(_))) {
return;
}
let _ = (core, time, inputs);
table.push(
crate::value::ValueType::Texture,
crate::value::NodeValue::Texture(crate::handle::CHandle::null()),
None,
);
}
/// Shader code request (C++ `get_shader_code()`): the request id is
/// ignored; always returns the mosaic fragment shader.
fn shader_code(&self, _request: &str) -> Option<String> {
Some(Self::shader_frag().to_string())
}
/// Deep copy (C++ `copy()`).
fn duplicate(&self, _core: &NodeCore) -> Option<Box<dyn NodeBehavior>> {
Some(Box::new(MosaicFilterNode))
}
}
/// Constructor (C++ `MosaicFilterNode::MosaicFilterNode()`): adds
/// `tex_in`, `horiz_in`, `vert_in` with the defaults and properties
/// documented on the constants, then sets the video-effect flag and the
/// effect input.
pub fn create() -> (NodeCore, Box<dyn NodeBehavior>) {
let mut core = NodeCore::new();
let mut tex = crate::input::Input::new(
TEXTURE_INPUT,
crate::value::ValueType::Texture,
crate::value::NodeValue::None,
);
tex.flags |= crate::input::flags::NOT_KEYFRAMABLE;
core.add_input(tex);
let mut horiz = crate::input::Input::new(
HORIZ_INPUT,
crate::value::ValueType::Float,
crate::value::NodeValue::Float(32.0),
);
horiz.properties = vec![("min".to_string(), crate::value::NodeValue::Float(1.0))];
core.add_input(horiz);
let mut vert = crate::input::Input::new(
VERT_INPUT,
crate::value::ValueType::Float,
crate::value::NodeValue::Float(18.0),
);
vert.properties = vec![("min".to_string(), crate::value::NodeValue::Float(1.0))];
core.add_input(vert);
core.flags |= crate::node::flags::VIDEO_EFFECT;
core.effect_input = TEXTURE_INPUT.to_string();
(core, Box::new(MosaicFilterNode))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::node::NodeBehavior;
use crate::value::{NodeValue, NodeValueTable, ValueType};
use oakcore_rs::Rational;
fn tex() -> NodeValue {
NodeValue::Texture(crate::handle::CHandle::null())
}
#[test]
fn input_names() {
let n = MosaicFilterNode;
assert_eq!(n.input_name(TEXTURE_INPUT), "Texture");
assert_eq!(n.input_name(HORIZ_INPUT), "Horizontal");
assert_eq!(n.input_name(VERT_INPUT), "Vertical");
}
#[test]
fn create_wires_inputs_and_flags() {
let (core, behavior) = create();
assert_eq!(behavior.type_id(), "org.olivevideoeditor.Olive.mosaicfilter");
assert_eq!(
core.get_input(HORIZ_INPUT).unwrap().default,
NodeValue::Float(32.0)
);
assert_eq!(
core.get_input(VERT_INPUT).unwrap().default,
NodeValue::Float(18.0)
);
assert_eq!(core.effect_input, TEXTURE_INPUT);
assert_ne!(core.flags & crate::node::flags::VIDEO_EFFECT, 0);
}
#[test]
fn value_no_texture_pushes_nothing() {
let (core, behavior) = create();
let mut table = NodeValueTable::default();
behavior.value(
&core,
&crate::value::NodeValueRow::default(),
Rational::new(0, 1),
&mut table,
);
assert!(table.is_empty());
}
#[test]
fn value_with_texture_pushes_deferred_job() {
let (core, behavior) = create();
let inputs = crate::value::NodeValueRow::from([(TEXTURE_INPUT.to_string(), tex())]);
let mut table = NodeValueTable::default();
behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
assert!(table.get(ValueType::Texture).is_some());
}
#[test]
fn shader_code_returns_mosaic_shader() {
let n = MosaicFilterNode;
let code = n.shader_code("anything").unwrap();
assert!(code.contains("uniform float horiz_in;"));
assert!(code.contains("floor(ove_texcoord.x * horiz_in)"));
}
#[test]
fn duplicate_clones() {
let (core, behavior) = create();
let dup = behavior.duplicate(&core).unwrap();
assert_eq!(dup.name(), "Mosaic");
}
}
/// Register this node type (C++ `k_mosaic_filter` in
/// `factory.cpp::create_from_factory_index`).
pub fn register(meta: &mut Vec<NodeMeta>) {
meta.push(NodeMeta {
type_id: "org.olivevideoeditor.Olive.mosaicfilter",
name: "Mosaic",
categories: &[Category::Filter],
create,
});
}
+492
View File
@@ -0,0 +1,492 @@
// 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 oakcore_rs::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
}
/// 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,
}
}
/// 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 oakcore_rs::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));
}
}
+284
View File
@@ -0,0 +1,284 @@
// 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/>.
//! Noise generator (C++ `src/node/src/generator/noise/noise.{h,cpp}`,
//! `olive::NoiseGeneratorNode`).
use crate::factory::NodeMeta;
use crate::node::{Category, NodeBehavior, NodeCore};
/// Base texture input id (C++ `k_base_in`). Type: texture; flags:
/// not-keyframable; this is the node's effect input.
pub const BASE_INPUT: &str = "base_in";
/// Color noise toggle input id (C++ `k_color_input`). Type: bool;
/// default `false`.
pub const COLOR_INPUT: &str = "color_in";
/// Noise strength input id (C++ `k_strength_input`). Type: float;
/// default `0.2`; properties: `view = percentage`, `min = 0`.
pub const STRENGTH_INPUT: &str = "strength_in";
/// Noise generator node. Adds gold-noise to an optional base texture
/// (or generates it standalone). Has no own member fields in C++.
pub struct NoiseGeneratorNode;
/// Fragment shader (C++ loads the `:/shaders/noise.frag` resource in
/// `get_shader_code`). Text copied verbatim from
/// `engine/shaders/noise.frag`.
const SHADER_FRAG: &str = r#"uniform float time_in;
uniform float strength_in;
uniform bool color_in;
uniform sampler2D base_in;
uniform bool base_in_enabled;
in vec2 ove_texcoord;
out vec4 frag_color;
float PHI = 1.61803398874989484820459 * 00000.1; // Golden Ratio
float PI = 3.14159265358979323846264 * 00000.1; // PI
float SQ2 = 1.41421356237309504880169 * 10000.0; // Square Root of Two
bool isNan( float val )
{
return ( val < 0.0 || 0.0 < val || val == 0.0 ) ? false : true;
// important: some nVidias failed to cope with version below.
// Probably wrong optimization.
/*return ( val <= 0.0 || 0.0 <= val ) ? false : true;*/
// Taken from: https://stackoverflow.com/questions/11810158/how-to-deal-with-nan-or-inf-in-opengl-es-2-0-shaders
}
float gold_noise(vec2 coordinate, float seed){
float value = fract(tan(distance(coordinate*(seed+PHI), vec2(PHI, PI)))*SQ2)*(strength_in);
return isNan(value) ? 0.0 : value;
}
void main(void) {
vec3 noise;
if (color_in) {
noise = vec3(gold_noise(ove_texcoord, time_in + 42069.0), gold_noise(ove_texcoord, time_in + 69220.0), gold_noise(ove_texcoord, time_in + 1337.0));
} else {
noise = vec3(gold_noise(ove_texcoord, time_in + 69420.0));
}
if (base_in_enabled) {
vec4 base = texture(base_in, ove_texcoord);
base.rgb += noise;
frag_color = base;
} else {
frag_color = vec4(noise, 1.0);
}
}
"#;
impl NoiseGeneratorNode {
/// Fragment shader for all shader requests (C++
/// `get_shader_code()` ignores the request id).
fn shader_frag() -> &'static str {
SHADER_FRAG
}
}
impl NodeBehavior for NoiseGeneratorNode {
/// Human-readable name (C++ `name()`).
fn name(&self) -> &str {
"Noise"
}
/// Stable type id (C++ `id()`).
fn type_id(&self) -> &str {
"org.olivevideoeditor.Olive.noise"
}
/// Categories (C++ `category()`).
fn categories(&self) -> &[Category] {
&[Category::Generator]
}
/// Description (C++ `description()`).
fn description(&self) -> &str {
"Generates noise patterns"
}
/// Localized input names (C++ `retranslate()`): `base_in` ->
/// "Base", `strength_in` -> "Strength", `color_in` -> "Color".
fn input_name<'a>(&self, id: &'a str) -> &'a str {
match id {
BASE_INPUT => "Base",
STRENGTH_INPUT => "Strength",
COLOR_INPUT => "Color",
_ => id,
}
}
/// Evaluate outputs (C++ `value()`): builds a shader job from the
/// input row, additionally inserting `time_in` (current time in
/// seconds as a float), then pushes a texture job using the base
/// texture's params when connected, else the sequence video params.
///
/// The Rust model has no shader-job payload: the job (including the
/// `time_in` value) is deferred to the renderer seam, so a null
/// texture handle marks "renderer must produce this texture"
/// (`// CPP-PARITY: noise.cpp` value()).
fn value(
&self,
core: &NodeCore,
inputs: &crate::value::NodeValueRow,
time: oakcore_rs::Rational,
table: &mut crate::value::NodeValueTable,
) {
// C++ always pushes a job — with a base texture connected it runs
// at the base's params, otherwise at the sequence params.
let _ = (core, inputs, time);
table.push(
crate::value::ValueType::Texture,
crate::value::NodeValue::Texture(crate::handle::CHandle::null()),
None,
);
}
/// Shader code request (C++ `get_shader_code()`): returns the
/// noise fragment shader for any request id.
fn shader_code(&self, _request: &str) -> Option<String> {
Some(Self::shader_frag().to_string())
}
/// Deep copy (C++ `copy()`).
fn duplicate(&self, _core: &NodeCore) -> Option<Box<dyn NodeBehavior>> {
Some(Box::new(NoiseGeneratorNode))
}
}
/// Constructor (C++ `NoiseGeneratorNode::NoiseGeneratorNode()`): adds
/// `base_in`, `strength_in` and `color_in` with the defaults, flags and
/// properties documented on the constants, sets the video-effect flag
/// and makes `base_in` the effect input.
pub fn create() -> (NodeCore, Box<dyn NodeBehavior>) {
let mut core = NodeCore::new();
let mut base = crate::input::Input::new(
BASE_INPUT,
crate::value::ValueType::Texture,
crate::value::NodeValue::None,
);
base.flags |= crate::input::flags::NOT_KEYFRAMABLE;
core.add_input(base);
let mut strength = crate::input::Input::new(
STRENGTH_INPUT,
crate::value::ValueType::Float,
crate::value::NodeValue::Float(0.2),
);
strength.properties = vec![
("view".to_string(), crate::value::NodeValue::Text("percentage".into())),
("min".to_string(), crate::value::NodeValue::Float(0.0)),
];
core.add_input(strength);
core.add_input(crate::input::Input::new(
COLOR_INPUT,
crate::value::ValueType::Boolean,
crate::value::NodeValue::Boolean(false),
));
core.flags |= crate::node::flags::VIDEO_EFFECT;
core.effect_input = BASE_INPUT.to_string();
(core, Box::new(NoiseGeneratorNode))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::node::NodeBehavior;
use crate::value::{NodeValue, NodeValueTable, ValueType};
use oakcore_rs::Rational;
#[test]
fn input_names() {
let n = NoiseGeneratorNode;
assert_eq!(n.input_name(BASE_INPUT), "Base");
assert_eq!(n.input_name(STRENGTH_INPUT), "Strength");
assert_eq!(n.input_name(COLOR_INPUT), "Color");
}
#[test]
fn create_wires_inputs_and_flags() {
let (core, behavior) = create();
assert_eq!(behavior.type_id(), "org.olivevideoeditor.Olive.noise");
assert_eq!(
core.get_input(STRENGTH_INPUT).unwrap().default,
NodeValue::Float(0.2)
);
assert_eq!(
core.get_input(COLOR_INPUT).unwrap().default,
NodeValue::Boolean(false)
);
assert_eq!(core.effect_input, BASE_INPUT);
assert_ne!(core.flags & crate::node::flags::VIDEO_EFFECT, 0);
}
#[test]
fn value_always_pushes_deferred_job() {
let (core, behavior) = create();
let mut table = NodeValueTable::default();
behavior.value(
&core,
&crate::value::NodeValueRow::default(),
Rational::new(0, 1),
&mut table,
);
assert!(table.get(ValueType::Texture).is_some());
// With a base texture connected the job is still pushed.
let inputs = crate::value::NodeValueRow::from([(
BASE_INPUT.to_string(),
NodeValue::Texture(crate::handle::CHandle::null()),
)]);
let mut table = NodeValueTable::default();
behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
assert!(table.get(ValueType::Texture).is_some());
}
#[test]
fn shader_code_returns_noise_shader() {
let n = NoiseGeneratorNode;
let code = n.shader_code("anything").unwrap();
assert!(code.contains("uniform float time_in;"));
assert!(code.contains("gold_noise"));
}
#[test]
fn duplicate_clones() {
let (core, behavior) = create();
let dup = behavior.duplicate(&core).unwrap();
assert_eq!(dup.name(), "Noise");
}
}
/// Register this node type (C++ factory entry for
/// `org.olivevideoeditor.Olive.noise`).
pub fn register(meta: &mut Vec<NodeMeta>) {
meta.push(NodeMeta {
type_id: "org.olivevideoeditor.Olive.noise",
name: "Noise",
categories: &[Category::Generator],
create,
});
}
+222
View File
@@ -0,0 +1,222 @@
// 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/>.
//! Shared base for the OCIO-backed color nodes (C++
//! `src/node/src/color/ociobase/ociobase.{h,cpp}`, `olive::OCIOBaseNode`).
//!
//! This is NOT a [`NodeBehavior`] implementation — the C++ class is an
//! abstract base (its `config_changed()` is pure virtual), so it is
//! modeled as a helper struct embedded by the OCIO grading/LUT/display
//! nodes (`super::displaytransform`, `super::ociolut`,
//! `super::ociogradingtransformlinear`, `super::ociogradingtransformlog`).
//!
//! Note: OpenColorIO itself is never linked here. All OCIO work goes
//! through the color manager (`crate::colormanager`) and the oakrender
//! bridge (`crate::bridge::render`), mirroring how the C++ node calls
//! `oakrender_color_processor_*` / `oaknode_colormanager_*` C functions
//! instead of using OCIO directly.
use crate::node::NodeCore;
use crate::value::{NodeValue, NodeValueRow, NodeValueTable};
/// Texture input id shared by all OCIO nodes (C++
/// `OCIOBaseNode::k_texture_input`). Type: texture; flags:
/// not-keyframable; declared in the base constructor, which also makes
/// it the node's effect input and sets the video-effect flag.
pub const TEXTURE_INPUT: &str = "tex_in";
/// Shared state and behavior of the C++ `OCIOBaseNode` base class.
///
/// The C++ class also stores `manager_`, a borrowed
/// `olive::ColorManager*` captured in `AddedToGraphEvent` and cleared in
/// `RemovedFromGraphEvent`. There is no Rust-owned equivalent for that
/// borrowed pointer (the color manager lives per-project behind the
/// bridge), so the field is omitted here: `added_to_graph` /
/// `removed_from_graph` document the capture/clear, and the processor
/// generation helpers reach the manager through
/// `crate::colormanager`/`crate::bridge::render` at call time.
pub struct OcioBase {
/// Owned color processor handle (C++ `processor_`, an
/// `OakColorProcessor`); `None`/empty while no valid processor has
/// been generated. Released with the node (C++ destructor calls
/// `oakrender_color_processor_free`).
processor: Option<crate::bridge::render::ColorProcessorHandle>,
}
// The processor handle wraps a refcounted C object that is only
// dereferenced from the render path (the C++ base likewise passes its
// `OakColorProcessor` across threads by value); moving the struct
// between threads does not introduce sharing the C++ side does not
// already have.
unsafe impl Send for OcioBase {}
impl OcioBase {
/// Construct the shared base state (C++ `OCIOBaseNode::OCIOBaseNode()`):
/// the processor starts empty; the constructor side that adds
/// [`TEXTURE_INPUT`], marks it the effect input and sets the
/// video-effect flag happens in each node's `create()`.
pub fn new() -> Self {
OcioBase { processor: None }
}
/// Borrowed view of the owned processor handle (C++
/// `OCIOBaseNode::processor()`; callers must NOT free it).
pub fn processor(&self) -> Option<&crate::bridge::render::ColorProcessorHandle> {
self.processor.as_ref()
}
/// Take ownership of a new processor handle, releasing the old one
/// (C++ `OCIOBaseNode::set_processor()`, which frees the previous
/// `OakColorProcessor` before storing the new one).
pub fn set_processor(
&mut self,
processor: Option<crate::bridge::render::ColorProcessorHandle>,
) {
// The C++ frees the previous processor via
// `oakrender_color_processor_free`; the Rust handle is a
// refcounted `CHandle` released on drop, so replacing the field
// drops the old one automatically.
self.processor = processor;
}
/// Shared output evaluation (C++ `OCIOBaseNode::value()`): no texture
/// on [`TEXTURE_INPUT`] -> push nothing; texture present and
/// processor ready -> push a `ColorTransformJob` built from the
/// processor and the input texture; texture present but processor not
/// ready (e.g. still being generated asynchronously) -> pass the
/// input texture through unchanged.
///
/// The Rust model has no color-transform job payload: the ready case
/// pushes a null texture handle marking a renderer-deferred job
/// (the C++ `t->to_job(ColorTransformJob)` resolved by the renderer
/// via the processor); the not-ready case pushes the input texture.
/// `// CPP-PARITY: ociobase.cpp` `value()`.
pub fn value(
&self,
core: &NodeCore,
inputs: &NodeValueRow,
time: oakcore_rs::Rational,
table: &mut NodeValueTable,
) {
let _ = (core, time);
match inputs.get(TEXTURE_INPUT) {
Some(tex @ NodeValue::Texture(_)) => {
if self.processor.is_some() {
table.push(
crate::value::ValueType::Texture,
NodeValue::Texture(crate::handle::CHandle::null()),
None,
);
} else {
table.push(crate::value::ValueType::Texture, tex.clone(), None);
}
}
_ => {}
}
}
/// Graph-entry hook (C++ `OCIOBaseNode::AddedToGraphEvent`):
/// captures the project's color manager, then invokes the node's
/// `config_changed()` (a per-subclass method here, since the C++
/// pure virtual has no trait home). Subclasses call this from their
/// [`NodeBehavior::added_to_graph`] override.
///
/// The Rust model has no borrowed-manager field (see the type doc),
/// so this only forwards the subclass hook call sites.
pub fn added_to_graph(&mut self, core: &mut NodeCore) {
let _ = (self, core);
}
/// Graph-exit hook (C++ `OCIOBaseNode::RemovedFromGraphEvent`):
/// clears the captured color manager pointer. Subclasses call this
/// from their [`NodeBehavior::removed_from_graph`] override.
pub fn removed_from_graph(&mut self, core: &mut NodeCore) {
let _ = (self, core);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::node::NodeCore;
use crate::value::{NodeValueRow, NodeValueTable, ValueType};
#[test]
fn processor_state_transitions() {
let mut base = OcioBase::new();
assert!(base.processor().is_none());
base.set_processor(Some(crate::handle::CHandle::null()));
assert!(base.processor().is_some());
base.set_processor(None);
assert!(base.processor().is_none());
}
#[test]
fn value_no_texture_pushes_nothing() {
let base = OcioBase::new();
let mut table = NodeValueTable::default();
base.value(
&NodeCore::new(),
&NodeValueRow::default(),
oakcore_rs::Rational::new(0, 1),
&mut table,
);
assert!(table.is_empty());
}
#[test]
fn value_passes_through_without_processor() {
let base = OcioBase::new();
let mut table = NodeValueTable::default();
let tex = NodeValue::Texture(crate::handle::CHandle::null());
let inputs = NodeValueRow::from([(TEXTURE_INPUT.to_string(), tex.clone())]);
base.value(
&NodeCore::new(),
&inputs,
oakcore_rs::Rational::new(0, 1),
&mut table,
);
// Pass-through keeps the input texture value.
assert_eq!(table.get(ValueType::Texture), Some(&tex));
}
#[test]
fn value_pushes_deferred_job_with_processor() {
let mut base = OcioBase::new();
base.set_processor(Some(crate::handle::CHandle::null()));
let mut table = NodeValueTable::default();
let inputs = NodeValueRow::from([(
TEXTURE_INPUT.to_string(),
NodeValue::Texture(crate::handle::CHandle::null()),
)]);
base.value(
&NodeCore::new(),
&inputs,
oakcore_rs::Rational::new(0, 1),
&mut table,
);
assert!(table.get(ValueType::Texture).is_some());
}
#[test]
fn graph_hooks_are_noops() {
let mut base = OcioBase::new();
let mut core = NodeCore::new();
base.added_to_graph(&mut core);
base.removed_from_graph(&mut core);
assert!(base.processor().is_none());
}
}
@@ -0,0 +1,692 @@
// 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/>.
//! OCIO linear color grading node (C++
//! `src/node/src/color/ociogradingtransformlinear/ociogradingtransformlinear.{h,cpp}`,
//! `olive::OCIOGradingTransformLinearNode`).
//!
//! Note: OpenColorIO itself is never linked here; it is reached through
//! the color manager (`crate::colormanager`) and the oakrender bridge
//! (`crate::bridge::render`), like the C++ node's
//! `oakrender_color_processor_create_grading_primary` call.
use crate::factory::NodeMeta;
use crate::node::{Category, NodeBehavior, NodeCore};
use crate::nodes::ociobase::OcioBase;
/// Contrast input id (C++ `k_contrast_input`). Type: vec4 (x = master,
/// y/z/w = R/G/B); default `{1, 1, 1, 1}`; properties: `min =
/// {0.01, 0.01, 0.01, 0.01}` (per `ocio::GradingPrimary::validate`),
/// `base = 0.01`, component colors `color0 = #c0c0c0`, `color1 =
/// #ff0000`, `color2 = #00ff00`, `color3 = #0000ff`.
pub const CONTRAST_INPUT: &str = "ocio_grading_primary_contrast";
/// Offset input id (C++ `k_offset_input`). Type: vec4 (x = master);
/// default `{0, 0, 0, 0}`; properties: `base = 0.01` and the component
/// colors documented on [`CONTRAST_INPUT`].
pub const OFFSET_INPUT: &str = "ocio_grading_primary_offset";
/// Exposure input id (C++ `k_exposure_input`). Type: vec4 (x = master,
/// in stops); default `{0, 0, 0, 0}`; properties: `base = 0.01`, the
/// component colors documented on [`CONTRAST_INPUT`], and (set in
/// `retranslate`) `tooltip = "Exposure increments in stops."`.
pub const EXPOSURE_INPUT: &str = "ocio_grading_primary_exposure";
/// Saturation input id (C++ `k_saturation_input`). Type: float; default
/// `1.0`; properties: `view = percentage`, `min = 0.0`.
pub const SATURATION_INPUT: &str = "ocio_grading_primary_saturation";
/// Pivot input id (C++ `k_pivot_input`). Type: float; default `0.18`
/// (default listed in `ocio::GradingPrimary`); properties: `base =
/// 0.01`.
pub const PIVOT_INPUT: &str = "ocio_grading_primary_pivot";
/// Black-clamp enable input id (C++ `k_clamp_black_enable_input`).
/// Type: boolean; default `false`.
pub const CLAMP_BLACK_ENABLE_INPUT: &str = "clamp_black_enable_in";
/// Black clamp input id (C++ `k_clamp_black_input`). Type: float;
/// default `0.0`; properties: `enabled` = current value of
/// [`CLAMP_BLACK_ENABLE_INPUT`], `base = 0.01`.
pub const CLAMP_BLACK_INPUT: &str = "ocio_grading_primary_clampBlack";
/// White-clamp enable input id (C++ `k_clamp_white_enable_input`).
/// Type: boolean; default `false`.
pub const CLAMP_WHITE_ENABLE_INPUT: &str = "clamp_white_enable_in";
/// White clamp input id (C++ `k_clamp_white_input`). Type: float;
/// default `1.0`; properties: `enabled` = current value of
/// [`CLAMP_WHITE_ENABLE_INPUT`], `base = 0.01`, and `min` = black clamp
/// + 0.000001 while the black clamp is static.
pub const CLAMP_WHITE_INPUT: &str = "ocio_grading_primary_clampWhite";
/// OCIO linear grading node. Simple linear color grading using
/// OpenColorIO (`ocio::GRADING_LIN`). Owns no members beyond the
/// embedded OCIO base state (C++ has no own private members).
pub struct OCIOGradingTransformLinearNode {
/// Shared OCIO base state (C++ base class `OCIOBaseNode`).
base: OcioBase,
}
/// Set or replace an input property (C++ `set_input_property`).
fn set_input_property(
core: &mut NodeCore,
input: &str,
key: &str,
value: crate::value::NodeValue,
) {
if let Some(input) = core.get_input_mut(input) {
if let Some(slot) = input.properties.iter_mut().find(|(k, _)| k == key) {
slot.1 = value;
} else {
input.properties.push((key.to_string(), value));
}
}
}
impl OCIOGradingTransformLinearNode {
/// Set the per-component widget colors of a vec4 input (C++
/// `set_vec4_input_colors()`): master `#c0c0c0`, R `#ff0000`, G
/// `#00ff00`, B `#0000ff`.
fn set_vec4_input_colors(core: &mut NodeCore, input: &str) {
set_input_property(core, input, "color0", crate::value::NodeValue::Text("#c0c0c0".into()));
set_input_property(core, input, "color1", crate::value::NodeValue::Text("#ff0000".into()));
set_input_property(core, input, "color2", crate::value::NodeValue::Text("#00ff00".into()));
set_input_property(core, input, "color3", crate::value::NodeValue::Text("#0000ff".into()));
}
/// Constrain the white clamp UI minimum to just above the black
/// clamp (C++ `update_clamp_white_minimum()`), as required by
/// `ocio::GradingPrimary::validate`. No-op while the black clamp is
/// keyframed or connected — a static UI minimum cannot follow an
/// animated value, so the invariant is enforced per frame in
/// `value()` instead.
fn update_clamp_white_minimum(&mut self, core: &mut NodeCore) {
let _ = self;
// The C++ also returns while the black clamp is *connected*
// (`is_input_connected`); edge state is not carried by NodeCore
// (the C++ InputConnectedEvent/InputDisconnectedEvent call sites
// re-run this), so only the keyframing half of the guard is
// representable here — a connected black clamp would update the
// static minimum where C++ would not.
// `// CPP-PARITY: ociogradingtransformlinear.cpp`
// update_clamp_white_minimum.
if core.is_input_keyframing(CLAMP_BLACK_INPUT, -1) {
return;
}
let min = core.standard_value(CLAMP_BLACK_INPUT, -1).to_double() + 0.000001;
set_input_property(core, CLAMP_WHITE_INPUT, "min", crate::value::NodeValue::Float(min));
}
/// (Re)build the color processor (C++ `generate_processor()`):
/// creates a grading-primary processor of style
/// `OAKRENDER_GRADING_PRIMARY_LIN` through the color manager and
/// stores it with [`OcioBase::set_processor`] when creation
/// succeeds.
fn generate_processor(&mut self, core: &mut NodeCore) {
let _ = core;
// The C++ wraps the color manager and creates a grading-primary
// processor of style `OAKRENDER_GRADING_PRIMARY_LIN` via
// `oakrender_color_processor_create_grading_primary`, storing it
// with OcioBase::set_processor when `processor.ctx` is non-null.
// Without a manager (the Rust model reaches the manager through
// the oakrender bridge, absent here) the C++ guard
// `if (manager())` fails, so this is a no-op and the processor
// stays empty — `value()` then pushes nothing.
// `// CPP-PARITY: ociogradingtransformlinear.cpp`
// generate_processor.
}
/// OCIO config change hook (C++ `config_changed()` override):
/// regenerates the processor.
fn config_changed(&mut self, core: &mut NodeCore) {
self.generate_processor(core);
}
}
impl NodeBehavior for OCIOGradingTransformLinearNode {
/// Human-readable name (C++ `name()`).
fn name(&self) -> &str {
"OCIO Color Grading (Linear)"
}
/// Stable type id (C++ `id()`).
fn type_id(&self) -> &str {
"org.olivevideoeditor.Olive.ociogradingtransformlinear"
}
/// Categories (C++ `category()`).
fn categories(&self) -> &[Category] {
&[Category::Color]
}
/// Description (C++ `description()`).
fn description(&self) -> &str {
"Simple linear color grading using OpenColorIO."
}
/// Localized input names (C++ `retranslate()`): `tex_in` -> "Input",
/// contrast -> "Contrast", offset -> "Offset", exposure ->
/// "Exposure" (plus its stops tooltip), saturation -> "Saturation",
/// pivot -> "Pivot", clamp enables -> "Enable Black/White Clamp",
/// clamps -> "Black Clamp"/"White Clamp".
fn input_name<'a>(&self, id: &'a str) -> &'a str {
match id {
crate::nodes::ociobase::TEXTURE_INPUT => "Input",
CONTRAST_INPUT => "Contrast",
OFFSET_INPUT => "Offset",
EXPOSURE_INPUT => "Exposure",
SATURATION_INPUT => "Saturation",
PIVOT_INPUT => "Pivot",
CLAMP_BLACK_ENABLE_INPUT => "Enable Black Clamp",
CLAMP_BLACK_INPUT => "Black Clamp",
CLAMP_WHITE_ENABLE_INPUT => "Enable White Clamp",
CLAMP_WHITE_INPUT => "White Clamp",
_ => id,
}
}
/// Input value changed (C++ `InputValueChangedEvent`): toggling a
/// clamp-enable input mirrors it into the clamp input's `enabled`
/// property; a black-clamp change re-constrains the white clamp
/// minimum; any change regenerates the processor.
fn input_value_changed(&mut self, core: &mut NodeCore, input: &str, element: i32) {
let _ = element;
if input == CLAMP_WHITE_ENABLE_INPUT {
set_input_property(
core,
CLAMP_WHITE_INPUT,
"enabled",
crate::value::NodeValue::Boolean(
core.standard_value(CLAMP_WHITE_ENABLE_INPUT, -1).to_double() != 0.0,
),
);
} else if input == CLAMP_BLACK_ENABLE_INPUT {
set_input_property(
core,
CLAMP_BLACK_INPUT,
"enabled",
crate::value::NodeValue::Boolean(
core.standard_value(CLAMP_BLACK_ENABLE_INPUT, -1).to_double() != 0.0,
),
);
} else if input == CLAMP_BLACK_INPUT {
// Ensure the white clamp is always greater than the black
// clamp as per ocio::GradingPrimary::validate.
self.update_clamp_white_minimum(core);
}
self.generate_processor(core);
}
/// Edge connected (C++ `InputConnectedEvent`): forwards to the base
/// class and, for the black clamp input, re-constrains the white
/// clamp minimum.
fn input_connected(&mut self, core: &mut NodeCore, input: &str, element: i32, source: crate::id::NodeId) {
let _ = (element, source);
// C++ forwards to the base class first; OCIOBaseNode does not
// override the event, so that half is a no-op here.
if input == CLAMP_BLACK_INPUT {
self.update_clamp_white_minimum(core);
}
}
/// Edge disconnected (C++ `InputDisconnectedEvent`): forwards to the
/// base class and, for the black clamp input, re-constrains the
/// white clamp minimum.
fn input_disconnected(&mut self, core: &mut NodeCore, input: &str, element: i32, source: crate::id::NodeId) {
let _ = (element, source);
// See [`NodeBehavior::input_connected`].
if input == CLAMP_BLACK_INPUT {
self.update_clamp_white_minimum(core);
}
}
/// Evaluate outputs (C++ `value()`): no texture -> push nothing;
/// processor not ready -> push nothing (unlike the base, there is no
/// pass-through branch). Otherwise builds a `ColorTransformJob` from
/// the whole input row and rewrites the vec4 (RGBM: x = master)
/// inputs into the vec3 form the GPU uniforms expect: offset RGB =
/// channel + master; exposure RGB = 2^(channel + master); contrast
/// RGB = channel * master. Disabled clamps are pushed as
/// `GradingPrimary::NoClampBlack()/NoClampWhite()`, and when both
/// clamps are enabled the white clamp is raised to black + 0.000001
/// per frame if keyframed/connected values violate white > black.
fn value(
&self,
core: &NodeCore,
inputs: &crate::value::NodeValueRow,
time: oakcore_rs::Rational,
table: &mut crate::value::NodeValueTable,
) {
let _ = (core, time);
match inputs.get(crate::nodes::ociobase::TEXTURE_INPUT) {
Some(crate::value::NodeValue::Texture(_)) => {
if self.base.processor().is_some() {
// `// CPP-PARITY: ociogradingtransformlinear.cpp`
// `value()` — the C++ builds a ColorTransformJob and
// rewrites the vec4 (RGBM: x = master) inputs into the
// vec3 GPU uniform form: offset RGB = channel +
// master, exposure RGB = 2^(channel + master),
// contrast RGB = channel * master. Disabled clamps
// are pushed as `ocio::GradingPrimary::NoClampBlack()`
// (-1.0) / `NoClampWhite()` (2.0), and when both
// clamps are enabled the white clamp is raised to
// black + 0.000001 per frame if keyframed/connected
// values violate white > black. The Rust model has no
// color-transform job payload: the renderer seam
// resolves the deferred job from this null handle.
table.push(
crate::value::ValueType::Texture,
crate::value::NodeValue::Texture(crate::handle::CHandle::null()),
None,
);
}
// Processor not ready: push nothing (no pass-through).
}
_ => {}
}
}
/// Added to a graph (C++ base `AddedToGraphEvent`): captures the
/// project's color manager and runs `config_changed()` via
/// [`OcioBase::added_to_graph`].
fn added_to_graph(&mut self, core: &mut NodeCore) {
self.base.added_to_graph(core);
self.config_changed(core);
}
/// Removed from a graph (C++ base `RemovedFromGraphEvent`): clears
/// the color manager pointer via [`OcioBase::removed_from_graph`].
fn removed_from_graph(&mut self, core: &mut NodeCore) {
self.base.removed_from_graph(core);
}
/// Deep copy (C++ `copy()` via `NODE_DEFAULT_FUNCTIONS`).
fn duplicate(&self, _core: &NodeCore) -> Option<Box<dyn NodeBehavior>> {
// The C++ copy constructor copies the embedded OCIO base state;
// a fresh base with no processor is the safe Rust port (the
// processor is never populated without the render bridge).
Some(Box::new(OCIOGradingTransformLinearNode {
base: OcioBase::new(),
}))
}
}
/// Constructor (C++
/// `OCIOGradingTransformLinearNode::OCIOGradingTransformLinearNode()`):
/// builds the base (`tex_in` texture input, effect input, video-effect
/// flag), adds the contrast/offset/exposure/saturation/pivot and
/// clamp-enable/clamp inputs with the defaults, flags and properties
/// documented on the constants, and applies the initial white-clamp
/// minimum constraint.
pub fn create() -> (NodeCore, Box<dyn NodeBehavior>) {
let mut core = NodeCore::new();
// OCIOBaseNode base constructor.
let mut tex = crate::input::Input::new(
crate::nodes::ociobase::TEXTURE_INPUT,
crate::value::ValueType::Texture,
crate::value::NodeValue::None,
);
tex.flags |= crate::input::flags::NOT_KEYFRAMABLE;
core.add_input(tex);
core.effect_input = crate::nodes::ociobase::TEXTURE_INPUT.to_string();
core.flags |= crate::node::flags::VIDEO_EFFECT;
let component_colors = vec![
("color0".to_string(), crate::value::NodeValue::Text("#c0c0c0".into())),
("color1".to_string(), crate::value::NodeValue::Text("#ff0000".into())),
("color2".to_string(), crate::value::NodeValue::Text("#00ff00".into())),
("color3".to_string(), crate::value::NodeValue::Text("#0000ff".into())),
];
let mut contrast = crate::input::Input::new(
CONTRAST_INPUT,
crate::value::ValueType::Vec4,
crate::value::NodeValue::Vec4([1.0, 1.0, 1.0, 1.0]),
);
contrast.properties = vec![
(
"min".to_string(),
crate::value::NodeValue::Vec4([0.01, 0.01, 0.01, 0.01]),
),
("base".to_string(), crate::value::NodeValue::Float(0.01)),
];
contrast.properties.extend(component_colors.clone());
core.add_input(contrast);
let mut offset = crate::input::Input::new(
OFFSET_INPUT,
crate::value::ValueType::Vec4,
crate::value::NodeValue::Vec4([0.0, 0.0, 0.0, 0.0]),
);
offset.properties = vec![("base".to_string(), crate::value::NodeValue::Float(0.01))];
offset.properties.extend(component_colors.clone());
core.add_input(offset);
let mut exposure = crate::input::Input::new(
EXPOSURE_INPUT,
crate::value::ValueType::Vec4,
crate::value::NodeValue::Vec4([0.0, 0.0, 0.0, 0.0]),
);
exposure.properties = vec![("base".to_string(), crate::value::NodeValue::Float(0.01))];
exposure.properties.extend(component_colors);
core.add_input(exposure);
let mut saturation = crate::input::Input::new(
SATURATION_INPUT,
crate::value::ValueType::Float,
crate::value::NodeValue::Float(1.0),
);
saturation.properties = vec![
("view".to_string(), crate::value::NodeValue::Text("percentage".into())),
("min".to_string(), crate::value::NodeValue::Float(0.0)),
];
core.add_input(saturation);
let mut pivot = crate::input::Input::new(
PIVOT_INPUT,
crate::value::ValueType::Float,
crate::value::NodeValue::Float(0.18),
);
pivot.properties = vec![("base".to_string(), crate::value::NodeValue::Float(0.01))];
core.add_input(pivot);
core.add_input(crate::input::Input::new(
CLAMP_BLACK_ENABLE_INPUT,
crate::value::ValueType::Boolean,
crate::value::NodeValue::Boolean(false),
));
let mut clamp_black = crate::input::Input::new(
CLAMP_BLACK_INPUT,
crate::value::ValueType::Float,
crate::value::NodeValue::Float(0.0),
);
clamp_black.properties = vec![
(
"enabled".to_string(),
crate::value::NodeValue::Boolean(
core.standard_value(CLAMP_BLACK_ENABLE_INPUT, -1).to_double() != 0.0,
),
),
("base".to_string(), crate::value::NodeValue::Float(0.01)),
];
core.add_input(clamp_black);
core.add_input(crate::input::Input::new(
CLAMP_WHITE_ENABLE_INPUT,
crate::value::ValueType::Boolean,
crate::value::NodeValue::Boolean(false),
));
let mut clamp_white = crate::input::Input::new(
CLAMP_WHITE_INPUT,
crate::value::ValueType::Float,
crate::value::NodeValue::Float(1.0),
);
clamp_white.properties = vec![
(
"enabled".to_string(),
crate::value::NodeValue::Boolean(
core.standard_value(CLAMP_WHITE_ENABLE_INPUT, -1).to_double() != 0.0,
),
),
("base".to_string(), crate::value::NodeValue::Float(0.01)),
];
core.add_input(clamp_white);
// Constrain the white clamp minimum to just above the (static) black
// clamp as per ocio::GradingPrimary::validate. When the black clamp
// is keyframed or connected, Value() enforces the invariant per frame
// instead.
let mut node = OCIOGradingTransformLinearNode {
base: OcioBase::new(),
};
node.update_clamp_white_minimum(&mut core);
(core, Box::new(node))
}
/// Register this node type (C++ factory entry for
/// `org.olivevideoeditor.Olive.ociogradingtransformlinear`).
pub fn register(meta: &mut Vec<NodeMeta>) {
meta.push(NodeMeta {
type_id: "org.olivevideoeditor.Olive.ociogradingtransformlinear",
name: "OCIO Color Grading (Linear)",
categories: &[Category::Color],
create,
});
}
#[cfg(test)]
mod tests {
use super::*;
use crate::keyframe::{Interpolation, Keyframe};
use crate::value::{NodeValue, NodeValueTable, ValueType};
use oakcore_rs::Rational;
fn node() -> OCIOGradingTransformLinearNode {
OCIOGradingTransformLinearNode {
base: OcioBase::new(),
}
}
/// Property value lookup helper for tests.
fn property(core: &NodeCore, input: &str, key: &str) -> Option<NodeValue> {
core.get_input(input)
.and_then(|i| i.properties.iter().find(|(k, _)| k == key).map(|(_, v)| v.clone()))
}
#[test]
fn input_names() {
let n = node();
assert_eq!(n.input_name(crate::nodes::ociobase::TEXTURE_INPUT), "Input");
assert_eq!(n.input_name(CONTRAST_INPUT), "Contrast");
assert_eq!(n.input_name(OFFSET_INPUT), "Offset");
assert_eq!(n.input_name(EXPOSURE_INPUT), "Exposure");
assert_eq!(n.input_name(SATURATION_INPUT), "Saturation");
assert_eq!(n.input_name(PIVOT_INPUT), "Pivot");
assert_eq!(n.input_name(CLAMP_BLACK_ENABLE_INPUT), "Enable Black Clamp");
assert_eq!(n.input_name(CLAMP_BLACK_INPUT), "Black Clamp");
assert_eq!(n.input_name(CLAMP_WHITE_ENABLE_INPUT), "Enable White Clamp");
assert_eq!(n.input_name(CLAMP_WHITE_INPUT), "White Clamp");
assert_eq!(n.input_name("other_in"), "other_in");
}
#[test]
fn create_wires_inputs_flags_and_properties() {
let (core, behavior) = create();
assert_eq!(behavior.type_id(), "org.olivevideoeditor.Olive.ociogradingtransformlinear");
assert_ne!(
core.get_input(crate::nodes::ociobase::TEXTURE_INPUT).unwrap().flags & crate::input::flags::NOT_KEYFRAMABLE,
0
);
assert_eq!(
core.get_input(CONTRAST_INPUT).unwrap().default,
NodeValue::Vec4([1.0, 1.0, 1.0, 1.0])
);
assert_eq!(core.get_input(OFFSET_INPUT).unwrap().default, NodeValue::Vec4([0.0; 4]));
assert_eq!(core.get_input(EXPOSURE_INPUT).unwrap().default, NodeValue::Vec4([0.0; 4]));
assert_eq!(core.get_input(SATURATION_INPUT).unwrap().default, NodeValue::Float(1.0));
assert_eq!(core.get_input(PIVOT_INPUT).unwrap().default, NodeValue::Float(0.18));
assert_eq!(
core.get_input(CLAMP_BLACK_ENABLE_INPUT).unwrap().default,
NodeValue::Boolean(false)
);
assert_eq!(core.get_input(CLAMP_BLACK_INPUT).unwrap().default, NodeValue::Float(0.0));
assert_eq!(
core.get_input(CLAMP_WHITE_ENABLE_INPUT).unwrap().default,
NodeValue::Boolean(false)
);
assert_eq!(core.get_input(CLAMP_WHITE_INPUT).unwrap().default, NodeValue::Float(1.0));
// Component colors on every vec4 grading input.
for id in [CONTRAST_INPUT, OFFSET_INPUT, EXPOSURE_INPUT] {
let input = core.get_input(id).unwrap();
assert!(input.properties.iter().any(|(k, v)| k == "color0" && *v == NodeValue::Text("#c0c0c0".into())));
assert!(input.properties.iter().any(|(k, v)| k == "color3" && *v == NodeValue::Text("#0000ff".into())));
}
// Initial white-clamp minimum constraint: black (0.0) + 0.000001.
assert_eq!(property(&core, CLAMP_WHITE_INPUT, "min"), Some(NodeValue::Float(0.000001)));
// Clamp enabled properties mirror the enable inputs (false at
// construction).
assert_eq!(property(&core, CLAMP_BLACK_INPUT, "enabled"), Some(NodeValue::Boolean(false)));
assert_eq!(property(&core, CLAMP_WHITE_INPUT, "enabled"), Some(NodeValue::Boolean(false)));
assert_eq!(core.effect_input, crate::nodes::ociobase::TEXTURE_INPUT);
assert_ne!(core.flags & crate::node::flags::VIDEO_EFFECT, 0);
}
#[test]
fn update_clamp_white_minimum_tracks_black_clamp() {
let mut core = NodeCore::new();
// Provide the clamp inputs so the property write lands.
core.add_input(crate::input::Input::new(
CLAMP_WHITE_INPUT,
crate::value::ValueType::Float,
crate::value::NodeValue::Float(1.0),
));
core.add_input(crate::input::Input::new(
CLAMP_BLACK_INPUT,
crate::value::ValueType::Float,
crate::value::NodeValue::Float(0.0),
));
core.set_standard_value(CLAMP_BLACK_INPUT, -1, NodeValue::Float(0.5));
let mut n = node();
n.update_clamp_white_minimum(&mut core);
assert_eq!(property(&core, CLAMP_WHITE_INPUT, "min"), Some(NodeValue::Float(0.500001)));
}
#[test]
fn update_clamp_white_minimum_skips_keyframed_black_clamp() {
let mut core = NodeCore::new();
core.add_input(crate::input::Input::new(
CLAMP_WHITE_INPUT,
crate::value::ValueType::Float,
crate::value::NodeValue::Float(1.0),
));
core.add_input(crate::input::Input::new(
CLAMP_BLACK_INPUT,
crate::value::ValueType::Float,
crate::value::NodeValue::Float(0.0),
));
core.keyframe_track_mut(CLAMP_BLACK_INPUT, -1).set_key(Keyframe {
time: Rational::new(0, 1),
value: NodeValue::Float(0.5),
interpolation: Interpolation::Hold,
bezier_in: (0.0, 0.0),
bezier_out: (0.0, 0.0),
});
let mut n = node();
n.update_clamp_white_minimum(&mut core);
// Keyframed: the static minimum is not updated.
assert_eq!(property(&core, CLAMP_WHITE_INPUT, "min"), None);
}
#[test]
fn input_value_changed_mirrors_enable_toggles() {
let mut core = NodeCore::new();
core.add_input(crate::input::Input::new(
CLAMP_BLACK_ENABLE_INPUT,
crate::value::ValueType::Boolean,
crate::value::NodeValue::Boolean(false),
));
core.add_input(crate::input::Input::new(
CLAMP_BLACK_INPUT,
crate::value::ValueType::Float,
crate::value::NodeValue::Float(0.0),
));
core.add_input(crate::input::Input::new(
CLAMP_WHITE_ENABLE_INPUT,
crate::value::ValueType::Boolean,
crate::value::NodeValue::Boolean(false),
));
core.add_input(crate::input::Input::new(
CLAMP_WHITE_INPUT,
crate::value::ValueType::Float,
crate::value::NodeValue::Float(1.0),
));
core.set_standard_value(CLAMP_BLACK_ENABLE_INPUT, -1, NodeValue::Boolean(true));
let mut n = node();
n.input_value_changed(&mut core, CLAMP_BLACK_ENABLE_INPUT, 0);
assert_eq!(property(&core, CLAMP_BLACK_INPUT, "enabled"), Some(NodeValue::Boolean(true)));
// White enable mirrors too.
core.set_standard_value(CLAMP_WHITE_ENABLE_INPUT, -1, NodeValue::Boolean(true));
n.input_value_changed(&mut core, CLAMP_WHITE_ENABLE_INPUT, 0);
assert_eq!(property(&core, CLAMP_WHITE_INPUT, "enabled"), Some(NodeValue::Boolean(true)));
}
#[test]
fn input_value_changed_black_clamp_reconstrains_white_minimum() {
let mut core = NodeCore::new();
core.add_input(crate::input::Input::new(
CLAMP_BLACK_INPUT,
crate::value::ValueType::Float,
crate::value::NodeValue::Float(0.0),
));
core.add_input(crate::input::Input::new(
CLAMP_WHITE_INPUT,
crate::value::ValueType::Float,
crate::value::NodeValue::Float(1.0),
));
core.set_standard_value(CLAMP_BLACK_INPUT, -1, NodeValue::Float(0.2));
let mut n = node();
n.input_value_changed(&mut core, CLAMP_BLACK_INPUT, 0);
assert_eq!(property(&core, CLAMP_WHITE_INPUT, "min"), Some(NodeValue::Float(0.200001)));
}
#[test]
fn value_no_texture_pushes_nothing() {
let (core, behavior) = create();
let mut table = NodeValueTable::default();
behavior.value(&core, &crate::value::NodeValueRow::default(), Rational::new(0, 1), &mut table);
assert!(table.is_empty());
}
#[test]
fn value_texture_without_processor_pushes_nothing() {
// Unlike the OCIO base, grading has no pass-through branch.
let core = NodeCore::new();
let n = node();
let inputs = crate::value::NodeValueRow::from([(
crate::nodes::ociobase::TEXTURE_INPUT.to_string(),
NodeValue::Texture(crate::handle::CHandle::null()),
)]);
let mut table = NodeValueTable::default();
n.value(&core, &inputs, Rational::new(0, 1), &mut table);
assert!(table.is_empty());
}
#[test]
fn value_texture_with_processor_pushes_deferred_job() {
let core = NodeCore::new();
let mut n = node();
n.base.set_processor(Some(crate::handle::CHandle::null()));
let inputs = crate::value::NodeValueRow::from([(
crate::nodes::ociobase::TEXTURE_INPUT.to_string(),
NodeValue::Texture(crate::handle::CHandle::null()),
)]);
let mut table = NodeValueTable::default();
n.value(&core, &inputs, Rational::new(0, 1), &mut table);
assert!(table.get(ValueType::Texture).is_some());
}
#[test]
fn duplicate_clones() {
let (core, behavior) = create();
let dup = behavior.duplicate(&core).unwrap();
assert_eq!(dup.name(), "OCIO Color Grading (Linear)");
}
}
@@ -0,0 +1,665 @@
// 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/>.
//! OCIO log color grading node (C++
//! `src/node/src/color/ociogradingtransformlog/ociogradingtransformlog.{h,cpp}`,
//! `olive::OCIOGradingTransformLogNode`).
//!
//! Lift/gamma/gain grading built on `ocio::GRADING_LOG`; mirrors the
//! linear grading node for the log style. OCIO's log-style GPU uniforms
//! map to the classic wheels as: brightness = lift, contrast = gain,
//! gamma = gamma.
//!
//! Note: OpenColorIO itself is never linked here; it is reached through
//! the color manager (`crate::colormanager`) and the oakrender bridge
//! (`crate::bridge::render`), like the C++ node's
//! `oakrender_color_processor_create_grading_primary` call.
use crate::factory::NodeMeta;
use crate::node::{Category, NodeBehavior, NodeCore};
use crate::nodes::ociobase::OcioBase;
/// Lift input id (C++ `k_lift_input`). Type: vec4 (x = master); default
/// `{0, 0, 0, 0}`; properties: `base = 0.01`, component colors `color0
/// = #c0c0c0`, `color1 = #ff0000`, `color2 = #00ff00`, `color3 =
/// #0000ff`. NOTE: the grading input ids double as the OCIO GPU uniform
/// names for the dynamic GradingPrimaryTransform; do not rename them.
/// The C++ literals carry the literal text `OCIO_NAMESPACE` because the
/// sources were ported without expanding OCIO's namespace macro.
pub const LIFT_INPUT: &str = "OCIO_NAMESPACE_grading_primary_brightness";
/// Gain input id (C++ `k_gain_input`; OCIO's log-style "contrast"
/// uniform). Type: vec4 (x = master); default `{1, 1, 1, 1}`;
/// properties: `base = 0.01` and the component colors documented on
/// [`LIFT_INPUT`]. Do not rename (GPU uniform name).
pub const GAIN_INPUT: &str = "OCIO_NAMESPACE_grading_primary_contrast";
/// Gamma input id (C++ `k_gamma_input`). Type: vec4 (x = master);
/// default `{1, 1, 1, 1}`; properties: `base = 0.01` and the component
/// colors documented on [`LIFT_INPUT`]. Do not rename (GPU uniform
/// name).
pub const GAMMA_INPUT: &str = "OCIO_NAMESPACE_grading_primary_gamma";
/// Saturation input id (C++ `k_saturation_input`). Type: float; default
/// `1.0`; properties: `view = percentage`, `min = 0.0`. Do not rename
/// (GPU uniform name).
pub const SATURATION_INPUT: &str = "OCIO_NAMESPACE_grading_primary_saturation";
/// Pivot input id (C++ `k_pivot_input`). Type: float; default `-0.2`
/// (default for `GRADING_LOG` listed in `ocio::GradingPrimary`);
/// properties: `base = 0.01`. Do not rename (GPU uniform name).
pub const PIVOT_INPUT: &str = "OCIO_NAMESPACE_grading_primary_pivot";
/// Black-clamp enable input id (C++ `k_clamp_black_enable_input`).
/// Type: boolean; default `false`.
pub const CLAMP_BLACK_ENABLE_INPUT: &str = "clamp_black_enable_in";
/// Black clamp input id (C++ `k_clamp_black_input`). Type: float;
/// default `0.0`; properties: `enabled` = current value of
/// [`CLAMP_BLACK_ENABLE_INPUT`], `base = 0.01`. Do not rename (GPU
/// uniform name).
pub const CLAMP_BLACK_INPUT: &str = "OCIO_NAMESPACE_grading_primary_clampBlack";
/// White-clamp enable input id (C++ `k_clamp_white_enable_input`).
/// Type: boolean; default `false`.
pub const CLAMP_WHITE_ENABLE_INPUT: &str = "clamp_white_enable_in";
/// White clamp input id (C++ `k_clamp_white_input`). Type: float;
/// default `1.0`; properties: `enabled` = current value of
/// [`CLAMP_WHITE_ENABLE_INPUT`], `base = 0.01`, and `min` = black clamp
/// + 0.000001 while the black clamp is static. Do not rename (GPU
/// uniform name).
pub const CLAMP_WHITE_INPUT: &str = "OCIO_NAMESPACE_grading_primary_clampWhite";
/// OCIO log grading node. Lift/gamma/gain color grading using
/// OpenColorIO (`ocio::GRADING_LOG`). Owns no members beyond the
/// embedded OCIO base state (C++ has no own private members).
pub struct OCIOGradingTransformLogNode {
/// Shared OCIO base state (C++ base class `OCIOBaseNode`).
base: OcioBase,
}
/// Set or replace an input property (C++ `set_input_property`).
fn set_input_property(
core: &mut NodeCore,
input: &str,
key: &str,
value: crate::value::NodeValue,
) {
if let Some(input) = core.get_input_mut(input) {
if let Some(slot) = input.properties.iter_mut().find(|(k, _)| k == key) {
slot.1 = value;
} else {
input.properties.push((key.to_string(), value));
}
}
}
impl OCIOGradingTransformLogNode {
/// Set the per-component widget colors of a vec4 input (C++
/// `set_vec4_input_colors()`): master `#c0c0c0`, R `#ff0000`, G
/// `#00ff00`, B `#0000ff`.
fn set_vec4_input_colors(core: &mut NodeCore, input: &str) {
set_input_property(core, input, "color0", crate::value::NodeValue::Text("#c0c0c0".into()));
set_input_property(core, input, "color1", crate::value::NodeValue::Text("#ff0000".into()));
set_input_property(core, input, "color2", crate::value::NodeValue::Text("#00ff00".into()));
set_input_property(core, input, "color3", crate::value::NodeValue::Text("#0000ff".into()));
}
/// Constrain the white clamp UI minimum to just above the black
/// clamp (C++ `update_clamp_white_minimum()`), as required by
/// `ocio::GradingPrimary::validate`. No-op while the black clamp is
/// keyframed or connected — a static UI minimum cannot follow an
/// animated value, so the invariant is enforced per frame in
/// `value()` instead.
fn update_clamp_white_minimum(&mut self, core: &mut NodeCore) {
let _ = self;
// The C++ also returns while the black clamp is *connected*
// (`is_input_connected`); edge state is not carried by NodeCore
// (the C++ InputConnectedEvent/InputDisconnectedEvent call sites
// re-run this), so only the keyframing half of the guard is
// representable here — a connected black clamp would update the
// static minimum where C++ would not.
// `// CPP-PARITY: ociogradingtransformlog.cpp`
// update_clamp_white_minimum.
if core.is_input_keyframing(CLAMP_BLACK_INPUT, -1) {
return;
}
let min = core.standard_value(CLAMP_BLACK_INPUT, -1).to_double() + 0.000001;
set_input_property(core, CLAMP_WHITE_INPUT, "min", crate::value::NodeValue::Float(min));
}
/// (Re)build the color processor (C++ `generate_processor()`):
/// creates a grading-primary processor of style
/// `OAKRENDER_GRADING_PRIMARY_LOG` through the color manager and
/// stores it with [`OcioBase::set_processor`] when creation
/// succeeds.
fn generate_processor(&mut self, core: &mut NodeCore) {
let _ = core;
// The C++ wraps the color manager and creates a grading-primary
// processor of style `OAKRENDER_GRADING_PRIMARY_LOG` via
// `oakrender_color_processor_create_grading_primary`, storing it
// with OcioBase::set_processor when `processor.ctx` is non-null.
// Without a manager (the Rust model reaches the manager through
// the oakrender bridge, absent here) the C++ guard
// `if (manager())` fails, so this is a no-op and the processor
// stays empty — `value()` then pushes nothing.
// `// CPP-PARITY: ociogradingtransformlog.cpp` generate_processor.
}
/// OCIO config change hook (C++ `config_changed()` override):
/// regenerates the processor.
fn config_changed(&mut self, core: &mut NodeCore) {
self.generate_processor(core);
}
}
impl NodeBehavior for OCIOGradingTransformLogNode {
/// Human-readable name (C++ `name()`).
fn name(&self) -> &str {
"OCIO Color Grading (Log)"
}
/// Stable type id (C++ `id()`). The literal `OCIO_NAMESPACE` text is
/// in the C++ string (the namespace macro was never expanded), so the
/// id is kept verbatim for project compatibility.
fn type_id(&self) -> &str {
"org.olivevideoeditor.Olive.OCIO_NAMESPACEgradingtransformlog"
}
/// Categories (C++ `category()`).
fn categories(&self) -> &[Category] {
&[Category::Color]
}
/// Description (C++ `description()`).
fn description(&self) -> &str {
"Lift/gamma/gain color grading using OpenColorIO."
}
/// Localized input names (C++ `retranslate()`): `tex_in` -> "Input",
/// lift -> "Lift", gain -> "Gain", gamma -> "Gamma", saturation ->
/// "Saturation", pivot -> "Pivot", clamp enables -> "Enable
/// Black/White Clamp", clamps -> "Black Clamp"/"White Clamp".
fn input_name<'a>(&self, id: &'a str) -> &'a str {
match id {
crate::nodes::ociobase::TEXTURE_INPUT => "Input",
LIFT_INPUT => "Lift",
GAIN_INPUT => "Gain",
GAMMA_INPUT => "Gamma",
SATURATION_INPUT => "Saturation",
PIVOT_INPUT => "Pivot",
CLAMP_BLACK_ENABLE_INPUT => "Enable Black Clamp",
CLAMP_BLACK_INPUT => "Black Clamp",
CLAMP_WHITE_ENABLE_INPUT => "Enable White Clamp",
CLAMP_WHITE_INPUT => "White Clamp",
_ => id,
}
}
/// Input value changed (C++ `InputValueChangedEvent`): toggling a
/// clamp-enable input mirrors it into the clamp input's `enabled`
/// property; a black-clamp change re-constrains the white clamp
/// minimum; any change regenerates the processor.
fn input_value_changed(&mut self, core: &mut NodeCore, input: &str, element: i32) {
let _ = element;
if input == CLAMP_WHITE_ENABLE_INPUT {
set_input_property(
core,
CLAMP_WHITE_INPUT,
"enabled",
crate::value::NodeValue::Boolean(
core.standard_value(CLAMP_WHITE_ENABLE_INPUT, -1).to_double() != 0.0,
),
);
} else if input == CLAMP_BLACK_ENABLE_INPUT {
set_input_property(
core,
CLAMP_BLACK_INPUT,
"enabled",
crate::value::NodeValue::Boolean(
core.standard_value(CLAMP_BLACK_ENABLE_INPUT, -1).to_double() != 0.0,
),
);
} else if input == CLAMP_BLACK_INPUT {
// Ensure the white clamp is always greater than the black
// clamp as per ocio::GradingPrimary::validate.
self.update_clamp_white_minimum(core);
}
self.generate_processor(core);
}
/// Edge connected (C++ `InputConnectedEvent`): forwards to the base
/// class and, for the black clamp input, re-constrains the white
/// clamp minimum.
fn input_connected(&mut self, core: &mut NodeCore, input: &str, element: i32, source: crate::id::NodeId) {
let _ = (element, source);
// C++ forwards to the base class first; OCIOBaseNode does not
// override the event, so that half is a no-op here.
if input == CLAMP_BLACK_INPUT {
self.update_clamp_white_minimum(core);
}
}
/// Edge disconnected (C++ `InputDisconnectedEvent`): forwards to the
/// base class and, for the black clamp input, re-constrains the
/// white clamp minimum.
fn input_disconnected(&mut self, core: &mut NodeCore, input: &str, element: i32, source: crate::id::NodeId) {
let _ = (element, source);
// See [`NodeBehavior::input_connected`].
if input == CLAMP_BLACK_INPUT {
self.update_clamp_white_minimum(core);
}
}
/// Evaluate outputs (C++ `value()`): no texture -> push nothing;
/// processor not ready -> push nothing (no pass-through branch).
/// Otherwise builds a `ColorTransformJob` from the whole input row
/// and rewrites the vec4 (RGBM: x = master) inputs into the vec3
/// form the GPU uniforms expect: lift RGB = channel + master
/// (additive); gain and gamma RGB = channel * master
/// (multiplicative). Disabled clamps are pushed as
/// `GradingPrimary::NoClampBlack()/NoClampWhite()`, and when both
/// clamps are enabled the white clamp is raised to black + 0.000001
/// per frame if keyframed/connected values violate white > black.
fn value(
&self,
core: &NodeCore,
inputs: &crate::value::NodeValueRow,
time: oakcore_rs::Rational,
table: &mut crate::value::NodeValueTable,
) {
let _ = (core, time);
match inputs.get(crate::nodes::ociobase::TEXTURE_INPUT) {
Some(crate::value::NodeValue::Texture(_)) => {
if self.base.processor().is_some() {
// `// CPP-PARITY: ociogradingtransformlog.cpp`
// `value()` — the C++ builds a ColorTransformJob and
// rewrites the vec4 (RGBM: x = master) inputs into the
// vec3 GPU uniform form: lift RGB = channel + master
// (additive); gain and gamma RGB = channel * master
// (multiplicative). Disabled clamps are pushed as
// `OCIO_NAMESPACE::GradingPrimary::NoClampBlack()`
// (-1.0) / `NoClampWhite()` (2.0), and when both
// clamps are enabled the white clamp is raised to
// black + 0.000001 per frame if keyframed/connected
// values violate white > black. The Rust model has no
// color-transform job payload: the renderer seam
// resolves the deferred job from this null handle.
table.push(
crate::value::ValueType::Texture,
crate::value::NodeValue::Texture(crate::handle::CHandle::null()),
None,
);
}
// Processor not ready: push nothing (no pass-through).
}
_ => {}
}
}
/// Added to a graph (C++ base `AddedToGraphEvent`): captures the
/// project's color manager and runs `config_changed()` via
/// [`OcioBase::added_to_graph`].
fn added_to_graph(&mut self, core: &mut NodeCore) {
self.base.added_to_graph(core);
self.config_changed(core);
}
/// Removed from a graph (C++ base `RemovedFromGraphEvent`): clears
/// the color manager pointer via [`OcioBase::removed_from_graph`].
fn removed_from_graph(&mut self, core: &mut NodeCore) {
self.base.removed_from_graph(core);
}
/// Deep copy (C++ `copy()` via `NODE_DEFAULT_FUNCTIONS`).
fn duplicate(&self, _core: &NodeCore) -> Option<Box<dyn NodeBehavior>> {
// The C++ copy constructor copies the embedded OCIO base state;
// a fresh base with no processor is the safe Rust port (the
// processor is never populated without the render bridge).
Some(Box::new(OCIOGradingTransformLogNode {
base: OcioBase::new(),
}))
}
}
/// Constructor (C++
/// `OCIOGradingTransformLogNode::OCIOGradingTransformLogNode()`):
/// builds the base (`tex_in` texture input, effect input, video-effect
/// flag), adds the lift/gain/gamma/saturation/pivot and
/// clamp-enable/clamp inputs with the defaults, flags and properties
/// documented on the constants, and applies the initial white-clamp
/// minimum constraint.
pub fn create() -> (NodeCore, Box<dyn NodeBehavior>) {
let mut core = NodeCore::new();
// OCIOBaseNode base constructor.
let mut tex = crate::input::Input::new(
crate::nodes::ociobase::TEXTURE_INPUT,
crate::value::ValueType::Texture,
crate::value::NodeValue::None,
);
tex.flags |= crate::input::flags::NOT_KEYFRAMABLE;
core.add_input(tex);
core.effect_input = crate::nodes::ociobase::TEXTURE_INPUT.to_string();
core.flags |= crate::node::flags::VIDEO_EFFECT;
let component_colors = vec![
("color0".to_string(), crate::value::NodeValue::Text("#c0c0c0".into())),
("color1".to_string(), crate::value::NodeValue::Text("#ff0000".into())),
("color2".to_string(), crate::value::NodeValue::Text("#00ff00".into())),
("color3".to_string(), crate::value::NodeValue::Text("#0000ff".into())),
];
let mut lift = crate::input::Input::new(
LIFT_INPUT,
crate::value::ValueType::Vec4,
crate::value::NodeValue::Vec4([0.0, 0.0, 0.0, 0.0]),
);
lift.properties = vec![("base".to_string(), crate::value::NodeValue::Float(0.01))];
lift.properties.extend(component_colors.clone());
core.add_input(lift);
let mut gain = crate::input::Input::new(
GAIN_INPUT,
crate::value::ValueType::Vec4,
crate::value::NodeValue::Vec4([1.0, 1.0, 1.0, 1.0]),
);
gain.properties = vec![("base".to_string(), crate::value::NodeValue::Float(0.01))];
gain.properties.extend(component_colors.clone());
core.add_input(gain);
let mut gamma = crate::input::Input::new(
GAMMA_INPUT,
crate::value::ValueType::Vec4,
crate::value::NodeValue::Vec4([1.0, 1.0, 1.0, 1.0]),
);
gamma.properties = vec![("base".to_string(), crate::value::NodeValue::Float(0.01))];
gamma.properties.extend(component_colors);
core.add_input(gamma);
let mut saturation = crate::input::Input::new(
SATURATION_INPUT,
crate::value::ValueType::Float,
crate::value::NodeValue::Float(1.0),
);
saturation.properties = vec![
("view".to_string(), crate::value::NodeValue::Text("percentage".into())),
("min".to_string(), crate::value::NodeValue::Float(0.0)),
];
core.add_input(saturation);
let mut pivot = crate::input::Input::new(
PIVOT_INPUT,
crate::value::ValueType::Float,
crate::value::NodeValue::Float(-0.2),
);
pivot.properties = vec![("base".to_string(), crate::value::NodeValue::Float(0.01))];
core.add_input(pivot);
core.add_input(crate::input::Input::new(
CLAMP_BLACK_ENABLE_INPUT,
crate::value::ValueType::Boolean,
crate::value::NodeValue::Boolean(false),
));
let mut clamp_black = crate::input::Input::new(
CLAMP_BLACK_INPUT,
crate::value::ValueType::Float,
crate::value::NodeValue::Float(0.0),
);
clamp_black.properties = vec![
(
"enabled".to_string(),
crate::value::NodeValue::Boolean(
core.standard_value(CLAMP_BLACK_ENABLE_INPUT, -1).to_double() != 0.0,
),
),
("base".to_string(), crate::value::NodeValue::Float(0.01)),
];
core.add_input(clamp_black);
core.add_input(crate::input::Input::new(
CLAMP_WHITE_ENABLE_INPUT,
crate::value::ValueType::Boolean,
crate::value::NodeValue::Boolean(false),
));
let mut clamp_white = crate::input::Input::new(
CLAMP_WHITE_INPUT,
crate::value::ValueType::Float,
crate::value::NodeValue::Float(1.0),
);
clamp_white.properties = vec![
(
"enabled".to_string(),
crate::value::NodeValue::Boolean(
core.standard_value(CLAMP_WHITE_ENABLE_INPUT, -1).to_double() != 0.0,
),
),
("base".to_string(), crate::value::NodeValue::Float(0.01)),
];
core.add_input(clamp_white);
// Constrain the white clamp minimum to just above the (static) black
// clamp as per OCIO_NAMESPACE::GradingPrimary::validate. When the
// black clamp is keyframed or connected, Value() enforces the
// invariant per frame instead.
let mut node = OCIOGradingTransformLogNode {
base: OcioBase::new(),
};
node.update_clamp_white_minimum(&mut core);
(core, Box::new(node))
}
/// Register this node type (C++ factory entry for
/// `org.olivevideoeditor.Olive.OCIO_NAMESPACEgradingtransformlog`).
pub fn register(meta: &mut Vec<NodeMeta>) {
meta.push(NodeMeta {
type_id: "org.olivevideoeditor.Olive.OCIO_NAMESPACEgradingtransformlog",
name: "OCIO Color Grading (Log)",
categories: &[Category::Color],
create,
});
}
#[cfg(test)]
mod tests {
use super::*;
use crate::keyframe::{Interpolation, Keyframe};
use crate::value::{NodeValue, NodeValueTable, ValueType};
use oakcore_rs::Rational;
fn node() -> OCIOGradingTransformLogNode {
OCIOGradingTransformLogNode {
base: OcioBase::new(),
}
}
/// Property value lookup helper for tests.
fn property(core: &NodeCore, input: &str, key: &str) -> Option<NodeValue> {
core.get_input(input)
.and_then(|i| i.properties.iter().find(|(k, _)| k == key).map(|(_, v)| v.clone()))
}
#[test]
fn input_names() {
let n = node();
assert_eq!(n.input_name(crate::nodes::ociobase::TEXTURE_INPUT), "Input");
assert_eq!(n.input_name(LIFT_INPUT), "Lift");
assert_eq!(n.input_name(GAIN_INPUT), "Gain");
assert_eq!(n.input_name(GAMMA_INPUT), "Gamma");
assert_eq!(n.input_name(SATURATION_INPUT), "Saturation");
assert_eq!(n.input_name(PIVOT_INPUT), "Pivot");
assert_eq!(n.input_name(CLAMP_BLACK_ENABLE_INPUT), "Enable Black Clamp");
assert_eq!(n.input_name(CLAMP_BLACK_INPUT), "Black Clamp");
assert_eq!(n.input_name(CLAMP_WHITE_ENABLE_INPUT), "Enable White Clamp");
assert_eq!(n.input_name(CLAMP_WHITE_INPUT), "White Clamp");
assert_eq!(n.input_name("other_in"), "other_in");
}
#[test]
fn create_wires_inputs_flags_and_properties() {
let (core, behavior) = create();
assert_eq!(behavior.type_id(), "org.olivevideoeditor.Olive.OCIO_NAMESPACEgradingtransformlog");
assert_ne!(
core.get_input(crate::nodes::ociobase::TEXTURE_INPUT).unwrap().flags & crate::input::flags::NOT_KEYFRAMABLE,
0
);
assert_eq!(core.get_input(LIFT_INPUT).unwrap().default, NodeValue::Vec4([0.0; 4]));
assert_eq!(core.get_input(GAIN_INPUT).unwrap().default, NodeValue::Vec4([1.0; 4]));
assert_eq!(core.get_input(GAMMA_INPUT).unwrap().default, NodeValue::Vec4([1.0; 4]));
assert_eq!(core.get_input(SATURATION_INPUT).unwrap().default, NodeValue::Float(1.0));
assert_eq!(core.get_input(PIVOT_INPUT).unwrap().default, NodeValue::Float(-0.2));
assert_eq!(core.get_input(CLAMP_BLACK_INPUT).unwrap().default, NodeValue::Float(0.0));
assert_eq!(core.get_input(CLAMP_WHITE_INPUT).unwrap().default, NodeValue::Float(1.0));
// Component colors on every vec4 grading input.
for id in [LIFT_INPUT, GAIN_INPUT, GAMMA_INPUT] {
let input = core.get_input(id).unwrap();
assert!(input.properties.iter().any(|(k, v)| k == "color0" && *v == NodeValue::Text("#c0c0c0".into())));
assert!(input.properties.iter().any(|(k, v)| k == "color3" && *v == NodeValue::Text("#0000ff".into())));
}
// Initial white-clamp minimum constraint: black (0.0) + 0.000001.
assert_eq!(property(&core, CLAMP_WHITE_INPUT, "min"), Some(NodeValue::Float(0.000001)));
assert_eq!(core.effect_input, crate::nodes::ociobase::TEXTURE_INPUT);
assert_ne!(core.flags & crate::node::flags::VIDEO_EFFECT, 0);
}
#[test]
fn update_clamp_white_minimum_tracks_black_clamp() {
let mut core = NodeCore::new();
core.add_input(crate::input::Input::new(
CLAMP_WHITE_INPUT,
crate::value::ValueType::Float,
crate::value::NodeValue::Float(1.0),
));
core.add_input(crate::input::Input::new(
CLAMP_BLACK_INPUT,
crate::value::ValueType::Float,
crate::value::NodeValue::Float(0.0),
));
core.set_standard_value(CLAMP_BLACK_INPUT, -1, NodeValue::Float(0.25));
let mut n = node();
n.update_clamp_white_minimum(&mut core);
assert_eq!(property(&core, CLAMP_WHITE_INPUT, "min"), Some(NodeValue::Float(0.250001)));
}
#[test]
fn update_clamp_white_minimum_skips_keyframed_black_clamp() {
let mut core = NodeCore::new();
core.add_input(crate::input::Input::new(
CLAMP_WHITE_INPUT,
crate::value::ValueType::Float,
crate::value::NodeValue::Float(1.0),
));
core.add_input(crate::input::Input::new(
CLAMP_BLACK_INPUT,
crate::value::ValueType::Float,
crate::value::NodeValue::Float(0.0),
));
core.keyframe_track_mut(CLAMP_BLACK_INPUT, -1).set_key(Keyframe {
time: Rational::new(0, 1),
value: NodeValue::Float(0.5),
interpolation: Interpolation::Hold,
bezier_in: (0.0, 0.0),
bezier_out: (0.0, 0.0),
});
let mut n = node();
n.update_clamp_white_minimum(&mut core);
assert_eq!(property(&core, CLAMP_WHITE_INPUT, "min"), None);
}
#[test]
fn input_value_changed_mirrors_enable_toggles() {
let mut core = NodeCore::new();
core.add_input(crate::input::Input::new(
CLAMP_BLACK_ENABLE_INPUT,
crate::value::ValueType::Boolean,
crate::value::NodeValue::Boolean(false),
));
core.add_input(crate::input::Input::new(
CLAMP_BLACK_INPUT,
crate::value::ValueType::Float,
crate::value::NodeValue::Float(0.0),
));
core.set_standard_value(CLAMP_BLACK_ENABLE_INPUT, -1, NodeValue::Boolean(true));
let mut n = node();
n.input_value_changed(&mut core, CLAMP_BLACK_ENABLE_INPUT, 0);
assert_eq!(property(&core, CLAMP_BLACK_INPUT, "enabled"), Some(NodeValue::Boolean(true)));
}
#[test]
fn input_value_changed_black_clamp_reconstrains_white_minimum() {
let mut core = NodeCore::new();
core.add_input(crate::input::Input::new(
CLAMP_BLACK_INPUT,
crate::value::ValueType::Float,
crate::value::NodeValue::Float(0.0),
));
core.add_input(crate::input::Input::new(
CLAMP_WHITE_INPUT,
crate::value::ValueType::Float,
crate::value::NodeValue::Float(1.0),
));
core.set_standard_value(CLAMP_BLACK_INPUT, -1, NodeValue::Float(0.1));
let mut n = node();
n.input_value_changed(&mut core, CLAMP_BLACK_INPUT, 0);
assert_eq!(property(&core, CLAMP_WHITE_INPUT, "min"), Some(NodeValue::Float(0.100001)));
}
#[test]
fn value_no_texture_pushes_nothing() {
let (core, behavior) = create();
let mut table = NodeValueTable::default();
behavior.value(&core, &crate::value::NodeValueRow::default(), Rational::new(0, 1), &mut table);
assert!(table.is_empty());
}
#[test]
fn value_texture_without_processor_pushes_nothing() {
let core = NodeCore::new();
let n = node();
let inputs = crate::value::NodeValueRow::from([(
crate::nodes::ociobase::TEXTURE_INPUT.to_string(),
NodeValue::Texture(crate::handle::CHandle::null()),
)]);
let mut table = NodeValueTable::default();
n.value(&core, &inputs, Rational::new(0, 1), &mut table);
assert!(table.is_empty());
}
#[test]
fn value_texture_with_processor_pushes_deferred_job() {
let core = NodeCore::new();
let mut n = node();
n.base.set_processor(Some(crate::handle::CHandle::null()));
let inputs = crate::value::NodeValueRow::from([(
crate::nodes::ociobase::TEXTURE_INPUT.to_string(),
NodeValue::Texture(crate::handle::CHandle::null()),
)]);
let mut table = NodeValueTable::default();
n.value(&core, &inputs, Rational::new(0, 1), &mut table);
assert!(table.get(ValueType::Texture).is_some());
}
#[test]
fn duplicate_clones() {
let (core, behavior) = create();
let dup = behavior.duplicate(&core).unwrap();
assert_eq!(dup.name(), "OCIO Color Grading (Log)");
}
}
+668
View File
@@ -0,0 +1,668 @@
// 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/>.
//! OCIO LUT file node (C++ `src/node/src/color/ociolut/ociolut.{h,cpp}`,
//! `olive::OCIOLutNode`).
//!
//! Note: OpenColorIO itself is never linked here; it is reached through
//! the color manager (`crate::colormanager`) and the oakrender bridge
//! (`crate::bridge::render`), like the C++ node's
//! `oakrender_color_processor_create_lut` / `oakrender_lut_*` calls.
use std::sync::Mutex;
use crate::factory::NodeMeta;
use crate::node::{Category, NodeBehavior, NodeCore};
use crate::nodes::ociobase::OcioBase;
/// LUT file input id (C++ `k_file_input`). Type: file; default: empty
/// string; flags: not-keyframable, not-connectable; properties:
/// `filter = "LUT Files (*.<ext> ...);;All Files (*)"` (extensions from
/// `oakrender_lut_supported_extension_at`, `*.*` fallback),
/// `placeholder = "Select a LUT file"`, `lut_library = true`.
pub const FILE_INPUT: &str = "lut_file_in";
/// Direction combo input id (C++ `k_direction_input`). Type: combo;
/// default `0` (forward); flags: not-keyframable, not-connectable.
/// Combo strings (set in `retranslate`): "Forward", "Inverse".
pub const DIRECTION_INPUT: &str = "lut_dir_in";
/// Deferred processor-generation state (C++ `mutable` members
/// `gen_mutex_`, `processor_dirty_`, `last_path_`, `last_direction_`,
/// `last_processor_`, `last_error_`). Grouped behind one mutex: the C++
/// mutable-in-const-method pattern maps to interior mutability here,
/// and the C++ code already serializes all of these under `gen_mutex_`.
struct ProcessorState {
/// Regeneration pending (C++ `processor_dirty_`, starts `true`).
dirty: bool,
/// Path of the LUT the cached processor was built from (C++
/// `last_path_`).
last_path: String,
/// Direction the cached processor was built for (C++
/// `last_direction_`, starts `-1`).
last_direction: i64,
/// Cached processor for change detection (C++ `last_processor_`);
/// released with the node.
last_processor: Option<crate::bridge::render::ColorProcessorHandle>,
/// Human-readable reason no LUT processor is active (C++
/// `last_error_`); empty when a valid processor is in use or no LUT
/// file has been selected yet.
last_error: String,
}
impl Default for ProcessorState {
/// Fresh state: dirty, empty path, no cached processor or error.
fn default() -> Self {
ProcessorState {
dirty: true,
last_path: String::new(),
last_direction: -1,
last_processor: None,
last_error: String::new(),
}
}
}
// The cached processor handle wraps a refcounted C object that is only
// dereferenced from the render path; see `OcioBase` for the rationale.
unsafe impl Send for ProcessorState {}
/// OCIO LUT node. Applies a LUT file through OpenColorIO.
pub struct OCIOLutNode {
/// Shared OCIO base state (C++ base class `OCIOBaseNode`).
base: OcioBase,
/// Processor generation cache/lock (C++ `gen_mutex_` + the mutable
/// `last_*` members).
state: Mutex<ProcessorState>,
}
impl OCIOLutNode {
/// Human-readable description of why no LUT processor is active
/// (C++ `last_error()`); empty when a valid LUT processor is in use
/// or no LUT file has been selected yet.
pub fn last_error(&self) -> String {
self.state.lock().unwrap().last_error.clone()
}
/// Record a new error string (C++ `set_last_error()`); no-op when
/// unchanged. The Qt version surfaced the error on the main-window
/// status bar; here it is only recorded and read back via
/// [`Self::last_error`].
fn set_last_error(&self, error: &str) {
let mut state = self.state.lock().unwrap();
if state.last_error == error {
return;
}
state.last_error = error.to_string();
}
/// Direction combo value with legacy fallback (C++ file-static
/// `read_direction_input()`): integer combo value, or — for old
/// serializers that stored the combo as a string — case-insensitive
/// "forward"/"0" -> 0 and "inverse"/"1" -> 1, defaulting to 0 with a
/// stderr warning for anything else.
fn read_direction_input(core: &NodeCore) -> i64 {
match &core.standard_value(DIRECTION_INPUT, -1) {
crate::value::NodeValue::Combo(i) => *i,
crate::value::NodeValue::Int(i) => *i,
crate::value::NodeValue::Text(s) => {
let lower = s.to_lowercase();
if lower == "forward" || lower == "0" {
0
} else if lower == "inverse" || lower == "1" {
1
} else {
eprintln!("OCIOLutNode: unexpected direction value {}", s);
0
}
}
other => {
eprintln!(
"OCIOLutNode: unexpected direction value {}",
other.to_double()
);
0
}
}
}
/// Whether this is the main GUI process (C++ file-static
/// `is_main_process()`): true when a render manager exists (the
/// render worker never creates one).
fn is_main_process() -> bool {
// The C++ probes `oakrender_manager_available()`, which the
// oakrender bridge does not expose. Without a render manager (the
// oakrender-free test environment) the probe would report the
// worker process, so the worker branch — defer processor
// generation to value() time — always applies here.
// `// CPP-PARITY: ociolut.cpp` is_main_process.
false
}
/// (Re)generate the processor now (C++ `generate_processor()`):
/// ensures the processor is current, then — in the main process
/// only — invalidates the texture-input cache and cancels background
/// video cache tasks so in-flight renders cannot write stale frames.
fn generate_processor(&mut self, core: &mut NodeCore) {
self.ensure_processor(core);
// The C++ main-process half (`invalidate_all(k_texture_input)`
// + `oakrender_cancel_video_tasks(0)`) refreshes the viewer after
// a processor change; it needs the render manager seam that the
// Rust model does not expose, and `is_main_process()` is false
// here, so it is skipped (`// CPP-PARITY: ociolut.cpp`
// generate_processor).
}
/// Ensure the processor matches the current inputs (C++
/// `ensure_processor()`): under the generation mutex, returns early
/// when not dirty, a cached processor exists, and path/direction are
/// unchanged; otherwise rebuilds from the inputs.
fn ensure_processor(&self, core: &NodeCore) {
{
let state = self.state.lock().unwrap();
if !state.dirty
&& state
.last_processor
.as_ref()
.is_some_and(|p| !p.is_null())
&& Self::file_path(core) == state.last_path
&& Self::read_direction_input(core) == state.last_direction
{
return;
}
}
self.create_processor_from_inputs(core);
}
/// Rebuild the processor from the LUT path and direction (C++
/// `create_processor_from_inputs()`): no manager, empty path,
/// non-regular file, or unsupported extension -> clear both
/// processors, reset the cache markers, record the error, and return
/// false; unchanged path+direction with a live cached processor ->
/// clear the dirty flag and return false (reuse); otherwise create
/// the LUT processor via `oakrender_color_processor_create_lut`
/// (direction 0 = forward), update the cache markers and both
/// processor slots, and return true.
fn create_processor_from_inputs(&self, core: &NodeCore) -> bool {
let _ = core;
let mut state = self.state.lock().unwrap();
// C++ branch 1: no color manager. The Rust model reaches the
// manager through the oakrender bridge (absent here), so this
// branch is always taken: reset the cache markers and report
// false. The C++ additionally clears the standard processor and
// frees `last_processor_` — the Rust base processor is only
// reachable through `&mut self` and can never hold a processor
// without the render bridge, so those clears are no-ops here.
// The empty-path/non-regular-file/unsupported-extension error
// branches are unreachable without a manager and are not
// representable. `// CPP-PARITY: ociolut.cpp`
// create_processor_from_inputs.
state.last_processor = None;
state.last_path.clear();
state.last_direction = -1;
state.dirty = false;
false
}
/// OCIO config change hook (C++ `config_changed()` override):
/// regenerates immediately in the main process, or just marks the
/// processor dirty in the render worker (deferred to render time).
fn config_changed(&mut self, core: &mut NodeCore) {
let _ = core;
// C++: main process -> generate_processor(); render worker ->
// mark dirty. `is_main_process()` is false here, so the worker
// branch applies and the processor is rebuilt at value() time.
self.state.lock().unwrap().dirty = true;
}
/// The standard value of [`FILE_INPUT`] as a path string.
fn file_path(core: &NodeCore) -> String {
match &core.standard_value(FILE_INPUT, -1) {
crate::value::NodeValue::Text(s) => s.clone(),
_ => String::new(),
}
}
}
impl NodeBehavior for OCIOLutNode {
/// Human-readable name (C++ `name()`).
fn name(&self) -> &str {
"OCIO LUT"
}
/// Stable type id (C++ `id()`).
fn type_id(&self) -> &str {
"org.olivevideoeditor.Olive.ociolut"
}
/// Categories (C++ `category()`).
fn categories(&self) -> &[Category] {
&[Category::Color]
}
/// Description (C++ `description()`).
fn description(&self) -> &str {
"Applies a LUT file through OpenColorIO."
}
/// Localized input names (C++ `retranslate()`): `tex_in` -> "Input",
/// `lut_file_in` -> "LUT File", `lut_dir_in` -> "Direction" (also
/// sets the direction combo strings "Forward"/"Inverse").
fn input_name<'a>(&self, id: &'a str) -> &'a str {
match id {
crate::nodes::ociobase::TEXTURE_INPUT => "Input",
FILE_INPUT => "LUT File",
DIRECTION_INPUT => "Direction",
_ => id,
}
}
/// Input value changed (C++ `InputValueChangedEvent`): for
/// `lut_file_in` or `lut_dir_in`, regenerates the processor
/// immediately in the main process; in the render worker (where
/// generation can be slow and the main process may be blocked
/// waiting on LoadGraph) only marks the processor dirty so it is
/// rebuilt at render time.
fn input_value_changed(&mut self, core: &mut NodeCore, input: &str, element: i32) {
let _ = element;
if input == FILE_INPUT || input == DIRECTION_INPUT {
// C++: main process -> generate_processor(); render worker ->
// mark dirty. `is_main_process()` is false here (no render
// manager), so the worker branch applies.
self.state.lock().unwrap().dirty = true;
let _ = core;
}
}
/// Evaluate outputs (C++ `value()`): first `ensure_processor()` so
/// the processor is up to date before the base class emits the color
/// transform job (essential in the render worker, where creation is
/// deferred until the first render), then delegates to
/// [`OcioBase::value`].
fn value(
&self,
core: &NodeCore,
inputs: &crate::value::NodeValueRow,
time: oakcore_rs::Rational,
table: &mut crate::value::NodeValueTable,
) {
self.ensure_processor(core);
self.base.value(core, inputs, time, table);
}
/// Added to a graph (C++ base `AddedToGraphEvent`): captures the
/// project's color manager and runs `config_changed()` via
/// [`OcioBase::added_to_graph`].
fn added_to_graph(&mut self, core: &mut NodeCore) {
self.base.added_to_graph(core);
self.config_changed(core);
}
/// Removed from a graph (C++ base `RemovedFromGraphEvent`): clears
/// the color manager pointer via [`OcioBase::removed_from_graph`].
fn removed_from_graph(&mut self, core: &mut NodeCore) {
self.base.removed_from_graph(core);
}
/// Deep copy (C++ `copy()` via `NODE_COPY_FUNCTION`; the destructor
/// additionally disconnects all signals and frees the cached
/// processor).
fn duplicate(&self, _core: &NodeCore) -> Option<Box<dyn NodeBehavior>> {
// The C++ copy constructor carries over the processor-cache
// members; the refcounted `last_processor_` cannot be shared
// safely by value (the destructor frees it), so a fresh
// processor state (dirty, no cache) plus a fresh empty OcioBase
// is the safe port — the processor is never populated without
// the render bridge anyway.
Some(Box::new(OCIOLutNode {
base: OcioBase::new(),
state: Mutex::new(ProcessorState::default()),
}))
}
}
/// Constructor (C++ `OCIOLutNode::OCIOLutNode()`): builds the base
/// (`tex_in` texture input, effect input, video-effect flag) and adds
/// `lut_file_in` (with the LUT filter/placeholder/lut-library
/// properties) and `lut_dir_in` with the defaults, flags and properties
/// documented on the constants; the processor state starts dirty.
pub fn create() -> (NodeCore, Box<dyn NodeBehavior>) {
let mut core = NodeCore::new();
// OCIOBaseNode base constructor.
let mut tex = crate::input::Input::new(
crate::nodes::ociobase::TEXTURE_INPUT,
crate::value::ValueType::Texture,
crate::value::NodeValue::None,
);
tex.flags |= crate::input::flags::NOT_KEYFRAMABLE;
core.add_input(tex);
core.effect_input = crate::nodes::ociobase::TEXTURE_INPUT.to_string();
core.flags |= crate::node::flags::VIDEO_EFFECT;
// The file input is a string-carried type in Rust (no file value
// type); the C++ k_file type maps to the same string marshalling.
let mut file = crate::input::Input::new(
FILE_INPUT,
crate::value::ValueType::Text,
crate::value::NodeValue::Text(String::new()),
);
file.flags |= crate::input::flags::NOT_KEYFRAMABLE | crate::input::flags::NOT_CONNECTABLE;
file.properties = vec![
// The C++ collects the supported LUT extensions from
// `oakrender_lut_supported_extension_at`; without the render
// bridge the list is empty and the `*.*` fallback applies
// (`// CPP-PARITY: ociolut.cpp` constructor).
(
"filter".to_string(),
crate::value::NodeValue::Text("LUT Files (*.*);;All Files (*)".into()),
),
(
"placeholder".to_string(),
crate::value::NodeValue::Text("Select a LUT file".into()),
),
("lut_library".to_string(), crate::value::NodeValue::Boolean(true)),
];
core.add_input(file);
let mut direction = crate::input::Input::new(
DIRECTION_INPUT,
crate::value::ValueType::Combo,
crate::value::NodeValue::Combo(0),
);
direction.flags |= crate::input::flags::NOT_KEYFRAMABLE | crate::input::flags::NOT_CONNECTABLE;
core.add_input(direction);
(
core,
Box::new(OCIOLutNode {
base: OcioBase::new(),
state: Mutex::new(ProcessorState::default()),
}),
)
}
/// Register this node type (C++ factory entry for
/// `org.olivevideoeditor.Olive.ociolut`).
pub fn register(meta: &mut Vec<NodeMeta>) {
meta.push(NodeMeta {
type_id: "org.olivevideoeditor.Olive.ociolut",
name: "OCIO LUT",
categories: &[Category::Color],
create,
});
}
#[cfg(test)]
mod tests {
use super::*;
use crate::value::{NodeValue, NodeValueTable, ValueType};
use oakcore_rs::Rational;
fn node() -> OCIOLutNode {
OCIOLutNode {
base: OcioBase::new(),
state: Mutex::new(ProcessorState::default()),
}
}
#[test]
fn input_names() {
let n = node();
assert_eq!(n.input_name(crate::nodes::ociobase::TEXTURE_INPUT), "Input");
assert_eq!(n.input_name(FILE_INPUT), "LUT File");
assert_eq!(n.input_name(DIRECTION_INPUT), "Direction");
assert_eq!(n.input_name("other_in"), "other_in");
}
#[test]
fn create_wires_inputs_flags_and_properties() {
let (core, behavior) = create();
assert_eq!(behavior.type_id(), "org.olivevideoeditor.Olive.ociolut");
assert_ne!(
core.get_input(crate::nodes::ociobase::TEXTURE_INPUT).unwrap().flags & crate::input::flags::NOT_KEYFRAMABLE,
0
);
let file = core.get_input(FILE_INPUT).unwrap();
assert_eq!(file.default, NodeValue::Text(String::new()));
assert_ne!(file.flags & crate::input::flags::NOT_KEYFRAMABLE, 0);
assert_ne!(file.flags & crate::input::flags::NOT_CONNECTABLE, 0);
// `*.*` filter fallback without the render bridge.
assert!(file
.properties
.iter()
.any(|(k, v)| k == "filter" && *v == NodeValue::Text("LUT Files (*.*);;All Files (*)".into())));
assert!(file
.properties
.iter()
.any(|(k, v)| k == "placeholder" && *v == NodeValue::Text("Select a LUT file".into())));
assert!(file
.properties
.iter()
.any(|(k, v)| k == "lut_library" && *v == NodeValue::Boolean(true)));
let dir = core.get_input(DIRECTION_INPUT).unwrap();
assert_eq!(dir.default, NodeValue::Combo(0));
assert_ne!(dir.flags & crate::input::flags::NOT_KEYFRAMABLE, 0);
assert_ne!(dir.flags & crate::input::flags::NOT_CONNECTABLE, 0);
assert_eq!(core.effect_input, crate::nodes::ociobase::TEXTURE_INPUT);
assert_ne!(core.flags & crate::node::flags::VIDEO_EFFECT, 0);
}
#[test]
fn read_direction_input_parses_combo_and_legacy_strings() {
let mut core = NodeCore::new();
core.add_input(crate::input::Input::new(
DIRECTION_INPUT,
crate::value::ValueType::Combo,
crate::value::NodeValue::Combo(0),
));
assert_eq!(OCIOLutNode::read_direction_input(&core), 0);
core.set_standard_value(DIRECTION_INPUT, -1, NodeValue::Combo(1));
assert_eq!(OCIOLutNode::read_direction_input(&core), 1);
// Legacy string storage (old serializers).
core.set_standard_value(DIRECTION_INPUT, -1, NodeValue::Text("forward".into()));
assert_eq!(OCIOLutNode::read_direction_input(&core), 0);
core.set_standard_value(DIRECTION_INPUT, -1, NodeValue::Text("Inverse".into()));
assert_eq!(OCIOLutNode::read_direction_input(&core), 1);
core.set_standard_value(DIRECTION_INPUT, -1, NodeValue::Text("0".into()));
assert_eq!(OCIOLutNode::read_direction_input(&core), 0);
}
#[test]
fn last_error_roundtrip_and_set_once() {
let n = node();
assert_eq!(n.last_error(), "");
n.set_last_error("OCIO LUT: file does not exist: /nope.cube");
assert_eq!(n.last_error(), "OCIO LUT: file does not exist: /nope.cube");
// No-op when unchanged.
n.set_last_error("OCIO LUT: file does not exist: /nope.cube");
assert_eq!(n.last_error(), "OCIO LUT: file does not exist: /nope.cube");
n.set_last_error("");
assert_eq!(n.last_error(), "");
}
#[test]
fn create_processor_from_inputs_resets_markers_without_manager() {
let n = node();
let core = NodeCore::new();
{
let mut state = n.state.lock().unwrap();
state.dirty = true;
state.last_path = "/tmp/foo.cube".to_string();
state.last_direction = 1;
state.last_processor = Some(crate::handle::CHandle::null());
state.last_error = "stale error".to_string();
}
let created = n.create_processor_from_inputs(&core);
assert!(!created);
let state = n.state.lock().unwrap();
assert!(!state.dirty);
assert_eq!(state.last_path, "");
assert_eq!(state.last_direction, -1);
assert!(state.last_processor.is_none());
// The no-manager branch does not touch the recorded error (C++).
assert_eq!(state.last_error, "stale error");
}
#[test]
fn ensure_processor_rebuilds_when_no_cached_processor() {
let n = node();
let core = NodeCore::new();
// Fresh state is dirty -> rebuild (resets markers, returns false).
n.ensure_processor(&core);
let state = n.state.lock().unwrap();
assert!(!state.dirty);
assert!(state.last_processor.is_none());
}
#[test]
fn input_value_changed_marks_dirty() {
let mut core = NodeCore::new();
let mut n = node();
n.state.lock().unwrap().dirty = false;
n.input_value_changed(&mut core, FILE_INPUT, 0);
assert!(n.state.lock().unwrap().dirty);
n.state.lock().unwrap().dirty = false;
n.input_value_changed(&mut core, DIRECTION_INPUT, 0);
assert!(n.state.lock().unwrap().dirty);
// Unrelated inputs are ignored.
n.state.lock().unwrap().dirty = false;
n.input_value_changed(&mut core, crate::nodes::ociobase::TEXTURE_INPUT, 0);
assert!(!n.state.lock().unwrap().dirty);
}
#[test]
fn config_changed_marks_dirty() {
let mut core = NodeCore::new();
let mut n = node();
n.state.lock().unwrap().dirty = false;
n.config_changed(&mut core);
assert!(n.state.lock().unwrap().dirty);
}
#[test]
fn file_path_reads_text_standard_value() {
let mut core = NodeCore::new();
core.add_input(crate::input::Input::new(
FILE_INPUT,
crate::value::ValueType::Text,
crate::value::NodeValue::Text(String::new()),
));
core.set_standard_value(FILE_INPUT, -1, NodeValue::Text("/tmp/x.cube".into()));
assert_eq!(OCIOLutNode::file_path(&core), "/tmp/x.cube");
// Non-text standard value (or missing input) yields empty.
core.set_standard_value(FILE_INPUT, -1, NodeValue::Float(3.0));
assert_eq!(OCIOLutNode::file_path(&core), "");
}
#[test]
fn ensure_processor_reuses_cached_when_unchanged() {
let n = node();
let mut core = NodeCore::new();
core.add_input(crate::input::Input::new(
FILE_INPUT,
crate::value::ValueType::Text,
crate::value::NodeValue::Text(String::new()),
));
core.add_input(crate::input::Input::new(
DIRECTION_INPUT,
crate::value::ValueType::Combo,
crate::value::NodeValue::Combo(0),
));
core.set_standard_value(FILE_INPUT, -1, NodeValue::Text("/tmp/x.cube".into()));
core.set_standard_value(DIRECTION_INPUT, -1, NodeValue::Combo(1));
{
let mut state = n.state.lock().unwrap();
state.dirty = false;
state.last_path = "/tmp/x.cube".to_string();
state.last_direction = 1;
state.last_processor = Some(crate::handle::make_owned::<u8>(1));
}
// Unchanged path+direction with a live processor: early return,
// markers are preserved (create_processor_from_inputs would have
// reset them).
n.ensure_processor(&core);
let state = n.state.lock().unwrap();
assert_eq!(state.last_path, "/tmp/x.cube");
assert_eq!(state.last_direction, 1);
assert!(state.last_processor.is_some());
}
#[test]
fn generate_processor_wraps_ensure_processor() {
let mut n = node();
let mut core = NodeCore::new();
n.state.lock().unwrap().dirty = true;
// Worker branch: generate_processor just ensures; markers reset.
n.generate_processor(&mut core);
let state = n.state.lock().unwrap();
assert!(!state.dirty);
assert_eq!(state.last_path, "");
assert_eq!(state.last_direction, -1);
}
#[test]
fn is_main_process_always_false_without_bridge() {
assert!(!OCIOLutNode::is_main_process());
}
#[test]
fn value_no_texture_pushes_nothing() {
let (core, behavior) = create();
let mut table = NodeValueTable::default();
behavior.value(&core, &crate::value::NodeValueRow::default(), Rational::new(0, 1), &mut table);
assert!(table.is_empty());
}
#[test]
fn value_passes_texture_through_without_processor() {
// Without the render bridge no LUT processor can be created, so
// the base value() passes the input texture through unchanged.
let core = NodeCore::new();
let n = node();
let tex = NodeValue::Texture(crate::handle::CHandle::null());
let inputs = crate::value::NodeValueRow::from([(
crate::nodes::ociobase::TEXTURE_INPUT.to_string(),
tex.clone(),
)]);
let mut table = NodeValueTable::default();
n.value(&core, &inputs, Rational::new(0, 1), &mut table);
assert_eq!(table.get(ValueType::Texture), Some(&tex));
}
#[test]
fn value_pushes_deferred_job_with_processor() {
let core = NodeCore::new();
let mut n = node();
n.base.set_processor(Some(crate::handle::CHandle::null()));
let inputs = crate::value::NodeValueRow::from([(
crate::nodes::ociobase::TEXTURE_INPUT.to_string(),
NodeValue::Texture(crate::handle::CHandle::null()),
)]);
let mut table = NodeValueTable::default();
n.value(&core, &inputs, Rational::new(0, 1), &mut table);
assert!(table.get(ValueType::Texture).is_some());
}
#[test]
fn duplicate_clones() {
let (core, behavior) = create();
let dup = behavior.duplicate(&core).unwrap();
assert_eq!(dup.name(), "OCIO LUT");
}
}
+376
View File
@@ -0,0 +1,376 @@
// 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/>.
//! Opacity effect (C++ `src/node/src/effect/opacity/opacityeffect.{h,cpp}`,
//! `olive::OpacityEffect`).
use crate::factory::NodeMeta;
use crate::node::{Category, NodeBehavior, NodeCore};
/// Texture input id (C++ `k_texture_input`). Type: texture; flags:
/// not-keyframable; this is the node's effect input.
pub const TEXTURE_INPUT: &str = "tex_in";
/// Opacity input id (C++ `k_value_input`). Type: float; default `1.0`;
/// properties: `view = percentage`, `min = 0.0`, `max = 1.0`.
pub const VALUE_INPUT: &str = "opacity_in";
/// Opacity effect node. Multiplies a texture's alpha by a 0..1 factor.
pub struct OpacityEffect {
/// Owned child math node configured to `multiply` (C++ `math_`,
/// formerly a QObject child).
math: Box<super::math::MathNode>,
}
/// Fragment shader for the plain opacity path (C++ loads the
/// `:/shaders/opacity.frag` resource in `get_shader_code`).
/// Text copied verbatim from `engine/shaders/opacity.frag`.
const SHADER_FRAG: &str = r#"// Inputs
uniform sampler2D tex_in;
uniform float opacity_in;
// Input texture coordinate
in vec2 ove_texcoord;
out vec4 frag_color;
void main() {
frag_color = texture(tex_in, ove_texcoord) * opacity_in;
}
"#;
/// Fragment shader for the `rgbmult` shader id (C++
/// `:/shaders/opacity_rgb.frag`), used when the opacity input is
/// itself a texture. Text copied verbatim from
/// `engine/shaders/opacity_rgb.frag`.
const SHADER_RGB_MULT_FRAG: &str = r#"// Inputs
uniform sampler2D tex_in;
uniform sampler2D opacity_in;
// Input texture coordinate
in vec2 ove_texcoord;
out vec4 frag_color;
vec3 rgb2hsv(vec3 c)
{
vec4 K = vec4(0.0, -1.0 / 3.0, 2.0 / 3.0, -1.0);
vec4 p = mix(vec4(c.bg, K.wz), vec4(c.gb, K.xy), step(c.b, c.g));
vec4 q = mix(vec4(p.xyw, c.r), vec4(c.r, p.yzx), step(p.x, c.r));
float d = q.x - min(q.w, q.y);
float e = 1.0e-10;
return vec3(abs(q.z + (q.w - q.y) / (6.0 * d + e)), d / (q.x + e), q.x);
}
void main() {
vec4 value = texture(opacity_in, ove_texcoord);
float v = rgb2hsv(value.rgb).b;
vec4 c = texture(tex_in, ove_texcoord);
c *= v;
frag_color = c;
}
"#;
impl OpacityEffect {
/// Fragment shader for the plain opacity path (C++
/// `get_shader_code()` default branch).
fn shader_frag() -> &'static str {
SHADER_FRAG
}
/// Fragment shader for the `rgbmult` request (C++
/// `get_shader_code()` `"rgbmult"` branch).
fn shader_rgb_mult_frag() -> &'static str {
SHADER_RGB_MULT_FRAG
}
}
impl NodeBehavior for OpacityEffect {
/// Human-readable name (C++ `name()`).
fn name(&self) -> &str {
"Opacity"
}
/// Stable type id (C++ `id()`).
fn type_id(&self) -> &str {
"org.olivevideoeditor.Olive.opacity"
}
/// Categories (C++ `category()`; note: C++ files Opacity under
/// `k_category_filter` even though the sources live in `effect/`).
fn categories(&self) -> &[Category] {
&[Category::Filter]
}
/// Description (C++ `description()`).
fn description(&self) -> &str {
"Alter a video's opacity.\n\nThis is equivalent to multiplying a video by a number between 0.0 and 1.0."
}
/// Localized input names (C++ `retranslate()`): `tex_in` ->
/// "Texture", `opacity_in` -> "Opacity".
fn input_name<'a>(&self, id: &'a str) -> &'a str {
match id {
TEXTURE_INPUT => "Texture",
VALUE_INPUT => "Opacity",
_ => id,
}
}
/// Evaluate outputs (C++ `value()`): no texture -> push nothing;
/// texture opacity input -> `rgbmult` shader job; scalar opacity
/// != 1.0 -> plain shader job; opacity == 1.0 -> pass-through push
/// of the input texture unchanged.
///
/// The Rust model has no shader-job payload: the two job cases push
/// a null texture handle marking a renderer-deferred job resolved
/// via [`Self::shader_code`] (`// CPP-PARITY: opacityeffect.cpp`
/// `value()`).
fn value(
&self,
core: &NodeCore,
inputs: &crate::value::NodeValueRow,
time: oakcore_rs::Rational,
table: &mut crate::value::NodeValueTable,
) {
let _ = (core, time);
let tex = match inputs.get(TEXTURE_INPUT) {
Some(tex @ crate::value::NodeValue::Texture(_)) => tex.clone(),
_ => return,
};
match inputs.get(VALUE_INPUT) {
Some(crate::value::NodeValue::Texture(_)) => {
// Texture opacity input: rgbmult shader job.
table.push(
crate::value::ValueType::Texture,
crate::value::NodeValue::Texture(crate::handle::CHandle::null()),
None,
);
}
Some(v) => {
let opacity = v.to_double();
// Same semantics as `!qFuzzyCompare(opacity, 1.0)`
// (double overload).
if (opacity - 1.0).abs() * 1e12 > opacity.abs().min(1.0) {
table.push(
crate::value::ValueType::Texture,
crate::value::NodeValue::Texture(crate::handle::CHandle::null()),
None,
);
} else {
table.push(crate::value::ValueType::Texture, tex, None);
}
}
None => {
let opacity = core.value_at_time(VALUE_INPUT, -1, time).to_double();
if (opacity - 1.0).abs() * 1e12 > opacity.abs().min(1.0) {
table.push(
crate::value::ValueType::Texture,
crate::value::NodeValue::Texture(crate::handle::CHandle::null()),
None,
);
} else {
table.push(crate::value::ValueType::Texture, tex, None);
}
}
}
}
/// Shader code request (C++ `get_shader_code()`): dispatch on
/// request id (`"rgbmult"` vs default) between the two fragment
/// shaders above.
fn shader_code(&self, request: &str) -> Option<String> {
if request == "rgbmult" {
Some(SHADER_RGB_MULT_FRAG.to_string())
} else {
Some(SHADER_FRAG.to_string())
}
}
/// Deep copy (C++ `copy()`); clones the owned child math node too.
fn duplicate(&self, _core: &NodeCore) -> Option<Box<dyn NodeBehavior>> {
Some(Box::new(OpacityEffect {
math: super::math::MathNode::new(),
}))
}
}
/// Constructor (C++ `OpacityEffect::OpacityEffect()`): builds the child
/// math node, adds `tex_in`/`opacity_in` with the defaults and
/// properties documented on the constants, sets the video-effect flag
/// and the effect input.
pub fn create() -> (NodeCore, Box<dyn NodeBehavior>) {
let mut core = NodeCore::new();
let mut tex = crate::input::Input::new(
TEXTURE_INPUT,
crate::value::ValueType::Texture,
crate::value::NodeValue::None,
);
tex.flags |= crate::input::flags::NOT_KEYFRAMABLE;
core.add_input(tex);
let mut opacity = crate::input::Input::new(
VALUE_INPUT,
crate::value::ValueType::Float,
crate::value::NodeValue::Float(1.0),
);
opacity.properties = vec![
("view".to_string(), crate::value::NodeValue::Text("percentage".into())),
("min".to_string(), crate::value::NodeValue::Float(0.0)),
("max".to_string(), crate::value::NodeValue::Float(1.0)),
];
core.add_input(opacity);
core.flags |= crate::node::flags::VIDEO_EFFECT;
core.effect_input = TEXTURE_INPUT.to_string();
(core, Box::new(OpacityEffect {
math: super::math::MathNode::new(),
}))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::node::NodeBehavior;
use crate::value::{NodeValue, NodeValueTable, ValueType};
use oakcore_rs::Rational;
#[test]
fn input_names() {
let n = OpacityEffect {
math: super::super::math::MathNode::new(),
};
assert_eq!(n.input_name(TEXTURE_INPUT), "Texture");
assert_eq!(n.input_name(VALUE_INPUT), "Opacity");
}
#[test]
fn create_wires_inputs_and_flags() {
let (core, behavior) = create();
assert_eq!(behavior.type_id(), "org.olivevideoeditor.Olive.opacity");
assert_eq!(core.get_input(VALUE_INPUT).unwrap().default, NodeValue::Float(1.0));
assert_eq!(core.effect_input, TEXTURE_INPUT);
assert_ne!(core.flags & crate::node::flags::VIDEO_EFFECT, 0);
}
#[test]
fn value_no_texture_pushes_nothing() {
let (core, behavior) = create();
let mut table = NodeValueTable::default();
behavior.value(&core, &crate::value::NodeValueRow::default(), Rational::new(0, 1), &mut table);
assert!(table.is_empty());
}
#[test]
fn value_opacity_unity_passes_texture_through() {
let (mut core, behavior) = create();
core.set_standard_value(VALUE_INPUT, -1, NodeValue::Float(1.0));
let tex = NodeValue::Texture(crate::handle::CHandle::null());
let inputs = crate::value::NodeValueRow::from([(TEXTURE_INPUT.to_string(), tex.clone())]);
let mut table = NodeValueTable::default();
behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
assert_eq!(table.get(ValueType::Texture), Some(&tex));
}
#[test]
fn value_opacity_scaled_pushes_job_placeholder() {
let (mut core, behavior) = create();
core.set_standard_value(VALUE_INPUT, -1, NodeValue::Float(0.5));
let inputs = crate::value::NodeValueRow::from([(
TEXTURE_INPUT.to_string(),
NodeValue::Texture(crate::handle::CHandle::null()),
)]);
let mut table = NodeValueTable::default();
behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
assert!(table.get(ValueType::Texture).is_some());
}
#[test]
fn value_opacity_in_row_scaled_pushes_job_placeholder() {
let (core, behavior) = create();
let inputs = crate::value::NodeValueRow::from([
(
TEXTURE_INPUT.to_string(),
NodeValue::Texture(crate::handle::CHandle::null()),
),
(VALUE_INPUT.to_string(), NodeValue::Float(0.5)),
]);
let mut table = NodeValueTable::default();
behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
assert!(table.get(ValueType::Texture).is_some());
}
#[test]
fn value_opacity_in_row_unity_passes_through() {
let (core, behavior) = create();
let tex = NodeValue::Texture(crate::handle::CHandle::null());
let inputs = crate::value::NodeValueRow::from([
(TEXTURE_INPUT.to_string(), tex.clone()),
(VALUE_INPUT.to_string(), NodeValue::Float(1.0)),
]);
let mut table = NodeValueTable::default();
behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
assert_eq!(table.get(ValueType::Texture), Some(&tex));
}
#[test]
fn value_texture_opacity_pushes_rgbmult_placeholder() {
let (core, behavior) = create();
let inputs = crate::value::NodeValueRow::from([
(
TEXTURE_INPUT.to_string(),
NodeValue::Texture(crate::handle::CHandle::null()),
),
(
VALUE_INPUT.to_string(),
NodeValue::Texture(crate::handle::CHandle::null()),
),
]);
let mut table = NodeValueTable::default();
behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
assert!(table.get(ValueType::Texture).is_some());
}
#[test]
fn shader_code_dispatches() {
let n = OpacityEffect {
math: super::super::math::MathNode::new(),
};
assert!(n.shader_code("rgbmult").unwrap().contains("rgb2hsv"));
assert!(n.shader_code("other").unwrap().contains("opacity_in"));
}
#[test]
fn duplicate_clones_behavior() {
let (core, behavior) = create();
let dup = behavior.duplicate(&core).unwrap();
assert_eq!(dup.name(), "Opacity");
}
}
/// Register this node type (C++ `k_opacity_effect` in
/// `factory.cpp::create_from_factory_index`).
pub fn register(meta: &mut Vec<NodeMeta>) {
meta.push(NodeMeta {
type_id: "org.olivevideoeditor.Olive.opacity",
name: "Opacity",
categories: &[Category::Filter],
create,
});
}
+353
View File
@@ -0,0 +1,353 @@
// 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/>.
//! Pan audio effect (C++ `src/node/src/audio/pan/pan.{h,cpp}`,
//! `olive::PanNode`).
use crate::factory::NodeMeta;
use crate::node::{Category, NodeBehavior, NodeCore};
use crate::value::{NodeValue, ValueType};
/// Samples input id (C++ `k_samples_input`). Type: samples; flags:
/// not-keyframable; this is the node's effect input
/// (`set_effect_input(k_samples_input)`).
pub const SAMPLES_INPUT: &str = "samples_in";
/// Panning input id (C++ `k_panning_input`). Type: float; default `0.0`;
/// properties: `min = -1.0`, `max = 1.0`, `view = percentage`.
pub const PANNING_INPUT: &str = "panning_in";
/// Pan effect node. Attenuates the left or right channel of a stereo
/// sample buffer according to a -1..1 panning value.
///
/// The C++ class keeps `NodeInput *samples_input_` /
/// `NodeInput *panning_input_` back-pointers to its own inputs; in Rust
/// inputs live in [`NodeCore`], so there are no own fields.
pub struct PanNode;
impl NodeBehavior for PanNode {
/// Human-readable name (C++ `name()`).
fn name(&self) -> &str {
"Pan"
}
/// Stable type id (C++ `id()`).
fn type_id(&self) -> &str {
"org.olivevideoeditor.Olive.pan"
}
/// Categories (C++ `category()`).
fn categories(&self) -> &[Category] {
&[Category::Filter]
}
/// Description (C++ `description()`).
fn description(&self) -> &str {
"Adjust the stereo panning of an audio source."
}
/// Localized input names (C++ `retranslate()`): `samples_in` ->
/// "Samples", `panning_in` -> "Pan".
fn input_name<'a>(&self, id: &'a str) -> &'a str {
match id {
SAMPLES_INPUT => "Samples",
PANNING_INPUT => "Pan",
_ => id,
}
}
/// Evaluate outputs (C++ `value()`): unallocated samples input ->
/// push nothing; non-stereo (channel count != 2) samples -> push the
/// input through unchanged; stereo with a static panning input ->
/// apply the attenuation immediately (pan > 0 scales channel 0 by
/// `1.0 - pan`, pan < 0 scales channel 1 by `1.0 + pan`, pan == 0
/// leaves the buffer untouched) and push the transformed samples;
/// stereo with a non-static panning input -> build a `SampleJob`
/// over `samples_in` + `panning_in` and push it as a samples value.
///
/// The Rust model has no `SampleJob` payload: the dynamic case
/// pushes the input samples through unchanged and the audio
/// renderer applies the per-index pan via [`Self::process_samples`]
/// (`// CPP-PARITY: pan.cpp` `value()`).
fn value(
&self,
core: &NodeCore,
inputs: &crate::value::NodeValueRow,
time: oakcore_rs::Rational,
table: &mut crate::value::NodeValueTable,
) {
let samples = match inputs.get(SAMPLES_INPUT) {
Some(NodeValue::Samples(b)) if b.is_allocated() => b.clone(),
_ => return,
};
// This node is only compatible with stereo audio.
if samples.channels != 2 {
table.push(ValueType::Samples, NodeValue::Samples(samples), None);
return;
}
if core.is_input_static(inputs, PANNING_INPUT, -1) {
let pan_volume = core.value_at_time(PANNING_INPUT, -1, time).to_double();
if pan_volume != 0.0 {
let mut transformed = samples;
if pan_volume > 0.0 {
transformed.transform_volume_for_channel(0, 1.0 - pan_volume);
} else {
transformed.transform_volume_for_channel(1, 1.0 + pan_volume);
}
table.push(ValueType::Samples, NodeValue::Samples(transformed), None);
} else {
table.push(ValueType::Samples, NodeValue::Samples(samples), None);
}
} else {
// Dynamic panning input: deferred sample job.
table.push(ValueType::Samples, NodeValue::Samples(samples), None);
}
}
/// Process a span of samples (C++ `process_samples()`): copies every
/// input channel to the output at the current index, then applies the
/// pan — pan > 0 attenuates output channel 0 by `1.0 - pan`, pan < 0
/// attenuates output channel 1 by `1.0 - abs(pan)`, pan == 0 is a
/// straight copy. The C++ signature receives the input buffer and a
/// sample index; the Rust trait instead hands over a time `range` and
/// the destination buffer, so the whole output span is filled here.
fn process_samples(
&self,
core: &NodeCore,
inputs: &crate::value::NodeValueRow,
range: oakcore_rs::TimeRange,
output: &mut crate::value::SampleBuffer,
) {
let input = match inputs.get(SAMPLES_INPUT) {
Some(NodeValue::Samples(b)) => b,
_ => return,
};
let pan_val = match inputs.get(PANNING_INPUT) {
Some(v) => v.to_double(),
None => core.value_at_time(PANNING_INPUT, -1, range.in_()).to_double(),
};
for c in 0..output.channels {
for i in 0..output.sample_count {
let v = input.sample_value(c, i);
output.set_sample_value(c, i, v);
}
}
if pan_val > 0.0 {
for i in 0..output.sample_count {
let v = output.sample_value(0, i);
output.set_sample_value(0, i, v * (1.0 - pan_val));
}
} else if pan_val < 0.0 {
for i in 0..output.sample_count {
let v = output.sample_value(1, i);
output.set_sample_value(1, i, v * (1.0 - pan_val.abs()));
}
}
}
/// Deep copy (C++ `copy()`).
fn duplicate(&self, _core: &NodeCore) -> Option<Box<dyn NodeBehavior>> {
Some(Box::new(PanNode))
}
}
/// Constructor (C++ `PanNode::PanNode()`): adds `samples_in` (samples,
/// not-keyframable) and `panning_in` (float, default 0.0, min/max/view
/// properties documented on the constant), sets the audio-effect flag
/// and makes `samples_in` the effect input.
pub fn create() -> (NodeCore, Box<dyn NodeBehavior>) {
let mut core = NodeCore::new();
let mut samples = crate::input::Input::new(
SAMPLES_INPUT,
ValueType::Samples,
NodeValue::None,
);
samples.flags |= crate::input::flags::NOT_KEYFRAMABLE;
core.add_input(samples);
let mut panning = crate::input::Input::new(
PANNING_INPUT,
ValueType::Float,
NodeValue::Float(0.0),
);
panning.properties = vec![
("min".to_string(), NodeValue::Float(-1.0)),
("max".to_string(), NodeValue::Float(1.0)),
("view".to_string(), NodeValue::Text("percentage".into())),
];
core.add_input(panning);
core.flags |= crate::node::flags::AUDIO_EFFECT;
core.effect_input = SAMPLES_INPUT.to_string();
(core, Box::new(PanNode))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::value::NodeValueTable;
use oakcore_rs::{Rational, SampleFormat, TimeRange};
fn planar(channels: usize, count: usize, values: &[f64]) -> crate::value::SampleBuffer {
let mut buf = crate::value::SampleBuffer {
format: SampleFormat::F32Planar,
channels,
sample_count: count,
data: vec![0u8; channels * count * 4],
};
for c in 0..channels {
for i in 0..count {
buf.set_sample_value(c, i, values[c * count + i]);
}
}
buf
}
#[test]
fn input_names() {
let n = PanNode;
assert_eq!(n.input_name(SAMPLES_INPUT), "Samples");
assert_eq!(n.input_name(PANNING_INPUT), "Pan");
}
#[test]
fn create_wires_inputs_and_flags() {
let (core, behavior) = create();
assert_eq!(behavior.type_id(), "org.olivevideoeditor.Olive.pan");
assert_eq!(core.get_input(PANNING_INPUT).unwrap().default, NodeValue::Float(0.0));
assert_eq!(core.effect_input, SAMPLES_INPUT);
assert_ne!(core.flags & crate::node::flags::AUDIO_EFFECT, 0);
}
#[test]
fn value_mono_passes_through() {
let (mut core, behavior) = create();
core.set_standard_value(PANNING_INPUT, -1, NodeValue::Float(1.0));
let buf = planar(1, 2, &[1.0, 2.0]);
let inputs = std::collections::BTreeMap::from([(
SAMPLES_INPUT.to_string(),
NodeValue::Samples(buf.clone()),
)]);
let mut table = NodeValueTable::default();
behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
let out = match table.get(ValueType::Samples).unwrap() {
NodeValue::Samples(s) => s,
_ => panic!("samples"),
};
assert_eq!(out.sample_value(0, 0), 1.0, "non-stereo untouched");
}
#[test]
fn value_static_pan_left_attenuates_right() {
let (mut core, behavior) = create();
core.set_standard_value(PANNING_INPUT, -1, NodeValue::Float(-1.0));
let buf = planar(2, 1, &[1.0, 1.0]);
let inputs = std::collections::BTreeMap::from([(
SAMPLES_INPUT.to_string(),
NodeValue::Samples(buf.clone()),
)]);
let mut table = NodeValueTable::default();
behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
let out = match table.get(ValueType::Samples).unwrap() {
NodeValue::Samples(s) => s,
_ => panic!("samples"),
};
assert_eq!(out.sample_value(0, 0), 1.0, "left stays");
assert_eq!(out.sample_value(1, 0), 0.0, "right = 1 + (-1)");
}
#[test]
fn value_static_pan_right_attenuates_left() {
let (mut core, behavior) = create();
core.set_standard_value(PANNING_INPUT, -1, NodeValue::Float(0.5));
let buf = planar(2, 1, &[1.0, 1.0]);
let inputs = std::collections::BTreeMap::from([(
SAMPLES_INPUT.to_string(),
NodeValue::Samples(buf.clone()),
)]);
let mut table = NodeValueTable::default();
behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
let out = match table.get(ValueType::Samples).unwrap() {
NodeValue::Samples(s) => s,
_ => panic!("samples"),
};
assert_eq!(out.sample_value(0, 0), 0.5, "left = 1 - 0.5");
assert_eq!(out.sample_value(1, 0), 1.0, "right stays");
}
#[test]
fn value_static_pan_center_untouched() {
let (mut core, behavior) = create();
core.set_standard_value(PANNING_INPUT, -1, NodeValue::Float(0.0));
let buf = planar(2, 1, &[1.0, 1.0]);
let inputs = std::collections::BTreeMap::from([(
SAMPLES_INPUT.to_string(),
NodeValue::Samples(buf.clone()),
)]);
let mut table = NodeValueTable::default();
behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
let out = match table.get(ValueType::Samples).unwrap() {
NodeValue::Samples(s) => s,
_ => panic!("samples"),
};
assert_eq!(out.sample_value(0, 0), 1.0);
assert_eq!(out.sample_value(1, 0), 1.0);
}
#[test]
fn process_samples_pan_right() {
let (mut core, behavior) = create();
core.set_standard_value(PANNING_INPUT, -1, NodeValue::Float(0.25));
let buf = planar(2, 1, &[1.0, 1.0]);
let inputs = std::collections::BTreeMap::from([
(SAMPLES_INPUT.to_string(), NodeValue::Samples(buf)),
(PANNING_INPUT.to_string(), NodeValue::Float(0.25)),
]);
let mut out = planar(2, 1, &[0.0, 0.0]);
behavior.process_samples(
&core,
&inputs,
TimeRange::new(Rational::new(0, 1), Rational::new(1, 1)),
&mut out,
);
assert_eq!(out.sample_value(0, 0), 0.75);
assert_eq!(out.sample_value(1, 0), 1.0);
}
#[test]
fn duplicate_clones() {
let (core, behavior) = create();
let dup = behavior.duplicate(&core).unwrap();
assert_eq!(dup.name(), "Pan");
}
}
/// Register this node type (C++ `k_audio_panning` in
/// `factory.cpp::create_from_factory_index`).
pub fn register(meta: &mut Vec<NodeMeta>) {
meta.push(NodeMeta {
type_id: "org.olivevideoeditor.Olive.pan",
name: "Pan",
categories: &[Category::Filter],
create,
});
}
+520
View File
@@ -0,0 +1,520 @@
// 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/>.
//! OpenFX plugin node (C++ `src/node/src/plugins/plugin.{h,cpp}`,
//! `olive::plugin::PluginNode`).
//!
//! DECLARATION ONLY. This node is a thin wrapper over an OFX plugin
//! instance that lives behind the `oakplugin` crate's C ABI bridge
//! (`crate::bridge`); no OFX types (`OFX::Host::ImageEffect::Instance`,
//! `kOfxParam*`, ...) are declared here. The plugin instance is
//! represented as the opaque [`PluginInstanceHandle`] below — the real
//! definition belongs to the oakplugin bridge module and this draft
//! stands in for it.
//!
//! All per-plugin data (inputs, defaults, properties, labels) is
//! discovered at runtime from the plugin descriptor through the
//! bridge, mirroring the C++ constructor.
use crate::factory::NodeMeta;
use crate::node::{Category, NodeBehavior, NodeCore};
use crate::value::{NodeValue, NodeValueRow, NodeValueTable};
use oakcore_rs::{Rational, TimeRange};
/// Texture input id (C++ `plugin::k_texture_input`). Type: texture;
/// no default. Only synthesized when the plugin declares clip inputs
/// but none of them is the simple-source clip (see [`create`]); then
/// it becomes the node's effect input with display name "Texture".
pub const TEXTURE_INPUT: &str = "tex_in";
/// OFX simple-source clip name (C++ `kOfxImageEffectSimpleSourceClipName`):
/// the canonical first texture clip of a filter plugin.
pub const SOURCE_CLIP: &str = "Source";
/// Opaque handle to an OFX plugin instance owned by the `oakplugin`
/// crate C ABI bridge (C++ `OFX::Host::ImageEffect::Instance *`,
/// member `plugin_instance_`). Placeholder type for this draft — the
/// bridge's real handle type replaces it.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct PluginInstanceHandle(pub u64);
impl PluginInstanceHandle {
/// The null/absent instance handle (no plugin instance behind the
/// node).
pub fn null() -> Self {
PluginInstanceHandle(0)
}
/// True when no instance is behind this handle.
pub fn is_null(&self) -> bool {
self.0 == 0
}
}
/// OFX plugin node. Wraps one plugin instance; inputs mirror the
/// plugin's declared params and clips.
///
/// The C++ class has a single own member besides the instance
/// pointer, `sub_category_`. Because `name()`/`id()`/`description()`
/// read the plugin descriptor at call time in C++ and the Rust trait
/// returns `&str`, this port caches those strings on the struct
/// (populated from the bridge at construction).
pub struct PluginNode {
/// The wrapped plugin instance (C++ `plugin_instance_`; an
/// opaque bridge handle here).
instance: PluginInstanceHandle,
/// Sub-category derived from the OFX context (C++
/// `sub_category_`): "Filter", "Generator", "Transition", or
/// "General".
sub_category: String,
/// Cached display name (C++ `name()` reads the descriptor's
/// `kOfxPropLabel` on every call).
name: String,
/// Cached type id (C++ `id()` = the plugin identifier).
type_id: String,
/// Cached description (C++ `description()` reads the
/// descriptor's `kOfxPropPluginDescription`).
description: String,
}
impl PluginNode {
/// Forward a push-button param activation to the plugin (C++
/// `push_button_clicked()`; currently a no-op upstream).
pub fn push_button_clicked(&mut self, core: &mut NodeCore, name: String) {
let _ = (core, name);
}
}
impl NodeBehavior for PluginNode {
/// Human-readable name (C++ `name()` = the plugin descriptor's
/// `kOfxPropLabel`; served from the cached copy).
fn name(&self) -> &str {
&self.name
}
/// Stable type id (C++ `id()` = the plugin identifier; served
/// from the cached copy).
fn type_id(&self) -> &str {
&self.type_id
}
/// Categories (C++ `category()`; always OpenFx).
fn categories(&self) -> &[Category] {
&[Category::OpenFx]
}
/// Sub-category (C++ `sub_category()` = the context string
/// captured at construction).
fn sub_category(&self) -> &str {
&self.sub_category
}
/// Description (C++ `description()` = the descriptor's
/// `kOfxPropPluginDescription`; served from the cached copy).
fn description(&self) -> &str {
&self.description
}
/// Evaluate outputs (C++ `value()`): re-pushes every non-texture,
/// non-none input value tagged with its input id (the tag routes
/// the value to the matching OFX param); then resolves the input
/// texture — simple-source clip first, then `tex_in`, then the
/// first texture-typed input — and, when both texture and plugin
/// instance exist, pushes the texture converted to a plugin job
/// bound to this node and the request time.
///
/// The Rust model has no plugin-job payload: the job case pushes a
/// null texture handle marking a renderer-deferred plugin job
/// (`// CPP-PARITY: plugin.cpp` `value()`).
fn value(&self, core: &NodeCore, inputs: &NodeValueRow, time: Rational, table: &mut NodeValueTable) {
let _ = (core, time);
// Re-push every non-texture, non-none input value, tagged with
// its input id.
for (id, v) in inputs.iter() {
if matches!(v, NodeValue::Texture(_) | NodeValue::None) {
continue;
}
table.push(v.value_type(), v.clone(), Some(id.clone()));
}
// Resolve the input texture: simple-source clip, then tex_in,
// then the first texture-typed input.
let tex = inputs
.get(SOURCE_CLIP)
.filter(|v| matches!(v, NodeValue::Texture(_)))
.or_else(|| inputs.get(TEXTURE_INPUT).filter(|v| matches!(v, NodeValue::Texture(_))))
.or_else(|| inputs.values().find(|v| matches!(v, NodeValue::Texture(_))));
if tex.is_some() && !self.instance.is_null() {
// C++ `table->push(NodeValue::k_texture, tex->to_job(job),
// this)` — a deferred plugin job; no job payload here.
table.push(
crate::value::ValueType::Texture,
NodeValue::Texture(crate::handle::CHandle::null()),
None,
);
}
}
/// Process audio samples (C++ `process_samples()`): passthrough —
/// silences the output when the input is empty/unallocated,
/// otherwise reallocates the output to the input's params when
/// mismatched and copies every channel verbatim.
///
/// The C++ receives the input buffer from the audio traversal; the
/// Rust trait hands only the value row, so the input buffer is
/// located as the first samples value in the row.
fn process_samples(
&self,
core: &NodeCore,
inputs: &NodeValueRow,
range: TimeRange,
output: &mut crate::value::SampleBuffer,
) {
let _ = (core, range);
let input = match inputs.values().find(|v| matches!(v, NodeValue::Samples(_))) {
Some(NodeValue::Samples(b)) => b,
_ => {
// Empty/unallocated input: silence the output (C++
// `output.silence()`).
if output.is_allocated() {
output.data.fill(0);
}
return;
}
};
if !input.is_allocated() || input.channels == 0 || input.sample_count == 0 {
if output.is_allocated() {
output.data.fill(0);
}
return;
}
// Reallocate the output to the input's params when mismatched
// (C++ `set_audio_params` + `set_sample_count` + `allocate`).
if !output.is_allocated()
|| output.channels != input.channels
|| output.sample_count != input.sample_count
{
output.format = input.format;
output.channels = input.channels;
output.sample_count = input.sample_count;
let bps = input.format.bytes_per_sample();
output.data = vec![0u8; input.channels * input.sample_count * bps];
}
// Copy every channel verbatim (C++ `output.fast_set(input, c)`).
for c in 0..input.channels {
for i in 0..input.sample_count {
let v = input.sample_value(c, i);
output.set_sample_value(c, i, v);
}
}
}
/// Direct frame generation (C++ `generate_frame()`): allocates
/// the destination frame if needed and zero-fills it (plugins do
/// their real image work in the plugin job, not here).
///
/// The Rust frame is an opaque [`crate::bridge::render::TextureHandle`]
/// whose pixels cannot be read or written from this crate, so the
/// body is a documented no-op (`// CPP-PARITY: plugin.cpp`
/// `generate_frame`).
fn generate_frame(&self, core: &NodeCore, frame: &mut crate::bridge::render::TextureHandle, time: Rational) {
let _ = (core, frame, time);
}
/// Deep copy (C++ `copy()`): asks the bridge to create a fresh
/// plugin instance — filter context when supported, else the
/// plugin's first declared context — and wraps it in a new node;
/// `None` when there is no instance or instance creation fails.
///
/// This crate's bridge has no instance-creation call, so a plugin
/// node can never be duplicated here and `None` is always returned
/// (`// CPP-PARITY: plugin.cpp` `copy()`).
fn duplicate(&self, _core: &NodeCore) -> Option<Box<dyn NodeBehavior>> {
None
}
/// Downcast to [`Self`] (instance/metadata 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++ `PluginNode::PluginNode(instance)`): stores the
/// instance handle and derives the sub-category from the OFX context
/// (filter/generator/transition, else "General"). Then walks the
/// plugin's params through the bridge:
///
/// - group params seed a group-label map; page params seed a
/// page-label map and a param->page-label map (skipping
/// skip-row/skip-column sentinels);
/// - each value param becomes an input: int/choice -> int/combo,
/// double -> float, boolean -> boolean, string -> text,
/// RGB/RGBA -> color, 2D/3D double/int -> vec2/vec3,
/// str-choice -> str-combo, bytes/custom -> binary, push-button ->
/// push-button; group/page and unknown types are skipped;
/// - defaults come from a per-plugin-id cache built from the OFX
/// `kOfxParamPropDefault` properties (normalised-coordinate doubles
/// are converted to canonical pixels against the project extent);
/// a non-null default is added as the input default and set as the
/// standard value (except for push-buttons);
/// - secret params get the hidden flag; the param label (or name)
/// becomes the input display name; parent groups set the `ui_group`
/// property, pages the `ui_page` property;
/// - color inputs get a `color_semantic` property ("color"/"scalar",
/// deduced from label/hint/display-range/default/group heuristics),
/// `min`/`max` from the display range, and a `tooltip` from the
/// hint;
/// - combo inputs get combo-box strings from the choice options
/// (ordered by the choice-order property when present), and
/// str-combos additionally a `combo_value_str` property.
///
/// Finally every non-output clip becomes a texture input named from
/// its label ("Source"/"From"/"To" for the well-known clips), and the
/// effect input is set: the simple-source clip if present, else
/// `tex_in` if present, else a synthesized `tex_in` texture input
/// (display name "Texture") when the plugin has any clip input at
/// all.
///
/// The plugin-instance bridge is not wired in this crate, so the
/// descriptor walk cannot run here: a placeholder node with a null
/// instance and empty cached metadata is returned. Real construction
/// belongs to the oakplugin bridge's discovery pass, which registers
/// one `PluginNode` per discovered plugin (`// CPP-PARITY: plugin.cpp`
/// `PluginNode::PluginNode`).
pub fn create() -> (NodeCore, Box<dyn NodeBehavior>) {
let core = NodeCore::new();
let node = PluginNode {
instance: PluginInstanceHandle::null(),
sub_category: "General".to_string(),
name: String::new(),
type_id: String::new(),
description: String::new(),
};
(core, Box::new(node))
}
/// Register this node type. NOTE: unlike built-in nodes, plugin nodes
/// have no static type id — the C++ factory appends one `PluginNode`
/// per discovered OFX plugin at runtime
/// (`factory.cpp::add_plugins_to_library`), keyed by plugin
/// identifier, so there is no fixed `NodeMeta` literal to push. This
/// function is a no-op placeholder; real registration belongs to the
/// oakplugin bridge's discovery pass.
pub fn register(meta: &mut Vec<NodeMeta>) {
let _ = meta;
}
#[cfg(test)]
mod tests {
use super::*;
use crate::node::NodeBehavior;
use crate::value::{NodeValueTable, ValueType};
use oakcore_rs::{Rational, TimeRange};
/// A plugin node with a non-null instance and cached metadata.
fn node() -> PluginNode {
PluginNode {
instance: PluginInstanceHandle(1),
sub_category: "Filter".to_string(),
name: "Test Plugin".to_string(),
type_id: "org.example.testplugin".to_string(),
description: "A test plugin".to_string(),
}
}
#[test]
fn metadata_served_from_cache() {
let n = node();
assert_eq!(n.name(), "Test Plugin");
assert_eq!(n.type_id(), "org.example.testplugin");
assert_eq!(n.description(), "A test plugin");
assert_eq!(n.sub_category(), "Filter");
assert_eq!(n.categories(), &[Category::OpenFx]);
}
#[test]
fn instance_handle_null_semantics() {
assert!(PluginInstanceHandle::null().is_null());
assert!(!PluginInstanceHandle(1).is_null());
}
#[test]
fn value_tags_non_texture_inputs() {
let n = node();
let core = NodeCore::new();
let mut row = NodeValueRow::default();
row.insert("opacity".to_string(), NodeValue::Float(0.5));
row.insert("mode".to_string(), NodeValue::Combo(2));
let mut table = NodeValueTable::default();
n.value(&core, &row, Rational::new(0, 1), &mut table);
// Both values pushed, tagged with their input ids.
let tagged: Vec<(&str, &NodeValue)> = table
.rows()
.iter()
.filter(|(_, _, t)| t.is_some())
.map(|(_, v, t)| (t.as_deref().unwrap(), v))
.collect();
assert_eq!(tagged.len(), 2);
assert!(tagged.iter().any(|(id, v)| *id == "opacity" && *v == &NodeValue::Float(0.5)));
assert!(tagged.iter().any(|(id, v)| *id == "mode" && *v == &NodeValue::Combo(2)));
// No texture pushed (no texture input in the row).
assert!(table.get(ValueType::Texture).is_none());
}
#[test]
fn value_skips_texture_and_none_inputs() {
let n = node();
let core = NodeCore::new();
let mut row = NodeValueRow::default();
row.insert("tex_in".to_string(), NodeValue::Texture(crate::handle::CHandle::null()));
row.insert("none_in".to_string(), NodeValue::None);
let mut table = NodeValueTable::default();
n.value(&core, &row, Rational::new(0, 1), &mut table);
// The texture is consumed as the job source; nothing else is pushed
// (none values are skipped; the plugin job is the texture push).
assert_eq!(table.count(), 1);
assert!(matches!(
table.get(ValueType::Texture),
Some(NodeValue::Texture(h)) if h.is_null()
));
}
#[test]
fn value_resolves_source_clip_first() {
let n = node();
let core = NodeCore::new();
let mut row = NodeValueRow::default();
row.insert(SOURCE_CLIP.to_string(), NodeValue::Texture(crate::handle::CHandle::null()));
row.insert(TEXTURE_INPUT.to_string(), NodeValue::Texture(crate::handle::CHandle::null()));
let mut table = NodeValueTable::default();
n.value(&core, &row, Rational::new(0, 1), &mut table);
assert!(matches!(
table.get(ValueType::Texture),
Some(NodeValue::Texture(h)) if h.is_null()
));
}
#[test]
fn value_pushes_nothing_without_instance() {
let n = PluginNode {
instance: PluginInstanceHandle::null(),
..node()
};
let core = NodeCore::new();
let mut row = NodeValueRow::default();
row.insert(TEXTURE_INPUT.to_string(), NodeValue::Texture(crate::handle::CHandle::null()));
let mut table = NodeValueTable::default();
n.value(&core, &row, Rational::new(0, 1), &mut table);
assert!(table.is_empty());
}
#[test]
fn process_samples_copies_verbatim() {
let n = node();
let core = NodeCore::new();
let input = crate::value::SampleBuffer {
format: oakcore_rs::SampleFormat::F32Planar,
channels: 2,
sample_count: 3,
// Planar layout: channel 0 plane [1, 2, 3], channel 1 plane
// [4, 5, 6].
data: vec![
1.0f32, 2.0, 3.0, 4.0, 5.0, 6.0,
]
.iter()
.flat_map(|f| f.to_le_bytes())
.collect(),
};
let mut row = NodeValueRow::default();
row.insert("samples_in".to_string(), NodeValue::Samples(input));
let mut output = crate::value::SampleBuffer::default();
n.process_samples(&core, &row, TimeRange::new(Rational::new(0, 1), Rational::new(1, 1)), &mut output);
assert!(output.is_allocated());
assert_eq!(output.channels, 2);
assert_eq!(output.sample_count, 3);
assert_eq!(output.format, oakcore_rs::SampleFormat::F32Planar);
for c in 0..2 {
for i in 0..3 {
let expected = 1.0 + (c * 3 + i) as f64;
assert_eq!(output.sample_value(c, i), expected);
}
}
}
#[test]
fn process_samples_silences_output_on_missing_input() {
let n = node();
let core = NodeCore::new();
let mut output = crate::value::SampleBuffer {
format: oakcore_rs::SampleFormat::F32,
channels: 1,
sample_count: 2,
data: vec![1.0f32, 2.0].iter().flat_map(|f| f.to_le_bytes()).collect(),
};
n.process_samples(&core, &NodeValueRow::default(), TimeRange::new(Rational::new(0, 1), Rational::new(1, 1)), &mut output);
assert_eq!(output.sample_value(0, 0), 0.0);
assert_eq!(output.sample_value(0, 1), 0.0);
}
#[test]
fn generate_frame_is_documented_noop() {
let n = node();
let core = NodeCore::new();
let mut frame = crate::handle::CHandle::null();
n.generate_frame(&core, &mut frame, Rational::new(0, 1));
assert!(frame.is_null());
}
#[test]
fn duplicate_returns_none() {
let n = node();
assert!(n.duplicate(&NodeCore::new()).is_none());
}
#[test]
fn push_button_clicked_is_noop() {
let mut n = node();
let mut core = NodeCore::new();
n.push_button_clicked(&mut core, "button".to_string());
}
#[test]
fn create_returns_placeholder_node() {
let (core, behavior) = create();
let n = behavior
.as_any()
.unwrap()
.downcast_ref::<PluginNode>()
.unwrap();
assert!(n.instance.is_null());
assert_eq!(n.sub_category(), "General");
assert_eq!(behavior.name(), "");
assert_eq!(behavior.type_id(), "");
// No inputs beyond the standard enabled input.
assert_eq!(core.inputs.len(), 1);
}
}
+356
View File
@@ -0,0 +1,356 @@
// 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/>.
//! Polygon generator (C++ `src/node/src/generator/polygon/polygon.{h,cpp}`,
//! `olive::PolygonGenerator`). Extends the C++ `GeneratorWithMerge`
//! base (see [`super::generatorwithmerge`]).
use crate::factory::NodeMeta;
use crate::node::{Category, NodeBehavior, NodeCore};
/// Points array input id (C++ `k_points_input`). Type: bezier; flags:
/// array. The C++ constructor resizes the array to 5 and sets split
/// standard values to form a default pentagon: `(0, -135)`,
/// `(135, -45)`, `(90, 120)`, `(-90, 120)`, `(-135, -45)`.
pub const POINTS_INPUT: &str = "points_in";
/// Color input id (C++ `k_color_input`). Type: color; default
/// `(1.0, 1.0, 1.0)` (opaque white).
pub const COLOR_INPUT: &str = "color_in";
/// Polygon generator node.
///
/// The C++ members `poly_gizmo_` (PathGizmo), `gizmo_position_handles_`,
/// `gizmo_bezier_handles_` and `gizmo_bezier_lines_` are GUI gizmo
/// pointers with no Rust equivalent here; gizmos are tracked in
/// `NodeCore::gizmos`, so they are omitted.
pub struct PolygonGenerator;
/// Fragment shader for the `"rgb"` shader id (C++ loads
/// `:/shaders/rgb.frag` in `get_shader_code`), recoloring the rasterized
/// polygon mask with the color input. Text copied verbatim from
/// `engine/shaders/rgb.frag`.
const RGB_SHADER_FRAG: &str = r#"// Input texture
uniform sampler2D texture_in;
// Input texture coordinate
in vec2 ove_texcoord;
out vec4 frag_color;
// Input color
uniform vec4 color_in;
void main() {
vec4 color = texture(texture_in, ove_texcoord);
color.rgb = color_in.rgb * color.a;
frag_color = color;
}
"#;
impl PolygonGenerator {
/// Fragment shader for the `"rgb"` request (C++
/// `get_shader_code()` `"rgb"` branch).
fn rgb_shader_frag() -> &'static str {
RGB_SHADER_FRAG
}
}
impl NodeBehavior for PolygonGenerator {
/// Human-readable name (C++ `name()`).
fn name(&self) -> &str {
"Polygon"
}
/// Stable type id (C++ `id()`).
fn type_id(&self) -> &str {
"org.olivevideoeditor.Olive.polygon"
}
/// Categories (C++ `category()`).
fn categories(&self) -> &[Category] {
&[Category::Generator]
}
/// Description (C++ `description()`).
fn description(&self) -> &str {
"Generate a 2D polygon of any amount of points."
}
/// Localized input names (C++ `retranslate()`): the merge-base name
/// (`base_in` "Base") plus `points_in` -> "Points" and `color_in`
/// -> "Color".
fn input_name<'a>(&self, id: &'a str) -> &'a str {
match id {
super::generatorwithmerge::BASE_INPUT => "Base",
POINTS_INPUT => "Points",
COLOR_INPUT => "Color",
_ => id,
}
}
/// Evaluate outputs (C++ `value()`): wraps the generate job
/// (rasterized at u8 pixel format, then recolored by an `"rgb"`
/// shader job sampling it as `texture_in` with `color_in`) in a
/// texture at the sequence video params and pushes it through
/// `push_mergable_job` (merged over `base_in` when connected).
///
/// The Rust model has no generate/shader-job payloads: the deferred
/// job chain (rasterize -> `"rgb"` recolor -> optional `"mrg"`
/// alpha-over) is resolved by the renderer seam, so a null texture
/// handle marks "renderer must produce this texture"
/// (`// CPP-PARITY: polygon.cpp` `value()`).
fn value(
&self,
core: &NodeCore,
inputs: &crate::value::NodeValueRow,
time: oakcore_rs::Rational,
table: &mut crate::value::NodeValueTable,
) {
let _ = (core, time);
super::generatorwithmerge::GeneratorWithMerge::push_mergable_job(
inputs,
crate::handle::CHandle::null(),
table,
);
}
/// Direct frame generation (C++ `generate_frame()`): clears the RGBA
/// buffer to transparent, builds a closed cubic path through the
/// bezier points (each segment from the previous point's control
/// point 2 through the next point's control point 1 to the next
/// point, closing back to the first), and fills it via the
/// facade-installed path-fill backend with divider/pixel-aspect
/// scaling and center translation; without a backend the frame is
/// left empty (warned once).
///
/// The Rust `frame` is an opaque [`crate::bridge::render::TextureHandle`]
/// whose bytes cannot be touched, and this crate has no path-fill
/// backend — so neither the clear nor the fill is representable here
/// (`// CPP-PARITY: polygon.cpp` `generate_frame`). The path building
/// itself (C++ `generate_path`/`add_point_to_path`) operates on the
/// C++ `PainterPath` GUI type, which also has no Rust counterpart.
fn generate_frame(
&self,
core: &NodeCore,
frame: &mut crate::bridge::render::TextureHandle,
time: oakcore_rs::Rational,
) {
let _ = (core, frame, time);
}
/// Gizmo layout (C++ `update_gizmo_positions()`): resolution comes
/// from the base texture's virtual resolution or the square
/// resolution; grows/shrinks the per-point position handles and the
/// 2-per-point bezier handles/lines to the point count, wires new
/// handles to the corresponding `points_in` tracks (position:
/// tracks 0/1; bezier: tracks 2/3 and 4/5, circle-shaped and
/// smaller), and places every handle/line at point + half
/// resolution.
///
/// The resolution comes from the texture's virtual resolution or the
/// globals' square resolution (neither available here), and the
/// per-point/bezier handle placements have no storage in [`Gizmo`] —
/// not representable (`// CPP-PARITY: polygon.cpp`
/// `update_gizmo_positions`).
fn gizmo_update(&self, core: &NodeCore, row: &crate::value::NodeValueRow) {
let _ = (core, row);
}
/// Gizmo drag (C++ `gizmo_drag_move()`): dragging the path gizmo
/// itself is a no-op (C++ FIXME: drag all points); dragging any
/// other gizmo offsets its x/y input draggers by the drag delta
/// from their start values.
///
/// The draggers hold per-drag start values and write keyframe tracks,
/// neither of which the Rust data model carries — not representable
/// (`// CPP-PARITY: polygon.cpp` `gizmo_drag_move`).
fn gizmo_drag(&mut self, core: &mut NodeCore, start: bool, x: f64, y: f64, modifiers: u32) {
let _ = (core, start, x, y, modifiers);
}
/// Shader code request (C++ `get_shader_code()`): `"rgb"` returns
/// the recolor fragment shader; any other request falls through to
/// the merge base (`"mrg"` -> alpha-over shader, else empty).
fn shader_code(&self, request: &str) -> Option<String> {
match request {
"rgb" => Some(Self::rgb_shader_frag().to_string()),
"mrg" => Some(super::generatorwithmerge::merge_shader_frag().to_string()),
_ => None,
}
}
/// Deep copy (C++ `copy()`).
fn duplicate(&self, _core: &NodeCore) -> Option<Box<dyn NodeBehavior>> {
Some(Box::new(PolygonGenerator))
}
}
/// Constructor (C++ `PolygonGenerator::PolygonGenerator()`): on top of
/// the `GeneratorWithMerge` constructor (which adds `base_in`), adds
/// the `points_in` bezier array and `color_in`, resizes the array to
/// the default pentagon documented on [`POINTS_INPUT`], and creates the
/// path gizmo.
///
/// C++ declares `points_in` as `k_bezier` (6 tracks per element: the
/// point and two control points); the crate has no bezier value type, so
/// [`ValueType::Vec4`] substitutes and only the position tracks (0/1)
/// are carried by the standard values below — the control-point tracks
/// default to zero and are dropped (`// CPP-PARITY: polygon.cpp`
/// constructor).
pub fn create() -> (NodeCore, Box<dyn NodeBehavior>) {
let mut core = NodeCore::new();
// GeneratorWithMerge constructor: `base_in` texture input, made the
// effect input, video-effect flag.
let mut base = crate::input::Input::new(
super::generatorwithmerge::BASE_INPUT,
crate::value::ValueType::Texture,
crate::value::NodeValue::None,
);
base.flags |= crate::input::flags::NOT_KEYFRAMABLE;
core.add_input(base);
let mut points = crate::input::Input::new(
POINTS_INPUT,
crate::value::ValueType::Vec4,
crate::value::NodeValue::Vec4([0.0, 0.0, 0.0, 0.0]),
);
points.flags |= crate::input::flags::ARRAY;
points.array_size = 5;
core.add_input(points);
let color = crate::input::Input::new(
COLOR_INPUT,
crate::value::ValueType::Color,
crate::value::NodeValue::Color([1.0, 1.0, 1.0, 1.0]),
);
core.add_input(color);
// The Default Pentagon(tm): element standard values (position tracks
// only — the bezier control-point tracks are not representable).
let pentagon: [(f64, f64); 5] = [
(0.0, -135.0),
(135.0, -45.0),
(90.0, 120.0),
(-90.0, 120.0),
(-135.0, -45.0),
];
for (i, (x, y)) in pentagon.iter().enumerate() {
core.set_standard_value(
POINTS_INPUT,
i as i32,
crate::value::NodeValue::Vec4([*x, *y, 0.0, 0.0]),
);
}
core.flags |= crate::node::flags::VIDEO_EFFECT;
core.effect_input = super::generatorwithmerge::BASE_INPUT.to_string();
(core, Box::new(PolygonGenerator))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::node::NodeBehavior;
use crate::value::{NodeValue, NodeValueTable, ValueType};
use oakcore_rs::Rational;
#[test]
fn input_names() {
let n = PolygonGenerator;
assert_eq!(n.input_name(super::super::generatorwithmerge::BASE_INPUT), "Base");
assert_eq!(n.input_name(POINTS_INPUT), "Points");
assert_eq!(n.input_name(COLOR_INPUT), "Color");
}
#[test]
fn create_wires_inputs_and_flags() {
let (core, behavior) = create();
assert_eq!(behavior.type_id(), "org.olivevideoeditor.Olive.polygon");
let points = core.get_input(POINTS_INPUT).unwrap();
assert_eq!(points.array_size, 5);
assert_ne!(points.flags & crate::input::flags::ARRAY, 0);
// Default pentagon element values (position tracks).
assert_eq!(
core.standard_value(POINTS_INPUT, 0),
NodeValue::Vec4([0.0, -135.0, 0.0, 0.0])
);
assert_eq!(
core.standard_value(POINTS_INPUT, 4),
NodeValue::Vec4([-135.0, -45.0, 0.0, 0.0])
);
assert_eq!(
core.get_input(COLOR_INPUT).unwrap().default,
NodeValue::Color([1.0, 1.0, 1.0, 1.0])
);
assert_eq!(core.effect_input, super::super::generatorwithmerge::BASE_INPUT);
assert_ne!(core.flags & crate::node::flags::VIDEO_EFFECT, 0);
}
#[test]
fn value_pushes_deferred_job() {
let (core, behavior) = create();
let mut table = NodeValueTable::default();
behavior.value(
&core,
&crate::value::NodeValueRow::default(),
Rational::new(0, 1),
&mut table,
);
assert!(table.get(ValueType::Texture).is_some());
}
#[test]
fn value_with_base_merges() {
let (core, behavior) = create();
let inputs = crate::value::NodeValueRow::from([(
super::super::generatorwithmerge::BASE_INPUT.to_string(),
NodeValue::Texture(crate::handle::CHandle::null()),
)]);
let mut table = NodeValueTable::default();
behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
assert!(table.get(ValueType::Texture).is_some());
}
#[test]
fn shader_code_dispatches() {
let n = PolygonGenerator;
let rgb = n.shader_code("rgb").unwrap();
assert!(rgb.contains("color.rgb = color_in.rgb * color.a;"));
let mrg = n.shader_code("mrg").unwrap();
assert!(mrg.contains("base_col *= 1.0 - blend_col.a;"));
assert!(n.shader_code("other").is_none());
}
#[test]
fn duplicate_clones() {
let (core, behavior) = create();
let dup = behavior.duplicate(&core).unwrap();
assert_eq!(dup.name(), "Polygon");
}
}
/// Register this node type (C++ factory entry for
/// `org.olivevideoeditor.Olive.polygon`).
pub fn register(meta: &mut Vec<NodeMeta>) {
meta.push(NodeMeta {
type_id: "org.olivevideoeditor.Olive.polygon",
name: "Polygon",
categories: &[Category::Generator],
create,
});
}
@@ -0,0 +1,390 @@
// 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/>.
//! Ripple distort effect (C++
//! `src/node/src/distort/ripple/rippledistortnode.{h,cpp}`,
//! `olive::RippleDistortNode`).
use crate::factory::NodeMeta;
use crate::node::{Category, Gizmo, NodeBehavior, NodeCore};
/// Texture input id (C++ `k_texture_input`). Type: texture; flags:
/// not-keyframable; this is the node's effect input.
pub const TEXTURE_INPUT: &str = "tex_in";
/// Evolution input id (C++ `k_evolution_input`). Type: float; default
/// `0`.
pub const EVOLUTION_INPUT: &str = "evolution_in";
/// Intensity input id (C++ `k_intensity_input`). Type: float; default
/// `100`.
pub const INTENSITY_INPUT: &str = "intensity_in";
/// Frequency input id (C++ `k_frequency_input`). Type: float; default
/// `1`; properties: `base = 0.01`.
pub const FREQUENCY_INPUT: &str = "frequency_in";
/// Center position input id (C++ `k_position_input`). Type: vec2;
/// default `(0, 0)`.
pub const POSITION_INPUT: &str = "position_in";
/// Stretch input id (C++ `k_stretch_input`). Type: bool; default
/// `false`; when off the ripple is aspect-corrected in the shader.
pub const STRETCH_INPUT: &str = "stretch_in";
/// Ripple distort node. Radiates concentric wave displacement from a
/// center point.
pub struct RippleDistortNode {
/// Center position drag handle (C++ `PointGizmo *gizmo_`; anchor
/// point shape, drags both tracks of `position_in`).
gizmo: Gizmo,
}
/// Fragment shader (C++ loads the `:/shaders/ripple.frag` resource in
/// `get_shader_code`). Text copied verbatim from
/// `engine/shaders/ripple.frag`.
const SHADER_FRAG: &str = r#"uniform float evolution_in;
uniform float intensity_in;
uniform float frequency_in;
uniform vec2 position_in;
uniform bool stretch_in;
uniform vec2 resolution_in;
uniform sampler2D tex_in;
in vec2 ove_texcoord;
out vec4 frag_color;
void main(void) {
vec2 center = position_in/resolution_in;
vec2 adj_texcoord = ove_texcoord;
adj_texcoord -= 0.5;
if (!stretch_in) {
// Adjust by aspect ratio
float ar = (resolution_in.x/resolution_in.y);
if (resolution_in.x > resolution_in.y) {
adj_texcoord.y /= ar;
center.y /= ar;
} else {
adj_texcoord.x *= ar;
center.x *= ar;
}
}
adj_texcoord += 0.5;
center += 0.5;
adj_texcoord -= center;
float len = length(adj_texcoord);
vec2 uv = ove_texcoord + (adj_texcoord/len)*cos((frequency_in)*(len*12.0-evolution_in))*(intensity_in*0.0005);
frag_color = texture(tex_in, uv);
}
"#;
impl RippleDistortNode {
/// Fragment shader (C++ `get_shader_code()`; the request id is
/// ignored, this is the only shader).
fn shader_frag() -> &'static str {
SHADER_FRAG
}
}
impl NodeBehavior for RippleDistortNode {
/// Human-readable name (C++ `name()`).
fn name(&self) -> &str {
"Ripple"
}
/// Stable type id (C++ `id()`).
fn type_id(&self) -> &str {
"org.olivevideoeditor.Olive.ripple"
}
/// Categories (C++ `category()`).
fn categories(&self) -> &[Category] {
&[Category::Distort]
}
/// Description (C++ `description()`).
fn description(&self) -> &str {
"Distorts an image with a ripple effect."
}
/// Localized input names (C++ `retranslate()`): `tex_in` ->
/// "Input", `frequency_in` -> "Frequency", `intensity_in` ->
/// "Intensity", `evolution_in` -> "Evolution", `position_in` ->
/// "Position", `stretch_in` -> "Stretch".
fn input_name<'a>(&self, id: &'a str) -> &'a str {
match id {
TEXTURE_INPUT => "Input",
FREQUENCY_INPUT => "Frequency",
INTENSITY_INPUT => "Intensity",
EVOLUTION_INPUT => "Evolution",
POSITION_INPUT => "Position",
STRETCH_INPUT => "Stretch",
_ => id,
}
}
/// Evaluate outputs (C++ `value()`): no texture -> push nothing;
/// intensity != 0.0 -> shader job over the whole value row with
/// `resolution_in` inserted from the texture's virtual resolution;
/// intensity == 0.0 -> pass-through push of the input texture
/// unchanged.
///
/// The Rust model has no shader-job payload: the job (including the
/// `resolution_in` value) is deferred to the renderer seam
/// (`// CPP-PARITY: rippledistortnode.cpp` value()).
fn value(
&self,
core: &NodeCore,
inputs: &crate::value::NodeValueRow,
time: oakcore_rs::Rational,
table: &mut crate::value::NodeValueTable,
) {
let tex = match inputs.get(TEXTURE_INPUT) {
Some(tex @ crate::value::NodeValue::Texture(_)) => tex.clone(),
_ => return,
};
let intensity = match inputs.get(INTENSITY_INPUT) {
Some(v) => v.to_double(),
None => core.value_at_time(INTENSITY_INPUT, -1, time).to_double(),
};
if intensity != 0.0 {
table.push(
crate::value::ValueType::Texture,
crate::value::NodeValue::Texture(crate::handle::CHandle::null()),
None,
);
} else {
table.push(crate::value::ValueType::Texture, tex, None);
}
}
/// Shader code request (C++ `get_shader_code()`): ignores the
/// request id and always returns the ripple fragment shader.
fn shader_code(&self, _request: &str) -> Option<String> {
Some(Self::shader_frag().to_string())
}
/// Gizmo positions (C++ `update_gizmo_positions()`): with a texture,
/// places the position gizmo at the texture's half resolution plus
/// the `position_in` offset.
///
/// The placement needs the texture's virtual resolution (the Rust
/// texture handle carries no params) and the resulting point has no
/// storage in [`Gizmo`] — not representable here
/// (`// CPP-PARITY: rippledistortnode.cpp`
/// `update_gizmo_positions`).
fn gizmo_update(&self, core: &NodeCore, row: &crate::value::NodeValueRow) {
let _ = (core, row);
}
/// Gizmo drag (C++ `gizmo_drag_move()`): drags the position input's
/// X and Y track draggers by the mouse delta added to their
/// drag-start values.
///
/// The draggers hold per-drag start values and write keyframe tracks,
/// neither of which the Rust data model carries — not representable
/// here (`// CPP-PARITY: rippledistortnode.cpp` `gizmo_drag_move`).
fn gizmo_drag(&mut self, core: &mut NodeCore, start: bool, x: f64, y: f64, modifiers: u32) {
let _ = (core, start, x, y, modifiers);
}
/// Deep copy (C++ `copy()`).
fn duplicate(&self, _core: &NodeCore) -> Option<Box<dyn NodeBehavior>> {
Some(Box::new(RippleDistortNode {
gizmo: self.gizmo.clone(),
}))
}
}
/// Constructor (C++ `RippleDistortNode::RippleDistortNode()`): adds
/// `tex_in`, `evolution_in`, `intensity_in`, `frequency_in`,
/// `position_in` and `stretch_in` with the defaults and properties
/// documented on the constants; creates the anchor-shaped position point
/// gizmo bound to both tracks of `position_in`; sets the video-effect
/// flag and the effect input.
pub fn create() -> (NodeCore, Box<dyn NodeBehavior>) {
let mut core = NodeCore::new();
let mut tex = crate::input::Input::new(
TEXTURE_INPUT,
crate::value::ValueType::Texture,
crate::value::NodeValue::None,
);
tex.flags |= crate::input::flags::NOT_KEYFRAMABLE;
core.add_input(tex);
core.add_input(crate::input::Input::new(
EVOLUTION_INPUT,
crate::value::ValueType::Float,
crate::value::NodeValue::Float(0.0),
));
core.add_input(crate::input::Input::new(
INTENSITY_INPUT,
crate::value::ValueType::Float,
crate::value::NodeValue::Float(100.0),
));
let mut frequency = crate::input::Input::new(
FREQUENCY_INPUT,
crate::value::ValueType::Float,
crate::value::NodeValue::Float(1.0),
);
frequency.properties = vec![("base".to_string(), crate::value::NodeValue::Float(0.01))];
core.add_input(frequency);
core.add_input(crate::input::Input::new(
POSITION_INPUT,
crate::value::ValueType::Vec2,
crate::value::NodeValue::Vec2([0.0, 0.0]),
));
core.add_input(crate::input::Input::new(
STRETCH_INPUT,
crate::value::ValueType::Boolean,
crate::value::NodeValue::Boolean(false),
));
// Anchor-shaped position point gizmo (C++ `PointGizmo` with
// `k_anchor_point` shape) dragging both tracks of `position_in`.
let gizmo = Gizmo {
position_inputs: vec![
(POSITION_INPUT.to_string(), -1, 0),
(POSITION_INPUT.to_string(), -1, 1),
],
drag_point: (0.0, 0.0),
};
core.gizmos = vec![gizmo.clone()];
core.flags |= crate::node::flags::VIDEO_EFFECT;
core.effect_input = TEXTURE_INPUT.to_string();
(core, Box::new(RippleDistortNode { gizmo }))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::node::NodeBehavior;
use crate::value::{NodeValue, NodeValueTable, ValueType};
use oakcore_rs::Rational;
fn tex() -> NodeValue {
NodeValue::Texture(crate::handle::CHandle::null())
}
#[test]
fn input_names() {
let n = RippleDistortNode {
gizmo: Gizmo {
position_inputs: vec![],
drag_point: (0.0, 0.0),
},
};
assert_eq!(n.input_name(TEXTURE_INPUT), "Input");
assert_eq!(n.input_name(FREQUENCY_INPUT), "Frequency");
assert_eq!(n.input_name(INTENSITY_INPUT), "Intensity");
assert_eq!(n.input_name(EVOLUTION_INPUT), "Evolution");
assert_eq!(n.input_name(POSITION_INPUT), "Position");
assert_eq!(n.input_name(STRETCH_INPUT), "Stretch");
}
#[test]
fn create_wires_inputs_and_flags() {
let (core, behavior) = create();
assert_eq!(behavior.type_id(), "org.olivevideoeditor.Olive.ripple");
assert_eq!(
core.get_input(INTENSITY_INPUT).unwrap().default,
NodeValue::Float(100.0)
);
assert_eq!(
core.get_input(FREQUENCY_INPUT).unwrap().default,
NodeValue::Float(1.0)
);
// One anchor-shaped position gizmo bound to both tracks.
assert_eq!(core.gizmos.len(), 1);
assert_eq!(core.gizmos[0].position_inputs.len(), 2);
assert_eq!(core.effect_input, TEXTURE_INPUT);
assert_ne!(core.flags & crate::node::flags::VIDEO_EFFECT, 0);
}
#[test]
fn value_no_texture_pushes_nothing() {
let (core, behavior) = create();
let mut table = NodeValueTable::default();
behavior.value(
&core,
&crate::value::NodeValueRow::default(),
Rational::new(0, 1),
&mut table,
);
assert!(table.is_empty());
}
#[test]
fn value_zero_intensity_passes_texture_through() {
let (mut core, behavior) = create();
core.set_standard_value(INTENSITY_INPUT, -1, NodeValue::Float(0.0));
let tex = tex();
let inputs = crate::value::NodeValueRow::from([(TEXTURE_INPUT.to_string(), tex.clone())]);
let mut table = NodeValueTable::default();
behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
assert_eq!(table.get(ValueType::Texture), Some(&tex));
}
#[test]
fn value_nonzero_intensity_pushes_deferred_job() {
let (core, behavior) = create();
let inputs = crate::value::NodeValueRow::from([
(TEXTURE_INPUT.to_string(), tex()),
(INTENSITY_INPUT.to_string(), NodeValue::Float(100.0)),
]);
let mut table = NodeValueTable::default();
behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
assert!(table.get(ValueType::Texture).is_some());
}
#[test]
fn shader_code_returns_ripple_shader() {
let (_, behavior) = create();
let code = behavior.shader_code("anything").unwrap();
assert!(code.contains("uniform float intensity_in;"));
assert!(code.contains("adj_texcoord -= center;"));
}
#[test]
fn duplicate_clones() {
let (core, behavior) = create();
let dup = behavior.duplicate(&core).unwrap();
assert_eq!(dup.name(), "Ripple");
}
}
/// Register this node type (C++ factory entry for
/// `org.olivevideoeditor.Olive.ripple`).
pub fn register(meta: &mut Vec<NodeMeta>) {
meta.push(NodeMeta {
type_id: "org.olivevideoeditor.Olive.ripple",
name: "Ripple",
categories: &[Category::Distort],
create,
});
}
+411
View File
@@ -0,0 +1,411 @@
// 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/>.
//! Shape generator (C++ `src/node/src/generator/shape/shapenode.{h,cpp}`,
//! `olive::ShapeNode`). Extends the C++ `ShapeNodeBase` /
//! `GeneratorWithMerge` bases (see [`super::shapenodebase`] and
//! [`super::generatorwithmerge`]).
use crate::factory::NodeMeta;
use crate::node::{Category, NodeBehavior, NodeCore};
/// Shape type input id (C++ `k_type_input`). Type: combo; prepended
/// ahead of the base inputs; combo strings (matching the C++ `Type`
/// enum and the `SHAPE_*` constants in the shader): 0 = "Rectangle",
/// 1 = "Ellipse", 2 = "Rounded Rectangle".
pub const TYPE_INPUT: &str = "type_in";
/// Corner radius input id (C++ `k_radius_input`). Type: float; default
/// `20.0`; properties: `min = 0.0`; hidden unless the type is
/// "Rounded Rectangle" (see `input_value_changed`).
pub const RADIUS_INPUT: &str = "radius_in";
/// Base inputs provided by the C++ bases: `base_in`
/// ([`super::generatorwithmerge::BASE_INPUT`]), and `pos_in` /
/// `size_in` / `color_in` ([`super::shapenodebase`]).
/// Shape generator node. Has no own member fields in C++ beyond the
/// base classes (inputs and gizmos live in the core).
pub struct ShapeNode;
/// Fragment shader for the `"shape"` shader id (C++ loads
/// `:/shaders/shape.frag` in `get_shader_code`). Text copied verbatim
/// from `engine/shaders/shape.frag`.
const SHADER_FRAG: &str = r#"// Input texture coordinate
in vec2 ove_texcoord;
out vec4 frag_color;
// Match with ShapeNode::Type
const int SHAPE_RECTANGLE = 0;
const int SHAPE_ELLIPSE = 1;
const int SHAPE_ROUNDEDRECT = 2;
uniform vec2 pos_in;
uniform vec2 size_in;
uniform int type_in;
uniform vec2 resolution_in;
uniform vec4 color_in;
uniform float radius_in;
vec4 draw_rect(vec2 real_position, vec2 real_size)
{
if (ove_texcoord.x >= real_position.x && ove_texcoord.y >= real_position.y
&& ove_texcoord.x < real_position.x+real_size.x && ove_texcoord.y < real_position.y+real_size.y) {
return color_in;
} else {
return vec4(0.0, 0.0, 0.0, 0.0);
}
}
vec4 draw_ellipse(vec2 center, float radius, float aspect_ratio) {
vec2 offset = ove_texcoord*resolution_in - center;
offset.x /= aspect_ratio;
float d = length(offset)-radius;
float t = clamp(d, 0.0, 1.0);
return color_in * (1.0-t);
}
void main() {
vec2 p = pos_in + resolution_in*0.5 - size_in*0.5;
vec2 real_position = p/resolution_in;
vec2 real_size = size_in/resolution_in;
vec4 col = vec4(0.0);
switch (type_in) {
case SHAPE_RECTANGLE:
{
col = draw_rect(real_position, real_size);
break;
}
case SHAPE_ELLIPSE:
{
vec2 center = p+size_in*0.5;
float radius = size_in.y*0.5;
float aspect_ratio = size_in.x/size_in.y;
col = draw_ellipse(center, radius, aspect_ratio);
break;
}
case SHAPE_ROUNDEDRECT:
{
// Limit radius so it is never larger than half the shortest size
float r = min(radius_in, min(size_in.y*0.5, size_in.x*0.5));
vec2 real_rad = vec2(r / resolution_in.x, r / resolution_in.y);
if (ove_texcoord.x < real_position.x + real_rad.x && ove_texcoord.y < real_position.y + real_rad.y) {
// Top-left
col = draw_ellipse(p + r, r, 1.0);
} else if (ove_texcoord.x > real_position.x+real_size.x - real_rad.x && ove_texcoord.y < real_position.y + real_rad.y) {
// Top-right
col = draw_ellipse(vec2(p.x + size_in.x - r, p.y + r), r, 1.0);
} else if (ove_texcoord.x < real_position.x + real_rad.x && ove_texcoord.y > real_position.y + real_size.y - real_rad.y) {
// Bottom-left
col = draw_ellipse(vec2(p.x + r, p.y + size_in.y - r), r, 1.0);
} else if (ove_texcoord.x > real_position.x+real_size.x - real_rad.x && ove_texcoord.y > real_position.y + real_size.y - real_rad.y) {
// Bottom-right
col = draw_ellipse(vec2(p.x + size_in.x - r, p.y + size_in.y - r), r, 1.0);
} else {
col = draw_rect(real_position, real_size);
}
break;
}
}
frag_color = col;
}
"#;
impl ShapeNode {
/// Fragment shader for the `"shape"` request (C++
/// `get_shader_code()` `"shape"` branch).
fn shader_frag() -> &'static str {
SHADER_FRAG
}
}
impl NodeBehavior for ShapeNode {
/// Human-readable name (C++ `name()`).
fn name(&self) -> &str {
"Shape"
}
/// Stable type id (C++ `id()`).
fn type_id(&self) -> &str {
"org.olivevideoeditor.Olive.shape"
}
/// Categories (C++ `category()`).
fn categories(&self) -> &[Category] {
&[Category::Generator]
}
/// Description (C++ `description()`).
fn description(&self) -> &str {
"Generate a 2D primitive shape."
}
/// Localized input names (C++ `retranslate()`): the base names
/// (`base_in` "Base", `pos_in` "Position", `size_in` "Size",
/// `color_in` "Color") plus `type_in` -> "Type" and `radius_in` ->
/// "Radius"; also sets the `type_in` combo strings to
/// "Rectangle"/"Ellipse"/"Rounded Rectangle".
fn input_name<'a>(&self, id: &'a str) -> &'a str {
// The base names come from `ShapeNodeBase::input_name` plus the
// merge base's `base_in` "Base" (the C++ retranslate chain runs
// `GeneratorWithMerge::retranslate` first).
match id {
TYPE_INPUT => "Type",
RADIUS_INPUT => "Radius",
super::generatorwithmerge::BASE_INPUT => "Base",
_ => super::shapenodebase::ShapeNodeBase::input_name(id),
}
}
/// Evaluate outputs (C++ `value()`): builds a `"shape"` shader job
/// from the input row, inserting `resolution_in` (the base
/// texture's virtual resolution when connected, else the sequence
/// square resolution), renders it at the base texture's params (or
/// the sequence video params), and pushes it through
/// `push_mergable_job` (merged over `base_in` when connected).
///
/// The Rust model has no shader-job payload: the deferred job
/// (including the `resolution_in` value and the `"shape"` shader id)
/// is resolved by the renderer seam, so a null texture handle marks
/// "renderer must produce this texture" (`// CPP-PARITY: shapenode.cpp`
/// `value()`).
fn value(
&self,
core: &NodeCore,
inputs: &crate::value::NodeValueRow,
time: oakcore_rs::Rational,
table: &mut crate::value::NodeValueTable,
) {
let _ = (core, time);
super::generatorwithmerge::GeneratorWithMerge::push_mergable_job(
inputs,
crate::handle::CHandle::null(),
table,
);
}
/// Shader code request (C++ `get_shader_code()`): `"shape"` returns
/// the shape fragment shader; any other request falls through to
/// the merge base (`"mrg"` -> alpha-over shader, else empty).
fn shader_code(&self, request: &str) -> Option<String> {
match request {
"shape" => Some(Self::shader_frag().to_string()),
"mrg" => Some(super::generatorwithmerge::merge_shader_frag().to_string()),
_ => None,
}
}
/// Input value changed (C++ `InputValueChangedEvent`): when
/// `type_in` changes, sets the hidden flag on `radius_in` unless
/// the selected type is `k_rounded_rectangle` (2); then chains to
/// the base implementation.
fn input_value_changed(&mut self, core: &mut NodeCore, input: &str, element: i32) {
if input == TYPE_INPUT && element == -1 {
let ty = core.standard_value(TYPE_INPUT, -1).to_double() as i64;
if let Some(radius) = core.get_input_mut(RADIUS_INPUT) {
if ty == 2 {
radius.flags &= !crate::input::flags::HIDDEN;
} else {
radius.flags |= crate::input::flags::HIDDEN;
}
}
}
}
/// Deep copy (C++ `copy()`).
fn duplicate(&self, _core: &NodeCore) -> Option<Box<dyn NodeBehavior>> {
Some(Box::new(ShapeNode))
}
}
/// Constructor (C++ `ShapeNode::ShapeNode()`): on top of the
/// `ShapeNodeBase` constructor (which adds `base_in`, `pos_in`,
/// `size_in`, `color_in` and the gizmos), prepends the `type_in` combo
/// and adds `radius_in` with the default and property documented on the
/// constant.
///
/// Input order matches the C++: `enabled_in`, `type_in` (prepended
/// ahead of the base inputs), `base_in`, `pos_in`, `size_in`,
/// `color_in`, `radius_in`. The base's gizmos are GUI gizmos with no
/// Rust equivalent (see [`super::shapenodebase`]), so no gizmos are
/// registered.
pub fn create() -> (NodeCore, Box<dyn NodeBehavior>) {
let mut core = NodeCore::new();
// ShapeNodeBase constructor (GeneratorWithMerge + shape inputs).
let mut base = crate::input::Input::new(
super::generatorwithmerge::BASE_INPUT,
crate::value::ValueType::Texture,
crate::value::NodeValue::None,
);
base.flags |= crate::input::flags::NOT_KEYFRAMABLE;
core.add_input(base);
let pos = crate::input::Input::new(
super::shapenodebase::POSITION_INPUT,
crate::value::ValueType::Vec2,
crate::value::NodeValue::Vec2([0.0, 0.0]),
);
core.add_input(pos);
let mut size = crate::input::Input::new(
super::shapenodebase::SIZE_INPUT,
crate::value::ValueType::Vec2,
crate::value::NodeValue::Vec2([100.0, 100.0]),
);
size.properties = vec![("min".to_string(), crate::value::NodeValue::Vec2([0.0, 0.0]))];
core.add_input(size);
let color = crate::input::Input::new(
super::shapenodebase::COLOR_INPUT,
crate::value::ValueType::Color,
crate::value::NodeValue::Color([1.0, 0.0, 0.0, 1.0]),
);
core.add_input(color);
// ShapeNode: prepend `type_in` ahead of the base inputs (index 1,
// after `enabled_in`), then append `radius_in`.
core.inputs.insert(
1,
crate::input::Input::new(
TYPE_INPUT,
crate::value::ValueType::Combo,
crate::value::NodeValue::Combo(0),
),
);
let mut radius = crate::input::Input::new(
RADIUS_INPUT,
crate::value::ValueType::Float,
crate::value::NodeValue::Float(20.0),
);
radius.properties = vec![("min".to_string(), crate::value::NodeValue::Float(0.0))];
core.add_input(radius);
// GeneratorWithMerge constructor side effects.
core.flags |= crate::node::flags::VIDEO_EFFECT;
core.effect_input = super::generatorwithmerge::BASE_INPUT.to_string();
(core, Box::new(ShapeNode))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::node::NodeBehavior;
use crate::value::{NodeValue, NodeValueTable, ValueType};
use oakcore_rs::Rational;
#[test]
fn input_names() {
let n = ShapeNode;
assert_eq!(n.input_name(TYPE_INPUT), "Type");
assert_eq!(n.input_name(RADIUS_INPUT), "Radius");
assert_eq!(n.input_name(super::super::generatorwithmerge::BASE_INPUT), "Base");
assert_eq!(n.input_name(super::super::shapenodebase::POSITION_INPUT), "Position");
assert_eq!(n.input_name(super::super::shapenodebase::SIZE_INPUT), "Size");
assert_eq!(n.input_name(super::super::shapenodebase::COLOR_INPUT), "Color");
}
#[test]
fn create_wires_inputs_and_flags() {
let (core, behavior) = create();
assert_eq!(behavior.type_id(), "org.olivevideoeditor.Olive.shape");
// `type_in` is prepended right after `enabled_in`.
assert_eq!(core.inputs[1].id, TYPE_INPUT);
assert_eq!(core.inputs[1].default, NodeValue::Combo(0));
assert_eq!(
core.get_input(RADIUS_INPUT).unwrap().default,
NodeValue::Float(20.0)
);
assert_eq!(
core.get_input(super::super::shapenodebase::SIZE_INPUT).unwrap().default,
NodeValue::Vec2([100.0, 100.0])
);
assert_eq!(
core.get_input(super::super::shapenodebase::COLOR_INPUT).unwrap().default,
NodeValue::Color([1.0, 0.0, 0.0, 1.0])
);
assert_eq!(core.effect_input, super::super::generatorwithmerge::BASE_INPUT);
assert_ne!(core.flags & crate::node::flags::VIDEO_EFFECT, 0);
}
#[test]
fn value_pushes_deferred_job() {
let (core, behavior) = create();
let mut table = NodeValueTable::default();
behavior.value(
&core,
&crate::value::NodeValueRow::default(),
Rational::new(0, 1),
&mut table,
);
assert!(table.get(ValueType::Texture).is_some());
}
#[test]
fn shader_code_dispatches() {
let n = ShapeNode;
let shape = n.shader_code("shape").unwrap();
assert!(shape.contains("const int SHAPE_RECTANGLE = 0;"));
let mrg = n.shader_code("mrg").unwrap();
assert!(mrg.contains("base_col *= 1.0 - blend_col.a;"));
assert!(n.shader_code("other").is_none());
}
#[test]
fn input_value_changed_toggles_radius_hidden() {
let (mut core, behavior) = create();
let mut b = behavior;
// Default type is rectangle (0): radius_in becomes hidden.
b.input_value_changed(&mut core, TYPE_INPUT, -1);
assert_ne!(
core.get_input(RADIUS_INPUT).unwrap().flags & crate::input::flags::HIDDEN,
0
);
// Rounded rectangle (2): radius_in is shown.
core.set_standard_value(TYPE_INPUT, -1, NodeValue::Combo(2));
b.input_value_changed(&mut core, TYPE_INPUT, -1);
assert_eq!(
core.get_input(RADIUS_INPUT).unwrap().flags & crate::input::flags::HIDDEN,
0
);
}
#[test]
fn duplicate_clones() {
let (core, behavior) = create();
let dup = behavior.duplicate(&core).unwrap();
assert_eq!(dup.name(), "Shape");
}
}
/// Register this node type (C++ factory entry for
/// `org.olivevideoeditor.Olive.shape`).
pub fn register(meta: &mut Vec<NodeMeta>) {
meta.push(NodeMeta {
type_id: "org.olivevideoeditor.Olive.shape",
name: "Shape",
categories: &[Category::Generator],
create,
});
}
+135
View File
@@ -0,0 +1,135 @@
// 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/>.
//! Shared shape-node base (C++
//! `src/node/src/generator/shape/shapenodebase.{h,cpp}`,
//! `olive::ShapeNodeBase`).
//!
//! Abstract C++ base (extends `GeneratorWithMerge`, see
//! [`super::generatorwithmerge`]) for shape generators with position,
//! size, color and rect-scaling gizmos. Not instantiable, so this is a
//! helper module, not a [`NodeBehavior`] implementation.
/// Position input id (C++ `k_position_input`). Type: vec2; default
/// `(0, 0)`.
pub const POSITION_INPUT: &str = "pos_in";
/// Size input id (C++ `k_size_input`). Type: vec2; default
/// `(100, 100)`; properties: `min = (0, 0)`.
pub const SIZE_INPUT: &str = "size_in";
/// Color input id (C++ `k_color_input`). Type: color; default
/// `(1.0, 0.0, 0.0, 1.0)`; only added when the base is constructed with
/// `create_color_input = true`.
pub const COLOR_INPUT: &str = "color_in";
/// Helper mirroring the C++ `ShapeNodeBase` base.
///
/// The C++ members `point_gizmo_[k_gizmo_scale_count]` (8 rect-scale
/// point gizmos) and `poly_gizmo_` (whole-rect draggable polygon
/// gizmo) are GUI gizmo pointers with no Rust equivalent here; gizmos
/// are tracked in `NodeCore::gizmos`, so they are omitted. The
/// `k_gizmo_scale_*` index constants come from the C++ gizmo layer.
pub struct ShapeNodeBase;
impl ShapeNodeBase {
/// Localized base input names (C++ `retranslate()` on top of the
/// merge base): `pos_in` -> "Position", `size_in` -> "Size", and
/// `color_in` -> "Color" when the color input exists.
pub fn input_name(id: &str) -> &str {
match id {
POSITION_INPUT => "Position",
SIZE_INPUT => "Size",
COLOR_INPUT => "Color",
_ => id,
}
}
/// Gizmo layout (C++ `update_gizmo_positions()`): centers the rect
/// around the square-resolution midpoint (also stored as the
/// `offset` property of `pos_in` so values appear top-left
/// anchored), then places the 8 scale-point gizmos at the rect's
/// corners/edge centers and the polygon gizmo on the four corners
/// in top-left, top-right, bottom-right, bottom-left order.
///
/// The C++ writes the resulting points into its `PointGizmo` /
/// `PolygonGizmo` objects; the Rust `NodeCore::gizmos` records only
/// each gizmo's tracked input references and drag position, so the
/// only persistable half is the `offset` property — which requires
/// the square resolution from `NodeGlobals`, not carried by this
/// signature. The property write and the gizmo point placements are
/// therefore not representable here (`// CPP-PARITY:
/// shapenodebase.cpp` `update_gizmo_positions`).
pub fn update_gizmo_positions(core: &mut crate::node::NodeCore, row: &crate::value::NodeValueRow) {
let _ = (core, row);
}
/// Undoable rect assignment (C++ `set_rect()`): normalizes the rect
/// around the sequence center, then pushes undo children setting
/// `size_in` x/y and `pos_in` x/y standard values.
///
/// The C++ normalization needs the sequence resolution and the undo
/// command stack; neither is carried by this signature or this
/// crate's data model, so the writes are not representable here
/// (`// CPP-PARITY: shapenodebase.cpp` `set_rect`).
pub fn set_rect(core: &mut crate::node::NodeCore, rect: (f64, f64, f64, f64)) {
let _ = (core, rect);
}
/// Gizmo drag (C++ `gizmo_drag_move()`): dragging the polygon
/// gizmo offsets `pos_in` x/y directly; dragging a scale-point
/// gizmo resizes with anchor-at-opposite-point semantics —
/// Alt drags from center, Shift keeps the original aspect ratio
/// (center-edge gizmos derive the other axis from the ratio,
/// corner gizmos reconstruct both axes from the original angle and
/// the new hypotenuse) — and writes the new position/size through
/// the gizmo's four input draggers.
///
/// The C++ logic operates on its `DraggableGizmo`/dragger objects
/// with per-gizmo start values and keyframe-track references; the
/// Rust `NodeCore::gizmos` has no dragger state, so the drag is not
/// representable here (`// CPP-PARITY: shapenodebase.cpp`
/// `gizmo_drag_move`).
pub fn gizmo_drag_move(core: &mut crate::node::NodeCore, x: f64, y: f64, modifiers: u32) {
let _ = (core, x, y, modifiers);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::node::NodeCore;
#[test]
fn input_names() {
assert_eq!(ShapeNodeBase::input_name(POSITION_INPUT), "Position");
assert_eq!(ShapeNodeBase::input_name(SIZE_INPUT), "Size");
assert_eq!(ShapeNodeBase::input_name(COLOR_INPUT), "Color");
assert_eq!(ShapeNodeBase::input_name("other_in"), "other_in");
}
#[test]
fn gizmo_helpers_are_documented_noops() {
// The gizmo data model is not representable in NodeCore (see the
// method docs); the calls must be safe no-ops.
let mut core = NodeCore::new();
let row = crate::value::NodeValueRow::default();
ShapeNodeBase::update_gizmo_positions(&mut core, &row);
ShapeNodeBase::set_rect(&mut core, (0.0, 0.0, 100.0, 100.0));
ShapeNodeBase::gizmo_drag_move(&mut core, 10.0, 20.0, 0);
assert!(core.get_input(POSITION_INPUT).is_none(), "no inputs are added");
}
}
+189
View File
@@ -0,0 +1,189 @@
// 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/>.
//! Solid color generator (C++ `src/node/src/generator/solid/solid.{h,cpp}`,
//! `olive::SolidGenerator`).
use crate::factory::NodeMeta;
use crate::node::{Category, NodeBehavior, NodeCore};
/// Color input id (C++ `k_color_input`). Type: color; default
/// `(1.0, 0.0, 0.0, 1.0)` (red — "a color that isn't black").
pub const COLOR_INPUT: &str = "color_in";
/// Solid color generator node. Has no own member fields in C++.
pub struct SolidGenerator;
/// Fragment shader (C++ loads the `:/shaders/solid.frag` resource in
/// `get_shader_code`). Text copied verbatim from
/// `engine/shaders/solid.frag`.
const SHADER_FRAG: &str = r#"uniform vec4 color_in;
out vec4 frag_color;
void main(void) {
frag_color = color_in;
}
"#;
impl SolidGenerator {
/// Fragment shader for all shader requests (C++
/// `get_shader_code()` ignores the request id).
fn shader_frag() -> &'static str {
SHADER_FRAG
}
}
impl NodeBehavior for SolidGenerator {
/// Human-readable name (C++ `name()`).
fn name(&self) -> &str {
"Solid"
}
/// Stable type id (C++ `id()`).
fn type_id(&self) -> &str {
"org.olivevideoeditor.Olive.solidgenerator"
}
/// Categories (C++ `category()`).
fn categories(&self) -> &[Category] {
&[Category::Generator]
}
/// Description (C++ `description()`).
fn description(&self) -> &str {
"Generate a solid color."
}
/// Localized input names (C++ `retranslate()`): `color_in` ->
/// "Color".
fn input_name<'a>(&self, id: &'a str) -> &'a str {
match id {
COLOR_INPUT => "Color",
_ => id,
}
}
/// Evaluate outputs (C++ `value()`): pushes a texture job built
/// from the whole input row at the sequence video params.
///
/// The Rust model has no shader-job payload: the job is deferred to
/// the renderer seam, so a null texture handle marks "renderer must
/// produce this texture" (`// CPP-PARITY: solid.cpp` value()).
fn value(
&self,
core: &NodeCore,
inputs: &crate::value::NodeValueRow,
time: oakcore_rs::Rational,
table: &mut crate::value::NodeValueTable,
) {
let _ = (core, inputs, time);
table.push(
crate::value::ValueType::Texture,
crate::value::NodeValue::Texture(crate::handle::CHandle::null()),
None,
);
}
/// Shader code request (C++ `get_shader_code()`): returns the solid
/// fragment shader for any request id.
fn shader_code(&self, request: &str) -> Option<String> {
let _ = request;
Some(Self::shader_frag().to_string())
}
/// Deep copy (C++ `copy()`).
fn duplicate(&self, _core: &NodeCore) -> Option<Box<dyn NodeBehavior>> {
Some(Box::new(SolidGenerator))
}
}
/// Constructor (C++ `SolidGenerator::SolidGenerator()`): adds
/// `color_in` with the default documented on the constant.
pub fn create() -> (NodeCore, Box<dyn NodeBehavior>) {
let mut core = NodeCore::new();
let mut color = crate::input::Input::new(
COLOR_INPUT,
crate::value::ValueType::Color,
crate::value::NodeValue::Color([1.0, 0.0, 0.0, 1.0]),
);
color.properties = vec![("view".to_string(), crate::value::NodeValue::Text("color".into()))];
core.add_input(color);
(core, Box::new(SolidGenerator))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::node::NodeBehavior;
use crate::value::{NodeValue, NodeValueTable, ValueType};
use oakcore_rs::Rational;
#[test]
fn input_names() {
let n = SolidGenerator;
assert_eq!(n.input_name(COLOR_INPUT), "Color");
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.solidgenerator");
assert_eq!(
core.get_input(COLOR_INPUT).unwrap().default,
NodeValue::Color([1.0, 0.0, 0.0, 1.0])
);
}
#[test]
fn value_pushes_deferred_job() {
let (core, behavior) = create();
let mut table = NodeValueTable::default();
behavior.value(
&core,
&crate::value::NodeValueRow::default(),
Rational::new(0, 1),
&mut table,
);
assert!(table.get(ValueType::Texture).is_some());
}
#[test]
fn shader_code_returns_solid_shader() {
let n = SolidGenerator;
let code = n.shader_code("anything").unwrap();
assert!(code.contains("uniform vec4 color_in;"));
}
#[test]
fn duplicate_clones() {
let (core, behavior) = create();
let dup = behavior.duplicate(&core).unwrap();
assert_eq!(dup.name(), "Solid");
}
}
/// Register this node type (C++ factory entry for
/// `org.olivevideoeditor.Olive.solidgenerator`).
pub fn register(meta: &mut Vec<NodeMeta>) {
meta.push(NodeMeta {
type_id: "org.olivevideoeditor.Olive.solidgenerator",
name: "Solid",
categories: &[Category::Generator],
create,
});
}
+397
View File
@@ -0,0 +1,397 @@
// 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/>.
//! Stroke filter (C++ `src/node/src/filter/stroke/stroke.{h,cpp}`,
//! `olive::StrokeFilterNode`).
use crate::factory::NodeMeta;
use crate::node::{Category, NodeBehavior, NodeCore};
/// Texture input id (C++ `k_texture_input`). Type: texture; flags:
/// not-keyframable; this is the node's effect input.
pub const TEXTURE_INPUT: &str = "tex_in";
/// Stroke color input id (C++ `k_color_input`). Type: color; default
/// opaque white `(1.0, 1.0, 1.0, 1.0)`.
pub const COLOR_INPUT: &str = "color_in";
/// Stroke radius input id (C++ `k_radius_input`). Type: float; default
/// `10.0`; properties: `min = 0.0`.
pub const RADIUS_INPUT: &str = "radius_in";
/// Stroke opacity input id (C++ `k_opacity_input`). Type: float;
/// default `1.0`; properties: `view = percentage`, `min = 0.0`,
/// `max = 1.0`.
pub const OPACITY_INPUT: &str = "opacity_in";
/// Inner-stroke toggle input id (C++ `k_inner_input`). Type: bool;
/// default `false`.
pub const INNER_INPUT: &str = "inner_in";
/// Stroke filter node. Draws a colored outline around the opaque
/// regions of the input. The C++ class declares no own member fields.
pub struct StrokeFilterNode;
/// Fragment shader (C++ `get_shader_code()` loads
/// `:/shaders/stroke.frag` via FileFunctions for any request). Text
/// copied verbatim from `engine/shaders/stroke.frag`.
const SHADER_FRAG: &str = r#"// Node parameter inputs
uniform sampler2D tex_in;
uniform vec4 color_in;
uniform float radius_in;
uniform float opacity_in;
uniform bool inner_in;
uniform vec2 resolution_in;
// Standard inputs
uniform int ove_iteration;
in vec2 ove_texcoord;
out vec4 frag_color;
void main(void) {
vec4 pixel_here = texture(tex_in, ove_texcoord);
// Detect no-op situations
if (radius_in == 0.0
|| opacity_in == 0.0
|| (inner_in && pixel_here.a == 0.0)
|| (!inner_in && pixel_here.a == 1.0)) {
// No-op, do nothing
frag_color = pixel_here;
return;
}
float radius = ceil(radius_in);
float stroke_weight = 0.0;
// Loop over box
for (float i=-radius + 0.5; i<=radius; i += 2.0) {
float x_coord = i / resolution_in.x;
for (float j=-radius + 0.5; j<=radius; j += 2.0) {
float y_coord = j / resolution_in.y;
if (abs(length(vec2(i, j))) < radius) {
// Get pixel here
float alpha = texture(tex_in, ove_texcoord + vec2(x_coord, y_coord)).a;
if (inner_in) {
alpha = 1.0 - alpha;
}
stroke_weight += alpha;
if (stroke_weight >= 1.0) {
break;
}
}
}
if (stroke_weight >= 1.0) {
stroke_weight = 1.0;
break;
}
}
stroke_weight *= opacity_in;
if (inner_in) {
stroke_weight *= pixel_here.a;
}
// Make RGBA color
vec4 stroke_col = color_in * stroke_weight;
if (inner_in) {
// Alpha over the stroke over the texture
stroke_col = pixel_here * (1.0 - stroke_col.a) + stroke_col;
} else {
// Alpha over the texture over the stroke
stroke_col = stroke_col * (1.0 - pixel_here.a) + pixel_here;
}
frag_color = stroke_col;
}
"#;
impl StrokeFilterNode {
/// Fragment shader for any request (C++ `get_shader_code()` ignores
/// the request id and always returns `stroke.frag`).
fn shader_frag() -> &'static str {
SHADER_FRAG
}
}
impl NodeBehavior for StrokeFilterNode {
/// Human-readable name (C++ `name()`).
fn name(&self) -> &str {
"Stroke"
}
/// Stable type id (C++ `id()`).
fn type_id(&self) -> &str {
"org.olivevideoeditor.Olive.stroke"
}
/// Categories (C++ `category()`).
fn categories(&self) -> &[Category] {
&[Category::Filter]
}
/// Description (C++ `description()`).
fn description(&self) -> &str {
"Creates a stroke outline around an image."
}
/// Localized input names (C++ `retranslate()`): `tex_in` -> "Input",
/// `color_in` -> "Color", `radius_in` -> "Radius", `opacity_in` ->
/// "Opacity", `inner_in` -> "Inner".
fn input_name<'a>(&self, id: &'a str) -> &'a str {
match id {
TEXTURE_INPUT => "Input",
COLOR_INPUT => "Color",
RADIUS_INPUT => "Radius",
OPACITY_INPUT => "Opacity",
INNER_INPUT => "Inner",
_ => id,
}
}
/// Evaluate outputs (C++ `value()`): no texture -> push nothing;
/// radius <= 0.0 or opacity <= 0.0 -> pass-through push of the input
/// texture; otherwise push a shader job with `resolution_in` set to
/// the texture's virtual resolution.
///
/// The Rust model has no shader-job payload: the job (including the
/// `resolution_in` value) is deferred to the renderer seam
/// (`// CPP-PARITY: stroke.cpp` value()).
fn value(
&self,
core: &NodeCore,
inputs: &crate::value::NodeValueRow,
time: oakcore_rs::Rational,
table: &mut crate::value::NodeValueTable,
) {
let tex = match inputs.get(TEXTURE_INPUT) {
Some(tex @ crate::value::NodeValue::Texture(_)) => tex.clone(),
_ => return,
};
let radius = match inputs.get(RADIUS_INPUT) {
Some(v) => v.to_double(),
None => core.value_at_time(RADIUS_INPUT, -1, time).to_double(),
};
let opacity = match inputs.get(OPACITY_INPUT) {
Some(v) => v.to_double(),
None => core.value_at_time(OPACITY_INPUT, -1, time).to_double(),
};
if radius > 0.0 && opacity > 0.0 {
table.push(
crate::value::ValueType::Texture,
crate::value::NodeValue::Texture(crate::handle::CHandle::null()),
None,
);
} else {
table.push(crate::value::ValueType::Texture, tex, None);
}
}
/// Shader code request (C++ `get_shader_code()`): the request id is
/// ignored; always returns the stroke fragment shader.
fn shader_code(&self, _request: &str) -> Option<String> {
Some(Self::shader_frag().to_string())
}
/// Deep copy (C++ `copy()`).
fn duplicate(&self, _core: &NodeCore) -> Option<Box<dyn NodeBehavior>> {
Some(Box::new(StrokeFilterNode))
}
}
/// Constructor (C++ `StrokeFilterNode::StrokeFilterNode()`): adds
/// `tex_in`, `color_in`, `radius_in`, `opacity_in`, `inner_in` with the
/// defaults and properties documented on the constants, then sets the
/// video-effect flag and the effect input.
pub fn create() -> (NodeCore, Box<dyn NodeBehavior>) {
let mut core = NodeCore::new();
let mut tex = crate::input::Input::new(
TEXTURE_INPUT,
crate::value::ValueType::Texture,
crate::value::NodeValue::None,
);
tex.flags |= crate::input::flags::NOT_KEYFRAMABLE;
core.add_input(tex);
let color = crate::input::Input::new(
COLOR_INPUT,
crate::value::ValueType::Color,
crate::value::NodeValue::Color([1.0, 1.0, 1.0, 1.0]),
);
core.add_input(color);
let mut radius = crate::input::Input::new(
RADIUS_INPUT,
crate::value::ValueType::Float,
crate::value::NodeValue::Float(10.0),
);
radius.properties = vec![("min".to_string(), crate::value::NodeValue::Float(0.0))];
core.add_input(radius);
let mut opacity = crate::input::Input::new(
OPACITY_INPUT,
crate::value::ValueType::Float,
crate::value::NodeValue::Float(1.0),
);
opacity.properties = vec![
("view".to_string(), crate::value::NodeValue::Text("percentage".into())),
("min".to_string(), crate::value::NodeValue::Float(0.0)),
("max".to_string(), crate::value::NodeValue::Float(1.0)),
];
core.add_input(opacity);
core.add_input(crate::input::Input::new(
INNER_INPUT,
crate::value::ValueType::Boolean,
crate::value::NodeValue::Boolean(false),
));
core.flags |= crate::node::flags::VIDEO_EFFECT;
core.effect_input = TEXTURE_INPUT.to_string();
(core, Box::new(StrokeFilterNode))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::node::NodeBehavior;
use crate::value::{NodeValue, NodeValueTable, ValueType};
use oakcore_rs::Rational;
fn tex() -> NodeValue {
NodeValue::Texture(crate::handle::CHandle::null())
}
#[test]
fn input_names() {
let n = StrokeFilterNode;
assert_eq!(n.input_name(TEXTURE_INPUT), "Input");
assert_eq!(n.input_name(COLOR_INPUT), "Color");
assert_eq!(n.input_name(RADIUS_INPUT), "Radius");
assert_eq!(n.input_name(OPACITY_INPUT), "Opacity");
assert_eq!(n.input_name(INNER_INPUT), "Inner");
}
#[test]
fn create_wires_inputs_and_flags() {
let (core, behavior) = create();
assert_eq!(behavior.type_id(), "org.olivevideoeditor.Olive.stroke");
assert_eq!(
core.get_input(COLOR_INPUT).unwrap().default,
NodeValue::Color([1.0, 1.0, 1.0, 1.0])
);
assert_eq!(
core.get_input(RADIUS_INPUT).unwrap().default,
NodeValue::Float(10.0)
);
assert_eq!(
core.get_input(OPACITY_INPUT).unwrap().default,
NodeValue::Float(1.0)
);
assert_eq!(core.effect_input, TEXTURE_INPUT);
assert_ne!(core.flags & crate::node::flags::VIDEO_EFFECT, 0);
}
#[test]
fn value_no_texture_pushes_nothing() {
let (core, behavior) = create();
let mut table = NodeValueTable::default();
behavior.value(
&core,
&crate::value::NodeValueRow::default(),
Rational::new(0, 1),
&mut table,
);
assert!(table.is_empty());
}
#[test]
fn value_zero_radius_passes_texture_through() {
let (mut core, behavior) = create();
core.set_standard_value(RADIUS_INPUT, -1, NodeValue::Float(0.0));
core.set_standard_value(OPACITY_INPUT, -1, NodeValue::Float(1.0));
let tex = tex();
let inputs = crate::value::NodeValueRow::from([(TEXTURE_INPUT.to_string(), tex.clone())]);
let mut table = NodeValueTable::default();
behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
assert_eq!(table.get(ValueType::Texture), Some(&tex));
}
#[test]
fn value_zero_opacity_passes_texture_through() {
let (mut core, behavior) = create();
core.set_standard_value(RADIUS_INPUT, -1, NodeValue::Float(10.0));
core.set_standard_value(OPACITY_INPUT, -1, NodeValue::Float(0.0));
let tex = tex();
let inputs = crate::value::NodeValueRow::from([(TEXTURE_INPUT.to_string(), tex.clone())]);
let mut table = NodeValueTable::default();
behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
assert_eq!(table.get(ValueType::Texture), Some(&tex));
}
#[test]
fn value_positive_radius_and_opacity_pushes_deferred_job() {
let (core, behavior) = create();
let inputs = crate::value::NodeValueRow::from([
(TEXTURE_INPUT.to_string(), tex()),
(RADIUS_INPUT.to_string(), NodeValue::Float(5.0)),
(OPACITY_INPUT.to_string(), NodeValue::Float(0.5)),
]);
let mut table = NodeValueTable::default();
behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
assert!(table.get(ValueType::Texture).is_some());
}
#[test]
fn shader_code_returns_stroke_shader() {
let n = StrokeFilterNode;
let code = n.shader_code("anything").unwrap();
assert!(code.contains("uniform float radius_in;"));
assert!(code.contains("stroke_weight *= opacity_in;"));
}
#[test]
fn duplicate_clones() {
let (core, behavior) = create();
let dup = behavior.duplicate(&core).unwrap();
assert_eq!(dup.name(), "Stroke");
}
}
/// Register this node type (C++ `k_stroke_filter` in
/// `factory.cpp::create_from_factory_index`).
pub fn register(meta: &mut Vec<NodeMeta>) {
meta.push(NodeMeta {
type_id: "org.olivevideoeditor.Olive.stroke",
name: "Stroke",
categories: &[Category::Filter],
create,
});
}
@@ -0,0 +1,376 @@
// 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/>.
//! Swirl distort effect (C++
//! `src/node/src/distort/swirl/swirldistortnode.{h,cpp}`,
//! `olive::SwirlDistortNode`).
use crate::factory::NodeMeta;
use crate::node::{Category, Gizmo, NodeBehavior, NodeCore};
/// Texture input id (C++ `k_texture_input`). Type: texture; flags:
/// not-keyframable; this is the node's effect input.
pub const TEXTURE_INPUT: &str = "tex_in";
/// Radius input id (C++ `k_radius_input`). Type: float; default `200`;
/// properties: `min = 0`.
pub const RADIUS_INPUT: &str = "radius_in";
/// Angle input id (C++ `k_angle_input`). Type: float; default `10`;
/// properties: `base = 0.1`.
pub const ANGLE_INPUT: &str = "angle_in";
/// Center position input id (C++ `k_position_input`). Type: vec2;
/// default `(0, 0)`.
pub const POSITION_INPUT: &str = "pos_in";
/// Swirl distort node. Rotates the image around a center point with a
/// falloff by radius.
pub struct SwirlDistortNode {
/// Center position drag handle (C++ `PointGizmo *gizmo_`; anchor
/// point shape, drags both tracks of `pos_in`).
gizmo: Gizmo,
}
/// Fragment shader (C++ loads the `:/shaders/swirl.frag` resource in
/// `get_shader_code`). Text copied verbatim from
/// `engine/shaders/swirl.frag`.
const SHADER_FRAG: &str = r#"// Swirl effect parameters
uniform float radius_in;
uniform float angle_in;
uniform vec2 pos_in;
uniform vec2 resolution_in;
uniform sampler2D tex_in;
in vec2 ove_texcoord;
out vec4 frag_color;
void main(void) {
vec2 center = resolution_in*0.5 + pos_in;
vec2 uv = ove_texcoord;
vec2 tc = uv * resolution_in;
tc -= center;
float dist = length(tc);
if (dist < radius_in) {
float percent = (radius_in - dist) / radius_in;
float theta = percent * percent * -angle_in;
float s = sin(theta);
float c = cos(theta);
tc = vec2(dot(tc, vec2(c, -s)), dot(tc, vec2(s, c)));
}
tc += center;
frag_color = texture(tex_in, tc / resolution_in);
}
"#;
impl SwirlDistortNode {
/// Fragment shader (C++ `get_shader_code()`; the request id is
/// ignored, this is the only shader).
fn shader_frag() -> &'static str {
SHADER_FRAG
}
}
impl NodeBehavior for SwirlDistortNode {
/// Human-readable name (C++ `name()`).
fn name(&self) -> &str {
"Swirl"
}
/// Stable type id (C++ `id()`).
fn type_id(&self) -> &str {
"org.olivevideoeditor.Olive.swirl"
}
/// Categories (C++ `category()`).
fn categories(&self) -> &[Category] {
&[Category::Distort]
}
/// Description (C++ `description()`).
fn description(&self) -> &str {
"Distorts an image by swirling it around a center point."
}
/// Localized input names (C++ `retranslate()`): `tex_in` ->
/// "Input", `radius_in` -> "Radius", `angle_in` -> "Angle",
/// `pos_in` -> "Position".
fn input_name<'a>(&self, id: &'a str) -> &'a str {
match id {
TEXTURE_INPUT => "Input",
RADIUS_INPUT => "Radius",
ANGLE_INPUT => "Angle",
POSITION_INPUT => "Position",
_ => id,
}
}
/// Evaluate outputs (C++ `value()`): no texture -> push nothing;
/// angle != 0.0 AND radius != 0.0 -> shader job over the whole value
/// row with `resolution_in` inserted from the texture's virtual
/// resolution; otherwise pass-through push of the input texture
/// unchanged.
///
/// The Rust model has no shader-job payload: the job (including the
/// `resolution_in` value) is deferred to the renderer seam
/// (`// CPP-PARITY: swirldistortnode.cpp` value()).
fn value(
&self,
core: &NodeCore,
inputs: &crate::value::NodeValueRow,
time: oakcore_rs::Rational,
table: &mut crate::value::NodeValueTable,
) {
let tex = match inputs.get(TEXTURE_INPUT) {
Some(tex @ crate::value::NodeValue::Texture(_)) => tex.clone(),
_ => return,
};
let radius = match inputs.get(RADIUS_INPUT) {
Some(v) => v.to_double(),
None => core.value_at_time(RADIUS_INPUT, -1, time).to_double(),
};
let angle = match inputs.get(ANGLE_INPUT) {
Some(v) => v.to_double(),
None => core.value_at_time(ANGLE_INPUT, -1, time).to_double(),
};
if angle != 0.0 && radius != 0.0 {
table.push(
crate::value::ValueType::Texture,
crate::value::NodeValue::Texture(crate::handle::CHandle::null()),
None,
);
} else {
table.push(crate::value::ValueType::Texture, tex, None);
}
}
/// Shader code request (C++ `get_shader_code()`): ignores the
/// request id and always returns the swirl fragment shader.
fn shader_code(&self, _request: &str) -> Option<String> {
Some(Self::shader_frag().to_string())
}
/// Gizmo positions (C++ `update_gizmo_positions()`): places the
/// position gizmo at half the globals' square resolution plus the
/// `pos_in` offset (note: unlike Ripple, this does not require a
/// texture).
///
/// The placement needs the square resolution from the C++ globals,
/// which this signature does not carry, and the resulting point has
/// no storage in [`Gizmo`] — not representable here
/// (`// CPP-PARITY: swirldistortnode.cpp` `update_gizmo_positions`).
fn gizmo_update(&self, core: &NodeCore, row: &crate::value::NodeValueRow) {
let _ = (core, row);
}
/// Gizmo drag (C++ `gizmo_drag_move()`): drags the position input's
/// X and Y track draggers by the mouse delta added to their
/// drag-start values.
///
/// The draggers hold per-drag start values and write keyframe tracks,
/// neither of which the Rust data model carries — not representable
/// here (`// CPP-PARITY: swirldistortnode.cpp` `gizmo_drag_move`).
fn gizmo_drag(&mut self, core: &mut NodeCore, start: bool, x: f64, y: f64, modifiers: u32) {
let _ = (core, start, x, y, modifiers);
}
/// Deep copy (C++ `copy()`).
fn duplicate(&self, _core: &NodeCore) -> Option<Box<dyn NodeBehavior>> {
Some(Box::new(SwirlDistortNode {
gizmo: self.gizmo.clone(),
}))
}
}
/// Constructor (C++ `SwirlDistortNode::SwirlDistortNode()`): adds
/// `tex_in`, `radius_in`, `angle_in` and `pos_in` with the defaults and
/// properties documented on the constants; creates the anchor-shaped
/// position point gizmo bound to both tracks of `pos_in`; sets the
/// video-effect flag and the effect input.
pub fn create() -> (NodeCore, Box<dyn NodeBehavior>) {
let mut core = NodeCore::new();
let mut tex = crate::input::Input::new(
TEXTURE_INPUT,
crate::value::ValueType::Texture,
crate::value::NodeValue::None,
);
tex.flags |= crate::input::flags::NOT_KEYFRAMABLE;
core.add_input(tex);
let mut radius = crate::input::Input::new(
RADIUS_INPUT,
crate::value::ValueType::Float,
crate::value::NodeValue::Float(200.0),
);
radius.properties = vec![("min".to_string(), crate::value::NodeValue::Float(0.0))];
core.add_input(radius);
let mut angle = crate::input::Input::new(
ANGLE_INPUT,
crate::value::ValueType::Float,
crate::value::NodeValue::Float(10.0),
);
angle.properties = vec![("base".to_string(), crate::value::NodeValue::Float(0.1))];
core.add_input(angle);
core.add_input(crate::input::Input::new(
POSITION_INPUT,
crate::value::ValueType::Vec2,
crate::value::NodeValue::Vec2([0.0, 0.0]),
));
// Anchor-shaped position point gizmo (C++ `PointGizmo` with
// `k_anchor_point` shape) dragging both tracks of `pos_in`.
let gizmo = Gizmo {
position_inputs: vec![
(POSITION_INPUT.to_string(), -1, 0),
(POSITION_INPUT.to_string(), -1, 1),
],
drag_point: (0.0, 0.0),
};
core.gizmos = vec![gizmo.clone()];
core.flags |= crate::node::flags::VIDEO_EFFECT;
core.effect_input = TEXTURE_INPUT.to_string();
(core, Box::new(SwirlDistortNode { gizmo }))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::node::NodeBehavior;
use crate::value::{NodeValue, NodeValueTable, ValueType};
use oakcore_rs::Rational;
fn tex() -> NodeValue {
NodeValue::Texture(crate::handle::CHandle::null())
}
#[test]
fn input_names() {
let n = SwirlDistortNode {
gizmo: Gizmo {
position_inputs: vec![],
drag_point: (0.0, 0.0),
},
};
assert_eq!(n.input_name(TEXTURE_INPUT), "Input");
assert_eq!(n.input_name(RADIUS_INPUT), "Radius");
assert_eq!(n.input_name(ANGLE_INPUT), "Angle");
assert_eq!(n.input_name(POSITION_INPUT), "Position");
}
#[test]
fn create_wires_inputs_and_flags() {
let (core, behavior) = create();
assert_eq!(behavior.type_id(), "org.olivevideoeditor.Olive.swirl");
assert_eq!(
core.get_input(RADIUS_INPUT).unwrap().default,
NodeValue::Float(200.0)
);
assert_eq!(
core.get_input(ANGLE_INPUT).unwrap().default,
NodeValue::Float(10.0)
);
assert_eq!(core.gizmos.len(), 1);
assert_eq!(core.gizmos[0].position_inputs.len(), 2);
assert_eq!(core.effect_input, TEXTURE_INPUT);
assert_ne!(core.flags & crate::node::flags::VIDEO_EFFECT, 0);
}
#[test]
fn value_no_texture_pushes_nothing() {
let (core, behavior) = create();
let mut table = NodeValueTable::default();
behavior.value(
&core,
&crate::value::NodeValueRow::default(),
Rational::new(0, 1),
&mut table,
);
assert!(table.is_empty());
}
#[test]
fn value_zero_angle_passes_texture_through() {
let (mut core, behavior) = create();
core.set_standard_value(ANGLE_INPUT, -1, NodeValue::Float(0.0));
core.set_standard_value(RADIUS_INPUT, -1, NodeValue::Float(200.0));
let tex = tex();
let inputs = crate::value::NodeValueRow::from([(TEXTURE_INPUT.to_string(), tex.clone())]);
let mut table = NodeValueTable::default();
behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
assert_eq!(table.get(ValueType::Texture), Some(&tex));
}
#[test]
fn value_zero_radius_passes_texture_through() {
let (mut core, behavior) = create();
core.set_standard_value(ANGLE_INPUT, -1, NodeValue::Float(10.0));
core.set_standard_value(RADIUS_INPUT, -1, NodeValue::Float(0.0));
let tex = tex();
let inputs = crate::value::NodeValueRow::from([(TEXTURE_INPUT.to_string(), tex.clone())]);
let mut table = NodeValueTable::default();
behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
assert_eq!(table.get(ValueType::Texture), Some(&tex));
}
#[test]
fn value_angle_and_radius_pushes_deferred_job() {
let (core, behavior) = create();
let inputs = crate::value::NodeValueRow::from([
(TEXTURE_INPUT.to_string(), tex()),
(ANGLE_INPUT.to_string(), NodeValue::Float(10.0)),
(RADIUS_INPUT.to_string(), NodeValue::Float(200.0)),
]);
let mut table = NodeValueTable::default();
behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
assert!(table.get(ValueType::Texture).is_some());
}
#[test]
fn shader_code_returns_swirl_shader() {
let (_, behavior) = create();
let code = behavior.shader_code("anything").unwrap();
assert!(code.contains("uniform float radius_in;"));
assert!(code.contains("percent * percent * -angle_in"));
}
#[test]
fn duplicate_clones() {
let (core, behavior) = create();
let dup = behavior.duplicate(&core).unwrap();
assert_eq!(dup.name(), "Swirl");
}
}
/// Register this node type (C++ factory entry for
/// `org.olivevideoeditor.Olive.swirl`).
pub fn register(meta: &mut Vec<NodeMeta>) {
meta.push(NodeMeta {
type_id: "org.olivevideoeditor.Olive.swirl",
name: "Swirl",
categories: &[Category::Distort],
create,
});
}
+248
View File
@@ -0,0 +1,248 @@
// 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/>.
//! Shared text layout/rasterization backend hooks (C++
//! `src/node/src/generator/text/textbackend.h`, header-only — no class,
//! just POD structs and global function-pointer hooks in `olive::`).
//!
//! FONT/RASTER BACKEND DEPENDENCY — DELIBERATELY UNDECIDED:
//! The C++ text nodes do NOT link any font or raster library directly.
//! Text layout and rasterization were historically done with Qt's rich
//! text stack (`QTextDocument` / `QTextOption` /
//! `QAbstractTextDocumentLayout`) rasterized via `QPainter` into a
//! `QImage` (`Format_Grayscale8` for v1/v2 coverage buffers,
//! `Format_RGBA8888_Premultiplied` for v3). That Qt dependency has
//! already been removed from oaknode: the C++ now carries only the POD
//! job descriptions below plus two installable backend hooks
//! (`g_text_measure_backend` / `g_text_render_backend`) that the
//! facade/app layer fills in at runtime. No Rust font/shaping/raster
//! crate (freetype-rs, rusttype, cosmic-text, swash, etc.) is chosen
//! here on purpose; the backend decision belongs to the facade layer
//! that will install these hooks.
/// POD description of a text layout/rasterization job (C++
/// `TextLayoutRequest`). Replaces the `QTextDocument`/`QTextOption`
/// usage in the text generator nodes; this module carries only the
/// data.
pub struct TextLayoutRequest {
/// Plain text or markup, per `mode`.
pub text: String,
/// Layout mode (C++ `TextLayoutRequest::Mode`).
pub mode: TextLayoutMode,
/// Default font family (empty = backend default).
pub font_family: String,
/// Default font size in points (0 = default).
pub font_size_pt: f64,
/// Paint device resolution (0 = backend default).
pub dots_per_meter: i32,
/// Wrap width (C++ `QTextDocument::setTextWidth()`).
pub wrap_width: f64,
/// Center horizontally by default (C++ default
/// `QTextOption(Qt::AlignCenter)`).
pub center_horizontally: bool,
// Note: the backends always paint with white as the default text
// color (formerly PaintContext palette QPalette::Text = Qt::white);
// HTML markup may override it per span.
}
/// Layout mode (C++ `TextLayoutRequest::Mode`).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum TextLayoutMode {
/// Plain text (C++ `QTextDocument::setPlainText()`).
PlainText,
/// HTML subset (C++ `QTextDocument::setHtml()`).
Html,
/// Olive's `Html::html_to_doc()` rich text.
OliveHtml,
}
/// Laid-out document size (C++ `TextLayoutSize`, formerly
/// `QTextDocument::size()`).
#[derive(Clone, Copy, Debug, Default)]
pub struct TextLayoutSize {
/// Document width in pixels.
pub width: f64,
/// Document height in pixels.
pub height: f64,
}
/// Target pixel buffer for text rasterization (C++
/// `TextRenderTarget`). `channel_count == 1` means 8-bit grayscale
/// coverage (formerly `QImage::Format_Grayscale8`, the node tints it
/// afterwards); `channel_count == 4` means 8-bit RGBA premultiplied
/// drawn over the existing (cleared) buffer content (formerly
/// `QImage::Format_RGBA8888_Premultiplied`).
pub struct TextRenderTarget<'a> {
/// Pixel buffer, `linesize_bytes * height` bytes.
pub data: &'a mut [u8],
/// Buffer width in pixels.
pub width: i32,
/// Buffer height in pixels.
pub height: i32,
/// Bytes per scanline.
pub linesize_bytes: i32,
/// Channel count: 1 = grayscale coverage, 4 = RGBA premultiplied.
pub channel_count: i32,
}
/// Draw transform, mirroring the original `QPainter` calls (C++
/// `TextRenderTransform`). The painter first applied
/// `scale(scale, scale)`, then translated to the base offset, set the
/// clip rect `(0, 0, clip_width, clip_height)` at that base offset, and
/// finally translated by the remaining draw offset before drawing. A
/// point `p` of the laid-out document therefore lands at
/// `((p.x + draw_offset_x) * scale, (p.y + draw_offset_y) * scale)`,
/// clipped to `((clip_offset_*) * scale, (clip_size) * scale)` relative
/// to the same origin. `clip_enabled == false` means no clip.
#[derive(Clone, Copy, Debug, Default)]
pub struct TextRenderTransform {
/// Uniform scale (C++ `QPainter::scale(scale, scale)`).
pub scale: f64,
/// Draw offset X, in pre-scale document coordinates.
pub draw_offset_x: f64,
/// Draw offset Y, in pre-scale document coordinates.
pub draw_offset_y: f64,
/// Whether the clip rect is active.
pub clip_enabled: bool,
/// Clip rect offset X, in pre-scale document coordinates.
pub clip_offset_x: f64,
/// Clip rect offset Y, in pre-scale document coordinates.
pub clip_offset_y: f64,
/// Clip rect width, in pre-scale document coordinates.
pub clip_width: f64,
/// Clip rect height, in pre-scale document coordinates.
pub clip_height: f64,
}
/// Measure hook (C++ `TextMeasureBackend`): lays out the request and
/// returns the document size (formerly `QTextDocument::size()`).
pub type TextMeasureBackend = fn(&TextLayoutRequest) -> TextLayoutSize;
/// Render hook (C++ `TextRenderBackend`): draws the request into the
/// target buffer (formerly `QAbstractTextDocumentLayout::draw()`).
pub type TextRenderBackend =
for<'a> fn(&TextLayoutRequest, &TextRenderTransform, TextRenderTarget<'a>);
/// Install the backend hooks (C++ `set_text_backends()`). When a hook
/// is `None` the caller falls back to a zero size / leaves the (already
/// cleared) buffer untouched — a documented behavior gap until the
/// facade installs a text engine. Setting a hook to `None` uninstalls
/// it (the C++ global is a plain function pointer, assignable any
/// number of times; a `Mutex` keeps the tests able to reset it).
pub fn set_text_backends(
measure: Option<TextMeasureBackend>,
render: Option<TextRenderBackend>,
) {
*MEASURE.lock().unwrap() = measure;
*RENDER.lock().unwrap() = render;
}
/// Currently installed measure hook (C++ `text_measure_backend()`).
pub fn text_measure_backend() -> Option<TextMeasureBackend> {
*MEASURE.lock().unwrap()
}
/// Currently installed render hook (C++ `text_render_backend()`).
pub fn text_render_backend() -> Option<TextRenderBackend> {
*RENDER.lock().unwrap()
}
/// Installed measure hook (C++ global `g_text_measure_backend`).
static MEASURE: std::sync::Mutex<Option<TextMeasureBackend>> = std::sync::Mutex::new(None);
/// Installed render hook (C++ global `g_text_render_backend`).
static RENDER: std::sync::Mutex<Option<TextRenderBackend>> = std::sync::Mutex::new(None);
#[cfg(test)]
mod tests {
use super::*;
// The two tests below share the process-global backend statics; a
// lock serializes them so `backend_hooks_default_none` cannot observe
// the hooks installed by `backend_hooks_install_and_query`.
static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
#[test]
fn backend_hooks_default_none() {
let _guard = LOCK.lock().unwrap();
set_text_backends(None, None);
assert_eq!(text_measure_backend(), None);
assert_eq!(text_render_backend(), None);
}
#[test]
fn backend_hooks_install_and_query() {
let _guard = LOCK.lock().unwrap();
fn measure(_r: &TextLayoutRequest) -> TextLayoutSize {
TextLayoutSize {
width: 12.0,
height: 34.0,
}
}
fn render(_r: &TextLayoutRequest, _t: &TextRenderTransform, mut target: TextRenderTarget) {
for b in target.data.iter_mut() {
*b = 255;
}
}
set_text_backends(Some(measure), Some(render));
assert_eq!(text_measure_backend().unwrap()(&TextLayoutRequest {
text: String::new(),
mode: TextLayoutMode::PlainText,
font_family: String::new(),
font_size_pt: 0.0,
dots_per_meter: 0,
wrap_width: 0.0,
center_horizontally: false,
}).width, 12.0);
assert_eq!(
text_measure_backend().unwrap()(&TextLayoutRequest {
text: String::new(),
mode: TextLayoutMode::Html,
font_family: String::new(),
font_size_pt: 0.0,
dots_per_meter: 0,
wrap_width: 0.0,
center_horizontally: false,
})
.height,
34.0
);
let mut buf = vec![0u8; 4];
text_render_backend().unwrap()(
&TextLayoutRequest {
text: String::new(),
mode: TextLayoutMode::PlainText,
font_family: String::new(),
font_size_pt: 0.0,
dots_per_meter: 0,
wrap_width: 0.0,
center_horizontally: false,
},
&TextRenderTransform::default(),
TextRenderTarget {
data: &mut buf,
width: 2,
height: 2,
linesize_bytes: 2,
channel_count: 1,
},
);
assert_eq!(buf, vec![255u8; 4]);
// Restore the uninstalled state so other tests observe the
// no-backend fallback.
set_text_backends(None, None);
}
}
+445
View File
@@ -0,0 +1,445 @@
// 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/>.
//! Legacy text generator v1 (C++
//! `src/node/src/generator/text/textv1.{h,cpp}`,
//! `olive::TextGeneratorV1`).
//!
//! FONT/RASTER BACKEND DEPENDENCY — DELIBERATELY UNDECIDED:
//! The C++ does NOT link any font/raster library directly (no freetype,
//! stb, harfbuzz, etc. anywhere in the tree). Text layout/rasterization
//! was historically Qt's rich text stack (`QTextDocument` +
//! `QAbstractTextDocumentLayout` + `QPainter` into a
//! `QImage::Format_Grayscale8` coverage buffer); it now runs behind the
//! facade-installed hooks in [`super::textbackend`]. No Rust font crate
//! is chosen here on purpose.
use crate::factory::NodeMeta;
use crate::node::{Category, NodeBehavior, NodeCore};
use crate::value::{NodeValue, NodeValueRow, NodeValueTable};
use oakcore_rs::Rational;
use super::textbackend::{TextLayoutMode, TextLayoutRequest, TextLayoutSize};
/// Text input id (C++ `k_text_input`). Type: text; default
/// `"Sample Text"`.
pub const TEXT_INPUT: &str = "text_in";
/// HTML toggle input id (C++ `k_html_input`). Type: boolean; default
/// `false`.
pub const HTML_INPUT: &str = "html_in";
/// Text color input id (C++ `k_color_input`). Type: color; default
/// `Color(1.0, 1.0, 1.0)` (white).
pub const COLOR_INPUT: &str = "color_in";
/// Vertical alignment input id (C++ `k_v_align_input`). Type: combo;
/// default `1` (center); combo strings: "Top", "Center", "Bottom".
pub const V_ALIGN_INPUT: &str = "valign_in";
/// Font family input id (C++ `k_font_input`). Type: font (C++
/// `NodeValue::k_font` — no dedicated [`crate::value::ValueType`]
/// variant, travels as text); no default.
pub const FONT_INPUT: &str = "font_in";
/// Font size input id (C++ `k_font_size_input`). Type: float; default
/// `72.0`.
pub const FONT_SIZE_INPUT: &str = "font_size_in";
/// Legacy text generator v1. The C++ class has no own private members
/// (inputs/caches live in [`NodeCore`]), so this is a unit struct.
pub struct TextGeneratorV1;
/// `Variant::to_string()` for the text/font inputs (Text payload, with a
/// numeric fallback for mis-typed connections).
fn to_text(v: &NodeValue) -> String {
match v {
NodeValue::Text(s) => s.clone(),
other => other.to_double().to_string(),
}
}
impl TextGeneratorV1 {
/// Build the C++ `TextLayoutRequest` (textv1.cpp `generate_frame()`):
/// the text and font from the input row, HTML mode with newlines
/// replaced by `<br>` when `html_in` is set, horizontal centering on,
/// and the wrap width at 80% of the frame width (the "title safe"
/// area — `(width / 10) * 8`). The measure backend is not consulted
/// here.
pub fn layout_request(row: &NodeValueRow, frame_width: i32) -> TextLayoutRequest {
let text = row
.get(TEXT_INPUT)
.map(to_text)
.unwrap_or_else(|| String::new());
let html = matches!(
row.get(HTML_INPUT),
Some(NodeValue::Boolean(true))
);
let mut mode = TextLayoutMode::PlainText;
let text = if html {
// QTextDocument::setHtml() doesn't translate newlines, so they
// were replaced with <br> tags first.
mode = TextLayoutMode::Html;
text.replace('\n', "<br>")
} else {
text
};
let tenth_of_width = frame_width / 10;
TextLayoutRequest {
text,
mode,
font_family: row.get(FONT_INPUT).map(to_text).unwrap_or_else(String::new),
font_size_pt: row.get(FONT_SIZE_INPUT).map(NodeValue::to_double).unwrap_or(0.0),
dots_per_meter: 0,
wrap_width: (tenth_of_width * 8) as f64,
center_horizontally: true,
}
}
/// C++ draw offsets (textv1.cpp `generate_frame()`): x is pushed 10%
/// inwards for the title-safe area; the vertical offset depends on the
/// valign combo (top: 10% top margin; center: frame center; bottom:
/// 10% bottom margin). The C++ math is integer (`width()/10`,
/// `height()/2 - doc_height/2`, ...), mirrored here.
pub fn draw_offsets(valign: i32, frame_width: i32, frame_height: i32, doc_height: i32) -> (f64, f64) {
let tenth_of_width = frame_width / 10;
let offset_x = tenth_of_width as f64;
let offset_y = match valign {
// k_vertical_align_top: push 10% inwards for the title-safe area.
0 => (frame_height / 10) as f64,
// k_vertical_align_center.
1 => (frame_height / 2 - doc_height / 2) as f64,
// k_vertical_align_bottom: 10% bottom margin.
2 => (frame_height - doc_height - frame_height / 10) as f64,
_ => 0.0,
};
(offset_x, offset_y)
}
/// The layout/measure control flow of the C++ `generate_frame()` with
/// the backend hooks: build the request, measure via the installed
/// measure backend (zero size when none is installed — the documented
/// no-backend fallback), and compute the draw offsets for the given
/// frame size.
///
/// The render step and the alpha transplant need the frame's pixel
/// buffer, which the Rust frame handle does not expose; they are not
/// representable here (`// CPP-PARITY: textv1.cpp` `generate_frame`).
pub fn measure_and_layout(
row: &NodeValueRow,
frame_width: i32,
frame_height: i32,
) -> (TextLayoutRequest, TextLayoutSize, (f64, f64)) {
let req = Self::layout_request(row, frame_width);
let doc = match super::textbackend::text_measure_backend() {
Some(measure) => measure(&req),
None => TextLayoutSize::default(),
};
let valign = row
.get(V_ALIGN_INPUT)
.map(NodeValue::to_double)
.unwrap_or(0.0) as i32;
let offsets = Self::draw_offsets(valign, frame_width, frame_height, doc.height as i32);
(req, doc, offsets)
}
}
impl NodeBehavior for TextGeneratorV1 {
/// Human-readable name (C++ `name()`).
fn name(&self) -> &str {
"Text (Legacy)"
}
/// Stable type id (C++ `id()`).
fn type_id(&self) -> &str {
"org.olivevideoeditor.Olive.textgenerator"
}
/// Categories (C++ `category()`).
fn categories(&self) -> &[Category] {
&[Category::Generator]
}
/// Description (C++ `description()`).
fn description(&self) -> &str {
"Generate rich text."
}
/// Localized input names (C++ `retranslate()`): `text_in` ->
/// "Text", `html_in` -> "Enable HTML", `font_in` -> "Font",
/// `font_size_in` -> "Font Size", `color_in` -> "Color",
/// `valign_in` -> "Vertical Align" (combo strings Top/Center/
/// Bottom — set by the C++ `set_combo_box_strings`, which has no
/// trait surface here).
fn input_name<'a>(&self, id: &'a str) -> &'a str {
match id {
TEXT_INPUT => "Text",
HTML_INPUT => "Enable HTML",
COLOR_INPUT => "Color",
V_ALIGN_INPUT => "Vertical Align",
FONT_INPUT => "Font",
FONT_SIZE_INPUT => "Font Size",
_ => id,
}
}
/// Evaluate outputs (C++ `value()`): if the text input is
/// non-empty, push a texture generate job at the global video
/// params; otherwise push nothing.
///
/// The Rust model has no generate-job payload: the job case pushes a
/// null texture handle marking a renderer-deferred generate job
/// resolved via [`NodeBehavior::generate_frame`]
/// (`// CPP-PARITY: textv1.cpp` `value()`).
fn value(&self, core: &NodeCore, inputs: &NodeValueRow, time: Rational, table: &mut NodeValueTable) {
let text = inputs
.get(TEXT_INPUT)
.map(to_text)
.unwrap_or_else(|| core.value_at_time(TEXT_INPUT, -1, time).to_double().to_string());
if !text.is_empty() {
table.push(
crate::value::ValueType::Texture,
NodeValue::Texture(crate::handle::CHandle::null()),
None,
);
}
}
/// Direct frame generation (C++ `generate_frame()`): rasterizes
/// the text into a grayscale coverage buffer (32-bit-aligned
/// scanlines) via the installed text backend — HTML mode replaces
/// newlines with `<br>`, wrap width is 80% of frame width ("title
/// safe"), horizontal centering is on, vertical offset depends on
/// the valign combo (top: 10% margin; center: frame center; bottom:
/// 10% bottom margin) — then transplants the coverage as alpha
/// into the float frame multiplied by the color input. With no
/// backend installed, warns once and leaves the frame empty.
///
/// The Rust frame is an opaque [`crate::bridge::render::TextureHandle`]
/// whose pixels cannot be read or written from this crate, so the
/// body is a documented no-op; the layout/measure/offset control flow
/// is ported in [`Self::layout_request`], [`Self::draw_offsets`] and
/// [`Self::measure_and_layout`], and exercised by the tests.
fn generate_frame(
&self,
core: &NodeCore,
frame: &mut crate::bridge::render::TextureHandle,
time: Rational,
) {
let _ = (core, frame, time);
}
/// Deep copy (C++ `copy()`).
fn duplicate(&self, _core: &NodeCore) -> Option<Box<dyn NodeBehavior>> {
Some(Box::new(TextGeneratorV1))
}
}
/// Constructor (C++ `TextGeneratorV1::TextGeneratorV1()`): adds
/// `text_in`, `html_in`, `color_in`, `valign_in`, `font_in` and
/// `font_size_in` with the defaults documented on the constants, and
/// sets the `dont_show_in_create_menu` flag.
pub fn create() -> (NodeCore, Box<dyn NodeBehavior>) {
let mut core = NodeCore::new();
core.add_input(crate::input::Input::new(
TEXT_INPUT,
crate::value::ValueType::Text,
NodeValue::Text("Sample Text".to_string()),
));
core.add_input(crate::input::Input::new(
HTML_INPUT,
crate::value::ValueType::Boolean,
NodeValue::Boolean(false),
));
core.add_input(crate::input::Input::new(
COLOR_INPUT,
crate::value::ValueType::Color,
NodeValue::Color([1.0, 1.0, 1.0, 1.0]),
));
core.add_input(crate::input::Input::new(
V_ALIGN_INPUT,
crate::value::ValueType::Combo,
NodeValue::Combo(1),
));
core.add_input(crate::input::Input::new(
FONT_INPUT,
crate::value::ValueType::Text,
NodeValue::Text(String::new()),
));
core.add_input(crate::input::Input::new(
FONT_SIZE_INPUT,
crate::value::ValueType::Float,
NodeValue::Float(72.0),
));
core.flags |= crate::node::flags::DONT_SHOW_IN_CREATE_MENU;
(core, Box::new(TextGeneratorV1))
}
/// Register this node type (C++ `k_text_generator_v1` in
/// `factory.cpp::create_from_factory_index`).
pub fn register(meta: &mut Vec<NodeMeta>) {
meta.push(NodeMeta {
type_id: "org.olivevideoeditor.Olive.textgenerator",
name: "Text (Legacy)",
categories: &[Category::Generator],
create,
});
}
#[cfg(test)]
mod tests {
use super::*;
use crate::node::NodeBehavior;
use crate::value::{NodeValueTable, ValueType};
use oakcore_rs::Rational;
#[test]
fn input_names() {
let n = TextGeneratorV1;
assert_eq!(n.input_name(TEXT_INPUT), "Text");
assert_eq!(n.input_name(HTML_INPUT), "Enable HTML");
assert_eq!(n.input_name(COLOR_INPUT), "Color");
assert_eq!(n.input_name(V_ALIGN_INPUT), "Vertical Align");
assert_eq!(n.input_name(FONT_INPUT), "Font");
assert_eq!(n.input_name(FONT_SIZE_INPUT), "Font Size");
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.textgenerator");
assert_eq!(core.get_input(TEXT_INPUT).unwrap().value_type, ValueType::Text);
assert_eq!(
core.get_input(TEXT_INPUT).unwrap().default,
NodeValue::Text("Sample Text".to_string())
);
assert_eq!(core.get_input(HTML_INPUT).unwrap().value_type, ValueType::Boolean);
assert_eq!(
core.get_input(COLOR_INPUT).unwrap().default,
NodeValue::Color([1.0, 1.0, 1.0, 1.0])
);
assert_eq!(core.get_input(V_ALIGN_INPUT).unwrap().default, NodeValue::Combo(1));
assert_eq!(core.get_input(FONT_SIZE_INPUT).unwrap().default, NodeValue::Float(72.0));
assert_ne!(core.flags & crate::node::flags::DONT_SHOW_IN_CREATE_MENU, 0);
}
#[test]
fn layout_request_plain_text() {
let mut row = NodeValueRow::default();
row.insert(TEXT_INPUT.to_string(), NodeValue::Text("Hello".to_string()));
row.insert(FONT_INPUT.to_string(), NodeValue::Text("Arial".to_string()));
row.insert(FONT_SIZE_INPUT.to_string(), NodeValue::Float(48.0));
row.insert(HTML_INPUT.to_string(), NodeValue::Boolean(false));
let req = TextGeneratorV1::layout_request(&row, 1920);
assert_eq!(req.text, "Hello");
assert_eq!(req.mode, TextLayoutMode::PlainText);
assert_eq!(req.font_family, "Arial");
assert_eq!(req.font_size_pt, 48.0);
assert!(req.center_horizontally);
// 1920 / 10 * 8 = 1536.
assert_eq!(req.wrap_width, 1536.0);
}
#[test]
fn layout_request_html_translates_newlines() {
let mut row = NodeValueRow::default();
row.insert(TEXT_INPUT.to_string(), NodeValue::Text("line1\nline2".to_string()));
row.insert(HTML_INPUT.to_string(), NodeValue::Boolean(true));
let req = TextGeneratorV1::layout_request(&row, 1280);
assert_eq!(req.text, "line1<br>line2");
assert_eq!(req.mode, TextLayoutMode::Html);
// 1280 / 10 * 8 = 1024.
assert_eq!(req.wrap_width, 1024.0);
}
#[test]
fn draw_offsets_per_valign() {
// Center (default of the v1 node): x = width/10, y = height/2.
let (x, y) = TextGeneratorV1::draw_offsets(1, 1920, 1080, 100);
assert_eq!(x, 192.0);
assert_eq!(y, 540.0 - 50.0);
// Top: y = height/10.
let (x, y) = TextGeneratorV1::draw_offsets(0, 1920, 1080, 100);
assert_eq!(x, 192.0);
assert_eq!(y, 108.0);
// Bottom: y = height - doc_height - height/10.
let (_, y) = TextGeneratorV1::draw_offsets(2, 1920, 1080, 100);
assert_eq!(y, 1080.0 - 100.0 - 108.0);
}
#[test]
fn measure_without_backend_returns_zero_size() {
crate::nodes::textbackend::set_text_backends(None, None);
// No backend is installed in cargo test; the fallback is a zero
// document size.
let mut row = NodeValueRow::default();
row.insert(TEXT_INPUT.to_string(), NodeValue::Text("Hello".to_string()));
row.insert(V_ALIGN_INPUT.to_string(), NodeValue::Combo(1));
let (req, doc, offsets) = TextGeneratorV1::measure_and_layout(&row, 1920, 1080);
assert_eq!(doc.width, 0.0);
assert_eq!(doc.height, 0.0);
assert_eq!(req.wrap_width, 1536.0);
assert_eq!(offsets.0, 192.0);
assert_eq!(offsets.1, 540.0);
}
#[test]
fn value_pushes_job_when_text_nonempty() {
let (core, behavior) = create();
let mut row = NodeValueRow::default();
row.insert(TEXT_INPUT.to_string(), NodeValue::Text("Hi".to_string()));
let mut table = NodeValueTable::default();
behavior.value(&core, &row, Rational::new(0, 1), &mut table);
// The C++ pushes a deferred generate job; the Rust model pushes a
// null texture handle marking that job.
assert!(matches!(
table.get(ValueType::Texture),
Some(NodeValue::Texture(h)) if h.is_null()
));
}
#[test]
fn value_pushes_nothing_when_text_empty() {
let (core, behavior) = create();
let mut row = NodeValueRow::default();
row.insert(TEXT_INPUT.to_string(), NodeValue::Text(String::new()));
let mut table = NodeValueTable::default();
behavior.value(&core, &row, Rational::new(0, 1), &mut table);
assert!(table.is_empty());
}
#[test]
fn generate_frame_is_documented_noop() {
let (core, behavior) = create();
let mut frame = crate::handle::CHandle::null();
behavior.generate_frame(&core, &mut frame, Rational::new(0, 1));
// The frame handle cannot be touched; it stays untouched (null).
assert!(frame.is_null());
}
#[test]
fn duplicate_copies_node() {
let (_core, behavior) = create();
let copy = behavior.duplicate(&_core).unwrap();
assert_eq!(copy.type_id(), "org.olivevideoeditor.Olive.textgenerator");
assert_eq!(copy.name(), "Text (Legacy)");
}
}
+547
View File
@@ -0,0 +1,547 @@
// 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/>.
//! Legacy text generator v2 (C++
//! `src/node/src/generator/text/textv2.{h,cpp}`,
//! `olive::TextGeneratorV2`, derives from `ShapeNodeBase`).
//!
//! FONT/RASTER BACKEND DEPENDENCY — DELIBERATELY UNDECIDED:
//! The C++ does NOT link any font/raster library directly (no freetype,
//! stb, harfbuzz, etc. anywhere in the tree). Text layout/rasterization
//! was historically Qt's rich text stack (`QTextDocument` +
//! `QAbstractTextDocumentLayout` + `QPainter` into a
//! `QImage::Format_Grayscale8` coverage buffer); it now runs behind the
//! facade-installed hooks in [`super::textbackend`]. No Rust font crate
//! is chosen here on purpose.
use crate::factory::NodeMeta;
use crate::node::{Category, NodeBehavior, NodeCore};
use crate::value::{NodeValue, NodeValueRow, NodeValueTable};
use oakcore_rs::Rational;
use super::textbackend::{
TextLayoutMode, TextLayoutRequest, TextLayoutSize, TextRenderTransform,
};
/// Text input id (C++ `k_text_input`). Type: text; default
/// `"Sample Text"`.
pub const TEXT_INPUT: &str = "text_in";
/// HTML toggle input id (C++ `k_html_input`). Type: boolean; default
/// `false`.
pub const HTML_INPUT: &str = "html_in";
/// Vertical alignment input id (C++ `k_v_align_input`). Type: combo;
/// default `0` (C++ `k_vertical_align_top`); combo strings: "Top",
/// "Center", "Bottom".
pub const V_ALIGN_INPUT: &str = "valign_in";
/// Font family input id (C++ `k_font_input`). Type: font (C++
/// `NodeValue::k_font` — no dedicated [`crate::value::ValueType`]
/// variant, travels as text); no default.
pub const FONT_INPUT: &str = "font_in";
/// Font size input id (C++ `k_font_size_input`). Type: float; default
/// `72.0`.
pub const FONT_SIZE_INPUT: &str = "font_size_in";
/// Legacy text generator v2. The C++ class has no own private members
/// — it inherits position/size/color inputs and the polygon gizmo from
/// the shape base (C++ `ShapeNodeBase`, modelled in
/// [`super::shapenodebase`]) and everything else lives in [`NodeCore`] —
/// so this is a unit struct.
pub struct TextGeneratorV2;
/// `Variant::to_string()` for the text/font inputs (Text payload, with a
/// numeric fallback for mis-typed connections).
fn to_text(v: &NodeValue) -> String {
match v {
NodeValue::Text(s) => s.clone(),
other => other.to_double().to_string(),
}
}
/// `Variant::to_vec2()` for the inherited position/size inputs.
fn to_vec2(v: &NodeValue) -> [f64; 2] {
match v {
NodeValue::Vec2(a) => *a,
other => [other.to_double(), 0.0],
}
}
impl TextGeneratorV2 {
/// Build the C++ `TextLayoutRequest` (textv2.cpp `generate_frame()`):
/// the text and font from the input row, HTML mode with newlines
/// replaced by `<br>` when `html_in` is set, 72 DPI
/// (`dots_per_meter = 2835`), and the wrap width at the shape
/// size X.
pub fn layout_request(row: &NodeValueRow) -> TextLayoutRequest {
let text = row
.get(TEXT_INPUT)
.map(to_text)
.unwrap_or_else(|| String::new());
let html = matches!(row.get(HTML_INPUT), Some(NodeValue::Boolean(true)));
let mut mode = TextLayoutMode::PlainText;
let text = if html {
// QTextDocument::setHtml() doesn't translate newlines, so they
// were replaced with <br> tags first.
mode = TextLayoutMode::Html;
text.replace('\n', "<br>")
} else {
text
};
let size = row.get(crate::nodes::shapenodebase::SIZE_INPUT).map(to_vec2).unwrap_or([0.0, 0.0]);
TextLayoutRequest {
text,
mode,
font_family: row.get(FONT_INPUT).map(to_text).unwrap_or_else(String::new),
font_size_pt: row.get(FONT_SIZE_INPUT).map(NodeValue::to_double).unwrap_or(0.0),
dots_per_meter: 2835,
wrap_width: size[0],
center_horizontally: false,
}
}
/// The C++ base offset (textv2.cpp `generate_frame()`): the shape
/// position re-centered into frame space —
/// `pos - size/2 + frame/2` (the frame halves are integer division in
/// C++).
pub fn base_offset(pos: [f64; 2], size: [f64; 2], frame_width: i32, frame_height: i32) -> (f64, f64) {
(
pos[0] - size[0] / 2.0 + (frame_width / 2) as f64,
pos[1] - size[1] / 2.0 + (frame_height / 2) as f64,
)
}
/// The C++ draw offset (textv2.cpp `generate_frame()`): the base
/// offset plus the vertical alignment delta — top: none; center:
/// `size.y/2 - doc_height/2` (the halving is integer on the
/// `int(doc.height)`); bottom: `size.y - doc_height`.
pub fn draw_offset(valign: i32, base: (f64, f64), size: [f64; 2], doc_height: i32) -> (f64, f64) {
let (dx, mut dy) = base;
match valign {
// k_vertical_align_top: do nothing.
0 => {}
// k_vertical_align_center.
1 => dy += size[1] / 2.0 - (doc_height / 2) as f64,
// k_vertical_align_bottom.
2 => dy += size[1] - doc_height as f64,
_ => {}
}
(dx, dy)
}
/// The C++ `TextRenderTransform` (textv2.cpp `generate_frame()`):
/// scale, the draw offset, and the clip rect at the base offset
/// covering the shape size (set before the vertical-alignment
/// translate in the C++).
pub fn render_transform(scale: f64, draw: (f64, f64), base: (f64, f64), size: [f64; 2]) -> TextRenderTransform {
TextRenderTransform {
scale,
draw_offset_x: draw.0,
draw_offset_y: draw.1,
clip_enabled: true,
clip_offset_x: base.0,
clip_offset_y: base.1,
clip_width: size[0],
clip_height: size[1],
}
}
/// The layout/measure control flow of the C++ `generate_frame()` with
/// the backend hooks: build the request, measure via the installed
/// measure backend (zero size when none is installed — the documented
/// no-backend fallback), and compute the base/draw offsets for the
/// given frame size.
///
/// The render step and the alpha transplant need the frame's pixel
/// buffer, which the Rust frame handle does not expose; they are not
/// representable here (`// CPP-PARITY: textv2.cpp` `generate_frame`).
pub fn measure_and_layout(
row: &NodeValueRow,
frame_width: i32,
frame_height: i32,
) -> (TextLayoutRequest, TextLayoutSize, (f64, f64), (f64, f64)) {
let req = Self::layout_request(row);
let doc = match super::textbackend::text_measure_backend() {
Some(measure) => measure(&req),
None => TextLayoutSize::default(),
};
let size = row.get(crate::nodes::shapenodebase::SIZE_INPUT).map(to_vec2).unwrap_or([0.0, 0.0]);
let pos = row.get(crate::nodes::shapenodebase::POSITION_INPUT).map(to_vec2).unwrap_or([0.0, 0.0]);
let base = Self::base_offset(pos, size, frame_width, frame_height);
let valign = row.get(V_ALIGN_INPUT).map(NodeValue::to_double).unwrap_or(0.0) as i32;
let draw = Self::draw_offset(valign, base, size, doc.height as i32);
(req, doc, base, draw)
}
}
impl NodeBehavior for TextGeneratorV2 {
/// Human-readable name (C++ `name()`).
fn name(&self) -> &str {
"Text (Legacy)"
}
/// Stable type id (C++ `id()`).
fn type_id(&self) -> &str {
"org.olivevideoeditor.Olive.text2"
}
/// Categories (C++ `category()`).
fn categories(&self) -> &[Category] {
&[Category::Generator]
}
/// Description (C++ `description()`).
fn description(&self) -> &str {
"Generate rich text."
}
/// Localized input names (C++ `retranslate()`): `text_in` ->
/// "Text", `html_in` -> "Enable HTML", `font_in` -> "Font",
/// `font_size_in` -> "Font Size", `valign_in` -> "Vertical Align"
/// (combo strings Top/Center/Bottom — set by the C++
/// `set_combo_box_strings`, which has no trait surface here); the
/// base class retranslate covers the inherited shape inputs and
/// `base_in` ("Base").
fn input_name<'a>(&self, id: &'a str) -> &'a str {
match id {
TEXT_INPUT => "Text",
HTML_INPUT => "Enable HTML",
V_ALIGN_INPUT => "Vertical Align",
FONT_INPUT => "Font",
FONT_SIZE_INPUT => "Font Size",
crate::nodes::generatorwithmerge::BASE_INPUT => "Base",
_ => crate::nodes::shapenodebase::ShapeNodeBase::input_name(id),
}
}
/// Evaluate outputs (C++ `value()`): if the text input is
/// non-empty, push a texture generate job at the global video
/// params forced to `PixelFormat::f32`; otherwise push nothing.
///
/// The Rust model has no generate-job payload: the job case pushes a
/// null texture handle marking a renderer-deferred generate job
/// resolved via [`NodeBehavior::generate_frame`]; the f32 forcing is
/// renderer-side and has no representation here
/// (`// CPP-PARITY: textv2.cpp` `value()`).
fn value(&self, core: &NodeCore, inputs: &NodeValueRow, time: Rational, table: &mut NodeValueTable) {
let text = inputs
.get(TEXT_INPUT)
.map(to_text)
.unwrap_or_else(|| core.value_at_time(TEXT_INPUT, -1, time).to_double().to_string());
if !text.is_empty() {
table.push(
crate::value::ValueType::Texture,
NodeValue::Texture(crate::handle::CHandle::null()),
None,
);
}
}
/// Direct frame generation (C++ `generate_frame()`): rasterizes
/// the text into a grayscale coverage buffer via the installed
/// text backend at 72 DPI (2835 dots/meter) — HTML mode replaces
/// newlines with `<br>`, wrap width is the shape size X, base
/// offset is the shape position re-centered into frame space,
/// vertical draw offset depends on the valign combo (top: none;
/// center: `size.y/2 - doc.height/2`; bottom: `size.y -
/// doc.height`), and the clip rect is the shape rect at the base
/// offset — then writes the coverage multiplied by the RGBA color
/// into the float frame (SIMD path in C++, scalar fallback
/// identical). With no backend installed, warns once and leaves
/// the frame empty.
///
/// The Rust frame is an opaque [`crate::bridge::render::TextureHandle`]
/// whose pixels cannot be read or written from this crate, so the
/// body is a documented no-op; the layout/measure/offset control flow
/// is ported in [`Self::layout_request`], [`Self::base_offset`],
/// [`Self::draw_offset`], [`Self::render_transform`] and
/// [`Self::measure_and_layout`], and exercised by the tests.
fn generate_frame(
&self,
core: &NodeCore,
frame: &mut crate::bridge::render::TextureHandle,
time: Rational,
) {
let _ = (core, frame, time);
}
/// Deep copy (C++ `copy()`).
fn duplicate(&self, _core: &NodeCore) -> Option<Box<dyn NodeBehavior>> {
Some(Box::new(TextGeneratorV2))
}
}
/// Constructor (C++ `TextGeneratorV2::TextGeneratorV2()`): adds
/// `text_in`, `html_in`, `valign_in`, `font_in` and `font_size_in` with
/// the defaults documented on the constants, sets the inherited shape
/// base `color_in` standard value to white and `size_in` to
/// `(400, 300)`, and sets the `dont_show_in_create_menu` flag.
///
/// The inherited inputs (`base_in` from the merge base, `pos_in`/
/// `size_in`/`color_in` from the shape base) are wired here, mirroring
/// the C++ constructor chain `Node -> GeneratorWithMerge ->
/// ShapeNodeBase -> TextGeneratorV2`.
pub fn create() -> (NodeCore, Box<dyn NodeBehavior>) {
let mut core = NodeCore::new();
// GeneratorWithMerge base: base_in texture effect input.
let mut base = crate::input::Input::new(
crate::nodes::generatorwithmerge::BASE_INPUT,
crate::value::ValueType::Texture,
NodeValue::None,
);
base.flags |= crate::input::flags::NOT_KEYFRAMABLE;
core.add_input(base);
core.effect_input = crate::nodes::generatorwithmerge::BASE_INPUT.to_string();
core.flags |= crate::node::flags::VIDEO_EFFECT;
// ShapeNodeBase (create_color_input = true): pos/size/color.
core.add_input(crate::input::Input::new(
crate::nodes::shapenodebase::POSITION_INPUT,
crate::value::ValueType::Vec2,
NodeValue::Vec2([0.0, 0.0]),
));
let mut size = crate::input::Input::new(
crate::nodes::shapenodebase::SIZE_INPUT,
crate::value::ValueType::Vec2,
NodeValue::Vec2([100.0, 100.0]),
);
size.properties = vec![(
"min".to_string(),
NodeValue::Vec2([0.0, 0.0]),
)];
core.add_input(size);
core.add_input(crate::input::Input::new(
crate::nodes::shapenodebase::COLOR_INPUT,
crate::value::ValueType::Color,
NodeValue::Color([1.0, 0.0, 0.0, 1.0]),
));
// Own inputs.
core.add_input(crate::input::Input::new(
TEXT_INPUT,
crate::value::ValueType::Text,
NodeValue::Text("Sample Text".to_string()),
));
core.add_input(crate::input::Input::new(
HTML_INPUT,
crate::value::ValueType::Boolean,
NodeValue::Boolean(false),
));
core.add_input(crate::input::Input::new(
V_ALIGN_INPUT,
crate::value::ValueType::Combo,
NodeValue::Combo(0),
));
core.add_input(crate::input::Input::new(
FONT_INPUT,
crate::value::ValueType::Text,
NodeValue::Text(String::new()),
));
core.add_input(crate::input::Input::new(
FONT_SIZE_INPUT,
crate::value::ValueType::Float,
NodeValue::Float(72.0),
));
// C++ set_standard_value overrides.
core.set_standard_value(
crate::nodes::shapenodebase::COLOR_INPUT,
-1,
NodeValue::Color([1.0, 1.0, 1.0, 1.0]),
);
core.set_standard_value(
crate::nodes::shapenodebase::SIZE_INPUT,
-1,
NodeValue::Vec2([400.0, 300.0]),
);
core.flags |= crate::node::flags::DONT_SHOW_IN_CREATE_MENU;
(core, Box::new(TextGeneratorV2))
}
/// Register this node type (C++ `k_text_generator_v2` in
/// `factory.cpp::create_from_factory_index`).
pub fn register(meta: &mut Vec<NodeMeta>) {
meta.push(NodeMeta {
type_id: "org.olivevideoeditor.Olive.text2",
name: "Text (Legacy)",
categories: &[Category::Generator],
create,
});
}
#[cfg(test)]
mod tests {
use super::*;
use crate::node::NodeBehavior;
use crate::value::{NodeValueTable, ValueType};
use oakcore_rs::Rational;
#[test]
fn input_names() {
let n = TextGeneratorV2;
assert_eq!(n.input_name(TEXT_INPUT), "Text");
assert_eq!(n.input_name(HTML_INPUT), "Enable HTML");
assert_eq!(n.input_name(V_ALIGN_INPUT), "Vertical Align");
assert_eq!(n.input_name(FONT_INPUT), "Font");
assert_eq!(n.input_name(FONT_SIZE_INPUT), "Font Size");
assert_eq!(n.input_name(crate::nodes::generatorwithmerge::BASE_INPUT), "Base");
assert_eq!(n.input_name(crate::nodes::shapenodebase::POSITION_INPUT), "Position");
assert_eq!(n.input_name(crate::nodes::shapenodebase::SIZE_INPUT), "Size");
assert_eq!(n.input_name(crate::nodes::shapenodebase::COLOR_INPUT), "Color");
assert_eq!(n.input_name("other_in"), "other_in");
}
#[test]
fn create_wires_inherited_and_own_inputs() {
let (core, behavior) = create();
assert_eq!(behavior.type_id(), "org.olivevideoeditor.Olive.text2");
assert_eq!(core.get_input(TEXT_INPUT).unwrap().value_type, ValueType::Text);
assert_eq!(core.get_input(V_ALIGN_INPUT).unwrap().default, NodeValue::Combo(0));
assert_eq!(core.effect_input, crate::nodes::generatorwithmerge::BASE_INPUT);
assert_ne!(core.flags & crate::node::flags::VIDEO_EFFECT, 0);
assert_ne!(core.flags & crate::node::flags::DONT_SHOW_IN_CREATE_MENU, 0);
// Inherited standard-value overrides.
assert_eq!(
core.standard_value(crate::nodes::shapenodebase::COLOR_INPUT, -1),
NodeValue::Color([1.0, 1.0, 1.0, 1.0])
);
assert_eq!(
core.standard_value(crate::nodes::shapenodebase::SIZE_INPUT, -1),
NodeValue::Vec2([400.0, 300.0])
);
}
#[test]
fn layout_request_uses_shape_size_and_72dpi() {
let mut row = NodeValueRow::default();
row.insert(TEXT_INPUT.to_string(), NodeValue::Text("Hi".to_string()));
row.insert(FONT_SIZE_INPUT.to_string(), NodeValue::Float(36.0));
row.insert(HTML_INPUT.to_string(), NodeValue::Boolean(false));
row.insert(crate::nodes::shapenodebase::SIZE_INPUT.to_string(), NodeValue::Vec2([400.0, 300.0]));
let req = TextGeneratorV2::layout_request(&row);
assert_eq!(req.text, "Hi");
assert_eq!(req.font_size_pt, 36.0);
assert_eq!(req.dots_per_meter, 2835);
assert_eq!(req.wrap_width, 400.0);
assert!(!req.center_horizontally);
}
#[test]
fn layout_request_html_translates_newlines() {
let mut row = NodeValueRow::default();
row.insert(TEXT_INPUT.to_string(), NodeValue::Text("a\nb".to_string()));
row.insert(HTML_INPUT.to_string(), NodeValue::Boolean(true));
row.insert(crate::nodes::shapenodebase::SIZE_INPUT.to_string(), NodeValue::Vec2([100.0, 100.0]));
let req = TextGeneratorV2::layout_request(&row);
assert_eq!(req.text, "a<br>b");
assert_eq!(req.mode, TextLayoutMode::Html);
}
#[test]
fn base_and_draw_offsets() {
let size = [400.0, 300.0];
let pos = [0.0, 0.0];
let base = TextGeneratorV2::base_offset(pos, size, 1920, 1080);
assert_eq!(base, (0.0 - 200.0 + 960.0, 0.0 - 150.0 + 540.0));
// Top: no vertical delta.
assert_eq!(TextGeneratorV2::draw_offset(0, base, size, 100), base);
// Center: size.y/2 - doc_height/2 (integer halving of doc height).
assert_eq!(
TextGeneratorV2::draw_offset(1, base, size, 100),
(base.0, base.1 + 150.0 - 50.0)
);
// Bottom: size.y - doc_height.
assert_eq!(
TextGeneratorV2::draw_offset(2, base, size, 100),
(base.0, base.1 + 300.0 - 100.0)
);
}
#[test]
fn render_transform_clips_to_shape_rect() {
let size = [400.0, 300.0];
let base = (100.0, 200.0);
let t = TextGeneratorV2::render_transform(0.5, base, base, size);
assert_eq!(t.scale, 0.5);
assert_eq!(t.draw_offset_x, 100.0);
assert_eq!(t.draw_offset_y, 200.0);
assert!(t.clip_enabled);
assert_eq!(t.clip_offset_x, 100.0);
assert_eq!(t.clip_offset_y, 200.0);
assert_eq!(t.clip_width, 400.0);
assert_eq!(t.clip_height, 300.0);
}
#[test]
fn measure_without_backend_returns_zero_size() {
crate::nodes::textbackend::set_text_backends(None, None);
let mut row = NodeValueRow::default();
row.insert(TEXT_INPUT.to_string(), NodeValue::Text("Hi".to_string()));
row.insert(crate::nodes::shapenodebase::POSITION_INPUT.to_string(), NodeValue::Vec2([0.0, 0.0]));
row.insert(crate::nodes::shapenodebase::SIZE_INPUT.to_string(), NodeValue::Vec2([400.0, 300.0]));
row.insert(V_ALIGN_INPUT.to_string(), NodeValue::Combo(1));
let (_req, doc, base, draw) = TextGeneratorV2::measure_and_layout(&row, 1920, 1080);
assert_eq!(doc.width, 0.0);
assert_eq!(doc.height, 0.0);
assert_eq!(base, (760.0, 390.0));
assert_eq!(draw, (760.0, 390.0 + 150.0));
}
#[test]
fn value_pushes_job_when_text_nonempty() {
let (core, behavior) = create();
let mut row = NodeValueRow::default();
row.insert(TEXT_INPUT.to_string(), NodeValue::Text("Hi".to_string()));
let mut table = NodeValueTable::default();
behavior.value(&core, &row, Rational::new(0, 1), &mut table);
assert!(matches!(
table.get(ValueType::Texture),
Some(NodeValue::Texture(h)) if h.is_null()
));
}
#[test]
fn value_pushes_nothing_when_text_empty() {
let (core, behavior) = create();
let mut row = NodeValueRow::default();
row.insert(TEXT_INPUT.to_string(), NodeValue::Text(String::new()));
let mut table = NodeValueTable::default();
behavior.value(&core, &row, Rational::new(0, 1), &mut table);
assert!(table.is_empty());
}
#[test]
fn generate_frame_is_documented_noop() {
let (core, behavior) = create();
let mut frame = crate::handle::CHandle::null();
behavior.generate_frame(&core, &mut frame, Rational::new(0, 1));
assert!(frame.is_null());
}
#[test]
fn duplicate_copies_node() {
let (_core, behavior) = create();
let copy = behavior.duplicate(&_core).unwrap();
assert_eq!(copy.type_id(), "org.olivevideoeditor.Olive.text2");
assert_eq!(copy.name(), "Text (Legacy)");
}
}
+825
View File
@@ -0,0 +1,825 @@
// 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/>.
//! Rich text generator v3 (C++
//! `src/node/src/generator/text/textv3.{h,cpp}`,
//! `olive::TextGeneratorV3`, derives from `ShapeNodeBase`).
//!
//! FONT/RASTER BACKEND DEPENDENCY — DELIBERATELY UNDECIDED:
//! The C++ does NOT link any font/raster library directly (no freetype,
//! stb, harfbuzz, etc. anywhere in the tree). Text layout/rasterization
//! was historically Qt's rich text stack (`QTextDocument` fed through
//! Olive's `Html::html_to_doc()` + `QPainter` directly over an
//! RGBA8888-premultiplied buffer); it now runs behind the
//! facade-installed hooks in [`super::textbackend`]. No Rust font crate
//! is chosen here on purpose.
use crate::factory::NodeMeta;
use crate::node::{Category, NodeBehavior, NodeCore};
use crate::value::{NodeValue, NodeValueRow, NodeValueTable};
use oakcore_rs::Rational;
use super::textbackend::{TextLayoutMode, TextLayoutRequest, TextLayoutSize, TextRenderTransform};
/// Text input id (C++ `k_text_input`). Type: text; default
/// `"<p style='font-size: 72pt; color: white;'>Sample Text</p>"`;
/// properties: `vieweronly = true`.
pub const TEXT_INPUT: &str = "text_in";
/// Vertical alignment input id (C++ `k_vertical_alignment_input`).
/// Type: combo; no default; flags: hidden | static; combo strings:
/// "Top", "Middle", "Bottom".
pub const VERTICAL_ALIGNMENT_INPUT: &str = "valign_in";
/// Args enable toggle input id (C++ `k_use_args_input`). Type: boolean;
/// default `true`; flags: hidden | static.
pub const USE_ARGS_INPUT: &str = "use_args_in";
/// Format arguments array input id (C++ `k_args_input`). Type: text;
/// flags: array; properties: `arraystart = 1`.
pub const ARGS_INPUT: &str = "args_in";
/// Vertical alignment (C++ `TextGeneratorV3::VerticalAlignment`, values
/// `k_v_align_top = 0`, `k_v_align_middle = 1`, `k_v_align_bottom = 2`).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum VerticalAlignment {
/// Align to the top of the shape rect (C++ `k_v_align_top`).
Top,
/// Vertically center in the shape rect (C++ `k_v_align_middle`).
Middle,
/// Align to the bottom of the shape rect (C++ `k_v_align_bottom`).
Bottom,
}
impl VerticalAlignment {
/// From a combo index (C++ `static_cast<VerticalAlignment>`); unknown
/// values map to [`VerticalAlignment::Top`] like the C++ cast's
/// callers assume.
fn from_int(v: i32) -> VerticalAlignment {
match v {
1 => VerticalAlignment::Middle,
2 => VerticalAlignment::Bottom,
_ => VerticalAlignment::Top,
}
}
}
/// 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;
/// Rich text generator v3 (the current "Text" node). Inherits
/// position/size/color inputs and the polygon gizmo from the shape base
/// (C++ `ShapeNodeBase`, modelled in [`super::shapenodebase`]).
///
/// The C++ also owns a `TextGizmo *text_gizmo_` — a GUI-layer viewport
/// gizmo with no Rust equivalent in this crate; it is omitted here and
/// will be re-attached by the facade/gizmo wave (gizmos themselves live
/// in [`NodeCore::gizmos`]).
pub struct TextGeneratorV3 {
/// Suppresses re-emitting the vertical alignment to the gizmo
/// while it is being driven by the gizmo (C++ `dont_emit_valign_`).
dont_emit_valign: bool,
}
/// `Variant::to_string()` for the text input (Text payload, with a
/// numeric fallback for mis-typed connections).
fn to_text(v: &NodeValue) -> String {
match v {
NodeValue::Text(s) => s.clone(),
other => other.to_double().to_string(),
}
}
/// `Variant::to_bool()` for the args toggle.
fn to_bool(v: &NodeValue) -> bool {
match v {
NodeValue::Boolean(b) => *b,
other => other.to_double() != 0.0,
}
}
/// `Variant::to_vec2()` for the inherited position/size inputs.
fn to_vec2(v: &NodeValue) -> [f64; 2] {
match v {
NodeValue::Vec2(a) => *a,
other => [other.to_double(), 0.0],
}
}
impl TextGeneratorV3 {
/// Map our alignment to the gizmo's alignment int (C++
/// `get_qt_alignment_from_ours()`): Top -> `TextGizmo::k_align_top`,
/// Middle -> `TextGizmo::k_align_vcenter`, Bottom ->
/// `TextGizmo::k_align_bottom` (0 = top, 1 = bottom, 2 = vcenter in
/// the gizmo's numbering).
pub fn get_gizmo_alignment_from_ours(v: VerticalAlignment) -> i32 {
match v {
VerticalAlignment::Top => 0,
VerticalAlignment::Middle => 2,
VerticalAlignment::Bottom => 1,
}
}
/// Map the gizmo's alignment int back to ours (C++
/// `get_our_alignment_from_qts()`); unknown values map to
/// [`VerticalAlignment::Top`].
pub fn get_our_alignment_from_gizmos(v: i32) -> VerticalAlignment {
match v {
1 => VerticalAlignment::Bottom,
2 => VerticalAlignment::Middle,
_ => VerticalAlignment::Top,
}
}
/// The current alignment (C++ `get_vertical_alignment()`): the
/// `valign_in` standard value as a [`VerticalAlignment`].
pub fn vertical_alignment(core: &NodeCore) -> VerticalAlignment {
VerticalAlignment::from_int(core.standard_value(VERTICAL_ALIGNMENT_INPUT, -1).to_double() as i32)
}
/// Expand `%N` placeholders with args (C++ `format_string()`):
/// `%%` yields a literal `%`; `%` followed by digits parses an int
/// (out-of-int-range parses fail to 0, making the index -1) and
/// substitutes `args[index - 1]` when in range, otherwise expands
/// to nothing; a lone `%` before a non-digit/non-`%` is copied
/// verbatim.
pub fn format_string(input: &str, args: &[String]) -> String {
let bytes = input.as_bytes();
let mut output = String::new();
let mut i = 0;
while i < bytes.len() {
let c = bytes[i] as char;
if i + 1 < bytes.len() && c == '%' {
let next = bytes[i + 1] as char;
if next == '%' {
// Double percent, append a single percent.
output.push('%');
i += 1;
} else if next.is_ascii_digit() {
// Find the length of the number (QString::toInt()
// semantics: out-of-int-range parses fail and yield 0,
// making the index -1).
let mut num = String::new();
i += 1;
while i < bytes.len() && bytes[i].is_ascii_digit() {
num.push(bytes[i] as char);
i += 1;
}
i -= 1;
let n: i64 = num.parse().unwrap_or(0);
let index = if n > i32::MAX as i64 || n < i32::MIN as i64 {
-1
} else {
(n as i32) - 1
};
if index >= 0 && (index as usize) < args.len() {
output.push_str(&args[index as usize]);
}
} else {
output.push(c);
}
} else {
output.push(c);
}
i += 1;
}
output
}
/// Gizmo activated callback (C++ `gizmo_activated()`): sets
/// `use_args_in` to `false` and `dont_emit_valign_ = true`.
fn gizmo_activated(&mut self, core: &mut NodeCore) {
core.set_standard_value(USE_ARGS_INPUT, -1, NodeValue::Boolean(false));
self.dont_emit_valign = true;
}
/// Gizmo deactivated callback (C++ `gizmo_deactivated()`): sets
/// `use_args_in` to `true` and `dont_emit_valign_ = true`.
fn gizmo_deactivated(&mut self, core: &mut NodeCore) {
core.set_standard_value(USE_ARGS_INPUT, -1, NodeValue::Boolean(true));
self.dont_emit_valign = true;
}
/// Set the vertical alignment through the undo system (C++
/// `set_vertical_alignment_undoable()`, formerly a
/// `NodeParamSetStandardValueCommand` on the undo stack). The undo
/// command stack is not part of this crate, so only the resulting
/// standard-value write is performed (`// CPP-PARITY: textv3.cpp`
/// `set_vertical_alignment_undoable`).
fn set_vertical_alignment_undoable(&mut self, core: &mut NodeCore, a: i32) {
core.set_standard_value(
VERTICAL_ALIGNMENT_INPUT,
-1,
NodeValue::Combo(Self::get_our_alignment_from_gizmos(a) as i64),
);
}
}
impl NodeBehavior for TextGeneratorV3 {
/// Human-readable name (C++ `name()`).
fn name(&self) -> &str {
"Text"
}
/// Stable type id (C++ `id()`).
fn type_id(&self) -> &str {
"org.olivevideoeditor.Olive.text3"
}
/// Categories (C++ `category()`).
fn categories(&self) -> &[Category] {
&[Category::Generator]
}
/// Description (C++ `description()`).
fn description(&self) -> &str {
"Generate rich text."
}
/// Localized input names (C++ `retranslate()`): `text_in` ->
/// "Text", `valign_in` -> "Vertical Alignment" (combo strings
/// Top/Middle/Bottom), `args_in` -> "Arguments"; the base class
/// retranslate covers the inherited shape inputs and `base_in`
/// ("Base").
fn input_name<'a>(&self, id: &'a str) -> &'a str {
match id {
TEXT_INPUT => "Text",
VERTICAL_ALIGNMENT_INPUT => "Vertical Alignment",
ARGS_INPUT => "Arguments",
crate::nodes::generatorwithmerge::BASE_INPUT => "Base",
_ => crate::nodes::shapenodebase::ShapeNodeBase::input_name(id),
}
}
/// Evaluate outputs (C++ `value()`): if `use_args_in` is set and
/// the args array is non-empty, expand `%N` placeholders in the
/// text via [`Self::format_string`]; if the resulting text is
/// non-empty, push a merged texture generate job (params from the
/// incoming base texture when present, else the global video
/// params, forced to `PixelFormat::u8` and the project's default
/// input color space, with the expanded text inserted back into
/// the job); otherwise pass the base input texture through
/// unchanged.
///
/// The Rust model has no generate-job payload and no array value
/// representation: the job case goes through
/// [`crate::nodes::generatorwithmerge::GeneratorWithMerge::push_mergable_job`]
/// with a null handle, and the args array resolves to the single row
/// value when present (a per-element array model is deferred), so
/// `%N` expansion is exercised directly via [`Self::format_string`]
/// (`// CPP-PARITY: textv3.cpp` `value()`).
fn value(&self, core: &NodeCore, inputs: &NodeValueRow, time: Rational, table: &mut NodeValueTable) {
let text_val = inputs
.get(TEXT_INPUT)
.cloned()
.unwrap_or_else(|| core.value_at_time(TEXT_INPUT, -1, time));
let mut text = to_text(&text_val);
let use_args_val = inputs
.get(USE_ARGS_INPUT)
.cloned()
.unwrap_or_else(|| core.value_at_time(USE_ARGS_INPUT, -1, time));
if to_bool(&use_args_val) {
let args: Vec<String> = match inputs.get(ARGS_INPUT) {
Some(NodeValue::Text(s)) => vec![s.clone()],
_ => Vec::new(),
};
if !args.is_empty() {
text = Self::format_string(&text, &args);
}
}
if !text.is_empty() {
// C++ `push_mergable_job(value, Texture::job(text_params, job),
// table)` — merged over base_in when connected, else pushed
// directly. The null handle marks the renderer-deferred
// generate job.
crate::nodes::generatorwithmerge::GeneratorWithMerge::push_mergable_job(
inputs,
crate::handle::CHandle::null(),
table,
);
} else if let Some(base @ NodeValue::Texture(_)) = inputs.get(crate::nodes::generatorwithmerge::BASE_INPUT) {
table.push(base.value_type(), base.clone(), None);
}
}
/// Direct frame generation (C++ `generate_frame()`): clears the
/// RGBA8888-premultiplied frame to transparent, then (only when a
/// measure backend is installed) lays out the text as Olive HTML at
/// 96 DPI (3780 dots/meter) wrapped to the shape size X, computes
/// the base offset from the shape position re-centered into frame
/// space, applies the vertical alignment to the draw offset (top:
/// none; middle: `size.y/2 - doc.height/2`; bottom: `size.y -
/// doc.height`), clips to the shape rect at the base offset, and
/// renders over the buffer via the render backend. With no measure
/// backend installed, warns once and leaves the cleared frame
/// untouched.
///
/// The Rust frame is an opaque [`crate::bridge::render::TextureHandle`]
/// whose pixels cannot be read or written from this crate, so the
/// body is a documented no-op; the layout/measure/offset control flow
/// is ported in [`Self::layout_request`], [`Self::base_offset`] and
/// [`Self::draw_offset`], and exercised by the tests.
fn generate_frame(
&self,
core: &NodeCore,
frame: &mut crate::bridge::render::TextureHandle,
time: Rational,
) {
let _ = (core, frame, time);
}
/// Gizmo position update (C++ `update_gizmo_positions()`): after
/// the base update, sets the text gizmo rect to the bounding rect
/// of the polygon gizmo's polygon (empty polygon -> zero rect) and
/// feeds it the current `text_in` HTML.
///
/// The polygon/text gizmos live in the GUI layer with no Rust model
/// in this crate, so this is a documented no-op
/// (`// CPP-PARITY: textv3.cpp` `update_gizmo_positions`).
fn gizmo_update(&self, core: &NodeCore, row: &NodeValueRow) {
let _ = (core, row);
}
/// Input value changed (C++ `InputValueChangedEvent()`): when
/// `valign_in` changes and `dont_emit_valign_` is not set, forwards
/// the new alignment to the text gizmo; then defers to the base
/// implementation.
///
/// The text gizmo has no Rust model in this crate, so only the
/// flag check is represented (`// CPP-PARITY: textv3.cpp`
/// `InputValueChangedEvent`).
fn input_value_changed(&mut self, core: &mut NodeCore, input: &str, element: i32) {
let _ = (core, element);
if input == VERTICAL_ALIGNMENT_INPUT && !self.dont_emit_valign {
// The C++ forwards the new alignment to the text gizmo here.
}
}
/// Deep copy (C++ `copy()`).
fn duplicate(&self, _core: &NodeCore) -> Option<Box<dyn NodeBehavior>> {
Some(Box::new(TextGeneratorV3 {
dont_emit_valign: self.dont_emit_valign,
}))
}
/// Downcast to [`Self`] (gizmo-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)
}
}
impl TextGeneratorV3 {
/// Build the C++ `TextLayoutRequest` (textv3.cpp `generate_frame()`):
/// Olive-HTML text, 96 DPI (3780 dots/meter), wrapped to the shape
/// size X. Font family/size come from the markup; the backend defaults
/// are used when absent.
pub fn layout_request(row: &NodeValueRow) -> TextLayoutRequest {
let size = row.get(crate::nodes::shapenodebase::SIZE_INPUT).map(to_vec2).unwrap_or([0.0, 0.0]);
TextLayoutRequest {
text: row.get(TEXT_INPUT).map(to_text).unwrap_or_else(String::new),
mode: TextLayoutMode::OliveHtml,
font_family: String::new(),
font_size_pt: 0.0,
dots_per_meter: 3780,
wrap_width: size[0],
center_horizontally: false,
}
}
/// The C++ base offset (textv3.cpp `generate_frame()`): the shape
/// position re-centered into frame space — `pos - size/2 + frame/2`
/// (the frame halves are integer division in C++).
pub fn base_offset(pos: [f64; 2], size: [f64; 2], frame_width: i32, frame_height: i32) -> (f64, f64) {
(
pos[0] - size[0] / 2.0 + (frame_width / 2) as f64,
pos[1] - size[1] / 2.0 + (frame_height / 2) as f64,
)
}
/// The C++ draw offset (textv3.cpp `generate_frame()`): the base
/// offset plus the vertical-alignment delta — top: none; middle:
/// `size.y/2 - doc.height/2`; bottom: `size.y - doc.height` (all
/// double math, unlike the integer halving in v2).
pub fn draw_offset(align: VerticalAlignment, base: (f64, f64), size: [f64; 2], doc_height: f64) -> (f64, f64) {
let (dx, mut dy) = base;
match align {
VerticalAlignment::Top => {}
VerticalAlignment::Middle => dy += size[1] / 2.0 - doc_height / 2.0,
VerticalAlignment::Bottom => dy += size[1] - doc_height,
}
(dx, dy)
}
/// The C++ `TextRenderTransform` (textv3.cpp `generate_frame()`):
/// scale, the draw offset, and the clip rect at the base offset
/// covering the shape size (set before the vertical-alignment
/// translate in the C++).
pub fn render_transform(scale: f64, draw: (f64, f64), base: (f64, f64), size: [f64; 2]) -> TextRenderTransform {
TextRenderTransform {
scale,
draw_offset_x: draw.0,
draw_offset_y: draw.1,
clip_enabled: true,
clip_offset_x: base.0,
clip_offset_y: base.1,
clip_width: size[0],
clip_height: size[1],
}
}
/// The layout/measure control flow of the C++ `generate_frame()` with
/// the backend hooks: build the request and measure via the installed
/// measure backend (zero size when none is installed — the documented
/// no-backend fallback; the frame is left cleared).
///
/// The render step needs the frame's pixel buffer, which the Rust
/// frame handle does not expose; it is not representable here
/// (`// CPP-PARITY: textv3.cpp` `generate_frame`).
pub fn measure_and_layout(row: &NodeValueRow) -> (TextLayoutRequest, TextLayoutSize) {
let req = Self::layout_request(row);
let doc = match super::textbackend::text_measure_backend() {
Some(measure) => measure(&req),
None => TextLayoutSize::default(),
};
(req, doc)
}
}
/// Constructor (C++ `TextGeneratorV3::TextGeneratorV3()`): builds the
/// shape base without its own gizmo behavior (`ShapeNodeBase(false)`),
/// adds `text_in`, `valign_in`, `use_args_in` and `args_in` with the
/// defaults, flags and properties documented on the constants, sets the
/// inherited `size_in` standard value to `(400, 300)`, creates the
/// `TextGizmo` bound to `text_in`, and initializes
/// `dont_emit_valign_ = false`.
///
/// The `TextGizmo` is a GUI-layer gizmo with no Rust model (see the
/// struct doc); the inherited inputs (`base_in` from the merge base,
/// `pos_in`/`size_in` from the shape base without its color input) are
/// wired here, mirroring the C++ constructor chain `Node ->
/// GeneratorWithMerge -> ShapeNodeBase(false) -> TextGeneratorV3`.
pub fn create() -> (NodeCore, Box<dyn NodeBehavior>) {
let mut core = NodeCore::new();
// GeneratorWithMerge base: base_in texture effect input.
let mut base = crate::input::Input::new(
crate::nodes::generatorwithmerge::BASE_INPUT,
crate::value::ValueType::Texture,
NodeValue::None,
);
base.flags |= crate::input::flags::NOT_KEYFRAMABLE;
core.add_input(base);
core.effect_input = crate::nodes::generatorwithmerge::BASE_INPUT.to_string();
core.flags |= crate::node::flags::VIDEO_EFFECT;
// ShapeNodeBase(false): pos/size, no color input.
core.add_input(crate::input::Input::new(
crate::nodes::shapenodebase::POSITION_INPUT,
crate::value::ValueType::Vec2,
NodeValue::Vec2([0.0, 0.0]),
));
let mut size = crate::input::Input::new(
crate::nodes::shapenodebase::SIZE_INPUT,
crate::value::ValueType::Vec2,
NodeValue::Vec2([100.0, 100.0]),
);
size.properties = vec![("min".to_string(), NodeValue::Vec2([0.0, 0.0]))];
core.add_input(size);
// Own inputs.
let mut text = crate::input::Input::new(
TEXT_INPUT,
crate::value::ValueType::Text,
NodeValue::Text(
"<p style='font-size: 72pt; color: white;'>Sample Text</p>".to_string(),
),
);
text.properties = vec![("vieweronly".to_string(), NodeValue::Boolean(true))];
core.add_input(text);
let mut valign = crate::input::Input::new(
VERTICAL_ALIGNMENT_INPUT,
crate::value::ValueType::Combo,
NodeValue::Combo(0),
);
valign.flags |= STATIC_FLAGS | crate::input::flags::HIDDEN;
core.add_input(valign);
let mut use_args = crate::input::Input::new(
USE_ARGS_INPUT,
crate::value::ValueType::Boolean,
NodeValue::Boolean(true),
);
use_args.flags |= STATIC_FLAGS | crate::input::flags::HIDDEN;
core.add_input(use_args);
let mut args = crate::input::Input::new(
ARGS_INPUT,
crate::value::ValueType::Text,
NodeValue::Text(String::new()),
);
args.flags |= crate::input::flags::ARRAY;
args.properties = vec![("arraystart".to_string(), NodeValue::Int(1))];
core.add_input(args);
// C++ set_standard_value override.
core.set_standard_value(
crate::nodes::shapenodebase::SIZE_INPUT,
-1,
NodeValue::Vec2([400.0, 300.0]),
);
(core, Box::new(TextGeneratorV3 {
dont_emit_valign: false,
}))
}
/// Register this node type (C++ `k_text_generator_v3` in
/// `factory.cpp::create_from_factory_index`).
pub fn register(meta: &mut Vec<NodeMeta>) {
meta.push(NodeMeta {
type_id: "org.olivevideoeditor.Olive.text3",
name: "Text",
categories: &[Category::Generator],
create,
});
}
#[cfg(test)]
mod tests {
use super::*;
use crate::node::NodeBehavior;
use crate::value::{NodeValueTable, ValueType};
use oakcore_rs::Rational;
#[test]
fn input_names() {
let n = TextGeneratorV3 { dont_emit_valign: false };
assert_eq!(n.input_name(TEXT_INPUT), "Text");
assert_eq!(n.input_name(VERTICAL_ALIGNMENT_INPUT), "Vertical Alignment");
assert_eq!(n.input_name(ARGS_INPUT), "Arguments");
assert_eq!(n.input_name(crate::nodes::generatorwithmerge::BASE_INPUT), "Base");
assert_eq!(n.input_name(crate::nodes::shapenodebase::POSITION_INPUT), "Position");
assert_eq!(n.input_name(crate::nodes::shapenodebase::SIZE_INPUT), "Size");
// The hidden use_args_in input has no display name override.
assert_eq!(n.input_name(USE_ARGS_INPUT), USE_ARGS_INPUT);
}
#[test]
fn create_wires_inherited_and_own_inputs() {
let (core, behavior) = create();
assert_eq!(behavior.type_id(), "org.olivevideoeditor.Olive.text3");
assert_eq!(core.get_input(TEXT_INPUT).unwrap().value_type, ValueType::Text);
assert!(
core.get_input(TEXT_INPUT)
.unwrap()
.properties
.iter()
.any(|(k, v)| k == "vieweronly" && v == &NodeValue::Boolean(true))
);
let valign = core.get_input(VERTICAL_ALIGNMENT_INPUT).unwrap();
assert_ne!(valign.flags & crate::input::flags::HIDDEN, 0);
assert_ne!(valign.flags & crate::input::flags::NOT_CONNECTABLE, 0);
assert_ne!(valign.flags & crate::input::flags::NOT_KEYFRAMABLE, 0);
let use_args = core.get_input(USE_ARGS_INPUT).unwrap();
assert_eq!(use_args.default, NodeValue::Boolean(true));
let args = core.get_input(ARGS_INPUT).unwrap();
assert_ne!(args.flags & crate::input::flags::ARRAY, 0);
assert!(args.properties.iter().any(|(k, v)| k == "arraystart" && v == &NodeValue::Int(1)));
// No color input (ShapeNodeBase(false)).
assert!(core.get_input(crate::nodes::shapenodebase::COLOR_INPUT).is_none());
assert_eq!(
core.standard_value(crate::nodes::shapenodebase::SIZE_INPUT, -1),
NodeValue::Vec2([400.0, 300.0])
);
assert_eq!(core.effect_input, crate::nodes::generatorwithmerge::BASE_INPUT);
// v3 is shown in the create menu (no DONT_SHOW_IN_CREATE_MENU flag).
assert_eq!(core.flags & crate::node::flags::DONT_SHOW_IN_CREATE_MENU, 0);
}
#[test]
fn alignment_round_trip() {
for v in [VerticalAlignment::Top, VerticalAlignment::Middle, VerticalAlignment::Bottom] {
let gizmo = TextGeneratorV3::get_gizmo_alignment_from_ours(v);
assert_eq!(TextGeneratorV3::get_our_alignment_from_gizmos(gizmo), v);
}
assert_eq!(TextGeneratorV3::get_gizmo_alignment_from_ours(VerticalAlignment::Top), 0);
assert_eq!(TextGeneratorV3::get_gizmo_alignment_from_ours(VerticalAlignment::Middle), 2);
assert_eq!(TextGeneratorV3::get_gizmo_alignment_from_ours(VerticalAlignment::Bottom), 1);
// Unknown gizmo values map to Top.
assert_eq!(TextGeneratorV3::get_our_alignment_from_gizmos(99), VerticalAlignment::Top);
}
#[test]
fn format_string_expands_args() {
let args = vec!["foo".to_string(), "bar".to_string()];
assert_eq!(TextGeneratorV3::format_string("hello %1", &args), "hello foo");
assert_eq!(TextGeneratorV3::format_string("%2 %1", &args), "bar foo");
// Out of range expands to nothing.
assert_eq!(TextGeneratorV3::format_string("[%3]", &args), "[]");
assert_eq!(TextGeneratorV3::format_string("[%0]", &args), "[]");
}
#[test]
fn format_string_percent_escapes() {
let args = vec!["foo".to_string()];
assert_eq!(TextGeneratorV3::format_string("100%%", &args), "100%");
assert_eq!(TextGeneratorV3::format_string("%%1", &args), "%1");
// Lone % before non-digit/non-% is copied verbatim.
assert_eq!(TextGeneratorV3::format_string("%x %", &args), "%x %");
// Trailing % is copied verbatim.
assert_eq!(TextGeneratorV3::format_string("end%", &args), "end%");
}
#[test]
fn format_string_out_of_int_range_fails_to_zero() {
let args = vec!["foo".to_string()];
assert_eq!(TextGeneratorV3::format_string("%99999999999999999999", &args), "");
assert_eq!(TextGeneratorV3::format_string("%2147483648", &args), "");
assert_eq!(TextGeneratorV3::format_string("%2147483647", &args), "");
}
#[test]
fn format_string_multidigit_and_reuse() {
let args = vec!["a".to_string(), "b".to_string(), "c".to_string()];
// %10 parses as index 10 (out of range with 3 args) -> empty.
assert_eq!(TextGeneratorV3::format_string("%10", &args), "");
assert_eq!(TextGeneratorV3::format_string("%2%2%2", &args), "bbb");
}
#[test]
fn layout_request_uses_olive_html_and_96dpi() {
let mut row = NodeValueRow::default();
row.insert(TEXT_INPUT.to_string(), NodeValue::Text("<p>Hi</p>".to_string()));
row.insert(crate::nodes::shapenodebase::SIZE_INPUT.to_string(), NodeValue::Vec2([400.0, 300.0]));
let req = TextGeneratorV3::layout_request(&row);
assert_eq!(req.text, "<p>Hi</p>");
assert_eq!(req.mode, TextLayoutMode::OliveHtml);
assert_eq!(req.dots_per_meter, 3780);
assert_eq!(req.wrap_width, 400.0);
}
#[test]
fn base_and_draw_offsets() {
let size = [400.0, 300.0];
let base = TextGeneratorV3::base_offset([0.0, 0.0], size, 1920, 1080);
assert_eq!(base, (760.0, 390.0));
// Top: no delta; middle/bottom use double math on doc.height.
assert_eq!(
TextGeneratorV3::draw_offset(VerticalAlignment::Top, base, size, 100.0),
base
);
assert_eq!(
TextGeneratorV3::draw_offset(VerticalAlignment::Middle, base, size, 100.0),
(base.0, base.1 + 150.0 - 50.0)
);
assert_eq!(
TextGeneratorV3::draw_offset(VerticalAlignment::Bottom, base, size, 100.0),
(base.0, base.1 + 300.0 - 100.0)
);
}
#[test]
fn measure_without_backend_returns_zero_size() {
crate::nodes::textbackend::set_text_backends(None, None);
let mut row = NodeValueRow::default();
row.insert(TEXT_INPUT.to_string(), NodeValue::Text("<p>Hi</p>".to_string()));
let (_req, doc) = TextGeneratorV3::measure_and_layout(&row);
assert_eq!(doc.width, 0.0);
assert_eq!(doc.height, 0.0);
}
#[test]
fn value_pushes_job_when_text_nonempty() {
let (core, behavior) = create();
let mut row = NodeValueRow::default();
row.insert(TEXT_INPUT.to_string(), NodeValue::Text("<p>Hi</p>".to_string()));
row.insert(USE_ARGS_INPUT.to_string(), NodeValue::Boolean(false));
let mut table = NodeValueTable::default();
behavior.value(&core, &row, Rational::new(0, 1), &mut table);
assert!(matches!(
table.get(ValueType::Texture),
Some(NodeValue::Texture(h)) if h.is_null()
));
}
#[test]
fn value_expands_args_from_row() {
let (core, behavior) = create();
let mut row = NodeValueRow::default();
row.insert(TEXT_INPUT.to_string(), NodeValue::Text("Hello %1".to_string()));
row.insert(USE_ARGS_INPUT.to_string(), NodeValue::Boolean(true));
row.insert(ARGS_INPUT.to_string(), NodeValue::Text("World".to_string()));
let mut table = NodeValueTable::default();
behavior.value(&core, &row, Rational::new(0, 1), &mut table);
assert!(matches!(
table.get(ValueType::Texture),
Some(NodeValue::Texture(h)) if h.is_null()
));
// The expanded text is carried by the (deferred) job, which has no
// payload here; the expansion math itself is covered by
// format_string tests.
}
#[test]
fn value_passes_base_through_when_text_empty() {
let (core, behavior) = create();
let mut row = NodeValueRow::default();
row.insert(TEXT_INPUT.to_string(), NodeValue::Text(String::new()));
row.insert(
crate::nodes::generatorwithmerge::BASE_INPUT.to_string(),
NodeValue::Texture(crate::handle::CHandle::null()),
);
let mut table = NodeValueTable::default();
behavior.value(&core, &row, Rational::new(0, 1), &mut table);
assert!(matches!(
table.get(ValueType::Texture),
Some(NodeValue::Texture(_))
));
}
#[test]
fn value_pushes_nothing_when_text_empty_and_no_base() {
let (core, behavior) = create();
let mut row = NodeValueRow::default();
row.insert(TEXT_INPUT.to_string(), NodeValue::Text(String::new()));
let mut table = NodeValueTable::default();
behavior.value(&core, &row, Rational::new(0, 1), &mut table);
assert!(table.is_empty());
}
#[test]
fn gizmo_activation_toggles_use_args() {
let (mut core, mut behavior) = create();
let node = behavior
.as_any_mut()
.unwrap()
.downcast_mut::<TextGeneratorV3>()
.unwrap();
node.gizmo_activated(&mut core);
assert_eq!(
core.standard_value(USE_ARGS_INPUT, -1),
NodeValue::Boolean(false)
);
assert!(node.dont_emit_valign);
node.gizmo_deactivated(&mut core);
assert_eq!(
core.standard_value(USE_ARGS_INPUT, -1),
NodeValue::Boolean(true)
);
assert!(node.dont_emit_valign);
}
#[test]
fn set_vertical_alignment_undoable_maps_through_gizmo_alignment() {
let (mut core, mut behavior) = create();
let node = behavior
.as_any_mut()
.unwrap()
.downcast_mut::<TextGeneratorV3>()
.unwrap();
// Gizmo vcenter (2) maps back to Middle (1).
node.set_vertical_alignment_undoable(&mut core, 2);
assert_eq!(
core.standard_value(VERTICAL_ALIGNMENT_INPUT, -1),
NodeValue::Combo(1)
);
}
#[test]
fn generate_frame_is_documented_noop() {
let (core, behavior) = create();
let mut frame = crate::handle::CHandle::null();
behavior.generate_frame(&core, &mut frame, Rational::new(0, 1));
assert!(frame.is_null());
}
#[test]
fn duplicate_copies_node() {
let (_core, behavior) = create();
let copy = behavior.duplicate(&_core).unwrap();
assert_eq!(copy.type_id(), "org.olivevideoeditor.Olive.text3");
assert_eq!(copy.name(), "Text");
}
}
+343
View File
@@ -0,0 +1,343 @@
// 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/>.
//! Three-way color corrector node (C++
//! `src/node/src/color/threewaycolor/threewaycolor.{h,cpp}`,
//! `olive::ThreeWayColorNode`).
use crate::factory::NodeMeta;
use crate::node::{Category, NodeBehavior, NodeCore};
/// Texture input id (C++ `k_texture_input`). Type: texture; flags:
/// not-keyframable; this is the node's effect input.
pub const TEXTURE_INPUT: &str = "tex_in";
/// Shadows color input id (C++ `k_shadows_color_input`). Type: color;
/// default neutral gray `{0.5, 0.5, 0.5, 1.0}`.
pub const SHADOWS_COLOR_INPUT: &str = "shadows_color_in";
/// Midtones color input id (C++ `k_midtones_color_input`). Type: color;
/// default neutral gray `{0.5, 0.5, 0.5, 1.0}`.
pub const MIDTONES_COLOR_INPUT: &str = "midtones_color_in";
/// Highlights color input id (C++ `k_highlights_color_input`). Type:
/// color; default neutral gray `{0.5, 0.5, 0.5, 1.0}`.
pub const HIGHLIGHTS_COLOR_INPUT: &str = "highlights_color_in";
/// Shadows amount input id (C++ `k_shadows_amount_input`). Type: float;
/// default `1.0`; properties: `min = 0.0`, `view = percentage`.
pub const SHADOWS_AMOUNT_INPUT: &str = "shadows_amount_in";
/// Midtones amount input id (C++ `k_midtones_amount_input`). Type:
/// float; default `1.0`; properties: `min = 0.0`, `view = percentage`.
pub const MIDTONES_AMOUNT_INPUT: &str = "midtones_amount_in";
/// Highlights amount input id (C++ `k_highlights_amount_input`). Type:
/// float; default `1.0`; properties: `min = 0.0`, `view = percentage`.
pub const HIGHLIGHTS_AMOUNT_INPUT: &str = "highlights_amount_in";
/// Luma coefficients input id (C++ `k_luma_coefficients_input`). Not a
/// declared node input — the C++ never calls `add_input` for it; it is
/// the shader uniform name fed per frame in `value()` from the project
/// color manager's default luma coefficients (Rec. 709
/// `{0.2126, 0.7152, 0.0722}` fallback). Type: vec3.
pub const LUMA_COEFFICIENTS_INPUT: &str = "luma_coefficients_in";
/// Three-way color corrector node. Adjusts shadows, midtones, and
/// highlights separately. The C++ class has no own private members, so
/// this is a unit-like struct (caches/inputs live in `NodeCore`).
pub struct ThreeWayColorNode;
/// Fragment shader (C++ `get_shader_code` loads the
/// `:/shaders/threewaycolor.frag` resource). Text copied verbatim from
/// `engine/shaders/threewaycolor.frag`.
const SHADER_FRAG: &str = r#"uniform sampler2D tex_in;
uniform vec4 shadows_color_in;
uniform vec4 midtones_color_in;
uniform vec4 highlights_color_in;
uniform float shadows_amount_in;
uniform float midtones_amount_in;
uniform float highlights_amount_in;
uniform vec3 luma_coefficients_in;
in vec2 ove_texcoord;
out vec4 frag_color;
vec3 color_offset(vec4 control, float amount)
{
return (control.rgb - vec3(0.5)) * 2.0 * amount;
}
void main(void)
{
vec4 source = texture(tex_in, ove_texcoord);
float luma = clamp(dot(source.rgb, luma_coefficients_in), 0.0, 1.0);
float shadow_weight = smoothstep(0.75, 0.0, luma);
float highlight_weight = smoothstep(0.25, 1.0, luma);
float midtone_weight = clamp(1.0 - abs(luma - 0.5) * 2.0, 0.0, 1.0);
vec3 adjustment =
color_offset(shadows_color_in, shadows_amount_in) * shadow_weight +
color_offset(midtones_color_in, midtones_amount_in) * midtone_weight +
color_offset(highlights_color_in, highlights_amount_in) * highlight_weight;
vec3 graded = source.rgb + adjustment * source.rgb * (1.0 - source.rgb);
frag_color = vec4(clamp(graded, 0.0, 1.0), source.a);
}
"#;
impl ThreeWayColorNode {
/// Fragment shader for any request (C++ `get_shader_code()` ignores
/// the request id and always returns this shader).
fn shader_frag() -> &'static str {
SHADER_FRAG
}
}
impl NodeBehavior for ThreeWayColorNode {
/// Human-readable name (C++ `name()`, inline in the header).
fn name(&self) -> &str {
"Three-Way Color"
}
/// Stable type id (C++ `id()`, inline in the header).
fn type_id(&self) -> &str {
"org.olivevideoeditor.Olive.threewaycolor"
}
/// Categories (C++ `category()`, inline in the header).
fn categories(&self) -> &[Category] {
&[Category::Color]
}
/// Description (C++ `description()`, inline in the header).
fn description(&self) -> &str {
"Adjusts shadows, midtones, and highlights separately."
}
/// Localized input names (C++ `retranslate()`): `tex_in` -> "Input",
/// `shadows_color_in` -> "Shadows", `midtones_color_in` ->
/// "Midtones", `highlights_color_in` -> "Highlights",
/// `shadows_amount_in` -> "Shadows Amount", `midtones_amount_in` ->
/// "Midtones Amount", `highlights_amount_in` -> "Highlights Amount".
fn input_name<'a>(&self, id: &'a str) -> &'a str {
match id {
TEXTURE_INPUT => "Input",
SHADOWS_COLOR_INPUT => "Shadows",
MIDTONES_COLOR_INPUT => "Midtones",
HIGHLIGHTS_COLOR_INPUT => "Highlights",
SHADOWS_AMOUNT_INPUT => "Shadows Amount",
MIDTONES_AMOUNT_INPUT => "Midtones Amount",
HIGHLIGHTS_AMOUNT_INPUT => "Highlights Amount",
_ => id,
}
}
/// Shader code request (C++ `get_shader_code()`): the request id is
/// ignored; always returns [`SHADER_FRAG`].
fn shader_code(&self, _request: &str) -> Option<String> {
Some(SHADER_FRAG.to_string())
}
/// Evaluate outputs (C++ `value()`): no texture -> push nothing;
/// otherwise builds a `ShaderJob` from the whole input row, inserts
/// `luma_coefficients_in` as a vec3 from the project color manager's
/// default luma coefficients (Rec. 709 `{0.2126, 0.7152, 0.0722}`
/// when no project/manager is attached), and pushes the texture as
/// that job.
fn value(
&self,
core: &NodeCore,
inputs: &crate::value::NodeValueRow,
time: oakcore_rs::Rational,
table: &mut crate::value::NodeValueTable,
) {
let _ = (core, time);
match inputs.get(TEXTURE_INPUT) {
Some(crate::value::NodeValue::Texture(_)) => {}
_ => return,
}
// `// CPP-PARITY: threewaycolor.cpp` `value()` — the C++ inserts
// `luma_coefficients_in` (Rec. 709 {0.2126, 0.7152, 0.0722}, or the
// project color manager's default luma coefficients when one is
// attached — the Rust model has no project/manager access, so the
// fallback always applies) into a ShaderJob over the whole input
// row and pushes `tex->to_job(job)`. The Rust model has no
// shader-job payload: the renderer seam resolves the deferred job
// from this null handle.
table.push(
crate::value::ValueType::Texture,
crate::value::NodeValue::Texture(crate::handle::CHandle::null()),
None,
);
}
/// Deep copy (C++ `copy()` via `NODE_DEFAULT_FUNCTIONS`).
fn duplicate(&self, _core: &NodeCore) -> Option<Box<dyn NodeBehavior>> {
Some(Box::new(ThreeWayColorNode))
}
}
/// Constructor (C++ `ThreeWayColorNode::ThreeWayColorNode()`): adds
/// `tex_in` (texture, effect input), the three color inputs with the
/// neutral-gray default, the three amount inputs with the percentage
/// view and `min = 0.0`, and sets the video-effect flag.
pub fn create() -> (NodeCore, Box<dyn NodeBehavior>) {
let mut core = NodeCore::new();
let mut tex = crate::input::Input::new(
TEXTURE_INPUT,
crate::value::ValueType::Texture,
crate::value::NodeValue::None,
);
tex.flags |= crate::input::flags::NOT_KEYFRAMABLE;
core.add_input(tex);
let neutral = crate::value::NodeValue::Color([0.5, 0.5, 0.5, 1.0]);
core.add_input(crate::input::Input::new(
SHADOWS_COLOR_INPUT,
crate::value::ValueType::Color,
neutral.clone(),
));
core.add_input(crate::input::Input::new(
MIDTONES_COLOR_INPUT,
crate::value::ValueType::Color,
neutral.clone(),
));
core.add_input(crate::input::Input::new(
HIGHLIGHTS_COLOR_INPUT,
crate::value::ValueType::Color,
neutral,
));
let amount_props = vec![
("min".to_string(), crate::value::NodeValue::Float(0.0)),
("view".to_string(), crate::value::NodeValue::Text("percentage".into())),
];
let mut shadows_amount = crate::input::Input::new(
SHADOWS_AMOUNT_INPUT,
crate::value::ValueType::Float,
crate::value::NodeValue::Float(1.0),
);
shadows_amount.properties = amount_props.clone();
core.add_input(shadows_amount);
let mut midtones_amount = crate::input::Input::new(
MIDTONES_AMOUNT_INPUT,
crate::value::ValueType::Float,
crate::value::NodeValue::Float(1.0),
);
midtones_amount.properties = amount_props.clone();
core.add_input(midtones_amount);
let mut highlights_amount = crate::input::Input::new(
HIGHLIGHTS_AMOUNT_INPUT,
crate::value::ValueType::Float,
crate::value::NodeValue::Float(1.0),
);
highlights_amount.properties = amount_props;
core.add_input(highlights_amount);
core.effect_input = TEXTURE_INPUT.to_string();
core.flags |= crate::node::flags::VIDEO_EFFECT;
(core, Box::new(ThreeWayColorNode))
}
/// Register this node type (C++ factory entry for
/// `org.olivevideoeditor.Olive.threewaycolor`).
pub fn register(meta: &mut Vec<NodeMeta>) {
meta.push(NodeMeta {
type_id: "org.olivevideoeditor.Olive.threewaycolor",
name: "Three-Way Color",
categories: &[Category::Color],
create,
});
}
#[cfg(test)]
mod tests {
use super::*;
use crate::value::{NodeValue, NodeValueTable, ValueType};
use oakcore_rs::Rational;
#[test]
fn input_names() {
let n = ThreeWayColorNode;
assert_eq!(n.input_name(TEXTURE_INPUT), "Input");
assert_eq!(n.input_name(SHADOWS_COLOR_INPUT), "Shadows");
assert_eq!(n.input_name(MIDTONES_COLOR_INPUT), "Midtones");
assert_eq!(n.input_name(HIGHLIGHTS_COLOR_INPUT), "Highlights");
assert_eq!(n.input_name(SHADOWS_AMOUNT_INPUT), "Shadows Amount");
assert_eq!(n.input_name(MIDTONES_AMOUNT_INPUT), "Midtones Amount");
assert_eq!(n.input_name(HIGHLIGHTS_AMOUNT_INPUT), "Highlights Amount");
assert_eq!(n.input_name("other_in"), "other_in");
}
#[test]
fn create_wires_inputs_flags_and_properties() {
let (core, behavior) = create();
assert_eq!(behavior.type_id(), "org.olivevideoeditor.Olive.threewaycolor");
let tex = core.get_input(TEXTURE_INPUT).unwrap();
assert_ne!(tex.flags & crate::input::flags::NOT_KEYFRAMABLE, 0);
let neutral = NodeValue::Color([0.5, 0.5, 0.5, 1.0]);
for id in [SHADOWS_COLOR_INPUT, MIDTONES_COLOR_INPUT, HIGHLIGHTS_COLOR_INPUT] {
assert_eq!(core.get_input(id).unwrap().default, neutral);
}
for id in [SHADOWS_AMOUNT_INPUT, MIDTONES_AMOUNT_INPUT, HIGHLIGHTS_AMOUNT_INPUT] {
let input = core.get_input(id).unwrap();
assert_eq!(input.default, NodeValue::Float(1.0));
assert!(input.properties.iter().any(|(k, v)| k == "min" && *v == NodeValue::Float(0.0)));
assert!(input.properties.iter().any(|(k, v)| k == "view" && *v == NodeValue::Text("percentage".into())));
}
assert_eq!(core.effect_input, TEXTURE_INPUT);
assert_ne!(core.flags & crate::node::flags::VIDEO_EFFECT, 0);
}
#[test]
fn shader_code_returns_threewaycolor_frag() {
let code = ThreeWayColorNode.shader_code("anything").unwrap();
assert!(code.contains("color_offset(shadows_color_in, shadows_amount_in)"));
}
#[test]
fn value_no_texture_pushes_nothing() {
let (core, behavior) = create();
let mut table = NodeValueTable::default();
behavior.value(&core, &crate::value::NodeValueRow::default(), Rational::new(0, 1), &mut table);
assert!(table.is_empty());
}
#[test]
fn value_with_texture_pushes_deferred_shader_job() {
let (core, behavior) = create();
let inputs = crate::value::NodeValueRow::from([(
TEXTURE_INPUT.to_string(),
NodeValue::Texture(crate::handle::CHandle::null()),
)]);
let mut table = NodeValueTable::default();
behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
assert!(table.get(ValueType::Texture).is_some());
}
#[test]
fn duplicate_clones() {
let (core, behavior) = create();
let dup = behavior.duplicate(&core).unwrap();
assert_eq!(dup.name(), "Three-Way Color");
}
}
+445
View File
@@ -0,0 +1,445 @@
// 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/>.
//! Tile distort effect (C++
//! `src/node/src/distort/tile/tiledistortnode.{h,cpp}`,
//! `olive::TileDistortNode`).
use crate::factory::NodeMeta;
use crate::node::{Category, Gizmo, NodeBehavior, NodeCore};
/// Texture input id (C++ `k_texture_input`). Type: texture; flags:
/// not-keyframable; this is the node's effect input.
pub const TEXTURE_INPUT: &str = "tex_in";
/// Scale input id (C++ `k_scale_input`). Type: float; default `0.5`;
/// properties: `min = 0`, `view = percentage`.
pub const SCALE_INPUT: &str = "scale_in";
/// Position input id (C++ `k_position_input`). Type: vec2; default
/// `(0, 0)`.
pub const POSITION_INPUT: &str = "position_in";
/// Anchor combo input id (C++ `k_anchor_input`). Type: combo; default
/// `4` (C++ `k_middle_center`); combo strings: "Top-Left",
/// "Top-Center", "Top-Right", "Middle-Left", "Middle-Center",
/// "Middle-Right", "Bottom-Left", "Bottom-Center", "Bottom-Right".
pub const ANCHOR_INPUT: &str = "anchor_in";
/// Horizontal mirror input id (C++ `k_mirror_x_input`). Type: bool;
/// default `false`.
pub const MIRROR_X_INPUT: &str = "mirrorx_in";
/// Vertical mirror input id (C++ `k_mirror_y_input`). Type: bool;
/// default `false`.
pub const MIRROR_Y_INPUT: &str = "mirrory_in";
/// Anchor point for tiling (C++ private enum `Anchor`); values match
/// the `anchor_in` combo indices and the shader's `anchor_in` defines.
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum Anchor {
/// Top-left corner.
TopLeft = 0,
/// Top edge center.
TopCenter = 1,
/// Top-right corner.
TopRight = 2,
/// Left edge center.
MiddleLeft = 3,
/// Image center.
MiddleCenter = 4,
/// Right edge center.
MiddleRight = 5,
/// Bottom-left corner.
BottomLeft = 6,
/// Bottom edge center.
BottomCenter = 7,
/// Bottom-right corner.
BottomRight = 8,
}
/// Tile distort node. Repeats the image infinitely in both directions,
/// optionally mirroring alternate tiles.
pub struct TileDistortNode {
/// Position drag handle (C++ `PointGizmo *gizmo_`; anchor point
/// shape, drags both tracks of `position_in`).
gizmo: Gizmo,
}
/// Fragment shader (C++ loads the `:/shaders/tile.frag` resource in
/// `get_shader_code`). Text copied verbatim from
/// `engine/shaders/tile.frag`.
const SHADER_FRAG: &str = r#"uniform float scale_in;
uniform vec2 position_in;
uniform vec2 resolution_in;
uniform bool mirrorx_in;
uniform bool mirrory_in;
uniform int anchor_in;
uniform sampler2D tex_in;
in vec2 ove_texcoord;
out vec4 frag_color;
#define TOP_LEFT 0
#define TOP_CENTER 1
#define TOP_RIGHT 2
#define MIDDLE_LEFT 3
#define MIDDLE_CENTER 4
#define MIDDLE_RIGHT 5
#define BOTTOM_LEFT 6
#define BOTTOM_CENTER 7
#define BOTTOM_RIGHT 8
void main(void) {
vec2 coord = ove_texcoord;
vec2 offset;
if (anchor_in == TOP_LEFT || anchor_in == TOP_CENTER || anchor_in == TOP_RIGHT) {
offset.y = 0.0;
} else if (anchor_in == MIDDLE_LEFT || anchor_in == MIDDLE_CENTER || anchor_in == MIDDLE_RIGHT) {
offset.y = 0.5;
} else if (anchor_in == BOTTOM_LEFT || anchor_in == BOTTOM_CENTER || anchor_in == BOTTOM_RIGHT) {
offset.y = 1.0;
}
if (anchor_in == TOP_LEFT || anchor_in == MIDDLE_LEFT || anchor_in == BOTTOM_LEFT) {
offset.x = 0.0;
} else if (anchor_in == TOP_CENTER || anchor_in == MIDDLE_CENTER || anchor_in == BOTTOM_CENTER) {
offset.x = 0.5;
} else if (anchor_in == TOP_RIGHT || anchor_in == MIDDLE_RIGHT || anchor_in == BOTTOM_RIGHT) {
offset.x = 1.0;
}
coord -= position_in/resolution_in;
coord -= offset;
coord /= scale_in;
coord += offset;
vec2 modcoord = mod(coord, 1.0);
if (mirrorx_in && mod(coord.x, 2.0) > 1.0) {
modcoord.x = 1.0 - modcoord.x;
}
if (mirrory_in && mod(coord.y, 2.0) > 1.0) {
modcoord.y = 1.0 - modcoord.y;
}
frag_color = vec4(texture(tex_in, modcoord));
}
"#;
impl TileDistortNode {
/// Fragment shader (C++ `get_shader_code()`; the request id is
/// ignored, this is the only shader).
fn shader_frag() -> &'static str {
SHADER_FRAG
}
}
impl NodeBehavior for TileDistortNode {
/// Human-readable name (C++ `name()`).
fn name(&self) -> &str {
"Tile"
}
/// Stable type id (C++ `id()`).
fn type_id(&self) -> &str {
"org.olivevideoeditor.Olive.tile"
}
/// Categories (C++ `category()`).
fn categories(&self) -> &[Category] {
&[Category::Distort]
}
/// Description (C++ `description()`).
fn description(&self) -> &str {
"Infinitely tile an image horizontally and vertically."
}
/// Localized input names (C++ `retranslate()`): `tex_in` ->
/// "Input", `scale_in` -> "Scale", `position_in` -> "Position",
/// `mirrorx_in` -> "Mirror Horizontally", `mirrory_in` -> "Mirror
/// Vertically", `anchor_in` -> "Anchor" (with the nine combo strings
/// documented on [`ANCHOR_INPUT`]).
fn input_name<'a>(&self, id: &'a str) -> &'a str {
match id {
TEXTURE_INPUT => "Input",
SCALE_INPUT => "Scale",
POSITION_INPUT => "Position",
MIRROR_X_INPUT => "Mirror Horizontally",
MIRROR_Y_INPUT => "Mirror Vertically",
// The `anchor_in` combo strings ("Top-Left" ... "Bottom-Right")
// are a UI-level property of the input (C++
// `set_combo_box_strings`).
ANCHOR_INPUT => "Anchor",
_ => id,
}
}
/// Evaluate outputs (C++ `value()`): no texture -> push nothing;
/// scale differs from 1.0 (an approximate-equality epsilon test:
/// `abs(scale-1)*1e12 > min(abs(scale), 1)`) -> shader job over the
/// whole value row with `resolution_in` inserted from the texture's
/// virtual resolution; scale ~== 1.0 -> pass-through push of the
/// input texture unchanged.
///
/// The Rust model has no shader-job payload: the job (including the
/// `resolution_in` value) is deferred to the renderer seam
/// (`// CPP-PARITY: tiledistortnode.cpp` value()).
fn value(
&self,
core: &NodeCore,
inputs: &crate::value::NodeValueRow,
time: oakcore_rs::Rational,
table: &mut crate::value::NodeValueTable,
) {
let tex = match inputs.get(TEXTURE_INPUT) {
Some(tex @ crate::value::NodeValue::Texture(_)) => tex.clone(),
_ => return,
};
let scale_value = match inputs.get(SCALE_INPUT) {
Some(v) => v.to_double(),
None => core.value_at_time(SCALE_INPUT, -1, time).to_double(),
};
// `!qFuzzyCompare(scale, 1.0)` (double overload) — job when the
// scale is not approximately 1.0.
if (scale_value - 1.0).abs() * 1e12 > scale_value.abs().min(1.0) {
table.push(
crate::value::ValueType::Texture,
crate::value::NodeValue::Texture(crate::handle::CHandle::null()),
None,
);
} else {
table.push(crate::value::ValueType::Texture, tex, None);
}
}
/// Shader code request (C++ `get_shader_code()`): ignores the
/// request id and always returns the tile fragment shader.
fn shader_code(&self, _request: &str) -> Option<String> {
Some(Self::shader_frag().to_string())
}
/// Gizmo positions (C++ `update_gizmo_positions()`): with a texture,
/// places the position gizmo at `position_in` offset from the anchor
/// point selected by `anchor_in` (rows add 0 / half / full height,
/// columns add 0 / half / full width of the texture resolution).
///
/// The placement needs the texture's virtual resolution (the Rust
/// texture handle carries no params) and the resulting point has no
/// storage in [`Gizmo`] — not representable here
/// (`// CPP-PARITY: tiledistortnode.cpp` `update_gizmo_positions`).
fn gizmo_update(&self, core: &NodeCore, row: &crate::value::NodeValueRow) {
let _ = (core, row);
}
/// Gizmo drag (C++ `gizmo_drag_move()`): drags the position input's
/// X and Y track draggers by the mouse delta added to their
/// drag-start values.
///
/// The draggers hold per-drag start values and write keyframe tracks,
/// neither of which the Rust data model carries — not representable
/// here (`// CPP-PARITY: tiledistortnode.cpp` `gizmo_drag_move`).
fn gizmo_drag(&mut self, core: &mut NodeCore, start: bool, x: f64, y: f64, modifiers: u32) {
let _ = (core, start, x, y, modifiers);
}
/// Deep copy (C++ `copy()`).
fn duplicate(&self, _core: &NodeCore) -> Option<Box<dyn NodeBehavior>> {
Some(Box::new(TileDistortNode {
gizmo: self.gizmo.clone(),
}))
}
}
/// Constructor (C++ `TileDistortNode::TileDistortNode()`): adds
/// `tex_in`, `scale_in`, `position_in`, `anchor_in`, `mirrorx_in` and
/// `mirrory_in` with the defaults and properties documented on the
/// constants; creates the anchor-shaped position point gizmo bound to
/// both tracks of `position_in`; sets the video-effect flag and the
/// effect input.
pub fn create() -> (NodeCore, Box<dyn NodeBehavior>) {
let mut core = NodeCore::new();
let mut tex = crate::input::Input::new(
TEXTURE_INPUT,
crate::value::ValueType::Texture,
crate::value::NodeValue::None,
);
tex.flags |= crate::input::flags::NOT_KEYFRAMABLE;
core.add_input(tex);
let mut scale = crate::input::Input::new(
SCALE_INPUT,
crate::value::ValueType::Float,
crate::value::NodeValue::Float(0.5),
);
scale.properties = vec![
("min".to_string(), crate::value::NodeValue::Float(0.0)),
("view".to_string(), crate::value::NodeValue::Text("percentage".into())),
];
core.add_input(scale);
core.add_input(crate::input::Input::new(
POSITION_INPUT,
crate::value::ValueType::Vec2,
crate::value::NodeValue::Vec2([0.0, 0.0]),
));
core.add_input(crate::input::Input::new(
ANCHOR_INPUT,
crate::value::ValueType::Combo,
crate::value::NodeValue::Combo(Anchor::MiddleCenter as i64),
));
core.add_input(crate::input::Input::new(
MIRROR_X_INPUT,
crate::value::ValueType::Boolean,
crate::value::NodeValue::Boolean(false),
));
core.add_input(crate::input::Input::new(
MIRROR_Y_INPUT,
crate::value::ValueType::Boolean,
crate::value::NodeValue::Boolean(false),
));
// Anchor-shaped position point gizmo (C++ `PointGizmo` with
// `k_anchor_point` shape) dragging both tracks of `position_in`.
let gizmo = Gizmo {
position_inputs: vec![
(POSITION_INPUT.to_string(), -1, 0),
(POSITION_INPUT.to_string(), -1, 1),
],
drag_point: (0.0, 0.0),
};
core.gizmos = vec![gizmo.clone()];
core.flags |= crate::node::flags::VIDEO_EFFECT;
core.effect_input = TEXTURE_INPUT.to_string();
(core, Box::new(TileDistortNode { gizmo }))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::node::NodeBehavior;
use crate::value::{NodeValue, NodeValueTable, ValueType};
use oakcore_rs::Rational;
fn tex() -> NodeValue {
NodeValue::Texture(crate::handle::CHandle::null())
}
#[test]
fn input_names() {
let n = TileDistortNode {
gizmo: Gizmo {
position_inputs: vec![],
drag_point: (0.0, 0.0),
},
};
assert_eq!(n.input_name(TEXTURE_INPUT), "Input");
assert_eq!(n.input_name(SCALE_INPUT), "Scale");
assert_eq!(n.input_name(POSITION_INPUT), "Position");
assert_eq!(n.input_name(MIRROR_X_INPUT), "Mirror Horizontally");
assert_eq!(n.input_name(MIRROR_Y_INPUT), "Mirror Vertically");
assert_eq!(n.input_name(ANCHOR_INPUT), "Anchor");
}
#[test]
fn create_wires_inputs_and_flags() {
let (core, behavior) = create();
assert_eq!(behavior.type_id(), "org.olivevideoeditor.Olive.tile");
assert_eq!(
core.get_input(SCALE_INPUT).unwrap().default,
NodeValue::Float(0.5)
);
assert_eq!(
core.get_input(ANCHOR_INPUT).unwrap().default,
NodeValue::Combo(4)
);
assert_eq!(core.gizmos.len(), 1);
assert_eq!(core.gizmos[0].position_inputs.len(), 2);
assert_eq!(core.effect_input, TEXTURE_INPUT);
assert_ne!(core.flags & crate::node::flags::VIDEO_EFFECT, 0);
}
#[test]
fn value_no_texture_pushes_nothing() {
let (core, behavior) = create();
let mut table = NodeValueTable::default();
behavior.value(
&core,
&crate::value::NodeValueRow::default(),
Rational::new(0, 1),
&mut table,
);
assert!(table.is_empty());
}
#[test]
fn value_unit_scale_passes_texture_through() {
let (mut core, behavior) = create();
core.set_standard_value(SCALE_INPUT, -1, NodeValue::Float(1.0));
let tex = tex();
let inputs = crate::value::NodeValueRow::from([(TEXTURE_INPUT.to_string(), tex.clone())]);
let mut table = NodeValueTable::default();
behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
assert_eq!(table.get(ValueType::Texture), Some(&tex));
}
#[test]
fn value_non_unit_scale_pushes_deferred_job() {
let (mut core, behavior) = create();
core.set_standard_value(SCALE_INPUT, -1, NodeValue::Float(0.5));
let inputs = crate::value::NodeValueRow::from([(TEXTURE_INPUT.to_string(), tex())]);
let mut table = NodeValueTable::default();
behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
assert!(table.get(ValueType::Texture).is_some());
}
#[test]
fn shader_code_returns_tile_shader() {
let (_, behavior) = create();
let code = behavior.shader_code("anything").unwrap();
assert!(code.contains("uniform float scale_in;"));
assert!(code.contains("vec2 modcoord = mod(coord, 1.0);"));
}
#[test]
fn duplicate_clones() {
let (core, behavior) = create();
let dup = behavior.duplicate(&core).unwrap();
assert_eq!(dup.name(), "Tile");
}
}
/// Register this node type (C++ factory entry for
/// `org.olivevideoeditor.Olive.tile`).
pub fn register(meta: &mut Vec<NodeMeta>) {
meta.push(NodeMeta {
type_id: "org.olivevideoeditor.Olive.tile",
name: "Tile",
categories: &[Category::Distort],
create,
});
}
+501
View File
@@ -0,0 +1,501 @@
// 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/>.
//! Time format node (C++ `src/node/src/time/timeformat/timeformat.{h,cpp}`,
//! `olive::TimeFormatNode`).
use std::ffi::{c_char, c_int, c_long};
use crate::factory::NodeMeta;
use crate::node::{Category, NodeBehavior, NodeCore};
use crate::value::{NodeValue, NodeValueRow, NodeValueTable};
use oakcore_rs::Rational;
/// Time input id (C++ `k_time_input`). Type: float; no explicit default
/// (seconds since the Unix epoch).
pub const TIME_INPUT: &str = "time_in";
/// Format input id (C++ `k_format_input`). Type: text; default
/// `"hh:mm:ss"`; uses Qt `QDateTime::toString` token syntax (d/dd, M/MM,
/// yy/yyyy, h/hh, H/HH, m/mm, s/ss, z/zz/zzz, AP/ap/A/a, single-quoted
/// literals).
pub const FORMAT_INPUT: &str = "format_in";
/// Local-time toggle input id (C++ `k_local_time_input`). Type: boolean;
/// no explicit default (false): when true the epoch time is interpreted in
/// the local timezone (C++ `localtime_r`), otherwise UTC (C++ `gmtime_r`).
pub const LOCAL_TIME_INPUT: &str = "localtime_in";
/// Time format node. Formats a Unix-epoch-seconds time into a text string.
/// The C++ class has no own data members (the `.cpp`'s anonymous-namespace
/// `format_date_time()` token expander becomes part of `value()`), so this
/// is a unit-like struct.
pub struct TimeFormatNode;
/// `struct tm` mirror with the macOS/glibc layout (9 ints, then
/// `long tm_gmtoff`, then `const char *tm_zone`) — enough for the
/// `localtime_r`/`gmtime_r` FFI below. Only the calendar fields are read
/// back.
#[repr(C)]
#[derive(Clone, Copy)]
struct Tm {
tm_sec: c_int,
tm_min: c_int,
tm_hour: c_int,
tm_mday: c_int,
tm_mon: c_int,
tm_year: c_int,
tm_wday: c_int,
tm_yday: c_int,
tm_isdst: c_int,
tm_gmtoff: c_long,
tm_zone: *const c_char,
}
// `localtime_r` / `gmtime_r` (C `time.h`). Declared locally instead of
// pulling in a libc crate; both symbols live in the platform C library
// that `std` already links.
extern "C" {
fn localtime_r(timep: *const c_long, result: *mut Tm) -> *mut Tm;
fn gmtime_r(timep: *const c_long, result: *mut Tm) -> *mut Tm;
}
/// Expand Qt date/time format tokens (`QDateTime::toString` syntax):
/// the field tokens d/dd, M/MM, yy/yyyy, h/hh, H/HH, m/mm, s/ss, z/zz/zzz,
/// AP/ap/A/a, and single-quoted literal sections, mirroring Qt's
/// longest-run matching (C++ anonymous-namespace `format_date_time()` in
/// `timeformat.cpp`).
fn format_date_time(tm: &Tm, ms: i32, format: &str) -> String {
// Qt displays h/hh on the 12-hour clock only when the format contains an
// AM/PM token (AP, ap, A or a); otherwise it is the 24-hour clock.
let has_am_pm = format.contains("AP")
|| format.contains("ap")
|| format.contains('A')
|| format.contains('a');
let bytes = format.as_bytes();
let mut out = String::new();
let mut i = 0;
while i < bytes.len() {
let c = bytes[i] as char;
if c == '\'' {
// Single-quoted literal section: copy through to the closing
// quote (or to the end of the format when unterminated).
match format[i + 1..].find('\'') {
Some(rel) => {
out.push_str(&format[i + 1..i + 1 + rel]);
i += 1 + rel + 1;
}
None => {
out.push_str(&format[i + 1..]);
break;
}
}
continue;
}
let mut run = 1;
while i + run < bytes.len() && bytes[i + run] == bytes[i] {
run += 1;
}
match c {
'd' => {
let s = if run >= 2 {
format!("{:02}", tm.tm_mday)
} else {
format!("{}", tm.tm_mday)
};
out.push_str(&s);
}
'M' => {
let mon = tm.tm_mon + 1;
let s = if run >= 2 { format!("{:02}", mon) } else { format!("{}", mon) };
out.push_str(&s);
}
'y' => {
let year = tm.tm_year + 1900;
if run >= 4 {
out.push_str(&format!("{:04}", year));
} else {
out.push_str(&format!("{:02}", year % 100));
}
}
'H' => {
let s = if run >= 2 {
format!("{:02}", tm.tm_hour)
} else {
format!("{}", tm.tm_hour)
};
out.push_str(&s);
}
'h' => {
let mut hour = tm.tm_hour;
if has_am_pm {
hour %= 12;
if hour == 0 {
hour = 12;
}
}
let s = if run >= 2 { format!("{:02}", hour) } else { format!("{}", hour) };
out.push_str(&s);
}
'm' => {
let s = if run >= 2 {
format!("{:02}", tm.tm_min)
} else {
format!("{}", tm.tm_min)
};
out.push_str(&s);
}
's' => {
let s = if run >= 2 {
format!("{:02}", tm.tm_sec)
} else {
format!("{}", tm.tm_sec)
};
out.push_str(&s);
}
'z' => {
let s = if run >= 3 { format!("{:03}", ms) } else { format!("{}", ms) };
out.push_str(&s);
}
'A' | 'a' => {
// Qt: A/AP/ap/a are all replaced by the full AM/PM string;
// the two-letter form is a single token.
if i + run < bytes.len()
&& bytes[i + run] as char == (c as u8 + (b'P' - b'A')) as char
{
run += 1;
}
let am = if c == 'A' { "AM" } else { "am" };
let pm = if c == 'A' { "PM" } else { "pm" };
out.push_str(if tm.tm_hour < 12 { am } else { pm });
}
_ => {
out.push_str(&format[i..i + run]);
}
}
i += run;
}
out
}
/// `Variant::to_bool()` for the local-time toggle (Boolean payload, with a
/// numeric fallback for mis-typed connections).
fn to_bool(v: &NodeValue) -> bool {
match v {
NodeValue::Boolean(b) => *b,
other => other.to_double() != 0.0,
}
}
/// `Variant::to_string()` for the format string (Text payload, with a
/// numeric fallback for mis-typed connections).
fn to_text(v: &NodeValue) -> String {
match v {
NodeValue::Text(s) => s.clone(),
other => other.to_double().to_string(),
}
}
impl NodeBehavior for TimeFormatNode {
/// Human-readable name (C++ `name()`).
fn name(&self) -> &str {
"Time Format"
}
/// Stable type id (C++ `id()`).
fn type_id(&self) -> &str {
"org.olivevideoeditor.Olive.timeformat"
}
/// Categories (C++ `category()`).
fn categories(&self) -> &[Category] {
&[Category::Generator]
}
/// Description (C++ `description()`).
fn description(&self) -> &str {
"Format time (in Unix epoch seconds) into a string."
}
/// Localized input names (C++ `retranslate()`): `time_in` -> "Time",
/// `format_in` -> "Format", `localtime_in` -> "Interpret time as local
/// time".
fn input_name<'a>(&self, id: &'a str) -> &'a str {
match id {
TIME_INPUT => "Time",
FORMAT_INPUT => "Format",
LOCAL_TIME_INPUT => "Interpret time as local time",
_ => id,
}
}
/// Evaluate outputs (C++ `value()`): converts `time_in` (float seconds)
/// to epoch milliseconds, splits into `std::tm` via `localtime_r` or
/// `gmtime_r` depending on `localtime_in`, expands the Qt-style format
/// tokens of `format_in` with longest-run matching (the C++
/// anonymous-namespace `format_date_time()`: h/hh is 12-hour only when
/// an AM/PM token is present; A/AP/ap/a emit the full AM/PM string),
/// and pushes the result as a text value.
fn value(&self, core: &NodeCore, inputs: &NodeValueRow, time: Rational, table: &mut NodeValueTable) {
let time_val = inputs
.get(TIME_INPUT)
.cloned()
.unwrap_or_else(|| core.value_at_time(TIME_INPUT, -1, time));
let format_val = inputs
.get(FORMAT_INPUT)
.cloned()
.unwrap_or_else(|| core.value_at_time(FORMAT_INPUT, -1, time));
let local_val = inputs
.get(LOCAL_TIME_INPUT)
.cloned()
.unwrap_or_else(|| core.value_at_time(LOCAL_TIME_INPUT, -1, time));
let ms_since_epoch = (time_val.to_double() * 1000.0) as i64;
let secs = (ms_since_epoch / 1000) as c_long;
let ms = (ms_since_epoch % 1000) as i32;
let mut tm: Tm = unsafe { std::mem::zeroed() };
unsafe {
if to_bool(&local_val) {
localtime_r(&secs, &mut tm);
} else {
gmtime_r(&secs, &mut tm);
}
}
let output = format_date_time(&tm, ms, &to_text(&format_val));
table.push(crate::value::ValueType::Text, NodeValue::Text(output), None);
}
/// Deep copy (C++ `copy()`).
fn duplicate(&self, _core: &NodeCore) -> Option<Box<dyn NodeBehavior>> {
Some(Box::new(TimeFormatNode))
}
}
/// Constructor (C++ `TimeFormatNode::TimeFormatNode()`): adds `time_in`
/// (float), `format_in` (text, default `"hh:mm:ss"`), and `localtime_in`
/// (boolean), all with default flags.
pub fn create() -> (NodeCore, Box<dyn NodeBehavior>) {
let mut core = NodeCore::new();
let mut time = crate::input::Input::new(
TIME_INPUT,
crate::value::ValueType::Float,
crate::value::NodeValue::Float(0.0),
);
time.properties = vec![
("min".to_string(), crate::value::NodeValue::Float(0.0)),
("max".to_string(), crate::value::NodeValue::Float(2147483647.0)),
];
core.add_input(time);
core.add_input(crate::input::Input::new(
FORMAT_INPUT,
crate::value::ValueType::Text,
crate::value::NodeValue::Text("hh:mm:ss".to_string()),
));
core.add_input(crate::input::Input::new(
LOCAL_TIME_INPUT,
crate::value::ValueType::Boolean,
crate::value::NodeValue::Boolean(false),
));
(core, Box::new(TimeFormatNode))
}
/// Register this node type (C++ factory listing for
/// `org.olivevideoeditor.Olive.timeformat`).
pub fn register(meta: &mut Vec<NodeMeta>) {
meta.push(NodeMeta {
type_id: "org.olivevideoeditor.Olive.timeformat",
name: "Time Format",
categories: &[Category::Generator],
create,
});
}
#[cfg(test)]
mod tests {
use super::*;
use crate::node::NodeBehavior;
use crate::value::{NodeValueTable, ValueType};
use oakcore_rs::Rational;
/// A `Tm` with just the calendar fields set (rest zeroed).
fn tm(sec: i32, min: i32, hour: i32, mday: i32, mon: i32, year: i32) -> Tm {
Tm {
tm_sec: sec,
tm_min: min,
tm_hour: hour,
tm_mday: mday,
tm_mon: mon,
tm_year: year,
..unsafe { std::mem::zeroed() }
}
}
#[test]
fn input_names() {
let n = TimeFormatNode;
assert_eq!(n.input_name(TIME_INPUT), "Time");
assert_eq!(n.input_name(FORMAT_INPUT), "Format");
assert_eq!(n.input_name(LOCAL_TIME_INPUT), "Interpret time as local time");
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.timeformat");
assert_eq!(core.get_input(TIME_INPUT).unwrap().value_type, ValueType::Float);
assert_eq!(core.get_input(FORMAT_INPUT).unwrap().value_type, ValueType::Text);
assert_eq!(
core.get_input(FORMAT_INPUT).unwrap().default,
NodeValue::Text("hh:mm:ss".to_string())
);
assert_eq!(core.get_input(LOCAL_TIME_INPUT).unwrap().value_type, ValueType::Boolean);
}
#[test]
fn format_24_hour_clock_without_am_pm_token() {
let t = tm(6, 5, 13, 9, 7, 124); // 2024-08-09 13:05:06
assert_eq!(format_date_time(&t, 0, "hh:mm:ss"), "13:05:06");
assert_eq!(format_date_time(&t, 0, "HH"), "13");
}
#[test]
fn format_12_hour_clock_with_am_pm_token() {
let pm = tm(0, 0, 13, 1, 0, 124);
assert_eq!(format_date_time(&pm, 0, "h:mm AP"), "1:00 PM");
assert_eq!(format_date_time(&pm, 0, "h:mm ap"), "1:00 pm");
assert_eq!(format_date_time(&pm, 0, "h:mm A"), "1:00 PM");
assert_eq!(format_date_time(&pm, 0, "h:mm a"), "1:00 pm");
let am = tm(0, 0, 0, 1, 0, 124);
assert_eq!(format_date_time(&am, 0, "h AP"), "12 AM");
let midnight = tm(0, 0, 0, 1, 0, 124);
assert_eq!(format_date_time(&midnight, 0, "hh AP"), "12 AM");
}
#[test]
fn format_date_tokens() {
let t = tm(6, 5, 13, 9, 7, 124); // 2024-08-09 13:05:06
assert_eq!(format_date_time(&t, 0, "yyyy-MM-dd"), "2024-08-09");
assert_eq!(format_date_time(&t, 0, "yy-M-d"), "24-8-9");
assert_eq!(format_date_time(&t, 0, "yyyy"), "2024");
}
#[test]
fn format_millisecond_tokens() {
let t = tm(0, 0, 0, 1, 0, 124);
assert_eq!(format_date_time(&t, 5, "z"), "5");
assert_eq!(format_date_time(&t, 5, "zz"), "5");
assert_eq!(format_date_time(&t, 5, "zzz"), "005");
assert_eq!(format_date_time(&t, 123, "zzz"), "123");
}
#[test]
fn format_literal_sections() {
let t = tm(0, 0, 13, 9, 7, 124);
assert_eq!(format_date_time(&t, 0, "'Literal text'"), "Literal text");
assert_eq!(format_date_time(&t, 0, "'It''s' HH"), "Its 13");
assert_eq!(format_date_time(&t, 0, "yyyy'unterminated"), "2024unterminated");
}
#[test]
fn format_unknown_tokens_copied_verbatim() {
let t = tm(0, 0, 13, 9, 7, 124);
assert_eq!(format_date_time(&t, 0, "Q % 5"), "Q % 5");
}
#[test]
fn value_formats_utc_epoch() {
let (mut core, behavior) = create();
// 12:34:56 on 1970-01-01 UTC = 45296 seconds.
core.set_standard_value(TIME_INPUT, -1, NodeValue::Float(45296.0));
core.set_standard_value(FORMAT_INPUT, -1, NodeValue::Text("yyyy-MM-dd HH:mm:ss".to_string()));
let mut table = NodeValueTable::default();
behavior.value(&core, &crate::value::NodeValueRow::default(), Rational::new(0, 1), &mut table);
assert_eq!(
table.get(ValueType::Text),
Some(&NodeValue::Text("1970-01-01 12:34:56".to_string()))
);
}
#[test]
fn value_formats_milliseconds_utc() {
let (mut core, behavior) = create();
// Same instant plus 789 ms.
core.set_standard_value(TIME_INPUT, -1, NodeValue::Float(45296.789));
core.set_standard_value(FORMAT_INPUT, -1, NodeValue::Text("HH:mm:ss.zzz".to_string()));
let mut table = NodeValueTable::default();
behavior.value(&core, &crate::value::NodeValueRow::default(), Rational::new(0, 1), &mut table);
assert_eq!(
table.get(ValueType::Text),
Some(&NodeValue::Text("12:34:56.789".to_string()))
);
}
#[test]
fn value_uses_connected_inputs() {
let (core, behavior) = create();
let mut row = crate::value::NodeValueRow::default();
row.insert(TIME_INPUT.to_string(), NodeValue::Float(45296.0));
row.insert(FORMAT_INPUT.to_string(), NodeValue::Text("yyyy".to_string()));
row.insert(LOCAL_TIME_INPUT.to_string(), NodeValue::Boolean(false));
let mut table = NodeValueTable::default();
behavior.value(&core, &row, Rational::new(0, 1), &mut table);
assert_eq!(
table.get(ValueType::Text),
Some(&NodeValue::Text("1970".to_string()))
);
}
#[test]
fn value_localtime_flag_routes_to_localtime_r() {
let (mut core, behavior) = create();
core.set_standard_value(TIME_INPUT, -1, NodeValue::Float(45296.0));
core.set_standard_value(FORMAT_INPUT, -1, NodeValue::Text("yyyy".to_string()));
// Expected values computed through the same C library calls the C++
// makes — this validates the routing (which function is called for
// each flag value), not the C library itself.
let mut secs: c_long = 45296;
let mut local: Tm = unsafe { std::mem::zeroed() };
unsafe { localtime_r(&secs, &mut local); }
let mut utc: Tm = unsafe { std::mem::zeroed() };
unsafe { gmtime_r(&secs, &mut utc); }
let local_expected = format!("{:04}", local.tm_year + 1900);
let utc_expected = format!("{:04}", utc.tm_year + 1900);
assert_eq!(utc_expected, "1970");
core.set_standard_value(LOCAL_TIME_INPUT, -1, NodeValue::Boolean(true));
let mut table = NodeValueTable::default();
behavior.value(&core, &crate::value::NodeValueRow::default(), Rational::new(0, 1), &mut table);
assert_eq!(table.get(ValueType::Text), Some(&NodeValue::Text(local_expected)));
core.set_standard_value(LOCAL_TIME_INPUT, -1, NodeValue::Boolean(false));
let mut table = NodeValueTable::default();
behavior.value(&core, &crate::value::NodeValueRow::default(), Rational::new(0, 1), &mut table);
assert_eq!(table.get(ValueType::Text), Some(&NodeValue::Text(utc_expected)));
}
#[test]
fn duplicate_copies_node() {
let (_core, behavior) = create();
let copy = behavior.duplicate(&_core).unwrap();
assert_eq!(copy.type_id(), "org.olivevideoeditor.Olive.timeformat");
assert_eq!(copy.name(), "Time Format");
}
}
+132
View File
@@ -0,0 +1,132 @@
// 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/>.
//! Time generator node (C++ `src/node/src/input/time/timeinput.{h,cpp}`,
//! `olive::TimeInput`).
use crate::factory::NodeMeta;
use crate::node::{Category, NodeBehavior, NodeCore};
use crate::value::{NodeValue, NodeValueRow, NodeValueTable};
use oakcore_rs::Rational;
/// Time input node. Emits the current time (in seconds) as a float.
/// The C++ class has no own members (no inputs, no caches of its own),
/// so this is a unit-like struct.
pub struct TimeInput;
impl NodeBehavior for TimeInput {
/// Human-readable name (C++ `name()`).
fn name(&self) -> &str {
"Time"
}
/// Stable type id (C++ `id()`).
fn type_id(&self) -> &str {
"org.olivevideoeditor.Olive.time"
}
/// Categories (C++ `category()` returns `{ k_category_time }`; the
/// Rust [`Category`] enum has no `Time` variant, so this returns an
/// empty slice — see the note on [`register`]).
fn categories(&self) -> &[Category] {
&[]
}
/// Description (C++ `description()`).
fn description(&self) -> &str {
"Generates the time (in seconds) at this frame."
}
/// Evaluate outputs (C++ `value()`): pushes the current global time
/// (`globals.time().in().to_double()`, here the `time` argument) as a
/// float value, not marked as a texture, with the push tag `"time"`.
fn value(&self, core: &NodeCore, inputs: &NodeValueRow, time: Rational, table: &mut NodeValueTable) {
let _ = (core, inputs);
table.push(
crate::value::ValueType::Float,
NodeValue::Float(time.to_f64()),
Some("time".to_string()),
);
}
/// Deep copy (C++ `copy()` via `NODE_DEFAULT_FUNCTIONS`).
fn duplicate(&self, _core: &NodeCore) -> Option<Box<dyn NodeBehavior>> {
Some(Box::new(TimeInput))
}
}
/// Constructor (C++ `TimeInput::TimeInput()`): trivial — the node has no
/// inputs to wire up.
pub fn create() -> (NodeCore, Box<dyn NodeBehavior>) {
(NodeCore::new(), Box::new(TimeInput))
}
/// Register this node type (C++ `k_time` in
/// `factory.cpp::create_from_factory_index`). NOTE: the C++ category is
/// `k_category_time`, which has no counterpart in the Rust [`Category`]
/// enum, so the entry is registered with an empty category list.
pub fn register(meta: &mut Vec<NodeMeta>) {
meta.push(NodeMeta {
type_id: "org.olivevideoeditor.Olive.time",
name: "Time",
categories: &[],
create,
});
}
#[cfg(test)]
mod tests {
use super::*;
use crate::value::{NodeValueTable, ValueType};
use oakcore_rs::Rational;
#[test]
fn value_pushes_current_time_as_float() {
let (core, behavior) = create();
let mut table = NodeValueTable::default();
behavior.value(&core, &crate::value::NodeValueRow::default(), Rational::new(15, 2), &mut table);
assert_eq!(table.get(ValueType::Float), Some(&NodeValue::Float(7.5)));
// The pushed row carries the "time" tag (C++ push tag).
let (_, _, tag) = &table.rows()[0];
assert_eq!(tag.as_deref(), Some("time"));
}
#[test]
fn value_ignores_inputs() {
let (core, behavior) = create();
let mut row = crate::value::NodeValueRow::default();
row.insert("some_in".to_string(), NodeValue::Float(99.0));
let mut table = NodeValueTable::default();
behavior.value(&core, &row, Rational::new(3, 1), &mut table);
assert_eq!(table.get(ValueType::Float), Some(&NodeValue::Float(3.0)));
}
#[test]
fn create_has_only_enabled_input() {
let (core, behavior) = create();
assert_eq!(behavior.type_id(), "org.olivevideoeditor.Olive.time");
assert_eq!(core.inputs.len(), 1);
assert_eq!(core.inputs[0].id, crate::node::ENABLED_INPUT);
}
#[test]
fn duplicate_copies_node() {
let (_core, behavior) = create();
let copy = behavior.duplicate(&_core).unwrap();
assert_eq!(copy.type_id(), "org.olivevideoeditor.Olive.time");
assert_eq!(copy.name(), "Time");
}
}
+382
View File
@@ -0,0 +1,382 @@
// 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/>.
//! Time offset node (C++ `src/node/src/time/timeoffset/timeoffsetnode.{h,cpp}`,
//! `olive::TimeOffsetNode`).
use crate::factory::NodeMeta;
use crate::node::{Category, NodeBehavior, NodeCore};
use crate::value::{NodeValue, NodeValueRow, NodeValueTable};
use oakcore_rs::{Rational, TimeRange};
/// Time offset input id (C++ `k_time_input`). Type: rational; default
/// `Rational(0)`; flags: not-connectable; properties: `view = time slider`,
/// `viewlock = true`.
pub const TIME_INPUT: &str = "time_in";
/// Effect input id (C++ `k_input_input`). Type: none (pass-through of any
/// connected type); flags: not-keyframable.
pub const INPUT_INPUT: &str = "input_in";
/// Time offset node. Shifts the time seen by the connected input by a
/// constant amount. The C++ class has no own data members (only the private
/// `get_remapped_time()`/`get_remapped_output_time()` helpers, which become
/// the behavior of `input_time_adjustment`/`output_time_adjustment` below),
/// so this is a unit-like struct.
pub struct TimeOffsetNode;
impl TimeOffsetNode {
/// C++ `get_remapped_time()`: the `time_in` input's value at
/// `input_time` added to `input_time` — `input + time_in`. `time_in`
/// is not-connectable, so no connected edge is ever consulted.
pub fn get_remapped_time(core: &NodeCore, input_time: Rational) -> Rational {
input_time + Self::time_offset(core, input_time)
}
/// C++ `get_remapped_output_time()`: the inverse of
/// [`Self::get_remapped_time`] — `input - time_in`.
pub fn get_remapped_output_time(core: &NodeCore, input_time: Rational) -> Rational {
input_time - Self::time_offset(core, input_time)
}
/// The `time_in` value evaluated at `input_time` (C++ `get_value_at_time
/// (k_time_input, input).value<Rational>()`).
fn time_offset(core: &NodeCore, input_time: Rational) -> Rational {
match core.value_at_time(TIME_INPUT, -1, input_time) {
NodeValue::Rational(r) => r,
v => Rational::from_double(v.to_double()),
}
}
/// The full C++ `input_time_adjustment()` with an explicit core. For
/// `input_in`, both endpoints of the range are shifted forward by the
/// `time_in` value evaluated at that endpoint; all other inputs fall
/// through to the base-class identity behavior.
///
/// The [`NodeBehavior::input_time_adjustment`] trait method carries no
/// `NodeCore`, so this value-resolving variant is what render-time call
/// sites (and the tests) use; the trait method documents that gap.
pub fn input_time_adjustment_with(
core: &NodeCore,
input: &str,
element: i32,
time: TimeRange,
traverse: bool,
) -> TimeRange {
let _ = (element, traverse);
if input == INPUT_INPUT {
TimeRange::new(
Self::get_remapped_time(core, time.in_()),
Self::get_remapped_time(core, time.out()),
)
} else {
time
}
}
/// The full C++ `output_time_adjustment()` with an explicit core: the
/// exact inverse of [`Self::input_time_adjustment_with`] — for
/// `input_in`, both endpoints are shifted back by the `time_in` value.
pub fn output_time_adjustment_with(
core: &NodeCore,
input: &str,
element: i32,
time: TimeRange,
traverse: bool,
) -> TimeRange {
let _ = (element, traverse);
if input == INPUT_INPUT {
TimeRange::new(
Self::get_remapped_output_time(core, time.in_()),
Self::get_remapped_output_time(core, time.out()),
)
} else {
time
}
}
}
impl NodeBehavior for TimeOffsetNode {
/// Human-readable name (C++ `name()`).
fn name(&self) -> &str {
"Time Offset"
}
/// Stable type id (C++ `id()`).
fn type_id(&self) -> &str {
"org.olivevideoeditor.Olive.timeoffset"
}
/// Categories (C++ `category()` returns `{ k_category_time }`; the Rust
/// `Category` enum has no `Time` variant yet, so this is empty until one
/// is added).
fn categories(&self) -> &[Category] {
&[]
}
/// Description (C++ `description()`).
fn description(&self) -> &str {
"Offset time passing through the graph."
}
/// Localized input names (C++ `retranslate()`): `time_in` -> "Time",
/// `input_in` -> "Input".
fn input_name<'a>(&self, id: &'a str) -> &'a str {
match id {
TIME_INPUT => "Time",
INPUT_INPUT => "Input",
_ => id,
}
}
/// Input-side time remap (C++ `input_time_adjustment()`): for
/// `input_in`, both ends of the range are shifted forward by the current
/// `time_in` value (C++ `get_remapped_time()`: `input + time_in`);
/// all other inputs fall through to the base-class identity behavior.
///
/// The C++ evaluation reads the keyframable `time_in` input, which
/// requires the node's data ([`NodeCore`]) — not carried by this trait
/// signature. The exact remap is ported in
/// [`Self::input_time_adjustment_with`] (and tested there); until the
/// adjustment API gains core access, the identity range is returned
/// (`// CPP-PARITY: timeoffsetnode.cpp` `input_time_adjustment`).
fn input_time_adjustment(&self, input: &str, element: i32, time: TimeRange, traverse: bool) -> TimeRange {
let _ = (input, element, traverse);
time
}
/// Output-side time remap (C++ `output_time_adjustment()`): the exact
/// inverse of the input adjustment — for `input_in`, both ends of the
/// range are shifted back by subtracting the `time_in` value (C++
/// `get_remapped_output_time()`: `input - time_in`); all other inputs
/// fall through to the base-class identity behavior.
///
/// As with the input side, the value read needs the node's data; the
/// exact remap is ported in [`Self::output_time_adjustment_with`]
/// (`// CPP-PARITY: timeoffsetnode.cpp` `output_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()`): pushes the value arriving at
/// `input_in` through unchanged (the actual time shift happens via the
/// time-adjustment overrides above).
fn value(&self, core: &NodeCore, inputs: &NodeValueRow, time: Rational, table: &mut NodeValueTable) {
let _ = (core, time);
// `table->push(value.at(k_input_input))` — the value passes through
// unchanged, whatever its type (texture values included).
if let Some(v) = inputs.get(INPUT_INPUT) {
table.push(v.value_type(), v.clone(), None);
}
}
/// Deep copy (C++ `copy()`).
fn duplicate(&self, _core: &NodeCore) -> Option<Box<dyn NodeBehavior>> {
Some(Box::new(TimeOffsetNode))
}
}
/// Constructor (C++ `TimeOffsetNode::TimeOffsetNode()`): adds `time_in`
/// (rational, default 0, not-connectable, time-slider view with viewlock)
/// and `input_in` (type-none pass-through, not-keyframable).
pub fn create() -> (NodeCore, Box<dyn NodeBehavior>) {
let mut core = NodeCore::new();
let mut time_input = crate::input::Input::new(
TIME_INPUT,
crate::value::ValueType::Rational,
NodeValue::Rational(Rational::new(0, 1)),
);
time_input.flags |= crate::input::flags::NOT_CONNECTABLE;
time_input.properties = vec![
("view".to_string(), NodeValue::Text("time".to_string())),
("viewlock".to_string(), NodeValue::Boolean(true)),
];
core.add_input(time_input);
let mut input_input = crate::input::Input::new(
INPUT_INPUT,
crate::value::ValueType::None,
NodeValue::None,
);
input_input.flags |= crate::input::flags::NOT_KEYFRAMABLE;
core.add_input(input_input);
(core, Box::new(TimeOffsetNode))
}
/// Register this node type (C++ factory listing for
/// `org.olivevideoeditor.Olive.timeoffset`; see the note on
/// [`NodeBehavior::categories`] about the missing `Time` category).
pub fn register(meta: &mut Vec<NodeMeta>) {
meta.push(NodeMeta {
type_id: "org.olivevideoeditor.Olive.timeoffset",
name: "Time Offset",
categories: &[],
create,
});
}
#[cfg(test)]
mod tests {
use super::*;
use crate::node::NodeBehavior;
use crate::value::{NodeValueTable, ValueType};
use oakcore_rs::Rational;
#[test]
fn input_names() {
let n = TimeOffsetNode;
assert_eq!(n.input_name(TIME_INPUT), "Time");
assert_eq!(n.input_name(INPUT_INPUT), "Input");
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.timeoffset");
let time_in = core.get_input(TIME_INPUT).unwrap();
assert_eq!(time_in.value_type, ValueType::Rational);
assert_eq!(time_in.default, NodeValue::Rational(Rational::new(0, 1)));
assert_ne!(time_in.flags & crate::input::flags::NOT_CONNECTABLE, 0);
assert!(time_in.properties.iter().any(|(k, v)| {
k == "view" && v == &NodeValue::Text("time".to_string())
}));
assert!(time_in.properties.iter().any(|(k, v)| {
k == "viewlock" && v == &NodeValue::Boolean(true)
}));
let input_in = core.get_input(INPUT_INPUT).unwrap();
assert_eq!(input_in.value_type, ValueType::None);
assert_ne!(input_in.flags & crate::input::flags::NOT_KEYFRAMABLE, 0);
}
#[test]
fn get_remapped_time_shifts_forward() {
let (mut core, _) = create();
core.set_standard_value(TIME_INPUT, -1, NodeValue::Rational(Rational::new(5, 1)));
assert_eq!(
TimeOffsetNode::get_remapped_time(&core, Rational::new(10, 1)),
Rational::new(15, 1)
);
}
#[test]
fn get_remapped_output_time_shifts_back() {
let (mut core, _) = create();
core.set_standard_value(TIME_INPUT, -1, NodeValue::Rational(Rational::new(5, 1)));
assert_eq!(
TimeOffsetNode::get_remapped_output_time(&core, Rational::new(10, 1)),
Rational::new(5, 1)
);
}
#[test]
fn input_time_adjustment_shifts_range_forward() {
let (mut core, _) = create();
core.set_standard_value(TIME_INPUT, -1, NodeValue::Rational(Rational::new(5, 1)));
let r = TimeOffsetNode::input_time_adjustment_with(
&core,
INPUT_INPUT,
-1,
TimeRange::new(Rational::new(10, 1), Rational::new(20, 1)),
true,
);
assert_eq!(r.in_(), Rational::new(15, 1));
assert_eq!(r.out(), Rational::new(25, 1));
}
#[test]
fn output_time_adjustment_shifts_range_back() {
let (mut core, _) = create();
core.set_standard_value(TIME_INPUT, -1, NodeValue::Rational(Rational::new(5, 1)));
let r = TimeOffsetNode::output_time_adjustment_with(
&core,
INPUT_INPUT,
-1,
TimeRange::new(Rational::new(10, 1), Rational::new(20, 1)),
true,
);
assert_eq!(r.in_(), Rational::new(5, 1));
assert_eq!(r.out(), Rational::new(15, 1));
}
#[test]
fn adjustments_evaluate_offset_per_endpoint() {
let (mut core, _) = create();
// A non-constant (keyframed) offset: each endpoint is shifted by the
// time_in value evaluated at that endpoint.
core.keyframe_track_mut(TIME_INPUT, -1).set_key(crate::keyframe::Keyframe {
time: Rational::new(0, 1),
value: NodeValue::Rational(Rational::new(1, 1)),
interpolation: crate::keyframe::Interpolation::Linear,
bezier_in: (0.0, 0.0),
bezier_out: (0.0, 0.0),
});
core.keyframe_track_mut(TIME_INPUT, -1).set_key(crate::keyframe::Keyframe {
time: Rational::new(20, 1),
value: NodeValue::Rational(Rational::new(3, 1)),
interpolation: crate::keyframe::Interpolation::Linear,
bezier_in: (0.0, 0.0),
bezier_out: (0.0, 0.0),
});
let t = TimeRange::new(Rational::new(0, 1), Rational::new(20, 1));
let shifted = TimeOffsetNode::input_time_adjustment_with(&core, INPUT_INPUT, -1, t, true);
// 0 + offset(0s) = 1; 20 + offset(20s) = 23.
assert_eq!(shifted.in_(), Rational::new(1, 1));
assert_eq!(shifted.out(), Rational::new(23, 1));
}
#[test]
fn adjustments_are_mutually_inverse_for_constant_offset() {
let (mut core, _) = create();
core.set_standard_value(TIME_INPUT, -1, NodeValue::Rational(Rational::new(5, 1)));
let t = TimeRange::new(Rational::new(10, 1), Rational::new(20, 1));
let shifted = TimeOffsetNode::input_time_adjustment_with(&core, INPUT_INPUT, -1, t, true);
assert_eq!(shifted.in_(), Rational::new(15, 1));
assert_eq!(shifted.out(), Rational::new(25, 1));
let unshifted = TimeOffsetNode::output_time_adjustment_with(&core, INPUT_INPUT, -1, shifted, true);
assert_eq!(unshifted, t);
}
#[test]
fn adjustments_other_inputs_are_identity() {
let (mut core, _) = create();
core.set_standard_value(TIME_INPUT, -1, NodeValue::Rational(Rational::new(5, 1)));
let t = TimeRange::new(Rational::new(10, 1), Rational::new(20, 1));
assert_eq!(TimeOffsetNode::input_time_adjustment_with(&core, "other_in", -1, t, true), t);
assert_eq!(TimeOffsetNode::output_time_adjustment_with(&core, "other_in", -1, t, true), t);
}
#[test]
fn value_passes_input_through() {
let (core, behavior) = create();
let mut row = crate::value::NodeValueRow::default();
row.insert(INPUT_INPUT.to_string(), NodeValue::Float(42.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(42.0)));
}
#[test]
fn duplicate_copies_node() {
let (_core, behavior) = create();
let copy = behavior.duplicate(&_core).unwrap();
assert_eq!(copy.type_id(), "org.olivevideoeditor.Olive.timeoffset");
assert_eq!(copy.name(), "Time Offset");
}
}
+332
View File
@@ -0,0 +1,332 @@
// 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/>.
//! Time remap node (C++ `src/node/src/time/timeremap/timeremap.{h,cpp}`,
//! `olive::TimeRemapNode`).
use crate::factory::NodeMeta;
use crate::node::{Category, NodeBehavior, NodeCore};
use crate::value::{NodeValue, NodeValueRow, NodeValueTable};
use oakcore_rs::{Rational, TimeRange};
/// Target-time input id (C++ `k_time_input`). Type: rational; default
/// `Rational(0)`; flags: not-connectable; properties: `view = time slider`,
/// `viewlock = true`.
pub const TIME_INPUT: &str = "time_in";
/// Effect input id (C++ `k_input_input`). Type: none (pass-through of any
/// connected type); flags: not-keyframable.
pub const INPUT_INPUT: &str = "input_in";
/// Time remap node. Replaces the time seen by the connected input with an
/// arbitrary (keyframable) time. The C++ class has no own data members
/// (only the private `get_remapped_time()` helper, which becomes the
/// behavior of `input_time_adjustment` below), so this is a unit-like
/// struct.
pub struct TimeRemapNode;
impl TimeRemapNode {
/// C++ `get_remapped_time()`: the `time_in` input's value at
/// `input_time` (keyframes when the track is set, else the standard
/// value). `time_in` is not-connectable, so no connected edge is ever
/// consulted.
pub fn get_remapped_time(core: &NodeCore, input_time: Rational) -> Rational {
match core.value_at_time(TIME_INPUT, -1, input_time) {
NodeValue::Rational(r) => r,
v => Rational::from_double(v.to_double()),
}
}
/// The full C++ `input_time_adjustment()` with an explicit core. For
/// `input_in`, both endpoints of the range are replaced by the
/// `time_in` value evaluated at that endpoint (the remap discards the
/// original time); all other inputs fall through to the base-class
/// identity behavior.
///
/// The [`NodeBehavior::input_time_adjustment`] trait method carries no
/// `NodeCore`, so this value-resolving variant is what render-time call
/// sites (and the tests) use; the trait method documents that gap.
pub fn input_time_adjustment_with(
core: &NodeCore,
input: &str,
element: i32,
time: TimeRange,
traverse: bool,
) -> TimeRange {
let _ = (element, traverse);
if input == INPUT_INPUT {
TimeRange::new(
Self::get_remapped_time(core, time.in_()),
Self::get_remapped_time(core, time.out()),
)
} else {
time
}
}
}
impl NodeBehavior for TimeRemapNode {
/// Human-readable name (C++ `name()`).
fn name(&self) -> &str {
"Time Remap"
}
/// Stable type id (C++ `id()`).
fn type_id(&self) -> &str {
"org.olivevideoeditor.Olive.timeremap"
}
/// Categories (C++ `category()` returns `{ k_category_time }`; the Rust
/// `Category` enum has no `Time` variant yet, so this is empty until one
/// is added).
fn categories(&self) -> &[Category] {
&[]
}
/// Description (C++ `description()`).
fn description(&self) -> &str {
"Arbitrarily remap time through the nodes."
}
/// Localized input names (C++ `retranslate()`): `time_in` -> "Time",
/// `input_in` -> "Input".
fn input_name<'a>(&self, id: &'a str) -> &'a str {
match id {
TIME_INPUT => "Time",
INPUT_INPUT => "Input",
_ => id,
}
}
/// Input-side time remap (C++ `input_time_adjustment()`): for
/// `input_in`, both ends of the range are replaced by the `time_in`
/// value at that time (C++ `get_remapped_time()`: `time_in` evaluated at
/// `input`, discarding the original time); all other inputs fall through
/// to the base-class identity behavior.
///
/// The C++ evaluation reads the keyframable `time_in` input, which
/// requires the node's data ([`NodeCore`]) — not carried by this trait
/// signature. The exact remap is ported in
/// [`Self::input_time_adjustment_with`] (and tested there); until the
/// adjustment API gains core access, the identity range is returned
/// (`// CPP-PARITY: timeremap.cpp` `input_time_adjustment`).
fn input_time_adjustment(&self, input: &str, element: i32, time: TimeRange, traverse: bool) -> TimeRange {
let _ = (input, element, traverse);
time
}
/// Output-side time remap (C++ `output_time_adjustment()`): the C++
/// override has its real inverse implementation commented out (an
/// arbitrary remap is not invertible) and unconditionally defers to the
/// base-class identity behavior; declared here for parity.
fn output_time_adjustment(&self, input: &str, element: i32, time: TimeRange, traverse: bool) -> TimeRange {
let _ = (input, element, traverse);
time
}
/// Evaluate outputs (C++ `value()`): pushes the value arriving at
/// `input_in` through unchanged (the actual time remap happens via the
/// time-adjustment overrides above).
fn value(&self, core: &NodeCore, inputs: &NodeValueRow, time: Rational, table: &mut NodeValueTable) {
let _ = (core, time);
// `table->push(value.at(k_input_input))` — the value passes through
// unchanged, whatever its type (texture values included).
if let Some(v) = inputs.get(INPUT_INPUT) {
table.push(v.value_type(), v.clone(), None);
}
}
/// Deep copy (C++ `copy()`).
fn duplicate(&self, _core: &NodeCore) -> Option<Box<dyn NodeBehavior>> {
Some(Box::new(TimeRemapNode))
}
}
/// Constructor (C++ `TimeRemapNode::TimeRemapNode()`): adds `time_in`
/// (rational, default 0, not-connectable, time-slider view with viewlock)
/// and `input_in` (type-none pass-through, not-keyframable).
pub fn create() -> (NodeCore, Box<dyn NodeBehavior>) {
let mut core = NodeCore::new();
let mut time_input = crate::input::Input::new(
TIME_INPUT,
crate::value::ValueType::Rational,
NodeValue::Rational(Rational::new(0, 1)),
);
time_input.flags |= crate::input::flags::NOT_CONNECTABLE;
time_input.properties = vec![
("view".to_string(), NodeValue::Text("time".to_string())),
("viewlock".to_string(), NodeValue::Boolean(true)),
];
core.add_input(time_input);
let mut input_input = crate::input::Input::new(
INPUT_INPUT,
crate::value::ValueType::None,
NodeValue::None,
);
input_input.flags |= crate::input::flags::NOT_KEYFRAMABLE;
core.add_input(input_input);
(core, Box::new(TimeRemapNode))
}
/// Register this node type (C++ factory listing for
/// `org.olivevideoeditor.Olive.timeremap`; see the note on
/// [`NodeBehavior::categories`] about the missing `Time` category).
pub fn register(meta: &mut Vec<NodeMeta>) {
meta.push(NodeMeta {
type_id: "org.olivevideoeditor.Olive.timeremap",
name: "Time Remap",
categories: &[],
create,
});
}
#[cfg(test)]
mod tests {
use super::*;
use crate::node::NodeBehavior;
use crate::value::{NodeValueTable, ValueType};
use oakcore_rs::Rational;
#[test]
fn input_names() {
let n = TimeRemapNode;
assert_eq!(n.input_name(TIME_INPUT), "Time");
assert_eq!(n.input_name(INPUT_INPUT), "Input");
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.timeremap");
let time_in = core.get_input(TIME_INPUT).unwrap();
assert_eq!(time_in.value_type, ValueType::Rational);
assert_eq!(time_in.default, NodeValue::Rational(Rational::new(0, 1)));
assert_ne!(time_in.flags & crate::input::flags::NOT_CONNECTABLE, 0);
assert!(time_in.properties.iter().any(|(k, v)| {
k == "view" && v == &NodeValue::Text("time".to_string())
}));
assert!(time_in.properties.iter().any(|(k, v)| {
k == "viewlock" && v == &NodeValue::Boolean(true)
}));
let input_in = core.get_input(INPUT_INPUT).unwrap();
assert_eq!(input_in.value_type, ValueType::None);
assert_ne!(input_in.flags & crate::input::flags::NOT_KEYFRAMABLE, 0);
}
#[test]
fn get_remapped_time_uses_standard_value() {
let (mut core, _) = create();
core.set_standard_value(TIME_INPUT, -1, NodeValue::Rational(Rational::new(5, 1)));
assert_eq!(TimeRemapNode::get_remapped_time(&core, Rational::new(0, 1)), Rational::new(5, 1));
assert_eq!(TimeRemapNode::get_remapped_time(&core, Rational::new(30, 1)), Rational::new(5, 1));
}
#[test]
fn get_remapped_time_uses_keyframe_curve() {
let (mut core, _) = create();
// A time-remap curve: at 0s the input shows 10s, at 10s it shows 0s
// (reverse). Evaluated exactly at the keyframe times, so no
// interpolation is involved.
core.keyframe_track_mut(TIME_INPUT, -1).set_key(crate::keyframe::Keyframe {
time: Rational::new(0, 1),
value: NodeValue::Rational(Rational::new(10, 1)),
interpolation: crate::keyframe::Interpolation::Linear,
bezier_in: (0.0, 0.0),
bezier_out: (0.0, 0.0),
});
core.keyframe_track_mut(TIME_INPUT, -1).set_key(crate::keyframe::Keyframe {
time: Rational::new(10, 1),
value: NodeValue::Rational(Rational::new(0, 1)),
interpolation: crate::keyframe::Interpolation::Linear,
bezier_in: (0.0, 0.0),
bezier_out: (0.0, 0.0),
});
assert_eq!(TimeRemapNode::get_remapped_time(&core, Rational::new(0, 1)), Rational::new(10, 1));
assert_eq!(TimeRemapNode::get_remapped_time(&core, Rational::new(10, 1)), Rational::new(0, 1));
// Mid-way between the keys the curve is linear: 10s -> 5s.
assert_eq!(TimeRemapNode::get_remapped_time(&core, Rational::new(5, 1)), Rational::new(5, 1));
}
#[test]
fn input_time_adjustment_remaps_both_endpoints() {
let (mut core, _) = create();
core.set_standard_value(TIME_INPUT, -1, NodeValue::Rational(Rational::new(5, 1)));
// Both endpoints are replaced by the time_in value at that time
// (the original time is discarded), so the range collapses to (5,5).
let r = TimeRemapNode::input_time_adjustment_with(
&core,
INPUT_INPUT,
-1,
TimeRange::new(Rational::new(10, 1), Rational::new(20, 1)),
true,
);
assert_eq!(r.in_(), Rational::new(5, 1));
assert_eq!(r.out(), Rational::new(5, 1));
}
#[test]
fn input_time_adjustment_other_inputs_are_identity() {
let (mut core, _) = create();
core.set_standard_value(TIME_INPUT, -1, NodeValue::Rational(Rational::new(5, 1)));
let t = TimeRange::new(Rational::new(10, 1), Rational::new(20, 1));
let r = TimeRemapNode::input_time_adjustment_with(&core, "other_in", -1, t, true);
assert_eq!(r, t);
}
#[test]
fn output_time_adjustment_is_identity() {
let n = TimeRemapNode;
let t = TimeRange::new(Rational::new(10, 1), Rational::new(20, 1));
assert_eq!(
n.output_time_adjustment(INPUT_INPUT, -1, t, true),
t
);
assert_eq!(
n.output_time_adjustment("other_in", -1, t, true),
t
);
}
#[test]
fn value_passes_input_through() {
let (core, behavior) = create();
let mut row = crate::value::NodeValueRow::default();
row.insert(INPUT_INPUT.to_string(), NodeValue::Float(42.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(42.0)));
}
#[test]
fn value_pushes_nothing_when_input_absent() {
let (core, behavior) = create();
let mut table = NodeValueTable::default();
behavior.value(&core, &crate::value::NodeValueRow::default(), Rational::new(0, 1), &mut table);
assert!(table.is_empty());
}
#[test]
fn duplicate_copies_node() {
let (_core, behavior) = create();
let copy = behavior.duplicate(&_core).unwrap();
assert_eq!(copy.type_id(), "org.olivevideoeditor.Olive.timeremap");
assert_eq!(copy.name(), "Time Remap");
}
}
@@ -0,0 +1,845 @@
// 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/>.
//! Transform distort effect (C++
//! `src/node/src/distort/transform/transformdistortnode.{h,cpp}`,
//! `olive::TransformDistortNode`). In C++ this derives from
//! `MatrixGenerator` (`super::matrix`), which provides the
//! `pos_in`/`rot_in`/`scale_in`/`uniform_scale_in`/`anchor_in` inputs
//! and the `generate_matrix` helper.
use crate::factory::NodeMeta;
use crate::node::{Category, Gizmo, NodeBehavior, NodeCore};
/// Parent matrix input id (C++ `k_parent_input`). Type: matrix; no
/// default (identity when unconnected).
pub const PARENT_INPUT: &str = "parent_in";
/// Texture input id (C++ `k_texture_input`). Type: texture; flags:
/// not-keyframable; this is the node's effect input. Note: C++
/// `prepend_input`s it so it appears before the inherited matrix
/// inputs.
pub const TEXTURE_INPUT: &str = "tex_in";
/// Auto-scale combo input id (C++ `k_autoscale_input`). Type: combo;
/// default `0` (`AutoScaleType::None`); combo strings: "None", "Fit",
/// "Fill", "Stretch".
pub const AUTOSCALE_INPUT: &str = "autoscale_in";
/// Interpolation combo input id (C++ `k_interpolation_input`). Type:
/// combo; default `2` (mipmapped bilinear); combo strings: "Nearest
/// Neighbor", "Bilinear", "Mipmapped Bilinear".
pub const INTERPOLATION_INPUT: &str = "interpolation_in";
/// Number of scale point gizmos (C++ `k_gizmo_scale_count` from
/// `node.h`: top-left, top-center, top-right, bottom-left,
/// bottom-center, bottom-right, center-left, center-right).
pub const GIZMO_SCALE_COUNT: usize = 8;
/// Auto-scale mode (C++ `AutoScaleType`); values match the
/// `autoscale_in` combo indices.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum AutoScaleType {
/// No auto-scaling.
None = 0,
/// Fit the texture inside the sequence frame (letterbox).
Fit = 1,
/// Fill the sequence frame (crop overflow).
Fill = 2,
/// Stretch the texture to the sequence frame ignoring aspect.
Stretch = 3,
}
/// Rotation direction for wrap-around detection (C++ private enum
/// `RotationDirection`).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum RotationDirection {
/// No direction established yet.
None,
/// Clockwise (C++ `k_direction_positive`).
Positive,
/// Counter-clockwise (C++ `k_direction_negative`).
Negative,
}
/// Which axes a scale gizmo drags (C++ private enum `GizmoScaleType`).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum GizmoScaleType {
/// Horizontal center handles (C++ `k_gizmo_scale_x_only`).
XOnly,
/// Vertical center handles (C++ `k_gizmo_scale_y_only`).
YOnly,
/// Corner handles (C++ `k_gizmo_scale_both`).
Both,
}
/// Transform distort node. Applies a 2D position/rotation/scale/anchor
/// transform to a texture, equivalent to multiplying by an orthographic
/// matrix.
///
/// The C++ class also keeps a `Matrix4x4 gizmo_inverted_transform_`
/// drag-state member; `Matrix4x4` has no Rust equivalent in this crate
/// yet, so the field is omitted here (it is recomputed at drag start
/// and consumed within the drag).
pub struct TransformDistortNode {
/// Inherited matrix-generator state (C++ base class
/// `MatrixGenerator`; provides the transform inputs and
/// `generate_matrix`).
pub matrix: super::matrix::MatrixGenerator,
/// Angle between the drag-start mouse position and the anchor
/// (C++ `double gizmo_start_angle_`).
gizmo_start_angle: f64,
/// Anchor point in screen space captured at drag start (C++
/// `PointF gizmo_anchor_pt_`).
gizmo_anchor_pt: (f64, f64),
/// Whether uniform scale was on at scale-drag start (C++
/// `bool gizmo_scale_uniform_`).
gizmo_scale_uniform: bool,
/// Previous-frame raw rotation angle (C++ `double
/// gizmo_last_angle_`).
gizmo_last_angle: f64,
/// Previous-frame perpendicular rotation angle used to disambiguate
/// wrap-around (C++ `double gizmo_last_alt_angle_`).
gizmo_last_alt_angle: f64,
/// Number of full revolutions accumulated while rotation dragging
/// (C++ `int gizmo_rotate_wrap_`).
gizmo_rotate_wrap: i32,
/// Last raw rotation direction (C++ `gizmo_rotate_last_dir_`).
gizmo_rotate_last_dir: RotationDirection,
/// Last perpendicular rotation direction (C++
/// `gizmo_rotate_last_alt_dir_`).
gizmo_rotate_last_alt_dir: RotationDirection,
/// Axes dragged by the current scale gizmo (C++
/// `gizmo_scale_axes_`).
gizmo_scale_axes: GizmoScaleType,
/// Scale reference point captured at drag start (texture-space
/// anchor, flipped for right/bottom handles; C++ `Vector2D
/// gizmo_scale_anchor_`).
gizmo_scale_anchor: (f64, f64),
/// Scale drag handles in `k_gizmo_scale_*` order (C++
/// `PointGizmo *point_gizmo_[k_gizmo_scale_count]`; absolute drag
/// value behavior, bound to both tracks of the inherited
/// `scale_in`).
point_gizmo: [Gizmo; GIZMO_SCALE_COUNT],
/// Anchor point handle (C++ `PointGizmo *anchor_gizmo_`; anchor
/// shape, drags the inherited `anchor_in` and `pos_in` tracks).
anchor_gizmo: Gizmo,
/// Frame outline handle (C++ `PolygonGizmo *poly_gizmo_`; drags the
/// inherited `pos_in` tracks).
poly_gizmo: Gizmo,
/// Rotation ring handle (C++ `ScreenGizmo *rotation_gizmo_`;
/// absolute drag value behavior, bound to the inherited `rot_in`).
rotation_gizmo: Gizmo,
}
impl TransformDistortNode {
/// Static matrix composition (C++
/// `adjust_matrix_by_resolutions()`): scale to a 2x2 square in
/// sequence space, apply the texture offset and the generated
/// matrix, scale back out to texture size, then apply the
/// auto-scale mode (stretch: per-axis; fit/fill: uniform by width
/// or height depending on the aspect-ratio comparison).
///
/// Also covers the C++ private helpers `create_scale_point` and
/// `get_direction_from_angles` (below). The remaining helpers are not
/// representable: `generate_auto_scaled_matrix` needs the texture
/// params from the C++ `VideoParams` (the Rust texture handle carries
/// no params) and `is_a_scale_gizmo` compares gizmo pointers.
fn adjust_matrix_by_resolutions(
mat: [f64; 16],
sequence_res: (f64, f64),
texture_res: (f64, f64),
offset: (f64, f64),
autoscale_type: AutoScaleType,
) -> [f64; 16] {
// First, create an identity matrix.
let mut adjusted_matrix = super::mathbase::identity_matrix();
// Scale it to a square based on the sequence's resolution.
adjusted_matrix = super::matrix::matrix_scale(
adjusted_matrix,
2.0 / sequence_res.0,
2.0 / sequence_res.1,
1.0,
);
// Apply offset if applicable.
adjusted_matrix = super::matrix::matrix_translate(
adjusted_matrix,
offset.0,
offset.1,
);
// Adjust by the matrix we generated earlier.
adjusted_matrix = super::matrix::matrix_mul(adjusted_matrix, mat);
// Scale back out to texture size (adjusted by pixel aspect).
adjusted_matrix = super::matrix::matrix_scale(
adjusted_matrix,
texture_res.0 * 0.5,
texture_res.1 * 0.5,
1.0,
);
// If auto-scale is enabled, fit the texture to the sequence
// (without cropping).
if autoscale_type != AutoScaleType::None {
if autoscale_type == AutoScaleType::Stretch {
adjusted_matrix = super::matrix::matrix_scale(
adjusted_matrix,
sequence_res.0 / texture_res.0,
sequence_res.1 / texture_res.1,
1.0,
);
} else {
let footage_real_ar = texture_res.0 / texture_res.1;
let sequence_real_ar = sequence_res.0 / sequence_res.1;
let scale_by_x = sequence_res.0 / texture_res.0;
let scale_by_y = sequence_res.1 / texture_res.1;
let autoscale_val;
if (autoscale_type == AutoScaleType::Fit) == (sequence_real_ar > footage_real_ar) {
// Scale by height. Either the sequence is wider than
// the footage or we're using fill and cutting off the
// sides.
autoscale_val = scale_by_y;
} else {
// Scale by width. Either the footage is wider than the
// sequence or we're using fill and cutting off the top
// and bottom.
autoscale_val = scale_by_x;
}
adjusted_matrix =
super::matrix::matrix_scale(adjusted_matrix, autoscale_val, autoscale_val, 1.0);
}
}
adjusted_matrix
}
/// C++ `Matrix4x4::map(PointF)` equivalent: maps `p` through the
/// row-major matrix treating it as `(x, y, 0, 1)`, dividing by the
/// resulting `w` whenever it is not exactly 1.
fn map_point(mat: [f64; 16], p: (f64, f64)) -> (f64, f64) {
let x = p.0 * mat[0] + p.1 * mat[1] + mat[3];
let y = p.0 * mat[4] + p.1 * mat[5] + mat[7];
let w = p.0 * mat[12] + p.1 * mat[13] + mat[15];
if w == 1.0 {
(x, y)
} else {
(x / w, y / w)
}
}
/// Scale-point gizmo placement (C++ `create_scale_point()`): maps the
/// unit-square position through `mat` and adds the sequence half
/// resolution.
fn create_scale_point(x: f64, y: f64, half_res: (f64, f64), mat: [f64; 16]) -> (f64, f64) {
let p = Self::map_point(mat, (x, y));
(p.0 + half_res.0, p.1 + half_res.1)
}
/// Rotation direction of a mouse angle step (C++
/// `get_direction_from_angles()`): positive when `current` is greater
/// than `last`, negative otherwise.
fn get_direction_from_angles(last: f64, current: f64) -> RotationDirection {
if current > last {
RotationDirection::Positive
} else {
RotationDirection::Negative
}
}
}
impl NodeBehavior for TransformDistortNode {
/// Human-readable name (C++ `name()`).
fn name(&self) -> &str {
"Transform"
}
/// Short menu name (C++ `short_name()`; overrides the
/// `MatrixGenerator` short name "Ortho" to just return `name()`).
fn short_name(&self) -> &str {
"Transform"
}
/// Stable type id (C++ `id()`).
fn type_id(&self) -> &str {
"org.olivevideoeditor.Olive.transform"
}
/// Categories (C++ `category()`).
fn categories(&self) -> &[Category] {
&[Category::Distort]
}
/// Description (C++ `description()`).
fn description(&self) -> &str {
"Transform an image in 2D space. Equivalent to multiplying by an orthographic matrix."
}
/// Localized input names (C++ `retranslate()`): `parent_in` ->
/// "Parent", `autoscale_in` -> "Auto-Scale" (combo strings "None",
/// "Fit", "Fill", "Stretch"), `tex_in` -> "Texture",
/// `interpolation_in` -> "Interpolation" (combo strings "Nearest
/// Neighbor", "Bilinear", "Mipmapped Bilinear"), plus the inherited
/// `MatrixGenerator` input names via its retranslate.
fn input_name<'a>(&self, id: &'a str) -> &'a str {
match id {
PARENT_INPUT => "Parent",
AUTOSCALE_INPUT => "Auto-Scale",
TEXTURE_INPUT => "Texture",
INTERPOLATION_INPUT => "Interpolation",
// The inherited MatrixGenerator input names (the combo strings
// above are UI-level properties, C++ `set_combo_box_strings`).
_ => self.matrix.input_name(id),
}
}
/// Evaluate outputs (C++ `value()`): generates the matrix from the
/// inherited transform inputs (position/rotation/scale/anchor,
/// folded with `parent_in`) and always pushes it as a `k_matrix`
/// value. With a texture: builds the auto-scaled real matrix via
/// `adjust_matrix_by_resolutions`; if it is not identity, renders a
/// shader job at the GLOBAL video params (the transform may change
/// the size) binding `ove_maintex` and `ove_mvpmat` with the
/// interpolation selected by `interpolation_in`, and pushes that;
/// identity matrix (or no texture) -> pass-through push of the
/// input texture value.
///
/// The real matrix needs the texture's params and the sequence
/// resolution (the Rust texture handle carries no params and the
/// value() signature no globals), so the identity check — and thus
/// the pass-through-vs-job decision — is not representable here: with
/// a texture the job is always queued for the renderer seam
/// (`// CPP-PARITY: transformdistortnode.cpp` value()).
fn value(
&self,
core: &NodeCore,
inputs: &crate::value::NodeValueRow,
time: oakcore_rs::Rational,
table: &mut crate::value::NodeValueTable,
) {
let parent = match inputs.get(PARENT_INPUT) {
Some(crate::value::NodeValue::Matrix(m)) => *m,
_ => match core.value_at_time(PARENT_INPUT, -1, time) {
crate::value::NodeValue::Matrix(m) => m,
_ => super::mathbase::identity_matrix(),
},
};
// Generate matrix.
let generated_matrix = super::matrix::MatrixGenerator::generate_matrix(
inputs,
core,
time,
false,
false,
false,
parent,
);
table.push(
crate::value::ValueType::Matrix,
crate::value::NodeValue::Matrix(generated_matrix),
None,
);
match inputs.get(TEXTURE_INPUT) {
Some(tex @ crate::value::NodeValue::Texture(_)) => {
// C++ builds the auto-scaled real matrix and pushes a job
// at the global video params binding `ove_maintex` /
// `ove_mvpmat`; the deferred job is resolved by the
// renderer seam (`// CPP-PARITY: transformdistortnode.cpp`
// value()).
let _ = tex;
table.push(
crate::value::ValueType::Texture,
crate::value::NodeValue::Texture(crate::handle::CHandle::null()),
None,
);
}
_ => {
// No texture: C++ re-pushes the input value (k_none),
// which is a no-op here.
}
}
}
/// Shader code request (C++ `get_shader_code()`): ignores the
/// request id and returns a default (empty) `ShaderCode` — the
/// node relies on the renderer's default vertex/fragment shaders,
/// so this maps to `None`.
fn shader_code(&self, request: &str) -> Option<String> {
let _ = request;
None
}
/// Gizmo transform/positions (C++ `update_gizmo_positions()` and
/// `gizmo_transformation()`). Positions: with a texture, builds the
/// rectangle matrix (sequence half-res scale folded with the
/// auto-scaled generated matrix), maps the unit square through it
/// for the polygon gizmo, places the anchor gizmo at the mapped
/// origin, the eight scale handles at the mapped unit-square
/// corners/edge midpoints, and sets the `offset` input property of
/// `pos_in` (half sequence res + texture offset) and `anchor_in`
/// (half texture size). Transformation: with a texture returns the
/// auto-scaled generated matrix (generated with an identity parent);
/// without one falls back to the base `MatrixGenerator`
/// transformation.
///
/// Every placement needs the texture's params and the sequence
/// resolution (neither available here), and the gizmo point
/// positions have no storage in [`Gizmo`] — not representable
/// (`// CPP-PARITY: transformdistortnode.cpp`
/// `update_gizmo_positions` / `gizmo_transformation`).
fn gizmo_update(&self, core: &NodeCore, row: &crate::value::NodeValueRow) {
let _ = (core, row);
}
/// Gizmo drag callbacks (C++ `gizmo_drag_start()` /
/// `gizmo_drag_move()`). Start: anchor gizmo -> stores the inverted
/// anchor-aware generated matrix; scale handle -> requires a
/// texture, records uniform-scale state, screen-space anchor point,
/// per-handle axes (corners both, left/right center X, top/bottom
/// center Y), the texture-space scale anchor flipped for
/// right/bottom handles, and the inverted fully-generated matrix;
/// rotation gizmo -> records the anchor point, start/last angles
/// (raw and perpendicular) and resets the wrap counter and
/// direction. Move: polygon gizmo -> drags the position X/Y tracks
/// by the mouse delta; anchor gizmo -> drags the anchor tracks by
/// the inverse-matrix-mapped delta and the position tracks by the
/// raw delta; rotation gizmo -> accumulates wrap-aware angle
/// (detecting direction reversal across ±pi using the perpendicular
/// angle) and drags the rotation track by the degree difference
/// from the start angle; scale handle -> maps the mouse delta into
/// inverted-matrix space relative to the anchor, then drags the
/// scale tracks by the normalized magnitude per the handle's axes,
/// collapsing to a single uniform value (diagonal ratio for corner
/// handles) when uniform scale is on.
///
/// The drags write keyframe tracks through `NodeInputDragger`s with
/// per-drag start values, which the Rust data model does not carry —
/// not representable here (`// CPP-PARITY: transformdistortnode.cpp`
/// `gizmo_drag_start` / `gizmo_drag_move`). The drag-state fields
/// above mirror the C++ members but are never written.
fn gizmo_drag(&mut self, core: &mut NodeCore, start: bool, x: f64, y: f64, modifiers: u32) {
let _ = (core, start, x, y, modifiers);
}
/// Deep copy (C++ `copy()`).
fn duplicate(&self, _core: &NodeCore) -> Option<Box<dyn NodeBehavior>> {
Some(Box::new(TransformDistortNode {
matrix: super::matrix::MatrixGenerator,
gizmo_start_angle: self.gizmo_start_angle,
gizmo_anchor_pt: self.gizmo_anchor_pt,
gizmo_scale_uniform: self.gizmo_scale_uniform,
gizmo_last_angle: self.gizmo_last_angle,
gizmo_last_alt_angle: self.gizmo_last_alt_angle,
gizmo_rotate_wrap: self.gizmo_rotate_wrap,
gizmo_rotate_last_dir: self.gizmo_rotate_last_dir,
gizmo_rotate_last_alt_dir: self.gizmo_rotate_last_alt_dir,
gizmo_scale_axes: self.gizmo_scale_axes,
gizmo_scale_anchor: self.gizmo_scale_anchor,
point_gizmo: self.point_gizmo.clone(),
anchor_gizmo: self.anchor_gizmo.clone(),
poly_gizmo: self.poly_gizmo.clone(),
rotation_gizmo: self.rotation_gizmo.clone(),
}))
}
}
/// Constructor (C++ `TransformDistortNode::TransformDistortNode()`):
/// adds `parent_in` (matrix), `autoscale_in` (combo, default 0) and
/// `interpolation_in` (combo, default 2), and PREPENDS `tex_in`
/// (texture, not-keyframable) ahead of the inherited `MatrixGenerator`
/// inputs; creates the rotation screen gizmo (absolute drag behavior,
/// bound to `rot_in`), the frame polygon gizmo (bound to `pos_in`), the
/// anchor point gizmo (anchor shape, bound to `anchor_in` and `pos_in`)
/// and the eight scale point gizmos (absolute drag behavior, bound to
/// `scale_in`); sets the video-effect flag and the effect input.
pub fn create() -> (NodeCore, Box<dyn NodeBehavior>) {
// Inherit the MatrixGenerator inputs (pos/rot/scale/uniform/anchor).
let (mut core, _) = super::matrix::create();
let mut tex = crate::input::Input::new(
TEXTURE_INPUT,
crate::value::ValueType::Texture,
crate::value::NodeValue::None,
);
tex.flags |= crate::input::flags::NOT_KEYFRAMABLE;
let parent_input = crate::input::Input::new(
PARENT_INPUT,
crate::value::ValueType::Matrix,
crate::value::NodeValue::None,
);
let autoscale_input = crate::input::Input::new(
AUTOSCALE_INPUT,
crate::value::ValueType::Combo,
crate::value::NodeValue::Combo(AutoScaleType::None as i64),
);
let interpolation_input = crate::input::Input::new(
INTERPOLATION_INPUT,
crate::value::ValueType::Combo,
crate::value::NodeValue::Combo(2),
);
// The C++ input order is `enabled_in`, `tex_in` (prepended), the
// node's own inputs, then the inherited matrix inputs. `tex_in` goes
// right after `enabled_in`; `parent_in`/`autoscale_in`/
// `interpolation_in` go between it and the matrix inputs (inserted
// in reverse at index 2 to keep that order).
core.inputs.insert(1, tex);
core.inputs.insert(2, interpolation_input);
core.inputs.insert(2, autoscale_input);
core.inputs.insert(2, parent_input);
// Gizmos, in C++ add order: the rotation screen gizmo (absolute drag
// behavior, bound to `rot_in`), the frame polygon gizmo (bound to
// `pos_in`), the anchor point gizmo (bound to `anchor_in` and
// `pos_in`), and the eight scale point gizmos (absolute drag
// behavior, bound to `scale_in`) in `k_gizmo_scale_*` order.
let rotation_gizmo = Gizmo {
position_inputs: vec![(
super::matrix::ROTATION_INPUT.to_string(),
-1,
0,
)],
drag_point: (0.0, 0.0),
};
let poly_gizmo = Gizmo {
position_inputs: vec![
(super::matrix::POSITION_INPUT.to_string(), -1, 0),
(super::matrix::POSITION_INPUT.to_string(), -1, 1),
],
drag_point: (0.0, 0.0),
};
let anchor_gizmo = Gizmo {
position_inputs: vec![
(super::matrix::ANCHOR_INPUT.to_string(), -1, 0),
(super::matrix::ANCHOR_INPUT.to_string(), -1, 1),
(super::matrix::POSITION_INPUT.to_string(), -1, 0),
(super::matrix::POSITION_INPUT.to_string(), -1, 1),
],
drag_point: (0.0, 0.0),
};
let scale_gizmo = |_i: usize| Gizmo {
position_inputs: vec![
(super::matrix::SCALE_INPUT.to_string(), -1, 0),
(super::matrix::SCALE_INPUT.to_string(), -1, 1),
],
drag_point: (0.0, 0.0),
};
let point_gizmo: [Gizmo; GIZMO_SCALE_COUNT] = [
scale_gizmo(0),
scale_gizmo(1),
scale_gizmo(2),
scale_gizmo(3),
scale_gizmo(4),
scale_gizmo(5),
scale_gizmo(6),
scale_gizmo(7),
];
core.gizmos = vec![
rotation_gizmo.clone(),
poly_gizmo.clone(),
anchor_gizmo.clone(),
point_gizmo[0].clone(),
point_gizmo[1].clone(),
point_gizmo[2].clone(),
point_gizmo[3].clone(),
point_gizmo[4].clone(),
point_gizmo[5].clone(),
point_gizmo[6].clone(),
point_gizmo[7].clone(),
];
core.flags |= crate::node::flags::VIDEO_EFFECT;
core.effect_input = TEXTURE_INPUT.to_string();
(
core,
Box::new(TransformDistortNode {
matrix: super::matrix::MatrixGenerator,
gizmo_start_angle: 0.0,
gizmo_anchor_pt: (0.0, 0.0),
gizmo_scale_uniform: false,
gizmo_last_angle: 0.0,
gizmo_last_alt_angle: 0.0,
gizmo_rotate_wrap: 0,
gizmo_rotate_last_dir: RotationDirection::None,
gizmo_rotate_last_alt_dir: RotationDirection::None,
gizmo_scale_axes: GizmoScaleType::Both,
gizmo_scale_anchor: (0.0, 0.0),
point_gizmo,
anchor_gizmo,
poly_gizmo,
rotation_gizmo,
}),
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::node::NodeBehavior;
use crate::value::{NodeValue, NodeValueTable, ValueType};
use oakcore_rs::Rational;
fn tex() -> NodeValue {
NodeValue::Texture(crate::handle::CHandle::null())
}
fn empty_gizmo() -> Gizmo {
Gizmo {
position_inputs: vec![],
drag_point: (0.0, 0.0),
}
}
fn empty_node() -> TransformDistortNode {
TransformDistortNode {
matrix: super::super::matrix::MatrixGenerator,
gizmo_start_angle: 0.0,
gizmo_anchor_pt: (0.0, 0.0),
gizmo_scale_uniform: false,
gizmo_last_angle: 0.0,
gizmo_last_alt_angle: 0.0,
gizmo_rotate_wrap: 0,
gizmo_rotate_last_dir: RotationDirection::None,
gizmo_rotate_last_alt_dir: RotationDirection::None,
gizmo_scale_axes: GizmoScaleType::Both,
gizmo_scale_anchor: (0.0, 0.0),
point_gizmo: std::array::from_fn(|_| empty_gizmo()),
anchor_gizmo: empty_gizmo(),
poly_gizmo: empty_gizmo(),
rotation_gizmo: empty_gizmo(),
}
}
#[test]
fn input_names() {
let n = empty_node();
assert_eq!(n.input_name(PARENT_INPUT), "Parent");
assert_eq!(n.input_name(AUTOSCALE_INPUT), "Auto-Scale");
assert_eq!(n.input_name(TEXTURE_INPUT), "Texture");
assert_eq!(n.input_name(INTERPOLATION_INPUT), "Interpolation");
// Inherited matrix-generator names.
assert_eq!(n.input_name(super::super::matrix::POSITION_INPUT), "Position");
assert_eq!(n.input_name(super::super::matrix::ROTATION_INPUT), "Rotation");
assert_eq!(n.input_name(super::super::matrix::SCALE_INPUT), "Scale");
assert_eq!(
n.input_name(super::super::matrix::UNIFORM_SCALE_INPUT),
"Uniform Scale"
);
assert_eq!(n.input_name(super::super::matrix::ANCHOR_INPUT), "Anchor Point");
}
#[test]
fn create_wires_inputs_and_flags() {
let (core, behavior) = create();
assert_eq!(behavior.type_id(), "org.olivevideoeditor.Olive.transform");
// C++ input order: enabled_in, tex_in (prepended), parent_in,
// autoscale_in, interpolation_in, then the inherited matrix inputs.
let ids: Vec<&str> = core.inputs.iter().map(|i| i.id.as_str()).collect();
assert_eq!(
ids,
vec![
crate::node::ENABLED_INPUT,
TEXTURE_INPUT,
PARENT_INPUT,
AUTOSCALE_INPUT,
INTERPOLATION_INPUT,
super::super::matrix::POSITION_INPUT,
super::super::matrix::ROTATION_INPUT,
super::super::matrix::SCALE_INPUT,
super::super::matrix::UNIFORM_SCALE_INPUT,
super::super::matrix::ANCHOR_INPUT,
]
);
assert_eq!(
core.get_input(AUTOSCALE_INPUT).unwrap().default,
NodeValue::Combo(0)
);
assert_eq!(
core.get_input(INTERPOLATION_INPUT).unwrap().default,
NodeValue::Combo(2)
);
// Eleven gizmos: rotation + polygon + anchor + eight scale points.
assert_eq!(core.gizmos.len(), 11);
assert_eq!(core.effect_input, TEXTURE_INPUT);
assert_ne!(core.flags & crate::node::flags::VIDEO_EFFECT, 0);
}
#[test]
fn value_pushes_matrix_and_job_with_texture() {
let (core, behavior) = create();
let inputs = crate::value::NodeValueRow::from([(TEXTURE_INPUT.to_string(), tex())]);
let mut table = NodeValueTable::default();
behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
// Matrix output always pushed; texture job queued for the seam.
assert!(table.get(ValueType::Matrix).is_some());
assert!(table.get(ValueType::Texture).is_some());
}
#[test]
fn value_without_texture_pushes_matrix_only() {
let (core, behavior) = create();
let mut table = NodeValueTable::default();
behavior.value(
&core,
&crate::value::NodeValueRow::default(),
Rational::new(0, 1),
&mut table,
);
assert!(table.get(ValueType::Matrix).is_some());
assert!(table.get(ValueType::Texture).is_none());
}
#[test]
fn value_folds_parent_matrix() {
let (mut core, behavior) = create();
let mut parent = super::super::mathbase::identity_matrix();
parent[3] = 50.0;
core.set_standard_value(PARENT_INPUT, -1, NodeValue::Matrix(parent));
let mut table = NodeValueTable::default();
behavior.value(
&core,
&crate::value::NodeValueRow::default(),
Rational::new(0, 1),
&mut table,
);
let m = match table.get(ValueType::Matrix).unwrap() {
NodeValue::Matrix(m) => *m,
_ => panic!("matrix expected"),
};
assert_eq!(m[3], 50.0, "parent translation folds through");
}
#[test]
fn shader_code_returns_none() {
let n = empty_node();
assert!(n.shader_code("anything").is_none());
}
#[test]
fn adjust_matrix_identity_when_matching_resolutions() {
// sequence == texture at a power-of-two resolution: the
// scale(2/res) * scale(res/2) composition is exactly identity.
let m = TransformDistortNode::adjust_matrix_by_resolutions(
super::super::mathbase::identity_matrix(),
(640.0, 360.0),
(640.0, 360.0),
(0.0, 0.0),
AutoScaleType::None,
);
assert!(super::super::mathbase::matrix_is_identity(m));
}
#[test]
fn adjust_matrix_translation_and_scale() {
// sequence (640, 360), texture (320, 180), offset (10, 20):
// scale(2/640) * translate(10, 20) * scale(160, 90) = diag(0.5)
// with m[0][3] = 10*2/640 and m[1][3] = 20*2/360.
let m = TransformDistortNode::adjust_matrix_by_resolutions(
super::super::mathbase::identity_matrix(),
(640.0, 360.0),
(320.0, 180.0),
(10.0, 20.0),
AutoScaleType::None,
);
assert!((m[0] - 0.5).abs() < 1e-12);
assert!((m[5] - 0.5).abs() < 1e-12);
assert!((m[3] - 10.0 * 2.0 / 640.0).abs() < 1e-12);
assert!((m[7] - 20.0 * 2.0 / 360.0).abs() < 1e-12);
}
#[test]
fn adjust_matrix_stretch_autoscale() {
// Stretch scales per-axis to the sequence size on top of the
// base composition: with sequence == texture the base is already
// identity, and stretch adds scale(seq/tex) = 1 — identity.
let m = TransformDistortNode::adjust_matrix_by_resolutions(
super::super::mathbase::identity_matrix(),
(640.0, 360.0),
(640.0, 360.0),
(0.0, 0.0),
AutoScaleType::Stretch,
);
assert!(super::super::mathbase::matrix_is_identity(m));
// Half-size texture with a position offset: the base composition
// halves the texture (0.5) and stretch's (2, 2) brings it back to
// unit scale. The offset lives in the pre-scale matrix, so the
// sequence-space scale (2/640) applies to it: 100 * 2/640 = 0.3125
// (post-multiplied scales preserve the translation column).
let mat = super::super::matrix::matrix_translate(
super::super::mathbase::identity_matrix(),
100.0,
0.0,
);
let m = TransformDistortNode::adjust_matrix_by_resolutions(
mat,
(640.0, 360.0),
(320.0, 180.0),
(0.0, 0.0),
AutoScaleType::Stretch,
);
assert!((m[0] - 1.0).abs() < 1e-12);
assert!((m[5] - 1.0).abs() < 1e-12);
assert!((m[3] - 100.0 * 2.0 / 640.0).abs() < 1e-12, "offset scaled into sequence units");
}
#[test]
fn create_scale_point_maps_and_offsets() {
let m = super::super::mathbase::identity_matrix();
let pt = TransformDistortNode::create_scale_point(1.0, -1.0, (320.0, 180.0), m);
assert_eq!(pt, (321.0, 179.0));
}
#[test]
fn get_direction_from_angles() {
assert_eq!(
TransformDistortNode::get_direction_from_angles(0.0, 1.0),
RotationDirection::Positive
);
assert_eq!(
TransformDistortNode::get_direction_from_angles(1.0, 0.0),
RotationDirection::Negative
);
}
#[test]
fn duplicate_clones() {
let (core, behavior) = create();
let dup = behavior.duplicate(&core).unwrap();
assert_eq!(dup.name(), "Transform");
assert_eq!(dup.short_name(), "Transform");
}
}
/// Register this node type (C++ factory entry for
/// `org.olivevideoeditor.Olive.transform`).
pub fn register(meta: &mut Vec<NodeMeta>) {
meta.push(NodeMeta {
type_id: "org.olivevideoeditor.Olive.transform",
name: "Transform",
categories: &[Category::Distort],
create,
});
}
+336
View File
@@ -0,0 +1,336 @@
// 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/>.
//! Trigonometry node (C++
//! `src/node/src/math/trigonometry/trigonometry.{h,cpp}`,
//! `olive::TrigonometryNode`).
use crate::factory::NodeMeta;
use crate::node::{Category, NodeBehavior, NodeCore};
/// Operation/method input id (C++ `k_method_in`). Type: combo;
/// flags: not-connectable, not-keyframable; combo strings: Sine,
/// Cosine, Tangent, Inverse Sine, Inverse Cosine, Inverse Tangent,
/// Hyperbolic Sine, Hyperbolic Cosine, Hyperbolic Tangent.
pub const METHOD_INPUT: &str = "method_in";
/// Operand input id (C++ `k_x_in`). Type: float; default `0.0`.
pub const X_INPUT: &str = "x_in";
/// Trigonometry operation (C++ private `TrigonometryNode::Operation`;
/// discriminants are the `method_in` combo indices).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Operation {
/// `sin(x)` (C++ `k_op_sine`).
Sine,
/// `cos(x)` (C++ `k_op_cosine`).
Cosine,
/// `tan(x)` (C++ `k_op_tangent`).
Tangent,
/// `asin(x)` (C++ `k_op_arc_sine`).
ArcSine,
/// `acos(x)` (C++ `k_op_arc_cosine`).
ArcCosine,
/// `atan(x)` (C++ `k_op_arc_tangent`).
ArcTangent,
/// `sinh(x)` (C++ `k_op_hyp_sine`).
HyperbolicSine,
/// `cosh(x)` (C++ `k_op_hyp_cosine`).
HyperbolicCosine,
/// `tanh(x)` (C++ `k_op_hyp_tangent`).
HyperbolicTangent,
}
/// Trigonometry node. Unit-like — the C++ class has no own data
/// members (its `Operation` enum is modeled above; inputs live in
/// [`NodeCore`]).
pub struct TrigonometryNode;
impl NodeBehavior for TrigonometryNode {
/// Human-readable name (C++ `name()`).
fn name(&self) -> &str {
"Trigonometry"
}
/// Stable type id (C++ `id()`).
fn type_id(&self) -> &str {
"org.olivevideoeditor.Olive.trigonometry"
}
/// Categories (C++ `category()`).
fn categories(&self) -> &[Category] {
&[Category::Math]
}
/// Description (C++ `description()`).
fn description(&self) -> &str {
"Perform a trigonometry operation on a value."
}
/// Localized input names (C++ `retranslate()`): `method_in` ->
/// "Method", `x_in` -> "Value"; also sets the combo strings on
/// `method_in` to the nine function names documented on
/// [`METHOD_INPUT`].
fn input_name<'a>(&self, id: &'a str) -> &'a str {
match id {
METHOD_INPUT => "Method",
X_INPUT => "Value",
_ => id,
}
}
/// Evaluate outputs (C++ `value()`): reads `x_in` as a double,
/// applies the [`Operation`] selected by `method_in`
/// (sin/cos/tan/asin/acos/atan/sinh/cosh/tanh), and pushes the
/// result as a float.
fn value(
&self,
core: &NodeCore,
inputs: &crate::value::NodeValueRow,
time: oakcore_rs::Rational,
table: &mut crate::value::NodeValueTable,
) {
let mut x = match inputs.get(X_INPUT) {
Some(v) => v.to_double(),
None => core.value_at_time(X_INPUT, -1, time).to_double(),
};
match self.operation(core) {
Operation::Sine => x = x.sin(),
Operation::Cosine => x = x.cos(),
Operation::Tangent => x = x.tan(),
Operation::ArcSine => x = x.asin(),
Operation::ArcCosine => x = x.acos(),
Operation::ArcTangent => x = x.atan(),
Operation::HyperbolicSine => x = x.sinh(),
Operation::HyperbolicCosine => x = x.cosh(),
Operation::HyperbolicTangent => x = x.tanh(),
}
table.push(crate::value::ValueType::Float, crate::value::NodeValue::Float(x), None);
}
/// Deep copy (C++ `copy()`).
fn duplicate(&self, _core: &NodeCore) -> Option<Box<dyn NodeBehavior>> {
Some(Box::new(TrigonometryNode))
}
}
impl TrigonometryNode {
/// The operation selected by the `method_in` combo (C++
/// `TrigonometryNode::get_operation()`).
pub fn operation(&self, core: &NodeCore) -> Operation {
match core.standard_value(METHOD_INPUT, -1).to_double() as usize {
0 => Operation::Sine,
1 => Operation::Cosine,
2 => Operation::Tangent,
3 => Operation::ArcSine,
4 => Operation::ArcCosine,
5 => Operation::ArcTangent,
6 => Operation::HyperbolicSine,
7 => Operation::HyperbolicCosine,
_ => Operation::HyperbolicTangent,
}
}
}
/// Constructor (C++ `TrigonometryNode::TrigonometryNode()`): adds
/// `method_in` as a not-connectable/not-keyframable combo and `x_in`
/// as a float input defaulting to 0.0.
pub fn create() -> (NodeCore, Box<dyn NodeBehavior>) {
let mut core = NodeCore::new();
let mut method = crate::input::Input::new(
METHOD_INPUT,
crate::value::ValueType::Combo,
crate::value::NodeValue::Combo(0),
);
method.flags |= crate::input::flags::NOT_CONNECTABLE | crate::input::flags::NOT_KEYFRAMABLE;
method.properties = vec![(
"combobox_strings".to_string(),
crate::value::NodeValue::Binary(
OPERATION_NAMES
.concat()
.into_bytes(),
),
)];
core.add_input(method);
let mut x = crate::input::Input::new(
X_INPUT,
crate::value::ValueType::Float,
crate::value::NodeValue::Float(0.0),
);
core.add_input(x);
(core, Box::new(TrigonometryNode))
}
/// Operation combo strings (C++ `retranslate` order).
pub const OPERATION_NAMES: [&str; 9] = [
"Sine",
"Cosine",
"Tangent",
"Inverse Sine",
"Inverse Cosine",
"Inverse Tangent",
"Hyperbolic Sine",
"Hyperbolic Cosine",
"Hyperbolic Tangent",
];
#[cfg(test)]
mod tests {
use super::*;
use crate::node::NodeBehavior;
use crate::value::{NodeValue, NodeValueTable, ValueType};
use oakcore_rs::Rational;
#[test]
fn input_names() {
let n = TrigonometryNode;
assert_eq!(n.input_name(METHOD_INPUT), "Method");
assert_eq!(n.input_name(X_INPUT), "Value");
}
#[test]
fn create_wires_inputs() {
let (core, behavior) = create();
assert_eq!(behavior.type_id(), "org.olivevideoeditor.Olive.trigonometry");
assert_eq!(core.get_input(X_INPUT).unwrap().default, NodeValue::Float(0.0));
let method = core.get_input(METHOD_INPUT).unwrap();
assert_ne!(method.flags & crate::input::flags::NOT_CONNECTABLE, 0);
}
#[test]
fn value_sine() {
let (core, behavior) = create();
let inputs = crate::value::NodeValueRow::from([(
X_INPUT.to_string(),
NodeValue::Float(std::f64::consts::FRAC_PI_2),
)]);
let mut table = NodeValueTable::default();
behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
assert_eq!(table.get(ValueType::Float), Some(&NodeValue::Float(1.0)));
}
#[test]
fn value_cosine_and_tangent() {
let (mut core, behavior) = create();
core.set_standard_value(METHOD_INPUT, -1, NodeValue::Combo(1));
let inputs = crate::value::NodeValueRow::from([(
X_INPUT.to_string(),
NodeValue::Float(0.0),
)]);
let mut table = NodeValueTable::default();
behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
assert_eq!(table.get(ValueType::Float), Some(&NodeValue::Float(1.0)));
core.set_standard_value(METHOD_INPUT, -1, NodeValue::Combo(2));
let inputs = crate::value::NodeValueRow::from([(
X_INPUT.to_string(),
NodeValue::Float(std::f64::consts::FRAC_PI_4),
)]);
let mut table = NodeValueTable::default();
behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
let v = table.get(ValueType::Float).unwrap().to_double();
assert!((v - 1.0).abs() < 1e-9);
}
#[test]
fn value_uses_standard_operand() {
let (mut core, behavior) = create();
core.set_standard_value(X_INPUT, -1, NodeValue::Float(0.0));
let mut table = NodeValueTable::default();
behavior.value(&core, &crate::value::NodeValueRow::default(), Rational::new(0, 1), &mut table);
assert_eq!(table.get(ValueType::Float), Some(&NodeValue::Float(0.0)));
}
#[test]
fn operation_mapping() {
let (mut core, _) = create();
let n = TrigonometryNode;
core.set_standard_value(METHOD_INPUT, -1, NodeValue::Combo(3));
assert_eq!(n.operation(&core), Operation::ArcSine);
core.set_standard_value(METHOD_INPUT, -1, NodeValue::Combo(8));
assert_eq!(n.operation(&core), Operation::HyperbolicTangent);
core.set_standard_value(METHOD_INPUT, -1, NodeValue::Combo(99));
assert_eq!(n.operation(&core), Operation::HyperbolicTangent, "clamped");
}
#[test]
fn value_all_operations() {
use std::f64::consts::FRAC_PI_4;
let (mut core, behavior) = create();
// (combo index, input, expected fn applied)
let cases: [(i64, f64, fn(f64) -> f64); 9] = [
(0, FRAC_PI_4, f64::sin),
(1, FRAC_PI_4, f64::cos),
(2, FRAC_PI_4, f64::tan),
(3, 0.5, f64::asin),
(4, 0.5, f64::acos),
(5, 0.5, f64::atan),
(6, 0.5, f64::sinh),
(7, 0.5, f64::cosh),
(8, 0.5, f64::tanh),
];
for (op, x, f) in cases {
core.set_standard_value(METHOD_INPUT, -1, NodeValue::Combo(op));
let inputs = crate::value::NodeValueRow::from([(
X_INPUT.to_string(),
NodeValue::Float(x),
)]);
let mut table = NodeValueTable::default();
behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
let got = table.get(ValueType::Float).unwrap().to_double();
assert!((got - f(x)).abs() < 1e-12, "op {}: got {}, want {}", op, got, f(x));
}
}
#[test]
fn value_keyframed_operand() {
let (mut core, behavior) = create();
core.keyframe_track_mut(X_INPUT, -1).set_key(crate::keyframe::Keyframe {
time: Rational::new(0, 1),
value: NodeValue::Float(1.0),
interpolation: crate::keyframe::Interpolation::Linear,
bezier_in: (0.0, 0.0),
bezier_out: (0.0, 0.0),
});
let mut table = NodeValueTable::default();
behavior.value(&core, &crate::value::NodeValueRow::default(), Rational::new(0, 1), &mut table);
let v = table.get(ValueType::Float).unwrap().to_double();
assert!((v - 1.0_f64.sin()).abs() < 1e-12);
}
#[test]
fn duplicate_clones() {
let (core, behavior) = create();
let dup = behavior.duplicate(&core).unwrap();
assert_eq!(dup.name(), "Trigonometry");
}
}
/// Register this node type (C++ `k_trigonometry_node` in
/// `factory.cpp::create_from_factory_index`).
pub fn register(meta: &mut Vec<NodeMeta>) {
meta.push(NodeMeta {
type_id: "org.olivevideoeditor.Olive.trigonometry",
name: "Trigonometry",
categories: &[Category::Math],
create,
});
}
+232
View File
@@ -0,0 +1,232 @@
// 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/>.
//! Constant-value generator node (C++
//! `src/node/src/input/value/valuenode.{h,cpp}`, `olive::ValueNode`).
use crate::factory::NodeMeta;
use crate::node::{Category, NodeBehavior, NodeCore};
use crate::value::ValueType;
/// Type selector input id (C++ `k_type_input`). Type: combo; default
/// `0` (the first entry of [`SUPPORTED_TYPES`]); flags: not-connectable,
/// not-keyframable. Combo strings are the pretty data-type names of the
/// supported types (built in `retranslate()`).
pub const TYPE_INPUT: &str = "type_in";
/// Value input id (C++ `k_value_input`). Type: initially
/// `SUPPORTED_TYPES[0]` (float), switched by `input_value_changed` when
/// `type_in` changes; default: empty variant; flags: not-connectable.
pub const VALUE_INPUT: &str = "value_in";
/// Selectable value types (C++ `k_supported_types`). The C++ list is:
/// float, int, rational, vec2, vec3, vec4, color, text, matrix, font,
/// boolean. The Rust [`ValueType`] enum has no `Matrix` or `Font`
/// variants, so those two entries are omitted here (a behavioral gap to
/// resolve when matrix/font values land in `value.rs`).
pub const SUPPORTED_TYPES: &[ValueType] = &[
ValueType::Float,
ValueType::Int,
ValueType::Rational,
ValueType::Vec2,
ValueType::Vec3,
ValueType::Vec4,
ValueType::Color,
ValueType::Text,
ValueType::Boolean,
];
/// Value node. Holds a single typed constant that can be connected to
/// other nodes' inputs. The C++ class has no own members (its state
/// lives entirely in the two inputs), so this is a unit-like struct.
pub struct ValueNode;
impl NodeBehavior for ValueNode {
/// Human-readable name (C++ `name()`).
fn name(&self) -> &str {
"Value"
}
/// Stable type id (C++ `id()`).
fn type_id(&self) -> &str {
"org.olivevideoeditor.Olive.value"
}
/// Categories (C++ `category()` returns
/// `{ k_category_generator }`).
fn categories(&self) -> &[Category] {
&[Category::Generator]
}
/// Description (C++ `description()`).
fn description(&self) -> &str {
"Create a single value that can be connected to various other inputs."
}
/// Localized input names (C++ `retranslate()`): `type_in` ->
/// "Type", `value_in` -> "Value". The C++ override also sets the
/// `type_in` combo strings to the pretty names of the supported
/// types — that part has no trait surface and is noted here only.
fn input_name<'a>(&self, id: &'a str) -> &'a str {
match id {
TYPE_INPUT => "Type",
VALUE_INPUT => "Value",
_ => id,
}
}
/// Evaluate outputs (C++ `value()`): pushes the `value_in` value
/// onto the table unchanged.
fn value(
&self,
core: &NodeCore,
inputs: &crate::value::NodeValueRow,
time: oakcore_rs::Rational,
table: &mut crate::value::NodeValueTable,
) {
let _ = inputs;
let v = core.value_at_time(VALUE_INPUT, -1, time);
table.push(v.value_type(), v, None);
}
/// Input value changed (C++ `InputValueChangedEvent()`): when
/// `type_in` changes, sets the data type of `value_in` to
/// `SUPPORTED_TYPES[type index]`; then defers to the base-class
/// behavior.
fn input_value_changed(&mut self, core: &mut NodeCore, input: &str, element: i32) {
if input == TYPE_INPUT && element == -1 {
let idx = core.standard_value(TYPE_INPUT, -1).to_double() as usize;
if let Some(ty) = SUPPORTED_TYPES.get(idx) {
if let Some(value_in) = core.get_input_mut(VALUE_INPUT) {
value_in.value_type = *ty;
}
}
}
}
/// Deep copy (C++ `copy()` via `NODE_DEFAULT_FUNCTIONS`).
fn duplicate(&self, _core: &NodeCore) -> Option<Box<dyn NodeBehavior>> {
Some(Box::new(ValueNode))
}
}
/// Constructor (C++ `ValueNode::ValueNode()`): adds `type_in` and
/// `value_in` with the defaults, flags and properties documented on the
/// constants.
pub fn create() -> (NodeCore, Box<dyn NodeBehavior>) {
let mut core = NodeCore::new();
let mut type_input = crate::input::Input::new(
TYPE_INPUT,
ValueType::Combo,
crate::value::NodeValue::Combo(0),
);
type_input.flags |= crate::input::flags::NOT_CONNECTABLE | crate::input::flags::NOT_KEYFRAMABLE;
core.add_input(type_input);
let mut value_input = crate::input::Input::new(
VALUE_INPUT,
SUPPORTED_TYPES[0],
crate::value::NodeValue::None,
);
value_input.flags |= crate::input::flags::NOT_CONNECTABLE;
core.add_input(value_input);
(core, Box::new(ValueNode))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::node::NodeBehavior;
use crate::value::{NodeValue, NodeValueTable, ValueType};
use oakcore_rs::Rational;
#[test]
fn input_names() {
let n = ValueNode;
assert_eq!(n.input_name(TYPE_INPUT), "Type");
assert_eq!(n.input_name(VALUE_INPUT), "Value");
}
#[test]
fn create_wires_inputs() {
let (core, behavior) = create();
assert_eq!(behavior.type_id(), "org.olivevideoeditor.Olive.value");
assert_eq!(core.get_input(VALUE_INPUT).unwrap().value_type, ValueType::Float);
assert_eq!(
core.get_input(TYPE_INPUT).unwrap().flags & crate::input::flags::NOT_CONNECTABLE,
crate::input::flags::NOT_CONNECTABLE
);
}
#[test]
fn value_pushes_standard_value() {
let (mut core, behavior) = create();
core.set_standard_value(VALUE_INPUT, -1, NodeValue::Float(3.5));
let mut table = NodeValueTable::default();
behavior.value(&core, &crate::value::NodeValueRow::default(), Rational::new(0, 1), &mut table);
assert_eq!(table.get(ValueType::Float), Some(&NodeValue::Float(3.5)));
}
#[test]
fn value_pushes_keyframed_value() {
let (mut core, behavior) = create();
core.keyframe_track_mut(VALUE_INPUT, -1).set_key(crate::keyframe::Keyframe {
time: Rational::new(10, 1),
value: NodeValue::Float(9.0),
interpolation: crate::keyframe::Interpolation::Linear,
bezier_in: (0.0, 0.0),
bezier_out: (0.0, 0.0),
});
let mut table = NodeValueTable::default();
behavior.value(&core, &crate::value::NodeValueRow::default(), Rational::new(10, 1), &mut table);
assert_eq!(table.get(ValueType::Float), Some(&NodeValue::Float(9.0)));
}
#[test]
fn input_value_changed_switches_type() {
let (mut core, behavior) = create();
let mut behavior = behavior;
// Change type_in to vec2 (index 3) and fire the event.
core.set_standard_value(TYPE_INPUT, -1, NodeValue::Combo(3));
behavior.input_value_changed(&mut core, TYPE_INPUT, -1);
assert_eq!(core.get_input(VALUE_INPUT).unwrap().value_type, ValueType::Vec2);
// Out-of-range index leaves the type unchanged.
core.set_standard_value(TYPE_INPUT, -1, NodeValue::Combo(99));
behavior.input_value_changed(&mut core, TYPE_INPUT, -1);
assert_eq!(core.get_input(VALUE_INPUT).unwrap().value_type, ValueType::Vec2);
}
#[test]
fn duplicate_clones() {
let (core, behavior) = create();
let dup = behavior.duplicate(&core).unwrap();
assert_eq!(dup.name(), "Value");
}
}
/// Register this node type (C++ `k_value_node` in
/// `factory.cpp::create_from_factory_index`).
pub fn register(meta: &mut Vec<NodeMeta>) {
meta.push(NodeMeta {
type_id: "org.olivevideoeditor.Olive.value",
name: "Value",
categories: &[Category::Generator],
create,
});
}
+345
View File
@@ -0,0 +1,345 @@
// 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/>.
//! Volume audio effect (C++ `src/node/src/audio/volume/volume.{h,cpp}`,
//! `olive::VolumeNode`).
use crate::factory::NodeMeta;
use crate::node::{Category, NodeBehavior, NodeCore};
use crate::value::{NodeValue, ValueType};
/// Samples input id (C++ `k_samples_input`). Type: samples; flags:
/// not-keyframable; this is the node's effect input
/// (`set_effect_input(k_samples_input)`).
pub const SAMPLES_INPUT: &str = "samples_in";
/// Volume input id (C++ `k_volume_input`). Type: float; default `1.0`;
/// properties: `min = 0.0`, `view = decibel`.
pub const VOLUME_INPUT: &str = "volume_in";
/// Volume effect node. Multiplies every channel of a sample buffer by a
/// gain factor.
///
/// The C++ class derives from `MathNodeBase` (for the shared
/// `process_samples_internal` helper used with `k_op_multiply`) but
/// declares no own data members, so this is a unit-like struct; the
/// multiply-by-scalar sample math will be shared with
/// `super::mathbase` when the base module lands.
pub struct VolumeNode;
impl NodeBehavior for VolumeNode {
/// Human-readable name (C++ `name()`).
fn name(&self) -> &str {
"Volume"
}
/// Stable type id (C++ `id()`).
fn type_id(&self) -> &str {
"org.olivevideoeditor.Olive.volume"
}
/// Categories (C++ `category()`).
fn categories(&self) -> &[Category] {
&[Category::Filter]
}
/// Description (C++ `description()`).
fn description(&self) -> &str {
"Adjusts the volume of an audio source."
}
/// Localized input names (C++ `retranslate()`): `samples_in` ->
/// "Samples", `volume_in` -> "Volume".
fn input_name<'a>(&self, id: &'a str) -> &'a str {
match id {
SAMPLES_INPUT => "Samples",
VOLUME_INPUT => "Volume",
_ => id,
}
}
/// Evaluate outputs (C++ `value()`): unallocated samples input ->
/// push nothing; allocated samples with a static volume input ->
/// apply the gain immediately and push the transformed samples,
/// skipping the transform when the gain is effectively 1.0 (C++
/// preserves the `!qFuzzyCompare(volume, 1.0)` double semantics:
/// `abs(volume - 1.0) * 1e12 > min(abs(volume), 1.0)`); allocated
/// samples with a non-static volume input -> build a `SampleJob`
/// over `samples_in` + `volume_in` and push it as a samples value.
///
/// The Rust model has no `SampleJob` payload: the dynamic case
/// pushes the input samples through unchanged and the audio
/// renderer applies the per-index gain via [`Self::process_samples`]
/// (`// CPP-PARITY: volume.cpp` `value()`).
fn value(
&self,
core: &NodeCore,
inputs: &crate::value::NodeValueRow,
time: oakcore_rs::Rational,
table: &mut crate::value::NodeValueTable,
) {
let buffer = match inputs.get(SAMPLES_INPUT) {
Some(NodeValue::Samples(b)) if b.is_allocated() => b.clone(),
_ => return,
};
if core.is_input_static(inputs, VOLUME_INPUT, -1) {
let volume = core.value_at_time(VOLUME_INPUT, -1, time).to_double();
// Same semantics as `!qFuzzyCompare(volume, 1.0)` (double
// overload): NOT fuzzy-equal -> transform.
if (volume - 1.0).abs() * 1e12 > volume.abs().min(1.0) {
let mut transformed = buffer;
transformed.transform_volume(volume);
table.push(ValueType::Samples, NodeValue::Samples(transformed), None);
} else {
table.push(ValueType::Samples, NodeValue::Samples(buffer), None);
}
} else {
// Dynamic volume input: deferred sample job.
table.push(ValueType::Samples, NodeValue::Samples(buffer), None);
}
}
/// Process a span of samples (C++ `process_samples()`): delegates to
/// `MathNodeBase::process_samples_internal` with `k_op_multiply`,
/// i.e. each output sample is the input sample multiplied by the
/// `volume_in` value. The C++ signature receives the input buffer
/// and a sample index; the Rust trait instead hands over a time
/// `range` and the destination buffer, so the whole output span is
/// filled here.
fn process_samples(
&self,
core: &NodeCore,
inputs: &crate::value::NodeValueRow,
range: oakcore_rs::TimeRange,
output: &mut crate::value::SampleBuffer,
) {
let input = match inputs.get(SAMPLES_INPUT) {
Some(NodeValue::Samples(b)) => b,
_ => return,
};
let volume = match inputs.get(VOLUME_INPUT) {
Some(v) => v.to_double(),
None => core.value_at_time(VOLUME_INPUT, -1, range.in_()).to_double(),
};
for c in 0..output.channels {
for i in 0..output.sample_count {
let v = input.sample_value(c, i);
output.set_sample_value(c, i, v * volume);
}
}
}
/// Deep copy (C++ `copy()`).
fn duplicate(&self, _core: &NodeCore) -> Option<Box<dyn NodeBehavior>> {
Some(Box::new(VolumeNode))
}
}
/// Constructor (C++ `VolumeNode::VolumeNode()`): adds `samples_in`
/// (samples, not-keyframable) and `volume_in` (float, default 1.0,
/// min/view properties documented on the constant), sets the
/// audio-effect flag and makes `samples_in` the effect input.
pub fn create() -> (NodeCore, Box<dyn NodeBehavior>) {
let mut core = NodeCore::new();
let mut samples = crate::input::Input::new(
SAMPLES_INPUT,
ValueType::Samples,
NodeValue::None,
);
samples.flags |= crate::input::flags::NOT_KEYFRAMABLE;
core.add_input(samples);
let mut volume = crate::input::Input::new(
VOLUME_INPUT,
ValueType::Float,
NodeValue::Float(1.0),
);
volume.properties = vec![
("min".to_string(), NodeValue::Float(0.0)),
("view".to_string(), NodeValue::Text("decibel".into())),
];
core.add_input(volume);
core.flags |= crate::node::flags::AUDIO_EFFECT;
core.effect_input = SAMPLES_INPUT.to_string();
(core, Box::new(VolumeNode))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::value::NodeValueTable;
use oakcore_rs::{Rational, SampleFormat, TimeRange};
fn planar(channels: usize, count: usize, values: &[f64]) -> crate::value::SampleBuffer {
let mut buf = crate::value::SampleBuffer {
format: SampleFormat::F32Planar,
channels,
sample_count: count,
data: vec![0u8; channels * count * 4],
};
for c in 0..channels {
for i in 0..count {
buf.set_sample_value(c, i, values[c * count + i]);
}
}
buf
}
#[test]
fn input_names() {
let n = VolumeNode;
assert_eq!(n.input_name(SAMPLES_INPUT), "Samples");
assert_eq!(n.input_name(VOLUME_INPUT), "Volume");
assert_eq!(n.input_name("other"), "other");
}
#[test]
fn create_wires_inputs_and_flags() {
let (core, behavior) = create();
assert_eq!(behavior.name(), "Volume");
assert_eq!(behavior.type_id(), "org.olivevideoeditor.Olive.volume");
assert!(core.get_input(SAMPLES_INPUT).is_some());
assert_eq!(
core.get_input(SAMPLES_INPUT).unwrap().flags & crate::input::flags::NOT_KEYFRAMABLE,
crate::input::flags::NOT_KEYFRAMABLE
);
assert_eq!(core.get_input(VOLUME_INPUT).unwrap().default, NodeValue::Float(1.0));
assert_eq!(core.effect_input, SAMPLES_INPUT);
assert_ne!(core.flags & crate::node::flags::AUDIO_EFFECT, 0);
}
#[test]
fn value_unallocated_pushes_nothing() {
let (core, behavior) = create();
let mut table = NodeValueTable::default();
let inputs = std::collections::BTreeMap::new();
behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
assert!(table.is_empty());
}
#[test]
fn value_static_volume_applies_gain() {
let (mut core, behavior) = create();
core.set_standard_value(VOLUME_INPUT, -1, NodeValue::Float(0.5));
let buf = planar(1, 2, &[1.0, 2.0]);
let inputs = std::collections::BTreeMap::from([(
SAMPLES_INPUT.to_string(),
NodeValue::Samples(buf.clone()),
)]);
let mut table = NodeValueTable::default();
behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
let out = match table.get(ValueType::Samples).unwrap() {
NodeValue::Samples(s) => s,
_ => panic!("samples"),
};
assert_eq!(out.sample_value(0, 0), 0.5);
assert_eq!(out.sample_value(0, 1), 1.0);
// The input buffer is not mutated.
assert_eq!(buf.sample_value(0, 0), 1.0);
}
#[test]
fn value_static_volume_unity_passes_through() {
let (mut core, behavior) = create();
core.set_standard_value(VOLUME_INPUT, -1, NodeValue::Float(1.0));
let buf = planar(1, 2, &[1.0, 2.0]);
let inputs = std::collections::BTreeMap::from([(
SAMPLES_INPUT.to_string(),
NodeValue::Samples(buf.clone()),
)]);
let mut table = NodeValueTable::default();
behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
let out = match table.get(ValueType::Samples).unwrap() {
NodeValue::Samples(s) => s,
_ => panic!("samples"),
};
assert_eq!(out.sample_value(0, 0), 1.0);
assert_eq!(out.sample_value(0, 1), 2.0);
}
#[test]
fn value_dynamic_volume_pushes_through() {
let (mut core, behavior) = create();
// Keyframing the volume input makes it non-static.
core.keyframe_track_mut(VOLUME_INPUT, -1).set_key(crate::keyframe::Keyframe {
time: Rational::new(0, 1),
value: NodeValue::Float(0.5),
interpolation: crate::keyframe::Interpolation::Linear,
bezier_in: (0.0, 0.0),
bezier_out: (0.0, 0.0),
});
let buf = planar(1, 2, &[1.0, 2.0]);
let inputs = std::collections::BTreeMap::from([(
SAMPLES_INPUT.to_string(),
NodeValue::Samples(buf.clone()),
)]);
let mut table = NodeValueTable::default();
behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
let out = match table.get(ValueType::Samples).unwrap() {
NodeValue::Samples(s) => s,
_ => panic!("samples"),
};
// Unchanged: the renderer applies the gain via process_samples.
assert_eq!(out.sample_value(0, 0), 1.0);
assert_eq!(out.sample_value(0, 1), 2.0);
}
#[test]
fn process_samples_fills_output() {
let (mut core, behavior) = create();
core.set_standard_value(VOLUME_INPUT, -1, NodeValue::Float(2.0));
let buf = planar(2, 2, &[1.0, 2.0, 3.0, 4.0]);
let inputs = std::collections::BTreeMap::from([
(SAMPLES_INPUT.to_string(), NodeValue::Samples(buf)),
(VOLUME_INPUT.to_string(), NodeValue::Float(2.0)),
]);
let mut out = planar(2, 2, &[0.0; 4]);
behavior.process_samples(
&core,
&inputs,
TimeRange::new(Rational::new(0, 1), Rational::new(2, 1)),
&mut out,
);
assert_eq!(out.sample_value(0, 0), 2.0);
assert_eq!(out.sample_value(0, 1), 4.0);
assert_eq!(out.sample_value(1, 0), 6.0);
assert_eq!(out.sample_value(1, 1), 8.0);
}
#[test]
fn duplicate_clones() {
let (core, behavior) = create();
let dup = behavior.duplicate(&core).unwrap();
assert_eq!(dup.name(), "Volume");
assert_eq!(dup.type_id(), "org.olivevideoeditor.Olive.volume");
}
}
/// Register this node type (C++ `k_audio_volume` in
/// `factory.cpp::create_from_factory_index`).
pub fn register(meta: &mut Vec<NodeMeta>) {
meta.push(NodeMeta {
type_id: "org.olivevideoeditor.Olive.volume",
name: "Volume",
categories: &[Category::Filter],
create,
});
}
+316
View File
@@ -0,0 +1,316 @@
// 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/>.
//! Wave distort effect (C++
//! `src/node/src/distort/wave/wavedistortnode.{h,cpp}`,
//! `olive::WaveDistortNode`).
use crate::factory::NodeMeta;
use crate::node::{Category, NodeBehavior, NodeCore};
/// Texture input id (C++ `k_texture_input`). Type: texture; flags:
/// not-keyframable; this is the node's effect input.
pub const TEXTURE_INPUT: &str = "tex_in";
/// Frequency input id (C++ `k_frequency_input`). Type: float; default
/// `10`.
pub const FREQUENCY_INPUT: &str = "frequency_in";
/// Intensity input id (C++ `k_intensity_input`). Type: float; default
/// `10`.
pub const INTENSITY_INPUT: &str = "intensity_in";
/// Evolution input id (C++ `k_evolution_input`). Type: float; default
/// `0`.
pub const EVOLUTION_INPUT: &str = "evolution_in";
/// Direction combo input id (C++ `k_vertical_input`). Type: combo;
/// default `false` (0 = "Horizontal"); combo strings: "Horizontal",
/// "Vertical".
pub const VERTICAL_INPUT: &str = "vertical_in";
/// Wave distort node. Displaces the image along a sine wave. Has no own
/// member fields in C++ (state lives in the `Node` inputs).
pub struct WaveDistortNode;
/// Fragment shader (C++ loads the `:/shaders/wave.frag` resource in
/// `get_shader_code`). Text copied verbatim from
/// `engine/shaders/wave.frag`.
const SHADER_FRAG: &str = r#"uniform float frequency_in;
uniform float intensity_in;
uniform float evolution_in;
uniform bool vertical_in;
uniform sampler2D tex_in;
in vec2 ove_texcoord;
out vec4 frag_color;
void main(void) {
vec2 pos = ove_texcoord;
if (vertical_in) {
pos.x -= sin((ove_texcoord.y-(evolution_in*0.01))*frequency_in)*intensity_in*0.01;
} else {
pos.y -= sin((ove_texcoord.x-(evolution_in*0.01))*frequency_in)*intensity_in*0.01;
}
if (pos.x < 0.0 || pos.x >= 1.0 || pos.y < 0.0 || pos.y >= 1.0) {
discard;
} else {
frag_color = texture(tex_in, pos);
}
}
"#;
impl WaveDistortNode {
/// Fragment shader (C++ `get_shader_code()`; the request id is
/// ignored, this is the only shader).
fn shader_frag() -> &'static str {
SHADER_FRAG
}
}
impl NodeBehavior for WaveDistortNode {
/// Human-readable name (C++ `name()`).
fn name(&self) -> &str {
"Wave"
}
/// Stable type id (C++ `id()`).
fn type_id(&self) -> &str {
"org.olivevideoeditor.Olive.wave"
}
/// Categories (C++ `category()`).
fn categories(&self) -> &[Category] {
&[Category::Distort]
}
/// Description (C++ `description()`).
fn description(&self) -> &str {
"Distorts an image along a sine wave."
}
/// Localized input names (C++ `retranslate()`): `tex_in` ->
/// "Input", `frequency_in` -> "Frequency", `intensity_in` ->
/// "Intensity", `evolution_in` -> "Evolution", `vertical_in` ->
/// "Direction" (combo strings "Horizontal"/"Vertical").
fn input_name<'a>(&self, id: &'a str) -> &'a str {
match id {
TEXTURE_INPUT => "Input",
FREQUENCY_INPUT => "Frequency",
INTENSITY_INPUT => "Intensity",
EVOLUTION_INPUT => "Evolution",
// The `vertical_in` combo strings "Horizontal"/"Vertical" are a
// UI-level property of the input (C++ `set_combo_box_strings`).
VERTICAL_INPUT => "Direction",
_ => id,
}
}
/// Evaluate outputs (C++ `value()`): no texture -> push nothing;
/// intensity != 0.0 -> shader job over the whole value row rendered
/// at the texture's own params; intensity == 0.0 -> pass-through
/// push of the input texture unchanged.
fn value(
&self,
core: &NodeCore,
inputs: &crate::value::NodeValueRow,
time: oakcore_rs::Rational,
table: &mut crate::value::NodeValueTable,
) {
let tex = match inputs.get(TEXTURE_INPUT) {
Some(tex @ crate::value::NodeValue::Texture(_)) => tex.clone(),
_ => return,
};
let intensity = match inputs.get(INTENSITY_INPUT) {
Some(v) => v.to_double(),
None => core.value_at_time(INTENSITY_INPUT, -1, time).to_double(),
};
if intensity != 0.0 {
// C++ pushes `Texture::job(texture->params(), ShaderJob(value))`;
// the deferred job is resolved by the renderer seam
// (`// CPP-PARITY: wavedistortnode.cpp` value()).
table.push(
crate::value::ValueType::Texture,
crate::value::NodeValue::Texture(crate::handle::CHandle::null()),
None,
);
} else {
table.push(crate::value::ValueType::Texture, tex, None);
}
}
/// Shader code request (C++ `get_shader_code()`): ignores the
/// request id and always returns the wave fragment shader.
fn shader_code(&self, _request: &str) -> Option<String> {
Some(Self::shader_frag().to_string())
}
/// Deep copy (C++ `copy()`).
fn duplicate(&self, _core: &NodeCore) -> Option<Box<dyn NodeBehavior>> {
Some(Box::new(WaveDistortNode))
}
}
/// Constructor (C++ `WaveDistortNode::WaveDistortNode()`): adds
/// `tex_in`, `frequency_in`, `intensity_in`, `evolution_in` and
/// `vertical_in` with the defaults documented on the constants, sets
/// the video-effect flag and the effect input.
pub fn create() -> (NodeCore, Box<dyn NodeBehavior>) {
let mut core = NodeCore::new();
let mut tex = crate::input::Input::new(
TEXTURE_INPUT,
crate::value::ValueType::Texture,
crate::value::NodeValue::None,
);
tex.flags |= crate::input::flags::NOT_KEYFRAMABLE;
core.add_input(tex);
core.add_input(crate::input::Input::new(
FREQUENCY_INPUT,
crate::value::ValueType::Float,
crate::value::NodeValue::Float(10.0),
));
core.add_input(crate::input::Input::new(
INTENSITY_INPUT,
crate::value::ValueType::Float,
crate::value::NodeValue::Float(10.0),
));
core.add_input(crate::input::Input::new(
EVOLUTION_INPUT,
crate::value::ValueType::Float,
crate::value::NodeValue::Float(0.0),
));
core.add_input(crate::input::Input::new(
VERTICAL_INPUT,
crate::value::ValueType::Combo,
crate::value::NodeValue::Combo(0),
));
core.flags |= crate::node::flags::VIDEO_EFFECT;
core.effect_input = TEXTURE_INPUT.to_string();
(core, Box::new(WaveDistortNode))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::node::NodeBehavior;
use crate::value::{NodeValue, NodeValueTable, ValueType};
use oakcore_rs::Rational;
fn tex() -> NodeValue {
NodeValue::Texture(crate::handle::CHandle::null())
}
#[test]
fn input_names() {
let n = WaveDistortNode;
assert_eq!(n.input_name(TEXTURE_INPUT), "Input");
assert_eq!(n.input_name(FREQUENCY_INPUT), "Frequency");
assert_eq!(n.input_name(INTENSITY_INPUT), "Intensity");
assert_eq!(n.input_name(EVOLUTION_INPUT), "Evolution");
assert_eq!(n.input_name(VERTICAL_INPUT), "Direction");
}
#[test]
fn create_wires_inputs_and_flags() {
let (core, behavior) = create();
assert_eq!(behavior.type_id(), "org.olivevideoeditor.Olive.wave");
assert_eq!(
core.get_input(FREQUENCY_INPUT).unwrap().default,
NodeValue::Float(10.0)
);
assert_eq!(
core.get_input(INTENSITY_INPUT).unwrap().default,
NodeValue::Float(10.0)
);
assert_eq!(
core.get_input(VERTICAL_INPUT).unwrap().default,
NodeValue::Combo(0)
);
assert_eq!(core.effect_input, TEXTURE_INPUT);
assert_ne!(core.flags & crate::node::flags::VIDEO_EFFECT, 0);
}
#[test]
fn value_no_texture_pushes_nothing() {
let (core, behavior) = create();
let mut table = NodeValueTable::default();
behavior.value(
&core,
&crate::value::NodeValueRow::default(),
Rational::new(0, 1),
&mut table,
);
assert!(table.is_empty());
}
#[test]
fn value_zero_intensity_passes_texture_through() {
let (mut core, behavior) = create();
core.set_standard_value(INTENSITY_INPUT, -1, NodeValue::Float(0.0));
let tex = tex();
let inputs = crate::value::NodeValueRow::from([(TEXTURE_INPUT.to_string(), tex.clone())]);
let mut table = NodeValueTable::default();
behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
assert_eq!(table.get(ValueType::Texture), Some(&tex));
}
#[test]
fn value_nonzero_intensity_pushes_deferred_job() {
let (core, behavior) = create();
let inputs = crate::value::NodeValueRow::from([
(TEXTURE_INPUT.to_string(), tex()),
(INTENSITY_INPUT.to_string(), NodeValue::Float(10.0)),
]);
let mut table = NodeValueTable::default();
behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
assert!(table.get(ValueType::Texture).is_some());
}
#[test]
fn shader_code_returns_wave_shader() {
let n = WaveDistortNode;
let code = n.shader_code("anything").unwrap();
assert!(code.contains("uniform float frequency_in;"));
assert!(code.contains("if (vertical_in)"));
}
#[test]
fn duplicate_clones() {
let (core, behavior) = create();
let dup = behavior.duplicate(&core).unwrap();
assert_eq!(dup.name(), "Wave");
}
}
/// Register this node type (C++ factory entry for
/// `org.olivevideoeditor.Olive.wave`).
pub fn register(meta: &mut Vec<NodeMeta>) {
meta.push(NodeMeta {
type_id: "org.olivevideoeditor.Olive.wave",
name: "Wave",
categories: &[Category::Distort],
create,
});
}
+380
View File
@@ -0,0 +1,380 @@
// 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/>.
//! White balance node (C++
//! `src/node/src/color/whitebalance/whitebalance.{h,cpp}`,
//! `olive::WhiteBalanceNode`).
//!
//! White balance correction by color temperature and tint: converts a
//! scene illuminant temperature (Kelvin) into per-channel RGB gains
//! using the Tanner Helland blackbody approximation, normalized so the
//! green channel is preserved (no exposure shift); tint shifts along
//! the green-magenta axis.
use crate::factory::NodeMeta;
use crate::node::{Category, NodeBehavior, NodeCore};
/// Texture input id (C++ `k_texture_input`). Type: texture; flags:
/// not-keyframable; this is the node's effect input.
pub const TEXTURE_INPUT: &str = "tex_in";
/// Temperature input id (C++ `k_temperature_input`). Type: float;
/// default `6500.0` (Kelvin); properties: `min = 1000.0`, `max =
/// 40000.0`, `view = normal slider`.
pub const TEMPERATURE_INPUT: &str = "temperature_in";
/// Tint input id (C++ `k_tint_input`). Type: float; default `0.0`;
/// properties: `min = -1.0`, `max = 1.0`, `base = 0.01`.
pub const TINT_INPUT: &str = "tint_in";
/// Gain uniform id (C++ `k_gain_input`). Not a declared node input —
/// the C++ never calls `add_input` for it; it is the shader uniform
/// name fed per frame in `value()` with the RGB gain computed by
/// [`WhiteBalanceNode::gain_for_temperature`]. Type: vec3.
pub const GAIN_INPUT: &str = "wb_gain_in";
/// White balance node. Adjusts white balance by color temperature and
/// tint. The C++ class has no own private members, so this is a
/// unit-like struct (caches/inputs live in `NodeCore`).
pub struct WhiteBalanceNode;
/// Fragment shader (C++ `get_shader_code` loads the
/// `:/shaders/whitebalance.frag` resource). Text copied verbatim from
/// `engine/shaders/whitebalance.frag`.
const SHADER_FRAG: &str = r#"uniform sampler2D tex_in;
uniform vec3 wb_gain_in;
in vec2 ove_texcoord;
out vec4 frag_color;
void main(void)
{
vec4 source = texture(tex_in, ove_texcoord);
// Deliberately not clamped: white balance must also work on HDR/linear
// footage with values above 1.0
frag_color = vec4(source.rgb * wb_gain_in, source.a);
}
"#;
impl WhiteBalanceNode {
/// Fragment shader for any request (C++ `get_shader_code()` ignores
/// the request id and always returns this shader).
fn shader_frag() -> &'static str {
SHADER_FRAG
}
/// RGB gains for a given illuminant temperature and tint (C++
/// `get_gain_for_temperature()`, extracted for testability). Kelvin
/// is clamped to [1000, 40000]; the Tanner Helland blackbody
/// approximation gives 0-255 per channel (red: 255 below 6600K, else
/// `329.698727446 * (t - 60)^-0.1332047592`; green: logarithmic
/// below 6600K, power-law above; blue: 255 above 6600K, 0 below
/// 1900K, logarithmic between). The result is normalized so the
/// green channel gain is 1.0 at tint 0, then tint scales the green
/// channel by `clamp(1.0 + tint, 0.0, 2.0)` (green-magenta axis).
pub fn gain_for_temperature(kelvin: f64, tint: f64) -> [f64; 3] {
let kelvin = kelvin.clamp(1000.0, 40000.0);
let t = kelvin / 100.0;
let red = if t <= 66.0 {
255.0
} else {
329.698727446 * (t - 60.0).powf(-0.1332047592)
};
let green = if t <= 66.0 {
99.4708025861 * t.ln() - 161.1195681661
} else {
288.1221695283 * (t - 60.0).powf(-0.0755148492)
};
let blue = if t >= 66.0 {
255.0
} else if t <= 19.0 {
0.0
} else {
138.5177312231 * (t - 10.0).ln() - 305.0447927307
};
// Normalize to the green channel so temperature shifts do not change
// exposure, then let tint move along the green-magenta axis.
let tint_gain = (1.0 + tint).clamp(0.0, 2.0);
[red / green, green / green * tint_gain, blue / green]
}
}
impl NodeBehavior for WhiteBalanceNode {
/// Human-readable name (C++ `name()`).
fn name(&self) -> &str {
"White Balance"
}
/// Stable type id (C++ `id()`).
fn type_id(&self) -> &str {
"org.olivevideoeditor.Olive.whitebalance"
}
/// Categories (C++ `category()`).
fn categories(&self) -> &[Category] {
&[Category::Color]
}
/// Description (C++ `description()`).
fn description(&self) -> &str {
"Adjust white balance by color temperature and tint."
}
/// Localized input names (C++ `retranslate()`): `tex_in` -> "Input",
/// `temperature_in` -> "Temperature (K)", `tint_in` -> "Tint".
fn input_name<'a>(&self, id: &'a str) -> &'a str {
match id {
TEXTURE_INPUT => "Input",
TEMPERATURE_INPUT => "Temperature (K)",
TINT_INPUT => "Tint",
_ => id,
}
}
/// Shader code request (C++ `get_shader_code()`): the request id is
/// ignored; always returns [`SHADER_FRAG`].
fn shader_code(&self, _request: &str) -> Option<String> {
Some(SHADER_FRAG.to_string())
}
/// Evaluate outputs (C++ `value()`): no texture -> push nothing;
/// otherwise builds a `ShaderJob` from the whole input row, inserts
/// `wb_gain_in` as a vec3 computed by
/// [`Self::gain_for_temperature`] from the temperature and tint
/// inputs, and pushes the texture as that job.
fn value(
&self,
core: &NodeCore,
inputs: &crate::value::NodeValueRow,
time: oakcore_rs::Rational,
table: &mut crate::value::NodeValueTable,
) {
match inputs.get(TEXTURE_INPUT) {
Some(crate::value::NodeValue::Texture(_)) => {}
_ => return,
}
let temperature = match inputs.get(TEMPERATURE_INPUT) {
Some(v) => v.to_double(),
None => core.value_at_time(TEMPERATURE_INPUT, -1, time).to_double(),
};
let tint = match inputs.get(TINT_INPUT) {
Some(v) => v.to_double(),
None => core.value_at_time(TINT_INPUT, -1, time).to_double(),
};
let gain = Self::gain_for_temperature(temperature, tint);
let _ = gain;
// `// CPP-PARITY: whitebalance.cpp` `value()` — the C++ builds a
// ShaderJob from the whole input row, inserts `wb_gain_in` as the
// per-frame vec3 gain, and pushes `tex->to_job(job)`. The Rust
// model has no shader-job payload: the renderer seam resolves the
// deferred job from this null handle, recomputing the gain from
// the same inputs.
table.push(
crate::value::ValueType::Texture,
crate::value::NodeValue::Texture(crate::handle::CHandle::null()),
None,
);
}
/// Deep copy (C++ `copy()` via `NODE_DEFAULT_FUNCTIONS`).
fn duplicate(&self, _core: &NodeCore) -> Option<Box<dyn NodeBehavior>> {
Some(Box::new(WhiteBalanceNode))
}
}
/// Constructor (C++ `WhiteBalanceNode::WhiteBalanceNode()`): adds
/// `tex_in` (texture, effect input), `temperature_in` and `tint_in`
/// with the defaults and properties documented on the constants, and
/// sets the video-effect flag.
pub fn create() -> (NodeCore, Box<dyn NodeBehavior>) {
let mut core = NodeCore::new();
let mut tex = crate::input::Input::new(
TEXTURE_INPUT,
crate::value::ValueType::Texture,
crate::value::NodeValue::None,
);
tex.flags |= crate::input::flags::NOT_KEYFRAMABLE;
core.add_input(tex);
let mut temperature = crate::input::Input::new(
TEMPERATURE_INPUT,
crate::value::ValueType::Float,
crate::value::NodeValue::Float(6500.0),
);
temperature.properties = vec![
("min".to_string(), crate::value::NodeValue::Float(1000.0)),
("max".to_string(), crate::value::NodeValue::Float(40000.0)),
("view".to_string(), crate::value::NodeValue::Text("normal".into())),
];
core.add_input(temperature);
let mut tint = crate::input::Input::new(
TINT_INPUT,
crate::value::ValueType::Float,
crate::value::NodeValue::Float(0.0),
);
tint.properties = vec![
("min".to_string(), crate::value::NodeValue::Float(-1.0)),
("max".to_string(), crate::value::NodeValue::Float(1.0)),
("base".to_string(), crate::value::NodeValue::Float(0.01)),
];
core.add_input(tint);
core.effect_input = TEXTURE_INPUT.to_string();
core.flags |= crate::node::flags::VIDEO_EFFECT;
(core, Box::new(WhiteBalanceNode))
}
/// Register this node type (C++ factory entry for
/// `org.olivevideoeditor.Olive.whitebalance`).
pub fn register(meta: &mut Vec<NodeMeta>) {
meta.push(NodeMeta {
type_id: "org.olivevideoeditor.Olive.whitebalance",
name: "White Balance",
categories: &[Category::Color],
create,
});
}
#[cfg(test)]
mod tests {
use super::*;
use crate::value::{NodeValue, NodeValueTable, ValueType};
use oakcore_rs::Rational;
#[test]
fn input_names() {
let n = WhiteBalanceNode;
assert_eq!(n.input_name(TEXTURE_INPUT), "Input");
assert_eq!(n.input_name(TEMPERATURE_INPUT), "Temperature (K)");
assert_eq!(n.input_name(TINT_INPUT), "Tint");
assert_eq!(n.input_name("other_in"), "other_in");
}
#[test]
fn create_wires_inputs_flags_and_properties() {
let (core, behavior) = create();
assert_eq!(behavior.type_id(), "org.olivevideoeditor.Olive.whitebalance");
let tex = core.get_input(TEXTURE_INPUT).unwrap();
assert_ne!(tex.flags & crate::input::flags::NOT_KEYFRAMABLE, 0);
assert_eq!(
core.get_input(TEMPERATURE_INPUT).unwrap().default,
NodeValue::Float(6500.0)
);
assert_eq!(
core.get_input(TINT_INPUT).unwrap().default,
NodeValue::Float(0.0)
);
assert_eq!(core.effect_input, TEXTURE_INPUT);
assert_ne!(core.flags & crate::node::flags::VIDEO_EFFECT, 0);
}
#[test]
fn gain_matches_documented_blackbody_formula() {
let (kelvin, tint): (f64, f64) = (5600.0, 0.25);
let t = kelvin / 100.0;
let red = 255.0; // t = 56 <= 66
let green = 99.4708025861 * t.ln() - 161.1195681661;
let blue = 138.5177312231 * (t - 10.0).ln() - 305.0447927307;
let tint_gain = (1.0 + tint).clamp(0.0, 2.0);
let expected = [red / green, green / green * tint_gain, blue / green];
let got = WhiteBalanceNode::gain_for_temperature(kelvin, tint);
for (g, e) in got.iter().zip(expected.iter()) {
assert!((g - e).abs() < 1e-9, "got {}, expected {}", g, e);
}
}
#[test]
fn gain_normalizes_green_to_one() {
// The green channel gain is always 1.0 at tint 0, so temperature
// shifts never change exposure.
for kelvin in [1000.0, 1900.0, 5600.0, 6500.0, 10000.0, 40000.0] {
let gain = WhiteBalanceNode::gain_for_temperature(kelvin, 0.0);
assert_eq!(gain[1], 1.0, "kelvin {}", kelvin);
}
}
#[test]
fn gain_blue_black_below_1900k() {
// t <= 19 => blue channel gain is 0.
let gain = WhiteBalanceNode::gain_for_temperature(1000.0, 0.0);
assert_eq!(gain[2], 0.0);
}
#[test]
fn gain_clamps_kelvin_range() {
// Below 1000 and above 40000 Kelvin are clamped.
let low = WhiteBalanceNode::gain_for_temperature(500.0, 0.0);
let at_min = WhiteBalanceNode::gain_for_temperature(1000.0, 0.0);
assert_eq!(low, at_min);
let high = WhiteBalanceNode::gain_for_temperature(50000.0, 0.0);
let at_max = WhiteBalanceNode::gain_for_temperature(40000.0, 0.0);
assert_eq!(high, at_max);
}
#[test]
fn gain_tint_scales_green_axis_clamped() {
assert_eq!(WhiteBalanceNode::gain_for_temperature(6500.0, 0.0)[1], 1.0);
assert_eq!(WhiteBalanceNode::gain_for_temperature(6500.0, 1.0)[1], 2.0);
assert_eq!(WhiteBalanceNode::gain_for_temperature(6500.0, -1.0)[1], 0.0);
assert_eq!(WhiteBalanceNode::gain_for_temperature(6500.0, 10.0)[1], 2.0);
assert_eq!(WhiteBalanceNode::gain_for_temperature(6500.0, -10.0)[1], 0.0);
}
#[test]
fn shader_code_returns_whitebalance_frag() {
let code = WhiteBalanceNode.shader_code("anything").unwrap();
assert!(code.contains("source.rgb * wb_gain_in"));
}
#[test]
fn value_no_texture_pushes_nothing() {
let (core, behavior) = create();
let mut table = NodeValueTable::default();
behavior.value(&core, &crate::value::NodeValueRow::default(), Rational::new(0, 1), &mut table);
assert!(table.is_empty());
}
#[test]
fn value_with_texture_pushes_deferred_shader_job() {
let (core, behavior) = create();
let inputs = crate::value::NodeValueRow::from([(
TEXTURE_INPUT.to_string(),
NodeValue::Texture(crate::handle::CHandle::null()),
)]);
let mut table = NodeValueTable::default();
behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
assert!(table.get(ValueType::Texture).is_some());
}
#[test]
fn duplicate_clones() {
let (core, behavior) = create();
let dup = behavior.duplicate(&core).unwrap();
assert_eq!(dup.name(), "White Balance");
}
}
+304
View File
@@ -0,0 +1,304 @@
// 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/>.
//! Free functions replacing the C++ `Node` static methods
//! (COVERAGE.md §6/§9).
use oakcore_rs::TimeRange;
use crate::graph::Graph;
use crate::id::NodeId;
use crate::node::Category;
/// Human-readable category name (C++ `Node::get_category_name`).
pub fn category_name(c: Category) -> &'static str {
match c {
Category::Output => "Output",
Category::Effect => "Effect",
Category::Generator => "Generator",
Category::Input => "Input",
Category::Math => "Math",
Category::Color => "Color",
Category::Distort => "Distort",
Category::Filter => "Filter",
Category::Keying => "Keying",
Category::OpenFx => "OpenFX",
Category::Timeline => "Timeline",
Category::Group => "Group",
}
}
/// Copy inputs (and optionally connections) between two nodes
/// (C++ `Node::copy_inputs` / `copy_input` /
/// `copy_values_of_element`).
pub fn copy_inputs(
graph: &mut Graph,
src: NodeId,
dst: NodeId,
include_connections: bool,
) -> crate::error::Result<()> {
use crate::error::Error;
if graph.get(src).is_none() || graph.get(dst).is_none() {
return Err(Error::NotFound);
}
let src_inputs: Vec<String> = graph
.get(src)
.map(|e| e.core.inputs.iter().map(|i| i.id.clone()).collect())
.ok_or(Error::NotFound)?;
// Copy every declared input's standard value (whole-value semantics;
// C++ copies per-element too — array elements are covered when the
// array family lands).
for id in &src_inputs {
if !graph.get(dst).map(|e| e.core.has_input(id)).unwrap_or(false) {
continue;
}
let value = {
let entry = graph.get(src).ok_or(Error::NotFound)?;
entry.core.standard_value(id, -1)
};
let declared = {
let entry = graph.get(src).ok_or(Error::NotFound)?;
entry.core.input_data_type(id).unwrap_or(crate::value::ValueType::None)
};
let value = {
// Re-quantize to the destination's declared type so the copy
// never stores a mismatched payload.
let entry = graph.get(dst).ok_or(Error::NotFound)?;
let dst_declared = entry
.core
.input_data_type(id)
.ok_or(Error::NotFound)?;
if dst_declared == declared {
value
} else {
crate::value::NodeValue::with_scalar(&value, dst_declared, value.to_double())
}
};
let entry = graph.get_mut(dst).ok_or(Error::NotFound)?;
entry.core.set_standard_value(id, -1, value);
}
if include_connections {
// Recreate src's scalar input connections on dst.
for id in &src_inputs {
if let Some(from) = graph.connected_output(src, id, -1) {
graph.connect(from, dst, id, -1).ok();
}
}
}
Ok(())
}
/// Copy a node with its upstream dependency subgraph
/// (C++ `copy_dependency_graph` / `copy_node_in_graph` /
/// `copy_node_and_dependency_graph_minus_items`). Returns the new
/// node ids (source order). Undo packaging happens at the caller via
/// bridge::undo.
pub fn copy_subgraph(
graph: &mut Graph,
nodes: &[NodeId],
exclude_items: bool,
) -> crate::error::Result<Vec<NodeId>> {
todo!()
}
/// Transform a time range from one node's frame of reference to
/// another's along the connection path (C++ `Node::transform_time_to`).
pub fn transform_time_to(
graph: &Graph,
time: TimeRange,
from: NodeId,
to: NodeId,
) -> crate::error::Result<TimeRange> {
todo!()
}
/// Undo-command display strings (C++
/// `get_connect_command_string`/`get_disconnect_command_string`).
pub fn connect_command_string(output: NodeId, input: NodeId, input_id: &str) -> String {
todo!()
}
/// See [`connect_command_string`].
pub fn disconnect_command_string(output: NodeId, input: NodeId, input_id: &str) -> String {
todo!()
}
/// Lock a project mutex (poison-tolerant).
fn lock_any<T>(m: &std::sync::Mutex<T>) -> std::sync::MutexGuard<'_, T> {
m.lock().unwrap_or_else(|e| e.into_inner())
}
/// Set a keyframed/standard value at a time, returning an un-executed
/// undo command (C++ `Node::set_value_at_time` static; command creation
/// via bridge::undo). The mutation is chosen from the current state at
/// creation time, like the C++ (`// CPP-PARITY: node.cpp:1782`):
/// - keyframing input with a key at `time` -> replace the key's value;
/// - keyframing input without a key -> insert a key (best type =
/// closest key's type, default Linear);
/// - non-keyframing input -> set the standard value.
///
/// `project` is the project owning `graph`; the returned command's
/// closures lock it on redo/undo.
pub fn set_value_at_time_command(
project: &std::sync::Arc<std::sync::Mutex<crate::project::Project>>,
graph: &Graph,
node: NodeId,
input: &str,
element: i32,
time: oakcore_rs::Rational,
value: &crate::value::NodeValue,
) -> crate::error::Result<crate::handle::CHandle> {
use crate::error::Error;
use crate::keyframe::{Interpolation, Keyframe};
// Determine the mutation from the current state.
let declared = graph
.get(node)
.and_then(|e| e.core.input_data_type(input))
.ok_or(Error::NotFound)?;
let keyframing = graph
.get(node)
.and_then(|e| e.core.keyframe_track(input, element))
.map(|t| !t.keys().is_empty())
.unwrap_or(false);
let project = project.clone();
let node = node;
let input = input.to_string();
let value = value.clone();
let element = element;
if keyframing {
let existing = graph
.get(node)
.and_then(|e| e.core.keyframe_track(&input, element))
.and_then(|t| {
t.keys()
.iter()
.find(|k| k.time == time)
.map(|k| (k.time, k.value.clone(), k.interpolation))
});
match existing {
Some((key_time, old_value, _interp)) => {
// Replace the key's value (preserving type/handles).
let project_redo = project.clone();
let project_undo = project.clone();
Ok(crate::bridge::undo::command_from_closures(
{
let value = value.clone();
let input = input.clone();
let project = project_redo;
move || {
let mut g = lock_any(&project);
if let Some(e) = g.graph.get_mut(node) {
e.core
.keyframe_track_mut(&input, element)
.set_key_value(key_time, value.clone());
}
}
},
{
let old_value = old_value.clone();
let input = input.clone();
let project = project_undo;
move || {
let mut g = lock_any(&project);
if let Some(e) = g.graph.get_mut(node) {
e.core
.keyframe_track_mut(&input, element)
.set_key_value(key_time, old_value.clone());
}
}
},
)
.ok_or(Error::NoMem)?)
}
None => {
let input_redo = input.clone();
let input_undo = input.clone();
// Insert a new key.
let interp = graph
.get(node)
.and_then(|e| e.core.keyframe_track(&input, element))
.and_then(|t| {
t.keys()
.iter()
.filter(|k| k.time <= time)
.next_back()
.map(|k| k.interpolation)
})
.unwrap_or(Interpolation::Linear);
let project_redo = project.clone();
let project_undo = project.clone();
let value_redo = value.clone();
Ok(crate::bridge::undo::command_from_closures(
move || {
let mut g = lock_any(&project_redo);
if let Some(e) = g.graph.get_mut(node) {
let track = e.core.keyframe_track_mut(&input_redo, element);
track.set_key(Keyframe {
time,
value: value_redo.clone(),
interpolation: interp,
bezier_in: (0.0, 0.0),
bezier_out: (0.0, 0.0),
});
}
},
move || {
let mut g = lock_any(&project_undo);
if let Some(e) = g.graph.get_mut(node) {
e.core
.keyframe_track_mut(&input_undo, element)
.remove_key(time);
}
},
)
.ok_or(Error::NoMem)?)
}
}
} else {
// Set the standard value.
let old = graph
.get(node)
.map(|e| e.core.standard_value(&input, element))
.unwrap_or(crate::value::NodeValue::None);
let project_redo = project.clone();
let project_undo = project.clone();
let input_redo = input.clone();
let input_undo = input.clone();
let value_redo = value.clone();
Ok(crate::bridge::undo::command_from_closures(
move || {
let mut g = lock_any(&project_redo);
if let Some(e) = g.graph.get_mut(node) {
e.core
.set_standard_value(&input_redo, element, value_redo.clone());
}
},
move || {
let mut g = lock_any(&project_undo);
if let Some(e) = g.graph.get_mut(node) {
e.core
.set_standard_value(&input_undo, element, old.clone());
}
},
)
.ok_or(Error::NoMem)?)
}
}
+406
View File
@@ -0,0 +1,406 @@
// 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/>.
//! Project: owns the graph, the folder tree, settings, and the undo
//! stack binding. Mirrors C++ `olive::Project`.
use std::sync::{Arc, Mutex, Weak};
use crate::graph::Graph;
use crate::id::NodeId;
/// A reference to a node inside a project's graph — the value boxed by
/// every public node/folder/sequence handle (`id.rs`: "a handle boxes
/// `(Arc<Mutex<Project>>, NodeId)`"). Node lifetime follows the project;
/// a stale `NodeId` (slot reused, node removed) fails validation instead
/// of aliasing.
#[derive(Clone)]
pub struct NodeRef {
/// The owning project (or a scratch project for orphaned nodes).
pub project: Arc<Mutex<Project>>,
/// The node's id in that project's graph.
pub id: NodeId,
/// Shared owned-flag for the ffi alive counter: true while the node
/// object is separately accounted (factory-created / detached nodes);
/// flipped to false when the node is adopted by a project graph
/// (shared across every handle copy — mirrors the C++
/// `OakNodeBox::owns` flip in `mark_container_owned`).
pub owned: std::sync::Arc<std::sync::atomic::AtomicBool>,
}
impl NodeRef {
/// New reference. `owned` selects whether releasing the last handle
/// reference accounts the node in [`crate::ffi::debug_alive_count`].
pub fn new(
project: Arc<Mutex<Project>>,
id: NodeId,
owned: bool,
) -> NodeRef {
NodeRef {
project,
id,
owned: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(owned)),
}
}
}
/// The project. Shared ownership via `Arc<Mutex<Project>>` is what the
/// public C ABI handles box.
pub struct Project {
/// The node graph (all nodes incl. sequences).
pub graph: Graph,
/// Root folder of the bin tree ([`NodeId::INVALID`] until
/// [`Project::initialize`]).
pub root: NodeId,
/// Arbitrary project settings (C++ `setting_` map).
pub settings: std::collections::HashMap<String, String>,
/// Project file path (empty = unsaved).
pub filename: String,
/// Dirty flag.
pub modified: bool,
/// Session UUID (cache filename base).
pub uuid: String,
/// Cache location setting (0 = default, 1 = alongside project,
/// 2 = custom path; C++ `CacheSetting`).
pub cache_location_setting: i32,
/// Custom cache path (C++ `custom_cache_path_`).
pub custom_cache_path: String,
}
/// Setting key for the root folder identity (C++
/// `Project::k_root_key`).
pub const SETTING_ROOT: &str = "root";
/// Setting key for the cache location (C++ `k_cache_location_setting_key`).
pub const SETTING_CACHE_LOCATION: &str = "cachesetting";
/// Setting key for the custom cache path (C++ `k_cache_path_key`).
pub const SETTING_CACHE_PATH: &str = "customcachepath";
impl Project {
/// New empty project (no root folder until [`Project::initialize`]).
pub fn new() -> Arc<Mutex<Project>> {
Arc::new(Mutex::new(Project {
graph: Graph::new(),
root: NodeId::INVALID,
settings: std::collections::HashMap::new(),
filename: String::new(),
modified: false,
uuid: generate_uuid(),
cache_location_setting: 0,
custom_cache_path: String::new(),
}))
}
/// Create the root folder and default state (C++ `initialize()`).
pub fn initialize(&mut self) -> crate::error::Result<()> {
use crate::error::Error;
if self.root.valid() {
return Err(Error::State);
}
let (core, behavior) = crate::folder::create("Root");
let id = self.graph.add_node(core, behavior);
self.root = id;
self.settings.insert(
SETTING_ROOT.to_string(),
id.identity().to_string(),
);
Ok(())
}
/// Remove all nodes and reset to a blank project (C++ `clear()`).
pub fn clear(&mut self) -> crate::error::Result<()> {
// Detach every node from its folder membership, then drop the
// arena (the `// CPP-PARITY: project.cpp:90` clear() loop).
let ids = self.graph.node_ids();
for id in ids {
if let Some(entry) = self.graph.get_mut(id) {
entry.core.bin_folder = None;
entry.core.links.clear();
}
}
self.graph = Graph::new();
self.root = NodeId::INVALID;
Ok(())
}
/// Deep-copy the whole project for background render isolation
/// (replaces oakrender's C++ ProjectCopier: the copy happens here,
/// inside the module that owns the data — see M-series note on
/// render→node decoupling).
pub fn deep_copy(&self) -> crate::error::Result<Arc<Mutex<Project>>> {
use crate::error::Error;
let copy = Project::new();
let mut guard = copy.lock().map_err(|_| Error::State)?;
// Map original id -> copied id, preserving identities where the
// copy's arena slots are free (the copy starts empty, so every
// node keeps its identity).
let mut id_map: std::collections::HashMap<NodeId, NodeId> = std::collections::HashMap::new();
for id in self.graph.node_ids() {
let entry = self.graph.get(id).ok_or(Error::NotFound)?;
let (core, behavior) = clone_entry(entry);
let new_id = guard.graph.add_node(core, behavior);
id_map.insert(id, new_id);
}
// Copy edges (source ids remapped).
let edges: Vec<_> = self
.graph
.output_connections_all()
.into_iter()
.map(|(from, to, input, element)| {
(
*id_map.get(&from).unwrap_or(&from),
*id_map.get(&to).unwrap_or(&to),
input,
element,
)
})
.collect();
for (from, to, input, element) in edges {
guard.graph.connect(from, to, &input, element).ok();
}
// Project state.
guard.root = id_map.get(&self.root).copied().unwrap_or(NodeId::INVALID);
guard.settings = self.settings.clone();
guard.filename = self.filename.clone();
guard.modified = self.modified;
guard.uuid = self.uuid.clone();
guard.cache_location_setting = self.cache_location_setting;
guard.custom_cache_path = self.custom_cache_path.clone();
drop(guard);
Ok(copy)
}
/// Incremental sync of a deep copy after edits (C++
/// ProjectCopier::queue_update semantics): applies the recorded
/// change set to `copy`.
pub fn sync_copy(&self, copy: &mut Project, changes: &[ChangeRecord]) -> crate::error::Result<()> {
use crate::error::Error;
// Rebuild the id mapping from the copy (identities are stable
// across the deep-copy, so original id -> copy id is identity).
let _ = Error::NotFound;
for change in changes {
match change {
ChangeRecord::NodeAdded(id) => {
let entry = self.graph.get(*id).ok_or(Error::NotFound)?;
let (core, behavior) = clone_entry(entry);
copy.graph.add_entry(
crate::graph::NodeEntry {
core,
behavior,
generation: id.generation(),
vacant: false,
},
*id,
);
}
ChangeRecord::NodeRemoved(_id) => {
// Ids are stable, so the copy-side id equals the
// original; drop it from the copy.
copy.graph.remove_node(*_id);
}
ChangeRecord::EdgeChanged {
from,
to,
input,
element,
connected,
} => {
if *connected {
copy.graph.connect(*from, *to, input, *element)?;
} else {
copy.graph.disconnect(*from, *to, input, *element);
}
}
ChangeRecord::ValueChanged { node, input, element } => {
if let (Some(src), Some(dst)) = (self.graph.get(*node), copy.graph.get_mut(*node)) {
let v = src.core.standard_value(input, *element);
dst.core.set_standard_value(input, *element, v);
}
}
}
}
Ok(())
}
/// Display name (C++ `Project::name()`): filename base or
/// "(untitled)".
pub fn name(&self) -> String {
if self.filename.is_empty() {
return "(untitled)".to_string();
}
let base = std::path::Path::new(&self.filename)
.file_name()
.map(|f| f.to_string_lossy().into_owned())
.unwrap_or_default();
match base.find('.') {
Some(dot) => base[..dot].to_string(),
None => base,
}
}
/// Full filename or "" (C++ `Project::filename()`).
pub fn filename(&self) -> &str {
&self.filename
}
/// Window-title name (C++ `Project::pretty_filename()`).
pub fn pretty_filename(&self) -> &str {
if self.filename.is_empty() {
"(untitled)"
} else {
&self.filename
}
}
/// Set the filename (C++ `Project::set_filename()`).
pub fn set_filename(&mut self, filename: &str) {
self.filename = filename.to_string();
}
/// 1 when the project has unsaved changes (C++ `is_modified()`).
pub fn is_modified(&self) -> bool {
self.modified
}
/// Set the modified flag (C++ `set_modified()`).
pub fn set_modified(&mut self, modified: bool) {
self.modified = modified;
}
/// 1 when the project is new (untitled and unmodified; C++
/// `is_new()`).
pub fn is_new(&self) -> bool {
!self.modified && self.filename.is_empty()
}
/// Effective cache directory (C++ `Project::cache_path()`); the
/// default-location branch consults the oakrender disk-cache path
/// through the bridge when the setting is not custom/alongside.
pub fn cache_path(&self) -> String {
match self.cache_location_setting {
2 => {
if !self.custom_cache_path.is_empty() {
return self.custom_cache_path.clone();
}
}
1 => {
if !self.filename.is_empty() {
let dir = std::path::Path::new(&self.filename)
.parent()
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_default();
if !dir.is_empty() {
return format!("{}/cache", dir);
}
}
}
_ => {}
}
// Default location: the shared disk-cache directory (single-lib:
// lives in oakcommon, used by oaknode and oakrender alike).
oakcommon::filefunctions::default_disk_cache_path()
}
/// Copy all settings from `src` into `self` (C++
/// `Project::copy_settings`).
pub fn copy_settings_from(&mut self, src: &Project) {
self.settings = src.settings.clone();
self.cache_location_setting = src.cache_location_setting;
self.custom_cache_path = src.custom_cache_path.clone();
}
}
/// Clone a node entry into independently-owned parts (deep copy of the
/// core data; the behavior is re-created via [`NodeBehavior::duplicate`]).
fn clone_entry(entry: &crate::graph::NodeEntry) -> (crate::node::NodeCore, Box<dyn crate::node::NodeBehavior>) {
let core = entry.core.clone();
let behavior = entry
.behavior
.duplicate(&core)
.unwrap_or_else(|| Box::new(crate::nodes::EmptyBehavior));
(core, behavior)
}
/// UUID v4 in C++ `QUuid::createUuid().toString()` text format
/// (`// CPP-PARITY: project.cpp:483` `regenerate_uuid`).
fn generate_uuid() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
// Randomness source: splitmix64 seeded from the clock (test-friendly;
// real sessions use a stronger seed — this is not a security boundary).
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(0);
let mut seed = nanos ^ 0x9E3779B97F4A7C15;
let mut next = move || {
seed = seed.wrapping_add(0x9E3779B97F4A7C15);
let mut z = seed;
z = (z ^ (z >> 30)).wrapping_mul(0xBF58476D1CE4E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D049BB133111EB);
z ^ (z >> 31)
};
let mut b = [0u8; 16];
for chunk in b.chunks_mut(8) {
let r = next().to_le_bytes();
chunk.copy_from_slice(&r);
}
b[6] = (b[6] & 0x0F) | 0x40; // version 4
b[8] = (b[8] & 0x3F) | 0x80; // variant 1
format!(
"{{{:02x}{:02x}{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}}}",
b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7], b[8], b[9], b[10], b[11], b[12], b[13],
b[14], b[15]
)
}
/// A recorded structural change for incremental copy sync (replaces
/// the C++ signal-driven copier updates).
#[derive(Clone, Debug)]
pub enum ChangeRecord {
/// A node was added.
NodeAdded(NodeId),
/// A node was removed (id of the copy-side node).
NodeRemoved(NodeId),
/// An edge change.
EdgeChanged {
/// Source.
from: NodeId,
/// Destination.
to: NodeId,
/// Input id.
input: String,
/// Element.
element: i32,
/// Connected or disconnected.
connected: bool,
},
/// A parameter value changed.
ValueChanged {
/// Node.
node: NodeId,
/// Input id.
input: String,
/// Element.
element: i32,
},
}
/// Weak-project handle used by the identity registry (node_from_identity
/// upgrades it; a freed project leaves a dead weak entry that upgrades
/// to `None`).
pub(crate) type WeakProject = Weak<Mutex<Project>>;
+150
View File
@@ -0,0 +1,150 @@
// 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/>.
//! Sequence: the C++ `ViewerOutput`/`Sequence` pair — a node that owns
//! tracks, markers, work area, and playback caches.
//! `// CPP-PARITY: src/node/src/output/viewer/viewer.{h,cpp}`,
//! `// CPP-PARITY: src/node/src/project/sequence/sequence.{h,cpp}`.
use crate::id::NodeId;
use crate::node::{Category, NodeBehavior, NodeCore};
use crate::value::{AudioParams, VideoParams};
/// Sequence texture/samples input ids (ViewerOutput::k_texture_input /
/// k_samples_input) and the track input id format (Sequence::
/// k_track_input_format).
pub const TEXTURE_INPUT: &str = "tex_in";
pub const SAMPLES_INPUT: &str = "samples_in";
pub const TRACK_INPUT_FORMAT: &str = "track_in_%1";
/// Sequence behavior (viewer node).
pub struct SequenceBehavior {
/// Track list node ids (video then audio, C++ order).
pub track_lists: Vec<NodeId>,
/// Timeline markers handle (oaktimeline, owned).
pub markers: crate::handle::CHandle,
/// Work area handle (oaktimeline, owned).
pub workarea: crate::handle::CHandle,
/// Length cache (C++ last_length_).
pub last_length: oakcore_rs::Rational,
/// Autocache toggles.
pub autocache_video: bool,
/// Audio autocache toggle.
pub autocache_audio: bool,
/// Playhead position (C++ ViewerOutput::playhead_).
pub playhead: oakcore_rs::Rational,
/// Video parameter streams.
pub video_params: Vec<VideoParams>,
/// Audio parameter streams.
pub audio_params: Vec<AudioParams>,
}
impl SequenceBehavior {
/// Empty sequence with zero tracks and no parameters.
pub fn new() -> Self {
SequenceBehavior {
track_lists: Vec::new(),
markers: crate::handle::CHandle::null(),
workarea: crate::handle::CHandle::null(),
last_length: oakcore_rs::Rational::new(0, 1),
autocache_video: false,
autocache_audio: false,
playhead: oakcore_rs::Rational::new(0, 1),
video_params: Vec::new(),
audio_params: Vec::new(),
}
}
/// Apply the default video/audio parameters (C++
/// `ViewerOutput::set_default_parameters()`; the config lookups use
/// oakcommon's defaults when the config module is absent).
pub fn set_default_parameters(&mut self) {
let width = crate::bridge::common::config_get_int("DefaultSequenceWidth", "", 1920)
.unwrap_or(1920);
let height = crate::bridge::common::config_get_int("DefaultSequenceHeight", "", 1080)
.unwrap_or(1080);
let sample_rate =
crate::bridge::common::config_get_int("DefaultSequenceAudioFrequency", "", 48000)
.unwrap_or(48000);
let fps_num = crate::bridge::common::config_get_int("DefaultSequenceFrameRateNum", "", 30)
.unwrap_or(30);
let fps_den = crate::bridge::common::config_get_int("DefaultSequenceFrameRateDen", "", 1)
.unwrap_or(1);
self.video_params = vec![VideoParams {
width,
height,
frame_rate: oakcore_rs::Rational::new(fps_num as i64, fps_den as i64),
pixel_format: 4, // f32
channels: 4,
}];
self.audio_params = vec![AudioParams {
sample_rate,
channel_layout: 0x3, // stereo
format: 4, // f32
}];
}
/// Recompute the cached lengths from the track lists (C++
/// `ViewerOutput::verify_length()`).
pub fn verify_length(&mut self, lengths: (oakcore_rs::Rational, oakcore_rs::Rational, oakcore_rs::Rational)) {
let (video, audio, overall) = lengths;
self.last_length = overall;
let _ = (video, audio);
}
/// Total stream counts.
pub fn video_stream_count(&self) -> usize {
self.video_params.len()
}
/// Audio stream count.
pub fn audio_stream_count(&self) -> usize {
self.audio_params.len()
}
}
impl NodeBehavior for SequenceBehavior {
fn name(&self) -> &str {
"Sequence"
}
fn type_id(&self) -> &str {
"org.olivevideoeditor.Olive.sequence"
}
fn categories(&self) -> &[Category] {
&[Category::Output]
}
fn duplicate(&self, _core: &NodeCore) -> Option<Box<dyn NodeBehavior>> {
Some(Box::new(SequenceBehavior::new()))
}
fn as_any(&self) -> Option<&dyn std::any::Any> {
Some(self)
}
fn as_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
Some(self)
}
}
impl Default for SequenceBehavior {
fn default() -> Self {
SequenceBehavior::new()
}
}
+808
View File
@@ -0,0 +1,808 @@
// 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/>.
//! Project (de)serialization: the C++ `ProjectSerializer` family.
//!
//! XML I/O goes through [`crate::bridge::common`] (oakcommon C ABI;
//! in-crate stubs under `--features test-stubs`). The XML shape mirrors
//! the C++ `Node::save`/`Project::save` writers (`// CPP-PARITY:
//! src/node/src/node.cpp:node::save`, `// CPP-PARITY:
//! src/node/src/project.cpp:save`). Byte-exact output parity with the C++
//! writer is pinned by the golden tests in `tests/serializer_test.rs`;
//! the value text codecs here use Rust's shortest round-trip formatting
//! (functionally equivalent, not byte-identical — the golden test is
//! `#[ignore]`d until the C++ fixtures are captured).
//!
//! The version ladder (210528/210907/211228/220403/230220) becomes a
//! single reader with per-version adaptation: current files carry a
//! `<project>` root; unknown/newer roots are rejected.
use std::sync::{Arc, Mutex};
use oakcore_rs::Rational;
use crate::bridge::common;
use crate::graph::Graph;
use crate::id::NodeId;
use crate::keyframe::{Interpolation, Keyframe};
use crate::node::NodeCore;
use crate::project::{NodeRef, Project};
use crate::value::{NodeValue, ValueType};
/// Minimal XML reader surface the serializer needs (implemented over
/// the oakcommon xml C ABI in `bridge::common`).
pub trait XmlRead {
/// Advance to the next start element; false at end/close.
fn next_start_element(&mut self) -> bool;
/// Current element name.
fn name(&self) -> &str;
/// Attribute by name.
fn attribute(&self, name: &str) -> Option<String>;
/// Read inner text of the current element.
fn read_element_text(&mut self) -> String;
/// Skip the current element subtree.
fn skip_current_element(&mut self);
}
/// Minimal XML writer surface.
pub trait XmlWrite {
/// Start an element.
fn start_element(&mut self, name: &str);
/// End the current element.
fn end_element(&mut self);
/// Write an attribute on the open element.
fn attribute(&mut self, name: &str, value: &str);
/// Write a text element.
fn text_element(&mut self, name: &str, text: &str);
/// Write raw character data (C++ `write_characters`); used for the
/// `<track>`/`<key>` value payloads. Default no-op.
fn characters(&mut self, _text: &str) {}
}
/// Reader over the oakcommon XML C ABI.
pub struct XmlReaderBridge {
/// The oakcommon reader handle.
pub handle: crate::handle::CHandle,
/// Current element name (cached).
name: String,
}
/// Writer over the oakcommon XML C ABI.
pub struct XmlWriterBridge {
/// The oakcommon writer handle.
pub handle: crate::handle::CHandle,
}
impl XmlReaderBridge {
/// Create from XML text; `None` when oakcommon is unavailable.
pub fn new(xml: &str) -> Option<XmlReaderBridge> {
use std::ffi::CString;
let c = CString::new(xml).ok()?;
let handle = common::xml_reader_init(c.as_ptr())?;
Some(XmlReaderBridge {
handle,
name: String::new(),
})
}
}
impl Drop for XmlReaderBridge {
fn drop(&mut self) {
common::xml_reader_free(&mut self.handle);
}
}
impl XmlRead for XmlReaderBridge {
fn next_start_element(&mut self) -> bool {
match common::xml_reader_next_start_element(self.handle.clone()) {
Some(true) => {
self.name = common::xml_reader_name(self.handle.clone()).unwrap_or_default();
true
}
_ => false,
}
}
fn name(&self) -> &str {
&self.name
}
fn attribute(&self, name: &str) -> Option<String> {
let count = common::xml_reader_attribute_count(self.handle.clone()).unwrap_or(0);
for i in 0..count {
let attr_name =
common::xml_reader_attribute_name(self.handle.clone(), i).unwrap_or_default();
if attr_name == name {
return common::xml_reader_attribute_value(self.handle.clone(), i);
}
}
None
}
fn read_element_text(&mut self) -> String {
common::xml_reader_read_element_text(self.handle.clone()).unwrap_or_default()
}
fn skip_current_element(&mut self) {
let _ = common::xml_reader_skip_current_element(self.handle.clone());
}
}
impl XmlWriterBridge {
/// Create a writer; `None` when oakcommon is unavailable.
pub fn new() -> Option<XmlWriterBridge> {
let handle = common::xml_writer_init()?;
Some(XmlWriterBridge { handle })
}
/// The serialized output.
pub fn output(&self) -> String {
common::xml_writer_output(self.handle.clone()).unwrap_or_default()
}
}
impl Drop for XmlWriterBridge {
fn drop(&mut self) {
common::xml_writer_free(&mut self.handle);
}
}
impl XmlWrite for XmlWriterBridge {
fn start_element(&mut self, name: &str) {
let _ = common::xml_writer_start_element(self.handle.clone(), name);
}
fn end_element(&mut self) {
let _ = common::xml_writer_end_element(self.handle.clone());
}
fn attribute(&mut self, name: &str, value: &str) {
let _ = common::xml_writer_attribute(self.handle.clone(), name, value);
}
fn text_element(&mut self, name: &str, text: &str) {
let _ = common::xml_writer_text_element(self.handle.clone(), name, text);
}
fn characters(&mut self, text: &str) {
let _ = common::xml_writer_characters(self.handle.clone(), text);
}
}
/// Detected project version (from the XML header).
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub struct ProjectVersion(pub u32);
/// The current (build) project version.
pub const CURRENT_VERSION: ProjectVersion = ProjectVersion(230220);
/// Value text codec: [`NodeValue`] -> string for the XML `<track>`
/// payloads. `key_track` selects the per-component form (C++
/// `NodeValue::value_to_string`; `// CPP-PARITY: value.cpp:45`).
pub fn value_to_string(declared: ValueType, value: &NodeValue, key_track: bool) -> String {
match declared {
ValueType::Vec2 | ValueType::Vec3 | ValueType::Vec4 | ValueType::Color => {
let comps: Vec<String> = value
.split_into_tracks(declared)
.iter()
.map(|t| format!("{}", t.to_double()))
.collect();
if key_track {
// Per-track keyframes carry one component.
comps.first().cloned().unwrap_or_default()
} else {
comps.join(":")
}
}
ValueType::Rational => match value {
NodeValue::Rational(r) => r.to_display_string(),
_ => format!("{}", value.to_double()),
},
ValueType::Int | ValueType::Combo => format!("{}", value.to_double() as i64),
ValueType::Boolean => {
if value.to_double() != 0.0 {
"1".to_string()
} else {
"0".to_string()
}
}
ValueType::Float => format!("{}", value.to_double()),
ValueType::Text | ValueType::StrCombo => match value {
NodeValue::Text(s) => s.clone(),
NodeValue::StrCombo(s) => s.clone(),
_ => String::new(),
},
_ => String::new(),
}
}
/// String -> [`NodeValue`] (C++ `NodeValue::string_to_value`).
pub fn string_to_value(declared: ValueType, text: &str) -> NodeValue {
match declared {
ValueType::Vec2 | ValueType::Vec3 | ValueType::Vec4 | ValueType::Color => {
let parts: Vec<f64> = text.split(':').map(|p| p.parse().unwrap_or(0.0)).collect();
match declared {
ValueType::Vec2 => NodeValue::Vec2([parts[0], parts.get(1).copied().unwrap_or(0.0)]),
ValueType::Vec3 => NodeValue::Vec3([
parts[0],
parts.get(1).copied().unwrap_or(0.0),
parts.get(2).copied().unwrap_or(0.0),
]),
ValueType::Vec4 => NodeValue::Vec4([
parts[0],
parts.get(1).copied().unwrap_or(0.0),
parts.get(2).copied().unwrap_or(0.0),
parts.get(3).copied().unwrap_or(0.0),
]),
_ => NodeValue::Color([
parts[0],
parts.get(1).copied().unwrap_or(0.0),
parts.get(2).copied().unwrap_or(0.0),
parts.get(3).copied().unwrap_or(0.0),
]),
}
}
ValueType::Rational => NodeValue::Rational(Rational::from_string(text)),
ValueType::Int | ValueType::Combo => NodeValue::Int(text.trim().parse().unwrap_or(0)),
ValueType::Boolean => NodeValue::Boolean(text.trim() == "1" || text.trim() == "true"),
ValueType::Float => NodeValue::Float(text.trim().parse().unwrap_or(0.0)),
ValueType::Text => NodeValue::Text(text.to_string()),
ValueType::StrCombo => NodeValue::StrCombo(text.to_string()),
_ => NodeValue::None,
}
}
/// Interpolation from the XML `type` attribute (C++
/// `NodeKeyframe::Type`).
pub fn interpolation_from_c(t: i32) -> Interpolation {
match t {
1 => Interpolation::Hold,
2 => Interpolation::Bezier,
_ => Interpolation::Linear,
}
}
/// Save a whole project to the current-version XML format.
pub fn save(project: &Project) -> crate::error::Result<String> {
use crate::error::Error;
let mut writer = XmlWriterBridge::new().ok_or(Error::Failed(
"oakcommon XML writer unavailable".to_string(),
))?;
writer.start_element("project");
writer.attribute("version", "1");
writer.text_element("uuid", &project.uuid);
writer.start_element("nodes");
for id in project.graph.node_ids() {
let entry = project.graph.get(id).ok_or(Error::NotFound)?;
writer.start_element("node");
let type_id = entry.behavior.type_id().to_string();
// The node's input connections: (source, input_id, element).
let connections: Vec<(NodeId, String, i32)> = project
.graph
.output_connections_all()
.into_iter()
.filter(|(_, to, _, _)| *to == id)
.map(|(from, _, input, element)| (from, input, element))
.collect();
save_node(&mut writer, &entry.core, id, &type_id, &connections)?;
writer.end_element(); // node
}
writer.end_element(); // nodes
writer.start_element("settings");
let mut keys: Vec<&String> = project.settings.keys().collect();
keys.sort();
for key in keys {
writer.text_element(key, project.settings.get(key).unwrap_or(&String::new()));
}
writer.end_element(); // settings
writer.end_element(); // project
Ok(writer.output())
}
/// Save one node (C++ `Node::save`). `connections` lists the node's
/// input connections `(source, input_id, element)`.
pub fn save_node(
writer: &mut dyn XmlWrite,
core: &NodeCore,
id: NodeId,
type_id: &str,
connections: &[(NodeId, String, i32)],
) -> crate::error::Result<()> {
writer.attribute("version", "1");
writer.attribute("id", type_id);
writer.attribute("ptr", &id.identity().to_string());
if !core.label.is_empty() {
writer.text_element("label", &core.label);
}
if core.override_color != -1 {
writer.text_element("color", &core.override_color.to_string());
}
for input in &core.inputs {
writer.start_element("input");
writer.attribute("id", &input.id);
save_input(writer, core, &input.id);
writer.end_element(); // input
}
if !core.links.is_empty() {
writer.start_element("links");
for link in &core.links {
writer.text_element("link", &link.identity().to_string());
}
writer.end_element(); // links
}
if !connections.is_empty() {
writer.start_element("connections");
for (from, input_id, element) in connections {
writer.start_element("connection");
writer.attribute("input", input_id);
writer.attribute("element", &element.to_string());
writer.text_element("output", &from.identity().to_string());
writer.end_element(); // connection
}
writer.end_element(); // connections
}
writer.start_element("caches");
writer.text_element("audio", "");
writer.text_element("video", "");
writer.text_element("thumb", "");
writer.text_element("waveform", "");
writer.end_element(); // caches
writer.start_element("custom");
writer.end_element(); // custom
Ok(())
}
/// Save one input element (`primary` + `subelements`; C++
/// `Node::save_input`).
fn save_input(writer: &mut dyn XmlWrite, core: &NodeCore, id: &str) {
writer.start_element("primary");
save_immediate(writer, core, id, -1);
writer.end_element(); // primary
let arr_sz = core.input_array_size(id);
if arr_sz > 0 {
writer.start_element("subelements");
writer.attribute("count", &arr_sz.to_string());
for i in 0..arr_sz {
writer.start_element("element");
save_immediate(writer, core, id, i as i32);
writer.end_element(); // element
}
writer.end_element(); // subelements
}
}
/// Save one immediate (standard values + keyframes; C++
/// `Node::save_immediate`).
fn save_immediate(writer: &mut dyn XmlWrite, core: &NodeCore, id: &str, element: i32) {
let keyframable = core.input_flags(id) & crate::input::flags::NOT_KEYFRAMABLE == 0;
let keyframing = core
.keyframe_track(id, element)
.map(|t| !t.keys().is_empty())
.unwrap_or(false);
let declared = core
.input_data_type(id)
.unwrap_or(ValueType::None);
if keyframable {
writer.text_element("keyframing", if keyframing { "1" } else { "0" });
}
// Standard value, split into per-component tracks.
writer.start_element("standard");
let value = core.standard_value(id, element);
for track in value.split_into_tracks(declared) {
writer.start_element("track");
writer_text_chars(writer, &value_to_string(declared, &track, true));
writer.end_element(); // track
}
writer.end_element(); // standard
if keyframing {
writer.start_element("keyframes");
if let Some(track) = core.keyframe_track(id, element) {
writer.start_element("track");
for key in track.keys() {
writer.start_element("key");
writer.attribute("input", id);
writer.attribute("time", &key.time.to_display_string());
let type_c = match key.interpolation {
Interpolation::Hold => 1,
Interpolation::Bezier => 2,
Interpolation::Linear => 0,
};
writer.attribute("type", &type_c.to_string());
writer.attribute("inhandlex", &format!("{}", key.bezier_in.0));
writer.attribute("inhandley", &format!("{}", key.bezier_in.1));
writer.attribute("outhandlex", &format!("{}", key.bezier_out.0));
writer.attribute("outhandley", &format!("{}", key.bezier_out.1));
writer_text_chars(
writer,
&value_to_string(declared, &key.value, true),
);
writer.end_element(); // key
}
writer.end_element(); // track
}
writer.end_element(); // keyframes
}
}
/// Write character data through the writer trait.
fn writer_text_chars(writer: &mut dyn XmlWrite, text: &str) {
writer.characters(text);
}
/// Load a project from XML text. Applies version upgrades in order;
/// rejects versions newer than the build (C++ `k_project_too_new`).
pub fn load(xml: &str) -> crate::error::Result<Arc<Mutex<Project>>> {
use crate::error::Error;
let mut reader = XmlReaderBridge::new(xml).ok_or(Error::Failed(
"oakcommon XML reader unavailable".to_string(),
))?;
// Detect the root element.
if !reader.next_start_element() {
return Err(Error::Failed("empty XML document".to_string()));
}
let root = reader.name().to_string();
let root_version = reader
.attribute("version")
.and_then(|v| v.parse::<u32>().ok());
// Version gate: reject unknown/newer roots.
match root.as_str() {
"project" => {
// Current format (version 1 of the project schema).
let _ = root_version;
}
"olive" => {
// Historical roots: accept and upgrade when the version is
// known (<= current), reject newer.
if let Some(v) = root_version {
if v > CURRENT_VERSION.0 {
return Err(Error::Failed(format!(
"project version {} is newer than this build ({})",
v, CURRENT_VERSION.0
)));
}
}
}
_ => {
return Err(Error::Failed(format!(
"unrecognized project root element '{}'",
root
)));
}
}
let project = Project::new();
{
let mut guard = lock(&project);
load_project_body(&mut reader, &mut guard)?;
}
Ok(project)
}
/// Parse the `<project>` body: uuid, nodes, settings.
fn load_project_body(reader: &mut dyn XmlRead, project: &mut Project) -> crate::error::Result<()> {
use crate::error::Error;
// Identity -> NodeId map for connection resolution.
let mut id_map: std::collections::HashMap<u64, NodeId> = std::collections::HashMap::new();
// Deferred connections: (output_identity, input_node_id, input_id, element).
let mut connections: Vec<(u64, NodeId, String, i32)> = Vec::new();
// Deferred links: (identity_a, identity_b).
let mut links: Vec<(u64, u64)> = Vec::new();
while reader.next_start_element() {
match reader.name() {
"uuid" => {
project.uuid = reader.read_element_text();
}
"nodes" => {
while reader.next_start_element() {
if reader.name() == "node" {
let id = load_node(reader, &mut project.graph, &mut id_map, &mut connections, &mut links)?;
if project.root == NodeId::INVALID {
// The first node is the root folder when the
// project has no explicit root setting.
project.root = id;
}
} else {
reader.skip_current_element();
}
}
}
"settings" => {
while reader.next_start_element() {
let key = reader.name().to_string();
let val = reader.read_element_text();
project.settings.insert(key, val);
}
}
_ => reader.skip_current_element(),
}
}
// Resolve connections.
for (out_identity, in_id, input_id, element) in connections {
if let Some(out_id) = id_map.get(&out_identity) {
project.graph.connect(*out_id, in_id, &input_id, element).ok();
}
}
// Resolve links (the writer emits one entry per direction; linking
// is symmetric so each pair resolves to the same edge).
for (a, b) in links {
if let (Some(ai), Some(bi)) = (id_map.get(&a), id_map.get(&b)) {
project.graph.link(*ai, *bi);
}
}
// Root setting: honor an explicit "root" setting if present.
if let Some(root) = project.settings.get("root") {
if let Ok(identity) = root.parse::<u64>() {
if let Some(id) = id_map.get(&identity) {
project.root = *id;
}
}
}
Ok(())
}
/// Parse one `<node>` into the graph; returns its id.
#[allow(clippy::too_many_arguments)]
fn load_node(
reader: &mut dyn XmlRead,
graph: &mut Graph,
id_map: &mut std::collections::HashMap<u64, NodeId>,
connections: &mut Vec<(u64, NodeId, String, i32)>,
links: &mut Vec<(u64, u64)>,
) -> crate::error::Result<NodeId> {
use crate::error::Error;
let type_id = reader.attribute("id").unwrap_or_default();
let ptr = reader
.attribute("ptr")
.and_then(|p| p.parse::<u64>().ok())
.unwrap_or(0);
// Instantiate the node type; folders and unknown types fall back to
// an empty folder-ish core.
let (mut core, behavior): (NodeCore, Box<dyn crate::node::NodeBehavior>) = if type_id
== "org.olivevideoeditor.Olive.folder"
{
crate::folder::create("Folder")
} else {
match crate::factory::Factory::global().find(&type_id) {
Some(meta) => (meta.create)(),
None => {
// Unknown type: skip the element body.
reader.skip_current_element();
return Err(Error::Failed(format!("unknown node type '{}'", type_id)));
}
}
};
// 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 {
id_map.insert(ptr, id);
}
// Parse the node body (into the entry's core).
let entry = graph.get_mut(id).ok_or(Error::NotFound)?;
load_node_body(reader, &mut entry.core, id, connections, links)?;
Ok(id)
}
/// Parse the body of a `<node>` element (label/color/inputs/links/...).
fn load_node_body(
reader: &mut dyn XmlRead,
core: &mut NodeCore,
node_id: NodeId,
connections: &mut Vec<(u64, NodeId, String, i32)>,
links: &mut Vec<(u64, u64)>,
) -> crate::error::Result<()> {
while reader.next_start_element() {
match reader.name() {
"label" => core.label = reader.read_element_text(),
"color" => {
core.override_color = reader.read_element_text().trim().parse().unwrap_or(-1)
}
"input" => {
let input_id = reader.attribute("id").unwrap_or_default();
load_input_element(reader, core, &input_id);
}
"links" => {
while reader.next_start_element() {
if reader.name() == "link" {
if let Ok(identity) = reader.read_element_text().trim().parse::<u64>() {
links.push((node_id.identity(), identity));
}
} else {
reader.skip_current_element();
}
}
}
"connections" => {
while reader.next_start_element() {
if reader.name() == "connection" {
let input_id = reader.attribute("input").unwrap_or_default();
let element = reader
.attribute("element")
.and_then(|e| e.parse::<i32>().ok())
.unwrap_or(-1);
let mut output = 0u64;
while reader.next_start_element() {
if reader.name() == "output" {
output = reader.read_element_text().trim().parse().unwrap_or(0);
} else {
reader.skip_current_element();
}
}
connections.push((output, node_id, input_id, element));
} else {
reader.skip_current_element();
}
}
}
"caches" | "custom" => reader.skip_current_element(),
_ => reader.skip_current_element(),
}
}
Ok(())
}
/// Parse one `<input>` element: the primary immediate and subelements.
fn load_input_element(reader: &mut dyn XmlRead, core: &mut NodeCore, input_id: &str) {
let declared = core.input_data_type(input_id).unwrap_or(ValueType::None);
// Locate the input in the core (it exists because the node
// constructor created it).
if reader.next_start_element() && reader.name() == "primary" {
load_immediate(reader, core, input_id, -1, declared);
}
// The primary immediate consumes up to its end; the loop below
// re-enters at the next start element (subelements).
while reader.next_start_element() {
match reader.name() {
"subelements" => {
// Count attr; elements follow as `<element>`.
while reader.next_start_element() {
if reader.name() == "element" {
// Element index derived from order.
let element = core.input_array_size(input_id) as i32;
core.input_array_insert(input_id, element.max(0) as usize);
load_immediate(reader, core, input_id, element, declared);
} else {
reader.skip_current_element();
}
}
}
_ => reader.skip_current_element(),
}
}
}
/// Parse one immediate (standard values + keyframes).
fn load_immediate(reader: &mut dyn XmlRead, core: &mut NodeCore, input_id: &str, element: i32, declared: ValueType) {
let mut keyframing = false;
let mut standard_tracks: Vec<NodeValue> = Vec::new();
let mut keyframe_tracks: Vec<Vec<Keyframe>> = Vec::new();
while reader.next_start_element() {
match reader.name() {
"keyframing" => {
keyframing = reader.read_element_text().trim() == "1";
}
"standard" => {
standard_tracks.clear();
while reader.next_start_element() {
if reader.name() == "track" {
let text = reader.read_element_text();
standard_tracks.push(string_to_value(declared, &text));
} else {
reader.skip_current_element();
}
}
}
"keyframes" => {
keyframe_tracks.clear();
while reader.next_start_element() {
if reader.name() == "track" {
let mut track = Vec::new();
while reader.next_start_element() {
if reader.name() == "key" {
let time = reader
.attribute("time")
.map(|t| Rational::from_string(&t))
.unwrap_or_else(|| Rational::new(0, 1));
let type_c = reader
.attribute("type")
.and_then(|t| t.parse::<i32>().ok())
.unwrap_or(0);
let in_x = reader
.attribute("inhandlex")
.and_then(|v| v.parse::<f64>().ok())
.unwrap_or(0.0);
let in_y = reader
.attribute("inhandley")
.and_then(|v| v.parse::<f64>().ok())
.unwrap_or(0.0);
let out_x = reader
.attribute("outhandlex")
.and_then(|v| v.parse::<f64>().ok())
.unwrap_or(0.0);
let out_y = reader
.attribute("outhandley")
.and_then(|v| v.parse::<f64>().ok())
.unwrap_or(0.0);
let text = reader.read_element_text();
let value = string_to_value(declared, &text);
track.push(Keyframe {
time,
value,
interpolation: interpolation_from_c(type_c),
bezier_in: (in_x, in_y),
bezier_out: (out_x, out_y),
});
} else {
reader.skip_current_element();
}
}
keyframe_tracks.push(track);
} else {
reader.skip_current_element();
}
}
}
_ => reader.skip_current_element(),
}
}
// Apply the parsed state.
let value = NodeValue::combine_tracks(&standard_tracks, declared);
core.set_standard_value(input_id, element, value);
if keyframing {
let track = core.keyframe_track_mut(input_id, element);
for key in keyframe_tracks.into_iter().flatten() {
track.set_key(key);
}
} else {
// No keyframes: drop any pre-existing track.
core.keyframes.retain(|(i, e, _)| !(i == input_id && *e == element));
}
}
/// Lock a project mutex (poison-tolerant).
fn lock<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
m.lock().unwrap_or_else(|e| e.into_inner())
}
+373
View File
@@ -0,0 +1,373 @@
// 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/>.
//! Tracks and track lists (C++ `Track`, `TrackList`).
//! `// CPP-PARITY: src/node/src/output/track/track.{h,cpp}`.
use crate::id::NodeId;
use crate::node::{Category, NodeBehavior, NodeCore};
/// Track media type (values match C++ `Track::Type`).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum TrackType {
/// Video.
Video,
/// Audio.
Audio,
/// Subtitle.
Subtitle,
}
impl TrackType {
/// C ABI value (`OakNodeTrackType`: NONE=-1, VIDEO=0, AUDIO=1,
/// SUBTITLE=2).
pub fn to_c(self) -> i32 {
match self {
TrackType::Video => 0,
TrackType::Audio => 1,
TrackType::Subtitle => 2,
}
}
/// From a C ABI value; `None` for anything outside 0..=2.
pub fn from_c(v: i32) -> Option<TrackType> {
match v {
0 => Some(TrackType::Video),
1 => Some(TrackType::Audio),
2 => Some(TrackType::Subtitle),
_ => None,
}
}
}
/// Track behavior: an ordered block list (C++ `Track`).
#[derive(Clone)]
pub struct TrackBehavior {
/// Media type.
pub kind: TrackType,
/// Block node ids in timeline order.
pub blocks: Vec<NodeId>,
/// Muted flag.
pub muted: bool,
/// Locked flag.
pub locked: bool,
/// Height in internal units (C++ `track_height_`).
pub height: f64,
/// Index inside its track list (C++ `index_`).
pub index: i32,
/// Owning track list id (None when detached).
pub track_list: Option<NodeId>,
}
/// Track list behavior (C++ `TrackList`): the per-type collection of
/// tracks inside a sequence.
#[derive(Clone)]
pub struct TrackListBehavior {
/// Media type of this list.
pub kind: TrackType,
/// Track node ids in stack order.
pub tracks: Vec<NodeId>,
/// Owning sequence id (None when detached).
pub sequence: Option<NodeId>,
/// The sequence input-array element index base (C++
/// `TrackList::k_track_input_format` index; the first list owns
/// elements 0..n, the second n.., etc.).
pub array_base: i32,
}
/// Default track height in internal units (C++ `k_track_height_default`).
pub const DEFAULT_HEIGHT_INTERNAL: f64 = 3.0;
/// Minimum track height in internal units (C++ `k_track_height_minimum`).
pub const MINIMUM_HEIGHT_INTERNAL: f64 = 1.5;
/// Default font height in pixels (C++ `Track::default_font_height`).
pub const DEFAULT_FONT_HEIGHT: f64 = 13.0;
/// Internal -> pixel height (C++ `Track::internal_height_to_pixel_height`).
pub fn internal_height_to_pixel_height(h: f64) -> i32 {
(h * DEFAULT_FONT_HEIGHT).round() as i32
}
/// Pixel -> internal height (C++ `Track::pixel_height_to_internal_height`).
pub fn pixel_height_to_internal_height(h: i32) -> f64 {
h as f64 / DEFAULT_FONT_HEIGHT
}
impl TrackBehavior {
/// New track of the given type.
pub fn new(kind: TrackType) -> Self {
TrackBehavior {
kind,
blocks: Vec::new(),
muted: false,
locked: false,
height: DEFAULT_HEIGHT_INTERNAL,
index: 0,
track_list: None,
}
}
/// Block at `index` (None out of range).
pub fn block_at(&self, index: usize) -> Option<NodeId> {
self.blocks.get(index).copied()
}
/// Index of `block` in the block list.
pub fn block_index(&self, block: NodeId) -> Option<usize> {
self.blocks.iter().position(|b| *b == block)
}
/// Append a block (C++ `Track::append_block`).
pub fn append_block(&mut self, block: NodeId) {
self.blocks.push(block);
}
/// Prepend a block (C++ `Track::prepend_block`).
pub fn prepend_block(&mut self, block: NodeId) {
self.blocks.insert(0, block);
}
/// Insert a block at `index` (clamped; C++ `insert_block_at_index`).
pub fn insert_block_at_index(&mut self, block: NodeId, index: usize) {
let index = index.min(self.blocks.len());
self.blocks.insert(index, block);
}
/// Insert `block` after `before` (C++ `insert_block_after`).
pub fn insert_block_after(&mut self, block: NodeId, before: NodeId) -> bool {
if let Some(i) = self.block_index(before) {
self.blocks.insert(i + 1, block);
true
} else {
false
}
}
/// Insert `block` before `after` (C++ `insert_block_before`).
pub fn insert_block_before(&mut self, block: NodeId, after: NodeId) -> bool {
if let Some(i) = self.block_index(after) {
self.blocks.insert(i, block);
true
} else {
false
}
}
/// Remove `block` (no-op when absent; C++ `Track::remove_block`).
pub fn remove_block(&mut self, block: NodeId) -> bool {
let before = self.blocks.len();
self.blocks.retain(|b| *b != block);
self.blocks.len() != before
}
/// Ripple-remove: drop the block and shift the successors' positions
/// earlier by the block's length (C++ `Track::ripple_remove_block`).
pub fn ripple_remove_block(&mut self, block: NodeId) -> bool {
self.remove_block(block)
}
/// Replace `old` with `replace` (C++ `Track::replace_block`); both
/// must have equal lengths (the caller validates).
pub fn replace_block(&mut self, old: NodeId, replace: NodeId) -> bool {
if let Some(i) = self.block_index(old) {
self.blocks[i] = replace;
true
} else {
false
}
}
/// Block strictly containing `time` (in < time < out; C++
/// `block_containing_time`).
pub fn block_containing_time(&self, time: oakcore_rs::Rational, blocks: &dyn BlockRange) -> Option<NodeId> {
self.blocks
.iter()
.find(|b| blocks.contains_strict(**b, time))
.copied()
}
/// Block visible at `time` (in <= time < out; C++
/// `visible_block_at_time`).
pub fn visible_block_at_time(&self, time: oakcore_rs::Rational, blocks: &dyn BlockRange) -> Option<NodeId> {
self.blocks
.iter()
.find(|b| blocks.contains(**b, time))
.copied()
}
/// Whether the [in, out) range holds no block or only a gap (C++
/// `is_range_free`).
pub fn is_range_free(&self, range: oakcore_rs::TimeRange, blocks: &dyn BlockRange) -> bool {
!self
.blocks
.iter()
.any(|b| blocks.overlaps(*b, range))
}
/// Total length (end of the last block; C++ `Track::get_length`).
pub fn length(&self, blocks: &dyn BlockRange) -> oakcore_rs::Rational {
let mut end = oakcore_rs::Rational::new(0, 1);
for b in &self.blocks {
let out = blocks.out(*b);
if out > end {
end = out;
}
}
end
}
/// Track reference as (type, index) (C++ `Track::Reference`).
pub fn reference(&self) -> (i32, i32) {
(self.kind.to_c(), self.index)
}
}
/// Block-range accessor trait: the graph-backed queries the track needs
/// (block in/out/length) without coupling track.rs to the graph arena.
pub trait BlockRange {
/// In-point of `block`.
fn in_(&self, block: NodeId) -> oakcore_rs::Rational;
/// Out-point of `block`.
fn out(&self, block: NodeId) -> oakcore_rs::Rational;
/// True when `time` is strictly inside the block.
fn contains_strict(&self, block: NodeId, time: oakcore_rs::Rational) -> bool {
let (in_, out) = (self.in_(block), self.out(block));
time > in_ && time < out
}
/// True when `time` is visible on the block (in <= t < out).
fn contains(&self, block: NodeId, time: oakcore_rs::Rational) -> bool {
let (in_, out) = (self.in_(block), self.out(block));
time >= in_ && time < out
}
/// True when the block's span overlaps `range`.
fn overlaps(&self, block: NodeId, range: oakcore_rs::TimeRange) -> bool {
let (in_, out) = (self.in_(block), self.out(block));
!(out <= range.in_() || in_ >= range.out())
}
}
impl TrackListBehavior {
/// New empty list.
pub fn new(kind: TrackType) -> Self {
TrackListBehavior {
kind,
tracks: Vec::new(),
sequence: None,
array_base: 0,
}
}
/// Track at `index`.
pub fn track_at(&self, index: usize) -> Option<NodeId> {
self.tracks.get(index).copied()
}
/// Index of `track` in the list.
pub fn track_index(&self, track: NodeId) -> Option<usize> {
self.tracks.iter().position(|t| *t == track)
}
/// Combined length of the longest track (C++
/// `TrackList::get_total_length`).
pub fn total_length(&self, tracks: &dyn TrackRange) -> oakcore_rs::Rational {
let mut longest = oakcore_rs::Rational::new(0, 1);
for t in &self.tracks {
let len = tracks.length(*t);
if len > longest {
longest = len;
}
}
longest
}
}
impl NodeBehavior for TrackBehavior {
fn name(&self) -> &str {
match self.kind {
TrackType::Video => "Video Track",
TrackType::Audio => "Audio Track",
TrackType::Subtitle => "Subtitle Track",
}
}
fn type_id(&self) -> &str {
"org.olivevideoeditor.Olive.track"
}
fn categories(&self) -> &[Category] {
&[Category::Timeline]
}
fn duplicate(&self, _core: &NodeCore) -> Option<Box<dyn NodeBehavior>> {
Some(Box::new(TrackBehavior {
kind: self.kind,
blocks: self.blocks.clone(),
muted: self.muted,
locked: self.locked,
height: self.height,
index: self.index,
track_list: self.track_list,
}))
}
fn as_any(&self) -> Option<&dyn std::any::Any> {
Some(self)
}
fn as_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
Some(self)
}
}
impl NodeBehavior for TrackListBehavior {
fn name(&self) -> &str {
match self.kind {
TrackType::Video => "Video Tracks",
TrackType::Audio => "Audio Tracks",
TrackType::Subtitle => "Subtitle Tracks",
}
}
fn type_id(&self) -> &str {
"org.olivevideoeditor.Olive.tracklist"
}
fn categories(&self) -> &[Category] {
&[Category::Timeline]
}
fn duplicate(&self, _core: &NodeCore) -> Option<Box<dyn NodeBehavior>> {
Some(Box::new(TrackListBehavior {
kind: self.kind,
tracks: self.tracks.clone(),
sequence: self.sequence,
array_base: self.array_base,
}))
}
fn as_any(&self) -> Option<&dyn std::any::Any> {
Some(self)
}
fn as_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
Some(self)
}
}
/// Track-length accessor trait (the graph-backed query the list needs).
pub trait TrackRange {
/// Total length of `track`.
fn length(&self, track: NodeId) -> oakcore_rs::Rational;
}
+199
View File
@@ -0,0 +1,199 @@
// 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/>.
//! The evaluation engine — the C++ `NodeTraverser` restructured.
//!
//! Key change from C++: no inheritance. C++ `RenderProcessor :
//! NodeTraverser` overrode virtuals to plug rendering in; here the
//! traverser is a free engine and oakrender supplies [`RenderHooks`].
//! The graph is walked iteratively in topological order with an
//! explicit value stack (the C++ recursive path could blow the stack
//! on deep graphs — same order, no recursion).
//! `// CPP-PARITY: src/node/src/traverser.cpp`.
use std::collections::{HashMap, HashSet};
use oakcore_rs::{Rational, TimeRange};
use crate::graph::Graph;
use crate::id::NodeId;
use crate::value::{NodeValue, NodeValueRow, NodeValueTable, ValueType};
/// Backend hooks supplied by the consumer (oakrender). Default no-ops
/// give the C++ "offline evaluation" behavior.
pub trait RenderHooks {
/// Whether cached textures may be used (C++ `use_cache()`).
fn use_cache(&self) -> bool {
false
}
/// Convert a finished value row into a backend job/texture
/// (C++ `resolve_jobs` / `process_*_job` family).
fn resolve(&mut self, node: NodeId, row: &NodeValueRow, table: &mut NodeValueTable) {
let _ = (node, row, table);
}
/// Cancel-check polled between nodes (C++ `IsCancelled`).
fn is_cancelled(&self) -> bool {
false
}
}
/// Evaluation request.
pub struct EvalRequest {
/// Root node to evaluate.
pub root: NodeId,
/// Time.
pub time: Rational,
/// Optional range (for audio pulls).
pub range: Option<TimeRange>,
}
impl EvalRequest {
/// New request.
pub fn new(root: NodeId, time: Rational) -> EvalRequest {
EvalRequest {
root,
time,
range: None,
}
}
}
/// The traversal engine.
pub struct Traverser {
/// Value stack / per-node row cache for this pass.
stack: Vec<(NodeId, NodeValueTable)>,
/// Nodes touched by the last [`Traverser::invalidate_downstream`]
/// walk (observable for tests; the C++ fan-out has no return value).
last_invalidation: Vec<NodeId>,
}
impl Traverser {
/// New empty engine (reusable across evaluations).
pub fn new() -> Self {
Traverser {
stack: Vec::new(),
last_invalidation: Vec::new(),
}
}
/// Nodes marked by the last invalidation walk.
pub fn last_invalidation(&self) -> &[NodeId] {
&self.last_invalidation
}
/// Evaluate `request` against `graph`, calling `hooks` at the
/// backend seams. Returns the root's output table.
///
/// Errors: `State` on cancellation, `Failed` on node evaluation
/// errors (C++ returned empty tables; we surface the error —
/// `// CPP-PARITY: traverser.cpp` behavior notes inline).
pub fn evaluate(
&mut self,
graph: &Graph,
request: &EvalRequest,
hooks: &mut dyn RenderHooks,
) -> crate::error::Result<NodeValueTable> {
use crate::error::Error;
if !graph.is_valid(request.root) {
return Err(Error::NotFound);
}
self.stack.clear();
let order = graph.topological_order();
// Per-node output tables for this pass (memoization: a shared
// upstream evaluates once — `// CPP-PARITY: traverser.cpp`
// process_node_children).
let mut tables: HashMap<NodeId, NodeValueTable> = HashMap::new();
for node in order {
if hooks.is_cancelled() {
return Err(Error::State);
}
// Build this node's input row from its upstream outputs. The
// C++ picks the last value of the matching type per input;
// the Rust model keys rows by input id.
let mut row: NodeValueRow = std::collections::BTreeMap::new();
for (from, input_id, element) in graph.input_connections(node) {
let _ = element;
if let Some(from_table) = tables.get(&from) {
let value = from_table
.get(ValueType::Float)
.or_else(|| from_table.get(ValueType::Int))
.or_else(|| from_table.get(ValueType::Color))
.or_else(|| from_table.get(ValueType::Vec2))
.or_else(|| from_table.get(ValueType::Vec3))
.or_else(|| from_table.get(ValueType::Vec4))
.or_else(|| from_table.get(ValueType::Boolean))
.or_else(|| from_table.get(ValueType::Rational))
.or_else(|| from_table.get(ValueType::Text))
.or_else(|| from_table.get(ValueType::Combo))
.or_else(|| from_table.get(ValueType::StrCombo))
.cloned()
.unwrap_or(NodeValue::None);
row.insert(input_id, value);
}
}
// Evaluate the node's behavior into its output table.
let mut table = NodeValueTable::default();
let entry = graph.get(node).ok_or(Error::NotFound)?;
// The behavior writes outputs; the default no-op leaves the
// table empty (C++ `Node::value` default).
entry.behavior.value(&entry.core, &row, request.time, &mut table);
hooks.resolve(node, &row, &mut table);
tables.insert(node, table);
}
Ok(tables
.remove(&request.root)
.unwrap_or_default())
}
/// Invalidate walk: mark downstream caches dirty after an input
/// change (C++ `invalidate_cache` fan-out, signal-free). Records the
/// walked set in [`Traverser::last_invalidation`].
pub fn invalidate_downstream(&mut self, graph: &Graph, from: NodeId, range: TimeRange) {
let _ = range;
self.last_invalidation.clear();
let mut seen: HashSet<NodeId> = HashSet::new();
let mut queue: Vec<NodeId> = vec![from];
while let Some(n) = queue.pop() {
if !seen.insert(n) {
continue;
}
self.last_invalidation.push(n);
queue.extend(graph.downstream(n));
}
}
}
impl Default for Traverser {
fn default() -> Self {
Traverser::new()
}
}
/// A value database: per-node input rows over a time range (C++
/// `NodeValueDatabase`), exposed by the traverser ffi family.
pub struct ValueDatabase {
/// Rows keyed by node input id.
pub rows: Vec<(String, Vec<(ValueType, NodeValue)>)>,
}
+844
View File
@@ -0,0 +1,844 @@
// 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/>.
//! The node value system: replaces `olive::Variant` + `NodeValue`
//! (C++ type-erasure) with a closed enum.
//!
//! Boundary note: cross-module payloads (textures, sample buffers)
//! arrive as refcounted C handles — the enum stores those handles by
//! value and releases on drop, which keeps the ownership chain inside
//! the refcount discipline instead of the C++ shared_ptr-in-Variant
//! model (the one documented exception of the C++ tree; it does not
//! exist here).
use std::ffi::c_int;
use oakcore_rs::{Rational, SampleFormat};
/// `oaknode_value_type` discriminants (include/node/node.h), used by the
/// ffi layer to marshal [`NodeValue`]s across the C boundary.
pub mod oak {
use std::ffi::c_int;
/// `OAKNODE_VALUE_NONE` (types without a POD representation).
pub const NONE: c_int = 0;
/// `OAKNODE_VALUE_INT`.
pub const INT: c_int = 1;
/// `OAKNODE_VALUE_FLOAT`.
pub const FLOAT: c_int = 2;
/// `OAKNODE_VALUE_BOOL`.
pub const BOOL: c_int = 3;
/// `OAKNODE_VALUE_RATIONAL`.
pub const RATIONAL: c_int = 4;
/// `OAKNODE_VALUE_COLOR`.
pub const COLOR: c_int = 5;
/// `OAKNODE_VALUE_VEC2`.
pub const VEC2: c_int = 6;
/// `OAKNODE_VALUE_VEC3`.
pub const VEC3: c_int = 7;
/// `OAKNODE_VALUE_VEC4`.
pub const VEC4: c_int = 8;
/// `OAKNODE_VALUE_COMBO`.
pub const COMBO: c_int = 9;
/// `OAKNODE_VALUE_STRING` (string-family inputs; string APIs only).
pub const STRING: c_int = 10;
}
/// Value type tag (mirrors C++ `NodeValue::Type`; the C ABI marshals
/// these as ints in `ffi.rs`).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum ValueType {
/// No value.
None,
/// Integer.
Int,
/// Float (f64).
Float,
/// Color RGBA.
Color,
/// Text.
Text,
/// Boolean.
Boolean,
/// Texture handle (oakrender).
Texture,
/// Sample buffer (owned Rust buffer, F32 planar/packed).
Samples,
/// Rational time.
Rational,
/// Vec2/Vec3/Vec4.
Vec2,
/// Vec3.
Vec3,
/// Vec4.
Vec4,
/// 4x4 matrix (C++ `k_matrix`; row-major, 16 elements).
Matrix,
/// Combo index.
Combo,
/// String combo value.
StrCombo,
/// Video params.
VideoParams,
/// Audio params.
AudioParams,
/// Binary blob.
Binary,
/// Node reference (for node-typed inputs).
NodeRef,
/// Push button (no payload).
PushButton,
}
/// A node value. `Texture` stores an oakrender handle; dropping the
/// value releases one reference, and cloning addrefs it (the C++
/// shared_ptr-in-`Variant` model, kept inside the refcount discipline —
/// a plain bitwise clone would double-release on drop).
#[derive(Debug)]
pub enum NodeValue {
/// No value.
None,
/// Integer.
Int(i64),
/// Float.
Float(f64),
/// RGBA color.
Color([f64; 4]),
/// Text.
Text(String),
/// Boolean.
Boolean(bool),
/// Texture handle (owned reference).
Texture(crate::bridge::render::TextureHandle),
/// Interleaved/planar sample payload + format.
Samples(SampleBuffer),
/// Rational.
Rational(Rational),
/// Vec2.
Vec2([f64; 2]),
/// Vec3.
Vec3([f64; 3]),
/// Vec4.
Vec4([f64; 4]),
/// 4x4 matrix, row-major 16 elements (C++ `k_matrix`).
Matrix([f64; 16]),
/// Combo index.
Combo(i64),
/// String combo.
StrCombo(String),
/// Video parameters (frame size/format/rate; plain data).
VideoParams(VideoParams),
/// Audio parameters (plain data).
AudioParams(AudioParams),
/// Opaque bytes.
Binary(Vec<u8>),
/// Reference to another node (identity + generation checked).
NodeRef(crate::id::NodeId),
/// Push button.
PushButton,
}
/// Audio sample payload (owned).
#[derive(Clone, Debug)]
pub struct SampleBuffer {
/// Format of `data`.
pub format: SampleFormat,
/// Channel count.
pub channels: usize,
/// Samples per channel.
pub sample_count: usize,
/// Raw payload (layout per `format`).
pub data: Vec<u8>,
}
impl Default for SampleBuffer {
/// Empty buffer (format `Invalid`, no channels/samples/data).
fn default() -> Self {
SampleBuffer {
format: SampleFormat::Invalid,
channels: 0,
sample_count: 0,
data: Vec::new(),
}
}
}
impl SampleBuffer {
/// True when the payload is allocated (C++ `SampleBuffer::is_allocated`).
pub fn is_allocated(&self) -> bool {
!self.data.is_empty()
}
/// Byte offset of sample `index` of `channel` in `data`, per the
/// format's layout: planar formats store channel-major planes of
/// `sample_count` samples; packed formats interleave per frame.
fn sample_offset(&self, channel: usize, index: usize) -> Option<usize> {
if self.format == SampleFormat::Invalid
|| channel >= self.channels
|| index >= self.sample_count
{
return None;
}
let bps = self.format.bytes_per_sample();
let stride = if self.format.is_planar() {
self.sample_count
} else {
self.channels
};
let pos = if self.format.is_planar() {
channel * self.sample_count + index
} else {
index * self.channels + channel
};
let byte = pos * bps;
if byte + bps > self.data.len() {
return None;
}
Some(byte)
}
/// Read one sample as `f64` (C++ `SampleBuffer::data(channel)[index]`,
/// float pipeline). Out-of-range reads yield 0.0.
pub fn sample_value(&self, channel: usize, index: usize) -> f64 {
let byte = match self.sample_offset(channel, index) {
Some(b) => b,
None => return 0.0,
};
let bps = self.format.bytes_per_sample();
let raw = &self.data[byte..byte + bps];
match self.format {
SampleFormat::U8Planar | SampleFormat::U8 => raw[0] as f64,
SampleFormat::S16Planar | SampleFormat::S16 => {
i16::from_le_bytes([raw[0], raw[1]]) as f64
}
SampleFormat::S32Planar | SampleFormat::S32 | SampleFormat::F32Planar
| SampleFormat::F32 => {
f32::from_le_bytes([raw[0], raw[1], raw[2], raw[3]]) as f64
}
SampleFormat::S64Planar | SampleFormat::S64 => {
i64::from_le_bytes(raw.try_into().unwrap_or([0; 8])) as f64
}
SampleFormat::F64Planar | SampleFormat::F64 => {
f64::from_le_bytes(raw.try_into().unwrap_or([0; 8]))
}
SampleFormat::Invalid => 0.0,
}
}
/// Write one sample from an `f64` (C++
/// `SampleBuffer::data(channel)[index] = value`, float pipeline).
/// Out-of-range writes are ignored.
pub fn set_sample_value(&mut self, channel: usize, index: usize, value: f64) {
let byte = match self.sample_offset(channel, index) {
Some(b) => b,
None => return,
};
let bps = self.format.bytes_per_sample();
let raw = &mut self.data[byte..byte + bps];
match self.format {
SampleFormat::U8Planar | SampleFormat::U8 => raw[0] = value as u8,
SampleFormat::S16Planar | SampleFormat::S16 => {
raw.copy_from_slice(&(value as i16).to_le_bytes())
}
SampleFormat::S32Planar | SampleFormat::S32 => {
raw.copy_from_slice(&(value as i32).to_le_bytes())
}
SampleFormat::F32Planar | SampleFormat::F32 => {
raw.copy_from_slice(&(value as f32).to_le_bytes())
}
SampleFormat::S64Planar | SampleFormat::S64 => {
raw.copy_from_slice(&(value as i64).to_le_bytes())
}
SampleFormat::F64Planar | SampleFormat::F64 => {
raw.copy_from_slice(&value.to_le_bytes())
}
SampleFormat::Invalid => {}
}
}
/// Multiply every sample by `volume` in place (C++
/// `SampleBuffer::transform_volume`).
pub fn transform_volume(&mut self, volume: f64) {
for c in 0..self.channels {
for i in 0..self.sample_count {
let v = self.sample_value(c, i);
self.set_sample_value(c, i, v * volume);
}
}
}
/// Multiply one channel's samples by `volume` in place (C++
/// `SampleBuffer::transform_volume_for_channel`).
pub fn transform_volume_for_channel(&mut self, channel: usize, volume: f64) {
if channel >= self.channels {
return;
}
for i in 0..self.sample_count {
let v = self.sample_value(channel, i);
self.set_sample_value(channel, i, v * volume);
}
}
}
impl ValueType {
/// Pinned mapping to `oaknode_value_type` (`// CPP-PARITY:
/// src/node/c_api/valueconvert.h` `value_type_to_oak`). Types without a
/// POD representation map to [`oak::NONE`].
pub fn to_oak(self) -> c_int {
match self {
ValueType::Int => oak::INT,
ValueType::Float => oak::FLOAT,
ValueType::Boolean => oak::BOOL,
ValueType::Rational => oak::RATIONAL,
ValueType::Color => oak::COLOR,
ValueType::Vec2 => oak::VEC2,
ValueType::Vec3 => oak::VEC3,
ValueType::Vec4 => oak::VEC4,
ValueType::Combo => oak::COMBO,
// String-carried types (k_file/k_text/k_font/k_str_combo).
ValueType::Text | ValueType::StrCombo => oak::STRING,
_ => oak::NONE,
}
}
/// True for string-carried types (no POD representation; handled by
/// the dedicated string getters/setters, `// CPP-PARITY: valueconvert.h`
/// `value_type_is_string`).
pub fn is_string(self) -> bool {
matches!(self, ValueType::Text | ValueType::StrCombo)
}
/// Number of keyframe tracks the type splits into (C++
/// `NodeValue::get_number_of_keyframe_tracks`).
pub fn keyframe_track_count(self) -> usize {
match self {
ValueType::Vec2 => 2,
ValueType::Vec3 => 3,
ValueType::Vec4 | ValueType::Color => 4,
_ => 1,
}
}
/// The C++ `NodeValue::Type` enum discriminant (`src/node/src/value.h`).
/// Used to serialize shader ids as `"<op>.<pairing>.<type_a>.<type_b>"`
/// (`// CPP-PARITY: mathbase.cpp` `value_internal`). Types without a
/// C++ counterpart (e.g. [`ValueType::VideoParams`]) map to
/// `k_none = 0`.
pub fn to_cpp_discriminant(self) -> i32 {
match self {
ValueType::None => 0,
ValueType::Int => 1,
ValueType::Float => 2,
ValueType::Rational => 3,
ValueType::Boolean => 4,
ValueType::Color => 5,
ValueType::Matrix => 6,
ValueType::Text => 7,
// k_font = 8 / k_file = 9 have no Rust type counterpart.
ValueType::Texture => 10,
ValueType::Samples => 11,
ValueType::Vec2 => 12,
ValueType::Vec3 => 13,
ValueType::Vec4 => 14,
// k_bezier = 15 has no Rust type counterpart.
ValueType::Combo => 16,
ValueType::StrCombo => 17,
ValueType::VideoParams => 18,
ValueType::AudioParams => 19,
// k_subtitle_params = 20 has no Rust type counterpart.
ValueType::Binary => 21,
ValueType::PushButton => 22,
// No C++ counterpart (k_none).
ValueType::NodeRef => 0,
}
}
/// Whether values of this type can be interpolated between keyframes
/// (C++ `NodeValue::type_can_be_interpolated`; bezier is not a Rust
/// value type).
pub fn can_interpolate(self) -> bool {
matches!(
self,
ValueType::Float
| ValueType::Vec2
| ValueType::Vec3
| ValueType::Vec4
| ValueType::Color
| ValueType::Rational
)
}
}
impl NodeValue {
/// The value's type tag.
pub fn value_type(&self) -> ValueType {
match self {
NodeValue::None => ValueType::None,
NodeValue::Int(_) => ValueType::Int,
NodeValue::Float(_) => ValueType::Float,
NodeValue::Color(_) => ValueType::Color,
NodeValue::Text(_) => ValueType::Text,
NodeValue::Boolean(_) => ValueType::Boolean,
NodeValue::Texture(_) => ValueType::Texture,
NodeValue::Samples(_) => ValueType::Samples,
NodeValue::Rational(_) => ValueType::Rational,
NodeValue::Vec2(_) => ValueType::Vec2,
NodeValue::Vec3(_) => ValueType::Vec3,
NodeValue::Vec4(_) => ValueType::Vec4,
NodeValue::Matrix(_) => ValueType::Matrix,
NodeValue::Combo(_) => ValueType::Combo,
NodeValue::StrCombo(_) => ValueType::StrCombo,
NodeValue::VideoParams(_) => ValueType::VideoParams,
NodeValue::AudioParams(_) => ValueType::AudioParams,
NodeValue::Binary(_) => ValueType::Binary,
NodeValue::NodeRef(_) => ValueType::NodeRef,
NodeValue::PushButton => ValueType::PushButton,
}
}
/// Numeric conversion (C++ `Variant::to_double` used by keyframe
/// interpolation); non-numeric payloads yield 0.0.
pub fn to_double(&self) -> f64 {
match self {
NodeValue::Int(i) => *i as f64,
NodeValue::Float(f) => *f,
NodeValue::Color(c) => c[0],
NodeValue::Boolean(b) => {
if *b {
1.0
} else {
0.0
}
}
NodeValue::Rational(r) => r.to_f64(),
NodeValue::Vec2(v) => v[0],
NodeValue::Vec3(v) => v[0],
NodeValue::Vec4(v) => v[0],
NodeValue::Combo(i) => *i as f64,
_ => 0.0,
}
}
/// Whether this value can be interpolated (type-based).
pub fn can_interpolate(&self) -> bool {
self.value_type().can_interpolate()
}
/// Split a whole value into per-track components (C++
/// `NodeValue::split_normal_value_into_track_values`). Scalar types
/// split into a single element holding the whole value.
pub fn split_into_tracks(&self, declared: ValueType) -> Vec<NodeValue> {
let count = declared.keyframe_track_count();
let mut vals = vec![NodeValue::None; count];
match self {
NodeValue::Vec2(v) => {
vals[0] = NodeValue::Float(v[0]);
vals[1] = NodeValue::Float(v[1]);
}
NodeValue::Vec3(v) => {
vals[0] = NodeValue::Float(v[0]);
vals[1] = NodeValue::Float(v[1]);
vals[2] = NodeValue::Float(v[2]);
}
NodeValue::Vec4(v) => {
vals[0] = NodeValue::Float(v[0]);
vals[1] = NodeValue::Float(v[1]);
vals[2] = NodeValue::Float(v[2]);
vals[3] = NodeValue::Float(v[3]);
}
NodeValue::Color(c) => {
vals[0] = NodeValue::Float(c[0]);
vals[1] = NodeValue::Float(c[1]);
vals[2] = NodeValue::Float(c[2]);
vals[3] = NodeValue::Float(c[3]);
}
_ => {
vals[0] = self.clone();
}
}
vals
}
/// Recombine per-track components into a whole value (C++
/// `NodeValue::combine_track_values_into_normal_value`). An empty
/// slice yields [`NodeValue::None`].
pub fn combine_tracks(tracks: &[NodeValue], declared: ValueType) -> NodeValue {
if tracks.is_empty() {
return NodeValue::None;
}
let comp = |i: usize| tracks.get(i).map(NodeValue::to_double).unwrap_or(0.0);
match declared {
ValueType::Vec2 => NodeValue::Vec2([comp(0), comp(1)]),
ValueType::Vec3 => NodeValue::Vec3([comp(0), comp(1), comp(2)]),
ValueType::Vec4 => NodeValue::Vec4([comp(0), comp(1), comp(2), comp(3)]),
ValueType::Color => NodeValue::Color([comp(0), comp(1), comp(2), comp(3)]),
_ => tracks[0].clone(),
}
}
/// Interpolate between two values at `t` in [0, 1] (C++
/// `get_split_value_at_time_on_track` linear path, `lerp(a,b,t) =
/// a*(1-t)+b*t`; rational values re-quantize through
/// `Rational::from_double`). Non-interpolable types snap to `self`.
pub fn lerp(&self, other: &NodeValue, t: f64) -> NodeValue {
match (self, other) {
(NodeValue::Float(a), NodeValue::Float(b)) => NodeValue::Float(lerp_f(a, b, t)),
(NodeValue::Color(a), NodeValue::Color(b)) => {
NodeValue::Color(lerp_arr4(a, b, t))
}
(NodeValue::Vec2(a), NodeValue::Vec2(b)) => NodeValue::Vec2(lerp_arr2(a, b, t)),
(NodeValue::Vec3(a), NodeValue::Vec3(b)) => NodeValue::Vec3(lerp_arr3(a, b, t)),
(NodeValue::Vec4(a), NodeValue::Vec4(b)) => NodeValue::Vec4(lerp_arr4(a, b, t)),
(NodeValue::Rational(_), _) | (_, NodeValue::Rational(_)) => {
let a = self.to_double();
let b = other.to_double();
NodeValue::Rational(Rational::from_double(lerp_f(&a, &b, t)))
}
_ => self.clone(),
}
}
/// Rebuild a value of `declared` type carrying `scalar` as its single
/// numeric payload (used by the per-track bezier evaluation, where the
/// whole scalar value interpolates along the curve). Non-numeric
/// declared types fall back to the scalar's numeric conversion.
pub fn with_scalar(&self, declared: ValueType, scalar: f64) -> NodeValue {
match declared {
ValueType::Int => NodeValue::Int(scalar as i64),
ValueType::Float => NodeValue::Float(scalar),
ValueType::Boolean => NodeValue::Boolean(scalar != 0.0),
ValueType::Combo => NodeValue::Combo(scalar as i64),
ValueType::Color => NodeValue::Color([scalar, 0.0, 0.0, 0.0]),
ValueType::Vec2 => NodeValue::Vec2([scalar, 0.0]),
ValueType::Vec3 => NodeValue::Vec3([scalar, 0.0, 0.0]),
ValueType::Vec4 => NodeValue::Vec4([scalar, 0.0, 0.0, 0.0]),
_ => self.clone(),
}
}
}
/// `lerp` from the C++ `lerp.h` template: `a*(1.0 - t) + b*t`.
fn lerp_f(a: &f64, b: &f64, t: f64) -> f64 {
(a * (1.0 - t)) + (b * t)
}
fn lerp_arr2(a: &[f64; 2], b: &[f64; 2], t: f64) -> [f64; 2] {
[
lerp_f(&a[0], &b[0], t),
lerp_f(&a[1], &b[1], t),
]
}
fn lerp_arr3(a: &[f64; 3], b: &[f64; 3], t: f64) -> [f64; 3] {
[
lerp_f(&a[0], &b[0], t),
lerp_f(&a[1], &b[1], t),
lerp_f(&a[2], &b[2], t),
]
}
fn lerp_arr4(a: &[f64; 4], b: &[f64; 4], t: f64) -> [f64; 4] {
[
lerp_f(&a[0], &b[0], t),
lerp_f(&a[1], &b[1], t),
lerp_f(&a[2], &b[2], t),
lerp_f(&a[3], &b[3], t),
]
}
/// Video parameters (plain data; mirrors oakcommon `VideoParams` C++
/// fields — the C ABI marshals field-by-field).
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct VideoParams {
/// Width.
pub width: i32,
/// Height.
pub height: i32,
/// Frame rate.
pub frame_rate: Rational,
/// Pixel format as oakcore-rs enum discriminant.
pub pixel_format: i32,
/// Channel count.
pub channels: i32,
}
/// Audio parameters (plain data).
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct AudioParams {
/// Sample rate.
pub sample_rate: i32,
/// Channel layout mask.
pub channel_layout: u64,
/// Sample format discriminant.
pub format: i32,
}
/// One row of evaluated inputs: input id -> value at a time.
pub type NodeValueRow = std::collections::BTreeMap<String, NodeValue>;
/// Evaluation output table (C++ `NodeValueTable`): ordered pushes with
/// optional source tags; `get` returns the last push of a type.
#[derive(Default, Debug)]
pub struct NodeValueTable {
rows: Vec<(ValueType, NodeValue, Option<String>)>,
}
impl NodeValueTable {
/// Push a value with an optional tag (C++ `push`).
pub fn push(&mut self, ty: ValueType, value: NodeValue, tag: Option<String>) {
self.rows.push((ty, value, tag));
}
/// Last pushed value of `ty` (C++ `get` semantics).
pub fn get(&self, ty: ValueType) -> Option<&NodeValue> {
self.rows
.iter()
.rev()
.find(|(t, _, _)| *t == ty)
.map(|(_, v, _)| v)
}
/// Number of pushed rows (C++ `count()`).
pub fn count(&self) -> usize {
self.rows.len()
}
/// True when the table holds no rows (C++ `is_empty()`).
pub fn is_empty(&self) -> bool {
self.rows.is_empty()
}
/// Clear all rows (C++ `clear()`).
pub fn clear(&mut self) {
self.rows.clear();
}
/// All rows `(type, value, tag)` in push order.
pub fn rows(&self) -> &[(ValueType, NodeValue, Option<String>)] {
&self.rows
}
}
/// Structural equality: `Texture` compares by handle address, `Samples`
/// by payload. Mirrors the C++ `NodeValue::operator==` (type + tag +
/// data) for the types the crate supports; `None` equals only `None`.
impl PartialEq for NodeValue {
fn eq(&self, other: &Self) -> bool {
match (self, other) {
(NodeValue::None, NodeValue::None) => true,
(NodeValue::Int(a), NodeValue::Int(b)) => a == b,
(NodeValue::Float(a), NodeValue::Float(b)) => a == b,
(NodeValue::Color(a), NodeValue::Color(b)) => a == b,
(NodeValue::Text(a), NodeValue::Text(b)) => a == b,
(NodeValue::Boolean(a), NodeValue::Boolean(b)) => a == b,
(NodeValue::Texture(a), NodeValue::Texture(b)) => a.ctx == b.ctx,
(NodeValue::Samples(a), NodeValue::Samples(b)) => {
a.format == b.format
&& a.channels == b.channels
&& a.sample_count == b.sample_count
&& a.data == b.data
}
(NodeValue::Rational(a), NodeValue::Rational(b)) => a == b,
(NodeValue::Vec2(a), NodeValue::Vec2(b)) => a == b,
(NodeValue::Vec3(a), NodeValue::Vec3(b)) => a == b,
(NodeValue::Vec4(a), NodeValue::Vec4(b)) => a == b,
(NodeValue::Matrix(a), NodeValue::Matrix(b)) => a == b,
(NodeValue::Combo(a), NodeValue::Combo(b)) => a == b,
(NodeValue::StrCombo(a), NodeValue::StrCombo(b)) => a == b,
(NodeValue::VideoParams(a), NodeValue::VideoParams(b)) => a == b,
(NodeValue::AudioParams(a), NodeValue::AudioParams(b)) => a == b,
(NodeValue::Binary(a), NodeValue::Binary(b)) => a == b,
(NodeValue::NodeRef(a), NodeValue::NodeRef(b)) => a == b,
(NodeValue::PushButton, NodeValue::PushButton) => true,
_ => false,
}
}
}
impl Clone for NodeValue {
/// Clone with C++ `shared_ptr` semantics for [`NodeValue::Texture`]:
/// the handle is copied and addref'd, so each clone owns one
/// reference released on drop (a plain bitwise copy would
/// double-release). All other variants are bitwise-copied.
fn clone(&self) -> Self {
match self {
NodeValue::Texture(h) => {
let mut h2 = h.clone();
if let Some(f) = h2.addref {
// Safety: `h2` is a valid handle; addref only touches
// the refcount.
unsafe { f(h2.ctx) };
}
NodeValue::Texture(h2)
}
NodeValue::None => NodeValue::None,
NodeValue::Int(v) => NodeValue::Int(*v),
NodeValue::Float(v) => NodeValue::Float(*v),
NodeValue::Color(v) => NodeValue::Color(*v),
NodeValue::Text(v) => NodeValue::Text(v.clone()),
NodeValue::Boolean(v) => NodeValue::Boolean(*v),
NodeValue::Samples(v) => NodeValue::Samples(v.clone()),
NodeValue::Rational(v) => NodeValue::Rational(*v),
NodeValue::Vec2(v) => NodeValue::Vec2(*v),
NodeValue::Vec3(v) => NodeValue::Vec3(*v),
NodeValue::Vec4(v) => NodeValue::Vec4(*v),
NodeValue::Matrix(v) => NodeValue::Matrix(*v),
NodeValue::Combo(v) => NodeValue::Combo(*v),
NodeValue::StrCombo(v) => NodeValue::StrCombo(v.clone()),
NodeValue::VideoParams(v) => NodeValue::VideoParams(*v),
NodeValue::AudioParams(v) => NodeValue::AudioParams(*v),
NodeValue::Binary(v) => NodeValue::Binary(v.clone()),
NodeValue::NodeRef(v) => NodeValue::NodeRef(*v),
NodeValue::PushButton => NodeValue::PushButton,
}
}
}
impl Drop for NodeValue {
/// `Texture` payloads own one handle reference: dropping the value
/// releases it (the documented boundary rule — cross-module payloads
/// stay inside the refcount discipline).
fn drop(&mut self) {
if let NodeValue::Texture(h) = self {
if let Some(f) = h.release {
unsafe { f(h.ctx) };
}
}
}
}
/// `#[repr(C)]` mirror of the C `oaknode_value` POD (include/node/node.h),
/// used by the ffi layer for value-carrying exports (keyframe/dragger).
/// Only the fields meaningful for the value's `kind` are used; the layout
/// (int + 4-byte pad + two i64 + [f64; 4]) matches the C struct exactly.
#[repr(C)]
#[derive(Clone, Copy, Debug)]
pub struct OakNodeValue {
/// `oaknode_value_type` discriminant (0 = NONE ... 9 = STRING).
pub kind: c_int,
/// INT/COMBO value, BOOL 0/1, RATIONAL numerator.
pub num: i64,
/// RATIONAL denominator.
pub den: i64,
/// FLOAT f[0]; VEC2/3/4 f[0..n-1]; COLOR r,g,b,a.
pub f: [f64; 4],
}
impl OakNodeValue {
/// The zeroed POD (type `NONE`).
pub fn none() -> Self {
OakNodeValue {
kind: 0,
num: 0,
den: 0,
f: [0.0; 4],
}
}
/// Map an oaknode_value POD into a [`NodeValue`] of the input's
/// declared type (C++ `variant_from_value`). `OAKNODE_VALUE_STRING`
/// and unknown kinds are rejected with [`Error::Invalid`].
pub fn to_node_value(self, declared: ValueType) -> crate::error::Result<NodeValue> {
use crate::error::Error;
match self.kind {
oak::INT | oak::COMBO => Ok(NodeValue::Int(self.num)),
oak::FLOAT => Ok(NodeValue::Float(self.f[0])),
oak::BOOL => Ok(NodeValue::Boolean(self.num != 0)),
oak::RATIONAL => Ok(NodeValue::Rational(Rational::new(self.num, self.den))),
oak::COLOR => Ok(NodeValue::Color(self.f)),
oak::VEC2 => Ok(NodeValue::Vec2([self.f[0], self.f[1]])),
oak::VEC3 => Ok(NodeValue::Vec3([self.f[0], self.f[1], self.f[2]])),
oak::VEC4 => Ok(NodeValue::Vec4(self.f)),
_ => Err(Error::Invalid),
}
}
/// Map a [`NodeValue`] of the input's declared type into the POD
/// (C++ `value_from_variant`). String-carried declared types fail with
/// [`Error::Invalid`]; types without a POD representation fail with
/// [`Error::Failed`].
pub fn from_node_value(declared: ValueType, v: &NodeValue) -> crate::error::Result<OakNodeValue> {
use crate::error::Error;
if declared.is_string() {
return Err(Error::Invalid);
}
let mut out = OakNodeValue::none();
out.kind = declared.to_oak();
match declared {
ValueType::None => Ok(out),
ValueType::Int | ValueType::Combo => {
out.num = v.to_double() as i64;
Ok(out)
}
ValueType::Float => {
out.f[0] = v.to_double();
Ok(out)
}
ValueType::Boolean => {
out.num = v.to_double() as i64;
Ok(out)
}
ValueType::Rational => match v {
NodeValue::Rational(r) => {
out.num = r.numerator();
out.den = r.denominator();
Ok(out)
}
_ => {
out.num = v.to_double() as i64;
out.den = 1;
Ok(out)
}
},
ValueType::Color => {
let c = match v {
NodeValue::Color(c) => *c,
_ => return Err(Error::Failed("type has no POD representation".to_string())),
};
out.f = c;
Ok(out)
}
ValueType::Vec2 => {
let a = match v {
NodeValue::Vec2(a) => *a,
_ => return Err(Error::Failed("type has no POD representation".to_string())),
};
out.f = [a[0], a[1], 0.0, 0.0];
Ok(out)
}
ValueType::Vec3 => {
let a = match v {
NodeValue::Vec3(a) => *a,
_ => return Err(Error::Failed("type has no POD representation".to_string())),
};
out.f = [a[0], a[1], a[2], 0.0];
Ok(out)
}
ValueType::Vec4 => {
let a = match v {
NodeValue::Vec4(a) => *a,
_ => return Err(Error::Failed("type has no POD representation".to_string())),
};
out.f = a;
Ok(out)
}
_ => {
out.kind = oak::NONE;
Err(Error::Failed("type has no POD representation".to_string()))
}
}
}
}