// 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 .
//! White balance node (C++
//! `src/node/src/color/whitebalance/whitebalance.{h,cpp}`,
//! `olive::WhiteBalanceNode`).
//!
//! White balance correction by color temperature and tint: converts a
//! scene illuminant temperature (Kelvin) into per-channel RGB gains
//! using the Tanner Helland blackbody approximation, normalized so the
//! green channel is preserved (no exposure shift); tint shifts along
//! the green-magenta axis.
use crate::factory::NodeMeta;
use crate::jobs::ShaderJobPayload;
use crate::node::{Category, NodeBehavior, NodeCore};
/// Texture input id (C++ `k_texture_input`). Type: texture; flags:
/// not-keyframable; this is the node's effect input.
pub const TEXTURE_INPUT: &str = "tex_in";
/// Temperature input id (C++ `k_temperature_input`). Type: float;
/// default `6500.0` (Kelvin); properties: `min = 1000.0`, `max =
/// 40000.0`, `view = normal slider`.
pub const TEMPERATURE_INPUT: &str = "temperature_in";
/// Tint input id (C++ `k_tint_input`). Type: float; default `0.0`;
/// properties: `min = -1.0`, `max = 1.0`, `base = 0.01`.
pub const TINT_INPUT: &str = "tint_in";
/// Gain uniform id (C++ `k_gain_input`). Not a declared node input —
/// the C++ never calls `add_input` for it; it is the shader uniform
/// name fed per frame in `value()` with the RGB gain computed by
/// [`WhiteBalanceNode::gain_for_temperature`]. Type: vec3.
pub const GAIN_INPUT: &str = "wb_gain_in";
/// White balance node. Adjusts white balance by color temperature and
/// tint. The C++ class has no own private members, so this is a
/// unit-like struct (caches/inputs live in `NodeCore`).
pub struct WhiteBalanceNode;
/// Fragment shader (C++ `get_shader_code` loads the
/// `:/shaders/whitebalance.frag` resource). Text copied verbatim from
/// `engine/shaders/whitebalance.frag`.
const SHADER_FRAG: &str = r#"uniform sampler2D tex_in;
uniform vec3 wb_gain_in;
in vec2 ove_texcoord;
out vec4 frag_color;
void main(void)
{
vec4 source = texture(tex_in, ove_texcoord);
// Deliberately not clamped: white balance must also work on HDR/linear
// footage with values above 1.0
frag_color = vec4(source.rgb * wb_gain_in, source.a);
}
"#;
impl WhiteBalanceNode {
/// Fragment shader for any request (C++ `get_shader_code()` ignores
/// the request id and always returns this shader).
fn shader_frag() -> &'static str {
SHADER_FRAG
}
/// RGB gains for a given illuminant temperature and tint (C++
/// `get_gain_for_temperature()`, extracted for testability). Kelvin
/// is clamped to [1000, 40000]; the Tanner Helland blackbody
/// approximation gives 0-255 per channel (red: 255 below 6600K, else
/// `329.698727446 * (t - 60)^-0.1332047592`; green: logarithmic
/// below 6600K, power-law above; blue: 255 above 6600K, 0 below
/// 1900K, logarithmic between). The result is normalized so the
/// green channel gain is 1.0 at tint 0, then tint scales the green
/// channel by `clamp(1.0 + tint, 0.0, 2.0)` (green-magenta axis).
pub fn gain_for_temperature(kelvin: f64, tint: f64) -> [f64; 3] {
let kelvin = kelvin.clamp(1000.0, 40000.0);
let t = kelvin / 100.0;
let red = if t <= 66.0 {
255.0
} else {
329.698727446 * (t - 60.0).powf(-0.1332047592)
};
let green = if t <= 66.0 {
99.4708025861 * t.ln() - 161.1195681661
} else {
288.1221695283 * (t - 60.0).powf(-0.0755148492)
};
let blue = if t >= 66.0 {
255.0
} else if t <= 19.0 {
0.0
} else {
138.5177312231 * (t - 10.0).ln() - 305.0447927307
};
// Normalize to the green channel so temperature shifts do not change
// exposure, then let tint move along the green-magenta axis.
let tint_gain = (1.0 + tint).clamp(0.0, 2.0);
[red / green, green / green * tint_gain, blue / green]
}
}
impl NodeBehavior for WhiteBalanceNode {
/// Human-readable name (C++ `name()`).
fn name(&self) -> &str {
"White Balance"
}
/// Stable type id (C++ `id()`).
fn type_id(&self) -> &str {
"org.olivevideoeditor.Olive.whitebalance"
}
/// Categories (C++ `category()`).
fn categories(&self) -> &[Category] {
&[Category::Color]
}
/// Description (C++ `description()`).
fn description(&self) -> &str {
"Adjust white balance by color temperature and tint."
}
/// Localized input names (C++ `retranslate()`): `tex_in` -> "Input",
/// `temperature_in` -> "Temperature (K)", `tint_in` -> "Tint".
fn input_name<'a>(&self, id: &'a str) -> &'a str {
match id {
TEXTURE_INPUT => "Input",
TEMPERATURE_INPUT => "Temperature (K)",
TINT_INPUT => "Tint",
_ => id,
}
}
/// Shader code request (C++ `get_shader_code()`): the request id is
/// ignored; always returns [`SHADER_FRAG`].
fn shader_code(&self, _request: &str) -> Option {
Some(SHADER_FRAG.to_string())
}
/// Evaluate outputs (C++ `value()`): no texture -> push nothing;
/// otherwise builds a `ShaderJob` from the whole input row, inserts
/// `wb_gain_in` as a vec3 computed by
/// [`Self::gain_for_temperature`] from the temperature and tint
/// inputs, and pushes the texture as that job.
fn value(
&self,
core: &NodeCore,
inputs: &crate::value::NodeValueRow,
time: oak_core::Rational,
table: &mut crate::value::NodeValueTable,
) {
match inputs.get(TEXTURE_INPUT) {
Some(crate::value::NodeValue::Texture(_)) => {}
_ => return,
}
let temperature = match inputs.get(TEMPERATURE_INPUT) {
Some(v) => v.to_double(),
None => core.value_at_time(TEMPERATURE_INPUT, -1, time).to_double(),
};
let tint = match inputs.get(TINT_INPUT) {
Some(v) => v.to_double(),
None => core.value_at_time(TINT_INPUT, -1, time).to_double(),
};
let gain = Self::gain_for_temperature(temperature, tint);
// `// CPP-PARITY: whitebalance.cpp` `value()` — the C++ builds a
// ShaderJob from the whole input row, inserts `wb_gain_in` as the
// per-frame vec3 gain, and pushes `tex->to_job(job)`. The job is
// boxed here as a [`ShaderJobPayload`] that the renderer's resolve
// hook executes and replaces with the result texture; the params
// row carries the computed gain under the shader uniform name.
let mut params = inputs.clone();
params.insert(GAIN_INPUT.to_string(), crate::value::NodeValue::Vec3(gain));
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,
);
}
/// Deep copy (C++ `copy()` via `NODE_DEFAULT_FUNCTIONS`).
fn duplicate(&self, _core: &NodeCore) -> Option> {
Some(Box::new(WhiteBalanceNode))
}
}
/// Constructor (C++ `WhiteBalanceNode::WhiteBalanceNode()`): adds
/// `tex_in` (texture, effect input), `temperature_in` and `tint_in`
/// with the defaults and properties documented on the constants, and
/// sets the video-effect flag.
pub fn create() -> (NodeCore, Box) {
let mut core = NodeCore::new();
let mut tex = crate::input::Input::new(
TEXTURE_INPUT,
crate::value::ValueType::Texture,
crate::value::NodeValue::None,
);
tex.flags |= crate::input::flags::NOT_KEYFRAMABLE;
core.add_input(tex);
let mut temperature = crate::input::Input::new(
TEMPERATURE_INPUT,
crate::value::ValueType::Float,
crate::value::NodeValue::Float(6500.0),
);
temperature.properties = vec![
("min".to_string(), crate::value::NodeValue::Float(1000.0)),
("max".to_string(), crate::value::NodeValue::Float(40000.0)),
(
"view".to_string(),
crate::value::NodeValue::Text("normal".into()),
),
];
core.add_input(temperature);
let mut tint = crate::input::Input::new(
TINT_INPUT,
crate::value::ValueType::Float,
crate::value::NodeValue::Float(0.0),
);
tint.properties = vec![
("min".to_string(), crate::value::NodeValue::Float(-1.0)),
("max".to_string(), crate::value::NodeValue::Float(1.0)),
("base".to_string(), crate::value::NodeValue::Float(0.01)),
];
core.add_input(tint);
core.effect_input = TEXTURE_INPUT.to_string();
core.flags |= crate::node::flags::VIDEO_EFFECT;
(core, Box::new(WhiteBalanceNode))
}
/// Register this node type (C++ factory entry for
/// `org.olivevideoeditor.Olive.whitebalance`).
pub fn register(meta: &mut Vec) {
meta.push(NodeMeta {
type_id: "org.olivevideoeditor.Olive.whitebalance",
name: "White Balance",
categories: &[Category::Color],
create,
});
}
#[cfg(test)]
mod tests {
use super::*;
use crate::value::{NodeValue, NodeValueTable, ValueType};
use oak_core::Rational;
#[test]
fn input_names() {
let n = WhiteBalanceNode;
assert_eq!(n.input_name(TEXTURE_INPUT), "Input");
assert_eq!(n.input_name(TEMPERATURE_INPUT), "Temperature (K)");
assert_eq!(n.input_name(TINT_INPUT), "Tint");
assert_eq!(n.input_name("other_in"), "other_in");
}
#[test]
fn create_wires_inputs_flags_and_properties() {
let (core, behavior) = create();
assert_eq!(
behavior.type_id(),
"org.olivevideoeditor.Olive.whitebalance"
);
let tex = core.get_input(TEXTURE_INPUT).unwrap();
assert_ne!(tex.flags & crate::input::flags::NOT_KEYFRAMABLE, 0);
assert_eq!(
core.get_input(TEMPERATURE_INPUT).unwrap().default,
NodeValue::Float(6500.0)
);
assert_eq!(
core.get_input(TINT_INPUT).unwrap().default,
NodeValue::Float(0.0)
);
assert_eq!(core.effect_input, TEXTURE_INPUT);
assert_ne!(core.flags & crate::node::flags::VIDEO_EFFECT, 0);
}
#[test]
fn gain_matches_documented_blackbody_formula() {
let (kelvin, tint): (f64, f64) = (5600.0, 0.25);
let t = kelvin / 100.0;
let red = 255.0; // t = 56 <= 66
let green = 99.4708025861 * t.ln() - 161.1195681661;
let blue = 138.5177312231 * (t - 10.0).ln() - 305.0447927307;
let tint_gain = (1.0 + tint).clamp(0.0, 2.0);
let expected = [red / green, green / green * tint_gain, blue / green];
let got = WhiteBalanceNode::gain_for_temperature(kelvin, tint);
for (g, e) in got.iter().zip(expected.iter()) {
assert!((g - e).abs() < 1e-9, "got {}, expected {}", g, e);
}
}
#[test]
fn gain_normalizes_green_to_one() {
// The green channel gain is always 1.0 at tint 0, so temperature
// shifts never change exposure.
for kelvin in [1000.0, 1900.0, 5600.0, 6500.0, 10000.0, 40000.0] {
let gain = WhiteBalanceNode::gain_for_temperature(kelvin, 0.0);
assert_eq!(gain[1], 1.0, "kelvin {}", kelvin);
}
}
#[test]
fn gain_blue_black_below_1900k() {
// t <= 19 => blue channel gain is 0.
let gain = WhiteBalanceNode::gain_for_temperature(1000.0, 0.0);
assert_eq!(gain[2], 0.0);
}
#[test]
fn gain_clamps_kelvin_range() {
// Below 1000 and above 40000 Kelvin are clamped.
let low = WhiteBalanceNode::gain_for_temperature(500.0, 0.0);
let at_min = WhiteBalanceNode::gain_for_temperature(1000.0, 0.0);
assert_eq!(low, at_min);
let high = WhiteBalanceNode::gain_for_temperature(50000.0, 0.0);
let at_max = WhiteBalanceNode::gain_for_temperature(40000.0, 0.0);
assert_eq!(high, at_max);
}
#[test]
fn gain_tint_scales_green_axis_clamped() {
assert_eq!(WhiteBalanceNode::gain_for_temperature(6500.0, 0.0)[1], 1.0);
assert_eq!(WhiteBalanceNode::gain_for_temperature(6500.0, 1.0)[1], 2.0);
assert_eq!(WhiteBalanceNode::gain_for_temperature(6500.0, -1.0)[1], 0.0);
assert_eq!(WhiteBalanceNode::gain_for_temperature(6500.0, 10.0)[1], 2.0);
assert_eq!(
WhiteBalanceNode::gain_for_temperature(6500.0, -10.0)[1],
0.0
);
}
#[test]
fn shader_code_returns_whitebalance_frag() {
let code = WhiteBalanceNode.shader_code("anything").unwrap();
assert!(code.contains("source.rgb * wb_gain_in"));
}
#[test]
fn value_no_texture_pushes_nothing() {
let (core, behavior) = create();
let mut table = NodeValueTable::default();
behavior.value(
&core,
&crate::value::NodeValueRow::default(),
Rational::new(0, 1),
&mut table,
);
assert!(table.is_empty());
}
#[test]
fn value_with_texture_pushes_shader_job_payload() {
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 NodeValue::Texture(handle) = table.get(ValueType::Texture).unwrap() else {
panic!("expected a texture-typed value");
};
let payload =
unsafe { crate::handle::get_checked::(handle) }
.expect("payload boxed behind the handle");
assert_eq!(payload.type_id, "org.olivevideoeditor.Olive.whitebalance");
assert_eq!(payload.shader_id, "");
assert_eq!(payload.iterations, 1);
assert_eq!(payload.effect_input, TEXTURE_INPUT);
// The computed per-frame gain is injected under the shader uniform
// name, overriding any row value (C++ `job.Insert(k_gain_input, ...)`).
let gain = payload.params.get(GAIN_INPUT).unwrap();
let NodeValue::Vec3(g) = gain else {
panic!("expected a vec3 gain");
};
assert_eq!(g, &WhiteBalanceNode::gain_for_temperature(6500.0, 0.0));
}
#[test]
fn duplicate_clones() {
let (core, behavior) = create();
let dup = behavior.duplicate(&core).unwrap();
assert_eq!(dup.name(), "White Balance");
}
}