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` 全绿。