#![doc = include_str!("color_grading.md")]
use super::{LUMA_B, LUMA_G, LUMA_R};
use serde::{Deserialize, Serialize};
#[cfg_attr(feature = "docgen", derive(schemars::JsonSchema))]
#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)]
pub struct ColorWheel {
#[serde(default)]
#[cfg_attr(feature = "docgen", schemars(range(min = 0.0, max = 360.0)))]
pub hue: f32,
#[serde(default)]
#[cfg_attr(feature = "docgen", schemars(range(min = 0.0, max = 100.0)))]
pub saturation: f32,
#[serde(default)]
#[cfg_attr(feature = "docgen", schemars(range(min = -100.0, max = 100.0)))]
pub luminance: f32,
}
#[cfg_attr(feature = "docgen", derive(schemars::JsonSchema))]
#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)]
pub struct ColorGradingParams {
#[serde(default)]
pub shadows: ColorWheel,
#[serde(default)]
pub midtones: ColorWheel,
#[serde(default)]
pub highlights: ColorWheel,
#[serde(default)]
pub global: ColorWheel,
#[serde(default)]
#[cfg_attr(feature = "docgen", schemars(range(min = -100.0, max = 100.0)))]
pub balance: f32,
}
impl ColorGradingParams {
pub fn is_default(&self) -> bool {
*self == Self::default()
}
}
#[derive(Debug, Clone, Copy)]
pub struct ColorGradingPrecomputed {
shadow_tint: [f32; 3],
midtone_tint: [f32; 3],
highlight_tint: [f32; 3],
global_tint: [f32; 3],
shadow_lum: f32,
midtone_lum: f32,
highlight_lum: f32,
global_lum: f32,
balance_factor: f32,
balance_active: bool,
}
impl ColorGradingPrecomputed {
fn wheel_to_tint(wheel: &ColorWheel) -> [f32; 3] {
let hue_rad = wheel.hue * std::f32::consts::PI / 180.0;
let sat = wheel.saturation / 100.0;
[
1.0 + sat * hue_rad.cos(),
1.0 + sat * (hue_rad - 2.0 * std::f32::consts::PI / 3.0).cos(),
1.0 + sat * (hue_rad - 4.0 * std::f32::consts::PI / 3.0).cos(),
]
}
pub fn new(params: &ColorGradingParams) -> Self {
Self {
shadow_tint: Self::wheel_to_tint(¶ms.shadows),
midtone_tint: Self::wheel_to_tint(¶ms.midtones),
highlight_tint: Self::wheel_to_tint(¶ms.highlights),
global_tint: Self::wheel_to_tint(¶ms.global),
shadow_lum: params.shadows.luminance / 100.0,
midtone_lum: params.midtones.luminance / 100.0,
highlight_lum: params.highlights.luminance / 100.0,
global_lum: params.global.luminance / 100.0,
balance_factor: 2.0_f32.powf(-params.balance / 100.0),
balance_active: params.balance != 0.0,
}
}
}
#[inline]
pub fn apply_color_grading_pre(
r: f32,
g: f32,
b: f32,
pre: &ColorGradingPrecomputed,
) -> (f32, f32, f32) {
let lum = LUMA_R * r + LUMA_G * g + LUMA_B * b;
let lum_adj = if pre.balance_active {
lum.clamp(0.0, 1.0).powf(pre.balance_factor)
} else {
lum.clamp(0.0, 1.0)
};
let w_shadow = (1.0 - lum_adj) * (1.0 - lum_adj);
let w_highlight = lum_adj * lum_adj;
let w_midtone = 1.0 - w_shadow - w_highlight;
let regional_r = pre.shadow_tint[0] * w_shadow
+ pre.midtone_tint[0] * w_midtone
+ pre.highlight_tint[0] * w_highlight;
let regional_g = pre.shadow_tint[1] * w_shadow
+ pre.midtone_tint[1] * w_midtone
+ pre.highlight_tint[1] * w_highlight;
let regional_b = pre.shadow_tint[2] * w_shadow
+ pre.midtone_tint[2] * w_midtone
+ pre.highlight_tint[2] * w_highlight;
let combined_r = regional_r * pre.global_tint[0];
let combined_g = regional_g * pre.global_tint[1];
let combined_b = regional_b * pre.global_tint[2];
let mut out_r = (r * combined_r).clamp(0.0, 1.0);
let mut out_g = (g * combined_g).clamp(0.0, 1.0);
let mut out_b = (b * combined_b).clamp(0.0, 1.0);
let adjustment = pre.shadow_lum * w_shadow
+ pre.midtone_lum * w_midtone
+ pre.highlight_lum * w_highlight
+ pre.global_lum;
out_r = (out_r + adjustment).clamp(0.0, 1.0);
out_g = (out_g + adjustment).clamp(0.0, 1.0);
out_b = (out_b + adjustment).clamp(0.0, 1.0);
(out_r, out_g, out_b)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn color_grading_default_is_identity() {
let params = ColorGradingParams::default();
assert!(params.is_default());
assert_eq!(params.balance, 0.0);
assert_eq!(params.shadows.hue, 0.0);
assert_eq!(params.shadows.saturation, 0.0);
assert_eq!(params.shadows.luminance, 0.0);
}
#[test]
fn color_grading_default_no_change() {
let params = ColorGradingParams::default();
let pre = ColorGradingPrecomputed::new(¶ms);
let (r, g, b) = apply_color_grading_pre(0.5, 0.5, 0.5, &pre);
assert!((r - 0.5).abs() < 1e-6);
assert!((g - 0.5).abs() < 1e-6);
assert!((b - 0.5).abs() < 1e-6);
}
#[test]
fn color_grading_shadow_teal_shifts_dark_pixels() {
let mut params = ColorGradingParams::default();
params.shadows.hue = 180.0; params.shadows.saturation = 50.0;
let pre = ColorGradingPrecomputed::new(¶ms);
let (r, g, b) = apply_color_grading_pre(0.1, 0.1, 0.1, &pre);
assert!(g > r, "green should exceed red for teal tint on dark pixel");
assert!(b > r, "blue should exceed red for teal tint on dark pixel");
let (r2, g2, b2) = apply_color_grading_pre(0.9, 0.9, 0.9, &pre);
let shift = ((r2 - 0.9).abs() + (g2 - 0.9).abs() + (b2 - 0.9).abs()) / 3.0;
assert!(
shift < 0.05,
"bright pixel should be mostly unaffected by shadow tint"
);
}
#[test]
fn color_grading_highlight_orange_shifts_bright_pixels() {
let mut params = ColorGradingParams::default();
params.highlights.hue = 30.0; params.highlights.saturation = 50.0;
let pre = ColorGradingPrecomputed::new(¶ms);
let (r, g, b) = apply_color_grading_pre(0.9, 0.9, 0.9, &pre);
assert!(
r > g,
"red should exceed green for orange tint on bright pixel"
);
assert!(
g > b,
"green should exceed blue for orange tint on bright pixel"
);
let (r2, g2, b2) = apply_color_grading_pre(0.1, 0.1, 0.1, &pre);
let shift = ((r2 - 0.1).abs() + (g2 - 0.1).abs() + (b2 - 0.1).abs()) / 3.0;
assert!(
shift < 0.05,
"dark pixel should be mostly unaffected by highlight tint"
);
}
#[test]
fn color_grading_midtone_affects_mid_pixels() {
let mut params = ColorGradingParams::default();
params.midtones.hue = 120.0; params.midtones.saturation = 50.0;
let pre = ColorGradingPrecomputed::new(¶ms);
let (r, g, b) = apply_color_grading_pre(0.5, 0.5, 0.5, &pre);
assert!(
g > r,
"green should exceed red for green midtone tint on mid pixel"
);
assert!(
g > b,
"green should exceed blue for green midtone tint on mid pixel"
);
let (r2, g2, _) = apply_color_grading_pre(0.05, 0.05, 0.05, &pre);
let shift_dark = (g2 - r2).abs();
let (r3, g3, _) = apply_color_grading_pre(0.95, 0.95, 0.95, &pre);
let shift_bright = (g3 - r3).abs();
let shift_mid = (g - r).abs();
assert!(
shift_mid > shift_dark,
"midtone tint should affect mid pixels more than dark"
);
assert!(
shift_mid > shift_bright,
"midtone tint should affect mid pixels more than bright"
);
}
#[test]
fn color_grading_global_tint_affects_all() {
let mut params = ColorGradingParams::default();
params.global.hue = 0.0; params.global.saturation = 50.0;
let pre = ColorGradingPrecomputed::new(¶ms);
let (r1, _, b1) = apply_color_grading_pre(0.2, 0.2, 0.2, &pre);
assert!(r1 > b1, "global red tint on dark pixel");
let (r2, _, b2) = apply_color_grading_pre(0.5, 0.5, 0.5, &pre);
assert!(r2 > b2, "global red tint on mid pixel");
let (r3, _, b3) = apply_color_grading_pre(0.8, 0.8, 0.8, &pre);
assert!(r3 > b3, "global red tint on bright pixel");
}
#[test]
fn color_grading_saturation_zero_no_color_effect() {
let mut params = ColorGradingParams::default();
params.shadows.hue = 200.0;
params.shadows.saturation = 0.0;
let pre = ColorGradingPrecomputed::new(¶ms);
let (r, g, b) = apply_color_grading_pre(0.1, 0.1, 0.1, &pre);
assert!((r - 0.1).abs() < 1e-6);
assert!((g - 0.1).abs() < 1e-6);
assert!((b - 0.1).abs() < 1e-6);
}
#[test]
fn color_grading_luminance_weight_sum() {
for i in 0..=100 {
let lum = i as f32 / 100.0;
let w_shadow = (1.0 - lum) * (1.0 - lum);
let w_highlight = lum * lum;
let w_midtone = 1.0 - w_shadow - w_highlight;
let sum = w_shadow + w_midtone + w_highlight;
assert!(
(sum - 1.0).abs() < 1e-6,
"weights must sum to 1.0, got {} at lum={}",
sum,
lum
);
}
}
#[test]
fn color_grading_balance_shifts_weights() {
let mut params_neg = ColorGradingParams::default();
params_neg.shadows.hue = 200.0;
params_neg.shadows.saturation = 50.0;
params_neg.balance = -50.0;
let mut params_pos = ColorGradingParams::default();
params_pos.shadows.hue = 200.0;
params_pos.shadows.saturation = 50.0;
params_pos.balance = 50.0;
let pre_neg = ColorGradingPrecomputed::new(¶ms_neg);
let pre_pos = ColorGradingPrecomputed::new(¶ms_pos);
let (_, g_neg, _) = apply_color_grading_pre(0.5, 0.5, 0.5, &pre_neg);
let (_, g_pos, _) = apply_color_grading_pre(0.5, 0.5, 0.5, &pre_pos);
assert!(
g_neg > g_pos,
"negative balance should increase shadow influence on midtones"
);
}
}