nodes: OpenFX-Misc cleanroom GPU ports, grouped and collapsible in the library
21 built-in effects reimplemented as native GPU nodes from the OpenFX-Misc algorithm references (cleanroom, docs in docs/zh/plans/ofx-misc-gpu-cleanroom.md): - Color: Color Correct, Gamma, Saturation, Invert, Clamp, Grade - Matrix/morphology: Color Matrix, Edge Detect, Dilate, Erode - Blur: Directional Blur, Sharpen (unsharp mask) - Merge: Dissolve, Key Mix, Premultiply, Unpremultiply - Geometry/generators: Position, Mirror, Checkerboard, Color Bars, Ramp Every node carries unit tests plus GPU pixel tests (28 cases over five ofxmisc_* suites). The effect library groups built-ins by category (color/filter/distort/keying/generator/math/general) with collapsible group headers persisted to the config; the inspector's add menu groups the same way. Registration wiring and the factory smoke table land with the adjustment/transition wave sharing the same files.
This commit is contained in:
@@ -0,0 +1,410 @@
|
||||
// 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/>.
|
||||
|
||||
//! Checkerboard generator: clean-room reimplementation of the
|
||||
//! OpenFX-Misc `CheckerBoard` plugin's parameter semantics (upstream
|
||||
//! github.com/cgvirus/OpenFX-Misc, GPL2; read for behavior only, no
|
||||
//! upstream code copied).
|
||||
//!
|
||||
//! Deviations from the upstream plugin: it offers four checker colors
|
||||
//! (`color0`..`color3`, the second pair for a two-cell pattern), a line
|
||||
//! color/width pair and a centerline color/width pair; this node renders
|
||||
//! the plain two-color checkerboard only (`color1_in`/`color2_in`, the
|
||||
//! upstream `color1`/`color2`).
|
||||
|
||||
use crate::factory::NodeMeta;
|
||||
use crate::jobs::ShaderJobPayload;
|
||||
use crate::node::{Category, NodeBehavior, NodeCore};
|
||||
|
||||
/// Base texture input id (the shared generator-with-merge base). Type:
|
||||
/// texture; flags: not-keyframable; this is the generator's effect input.
|
||||
pub const BASE_INPUT: &str = super::generatorwithmerge::BASE_INPUT;
|
||||
|
||||
/// Box size input id (upstream `kParamBoxSize` "Box Size"). Type: vec2;
|
||||
/// default `[64.0, 64.0]`; properties: `min = [0.0, 0.0]`; units: pixels
|
||||
/// of `resolution_in`.
|
||||
pub const SIZE_INPUT: &str = "size_in";
|
||||
|
||||
/// First checker color input id (upstream `kParamColor1`, the color of
|
||||
/// the top-left box). Type: color; default `[0.1, 0.1, 0.1, 1.0]`;
|
||||
/// properties: `view = color`.
|
||||
pub const COLOR1_INPUT: &str = "color1_in";
|
||||
|
||||
/// Second checker color input id (upstream `kParamColor2`). Type: color;
|
||||
/// default `[0.5, 0.5, 0.5, 1.0]`; properties: `view = color`.
|
||||
pub const COLOR2_INPUT: &str = "color2_in";
|
||||
|
||||
/// Checkerboard generator node.
|
||||
pub struct CheckerBoardNode;
|
||||
|
||||
/// Fragment shader for the `"checkerboard"` shader id.
|
||||
///
|
||||
/// Pixel space: `ove_texcoord * resolution_in - resolution_in * 0.5`,
|
||||
/// i.e. the center-origin pixel coordinates used by
|
||||
/// [`super::transformdistortnode`]. The upstream plugin anchors the
|
||||
/// pattern origin at the center of the image (its `color0` hint names
|
||||
/// "the top-left of the image center"), so the cell index is
|
||||
/// `floor(px / box_size)` and the pattern's parity is the sum of the cell
|
||||
/// indices — the shaded cells alternate like a checkerboard. Sizes below
|
||||
/// one pixel would divide by zero, so the box size is clamped to
|
||||
/// `>= 1.0`.
|
||||
const SHADER_FRAG: &str = r#"uniform vec2 resolution_in;
|
||||
uniform vec2 size_in;
|
||||
uniform vec4 color1_in;
|
||||
uniform vec4 color2_in;
|
||||
|
||||
in vec2 ove_texcoord;
|
||||
out vec4 frag_color;
|
||||
|
||||
void main(void) {
|
||||
vec2 px = ove_texcoord * resolution_in - resolution_in * 0.5;
|
||||
vec2 box = max(size_in, vec2(1.0));
|
||||
vec2 cell = floor(px / box);
|
||||
|
||||
// Parity of the cell index sum, valid for negative cell indices too
|
||||
// (`mod` in GLSL is `x - y * floor(x / y)`).
|
||||
float s = cell.x + cell.y;
|
||||
float parity = s - 2.0 * floor(s * 0.5);
|
||||
|
||||
if (parity < 0.5) {
|
||||
frag_color = color1_in;
|
||||
} else {
|
||||
frag_color = color2_in;
|
||||
}
|
||||
}
|
||||
"#;
|
||||
|
||||
impl CheckerBoardNode {
|
||||
/// Fragment shader for the `"checkerboard"` request.
|
||||
fn shader_frag() -> &'static str {
|
||||
SHADER_FRAG
|
||||
}
|
||||
}
|
||||
|
||||
impl NodeBehavior for CheckerBoardNode {
|
||||
/// Human-readable name.
|
||||
fn name(&self) -> &str {
|
||||
"Checkerboard"
|
||||
}
|
||||
|
||||
/// Stable type id.
|
||||
fn type_id(&self) -> &str {
|
||||
"org.olivevideoeditor.Olive.checkerboard"
|
||||
}
|
||||
|
||||
/// Categories.
|
||||
fn categories(&self) -> &[Category] {
|
||||
&[Category::Generator]
|
||||
}
|
||||
|
||||
/// Description.
|
||||
fn description(&self) -> &str {
|
||||
"Generate a checkerboard pattern."
|
||||
}
|
||||
|
||||
/// Localized input names: the merge base's `base_in` -> "Base" plus
|
||||
/// `size_in` -> "Box Size", `color1_in` -> "Color 1", `color2_in` ->
|
||||
/// "Color 2".
|
||||
fn input_name<'a>(&self, id: &'a str) -> &'a str {
|
||||
match id {
|
||||
BASE_INPUT => "Base",
|
||||
SIZE_INPUT => "Box Size",
|
||||
COLOR1_INPUT => "Color 1",
|
||||
COLOR2_INPUT => "Color 2",
|
||||
_ => id,
|
||||
}
|
||||
}
|
||||
|
||||
/// Evaluate outputs: a `"checkerboard"` shader job over the value row,
|
||||
/// pushed through `push_mergable_job` — merged alpha-over `base_in`
|
||||
/// when a base texture is connected, pushed bare otherwise (matching
|
||||
/// the shared generator-with-merge wiring).
|
||||
///
|
||||
/// The params row carries `size_in` and the two colors; `resolution_in`
|
||||
/// is filled by the runner from the render target size, so it is not
|
||||
/// part of the params here.
|
||||
fn value(
|
||||
&self,
|
||||
core: &NodeCore,
|
||||
inputs: &crate::value::NodeValueRow,
|
||||
time: oak_core::Rational,
|
||||
table: &mut crate::value::NodeValueTable,
|
||||
) {
|
||||
let mut params = inputs.clone();
|
||||
// The inputs are part of the row in the traverser flow (the bare
|
||||
// key is always inserted for an unconnected input); fall back to
|
||||
// the node's own values for direct `value()` calls.
|
||||
for id in [SIZE_INPUT, COLOR1_INPUT, COLOR2_INPUT] {
|
||||
if !params.contains_key(id) {
|
||||
params.insert(id.to_string(), core.value_at_time(id, -1, time));
|
||||
}
|
||||
}
|
||||
|
||||
let job = crate::handle::make_owned(ShaderJobPayload {
|
||||
node_id: crate::id::NodeId::INVALID,
|
||||
time,
|
||||
iterations: 1,
|
||||
type_id: self.type_id().to_string(),
|
||||
shader_id: "checkerboard".to_string(),
|
||||
effect_input: core.effect_input.clone(),
|
||||
params,
|
||||
iterative_input: String::new(),
|
||||
});
|
||||
super::generatorwithmerge::GeneratorWithMerge::push_mergable_job(inputs, job, table);
|
||||
}
|
||||
|
||||
/// Shader code request: `"checkerboard"` returns this node's shader;
|
||||
/// the `"mrg"` request returns the shared alpha-over merge shader;
|
||||
/// anything else is unsupported.
|
||||
fn shader_code(&self, request: &str) -> Option<String> {
|
||||
match request {
|
||||
"checkerboard" => Some(Self::shader_frag().to_string()),
|
||||
"mrg" => Some(super::generatorwithmerge::merge_shader_frag().to_string()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Deep copy.
|
||||
fn duplicate(&self, _core: &NodeCore) -> Option<Box<dyn NodeBehavior>> {
|
||||
Some(Box::new(CheckerBoardNode))
|
||||
}
|
||||
}
|
||||
|
||||
/// Constructor: adds `base_in` (not-keyframable), `size_in`, `color1_in`
|
||||
/// and `color2_in` with the defaults and properties documented on the
|
||||
/// constants, sets the video-effect flag and makes `base_in` the effect
|
||||
/// input (the `GeneratorWithMerge` constructor side effects).
|
||||
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 size = crate::input::Input::new(
|
||||
SIZE_INPUT,
|
||||
crate::value::ValueType::Vec2,
|
||||
crate::value::NodeValue::Vec2([64.0, 64.0]),
|
||||
);
|
||||
size.properties = vec![("min".to_string(), crate::value::NodeValue::Vec2([0.0, 0.0]))];
|
||||
core.add_input(size);
|
||||
|
||||
let mut color1 = crate::input::Input::new(
|
||||
COLOR1_INPUT,
|
||||
crate::value::ValueType::Color,
|
||||
crate::value::NodeValue::Color([0.1, 0.1, 0.1, 1.0]),
|
||||
);
|
||||
color1.properties = vec![(
|
||||
"view".to_string(),
|
||||
crate::value::NodeValue::Text("color".into()),
|
||||
)];
|
||||
core.add_input(color1);
|
||||
|
||||
let mut color2 = crate::input::Input::new(
|
||||
COLOR2_INPUT,
|
||||
crate::value::ValueType::Color,
|
||||
crate::value::NodeValue::Color([0.5, 0.5, 0.5, 1.0]),
|
||||
);
|
||||
color2.properties = vec![(
|
||||
"view".to_string(),
|
||||
crate::value::NodeValue::Text("color".into()),
|
||||
)];
|
||||
core.add_input(color2);
|
||||
|
||||
core.flags |= crate::node::flags::VIDEO_EFFECT;
|
||||
core.effect_input = BASE_INPUT.to_string();
|
||||
|
||||
(core, Box::new(CheckerBoardNode))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::node::NodeBehavior;
|
||||
use crate::value::{NodeValue, NodeValueRow, NodeValueTable, ValueType};
|
||||
use oak_core::Rational;
|
||||
|
||||
#[test]
|
||||
fn input_names() {
|
||||
let n = CheckerBoardNode;
|
||||
assert_eq!(n.input_name(SIZE_INPUT), "Box Size");
|
||||
assert_eq!(n.input_name(COLOR1_INPUT), "Color 1");
|
||||
assert_eq!(n.input_name(COLOR2_INPUT), "Color 2");
|
||||
assert_eq!(
|
||||
n.input_name(super::super::generatorwithmerge::BASE_INPUT),
|
||||
"Base"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_wires_inputs_and_flags() {
|
||||
let (core, behavior) = create();
|
||||
assert_eq!(behavior.type_id(), "org.olivevideoeditor.Olive.checkerboard");
|
||||
assert_eq!(
|
||||
core.get_input(SIZE_INPUT).unwrap().default,
|
||||
NodeValue::Vec2([64.0, 64.0])
|
||||
);
|
||||
assert_eq!(
|
||||
core.get_input(COLOR1_INPUT).unwrap().default,
|
||||
NodeValue::Color([0.1, 0.1, 0.1, 1.0])
|
||||
);
|
||||
assert_eq!(
|
||||
core.get_input(COLOR2_INPUT).unwrap().default,
|
||||
NodeValue::Color([0.5, 0.5, 0.5, 1.0])
|
||||
);
|
||||
assert!(core.get_input(COLOR1_INPUT).unwrap().properties.iter().any(
|
||||
|(k, v)| k == "view" && *v == NodeValue::Text("color".into())
|
||||
));
|
||||
assert_eq!(core.effect_input, BASE_INPUT);
|
||||
assert_ne!(core.flags & crate::node::flags::VIDEO_EFFECT, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_pushes_generator_job() {
|
||||
let (core, behavior) = create();
|
||||
let mut table = NodeValueTable::default();
|
||||
behavior.value(
|
||||
&core,
|
||||
&NodeValueRow::default(),
|
||||
Rational::new(0, 1),
|
||||
&mut table,
|
||||
);
|
||||
let handle = match table.get(ValueType::Texture) {
|
||||
Some(NodeValue::Texture(h)) => *h,
|
||||
_ => panic!("texture expected"),
|
||||
};
|
||||
let payload = unsafe { crate::handle::get_checked::<ShaderJobPayload>(&handle) }
|
||||
.expect("shader job payload expected");
|
||||
assert_eq!(payload.type_id, "org.olivevideoeditor.Olive.checkerboard");
|
||||
assert_eq!(payload.shader_id, "checkerboard");
|
||||
assert_eq!(payload.iterations, 1);
|
||||
assert_eq!(payload.effect_input, BASE_INPUT);
|
||||
assert_eq!(payload.iterative_input, "");
|
||||
// The unconnected inputs resolve from the node defaults.
|
||||
assert_eq!(
|
||||
payload.params.get(SIZE_INPUT),
|
||||
Some(&NodeValue::Vec2([64.0, 64.0]))
|
||||
);
|
||||
assert_eq!(
|
||||
payload.params.get(COLOR1_INPUT),
|
||||
Some(&NodeValue::Color([0.1, 0.1, 0.1, 1.0]))
|
||||
);
|
||||
assert_eq!(
|
||||
payload.params.get(COLOR2_INPUT),
|
||||
Some(&NodeValue::Color([0.5, 0.5, 0.5, 1.0]))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_row_values_win_over_the_node_defaults() {
|
||||
let (core, behavior) = create();
|
||||
let inputs = NodeValueRow::from([
|
||||
(SIZE_INPUT.to_string(), NodeValue::Vec2([4.0, 4.0])),
|
||||
(COLOR1_INPUT.to_string(), NodeValue::Color([1.0, 0.0, 0.0, 1.0])),
|
||||
]);
|
||||
let mut table = NodeValueTable::default();
|
||||
behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
|
||||
let handle = match table.get(ValueType::Texture) {
|
||||
Some(NodeValue::Texture(h)) => *h,
|
||||
_ => panic!("texture expected"),
|
||||
};
|
||||
let payload = unsafe { crate::handle::get_checked::<ShaderJobPayload>(&handle) }
|
||||
.expect("shader job payload expected");
|
||||
assert_eq!(
|
||||
payload.params.get(SIZE_INPUT),
|
||||
Some(&NodeValue::Vec2([4.0, 4.0]))
|
||||
);
|
||||
assert_eq!(
|
||||
payload.params.get(COLOR1_INPUT),
|
||||
Some(&NodeValue::Color([1.0, 0.0, 0.0, 1.0]))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_with_base_merges_nested_job() {
|
||||
let (core, behavior) = create();
|
||||
let inputs = 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);
|
||||
let handle = match table.get(ValueType::Texture) {
|
||||
Some(NodeValue::Texture(h)) => *h,
|
||||
_ => panic!("texture expected"),
|
||||
};
|
||||
let merge = unsafe { crate::handle::get_checked::<ShaderJobPayload>(&handle) }
|
||||
.expect("merge job payload expected");
|
||||
assert_eq!(merge.shader_id, "mrg");
|
||||
assert_eq!(merge.iterations, 1);
|
||||
assert_eq!(merge.effect_input, BASE_INPUT);
|
||||
assert!(merge.params.contains_key(BASE_INPUT));
|
||||
// The generator's own parameters travel with the nested job; the
|
||||
// merge payload carries only the base and the blend input.
|
||||
assert!(!merge.params.contains_key(COLOR1_INPUT));
|
||||
// The checkerboard job is nested as the merge's blend texture.
|
||||
match merge.params.get(crate::nodes::merge::BLEND_INPUT) {
|
||||
Some(NodeValue::Texture(blend)) => {
|
||||
let nested = unsafe { crate::handle::get_checked::<ShaderJobPayload>(blend) }
|
||||
.expect("nested job payload boxed");
|
||||
assert_eq!(nested.shader_id, "checkerboard");
|
||||
assert_eq!(
|
||||
nested.params.get(COLOR1_INPUT),
|
||||
Some(&NodeValue::Color([0.1, 0.1, 0.1, 1.0]))
|
||||
);
|
||||
}
|
||||
_ => panic!("nested blend job expected"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shader_code_dispatches() {
|
||||
let n = CheckerBoardNode;
|
||||
let code = n.shader_code("checkerboard").unwrap();
|
||||
assert!(code.contains("uniform vec2 size_in;"));
|
||||
assert!(code.contains("uniform vec4 color1_in;"));
|
||||
assert!(code.contains("uniform vec4 color2_in;"));
|
||||
assert!(code.contains("uniform vec2 resolution_in;"));
|
||||
assert!(code.contains("float parity = s - 2.0 * floor(s * 0.5);"));
|
||||
assert!(!code.contains("switch"));
|
||||
assert!(n
|
||||
.shader_code("mrg")
|
||||
.unwrap()
|
||||
.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(), "Checkerboard");
|
||||
}
|
||||
}
|
||||
|
||||
/// Register this node type.
|
||||
pub fn register(meta: &mut Vec<NodeMeta>) {
|
||||
meta.push(NodeMeta {
|
||||
type_id: "org.olivevideoeditor.Olive.checkerboard",
|
||||
name: "Checkerboard",
|
||||
categories: &[Category::Generator],
|
||||
create,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
// 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/>.
|
||||
|
||||
//! Clamp effect — a clean-room reimplementation of the OpenFX-Misc
|
||||
//! `Clamp` plugin's parameter semantics (upstream
|
||||
//! `github.com/cgvirus/OpenFX-Misc`, GPL2; read for behavior only, no
|
||||
//! upstream code copied).
|
||||
//!
|
||||
//! Two scalars, one lower and one upper bound, applied to every
|
||||
//! channel:
|
||||
//!
|
||||
//! ```text
|
||||
//! lo = min(min_in, max_in)
|
||||
//! hi = max(min_in, max_in)
|
||||
//! x = min(max(x, lo), hi)
|
||||
//! ```
|
||||
//!
|
||||
//! The min bound is applied first and the max bound second, so when
|
||||
//! `max_in` is below `min_in` the result is `max_in` (the max wins the
|
||||
//! conflicting range). The reference clamps all four components —
|
||||
//! `processA` defaults to `true` — so alpha is clamped as well, which
|
||||
//! differs from the color-only nodes in this group.
|
||||
//!
|
||||
//! The reference clamps with an explicit low/high `vec4` built from its
|
||||
//! two parameters; the shader here does the equivalent with `min`/
|
||||
//! `max` over `vec4` operands (the scalar-operand `clamp` overloads are
|
||||
//! avoided on purpose — one less overload for the WGSL emitter to
|
||||
//! resolve).
|
||||
|
||||
use crate::factory::NodeMeta;
|
||||
use crate::jobs::ShaderJobPayload;
|
||||
use crate::node::{Category, NodeBehavior, NodeCore};
|
||||
|
||||
/// Texture input id. Type: texture; flags: not-keyframable; this is
|
||||
/// the node's effect input.
|
||||
pub const TEXTURE_INPUT: &str = "tex_in";
|
||||
|
||||
/// Lower bound input id. Type: float; default `0.0`. Values below it
|
||||
/// are raised to it.
|
||||
pub const MIN_INPUT: &str = "min_in";
|
||||
|
||||
/// Upper bound input id. Type: float; default `1.0`. Values above it
|
||||
/// are lowered to it.
|
||||
pub const MAX_INPUT: &str = "max_in";
|
||||
|
||||
/// Clamp node. The reference class holds no state beyond its parameter
|
||||
/// pointers, so this is a unit-like struct.
|
||||
pub struct ClampNode;
|
||||
|
||||
/// Fragment shader (clean-room GLSL for the reference's
|
||||
/// `ClampPlugin::render` chain). The uniforms are named after the node
|
||||
/// inputs: the renderer binds uniforms by matching the declared name
|
||||
/// against the job's parameter row.
|
||||
const SHADER_FRAG: &str = r#"// Inputs
|
||||
uniform sampler2D tex_in;
|
||||
|
||||
uniform float min_in;
|
||||
uniform float max_in;
|
||||
|
||||
// Input texture coordinate
|
||||
in vec2 ove_texcoord;
|
||||
out vec4 frag_color;
|
||||
|
||||
void main() {
|
||||
vec4 c = texture(tex_in, ove_texcoord);
|
||||
|
||||
// Lower bound first, upper bound second: when max_in < min_in the
|
||||
// upper bound wins.
|
||||
vec4 lo = vec4(min_in, min_in, min_in, min_in);
|
||||
vec4 hi = vec4(max_in, max_in, max_in, max_in);
|
||||
c = min(max(c, lo), hi);
|
||||
|
||||
frag_color = c;
|
||||
}
|
||||
"#;
|
||||
|
||||
impl NodeBehavior for ClampNode {
|
||||
/// Human-readable name.
|
||||
fn name(&self) -> &str {
|
||||
"Clamp"
|
||||
}
|
||||
|
||||
/// Stable type id.
|
||||
fn type_id(&self) -> &str {
|
||||
"org.olivevideoeditor.Olive.clamp"
|
||||
}
|
||||
|
||||
/// Categories.
|
||||
fn categories(&self) -> &[Category] {
|
||||
&[Category::Color]
|
||||
}
|
||||
|
||||
/// Description.
|
||||
fn description(&self) -> &str {
|
||||
"Clamp every channel to a lower and an upper bound."
|
||||
}
|
||||
|
||||
/// Localized input names: `tex_in` -> "Input", `min_in` -> "Min",
|
||||
/// `max_in` -> "Max".
|
||||
fn input_name<'a>(&self, id: &'a str) -> &'a str {
|
||||
match id {
|
||||
TEXTURE_INPUT => "Input",
|
||||
MIN_INPUT => "Min",
|
||||
MAX_INPUT => "Max",
|
||||
_ => id,
|
||||
}
|
||||
}
|
||||
|
||||
/// Evaluate outputs: no texture on `tex_in` -> push nothing;
|
||||
/// texture present -> push a shader job over the input row with
|
||||
/// both bounds resolved (so the renderer always finds a value for
|
||||
/// each uniform, whether the row carried the input or the node's own
|
||||
/// default/keyframe supplied it).
|
||||
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 resolve = |id: &str| match inputs.get(id) {
|
||||
Some(v) => v.clone(),
|
||||
None => core.value_at_time(id, -1, time),
|
||||
};
|
||||
|
||||
let mut params = inputs.clone();
|
||||
params.insert(MIN_INPUT.to_string(), resolve(MIN_INPUT));
|
||||
params.insert(MAX_INPUT.to_string(), resolve(MAX_INPUT));
|
||||
|
||||
table.push(
|
||||
crate::value::ValueType::Texture,
|
||||
crate::value::NodeValue::Texture(crate::handle::make_owned(ShaderJobPayload {
|
||||
node_id: crate::id::NodeId::INVALID,
|
||||
time,
|
||||
iterations: 1,
|
||||
type_id: self.type_id().to_string(),
|
||||
shader_id: String::new(),
|
||||
effect_input: core.effect_input.clone(),
|
||||
params,
|
||||
iterative_input: String::new(),
|
||||
})),
|
||||
None,
|
||||
);
|
||||
}
|
||||
|
||||
/// Shader code request: the request id is ignored; always returns
|
||||
/// [`SHADER_FRAG`].
|
||||
fn shader_code(&self, _request: &str) -> Option<String> {
|
||||
Some(SHADER_FRAG.to_string())
|
||||
}
|
||||
|
||||
/// Deep copy.
|
||||
fn duplicate(&self, _core: &NodeCore) -> Option<Box<dyn NodeBehavior>> {
|
||||
Some(Box::new(ClampNode))
|
||||
}
|
||||
}
|
||||
|
||||
/// Constructor: adds `tex_in` (texture, effect input) and the two
|
||||
/// bounds with the defaults 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);
|
||||
|
||||
add_float_input(&mut core, MIN_INPUT, 0.0);
|
||||
add_float_input(&mut core, MAX_INPUT, 1.0);
|
||||
|
||||
core.flags |= crate::node::flags::VIDEO_EFFECT;
|
||||
core.effect_input = TEXTURE_INPUT.to_string();
|
||||
|
||||
(core, Box::new(ClampNode))
|
||||
}
|
||||
|
||||
/// Add a float input with its default. The bounds themselves are
|
||||
/// unbounded (the reference parameter has no range), so no `min`/`max`
|
||||
/// properties are attached.
|
||||
fn add_float_input(core: &mut NodeCore, id: &str, default: f64) {
|
||||
core.add_input(crate::input::Input::new(
|
||||
id,
|
||||
crate::value::ValueType::Float,
|
||||
crate::value::NodeValue::Float(default),
|
||||
));
|
||||
}
|
||||
|
||||
/// Register this node type.
|
||||
pub fn register(meta: &mut Vec<NodeMeta>) {
|
||||
meta.push(NodeMeta {
|
||||
type_id: "org.olivevideoeditor.Olive.clamp",
|
||||
name: "Clamp",
|
||||
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 = ClampNode;
|
||||
assert_eq!(n.input_name(TEXTURE_INPUT), "Input");
|
||||
assert_eq!(n.input_name(MIN_INPUT), "Min");
|
||||
assert_eq!(n.input_name(MAX_INPUT), "Max");
|
||||
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.clamp");
|
||||
assert_eq!(behavior.name(), "Clamp");
|
||||
assert_eq!(behavior.categories(), &[Category::Color]);
|
||||
let tex = core.get_input(TEXTURE_INPUT).unwrap();
|
||||
assert_ne!(tex.flags & crate::input::flags::NOT_KEYFRAMABLE, 0);
|
||||
assert_eq!(
|
||||
core.get_input(MIN_INPUT).unwrap().default,
|
||||
NodeValue::Float(0.0)
|
||||
);
|
||||
assert_eq!(
|
||||
core.get_input(MAX_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_with_texture_pushes_shader_job_with_resolved_params() {
|
||||
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);
|
||||
let Some(NodeValue::Texture(handle)) = table.get(ValueType::Texture) else {
|
||||
panic!("expected a texture-typed value");
|
||||
};
|
||||
let payload =
|
||||
unsafe { crate::handle::get_checked::<crate::jobs::ShaderJobPayload>(handle) }
|
||||
.expect("shader job pushed");
|
||||
assert_eq!(payload.type_id, "org.olivevideoeditor.Olive.clamp");
|
||||
assert_eq!(payload.effect_input, TEXTURE_INPUT);
|
||||
assert_eq!(payload.params.get(MIN_INPUT), Some(&NodeValue::Float(0.0)));
|
||||
assert_eq!(payload.params.get(MAX_INPUT), Some(&NodeValue::Float(1.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_row_values_win_over_defaults() {
|
||||
let (mut core, behavior) = create();
|
||||
core.set_standard_value(MAX_INPUT, -1, NodeValue::Float(1.0));
|
||||
let inputs = crate::value::NodeValueRow::from([
|
||||
(
|
||||
TEXTURE_INPUT.to_string(),
|
||||
NodeValue::Texture(crate::handle::CHandle::null()),
|
||||
),
|
||||
(MAX_INPUT.to_string(), NodeValue::Float(0.5)),
|
||||
]);
|
||||
let mut table = NodeValueTable::default();
|
||||
behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
|
||||
let Some(NodeValue::Texture(handle)) = table.get(ValueType::Texture) else {
|
||||
panic!("expected a texture-typed value");
|
||||
};
|
||||
let payload =
|
||||
unsafe { crate::handle::get_checked::<crate::jobs::ShaderJobPayload>(handle) }
|
||||
.expect("shader job pushed");
|
||||
assert_eq!(payload.params.get(MAX_INPUT), Some(&NodeValue::Float(0.5)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shader_declares_uniforms_and_avoids_switch() {
|
||||
let code = ClampNode.shader_code("").unwrap();
|
||||
for uniform in ["tex_in", MIN_INPUT, MAX_INPUT] {
|
||||
assert!(code.contains(uniform), "uniform {uniform} declared");
|
||||
}
|
||||
assert!(code.contains("ove_texcoord"));
|
||||
assert!(code.contains("frag_color"));
|
||||
assert!(!code.contains("switch"), "naga rejects GLSL switch");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_clones() {
|
||||
let (core, behavior) = create();
|
||||
let dup = behavior.duplicate(&core).unwrap();
|
||||
assert_eq!(dup.name(), "Clamp");
|
||||
assert_eq!(dup.type_id(), "org.olivevideoeditor.Olive.clamp");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,395 @@
|
||||
// Oak Video Editor - Non-Linear Video Editor
|
||||
// Copyright (C) 2026 Oak Team
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! SMPTE color bars generator: clean-room reimplementation of the
|
||||
//! OpenFX-Misc `ColorBars` plugin's parameter semantics (upstream
|
||||
//! github.com/cgvirus/OpenFX-Misc, GPL2; read for behavior only, no
|
||||
//! upstream code copied).
|
||||
//!
|
||||
//! Deviations from the upstream plugin: it lays the pattern out on an
|
||||
//! IRE-scale grid driven by `barIntensity` (0..100 IRE, default 75) and
|
||||
//! `outputIRE`, with the bar heights in IRE units; this node renders the
|
||||
//! standard SMPTE ECR layout instead — a 75% 7-bar top field (two thirds
|
||||
//! of the frame), the mid strip of blue/black/magenta/black/cyan/black/
|
||||
//! white, and the reverse-grayscale bottom field with the PLUGE on the
|
||||
//! right — and exposes the upstream intensity choice as a two-way
|
||||
//! `standard_in` combo (75% / 100%).
|
||||
|
||||
use crate::factory::NodeMeta;
|
||||
use crate::jobs::ShaderJobPayload;
|
||||
use crate::node::{Category, NodeBehavior, NodeCore};
|
||||
|
||||
/// Base texture input id (the shared generator-with-merge base). Type:
|
||||
/// texture; flags: not-keyframable; this is the generator's effect input.
|
||||
pub const BASE_INPUT: &str = super::generatorwithmerge::BASE_INPUT;
|
||||
|
||||
/// Standard input id (upstream `kParamBarIntensity` "Bar intensity",
|
||||
/// default 75 IRE). Type: combo; default 0; combo strings: 0 = "SMPTE
|
||||
/// 75%", 1 = "Full 100%". The 100% setting scales the chromatic bars and
|
||||
/// the reverse-grayscale ramp to full amplitude.
|
||||
pub const STANDARD_INPUT: &str = "standard_in";
|
||||
|
||||
/// SMPTE color bars generator node.
|
||||
pub struct ColorBarsNode;
|
||||
|
||||
/// Fragment shader for the `"colorbars"` shader id.
|
||||
///
|
||||
/// The frame is split in normalized coordinates: the top field (`v <
|
||||
/// 2/3`) holds the seven bars (white, yellow, cyan, green, magenta, red,
|
||||
/// blue), the mid strip (`v < 3/4`) the blue/black/magenta/black/cyan/
|
||||
/// black/white row, and the bottom field the reverse-grayscale ramp
|
||||
/// (left-to-right decreasing) ending in the three-step PLUGE (below
|
||||
/// black / black / above black). `ove_texcoord.y` runs downward — frame
|
||||
/// row 0 is the top of the image — so the first branch is the top field.
|
||||
///
|
||||
/// `level` is the bar amplitude: 0.75 for the 75% standard, 1.0 for the
|
||||
/// 100% one. The branch chain is spelled with if/else (a `switch` would
|
||||
/// be rejected by the shader translator).
|
||||
const SHADER_FRAG: &str = r#"uniform vec2 resolution_in;
|
||||
uniform int standard_in;
|
||||
|
||||
in vec2 ove_texcoord;
|
||||
out vec4 frag_color;
|
||||
|
||||
void main(void) {
|
||||
float level = 0.75;
|
||||
if (standard_in == 1) {
|
||||
level = 1.0;
|
||||
}
|
||||
|
||||
float column = floor(ove_texcoord.x * 7.0);
|
||||
vec3 col = vec3(0.0);
|
||||
|
||||
if (ove_texcoord.y < 2.0 / 3.0) {
|
||||
// Top field: white, yellow, cyan, green, magenta, red, blue.
|
||||
if (column < 0.5) {
|
||||
col = vec3(level);
|
||||
} else if (column < 1.5) {
|
||||
col = vec3(level, level, 0.0);
|
||||
} else if (column < 2.5) {
|
||||
col = vec3(0.0, level, level);
|
||||
} else if (column < 3.5) {
|
||||
col = vec3(0.0, level, 0.0);
|
||||
} else if (column < 4.5) {
|
||||
col = vec3(level, 0.0, level);
|
||||
} else if (column < 5.5) {
|
||||
col = vec3(level, 0.0, 0.0);
|
||||
} else {
|
||||
col = vec3(0.0, 0.0, level);
|
||||
}
|
||||
} else if (ove_texcoord.y < 3.0 / 4.0) {
|
||||
// Mid strip: blue, black, magenta, black, cyan, black, white.
|
||||
if (column < 0.5) {
|
||||
col = vec3(0.0, 0.0, level);
|
||||
} else if (column < 1.5) {
|
||||
col = vec3(0.0);
|
||||
} else if (column < 2.5) {
|
||||
col = vec3(level, 0.0, level);
|
||||
} else if (column < 3.5) {
|
||||
col = vec3(0.0);
|
||||
} else if (column < 4.5) {
|
||||
col = vec3(0.0, level, level);
|
||||
} else if (column < 5.5) {
|
||||
col = vec3(0.0);
|
||||
} else {
|
||||
col = vec3(level);
|
||||
}
|
||||
} else {
|
||||
// Bottom field: reverse grayscale ramp, then the PLUGE steps
|
||||
// (below black / black / above black) in the last bar.
|
||||
float g = level * (5.0 - column) / 5.0;
|
||||
if (column > 5.5) {
|
||||
float sub = floor((ove_texcoord.x * 7.0 - 6.0) * 3.0);
|
||||
if (sub < 0.5) {
|
||||
g = -0.04;
|
||||
} else if (sub < 1.5) {
|
||||
g = 0.0;
|
||||
} else {
|
||||
g = 0.04;
|
||||
}
|
||||
}
|
||||
col = vec3(g);
|
||||
}
|
||||
|
||||
frag_color = vec4(col, 1.0);
|
||||
}
|
||||
"#;
|
||||
|
||||
impl ColorBarsNode {
|
||||
/// Fragment shader for the `"colorbars"` request.
|
||||
fn shader_frag() -> &'static str {
|
||||
SHADER_FRAG
|
||||
}
|
||||
}
|
||||
|
||||
impl NodeBehavior for ColorBarsNode {
|
||||
/// Human-readable name.
|
||||
fn name(&self) -> &str {
|
||||
"Color Bars"
|
||||
}
|
||||
|
||||
/// Stable type id.
|
||||
fn type_id(&self) -> &str {
|
||||
"org.olivevideoeditor.Olive.colorbars"
|
||||
}
|
||||
|
||||
/// Categories.
|
||||
fn categories(&self) -> &[Category] {
|
||||
&[Category::Generator]
|
||||
}
|
||||
|
||||
/// Description.
|
||||
fn description(&self) -> &str {
|
||||
"Generate SMPTE color bars."
|
||||
}
|
||||
|
||||
/// Localized input names: the merge base's `base_in` -> "Base" plus
|
||||
/// `standard_in` -> "Standard".
|
||||
fn input_name<'a>(&self, id: &'a str) -> &'a str {
|
||||
match id {
|
||||
BASE_INPUT => "Base",
|
||||
STANDARD_INPUT => "Standard",
|
||||
_ => id,
|
||||
}
|
||||
}
|
||||
|
||||
/// Combo input option labels: `standard_in` -> "SMPTE 75%",
|
||||
/// "Full 100%".
|
||||
fn input_combo_strings(&self, id: &str) -> Vec<&'static str> {
|
||||
match id {
|
||||
STANDARD_INPUT => vec!["SMPTE 75%", "Full 100%"],
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Evaluate outputs: a `"colorbars"` shader job over the value row,
|
||||
/// pushed through `push_mergable_job` — merged alpha-over `base_in`
|
||||
/// when a base texture is connected, pushed bare otherwise.
|
||||
///
|
||||
/// The params row carries `standard_in`; `resolution_in` is filled by
|
||||
/// the runner from the render target size, so it is not part of the
|
||||
/// params here.
|
||||
fn value(
|
||||
&self,
|
||||
core: &NodeCore,
|
||||
inputs: &crate::value::NodeValueRow,
|
||||
time: oak_core::Rational,
|
||||
table: &mut crate::value::NodeValueTable,
|
||||
) {
|
||||
let mut params = inputs.clone();
|
||||
// The input is part of the row in the traverser flow (the bare key
|
||||
// is always inserted for an unconnected input); fall back to the
|
||||
// node's own value for direct `value()` calls.
|
||||
if !params.contains_key(STANDARD_INPUT) {
|
||||
params.insert(
|
||||
STANDARD_INPUT.to_string(),
|
||||
core.value_at_time(STANDARD_INPUT, -1, time),
|
||||
);
|
||||
}
|
||||
|
||||
let job = crate::handle::make_owned(ShaderJobPayload {
|
||||
node_id: crate::id::NodeId::INVALID,
|
||||
time,
|
||||
iterations: 1,
|
||||
type_id: self.type_id().to_string(),
|
||||
shader_id: "colorbars".to_string(),
|
||||
effect_input: core.effect_input.clone(),
|
||||
params,
|
||||
iterative_input: String::new(),
|
||||
});
|
||||
super::generatorwithmerge::GeneratorWithMerge::push_mergable_job(inputs, job, table);
|
||||
}
|
||||
|
||||
/// Shader code request: `"colorbars"` returns this node's shader; the
|
||||
/// `"mrg"` request returns the shared alpha-over merge shader;
|
||||
/// anything else is unsupported.
|
||||
fn shader_code(&self, request: &str) -> Option<String> {
|
||||
match request {
|
||||
"colorbars" => Some(Self::shader_frag().to_string()),
|
||||
"mrg" => Some(super::generatorwithmerge::merge_shader_frag().to_string()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Deep copy.
|
||||
fn duplicate(&self, _core: &NodeCore) -> Option<Box<dyn NodeBehavior>> {
|
||||
Some(Box::new(ColorBarsNode))
|
||||
}
|
||||
}
|
||||
|
||||
/// Constructor: adds `base_in` (not-keyframable) and `standard_in` with
|
||||
/// the defaults documented on the constants, sets the video-effect flag
|
||||
/// and makes `base_in` the effect input (the `GeneratorWithMerge`
|
||||
/// constructor side effects).
|
||||
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);
|
||||
|
||||
core.add_input(crate::input::Input::new(
|
||||
STANDARD_INPUT,
|
||||
crate::value::ValueType::Combo,
|
||||
crate::value::NodeValue::Combo(0),
|
||||
));
|
||||
|
||||
core.flags |= crate::node::flags::VIDEO_EFFECT;
|
||||
core.effect_input = BASE_INPUT.to_string();
|
||||
|
||||
(core, Box::new(ColorBarsNode))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::node::NodeBehavior;
|
||||
use crate::value::{NodeValue, NodeValueRow, NodeValueTable, ValueType};
|
||||
use oak_core::Rational;
|
||||
|
||||
#[test]
|
||||
fn input_names_and_combo_strings() {
|
||||
let n = ColorBarsNode;
|
||||
assert_eq!(n.input_name(STANDARD_INPUT), "Standard");
|
||||
assert_eq!(
|
||||
n.input_name(super::super::generatorwithmerge::BASE_INPUT),
|
||||
"Base"
|
||||
);
|
||||
assert_eq!(n.input_combo_strings(STANDARD_INPUT), vec!["SMPTE 75%", "Full 100%"]);
|
||||
assert!(n.input_combo_strings("other_in").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_wires_inputs_and_flags() {
|
||||
let (core, behavior) = create();
|
||||
assert_eq!(behavior.type_id(), "org.olivevideoeditor.Olive.colorbars");
|
||||
assert_eq!(
|
||||
core.get_input(STANDARD_INPUT).unwrap().default,
|
||||
NodeValue::Combo(0)
|
||||
);
|
||||
assert_eq!(behavior.input_combo_strings(STANDARD_INPUT).len(), 2);
|
||||
assert_eq!(core.effect_input, BASE_INPUT);
|
||||
assert_ne!(core.flags & crate::node::flags::VIDEO_EFFECT, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_pushes_generator_job() {
|
||||
let (core, behavior) = create();
|
||||
let mut table = NodeValueTable::default();
|
||||
behavior.value(
|
||||
&core,
|
||||
&NodeValueRow::default(),
|
||||
Rational::new(0, 1),
|
||||
&mut table,
|
||||
);
|
||||
let handle = match table.get(ValueType::Texture) {
|
||||
Some(NodeValue::Texture(h)) => *h,
|
||||
_ => panic!("texture expected"),
|
||||
};
|
||||
let payload = unsafe { crate::handle::get_checked::<ShaderJobPayload>(&handle) }
|
||||
.expect("shader job payload expected");
|
||||
assert_eq!(payload.type_id, "org.olivevideoeditor.Olive.colorbars");
|
||||
assert_eq!(payload.shader_id, "colorbars");
|
||||
assert_eq!(payload.iterations, 1);
|
||||
assert_eq!(payload.effect_input, BASE_INPUT);
|
||||
assert_eq!(payload.iterative_input, "");
|
||||
assert_eq!(
|
||||
payload.params.get(STANDARD_INPUT),
|
||||
Some(&NodeValue::Combo(0))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_row_standard_wins_over_the_node_default() {
|
||||
let (core, behavior) = create();
|
||||
let inputs = NodeValueRow::from([(STANDARD_INPUT.to_string(), NodeValue::Combo(1))]);
|
||||
let mut table = NodeValueTable::default();
|
||||
behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
|
||||
let handle = match table.get(ValueType::Texture) {
|
||||
Some(NodeValue::Texture(h)) => *h,
|
||||
_ => panic!("texture expected"),
|
||||
};
|
||||
let payload = unsafe { crate::handle::get_checked::<ShaderJobPayload>(&handle) }
|
||||
.expect("shader job payload expected");
|
||||
assert_eq!(
|
||||
payload.params.get(STANDARD_INPUT),
|
||||
Some(&NodeValue::Combo(1))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_with_base_merges_nested_job() {
|
||||
let (core, behavior) = create();
|
||||
let inputs = 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);
|
||||
let handle = match table.get(ValueType::Texture) {
|
||||
Some(NodeValue::Texture(h)) => *h,
|
||||
_ => panic!("texture expected"),
|
||||
};
|
||||
let merge = unsafe { crate::handle::get_checked::<ShaderJobPayload>(&handle) }
|
||||
.expect("merge job payload expected");
|
||||
assert_eq!(merge.shader_id, "mrg");
|
||||
assert_eq!(merge.effect_input, BASE_INPUT);
|
||||
match merge.params.get(crate::nodes::merge::BLEND_INPUT) {
|
||||
Some(NodeValue::Texture(blend)) => {
|
||||
let nested = unsafe { crate::handle::get_checked::<ShaderJobPayload>(blend) }
|
||||
.expect("nested job payload boxed");
|
||||
assert_eq!(nested.shader_id, "colorbars");
|
||||
}
|
||||
_ => panic!("nested blend job expected"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shader_code_dispatches() {
|
||||
let n = ColorBarsNode;
|
||||
let code = n.shader_code("colorbars").unwrap();
|
||||
assert!(code.contains("uniform int standard_in;"));
|
||||
assert!(code.contains("uniform vec2 resolution_in;"));
|
||||
assert!(code.contains("float level = 0.75;"));
|
||||
assert!(!code.contains("switch"));
|
||||
assert!(n
|
||||
.shader_code("mrg")
|
||||
.unwrap()
|
||||
.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(), "Color Bars");
|
||||
}
|
||||
}
|
||||
|
||||
/// Register this node type.
|
||||
pub fn register(meta: &mut Vec<NodeMeta>) {
|
||||
meta.push(NodeMeta {
|
||||
type_id: "org.olivevideoeditor.Olive.colorbars",
|
||||
name: "Color Bars",
|
||||
categories: &[Category::Generator],
|
||||
create,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,455 @@
|
||||
// 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 Correct effect — a clean-room reimplementation of the
|
||||
//! OpenFX-Misc `ColorCorrect` plugin's parameter semantics (upstream
|
||||
//! `github.com/cgvirus/OpenFX-Misc`, GPL2; read for behavior only, no
|
||||
//! upstream code copied).
|
||||
//!
|
||||
//! The reference ships five color controls (saturation, contrast,
|
||||
//! gamma, gain, offset) per tone group (master/shadows/midtones/
|
||||
//! highlights) plus per-component enable booleans; this node keeps the
|
||||
//! master group's global controls only — one float per control, applied
|
||||
//! to all three color channels. Alpha is never touched (the reference
|
||||
//! leaves `processA` off by default).
|
||||
//!
|
||||
//! Formula, applied per pixel in this order (per color channel `x`):
|
||||
//!
|
||||
//! ```text
|
||||
//! luma = 0.2126*r + 0.7152*g + 0.0722*b (Rec. 709)
|
||||
//! x = (1 - saturation) * luma + saturation * x
|
||||
//! x = (x > 0) ? pow(x / 0.18, contrast) * 0.18 : x
|
||||
//! x = (x > 0) ? pow(x, 1 / gamma) : x
|
||||
//! x = x * gain + offset
|
||||
//! ```
|
||||
//!
|
||||
//! with `contrast` applied only when it differs from `1.0` and `gamma`
|
||||
//! only when it differs from `1.0`. Contrast pivots on the 0.18 mid
|
||||
//! gray of the reference (a photographic gray card in sRGB), and the
|
||||
//! gamma is the reciprocal exponent of the reference's gamma pass.
|
||||
|
||||
use crate::factory::NodeMeta;
|
||||
use crate::jobs::ShaderJobPayload;
|
||||
use crate::node::{Category, NodeBehavior, NodeCore};
|
||||
|
||||
/// Texture input id. Type: texture; flags: not-keyframable; this is
|
||||
/// the node's effect input.
|
||||
pub const TEXTURE_INPUT: &str = "tex_in";
|
||||
|
||||
/// Saturation input id. Type: float; default `1.0`; properties:
|
||||
/// `min = 0.0`, `max = 4.0`. Lerps every channel toward the pixel's
|
||||
/// Rec. 709 luma (`0.0` = fully desaturated, `1.0` = unchanged).
|
||||
pub const SATURATION_INPUT: &str = "saturation_in";
|
||||
|
||||
/// Contrast input id. Type: float; default `1.0`; properties:
|
||||
/// `min = 0.0`, `max = 4.0`. Power curve around the 0.18 mid gray.
|
||||
pub const CONTRAST_INPUT: &str = "contrast_in";
|
||||
|
||||
/// Gamma input id. Type: float; default `1.0`; properties:
|
||||
/// `min = 0.2`, `max = 5.0`. Reciprocal exponent (`pow(x, 1/gamma)`).
|
||||
pub const GAMMA_INPUT: &str = "gamma_in";
|
||||
|
||||
/// Gain input id. Type: float; default `1.0`; properties:
|
||||
/// `min = 0.0`, `max = 4.0`. Multiplies the color channels.
|
||||
pub const GAIN_INPUT: &str = "gain_in";
|
||||
|
||||
/// Offset input id. Type: float; default `0.0`; properties:
|
||||
/// `min = -1.0`, `max = 1.0`. Added to the color channels after gain.
|
||||
pub const OFFSET_INPUT: &str = "offset_in";
|
||||
|
||||
/// Color correct node. The reference class holds no state beyond its
|
||||
/// parameter pointers, so this is a unit-like struct.
|
||||
pub struct ColorCorrectNode;
|
||||
|
||||
/// Fragment shader (clean-room GLSL for the reference's
|
||||
/// `ColorCorrecter::process` chain). The uniforms are named after the
|
||||
/// node inputs: the renderer binds uniforms by matching the declared
|
||||
/// name against the job's parameter row.
|
||||
const SHADER_FRAG: &str = r#"// Inputs
|
||||
uniform sampler2D tex_in;
|
||||
|
||||
uniform float saturation_in;
|
||||
uniform float contrast_in;
|
||||
uniform float gamma_in;
|
||||
uniform float gain_in;
|
||||
uniform float offset_in;
|
||||
|
||||
// Input texture coordinate
|
||||
in vec2 ove_texcoord;
|
||||
out vec4 frag_color;
|
||||
|
||||
void main() {
|
||||
vec4 c = texture(tex_in, ove_texcoord);
|
||||
|
||||
// Saturation: lerp each channel toward the pixel's Rec. 709 luma.
|
||||
float luma = dot(c.rgb, vec3(0.2126, 0.7152, 0.0722));
|
||||
c.rgb = (1.0 - saturation_in) * luma + saturation_in * c.rgb;
|
||||
|
||||
// Contrast: power curve pivoting on 0.18 mid gray. Non-positive
|
||||
// values pass through unchanged (a negative base has no real power).
|
||||
if (contrast_in != 1.0) {
|
||||
if (c.r > 0.0) {
|
||||
c.r = pow(c.r / 0.18, contrast_in) * 0.18;
|
||||
}
|
||||
if (c.g > 0.0) {
|
||||
c.g = pow(c.g / 0.18, contrast_in) * 0.18;
|
||||
}
|
||||
if (c.b > 0.0) {
|
||||
c.b = pow(c.b / 0.18, contrast_in) * 0.18;
|
||||
}
|
||||
}
|
||||
|
||||
// Gamma: reciprocal exponent, positive values only. A non-positive
|
||||
// gamma would divide by zero, so it is clamped to a tiny minimum.
|
||||
if (gamma_in != 1.0) {
|
||||
float g = max(gamma_in, 1.0e-8);
|
||||
if (c.r > 0.0) {
|
||||
c.r = pow(c.r, 1.0 / g);
|
||||
}
|
||||
if (c.g > 0.0) {
|
||||
c.g = pow(c.g, 1.0 / g);
|
||||
}
|
||||
if (c.b > 0.0) {
|
||||
c.b = pow(c.b, 1.0 / g);
|
||||
}
|
||||
}
|
||||
|
||||
// Gain, then offset.
|
||||
c.rgb = c.rgb * gain_in + offset_in;
|
||||
|
||||
frag_color = c;
|
||||
}
|
||||
"#;
|
||||
|
||||
impl NodeBehavior for ColorCorrectNode {
|
||||
/// Human-readable name.
|
||||
fn name(&self) -> &str {
|
||||
"Color Correct"
|
||||
}
|
||||
|
||||
/// Stable type id.
|
||||
fn type_id(&self) -> &str {
|
||||
"org.olivevideoeditor.Olive.colorcorrect"
|
||||
}
|
||||
|
||||
/// Categories.
|
||||
fn categories(&self) -> &[Category] {
|
||||
&[Category::Color]
|
||||
}
|
||||
|
||||
/// Description.
|
||||
fn description(&self) -> &str {
|
||||
"Adjust color using saturation, contrast, gamma, gain, and offset."
|
||||
}
|
||||
|
||||
/// Localized input names: `tex_in` -> "Input", `saturation_in` ->
|
||||
/// "Saturation", `contrast_in` -> "Contrast", `gamma_in` ->
|
||||
/// "Gamma", `gain_in` -> "Gain", `offset_in` -> "Offset".
|
||||
fn input_name<'a>(&self, id: &'a str) -> &'a str {
|
||||
match id {
|
||||
TEXTURE_INPUT => "Input",
|
||||
SATURATION_INPUT => "Saturation",
|
||||
CONTRAST_INPUT => "Contrast",
|
||||
GAMMA_INPUT => "Gamma",
|
||||
GAIN_INPUT => "Gain",
|
||||
OFFSET_INPUT => "Offset",
|
||||
_ => id,
|
||||
}
|
||||
}
|
||||
|
||||
/// Evaluate outputs: no texture on `tex_in` -> push nothing;
|
||||
/// texture present -> push a shader job over the input row with
|
||||
/// every control resolved (so the renderer always finds a value for
|
||||
/// each uniform, whether the row carried the input or the node's
|
||||
/// own default/keyframe supplied it).
|
||||
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 resolve = |id: &str| match inputs.get(id) {
|
||||
Some(v) => v.clone(),
|
||||
None => core.value_at_time(id, -1, time),
|
||||
};
|
||||
|
||||
let mut params = inputs.clone();
|
||||
for id in [
|
||||
SATURATION_INPUT,
|
||||
CONTRAST_INPUT,
|
||||
GAMMA_INPUT,
|
||||
GAIN_INPUT,
|
||||
OFFSET_INPUT,
|
||||
] {
|
||||
params.insert(id.to_string(), resolve(id));
|
||||
}
|
||||
|
||||
table.push(
|
||||
crate::value::ValueType::Texture,
|
||||
crate::value::NodeValue::Texture(crate::handle::make_owned(ShaderJobPayload {
|
||||
node_id: crate::id::NodeId::INVALID,
|
||||
time,
|
||||
iterations: 1,
|
||||
type_id: self.type_id().to_string(),
|
||||
shader_id: String::new(),
|
||||
effect_input: core.effect_input.clone(),
|
||||
params,
|
||||
iterative_input: String::new(),
|
||||
})),
|
||||
None,
|
||||
);
|
||||
}
|
||||
|
||||
/// Shader code request: the request id is ignored; always returns
|
||||
/// [`SHADER_FRAG`].
|
||||
fn shader_code(&self, _request: &str) -> Option<String> {
|
||||
Some(SHADER_FRAG.to_string())
|
||||
}
|
||||
|
||||
/// Deep copy.
|
||||
fn duplicate(&self, _core: &NodeCore) -> Option<Box<dyn NodeBehavior>> {
|
||||
Some(Box::new(ColorCorrectNode))
|
||||
}
|
||||
}
|
||||
|
||||
/// Constructor: adds `tex_in` (texture, effect input) and the five
|
||||
/// controls with the defaults and ranges 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);
|
||||
|
||||
add_float_input(&mut core, SATURATION_INPUT, 1.0, 0.0, 4.0);
|
||||
add_float_input(&mut core, CONTRAST_INPUT, 1.0, 0.0, 4.0);
|
||||
add_float_input(&mut core, GAMMA_INPUT, 1.0, 0.2, 5.0);
|
||||
add_float_input(&mut core, GAIN_INPUT, 1.0, 0.0, 4.0);
|
||||
add_float_input(&mut core, OFFSET_INPUT, 0.0, -1.0, 1.0);
|
||||
|
||||
core.flags |= crate::node::flags::VIDEO_EFFECT;
|
||||
core.effect_input = TEXTURE_INPUT.to_string();
|
||||
|
||||
(core, Box::new(ColorCorrectNode))
|
||||
}
|
||||
|
||||
/// Add a float input with its default and `min`/`max`/`view`
|
||||
/// properties.
|
||||
fn add_float_input(core: &mut NodeCore, id: &str, default: f64, min: f64, max: f64) {
|
||||
let mut input = crate::input::Input::new(
|
||||
id,
|
||||
crate::value::ValueType::Float,
|
||||
crate::value::NodeValue::Float(default),
|
||||
);
|
||||
input.properties = vec![
|
||||
("min".to_string(), crate::value::NodeValue::Float(min)),
|
||||
("max".to_string(), crate::value::NodeValue::Float(max)),
|
||||
(
|
||||
"view".to_string(),
|
||||
crate::value::NodeValue::Text("normal".into()),
|
||||
),
|
||||
];
|
||||
core.add_input(input);
|
||||
}
|
||||
|
||||
/// Register this node type.
|
||||
pub fn register(meta: &mut Vec<NodeMeta>) {
|
||||
meta.push(NodeMeta {
|
||||
type_id: "org.olivevideoeditor.Olive.colorcorrect",
|
||||
name: "Color Correct",
|
||||
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 = ColorCorrectNode;
|
||||
assert_eq!(n.input_name(TEXTURE_INPUT), "Input");
|
||||
assert_eq!(n.input_name(SATURATION_INPUT), "Saturation");
|
||||
assert_eq!(n.input_name(CONTRAST_INPUT), "Contrast");
|
||||
assert_eq!(n.input_name(GAMMA_INPUT), "Gamma");
|
||||
assert_eq!(n.input_name(GAIN_INPUT), "Gain");
|
||||
assert_eq!(n.input_name(OFFSET_INPUT), "Offset");
|
||||
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.colorcorrect"
|
||||
);
|
||||
assert_eq!(behavior.name(), "Color Correct");
|
||||
assert_eq!(behavior.categories(), &[Category::Color]);
|
||||
let tex = core.get_input(TEXTURE_INPUT).unwrap();
|
||||
assert_ne!(tex.flags & crate::input::flags::NOT_KEYFRAMABLE, 0);
|
||||
assert_eq!(
|
||||
core.get_input(SATURATION_INPUT).unwrap().default,
|
||||
NodeValue::Float(1.0)
|
||||
);
|
||||
assert_eq!(
|
||||
core.get_input(CONTRAST_INPUT).unwrap().default,
|
||||
NodeValue::Float(1.0)
|
||||
);
|
||||
assert_eq!(
|
||||
core.get_input(GAMMA_INPUT).unwrap().default,
|
||||
NodeValue::Float(1.0)
|
||||
);
|
||||
assert_eq!(
|
||||
core.get_input(GAIN_INPUT).unwrap().default,
|
||||
NodeValue::Float(1.0)
|
||||
);
|
||||
assert_eq!(
|
||||
core.get_input(OFFSET_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 create_sets_control_ranges() {
|
||||
let (core, _) = create();
|
||||
let gamma = core.get_input(GAMMA_INPUT).unwrap();
|
||||
assert_eq!(gamma.properties[0].1, NodeValue::Float(0.2));
|
||||
assert_eq!(gamma.properties[1].1, NodeValue::Float(5.0));
|
||||
let offset = core.get_input(OFFSET_INPUT).unwrap();
|
||||
assert_eq!(offset.properties[0].1, NodeValue::Float(-1.0));
|
||||
assert_eq!(offset.properties[1].1, NodeValue::Float(1.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_shader_job_with_resolved_params() {
|
||||
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);
|
||||
let Some(NodeValue::Texture(handle)) = table.get(ValueType::Texture) else {
|
||||
panic!("expected a texture-typed value");
|
||||
};
|
||||
let payload =
|
||||
unsafe { crate::handle::get_checked::<crate::jobs::ShaderJobPayload>(handle) }
|
||||
.expect("shader job pushed");
|
||||
assert_eq!(payload.type_id, "org.olivevideoeditor.Olive.colorcorrect");
|
||||
assert_eq!(payload.shader_id, "");
|
||||
assert_eq!(payload.iterations, 1);
|
||||
assert_eq!(payload.effect_input, TEXTURE_INPUT);
|
||||
// Every control is resolved into the row, so the renderer finds
|
||||
// a value for each uniform even when the row omitted the input.
|
||||
assert_eq!(
|
||||
payload.params.get(SATURATION_INPUT),
|
||||
Some(&NodeValue::Float(1.0))
|
||||
);
|
||||
assert_eq!(
|
||||
payload.params.get(CONTRAST_INPUT),
|
||||
Some(&NodeValue::Float(1.0))
|
||||
);
|
||||
assert_eq!(payload.params.get(GAMMA_INPUT), Some(&NodeValue::Float(1.0)));
|
||||
assert_eq!(payload.params.get(GAIN_INPUT), Some(&NodeValue::Float(1.0)));
|
||||
assert_eq!(
|
||||
payload.params.get(OFFSET_INPUT),
|
||||
Some(&NodeValue::Float(0.0))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_row_values_win_over_defaults() {
|
||||
let (mut core, behavior) = create();
|
||||
core.set_standard_value(CONTRAST_INPUT, -1, NodeValue::Float(1.5));
|
||||
let inputs = crate::value::NodeValueRow::from([
|
||||
(
|
||||
TEXTURE_INPUT.to_string(),
|
||||
NodeValue::Texture(crate::handle::CHandle::null()),
|
||||
),
|
||||
(CONTRAST_INPUT.to_string(), NodeValue::Float(2.0)),
|
||||
]);
|
||||
let mut table = NodeValueTable::default();
|
||||
behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
|
||||
let Some(NodeValue::Texture(handle)) = table.get(ValueType::Texture) else {
|
||||
panic!("expected a texture-typed value");
|
||||
};
|
||||
let payload =
|
||||
unsafe { crate::handle::get_checked::<crate::jobs::ShaderJobPayload>(handle) }
|
||||
.expect("shader job pushed");
|
||||
assert_eq!(
|
||||
payload.params.get(CONTRAST_INPUT),
|
||||
Some(&NodeValue::Float(2.0))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shader_declares_uniforms_and_avoids_switch() {
|
||||
let code = ColorCorrectNode.shader_code("").unwrap();
|
||||
for uniform in [
|
||||
"tex_in",
|
||||
SATURATION_INPUT,
|
||||
CONTRAST_INPUT,
|
||||
GAMMA_INPUT,
|
||||
GAIN_INPUT,
|
||||
OFFSET_INPUT,
|
||||
] {
|
||||
assert!(code.contains(uniform), "uniform {uniform} declared");
|
||||
}
|
||||
assert!(code.contains("ove_texcoord"));
|
||||
assert!(code.contains("frag_color"));
|
||||
assert!(!code.contains("switch"), "naga rejects GLSL switch");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_clones() {
|
||||
let (core, behavior) = create();
|
||||
let dup = behavior.duplicate(&core).unwrap();
|
||||
assert_eq!(dup.name(), "Color Correct");
|
||||
assert_eq!(dup.type_id(), "org.olivevideoeditor.Olive.colorcorrect");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
// Oak Video Editor - Non-Linear Video Editor
|
||||
// Copyright (C) 2026 Oak Team
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! Color matrix filter (clean-room reimplementation of OpenFX
|
||||
//! `net.sf.openfx.ColorMatrixPlugin`; `ofx-misc` used for parameter
|
||||
//! semantics only, no code copied).
|
||||
|
||||
use crate::factory::NodeMeta;
|
||||
use crate::jobs::ShaderJobPayload;
|
||||
use crate::node::{Category, NodeBehavior, NodeCore};
|
||||
|
||||
/// Texture input id. Type: texture; flags: not-keyframable; this is the
|
||||
/// node's effect input.
|
||||
pub const TEXTURE_INPUT: &str = "tex_in";
|
||||
|
||||
/// The sixteen matrix input ids (C++ OFX exposes one matrix parameter;
|
||||
/// Olive has no matrix input widget, so the 4x4 matrix arrives as
|
||||
/// sixteen scalar inputs). Row-major: `m0..m3` are the weights feeding
|
||||
/// the output red channel from the input R, G, B, A; `m4..m7` the green
|
||||
/// channel; `m8..m11` blue; `m12..m15` alpha. Typed as pairs of indices
|
||||
/// by [`MATRIX_LABELS`].
|
||||
pub const MATRIX_INPUTS: [&str; 16] = [
|
||||
"m0", "m1", "m2", "m3", "m4", "m5", "m6", "m7", "m8", "m9", "m10", "m11", "m12", "m13",
|
||||
"m14", "m15",
|
||||
];
|
||||
|
||||
/// Human-readable label per [`MATRIX_INPUTS`] entry: the output channel
|
||||
/// before the arrow, the input channel after it. The diagonal (m0, m5,
|
||||
/// m10, m15) is `1.0`, every other element `0.0` — the identity matrix.
|
||||
pub const MATRIX_LABELS: [&str; 16] = [
|
||||
"R <- R", "R <- G", "R <- B", "R <- A", "G <- R", "G <- G", "G <- B", "G <- A", "B <- R",
|
||||
"B <- G", "B <- B", "B <- A", "A <- R", "A <- G", "A <- B", "A <- A",
|
||||
];
|
||||
|
||||
/// Matrix uniform name. Not a node input: the `value()` hook inserts the
|
||||
/// packed matrix into the job params row under this key (the
|
||||
/// `transform_in` precedent in `transformdistortnode.rs`). `resolution_in`
|
||||
/// is unused — the filter is coordinate-independent.
|
||||
pub const MATRIX_UNIFORM: &str = "matrix_in";
|
||||
|
||||
/// Color matrix filter node. Multiplies the RGBA vector by a 4x4 matrix.
|
||||
pub struct ColorMatrixNode;
|
||||
|
||||
/// Fragment shader: `NodeValue::Matrix` is row-major and the runner
|
||||
/// transposes it into GLSL's column-major layout, so `matrix_in * color`
|
||||
/// is the row-major matrix times the column vector — output channel `c`
|
||||
/// is `sum_j m[c * 4 + j] * color[j]`.
|
||||
const SHADER_FRAG: &str = r#"uniform sampler2D tex_in;
|
||||
uniform mat4 matrix_in;
|
||||
|
||||
in vec2 ove_texcoord;
|
||||
out vec4 frag_color;
|
||||
|
||||
void main() {
|
||||
frag_color = matrix_in * texture(tex_in, ove_texcoord);
|
||||
}
|
||||
"#;
|
||||
|
||||
impl ColorMatrixNode {
|
||||
/// Fragment shader for any request (this node has a single variant).
|
||||
fn shader_frag() -> &'static str {
|
||||
SHADER_FRAG
|
||||
}
|
||||
}
|
||||
|
||||
impl NodeBehavior for ColorMatrixNode {
|
||||
/// Human-readable name.
|
||||
fn name(&self) -> &str {
|
||||
"Color Matrix"
|
||||
}
|
||||
|
||||
/// Stable type id.
|
||||
fn type_id(&self) -> &str {
|
||||
"org.olivevideoeditor.Olive.colormatrix"
|
||||
}
|
||||
|
||||
/// Categories.
|
||||
fn categories(&self) -> &[Category] {
|
||||
&[Category::Color]
|
||||
}
|
||||
|
||||
/// Description.
|
||||
fn description(&self) -> &str {
|
||||
"Multiply a video's RGBA channels by a 4x4 matrix."
|
||||
}
|
||||
|
||||
/// Localized input names: `tex_in` -> "Input", `mN` ->
|
||||
/// "OUT <- IN" (see [`MATRIX_LABELS`]).
|
||||
fn input_name<'a>(&self, id: &'a str) -> &'a str {
|
||||
if id == TEXTURE_INPUT {
|
||||
return "Input";
|
||||
}
|
||||
MATRIX_INPUTS
|
||||
.iter()
|
||||
.position(|m| *m == id)
|
||||
.map(|i| MATRIX_LABELS[i])
|
||||
.unwrap_or(id)
|
||||
}
|
||||
|
||||
/// Evaluate outputs: no texture -> push nothing; otherwise push a
|
||||
/// shader job carrying the matrix as the `matrix_in` uniform. Unlike
|
||||
/// the identity cases elsewhere there is no pass-through shortcut:
|
||||
/// the identity matrix is what the shader computes anyway, and a
|
||||
/// shortcut would skip the (tested) GPU pass.
|
||||
fn value(
|
||||
&self,
|
||||
core: &NodeCore,
|
||||
inputs: &crate::value::NodeValueRow,
|
||||
time: oak_core::Rational,
|
||||
table: &mut crate::value::NodeValueTable,
|
||||
) {
|
||||
use crate::value::{NodeValue, ValueType};
|
||||
|
||||
match inputs.get(TEXTURE_INPUT) {
|
||||
Some(NodeValue::Texture(_)) => {}
|
||||
_ => return,
|
||||
}
|
||||
|
||||
let mut matrix = [0.0f64; 16];
|
||||
for (i, id) in MATRIX_INPUTS.iter().enumerate() {
|
||||
matrix[i] = match inputs.get(*id) {
|
||||
Some(v) => v.to_double(),
|
||||
None => core.value_at_time(id, -1, time).to_double(),
|
||||
};
|
||||
}
|
||||
|
||||
let mut params = inputs.clone();
|
||||
params.insert(MATRIX_UNIFORM.to_string(), NodeValue::Matrix(matrix));
|
||||
|
||||
table.push(
|
||||
ValueType::Texture,
|
||||
NodeValue::Texture(crate::handle::make_owned(ShaderJobPayload {
|
||||
node_id: crate::id::NodeId::INVALID,
|
||||
time,
|
||||
iterations: 1,
|
||||
type_id: self.type_id().to_string(),
|
||||
shader_id: String::new(),
|
||||
effect_input: core.effect_input.clone(),
|
||||
params,
|
||||
iterative_input: String::new(),
|
||||
})),
|
||||
None,
|
||||
);
|
||||
}
|
||||
|
||||
/// Shader code request: always the one fragment source.
|
||||
fn shader_code(&self, _request: &str) -> Option<String> {
|
||||
Some(Self::shader_frag().to_string())
|
||||
}
|
||||
|
||||
/// Deep copy.
|
||||
fn duplicate(&self, _core: &NodeCore) -> Option<Box<dyn NodeBehavior>> {
|
||||
Some(Box::new(ColorMatrixNode))
|
||||
}
|
||||
}
|
||||
|
||||
/// Constructor: adds `tex_in` plus the sixteen matrix inputs (identity
|
||||
/// defaults), 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);
|
||||
|
||||
for (i, id) in MATRIX_INPUTS.iter().enumerate() {
|
||||
// The identity diagonal: m0, m5, m10, m15.
|
||||
let default = if i % 5 == 0 { 1.0 } else { 0.0 };
|
||||
let mut input = crate::input::Input::new(
|
||||
id,
|
||||
crate::value::ValueType::Float,
|
||||
crate::value::NodeValue::Float(default),
|
||||
);
|
||||
input.flags |= crate::input::flags::NOT_KEYFRAMABLE;
|
||||
core.add_input(input);
|
||||
}
|
||||
|
||||
core.flags |= crate::node::flags::VIDEO_EFFECT;
|
||||
core.effect_input = TEXTURE_INPUT.to_string();
|
||||
|
||||
(core, Box::new(ColorMatrixNode))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
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 = ColorMatrixNode;
|
||||
assert_eq!(n.input_name(TEXTURE_INPUT), "Input");
|
||||
assert_eq!(n.input_name("m0"), "R <- R");
|
||||
assert_eq!(n.input_name("m15"), "A <- A");
|
||||
assert_eq!(n.input_name("other"), "other");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_wires_inputs_and_flags() {
|
||||
let (core, behavior) = create();
|
||||
assert_eq!(behavior.type_id(), "org.olivevideoeditor.Olive.colormatrix");
|
||||
assert_eq!(behavior.categories(), &[Category::Color]);
|
||||
assert_eq!(core.effect_input, TEXTURE_INPUT);
|
||||
assert_ne!(core.flags & crate::node::flags::VIDEO_EFFECT, 0);
|
||||
assert_eq!(core.get_input(TEXTURE_INPUT).unwrap().value_type, ValueType::Texture);
|
||||
for (i, id) in MATRIX_INPUTS.iter().enumerate() {
|
||||
let input = core.get_input(id).expect("matrix input registered");
|
||||
assert_eq!(input.value_type, ValueType::Float);
|
||||
let expected = if i % 5 == 0 { 1.0 } else { 0.0 };
|
||||
assert_eq!(input.default, NodeValue::Float(expected), "{id} default");
|
||||
}
|
||||
}
|
||||
|
||||
#[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_pushes_identity_matrix_by_default() {
|
||||
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);
|
||||
let Some(NodeValue::Texture(h)) = table.get(ValueType::Texture) else {
|
||||
panic!("shader job expected");
|
||||
};
|
||||
let payload = unsafe { crate::handle::get_checked::<ShaderJobPayload>(h) }
|
||||
.expect("shader job payload boxed");
|
||||
let mut identity = [0.0f64; 16];
|
||||
for i in 0..4 {
|
||||
identity[i * 5] = 1.0;
|
||||
}
|
||||
assert_eq!(
|
||||
payload.params.get(MATRIX_UNIFORM),
|
||||
Some(&NodeValue::Matrix(identity))
|
||||
);
|
||||
assert_eq!(payload.type_id, "org.olivevideoeditor.Olive.colormatrix");
|
||||
assert_eq!(payload.shader_id, "");
|
||||
assert_eq!(payload.effect_input, TEXTURE_INPUT);
|
||||
assert_eq!(payload.iterations, 1);
|
||||
assert!(payload.params.contains_key(TEXTURE_INPUT));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_packs_row_major_matrix_from_row() {
|
||||
let (core, behavior) = create();
|
||||
// Red -> green, green -> red, blue and alpha held.
|
||||
let swap = [0.0, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0,
|
||||
1.0];
|
||||
let mut inputs = crate::value::NodeValueRow::from([(TEXTURE_INPUT.to_string(), tex())]);
|
||||
for (i, id) in MATRIX_INPUTS.iter().enumerate() {
|
||||
inputs.insert(id.to_string(), NodeValue::Float(swap[i]));
|
||||
}
|
||||
let mut table = NodeValueTable::default();
|
||||
behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
|
||||
let Some(NodeValue::Texture(h)) = table.get(ValueType::Texture) else {
|
||||
panic!("shader job expected");
|
||||
};
|
||||
let payload = unsafe { crate::handle::get_checked::<ShaderJobPayload>(h) }
|
||||
.expect("shader job payload boxed");
|
||||
assert_eq!(
|
||||
payload.params.get(MATRIX_UNIFORM),
|
||||
Some(&NodeValue::Matrix(swap))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_reads_matrix_from_core_when_absent_from_row() {
|
||||
let (mut core, behavior) = create();
|
||||
core.set_standard_value("m1", -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);
|
||||
let Some(NodeValue::Texture(h)) = table.get(ValueType::Texture) else {
|
||||
panic!("shader job expected");
|
||||
};
|
||||
let payload = unsafe { crate::handle::get_checked::<ShaderJobPayload>(h) }
|
||||
.expect("shader job payload boxed");
|
||||
let Some(NodeValue::Matrix(m)) = payload.params.get(MATRIX_UNIFORM) else {
|
||||
panic!("matrix uniform expected");
|
||||
};
|
||||
assert_eq!(m[1], 0.25);
|
||||
assert_eq!(m[0], 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shader_code_declares_matrix_uniform() {
|
||||
let n = ColorMatrixNode;
|
||||
let glsl = n.shader_code("").unwrap();
|
||||
assert!(glsl.contains("uniform mat4 matrix_in;"));
|
||||
assert!(glsl.contains("frag_color = matrix_in * texture(tex_in, ove_texcoord);"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_clones_behavior() {
|
||||
let (core, behavior) = create();
|
||||
let dup = behavior.duplicate(&core).unwrap();
|
||||
assert_eq!(dup.name(), "Color Matrix");
|
||||
}
|
||||
}
|
||||
|
||||
/// Register this node type.
|
||||
pub fn register(meta: &mut Vec<NodeMeta>) {
|
||||
meta.push(NodeMeta {
|
||||
type_id: "org.olivevideoeditor.Olive.colormatrix",
|
||||
name: "Color Matrix",
|
||||
categories: &[Category::Color],
|
||||
create,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
// 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/>.
|
||||
|
||||
//! Dilate filter (clean-room reimplementation of the CImg `Dilate`/
|
||||
//! `net.sf.cimg.CImgDilate` effect; `ofx-misc` used for parameter
|
||||
//! semantics only, no code copied).
|
||||
|
||||
use crate::factory::NodeMeta;
|
||||
use crate::jobs::ShaderJobPayload;
|
||||
use crate::node::{Category, NodeBehavior, NodeCore};
|
||||
|
||||
/// Texture input id. Type: texture; flags: not-keyframable; this is the
|
||||
/// node's effect input.
|
||||
pub const TEXTURE_INPUT: &str = "tex_in";
|
||||
|
||||
/// Radius input id. Type: float; default `1.0`; properties: `min = 0.0`,
|
||||
/// `max = 7.0`. The structuring element is a square of `2 * radius + 1`
|
||||
/// pixels on a side, in pixels.
|
||||
pub const RADIUS_INPUT: &str = "radius_in";
|
||||
|
||||
/// Largest supported radius. The shader clamps to this; the neighborhood
|
||||
/// is `15 x 15` pixels, sampled in a single pass (see [`SHADER_FRAG`]).
|
||||
pub const MAX_RADIUS: f64 = 7.0;
|
||||
|
||||
/// Dilate filter node. Morphological maximum over a square neighborhood.
|
||||
pub struct DilateNode;
|
||||
|
||||
/// Fragment shader: one pass sampling the full `(2r+1)^2` neighborhood
|
||||
/// and taking the per-channel maximum. `iterations` stays 1 — a single
|
||||
/// wide pass is mathematically identical to `r` 3x3 iterations for a
|
||||
/// square structuring element (max is associative and idempotent), and
|
||||
/// avoids the ping-pong feedback textures entirely. `radius_in` is
|
||||
/// rounded to the nearest integer and clamped to `0..=7`; offsets are
|
||||
/// pixel-space texel steps (`resolution_in`, texel centers), with
|
||||
/// edge-of-frame taps clamping to the border pixel.
|
||||
const SHADER_FRAG: &str = r#"uniform sampler2D tex_in;
|
||||
uniform float radius_in;
|
||||
uniform vec2 resolution_in;
|
||||
|
||||
in vec2 ove_texcoord;
|
||||
out vec4 frag_color;
|
||||
|
||||
void main() {
|
||||
int radius = int(clamp(radius_in, 0.0, 7.0) + 0.5);
|
||||
vec2 texel = vec2(1.0) / resolution_in;
|
||||
|
||||
vec4 acc = texture(tex_in, ove_texcoord);
|
||||
for (int dy = -radius; dy <= radius; ++dy) {
|
||||
for (int dx = -radius; dx <= radius; ++dx) {
|
||||
vec2 uv = ove_texcoord + vec2(float(dx), float(dy)) * texel;
|
||||
acc = max(acc, texture(tex_in, uv));
|
||||
}
|
||||
}
|
||||
|
||||
frag_color = acc;
|
||||
}
|
||||
"#;
|
||||
|
||||
impl DilateNode {
|
||||
/// Fragment shader for any request (this node has a single variant).
|
||||
fn shader_frag() -> &'static str {
|
||||
SHADER_FRAG
|
||||
}
|
||||
}
|
||||
|
||||
impl NodeBehavior for DilateNode {
|
||||
/// Human-readable name.
|
||||
fn name(&self) -> &str {
|
||||
"Dilate"
|
||||
}
|
||||
|
||||
/// Stable type id.
|
||||
fn type_id(&self) -> &str {
|
||||
"org.olivevideoeditor.Olive.dilate"
|
||||
}
|
||||
|
||||
/// Categories.
|
||||
fn categories(&self) -> &[Category] {
|
||||
&[Category::Filter]
|
||||
}
|
||||
|
||||
/// Description.
|
||||
fn description(&self) -> &str {
|
||||
"Expand bright areas by taking the maximum over a square neighborhood."
|
||||
}
|
||||
|
||||
/// Localized input names: `tex_in` -> "Input", `radius_in` ->
|
||||
/// "Radius".
|
||||
fn input_name<'a>(&self, id: &'a str) -> &'a str {
|
||||
match id {
|
||||
TEXTURE_INPUT => "Input",
|
||||
RADIUS_INPUT => "Radius",
|
||||
_ => id,
|
||||
}
|
||||
}
|
||||
|
||||
/// Evaluate outputs: no texture -> push nothing; radius <= 0 -> push
|
||||
/// the input texture unchanged (the same no-work shortcut as
|
||||
/// `blur.rs`'s `can_push_job`); otherwise push the single-pass
|
||||
/// neighborhood shader job.
|
||||
fn value(
|
||||
&self,
|
||||
core: &NodeCore,
|
||||
inputs: &crate::value::NodeValueRow,
|
||||
time: oak_core::Rational,
|
||||
table: &mut crate::value::NodeValueTable,
|
||||
) {
|
||||
use crate::value::{NodeValue, ValueType};
|
||||
|
||||
let tex = match inputs.get(TEXTURE_INPUT) {
|
||||
Some(tex @ 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(),
|
||||
};
|
||||
|
||||
if radius <= 0.0 {
|
||||
table.push(ValueType::Texture, tex, None);
|
||||
return;
|
||||
}
|
||||
|
||||
let mut params = inputs.clone();
|
||||
params.insert(RADIUS_INPUT.to_string(), NodeValue::Float(radius));
|
||||
|
||||
table.push(
|
||||
ValueType::Texture,
|
||||
NodeValue::Texture(crate::handle::make_owned(ShaderJobPayload {
|
||||
node_id: crate::id::NodeId::INVALID,
|
||||
time,
|
||||
iterations: 1,
|
||||
type_id: self.type_id().to_string(),
|
||||
shader_id: String::new(),
|
||||
effect_input: core.effect_input.clone(),
|
||||
params,
|
||||
iterative_input: String::new(),
|
||||
})),
|
||||
None,
|
||||
);
|
||||
}
|
||||
|
||||
/// Shader code request: always the one fragment source.
|
||||
fn shader_code(&self, _request: &str) -> Option<String> {
|
||||
Some(Self::shader_frag().to_string())
|
||||
}
|
||||
|
||||
/// Deep copy.
|
||||
fn duplicate(&self, _core: &NodeCore) -> Option<Box<dyn NodeBehavior>> {
|
||||
Some(Box::new(DilateNode))
|
||||
}
|
||||
}
|
||||
|
||||
/// Constructor: adds `tex_in` and `radius_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 radius = crate::input::Input::new(
|
||||
RADIUS_INPUT,
|
||||
crate::value::ValueType::Float,
|
||||
crate::value::NodeValue::Float(1.0),
|
||||
);
|
||||
radius.properties = vec![
|
||||
("min".to_string(), crate::value::NodeValue::Float(0.0)),
|
||||
("max".to_string(), crate::value::NodeValue::Float(MAX_RADIUS)),
|
||||
];
|
||||
core.add_input(radius);
|
||||
|
||||
core.flags |= crate::node::flags::VIDEO_EFFECT;
|
||||
core.effect_input = TEXTURE_INPUT.to_string();
|
||||
|
||||
(core, Box::new(DilateNode))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
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 = DilateNode;
|
||||
assert_eq!(n.input_name(TEXTURE_INPUT), "Input");
|
||||
assert_eq!(n.input_name(RADIUS_INPUT), "Radius");
|
||||
assert_eq!(n.input_name("other"), "other");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_wires_inputs_and_flags() {
|
||||
let (core, behavior) = create();
|
||||
assert_eq!(behavior.type_id(), "org.olivevideoeditor.Olive.dilate");
|
||||
assert_eq!(behavior.categories(), &[Category::Filter]);
|
||||
assert_eq!(core.effect_input, TEXTURE_INPUT);
|
||||
assert_ne!(core.flags & crate::node::flags::VIDEO_EFFECT, 0);
|
||||
let radius = core.get_input(RADIUS_INPUT).expect("radius input");
|
||||
assert_eq!(radius.default, NodeValue::Float(1.0));
|
||||
assert!(radius
|
||||
.properties
|
||||
.iter()
|
||||
.any(|(k, v)| k == "min" && *v == NodeValue::Float(0.0)));
|
||||
assert!(radius
|
||||
.properties
|
||||
.iter()
|
||||
.any(|(k, v)| k == "max" && *v == NodeValue::Float(MAX_RADIUS)));
|
||||
}
|
||||
|
||||
#[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_through() {
|
||||
let (core, behavior) = create();
|
||||
let tex = tex();
|
||||
let inputs = crate::value::NodeValueRow::from([
|
||||
(TEXTURE_INPUT.to_string(), tex.clone()),
|
||||
(RADIUS_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::Texture), Some(&tex));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_pushes_single_pass_job() {
|
||||
let (core, behavior) = create();
|
||||
let inputs = crate::value::NodeValueRow::from([
|
||||
(TEXTURE_INPUT.to_string(), tex()),
|
||||
(RADIUS_INPUT.to_string(), NodeValue::Float(2.0)),
|
||||
]);
|
||||
let mut table = NodeValueTable::default();
|
||||
behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
|
||||
let Some(NodeValue::Texture(h)) = table.get(ValueType::Texture) else {
|
||||
panic!("shader job expected");
|
||||
};
|
||||
let payload = unsafe { crate::handle::get_checked::<ShaderJobPayload>(h) }
|
||||
.expect("shader job payload boxed");
|
||||
assert_eq!(payload.type_id, "org.olivevideoeditor.Olive.dilate");
|
||||
assert_eq!(payload.shader_id, "");
|
||||
assert_eq!(payload.effect_input, TEXTURE_INPUT);
|
||||
assert_eq!(payload.iterations, 1);
|
||||
assert_eq!(payload.iterative_input, "");
|
||||
assert_eq!(
|
||||
payload.params.get(RADIUS_INPUT),
|
||||
Some(&NodeValue::Float(2.0))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_default_radius_pushes_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);
|
||||
let Some(NodeValue::Texture(h)) = table.get(ValueType::Texture) else {
|
||||
panic!("shader job expected");
|
||||
};
|
||||
let payload = unsafe { crate::handle::get_checked::<ShaderJobPayload>(h) }
|
||||
.expect("shader job payload boxed");
|
||||
assert_eq!(
|
||||
payload.params.get(RADIUS_INPUT),
|
||||
Some(&NodeValue::Float(1.0))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shader_code_is_single_pass_max_without_switch() {
|
||||
let n = DilateNode;
|
||||
let glsl = n.shader_code("").unwrap();
|
||||
assert!(glsl.contains("uniform vec2 resolution_in;"));
|
||||
assert!(glsl.contains("acc = max(acc, texture(tex_in, uv));"));
|
||||
assert!(glsl.contains("int(clamp(radius_in, 0.0, 7.0) + 0.5)"));
|
||||
assert!(!glsl.contains("switch"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_clones_behavior() {
|
||||
let (core, behavior) = create();
|
||||
let dup = behavior.duplicate(&core).unwrap();
|
||||
assert_eq!(dup.name(), "Dilate");
|
||||
}
|
||||
}
|
||||
|
||||
/// Register this node type.
|
||||
pub fn register(meta: &mut Vec<NodeMeta>) {
|
||||
meta.push(NodeMeta {
|
||||
type_id: "org.olivevideoeditor.Olive.dilate",
|
||||
name: "Dilate",
|
||||
categories: &[Category::Filter],
|
||||
create,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,404 @@
|
||||
// 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/>.
|
||||
|
||||
//! Directional blur filter (clean-room reimplementation of the
|
||||
//! OpenFX-Misc `DirBlurOFX` / `net.sf.openfx.DirBlur` effect; `ofx-misc`
|
||||
//! used for parameter semantics only, no code copied).
|
||||
//!
|
||||
//! Upstream blurs by concatenating a per-frame transform, i.e. by
|
||||
//! resampling the image along the motion direction with an
|
||||
//! `amount`-pixel smear and a `centered`/`fading` shaping of the tap
|
||||
//! weights. The same picture is produced here by one fragment pass that
|
||||
//! averages a fixed number of taps along the direction vector, centered
|
||||
//! on the pixel (a box-shaped, non-fading smear — the `centered = true`,
|
||||
//! `fading = 0` case).
|
||||
|
||||
use crate::factory::NodeMeta;
|
||||
use crate::jobs::ShaderJobPayload;
|
||||
use crate::node::{Category, NodeBehavior, NodeCore};
|
||||
|
||||
/// Texture input id. Type: texture; flags: not-keyframable; this is the
|
||||
/// node's effect input.
|
||||
pub const TEXTURE_INPUT: &str = "tex_in";
|
||||
|
||||
/// Blur radius input id (upstream `dirBlurAmount`, "Amount"). Type:
|
||||
/// float; default `0.0`; properties: `min = 0.0`. The half-length of
|
||||
/// the smear in sequence pixels: each tap lies within `amount_in`
|
||||
/// pixels of the pixel along the direction vector.
|
||||
pub const AMOUNT_INPUT: &str = "amount_in";
|
||||
|
||||
/// Blur direction input id (upstream `dirBlurAngle`/"Angle" on top of
|
||||
/// the transform concatenation). Type: float; default `0.0`; degrees
|
||||
/// counter-clockwise from the +x axis in the un-flipped image frame (as
|
||||
/// in [`crate::nodes::blur`]'s `directional_degrees_in`, y grows down the
|
||||
/// frame, so a growing angle rotates the smear clockwise on screen).
|
||||
pub const ANGLE_INPUT: &str = "angle_in";
|
||||
|
||||
/// Directional blur node. Averages 16 taps evenly spaced along the
|
||||
/// direction vector, centered on the pixel.
|
||||
pub struct DirBlurNode;
|
||||
|
||||
/// Number of taps averaged per pixel. Fixed (upstream has no tap-count
|
||||
/// knob either): a power-of-two constant keeps the division exact and
|
||||
/// the shader otherwise branch-free.
|
||||
const TAP_COUNT: i32 = 16;
|
||||
|
||||
/// Fragment shader: one direction vector from `angle_in`, then a
|
||||
/// constant-count loop averaging the taps. The tap spacing collapses to
|
||||
/// zero when `amount_in` is zero, so a zero amount is an exact
|
||||
/// identity without a branch. Offsets are pixels (`resolution_in`,
|
||||
/// auto-filled by the renderer from the effect input's size, or from
|
||||
/// the sequence square resolution on the real render path — the amount
|
||||
/// is a sequence-pixel value, matching the blur node's radius); the
|
||||
/// sampler clamps to the border, so taps past the frame edge repeat the
|
||||
/// edge pixel. `switch` is deliberately not used (naga rejects it).
|
||||
const SHADER_FRAG: &str = r#"uniform sampler2D tex_in;
|
||||
uniform float amount_in;
|
||||
uniform float angle_in;
|
||||
uniform vec2 resolution_in;
|
||||
|
||||
in vec2 ove_texcoord;
|
||||
out vec4 frag_color;
|
||||
|
||||
// Taps averaged per pixel (keep in sync with TAP_COUNT).
|
||||
#define TAP_COUNT 16
|
||||
|
||||
// M_PI mirrors the blur node's directional-blur shader (degrees ->
|
||||
// radians without relying on built-in constants).
|
||||
#define M_PI 3.1415926535897932384626433832795
|
||||
|
||||
void main() {
|
||||
float angle = (angle_in * M_PI) / 180.0;
|
||||
vec2 direction = vec2(cos(angle), sin(angle));
|
||||
|
||||
// Distance between neighboring taps: TAP_COUNT taps evenly spread
|
||||
// over the 2 * amount_in pixel-long segment centered on the pixel.
|
||||
vec2 step = direction * (2.0 * amount_in) / (resolution_in * float(TAP_COUNT - 1));
|
||||
|
||||
vec4 sum = vec4(0.0);
|
||||
for (int i = 0; i < TAP_COUNT; ++i) {
|
||||
// Tap offset in [-amount_in, +amount_in] pixels, centered on 0.
|
||||
float t = float(i) - float(TAP_COUNT - 1) * 0.5;
|
||||
sum += texture(tex_in, ove_texcoord + step * t);
|
||||
}
|
||||
|
||||
frag_color = sum / float(TAP_COUNT);
|
||||
}
|
||||
"#;
|
||||
|
||||
impl DirBlurNode {
|
||||
/// Fragment shader for any request (this node has a single variant).
|
||||
fn shader_frag() -> &'static str {
|
||||
SHADER_FRAG
|
||||
}
|
||||
}
|
||||
|
||||
impl NodeBehavior for DirBlurNode {
|
||||
/// Human-readable name.
|
||||
fn name(&self) -> &str {
|
||||
"Directional Blur"
|
||||
}
|
||||
|
||||
/// Stable type id.
|
||||
fn type_id(&self) -> &str {
|
||||
"org.olivevideoeditor.Olive.dirblur"
|
||||
}
|
||||
|
||||
/// Categories.
|
||||
fn categories(&self) -> &[Category] {
|
||||
&[Category::Filter]
|
||||
}
|
||||
|
||||
/// Description.
|
||||
fn description(&self) -> &str {
|
||||
"Blurs an image along a direction."
|
||||
}
|
||||
|
||||
/// Localized input names: `tex_in` -> "Input", `amount_in` ->
|
||||
/// "Amount", `angle_in` -> "Angle".
|
||||
fn input_name<'a>(&self, id: &'a str) -> &'a str {
|
||||
match id {
|
||||
TEXTURE_INPUT => "Input",
|
||||
AMOUNT_INPUT => "Amount",
|
||||
ANGLE_INPUT => "Angle",
|
||||
_ => id,
|
||||
}
|
||||
}
|
||||
|
||||
/// Evaluate outputs: no texture -> push nothing; otherwise push the
|
||||
/// directional-blur shader job.
|
||||
///
|
||||
/// A zero `amount_in` still pushes the job (the shader's tap spacing
|
||||
/// then collapses to zero and the pass is an exact identity) rather
|
||||
/// than passing the input texture through as the blur node does for
|
||||
/// a zero radius: the plan's node template reserves the pass-through
|
||||
/// for the no-texture case, and feeding the input's CPU texture
|
||||
/// downstream here would silently break nodes that expect a GPU
|
||||
/// texture. The resolved amount and angle are written into the job
|
||||
/// row so the uniforms are always present, even when the incoming
|
||||
/// row only carries the effect input.
|
||||
///
|
||||
/// `resolution_in` is filled by the runner from the effect input's
|
||||
/// size (C++ `tex->virtual_resolution()`; see the blur node's
|
||||
/// `// CPP-PARITY: blur.cpp` note).
|
||||
fn value(
|
||||
&self,
|
||||
core: &NodeCore,
|
||||
inputs: &crate::value::NodeValueRow,
|
||||
time: oak_core::Rational,
|
||||
table: &mut crate::value::NodeValueTable,
|
||||
) {
|
||||
use crate::value::{NodeValue, ValueType};
|
||||
|
||||
match inputs.get(TEXTURE_INPUT) {
|
||||
Some(NodeValue::Texture(_)) => {}
|
||||
_ => return,
|
||||
}
|
||||
|
||||
let amount = match inputs.get(AMOUNT_INPUT) {
|
||||
Some(v) => v.to_double(),
|
||||
None => core.value_at_time(AMOUNT_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(),
|
||||
};
|
||||
|
||||
let mut params = inputs.clone();
|
||||
params.insert(AMOUNT_INPUT.to_string(), NodeValue::Float(amount));
|
||||
params.insert(ANGLE_INPUT.to_string(), NodeValue::Float(angle));
|
||||
|
||||
table.push(
|
||||
ValueType::Texture,
|
||||
NodeValue::Texture(crate::handle::make_owned(ShaderJobPayload {
|
||||
node_id: crate::id::NodeId::INVALID,
|
||||
time,
|
||||
iterations: 1,
|
||||
type_id: self.type_id().to_string(),
|
||||
shader_id: String::new(),
|
||||
effect_input: core.effect_input.clone(),
|
||||
params,
|
||||
iterative_input: String::new(),
|
||||
})),
|
||||
None,
|
||||
);
|
||||
}
|
||||
|
||||
/// Shader code request: always the one fragment source.
|
||||
fn shader_code(&self, _request: &str) -> Option<String> {
|
||||
Some(Self::shader_frag().to_string())
|
||||
}
|
||||
|
||||
/// Deep copy.
|
||||
fn duplicate(&self, _core: &NodeCore) -> Option<Box<dyn NodeBehavior>> {
|
||||
Some(Box::new(DirBlurNode))
|
||||
}
|
||||
}
|
||||
|
||||
/// Constructor: adds `tex_in`, `amount_in` and `angle_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 amount = crate::input::Input::new(
|
||||
AMOUNT_INPUT,
|
||||
crate::value::ValueType::Float,
|
||||
crate::value::NodeValue::Float(0.0),
|
||||
);
|
||||
amount.properties = vec![("min".to_string(), crate::value::NodeValue::Float(0.0))];
|
||||
core.add_input(amount);
|
||||
|
||||
core.add_input(crate::input::Input::new(
|
||||
ANGLE_INPUT,
|
||||
crate::value::ValueType::Float,
|
||||
crate::value::NodeValue::Float(0.0),
|
||||
));
|
||||
|
||||
core.flags |= crate::node::flags::VIDEO_EFFECT;
|
||||
core.effect_input = TEXTURE_INPUT.to_string();
|
||||
|
||||
(core, Box::new(DirBlurNode))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::value::{NodeValue, NodeValueTable, ValueType};
|
||||
use oak_core::Rational;
|
||||
|
||||
fn tex() -> NodeValue {
|
||||
NodeValue::Texture(crate::handle::CHandle::null())
|
||||
}
|
||||
|
||||
fn run(core: &NodeCore, inputs: &crate::value::NodeValueRow) -> ShaderJobPayload {
|
||||
let behavior = DirBlurNode;
|
||||
let mut table = NodeValueTable::default();
|
||||
behavior.value(core, inputs, Rational::new(0, 1), &mut table);
|
||||
let Some(NodeValue::Texture(h)) = table.get(ValueType::Texture) else {
|
||||
panic!("shader job expected");
|
||||
};
|
||||
unsafe { crate::handle::get_checked::<ShaderJobPayload>(h) }
|
||||
.expect("shader job payload boxed")
|
||||
.clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn input_names() {
|
||||
let n = DirBlurNode;
|
||||
assert_eq!(n.input_name(TEXTURE_INPUT), "Input");
|
||||
assert_eq!(n.input_name(AMOUNT_INPUT), "Amount");
|
||||
assert_eq!(n.input_name(ANGLE_INPUT), "Angle");
|
||||
assert_eq!(n.input_name("other"), "other");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_wires_inputs_and_flags() {
|
||||
let (core, behavior) = create();
|
||||
assert_eq!(behavior.type_id(), "org.olivevideoeditor.Olive.dirblur");
|
||||
assert_eq!(behavior.name(), "Directional Blur");
|
||||
assert_eq!(behavior.categories(), &[Category::Filter]);
|
||||
assert_eq!(core.effect_input, TEXTURE_INPUT);
|
||||
assert_ne!(core.flags & crate::node::flags::VIDEO_EFFECT, 0);
|
||||
|
||||
let amount = core.get_input(AMOUNT_INPUT).expect("amount input");
|
||||
assert_eq!(amount.default, NodeValue::Float(0.0));
|
||||
assert!(amount
|
||||
.properties
|
||||
.iter()
|
||||
.any(|(k, v)| k == "min" && *v == NodeValue::Float(0.0)));
|
||||
|
||||
let angle = core.get_input(ANGLE_INPUT).expect("angle input");
|
||||
assert_eq!(angle.default, NodeValue::Float(0.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_amount_still_pushes_job_with_defaults() {
|
||||
let (core, _) = create();
|
||||
let inputs = crate::value::NodeValueRow::from([(TEXTURE_INPUT.to_string(), tex())]);
|
||||
let payload = run(&core, &inputs);
|
||||
assert_eq!(payload.type_id, "org.olivevideoeditor.Olive.dirblur");
|
||||
assert_eq!(payload.shader_id, "");
|
||||
assert_eq!(payload.effect_input, TEXTURE_INPUT);
|
||||
assert_eq!(payload.iterations, 1);
|
||||
assert_eq!(payload.iterative_input, "");
|
||||
assert_eq!(
|
||||
payload.params.get(AMOUNT_INPUT),
|
||||
Some(&NodeValue::Float(0.0)),
|
||||
"the blur amount rides in the job params"
|
||||
);
|
||||
assert_eq!(
|
||||
payload.params.get(ANGLE_INPUT),
|
||||
Some(&NodeValue::Float(0.0))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_takes_params_from_row() {
|
||||
let (core, _) = create();
|
||||
let inputs = crate::value::NodeValueRow::from([
|
||||
(TEXTURE_INPUT.to_string(), tex()),
|
||||
(AMOUNT_INPUT.to_string(), NodeValue::Float(12.0)),
|
||||
(ANGLE_INPUT.to_string(), NodeValue::Float(90.0)),
|
||||
]);
|
||||
let payload = run(&core, &inputs);
|
||||
assert_eq!(
|
||||
payload.params.get(AMOUNT_INPUT),
|
||||
Some(&NodeValue::Float(12.0))
|
||||
);
|
||||
assert_eq!(
|
||||
payload.params.get(ANGLE_INPUT),
|
||||
Some(&NodeValue::Float(90.0))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_takes_params_from_core() {
|
||||
let (mut core, _) = create();
|
||||
core.set_standard_value(AMOUNT_INPUT, -1, NodeValue::Float(4.0));
|
||||
core.set_standard_value(ANGLE_INPUT, -1, NodeValue::Float(-45.0));
|
||||
let inputs = crate::value::NodeValueRow::from([(TEXTURE_INPUT.to_string(), tex())]);
|
||||
let payload = run(&core, &inputs);
|
||||
assert_eq!(
|
||||
payload.params.get(AMOUNT_INPUT),
|
||||
Some(&NodeValue::Float(4.0))
|
||||
);
|
||||
assert_eq!(
|
||||
payload.params.get(ANGLE_INPUT),
|
||||
Some(&NodeValue::Float(-45.0))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shader_code_is_centered_tap_average_without_switch() {
|
||||
let n = DirBlurNode;
|
||||
let glsl = n.shader_code("").unwrap();
|
||||
assert!(glsl.contains("uniform float amount_in;"));
|
||||
assert!(glsl.contains("uniform float angle_in;"));
|
||||
assert!(glsl.contains("uniform vec2 resolution_in;"));
|
||||
assert!(glsl.contains("#define TAP_COUNT 16"));
|
||||
assert!(glsl.contains("float(TAP_COUNT - 1) * 0.5"));
|
||||
assert!(glsl.contains("frag_color = sum / float(TAP_COUNT);"));
|
||||
assert!(!glsl.contains("switch"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tap_count_matches_shader() {
|
||||
let glsl = DirBlurNode::shader_frag();
|
||||
assert!(
|
||||
glsl.contains(&format!("#define TAP_COUNT {TAP_COUNT}")),
|
||||
"the shader and the Rust-side tap count must agree"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_clones_behavior() {
|
||||
let (core, behavior) = create();
|
||||
let dup = behavior.duplicate(&core).unwrap();
|
||||
assert_eq!(dup.name(), "Directional Blur");
|
||||
}
|
||||
}
|
||||
|
||||
/// Register this node type.
|
||||
pub fn register(meta: &mut Vec<NodeMeta>) {
|
||||
meta.push(NodeMeta {
|
||||
type_id: "org.olivevideoeditor.Olive.dirblur",
|
||||
name: "Directional Blur",
|
||||
categories: &[Category::Filter],
|
||||
create,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,414 @@
|
||||
// Oak Video Editor - Non-Linear Video Editor
|
||||
// Copyright (C) 2026 Oak Team
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! Dissolve effect (clean-room reimplementation of the OpenFX-Misc
|
||||
//! `DissolveOFX` / `net.sf.openfx.DissolvePlugin`; the `Dissolve`
|
||||
//! reference tree is used for parameter semantics only, no code copied).
|
||||
//!
|
||||
//! Upstream cross-fades a stack of inputs with a single `which` mix
|
||||
//! factor — Natron folds a many-input dissolve into one effect, and for
|
||||
//! two inputs that per-pixel result is the weighted average
|
||||
//! `(1 - mix) * a + mix * b`. This node implements exactly that: two
|
||||
//! texture inputs and a `mix_in` factor, so it is the atomic piece a
|
||||
//! transition would be built from.
|
||||
//!
|
||||
//! Like [`crate::nodes::merge`], the node never sets a `core.effect_input`;
|
||||
//! the both-present case boxes one [`ShaderJobPayload`] whose params row
|
||||
//! carries both textures, and the renderer binds `tex_in`/`blend_in` by
|
||||
//! name (the pass size follows the first bound texture — `blend_in`, the
|
||||
//! alphabetically first key of the job's `BTreeMap` row).
|
||||
|
||||
use crate::factory::NodeMeta;
|
||||
use crate::jobs::ShaderJobPayload;
|
||||
use crate::node::{Category, NodeBehavior, NodeCore};
|
||||
|
||||
/// First ("from") texture input id. Type: texture; flags:
|
||||
/// not-keyframable.
|
||||
pub const TEXTURE_INPUT: &str = "tex_in";
|
||||
|
||||
/// Second ("to") texture input id. Type: texture; flags:
|
||||
/// not-keyframable.
|
||||
pub const BLEND_INPUT: &str = "blend_in";
|
||||
|
||||
/// Mix factor input id (upstream `which`, "Which"). Type: float; default
|
||||
/// `0.5`; properties: `min = 0.0`, `max = 1.0`, `view = percentage`.
|
||||
/// `0.0` yields `tex_in`, `1.0` yields `blend_in`. Upstream leaves its
|
||||
/// default at OFX's `0.0` because `which` is an input *index* there;
|
||||
/// with two inputs a neutral 50/50 dissolve is the more useful default
|
||||
/// (`// CPP-PARITY: Dissolve.cpp` `describeInContext`).
|
||||
pub const MIX_INPUT: &str = "mix_in";
|
||||
|
||||
/// Dissolve node. Unit-like: there is no per-instance state.
|
||||
pub struct DissolveNode;
|
||||
|
||||
/// Fragment shader: cross-fade the two inputs by `mix_in`. The
|
||||
/// `_enabled` flags mirror the alpha-over shader's convention for
|
||||
/// unbound samplers (the renderer fills them in from the job's bound
|
||||
/// textures; `run_effect`'s first-sampler fallback cannot misfire here
|
||||
/// because `value()` passes the missing input through on the CPU side
|
||||
/// instead of pushing a job). `switch` is deliberately not used (naga
|
||||
/// rejects it).
|
||||
const SHADER_FRAG: &str = r#"uniform sampler2D tex_in;
|
||||
uniform sampler2D blend_in;
|
||||
uniform bool tex_in_enabled;
|
||||
uniform bool blend_in_enabled;
|
||||
uniform float mix_in;
|
||||
|
||||
in vec2 ove_texcoord;
|
||||
out vec4 frag_color;
|
||||
|
||||
void main(void) {
|
||||
if (!tex_in_enabled && !blend_in_enabled) {
|
||||
frag_color = vec4(0.0);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!tex_in_enabled) {
|
||||
frag_color = texture(blend_in, ove_texcoord);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!blend_in_enabled) {
|
||||
frag_color = texture(tex_in, ove_texcoord);
|
||||
return;
|
||||
}
|
||||
|
||||
vec4 tex_col = texture(tex_in, ove_texcoord);
|
||||
vec4 blend_col = texture(blend_in, ove_texcoord);
|
||||
|
||||
frag_color = mix(tex_col, blend_col, mix_in);
|
||||
}
|
||||
"#;
|
||||
|
||||
impl DissolveNode {
|
||||
/// Fragment shader (single variant, so the request id is ignored).
|
||||
fn shader_frag() -> &'static str {
|
||||
SHADER_FRAG
|
||||
}
|
||||
}
|
||||
|
||||
impl NodeBehavior for DissolveNode {
|
||||
/// Human-readable name.
|
||||
fn name(&self) -> &str {
|
||||
"Dissolve"
|
||||
}
|
||||
|
||||
/// Stable type id.
|
||||
fn type_id(&self) -> &str {
|
||||
"org.olivevideoeditor.Olive.dissolve"
|
||||
}
|
||||
|
||||
/// Categories. Filed under math like the alpha-over
|
||||
/// [`crate::nodes::merge`] (the `Category` enum has no merge group).
|
||||
fn categories(&self) -> &[Category] {
|
||||
&[Category::Math]
|
||||
}
|
||||
|
||||
/// Description.
|
||||
fn description(&self) -> &str {
|
||||
"Dissolve between two textures by a mix factor."
|
||||
}
|
||||
|
||||
/// Localized input names: `tex_in` -> "Input", `blend_in` ->
|
||||
/// "Blend", `mix_in` -> "Mix".
|
||||
fn input_name<'a>(&self, id: &'a str) -> &'a str {
|
||||
match id {
|
||||
TEXTURE_INPUT => "Input",
|
||||
BLEND_INPUT => "Blend",
|
||||
MIX_INPUT => "Mix",
|
||||
_ => id,
|
||||
}
|
||||
}
|
||||
|
||||
/// Evaluate outputs: if only one texture is present, push it as-is
|
||||
/// (a dissolve against nothing is the input itself — pushing a job
|
||||
/// would also let `run_effect`'s first-sampler fallback bind the
|
||||
/// lone texture to the missing one); if both are present, push one
|
||||
/// shader job over the whole input row; if neither, push nothing.
|
||||
///
|
||||
/// The resolved mix factor is written into the job row so the
|
||||
/// `mix_in` uniform is always present, even when the incoming row
|
||||
/// only carries the textures (the row/standard-value split mirrors
|
||||
/// the directional-blur node).
|
||||
fn value(
|
||||
&self,
|
||||
core: &NodeCore,
|
||||
inputs: &crate::value::NodeValueRow,
|
||||
time: oak_core::Rational,
|
||||
table: &mut crate::value::NodeValueTable,
|
||||
) {
|
||||
use crate::value::{NodeValue, ValueType};
|
||||
|
||||
let tex = inputs.get(TEXTURE_INPUT);
|
||||
let blend = inputs.get(BLEND_INPUT);
|
||||
|
||||
match (tex, blend) {
|
||||
(Some(NodeValue::Texture(_)), Some(NodeValue::Texture(_))) => {
|
||||
let mix = match inputs.get(MIX_INPUT) {
|
||||
Some(v) => v.to_double(),
|
||||
None => core.value_at_time(MIX_INPUT, -1, time).to_double(),
|
||||
};
|
||||
|
||||
let mut params = inputs.clone();
|
||||
params.insert(MIX_INPUT.to_string(), NodeValue::Float(mix));
|
||||
|
||||
table.push(
|
||||
ValueType::Texture,
|
||||
NodeValue::Texture(crate::handle::make_owned(ShaderJobPayload {
|
||||
node_id: crate::id::NodeId::INVALID,
|
||||
time,
|
||||
iterations: 1,
|
||||
type_id: self.type_id().to_string(),
|
||||
shader_id: String::new(),
|
||||
effect_input: core.effect_input.clone(),
|
||||
params,
|
||||
iterative_input: String::new(),
|
||||
})),
|
||||
None,
|
||||
);
|
||||
}
|
||||
(Some(t @ NodeValue::Texture(_)), _) => {
|
||||
table.push(ValueType::Texture, t.clone(), None);
|
||||
}
|
||||
(_, Some(b @ NodeValue::Texture(_))) => {
|
||||
table.push(ValueType::Texture, b.clone(), None);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Shader code request: always the cross-fade fragment source.
|
||||
fn shader_code(&self, _request: &str) -> Option<String> {
|
||||
Some(Self::shader_frag().to_string())
|
||||
}
|
||||
|
||||
/// Deep copy.
|
||||
fn duplicate(&self, _core: &NodeCore) -> Option<Box<dyn NodeBehavior>> {
|
||||
Some(Box::new(DissolveNode))
|
||||
}
|
||||
}
|
||||
|
||||
/// Constructor: adds `tex_in`, `blend_in` and `mix_in` with the defaults
|
||||
/// and properties documented on the constants and sets the video-effect
|
||||
/// flag (no effect input: both textures bind by name).
|
||||
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 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);
|
||||
|
||||
let mut mix = crate::input::Input::new(
|
||||
MIX_INPUT,
|
||||
crate::value::ValueType::Float,
|
||||
crate::value::NodeValue::Float(0.5),
|
||||
);
|
||||
mix.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(mix);
|
||||
|
||||
core.flags |= crate::node::flags::VIDEO_EFFECT;
|
||||
|
||||
(core, Box::new(DissolveNode))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::value::{NodeValue, NodeValueTable, ValueType};
|
||||
use oak_core::Rational;
|
||||
|
||||
fn tex() -> NodeValue {
|
||||
NodeValue::Texture(crate::handle::CHandle::null())
|
||||
}
|
||||
|
||||
fn run(core: &NodeCore, inputs: &crate::value::NodeValueRow) -> ShaderJobPayload {
|
||||
let behavior = DissolveNode;
|
||||
let mut table = NodeValueTable::default();
|
||||
behavior.value(core, inputs, Rational::new(0, 1), &mut table);
|
||||
let Some(NodeValue::Texture(h)) = table.get(ValueType::Texture) else {
|
||||
panic!("shader job expected");
|
||||
};
|
||||
unsafe { crate::handle::get_checked::<ShaderJobPayload>(h) }
|
||||
.expect("shader job payload boxed")
|
||||
.clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn input_names() {
|
||||
let n = DissolveNode;
|
||||
assert_eq!(n.input_name(TEXTURE_INPUT), "Input");
|
||||
assert_eq!(n.input_name(BLEND_INPUT), "Blend");
|
||||
assert_eq!(n.input_name(MIX_INPUT), "Mix");
|
||||
assert_eq!(n.input_name("other"), "other");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_wires_inputs_and_flags() {
|
||||
let (core, behavior) = create();
|
||||
assert_eq!(behavior.type_id(), "org.olivevideoeditor.Olive.dissolve");
|
||||
assert_eq!(behavior.name(), "Dissolve");
|
||||
assert_eq!(behavior.categories(), &[Category::Math]);
|
||||
assert_ne!(core.flags & crate::node::flags::VIDEO_EFFECT, 0);
|
||||
assert_eq!(core.effect_input, "");
|
||||
|
||||
for id in [TEXTURE_INPUT, BLEND_INPUT] {
|
||||
let input = core.get_input(id).expect("texture input");
|
||||
assert_ne!(input.flags & crate::input::flags::NOT_KEYFRAMABLE, 0);
|
||||
}
|
||||
|
||||
let mix = core.get_input(MIX_INPUT).expect("mix input");
|
||||
assert_eq!(mix.default, NodeValue::Float(0.5));
|
||||
assert!(mix
|
||||
.properties
|
||||
.iter()
|
||||
.any(|(k, v)| k == "min" && *v == NodeValue::Float(0.0)));
|
||||
assert!(mix
|
||||
.properties
|
||||
.iter()
|
||||
.any(|(k, v)| k == "max" && *v == NodeValue::Float(1.0)));
|
||||
assert!(mix
|
||||
.properties
|
||||
.iter()
|
||||
.any(|(k, v)| k == "view" && *v == NodeValue::Text("percentage".into())));
|
||||
}
|
||||
|
||||
#[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_tex_only_pushes_tex() {
|
||||
let (core, behavior) = create();
|
||||
let input = tex();
|
||||
let inputs = crate::value::NodeValueRow::from([(TEXTURE_INPUT.to_string(), input.clone())]);
|
||||
let mut table = NodeValueTable::default();
|
||||
behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
|
||||
assert_eq!(table.get(ValueType::Texture), Some(&input));
|
||||
}
|
||||
|
||||
#[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_job_payload_with_both_textures() {
|
||||
let (core, _) = create();
|
||||
let inputs = crate::value::NodeValueRow::from([
|
||||
(TEXTURE_INPUT.to_string(), tex()),
|
||||
(BLEND_INPUT.to_string(), tex()),
|
||||
]);
|
||||
let payload = run(&core, &inputs);
|
||||
assert_eq!(payload.type_id, "org.olivevideoeditor.Olive.dissolve");
|
||||
assert_eq!(payload.shader_id, "");
|
||||
assert_eq!(payload.iterations, 1);
|
||||
assert_eq!(payload.iterative_input, "");
|
||||
assert!(payload.params.contains_key(TEXTURE_INPUT));
|
||||
assert!(payload.params.contains_key(BLEND_INPUT));
|
||||
assert_eq!(
|
||||
payload.params.get(MIX_INPUT),
|
||||
Some(&NodeValue::Float(0.5)),
|
||||
"the mix factor rides in the job params"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_takes_mix_from_row() {
|
||||
let (core, _) = create();
|
||||
let inputs = crate::value::NodeValueRow::from([
|
||||
(TEXTURE_INPUT.to_string(), tex()),
|
||||
(BLEND_INPUT.to_string(), tex()),
|
||||
(MIX_INPUT.to_string(), NodeValue::Float(1.0)),
|
||||
]);
|
||||
let payload = run(&core, &inputs);
|
||||
assert_eq!(payload.params.get(MIX_INPUT), Some(&NodeValue::Float(1.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_takes_mix_from_core() {
|
||||
let (mut core, _) = create();
|
||||
core.set_standard_value(MIX_INPUT, -1, NodeValue::Float(0.25));
|
||||
let inputs = crate::value::NodeValueRow::from([
|
||||
(TEXTURE_INPUT.to_string(), tex()),
|
||||
(BLEND_INPUT.to_string(), tex()),
|
||||
]);
|
||||
let payload = run(&core, &inputs);
|
||||
assert_eq!(payload.params.get(MIX_INPUT), Some(&NodeValue::Float(0.25)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shader_code_declares_inputs_without_switch() {
|
||||
let n = DissolveNode;
|
||||
let glsl = n.shader_code("").unwrap();
|
||||
assert!(glsl.contains("uniform sampler2D tex_in;"));
|
||||
assert!(glsl.contains("uniform sampler2D blend_in;"));
|
||||
assert!(glsl.contains("uniform bool tex_in_enabled;"));
|
||||
assert!(glsl.contains("uniform bool blend_in_enabled;"));
|
||||
assert!(glsl.contains("uniform float mix_in;"));
|
||||
assert!(glsl.contains("frag_color = mix(tex_col, blend_col, mix_in);"));
|
||||
assert!(!glsl.contains("switch"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_clones_behavior() {
|
||||
let (core, behavior) = create();
|
||||
let dup = behavior.duplicate(&core).unwrap();
|
||||
assert_eq!(dup.name(), "Dissolve");
|
||||
}
|
||||
}
|
||||
|
||||
/// Register this node type.
|
||||
pub fn register(meta: &mut Vec<NodeMeta>) {
|
||||
meta.push(NodeMeta {
|
||||
type_id: "org.olivevideoeditor.Olive.dissolve",
|
||||
name: "Dissolve",
|
||||
categories: &[Category::Math],
|
||||
create,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
// 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/>.
|
||||
|
||||
//! Edge detect filter (clean-room reimplementation of the CImg
|
||||
//! `EdgeDetect`/`eu.cimg.EdgeDetect` effect; `ofx-misc` used for
|
||||
//! parameter semantics only, no code copied).
|
||||
|
||||
use crate::factory::NodeMeta;
|
||||
use crate::jobs::ShaderJobPayload;
|
||||
use crate::node::{Category, NodeBehavior, NodeCore};
|
||||
|
||||
/// Texture input id. Type: texture; flags: not-keyframable; this is the
|
||||
/// node's effect input.
|
||||
pub const TEXTURE_INPUT: &str = "tex_in";
|
||||
|
||||
/// Threshold input id. Type: float; default `0.0`; properties: `min =
|
||||
/// 0.0`. Gradient magnitudes below this value output black.
|
||||
pub const THRESHOLD_INPUT: &str = "threshold_in";
|
||||
|
||||
/// Edge detect filter node. Sobel gradient magnitude, per RGB channel.
|
||||
pub struct EdgeDetectNode;
|
||||
|
||||
/// Fragment shader: 3x3 Sobel gradient, per RGB channel; the magnitude is
|
||||
/// `sqrt(gx^2 + gy^2)`, zeroed where it falls below `threshold_in` (a
|
||||
/// branch-free `step`, no `switch`). The alpha channel passes through
|
||||
/// unchanged so the result stays a visible, composable image; magnitudes
|
||||
/// are not clamped (the filter is meant to feed a grade downstream).
|
||||
/// Pixel offsets are one texel (`resolution_in`), sampling at texel
|
||||
/// centers; edge-of-frame taps clamp to the border pixel.
|
||||
const SHADER_FRAG: &str = r#"uniform sampler2D tex_in;
|
||||
uniform float threshold_in;
|
||||
uniform vec2 resolution_in;
|
||||
|
||||
in vec2 ove_texcoord;
|
||||
out vec4 frag_color;
|
||||
|
||||
vec3 edge_sample(vec2 uv) {
|
||||
return texture(tex_in, uv).rgb;
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec2 texel = vec2(1.0) / resolution_in;
|
||||
|
||||
vec3 tl = edge_sample(ove_texcoord + texel * vec2(-1.0, -1.0));
|
||||
vec3 tc = edge_sample(ove_texcoord + texel * vec2( 0.0, -1.0));
|
||||
vec3 tr = edge_sample(ove_texcoord + texel * vec2( 1.0, -1.0));
|
||||
vec3 ml = edge_sample(ove_texcoord + texel * vec2(-1.0, 0.0));
|
||||
vec3 mr = edge_sample(ove_texcoord + texel * vec2( 1.0, 0.0));
|
||||
vec3 bl = edge_sample(ove_texcoord + texel * vec2(-1.0, 1.0));
|
||||
vec3 bc = edge_sample(ove_texcoord + texel * vec2( 0.0, 1.0));
|
||||
vec3 br = edge_sample(ove_texcoord + texel * vec2( 1.0, 1.0));
|
||||
|
||||
vec3 gx = (tr + 2.0 * mr + br) - (tl + 2.0 * ml + bl);
|
||||
vec3 gy = (bl + 2.0 * bc + br) - (tl + 2.0 * tc + tr);
|
||||
vec3 magnitude = sqrt(gx * gx + gy * gy);
|
||||
|
||||
vec3 masked = magnitude * step(vec3(threshold_in), magnitude);
|
||||
frag_color = vec4(masked, texture(tex_in, ove_texcoord).a);
|
||||
}
|
||||
"#;
|
||||
|
||||
impl EdgeDetectNode {
|
||||
/// Fragment shader for any request (this node has a single variant).
|
||||
fn shader_frag() -> &'static str {
|
||||
SHADER_FRAG
|
||||
}
|
||||
}
|
||||
|
||||
impl NodeBehavior for EdgeDetectNode {
|
||||
/// Human-readable name.
|
||||
fn name(&self) -> &str {
|
||||
"Edge Detect"
|
||||
}
|
||||
|
||||
/// Stable type id.
|
||||
fn type_id(&self) -> &str {
|
||||
"org.olivevideoeditor.Olive.edgedetect"
|
||||
}
|
||||
|
||||
/// Categories.
|
||||
fn categories(&self) -> &[Category] {
|
||||
&[Category::Filter]
|
||||
}
|
||||
|
||||
/// Description.
|
||||
fn description(&self) -> &str {
|
||||
"Detect edges with a Sobel gradient filter."
|
||||
}
|
||||
|
||||
/// Localized input names: `tex_in` -> "Input", `threshold_in` ->
|
||||
/// "Threshold".
|
||||
fn input_name<'a>(&self, id: &'a str) -> &'a str {
|
||||
match id {
|
||||
TEXTURE_INPUT => "Input",
|
||||
THRESHOLD_INPUT => "Threshold",
|
||||
_ => id,
|
||||
}
|
||||
}
|
||||
|
||||
/// Evaluate outputs: no texture -> push nothing; otherwise push the
|
||||
/// Sobel shader job (threshold rides in the params row as the
|
||||
/// `threshold_in` uniform).
|
||||
fn value(
|
||||
&self,
|
||||
core: &NodeCore,
|
||||
inputs: &crate::value::NodeValueRow,
|
||||
time: oak_core::Rational,
|
||||
table: &mut crate::value::NodeValueTable,
|
||||
) {
|
||||
use crate::value::{NodeValue, ValueType};
|
||||
|
||||
match inputs.get(TEXTURE_INPUT) {
|
||||
Some(NodeValue::Texture(_)) => {}
|
||||
_ => return,
|
||||
}
|
||||
|
||||
let threshold = match inputs.get(THRESHOLD_INPUT) {
|
||||
Some(v) => v.to_double(),
|
||||
None => core.value_at_time(THRESHOLD_INPUT, -1, time).to_double(),
|
||||
};
|
||||
|
||||
let mut params = inputs.clone();
|
||||
params.insert(THRESHOLD_INPUT.to_string(), NodeValue::Float(threshold));
|
||||
|
||||
table.push(
|
||||
ValueType::Texture,
|
||||
NodeValue::Texture(crate::handle::make_owned(ShaderJobPayload {
|
||||
node_id: crate::id::NodeId::INVALID,
|
||||
time,
|
||||
iterations: 1,
|
||||
type_id: self.type_id().to_string(),
|
||||
shader_id: String::new(),
|
||||
effect_input: core.effect_input.clone(),
|
||||
params,
|
||||
iterative_input: String::new(),
|
||||
})),
|
||||
None,
|
||||
);
|
||||
}
|
||||
|
||||
/// Shader code request: always the one fragment source.
|
||||
fn shader_code(&self, _request: &str) -> Option<String> {
|
||||
Some(Self::shader_frag().to_string())
|
||||
}
|
||||
|
||||
/// Deep copy.
|
||||
fn duplicate(&self, _core: &NodeCore) -> Option<Box<dyn NodeBehavior>> {
|
||||
Some(Box::new(EdgeDetectNode))
|
||||
}
|
||||
}
|
||||
|
||||
/// Constructor: adds `tex_in` and `threshold_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 threshold = crate::input::Input::new(
|
||||
THRESHOLD_INPUT,
|
||||
crate::value::ValueType::Float,
|
||||
crate::value::NodeValue::Float(0.0),
|
||||
);
|
||||
threshold.properties = vec![("min".to_string(), crate::value::NodeValue::Float(0.0))];
|
||||
core.add_input(threshold);
|
||||
|
||||
core.flags |= crate::node::flags::VIDEO_EFFECT;
|
||||
core.effect_input = TEXTURE_INPUT.to_string();
|
||||
|
||||
(core, Box::new(EdgeDetectNode))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::value::{NodeValue, NodeValueTable, ValueType};
|
||||
use oak_core::Rational;
|
||||
|
||||
fn tex() -> NodeValue {
|
||||
NodeValue::Texture(crate::handle::CHandle::null())
|
||||
}
|
||||
|
||||
fn run(core: &NodeCore, inputs: &crate::value::NodeValueRow) -> ShaderJobPayload {
|
||||
let behavior = EdgeDetectNode;
|
||||
let mut table = NodeValueTable::default();
|
||||
behavior.value(core, inputs, Rational::new(0, 1), &mut table);
|
||||
let Some(NodeValue::Texture(h)) = table.get(ValueType::Texture) else {
|
||||
panic!("shader job expected");
|
||||
};
|
||||
unsafe { crate::handle::get_checked::<ShaderJobPayload>(h) }
|
||||
.expect("shader job payload boxed")
|
||||
.clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn input_names() {
|
||||
let n = EdgeDetectNode;
|
||||
assert_eq!(n.input_name(TEXTURE_INPUT), "Input");
|
||||
assert_eq!(n.input_name(THRESHOLD_INPUT), "Threshold");
|
||||
assert_eq!(n.input_name("other"), "other");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_wires_inputs_and_flags() {
|
||||
let (core, behavior) = create();
|
||||
assert_eq!(behavior.type_id(), "org.olivevideoeditor.Olive.edgedetect");
|
||||
assert_eq!(behavior.categories(), &[Category::Filter]);
|
||||
assert_eq!(core.effect_input, TEXTURE_INPUT);
|
||||
assert_ne!(core.flags & crate::node::flags::VIDEO_EFFECT, 0);
|
||||
let threshold = core.get_input(THRESHOLD_INPUT).expect("threshold input");
|
||||
assert_eq!(threshold.default, NodeValue::Float(0.0));
|
||||
assert!(threshold
|
||||
.properties
|
||||
.iter()
|
||||
.any(|(k, v)| k == "min" && *v == NodeValue::Float(0.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_pushes_job_with_threshold_default() {
|
||||
let (core, _) = create();
|
||||
let inputs = crate::value::NodeValueRow::from([(TEXTURE_INPUT.to_string(), tex())]);
|
||||
let payload = run(&core, &inputs);
|
||||
assert_eq!(payload.type_id, "org.olivevideoeditor.Olive.edgedetect");
|
||||
assert_eq!(payload.shader_id, "");
|
||||
assert_eq!(payload.effect_input, TEXTURE_INPUT);
|
||||
assert_eq!(payload.iterations, 1);
|
||||
assert_eq!(payload.iterative_input, "");
|
||||
assert_eq!(
|
||||
payload.params.get(THRESHOLD_INPUT),
|
||||
Some(&NodeValue::Float(0.0))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_takes_threshold_from_row() {
|
||||
let (core, _) = create();
|
||||
let inputs = crate::value::NodeValueRow::from([
|
||||
(TEXTURE_INPUT.to_string(), tex()),
|
||||
(THRESHOLD_INPUT.to_string(), NodeValue::Float(0.5)),
|
||||
]);
|
||||
let payload = run(&core, &inputs);
|
||||
assert_eq!(
|
||||
payload.params.get(THRESHOLD_INPUT),
|
||||
Some(&NodeValue::Float(0.5))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_takes_threshold_from_core() {
|
||||
let (mut core, _) = create();
|
||||
core.set_standard_value(THRESHOLD_INPUT, -1, NodeValue::Float(0.75));
|
||||
let inputs = crate::value::NodeValueRow::from([(TEXTURE_INPUT.to_string(), tex())]);
|
||||
let payload = run(&core, &inputs);
|
||||
assert_eq!(
|
||||
payload.params.get(THRESHOLD_INPUT),
|
||||
Some(&NodeValue::Float(0.75))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shader_code_is_sobel_without_switch() {
|
||||
let n = EdgeDetectNode;
|
||||
let glsl = n.shader_code("").unwrap();
|
||||
assert!(glsl.contains("uniform vec2 resolution_in;"));
|
||||
assert!(glsl.contains("sqrt(gx * gx + gy * gy)"));
|
||||
assert!(glsl.contains("step(vec3(threshold_in), magnitude)"));
|
||||
assert!(!glsl.contains("switch"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_clones_behavior() {
|
||||
let (core, behavior) = create();
|
||||
let dup = behavior.duplicate(&core).unwrap();
|
||||
assert_eq!(dup.name(), "Edge Detect");
|
||||
}
|
||||
}
|
||||
|
||||
/// Register this node type.
|
||||
pub fn register(meta: &mut Vec<NodeMeta>) {
|
||||
meta.push(NodeMeta {
|
||||
type_id: "org.olivevideoeditor.Olive.edgedetect",
|
||||
name: "Edge Detect",
|
||||
categories: &[Category::Filter],
|
||||
create,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
// 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/>.
|
||||
|
||||
//! Erode filter (clean-room reimplementation of the CImg `Erode`/
|
||||
//! `net.sf.cimg.CImgErode` effect; `ofx-misc` used for parameter
|
||||
//! semantics only, no code copied).
|
||||
|
||||
use crate::factory::NodeMeta;
|
||||
use crate::jobs::ShaderJobPayload;
|
||||
use crate::node::{Category, NodeBehavior, NodeCore};
|
||||
|
||||
/// Texture input id. Type: texture; flags: not-keyframable; this is the
|
||||
/// node's effect input.
|
||||
pub const TEXTURE_INPUT: &str = "tex_in";
|
||||
|
||||
/// Radius input id. Type: float; default `1.0`; properties: `min = 0.0`,
|
||||
/// `max = 7.0`. The structuring element is a square of `2 * radius + 1`
|
||||
/// pixels on a side, in pixels.
|
||||
pub const RADIUS_INPUT: &str = "radius_in";
|
||||
|
||||
/// Largest supported radius. The shader clamps to this; the neighborhood
|
||||
/// is `15 x 15` pixels, sampled in a single pass (see [`SHADER_FRAG`]).
|
||||
pub const MAX_RADIUS: f64 = 7.0;
|
||||
|
||||
/// Erode filter node. Morphological minimum over a square neighborhood.
|
||||
pub struct ErodeNode;
|
||||
|
||||
/// Fragment shader: one pass sampling the full `(2r+1)^2` neighborhood
|
||||
/// and taking the per-channel minimum. `iterations` stays 1 — a single
|
||||
/// wide pass is mathematically identical to `r` 3x3 iterations for a
|
||||
/// square structuring element (min is associative and idempotent), and
|
||||
/// avoids the ping-pong feedback textures entirely. `radius_in` is
|
||||
/// rounded to the nearest integer and clamped to `0..=7`; offsets are
|
||||
/// pixel-space texel steps (`resolution_in`, texel centers), with
|
||||
/// edge-of-frame taps clamping to the border pixel.
|
||||
const SHADER_FRAG: &str = r#"uniform sampler2D tex_in;
|
||||
uniform float radius_in;
|
||||
uniform vec2 resolution_in;
|
||||
|
||||
in vec2 ove_texcoord;
|
||||
out vec4 frag_color;
|
||||
|
||||
void main() {
|
||||
int radius = int(clamp(radius_in, 0.0, 7.0) + 0.5);
|
||||
vec2 texel = vec2(1.0) / resolution_in;
|
||||
|
||||
vec4 acc = texture(tex_in, ove_texcoord);
|
||||
for (int dy = -radius; dy <= radius; ++dy) {
|
||||
for (int dx = -radius; dx <= radius; ++dx) {
|
||||
vec2 uv = ove_texcoord + vec2(float(dx), float(dy)) * texel;
|
||||
acc = min(acc, texture(tex_in, uv));
|
||||
}
|
||||
}
|
||||
|
||||
frag_color = acc;
|
||||
}
|
||||
"#;
|
||||
|
||||
impl ErodeNode {
|
||||
/// Fragment shader for any request (this node has a single variant).
|
||||
fn shader_frag() -> &'static str {
|
||||
SHADER_FRAG
|
||||
}
|
||||
}
|
||||
|
||||
impl NodeBehavior for ErodeNode {
|
||||
/// Human-readable name.
|
||||
fn name(&self) -> &str {
|
||||
"Erode"
|
||||
}
|
||||
|
||||
/// Stable type id.
|
||||
fn type_id(&self) -> &str {
|
||||
"org.olivevideoeditor.Olive.erode"
|
||||
}
|
||||
|
||||
/// Categories.
|
||||
fn categories(&self) -> &[Category] {
|
||||
&[Category::Filter]
|
||||
}
|
||||
|
||||
/// Description.
|
||||
fn description(&self) -> &str {
|
||||
"Shrink bright areas by taking the minimum over a square neighborhood."
|
||||
}
|
||||
|
||||
/// Localized input names: `tex_in` -> "Input", `radius_in` ->
|
||||
/// "Radius".
|
||||
fn input_name<'a>(&self, id: &'a str) -> &'a str {
|
||||
match id {
|
||||
TEXTURE_INPUT => "Input",
|
||||
RADIUS_INPUT => "Radius",
|
||||
_ => id,
|
||||
}
|
||||
}
|
||||
|
||||
/// Evaluate outputs: no texture -> push nothing; radius <= 0 -> push
|
||||
/// the input texture unchanged (the same no-work shortcut as
|
||||
/// `blur.rs`'s `can_push_job`); otherwise push the single-pass
|
||||
/// neighborhood shader job.
|
||||
fn value(
|
||||
&self,
|
||||
core: &NodeCore,
|
||||
inputs: &crate::value::NodeValueRow,
|
||||
time: oak_core::Rational,
|
||||
table: &mut crate::value::NodeValueTable,
|
||||
) {
|
||||
use crate::value::{NodeValue, ValueType};
|
||||
|
||||
let tex = match inputs.get(TEXTURE_INPUT) {
|
||||
Some(tex @ 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(),
|
||||
};
|
||||
|
||||
if radius <= 0.0 {
|
||||
table.push(ValueType::Texture, tex, None);
|
||||
return;
|
||||
}
|
||||
|
||||
let mut params = inputs.clone();
|
||||
params.insert(RADIUS_INPUT.to_string(), NodeValue::Float(radius));
|
||||
|
||||
table.push(
|
||||
ValueType::Texture,
|
||||
NodeValue::Texture(crate::handle::make_owned(ShaderJobPayload {
|
||||
node_id: crate::id::NodeId::INVALID,
|
||||
time,
|
||||
iterations: 1,
|
||||
type_id: self.type_id().to_string(),
|
||||
shader_id: String::new(),
|
||||
effect_input: core.effect_input.clone(),
|
||||
params,
|
||||
iterative_input: String::new(),
|
||||
})),
|
||||
None,
|
||||
);
|
||||
}
|
||||
|
||||
/// Shader code request: always the one fragment source.
|
||||
fn shader_code(&self, _request: &str) -> Option<String> {
|
||||
Some(Self::shader_frag().to_string())
|
||||
}
|
||||
|
||||
/// Deep copy.
|
||||
fn duplicate(&self, _core: &NodeCore) -> Option<Box<dyn NodeBehavior>> {
|
||||
Some(Box::new(ErodeNode))
|
||||
}
|
||||
}
|
||||
|
||||
/// Constructor: adds `tex_in` and `radius_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 radius = crate::input::Input::new(
|
||||
RADIUS_INPUT,
|
||||
crate::value::ValueType::Float,
|
||||
crate::value::NodeValue::Float(1.0),
|
||||
);
|
||||
radius.properties = vec![
|
||||
("min".to_string(), crate::value::NodeValue::Float(0.0)),
|
||||
("max".to_string(), crate::value::NodeValue::Float(MAX_RADIUS)),
|
||||
];
|
||||
core.add_input(radius);
|
||||
|
||||
core.flags |= crate::node::flags::VIDEO_EFFECT;
|
||||
core.effect_input = TEXTURE_INPUT.to_string();
|
||||
|
||||
(core, Box::new(ErodeNode))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
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 = ErodeNode;
|
||||
assert_eq!(n.input_name(TEXTURE_INPUT), "Input");
|
||||
assert_eq!(n.input_name(RADIUS_INPUT), "Radius");
|
||||
assert_eq!(n.input_name("other"), "other");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_wires_inputs_and_flags() {
|
||||
let (core, behavior) = create();
|
||||
assert_eq!(behavior.type_id(), "org.olivevideoeditor.Olive.erode");
|
||||
assert_eq!(behavior.categories(), &[Category::Filter]);
|
||||
assert_eq!(core.effect_input, TEXTURE_INPUT);
|
||||
assert_ne!(core.flags & crate::node::flags::VIDEO_EFFECT, 0);
|
||||
let radius = core.get_input(RADIUS_INPUT).expect("radius input");
|
||||
assert_eq!(radius.default, NodeValue::Float(1.0));
|
||||
assert!(radius
|
||||
.properties
|
||||
.iter()
|
||||
.any(|(k, v)| k == "min" && *v == NodeValue::Float(0.0)));
|
||||
assert!(radius
|
||||
.properties
|
||||
.iter()
|
||||
.any(|(k, v)| k == "max" && *v == NodeValue::Float(MAX_RADIUS)));
|
||||
}
|
||||
|
||||
#[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_through() {
|
||||
let (core, behavior) = create();
|
||||
let tex = tex();
|
||||
let inputs = crate::value::NodeValueRow::from([
|
||||
(TEXTURE_INPUT.to_string(), tex.clone()),
|
||||
(RADIUS_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::Texture), Some(&tex));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_pushes_single_pass_job() {
|
||||
let (core, behavior) = create();
|
||||
let inputs = crate::value::NodeValueRow::from([
|
||||
(TEXTURE_INPUT.to_string(), tex()),
|
||||
(RADIUS_INPUT.to_string(), NodeValue::Float(2.0)),
|
||||
]);
|
||||
let mut table = NodeValueTable::default();
|
||||
behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
|
||||
let Some(NodeValue::Texture(h)) = table.get(ValueType::Texture) else {
|
||||
panic!("shader job expected");
|
||||
};
|
||||
let payload = unsafe { crate::handle::get_checked::<ShaderJobPayload>(h) }
|
||||
.expect("shader job payload boxed");
|
||||
assert_eq!(payload.type_id, "org.olivevideoeditor.Olive.erode");
|
||||
assert_eq!(payload.shader_id, "");
|
||||
assert_eq!(payload.effect_input, TEXTURE_INPUT);
|
||||
assert_eq!(payload.iterations, 1);
|
||||
assert_eq!(payload.iterative_input, "");
|
||||
assert_eq!(
|
||||
payload.params.get(RADIUS_INPUT),
|
||||
Some(&NodeValue::Float(2.0))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_default_radius_pushes_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);
|
||||
let Some(NodeValue::Texture(h)) = table.get(ValueType::Texture) else {
|
||||
panic!("shader job expected");
|
||||
};
|
||||
let payload = unsafe { crate::handle::get_checked::<ShaderJobPayload>(h) }
|
||||
.expect("shader job payload boxed");
|
||||
assert_eq!(
|
||||
payload.params.get(RADIUS_INPUT),
|
||||
Some(&NodeValue::Float(1.0))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shader_code_is_single_pass_min_without_switch() {
|
||||
let n = ErodeNode;
|
||||
let glsl = n.shader_code("").unwrap();
|
||||
assert!(glsl.contains("uniform vec2 resolution_in;"));
|
||||
assert!(glsl.contains("acc = min(acc, texture(tex_in, uv));"));
|
||||
assert!(glsl.contains("int(clamp(radius_in, 0.0, 7.0) + 0.5)"));
|
||||
assert!(!glsl.contains("switch"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_clones_behavior() {
|
||||
let (core, behavior) = create();
|
||||
let dup = behavior.duplicate(&core).unwrap();
|
||||
assert_eq!(dup.name(), "Erode");
|
||||
}
|
||||
}
|
||||
|
||||
/// Register this node type.
|
||||
pub fn register(meta: &mut Vec<NodeMeta>) {
|
||||
meta.push(NodeMeta {
|
||||
type_id: "org.olivevideoeditor.Olive.erode",
|
||||
name: "Erode",
|
||||
categories: &[Category::Filter],
|
||||
create,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
// 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/>.
|
||||
|
||||
//! Gamma effect — a clean-room reimplementation of the OpenFX-Misc
|
||||
//! `Gamma` plugin's parameter semantics (upstream
|
||||
//! `github.com/cgvirus/OpenFX-Misc`, GPL2; read for behavior only, no
|
||||
//! upstream code copied).
|
||||
//!
|
||||
//! One gamma control, applied to all three color channels:
|
||||
//!
|
||||
//! ```text
|
||||
//! x = (x > 0) ? pow(x, 1 / gamma) : x
|
||||
//! ```
|
||||
//!
|
||||
//! Alpha is left untouched (the reference has no alpha toggle for this
|
||||
//! node), and the pass only runs when the control differs from `1.0`.
|
||||
//! Non-positive channels keep their value — a negative base has no real
|
||||
//! power — and a non-positive gamma is clamped to a tiny positive
|
||||
//! minimum so the reciprocal exponent never divides by zero.
|
||||
|
||||
use crate::factory::NodeMeta;
|
||||
use crate::jobs::ShaderJobPayload;
|
||||
use crate::node::{Category, NodeBehavior, NodeCore};
|
||||
|
||||
/// Texture input id. Type: texture; flags: not-keyframable; this is
|
||||
/// the node's effect input.
|
||||
pub const TEXTURE_INPUT: &str = "tex_in";
|
||||
|
||||
/// Gamma input id. Type: float; default `1.0`; properties:
|
||||
/// `min = 0.0`, `max = 4.0`. Reciprocal exponent (`pow(x, 1/gamma)`);
|
||||
/// `1.0` leaves the image unchanged.
|
||||
pub const GAMMA_INPUT: &str = "gamma_in";
|
||||
|
||||
/// Gamma node. The reference class holds no state beyond its parameter
|
||||
/// pointers, so this is a unit-like struct.
|
||||
pub struct GammaNode;
|
||||
|
||||
/// Fragment shader (clean-room GLSL for the reference's
|
||||
/// `GammaPlugin::render` chain). The uniforms are named after the node
|
||||
/// inputs: the renderer binds uniforms by matching the declared name
|
||||
/// against the job's parameter row.
|
||||
const SHADER_FRAG: &str = r#"// Inputs
|
||||
uniform sampler2D tex_in;
|
||||
|
||||
uniform float gamma_in;
|
||||
|
||||
// Input texture coordinate
|
||||
in vec2 ove_texcoord;
|
||||
out vec4 frag_color;
|
||||
|
||||
void main() {
|
||||
vec4 c = texture(tex_in, ove_texcoord);
|
||||
|
||||
// Gamma: reciprocal exponent on the color channels. A non-positive
|
||||
// gamma would divide by zero, so it is clamped to a tiny minimum, and
|
||||
// non-positive channels pass through unchanged.
|
||||
if (gamma_in != 1.0) {
|
||||
float g = max(gamma_in, 1.0e-8);
|
||||
if (c.r > 0.0) {
|
||||
c.r = pow(c.r, 1.0 / g);
|
||||
}
|
||||
if (c.g > 0.0) {
|
||||
c.g = pow(c.g, 1.0 / g);
|
||||
}
|
||||
if (c.b > 0.0) {
|
||||
c.b = pow(c.b, 1.0 / g);
|
||||
}
|
||||
}
|
||||
|
||||
frag_color = c;
|
||||
}
|
||||
"#;
|
||||
|
||||
impl NodeBehavior for GammaNode {
|
||||
/// Human-readable name.
|
||||
fn name(&self) -> &str {
|
||||
"Gamma"
|
||||
}
|
||||
|
||||
/// Stable type id.
|
||||
fn type_id(&self) -> &str {
|
||||
"org.olivevideoeditor.Olive.gamma"
|
||||
}
|
||||
|
||||
/// Categories.
|
||||
fn categories(&self) -> &[Category] {
|
||||
&[Category::Color]
|
||||
}
|
||||
|
||||
/// Description.
|
||||
fn description(&self) -> &str {
|
||||
"Apply a gamma (reciprocal-exponent) correction to the image."
|
||||
}
|
||||
|
||||
/// Localized input names: `tex_in` -> "Input", `gamma_in` ->
|
||||
/// "Gamma".
|
||||
fn input_name<'a>(&self, id: &'a str) -> &'a str {
|
||||
match id {
|
||||
TEXTURE_INPUT => "Input",
|
||||
GAMMA_INPUT => "Gamma",
|
||||
_ => id,
|
||||
}
|
||||
}
|
||||
|
||||
/// Evaluate outputs: no texture on `tex_in` -> push nothing;
|
||||
/// texture present -> push a shader job over the input row with the
|
||||
/// control resolved (so the renderer always finds a value for the
|
||||
/// uniform, whether the row carried the input or the node's own
|
||||
/// default/keyframe supplied it).
|
||||
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 resolve = |id: &str| match inputs.get(id) {
|
||||
Some(v) => v.clone(),
|
||||
None => core.value_at_time(id, -1, time),
|
||||
};
|
||||
|
||||
let mut params = inputs.clone();
|
||||
params.insert(GAMMA_INPUT.to_string(), resolve(GAMMA_INPUT));
|
||||
|
||||
table.push(
|
||||
crate::value::ValueType::Texture,
|
||||
crate::value::NodeValue::Texture(crate::handle::make_owned(ShaderJobPayload {
|
||||
node_id: crate::id::NodeId::INVALID,
|
||||
time,
|
||||
iterations: 1,
|
||||
type_id: self.type_id().to_string(),
|
||||
shader_id: String::new(),
|
||||
effect_input: core.effect_input.clone(),
|
||||
params,
|
||||
iterative_input: String::new(),
|
||||
})),
|
||||
None,
|
||||
);
|
||||
}
|
||||
|
||||
/// Shader code request: the request id is ignored; always returns
|
||||
/// [`SHADER_FRAG`].
|
||||
fn shader_code(&self, _request: &str) -> Option<String> {
|
||||
Some(SHADER_FRAG.to_string())
|
||||
}
|
||||
|
||||
/// Deep copy.
|
||||
fn duplicate(&self, _core: &NodeCore) -> Option<Box<dyn NodeBehavior>> {
|
||||
Some(Box::new(GammaNode))
|
||||
}
|
||||
}
|
||||
|
||||
/// Constructor: adds `tex_in` (texture, effect input) and the gamma
|
||||
/// control with the default and range documented on the constant, 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);
|
||||
|
||||
add_float_input(&mut core, GAMMA_INPUT, 1.0, 0.0, 4.0);
|
||||
|
||||
core.flags |= crate::node::flags::VIDEO_EFFECT;
|
||||
core.effect_input = TEXTURE_INPUT.to_string();
|
||||
|
||||
(core, Box::new(GammaNode))
|
||||
}
|
||||
|
||||
/// Add a float input with its default and `min`/`max`/`view`
|
||||
/// properties.
|
||||
fn add_float_input(core: &mut NodeCore, id: &str, default: f64, min: f64, max: f64) {
|
||||
let mut input = crate::input::Input::new(
|
||||
id,
|
||||
crate::value::ValueType::Float,
|
||||
crate::value::NodeValue::Float(default),
|
||||
);
|
||||
input.properties = vec![
|
||||
("min".to_string(), crate::value::NodeValue::Float(min)),
|
||||
("max".to_string(), crate::value::NodeValue::Float(max)),
|
||||
(
|
||||
"view".to_string(),
|
||||
crate::value::NodeValue::Text("normal".into()),
|
||||
),
|
||||
];
|
||||
core.add_input(input);
|
||||
}
|
||||
|
||||
/// Register this node type.
|
||||
pub fn register(meta: &mut Vec<NodeMeta>) {
|
||||
meta.push(NodeMeta {
|
||||
type_id: "org.olivevideoeditor.Olive.gamma",
|
||||
name: "Gamma",
|
||||
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 = GammaNode;
|
||||
assert_eq!(n.input_name(TEXTURE_INPUT), "Input");
|
||||
assert_eq!(n.input_name(GAMMA_INPUT), "Gamma");
|
||||
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.gamma");
|
||||
assert_eq!(behavior.name(), "Gamma");
|
||||
assert_eq!(behavior.categories(), &[Category::Color]);
|
||||
let tex = core.get_input(TEXTURE_INPUT).unwrap();
|
||||
assert_ne!(tex.flags & crate::input::flags::NOT_KEYFRAMABLE, 0);
|
||||
assert_eq!(
|
||||
core.get_input(GAMMA_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 create_sets_control_range() {
|
||||
let (core, _) = create();
|
||||
let gamma = core.get_input(GAMMA_INPUT).unwrap();
|
||||
assert_eq!(gamma.properties[0].1, NodeValue::Float(0.0));
|
||||
assert_eq!(gamma.properties[1].1, NodeValue::Float(4.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_shader_job_with_resolved_params() {
|
||||
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);
|
||||
let Some(NodeValue::Texture(handle)) = table.get(ValueType::Texture) else {
|
||||
panic!("expected a texture-typed value");
|
||||
};
|
||||
let payload =
|
||||
unsafe { crate::handle::get_checked::<crate::jobs::ShaderJobPayload>(handle) }
|
||||
.expect("shader job pushed");
|
||||
assert_eq!(payload.type_id, "org.olivevideoeditor.Olive.gamma");
|
||||
assert_eq!(payload.effect_input, TEXTURE_INPUT);
|
||||
assert_eq!(payload.params.get(GAMMA_INPUT), Some(&NodeValue::Float(1.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_row_values_win_over_defaults() {
|
||||
let (mut core, behavior) = create();
|
||||
core.set_standard_value(GAMMA_INPUT, -1, NodeValue::Float(3.0));
|
||||
let inputs = crate::value::NodeValueRow::from([
|
||||
(
|
||||
TEXTURE_INPUT.to_string(),
|
||||
NodeValue::Texture(crate::handle::CHandle::null()),
|
||||
),
|
||||
(GAMMA_INPUT.to_string(), NodeValue::Float(2.0)),
|
||||
]);
|
||||
let mut table = NodeValueTable::default();
|
||||
behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
|
||||
let Some(NodeValue::Texture(handle)) = table.get(ValueType::Texture) else {
|
||||
panic!("expected a texture-typed value");
|
||||
};
|
||||
let payload =
|
||||
unsafe { crate::handle::get_checked::<crate::jobs::ShaderJobPayload>(handle) }
|
||||
.expect("shader job pushed");
|
||||
assert_eq!(payload.params.get(GAMMA_INPUT), Some(&NodeValue::Float(2.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shader_declares_uniforms_and_avoids_switch() {
|
||||
let code = GammaNode.shader_code("").unwrap();
|
||||
for uniform in ["tex_in", GAMMA_INPUT] {
|
||||
assert!(code.contains(uniform), "uniform {uniform} declared");
|
||||
}
|
||||
assert!(code.contains("ove_texcoord"));
|
||||
assert!(code.contains("frag_color"));
|
||||
assert!(!code.contains("switch"), "naga rejects GLSL switch");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_clones() {
|
||||
let (core, behavior) = create();
|
||||
let dup = behavior.duplicate(&core).unwrap();
|
||||
assert_eq!(dup.name(), "Gamma");
|
||||
assert_eq!(dup.type_id(), "org.olivevideoeditor.Olive.gamma");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,417 @@
|
||||
// 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/>.
|
||||
|
||||
//! Grade effect — a clean-room reimplementation of the OpenFX-Misc
|
||||
//! `Grade` plugin's parameter semantics (upstream
|
||||
//! `github.com/cgvirus/OpenFX-Misc`, GPL2; read for behavior only, no
|
||||
//! upstream code copied).
|
||||
//!
|
||||
//! A five-point level remap of the three color channels:
|
||||
//!
|
||||
//! ```text
|
||||
//! d = whitepoint - blackpoint
|
||||
//! a = (d != 0) ? (white - black) / d : 0
|
||||
//! b = black - a * blackpoint
|
||||
//! x = a * x + b
|
||||
//! x = (x > 0) ? pow(x, 1 / gamma) : x
|
||||
//! ```
|
||||
//!
|
||||
//! Alpha is left untouched (the reference's grade pass is color-only).
|
||||
//! The first line is the reference's black/white point stretch and the
|
||||
//! second its gamma pass; only the reciprocal exponent is kept from the
|
||||
//! reference's gamma stage. Deviations from the reference, all
|
||||
//! deliberate:
|
||||
//!
|
||||
//! * The reference's `multiply`/`offset` parameters are dropped — they
|
||||
//! sit at their identity values (`1.0`/`0.0`) in the reference's
|
||||
//! default setup and would add two no-op controls.
|
||||
//! * The reference guards its gamma pass with a small positive
|
||||
//! threshold; here only non-positive channels are skipped (`pow` has
|
||||
//! no real value there) and a non-positive gamma is clamped to a tiny
|
||||
//! minimum rather than producing an infinity or a division by zero.
|
||||
//! * A degenerate point range (`whitepoint == blackpoint`) leaves the
|
||||
//! slope at `0.0` instead of dividing by zero.
|
||||
|
||||
use crate::factory::NodeMeta;
|
||||
use crate::jobs::ShaderJobPayload;
|
||||
use crate::node::{Category, NodeBehavior, NodeCore};
|
||||
|
||||
/// Texture input id. Type: texture; flags: not-keyframable; this is
|
||||
/// the node's effect input.
|
||||
pub const TEXTURE_INPUT: &str = "tex_in";
|
||||
|
||||
/// Black point input id. Type: float; default `0.0`. Input level mapped
|
||||
/// to [`BLACK_INPUT`].
|
||||
pub const BLACKPOINT_INPUT: &str = "blackpoint_in";
|
||||
|
||||
/// White point input id. Type: float; default `1.0`. Input level mapped
|
||||
/// to [`WHITE_INPUT`].
|
||||
pub const WHITEPOINT_INPUT: &str = "whitepoint_in";
|
||||
|
||||
/// Black output input id. Type: float; default `0.0`. Output value the
|
||||
/// black point maps to.
|
||||
pub const BLACK_INPUT: &str = "black_in";
|
||||
|
||||
/// White output input id. Type: float; default `1.0`. Output value the
|
||||
/// white point maps to.
|
||||
pub const WHITE_INPUT: &str = "white_in";
|
||||
|
||||
/// Gamma input id. Type: float; default `1.0`. Reciprocal exponent
|
||||
/// applied after the level remap (`pow(x, 1/gamma)`).
|
||||
pub const GAMMA_INPUT: &str = "gamma_in";
|
||||
|
||||
/// Grade node. The reference class holds no state beyond its parameter
|
||||
/// pointers, so this is a unit-like struct.
|
||||
pub struct GradeNode;
|
||||
|
||||
/// Fragment shader (clean-room GLSL for the reference's
|
||||
/// `GradePlugin::render` chain). The uniforms are named after the node
|
||||
/// inputs: the renderer binds uniforms by matching the declared name
|
||||
/// against the job's parameter row.
|
||||
const SHADER_FRAG: &str = r#"// Inputs
|
||||
uniform sampler2D tex_in;
|
||||
|
||||
uniform float blackpoint_in;
|
||||
uniform float whitepoint_in;
|
||||
uniform float black_in;
|
||||
uniform float white_in;
|
||||
uniform float gamma_in;
|
||||
|
||||
// Input texture coordinate
|
||||
in vec2 ove_texcoord;
|
||||
out vec4 frag_color;
|
||||
|
||||
// Remap one channel: stretch [blackpoint, whitepoint] onto
|
||||
// [black, white], then apply the reciprocal-exponent gamma.
|
||||
float ove_grade(float v) {
|
||||
float d = whitepoint_in - blackpoint_in;
|
||||
float a = 0.0;
|
||||
if (d != 0.0) {
|
||||
a = (white_in - black_in) / d;
|
||||
}
|
||||
float b = black_in - a * blackpoint_in;
|
||||
v = a * v + b;
|
||||
|
||||
if (gamma_in != 1.0) {
|
||||
float g = max(gamma_in, 1.0e-8);
|
||||
if (v > 0.0) {
|
||||
v = pow(v, 1.0 / g);
|
||||
}
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec4 c = texture(tex_in, ove_texcoord);
|
||||
|
||||
c.r = ove_grade(c.r);
|
||||
c.g = ove_grade(c.g);
|
||||
c.b = ove_grade(c.b);
|
||||
|
||||
frag_color = c;
|
||||
}
|
||||
"#;
|
||||
|
||||
impl NodeBehavior for GradeNode {
|
||||
/// Human-readable name.
|
||||
fn name(&self) -> &str {
|
||||
"Grade"
|
||||
}
|
||||
|
||||
/// Stable type id.
|
||||
fn type_id(&self) -> &str {
|
||||
"org.olivevideoeditor.Olive.grade"
|
||||
}
|
||||
|
||||
/// Categories.
|
||||
fn categories(&self) -> &[Category] {
|
||||
&[Category::Color]
|
||||
}
|
||||
|
||||
/// Description.
|
||||
fn description(&self) -> &str {
|
||||
"Remap the color range with black/white points and a gamma."
|
||||
}
|
||||
|
||||
/// Localized input names: `tex_in` -> "Input", `blackpoint_in` ->
|
||||
/// "Black Point", `whitepoint_in` -> "White Point", `black_in` ->
|
||||
/// "Black Output", `white_in` -> "White Output", `gamma_in` ->
|
||||
/// "Gamma".
|
||||
fn input_name<'a>(&self, id: &'a str) -> &'a str {
|
||||
match id {
|
||||
TEXTURE_INPUT => "Input",
|
||||
BLACKPOINT_INPUT => "Black Point",
|
||||
WHITEPOINT_INPUT => "White Point",
|
||||
BLACK_INPUT => "Black Output",
|
||||
WHITE_INPUT => "White Output",
|
||||
GAMMA_INPUT => "Gamma",
|
||||
_ => id,
|
||||
}
|
||||
}
|
||||
|
||||
/// Evaluate outputs: no texture on `tex_in` -> push nothing;
|
||||
/// texture present -> push a shader job over the input row with
|
||||
/// every control resolved (so the renderer always finds a value for
|
||||
/// each uniform, whether the row carried the input or the node's own
|
||||
/// default/keyframe supplied it).
|
||||
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 resolve = |id: &str| match inputs.get(id) {
|
||||
Some(v) => v.clone(),
|
||||
None => core.value_at_time(id, -1, time),
|
||||
};
|
||||
|
||||
let mut params = inputs.clone();
|
||||
for id in [
|
||||
BLACKPOINT_INPUT,
|
||||
WHITEPOINT_INPUT,
|
||||
BLACK_INPUT,
|
||||
WHITE_INPUT,
|
||||
GAMMA_INPUT,
|
||||
] {
|
||||
params.insert(id.to_string(), resolve(id));
|
||||
}
|
||||
|
||||
table.push(
|
||||
crate::value::ValueType::Texture,
|
||||
crate::value::NodeValue::Texture(crate::handle::make_owned(ShaderJobPayload {
|
||||
node_id: crate::id::NodeId::INVALID,
|
||||
time,
|
||||
iterations: 1,
|
||||
type_id: self.type_id().to_string(),
|
||||
shader_id: String::new(),
|
||||
effect_input: core.effect_input.clone(),
|
||||
params,
|
||||
iterative_input: String::new(),
|
||||
})),
|
||||
None,
|
||||
);
|
||||
}
|
||||
|
||||
/// Shader code request: the request id is ignored; always returns
|
||||
/// [`SHADER_FRAG`].
|
||||
fn shader_code(&self, _request: &str) -> Option<String> {
|
||||
Some(SHADER_FRAG.to_string())
|
||||
}
|
||||
|
||||
/// Deep copy.
|
||||
fn duplicate(&self, _core: &NodeCore) -> Option<Box<dyn NodeBehavior>> {
|
||||
Some(Box::new(GradeNode))
|
||||
}
|
||||
}
|
||||
|
||||
/// Constructor: adds `tex_in` (texture, effect input) and the five
|
||||
/// controls with the defaults 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);
|
||||
|
||||
add_float_input(&mut core, BLACKPOINT_INPUT, 0.0);
|
||||
add_float_input(&mut core, WHITEPOINT_INPUT, 1.0);
|
||||
add_float_input(&mut core, BLACK_INPUT, 0.0);
|
||||
add_float_input(&mut core, WHITE_INPUT, 1.0);
|
||||
add_float_input(&mut core, GAMMA_INPUT, 1.0);
|
||||
|
||||
core.flags |= crate::node::flags::VIDEO_EFFECT;
|
||||
core.effect_input = TEXTURE_INPUT.to_string();
|
||||
|
||||
(core, Box::new(GradeNode))
|
||||
}
|
||||
|
||||
/// Add a float input with its default. The reference declares no range
|
||||
/// for these parameters, so no `min`/`max` properties are attached.
|
||||
fn add_float_input(core: &mut NodeCore, id: &str, default: f64) {
|
||||
core.add_input(crate::input::Input::new(
|
||||
id,
|
||||
crate::value::ValueType::Float,
|
||||
crate::value::NodeValue::Float(default),
|
||||
));
|
||||
}
|
||||
|
||||
/// Register this node type.
|
||||
pub fn register(meta: &mut Vec<NodeMeta>) {
|
||||
meta.push(NodeMeta {
|
||||
type_id: "org.olivevideoeditor.Olive.grade",
|
||||
name: "Grade",
|
||||
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 = GradeNode;
|
||||
assert_eq!(n.input_name(TEXTURE_INPUT), "Input");
|
||||
assert_eq!(n.input_name(BLACKPOINT_INPUT), "Black Point");
|
||||
assert_eq!(n.input_name(WHITEPOINT_INPUT), "White Point");
|
||||
assert_eq!(n.input_name(BLACK_INPUT), "Black Output");
|
||||
assert_eq!(n.input_name(WHITE_INPUT), "White Output");
|
||||
assert_eq!(n.input_name(GAMMA_INPUT), "Gamma");
|
||||
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.grade");
|
||||
assert_eq!(behavior.name(), "Grade");
|
||||
assert_eq!(behavior.categories(), &[Category::Color]);
|
||||
let tex = core.get_input(TEXTURE_INPUT).unwrap();
|
||||
assert_ne!(tex.flags & crate::input::flags::NOT_KEYFRAMABLE, 0);
|
||||
assert_eq!(
|
||||
core.get_input(BLACKPOINT_INPUT).unwrap().default,
|
||||
NodeValue::Float(0.0)
|
||||
);
|
||||
assert_eq!(
|
||||
core.get_input(WHITEPOINT_INPUT).unwrap().default,
|
||||
NodeValue::Float(1.0)
|
||||
);
|
||||
assert_eq!(
|
||||
core.get_input(BLACK_INPUT).unwrap().default,
|
||||
NodeValue::Float(0.0)
|
||||
);
|
||||
assert_eq!(
|
||||
core.get_input(WHITE_INPUT).unwrap().default,
|
||||
NodeValue::Float(1.0)
|
||||
);
|
||||
assert_eq!(
|
||||
core.get_input(GAMMA_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_with_texture_pushes_shader_job_with_resolved_params() {
|
||||
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);
|
||||
let Some(NodeValue::Texture(handle)) = table.get(ValueType::Texture) else {
|
||||
panic!("expected a texture-typed value");
|
||||
};
|
||||
let payload =
|
||||
unsafe { crate::handle::get_checked::<crate::jobs::ShaderJobPayload>(handle) }
|
||||
.expect("shader job pushed");
|
||||
assert_eq!(payload.type_id, "org.olivevideoeditor.Olive.grade");
|
||||
assert_eq!(payload.effect_input, TEXTURE_INPUT);
|
||||
assert_eq!(
|
||||
payload.params.get(BLACKPOINT_INPUT),
|
||||
Some(&NodeValue::Float(0.0))
|
||||
);
|
||||
assert_eq!(
|
||||
payload.params.get(WHITEPOINT_INPUT),
|
||||
Some(&NodeValue::Float(1.0))
|
||||
);
|
||||
assert_eq!(payload.params.get(BLACK_INPUT), Some(&NodeValue::Float(0.0)));
|
||||
assert_eq!(payload.params.get(WHITE_INPUT), Some(&NodeValue::Float(1.0)));
|
||||
assert_eq!(payload.params.get(GAMMA_INPUT), Some(&NodeValue::Float(1.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_row_values_win_over_defaults() {
|
||||
let (mut core, behavior) = create();
|
||||
core.set_standard_value(BLACKPOINT_INPUT, -1, NodeValue::Float(0.0));
|
||||
let inputs = crate::value::NodeValueRow::from([
|
||||
(
|
||||
TEXTURE_INPUT.to_string(),
|
||||
NodeValue::Texture(crate::handle::CHandle::null()),
|
||||
),
|
||||
(BLACKPOINT_INPUT.to_string(), NodeValue::Float(0.1)),
|
||||
]);
|
||||
let mut table = NodeValueTable::default();
|
||||
behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
|
||||
let Some(NodeValue::Texture(handle)) = table.get(ValueType::Texture) else {
|
||||
panic!("expected a texture-typed value");
|
||||
};
|
||||
let payload =
|
||||
unsafe { crate::handle::get_checked::<crate::jobs::ShaderJobPayload>(handle) }
|
||||
.expect("shader job pushed");
|
||||
assert_eq!(
|
||||
payload.params.get(BLACKPOINT_INPUT),
|
||||
Some(&NodeValue::Float(0.1))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shader_declares_uniforms_and_avoids_switch() {
|
||||
let code = GradeNode.shader_code("").unwrap();
|
||||
for uniform in [
|
||||
"tex_in",
|
||||
BLACKPOINT_INPUT,
|
||||
WHITEPOINT_INPUT,
|
||||
BLACK_INPUT,
|
||||
WHITE_INPUT,
|
||||
GAMMA_INPUT,
|
||||
] {
|
||||
assert!(code.contains(uniform), "uniform {uniform} declared");
|
||||
}
|
||||
assert!(code.contains("ove_texcoord"));
|
||||
assert!(code.contains("frag_color"));
|
||||
assert!(!code.contains("switch"), "naga rejects GLSL switch");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_clones() {
|
||||
let (core, behavior) = create();
|
||||
let dup = behavior.duplicate(&core).unwrap();
|
||||
assert_eq!(dup.name(), "Grade");
|
||||
assert_eq!(dup.type_id(), "org.olivevideoeditor.Olive.grade");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
// 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/>.
|
||||
|
||||
//! Invert effect — a clean-room reimplementation of the OpenFX-Misc
|
||||
//! `Invert` plugin's parameter semantics (upstream
|
||||
//! `github.com/cgvirus/OpenFX-Misc`, GPL2; read for behavior only, no
|
||||
//! upstream code copied).
|
||||
//!
|
||||
//! One enable toggle per channel:
|
||||
//!
|
||||
//! ```text
|
||||
//! x = enabled ? 1 - x : x (independently for r, g, b, a)
|
||||
//! ```
|
||||
//!
|
||||
//! All four toggles default to on, matching the reference, whose
|
||||
//! `processA`/`processR`/`processG`/`processB` booleans all default to
|
||||
//! `true`. Unlike the reference (fixed per-component instance
|
||||
//! parameters), each toggle is a separate node input here.
|
||||
|
||||
use crate::factory::NodeMeta;
|
||||
use crate::jobs::ShaderJobPayload;
|
||||
use crate::node::{Category, NodeBehavior, NodeCore};
|
||||
|
||||
/// Texture input id. Type: texture; flags: not-keyframable; this is
|
||||
/// the node's effect input.
|
||||
pub const TEXTURE_INPUT: &str = "tex_in";
|
||||
|
||||
/// Red channel toggle id. Type: boolean; default `true`.
|
||||
pub const INVERT_R_INPUT: &str = "invert_r_in";
|
||||
|
||||
/// Green channel toggle id. Type: boolean; default `true`.
|
||||
pub const INVERT_G_INPUT: &str = "invert_g_in";
|
||||
|
||||
/// Blue channel toggle id. Type: boolean; default `true`.
|
||||
pub const INVERT_B_INPUT: &str = "invert_b_in";
|
||||
|
||||
/// Alpha channel toggle id. Type: boolean; default `true`.
|
||||
pub const INVERT_A_INPUT: &str = "invert_a_in";
|
||||
|
||||
/// Invert node. The reference class holds no state beyond its
|
||||
/// parameter pointers, so this is a unit-like struct.
|
||||
pub struct InvertNode;
|
||||
|
||||
/// Fragment shader (clean-room GLSL for the reference's
|
||||
/// `InvertPlugin::render` chain). The uniforms are named after the node
|
||||
/// inputs: the renderer binds uniforms by matching the declared name
|
||||
/// against the job's parameter row.
|
||||
const SHADER_FRAG: &str = r#"// Inputs
|
||||
uniform sampler2D tex_in;
|
||||
|
||||
uniform bool invert_r_in;
|
||||
uniform bool invert_g_in;
|
||||
uniform bool invert_b_in;
|
||||
uniform bool invert_a_in;
|
||||
|
||||
// Input texture coordinate
|
||||
in vec2 ove_texcoord;
|
||||
out vec4 frag_color;
|
||||
|
||||
void main() {
|
||||
vec4 c = texture(tex_in, ove_texcoord);
|
||||
|
||||
// One independent toggle per channel.
|
||||
if (invert_r_in) {
|
||||
c.r = 1.0 - c.r;
|
||||
}
|
||||
if (invert_g_in) {
|
||||
c.g = 1.0 - c.g;
|
||||
}
|
||||
if (invert_b_in) {
|
||||
c.b = 1.0 - c.b;
|
||||
}
|
||||
if (invert_a_in) {
|
||||
c.a = 1.0 - c.a;
|
||||
}
|
||||
|
||||
frag_color = c;
|
||||
}
|
||||
"#;
|
||||
|
||||
impl NodeBehavior for InvertNode {
|
||||
/// Human-readable name.
|
||||
fn name(&self) -> &str {
|
||||
"Invert"
|
||||
}
|
||||
|
||||
/// Stable type id.
|
||||
fn type_id(&self) -> &str {
|
||||
"org.olivevideoeditor.Olive.invert"
|
||||
}
|
||||
|
||||
/// Categories.
|
||||
fn categories(&self) -> &[Category] {
|
||||
&[Category::Color]
|
||||
}
|
||||
|
||||
/// Description.
|
||||
fn description(&self) -> &str {
|
||||
"Invert individual color channels."
|
||||
}
|
||||
|
||||
/// Localized input names: `tex_in` -> "Input" and one "Invert
|
||||
/// <Channel>" label per toggle.
|
||||
fn input_name<'a>(&self, id: &'a str) -> &'a str {
|
||||
match id {
|
||||
TEXTURE_INPUT => "Input",
|
||||
INVERT_R_INPUT => "Invert Red",
|
||||
INVERT_G_INPUT => "Invert Green",
|
||||
INVERT_B_INPUT => "Invert Blue",
|
||||
INVERT_A_INPUT => "Invert Alpha",
|
||||
_ => id,
|
||||
}
|
||||
}
|
||||
|
||||
/// Evaluate outputs: no texture on `tex_in` -> push nothing;
|
||||
/// texture present -> push a shader job over the input row with
|
||||
/// every toggle resolved (so the renderer always finds a value for
|
||||
/// each uniform, whether the row carried the input or the node's own
|
||||
/// default/keyframe supplied it).
|
||||
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 resolve = |id: &str| match inputs.get(id) {
|
||||
Some(v) => v.clone(),
|
||||
None => core.value_at_time(id, -1, time),
|
||||
};
|
||||
|
||||
let mut params = inputs.clone();
|
||||
for id in [
|
||||
INVERT_R_INPUT,
|
||||
INVERT_G_INPUT,
|
||||
INVERT_B_INPUT,
|
||||
INVERT_A_INPUT,
|
||||
] {
|
||||
params.insert(id.to_string(), resolve(id));
|
||||
}
|
||||
|
||||
table.push(
|
||||
crate::value::ValueType::Texture,
|
||||
crate::value::NodeValue::Texture(crate::handle::make_owned(ShaderJobPayload {
|
||||
node_id: crate::id::NodeId::INVALID,
|
||||
time,
|
||||
iterations: 1,
|
||||
type_id: self.type_id().to_string(),
|
||||
shader_id: String::new(),
|
||||
effect_input: core.effect_input.clone(),
|
||||
params,
|
||||
iterative_input: String::new(),
|
||||
})),
|
||||
None,
|
||||
);
|
||||
}
|
||||
|
||||
/// Shader code request: the request id is ignored; always returns
|
||||
/// [`SHADER_FRAG`].
|
||||
fn shader_code(&self, _request: &str) -> Option<String> {
|
||||
Some(SHADER_FRAG.to_string())
|
||||
}
|
||||
|
||||
/// Deep copy.
|
||||
fn duplicate(&self, _core: &NodeCore) -> Option<Box<dyn NodeBehavior>> {
|
||||
Some(Box::new(InvertNode))
|
||||
}
|
||||
}
|
||||
|
||||
/// Constructor: adds `tex_in` (texture, effect input) and the four
|
||||
/// channel toggles, all defaulting to on, 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);
|
||||
|
||||
for id in [
|
||||
INVERT_R_INPUT,
|
||||
INVERT_G_INPUT,
|
||||
INVERT_B_INPUT,
|
||||
INVERT_A_INPUT,
|
||||
] {
|
||||
core.add_input(crate::input::Input::new(
|
||||
id,
|
||||
crate::value::ValueType::Boolean,
|
||||
crate::value::NodeValue::Boolean(true),
|
||||
));
|
||||
}
|
||||
|
||||
core.flags |= crate::node::flags::VIDEO_EFFECT;
|
||||
core.effect_input = TEXTURE_INPUT.to_string();
|
||||
|
||||
(core, Box::new(InvertNode))
|
||||
}
|
||||
|
||||
/// Register this node type.
|
||||
pub fn register(meta: &mut Vec<NodeMeta>) {
|
||||
meta.push(NodeMeta {
|
||||
type_id: "org.olivevideoeditor.Olive.invert",
|
||||
name: "Invert",
|
||||
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 = InvertNode;
|
||||
assert_eq!(n.input_name(TEXTURE_INPUT), "Input");
|
||||
assert_eq!(n.input_name(INVERT_R_INPUT), "Invert Red");
|
||||
assert_eq!(n.input_name(INVERT_G_INPUT), "Invert Green");
|
||||
assert_eq!(n.input_name(INVERT_B_INPUT), "Invert Blue");
|
||||
assert_eq!(n.input_name(INVERT_A_INPUT), "Invert Alpha");
|
||||
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.invert");
|
||||
assert_eq!(behavior.name(), "Invert");
|
||||
assert_eq!(behavior.categories(), &[Category::Color]);
|
||||
let tex = core.get_input(TEXTURE_INPUT).unwrap();
|
||||
assert_ne!(tex.flags & crate::input::flags::NOT_KEYFRAMABLE, 0);
|
||||
// The reference defaults every process* toggle to on.
|
||||
for id in [
|
||||
INVERT_R_INPUT,
|
||||
INVERT_G_INPUT,
|
||||
INVERT_B_INPUT,
|
||||
INVERT_A_INPUT,
|
||||
] {
|
||||
assert_eq!(
|
||||
core.get_input(id).unwrap().default,
|
||||
NodeValue::Boolean(true),
|
||||
"{id} defaults to on"
|
||||
);
|
||||
}
|
||||
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_shader_job_with_resolved_params() {
|
||||
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);
|
||||
let Some(NodeValue::Texture(handle)) = table.get(ValueType::Texture) else {
|
||||
panic!("expected a texture-typed value");
|
||||
};
|
||||
let payload =
|
||||
unsafe { crate::handle::get_checked::<crate::jobs::ShaderJobPayload>(handle) }
|
||||
.expect("shader job pushed");
|
||||
assert_eq!(payload.type_id, "org.olivevideoeditor.Olive.invert");
|
||||
assert_eq!(payload.effect_input, TEXTURE_INPUT);
|
||||
for id in [
|
||||
INVERT_R_INPUT,
|
||||
INVERT_G_INPUT,
|
||||
INVERT_B_INPUT,
|
||||
INVERT_A_INPUT,
|
||||
] {
|
||||
assert_eq!(payload.params.get(id), Some(&NodeValue::Boolean(true)));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_row_values_win_over_defaults() {
|
||||
let (mut core, behavior) = create();
|
||||
core.set_standard_value(INVERT_A_INPUT, -1, NodeValue::Boolean(true));
|
||||
let inputs = crate::value::NodeValueRow::from([
|
||||
(
|
||||
TEXTURE_INPUT.to_string(),
|
||||
NodeValue::Texture(crate::handle::CHandle::null()),
|
||||
),
|
||||
(INVERT_A_INPUT.to_string(), NodeValue::Boolean(false)),
|
||||
]);
|
||||
let mut table = NodeValueTable::default();
|
||||
behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
|
||||
let Some(NodeValue::Texture(handle)) = table.get(ValueType::Texture) else {
|
||||
panic!("expected a texture-typed value");
|
||||
};
|
||||
let payload =
|
||||
unsafe { crate::handle::get_checked::<crate::jobs::ShaderJobPayload>(handle) }
|
||||
.expect("shader job pushed");
|
||||
assert_eq!(
|
||||
payload.params.get(INVERT_A_INPUT),
|
||||
Some(&NodeValue::Boolean(false))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shader_declares_uniforms_and_avoids_switch() {
|
||||
let code = InvertNode.shader_code("").unwrap();
|
||||
for uniform in [
|
||||
"tex_in",
|
||||
INVERT_R_INPUT,
|
||||
INVERT_G_INPUT,
|
||||
INVERT_B_INPUT,
|
||||
INVERT_A_INPUT,
|
||||
] {
|
||||
assert!(code.contains(uniform), "uniform {uniform} declared");
|
||||
}
|
||||
assert!(code.contains("ove_texcoord"));
|
||||
assert!(code.contains("frag_color"));
|
||||
assert!(!code.contains("switch"), "naga rejects GLSL switch");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_clones() {
|
||||
let (core, behavior) = create();
|
||||
let dup = behavior.duplicate(&core).unwrap();
|
||||
assert_eq!(dup.name(), "Invert");
|
||||
assert_eq!(dup.type_id(), "org.olivevideoeditor.Olive.invert");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
// 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/>.
|
||||
|
||||
//! KeyMix effect (clean-room reimplementation of the OpenFX-Misc
|
||||
//! `KeyMixOFX` / `net.sf.openfx.KeyMix`; the `KeyMix` reference tree is
|
||||
//! used for parameter semantics only, no code copied).
|
||||
//!
|
||||
//! Upstream copies the foreground over the background wherever the mask
|
||||
//! is opaque (`ofxsMaskMixPix` with its fixed `mix = 1`): a connected
|
||||
//! mask paints the foreground through its alpha, an unconnected mask is
|
||||
//! treated as white and the foreground wins everywhere. This node
|
||||
//! reduces that to the mask's alpha threshold — `mask.a > 0` takes the
|
||||
//! blend, else the input — which is the mask-binding piece of the
|
||||
//! Tier-1 merge group (the mask input mirrors the chroma-key/despill
|
||||
//! texture binding: a plain not-keyframable texture input).
|
||||
//!
|
||||
//! Like [`crate::nodes::merge`], the node never sets a
|
||||
//! `core.effect_input`; the both-present case boxes one
|
||||
//! [`ShaderJobPayload`] whose params row carries all three textures, and
|
||||
//! the renderer binds `tex_in`/`blend_in`/`mask_in` by name (the pass
|
||||
//! size follows the first bound texture — `blend_in`, the
|
||||
//! alphabetically first key of the job's `BTreeMap` row).
|
||||
|
||||
use crate::factory::NodeMeta;
|
||||
use crate::jobs::ShaderJobPayload;
|
||||
use crate::node::{Category, NodeBehavior, NodeCore};
|
||||
|
||||
/// Background texture input id. Type: texture; flags: not-keyframable.
|
||||
pub const TEXTURE_INPUT: &str = "tex_in";
|
||||
|
||||
/// Foreground texture input id. Type: texture; flags: not-keyframable.
|
||||
pub const BLEND_INPUT: &str = "blend_in";
|
||||
|
||||
/// Mask texture input id (upstream's mask input, "Mask"). Type:
|
||||
/// texture; flags: not-keyframable. Its **alpha** selects per pixel
|
||||
/// (`> 0` keeps the foreground); an unconnected mask selects it
|
||||
/// everywhere.
|
||||
pub const MASK_INPUT: &str = "mask_in";
|
||||
|
||||
/// KeyMix node. Unit-like: there is no per-instance state.
|
||||
pub struct KeyMixNode;
|
||||
|
||||
/// Fragment shader: copy the blend over the input through the mask's
|
||||
/// alpha. The `_enabled` flags mirror the alpha-over shader's convention
|
||||
/// for unbound samplers; the mask one carries upstream's "no mask means
|
||||
/// white" rule. `switch` is deliberately not used (naga rejects it).
|
||||
///
|
||||
/// The ternary is kept off the `texture()` call on purpose (naga's GLSL
|
||||
/// front end is happiest with the plain-branch spelling), and the
|
||||
/// `value()` pass-through means the "input missing" branches are only
|
||||
/// reachable through a direct renderer call.
|
||||
const SHADER_FRAG: &str = r#"uniform sampler2D tex_in;
|
||||
uniform sampler2D blend_in;
|
||||
uniform sampler2D mask_in;
|
||||
uniform bool tex_in_enabled;
|
||||
uniform bool blend_in_enabled;
|
||||
uniform bool mask_in_enabled;
|
||||
|
||||
in vec2 ove_texcoord;
|
||||
out vec4 frag_color;
|
||||
|
||||
void main(void) {
|
||||
if (!tex_in_enabled && !blend_in_enabled) {
|
||||
frag_color = vec4(0.0);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!tex_in_enabled) {
|
||||
frag_color = texture(blend_in, ove_texcoord);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!blend_in_enabled) {
|
||||
frag_color = texture(tex_in, ove_texcoord);
|
||||
return;
|
||||
}
|
||||
|
||||
vec4 tex_col = texture(tex_in, ove_texcoord);
|
||||
vec4 blend_col = texture(blend_in, ove_texcoord);
|
||||
|
||||
// No mask connected: upstream's mask-mix helper sees no mask image
|
||||
// and uses an opaque one, so the foreground wins everywhere.
|
||||
vec4 mask_col = vec4(1.0);
|
||||
if (mask_in_enabled) {
|
||||
mask_col = texture(mask_in, ove_texcoord);
|
||||
}
|
||||
|
||||
frag_color = mask_col.a > 0.0 ? blend_col : tex_col;
|
||||
}
|
||||
"#;
|
||||
|
||||
impl KeyMixNode {
|
||||
/// Fragment shader (single variant, so the request id is ignored).
|
||||
fn shader_frag() -> &'static str {
|
||||
SHADER_FRAG
|
||||
}
|
||||
}
|
||||
|
||||
impl NodeBehavior for KeyMixNode {
|
||||
/// Human-readable name.
|
||||
fn name(&self) -> &str {
|
||||
"KeyMix"
|
||||
}
|
||||
|
||||
/// Stable type id.
|
||||
fn type_id(&self) -> &str {
|
||||
"org.olivevideoeditor.Olive.keymix"
|
||||
}
|
||||
|
||||
/// Categories. Filed under math like the alpha-over
|
||||
/// [`crate::nodes::merge`] (the `Category` enum has no merge group).
|
||||
fn categories(&self) -> &[Category] {
|
||||
&[Category::Math]
|
||||
}
|
||||
|
||||
/// Description.
|
||||
fn description(&self) -> &str {
|
||||
"Mix two textures by a mask's alpha."
|
||||
}
|
||||
|
||||
/// Localized input names: `tex_in` -> "Input", `blend_in` ->
|
||||
/// "Blend", `mask_in` -> "Mask".
|
||||
fn input_name<'a>(&self, id: &'a str) -> &'a str {
|
||||
match id {
|
||||
TEXTURE_INPUT => "Input",
|
||||
BLEND_INPUT => "Blend",
|
||||
MASK_INPUT => "Mask",
|
||||
_ => id,
|
||||
}
|
||||
}
|
||||
|
||||
/// Evaluate outputs: if only one of the two picture textures is
|
||||
/// present, push it as-is (no mask can conjure the other side);
|
||||
/// if both are present, push one shader job over the whole input
|
||||
/// row — the mask rides in the row and may be absent, which the
|
||||
/// shader reads as fully opaque. If neither picture is present,
|
||||
/// push nothing.
|
||||
fn value(
|
||||
&self,
|
||||
core: &NodeCore,
|
||||
inputs: &crate::value::NodeValueRow,
|
||||
time: oak_core::Rational,
|
||||
table: &mut crate::value::NodeValueTable,
|
||||
) {
|
||||
use crate::value::{NodeValue, ValueType};
|
||||
|
||||
let tex = inputs.get(TEXTURE_INPUT);
|
||||
let blend = inputs.get(BLEND_INPUT);
|
||||
|
||||
match (tex, blend) {
|
||||
(Some(NodeValue::Texture(_)), Some(NodeValue::Texture(_))) => {
|
||||
table.push(
|
||||
ValueType::Texture,
|
||||
NodeValue::Texture(crate::handle::make_owned(ShaderJobPayload {
|
||||
node_id: crate::id::NodeId::INVALID,
|
||||
time,
|
||||
iterations: 1,
|
||||
type_id: self.type_id().to_string(),
|
||||
shader_id: String::new(),
|
||||
effect_input: core.effect_input.clone(),
|
||||
params: inputs.clone(),
|
||||
iterative_input: String::new(),
|
||||
})),
|
||||
None,
|
||||
);
|
||||
}
|
||||
(Some(t @ NodeValue::Texture(_)), _) => {
|
||||
table.push(ValueType::Texture, t.clone(), None);
|
||||
}
|
||||
(_, Some(b @ NodeValue::Texture(_))) => {
|
||||
table.push(ValueType::Texture, b.clone(), None);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Shader code request: always the mask-copy fragment source.
|
||||
fn shader_code(&self, _request: &str) -> Option<String> {
|
||||
Some(Self::shader_frag().to_string())
|
||||
}
|
||||
|
||||
/// Deep copy.
|
||||
fn duplicate(&self, _core: &NodeCore) -> Option<Box<dyn NodeBehavior>> {
|
||||
Some(Box::new(KeyMixNode))
|
||||
}
|
||||
}
|
||||
|
||||
/// Constructor: adds `tex_in`, `blend_in` and `mask_in` as
|
||||
/// not-keyframable texture inputs and sets the video-effect flag (no
|
||||
/// effect input: every texture binds by name).
|
||||
pub fn create() -> (NodeCore, Box<dyn NodeBehavior>) {
|
||||
let mut core = NodeCore::new();
|
||||
|
||||
for id in [TEXTURE_INPUT, BLEND_INPUT, MASK_INPUT] {
|
||||
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);
|
||||
}
|
||||
|
||||
core.flags |= crate::node::flags::VIDEO_EFFECT;
|
||||
|
||||
(core, Box::new(KeyMixNode))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::value::{NodeValue, NodeValueTable, ValueType};
|
||||
use oak_core::Rational;
|
||||
|
||||
fn tex() -> NodeValue {
|
||||
NodeValue::Texture(crate::handle::CHandle::null())
|
||||
}
|
||||
|
||||
fn run(core: &NodeCore, inputs: &crate::value::NodeValueRow) -> ShaderJobPayload {
|
||||
let behavior = KeyMixNode;
|
||||
let mut table = NodeValueTable::default();
|
||||
behavior.value(core, inputs, Rational::new(0, 1), &mut table);
|
||||
let Some(NodeValue::Texture(h)) = table.get(ValueType::Texture) else {
|
||||
panic!("shader job expected");
|
||||
};
|
||||
unsafe { crate::handle::get_checked::<ShaderJobPayload>(h) }
|
||||
.expect("shader job payload boxed")
|
||||
.clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn input_names() {
|
||||
let n = KeyMixNode;
|
||||
assert_eq!(n.input_name(TEXTURE_INPUT), "Input");
|
||||
assert_eq!(n.input_name(BLEND_INPUT), "Blend");
|
||||
assert_eq!(n.input_name(MASK_INPUT), "Mask");
|
||||
assert_eq!(n.input_name("other"), "other");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_wires_inputs_and_flags() {
|
||||
let (core, behavior) = create();
|
||||
assert_eq!(behavior.type_id(), "org.olivevideoeditor.Olive.keymix");
|
||||
assert_eq!(behavior.name(), "KeyMix");
|
||||
assert_eq!(behavior.categories(), &[Category::Math]);
|
||||
assert_ne!(core.flags & crate::node::flags::VIDEO_EFFECT, 0);
|
||||
assert_eq!(core.effect_input, "");
|
||||
|
||||
for id in [TEXTURE_INPUT, BLEND_INPUT, MASK_INPUT] {
|
||||
let input = core.get_input(id).expect("texture input");
|
||||
assert_eq!(input.value_type, ValueType::Texture);
|
||||
assert_ne!(input.flags & crate::input::flags::NOT_KEYFRAMABLE, 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_tex_only_pushes_tex() {
|
||||
let (core, behavior) = create();
|
||||
let input = tex();
|
||||
let inputs = crate::value::NodeValueRow::from([(TEXTURE_INPUT.to_string(), input.clone())]);
|
||||
let mut table = NodeValueTable::default();
|
||||
behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
|
||||
assert_eq!(table.get(ValueType::Texture), Some(&input));
|
||||
}
|
||||
|
||||
#[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_mask_only_pushes_nothing() {
|
||||
let (core, behavior) = create();
|
||||
let inputs = crate::value::NodeValueRow::from([(MASK_INPUT.to_string(), tex())]);
|
||||
let mut table = NodeValueTable::default();
|
||||
behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
|
||||
assert!(table.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_both_pushes_job_payload_with_mask() {
|
||||
let (core, _) = create();
|
||||
let inputs = crate::value::NodeValueRow::from([
|
||||
(TEXTURE_INPUT.to_string(), tex()),
|
||||
(BLEND_INPUT.to_string(), tex()),
|
||||
(MASK_INPUT.to_string(), tex()),
|
||||
]);
|
||||
let payload = run(&core, &inputs);
|
||||
assert_eq!(payload.type_id, "org.olivevideoeditor.Olive.keymix");
|
||||
assert_eq!(payload.shader_id, "");
|
||||
assert_eq!(payload.iterations, 1);
|
||||
assert_eq!(payload.iterative_input, "");
|
||||
assert!(payload.params.contains_key(TEXTURE_INPUT));
|
||||
assert!(payload.params.contains_key(BLEND_INPUT));
|
||||
assert!(payload.params.contains_key(MASK_INPUT));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_both_without_mask_still_pushes_job() {
|
||||
let (core, _) = create();
|
||||
let inputs = crate::value::NodeValueRow::from([
|
||||
(TEXTURE_INPUT.to_string(), tex()),
|
||||
(BLEND_INPUT.to_string(), tex()),
|
||||
]);
|
||||
let payload = run(&core, &inputs);
|
||||
assert!(!payload.params.contains_key(MASK_INPUT));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shader_code_declares_inputs_without_switch() {
|
||||
let n = KeyMixNode;
|
||||
let glsl = n.shader_code("").unwrap();
|
||||
assert!(glsl.contains("uniform sampler2D tex_in;"));
|
||||
assert!(glsl.contains("uniform sampler2D blend_in;"));
|
||||
assert!(glsl.contains("uniform sampler2D mask_in;"));
|
||||
assert!(glsl.contains("uniform bool mask_in_enabled;"));
|
||||
assert!(glsl.contains("mask_col = vec4(1.0);"));
|
||||
assert!(glsl.contains("frag_color = mask_col.a > 0.0 ? blend_col : tex_col;"));
|
||||
assert!(!glsl.contains("switch"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_clones_behavior() {
|
||||
let (core, behavior) = create();
|
||||
let dup = behavior.duplicate(&core).unwrap();
|
||||
assert_eq!(dup.name(), "KeyMix");
|
||||
}
|
||||
}
|
||||
|
||||
/// Register this node type.
|
||||
pub fn register(meta: &mut Vec<NodeMeta>) {
|
||||
meta.push(NodeMeta {
|
||||
type_id: "org.olivevideoeditor.Olive.keymix",
|
||||
name: "KeyMix",
|
||||
categories: &[Category::Math],
|
||||
create,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
// Oak Video Editor - Non-Linear Video Editor
|
||||
// Copyright (C) 2026 Oak Team
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! Mirror effect: clean-room reimplementation of the OpenFX-Misc `Mirror`
|
||||
//! plugin's parameter semantics (upstream github.com/cgvirus/OpenFX-Misc,
|
||||
//! GPL2; read for behavior only, no upstream code copied).
|
||||
//!
|
||||
//! Upstream `Mirror` and this crate's [`super::flipdistortnode`] describe
|
||||
//! the same operation with the same algebra (mirror the sample
|
||||
//! coordinates about the frame center), so this node is a thin alias: it
|
||||
//! reuses the flip fragment shader verbatim through
|
||||
//! [`super::flipdistortnode::FlipDistortNode`]'s
|
||||
//! [`NodeBehavior::shader_code`] and only maps its own input names onto
|
||||
//! the shader's uniforms. No second shader is duplicated.
|
||||
|
||||
use crate::factory::NodeMeta;
|
||||
use crate::jobs::ShaderJobPayload;
|
||||
use crate::node::{Category, NodeBehavior, NodeCore};
|
||||
|
||||
/// Main texture input id. Type: texture; flags: not-keyframable; this is
|
||||
/// the node's effect input. Same key as the flip node's texture input, so
|
||||
/// the flip shader's `tex_in` sampler finds it unchanged.
|
||||
pub const TEXTURE_INPUT: &str = "tex_in";
|
||||
|
||||
/// Horizontal mirror input id (upstream `kParamMirrorFlop` "Horizontal
|
||||
/// (flop)"). Type: bool; default `false`; mirrors left/right.
|
||||
pub const HORIZONTAL_INPUT: &str = "horizontal_in";
|
||||
|
||||
/// Vertical mirror input id (upstream `kParamMirrorFlip` "Vertical
|
||||
/// (flip)"). Type: bool; default `false`; mirrors top/bottom.
|
||||
pub const VERTICAL_INPUT: &str = "vertical_in";
|
||||
|
||||
/// Mirror node. Mirrors the image horizontally and/or vertically.
|
||||
pub struct MirrorNode;
|
||||
|
||||
impl NodeBehavior for MirrorNode {
|
||||
/// Human-readable name.
|
||||
fn name(&self) -> &str {
|
||||
"Mirror"
|
||||
}
|
||||
|
||||
/// Stable type id.
|
||||
fn type_id(&self) -> &str {
|
||||
"org.olivevideoeditor.Olive.mirror"
|
||||
}
|
||||
|
||||
/// Categories.
|
||||
fn categories(&self) -> &[Category] {
|
||||
&[Category::Distort]
|
||||
}
|
||||
|
||||
/// Description.
|
||||
fn description(&self) -> &str {
|
||||
"Mirrors an image horizontally or vertically"
|
||||
}
|
||||
|
||||
/// Localized input names. The upstream labels name the axis of the
|
||||
/// flip, so `horizontal_in` (flop, mirrors left/right) is labelled
|
||||
/// "Horizontal (flop)" and `vertical_in` (flip, mirrors top/bottom)
|
||||
/// "Vertical (flip)".
|
||||
fn input_name<'a>(&self, id: &'a str) -> &'a str {
|
||||
match id {
|
||||
TEXTURE_INPUT => "Input",
|
||||
HORIZONTAL_INPUT => "Horizontal (flop)",
|
||||
VERTICAL_INPUT => "Vertical (flip)",
|
||||
_ => id,
|
||||
}
|
||||
}
|
||||
|
||||
/// Evaluate outputs: no texture -> push nothing; neither flag set ->
|
||||
/// pass-through push of the input texture unchanged; otherwise a shader
|
||||
/// job running the shared flip fragment shader.
|
||||
///
|
||||
/// The job boxes a [`ShaderJobPayload`] whose `type_id` is this node's
|
||||
/// (so the behavior is looked up here) while its `shader_id` is empty:
|
||||
/// [`shader_code`](NodeBehavior::shader_code) forwards to the flip
|
||||
/// node. The params row is the value row with this node's boolean keys
|
||||
/// renamed to the uniform names the flip shader declares
|
||||
/// (`horiz_in`/`vert_in`) — the renderer packs uniforms by declared
|
||||
/// name and silently leaves undeclared ones at 0, so a raw pass-through
|
||||
/// row would render an unmirrored image.
|
||||
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 {
|
||||
table.push(crate::value::ValueType::Texture, tex, None);
|
||||
return;
|
||||
}
|
||||
|
||||
let mut params = inputs.clone();
|
||||
params.remove(HORIZONTAL_INPUT);
|
||||
params.remove(VERTICAL_INPUT);
|
||||
params.insert(
|
||||
super::flipdistortnode::HORIZONTAL_INPUT.to_string(),
|
||||
crate::value::NodeValue::Boolean(horiz),
|
||||
);
|
||||
params.insert(
|
||||
super::flipdistortnode::VERTICAL_INPUT.to_string(),
|
||||
crate::value::NodeValue::Boolean(vert),
|
||||
);
|
||||
|
||||
table.push(
|
||||
crate::value::ValueType::Texture,
|
||||
crate::value::NodeValue::Texture(crate::handle::make_owned(ShaderJobPayload {
|
||||
node_id: crate::id::NodeId::INVALID,
|
||||
time,
|
||||
iterations: 1,
|
||||
type_id: self.type_id().to_string(),
|
||||
shader_id: String::new(),
|
||||
effect_input: core.effect_input.clone(),
|
||||
params,
|
||||
iterative_input: TEXTURE_INPUT.to_string(),
|
||||
})),
|
||||
None,
|
||||
);
|
||||
}
|
||||
|
||||
/// Shader code request: delegates to the flip node's shader (the flip
|
||||
/// request id is ignored there — it has a single shader variant).
|
||||
fn shader_code(&self, request: &str) -> Option<String> {
|
||||
super::flipdistortnode::FlipDistortNode.shader_code(request)
|
||||
}
|
||||
|
||||
/// Deep copy.
|
||||
fn duplicate(&self, _core: &NodeCore) -> Option<Box<dyn NodeBehavior>> {
|
||||
Some(Box::new(MirrorNode))
|
||||
}
|
||||
}
|
||||
|
||||
/// Constructor: adds `tex_in`, `horizontal_in` and `vertical_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(MirrorNode))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::node::NodeBehavior;
|
||||
use crate::value::{NodeValue, NodeValueRow, NodeValueTable, ValueType};
|
||||
use oak_core::Rational;
|
||||
|
||||
fn tex() -> NodeValue {
|
||||
NodeValue::Texture(crate::handle::CHandle::null())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn input_names() {
|
||||
let n = MirrorNode;
|
||||
assert_eq!(n.input_name(TEXTURE_INPUT), "Input");
|
||||
assert_eq!(n.input_name(HORIZONTAL_INPUT), "Horizontal (flop)");
|
||||
assert_eq!(n.input_name(VERTICAL_INPUT), "Vertical (flip)");
|
||||
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.mirror");
|
||||
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_ne!(
|
||||
core.get_input(TEXTURE_INPUT).unwrap().flags & crate::input::flags::NOT_KEYFRAMABLE,
|
||||
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,
|
||||
&NodeValueRow::default(),
|
||||
Rational::new(0, 1),
|
||||
&mut table,
|
||||
);
|
||||
assert!(table.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_no_mirror_passes_texture_through() {
|
||||
let (core, behavior) = create();
|
||||
let tex = tex();
|
||||
let inputs = 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_translates_flag_names_to_the_flip_uniforms() {
|
||||
let (core, behavior) = create();
|
||||
let inputs = NodeValueRow::from([
|
||||
(TEXTURE_INPUT.to_string(), tex()),
|
||||
(HORIZONTAL_INPUT.to_string(), NodeValue::Boolean(true)),
|
||||
(VERTICAL_INPUT.to_string(), NodeValue::Boolean(false)),
|
||||
]);
|
||||
let mut table = NodeValueTable::default();
|
||||
behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
|
||||
let handle = match table.get(ValueType::Texture) {
|
||||
Some(NodeValue::Texture(h)) => *h,
|
||||
_ => panic!("texture expected"),
|
||||
};
|
||||
let payload = unsafe { crate::handle::get_checked::<ShaderJobPayload>(&handle) }
|
||||
.expect("shader job payload expected");
|
||||
assert_eq!(payload.type_id, "org.olivevideoeditor.Olive.mirror");
|
||||
assert_eq!(payload.shader_id, "");
|
||||
assert_eq!(payload.iterations, 1);
|
||||
assert_eq!(payload.effect_input, TEXTURE_INPUT);
|
||||
assert_eq!(payload.iterative_input, TEXTURE_INPUT);
|
||||
// The shader reads `horiz_in`/`vert_in`; the node's own keys are gone.
|
||||
assert_eq!(
|
||||
payload
|
||||
.params
|
||||
.get(super::super::flipdistortnode::HORIZONTAL_INPUT),
|
||||
Some(&NodeValue::Boolean(true))
|
||||
);
|
||||
assert_eq!(
|
||||
payload
|
||||
.params
|
||||
.get(super::super::flipdistortnode::VERTICAL_INPUT),
|
||||
Some(&NodeValue::Boolean(false))
|
||||
);
|
||||
assert!(!payload.params.contains_key(HORIZONTAL_INPUT));
|
||||
assert!(!payload.params.contains_key(VERTICAL_INPUT));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_connected_flags_win_over_the_node_values() {
|
||||
let (mut core, behavior) = create();
|
||||
core.set_standard_value(HORIZONTAL_INPUT, -1, NodeValue::Boolean(false));
|
||||
core.set_standard_value(VERTICAL_INPUT, -1, NodeValue::Boolean(true));
|
||||
let inputs = NodeValueRow::from([
|
||||
(TEXTURE_INPUT.to_string(), tex()),
|
||||
(VERTICAL_INPUT.to_string(), NodeValue::Boolean(false)),
|
||||
]);
|
||||
let mut table = NodeValueTable::default();
|
||||
behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
|
||||
// The row's false for vertical wins over the node's true, so both
|
||||
// flags are off and the texture passes through unmirrored.
|
||||
match table.get(ValueType::Texture) {
|
||||
Some(NodeValue::Texture(h)) => assert!(h.is_null()),
|
||||
other => panic!("pass-through texture expected, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shader_code_returns_the_flip_shader() {
|
||||
let code = MirrorNode.shader_code("").unwrap();
|
||||
assert!(code.contains("uniform sampler2D tex_in;"));
|
||||
assert!(code.contains("uniform bool horiz_in;"));
|
||||
assert!(code.contains("uniform bool vert_in;"));
|
||||
assert!(code.contains("if (horiz_in) new_coord.x = 1.0 - new_coord.x;"));
|
||||
assert!(code.contains("if (vert_in) new_coord.y = 1.0 - new_coord.y;"));
|
||||
assert!(!code.contains("switch"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_clones() {
|
||||
let (core, behavior) = create();
|
||||
let dup = behavior.duplicate(&core).unwrap();
|
||||
assert_eq!(dup.name(), "Mirror");
|
||||
}
|
||||
}
|
||||
|
||||
/// Register this node type.
|
||||
pub fn register(meta: &mut Vec<NodeMeta>) {
|
||||
meta.push(NodeMeta {
|
||||
type_id: "org.olivevideoeditor.Olive.mirror",
|
||||
name: "Mirror",
|
||||
categories: &[Category::Distort],
|
||||
create,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
// 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/>.
|
||||
|
||||
//! Position effect: clean-room reimplementation of the OpenFX-Misc
|
||||
//! `Position` plugin's parameter semantics (upstream
|
||||
//! github.com/cgvirus/OpenFX-Misc, GPL2; read for behavior only, no
|
||||
//! upstream code copied).
|
||||
|
||||
use crate::factory::NodeMeta;
|
||||
use crate::jobs::ShaderJobPayload;
|
||||
use crate::node::{Category, NodeBehavior, NodeCore};
|
||||
|
||||
/// Main texture input id (upstream `PositionPlugin`'s image input). Type:
|
||||
/// texture; flags: not-keyframable; this is the node's effect input.
|
||||
pub const TEXTURE_INPUT: &str = "tex_in";
|
||||
|
||||
/// Translate input id (upstream `kParamTranslate` "Translate"). Type:
|
||||
/// vec2; default `[0.0, 0.0]`; units: whole pixels of the center-origin
|
||||
/// pixel space (see the shader note). Not clamped.
|
||||
pub const OFFSET_INPUT: &str = "offset_in";
|
||||
|
||||
/// Position node. Translates the input image by a whole-pixel offset.
|
||||
/// Has no own member fields in C++ (state lives in the `Node` inputs).
|
||||
pub struct PositionNode;
|
||||
|
||||
/// Fragment shader. The sampling is the inverse of the visual offset:
|
||||
/// the output pixel at center-origin `px` reads the source at
|
||||
/// `px - offset`, so the image content moves by `+offset` on screen.
|
||||
///
|
||||
/// Pixel space: `ove_texcoord * resolution_in - resolution_in * 0.5`,
|
||||
/// i.e. the center-origin pixel coordinates used by
|
||||
/// [`super::transformdistortnode`]. This crate's frame rows run top to
|
||||
/// bottom, so `+y` moves the image DOWN — the opposite screen direction
|
||||
/// of the upstream OFX plugin, whose y axis points up (upstream hints
|
||||
/// the new position of the "bottom-left pixel"). The offset is rounded
|
||||
/// with the upstream integer rounding, `floor(x + 0.5)`.
|
||||
///
|
||||
/// There is deliberately no identity fast path: an all-zero offset still
|
||||
/// runs the shader pass (the upstream plugin does the same).
|
||||
const SHADER_FRAG: &str = r#"uniform sampler2D tex_in;
|
||||
uniform vec2 offset_in;
|
||||
uniform vec2 resolution_in;
|
||||
|
||||
in vec2 ove_texcoord;
|
||||
out vec4 frag_color;
|
||||
|
||||
void main(void) {
|
||||
vec2 half_res = resolution_in * 0.5;
|
||||
vec2 px = ove_texcoord * resolution_in - half_res;
|
||||
vec2 offset = floor(offset_in + 0.5);
|
||||
|
||||
frag_color = texture(tex_in, (px - offset + half_res) / resolution_in);
|
||||
}
|
||||
"#;
|
||||
|
||||
impl PositionNode {
|
||||
/// Fragment shader (the node has a single shader variant).
|
||||
fn shader_frag() -> &'static str {
|
||||
SHADER_FRAG
|
||||
}
|
||||
}
|
||||
|
||||
impl NodeBehavior for PositionNode {
|
||||
/// Human-readable name.
|
||||
fn name(&self) -> &str {
|
||||
"Position"
|
||||
}
|
||||
|
||||
/// Stable type id.
|
||||
fn type_id(&self) -> &str {
|
||||
"org.olivevideoeditor.Olive.position"
|
||||
}
|
||||
|
||||
/// Categories.
|
||||
fn categories(&self) -> &[Category] {
|
||||
&[Category::Distort]
|
||||
}
|
||||
|
||||
/// Description.
|
||||
fn description(&self) -> &str {
|
||||
"Translate an image by a whole-pixel offset."
|
||||
}
|
||||
|
||||
/// Localized input names: `tex_in` -> "Input", `offset_in` ->
|
||||
/// "Translate".
|
||||
fn input_name<'a>(&self, id: &'a str) -> &'a str {
|
||||
match id {
|
||||
TEXTURE_INPUT => "Input",
|
||||
OFFSET_INPUT => "Translate",
|
||||
_ => id,
|
||||
}
|
||||
}
|
||||
|
||||
/// Evaluate outputs: no texture -> push nothing; otherwise a shader
|
||||
/// job over the whole value row, translating the sampled coordinates
|
||||
/// by `offset_in` whole pixels.
|
||||
///
|
||||
/// The job boxes a [`ShaderJobPayload`] that the renderer's resolve
|
||||
/// hook executes and replaces with the result texture; the params row
|
||||
/// carries the input texture and the uniforms, keyed by the effect
|
||||
/// input. `resolution_in` is filled by the runner, so it is not part
|
||||
/// of the params here.
|
||||
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;
|
||||
}
|
||||
|
||||
// The offset is part of the row in the traverser flow (the bare key
|
||||
// is always inserted for an unconnected input); fall back to the
|
||||
// node's own value for direct `value()` calls.
|
||||
let mut params = inputs.clone();
|
||||
if !params.contains_key(OFFSET_INPUT) {
|
||||
params.insert(
|
||||
OFFSET_INPUT.to_string(),
|
||||
core.value_at_time(OFFSET_INPUT, -1, time),
|
||||
);
|
||||
}
|
||||
|
||||
table.push(
|
||||
crate::value::ValueType::Texture,
|
||||
crate::value::NodeValue::Texture(crate::handle::make_owned(ShaderJobPayload {
|
||||
node_id: crate::id::NodeId::INVALID,
|
||||
time,
|
||||
iterations: 1,
|
||||
type_id: self.type_id().to_string(),
|
||||
shader_id: String::new(),
|
||||
effect_input: core.effect_input.clone(),
|
||||
params,
|
||||
iterative_input: TEXTURE_INPUT.to_string(),
|
||||
})),
|
||||
None,
|
||||
);
|
||||
}
|
||||
|
||||
/// Shader code request: the node has a single shader variant, so the
|
||||
/// request id is ignored.
|
||||
fn shader_code(&self, _request: &str) -> Option<String> {
|
||||
Some(Self::shader_frag().to_string())
|
||||
}
|
||||
|
||||
/// Deep copy.
|
||||
fn duplicate(&self, _core: &NodeCore) -> Option<Box<dyn NodeBehavior>> {
|
||||
Some(Box::new(PositionNode))
|
||||
}
|
||||
}
|
||||
|
||||
/// Constructor: adds `tex_in` and `offset_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(
|
||||
OFFSET_INPUT,
|
||||
crate::value::ValueType::Vec2,
|
||||
crate::value::NodeValue::Vec2([0.0, 0.0]),
|
||||
));
|
||||
|
||||
core.flags |= crate::node::flags::VIDEO_EFFECT;
|
||||
core.effect_input = TEXTURE_INPUT.to_string();
|
||||
|
||||
(core, Box::new(PositionNode))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::node::NodeBehavior;
|
||||
use crate::value::{NodeValue, NodeValueRow, NodeValueTable, ValueType};
|
||||
use oak_core::Rational;
|
||||
|
||||
fn tex() -> NodeValue {
|
||||
NodeValue::Texture(crate::handle::CHandle::null())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn input_names() {
|
||||
let n = PositionNode;
|
||||
assert_eq!(n.input_name(TEXTURE_INPUT), "Input");
|
||||
assert_eq!(n.input_name(OFFSET_INPUT), "Translate");
|
||||
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.position");
|
||||
assert_eq!(
|
||||
core.get_input(OFFSET_INPUT).unwrap().default,
|
||||
NodeValue::Vec2([0.0, 0.0])
|
||||
);
|
||||
assert_ne!(
|
||||
core.get_input(TEXTURE_INPUT).unwrap().flags & crate::input::flags::NOT_KEYFRAMABLE,
|
||||
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,
|
||||
&NodeValueRow::default(),
|
||||
Rational::new(0, 1),
|
||||
&mut table,
|
||||
);
|
||||
assert!(table.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_pushes_shader_job_payload() {
|
||||
let (core, behavior) = create();
|
||||
let inputs = NodeValueRow::from([(TEXTURE_INPUT.to_string(), tex())]);
|
||||
let mut table = NodeValueTable::default();
|
||||
behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
|
||||
let handle = match table.get(ValueType::Texture) {
|
||||
Some(NodeValue::Texture(h)) => *h,
|
||||
_ => panic!("texture expected"),
|
||||
};
|
||||
let payload = unsafe { crate::handle::get_checked::<ShaderJobPayload>(&handle) }
|
||||
.expect("shader job payload expected");
|
||||
assert_eq!(payload.type_id, "org.olivevideoeditor.Olive.position");
|
||||
assert_eq!(payload.shader_id, "");
|
||||
assert_eq!(payload.iterations, 1);
|
||||
assert_eq!(payload.effect_input, TEXTURE_INPUT);
|
||||
assert_eq!(payload.iterative_input, TEXTURE_INPUT);
|
||||
assert_eq!(payload.time, Rational::new(0, 1));
|
||||
assert!(payload.params.contains_key(TEXTURE_INPUT));
|
||||
// The offset is not in the row: it resolves from the node default.
|
||||
assert_eq!(
|
||||
payload.params.get(OFFSET_INPUT),
|
||||
Some(&NodeValue::Vec2([0.0, 0.0]))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_row_offset_wins_over_the_node_value() {
|
||||
let (core, behavior) = create();
|
||||
let inputs = NodeValueRow::from([
|
||||
(TEXTURE_INPUT.to_string(), tex()),
|
||||
(OFFSET_INPUT.to_string(), NodeValue::Vec2([3.0, 2.0])),
|
||||
]);
|
||||
let mut table = NodeValueTable::default();
|
||||
behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
|
||||
let handle = match table.get(ValueType::Texture) {
|
||||
Some(NodeValue::Texture(h)) => *h,
|
||||
_ => panic!("texture expected"),
|
||||
};
|
||||
let payload = unsafe { crate::handle::get_checked::<ShaderJobPayload>(&handle) }
|
||||
.expect("shader job payload expected");
|
||||
assert_eq!(
|
||||
payload.params.get(OFFSET_INPUT),
|
||||
Some(&NodeValue::Vec2([3.0, 2.0]))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shader_declares_the_uniforms_it_reads() {
|
||||
let code = PositionNode.shader_code("").unwrap();
|
||||
assert!(code.contains("uniform sampler2D tex_in;"));
|
||||
assert!(code.contains("uniform vec2 offset_in;"));
|
||||
assert!(code.contains("uniform vec2 resolution_in;"));
|
||||
assert!(code.contains("frag_color = texture(tex_in, (px - offset + half_res) / resolution_in);"));
|
||||
assert!(!code.contains("switch"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_clones() {
|
||||
let (core, behavior) = create();
|
||||
let dup = behavior.duplicate(&core).unwrap();
|
||||
assert_eq!(dup.name(), "Position");
|
||||
}
|
||||
}
|
||||
|
||||
/// Register this node type.
|
||||
pub fn register(meta: &mut Vec<NodeMeta>) {
|
||||
meta.push(NodeMeta {
|
||||
type_id: "org.olivevideoeditor.Olive.position",
|
||||
name: "Position",
|
||||
categories: &[Category::Distort],
|
||||
create,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,381 @@
|
||||
// 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/>.
|
||||
|
||||
//! Premultiply effect (clean-room reimplementation of the OpenFX-Misc
|
||||
//! `PremultOFX` / `net.sf.openfx.PremultPlugin`; the `Premult` reference
|
||||
//! tree is used for parameter semantics only, no code copied).
|
||||
//!
|
||||
//! Upstream scales each channel by a chosen channel of the source —
|
||||
//! `dst = src * alpha` for the usual full-alpha premultiply, with the
|
||||
//! per-channel `process R/G/B/A` toggles and the multiplane premult
|
||||
//! channel choice as the knobs (`// CPP-PARITY: Premult.cpp`). This node
|
||||
//! keeps the channel-selection core: the RGB channels are scaled by the
|
||||
//! channel picked in [`CHANNEL_INPUT`] (default [`Channel::Alpha`]), and
|
||||
//! the source alpha rides through untouched.
|
||||
//!
|
||||
//! Single texture input, so `core.effect_input` is set to
|
||||
//! [`TEXTURE_INPUT`] (the despill node's shape) and the job binds that
|
||||
//! one sampler by name.
|
||||
|
||||
use crate::factory::NodeMeta;
|
||||
use crate::jobs::ShaderJobPayload;
|
||||
use crate::node::{Category, NodeBehavior, NodeCore};
|
||||
|
||||
/// Texture input id. Type: texture; flags: not-keyframable; this is the
|
||||
/// node's effect input.
|
||||
pub const TEXTURE_INPUT: &str = "tex_in";
|
||||
|
||||
/// Channel selector input id (upstream's "premult channel" choice). Type:
|
||||
/// combo; default [`Channel::Alpha`] (`Combo(4)`); flags:
|
||||
/// not-connectable, not-keyframable. Combo strings: "None", "R", "G",
|
||||
/// "B", "A".
|
||||
pub const CHANNEL_INPUT: &str = "premult_channel_in";
|
||||
|
||||
/// Channel selector values for [`CHANNEL_INPUT`], in combo order. The
|
||||
/// numeric values are the shader's channel indices: `0` selects nothing
|
||||
/// (the image passes through — a factor of 1) and `1..=4` select
|
||||
/// `r`/`g`/`b`/`a`.
|
||||
#[repr(i64)]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Channel {
|
||||
/// No channel: the image is left unchanged.
|
||||
None = 0,
|
||||
/// Red channel.
|
||||
Red = 1,
|
||||
/// Green channel.
|
||||
Green = 2,
|
||||
/// Blue channel.
|
||||
Blue = 3,
|
||||
/// Alpha channel (the default multiply factor).
|
||||
Alpha = 4,
|
||||
}
|
||||
|
||||
/// Premultiply node. Unit-like: there is no per-instance state.
|
||||
pub struct PremultiplyNode;
|
||||
|
||||
/// Fragment shader: `rgb *= selected_channel`. The channel dispatch is an
|
||||
/// if/else chain and not a `switch` (naga rejects the latter); an
|
||||
/// out-of-range selector falls back to a factor of 1, the "None" combo
|
||||
/// entry. No `tex_in_enabled` flag is declared: with a single texture
|
||||
/// input the sampler is either bound or the job never runs (`value()`
|
||||
/// requires the texture), and the despill node's shader has the same
|
||||
/// shape.
|
||||
const SHADER_FRAG: &str = r#"uniform sampler2D tex_in;
|
||||
uniform int premult_channel_in;
|
||||
|
||||
in vec2 ove_texcoord;
|
||||
out vec4 frag_color;
|
||||
|
||||
float selected_channel(vec4 col, int channel) {
|
||||
if (channel == 1) {
|
||||
return col.r;
|
||||
} else if (channel == 2) {
|
||||
return col.g;
|
||||
} else if (channel == 3) {
|
||||
return col.b;
|
||||
} else if (channel == 4) {
|
||||
return col.a;
|
||||
}
|
||||
return 1.0;
|
||||
}
|
||||
|
||||
void main(void) {
|
||||
vec4 col = texture(tex_in, ove_texcoord);
|
||||
float factor = selected_channel(col, premult_channel_in);
|
||||
|
||||
frag_color = vec4(col.rgb * factor, col.a);
|
||||
}
|
||||
"#;
|
||||
|
||||
impl PremultiplyNode {
|
||||
/// Fragment shader (single variant, so the request id is ignored).
|
||||
fn shader_frag() -> &'static str {
|
||||
SHADER_FRAG
|
||||
}
|
||||
}
|
||||
|
||||
impl NodeBehavior for PremultiplyNode {
|
||||
/// Human-readable name.
|
||||
fn name(&self) -> &str {
|
||||
"Premultiply"
|
||||
}
|
||||
|
||||
/// Stable type id.
|
||||
fn type_id(&self) -> &str {
|
||||
"org.olivevideoeditor.Olive.premult"
|
||||
}
|
||||
|
||||
/// Categories. Filed under math like the alpha-over
|
||||
/// [`crate::nodes::merge`] (the `Category` enum has no merge group).
|
||||
fn categories(&self) -> &[Category] {
|
||||
&[Category::Math]
|
||||
}
|
||||
|
||||
/// Description.
|
||||
fn description(&self) -> &str {
|
||||
"Multiply the RGB channels by a selected channel."
|
||||
}
|
||||
|
||||
/// Localized input names: `tex_in` -> "Input", `premult_channel_in`
|
||||
/// -> "Channel".
|
||||
fn input_name<'a>(&self, id: &'a str) -> &'a str {
|
||||
match id {
|
||||
TEXTURE_INPUT => "Input",
|
||||
CHANNEL_INPUT => "Channel",
|
||||
_ => id,
|
||||
}
|
||||
}
|
||||
|
||||
/// Combo input option labels: `premult_channel_in` -> "None", "R",
|
||||
/// "G", "B", "A" (the [`Channel`] order).
|
||||
fn input_combo_strings(&self, id: &str) -> Vec<&'static str> {
|
||||
match id {
|
||||
CHANNEL_INPUT => vec!["None", "R", "G", "B", "A"],
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Evaluate outputs: with no texture there is nothing to premultiply;
|
||||
/// otherwise push one shader job over the whole input row, with the
|
||||
/// resolved channel selector written in so the uniform is always
|
||||
/// present even when the incoming row only carries the texture (the
|
||||
/// row/standard-value split mirrors the directional-blur node).
|
||||
fn value(
|
||||
&self,
|
||||
core: &NodeCore,
|
||||
inputs: &crate::value::NodeValueRow,
|
||||
time: oak_core::Rational,
|
||||
table: &mut crate::value::NodeValueTable,
|
||||
) {
|
||||
use crate::value::{NodeValue, ValueType};
|
||||
|
||||
match inputs.get(TEXTURE_INPUT) {
|
||||
Some(NodeValue::Texture(_)) => {}
|
||||
_ => return,
|
||||
}
|
||||
|
||||
let channel = match inputs.get(CHANNEL_INPUT) {
|
||||
Some(v) => v.clone(),
|
||||
None => core.value_at_time(CHANNEL_INPUT, -1, time),
|
||||
};
|
||||
|
||||
let mut params = inputs.clone();
|
||||
params.insert(CHANNEL_INPUT.to_string(), channel);
|
||||
|
||||
table.push(
|
||||
ValueType::Texture,
|
||||
NodeValue::Texture(crate::handle::make_owned(ShaderJobPayload {
|
||||
node_id: crate::id::NodeId::INVALID,
|
||||
time,
|
||||
iterations: 1,
|
||||
type_id: self.type_id().to_string(),
|
||||
shader_id: String::new(),
|
||||
effect_input: core.effect_input.clone(),
|
||||
params,
|
||||
iterative_input: String::new(),
|
||||
})),
|
||||
None,
|
||||
);
|
||||
}
|
||||
|
||||
/// Shader code request: always the premultiply fragment source.
|
||||
fn shader_code(&self, _request: &str) -> Option<String> {
|
||||
Some(Self::shader_frag().to_string())
|
||||
}
|
||||
|
||||
/// Deep copy.
|
||||
fn duplicate(&self, _core: &NodeCore) -> Option<Box<dyn NodeBehavior>> {
|
||||
Some(Box::new(PremultiplyNode))
|
||||
}
|
||||
}
|
||||
|
||||
/// Constructor: adds `tex_in` (not-keyframable) and `premult_channel_in`
|
||||
/// (combo, default alpha), 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);
|
||||
|
||||
let mut channel = crate::input::Input::new(
|
||||
CHANNEL_INPUT,
|
||||
crate::value::ValueType::Combo,
|
||||
crate::value::NodeValue::Combo(Channel::Alpha as i64),
|
||||
);
|
||||
channel.flags |= crate::input::flags::NOT_CONNECTABLE | crate::input::flags::NOT_KEYFRAMABLE;
|
||||
core.add_input(channel);
|
||||
|
||||
core.flags |= crate::node::flags::VIDEO_EFFECT;
|
||||
core.effect_input = TEXTURE_INPUT.to_string();
|
||||
|
||||
(core, Box::new(PremultiplyNode))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::value::{NodeValue, NodeValueTable, ValueType};
|
||||
use oak_core::Rational;
|
||||
|
||||
fn tex() -> NodeValue {
|
||||
NodeValue::Texture(crate::handle::CHandle::null())
|
||||
}
|
||||
|
||||
fn run(core: &NodeCore, inputs: &crate::value::NodeValueRow) -> ShaderJobPayload {
|
||||
let behavior = PremultiplyNode;
|
||||
let mut table = NodeValueTable::default();
|
||||
behavior.value(core, inputs, Rational::new(0, 1), &mut table);
|
||||
let Some(NodeValue::Texture(h)) = table.get(ValueType::Texture) else {
|
||||
panic!("shader job expected");
|
||||
};
|
||||
unsafe { crate::handle::get_checked::<ShaderJobPayload>(h) }
|
||||
.expect("shader job payload boxed")
|
||||
.clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn input_names() {
|
||||
let n = PremultiplyNode;
|
||||
assert_eq!(n.input_name(TEXTURE_INPUT), "Input");
|
||||
assert_eq!(n.input_name(CHANNEL_INPUT), "Channel");
|
||||
assert_eq!(n.input_name("other"), "other");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn combo_strings_match_channel_values() {
|
||||
let n = PremultiplyNode;
|
||||
let strings = n.input_combo_strings(CHANNEL_INPUT);
|
||||
assert_eq!(strings, vec!["None", "R", "G", "B", "A"]);
|
||||
assert_eq!(Channel::None as i64, 0);
|
||||
assert_eq!(Channel::Red as i64, 1);
|
||||
assert_eq!(Channel::Green as i64, 2);
|
||||
assert_eq!(Channel::Blue as i64, 3);
|
||||
assert_eq!(Channel::Alpha as i64, 4);
|
||||
assert!(n.input_combo_strings("other").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_wires_inputs_flags_and_defaults() {
|
||||
let (core, behavior) = create();
|
||||
assert_eq!(behavior.type_id(), "org.olivevideoeditor.Olive.premult");
|
||||
assert_eq!(behavior.name(), "Premultiply");
|
||||
assert_eq!(behavior.categories(), &[Category::Math]);
|
||||
assert_ne!(core.flags & crate::node::flags::VIDEO_EFFECT, 0);
|
||||
assert_eq!(core.effect_input, TEXTURE_INPUT);
|
||||
|
||||
let tex_input = core.get_input(TEXTURE_INPUT).expect("texture input");
|
||||
assert_eq!(tex_input.value_type, ValueType::Texture);
|
||||
assert_ne!(tex_input.flags & crate::input::flags::NOT_KEYFRAMABLE, 0);
|
||||
|
||||
let channel = core.get_input(CHANNEL_INPUT).expect("channel input");
|
||||
assert_eq!(channel.value_type, ValueType::Combo);
|
||||
assert_eq!(channel.default, NodeValue::Combo(Channel::Alpha as i64));
|
||||
assert_ne!(channel.flags & crate::input::flags::NOT_CONNECTABLE, 0);
|
||||
assert_ne!(channel.flags & crate::input::flags::NOT_KEYFRAMABLE, 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_defaults_channel_to_alpha() {
|
||||
let (core, _) = create();
|
||||
let inputs = crate::value::NodeValueRow::from([(TEXTURE_INPUT.to_string(), tex())]);
|
||||
let payload = run(&core, &inputs);
|
||||
assert_eq!(payload.type_id, "org.olivevideoeditor.Olive.premult");
|
||||
assert_eq!(payload.shader_id, "");
|
||||
assert_eq!(payload.iterations, 1);
|
||||
assert_eq!(payload.iterative_input, "");
|
||||
assert_eq!(payload.effect_input, TEXTURE_INPUT);
|
||||
assert_eq!(
|
||||
payload.params.get(CHANNEL_INPUT),
|
||||
Some(&NodeValue::Combo(Channel::Alpha as i64)),
|
||||
"the default channel rides in the job params"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_takes_channel_from_row() {
|
||||
let (core, _) = create();
|
||||
let inputs = crate::value::NodeValueRow::from([
|
||||
(TEXTURE_INPUT.to_string(), tex()),
|
||||
(
|
||||
CHANNEL_INPUT.to_string(),
|
||||
NodeValue::Combo(Channel::Red as i64),
|
||||
),
|
||||
]);
|
||||
let payload = run(&core, &inputs);
|
||||
assert_eq!(
|
||||
payload.params.get(CHANNEL_INPUT),
|
||||
Some(&NodeValue::Combo(Channel::Red as i64))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_takes_channel_from_core() {
|
||||
let (mut core, _) = create();
|
||||
core.set_standard_value(CHANNEL_INPUT, -1, NodeValue::Combo(Channel::Green as i64));
|
||||
let inputs = crate::value::NodeValueRow::from([(TEXTURE_INPUT.to_string(), tex())]);
|
||||
let payload = run(&core, &inputs);
|
||||
assert_eq!(
|
||||
payload.params.get(CHANNEL_INPUT),
|
||||
Some(&NodeValue::Combo(Channel::Green as i64))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shader_code_declares_inputs_without_switch() {
|
||||
let n = PremultiplyNode;
|
||||
let glsl = n.shader_code("").unwrap();
|
||||
assert!(glsl.contains("uniform sampler2D tex_in;"));
|
||||
assert!(glsl.contains("uniform int premult_channel_in;"));
|
||||
assert!(glsl.contains("frag_color = vec4(col.rgb * factor, col.a);"));
|
||||
assert!(!glsl.contains("switch"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_clones_behavior() {
|
||||
let (core, behavior) = create();
|
||||
let dup = behavior.duplicate(&core).unwrap();
|
||||
assert_eq!(dup.name(), "Premultiply");
|
||||
}
|
||||
}
|
||||
|
||||
/// Register this node type.
|
||||
pub fn register(meta: &mut Vec<NodeMeta>) {
|
||||
meta.push(NodeMeta {
|
||||
type_id: "org.olivevideoeditor.Olive.premult",
|
||||
name: "Premultiply",
|
||||
categories: &[Category::Math],
|
||||
create,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,406 @@
|
||||
// Oak Video Editor - Non-Linear Video Editor
|
||||
// Copyright (C) 2026 Oak Team
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! Linear ramp generator: clean-room reimplementation of the OpenFX-Misc
|
||||
//! `Ramp` plugin's parameter semantics (upstream github.com/cgvirus/
|
||||
//! OpenFX-Misc, GPL2; read for behavior only, no upstream code copied).
|
||||
//!
|
||||
//! Deviations from the upstream plugin: it additionally offers a ramp
|
||||
//! `type` combo and an `interactive` gizmo switch, neither of which is
|
||||
//! reproduced here. The upstream parameter descriptors live in
|
||||
//! `ofxsRamp.h`, which is not part of the reference copy, so the default
|
||||
//! points below are this node's own choice (a horizontal black-to-white
|
||||
//! ramp across a 200px-wide frame).
|
||||
|
||||
use crate::factory::NodeMeta;
|
||||
use crate::jobs::ShaderJobPayload;
|
||||
use crate::node::{Category, NodeBehavior, NodeCore};
|
||||
|
||||
/// Base texture input id (the shared generator-with-merge base). Type:
|
||||
/// texture; flags: not-keyframable; this is the generator's effect input.
|
||||
pub const BASE_INPUT: &str = super::generatorwithmerge::BASE_INPUT;
|
||||
|
||||
/// First point input id (upstream `kParamPoint0` "Point 0"). Type: vec2;
|
||||
/// default `[-100.0, 0.0]`; units: pixels of the center-origin pixel
|
||||
/// space (see the shader note).
|
||||
pub const POINT0_INPUT: &str = "point0_in";
|
||||
|
||||
/// Second point input id (upstream `kParamPoint1` "Point 1"). Type: vec2;
|
||||
/// default `[100.0, 0.0]`; units: pixels of the center-origin pixel
|
||||
/// space.
|
||||
pub const POINT1_INPUT: &str = "point1_in";
|
||||
|
||||
/// First color input id (upstream `kParamColor0`). Type: color; default
|
||||
/// `[0.0, 0.0, 0.0, 1.0]` (black); properties: `view = color`.
|
||||
pub const COLOR0_INPUT: &str = "color0_in";
|
||||
|
||||
/// Second color input id (upstream `kParamColor1`). Type: color; default
|
||||
/// `[1.0, 1.0, 1.0, 1.0]` (white); properties: `view = color`.
|
||||
pub const COLOR1_INPUT: &str = "color1_in";
|
||||
|
||||
/// Linear ramp generator node.
|
||||
pub struct RampNode;
|
||||
|
||||
/// Fragment shader for the `"ramp"` shader id.
|
||||
///
|
||||
/// The gradient parameter `t` is the projection of the pixel onto the
|
||||
/// `point0_in -> point1_in` axis: `t = dot(px - point0, d) / dot(d, d)`
|
||||
/// with `d = point1 - point0`, exactly the upstream ramp function. `t`
|
||||
/// is not clamped, so values outside the `[0, 1]` span extrapolate the
|
||||
/// gradient past the endpoint colors (the upstream linear type does the
|
||||
/// same); a degenerate axis (`point0 == point1`) leaves `t = 0`.
|
||||
///
|
||||
/// Pixel space: `ove_texcoord * resolution_in - resolution_in * 0.5`,
|
||||
/// i.e. the center-origin pixel coordinates used by
|
||||
/// [`super::transformdistortnode`], with y running downward.
|
||||
const SHADER_FRAG: &str = r#"uniform vec2 resolution_in;
|
||||
uniform vec2 point0_in;
|
||||
uniform vec2 point1_in;
|
||||
uniform vec4 color0_in;
|
||||
uniform vec4 color1_in;
|
||||
|
||||
in vec2 ove_texcoord;
|
||||
out vec4 frag_color;
|
||||
|
||||
void main(void) {
|
||||
vec2 px = ove_texcoord * resolution_in - resolution_in * 0.5;
|
||||
vec2 d = point1_in - point0_in;
|
||||
float norm2 = dot(d, d);
|
||||
|
||||
float t = 0.0;
|
||||
if (norm2 > 0.0) {
|
||||
t = dot(px - point0_in, d) / norm2;
|
||||
}
|
||||
|
||||
frag_color = color0_in * (1.0 - t) + color1_in * t;
|
||||
}
|
||||
"#;
|
||||
|
||||
impl RampNode {
|
||||
/// Fragment shader for the `"ramp"` request.
|
||||
fn shader_frag() -> &'static str {
|
||||
SHADER_FRAG
|
||||
}
|
||||
}
|
||||
|
||||
impl NodeBehavior for RampNode {
|
||||
/// Human-readable name.
|
||||
fn name(&self) -> &str {
|
||||
"Ramp"
|
||||
}
|
||||
|
||||
/// Stable type id.
|
||||
fn type_id(&self) -> &str {
|
||||
"org.olivevideoeditor.Olive.ramp"
|
||||
}
|
||||
|
||||
/// Categories.
|
||||
fn categories(&self) -> &[Category] {
|
||||
&[Category::Generator]
|
||||
}
|
||||
|
||||
/// Description.
|
||||
fn description(&self) -> &str {
|
||||
"Generate a linear color ramp between two points."
|
||||
}
|
||||
|
||||
/// Localized input names: the merge base's `base_in` -> "Base" plus
|
||||
/// `point0_in` -> "Point 0", `point1_in` -> "Point 1", `color0_in` ->
|
||||
/// "Color 0", `color1_in` -> "Color 1".
|
||||
fn input_name<'a>(&self, id: &'a str) -> &'a str {
|
||||
match id {
|
||||
BASE_INPUT => "Base",
|
||||
POINT0_INPUT => "Point 0",
|
||||
POINT1_INPUT => "Point 1",
|
||||
COLOR0_INPUT => "Color 0",
|
||||
COLOR1_INPUT => "Color 1",
|
||||
_ => id,
|
||||
}
|
||||
}
|
||||
|
||||
/// Evaluate outputs: a `"ramp"` shader job over the value row, pushed
|
||||
/// through `push_mergable_job` — merged alpha-over `base_in` when a
|
||||
/// base texture is connected, pushed bare otherwise.
|
||||
///
|
||||
/// The params row carries the two points and the two colors;
|
||||
/// `resolution_in` is filled by the runner from the render target
|
||||
/// size, so it is not part of the params here.
|
||||
fn value(
|
||||
&self,
|
||||
core: &NodeCore,
|
||||
inputs: &crate::value::NodeValueRow,
|
||||
time: oak_core::Rational,
|
||||
table: &mut crate::value::NodeValueTable,
|
||||
) {
|
||||
let mut params = inputs.clone();
|
||||
// The inputs are part of the row in the traverser flow (the bare
|
||||
// key is always inserted for an unconnected input); fall back to
|
||||
// the node's own values for direct `value()` calls.
|
||||
for id in [POINT0_INPUT, POINT1_INPUT, COLOR0_INPUT, COLOR1_INPUT] {
|
||||
if !params.contains_key(id) {
|
||||
params.insert(id.to_string(), core.value_at_time(id, -1, time));
|
||||
}
|
||||
}
|
||||
|
||||
let job = crate::handle::make_owned(ShaderJobPayload {
|
||||
node_id: crate::id::NodeId::INVALID,
|
||||
time,
|
||||
iterations: 1,
|
||||
type_id: self.type_id().to_string(),
|
||||
shader_id: "ramp".to_string(),
|
||||
effect_input: core.effect_input.clone(),
|
||||
params,
|
||||
iterative_input: String::new(),
|
||||
});
|
||||
super::generatorwithmerge::GeneratorWithMerge::push_mergable_job(inputs, job, table);
|
||||
}
|
||||
|
||||
/// Shader code request: `"ramp"` returns this node's shader; the
|
||||
/// `"mrg"` request returns the shared alpha-over merge shader;
|
||||
/// anything else is unsupported.
|
||||
fn shader_code(&self, request: &str) -> Option<String> {
|
||||
match request {
|
||||
"ramp" => Some(Self::shader_frag().to_string()),
|
||||
"mrg" => Some(super::generatorwithmerge::merge_shader_frag().to_string()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Deep copy.
|
||||
fn duplicate(&self, _core: &NodeCore) -> Option<Box<dyn NodeBehavior>> {
|
||||
Some(Box::new(RampNode))
|
||||
}
|
||||
}
|
||||
|
||||
/// Constructor: adds `base_in` (not-keyframable), the two points and the
|
||||
/// two colors with the defaults and properties documented on the
|
||||
/// constants, sets the video-effect flag and makes `base_in` the effect
|
||||
/// input (the `GeneratorWithMerge` constructor side effects).
|
||||
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);
|
||||
|
||||
core.add_input(crate::input::Input::new(
|
||||
POINT0_INPUT,
|
||||
crate::value::ValueType::Vec2,
|
||||
crate::value::NodeValue::Vec2([-100.0, 0.0]),
|
||||
));
|
||||
core.add_input(crate::input::Input::new(
|
||||
POINT1_INPUT,
|
||||
crate::value::ValueType::Vec2,
|
||||
crate::value::NodeValue::Vec2([100.0, 0.0]),
|
||||
));
|
||||
|
||||
for (id, default) in [
|
||||
(COLOR0_INPUT, [0.0, 0.0, 0.0, 1.0]),
|
||||
(COLOR1_INPUT, [1.0, 1.0, 1.0, 1.0]),
|
||||
] {
|
||||
let mut color = crate::input::Input::new(
|
||||
id,
|
||||
crate::value::ValueType::Color,
|
||||
crate::value::NodeValue::Color(default),
|
||||
);
|
||||
color.properties = vec![(
|
||||
"view".to_string(),
|
||||
crate::value::NodeValue::Text("color".into()),
|
||||
)];
|
||||
core.add_input(color);
|
||||
}
|
||||
|
||||
core.flags |= crate::node::flags::VIDEO_EFFECT;
|
||||
core.effect_input = BASE_INPUT.to_string();
|
||||
|
||||
(core, Box::new(RampNode))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::node::NodeBehavior;
|
||||
use crate::value::{NodeValue, NodeValueRow, NodeValueTable, ValueType};
|
||||
use oak_core::Rational;
|
||||
|
||||
#[test]
|
||||
fn input_names() {
|
||||
let n = RampNode;
|
||||
assert_eq!(n.input_name(POINT0_INPUT), "Point 0");
|
||||
assert_eq!(n.input_name(POINT1_INPUT), "Point 1");
|
||||
assert_eq!(n.input_name(COLOR0_INPUT), "Color 0");
|
||||
assert_eq!(n.input_name(COLOR1_INPUT), "Color 1");
|
||||
assert_eq!(
|
||||
n.input_name(super::super::generatorwithmerge::BASE_INPUT),
|
||||
"Base"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_wires_inputs_and_flags() {
|
||||
let (core, behavior) = create();
|
||||
assert_eq!(behavior.type_id(), "org.olivevideoeditor.Olive.ramp");
|
||||
assert_eq!(
|
||||
core.get_input(POINT0_INPUT).unwrap().default,
|
||||
NodeValue::Vec2([-100.0, 0.0])
|
||||
);
|
||||
assert_eq!(
|
||||
core.get_input(POINT1_INPUT).unwrap().default,
|
||||
NodeValue::Vec2([100.0, 0.0])
|
||||
);
|
||||
assert_eq!(
|
||||
core.get_input(COLOR0_INPUT).unwrap().default,
|
||||
NodeValue::Color([0.0, 0.0, 0.0, 1.0])
|
||||
);
|
||||
assert_eq!(
|
||||
core.get_input(COLOR1_INPUT).unwrap().default,
|
||||
NodeValue::Color([1.0, 1.0, 1.0, 1.0])
|
||||
);
|
||||
assert!(core.get_input(COLOR1_INPUT).unwrap().properties.iter().any(
|
||||
|(k, v)| k == "view" && *v == NodeValue::Text("color".into())
|
||||
));
|
||||
assert_eq!(core.effect_input, BASE_INPUT);
|
||||
assert_ne!(core.flags & crate::node::flags::VIDEO_EFFECT, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_pushes_generator_job() {
|
||||
let (core, behavior) = create();
|
||||
let mut table = NodeValueTable::default();
|
||||
behavior.value(
|
||||
&core,
|
||||
&NodeValueRow::default(),
|
||||
Rational::new(0, 1),
|
||||
&mut table,
|
||||
);
|
||||
let handle = match table.get(ValueType::Texture) {
|
||||
Some(NodeValue::Texture(h)) => *h,
|
||||
_ => panic!("texture expected"),
|
||||
};
|
||||
let payload = unsafe { crate::handle::get_checked::<ShaderJobPayload>(&handle) }
|
||||
.expect("shader job payload expected");
|
||||
assert_eq!(payload.type_id, "org.olivevideoeditor.Olive.ramp");
|
||||
assert_eq!(payload.shader_id, "ramp");
|
||||
assert_eq!(payload.iterations, 1);
|
||||
assert_eq!(payload.effect_input, BASE_INPUT);
|
||||
assert_eq!(payload.iterative_input, "");
|
||||
assert_eq!(
|
||||
payload.params.get(POINT0_INPUT),
|
||||
Some(&NodeValue::Vec2([-100.0, 0.0]))
|
||||
);
|
||||
assert_eq!(
|
||||
payload.params.get(COLOR1_INPUT),
|
||||
Some(&NodeValue::Color([1.0, 1.0, 1.0, 1.0]))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_row_points_win_over_the_node_defaults() {
|
||||
let (core, behavior) = create();
|
||||
let inputs = NodeValueRow::from([
|
||||
(POINT0_INPUT.to_string(), NodeValue::Vec2([-4.0, 0.0])),
|
||||
(POINT1_INPUT.to_string(), NodeValue::Vec2([4.0, 0.0])),
|
||||
(COLOR0_INPUT.to_string(), NodeValue::Color([1.0, 0.0, 0.0, 1.0])),
|
||||
]);
|
||||
let mut table = NodeValueTable::default();
|
||||
behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
|
||||
let handle = match table.get(ValueType::Texture) {
|
||||
Some(NodeValue::Texture(h)) => *h,
|
||||
_ => panic!("texture expected"),
|
||||
};
|
||||
let payload = unsafe { crate::handle::get_checked::<ShaderJobPayload>(&handle) }
|
||||
.expect("shader job payload expected");
|
||||
assert_eq!(
|
||||
payload.params.get(POINT0_INPUT),
|
||||
Some(&NodeValue::Vec2([-4.0, 0.0]))
|
||||
);
|
||||
assert_eq!(
|
||||
payload.params.get(COLOR0_INPUT),
|
||||
Some(&NodeValue::Color([1.0, 0.0, 0.0, 1.0]))
|
||||
);
|
||||
// The unset color falls back to the node default.
|
||||
assert_eq!(
|
||||
payload.params.get(COLOR1_INPUT),
|
||||
Some(&NodeValue::Color([1.0, 1.0, 1.0, 1.0]))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_with_base_merges_nested_job() {
|
||||
let (core, behavior) = create();
|
||||
let inputs = 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);
|
||||
let handle = match table.get(ValueType::Texture) {
|
||||
Some(NodeValue::Texture(h)) => *h,
|
||||
_ => panic!("texture expected"),
|
||||
};
|
||||
let merge = unsafe { crate::handle::get_checked::<ShaderJobPayload>(&handle) }
|
||||
.expect("merge job payload expected");
|
||||
assert_eq!(merge.shader_id, "mrg");
|
||||
assert_eq!(merge.effect_input, BASE_INPUT);
|
||||
match merge.params.get(crate::nodes::merge::BLEND_INPUT) {
|
||||
Some(NodeValue::Texture(blend)) => {
|
||||
let nested = unsafe { crate::handle::get_checked::<ShaderJobPayload>(blend) }
|
||||
.expect("nested job payload boxed");
|
||||
assert_eq!(nested.shader_id, "ramp");
|
||||
}
|
||||
_ => panic!("nested blend job expected"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shader_code_dispatches() {
|
||||
let n = RampNode;
|
||||
let code = n.shader_code("ramp").unwrap();
|
||||
assert!(code.contains("uniform vec2 point0_in;"));
|
||||
assert!(code.contains("uniform vec2 point1_in;"));
|
||||
assert!(code.contains("uniform vec4 color0_in;"));
|
||||
assert!(code.contains("uniform vec4 color1_in;"));
|
||||
assert!(code.contains("uniform vec2 resolution_in;"));
|
||||
assert!(code.contains("t = dot(px - point0_in, d) / norm2;"));
|
||||
assert!(!code.contains("switch"));
|
||||
assert!(n
|
||||
.shader_code("mrg")
|
||||
.unwrap()
|
||||
.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(), "Ramp");
|
||||
}
|
||||
}
|
||||
|
||||
/// Register this node type.
|
||||
pub fn register(meta: &mut Vec<NodeMeta>) {
|
||||
meta.push(NodeMeta {
|
||||
type_id: "org.olivevideoeditor.Olive.ramp",
|
||||
name: "Ramp",
|
||||
categories: &[Category::Generator],
|
||||
create,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
// Oak Video Editor - Non-Linear Video Editor
|
||||
// Copyright (C) 2026 Oak Team
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! Saturation effect — a clean-room reimplementation of the OpenFX-Misc
|
||||
//! `Saturation` plugin's parameter semantics (upstream
|
||||
//! `github.com/cgvirus/OpenFX-Misc`, GPL2; read for behavior only, no
|
||||
//! upstream code copied).
|
||||
//!
|
||||
//! One saturation control that lerps every color channel between the
|
||||
//! pixel's Rec. 709 luma and its original value:
|
||||
//!
|
||||
//! ```text
|
||||
//! luma = 0.2126*r + 0.7152*g + 0.0722*b
|
||||
//! x = (1 - saturation) * luma + saturation * x
|
||||
//! ```
|
||||
//!
|
||||
//! so `0.0` produces a grayscale image, `1.0` is the identity, and
|
||||
//! values above `1.0` extrapolate away from gray. Alpha is left
|
||||
//! untouched (the reference's saturation pass has no alpha term). The
|
||||
//! lerp runs unconditionally — unlike the gamma-style passes there is
|
||||
//! no "changed" test in the reference.
|
||||
|
||||
use crate::factory::NodeMeta;
|
||||
use crate::jobs::ShaderJobPayload;
|
||||
use crate::node::{Category, NodeBehavior, NodeCore};
|
||||
|
||||
/// Texture input id. Type: texture; flags: not-keyframable; this is
|
||||
/// the node's effect input.
|
||||
pub const TEXTURE_INPUT: &str = "tex_in";
|
||||
|
||||
/// Saturation input id. Type: float; default `1.0`; properties:
|
||||
/// `min = 0.0`, `max = 4.0`. Lerps every channel toward the pixel's
|
||||
/// Rec. 709 luma (`0.0` = grayscale, `1.0` = unchanged).
|
||||
pub const SATURATION_INPUT: &str = "saturation_in";
|
||||
|
||||
/// Saturation node. The reference class holds no state beyond its
|
||||
/// parameter pointers, so this is a unit-like struct.
|
||||
pub struct SaturationNode;
|
||||
|
||||
/// Fragment shader (clean-room GLSL for the reference's
|
||||
/// `SaturationPlugin::render` chain). The uniforms are named after the
|
||||
/// node inputs: the renderer binds uniforms by matching the declared
|
||||
/// name against the job's parameter row.
|
||||
const SHADER_FRAG: &str = r#"// Inputs
|
||||
uniform sampler2D tex_in;
|
||||
|
||||
uniform float saturation_in;
|
||||
|
||||
// Input texture coordinate
|
||||
in vec2 ove_texcoord;
|
||||
out vec4 frag_color;
|
||||
|
||||
void main() {
|
||||
vec4 c = texture(tex_in, ove_texcoord);
|
||||
|
||||
// Lerp every color channel between the pixel's Rec. 709 luma and its
|
||||
// original value.
|
||||
float luma = dot(c.rgb, vec3(0.2126, 0.7152, 0.0722));
|
||||
c.rgb = (1.0 - saturation_in) * luma + saturation_in * c.rgb;
|
||||
|
||||
frag_color = c;
|
||||
}
|
||||
"#;
|
||||
|
||||
impl NodeBehavior for SaturationNode {
|
||||
/// Human-readable name.
|
||||
fn name(&self) -> &str {
|
||||
"Saturation"
|
||||
}
|
||||
|
||||
/// Stable type id.
|
||||
fn type_id(&self) -> &str {
|
||||
"org.olivevideoeditor.Olive.saturation"
|
||||
}
|
||||
|
||||
/// Categories.
|
||||
fn categories(&self) -> &[Category] {
|
||||
&[Category::Color]
|
||||
}
|
||||
|
||||
/// Description.
|
||||
fn description(&self) -> &str {
|
||||
"Adjust the color intensity by lerping toward the image luma."
|
||||
}
|
||||
|
||||
/// Localized input names: `tex_in` -> "Input", `saturation_in` ->
|
||||
/// "Saturation".
|
||||
fn input_name<'a>(&self, id: &'a str) -> &'a str {
|
||||
match id {
|
||||
TEXTURE_INPUT => "Input",
|
||||
SATURATION_INPUT => "Saturation",
|
||||
_ => id,
|
||||
}
|
||||
}
|
||||
|
||||
/// Evaluate outputs: no texture on `tex_in` -> push nothing;
|
||||
/// texture present -> push a shader job over the input row with the
|
||||
/// control resolved (so the renderer always finds a value for the
|
||||
/// uniform, whether the row carried the input or the node's own
|
||||
/// default/keyframe supplied it).
|
||||
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 resolve = |id: &str| match inputs.get(id) {
|
||||
Some(v) => v.clone(),
|
||||
None => core.value_at_time(id, -1, time),
|
||||
};
|
||||
|
||||
let mut params = inputs.clone();
|
||||
params.insert(SATURATION_INPUT.to_string(), resolve(SATURATION_INPUT));
|
||||
|
||||
table.push(
|
||||
crate::value::ValueType::Texture,
|
||||
crate::value::NodeValue::Texture(crate::handle::make_owned(ShaderJobPayload {
|
||||
node_id: crate::id::NodeId::INVALID,
|
||||
time,
|
||||
iterations: 1,
|
||||
type_id: self.type_id().to_string(),
|
||||
shader_id: String::new(),
|
||||
effect_input: core.effect_input.clone(),
|
||||
params,
|
||||
iterative_input: String::new(),
|
||||
})),
|
||||
None,
|
||||
);
|
||||
}
|
||||
|
||||
/// Shader code request: the request id is ignored; always returns
|
||||
/// [`SHADER_FRAG`].
|
||||
fn shader_code(&self, _request: &str) -> Option<String> {
|
||||
Some(SHADER_FRAG.to_string())
|
||||
}
|
||||
|
||||
/// Deep copy.
|
||||
fn duplicate(&self, _core: &NodeCore) -> Option<Box<dyn NodeBehavior>> {
|
||||
Some(Box::new(SaturationNode))
|
||||
}
|
||||
}
|
||||
|
||||
/// Constructor: adds `tex_in` (texture, effect input) and the
|
||||
/// saturation control with the default and range documented on the
|
||||
/// constant, 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);
|
||||
|
||||
add_float_input(&mut core, SATURATION_INPUT, 1.0, 0.0, 4.0);
|
||||
|
||||
core.flags |= crate::node::flags::VIDEO_EFFECT;
|
||||
core.effect_input = TEXTURE_INPUT.to_string();
|
||||
|
||||
(core, Box::new(SaturationNode))
|
||||
}
|
||||
|
||||
/// Add a float input with its default and `min`/`max`/`view`
|
||||
/// properties.
|
||||
fn add_float_input(core: &mut NodeCore, id: &str, default: f64, min: f64, max: f64) {
|
||||
let mut input = crate::input::Input::new(
|
||||
id,
|
||||
crate::value::ValueType::Float,
|
||||
crate::value::NodeValue::Float(default),
|
||||
);
|
||||
input.properties = vec![
|
||||
("min".to_string(), crate::value::NodeValue::Float(min)),
|
||||
("max".to_string(), crate::value::NodeValue::Float(max)),
|
||||
(
|
||||
"view".to_string(),
|
||||
crate::value::NodeValue::Text("normal".into()),
|
||||
),
|
||||
];
|
||||
core.add_input(input);
|
||||
}
|
||||
|
||||
/// Register this node type.
|
||||
pub fn register(meta: &mut Vec<NodeMeta>) {
|
||||
meta.push(NodeMeta {
|
||||
type_id: "org.olivevideoeditor.Olive.saturation",
|
||||
name: "Saturation",
|
||||
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 = SaturationNode;
|
||||
assert_eq!(n.input_name(TEXTURE_INPUT), "Input");
|
||||
assert_eq!(n.input_name(SATURATION_INPUT), "Saturation");
|
||||
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.saturation");
|
||||
assert_eq!(behavior.name(), "Saturation");
|
||||
assert_eq!(behavior.categories(), &[Category::Color]);
|
||||
let tex = core.get_input(TEXTURE_INPUT).unwrap();
|
||||
assert_ne!(tex.flags & crate::input::flags::NOT_KEYFRAMABLE, 0);
|
||||
assert_eq!(
|
||||
core.get_input(SATURATION_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 create_sets_control_range() {
|
||||
let (core, _) = create();
|
||||
let saturation = core.get_input(SATURATION_INPUT).unwrap();
|
||||
assert_eq!(saturation.properties[0].1, NodeValue::Float(0.0));
|
||||
assert_eq!(saturation.properties[1].1, NodeValue::Float(4.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_shader_job_with_resolved_params() {
|
||||
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);
|
||||
let Some(NodeValue::Texture(handle)) = table.get(ValueType::Texture) else {
|
||||
panic!("expected a texture-typed value");
|
||||
};
|
||||
let payload =
|
||||
unsafe { crate::handle::get_checked::<crate::jobs::ShaderJobPayload>(handle) }
|
||||
.expect("shader job pushed");
|
||||
assert_eq!(payload.type_id, "org.olivevideoeditor.Olive.saturation");
|
||||
assert_eq!(payload.effect_input, TEXTURE_INPUT);
|
||||
assert_eq!(
|
||||
payload.params.get(SATURATION_INPUT),
|
||||
Some(&NodeValue::Float(1.0))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_row_values_win_over_defaults() {
|
||||
let (mut core, behavior) = create();
|
||||
core.set_standard_value(SATURATION_INPUT, -1, NodeValue::Float(3.0));
|
||||
let inputs = crate::value::NodeValueRow::from([
|
||||
(
|
||||
TEXTURE_INPUT.to_string(),
|
||||
NodeValue::Texture(crate::handle::CHandle::null()),
|
||||
),
|
||||
(SATURATION_INPUT.to_string(), NodeValue::Float(0.25)),
|
||||
]);
|
||||
let mut table = NodeValueTable::default();
|
||||
behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
|
||||
let Some(NodeValue::Texture(handle)) = table.get(ValueType::Texture) else {
|
||||
panic!("expected a texture-typed value");
|
||||
};
|
||||
let payload =
|
||||
unsafe { crate::handle::get_checked::<crate::jobs::ShaderJobPayload>(handle) }
|
||||
.expect("shader job pushed");
|
||||
assert_eq!(
|
||||
payload.params.get(SATURATION_INPUT),
|
||||
Some(&NodeValue::Float(0.25))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shader_declares_uniforms_and_avoids_switch() {
|
||||
let code = SaturationNode.shader_code("").unwrap();
|
||||
for uniform in ["tex_in", SATURATION_INPUT] {
|
||||
assert!(code.contains(uniform), "uniform {uniform} declared");
|
||||
}
|
||||
assert!(code.contains("ove_texcoord"));
|
||||
assert!(code.contains("frag_color"));
|
||||
assert!(!code.contains("switch"), "naga rejects GLSL switch");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_clones() {
|
||||
let (core, behavior) = create();
|
||||
let dup = behavior.duplicate(&core).unwrap();
|
||||
assert_eq!(dup.name(), "Saturation");
|
||||
assert_eq!(dup.type_id(), "org.olivevideoeditor.Olive.saturation");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
// Oak Video Editor - Non-Linear Video Editor
|
||||
// Copyright (C) 2026 Oak Team
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! Sharpen filter (clean-room reimplementation of the CImg `Sharpen`
|
||||
//! / `net.sf.cimg.CImgSharpen` effect; `ofx-misc` used for parameter
|
||||
//! semantics only, no code copied).
|
||||
//!
|
||||
//! Upstream blurs a copy of the image and mixes it back as
|
||||
//! `input * (1 + amount) - blurred * amount`, i.e. an unsharp mask
|
||||
//! `out = x + amount * (x - blur(x))` with its CImg blur kernel
|
||||
//! (`amount` defaults to 1, negative values soften). The blur is a
|
||||
//! 3x3 box average computed inline in the same pass — upstream's
|
||||
//! separable, user-sized CImg blur would need a multi-pass job, which
|
||||
//! this node trades for a single-pass small-kernel mask.
|
||||
|
||||
use crate::factory::NodeMeta;
|
||||
use crate::jobs::ShaderJobPayload;
|
||||
use crate::node::{Category, NodeBehavior, NodeCore};
|
||||
|
||||
/// Texture input id. Type: texture; flags: not-keyframable; this is the
|
||||
/// node's effect input.
|
||||
pub const TEXTURE_INPUT: &str = "tex_in";
|
||||
|
||||
/// Sharpening amount input id (upstream `amount`, "Amount"). Type:
|
||||
/// float; default `1.0` (upstream default); no range restriction —
|
||||
/// upstream's range is unbounded, so a negative amount softens
|
||||
/// (`out = x + amount * (x - blur(x))` turns into a blend toward the
|
||||
/// blurred image). `0.0` is an exact identity.
|
||||
pub const AMOUNT_INPUT: &str = "amount_in";
|
||||
|
||||
/// Sharpen filter node. Unsharp mask against an inline 3x3 box blur.
|
||||
pub struct SharpenNode;
|
||||
|
||||
/// Fragment shader: 3x3 box blur of the pixel neighborhood, then the
|
||||
/// unsharp mask `x + amount * (x - blur(x))` on all four channels.
|
||||
/// Offsets are one texel (`resolution_in`, auto-filled by the renderer);
|
||||
/// edge-of-frame taps clamp to the border pixel. The result is not
|
||||
/// clamped: F32 output, and the overshoot on either side of an edge is
|
||||
/// the point of the effect. `switch` is deliberately not used (naga
|
||||
/// rejects it).
|
||||
const SHADER_FRAG: &str = r#"uniform sampler2D tex_in;
|
||||
uniform float amount_in;
|
||||
uniform vec2 resolution_in;
|
||||
|
||||
in vec2 ove_texcoord;
|
||||
out vec4 frag_color;
|
||||
|
||||
// Blur kernel edge (3x3 box, i.e. 9 taps).
|
||||
#define KERNEL_RADIUS 1
|
||||
|
||||
void main() {
|
||||
vec2 texel = vec2(1.0) / resolution_in;
|
||||
|
||||
vec4 center = texture(tex_in, ove_texcoord);
|
||||
|
||||
// 3x3 box blur, computed inline: the unsharp mask only needs a small
|
||||
// neighborhood, so no second pass (and no nested job) is involved.
|
||||
vec4 blurred = vec4(0.0);
|
||||
for (int y = -KERNEL_RADIUS; y <= KERNEL_RADIUS; ++y) {
|
||||
for (int x = -KERNEL_RADIUS; x <= KERNEL_RADIUS; ++x) {
|
||||
blurred += texture(tex_in, ove_texcoord + vec2(float(x), float(y)) * texel);
|
||||
}
|
||||
}
|
||||
float kernel_area = float((2 * KERNEL_RADIUS + 1) * (2 * KERNEL_RADIUS + 1));
|
||||
blurred /= kernel_area;
|
||||
|
||||
frag_color = center + amount_in * (center - blurred);
|
||||
}
|
||||
"#;
|
||||
|
||||
impl SharpenNode {
|
||||
/// Fragment shader for any request (this node has a single variant).
|
||||
fn shader_frag() -> &'static str {
|
||||
SHADER_FRAG
|
||||
}
|
||||
}
|
||||
|
||||
impl NodeBehavior for SharpenNode {
|
||||
/// Human-readable name.
|
||||
fn name(&self) -> &str {
|
||||
"Sharpen"
|
||||
}
|
||||
|
||||
/// Stable type id.
|
||||
fn type_id(&self) -> &str {
|
||||
"org.olivevideoeditor.Olive.sharpen"
|
||||
}
|
||||
|
||||
/// Categories.
|
||||
fn categories(&self) -> &[Category] {
|
||||
&[Category::Filter]
|
||||
}
|
||||
|
||||
/// Description.
|
||||
fn description(&self) -> &str {
|
||||
"Sharpens an image with an unsharp mask."
|
||||
}
|
||||
|
||||
/// Localized input names: `tex_in` -> "Input", `amount_in` ->
|
||||
/// "Amount".
|
||||
fn input_name<'a>(&self, id: &'a str) -> &'a str {
|
||||
match id {
|
||||
TEXTURE_INPUT => "Input",
|
||||
AMOUNT_INPUT => "Amount",
|
||||
_ => id,
|
||||
}
|
||||
}
|
||||
|
||||
/// Evaluate outputs: no texture -> push nothing; otherwise push the
|
||||
/// unsharp-mask shader job.
|
||||
///
|
||||
/// A zero `amount_in` still pushes the job (the mask then reduces to
|
||||
/// the input pixel exactly) rather than passing the input texture
|
||||
/// through as the blur node does for a zero radius: the plan's node
|
||||
/// template reserves the pass-through for the no-texture case, and
|
||||
/// feeding the input's CPU texture downstream here would silently
|
||||
/// break nodes that expect a GPU texture. The resolved amount is
|
||||
/// written into the job row so the uniform is always present, even
|
||||
/// when the incoming row only carries the effect input.
|
||||
///
|
||||
/// `resolution_in` is filled by the runner from the effect input's
|
||||
/// size (C++ `tex->virtual_resolution()`; see the blur node's
|
||||
/// `// CPP-PARITY: blur.cpp` note).
|
||||
fn value(
|
||||
&self,
|
||||
core: &NodeCore,
|
||||
inputs: &crate::value::NodeValueRow,
|
||||
time: oak_core::Rational,
|
||||
table: &mut crate::value::NodeValueTable,
|
||||
) {
|
||||
use crate::value::{NodeValue, ValueType};
|
||||
|
||||
match inputs.get(TEXTURE_INPUT) {
|
||||
Some(NodeValue::Texture(_)) => {}
|
||||
_ => return,
|
||||
}
|
||||
|
||||
let amount = match inputs.get(AMOUNT_INPUT) {
|
||||
Some(v) => v.to_double(),
|
||||
None => core.value_at_time(AMOUNT_INPUT, -1, time).to_double(),
|
||||
};
|
||||
|
||||
let mut params = inputs.clone();
|
||||
params.insert(AMOUNT_INPUT.to_string(), NodeValue::Float(amount));
|
||||
|
||||
table.push(
|
||||
ValueType::Texture,
|
||||
NodeValue::Texture(crate::handle::make_owned(ShaderJobPayload {
|
||||
node_id: crate::id::NodeId::INVALID,
|
||||
time,
|
||||
iterations: 1,
|
||||
type_id: self.type_id().to_string(),
|
||||
shader_id: String::new(),
|
||||
effect_input: core.effect_input.clone(),
|
||||
params,
|
||||
iterative_input: String::new(),
|
||||
})),
|
||||
None,
|
||||
);
|
||||
}
|
||||
|
||||
/// Shader code request: always the one fragment source.
|
||||
fn shader_code(&self, _request: &str) -> Option<String> {
|
||||
Some(Self::shader_frag().to_string())
|
||||
}
|
||||
|
||||
/// Deep copy.
|
||||
fn duplicate(&self, _core: &NodeCore) -> Option<Box<dyn NodeBehavior>> {
|
||||
Some(Box::new(SharpenNode))
|
||||
}
|
||||
}
|
||||
|
||||
/// Constructor: adds `tex_in` and `amount_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);
|
||||
|
||||
core.add_input(crate::input::Input::new(
|
||||
AMOUNT_INPUT,
|
||||
crate::value::ValueType::Float,
|
||||
crate::value::NodeValue::Float(1.0),
|
||||
));
|
||||
|
||||
core.flags |= crate::node::flags::VIDEO_EFFECT;
|
||||
core.effect_input = TEXTURE_INPUT.to_string();
|
||||
|
||||
(core, Box::new(SharpenNode))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::value::{NodeValue, NodeValueTable, ValueType};
|
||||
use oak_core::Rational;
|
||||
|
||||
fn tex() -> NodeValue {
|
||||
NodeValue::Texture(crate::handle::CHandle::null())
|
||||
}
|
||||
|
||||
fn run(core: &NodeCore, inputs: &crate::value::NodeValueRow) -> ShaderJobPayload {
|
||||
let behavior = SharpenNode;
|
||||
let mut table = NodeValueTable::default();
|
||||
behavior.value(core, inputs, Rational::new(0, 1), &mut table);
|
||||
let Some(NodeValue::Texture(h)) = table.get(ValueType::Texture) else {
|
||||
panic!("shader job expected");
|
||||
};
|
||||
unsafe { crate::handle::get_checked::<ShaderJobPayload>(h) }
|
||||
.expect("shader job payload boxed")
|
||||
.clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn input_names() {
|
||||
let n = SharpenNode;
|
||||
assert_eq!(n.input_name(TEXTURE_INPUT), "Input");
|
||||
assert_eq!(n.input_name(AMOUNT_INPUT), "Amount");
|
||||
assert_eq!(n.input_name("other"), "other");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_wires_inputs_and_flags() {
|
||||
let (core, behavior) = create();
|
||||
assert_eq!(behavior.type_id(), "org.olivevideoeditor.Olive.sharpen");
|
||||
assert_eq!(behavior.name(), "Sharpen");
|
||||
assert_eq!(behavior.categories(), &[Category::Filter]);
|
||||
assert_eq!(core.effect_input, TEXTURE_INPUT);
|
||||
assert_ne!(core.flags & crate::node::flags::VIDEO_EFFECT, 0);
|
||||
assert_eq!(
|
||||
core.get_input(AMOUNT_INPUT).expect("amount input").default,
|
||||
NodeValue::Float(1.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_pushes_job_with_default_amount() {
|
||||
let (core, _) = create();
|
||||
let inputs = crate::value::NodeValueRow::from([(TEXTURE_INPUT.to_string(), tex())]);
|
||||
let payload = run(&core, &inputs);
|
||||
assert_eq!(payload.type_id, "org.olivevideoeditor.Olive.sharpen");
|
||||
assert_eq!(payload.shader_id, "");
|
||||
assert_eq!(payload.effect_input, TEXTURE_INPUT);
|
||||
assert_eq!(payload.iterations, 1);
|
||||
assert_eq!(payload.iterative_input, "");
|
||||
assert_eq!(
|
||||
payload.params.get(AMOUNT_INPUT),
|
||||
Some(&NodeValue::Float(1.0)),
|
||||
"the sharpening amount rides in the job params"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_takes_amount_from_row() {
|
||||
let (core, _) = create();
|
||||
let inputs = crate::value::NodeValueRow::from([
|
||||
(TEXTURE_INPUT.to_string(), tex()),
|
||||
(AMOUNT_INPUT.to_string(), NodeValue::Float(0.0)),
|
||||
]);
|
||||
let payload = run(&core, &inputs);
|
||||
assert_eq!(
|
||||
payload.params.get(AMOUNT_INPUT),
|
||||
Some(&NodeValue::Float(0.0))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_takes_amount_from_core() {
|
||||
let (mut core, _) = create();
|
||||
core.set_standard_value(AMOUNT_INPUT, -1, NodeValue::Float(2.5));
|
||||
let inputs = crate::value::NodeValueRow::from([(TEXTURE_INPUT.to_string(), tex())]);
|
||||
let payload = run(&core, &inputs);
|
||||
assert_eq!(
|
||||
payload.params.get(AMOUNT_INPUT),
|
||||
Some(&NodeValue::Float(2.5))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shader_code_is_inline_unsharp_mask_without_switch() {
|
||||
let n = SharpenNode;
|
||||
let glsl = n.shader_code("").unwrap();
|
||||
assert!(glsl.contains("uniform float amount_in;"));
|
||||
assert!(glsl.contains("uniform vec2 resolution_in;"));
|
||||
assert!(glsl.contains("#define KERNEL_RADIUS 1"));
|
||||
assert!(glsl.contains("frag_color = center + amount_in * (center - blurred);"));
|
||||
assert!(!glsl.contains("switch"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_clones_behavior() {
|
||||
let (core, behavior) = create();
|
||||
let dup = behavior.duplicate(&core).unwrap();
|
||||
assert_eq!(dup.name(), "Sharpen");
|
||||
}
|
||||
}
|
||||
|
||||
/// Register this node type.
|
||||
pub fn register(meta: &mut Vec<NodeMeta>) {
|
||||
meta.push(NodeMeta {
|
||||
type_id: "org.olivevideoeditor.Olive.sharpen",
|
||||
name: "Sharpen",
|
||||
categories: &[Category::Filter],
|
||||
create,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
// 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/>.
|
||||
|
||||
//! Unpremultiply effect (clean-room reimplementation of the OpenFX-Misc
|
||||
//! `UnpremultOFX` / `net.sf.openfx.PremultPlugin` in its unpremultiply
|
||||
//! mode; the `Premult` reference tree is used for parameter semantics
|
||||
//! only, no code copied).
|
||||
//!
|
||||
//! The inverse of [`crate::nodes::premult`]: each RGB channel is divided
|
||||
//! by the channel picked in [`CHANNEL_INPUT`] (default
|
||||
//! [`Channel::Alpha`]) instead of multiplied by it, and the source alpha
|
||||
//! rides through untouched. Upstream guards the division —
|
||||
//! `!process_c || alpha <= FLT_EPSILON` keeps the source value
|
||||
//! (`// CPP-PARITY: Premult.cpp`) — and the shader keeps the same guard.
|
||||
//!
|
||||
//! Single texture input, so `core.effect_input` is set to
|
||||
//! [`TEXTURE_INPUT`] (the despill node's shape) and the job binds that
|
||||
//! one sampler by name. `None` (or an out-of-range selector) divides by
|
||||
//! one, leaving the image unchanged.
|
||||
|
||||
use crate::factory::NodeMeta;
|
||||
use crate::jobs::ShaderJobPayload;
|
||||
use crate::node::{Category, NodeBehavior, NodeCore};
|
||||
|
||||
/// Texture input id. Type: texture; flags: not-keyframable; this is the
|
||||
/// node's effect input.
|
||||
pub const TEXTURE_INPUT: &str = "tex_in";
|
||||
|
||||
/// Channel selector input id (upstream's "premult channel" choice, used
|
||||
/// here as the divisor). Type: combo; default [`Channel::Alpha`]
|
||||
/// (`Combo(4)`); flags: not-connectable, not-keyframable. Combo strings:
|
||||
/// "None", "R", "G", "B", "A".
|
||||
pub const CHANNEL_INPUT: &str = "unpremult_channel_in";
|
||||
|
||||
/// Channel selector values for [`CHANNEL_INPUT`], in combo order. The
|
||||
/// numeric values are the shader's channel indices: `0` selects nothing
|
||||
/// (the image passes through — a divisor of 1) and `1..=4` select
|
||||
/// `r`/`g`/`b`/`a`.
|
||||
#[repr(i64)]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Channel {
|
||||
/// No channel: the image is left unchanged.
|
||||
None = 0,
|
||||
/// Red channel.
|
||||
Red = 1,
|
||||
/// Green channel.
|
||||
Green = 2,
|
||||
/// Blue channel.
|
||||
Blue = 3,
|
||||
/// Alpha channel (the default divisor).
|
||||
Alpha = 4,
|
||||
}
|
||||
|
||||
/// Unpremultiply node. Unit-like: there is no per-instance state.
|
||||
pub struct UnpremultiplyNode;
|
||||
|
||||
/// Fragment shader: `rgb /= selected_channel` with the upstream
|
||||
/// near-zero guard. The channel dispatch is an if/else chain and not a
|
||||
/// `switch` (naga rejects the latter); an out-of-range selector falls
|
||||
/// back to a divisor of 1, the "None" combo entry. No `tex_in_enabled`
|
||||
/// flag is declared: with a single texture input the sampler is either
|
||||
/// bound or the job never runs (`value()` requires the texture), and the
|
||||
/// despill node's shader has the same shape.
|
||||
const SHADER_FRAG: &str = r#"uniform sampler2D tex_in;
|
||||
uniform int unpremult_channel_in;
|
||||
|
||||
in vec2 ove_texcoord;
|
||||
out vec4 frag_color;
|
||||
|
||||
float selected_channel(vec4 col, int channel) {
|
||||
if (channel == 1) {
|
||||
return col.r;
|
||||
} else if (channel == 2) {
|
||||
return col.g;
|
||||
} else if (channel == 3) {
|
||||
return col.b;
|
||||
} else if (channel == 4) {
|
||||
return col.a;
|
||||
}
|
||||
return 1.0;
|
||||
}
|
||||
|
||||
void main(void) {
|
||||
vec4 col = texture(tex_in, ove_texcoord);
|
||||
float factor = selected_channel(col, unpremult_channel_in);
|
||||
|
||||
// Upstream keeps the source value when the divisor is (near) zero
|
||||
// (`alpha <= FLT_EPSILON` in Premult.cpp), which is also what saves
|
||||
// an unpainted alpha of 0 from blowing the RGB channels up.
|
||||
if (factor <= 0.000001) {
|
||||
frag_color = col;
|
||||
return;
|
||||
}
|
||||
|
||||
frag_color = vec4(col.rgb / factor, col.a);
|
||||
}
|
||||
"#;
|
||||
|
||||
impl UnpremultiplyNode {
|
||||
/// Fragment shader (single variant, so the request id is ignored).
|
||||
fn shader_frag() -> &'static str {
|
||||
SHADER_FRAG
|
||||
}
|
||||
}
|
||||
|
||||
impl NodeBehavior for UnpremultiplyNode {
|
||||
/// Human-readable name.
|
||||
fn name(&self) -> &str {
|
||||
"Unpremultiply"
|
||||
}
|
||||
|
||||
/// Stable type id.
|
||||
fn type_id(&self) -> &str {
|
||||
"org.olivevideoeditor.Olive.unpremult"
|
||||
}
|
||||
|
||||
/// Categories. Filed under math like the alpha-over
|
||||
/// [`crate::nodes::merge`] (the `Category` enum has no merge group).
|
||||
fn categories(&self) -> &[Category] {
|
||||
&[Category::Math]
|
||||
}
|
||||
|
||||
/// Description.
|
||||
fn description(&self) -> &str {
|
||||
"Divide the RGB channels by a selected channel (zero guarded)."
|
||||
}
|
||||
|
||||
/// Localized input names: `tex_in` -> "Input", `unpremult_channel_in`
|
||||
/// -> "Channel".
|
||||
fn input_name<'a>(&self, id: &'a str) -> &'a str {
|
||||
match id {
|
||||
TEXTURE_INPUT => "Input",
|
||||
CHANNEL_INPUT => "Channel",
|
||||
_ => id,
|
||||
}
|
||||
}
|
||||
|
||||
/// Combo input option labels: `unpremult_channel_in` -> "None", "R",
|
||||
/// "G", "B", "A" (the [`Channel`] order).
|
||||
fn input_combo_strings(&self, id: &str) -> Vec<&'static str> {
|
||||
match id {
|
||||
CHANNEL_INPUT => vec!["None", "R", "G", "B", "A"],
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Evaluate outputs: with no texture there is nothing to divide;
|
||||
/// otherwise push one shader job over the whole input row, with the
|
||||
/// resolved channel selector written in so the uniform is always
|
||||
/// present even when the incoming row only carries the texture (the
|
||||
/// row/standard-value split mirrors the directional-blur node).
|
||||
fn value(
|
||||
&self,
|
||||
core: &NodeCore,
|
||||
inputs: &crate::value::NodeValueRow,
|
||||
time: oak_core::Rational,
|
||||
table: &mut crate::value::NodeValueTable,
|
||||
) {
|
||||
use crate::value::{NodeValue, ValueType};
|
||||
|
||||
match inputs.get(TEXTURE_INPUT) {
|
||||
Some(NodeValue::Texture(_)) => {}
|
||||
_ => return,
|
||||
}
|
||||
|
||||
let channel = match inputs.get(CHANNEL_INPUT) {
|
||||
Some(v) => v.clone(),
|
||||
None => core.value_at_time(CHANNEL_INPUT, -1, time),
|
||||
};
|
||||
|
||||
let mut params = inputs.clone();
|
||||
params.insert(CHANNEL_INPUT.to_string(), channel);
|
||||
|
||||
table.push(
|
||||
ValueType::Texture,
|
||||
NodeValue::Texture(crate::handle::make_owned(ShaderJobPayload {
|
||||
node_id: crate::id::NodeId::INVALID,
|
||||
time,
|
||||
iterations: 1,
|
||||
type_id: self.type_id().to_string(),
|
||||
shader_id: String::new(),
|
||||
effect_input: core.effect_input.clone(),
|
||||
params,
|
||||
iterative_input: String::new(),
|
||||
})),
|
||||
None,
|
||||
);
|
||||
}
|
||||
|
||||
/// Shader code request: always the unpremultiply fragment source.
|
||||
fn shader_code(&self, _request: &str) -> Option<String> {
|
||||
Some(Self::shader_frag().to_string())
|
||||
}
|
||||
|
||||
/// Deep copy.
|
||||
fn duplicate(&self, _core: &NodeCore) -> Option<Box<dyn NodeBehavior>> {
|
||||
Some(Box::new(UnpremultiplyNode))
|
||||
}
|
||||
}
|
||||
|
||||
/// Constructor: adds `tex_in` (not-keyframable) and
|
||||
/// `unpremult_channel_in` (combo, default alpha), 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);
|
||||
|
||||
let mut channel = crate::input::Input::new(
|
||||
CHANNEL_INPUT,
|
||||
crate::value::ValueType::Combo,
|
||||
crate::value::NodeValue::Combo(Channel::Alpha as i64),
|
||||
);
|
||||
channel.flags |= crate::input::flags::NOT_CONNECTABLE | crate::input::flags::NOT_KEYFRAMABLE;
|
||||
core.add_input(channel);
|
||||
|
||||
core.flags |= crate::node::flags::VIDEO_EFFECT;
|
||||
core.effect_input = TEXTURE_INPUT.to_string();
|
||||
|
||||
(core, Box::new(UnpremultiplyNode))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::value::{NodeValue, NodeValueTable, ValueType};
|
||||
use oak_core::Rational;
|
||||
|
||||
fn tex() -> NodeValue {
|
||||
NodeValue::Texture(crate::handle::CHandle::null())
|
||||
}
|
||||
|
||||
fn run(core: &NodeCore, inputs: &crate::value::NodeValueRow) -> ShaderJobPayload {
|
||||
let behavior = UnpremultiplyNode;
|
||||
let mut table = NodeValueTable::default();
|
||||
behavior.value(core, inputs, Rational::new(0, 1), &mut table);
|
||||
let Some(NodeValue::Texture(h)) = table.get(ValueType::Texture) else {
|
||||
panic!("shader job expected");
|
||||
};
|
||||
unsafe { crate::handle::get_checked::<ShaderJobPayload>(h) }
|
||||
.expect("shader job payload boxed")
|
||||
.clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn input_names() {
|
||||
let n = UnpremultiplyNode;
|
||||
assert_eq!(n.input_name(TEXTURE_INPUT), "Input");
|
||||
assert_eq!(n.input_name(CHANNEL_INPUT), "Channel");
|
||||
assert_eq!(n.input_name("other"), "other");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn combo_strings_match_channel_values() {
|
||||
let n = UnpremultiplyNode;
|
||||
let strings = n.input_combo_strings(CHANNEL_INPUT);
|
||||
assert_eq!(strings, vec!["None", "R", "G", "B", "A"]);
|
||||
assert_eq!(Channel::None as i64, 0);
|
||||
assert_eq!(Channel::Red as i64, 1);
|
||||
assert_eq!(Channel::Green as i64, 2);
|
||||
assert_eq!(Channel::Blue as i64, 3);
|
||||
assert_eq!(Channel::Alpha as i64, 4);
|
||||
assert!(n.input_combo_strings("other").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_wires_inputs_flags_and_defaults() {
|
||||
let (core, behavior) = create();
|
||||
assert_eq!(behavior.type_id(), "org.olivevideoeditor.Olive.unpremult");
|
||||
assert_eq!(behavior.name(), "Unpremultiply");
|
||||
assert_eq!(behavior.categories(), &[Category::Math]);
|
||||
assert_ne!(core.flags & crate::node::flags::VIDEO_EFFECT, 0);
|
||||
assert_eq!(core.effect_input, TEXTURE_INPUT);
|
||||
|
||||
let tex_input = core.get_input(TEXTURE_INPUT).expect("texture input");
|
||||
assert_eq!(tex_input.value_type, ValueType::Texture);
|
||||
assert_ne!(tex_input.flags & crate::input::flags::NOT_KEYFRAMABLE, 0);
|
||||
|
||||
let channel = core.get_input(CHANNEL_INPUT).expect("channel input");
|
||||
assert_eq!(channel.value_type, ValueType::Combo);
|
||||
assert_eq!(channel.default, NodeValue::Combo(Channel::Alpha as i64));
|
||||
assert_ne!(channel.flags & crate::input::flags::NOT_CONNECTABLE, 0);
|
||||
assert_ne!(channel.flags & crate::input::flags::NOT_KEYFRAMABLE, 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_defaults_channel_to_alpha() {
|
||||
let (core, _) = create();
|
||||
let inputs = crate::value::NodeValueRow::from([(TEXTURE_INPUT.to_string(), tex())]);
|
||||
let payload = run(&core, &inputs);
|
||||
assert_eq!(payload.type_id, "org.olivevideoeditor.Olive.unpremult");
|
||||
assert_eq!(payload.shader_id, "");
|
||||
assert_eq!(payload.iterations, 1);
|
||||
assert_eq!(payload.iterative_input, "");
|
||||
assert_eq!(payload.effect_input, TEXTURE_INPUT);
|
||||
assert_eq!(
|
||||
payload.params.get(CHANNEL_INPUT),
|
||||
Some(&NodeValue::Combo(Channel::Alpha as i64)),
|
||||
"the default channel rides in the job params"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_takes_channel_from_row() {
|
||||
let (core, _) = create();
|
||||
let inputs = crate::value::NodeValueRow::from([
|
||||
(TEXTURE_INPUT.to_string(), tex()),
|
||||
(
|
||||
CHANNEL_INPUT.to_string(),
|
||||
NodeValue::Combo(Channel::Red as i64),
|
||||
),
|
||||
]);
|
||||
let payload = run(&core, &inputs);
|
||||
assert_eq!(
|
||||
payload.params.get(CHANNEL_INPUT),
|
||||
Some(&NodeValue::Combo(Channel::Red as i64))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shader_code_declares_inputs_and_zero_guard() {
|
||||
let n = UnpremultiplyNode;
|
||||
let glsl = n.shader_code("").unwrap();
|
||||
assert!(glsl.contains("uniform sampler2D tex_in;"));
|
||||
assert!(glsl.contains("uniform int unpremult_channel_in;"));
|
||||
assert!(glsl.contains("if (factor <= 0.000001) {"));
|
||||
assert!(glsl.contains("frag_color = vec4(col.rgb / factor, col.a);"));
|
||||
assert!(!glsl.contains("switch"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_clones_behavior() {
|
||||
let (core, behavior) = create();
|
||||
let dup = behavior.duplicate(&core).unwrap();
|
||||
assert_eq!(dup.name(), "Unpremultiply");
|
||||
}
|
||||
}
|
||||
|
||||
/// Register this node type.
|
||||
pub fn register(meta: &mut Vec<NodeMeta>) {
|
||||
meta.push(NodeMeta {
|
||||
type_id: "org.olivevideoeditor.Olive.unpremult",
|
||||
name: "Unpremultiply",
|
||||
categories: &[Category::Math],
|
||||
create,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user