diff --git a/crates/oak-app/src/panels/effect_library.rs b/crates/oak-app/src/panels/effect_library.rs
index af8c15265..84959e7f3 100644
--- a/crates/oak-app/src/panels/effect_library.rs
+++ b/crates/oak-app/src/panels/effect_library.rs
@@ -15,10 +15,13 @@
// along with this program. If not, see .
//! The effect library panel (效果库): every effect type the engine can add
-//! to a clip's chain, as a flat list. Double-clicking an entry appends the
-//! effect to the selected clip's effect chain (the undoable
+//! to a clip's chain, as a grouped list. Double-clicking an entry appends
+//! the effect to the selected clip's effect chain (the undoable
//! [`AppEngine::add_effect`]; the insertion index is clamped to the chain
-//! end by the backend).
+//! end by the backend). Every group header collapses and expands its
+//! entries; the collapsed set is persisted across sessions.
+
+use std::collections::HashSet;
use gpui::colors::DefaultColors;
use gpui::dock::{DockPanel, PanelEvent};
@@ -30,16 +33,26 @@ use gpui_elements::editable_text::{EditableTextState, StringStorage, TextChanged
use crate::oakui::component::text_input;
use crate::i18n;
+use crate::oakui::effectchain::group_label;
+use crate::oakui::engine::EffectEntry;
use crate::oakui::AppEngine;
use crate::panels::commands::PanelCommandHandler;
use crate::panels::ids::EFFECT_LIBRARY;
+/// The config key the collapsed groups are persisted under (a comma
+/// separated list of group keys — the keys [`EffectEntry::group`] holds,
+/// not the display labels).
+const COLLAPSED_CONFIG_KEY: &str = "EffectLibraryCollapsed";
+
/// The effect library panel.
pub struct EffectLibraryPanel {
engine: Entity,
/// The search box state: live-filters the list by name / type id
/// (case-insensitive substring).
search: Entity,
+ /// The group keys whose entries are collapsed away. The headers stay
+ /// rendered (and clickable) so a collapsed group can be reopened.
+ collapsed: HashSet,
}
impl EffectLibraryPanel {
@@ -53,10 +66,81 @@ impl EffectLibraryPanel {
cx.notify();
})
.detach();
- Self { engine, search }
+ Self {
+ engine,
+ search,
+ collapsed: load_collapsed(),
+ }
}
}
+/// Reads the persisted collapsed groups (a missing or malformed value
+/// simply starts with every group expanded).
+fn load_collapsed() -> HashSet {
+ parse_collapsed(
+ &oak_core::configstore::ConfigStore::instance()
+ .get(None, COLLAPSED_CONFIG_KEY)
+ .unwrap_or_default(),
+ )
+}
+
+/// Parses the persisted comma separated key list; blanks and padding
+/// drop, so a hand-edited value cannot produce a phantom group.
+fn parse_collapsed(raw: &str) -> HashSet {
+ raw.split(',')
+ .map(str::trim)
+ .filter(|key| !key.is_empty())
+ .map(str::to_string)
+ .collect()
+}
+
+/// Serializes the collapsed set for the config: sorted and comma
+/// separated, so an unchanged set persists an unchanged string.
+fn format_collapsed(collapsed: &HashSet) -> String {
+ let mut keys: Vec<&str> = collapsed.iter().map(String::as_str).collect();
+ keys.sort_unstable();
+ keys.join(",")
+}
+
+/// Writes the collapsed set back to the config (the `UseProxyMedia`
+/// pattern: the config store is the source of truth, read once at panel
+/// construction).
+fn store_collapsed(collapsed: &HashSet) {
+ oak_core::configstore::ConfigStore::instance().set(
+ None,
+ COLLAPSED_CONFIG_KEY,
+ &format_collapsed(collapsed),
+ );
+}
+
+/// Flips the collapsed state of `group_key`, returning the new state
+/// (`true` = collapsed).
+fn toggle_collapsed(collapsed: &mut HashSet, group_key: &str) -> bool {
+ if collapsed.remove(group_key) {
+ false
+ } else {
+ collapsed.insert(group_key.to_string());
+ true
+ }
+}
+
+/// Whether `entry` matches the trimmed, lowercased search query.
+fn matches_query(entry: &EffectEntry, query: &str) -> bool {
+ query.is_empty()
+ || entry.name.to_lowercase().contains(query)
+ || entry.type_id.to_lowercase().contains(query)
+}
+
+/// Whether `entry`'s row is collapsed away. The collapse only narrows the
+/// unfiltered list: a search looks through every group, collapsed or not.
+fn hidden_by_collapse(entry: &EffectEntry, query: &str, collapsed: &HashSet) -> bool {
+ query.is_empty()
+ && entry
+ .group
+ .as_deref()
+ .is_some_and(|group| collapsed.contains(group))
+}
+
/// The effect library implements no focused-panel commands: everything
/// falls through to the shell's global handler.
impl PanelCommandHandler for EffectLibraryPanel {}
@@ -77,28 +161,45 @@ impl Render for EffectLibraryPanel {
.p_2()
.overflow_y_scroll();
- // Built-in effects render flat under a Built-in header; OpenFX
- // plugin entries are grouped under their sub-category header
- // (Filter / Generator / Transition / General — the C++
- // `factorymenu` OpenFX branch). The engine table arrives sorted
- // (built-ins first, then groups and names alphabetically); the
- // search box live-filters by name / type id.
+ // Built-in effects group by their category (color / filter /
+ // distort / keying / generator / math / general, see
+ // `effectchain::category_group_key`); OpenFX plugin entries group
+ // by their sub-category (Filter / Generator / Transition /
+ // General — the C++ `factorymenu` OpenFX branch). The engine table
+ // arrives sorted (built-ins first, then groups and names
+ // alphabetically); the search box live-filters by name / type id
+ // and always searches collapsed groups too.
let mut last_group: Option> = None;
for entry in &effects {
- if !query.is_empty()
- && !entry.name.to_lowercase().contains(&query)
- && !entry.type_id.to_lowercase().contains(&query)
- {
+ if !matches_query(entry, &query) {
continue;
}
let group_key = entry.group.clone();
if last_group.as_ref() != Some(&group_key) {
- last_group = Some(group_key);
+ last_group = Some(group_key.clone());
+ let key = group_key.clone().unwrap_or_default();
let label = match &entry.group {
- Some(group) => group.clone(),
+ Some(group) => group_label(group),
None => i18n::tr("effect_library.group.builtin").to_string(),
};
- list = list.child(group_header(&colors, &label));
+ let collapsed = self.collapsed.contains(&key);
+ list = list.child(group_header(
+ &colors,
+ &key,
+ &label,
+ collapsed,
+ cx.listener({
+ let key = key.clone();
+ move |this, _event: &ClickEvent, _window, cx| {
+ toggle_collapsed(&mut this.collapsed, &key);
+ store_collapsed(&this.collapsed);
+ cx.notify();
+ }
+ }),
+ ));
+ }
+ if hidden_by_collapse(entry, &query, &self.collapsed) {
+ continue;
}
let engine = self.engine.clone();
let row_id = entry.type_id.clone();
@@ -183,18 +284,37 @@ impl Render for EffectLibraryPanel {
}
}
-/// The sub-category header row of the OpenFX group (a muted, all-caps
-/// line above the plugin entries).
-fn group_header(colors: &gpui::colors::Colors, group: &str) -> impl IntoElement {
+/// The header row of a group: a muted semibold line with a ▶ / ▼ marker
+/// that collapses and expands the group's entries. `group_key` is the raw
+/// key ([`EffectEntry::group`], also the element id suffix); `label` is
+/// its display string.
+fn group_header(
+ colors: &gpui::colors::Colors,
+ group_key: &str,
+ label: &str,
+ collapsed: bool,
+ on_toggle: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
+) -> impl IntoElement {
div()
- .id(SharedString::from(format!("effect-library-group-{group}")))
+ .id(SharedString::from(format!("effect-library-group-{group_key}")))
+ .debug_selector({
+ let key = group_key.to_string();
+ move || format!("effect-library-group-row-{key}")
+ })
+ .cursor_pointer()
.pt_2()
.pb_1()
.px_2()
+ .flex()
+ .items_center()
+ .gap_1()
.text_xs()
.font_weight(gpui::FontWeight(600.0))
.text_color(colors.disabled)
- .child(group.to_string())
+ .hover(|style| style.text_color(colors.text))
+ .child(if collapsed { "▶" } else { "▼" })
+ .child(label.to_string())
+ .on_click(on_toggle)
}
/// The drag ghost shown under the pointer while an effect is dragged out
@@ -241,12 +361,99 @@ impl DockPanel for EffectLibraryPanel {
mod tests {
use super::*;
use crate::oakui::MockEngine;
- use gpui::{px, size, TestAppContext, VisualTestContext};
+ use gpui::{px, size, Modifiers, Point, TestAppContext, VisualTestContext};
+
+ /// Serializes the tests that read or write the process-global
+ /// collapsed-group key, and starts them from a clean value (a
+ /// developer's own persisted choice would otherwise hide rows from
+ /// the render tests).
+ fn collapsed_config_lock() -> std::sync::MutexGuard<'static, ()> {
+ static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
+ let guard = LOCK.lock().unwrap_or_else(|e| e.into_inner());
+ store_collapsed(&HashSet::new());
+ guard
+ }
+
+ /// A one-entry table row for the pure collapse logic.
+ fn entry(group: Option<&str>) -> EffectEntry {
+ EffectEntry {
+ type_id: "oak:testeffect".to_string(),
+ name: "Test Effect".to_string(),
+ group: group.map(str::to_string),
+ }
+ }
+
+ /// The row selector `debug_bounds` looks up (leaked: it needs a
+ /// `&'static str` and tests are process-lifetime).
+ fn row_selector(type_id: &str) -> &'static str {
+ Box::leak(format!("effect-library-row-{type_id}").into_boxed_str())
+ }
+
+ /// The collapse toggle and the collapsed-row predicate — a collapsed
+ /// group hides its rows, but a search still looks inside it.
+ #[test]
+ fn collapsed_rows_toggle_and_stay_searchable() {
+ let color = entry(Some("color"));
+ let mut collapsed = HashSet::new();
+
+ assert!(!hidden_by_collapse(&color, "", &collapsed));
+ assert!(toggle_collapsed(&mut collapsed, "color"), "first toggle collapses");
+ assert!(hidden_by_collapse(&color, "", &collapsed));
+ assert!(
+ !hidden_by_collapse(&color, "blur", &collapsed),
+ "a search looks through collapsed groups"
+ );
+ assert!(
+ !hidden_by_collapse(&color, "", &HashSet::new()),
+ "an unlisted group stays open"
+ );
+ assert!(!toggle_collapsed(&mut collapsed, "color"), "second toggle expands");
+ assert!(collapsed.is_empty());
+ }
+
+ /// The search predicate: name or type id, case-folded substring.
+ #[test]
+ fn query_matches_name_and_type_id() {
+ let color = entry(Some("color"));
+ assert!(matches_query(&color, ""));
+ assert!(matches_query(&color, "test"));
+ assert!(matches_query(&color, "oak:test"));
+ assert!(!matches_query(&color, "blur"));
+ }
+
+ /// The persisted form round-trips: sorted comma separated keys, with
+ /// padding and blank entries dropped.
+ #[test]
+ fn collapsed_list_round_trips() {
+ let collapsed: HashSet =
+ ["keying", "color"].iter().map(|k| k.to_string()).collect();
+ let raw = format_collapsed(&collapsed);
+ assert_eq!(raw, "color,keying");
+ assert_eq!(parse_collapsed(&raw), collapsed);
+ assert_eq!(parse_collapsed(&format!(" {raw} , ")), collapsed);
+ assert!(parse_collapsed("").is_empty());
+ assert!(parse_collapsed(" , ").is_empty());
+ }
+
+ /// The panel's storage path: the collapsed set survives a store/load
+ /// cycle through the process config store.
+ #[test]
+ fn collapsed_state_persists_in_the_config() {
+ let _guard = collapsed_config_lock();
+ assert!(load_collapsed().is_empty(), "the lock starts from a clear key");
+ let collapsed: HashSet =
+ ["distort", "general"].iter().map(|k| k.to_string()).collect();
+ store_collapsed(&collapsed);
+ assert_eq!(load_collapsed(), collapsed);
+ store_collapsed(&HashSet::new());
+ assert!(load_collapsed().is_empty());
+ }
/// The panel renders one row per addable effect of the engine (the
/// mock exposes the real factory's video-effect table).
#[gpui::test]
async fn lists_every_addable_effect(cx: &mut TestAppContext) {
+ let _guard = collapsed_config_lock();
cx.update(|cx| cx.init_colors());
let window = cx.open_window(size(px(400.0), px(600.0)), |window, cx| {
let engine = cx.new(|cx| MockEngine::demo(cx));
@@ -259,14 +466,121 @@ mod tests {
assert!(!expected.is_empty());
for entry in &expected {
let type_id = &entry.type_id;
- // `debug_bounds` takes a &'static selector; the per-row selector is
- // dynamic, so the test leaks it (process-lifetime, test-only).
- let selector: &'static str =
- Box::leak(format!("effect-library-row-{type_id}").into_boxed_str());
assert!(
- cx.debug_bounds(selector).is_some(),
+ cx.debug_bounds(row_selector(type_id)).is_some(),
"effect row {type_id} rendered"
);
}
}
+
+ /// A collapsed group keeps its header (so it can be reopened) but
+ /// renders none of its rows; the groups below it are unaffected.
+ #[gpui::test]
+ async fn collapsing_a_group_hides_its_rows(cx: &mut TestAppContext) {
+ let _guard = collapsed_config_lock();
+
+ let entries = crate::oakui::effectchain::addable_effects();
+ let group = entries
+ .first()
+ .and_then(|entry| entry.group.clone())
+ .expect("the engine's table is grouped");
+ let other = entries
+ .iter()
+ .find(|entry| entry.group.as_deref() != Some(group.as_str()))
+ .expect("the table spreads over more than one group")
+ .clone();
+
+ cx.update(|cx| cx.init_colors());
+ let window = cx.open_window(size(px(400.0), px(600.0)), |window, cx| {
+ let engine = cx.new(|cx| MockEngine::demo(cx));
+ let mut panel = EffectLibraryPanel::new(engine, window, cx);
+ // The state the toggle writes (and the config reloads on the
+ // next start), without touching the process-global key.
+ panel.collapsed.insert(group.clone());
+ panel
+ });
+ cx.run_until_parked();
+ let cx = VisualTestContext::from_window(window.into(), cx).into_mut();
+
+ assert!(
+ cx.debug_bounds(Box::leak(
+ format!("effect-library-group-row-{group}").into_boxed_str()
+ ))
+ .is_some(),
+ "the collapsed group keeps its header"
+ );
+ for entry in entries
+ .iter()
+ .filter(|entry| entry.group.as_deref() == Some(group.as_str()))
+ {
+ assert!(
+ cx.debug_bounds(row_selector(&entry.type_id)).is_none(),
+ "collapsed row {} is hidden",
+ entry.type_id
+ );
+ }
+ assert!(
+ cx.debug_bounds(row_selector(&other.type_id)).is_some(),
+ "the next group still renders its rows"
+ );
+ }
+
+ /// Clicking a group header is what writes the state: the group's rows
+ /// leave the render and the collapsed key lands in the persisted
+ /// config.
+ #[gpui::test]
+ async fn clicking_a_group_header_collapses_and_persists_it(cx: &mut TestAppContext) {
+ let _guard = collapsed_config_lock();
+
+ let entries = crate::oakui::effectchain::addable_effects();
+ let group = entries
+ .first()
+ .and_then(|entry| entry.group.clone())
+ .expect("the engine's table is grouped");
+ let row = entries
+ .iter()
+ .find(|entry| entry.group.as_deref() == Some(group.as_str()))
+ .expect("the group has rows")
+ .type_id
+ .clone();
+
+ cx.update(|cx| cx.init_colors());
+ let window = cx.open_window(size(px(400.0), px(600.0)), |window, cx| {
+ let engine = cx.new(|cx| MockEngine::demo(cx));
+ EffectLibraryPanel::new(engine, window, cx)
+ });
+ cx.run_until_parked();
+ let mut visual = VisualTestContext::from_window(window.into(), cx).into_mut();
+ visual.update(|window, cx| {
+ window.draw(cx).clear();
+ });
+
+ let header = visual
+ .debug_bounds(Box::leak(
+ format!("effect-library-group-row-{group}").into_boxed_str(),
+ ))
+ .expect("the group header is painted");
+ let center = Point::new(
+ header.origin.x + header.size.width * 0.5,
+ header.origin.y + header.size.height * 0.5,
+ );
+ assert!(
+ visual.debug_bounds(row_selector(&row)).is_some(),
+ "the row starts visible"
+ );
+
+ visual.simulate_click(center, Modifiers::default());
+ visual.update(|window, cx| {
+ window.draw(cx).clear();
+ });
+
+ assert!(
+ visual.debug_bounds(row_selector(&row)).is_none(),
+ "the clicked group collapsed"
+ );
+ assert!(
+ load_collapsed().contains(&group),
+ "the collapse was persisted under {COLLAPSED_CONFIG_KEY}"
+ );
+ }
}
diff --git a/crates/oak-app/src/panels/inspector.rs b/crates/oak-app/src/panels/inspector.rs
index 1931f3e19..8e14370f4 100644
--- a/crates/oak-app/src/panels/inspector.rs
+++ b/crates/oak-app/src/panels/inspector.rs
@@ -29,6 +29,8 @@ use gpui::{
use crate::oakui::component::menu::{Menu, MenuItem};
use crate::oakui::component::menu::{ContextMenuHandle, ContextMenuTriggered};
+use crate::oakui::effectchain::group_label;
+use crate::oakui::engine::EffectEntry;
use crate::oakui::AppEngine;
use crate::panels::commands::PanelCommandHandler;
use crate::panels::ids::INSPECTOR;
@@ -141,13 +143,13 @@ impl InspectorPanel {
}
}
- /// The "add effect" menu: one clickable row per addable effect of the
- /// engine. Selecting a row inserts that effect at the recorded stack
- /// index; the ✕ in the pinned header closes the menu without adding.
- /// The list is height-capped and scrollable (with the OFX plugins
- /// registered it runs to 150+ rows — an uncapped list pushed the
- /// dismiss affordance far off-screen, making the menu impossible to
- /// close).
+ /// The "add effect" menu: a group header row per resolved effect group,
+ /// each followed by that group's effect rows. Selecting an effect row
+ /// inserts it at the recorded stack index; the ✕ in the pinned header
+ /// closes the menu without adding. The list is height-capped and
+ /// scrollable (with the OFX plugins registered it runs to 150+ rows — an
+ /// uncapped list pushed the dismiss affordance far off-screen, making the
+ /// menu impossible to close).
fn render_add_menu(
&mut self,
index: usize,
@@ -165,7 +167,26 @@ impl InspectorPanel {
.px_2()
.py_1();
- for entry in &effects {
+ for row in add_menu_rows(&effects) {
+ let entry = match row {
+ AddMenuRow::Header { key, label } => {
+ let key = key.unwrap_or_default().to_string();
+ list = list.child(
+ div()
+ .id(SharedString::from(format!("add-effect-group-{key}")))
+ .debug_selector(move || format!("add-effect-group-row-{key}").into())
+ .pt_2()
+ .pb_1()
+ .px_2()
+ .text_xs()
+ .font_weight(gpui::FontWeight(600.0))
+ .text_color(colors.disabled)
+ .child(label),
+ );
+ continue;
+ }
+ AddMenuRow::Effect(entry) => entry,
+ };
let engine = self.engine.clone();
let type_id = entry.type_id.clone();
let name = entry.name.clone();
@@ -236,6 +257,51 @@ impl InspectorPanel {
}
}
+// ---------------------------------------------------------------------------
+// Add-effect menu rows — the engine's addable-effect table (already grouped
+// and sorted) flattened into one non-clickable header per group followed by
+// the group's entries. The labels come from the same
+// `effectchain::group_label` resolution the effect library uses, so both
+// surfaces name a group alike.
+// ---------------------------------------------------------------------------
+
+/// One row of the add-effect menu.
+enum AddMenuRow<'a> {
+ /// A group's header: the raw group key (`None` for an entry without a
+ /// group, i.e. a built-in from before the category grouping) and its
+ /// display label.
+ Header {
+ /// The group key, the same value [`EffectEntry::group`] holds.
+ key: Option<&'a str>,
+ /// The localized label.
+ label: String,
+ },
+ /// One addable effect.
+ Effect(&'a EffectEntry),
+}
+
+/// Flattens `entries` — in the order the engine yields them, groups
+/// contiguous and sorted — into header + entry rows.
+fn add_menu_rows(entries: &[EffectEntry]) -> Vec> {
+ let mut rows = Vec::with_capacity(entries.len() + 8);
+ let mut last: Option> = None;
+ for entry in entries {
+ let group = entry.group.as_deref();
+ if last != Some(group) {
+ last = Some(group);
+ rows.push(AddMenuRow::Header {
+ key: group,
+ label: match group {
+ Some(group) => group_label(group),
+ None => crate::i18n::tr("effect_library.group.builtin").to_string(),
+ },
+ });
+ }
+ rows.push(AddMenuRow::Effect(entry));
+ }
+ rows
+}
+
/// The inspector implements no focused-panel commands: everything falls
/// through to the shell's global handler.
impl PanelCommandHandler for InspectorPanel {}
@@ -378,4 +444,57 @@ mod tests {
}
}
}
+
+ /// The add menu flattens the engine's table into one header per group,
+ /// labelled through the same resolution the effect library uses, with
+ /// the entries keeping the engine's (grouped, sorted) order.
+ #[test]
+ fn add_menu_rows_emit_one_header_per_group() {
+ let entries = vec![
+ EffectEntry {
+ type_id: "oak:first".to_string(),
+ name: "First".to_string(),
+ group: Some("color".to_string()),
+ },
+ EffectEntry {
+ type_id: "oak:second".to_string(),
+ name: "Second".to_string(),
+ group: Some("color".to_string()),
+ },
+ EffectEntry {
+ type_id: "ofx:plugin".to_string(),
+ name: "Plugin".to_string(),
+ group: Some("Filter".to_string()),
+ },
+ EffectEntry {
+ type_id: "oak:ungrouped".to_string(),
+ name: "Ungrouped".to_string(),
+ group: None,
+ },
+ ];
+ let shape: Vec = add_menu_rows(&entries)
+ .iter()
+ .map(|row| match row {
+ AddMenuRow::Header { key, label } => {
+ format!("header:{}:{label}", key.unwrap_or("-"))
+ }
+ AddMenuRow::Effect(entry) => format!("effect:{}", entry.type_id),
+ })
+ .collect();
+ assert_eq!(
+ shape,
+ vec![
+ format!("header:color:{}", group_label("color")),
+ "effect:oak:first".to_string(),
+ "effect:oak:second".to_string(),
+ format!("header:Filter:{}", group_label("Filter")),
+ "effect:ofx:plugin".to_string(),
+ format!(
+ "header:-:{}",
+ crate::i18n::tr("effect_library.group.builtin")
+ ),
+ "effect:oak:ungrouped".to_string(),
+ ]
+ );
+ }
}
diff --git a/crates/oak-node/src/nodes/checkerboard.rs b/crates/oak-node/src/nodes/checkerboard.rs
new file mode 100644
index 000000000..5ef30788e
--- /dev/null
+++ b/crates/oak-node/src/nodes/checkerboard.rs
@@ -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 .
+
+//! 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 {
+ 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> {
+ 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) {
+ 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::(&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::(&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::(&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::(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) {
+ meta.push(NodeMeta {
+ type_id: "org.olivevideoeditor.Olive.checkerboard",
+ name: "Checkerboard",
+ categories: &[Category::Generator],
+ create,
+ });
+}
diff --git a/crates/oak-node/src/nodes/clamp.rs b/crates/oak-node/src/nodes/clamp.rs
new file mode 100644
index 000000000..1e188545e
--- /dev/null
+++ b/crates/oak-node/src/nodes/clamp.rs
@@ -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 .
+
+//! 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 {
+ Some(SHADER_FRAG.to_string())
+ }
+
+ /// Deep copy.
+ fn duplicate(&self, _core: &NodeCore) -> Option> {
+ 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) {
+ 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) {
+ 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::(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::(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");
+ }
+}
diff --git a/crates/oak-node/src/nodes/colorbars.rs b/crates/oak-node/src/nodes/colorbars.rs
new file mode 100644
index 000000000..6c34263b9
--- /dev/null
+++ b/crates/oak-node/src/nodes/colorbars.rs
@@ -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 .
+
+//! 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 {
+ 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> {
+ 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) {
+ 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::(&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::(&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::(&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::(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) {
+ meta.push(NodeMeta {
+ type_id: "org.olivevideoeditor.Olive.colorbars",
+ name: "Color Bars",
+ categories: &[Category::Generator],
+ create,
+ });
+}
diff --git a/crates/oak-node/src/nodes/colorcorrect.rs b/crates/oak-node/src/nodes/colorcorrect.rs
new file mode 100644
index 000000000..17dfc99b5
--- /dev/null
+++ b/crates/oak-node/src/nodes/colorcorrect.rs
@@ -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 .
+
+//! 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 {
+ Some(SHADER_FRAG.to_string())
+ }
+
+ /// Deep copy.
+ fn duplicate(&self, _core: &NodeCore) -> Option> {
+ 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) {
+ 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) {
+ 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::(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::(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");
+ }
+}
diff --git a/crates/oak-node/src/nodes/colormatrix.rs b/crates/oak-node/src/nodes/colormatrix.rs
new file mode 100644
index 000000000..0b11aaadc
--- /dev/null
+++ b/crates/oak-node/src/nodes/colormatrix.rs
@@ -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 .
+
+//! 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 {
+ Some(Self::shader_frag().to_string())
+ }
+
+ /// Deep copy.
+ fn duplicate(&self, _core: &NodeCore) -> Option> {
+ 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) {
+ 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::(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::(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::(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) {
+ meta.push(NodeMeta {
+ type_id: "org.olivevideoeditor.Olive.colormatrix",
+ name: "Color Matrix",
+ categories: &[Category::Color],
+ create,
+ });
+}
diff --git a/crates/oak-node/src/nodes/dilate.rs b/crates/oak-node/src/nodes/dilate.rs
new file mode 100644
index 000000000..124984c3c
--- /dev/null
+++ b/crates/oak-node/src/nodes/dilate.rs
@@ -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 .
+
+//! 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 {
+ Some(Self::shader_frag().to_string())
+ }
+
+ /// Deep copy.
+ fn duplicate(&self, _core: &NodeCore) -> Option> {
+ 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) {
+ 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::(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::(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) {
+ meta.push(NodeMeta {
+ type_id: "org.olivevideoeditor.Olive.dilate",
+ name: "Dilate",
+ categories: &[Category::Filter],
+ create,
+ });
+}
diff --git a/crates/oak-node/src/nodes/dirblur.rs b/crates/oak-node/src/nodes/dirblur.rs
new file mode 100644
index 000000000..39096a349
--- /dev/null
+++ b/crates/oak-node/src/nodes/dirblur.rs
@@ -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 .
+
+//! 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 {
+ Some(Self::shader_frag().to_string())
+ }
+
+ /// Deep copy.
+ fn duplicate(&self, _core: &NodeCore) -> Option> {
+ 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) {
+ 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::(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) {
+ meta.push(NodeMeta {
+ type_id: "org.olivevideoeditor.Olive.dirblur",
+ name: "Directional Blur",
+ categories: &[Category::Filter],
+ create,
+ });
+}
diff --git a/crates/oak-node/src/nodes/dissolve.rs b/crates/oak-node/src/nodes/dissolve.rs
new file mode 100644
index 000000000..c3c743502
--- /dev/null
+++ b/crates/oak-node/src/nodes/dissolve.rs
@@ -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 .
+
+//! 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 {
+ Some(Self::shader_frag().to_string())
+ }
+
+ /// Deep copy.
+ fn duplicate(&self, _core: &NodeCore) -> Option> {
+ 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) {
+ 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::(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) {
+ meta.push(NodeMeta {
+ type_id: "org.olivevideoeditor.Olive.dissolve",
+ name: "Dissolve",
+ categories: &[Category::Math],
+ create,
+ });
+}
diff --git a/crates/oak-node/src/nodes/edgedetect.rs b/crates/oak-node/src/nodes/edgedetect.rs
new file mode 100644
index 000000000..983d8f11a
--- /dev/null
+++ b/crates/oak-node/src/nodes/edgedetect.rs
@@ -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 .
+
+//! 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 {
+ Some(Self::shader_frag().to_string())
+ }
+
+ /// Deep copy.
+ fn duplicate(&self, _core: &NodeCore) -> Option> {
+ 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) {
+ 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::(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) {
+ meta.push(NodeMeta {
+ type_id: "org.olivevideoeditor.Olive.edgedetect",
+ name: "Edge Detect",
+ categories: &[Category::Filter],
+ create,
+ });
+}
diff --git a/crates/oak-node/src/nodes/erode.rs b/crates/oak-node/src/nodes/erode.rs
new file mode 100644
index 000000000..04c63592b
--- /dev/null
+++ b/crates/oak-node/src/nodes/erode.rs
@@ -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 .
+
+//! 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 {
+ Some(Self::shader_frag().to_string())
+ }
+
+ /// Deep copy.
+ fn duplicate(&self, _core: &NodeCore) -> Option> {
+ 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) {
+ 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::(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::(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) {
+ meta.push(NodeMeta {
+ type_id: "org.olivevideoeditor.Olive.erode",
+ name: "Erode",
+ categories: &[Category::Filter],
+ create,
+ });
+}
diff --git a/crates/oak-node/src/nodes/gamma.rs b/crates/oak-node/src/nodes/gamma.rs
new file mode 100644
index 000000000..4fcb839e3
--- /dev/null
+++ b/crates/oak-node/src/nodes/gamma.rs
@@ -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 .
+
+//! 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 {
+ Some(SHADER_FRAG.to_string())
+ }
+
+ /// Deep copy.
+ fn duplicate(&self, _core: &NodeCore) -> Option> {
+ 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) {
+ 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) {
+ 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::(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::(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");
+ }
+}
diff --git a/crates/oak-node/src/nodes/grade.rs b/crates/oak-node/src/nodes/grade.rs
new file mode 100644
index 000000000..7e64bf163
--- /dev/null
+++ b/crates/oak-node/src/nodes/grade.rs
@@ -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 .
+
+//! 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 {
+ Some(SHADER_FRAG.to_string())
+ }
+
+ /// Deep copy.
+ fn duplicate(&self, _core: &NodeCore) -> Option> {
+ 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) {
+ 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) {
+ 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::(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::(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");
+ }
+}
diff --git a/crates/oak-node/src/nodes/invert.rs b/crates/oak-node/src/nodes/invert.rs
new file mode 100644
index 000000000..889dc9108
--- /dev/null
+++ b/crates/oak-node/src/nodes/invert.rs
@@ -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 .
+
+//! 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
+ /// " 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 {
+ Some(SHADER_FRAG.to_string())
+ }
+
+ /// Deep copy.
+ fn duplicate(&self, _core: &NodeCore) -> Option> {
+ 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) {
+ 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) {
+ 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::(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::(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");
+ }
+}
diff --git a/crates/oak-node/src/nodes/keymix.rs b/crates/oak-node/src/nodes/keymix.rs
new file mode 100644
index 000000000..7e08a333d
--- /dev/null
+++ b/crates/oak-node/src/nodes/keymix.rs
@@ -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 .
+
+//! 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 {
+ Some(Self::shader_frag().to_string())
+ }
+
+ /// Deep copy.
+ fn duplicate(&self, _core: &NodeCore) -> Option> {
+ 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) {
+ 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::(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) {
+ meta.push(NodeMeta {
+ type_id: "org.olivevideoeditor.Olive.keymix",
+ name: "KeyMix",
+ categories: &[Category::Math],
+ create,
+ });
+}
diff --git a/crates/oak-node/src/nodes/mirror.rs b/crates/oak-node/src/nodes/mirror.rs
new file mode 100644
index 000000000..a38c3843e
--- /dev/null
+++ b/crates/oak-node/src/nodes/mirror.rs
@@ -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 .
+
+//! 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 {
+ super::flipdistortnode::FlipDistortNode.shader_code(request)
+ }
+
+ /// Deep copy.
+ fn duplicate(&self, _core: &NodeCore) -> Option> {
+ 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) {
+ 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::(&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) {
+ meta.push(NodeMeta {
+ type_id: "org.olivevideoeditor.Olive.mirror",
+ name: "Mirror",
+ categories: &[Category::Distort],
+ create,
+ });
+}
diff --git a/crates/oak-node/src/nodes/position.rs b/crates/oak-node/src/nodes/position.rs
new file mode 100644
index 000000000..4f593060a
--- /dev/null
+++ b/crates/oak-node/src/nodes/position.rs
@@ -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 .
+
+//! 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 {
+ Some(Self::shader_frag().to_string())
+ }
+
+ /// Deep copy.
+ fn duplicate(&self, _core: &NodeCore) -> Option> {
+ 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) {
+ 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::(&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::(&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) {
+ meta.push(NodeMeta {
+ type_id: "org.olivevideoeditor.Olive.position",
+ name: "Position",
+ categories: &[Category::Distort],
+ create,
+ });
+}
diff --git a/crates/oak-node/src/nodes/premult.rs b/crates/oak-node/src/nodes/premult.rs
new file mode 100644
index 000000000..f0094d726
--- /dev/null
+++ b/crates/oak-node/src/nodes/premult.rs
@@ -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 .
+
+//! 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 {
+ Some(Self::shader_frag().to_string())
+ }
+
+ /// Deep copy.
+ fn duplicate(&self, _core: &NodeCore) -> Option> {
+ 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) {
+ 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::(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) {
+ meta.push(NodeMeta {
+ type_id: "org.olivevideoeditor.Olive.premult",
+ name: "Premultiply",
+ categories: &[Category::Math],
+ create,
+ });
+}
diff --git a/crates/oak-node/src/nodes/ramp.rs b/crates/oak-node/src/nodes/ramp.rs
new file mode 100644
index 000000000..fc84b3b64
--- /dev/null
+++ b/crates/oak-node/src/nodes/ramp.rs
@@ -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 .
+
+//! 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 {
+ 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> {
+ 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) {
+ 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::(&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::(&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::(&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::(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) {
+ meta.push(NodeMeta {
+ type_id: "org.olivevideoeditor.Olive.ramp",
+ name: "Ramp",
+ categories: &[Category::Generator],
+ create,
+ });
+}
diff --git a/crates/oak-node/src/nodes/saturation.rs b/crates/oak-node/src/nodes/saturation.rs
new file mode 100644
index 000000000..710cb320a
--- /dev/null
+++ b/crates/oak-node/src/nodes/saturation.rs
@@ -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 .
+
+//! 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 {
+ Some(SHADER_FRAG.to_string())
+ }
+
+ /// Deep copy.
+ fn duplicate(&self, _core: &NodeCore) -> Option> {
+ 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) {
+ 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) {
+ 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::(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::(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");
+ }
+}
diff --git a/crates/oak-node/src/nodes/sharpen.rs b/crates/oak-node/src/nodes/sharpen.rs
new file mode 100644
index 000000000..76514c071
--- /dev/null
+++ b/crates/oak-node/src/nodes/sharpen.rs
@@ -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 .
+
+//! 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 {
+ Some(Self::shader_frag().to_string())
+ }
+
+ /// Deep copy.
+ fn duplicate(&self, _core: &NodeCore) -> Option> {
+ 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) {
+ 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::(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) {
+ meta.push(NodeMeta {
+ type_id: "org.olivevideoeditor.Olive.sharpen",
+ name: "Sharpen",
+ categories: &[Category::Filter],
+ create,
+ });
+}
diff --git a/crates/oak-node/src/nodes/unpremult.rs b/crates/oak-node/src/nodes/unpremult.rs
new file mode 100644
index 000000000..db72dfd99
--- /dev/null
+++ b/crates/oak-node/src/nodes/unpremult.rs
@@ -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 .
+
+//! 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 {
+ Some(Self::shader_frag().to_string())
+ }
+
+ /// Deep copy.
+ fn duplicate(&self, _core: &NodeCore) -> Option> {
+ 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) {
+ 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::(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) {
+ meta.push(NodeMeta {
+ type_id: "org.olivevideoeditor.Olive.unpremult",
+ name: "Unpremultiply",
+ categories: &[Category::Math],
+ create,
+ });
+}
diff --git a/crates/oak-render/tests/ofxmisc_blur.rs b/crates/oak-render/tests/ofxmisc_blur.rs
new file mode 100644
index 000000000..e2263148b
--- /dev/null
+++ b/crates/oak-render/tests/ofxmisc_blur.rs
@@ -0,0 +1,222 @@
+//! GPU pixel tests for the Tier-1 blur/sharpen nodes (skip without GPU).
+use oak_core::texture::Texture;
+use oak_core::{PixelFormat, Rational};
+use oak_node::value::{NodeValue, NodeValueRow, NodeValueTable, ValueType};
+
+fn texture_value(t: Texture) -> NodeValue { NodeValue::Texture(oak_node::handle::make_owned(t)) }
+fn gpu() -> bool { oak_core::backend::GpuContext::shared().is_some() }
+fn filled_frame(size: (i32, i32), rgba: [f32; 4]) -> Texture {
+ let mut f = oak_render::eval::generate_frame(Rational::new(0, 1), size, PixelFormat::F32).unwrap();
+ for px in f.data.chunks_exact_mut(16) { for (c, v) in px.chunks_exact_mut(4).zip(rgba) { c.copy_from_slice(&v.to_le_bytes()); } }
+ Texture::wrap_frame(f)
+}
+fn pixel_at(frame: &oak_core::texture::Frame, x: usize, y: usize) -> [f32; 4] {
+ let stride = frame.linesize_bytes() as usize;
+ let at = y * stride + x * 16;
+ let mut out = [0f32; 4];
+ for c in 0..4 { out[c] = f32::from_le_bytes(frame.data[at + c*4..at + c*4 + 4].try_into().unwrap()); }
+ out
+}
+fn eval_node_row(type_id: &str, inputs: NodeValueRow, frame_size: Option<(i32, i32)>) -> oak_core::texture::Frame {
+ use oak_node::traverser::RenderHooks;
+ let (core, behavior) = oak_node::factory::Factory::global().create_any(type_id).expect("node type registered");
+ let mut table = NodeValueTable::default();
+ behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
+ let mut hooks = oak_render::eval::RenderEvalHooks::new();
+ hooks.frame_size = frame_size;
+ hooks.resolve(oak_node::id::NodeId::INVALID, &inputs, &mut table);
+ let Some(NodeValue::Texture(handle)) = table.get(ValueType::Texture) else { panic!("{type_id}: no texture produced") };
+ if handle.ctx.is_null() { panic!("{type_id}: null texture produced"); }
+ let tex = unsafe { oak_node::handle::get_checked::(handle) }.expect("resolved texture");
+ assert!(matches!(tex, Texture::Gpu { .. }), "{type_id}: must render on the GPU");
+ tex.to_frame().expect("readback")
+}
+
+const DIRBLUR: &str = "org.olivevideoeditor.Olive.dirblur";
+const SHARPEN: &str = "org.olivevideoeditor.Olive.sharpen";
+
+/// A 16x16 F32 frame painted pixel by pixel from `paint(x, y)`.
+fn painted_frame(paint: impl Fn(usize, usize) -> [f32; 4]) -> Texture {
+ let mut f = oak_render::eval::generate_frame(Rational::new(0, 1), (16, 16), PixelFormat::F32).unwrap();
+ let stride = f.linesize_bytes() as usize;
+ for y in 0..16 {
+ for x in 0..16 {
+ let at = y * stride + x * 16;
+ for (c, v) in paint(x, y).iter().enumerate() {
+ f.data[at + c * 4..at + c * 4 + 4].copy_from_slice(&v.to_le_bytes());
+ }
+ }
+ }
+ Texture::wrap_frame(f)
+}
+
+/// A row carrying the effect input plus any explicitly set float inputs.
+fn row_with(texture: Texture, scalars: &[(&str, f64)]) -> NodeValueRow {
+ let mut row = NodeValueRow::new();
+ row.insert("tex_in".to_string(), texture_value(texture));
+ for (name, v) in scalars {
+ row.insert((*name).to_string(), NodeValue::Float(*v));
+ }
+ row
+}
+
+/// Assert every channel of `px` is within `tol` of `want`.
+fn assert_close(px: [f32; 4], want: [f32; 4], tol: f32, what: &str) {
+ for c in 0..4 {
+ assert!(
+ (px[c] - want[c]).abs() <= tol,
+ "{what}: channel {c}: got {px:?}, want {want:?} (tol {tol})"
+ );
+ }
+}
+
+/// Zero amount (the input default) collapses the tap spacing to zero, so
+/// the directional blur is an exact identity anchored to the sampled
+/// coordinate; it still renders on the GPU.
+#[test]
+fn dirblur_zero_amount_is_identity() {
+ if !gpu() { eprintln!("no adapter; skipping"); return; }
+ let src = painted_frame(|x, y| [x as f32 / 15.0, y as f32 / 15.0, 0.25, 1.0]);
+ let out = eval_node_row(DIRBLUR, row_with(src, &[]), None);
+ for (x, y) in [(0, 0), (5, 3), (8, 8), (14, 12), (15, 15)] {
+ assert_close(
+ pixel_at(&out, x, y),
+ [x as f32 / 15.0, y as f32 / 15.0, 0.25, 1.0],
+ 1e-3,
+ &format!("dirblur zero amount ({x},{y})"),
+ );
+ }
+}
+
+/// A one-pixel-wide white vertical line smears along +x at angle 0:
+/// pixels either side pick up the line, the line itself dims, and
+/// nothing leaks along y.
+#[test]
+fn dirblur_smears_horizontally_without_vertical_spread() {
+ if !gpu() { eprintln!("no adapter; skipping"); return; }
+ let src = painted_frame(|x, y| {
+ if x == 8 && (4..12).contains(&y) { [1.0, 1.0, 1.0, 1.0] } else { [0.0, 0.0, 0.0, 1.0] }
+ });
+ let out = eval_node_row(
+ DIRBLUR,
+ row_with(src, &[("amount_in", 2.0), ("angle_in", 0.0)]),
+ None,
+ );
+
+ let spread_l = pixel_at(&out, 6, 8)[0];
+ let spread_r = pixel_at(&out, 10, 8)[0];
+ assert!((0.05..0.3).contains(&spread_l), "pixel left of the line picks up the smear: {spread_l}");
+ assert!((spread_l - spread_r).abs() < 0.02, "smear is symmetric about the line: {spread_l} vs {spread_r}");
+
+ // The line dims, but not to nothing: the 16 taps sit 2*2/15 px apart,
+ // so they land at 8 + t*4/15 for t = -7.5..7.5 and the sampler's
+ // 1 px-wide linear reconstruction gives them 13/15, 9/15, 5/15 and
+ // 1/15 of the line each, twice over, leaving the center pixel at
+ // 2*(13+9+5+1)/(15*16) = 7/30.
+ let on_line = pixel_at(&out, 8, 8)[0];
+ assert!(
+ (on_line - 7.0 / 30.0).abs() < 0.02,
+ "a one-pixel line dims to ~7/30 of its brightness: {on_line}"
+ );
+
+ // The smear redistributes the line's brightness without adding or
+ // losing any: the linear-filter triangle has unit area, so the 16
+ // output pixels (each the average of 16 unit-spaced samples) still
+ // sum to the whole line. This holds whatever the filter mode.
+ let row_energy: f32 = (0..16).map(|x| pixel_at(&out, x, 8)[0]).sum();
+ assert!(
+ (row_energy - 1.0).abs() < 0.02,
+ "the row keeps the line's total brightness: {row_energy}"
+ );
+
+ assert!(pixel_at(&out, 5, 8)[0] < 0.01, "two pixels out stays black");
+ assert!(pixel_at(&out, 8, 3)[0] < 0.01, "no spread above the line");
+ assert!(pixel_at(&out, 8, 12)[0] < 0.01, "no spread below the line");
+ let alpha = pixel_at(&out, 6, 8)[3];
+ assert!((alpha - 1.0).abs() < 1e-3, "alpha passes through: {alpha}");
+}
+
+/// At angle 90 the smear runs along +y: the same one-pixel line laid
+/// horizontally bleeds into the rows above and below it, and not along
+/// its own axis.
+#[test]
+fn dirblur_angle_90_smears_vertically() {
+ if !gpu() { eprintln!("no adapter; skipping"); return; }
+ let src = painted_frame(|x, y| {
+ if y == 8 && (4..12).contains(&x) { [1.0, 1.0, 1.0, 1.0] } else { [0.0, 0.0, 0.0, 1.0] }
+ });
+ let out = eval_node_row(
+ DIRBLUR,
+ row_with(src, &[("amount_in", 2.0), ("angle_in", 90.0)]),
+ None,
+ );
+
+ let above = pixel_at(&out, 8, 6)[0];
+ let below = pixel_at(&out, 8, 10)[0];
+ assert!((0.05..0.3).contains(&above), "row above the line picks up the smear: {above}");
+ assert!((above - below).abs() < 0.02, "smear is symmetric about the line: {above} vs {below}");
+
+ // Same arithmetic as the horizontal case, rotated: 7/30 of the line.
+ let on_line = pixel_at(&out, 8, 8)[0];
+ assert!(
+ (on_line - 7.0 / 30.0).abs() < 0.02,
+ "a one-pixel line dims to ~7/30 of its brightness: {on_line}"
+ );
+
+ let column_energy: f32 = (0..16).map(|y| pixel_at(&out, 8, y)[0]).sum();
+ assert!(
+ (column_energy - 1.0).abs() < 0.02,
+ "the column keeps the line's total brightness: {column_energy}"
+ );
+
+ assert!(pixel_at(&out, 8, 5)[0] < 0.01, "two rows out stays black");
+ assert!(pixel_at(&out, 3, 6)[0] < 0.01, "no spread left of the line");
+ assert!(pixel_at(&out, 1, 8)[0] < 0.01, "no spread along the line's axis");
+}
+
+/// A flat region is untouched by the unsharp mask whatever the amount
+/// (the default 1.0 included), and an explicit zero amount is an exact
+/// identity on a gradient.
+#[test]
+fn sharpen_flat_and_zero_amount_pass_through() {
+ if !gpu() { eprintln!("no adapter; skipping"); return; }
+ let rgba = [0.4, 0.6, 0.8, 1.0];
+ let out = eval_node_row(SHARPEN, row_with(filled_frame((16, 16), rgba), &[]), None);
+ assert_close(pixel_at(&out, 8, 8), rgba, 1e-3, "sharpen default amount on a flat frame");
+
+ let src = painted_frame(|x, y| [x as f32 / 15.0, y as f32 / 15.0, 0.25, 1.0]);
+ let out = eval_node_row(SHARPEN, row_with(src, &[("amount_in", 0.0)]), None);
+ for (x, y) in [(0, 0), (5, 3), (8, 8), (14, 12), (15, 15)] {
+ assert_close(
+ pixel_at(&out, x, y),
+ [x as f32 / 15.0, y as f32 / 15.0, 0.25, 1.0],
+ 1e-3,
+ &format!("sharpen zero amount ({x},{y})"),
+ );
+ }
+}
+
+/// At a black/white step the 3x3 box blur is 2/3 white on the bright
+/// side (overshoot to 4/3) and 1/3 white on the dark side (undershoot to
+/// -1/3); flat columns pass through and the result is not clamped.
+#[test]
+fn sharpen_overshoots_and_undershoots_a_step_edge() {
+ if !gpu() { eprintln!("no adapter; skipping"); return; }
+ let src = painted_frame(|x, _| {
+ if x < 8 { [0.0, 0.0, 0.0, 1.0] } else { [1.0, 1.0, 1.0, 1.0] }
+ });
+ let out = eval_node_row(SHARPEN, row_with(src, &[("amount_in", 1.0)]), None);
+
+ let bright = pixel_at(&out, 8, 8);
+ assert!((bright[0] - 4.0 / 3.0).abs() < 0.01, "overshoot on the bright side: {bright:?}");
+ assert!(bright[0] > 1.0, "overshoot is not clamped: {bright:?}");
+ assert!((bright[3] - 1.0).abs() < 1e-3, "alpha passes through: {bright:?}");
+
+ let dark = pixel_at(&out, 7, 8);
+ assert!((dark[0] + 1.0 / 3.0).abs() < 0.01, "undershoot on the dark side: {dark:?}");
+ assert!(dark[0] < 0.0, "undershoot is not clamped: {dark:?}");
+ assert!((dark[3] - 1.0).abs() < 1e-3, "alpha passes through: {dark:?}");
+
+ assert_close(pixel_at(&out, 6, 8), [0.0, 0.0, 0.0, 1.0], 1e-3, "flat black column");
+ assert_close(pixel_at(&out, 9, 8), [1.0, 1.0, 1.0, 1.0], 1e-3, "flat white column");
+}
diff --git a/crates/oak-render/tests/ofxmisc_color.rs b/crates/oak-render/tests/ofxmisc_color.rs
new file mode 100644
index 000000000..af8fafa57
--- /dev/null
+++ b/crates/oak-render/tests/ofxmisc_color.rs
@@ -0,0 +1,238 @@
+//! GPU pixel tests for the Tier-1 color nodes (skip when no GPU adapter).
+use oak_core::texture::Texture;
+use oak_core::{PixelFormat, Rational};
+use oak_node::value::{NodeValue, NodeValueRow, NodeValueTable, ValueType};
+
+fn texture_value(t: Texture) -> NodeValue { NodeValue::Texture(oak_node::handle::make_owned(t)) }
+fn gpu() -> bool { oak_core::backend::GpuContext::shared().is_some() }
+fn filled_frame(size: (i32, i32), rgba: [f32; 4]) -> Texture {
+ let mut f = oak_render::eval::generate_frame(Rational::new(0, 1), size, PixelFormat::F32).unwrap();
+ for px in f.data.chunks_exact_mut(16) { for (c, v) in px.chunks_exact_mut(4).zip(rgba) { c.copy_from_slice(&v.to_le_bytes()); } }
+ Texture::wrap_frame(f)
+}
+fn pixel_at(frame: &oak_core::texture::Frame, x: usize, y: usize) -> [f32; 4] {
+ let stride = frame.linesize_bytes() as usize;
+ let at = y * stride + x * 16;
+ let mut out = [0f32; 4];
+ for c in 0..4 { out[c] = f32::from_le_bytes(frame.data[at + c*4..at + c*4 + 4].try_into().unwrap()); }
+ out
+}
+fn eval_node_row(type_id: &str, inputs: NodeValueRow, frame_size: Option<(i32, i32)>) -> oak_core::texture::Frame {
+ use oak_node::traverser::RenderHooks;
+ let (core, behavior) = oak_node::factory::Factory::global().create_any(type_id).expect("node type registered");
+ let mut table = NodeValueTable::default();
+ behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
+ let mut hooks = oak_render::eval::RenderEvalHooks::new();
+ hooks.frame_size = frame_size;
+ hooks.resolve(oak_node::id::NodeId::INVALID, &inputs, &mut table);
+ let Some(NodeValue::Texture(handle)) = table.get(ValueType::Texture) else { panic!("{type_id}: no texture produced") };
+ if handle.ctx.is_null() { panic!("{type_id}: null texture produced"); }
+ let tex = unsafe { oak_node::handle::get_checked::(handle) }.expect("resolved texture");
+ assert!(matches!(tex, Texture::Gpu { .. }), "{type_id}: must render on the GPU");
+ tex.to_frame().expect("readback")
+}
+
+const COLORCORRECT: &str = "org.olivevideoeditor.Olive.colorcorrect";
+const GAMMA: &str = "org.olivevideoeditor.Olive.gamma";
+const SATURATION: &str = "org.olivevideoeditor.Olive.saturation";
+const INVERT: &str = "org.olivevideoeditor.Olive.invert";
+const CLAMP: &str = "org.olivevideoeditor.Olive.clamp";
+const GRADE: &str = "org.olivevideoeditor.Olive.grade";
+
+/// A row carrying just the effect input, with `rgba` painted over the
+/// whole 8x8 frame.
+fn row_with(rgba: [f32; 4]) -> NodeValueRow {
+ let mut row = NodeValueRow::new();
+ row.insert("tex_in".to_string(), texture_value(filled_frame((8, 8), rgba)));
+ row
+}
+
+/// Insert float parameters (input id -> value).
+fn set_floats(row: &mut NodeValueRow, params: &[(&str, f64)]) {
+ for (id, v) in params {
+ row.insert((*id).to_string(), NodeValue::Float(*v));
+ }
+}
+
+/// Insert boolean parameters (input id -> value).
+fn set_bools(row: &mut NodeValueRow, params: &[(&str, bool)]) {
+ for (id, v) in params {
+ row.insert((*id).to_string(), NodeValue::Boolean(*v));
+ }
+}
+
+/// Assert the four channels of a pixel against the expected values.
+fn assert_pixel(px: [f32; 4], want: [f32; 4], what: &str) {
+ for c in 0..4 {
+ assert!(
+ (px[c] - want[c]).abs() < 1e-3,
+ "{what}: channel {c}: got {px:?}, want {want:?}"
+ );
+ }
+}
+
+/// ColorCorrect on mid gray: the saturation lerp is an identity on gray,
+/// so the chain reduces to the contrast/gamma/gain/offset passes.
+/// `0.5 -> pow(0.5/0.18, 1.2)*0.18 = 0.6133516 -> ^(1/0.8) = 0.5427963
+/// -> *1.1 = 0.5970759 -> +0.02 = 0.6170759`.
+#[test]
+fn colorcorrect_mid_gray_control_chain() {
+ if !gpu() {
+ eprintln!("no adapter; skipping");
+ return;
+ }
+ let mut row = row_with([0.5, 0.5, 0.5, 1.0]);
+ set_floats(
+ &mut row,
+ &[
+ ("saturation_in", 0.5),
+ ("contrast_in", 1.2),
+ ("gamma_in", 0.8),
+ ("gain_in", 1.1),
+ ("offset_in", 0.02),
+ ],
+ );
+ let frame = eval_node_row(COLORCORRECT, row, None);
+ for (x, y) in [(0, 0), (4, 4), (7, 7)] {
+ assert_pixel(
+ pixel_at(&frame, x, y),
+ [0.6170759, 0.6170759, 0.6170759, 1.0],
+ &format!("colorcorrect ({x},{y})"),
+ );
+ }
+}
+
+/// Gamma 2.0 is the square root: `pow(0.5, 1/2) = 0.7071068`. Alpha is
+/// untouched.
+#[test]
+fn gamma_mid_gray_square_root() {
+ if !gpu() {
+ eprintln!("no adapter; skipping");
+ return;
+ }
+ let mut row = row_with([0.5, 0.5, 0.5, 1.0]);
+ set_floats(&mut row, &[("gamma_in", 2.0)]);
+ let frame = eval_node_row(GAMMA, row, None);
+ assert_pixel(
+ pixel_at(&frame, 4, 4),
+ [0.70710677, 0.70710677, 0.70710677, 1.0],
+ "gamma",
+ );
+}
+
+/// Saturation 0.5 on a non-gray pixel lerps halfway to its Rec. 709
+/// luma (`0.2126*0.6 + 0.7152*0.4 + 0.0722*0.2 = 0.42808`), and is an
+/// identity on gray. Alpha is untouched.
+#[test]
+fn saturation_half_lerps_to_luma() {
+ if !gpu() {
+ eprintln!("no adapter; skipping");
+ return;
+ }
+ let mut row = row_with([0.6, 0.4, 0.2, 1.0]);
+ set_floats(&mut row, &[("saturation_in", 0.5)]);
+ let frame = eval_node_row(SATURATION, row, None);
+ assert_pixel(
+ pixel_at(&frame, 4, 4),
+ [0.51404, 0.41404, 0.31404, 1.0],
+ "saturation on color",
+ );
+
+ let mut gray = row_with([0.5, 0.5, 0.5, 1.0]);
+ set_floats(&mut gray, &[("saturation_in", 0.5)]);
+ let gray_frame = eval_node_row(SATURATION, gray, None);
+ assert_pixel(
+ pixel_at(&gray_frame, 4, 4),
+ [0.5, 0.5, 0.5, 1.0],
+ "saturation on gray",
+ );
+}
+
+/// Invert defaults to all four toggles on: mid gray 0.5 becomes 0.5 in
+/// RGB but the alpha flips to 0.0. With the alpha toggle off, an
+/// asymmetric pixel flips only its color channels.
+#[test]
+fn invert_per_channel_toggles() {
+ if !gpu() {
+ eprintln!("no adapter; skipping");
+ return;
+ }
+ // All four toggles default to on.
+ let frame = eval_node_row(INVERT, row_with([0.5, 0.5, 0.5, 1.0]), None);
+ assert_pixel(pixel_at(&frame, 4, 4), [0.5, 0.5, 0.5, 0.0], "invert defaults");
+
+ // Alpha toggle off: only the color channels invert.
+ let mut row = row_with([0.25, 0.75, 0.5, 1.0]);
+ set_bools(
+ &mut row,
+ &[
+ ("invert_r_in", true),
+ ("invert_g_in", true),
+ ("invert_b_in", true),
+ ("invert_a_in", false),
+ ],
+ );
+ let frame = eval_node_row(INVERT, row, None);
+ assert_pixel(
+ pixel_at(&frame, 4, 4),
+ [0.75, 0.25, 0.5, 1.0],
+ "invert without alpha",
+ );
+}
+
+/// Clamp raises mid gray to the 0.6 lower bound (alpha is clamped too,
+/// matching the reference's `processA` default), and a 0.4 upper bound
+/// pulls every channel down to 0.4.
+#[test]
+fn clamp_bounds_every_channel() {
+ if !gpu() {
+ eprintln!("no adapter; skipping");
+ return;
+ }
+ let mut row = row_with([0.5, 0.5, 0.5, 1.0]);
+ set_floats(&mut row, &[("min_in", 0.6), ("max_in", 1.0)]);
+ let frame = eval_node_row(CLAMP, row, None);
+ assert_pixel(pixel_at(&frame, 4, 4), [0.6, 0.6, 0.6, 1.0], "clamp to min");
+
+ let mut low = row_with([0.5, 0.5, 0.5, 1.0]);
+ set_floats(&mut low, &[("min_in", 0.0), ("max_in", 0.4)]);
+ let low_frame = eval_node_row(CLAMP, low, None);
+ assert_pixel(
+ pixel_at(&low_frame, 4, 4),
+ [0.4, 0.4, 0.4, 0.4],
+ "clamp to max",
+ );
+}
+
+/// Grade stretch + gamma: with the black point at 0.1 and the white
+/// point at 0.6 the slope is `(1-0)/(0.6-0.1) = 2` and the offset
+/// `-0.2`, so 0.5 maps to 0.8, then the gamma 2.0 pass gives
+/// `pow(0.8, 0.5) = 0.8944272`. The defaults are an identity.
+#[test]
+fn grade_stretch_and_gamma() {
+ if !gpu() {
+ eprintln!("no adapter; skipping");
+ return;
+ }
+ let mut row = row_with([0.5, 0.5, 0.5, 1.0]);
+ set_floats(
+ &mut row,
+ &[
+ ("blackpoint_in", 0.1),
+ ("whitepoint_in", 0.6),
+ ("black_in", 0.0),
+ ("white_in", 1.0),
+ ("gamma_in", 2.0),
+ ],
+ );
+ let frame = eval_node_row(GRADE, row, None);
+ assert_pixel(
+ pixel_at(&frame, 4, 4),
+ [0.8944272, 0.8944272, 0.8944272, 1.0],
+ "grade stretch",
+ );
+
+ // Default points and gamma: identity.
+ let frame = eval_node_row(GRADE, row_with([0.5, 0.5, 0.5, 1.0]), None);
+ assert_pixel(pixel_at(&frame, 4, 4), [0.5, 0.5, 0.5, 1.0], "grade identity");
+}
diff --git a/crates/oak-render/tests/ofxmisc_gen.rs b/crates/oak-render/tests/ofxmisc_gen.rs
new file mode 100644
index 000000000..449fa4ce2
--- /dev/null
+++ b/crates/oak-render/tests/ofxmisc_gen.rs
@@ -0,0 +1,213 @@
+//! GPU pixel tests for the Tier-1 geometry/generator nodes (skip without GPU).
+use oak_core::texture::Texture;
+use oak_core::{PixelFormat, Rational};
+use oak_node::value::{NodeValue, NodeValueRow, NodeValueTable, ValueType};
+
+fn texture_value(t: Texture) -> NodeValue { NodeValue::Texture(oak_node::handle::make_owned(t)) }
+fn gpu() -> bool { oak_core::backend::GpuContext::shared().is_some() }
+fn filled_frame(size: (i32, i32), rgba: [f32; 4]) -> Texture {
+ let mut f = oak_render::eval::generate_frame(Rational::new(0, 1), size, PixelFormat::F32).unwrap();
+ for px in f.data.chunks_exact_mut(16) { for (c, v) in px.chunks_exact_mut(4).zip(rgba) { c.copy_from_slice(&v.to_le_bytes()); } }
+ Texture::wrap_frame(f)
+}
+fn pixel_at(frame: &oak_core::texture::Frame, x: usize, y: usize) -> [f32; 4] {
+ let stride = frame.linesize_bytes() as usize;
+ let at = y * stride + x * 16;
+ let mut out = [0f32; 4];
+ for c in 0..4 { out[c] = f32::from_le_bytes(frame.data[at + c*4..at + c*4 + 4].try_into().unwrap()); }
+ out
+}
+fn eval_node_row(type_id: &str, inputs: NodeValueRow, frame_size: Option<(i32, i32)>) -> oak_core::texture::Frame {
+ use oak_node::traverser::RenderHooks;
+ let (core, behavior) = oak_node::factory::Factory::global().create_any(type_id).expect("node type registered");
+ let mut table = NodeValueTable::default();
+ behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
+ let mut hooks = oak_render::eval::RenderEvalHooks::new();
+ hooks.frame_size = frame_size;
+ hooks.resolve(oak_node::id::NodeId::INVALID, &inputs, &mut table);
+ let Some(NodeValue::Texture(handle)) = table.get(ValueType::Texture) else { panic!("{type_id}: no texture produced") };
+ if handle.ctx.is_null() { panic!("{type_id}: null texture produced"); }
+ let tex = unsafe { oak_node::handle::get_checked::(handle) }.expect("resolved texture");
+ assert!(matches!(tex, Texture::Gpu { .. }), "{type_id}: must render on the GPU");
+ tex.to_frame().expect("readback")
+}
+
+const POSITION: &str = "org.olivevideoeditor.Olive.position";
+const MIRROR: &str = "org.olivevideoeditor.Olive.mirror";
+const CHECKERBOARD: &str = "org.olivevideoeditor.Olive.checkerboard";
+const COLORBARS: &str = "org.olivevideoeditor.Olive.colorbars";
+const RAMP: &str = "org.olivevideoeditor.Olive.ramp";
+
+const WHITE: [f32; 4] = [1.0, 1.0, 1.0, 1.0];
+const BLACK: [f32; 4] = [0.0, 0.0, 0.0, 1.0];
+
+/// Paint one RGBA pixel into a CPU frame (these F32 frames are 16 bytes a
+/// pixel).
+fn paint(frame: &mut oak_core::texture::Frame, x: usize, y: usize, rgba: [f32; 4]) {
+ let at = y * frame.linesize_bytes() as usize + x * 16;
+ for (c, v) in rgba.iter().enumerate() {
+ frame.data[at + c * 4..at + c * 4 + 4].copy_from_slice(&v.to_le_bytes());
+ }
+}
+
+/// Opaque black with a single white pixel at `(x, y)`.
+fn white_pixel_frame(size: (i32, i32), x: usize, y: usize) -> Texture {
+ let mut frame = filled_frame(size, BLACK).to_frame().expect("cpu frame");
+ paint(&mut frame, x, y, WHITE);
+ Texture::wrap_frame(frame)
+}
+
+/// Opaque black with the left half (`x < size.0 / 2`) painted white.
+fn left_white_frame(size: (i32, i32)) -> Texture {
+ let mut frame = filled_frame(size, BLACK).to_frame().expect("cpu frame");
+ for y in 0..size.1 as usize {
+ for x in 0..(size.0 / 2) as usize {
+ paint(&mut frame, x, y, WHITE);
+ }
+ }
+ Texture::wrap_frame(frame)
+}
+
+/// Assert the four channels of a pixel within the tests' 0.02 tolerance.
+fn assert_pixel(px: [f32; 4], want: [f32; 4], what: &str) {
+ for c in 0..4 {
+ assert!(
+ (px[c] - want[c]).abs() < 0.02,
+ "{what}: channel {c}: got {px:?}, want {want:?}"
+ );
+ }
+}
+
+/// Position with a whole-pixel `offset_in`: the white pixel at (2, 3)
+/// lands at (5, 5) (frame rows run downward, so the offset moves the image
+/// right and down), and the source pixel it vacated reads black.
+#[test]
+fn position_shifts_a_white_pixel_by_whole_pixels() {
+ if !gpu() {
+ eprintln!("no adapter; skipping");
+ return;
+ }
+ let mut row = NodeValueRow::new();
+ row.insert(
+ "tex_in".to_string(),
+ texture_value(white_pixel_frame((8, 8), 2, 3)),
+ );
+ row.insert("offset_in".to_string(), NodeValue::Vec2([3.0, 2.0]));
+ let frame = eval_node_row(POSITION, row, None);
+ assert_pixel(pixel_at(&frame, 5, 5), WHITE, "position moved pixel");
+ assert_pixel(pixel_at(&frame, 2, 3), BLACK, "position vacated pixel");
+ assert_pixel(pixel_at(&frame, 0, 0), BLACK, "position untouched corner");
+}
+
+/// Mirror with `horizontal_in` on flips a one-sided white block about the
+/// frame center: the left-half white block moves to the right half.
+/// `vertical_in` stays at its default (off), so rows are untouched.
+#[test]
+fn mirror_horizontal_flips_the_white_block_to_the_other_side() {
+ if !gpu() {
+ eprintln!("no adapter; skipping");
+ return;
+ }
+ let mut row = NodeValueRow::new();
+ row.insert(
+ "tex_in".to_string(),
+ texture_value(left_white_frame((8, 8))),
+ );
+ row.insert("horizontal_in".to_string(), NodeValue::Boolean(true));
+ let frame = eval_node_row(MIRROR, row, None);
+ assert_pixel(pixel_at(&frame, 5, 3), WHITE, "mirror moved block");
+ assert_pixel(pixel_at(&frame, 2, 3), BLACK, "mirror vacated block");
+ assert_pixel(pixel_at(&frame, 7, 7), WHITE, "mirror bottom-right");
+ assert_pixel(pixel_at(&frame, 0, 7), BLACK, "mirror bottom-left");
+}
+
+/// Checkerboard with 4px boxes on an 8x8 frame: the parity of the cell
+/// index sum picks the color, with `color1_in` (red) in the cells reaching
+/// (0, 0), (3, 3) and (7, 7) and `color2_in` (green) in (7, 0), (0, 7),
+/// (0, 4) and (4, 0).
+#[test]
+fn checkerboard_alternates_colors_by_cell_parity() {
+ if !gpu() {
+ eprintln!("no adapter; skipping");
+ return;
+ }
+ let mut row = NodeValueRow::new();
+ row.insert("size_in".to_string(), NodeValue::Vec2([4.0, 4.0]));
+ row.insert(
+ "color1_in".to_string(),
+ NodeValue::Color([1.0, 0.0, 0.0, 1.0]),
+ );
+ row.insert(
+ "color2_in".to_string(),
+ NodeValue::Color([0.0, 1.0, 0.0, 1.0]),
+ );
+ let frame = eval_node_row(CHECKERBOARD, row, Some((8, 8)));
+ for (x, y) in [(0, 0), (3, 3), (7, 7)] {
+ assert_pixel(
+ pixel_at(&frame, x, y),
+ [1.0, 0.0, 0.0, 1.0],
+ &format!("checkerboard color1 ({x},{y})"),
+ );
+ }
+ for (x, y) in [(7, 0), (0, 7), (0, 4), (4, 0)] {
+ assert_pixel(
+ pixel_at(&frame, x, y),
+ [0.0, 1.0, 0.0, 1.0],
+ &format!("checkerboard color2 ({x},{y})"),
+ );
+ }
+}
+
+/// Color bars at the default SMPTE 75% standard: the top-left pixel is the
+/// 75% white bar, the pixel in the second bar is 75% yellow, and the
+/// top-left of the mid strip is the 75% blue bar.
+#[test]
+fn colorbars_75_percent_white_yellow_and_blue_bars() {
+ if !gpu() {
+ eprintln!("no adapter; skipping");
+ return;
+ }
+ let frame = eval_node_row(COLORBARS, NodeValueRow::new(), Some((8, 8)));
+ assert_pixel(
+ pixel_at(&frame, 0, 0),
+ [0.75, 0.75, 0.75, 1.0],
+ "colorbars 75% white bar",
+ );
+ assert_pixel(
+ pixel_at(&frame, 1, 0),
+ [0.75, 0.75, 0.0, 1.0],
+ "colorbars yellow bar",
+ );
+ assert_pixel(
+ pixel_at(&frame, 0, 5),
+ [0.0, 0.0, 0.75, 1.0],
+ "colorbars mid-strip blue bar",
+ );
+}
+
+/// Ramp from (default) black at `point0_in` to (default) white at
+/// `point1_in`: the gradient is the projection onto the p0->p1 axis, so an
+/// axis spanning (-4.5, 0) -> (3.5, 0) gives 0.5 at the pixel whose center
+/// is the midpoint, 0.125 one eighth of the way in, and 1.0 at the end.
+#[test]
+fn ramp_from_black_to_white_is_half_at_the_midpoint() {
+ if !gpu() {
+ eprintln!("no adapter; skipping");
+ return;
+ }
+ let mut row = NodeValueRow::new();
+ row.insert("point0_in".to_string(), NodeValue::Vec2([-4.5, 0.0]));
+ row.insert("point1_in".to_string(), NodeValue::Vec2([3.5, 0.0]));
+ let frame = eval_node_row(RAMP, row, Some((8, 8)));
+ assert_pixel(
+ pixel_at(&frame, 3, 3),
+ [0.5, 0.5, 0.5, 1.0],
+ "ramp midpoint",
+ );
+ assert_pixel(
+ pixel_at(&frame, 0, 3),
+ [0.125, 0.125, 0.125, 1.0],
+ "ramp near point0",
+ );
+ assert_pixel(pixel_at(&frame, 7, 3), WHITE, "ramp at point1");
+}
diff --git a/crates/oak-render/tests/ofxmisc_matrix.rs b/crates/oak-render/tests/ofxmisc_matrix.rs
new file mode 100644
index 000000000..1a152d240
--- /dev/null
+++ b/crates/oak-render/tests/ofxmisc_matrix.rs
@@ -0,0 +1,168 @@
+//! GPU pixel tests for the Tier-1 matrix/edge/morphology nodes (skip without GPU).
+use oak_core::texture::Texture;
+use oak_core::{PixelFormat, Rational};
+use oak_node::value::{NodeValue, NodeValueRow, NodeValueTable, ValueType};
+
+fn texture_value(t: Texture) -> NodeValue { NodeValue::Texture(oak_node::handle::make_owned(t)) }
+fn gpu() -> bool { oak_core::backend::GpuContext::shared().is_some() }
+fn filled_frame(size: (i32, i32), rgba: [f32; 4]) -> Texture {
+ let mut f = oak_render::eval::generate_frame(Rational::new(0, 1), size, PixelFormat::F32).unwrap();
+ for px in f.data.chunks_exact_mut(16) { for (c, v) in px.chunks_exact_mut(4).zip(rgba) { c.copy_from_slice(&v.to_le_bytes()); } }
+ Texture::wrap_frame(f)
+}
+fn pixel_at(frame: &oak_core::texture::Frame, x: usize, y: usize) -> [f32; 4] {
+ let stride = frame.linesize_bytes() as usize;
+ let at = y * stride + x * 16;
+ let mut out = [0f32; 4];
+ for c in 0..4 { out[c] = f32::from_le_bytes(frame.data[at + c*4..at + c*4 + 4].try_into().unwrap()); }
+ out
+}
+fn eval_node_row(type_id: &str, inputs: NodeValueRow, frame_size: Option<(i32, i32)>) -> oak_core::texture::Frame {
+ use oak_node::traverser::RenderHooks;
+ let (core, behavior) = oak_node::factory::Factory::global().create_any(type_id).expect("node type registered");
+ let mut table = NodeValueTable::default();
+ behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
+ let mut hooks = oak_render::eval::RenderEvalHooks::new();
+ hooks.frame_size = frame_size;
+ hooks.resolve(oak_node::id::NodeId::INVALID, &inputs, &mut table);
+ let Some(NodeValue::Texture(handle)) = table.get(ValueType::Texture) else { panic!("{type_id}: no texture produced") };
+ if handle.ctx.is_null() { panic!("{type_id}: null texture produced"); }
+ let tex = unsafe { oak_node::handle::get_checked::(handle) }.expect("resolved texture");
+ assert!(matches!(tex, Texture::Gpu { .. }), "{type_id}: must render on the GPU");
+ tex.to_frame().expect("readback")
+}
+
+const COLORMATRIX: &str = "org.olivevideoeditor.Olive.colormatrix";
+const EDGEDETECT: &str = "org.olivevideoeditor.Olive.edgedetect";
+const DILATE: &str = "org.olivevideoeditor.Olive.dilate";
+const ERODE: &str = "org.olivevideoeditor.Olive.erode";
+
+/// A 16x16 F32 frame painted pixel by pixel from `paint(x, y)`.
+fn painted_frame(paint: impl Fn(usize, usize) -> [f32; 4]) -> Texture {
+ let mut f = oak_render::eval::generate_frame(Rational::new(0, 1), (16, 16), PixelFormat::F32).unwrap();
+ let stride = f.linesize_bytes() as usize;
+ for y in 0..16 {
+ for x in 0..16 {
+ let at = y * stride + x * 16;
+ for (c, v) in paint(x, y).iter().enumerate() {
+ f.data[at + c * 4..at + c * 4 + 4].copy_from_slice(&v.to_le_bytes());
+ }
+ }
+ }
+ Texture::wrap_frame(f)
+}
+
+/// A row carrying just the texture effect input.
+fn row_with(texture: Texture) -> NodeValueRow {
+ let mut row = NodeValueRow::new();
+ row.insert("tex_in".to_string(), texture_value(texture));
+ row
+}
+
+/// Assert the four channels of a pixel against the expected values.
+fn assert_pixel(px: [f32; 4], want: [f32; 4], what: &str) {
+ for c in 0..4 {
+ assert!(
+ (px[c] - want[c]).abs() < 1e-3,
+ "{what}: channel {c}: got {px:?}, want {want:?}"
+ );
+ }
+}
+
+/// Identity matrix (the `m0..m15` input defaults): the color passes
+/// through unchanged.
+#[test]
+fn colormatrix_identity_keeps_color() {
+ if !gpu() { eprintln!("no adapter; skipping"); return; }
+ let rgba = [0.25, 0.5, 0.75, 1.0];
+ let out = eval_node_row(COLORMATRIX, row_with(filled_frame((16, 16), rgba)), None);
+ assert_pixel(pixel_at(&out, 8, 8), rgba, "identity center");
+ assert_pixel(pixel_at(&out, 0, 0), rgba, "identity corner");
+}
+
+/// Red/green swap matrix (`m0..m15` row-major: R <- G and G <- R, the
+/// rest identity) maps (1, 0, 0, 1) to (0, 1, 0, 1).
+#[test]
+fn colormatrix_swap_red_green() {
+ if !gpu() { eprintln!("no adapter; skipping"); return; }
+ let mut row = row_with(filled_frame((16, 16), [1.0, 0.0, 0.0, 1.0]));
+ let swap = [
+ 0.0, 1.0, 0.0, 0.0, // R <- (R, G, B, A)
+ 1.0, 0.0, 0.0, 0.0, // G <- (R, G, B, A)
+ 0.0, 0.0, 1.0, 0.0, // B <- (R, G, B, A)
+ 0.0, 0.0, 0.0, 1.0, // A <- (R, G, B, A)
+ ];
+ for (i, v) in swap.iter().enumerate() {
+ row.insert(format!("m{i}"), NodeValue::Float(*v));
+ }
+ let out = eval_node_row(COLORMATRIX, row, None);
+ assert_pixel(pixel_at(&out, 8, 8), [0.0, 1.0, 0.0, 1.0], "swap");
+}
+
+/// The Sobel magnitude of a black/white step is 1 + 2 + 1 = 4.0 on the
+/// two columns either side of the split (7 and 8) and 0.0 elsewhere in
+/// RGB; the alpha channel passes through. A threshold above the
+/// magnitude zeroes the whole frame.
+#[test]
+fn edgedetect_half_black_white_lights_boundary_columns() {
+ if !gpu() { eprintln!("no adapter; skipping"); return; }
+ let split = painted_frame(|x, _| {
+ if x < 8 { [0.0, 0.0, 0.0, 1.0] } else { [1.0, 1.0, 1.0, 1.0] }
+ });
+ let mut row = row_with(split);
+ row.insert("threshold_in".to_string(), NodeValue::Float(0.5));
+ let out = eval_node_row(EDGEDETECT, row, None);
+ for y in 0..16 {
+ for x in 0..16 {
+ let px = pixel_at(&out, x, y);
+ let rgb = if x == 7 || x == 8 { 4.0 } else { 0.0 };
+ assert_pixel(px, [rgb, rgb, rgb, 1.0], &format!("edgedetect ({x},{y})"));
+ }
+ }
+
+ // Threshold above 4.0: the step drops every magnitude.
+ let mut row = row_with(painted_frame(|x, _| {
+ if x < 8 { [0.0, 0.0, 0.0, 1.0] } else { [1.0, 1.0, 1.0, 1.0] }
+ }));
+ row.insert("threshold_in".to_string(), NodeValue::Float(10.0));
+ let out = eval_node_row(EDGEDETECT, row, None);
+ assert_pixel(pixel_at(&out, 7, 8), [0.0, 0.0, 0.0, 1.0], "edgedetect thresholded");
+}
+
+/// A single white pixel grows to its full 3x3 neighborhood at radius 1;
+/// the rest of the frame stays black.
+#[test]
+fn dilate_single_pixel_grows_to_3x3() {
+ if !gpu() { eprintln!("no adapter; skipping"); return; }
+ let white = [1.0, 1.0, 1.0, 1.0];
+ let black = [0.0, 0.0, 0.0, 1.0];
+ let mut row = row_with(painted_frame(|x, y| if (x, y) == (8, 8) { white } else { black }));
+ row.insert("radius_in".to_string(), NodeValue::Float(1.0));
+ let out = eval_node_row(DILATE, row, None);
+ for y in 0..16 {
+ for x in 0..16 {
+ let want = if (7..=9).contains(&x) && (7..=9).contains(&y) { white } else { black };
+ assert_pixel(pixel_at(&out, x, y), want, &format!("dilate ({x},{y})"));
+ }
+ }
+}
+
+/// A 3x3 white block shrinks to its center pixel at radius 1; the rest
+/// of the frame stays black.
+#[test]
+fn erode_white_block_shrinks_to_center() {
+ if !gpu() { eprintln!("no adapter; skipping"); return; }
+ let white = [1.0, 1.0, 1.0, 1.0];
+ let black = [0.0, 0.0, 0.0, 1.0];
+ let mut row = row_with(painted_frame(|x, y| {
+ if (7..=9).contains(&x) && (7..=9).contains(&y) { white } else { black }
+ }));
+ row.insert("radius_in".to_string(), NodeValue::Float(1.0));
+ let out = eval_node_row(ERODE, row, None);
+ for y in 0..16 {
+ for x in 0..16 {
+ let want = if (x, y) == (8, 8) { white } else { black };
+ assert_pixel(pixel_at(&out, x, y), want, &format!("erode ({x},{y})"));
+ }
+ }
+}
diff --git a/crates/oak-render/tests/ofxmisc_merge.rs b/crates/oak-render/tests/ofxmisc_merge.rs
new file mode 100644
index 000000000..03e3d9301
--- /dev/null
+++ b/crates/oak-render/tests/ofxmisc_merge.rs
@@ -0,0 +1,190 @@
+//! GPU pixel tests for the Tier-1 merge nodes (skip without GPU).
+use oak_core::texture::Texture;
+use oak_core::{PixelFormat, Rational};
+use oak_node::value::{NodeValue, NodeValueRow, NodeValueTable, ValueType};
+
+fn texture_value(t: Texture) -> NodeValue { NodeValue::Texture(oak_node::handle::make_owned(t)) }
+fn gpu() -> bool { oak_core::backend::GpuContext::shared().is_some() }
+fn filled_frame(size: (i32, i32), rgba: [f32; 4]) -> Texture {
+ let mut f = oak_render::eval::generate_frame(Rational::new(0, 1), size, PixelFormat::F32).unwrap();
+ for px in f.data.chunks_exact_mut(16) { for (c, v) in px.chunks_exact_mut(4).zip(rgba) { c.copy_from_slice(&v.to_le_bytes()); } }
+ Texture::wrap_frame(f)
+}
+fn pixel_at(frame: &oak_core::texture::Frame, x: usize, y: usize) -> [f32; 4] {
+ let stride = frame.linesize_bytes() as usize;
+ let at = y * stride + x * 16;
+ let mut out = [0f32; 4];
+ for c in 0..4 { out[c] = f32::from_le_bytes(frame.data[at + c*4..at + c*4 + 4].try_into().unwrap()); }
+ out
+}
+fn eval_node_row(type_id: &str, inputs: NodeValueRow, frame_size: Option<(i32, i32)>) -> oak_core::texture::Frame {
+ use oak_node::traverser::RenderHooks;
+ let (core, behavior) = oak_node::factory::Factory::global().create_any(type_id).expect("node type registered");
+ let mut table = NodeValueTable::default();
+ behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
+ let mut hooks = oak_render::eval::RenderEvalHooks::new();
+ hooks.frame_size = frame_size;
+ hooks.resolve(oak_node::id::NodeId::INVALID, &inputs, &mut table);
+ let Some(NodeValue::Texture(handle)) = table.get(ValueType::Texture) else { panic!("{type_id}: no texture produced") };
+ if handle.ctx.is_null() { panic!("{type_id}: null texture produced"); }
+ let tex = unsafe { oak_node::handle::get_checked::(handle) }.expect("resolved texture");
+ assert!(matches!(tex, Texture::Gpu { .. }), "{type_id}: must render on the GPU");
+ tex.to_frame().expect("readback")
+}
+
+const DISSOLVE: &str = "org.olivevideoeditor.Olive.dissolve";
+const KEYMIX: &str = "org.olivevideoeditor.Olive.keymix";
+const PREMULT: &str = "org.olivevideoeditor.Olive.premult";
+const UNPREMULT: &str = "org.olivevideoeditor.Olive.unpremult";
+
+/// Assert the four channels of a pixel against the expected values.
+fn assert_pixel(px: [f32; 4], want: [f32; 4], what: &str) {
+ for c in 0..4 {
+ assert!(
+ (px[c] - want[c]).abs() < 1e-3,
+ "{what}: channel {c}: got {px:?}, want {want:?}"
+ );
+ }
+}
+
+/// A row with the two picture inputs (and the mask for keymix) painted
+/// over the whole 8x8 frame.
+fn merge_row(tex: [f32; 4], blend: [f32; 4]) -> NodeValueRow {
+ let mut row = NodeValueRow::new();
+ row.insert("tex_in".to_string(), texture_value(filled_frame((8, 8), tex)));
+ row.insert("blend_in".to_string(), texture_value(filled_frame((8, 8), blend)));
+ row
+}
+
+/// A row with just the effect input, plus the channel combo.
+fn channel_row(rgba: [f32; 4], channel_input: &str, channel: i64) -> NodeValueRow {
+ let mut row = NodeValueRow::new();
+ row.insert("tex_in".to_string(), texture_value(filled_frame((8, 8), rgba)));
+ row.insert(channel_input.to_string(), NodeValue::Combo(channel));
+ row
+}
+
+/// Dissolve at mix 0.5 lerps halfway between red and green; the alpha
+/// rides the same lerp (both inputs are opaque here).
+#[test]
+fn dissolve_half_mix_blends_inputs() {
+ if !gpu() {
+ eprintln!("no adapter; skipping");
+ return;
+ }
+ let mut row = merge_row([1.0, 0.0, 0.0, 1.0], [0.0, 1.0, 0.0, 1.0]);
+ row.insert("mix_in".to_string(), NodeValue::Float(0.5));
+ let frame = eval_node_row(DISSOLVE, row, Some((8, 8)));
+ assert_pixel(
+ pixel_at(&frame, 2, 2),
+ [0.5, 0.5, 0.0, 1.0],
+ "dissolve mix 0.5",
+ );
+}
+
+/// Mix 0 must leave the first input untouched.
+#[test]
+fn dissolve_zero_mix_keeps_first_input() {
+ if !gpu() {
+ eprintln!("no adapter; skipping");
+ return;
+ }
+ let mut row = merge_row([1.0, 0.0, 0.0, 1.0], [0.0, 1.0, 0.0, 1.0]);
+ row.insert("mix_in".to_string(), NodeValue::Float(0.0));
+ let frame = eval_node_row(DISSOLVE, row, Some((8, 8)));
+ assert_pixel(
+ pixel_at(&frame, 2, 2),
+ [1.0, 0.0, 0.0, 1.0],
+ "dissolve mix 0",
+ );
+}
+
+/// KeyMix keys on the mask's alpha: a fully transparent mask keeps the
+/// input everywhere.
+#[test]
+fn keymix_transparent_mask_keeps_input() {
+ if !gpu() {
+ eprintln!("no adapter; skipping");
+ return;
+ }
+ let mut row = merge_row([1.0, 0.0, 0.0, 1.0], [0.0, 1.0, 0.0, 1.0]);
+ row.insert(
+ "mask_in".to_string(),
+ texture_value(filled_frame((8, 8), [0.0, 0.0, 0.0, 0.0])),
+ );
+ let frame = eval_node_row(KEYMIX, row, Some((8, 8)));
+ assert_pixel(
+ pixel_at(&frame, 2, 2),
+ [1.0, 0.0, 0.0, 1.0],
+ "keymix zero mask",
+ );
+}
+
+/// A fully opaque mask selects the blend everywhere.
+#[test]
+fn keymix_opaque_mask_takes_blend() {
+ if !gpu() {
+ eprintln!("no adapter; skipping");
+ return;
+ }
+ let mut row = merge_row([1.0, 0.0, 0.0, 1.0], [0.0, 1.0, 0.0, 1.0]);
+ row.insert(
+ "mask_in".to_string(),
+ texture_value(filled_frame((8, 8), [1.0, 1.0, 1.0, 1.0])),
+ );
+ let frame = eval_node_row(KEYMIX, row, Some((8, 8)));
+ assert_pixel(
+ pixel_at(&frame, 2, 2),
+ [0.0, 1.0, 0.0, 1.0],
+ "keymix opaque mask",
+ );
+}
+
+/// Premultiply scales RGB by the alpha channel; alpha is untouched.
+#[test]
+fn premult_scales_rgb_by_alpha() {
+ if !gpu() {
+ eprintln!("no adapter; skipping");
+ return;
+ }
+ let row = channel_row([1.0, 0.5, 0.25, 0.5], "premult_channel_in", 4);
+ let frame = eval_node_row(PREMULT, row, Some((8, 8)));
+ assert_pixel(
+ pixel_at(&frame, 2, 2),
+ [0.5, 0.25, 0.125, 0.5],
+ "premult alpha",
+ );
+}
+
+/// Unpremultiply is the inverse of premultiply within tolerance.
+#[test]
+fn unpremult_inverts_premult() {
+ if !gpu() {
+ eprintln!("no adapter; skipping");
+ return;
+ }
+ let row = channel_row([0.5, 0.25, 0.125, 0.5], "unpremult_channel_in", 4);
+ let frame = eval_node_row(UNPREMULT, row, Some((8, 8)));
+ assert_pixel(
+ pixel_at(&frame, 2, 2),
+ [1.0, 0.5, 0.25, 0.5],
+ "unpremult alpha",
+ );
+}
+
+/// A zero divisor is guarded: the pixel is passed through unchanged
+/// rather than blowing up to infinity.
+#[test]
+fn unpremult_zero_alpha_passes_through() {
+ if !gpu() {
+ eprintln!("no adapter; skipping");
+ return;
+ }
+ let row = channel_row([0.5, 0.25, 0.125, 0.0], "unpremult_channel_in", 4);
+ let frame = eval_node_row(UNPREMULT, row, Some((8, 8)));
+ assert_pixel(
+ pixel_at(&frame, 2, 2),
+ [0.5, 0.25, 0.125, 0.0],
+ "unpremult zero guard",
+ );
+}
diff --git a/docs/zh/plans/ofx-misc-gpu-cleanroom.md b/docs/zh/plans/ofx-misc-gpu-cleanroom.md
new file mode 100644
index 000000000..bf30f797f
--- /dev/null
+++ b/docs/zh/plans/ofx-misc-gpu-cleanroom.md
@@ -0,0 +1,155 @@
+# OpenFX-Misc 洁净室 GPU 重写(内置特效扩充)与特效分类/折叠计划
+
+> 面向实现者的任务书(2026-09-10)。本文只描述方案与工作项,不含已执行的代码修改。
+> 用户要求:"把 OpenFX-Misc 洁净室重写为 GPU 版本,作为内置特效加入进去,并给内置特效
+> 加分类和折叠分类的功能。"参考源码已克隆到本机 `/tmp/ofx-misc`(上游
+> `github.com/cgvirus/OpenFX-Misc`,GPL2,86 个插件目录,README 有完整清单)。
+>
+> 法律/工程边界:**洁净室**指不复制其代码——我们只读其算法描述与参数语义,
+> GLSL 与节点代码全部自写。仓内节点实现模式已有 60+ 先例(oak-node/src/nodes/*.rs),
+> 本计划实质是"参照 OpenFX-Misc 的特效清单,按仓内既有节点模式补齐内置特效"。
+
+## 1. 现状
+
+### 1.1 节点/渲染管线(完全够用)
+
+- 内置特效 = `oak-node/src/nodes/*.rs` 的 `NodeBehavior` 实现:声明输入(`Input`),
+ `value()` 推 `ShaderJobPayload`(`crates/oak-node/src/jobs.rs`),`shader_code()`
+ 返回 GLSL 片段。渲染端 `crates/oak-render/src/eval.rs::process_shader_job`:
+ 编译(naga→WGSL,**不支持 GLSL switch**——新 shader 一律 if/else,教训见提交
+ `37df1d3d3`)、按名绑定全部纹理参数、嵌套 payload 递归(深度上限 8)、
+ `resolution_in` 自动锚定序列分辨率(提交 `fc9060424`)、`iterations` 多轮 +
+ `previous_iteration_in` 反馈。GPU 像素测试模式:`eval.rs tests::eval_node_row`。
+- 坐标/基准约定:像素空间以**画面中心**为原点(transform 语义,提交 `d028a45ff`);
+ 像素尺寸参数(半径/宽度/距离)按序列分辨率解释。
+- 已有同类特效(避免重复):blur、opacity、transform、crop、flip(Distort 系)、
+ merge、mrg(生成器 alpha-over)、math、chromakey、colordifferencekey、despill、
+ solid、polygon、shape、noise、ociobase/lut/grading、whitebalance、threewaycolor、
+ mask、stroke、dropshadow、displaytransform、cornerpin(假实现,另案)、
+ tile/swirl/ripple/wave(Distort 系)、trigonometry、volume、pan。
+
+### 1.2 特效库 UI
+
+- `crates/oak-app/src/oakui/effectchain.rs::addable_effects`:内置(`group: None`)
+ + OFX 动态条目(`group: Some(子类)`),排序已按组+名字。
+- `crates/oak-app/src/panels/effect_library.rs`:渲染时组头已存在
+ (`group_header()`,内置统一一个 "Built-in" 头),**不可折叠**;有搜索框。
+- 检查器"添加特效"菜单(`panels/inspector.rs:157`)吃同一张 `addable_effects` 表。
+
+## 2. 目标
+
+1. 参照 OpenFX-Misc 清单,按 GPU 版本洁净室重写一批常用特效,作为**内置特效**
+ (oak-node 原生节点,非 OFX 运行时)加入。
+2. 内置特效按功能分类(Color / Filter / Keying / Distort / Generator / Merge / Time),
+ 特效库与检查器添加菜单都按分类分组,**分类可折叠**(折叠状态持久化)。
+
+## 3. 特效分批(实现范围)
+
+### Tier 1(本批必做,算法简单、GLSL 直译,全部像素可测)
+
+| 特效(参考) | 分类 | 输入(节点参数) | 算法要点 |
+|---|---|---|---|
+| ColorCorrectOFX | Color | saturation/contrast/gamma/gain/offset(各 5 组:master/shadows/midtones/highlights)太多了→**简化为全局 5 参数**(saturation/contrast/gamma/gain/offset) | 逐像素 `offset+gain*pow(x,gamma)`,contrast 绕 0.18 灰,saturation 绕 luma |
+| GammaOFX | Color | gamma(单值) | `pow(x, 1/g)` |
+| SaturationOFX | Color | saturation | luma 插值(与 whitebalance/threeway 不重复:它最简) |
+| InvertOFX | Color | channel 开关(RGBA) | `1-x`(按通道掩码) |
+| ClampOFX | Color | min/max | clamp 每通道 |
+| ColorMatrixOFX | Color | 4x4 矩阵(16 float) | 矩阵×RGBA(uniform mat4 已有先例:transform_in) |
+| GradeOFX | Color | blackPoint/whitePoint/blackOut/whiteOut/gamma | 黑白点重映射 |
+| DirBlurOFX | Filter | amount/angle | 方向模糊(迭代采样 N=16,角度→方向向量) |
+| SharpenCImg | Filter | amount | unsharp mask:x + amount·(x − blur(x))(blur 复用现有迭代模糊,嵌套 payload) |
+| EdgeDetectCImg | Filter | threshold/通道 | Sobel 幅值 |
+| Dilate/ErodeCImg | Filter | radius/shape(rect) | 3×3~7×7 结构元 max/min(radius 控制迭代轮数) |
+| DissolveOFX | Merge | mix(0..1)、第二输入 blend_in | 加权平均(merge.rs 双输入绑定已有先例;转场功能的原子件) |
+| KeyMixOFX | Merge | mask_in、blend_in | 按 mask 拷贝(mask 绑定已有先例:chromakey 的 garbage/core matte) |
+| PreMult/UnpremultOFX | Merge | channel 选择 | rgb *= a / rgb /= a(0 保护) |
+| PositionOFX | Distort | offset xy(整数 px) | 采样偏移(resolution_in 换算) |
+| MirrorOFX | Distort | horizontal/vertical | 翻转采样(flip 节点已有?若有重复则跳过——实现时先查 flip.rs 覆盖面) |
+| CheckerBoardOFX | Generator | size/color1/color2 | 程序化棋盘格 |
+| ColorBarsOFX | Generator | SMPTE/100%/75% | 彩条(分段填色) |
+| RampOFX | Generator | point0/point1/color0/color1 | 线性渐变 |
+| Rand(噪声已有) | — | — | **跳过**(noise.rs 已覆盖) |
+| Constant(solid 已有) | — | — | **跳过** |
+
+合计约 17 个新节点(Mirror 可能合并/跳过)。
+
+### Tier 2(第二批,涉及曲线/对数/卷积/积雨云)
+
+HSVTool(色相替换+keyer 能力)、Quantize(海报化/抖动)、Log2Lin/PLogLin、
+ClipTest(斑马纹超范围指示)、Matrix3x3/Matrix5x5(通用卷积)、GodRays(径向
+辉光,迭代采样)、ColorLookup(分通道曲线——**复用现有曲线编辑器**
+`gpui_widgets::curve_editor` + `oak_plugin::param_curve` 的 JSON 模型,参数为 Text)。
+
+### Tier 3(明确不做,写明理由)
+
+- Roto(要主机遮罩编辑)、TrackerPM(点跟踪,需交互与多帧)、Card3D(3D 投影)、
+ STMap/IDistort(位移图输入——其实可做,列 Tier 2 备选)、Shadertoy(沙盒运行时)、
+ 全部 Views/立体声(无多视图管线)、CImg 重型族(DenoiseSharpen/Smooth* PDE/Inpaint——
+ 迭代 PDE 不适合实时 GPU 预览)、**全部时间域**(FrameBlend/FrameHold/Retime/
+ TimeBlur/SlitScan/TimeOffset/AppendClip——`ShaderJobPayload` 只能采当前时刻纹理,
+ 多时刻采样需要 job 管线扩展,**单独立案**,不在本计划)。
+
+## 4. 节点实现模板(所有新节点统一)
+
+每个新节点 = `oak-node/src/nodes/` 一个文件,遵循既有模式(参照 `opacity.rs` /
+`colorcorrect` 无、参照 `blur.rs`/`math.rs`):
+
+1. 常量输入 id + `create()`(输入、默认值、min/max、combo 字符串、`VIDEO_EFFECT` 标志、
+ `core.effect_input = "tex_in"`;双输入节点参考 merge.rs 的 base/blend)。
+2. `value()`:无纹理直通(参考各节点的 `// CPP-PARITY` 注释体例),否则推
+ `ShaderJobPayload`(`shader_id: ""`,`iterations: 1`)。
+3. `shader_code()`:GLSL 片段(ove_texcoord/frag_color;**禁用 switch**;
+ 像素尺寸参数用 `resolution_in`;采样偏移用中心原点像素空间与否按特效语义——
+ 颜色类与坐标无关,几何类参照 transform 的中心原点)。
+4. `register()` 进 `nodes/mod.rs` 的注册表。
+5. 单元测试(输入默认值/隐藏标志/job 参数)+ **`crates/oak-render/src/eval.rs`
+ GPU 像素测试**(eval_node_row 模式,无 GPU 自动跳过)。颜色类用纯色输入断言
+ 输出值;几何/模糊类用点/块图案断言位移/扩散。
+
+## 5. 分类与折叠(UI)
+
+1. **内置特效分类**:`addable_effects()` 的内置分支改为 `group: Some(分类)`,
+ 分类取自节点 `categories()` 首个 `Category` 映射:
+ `Category::Color→"调色"`(或英文 "Color",跟 i18n key)、`Filter→"滤镜"`、
+ `Distort→"扭曲"`、`Keyer→"键控"`、`Generator→"生成器"`、`Merge→"合成"`、
+ `Time→"时间"`、`Math→"数学"`、`Channel→"通道"`。映射函数放
+ `effectchain.rs`(`node_category_key` 已有类似物,见 engine.rs:206,
+ 但该函数是给节点编辑器菜单的 i18n key,特效库分组可直接复用同一 key 体系)。
+ i18n:8 语言加 `effect_library.group.`。
+2. **折叠**:`effect_library.rs` 组头加点击折叠/展开(箭头 ▶/▼ + 组名):
+ - 面板 struct 增加 `collapsed: std::collections::HashSet`(组 key),
+ 点击组头切换;渲染时折叠组跳过其子行。
+ - 持久化:`oak_core::configstore`(参照现有 `UseProxyMedia` 等键的读写模式),
+ 键 `EffectLibraryCollapsed`(逗号分隔组 key 列表)。
+ - 检查器的添加菜单(inspector.rs:157 的菜单构建)同样按组分组
+ (menu.rs 支持子菜单——组做子菜单,比折叠更适合菜单形态;实现时确认
+ `MenuItem::with_submenu` 用法,与 proxy_submenu 一致)。
+3. 搜索时忽略折叠状态(搜索命中强制展开显示,已在循环内自然满足:
+ 搜索非空时不跳过子行)。
+
+## 6. 工作项(可分配给子代理的最小单元)
+
+- **W1 Tier1 颜色组(6 节点)**:ColorCorrect/Gamma/Saturation/Invert/Clamp/Grade。
+- **W2 Tier1 矩阵+卷积组(4 节点)**:ColorMatrix/EdgeDetect/Dilate/Erode
+ (+Tier2 的 Matrix3x3/5x5 若顺利一并)。
+- **W3 Tier1 模糊/锐化组(2 节点)**:DirBlur/Sharpen。
+- **W4 Tier1 合成组(4 节点)**:Dissolve/KeyMix/PreMult/Unpremult。
+- **W5 Tier1 几何+生成器组(4~5 节点)**:Position/Mirror(或跳过)/CheckerBoard/
+ ColorBars/Ramp。
+- **W6 分类与折叠 UI**:§5 全部(addable_effects 分组 + 特效库折叠 + 持久化 +
+ 检查器子菜单 + i18n)。
+- **W7 Tier2 批**:HSVTool/Quantize/Log2Lin/ClipTest/ColorLookup/GodRays
+ (W1-W6 完成并审查后再派)。
+
+W1-W5 互相独立(不同文件),可并行派 5 个子代理;W6 独立;每个子代理须交付:
+节点实现 + 单元测试 + GPU 像素测试 + `cargo test -p oak-node -p oak-render` 绿。
+**统一禁令**:GLSL 不写 switch;不动 eval.rs/traverser 等管线文件(冲突根);
+遵循 nodes/ 既有文件体例(GPL 头、CPP-PARITY 注释、输入常量文档)。
+
+## 7. 验收标准
+
+1. Tier1 全部节点出现在特效库对应分类下,可加到 clip,画面效果正确(GPU 测试
+ 逐节点覆盖核心算法)。
+2. 特效库分类可折叠,重启 app 折叠状态保留;检查器添加菜单按分类分组。
+3. 搜索框在任何折叠状态下都能搜到特效。
+4. `cargo test --workspace` 全绿。