// 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::{Job, 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. Only the shader-source test reads
/// it, so it is kept (with an `allow`) to document the GLSL define.
#[allow(dead_code)]
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(Job::ShaderJob(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))
}
/// 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,
});
}
#[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::jobs::shader_job(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");
}
}