workspace: kebab-case crates, app under crates/oak-app, shared versions
CI / Build & test (Windows) (push) Failing after 7s
CI / Build & test (Windows) (push) Failing after 7s
All crates take the oak-* kebab-case naming (oak-audio, oak-codec, oak-common, oak-core, oak-ffmpeg-link, oak-node, oak-otio, oak-plugin, oak-render, oak-storage, oak-task, oak-timeline, oak-undo), with the lib identifiers rewritten (oakrender:: -> oak_render::, oakcore_rs:: -> oak_core::, ...) across all 226 referencing files. The GUI application moves from the workspace root into crates/oak-app/: src/, build.rs (paths fixed for the new location) and tests/ travel with it, the root Cargo.toml becomes workspace-only ([workspace] + workspace.package + profiles), and the app package inherits the workspace version. The screenshots example becomes a standalone crate examples/simple_player/ with its own Cargo.toml. Every crate now inherits the single workspace version (version.workspace = true), and the workflows' crate paths and the build docs follow the renames. Validated with a clean cargo check --workspace.
This commit is contained in:
@@ -0,0 +1,708 @@
|
||||
// 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,
|
||||
}
|
||||
}
|
||||
|
||||
/// Combo input option labels (C++ `retranslate()` /
|
||||
/// `set_combo_box_strings`): `method_in` -> "Box", "Gaussian",
|
||||
/// "Directional", "Radial".
|
||||
fn input_combo_strings(&self, id: &str) -> Vec<&'static str> {
|
||||
match id {
|
||||
METHOD_INPUT => vec!["Box", "Gaussian", "Directional", "Radial"],
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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: oak_core::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 oak_core::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,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,617 @@
|
||||
// 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: oak_core::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 oak_core::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,388 @@
|
||||
// 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,
|
||||
}
|
||||
}
|
||||
|
||||
/// Combo input option labels (C++ `retranslate()` /
|
||||
/// `set_combo_box_strings`): `color_in` -> "Green", "Blue".
|
||||
fn input_combo_strings(&self, id: &str) -> Vec<&'static str> {
|
||||
match id {
|
||||
COLOR_INPUT => vec!["Green", "Blue"],
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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: oak_core::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 oak_core::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,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/>.
|
||||
|
||||
//! 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: oak_core::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 oak_core::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,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,514 @@
|
||||
// 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: oak_core::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 oak_core::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,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
// 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,
|
||||
}
|
||||
}
|
||||
|
||||
/// Combo input option labels (C++ `retranslate()` /
|
||||
/// `set_combo_box_strings`): `color_in` -> "Green", "Blue";
|
||||
/// `method_in` -> "Average", "Double Red Average", "Double Average",
|
||||
/// "Limit".
|
||||
fn input_combo_strings(&self, id: &str) -> Vec<&'static str> {
|
||||
match id {
|
||||
COLOR_INPUT => vec!["Green", "Blue"],
|
||||
METHOD_INPUT => vec!["Average", "Double Red Average", "Double Average", "Limit"],
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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: oak_core::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 oak_core::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,420 @@
|
||||
// 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
|
||||
//! (oakrender, opaque handles), 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,
|
||||
}
|
||||
}
|
||||
|
||||
/// Combo input option labels (C++ `retranslate()` /
|
||||
/// `set_combo_box_strings`): `dir_in` -> "Forward", "Inverse". The
|
||||
/// `display_in`/`view_in` strings come from the attached color
|
||||
/// manager at runtime (`update_displays`/`update_views`) and cannot
|
||||
/// be expressed statically.
|
||||
fn input_combo_strings(&self, id: &str) -> Vec<&'static str> {
|
||||
match id {
|
||||
DIRECTION_INPUT => vec!["Forward", "Inverse"],
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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: oak_core::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 oak_core::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,420 @@
|
||||
// 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: oak_core::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 oak_core::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,
|
||||
});
|
||||
}
|
||||
@@ -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: oak_core::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 oak_core::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,158 @@
|
||||
// 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. `job` is an opaque
|
||||
/// oakrender texture handle (cross-module payload; null in the
|
||||
/// deferred-job model) — see [`crate::value::NodeValue::Texture`].
|
||||
pub fn push_mergable_job(
|
||||
inputs: &crate::value::NodeValueRow,
|
||||
job: crate::handle::CHandle,
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
// 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
|
||||
//! oakundo commands in the ops 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,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,578 @@
|
||||
// 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: oak_core::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 oak_core::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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,401 @@
|
||||
// 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,
|
||||
}
|
||||
}
|
||||
|
||||
/// Combo input option labels (C++ `retranslate()` /
|
||||
/// `set_combo_box_strings`): `method_in` -> the five operation names
|
||||
/// "Add", "Subtract", "Multiply", "Divide", "Power".
|
||||
fn input_combo_strings(&self, id: &str) -> Vec<&'static str> {
|
||||
match id {
|
||||
METHOD_INPUT => vec!["Add", "Subtract", "Multiply", "Divide", "Power"],
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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: oak_core::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: oak_core::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 oak_core::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: oak_core::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: oak_core::SampleFormat::F32Planar,
|
||||
channels: 1,
|
||||
sample_count: 2,
|
||||
data: vec![0u8; 8],
|
||||
};
|
||||
behavior.process_samples(
|
||||
&core,
|
||||
&inputs,
|
||||
oak_core::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
@@ -0,0 +1,571 @@
|
||||
// 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: oak_core::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: oak_core::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: oak_core::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: oak_core::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: oak_core::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 oak_core::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,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
// 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: oak_core::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 oak_core::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,
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
pub mod multicamnode;
|
||||
mod noise;
|
||||
mod ociobase;
|
||||
mod ociogradingtransformlinear;
|
||||
mod ociogradingtransformlog;
|
||||
mod ociolut;
|
||||
mod opacity;
|
||||
mod pan;
|
||||
pub 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,280 @@
|
||||
// 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: oak_core::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 oak_core::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,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,570 @@
|
||||
// Oak Video Editor - Non-Linear Video Editor
|
||||
// Copyright (C) 2026 Oak Team
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! Multi-cam source switcher node (C++
|
||||
//! `src/node/src/input/multicam/multicamnode.{h,cpp}`,
|
||||
//! `olive::MultiCamNode`).
|
||||
|
||||
use crate::factory::NodeMeta;
|
||||
use crate::id::NodeId;
|
||||
use crate::node::{Category, NodeBehavior, NodeCore};
|
||||
use crate::value::{NodeValue, NodeValueRow, NodeValueTable};
|
||||
use oak_core::Rational;
|
||||
|
||||
/// Current source selector input id (C++ `k_current_input`). Type:
|
||||
/// combo; default `0`; flags: static (not-connectable +
|
||||
/// not-keyframable). Its combo-box strings are built in `retranslate()`
|
||||
/// as `"<index + 1>: <source name>"` entries.
|
||||
pub const CURRENT_INPUT: &str = "current_in";
|
||||
|
||||
/// Sources array input id (C++ `k_sources_input`). Type: none (any);
|
||||
/// flags: not-keyframable, array; properties: `arraystart = 1`.
|
||||
pub const SOURCES_INPUT: &str = "sources_in";
|
||||
|
||||
/// Sequence input id (C++ `k_sequence_input`). Type: none (node
|
||||
/// reference to a `Sequence`); flags: not-keyframable. Excluded from
|
||||
/// rendering via `ignore_inputs_for_rendering()`.
|
||||
pub const SEQUENCE_INPUT: &str = "sequence_in";
|
||||
|
||||
/// Sequence track-type selector input id (C++ `k_sequence_type_input`).
|
||||
/// Type: combo; default `0` (video); flags: static, hidden (unhidden
|
||||
/// while a sequence is connected). Combo strings: `"Video"`, `"Audio"`
|
||||
/// (C++ `Track::Type` values).
|
||||
pub const SEQUENCE_TYPE_INPUT: &str = "sequence_type_in";
|
||||
|
||||
/// Multi-cam node. Switches between multiple sources either from the
|
||||
/// `sources_in` array or, when a `Sequence` is connected to
|
||||
/// `sequence_in`, from that sequence's track list.
|
||||
pub struct MultiCamNode {
|
||||
/// Connected sequence whose track list supplies the sources (C++
|
||||
/// `sequence_`, a raw `Sequence *` set/cleared by the
|
||||
/// `sequence_in` connect/disconnect events). Stored here as the
|
||||
/// sequence node's id; `None` = no sequence connected.
|
||||
sequence: Option<NodeId>,
|
||||
}
|
||||
|
||||
/// The C++ `k_input_flag_static` mask: not-connectable +
|
||||
/// not-keyframable.
|
||||
const STATIC_FLAGS: u32 =
|
||||
crate::input::flags::NOT_CONNECTABLE | crate::input::flags::NOT_KEYFRAMABLE;
|
||||
|
||||
impl MultiCamNode {
|
||||
/// Index of the currently selected source (C++
|
||||
/// `get_current_source()` — the standard value of
|
||||
/// [`CURRENT_INPUT`] as int).
|
||||
pub fn current_source(&self, core: &NodeCore) -> i32 {
|
||||
core.standard_value(CURRENT_INPUT, -1).to_double() as i32
|
||||
}
|
||||
|
||||
/// The connected sequence node (C++ `sequence_`).
|
||||
pub fn sequence(&self) -> Option<NodeId> {
|
||||
self.sequence
|
||||
}
|
||||
|
||||
/// Set/clear the connected-sequence state (the effects of the C++
|
||||
/// `InputConnectedEvent`/`InputDisconnectedEvent` on `sequence_in`:
|
||||
/// store the sequence and toggle the `sequence_type_in` hidden flag).
|
||||
/// The graph arena does not dispatch behavior events on edge edits, so
|
||||
/// the commands that edit the `sequence_in` edge call this to keep the
|
||||
/// behavior state in sync with the graph.
|
||||
pub fn set_sequence(&mut self, core: &mut NodeCore, sequence: Option<NodeId>) {
|
||||
if let Some(slot) = core.get_input_mut(SEQUENCE_TYPE_INPUT) {
|
||||
if sequence.is_some() {
|
||||
slot.flags &= !crate::input::flags::HIDDEN;
|
||||
} else {
|
||||
slot.flags |= crate::input::flags::HIDDEN;
|
||||
}
|
||||
}
|
||||
self.sequence = sequence;
|
||||
}
|
||||
|
||||
/// Number of available sources (C++ `get_source_count()`): the
|
||||
/// connected sequence's track count when a sequence is set,
|
||||
/// otherwise the `sources_in` array size.
|
||||
///
|
||||
/// The sequence's track list lives in the sequence node's behavior
|
||||
/// (`crate::sequence::SequenceBehavior`) reachable only through the
|
||||
/// graph, which this signature does not carry; with a sequence set
|
||||
/// the count therefore falls back to the `sources_in` array size
|
||||
/// (`// CPP-PARITY: multicamnode.cpp` `get_source_count`).
|
||||
pub fn source_count(&self, core: &NodeCore) -> i32 {
|
||||
let _ = self;
|
||||
core.input_array_size(SOURCES_INPUT) as i32
|
||||
}
|
||||
|
||||
/// Grid layout for `sources` sources (C++ static
|
||||
/// `get_rows_and_columns(sources, rows, cols)`): starts at 1x1 and
|
||||
/// grows the smaller dimension until rows*cols >= sources.
|
||||
pub fn rows_and_columns(sources: i32) -> (i32, i32) {
|
||||
let mut rows = 1;
|
||||
let mut cols = 1;
|
||||
while rows * cols < sources {
|
||||
if rows < cols {
|
||||
rows += 1;
|
||||
} else {
|
||||
cols += 1;
|
||||
}
|
||||
}
|
||||
(rows, cols)
|
||||
}
|
||||
|
||||
/// Grid position of a source index (C++ static
|
||||
/// `index_to_row_cols()`): `col = index % total_cols`,
|
||||
/// `row = index / total_cols` (`total_rows` is unused in C++).
|
||||
pub fn index_to_row_cols(index: i32, total_rows: i32, total_cols: i32) -> (i32, i32) {
|
||||
let _ = total_rows;
|
||||
(index / total_cols, index % total_cols)
|
||||
}
|
||||
|
||||
/// Inverse of [`Self::index_to_row_cols`] (C++ static
|
||||
/// `rows_cols_to_index()`): `col + row * total_cols`.
|
||||
pub fn rows_cols_to_index(row: i32, col: i32, total_rows: i32, total_cols: i32) -> i32 {
|
||||
let _ = total_rows;
|
||||
col + row * total_cols
|
||||
}
|
||||
|
||||
/// Active `sources_in` element set (C++
|
||||
/// `get_active_elements_at_time()` for `sources_in`): only the
|
||||
/// element at the current source index, or no elements when the
|
||||
/// index is out of range. Core-carrying variant of
|
||||
/// [`NodeBehavior::active_elements_at_time`], which receives no
|
||||
/// `NodeCore`.
|
||||
pub fn active_elements(&self, core: &NodeCore) -> Vec<i32> {
|
||||
let src = self.current_source(core);
|
||||
if src >= 0 && src < self.source_count(core) {
|
||||
vec![src]
|
||||
} else {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl NodeBehavior for MultiCamNode {
|
||||
/// Human-readable name (C++ `name()`).
|
||||
fn name(&self) -> &str {
|
||||
"Multi-Cam"
|
||||
}
|
||||
|
||||
/// Stable type id (C++ `id()`).
|
||||
fn type_id(&self) -> &str {
|
||||
"org.olivevideoeditor.Olive.multicam"
|
||||
}
|
||||
|
||||
/// Categories (C++ `category()` returns `{ k_category_timeline }`).
|
||||
fn categories(&self) -> &[Category] {
|
||||
&[Category::Timeline]
|
||||
}
|
||||
|
||||
/// Description (C++ `description()`).
|
||||
fn description(&self) -> &str {
|
||||
"Allows easy switching between multiple sources."
|
||||
}
|
||||
|
||||
/// Localized input names (C++ `retranslate()`): `current_in` ->
|
||||
/// "Current", `sources_in` -> "Sources", `sequence_in` ->
|
||||
/// "Sequence", `sequence_type_in` -> "Sequence Type". The C++
|
||||
/// override also refreshes the combo strings: `sequence_type_in`
|
||||
/// gets {"Video", "Audio"} and `current_in` gets per-source
|
||||
/// `"<i + 1>: <connected source name>"` entries — that part has no
|
||||
/// trait surface and is noted here only.
|
||||
fn input_name<'a>(&self, id: &'a str) -> &'a str {
|
||||
match id {
|
||||
CURRENT_INPUT => "Current",
|
||||
SOURCES_INPUT => "Sources",
|
||||
SEQUENCE_INPUT => "Sequence",
|
||||
SEQUENCE_TYPE_INPUT => "Sequence Type",
|
||||
_ => id,
|
||||
}
|
||||
}
|
||||
|
||||
/// Combo input option labels (C++ `retranslate()` /
|
||||
/// `set_combo_box_strings`): `sequence_type_in` -> "Video", "Audio".
|
||||
/// `current_in`'s strings are built dynamically per connected source
|
||||
/// (`"<i + 1>: <name>"`) and cannot be expressed statically.
|
||||
fn input_combo_strings(&self, id: &str) -> Vec<&'static str> {
|
||||
match id {
|
||||
SEQUENCE_TYPE_INPUT => vec!["Video", "Audio"],
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Inputs excluded from rendering (C++
|
||||
/// `ignore_inputs_for_rendering()`): always
|
||||
/// `{ k_sequence_input }`.
|
||||
fn ignore_inputs_for_rendering(&self) -> &[String] {
|
||||
static IGNORED: std::sync::OnceLock<String> = std::sync::OnceLock::new();
|
||||
std::slice::from_ref(IGNORED.get_or_init(|| SEQUENCE_INPUT.to_string()))
|
||||
}
|
||||
|
||||
/// Active array elements (C++ `get_active_elements_at_time()`): for
|
||||
/// `sources_in`, only the element at the current source index (or
|
||||
/// no elements if the index is out of range); any other input
|
||||
/// defers to the base-class behavior.
|
||||
///
|
||||
/// The C++ reads the `current_in` standard value
|
||||
/// (`get_current_source()`), which requires the node's data
|
||||
/// ([`NodeCore`]) — not carried by this trait signature. The
|
||||
/// core-carrying equivalent is [`Self::active_elements`] (tested
|
||||
/// there); until the API gains core access, the base-class (empty)
|
||||
/// set is returned (`// CPP-PARITY: multicamnode.cpp`
|
||||
/// `get_active_elements_at_time`).
|
||||
fn active_elements_at_time(&self, input: &str, time: Rational) -> Vec<i32> {
|
||||
let _ = (input, time);
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// Render-time connection resolution (C++
|
||||
/// `get_connected_render_output()`): with a sequence connected,
|
||||
/// `sources_in` element `i` (in range) resolves to the track at
|
||||
/// index `i` of the selected track list instead of any connected
|
||||
/// edge; otherwise defers to the base-class behavior (no virtual
|
||||
/// output — the base returns `None`).
|
||||
///
|
||||
/// The track-at-index lookup needs the sequence's track list
|
||||
/// (graph-owned, unreachable from this signature), so the
|
||||
/// sequence-connected branch resolves to nothing here
|
||||
/// (`// CPP-PARITY: multicamnode.cpp` `get_connected_render_output`).
|
||||
///
|
||||
/// NOTE: the C++ class also overrides
|
||||
/// `is_input_connected_for_render()` (reports `sources_in` elements
|
||||
/// as connected whenever a sequence is set); the trait has no such
|
||||
/// method, so that behavior folds into this one.
|
||||
fn connected_render_output(
|
||||
&self,
|
||||
core: &NodeCore,
|
||||
input: &str,
|
||||
element: i32,
|
||||
) -> Option<NodeId> {
|
||||
if self.sequence.is_some() && input == SOURCES_INPUT && element >= 0 {
|
||||
let _ = (core, element);
|
||||
None
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Evaluate outputs (C++ `value()`): pushes the first value of the
|
||||
/// `sources_in` value array (which, per
|
||||
/// `active_elements_at_time`, is the currently selected source);
|
||||
/// pushes nothing when the array is empty.
|
||||
///
|
||||
/// The Rust row carries no array payload (the `sources_in` value is
|
||||
/// the single active element), so the connected value is pushed
|
||||
/// through as-is; when the input is absent nothing is pushed.
|
||||
fn value(
|
||||
&self,
|
||||
core: &NodeCore,
|
||||
inputs: &NodeValueRow,
|
||||
time: Rational,
|
||||
table: &mut NodeValueTable,
|
||||
) {
|
||||
let _ = (core, time);
|
||||
if let Some(v) = inputs.get(SOURCES_INPUT) {
|
||||
table.push(v.value_type(), v.clone(), None);
|
||||
}
|
||||
}
|
||||
|
||||
/// Edge connected (C++ `InputConnectedEvent()`): when a `Sequence`
|
||||
/// connects to `sequence_in`, stores it and unhides
|
||||
/// `sequence_type_in`.
|
||||
fn input_connected(&mut self, core: &mut NodeCore, input: &str, element: i32, source: NodeId) {
|
||||
let _ = element;
|
||||
if input == SEQUENCE_INPUT {
|
||||
// C++ additionally dynamic_casts the source to `Sequence`;
|
||||
// the graph type-checks connections at edit time, so the
|
||||
// identity is trusted here.
|
||||
if let Some(slot) = core.get_input_mut(SEQUENCE_TYPE_INPUT) {
|
||||
slot.flags &= !crate::input::flags::HIDDEN;
|
||||
}
|
||||
self.sequence = Some(source);
|
||||
}
|
||||
}
|
||||
|
||||
/// Edge disconnected (C++ `InputDisconnectedEvent()`): on
|
||||
/// `sequence_in` disconnect, clears the stored sequence and re-hides
|
||||
/// `sequence_type_in`.
|
||||
fn input_disconnected(
|
||||
&mut self,
|
||||
core: &mut NodeCore,
|
||||
input: &str,
|
||||
element: i32,
|
||||
source: NodeId,
|
||||
) {
|
||||
let _ = (element, source);
|
||||
if input == SEQUENCE_INPUT {
|
||||
if let Some(slot) = core.get_input_mut(SEQUENCE_TYPE_INPUT) {
|
||||
slot.flags |= crate::input::flags::HIDDEN;
|
||||
}
|
||||
self.sequence = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// Deep copy (C++ `copy()` via `NODE_DEFAULT_FUNCTIONS`).
|
||||
fn duplicate(&self, _core: &NodeCore) -> Option<Box<dyn NodeBehavior>> {
|
||||
Some(Box::new(MultiCamNode {
|
||||
sequence: self.sequence,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Downcast to [`Self`] (sequence state access).
|
||||
fn as_any(&self) -> Option<&dyn std::any::Any> {
|
||||
Some(self)
|
||||
}
|
||||
|
||||
/// Mutable downcast (see [`NodeBehavior::as_any`]).
|
||||
fn as_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
|
||||
Some(self)
|
||||
}
|
||||
}
|
||||
|
||||
/// Constructor (C++ `MultiCamNode::MultiCamNode()`): adds
|
||||
/// `current_in`/`sources_in`/`sequence_in`/`sequence_type_in` with the
|
||||
/// defaults, flags and properties documented on the constants, and
|
||||
/// initializes `sequence_` to null.
|
||||
pub fn create() -> (NodeCore, Box<dyn NodeBehavior>) {
|
||||
let mut core = NodeCore::new();
|
||||
|
||||
let mut current = crate::input::Input::new(
|
||||
CURRENT_INPUT,
|
||||
crate::value::ValueType::Combo,
|
||||
NodeValue::Combo(0),
|
||||
);
|
||||
current.flags |= STATIC_FLAGS;
|
||||
core.add_input(current);
|
||||
|
||||
let mut sources = crate::input::Input::new(
|
||||
SOURCES_INPUT,
|
||||
crate::value::ValueType::None,
|
||||
NodeValue::None,
|
||||
);
|
||||
sources.flags |= crate::input::flags::NOT_KEYFRAMABLE | crate::input::flags::ARRAY;
|
||||
sources.properties = vec![("arraystart".to_string(), NodeValue::Int(1))];
|
||||
core.add_input(sources);
|
||||
|
||||
let mut sequence = crate::input::Input::new(
|
||||
SEQUENCE_INPUT,
|
||||
crate::value::ValueType::None,
|
||||
NodeValue::None,
|
||||
);
|
||||
sequence.flags |= crate::input::flags::NOT_KEYFRAMABLE;
|
||||
core.add_input(sequence);
|
||||
|
||||
let mut sequence_type = crate::input::Input::new(
|
||||
SEQUENCE_TYPE_INPUT,
|
||||
crate::value::ValueType::Combo,
|
||||
NodeValue::Combo(0),
|
||||
);
|
||||
sequence_type.flags |= STATIC_FLAGS | crate::input::flags::HIDDEN;
|
||||
core.add_input(sequence_type);
|
||||
|
||||
(core, Box::new(MultiCamNode { sequence: None }))
|
||||
}
|
||||
|
||||
/// Register this node type (C++ `k_multicam_node` in
|
||||
/// `factory.cpp::create_from_factory_index`).
|
||||
pub fn register(meta: &mut Vec<NodeMeta>) {
|
||||
meta.push(NodeMeta {
|
||||
type_id: "org.olivevideoeditor.Olive.multicam",
|
||||
name: "Multi-Cam",
|
||||
categories: &[Category::Timeline],
|
||||
create,
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::id::NodeId;
|
||||
use crate::node::NodeBehavior;
|
||||
use crate::value::{NodeValueTable, ValueType};
|
||||
use oak_core::Rational;
|
||||
|
||||
/// A distinct, valid-looking node id for sequence connect tests.
|
||||
fn fake_id(n: u32) -> NodeId {
|
||||
NodeId::from_identity(n as u64).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn input_names() {
|
||||
let n = MultiCamNode { sequence: None };
|
||||
assert_eq!(n.input_name(CURRENT_INPUT), "Current");
|
||||
assert_eq!(n.input_name(SOURCES_INPUT), "Sources");
|
||||
assert_eq!(n.input_name(SEQUENCE_INPUT), "Sequence");
|
||||
assert_eq!(n.input_name(SEQUENCE_TYPE_INPUT), "Sequence Type");
|
||||
assert_eq!(n.input_name("other_in"), "other_in");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_wires_inputs() {
|
||||
let (core, behavior) = create();
|
||||
assert_eq!(behavior.type_id(), "org.olivevideoeditor.Olive.multicam");
|
||||
assert_eq!(
|
||||
core.get_input(CURRENT_INPUT).unwrap().value_type,
|
||||
ValueType::Combo
|
||||
);
|
||||
assert_eq!(
|
||||
core.get_input(CURRENT_INPUT).unwrap().default,
|
||||
NodeValue::Combo(0)
|
||||
);
|
||||
let sources = core.get_input(SOURCES_INPUT).unwrap();
|
||||
assert_ne!(sources.flags & crate::input::flags::ARRAY, 0);
|
||||
assert_ne!(sources.flags & crate::input::flags::NOT_KEYFRAMABLE, 0);
|
||||
assert!(sources
|
||||
.properties
|
||||
.iter()
|
||||
.any(|(k, v)| k == "arraystart" && v == &NodeValue::Int(1)));
|
||||
assert_ne!(
|
||||
core.get_input(SEQUENCE_INPUT).unwrap().flags & crate::input::flags::NOT_KEYFRAMABLE,
|
||||
0
|
||||
);
|
||||
let seq_type = core.get_input(SEQUENCE_TYPE_INPUT).unwrap();
|
||||
assert_ne!(seq_type.flags & crate::input::flags::HIDDEN, 0);
|
||||
assert_ne!(seq_type.flags & crate::input::flags::NOT_CONNECTABLE, 0);
|
||||
assert_ne!(seq_type.flags & crate::input::flags::NOT_KEYFRAMABLE, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn current_source_reads_standard_value() {
|
||||
let (mut core, _) = create();
|
||||
let node = MultiCamNode { sequence: None };
|
||||
assert_eq!(node.current_source(&core), 0);
|
||||
core.set_standard_value(CURRENT_INPUT, -1, NodeValue::Combo(2));
|
||||
assert_eq!(node.current_source(&core), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_count_is_array_size() {
|
||||
let (mut core, _) = create();
|
||||
// Grow the sources array to 3 elements.
|
||||
core.input_array_insert(SOURCES_INPUT, 0);
|
||||
core.input_array_insert(SOURCES_INPUT, 1);
|
||||
core.input_array_insert(SOURCES_INPUT, 2);
|
||||
let node = MultiCamNode { sequence: None };
|
||||
assert_eq!(node.source_count(&core), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rows_and_columns_square_growth() {
|
||||
assert_eq!(MultiCamNode::rows_and_columns(0), (1, 1));
|
||||
assert_eq!(MultiCamNode::rows_and_columns(1), (1, 1));
|
||||
assert_eq!(MultiCamNode::rows_and_columns(2), (1, 2));
|
||||
assert_eq!(MultiCamNode::rows_and_columns(3), (2, 2));
|
||||
assert_eq!(MultiCamNode::rows_and_columns(4), (2, 2));
|
||||
// The smaller dimension grows first: 1x1 -> 1x2 -> 2x2 -> 2x3.
|
||||
assert_eq!(MultiCamNode::rows_and_columns(5), (2, 3));
|
||||
assert_eq!(MultiCamNode::rows_and_columns(6), (2, 3));
|
||||
assert_eq!(MultiCamNode::rows_and_columns(9), (3, 3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn index_row_cols_round_trip() {
|
||||
for (i, rows, cols) in [
|
||||
(0, 3, 3),
|
||||
(1, 3, 3),
|
||||
(2, 3, 3),
|
||||
(3, 3, 3),
|
||||
(8, 3, 3),
|
||||
(5, 2, 3),
|
||||
] {
|
||||
let (r, c) = MultiCamNode::index_to_row_cols(i, rows, cols);
|
||||
assert_eq!(r, i / cols);
|
||||
assert_eq!(c, i % cols);
|
||||
assert_eq!(MultiCamNode::rows_cols_to_index(r, c, rows, cols), i);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn active_elements_selects_current_source() {
|
||||
let (mut core, _) = create();
|
||||
core.input_array_insert(SOURCES_INPUT, 0);
|
||||
core.input_array_insert(SOURCES_INPUT, 1);
|
||||
core.input_array_insert(SOURCES_INPUT, 2);
|
||||
let node = MultiCamNode { sequence: None };
|
||||
core.set_standard_value(CURRENT_INPUT, -1, NodeValue::Combo(1));
|
||||
assert_eq!(node.active_elements(&core), vec![1]);
|
||||
// Out of range -> no active elements.
|
||||
core.set_standard_value(CURRENT_INPUT, -1, NodeValue::Combo(9));
|
||||
assert!(node.active_elements(&core).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignore_inputs_always_sequence() {
|
||||
let node = MultiCamNode { sequence: None };
|
||||
assert_eq!(
|
||||
node.ignore_inputs_for_rendering(),
|
||||
&[SEQUENCE_INPUT.to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_pushes_connected_source_value() {
|
||||
let (core, behavior) = create();
|
||||
let mut row = NodeValueRow::default();
|
||||
row.insert(SOURCES_INPUT.to_string(), NodeValue::Float(7.0));
|
||||
let mut table = NodeValueTable::default();
|
||||
behavior.value(&core, &row, Rational::new(0, 1), &mut table);
|
||||
assert_eq!(table.get(ValueType::Float), Some(&NodeValue::Float(7.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_pushes_nothing_when_sources_empty() {
|
||||
let (core, behavior) = create();
|
||||
let mut table = NodeValueTable::default();
|
||||
behavior.value(
|
||||
&core,
|
||||
&NodeValueRow::default(),
|
||||
Rational::new(0, 1),
|
||||
&mut table,
|
||||
);
|
||||
assert!(table.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sequence_connect_toggles_hidden_flag() {
|
||||
let (mut core, mut behavior) = create();
|
||||
assert_ne!(
|
||||
core.get_input(SEQUENCE_TYPE_INPUT).unwrap().flags & crate::input::flags::HIDDEN,
|
||||
0
|
||||
);
|
||||
let seq = fake_id(7);
|
||||
behavior.input_connected(&mut core, SEQUENCE_INPUT, -1, seq);
|
||||
assert_eq!(
|
||||
core.get_input(SEQUENCE_TYPE_INPUT).unwrap().flags & crate::input::flags::HIDDEN,
|
||||
0
|
||||
);
|
||||
behavior.input_disconnected(&mut core, SEQUENCE_INPUT, -1, seq);
|
||||
assert_ne!(
|
||||
core.get_input(SEQUENCE_TYPE_INPUT).unwrap().flags & crate::input::flags::HIDDEN,
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_copies_sequence() {
|
||||
let seq = fake_id(7);
|
||||
let node = MultiCamNode {
|
||||
sequence: Some(seq),
|
||||
};
|
||||
let copy = node.duplicate(&NodeCore::new()).unwrap();
|
||||
assert_eq!(copy.type_id(), "org.olivevideoeditor.Olive.multicam");
|
||||
let down = copy
|
||||
.as_any()
|
||||
.unwrap()
|
||||
.downcast_ref::<MultiCamNode>()
|
||||
.unwrap();
|
||||
assert_eq!(down.sequence, Some(seq));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
// 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: oak_core::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 oak_core::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,
|
||||
});
|
||||
}
|
||||
@@ -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 (opaque oakrender handles), 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`/oakrender 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::handle::CHandle>,
|
||||
}
|
||||
|
||||
// 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::handle::CHandle> {
|
||||
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::handle::CHandle>,
|
||||
) {
|
||||
// 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: oak_core::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(),
|
||||
oak_core::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,
|
||||
oak_core::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,
|
||||
oak_core::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,803 @@
|
||||
// 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
|
||||
//! (oakrender, opaque handles), 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: oak_core::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 oak_core::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,770 @@
|
||||
// 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
|
||||
//! (oakrender, opaque handles), 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: oak_core::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 oak_core::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)");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,682 @@
|
||||
// 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
|
||||
//! (oakrender, opaque handles), 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::handle::CHandle>,
|
||||
/// 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,
|
||||
}
|
||||
}
|
||||
|
||||
/// Combo input option labels (C++ `retranslate()` /
|
||||
/// `set_combo_box_strings`): `lut_dir_in` -> "Forward", "Inverse".
|
||||
fn input_combo_strings(&self, id: &str) -> Vec<&'static str> {
|
||||
match id {
|
||||
DIRECTION_INPUT => vec!["Forward", "Inverse"],
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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: oak_core::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 oak_core::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");
|
||||
}
|
||||
}
|
||||
@@ -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/>.
|
||||
|
||||
//! 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: oak_core::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 oak_core::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,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
// 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: oak_core::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: oak_core::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 oak_core::{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,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,637 @@
|
||||
// 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++ `engine/node/plugins/plugin.{h,cpp}`,
|
||||
//! `olive::plugin::PluginNode`).
|
||||
//!
|
||||
//! Thin wrapper over an OFX plugin instance that lives in the
|
||||
//! `oakplugin` crate; no OFX types (`OFX::Host::ImageEffect::Instance`,
|
||||
//! `kOfxParam*`, ...) are declared here because oaknode sits below
|
||||
//! oakplugin in the dependency graph. The instance is represented as
|
||||
//! the opaque [`PluginInstanceHandle`] — an identity key into the
|
||||
//! oakplugin crate's instance registry.
|
||||
//!
|
||||
//! All per-plugin data (inputs, defaults, properties, labels) is
|
||||
//! discovered at runtime from the plugin descriptor by the oakplugin
|
||||
//! discovery pass (`oak_plugin::node_factory`), which builds the
|
||||
//! [`NodeCore`] inputs, constructs [`PluginNode`]s and registers one
|
||||
//! factory entry per discovered plugin (the C++
|
||||
//! `factory.cpp::register_plugin_nodes` + `PluginNode::PluginNode`
|
||||
//! split across the dependency seam).
|
||||
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
|
||||
use crate::factory::NodeMeta;
|
||||
use crate::node::{Category, NodeBehavior, NodeCore};
|
||||
use crate::value::{NodeValue, NodeValueRow, NodeValueTable};
|
||||
use oak_core::{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 the discovery
|
||||
/// pass); 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 (identity key into its instance registry; C++
|
||||
/// `OFX::Host::ImageEffect::Instance *`, member `plugin_instance_`).
|
||||
#[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
|
||||
}
|
||||
}
|
||||
|
||||
/// Plugin job payload (C++ `plugin::PluginJob`): everything the render
|
||||
/// seam needs to run the instance against the input texture. Boxed
|
||||
/// into a texture-typed [`NodeValue`] by [`PluginNode::value`] and
|
||||
/// resolved by the oakrender evaluation seam (C++
|
||||
/// `RenderProcessor::process_plugin_job`), which executes it through
|
||||
/// the oakplugin render driver.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PluginJobPayload {
|
||||
/// The instance identity (oakplugin registry key).
|
||||
pub instance: PluginInstanceHandle,
|
||||
/// The request time (C++ `globals.time().in()`).
|
||||
pub time: Rational,
|
||||
/// The effect input id the main source texture arrives on (C++
|
||||
/// `node->get_effect_input_id()`).
|
||||
pub effect_input_id: String,
|
||||
/// Snapshot of the full input row (C++ `PluginJob` holds the
|
||||
/// `NodeValueRow`: the tagged non-texture values become param
|
||||
/// overrides, the texture values feed the clip inputs).
|
||||
pub values: NodeValueRow,
|
||||
}
|
||||
|
||||
/// 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 descriptor at construction).
|
||||
pub struct PluginNode {
|
||||
/// The wrapped plugin instance (C++ `plugin_instance_`; an
|
||||
/// oakplugin registry identity 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,
|
||||
}
|
||||
|
||||
/// The plugin-node duplicator (installed by the oakplugin crate): old
|
||||
/// instance identity -> fresh instance identity (the C++
|
||||
/// `PluginNode::copy()` creates a fresh instance — filter context
|
||||
/// preferred, else the plugin's first declared context). `None` when
|
||||
/// instance creation fails.
|
||||
type PluginDuplicator = dyn Fn(PluginInstanceHandle) -> Option<PluginInstanceHandle> + Send + Sync;
|
||||
|
||||
static DUPLICATOR: OnceLock<Mutex<Option<Arc<PluginDuplicator>>>> = OnceLock::new();
|
||||
|
||||
fn duplicator_slot() -> &'static Mutex<Option<Arc<PluginDuplicator>>> {
|
||||
DUPLICATOR.get_or_init(|| Mutex::new(None))
|
||||
}
|
||||
|
||||
/// Install the plugin-node duplicator (oakplugin discovery path;
|
||||
/// `None` clears it). Without it [`PluginNode::duplicate`] returns
|
||||
/// `None`.
|
||||
pub fn set_plugin_duplicator(dup: Option<Arc<PluginDuplicator>>) {
|
||||
*duplicator_slot().lock().unwrap_or_else(|e| e.into_inner()) = dup;
|
||||
}
|
||||
|
||||
impl PluginNode {
|
||||
/// Constructor for the oakplugin discovery path (the C++
|
||||
/// `PluginNode::PluginNode(instance)`; the input walk happens in
|
||||
/// the oakplugin translation pass, which owns the OFX types).
|
||||
pub fn new(
|
||||
instance: PluginInstanceHandle,
|
||||
name: String,
|
||||
type_id: String,
|
||||
description: String,
|
||||
sub_category: String,
|
||||
) -> Self {
|
||||
PluginNode {
|
||||
instance,
|
||||
sub_category,
|
||||
name,
|
||||
type_id,
|
||||
description,
|
||||
}
|
||||
}
|
||||
|
||||
/// The wrapped instance identity (oakplugin registry key).
|
||||
pub fn instance_handle(&self) -> PluginInstanceHandle {
|
||||
self.instance
|
||||
}
|
||||
|
||||
/// 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 a [`PluginJobPayload`] boxed into a
|
||||
/// texture-typed value (the C++ `table->push(k_texture,
|
||||
/// tex->to_job(job), this)`; the render seam executes the job).
|
||||
fn value(
|
||||
&self,
|
||||
core: &NodeCore,
|
||||
inputs: &NodeValueRow,
|
||||
time: Rational,
|
||||
table: &mut NodeValueTable,
|
||||
) {
|
||||
// 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() {
|
||||
let payload = PluginJobPayload {
|
||||
instance: self.instance,
|
||||
time,
|
||||
effect_input_id: core.effect_input.clone(),
|
||||
values: inputs.clone(),
|
||||
};
|
||||
table.push(
|
||||
crate::value::ValueType::Texture,
|
||||
NodeValue::Texture(crate::handle::make_owned(payload)),
|
||||
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::handle::CHandle`]
|
||||
/// 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::handle::CHandle,
|
||||
time: Rational,
|
||||
) {
|
||||
let _ = (core, frame, time);
|
||||
}
|
||||
|
||||
/// Deep copy (C++ `copy()`): asks the oakplugin side (through the
|
||||
/// installed [`set_plugin_duplicator`] hook) for a fresh plugin
|
||||
/// instance — filter context when supported, else the plugin's
|
||||
/// first declared context — and wraps it in a new behavior; the
|
||||
/// caller clones the core (inputs included), matching the C++
|
||||
/// `Node::copy` split. `None` when there is no instance or no
|
||||
/// duplicator installed.
|
||||
fn duplicate(&self, _core: &NodeCore) -> Option<Box<dyn NodeBehavior>> {
|
||||
if self.instance.is_null() {
|
||||
return None;
|
||||
}
|
||||
let dup = duplicator_slot()
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.clone()?;
|
||||
let new_handle = dup(self.instance)?;
|
||||
Some(Box::new(PluginNode {
|
||||
instance: new_handle,
|
||||
sub_category: self.sub_category.clone(),
|
||||
name: self.name.clone(),
|
||||
type_id: self.type_id.clone(),
|
||||
description: self.description.clone(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// 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)`): the real
|
||||
/// construction (instance creation + the OFX param/clip -> input
|
||||
/// translation) belongs to the oakplugin discovery pass
|
||||
/// (`oak_plugin::node_factory`), which registers one factory entry per
|
||||
/// discovered plugin. This placeholder (null instance, empty cached
|
||||
/// metadata) stays for the static-registration surface and tests.
|
||||
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 oakplugin discovery pass appends one
|
||||
/// factory entry per discovered OFX plugin at runtime (C++
|
||||
/// `factory.cpp::register_plugin_nodes`) through
|
||||
/// [`crate::factory::Factory::register_dynamic`], keyed by plugin
|
||||
/// identifier, so there is no fixed `NodeMeta` literal to push. This
|
||||
/// function is a no-op placeholder kept for the static table.
|
||||
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 oak_core::{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, plus the
|
||||
// plugin job texture (no texture input in the row -> no job).
|
||||
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_pushes_job_payload_for_texture_input() {
|
||||
let n = node();
|
||||
let mut core = NodeCore::new();
|
||||
core.effect_input = TEXTURE_INPUT.to_string();
|
||||
let mut row = NodeValueRow::default();
|
||||
row.insert(
|
||||
"tex_in".to_string(),
|
||||
NodeValue::Texture(crate::handle::CHandle::null()),
|
||||
);
|
||||
row.insert("gain".to_string(), NodeValue::Float(0.25));
|
||||
row.insert("none_in".to_string(), NodeValue::None);
|
||||
let mut table = NodeValueTable::default();
|
||||
let time = Rational::new(3, 2);
|
||||
n.value(&core, &row, time, &mut table);
|
||||
// The texture is consumed as the job source: one tagged param
|
||||
// value + the boxed plugin job payload.
|
||||
assert_eq!(table.count(), 2);
|
||||
let Some(NodeValue::Texture(h)) = table.get(ValueType::Texture) else {
|
||||
panic!("expected the plugin job texture");
|
||||
};
|
||||
// SAFETY: the handle was created by value() boxing a
|
||||
// PluginJobPayload.
|
||||
let payload = unsafe { crate::handle::get::<PluginJobPayload>(h) }
|
||||
.expect("texture handle must box a PluginJobPayload");
|
||||
assert_eq!(payload.instance, PluginInstanceHandle(1));
|
||||
assert_eq!(payload.time, time);
|
||||
assert_eq!(payload.effect_input_id, TEXTURE_INPUT);
|
||||
assert_eq!(payload.values.len(), 3);
|
||||
assert_eq!(
|
||||
payload.values.get("gain"),
|
||||
Some(&NodeValue::Float(0.25))
|
||||
);
|
||||
}
|
||||
|
||||
#[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(_))));
|
||||
}
|
||||
|
||||
#[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: oak_core::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, oak_core::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: oak_core::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_uses_installed_duplicator() {
|
||||
// No duplicator installed -> None.
|
||||
set_plugin_duplicator(None);
|
||||
let n = node();
|
||||
assert!(n.duplicate(&NodeCore::new()).is_none());
|
||||
|
||||
// Installed duplicator: fresh handle, metadata copied.
|
||||
set_plugin_duplicator(Some(Arc::new(|h: PluginInstanceHandle| {
|
||||
Some(PluginInstanceHandle(h.0 + 100))
|
||||
})));
|
||||
let dup = n.duplicate(&NodeCore::new()).expect("duplicator installed");
|
||||
let dup = dup.as_any().unwrap().downcast_ref::<PluginNode>().unwrap();
|
||||
assert_eq!(dup.instance_handle(), PluginInstanceHandle(101));
|
||||
assert_eq!(dup.name(), "Test Plugin");
|
||||
assert_eq!(dup.sub_category(), "Filter");
|
||||
|
||||
// Duplicator failure -> None.
|
||||
set_plugin_duplicator(Some(Arc::new(|_: PluginInstanceHandle| None)));
|
||||
assert!(n.duplicate(&NodeCore::new()).is_none());
|
||||
set_plugin_duplicator(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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
// 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: oak_core::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::handle::CHandle`]
|
||||
/// 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::handle::CHandle,
|
||||
time: oak_core::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 oak_core::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: oak_core::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 oak_core::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,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,440 @@
|
||||
// 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),
|
||||
}
|
||||
}
|
||||
|
||||
/// Combo input option labels (C++ `retranslate()` /
|
||||
/// `set_combo_box_strings`): `type_in` -> "Rectangle", "Ellipse",
|
||||
/// "Rounded Rectangle".
|
||||
fn input_combo_strings(&self, id: &str) -> Vec<&'static str> {
|
||||
match id {
|
||||
TYPE_INPUT => vec!["Rectangle", "Ellipse", "Rounded Rectangle"],
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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: oak_core::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 oak_core::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,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
// 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
// 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: oak_core::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 oak_core::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,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,400 @@
|
||||
// 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: oak_core::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 oak_core::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: oak_core::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 oak_core::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,
|
||||
});
|
||||
}
|
||||
@@ -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/>.
|
||||
|
||||
//! 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,485 @@
|
||||
// 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 oak_core::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,
|
||||
}
|
||||
}
|
||||
|
||||
/// Combo input option labels (C++ `retranslate()` /
|
||||
/// `set_combo_box_strings`): `valign_in` -> "Top", "Center",
|
||||
/// "Bottom".
|
||||
fn input_combo_strings(&self, id: &str) -> Vec<&'static str> {
|
||||
match id {
|
||||
V_ALIGN_INPUT => vec!["Top", "Center", "Bottom"],
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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::handle::CHandle`]
|
||||
/// 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::handle::CHandle,
|
||||
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 oak_core::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)");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,622 @@
|
||||
// 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 oak_core::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),
|
||||
}
|
||||
}
|
||||
|
||||
/// Combo input option labels (C++ `retranslate()` /
|
||||
/// `set_combo_box_strings`): `valign_in` -> "Top", "Center",
|
||||
/// "Bottom".
|
||||
fn input_combo_strings(&self, id: &str) -> Vec<&'static str> {
|
||||
match id {
|
||||
V_ALIGN_INPUT => vec!["Top", "Center", "Bottom"],
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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::handle::CHandle`]
|
||||
/// 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::handle::CHandle,
|
||||
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 oak_core::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)");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,924 @@
|
||||
// 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 oak_core::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),
|
||||
}
|
||||
}
|
||||
|
||||
/// Combo input option labels (C++ `retranslate()` /
|
||||
/// `set_combo_box_strings`): `valign_in` -> "Top", "Middle",
|
||||
/// "Bottom".
|
||||
fn input_combo_strings(&self, id: &str) -> Vec<&'static str> {
|
||||
match id {
|
||||
VERTICAL_ALIGNMENT_INPUT => vec!["Top", "Middle", "Bottom"],
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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::handle::CHandle`]
|
||||
/// 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::handle::CHandle,
|
||||
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 oak_core::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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
// 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: oak_core::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 oak_core::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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,469 @@
|
||||
// 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,
|
||||
}
|
||||
}
|
||||
|
||||
/// Combo input option labels (C++ `retranslate()` /
|
||||
/// `set_combo_box_strings`): `anchor_in` -> "Top-Left", "Top-Center",
|
||||
/// "Top-Right", "Middle-Left", "Middle-Center", "Middle-Right",
|
||||
/// "Bottom-Left", "Bottom-Center", "Bottom-Right".
|
||||
fn input_combo_strings(&self, id: &str) -> Vec<&'static str> {
|
||||
match id {
|
||||
ANCHOR_INPUT => vec![
|
||||
"Top-Left",
|
||||
"Top-Center",
|
||||
"Top-Right",
|
||||
"Middle-Left",
|
||||
"Middle-Center",
|
||||
"Middle-Right",
|
||||
"Bottom-Left",
|
||||
"Bottom-Center",
|
||||
"Bottom-Right",
|
||||
],
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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: oak_core::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 oak_core::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,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,599 @@
|
||||
// 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 oak_core::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. Windows (UCRT) has the `_s` variants with
|
||||
// reversed argument order and a 64-bit time_t.
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
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;
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
extern "C" {
|
||||
// UCRT exports the 64-bit-time variants; `localtime_s`/`gmtime_s` are
|
||||
// header inlines on MinGW, not linkable symbols.
|
||||
fn _localtime64_s(result: *mut Tm, timep: *const i64) -> i32;
|
||||
fn _gmtime64_s(result: *mut Tm, timep: *const i64) -> i32;
|
||||
}
|
||||
|
||||
/// Split epoch seconds into calendar fields through the C library — local
|
||||
/// time when `local` is set, UTC otherwise (C++ `localtime_r`/`gmtime_r`).
|
||||
/// Shared by `value()` and its test so both stay on the same cfg-gated FFI.
|
||||
fn break_down_time(secs: c_long, local: bool) -> Tm {
|
||||
let mut tm: Tm = unsafe { std::mem::zeroed() };
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
unsafe {
|
||||
if local {
|
||||
localtime_r(&secs, &mut tm);
|
||||
} else {
|
||||
gmtime_r(&secs, &mut tm);
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
unsafe {
|
||||
let secs64 = secs as i64;
|
||||
if local {
|
||||
_localtime64_s(&mut tm, &secs64);
|
||||
} else {
|
||||
_gmtime64_s(&mut tm, &secs64);
|
||||
}
|
||||
}
|
||||
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 tm = break_down_time(secs, to_bool(&local_val));
|
||||
|
||||
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 oak_core::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 secs: c_long = 45296;
|
||||
let local = break_down_time(secs, true);
|
||||
let utc = break_down_time(secs, false);
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
// 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 oak_core::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 oak_core::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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
// 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 oak_core::{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 oak_core::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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
// 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 oak_core::{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 oak_core::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,855 @@
|
||||
// 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),
|
||||
}
|
||||
}
|
||||
|
||||
/// Combo input option labels (C++ `retranslate()` /
|
||||
/// `set_combo_box_strings`): `autoscale_in` -> "None", "Fit", "Fill",
|
||||
/// "Stretch"; `interpolation_in` -> "Nearest Neighbor", "Bilinear",
|
||||
/// "Mipmapped Bilinear".
|
||||
fn input_combo_strings(&self, id: &str) -> Vec<&'static str> {
|
||||
match id {
|
||||
AUTOSCALE_INPUT => vec!["None", "Fit", "Fill", "Stretch"],
|
||||
INTERPOLATION_INPUT => vec!["Nearest Neighbor", "Bilinear", "Mipmapped Bilinear"],
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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: oak_core::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 oak_core::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,
|
||||
});
|
||||
}
|
||||
@@ -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/>.
|
||||
|
||||
//! 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,
|
||||
}
|
||||
}
|
||||
|
||||
/// Combo input option labels (C++ `retranslate()` /
|
||||
/// `set_combo_box_strings`): `method_in` -> "Sine", "Cosine",
|
||||
/// "Tangent", "Inverse Sine", "Inverse Cosine", "Inverse Tangent",
|
||||
/// "Hyperbolic Sine", "Hyperbolic Cosine", "Hyperbolic Tangent".
|
||||
fn input_combo_strings(&self, id: &str) -> Vec<&'static str> {
|
||||
match id {
|
||||
METHOD_INPUT => vec![
|
||||
"Sine",
|
||||
"Cosine",
|
||||
"Tangent",
|
||||
"Inverse Sine",
|
||||
"Inverse Cosine",
|
||||
"Inverse Tangent",
|
||||
"Hyperbolic Sine",
|
||||
"Hyperbolic Cosine",
|
||||
"Hyperbolic Tangent",
|
||||
],
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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: oak_core::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 oak_core::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,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
// 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,
|
||||
}
|
||||
}
|
||||
|
||||
/// Combo input option labels (C++ `retranslate()` /
|
||||
/// `set_combo_box_strings`): `type_in` -> the pretty data-type names
|
||||
/// of [`SUPPORTED_TYPES`] in order — "Float", "Integer", "Rational",
|
||||
/// "Vector 2D", "Vector 3D", "Vector 4D", "Color", "Text", "Boolean"
|
||||
/// (matching the Rust list, which omits the C++ `k_matrix`/`k_font`
|
||||
/// entries).
|
||||
fn input_combo_strings(&self, id: &str) -> Vec<&'static str> {
|
||||
match id {
|
||||
TYPE_INPUT => vec![
|
||||
"Float",
|
||||
"Integer",
|
||||
"Rational",
|
||||
"Vector 2D",
|
||||
"Vector 3D",
|
||||
"Vector 4D",
|
||||
"Color",
|
||||
"Text",
|
||||
"Boolean",
|
||||
],
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Evaluate outputs (C++ `value()`): pushes the `value_in` value
|
||||
/// onto the table unchanged.
|
||||
fn value(
|
||||
&self,
|
||||
core: &NodeCore,
|
||||
inputs: &crate::value::NodeValueRow,
|
||||
time: oak_core::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 oak_core::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,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
// 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: oak_core::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: oak_core::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 oak_core::{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,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
// 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,
|
||||
}
|
||||
}
|
||||
|
||||
/// Combo input option labels (C++ `retranslate()` /
|
||||
/// `set_combo_box_strings`): `vertical_in` -> "Horizontal",
|
||||
/// "Vertical".
|
||||
fn input_combo_strings(&self, id: &str) -> Vec<&'static str> {
|
||||
match id {
|
||||
VERTICAL_INPUT => vec!["Horizontal", "Vertical"],
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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: oak_core::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 oak_core::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,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
// 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: oak_core::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 oak_core::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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user