From 7a96fab30267b586a1ac1d0db300616773bc60e1 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 08:13:58 -0400 Subject: [PATCH] M13.9: gpu colour corrections (fx_tone) Levels (Individual Controls), Gamma/Pedestal/Gain, Photo Filter, Change Color, Change to Color, Leave Color, Broadcast Colors, Color Balance (HLS), Video Limiter, PS Arbitrary Map, CC Toner, CC Color Offset, CC Kernel, Auto Levels / Contrast / Color and Equalize (histograms measured on the CPU, applied on the GPU) on the GPU; Hue/Saturation colour ranges and Levels' channel controls no longer fall back to the CPU. Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/color2.rs | 75 ++-- crates/effects/src/lib.rs | 23 +- crates/gpu/src/context.rs | 2 + crates/gpu/src/effects.rs | 3 + crates/gpu/src/fx_tone.rs | 299 ++++++++++++++ crates/gpu/src/lib.rs | 3 + crates/gpu/src/shaders/fx_tone.wgsl | 592 ++++++++++++++++++++++++++++ crates/gpu/src/tests_fx_tone.rs | 117 ++++++ 8 files changed, 1083 insertions(+), 31 deletions(-) create mode 100644 crates/gpu/src/fx_tone.rs create mode 100644 crates/gpu/src/shaders/fx_tone.wgsl create mode 100644 crates/gpu/src/tests_fx_tone.rs diff --git a/crates/effects/src/color2.rs b/crates/effects/src/color2.rs index 67063d319..ea6bdc65a 100644 --- a/crates/effects/src/color2.rs +++ b/crates/effects/src/color2.rs @@ -330,10 +330,21 @@ fn stretch(v: f32, lo: f32, hi: f32) -> f32 { /// 0 = Auto Levels (per channel), 1 = Auto Contrast (shared), 2 = Auto Color (per channel + neutral mids). fn auto_correct(ctx: &EffectCtx, mut b: Buf, kind: u32) -> Buf { + let blend = (ctx.params.f("blend") / 100.0).clamp(0.0, 1.0) as f32; + let (ranges, gam) = auto_correct_settings(ctx, &b.img, kind); + map_ca(&mut b.img, |c, a| { + let o = [0, 1, 2].map(|k| stretch(c[k], ranges[k].0, ranges[k].1).powf(gam[k])); + (lerp3(o, c, blend), a) + }); + b +} + +/// What Auto Levels (`kind` 0), Auto Contrast (1) and Auto Color (2) measure on `img`: each +/// channel's input range (low, high) and gamma. (The GPU effects apply them.) +pub fn auto_correct_settings(ctx: &EffectCtx, img: &Image, kind: u32) -> ([(f32, f32); 3], [f32; 3]) { let black = ctx.params.f("blackClip").clamp(0.0, 49.0) / 100.0; let white = ctx.params.f("whiteClip").clamp(0.0, 49.0) / 100.0; - let blend = (ctx.params.f("blend") / 100.0).clamp(0.0, 1.0) as f32; - let h = smoothed_histograms(ctx, &b.img); + let h = smoothed_histograms(ctx, img); let ranges: [(f32, f32); 3] = if kind == 1 { let mut combined = vec![0.0; BINS]; for k in 0..3 { @@ -350,7 +361,7 @@ fn auto_correct(ctx: &EffectCtx, mut b: Buf, kind: u32) -> Buf { let mut gam = [1.0f32; 3]; if kind == 2 && ctx.params.b("snapNeutralMidtones") { let mut sums = [0.0f64; 4]; - for px in &b.img.data { + for px in &img.data { let (c, a) = unpremul(*px); if a > 1e-4 { for k in 0..3 { @@ -369,11 +380,7 @@ fn auto_correct(ctx: &EffectCtx, mut b: Buf, kind: u32) -> Buf { } } } - map_ca(&mut b.img, |c, a| { - let o = [0, 1, 2].map(|k| stretch(c[k], ranges[k].0, ranges[k].1).powf(gam[k])); - (lerp3(o, c, blend), a) - }); - b + (ranges, gam) } fn auto_levels(ctx: &EffectCtx, b: Buf) -> Buf { @@ -397,24 +404,12 @@ fn cdf(h: &[f64]) -> Vec { .collect() } -fn equalize(ctx: &EffectCtx, mut b: Buf) -> Buf { - let amt = (ctx.params.f("amount") / 100.0).clamp(0.0, 1.0) as f32; - if amt <= 0.0 { - return b; - } - let style = ctx.params.e("style"); - let h = histograms(&b.img); - let look = |c: &Vec, v: f32| c[((v.clamp(0.0, 1.0) * (BINS - 1) as f32).round() as usize).min(BINS - 1)]; +/// Equalize's lookup tables (`BINS` entries each) for `img`: the R, G and B cumulative +/// histograms (Style RGB), the luminance one (Brightness) or the combined one (Photoshop Style). +pub fn equalize_tables(img: &Image, style: u32) -> Vec> { + let h = histograms(img); match style { - 1 => { - let c = cdf(&h[3]); - map_ca(&mut b.img, |col, a| { - let l = luminance(col[0], col[1], col[2]); - let nl = look(&c, l); - let o = if l > 1e-5 { col.map(|v| v * nl / l) } else { [nl; 3] }; - (lerp3(col, o, amt), a) - }); - } + 1 => vec![cdf(&h[3])], 2 => { let mut combined = vec![0.0; BINS]; for k in 0..3 { @@ -422,12 +417,34 @@ fn equalize(ctx: &EffectCtx, mut b: Buf) -> Buf { combined[i] += h[k][i]; } } - let c = cdf(&combined); - map_ca(&mut b.img, |col, a| (lerp3(col, col.map(|v| look(&c, v)), amt), a)); + vec![cdf(&combined)] + } + _ => vec![cdf(&h[0]), cdf(&h[1]), cdf(&h[2])], + } +} + +fn equalize(ctx: &EffectCtx, mut b: Buf) -> Buf { + let amt = (ctx.params.f("amount") / 100.0).clamp(0.0, 1.0) as f32; + if amt <= 0.0 { + return b; + } + let style = ctx.params.e("style"); + let t = equalize_tables(&b.img, style); + let look = |c: &Vec, v: f32| c[((v.clamp(0.0, 1.0) * (BINS - 1) as f32).round() as usize).min(BINS - 1)]; + match style { + 1 => { + map_ca(&mut b.img, |col, a| { + let l = luminance(col[0], col[1], col[2]); + let nl = look(&t[0], l); + let o = if l > 1e-5 { col.map(|v| v * nl / l) } else { [nl; 3] }; + (lerp3(col, o, amt), a) + }); + } + 2 => { + map_ca(&mut b.img, |col, a| (lerp3(col, col.map(|v| look(&t[0], v)), amt), a)); } _ => { - let cs = [cdf(&h[0]), cdf(&h[1]), cdf(&h[2])]; - map_ca(&mut b.img, |col, a| (lerp3(col, [0, 1, 2].map(|k| look(&cs[k], col[k])), amt), a)); + map_ca(&mut b.img, |col, a| (lerp3(col, [0, 1, 2].map(|k| look(&t[k], col[k])), amt), a)); } } b diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index ebb44d9eb..af99ec6fa 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -67,9 +67,10 @@ use std::sync::OnceLock; pub use card3d::{CompLight, CompScene}; pub use color_fx::{ - HUESAT_CHANNELS, LEVELS_CHANNELS, exposure_settings, fill_uses_masks, huesat_ranges_identity, levels_channel_ids, levels_channels_identity, levels_clip, + HUESAT_CHANNELS, HUESAT_RANGES, LEVELS_CHANNELS, PHOTO_FILTER_CUSTOM, PHOTO_FILTERS, exposure_settings, fill_uses_masks, huesat_ranges_identity, + levels_channel_ids, levels_channel_settings, levels_channels_identity, levels_clip, }; -pub use color2::Curve; +pub use color2::{Curve, auto_correct_settings, equalize_tables}; pub use distort::transform_shutter; pub use distort2::parse_mesh; use effectcraft_keyframe::Value; @@ -831,6 +832,24 @@ pub const GPU_EFFECTS: &[&str] = &[ "ec.channel.calculations", "ec.channel.compoundarithmetic", // + // effectcraft-gpu fx_tone (colour correction) + "ec.color.levelsic", + "ec.color.gammapedestalgain", + "ec.color.photofilter", + "ec.color.changecolor", + "ec.color.changetocolor", + "ec.color.leavecolor", + "ec.color.broadcast", + "ec.color.colorbalancehls", + "ec.color.videolimiter", + "ec.color.psarbitrarymap", + "ec.color.cctoner", + "ec.color.cccoloroffset", + "ec.color.cckernel", + "ec.color.autolevels", + "ec.color.autocontrast", + "ec.color.autocolor", + "ec.color.equalize", // effectcraft-gpu fx_stylize (stylize, distort) // // effectcraft-gpu fx_noise (noise, blur, time) diff --git a/crates/gpu/src/context.rs b/crates/gpu/src/context.rs index 77f79be9d..7bc7860af 100644 --- a/crates/gpu/src/context.rs +++ b/crates/gpu/src/context.rs @@ -154,6 +154,7 @@ impl GpuContext { include_str!("shaders/fx_key.wgsl"), include_str!("shaders/fx_stylize.wgsl"), include_str!("shaders/fx_noise.wgsl"), + include_str!("shaders/fx_tone.wgsl"), ] .concat(); let module = @@ -210,6 +211,7 @@ impl GpuContext { .chain(crate::fx_key::KERNELS) .chain(crate::fx_stylize::KERNELS) .chain(crate::fx_noise::KERNELS) + .chain(crate::fx_tone::KERNELS) .map(|e| (e, &layout)) .chain(EXT_ENTRIES.iter().map(|e| (e, &layout_ext))) .map(|(e, layout)| { diff --git a/crates/gpu/src/effects.rs b/crates/gpu/src/effects.rs index 8169164a4..423391106 100644 --- a/crates/gpu/src/effects.rs +++ b/crates/gpu/src/effects.rs @@ -117,6 +117,8 @@ fn apply(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option { "ec.perspective.dropshadow" => drop_shadow(e, ctx, b), "ec.distort.transform" => transform(e, ctx, b), "ec.color.curves" => curves(e, ctx, b), + "ec.color.huesaturation" if !effectcraft_effects::huesat_ranges_identity(ctx) => crate::fx_tone::hue_saturation(e, ctx, b), + "ec.color.levels" if !effectcraft_effects::levels_channels_identity(ctx) => crate::fx_tone::levels(e, ctx, b), _ if id.starts_with("ec.control.") => Some(b), _ if crate::fx_color::IDS.contains(&id) => crate::fx_color::apply(e, id, ctx, b), _ if crate::fx_distort::IDS.contains(&id) => crate::fx_distort::apply(e, id, ctx, b), @@ -124,6 +126,7 @@ fn apply(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option { _ if crate::fx_key::IDS.contains(&id) => crate::fx_key::apply(e, id, ctx, b), _ if crate::fx_stylize::IDS.contains(&id) => crate::fx_stylize::apply(e, id, ctx, b), _ if crate::fx_noise::IDS.contains(&id) => crate::fx_noise::apply(e, id, ctx, b), + _ if crate::fx_tone::IDS.contains(&id) => crate::fx_tone::apply(e, id, ctx, b), _ => pointwise(e, id, ctx, b), } } diff --git a/crates/gpu/src/fx_tone.rs b/crates/gpu/src/fx_tone.rs new file mode 100644 index 000000000..91b681b2b --- /dev/null +++ b/crates/gpu/src/fx_tone.rs @@ -0,0 +1,299 @@ +//! GPU effects, tonal and colour correction family (kernels in `shaders/fx_tone.wgsl`, every +//! entry point prefixed `fxt_`): one per-pixel pass each (`fxt_point`) with the CPU effect's +//! exact maths. +//! +//! Levels (Individual Controls), Gamma/Pedestal/Gain, Photo Filter, Change Color, Change to +//! Color, Leave Color, Broadcast Colors, Color Balance (HLS), Video Limiter, PS Arbitrary Map, +//! CC Toner, CC Color Offset and CC Kernel, plus Hue/Saturation's colour ranges and Levels' +//! per-channel controls (the parts the base kernels in `kernels.wgsl` leave to the CPU). Auto +//! Levels / Contrast / Color and Equalize measure the frame's histograms on the CPU (the image +//! is read back for that) and apply the correction on the GPU. + +use effectcraft_color::rgb_to_hsl; +use effectcraft_effects::EffectCtx; + +use crate::context::{Enc, Params}; +use crate::effects::GBuf; + +/// Compute entry points in `fx_tone.wgsl`. +pub(crate) const KERNELS: &[&str] = &["fxt_point"]; + +/// Effect ids implemented here. +pub(crate) const IDS: &[&str] = &[ + "ec.color.levelsic", + "ec.color.gammapedestalgain", + "ec.color.photofilter", + "ec.color.changecolor", + "ec.color.changetocolor", + "ec.color.leavecolor", + "ec.color.broadcast", + "ec.color.colorbalancehls", + "ec.color.videolimiter", + "ec.color.psarbitrarymap", + "ec.color.cctoner", + "ec.color.cccoloroffset", + "ec.color.cckernel", + "ec.color.autolevels", + "ec.color.autocontrast", + "ec.color.autocolor", + "ec.color.equalize", +]; + +/// Run effect `id` (one of [`IDS`]); `None` = this parameter combination runs on the CPU. +pub(crate) fn apply(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option { + point(e, id, ctx, b) +} + +fn rgb(c: [f32; 4]) -> [f32; 4] { + [c[0], c[1], c[2], 0.0] +} + +/// Hue/Saturation with colour ranges (Channel Control); Master and Colorize alone run in the +/// base `pointwise` kernel. +pub(crate) fn hue_saturation(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let pr = ctx.params; + let f = |id: String, d: f64| pr.get(&id).map(|v| v.as_f64()).unwrap_or(d) as f32; + let mut data = vec![]; + for (r, _, c) in effectcraft_effects::HUESAT_RANGES { + let range = [ + f(format!("{r}RangeStart"), c - 15.0), + f(format!("{r}RangeEnd"), c + 15.0), + f(format!("{r}StartFalloff"), 30.0).max(0.0), + f(format!("{r}EndFalloff"), 30.0).max(0.0), + f(format!("{r}Hue"), 0.0) / 360.0, + f(format!("{r}Saturation"), 0.0) / 100.0, + f(format!("{r}Lightness"), 0.0) / 100.0, + ]; + if range[4] != 0.0 || range[5] != 0.0 || range[6] != 0.0 { + data.extend(range); + } + } + let g = |k: &str| pr.f(k) as f32; + let mut p = Params::default(); + p.u[0] = [14, pr.b("colorize") as u32, (data.len() / 7) as u32, 0]; + p.f[0] = [g("hue") / 360.0, g("saturation") / 100.0, g("lightness") / 100.0, 0.0]; + p.f[1] = [g("colorizeHue") / 360.0, g("colorizeSaturation") / 100.0, g("colorizeLightness") / 100.0, 0.0]; + run(e, &p, b, Some(data)) +} + +/// Levels with Red / Green / Blue / Alpha controls (the RGB controls alone run in the base +/// `pointwise` kernel). +pub(crate) fn levels(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let pr = ctx.params; + let (cb, cw) = effectcraft_effects::levels_clip(ctx); + let mut data = vec![ + pr.f("inBlack") as f32, + pr.f("inWhite") as f32, + pr.f("gamma").max(0.01) as f32, + pr.f("outBlack") as f32, + pr.f("outWhite") as f32, + cb as u32 as f32, + cw as u32 as f32, + ]; + for (v, (cb, cw)) in effectcraft_effects::levels_channel_settings(ctx) { + data.extend(v); + data.extend([cb as u32 as f32, cw as u32 as f32]); + } + run(e, &op(15), b, Some(data)) +} + +fn op(code: u32) -> Params { + let mut p = Params::default(); + p.u[0][0] = code; + p +} + +fn run(e: &mut Enc, p: &Params, b: GBuf, data: Option>) -> Option { + let buf = data.map(|d| e.data(&d)); + let out = e.image(b.img.width, b.img.height); + e.pixels("fxt_point", p, &b.img, None, &out, buf.as_ref()); + Some(GBuf { img: out, ..b }) +} + +/// color3::parse_map. +fn parse_map(s: &str) -> Vec { + let vals: Vec = s.split(|ch: char| ch == ',' || ch.is_whitespace()).filter(|t| !t.is_empty()).filter_map(|t| t.parse::().ok()).collect(); + if vals.len() < 2 { + return (0..256).map(|i| i as f32 / 255.0).collect(); + } + let max = vals.iter().cloned().fold(0.0f32, f32::max); + let k = if max > 1.0 { 1.0 / 255.0 } else { 1.0 }; + vals.iter().map(|v| (v * k).clamp(0.0, 1.0)).collect() +} + +fn point(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option { + let pr = ctx.params; + let f = |k: &str| pr.f(k) as f32; + let mut p = Params::default(); + let mut data = None; + match id { + "ec.color.levelsic" => { + const IDS: [[&str; 5]; 5] = [ + ["rgb/rgbInBlack", "rgb/rgbInWhite", "rgb/rgbGamma", "rgb/rgbOutBlack", "rgb/rgbOutWhite"], + ["red/redInBlack", "red/redInWhite", "red/redGamma", "red/redOutBlack", "red/redOutWhite"], + ["green/greenInBlack", "green/greenInWhite", "green/greenGamma", "green/greenOutBlack", "green/greenOutWhite"], + ["blue/blueInBlack", "blue/blueInWhite", "blue/blueGamma", "blue/blueOutBlack", "blue/blueOutWhite"], + ["alpha/alphaInBlack", "alpha/alphaInWhite", "alpha/alphaGamma", "alpha/alphaOutBlack", "alpha/alphaOutWhite"], + ]; + let lv: Vec<[f32; 5]> = IDS.iter().map(|ids| ids.map(f)).collect(); + let ident = |l: &[f32; 5]| *l == [0.0, 1.0, 1.0, 0.0, 1.0]; + if lv.iter().all(ident) { + return Some(b); + } + let (cb, cw) = effectcraft_effects::levels_clip(ctx); + let mut d = vec![]; + for l in &lv { + d.extend(l); + d.push(ident(l) as u32 as f32); + } + p.u[0] = [1, cb as u32, cw as u32, 0]; + data = Some(d); + } + "ec.color.gammapedestalgain" => { + let ch = |n: &str| [f(&format!("{n}Gamma")).max(0.01), f(&format!("{n}Pedestal")), f(&format!("{n}Gain")), 0.0]; + p.u[0][0] = 2; + p.f[0] = ch("red"); + p.f[1] = ch("green"); + p.f[2] = ch("blue"); + p.f[3][0] = f("blackStretch").max(0.0); + } + "ec.color.photofilter" => { + let fi = pr.e("filter"); + let c = match effectcraft_effects::PHOTO_FILTERS.get(fi as usize) { + Some((_, rgb)) if fi != effectcraft_effects::PHOTO_FILTER_CUSTOM => rgb.map(|v| v as f32 / 255.0), + _ => { + let c = pr.color("color"); + [c[0], c[1], c[2]] + } + }; + p.u[0] = [3, pr.b("preserveLuminosity") as u32, 0, 0]; + p.f[0] = [c[0], c[1], c[2], f("density") / 100.0]; + } + "ec.color.changetocolor" => { + let from = pr.color("from"); + let to = pr.color("to"); + let (fh, fs, fl) = rgb_to_hsl(from[0], from[1], from[2]); + let (th, ts, tl) = rgb_to_hsl(to[0], to[1], to[2]); + let change = pr.e("change"); + let flags = matches!(change, 1 | 3) as u32 + | (matches!(change, 2 | 3) as u32) << 1 + | ((pr.e("changeBy") == 1) as u32) << 2 + | (pr.b("viewCorrectionMatte") as u32) << 3; + p.u[0] = [4, flags, 0, 0]; + p.f[0] = [fh, fs, fl, f("softness") / 100.0]; + p.f[1] = [th, ts, tl, 0.0]; + p.f[2] = [f("toleranceGroup/hue") / 100.0, f("toleranceGroup/lightness") / 100.0, f("toleranceGroup/saturation") / 100.0, 0.0]; + } + "ec.color.leavecolor" => { + let amt = f("amount") / 100.0; + if amt <= 0.0 { + return Some(b); + } + let k = pr.color("color"); + p.u[0] = [5, (pr.e("matchColors") == 1) as u32, 0, 0]; + p.f[0] = [k[0], k[1], k[2], rgb_to_hsl(k[0], k[1], k[2]).0]; + p.f[1] = [amt, f("tolerance") / 100.0, f("edgeSoftness") / 100.0, 0.0]; + } + "ec.color.changecolor" => { + let view_mask = pr.e("view") == 1; + let (dh, dl, ds) = (f("hueTransform") / 360.0, f("lightnessTransform") / 100.0, f("saturationTransform") / 100.0); + if !view_mask && dh == 0.0 && dl == 0.0 && ds == 0.0 { + return Some(b); + } + let k = pr.color("colorToChange"); + p.u[0] = [6, pr.e("matchColors"), view_mask as u32 | (pr.b("invertColorCorrectionMask") as u32) << 1, 0]; + p.f[0] = [k[0], k[1], k[2], rgb_to_hsl(k[0], k[1], k[2]).0]; + p.f[1] = [dh, dl, ds, 0.0]; + p.f[2] = [f("matchingTolerance") / 100.0, f("matchingSoftness") / 100.0, 0.0, 0.0]; + } + "ec.color.broadcast" => { + let pal = pr.e("locale") == 1; + let (setup, span) = if pal { (0.0, 100.0) } else { (7.5, 92.5) }; + p.u[0] = [7, pal as u32, pr.e("howToMakeSafe"), 0]; + p.f[0][0] = (f("maxSignal") - setup) / span; + } + "ec.color.colorbalancehls" => { + let (dh, dl, ds) = (f("hue") / 360.0, f("lightness") / 100.0, f("saturation") / 100.0); + if dh == 0.0 && dl == 0.0 && ds == 0.0 { + return Some(b); + } + p.u[0][0] = 8; + p.f[0] = [dh, dl, ds, 0.0]; + } + "ec.color.cctoner" => { + let g = |id: &str| rgb(pr.color(id)); + let stops = match pr.e("tones") { + 0 => vec![g("shadows"), g("highlights")], + 2 => vec![g("shadows"), g("darktones"), g("midtones"), g("brights"), g("highlights")], + _ => vec![g("shadows"), g("midtones"), g("highlights")], + }; + p.u[0] = [9, stops.len() as u32 - 1, 0, 0]; + p.f[0][0] = (f("blend") / 100.0).clamp(0.0, 1.0); + for (i, s) in stops.iter().enumerate() { + p.f[1 + i] = *s; + } + } + "ec.color.cccoloroffset" => { + let offs = ["redPhase", "greenPhase", "bluePhase"].map(|id| (pr.f(id) / 360.0).rem_euclid(1.0) as f32); + if offs.iter().all(|o| *o == 0.0) { + return Some(b); + } + p.u[0] = [10, pr.e("overflow"), 0, 0]; + p.f[0] = [offs[0], offs[1], offs[2], 0.0]; + } + "ec.color.cckernel" => { + let k = ["k1", "k2", "k3", "k4", "k5", "k6", "k7", "k8", "k9"].map(f); + let (scale, offset) = (f("scale"), f("offset")); + let identity = k.iter().enumerate().all(|(i, v)| if i == 4 { *v == 1.0 } else { *v == 0.0 }); + if identity && scale == 1.0 && offset == 0.0 { + return Some(b); + } + p.u[0] = [11, pr.b("absolute") as u32, 0, 0]; + p.f[0] = [k[0], k[1], k[2], k[3]]; + p.f[1] = [k[4], k[5], k[6], k[7]]; + p.f[2] = [k[8], scale, offset, 0.0]; + } + "ec.color.psarbitrarymap" => { + let map = parse_map(pr.s("map")); + p.u[0] = [12, pr.b("applyPhaseMapToAlpha") as u32, map.len() as u32, 0]; + p.f[0][0] = (pr.f("phase") * (map.len() - 1) as f64 / 255.0) as f32; + data = Some(map); + } + "ec.color.videolimiter" => { + const CLIP_LEVELS: [f32; 5] = [0.9, 0.95, 1.0, 1.05, 1.09]; + let level = CLIP_LEVELS[(pr.e("clipLevel") as usize).min(4)]; + let comp = [0.0, 0.03, 0.05, 0.1, 0.2][(pr.e("compressionBeforeClipping") as usize).min(4)]; + p.u[0] = [13, pr.e("clipMethod"), pr.b("gamutWarning") as u32, 0]; + p.f[0] = [level, level * (1.0 - comp), 0.0, 0.0]; + p.f[1] = rgb(pr.color("gamutWarningColor")); + } + "ec.color.autolevels" | "ec.color.autocontrast" | "ec.color.autocolor" | "ec.color.equalize" => { + // The histograms are measured on the CPU. + let img = e.download(&b.img)?; + if id == "ec.color.equalize" { + let amt = (pr.f("amount") / 100.0).clamp(0.0, 1.0) as f32; + if amt <= 0.0 { + return Some(b); + } + let style = pr.e("style"); + let tables = effectcraft_effects::equalize_tables(&img, style); + p.u[0] = [17, style, tables.first().map_or(0, |t| t.len()) as u32, 0]; + p.f[0][0] = amt; + data = Some(tables.concat()); + } else { + let kind = match id { + "ec.color.autolevels" => 0, + "ec.color.autocontrast" => 1, + _ => 2, + }; + let (ranges, gam) = effectcraft_effects::auto_correct_settings(ctx, &img, kind); + p.u[0][0] = 16; + p.f[0] = [ranges[0].0, ranges[1].0, ranges[2].0, 0.0]; + p.f[1] = [ranges[0].1, ranges[1].1, ranges[2].1, 0.0]; + p.f[2] = [gam[0], gam[1], gam[2], (pr.f("blend") / 100.0).clamp(0.0, 1.0) as f32]; + } + } + _ => return None, + } + run(e, &p, b, data) +} diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index 63816e98a..6955cf355 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -37,6 +37,7 @@ mod fx_generate; mod fx_key; mod fx_noise; mod fx_stylize; +mod fx_tone; mod ops; mod particles; mod walk; @@ -182,4 +183,6 @@ mod tests_fx_noise; #[cfg(test)] mod tests_fx_stylize; #[cfg(test)] +mod tests_fx_tone; +#[cfg(test)] mod tests_particles; diff --git a/crates/gpu/src/shaders/fx_tone.wgsl b/crates/gpu/src/shaders/fx_tone.wgsl new file mode 100644 index 000000000..35bd00f2d --- /dev/null +++ b/crates/gpu/src/shaders/fx_tone.wgsl @@ -0,0 +1,592 @@ +// GPU effects (tonal and colour correction family): see src/fx_tone.rs. Each operation mirrors +// the CPU effect in effectcraft-effects operation for operation. Every name here is prefixed +// `fxt_` (the family files share one module). + +fn fxt_straight(px: vec4) -> vec3 { + if (px.w > 1e-6) { + return px.xyz / px.w; + } + return vec3(0.0); +} + +fn fxt_mix(a: vec3, b: vec3, t: f32) -> vec3 { + return a + (b - a) * t; +} + +// f32::rem_euclid(360.0). +fn fxt_rem360(x: f32) -> f32 { + let r = x - 360.0 * floor(x / 360.0); + return select(r, 0.0, r >= 360.0); +} + +// Levels (Individual Controls) channel `i` (data: 5 × (ib, iw, g, ob, ow, identity)). +fn fxt_lv(i: u32, v: f32, clip_b: bool, clip_w: bool) -> f32 { + let o = i * 6u; + if (data[o + 5u] != 0.0) { + return v; + } + var t = (v - data[o]) / max(data[o + 1u] - data[o], 1e-6); + if (clip_b) { + t = max(t, 0.0); + } + if (clip_w) { + t = min(t, 1.0); + } + return data[o + 3u] + (data[o + 4u] - data[o + 3u]) * powz(max(t, 0.0), 1.0 / max(data[o + 2u], 0.01)); +} + +// color_fx::level (data offset o: ib, iw, g, ob, ow, clip black, clip white). +fn fxt_level(o: u32, v: f32) -> f32 { + var t = (v - data[o]) / max(data[o + 1u] - data[o], 1e-6); + if (data[o + 5u] != 0.0) { + t = max(t, 0.0); + } + if (data[o + 6u] != 0.0) { + t = min(t, 1.0); + } + t = powz(max(t, 0.0), 1.0 / data[o + 2u]); + return data[o + 3u] + (data[o + 4u] - data[o + 3u]) * t; +} + +fn fxt_level_ident(o: u32) -> bool { + return data[o] == 0.0 && data[o + 1u] == 1.0 && data[o + 2u] == 1.0 && data[o + 3u] == 0.0 && data[o + 4u] == 1.0; +} + +fn fxt_adjust_sat(s: f32, sat: f32) -> f32 { + if (sat >= 0.0) { + return s + (1.0 - s) * sat * min(s, 1.0); + } + return s * (1.0 + sat); +} + +fn fxt_adjust_light(l: f32, light: f32) -> f32 { + if (light >= 0.0) { + return l + (1.0 - l) * light; + } + return l * (1.0 + light); +} + +// HueRange::weight for range `r` (data: start, end, fall start, fall end, hue, sat, light). +fn fxt_range_weight(r: u32, h: f32) -> f32 { + let o = r * 7u; + let width = fxt_rem360(data[o + 1u] - data[o]); + let d = fxt_rem360(h - data[o]); + if (d <= width) { + return 1.0; + } + let after = d - width; + let before = 360.0 - d; + var wa = 0.0; + if (data[o + 3u] > 0.0) { + wa = 1.0 - after / data[o + 3u]; + } + var wb = 0.0; + if (data[o + 2u] > 0.0) { + wb = 1.0 - before / data[o + 2u]; + } + return clamp(max(wa, wb), 0.0, 1.0); +} + +// color3::colour_match. +fn fxt_colour_match(c: vec3, key: vec3, key_hue: f32, mode: u32, tol: f32, soft: f32) -> f32 { + var d: f32; + if (mode == 0u) { + d = sqrt(dot(c - key, c - key)) / sqrt(3.0); + } else if (mode == 1u) { + let hs = rgb_to_hsl(c); + let dh0 = abs(hs.x - key_hue); + let dh = min(dh0, 1.0 - dh0) * 2.0; + d = select(dh, 1.0, hs.y < 0.02); + } else { + let sa = max(c.x + c.y + c.z, 1e-6); + let sk = max(key.x + key.y + key.z, 1e-6); + let a = vec2(c.x / sa, c.y / sa); + let k = vec2(key.x / sk, key.y / sk); + d = sqrt(dot(a - k, a - k)) * 1.5; + } + if (d <= tol) { + return 1.0; + } + if (soft <= 1e-6) { + return 0.0; + } + return clamp(1.0 - (d - tol) / soft, 0.0, 1.0); +} + +// color3::knee. +fn fxt_knee(v: f32, start: f32, limit: f32) -> f32 { + if (v <= start || limit <= start) { + return min(v, limit); + } + let r = limit - start; + return start + r * (1.0 - exp(-(v - start) / r)); +} + +// PS Arbitrary Map lookup (map in data, n entries; f[0].x = phase offset in entries). +fn fxt_lookup(v: f32, n: u32) -> f32 { + let nf = f32(n); + var x = clamp(v, 0.0, 1.0) * (nf - 1.0) + P.f[0].x; + x = x - nf * floor(x / nf); + let fl = floor(x); + let i0 = u32(fl) % n; + let i1 = select(i0, i0 + 1u, i0 + 1u < n); + let t = x - fl; + return data[i0] + (data[i1] - data[i0]) * t; +} + +fn fxt_eq_look(table: u32, n: u32, v: f32) -> f32 { + let i = min(u32(round_away(clamp(v, 0.0, 1.0) * f32(n - 1u))), n - 1u); + return data[table * n + i]; +} + +// Per-pixel colour corrections. u[0].x = op (layouts in src/fx_tone.rs): +// 1 Levels (Individual Controls) 2 Gamma/Pedestal/Gain 3 Photo Filter 4 Change to Color +// 5 Leave Color 6 Change Color 7 Broadcast Colors 8 Color Balance (HLS) 9 CC Toner +// 10 CC Color Offset 11 CC Kernel 12 PS Arbitrary Map 13 Video Limiter +// 14 Hue/Saturation with colour ranges 15 Levels with channel controls +// 16 Auto Levels / Contrast / Color apply 17 Equalize apply +@compute @workgroup_size(16, 16) +fn fxt_point(@builtin(global_invocation_id) gid: vec3) { + let dims = out_dims(); + let p = vec2(gid.xy); + if (p.x >= dims.x || p.y >= dims.y) { + return; + } + let px = textureLoad(src, p, 0); + let a = px.w; + let u0 = P.u[0]; + let f0 = P.f[0]; + // Image::map_straight: transparent pixels stay as they are. + let straight_skip = a <= 0.0; + let c = fxt_straight(px); + var o = px; + switch u0.x { + case 1u: { + let cb = u0.y != 0u; + let cw = u0.z != 0u; + // color2::map_ca. + if (a <= 0.0 && px.x == 0.0 && px.y == 0.0 && px.z == 0.0) { + let na = clamp(fxt_lv(4u, 0.0, cb, cw), 0.0, 1.0); + if (na > 0.0) { + o = vec4(0.0, 0.0, 0.0, clamp(na, 0.0, 1.0)); + } + } else { + let aa = max(a, 0.0); + var nc: vec3; + for (var i = 0u; i < 3u; i++) { + nc[i] = fxt_lv(i + 1u, fxt_lv(0u, c[i], cb, cw), cb, cw); + } + let na = clamp(clamp(fxt_lv(4u, aa, cb, cw), 0.0, 1.0), 0.0, 1.0); + o = vec4(nc * na, na); + } + } + case 2u: { + if (straight_skip) { + break; + } + let stretch = P.f[3].x; + var r: vec3; + for (var i = 0; i < 3; i++) { + var v = c[i]; + if (stretch > 0.0 && v > 0.0 && v < 1.0) { + let q = 1.0 - v; + v = v + stretch * 0.25 * v * (q * q * q * q); + } + let g = P.f[i]; + r[i] = max(g.y + (g.z - g.y) * powz(max(v, 0.0), g.x), 0.0); + } + o = vec4(r * a, a); + } + case 3u: { + if (straight_skip) { + break; + } + var r = fxt_mix(c, c * f0.xyz, f0.w); + if (u0.y != 0u) { + let l0 = luminance(c); + let l1 = max(luminance(r), 1e-6); + r = r * l0 / l1; + } + o = vec4(r * a, a); + } + case 4u: { + if (straight_skip) { + break; + } + let fr = f0.xyz; + let tc = P.f[1].xyz; + let tol = P.f[2].xyz; + let soft = f0.w; + let hsl = rgb_to_hsl(c); + let d = vec3(abs(fract_euclid(hsl.x - fr.x + 0.5) - 0.5) * 2.0, abs(hsl.z - fr.z), abs(hsl.y - fr.y)); + var k = 1.0; + for (var i = 0; i < 3; i++) { + let over = d[i] - tol[i]; + if (over > 0.0) { + k = min(k, select(clamp(1.0 - over / soft, 0.0, 1.0), 0.0, soft <= 1e-6)); + } + } + let ch_l = (u0.y & 1u) != 0u; + let ch_s = (u0.y & 2u) != 0u; + var r = c; + if ((u0.y & 8u) != 0u) { + r = vec3(k); + } else if (k > 0.0) { + var nh: f32; + var ns = hsl.y; + var nl = hsl.z; + if ((u0.y & 4u) != 0u) { + let dh = (fract_euclid(tc.x - fr.x + 0.5) - 0.5) * k; + nh = fract_euclid(hsl.x + dh); + if (ch_s) { + ns = hsl.y + (tc.y - fr.y) * k; + } + if (ch_l) { + nl = hsl.z + (tc.z - fr.z) * k; + } + } else { + let dh = (fract_euclid(tc.x - hsl.x + 0.5) - 0.5) * k; + nh = fract_euclid(hsl.x + dh); + if (ch_s) { + ns = hsl.y + (tc.y - hsl.y) * k; + } + if (ch_l) { + nl = hsl.z + (tc.z - hsl.z) * k; + } + } + r = hsl_to_rgb(nh, clamp(ns, 0.0, 1.0), clamp(nl, 0.0, 1.0)); + } + o = vec4(r * a, a); + } + case 5u: { + if (straight_skip) { + break; + } + let keep = f0.xyz; + let amt = P.f[1].x; + let tol = P.f[1].y; + let soft = P.f[1].z; + var d: f32; + if (u0.y != 0u) { + let hsl = rgb_to_hsl(c); + d = select(abs(fract_euclid(hsl.x - f0.w + 0.5) - 0.5) * 2.0, 1.0, hsl.y < 0.02); + } else { + d = sqrt(dot(c - keep, c - keep)) / sqrt(3.0); + } + var keepk: f32; + if (tol >= 1.0 || d <= tol) { + keepk = 1.0; + } else if (soft <= 1e-6) { + keepk = 0.0; + } else { + keepk = clamp(1.0 - (d - tol) / soft, 0.0, 1.0); + } + let g = luminance(c); + let r = fxt_mix(c, vec3(g), amt * (1.0 - keepk)); + o = vec4(r * a, a); + } + case 6u: { + if (a <= 0.0) { + break; + } + var m = fxt_colour_match(c, f0.xyz, f0.w, u0.y, P.f[2].x, P.f[2].y); + if ((u0.z & 2u) != 0u) { + m = 1.0 - m; + } + var r = c; + if ((u0.z & 1u) != 0u) { + r = vec3(m); + } else if (m > 0.0) { + let hsl = rgb_to_hsl(c); + let dh = P.f[1].x; + let dl = P.f[1].y; + let ds = P.f[1].z; + let h2 = fract_euclid(hsl.x + dh); + let s2 = clamp(hsl.y + ds * select(hsl.y, 1.0 - hsl.y, ds > 0.0), 0.0, 1.0); + let l2 = clamp(hsl.z + dl * select(hsl.z, 1.0 - hsl.z, dl > 0.0), 0.0, 1.0); + r = fxt_mix(c, hsl_to_rgb(h2, s2, l2), m); + } + o = vec4(r * a, a); + } + case 7u: { + if (a <= 0.0) { + break; + } + let pal = u0.y != 0u; + let y = 0.299 * c.x + 0.587 * c.y + 0.114 * c.z; + var ch: f32; + if (pal) { + let uu = 0.492 * (c.z - y); + let vv = 0.877 * (c.x - y); + ch = sqrt(uu * uu + vv * vv); + } else { + let ii = 0.596 * c.x - 0.274 * c.y - 0.322 * c.z; + let qq = 0.211 * c.x - 0.523 * c.y + 0.312 * c.z; + ch = sqrt(ii * ii + qq * qq); + } + let max_norm = f0.x; + let unsafe_px = y + ch > max_norm + 1e-5; + switch u0.z { + case 2u: { + if (unsafe_px) { + o = vec4(0.0); + } + } + case 3u: { + if (!unsafe_px) { + o = vec4(0.0); + } + } + case 1u: { + if (unsafe_px) { + let target_v = max(max_norm - y, 0.0); + let s = select(1.0, target_v / ch, ch > 1e-6); + var r = y + (c - y) * s; + if (y > max_norm) { + r = r * max_norm / y; + } + o = vec4(r * a, a); + } + } + default: { + if (unsafe_px) { + let k = max_norm / (y + ch); + o = vec4(c * k * a, a); + } + } + } + } + case 8u: { + if (straight_skip) { + break; + } + let hsl = rgb_to_hsl(c); + let dh = f0.x; + let dl = f0.y; + let ds = f0.z; + var l = hsl.z; + if (dl > 0.0) { + l = l + (1.0 - l) * dl; + } else { + l = l + l * dl; + } + var s = hsl.y; + if (ds > 0.0) { + s = s + (1.0 - s) * ds; + } else { + s = s + s * ds; + } + let r = hsl_to_rgb(hsl.x + dh, clamp(s, 0.0, 1.0), clamp(l, 0.0, 1.0)); + o = vec4(r * a, a); + } + case 9u: { + if (straight_skip) { + break; + } + let n = u0.y; + let t = clamp(luminance(c), 0.0, 1.0) * f32(n); + let k = min(u32(floor(t)), n - 1u); + let tone = fxt_mix(P.f[1u + k].xyz, P.f[2u + k].xyz, t - f32(k)); + let r = fxt_mix(tone, c, f0.x); + o = vec4(r * a, a); + } + case 10u: { + if (straight_skip) { + break; + } + var r = c; + for (var k = 0; k < 3; k++) { + if (f0[k] == 0.0) { + continue; + } + let x = c[k] + f0[k]; + if (x <= 1.0) { + r[k] = x; + } else if (u0.y == 1u) { + r[k] = max(2.0 - x, 0.0); + } else if (u0.y == 2u) { + r[k] = select(1.0, 0.0, x - 1.0 < 0.5); + } else { + r[k] = fract_euclid(x); + } + } + o = vec4(r * a, a); + } + case 11u: { + if (a <= 0.0) { + break; + } + var acc = vec3(0.0); + for (var j = 0; j < 3; j++) { + for (var i = 0; i < 3; i++) { + let idx = j * 3 + i; + let w = P.f[idx / 4][idx % 4]; + if (w == 0.0) { + continue; + } + let q = tex_get_clamped(src, p.x + i - 1, p.y + j - 1); + acc += fxt_straight(q) * w; + } + } + var v = acc * P.f[2].y + P.f[2].z; + if (u0.y != 0u) { + v = abs(v); + } + o = vec4(max(v, vec3(0.0)) * a, a); + } + case 12u: { + let n = u0.z; + let aa = max(a, 0.0); + let r = vec3(fxt_lookup(c.x, n), fxt_lookup(c.y, n), fxt_lookup(c.z, n)); + var na = aa; + if (u0.y != 0u) { + na = fxt_lookup(aa, n); + } + o = vec4(r * na, na); + } + case 13u: { + if (a <= 0.0) { + break; + } + let level = f0.x; + let start = f0.y; + let y = luminance(c); + var r = c; + if (u0.y != 1u) { + let y2 = fxt_knee(max(y, 0.0), start, level); + r = r - y + y2; + } + if (u0.y != 0u) { + let yl = luminance(r); + var t = 1.0; + for (var i = 0; i < 3; i++) { + let v = r[i]; + if (v > level && v - yl > 1e-6) { + t = min(t, (level - yl) / (v - yl)); + } + if (v < 0.0 && yl - v > 1e-6) { + t = min(t, yl / (yl - v)); + } + } + t = clamp(t, 0.0, 1.0); + r = yl + (r - yl) * t; + } + r = clamp(r, vec3(0.0), vec3(level)); + if (u0.z != 0u && any(abs(c - r) > vec3(1e-4))) { + r = P.f[1].xyz; + } + o = vec4(r * a, a); + } + case 14u: { + if (straight_skip) { + break; + } + let hsl = rgb_to_hsl(c); + var h = hsl.x; + var s = hsl.y; + var l = hsl.z; + if (u0.y != 0u) { + let cl = P.f[1].z; + h = P.f[1].x; + s = P.f[1].y; + l = clamp(l + cl * select(l, 1.0 - l, cl > 0.0), 0.0, 1.0); + } else { + var s2 = fxt_adjust_sat(s, f0.y); + var l2 = fxt_adjust_light(l, f0.z); + var h2 = h + f0.x; + for (var r = 0u; r < u0.z; r++) { + let w = fxt_range_weight(r, hsl.x * 360.0) * min(hsl.y * 20.0, 1.0); + if (w > 0.0) { + let ro = r * 7u; + h2 += data[ro + 4u] * w; + s2 = fxt_adjust_sat(clamp(s2, 0.0, 1.0), data[ro + 5u] * w); + l2 = fxt_adjust_light(l2, data[ro + 6u] * w); + } + } + h = fract_euclid(h2); + s = clamp(s2, 0.0, 1.0); + l = l2; + } + o = vec4(hsl_to_rgb(h, s, l) * a, a); + } + case 15u: { + var r = c; + if (!straight_skip) { + for (var i = 0u; i < 3u; i++) { + r[i] = fxt_level(0u, c[i]); + } + } + // The master pass writes premultiplied values; the channel pass reads them back. + var q = px; + if (!straight_skip) { + q = vec4(r * a, a); + } + let qc = fxt_straight(q); + let qa = max(q.w, 0.0); + var any_ch = false; + for (var k = 0u; k < 4u; k++) { + if (!fxt_level_ident(7u + 7u * k)) { + any_ch = true; + } + } + if (!any_ch) { + o = q; + break; + } + var oc = qc; + for (var i = 0u; i < 3u; i++) { + let off = 7u + 7u * i; + if (!fxt_level_ident(off)) { + oc[i] = fxt_level(off, qc[i]); + } + } + var na = qa; + if (!fxt_level_ident(28u)) { + na = clamp(fxt_level(28u, qa), 0.0, 1.0); + } + o = vec4(oc * na, na); + } + case 16u: { + if (a <= 0.0 && px.x == 0.0 && px.y == 0.0 && px.z == 0.0) { + break; + } + let lo = f0.xyz; + let hi = P.f[1].xyz; + let g = P.f[2]; + var r: vec3; + for (var k = 0; k < 3; k++) { + let st = clamp((c[k] - lo[k]) / (hi[k] - lo[k]), 0.0, 1.0); + r[k] = powz(st, g[k]); + } + r = fxt_mix(r, c, g.w); + let na = clamp(max(a, 0.0), 0.0, 1.0); + o = vec4(r * na, na); + } + case 17u: { + if (a <= 0.0 && px.x == 0.0 && px.y == 0.0 && px.z == 0.0) { + break; + } + let n = u0.z; + let amt = f0.x; + var r: vec3; + if (u0.y == 1u) { + let l = luminance(c); + let nl = fxt_eq_look(0u, n, l); + if (l > 1e-5) { + r = c * nl / l; + } else { + r = vec3(nl); + } + } else if (u0.y == 2u) { + r = vec3(fxt_eq_look(0u, n, c.x), fxt_eq_look(0u, n, c.y), fxt_eq_look(0u, n, c.z)); + } else { + r = vec3(fxt_eq_look(0u, n, c.x), fxt_eq_look(1u, n, c.y), fxt_eq_look(2u, n, c.z)); + } + r = fxt_mix(c, r, amt); + let na = clamp(max(a, 0.0), 0.0, 1.0); + o = vec4(r * na, na); + } + default: {} + } + textureStore(out, p, o); +} diff --git a/crates/gpu/src/tests_fx_tone.rs b/crates/gpu/src/tests_fx_tone.rs new file mode 100644 index 000000000..5958e470a --- /dev/null +++ b/crates/gpu/src/tests_fx_tone.rs @@ -0,0 +1,117 @@ +//! GPU colour-correction family effects vs the CPU effects (the oracle): direct on a buffer at +//! full and half resolution, as adjustment, and composited at 8 and 32 bpc. + +use effectcraft_keyframe::Value; + +use crate::tests::{c, effect_case, n}; + +fn e(v: u32) -> Value { + Value::Enum(v) +} + +fn on() -> Value { + Value::Bool(true) +} + +fn off() -> Value { + Value::Bool(false) +} + +#[test] +fn levels_and_gamma() { + effect_case( + "ec.color.levelsic", + &[("rgb/rgbInBlack", n(0.1)), ("rgb/rgbGamma", n(1.3)), ("red/redOutWhite", n(0.8)), ("blue/blueInWhite", n(0.7)), ("alpha/alphaGamma", n(0.7))], + ); + effect_case("ec.color.levelsic", &[("green/greenInBlack", n(0.2)), ("green/greenInWhite", n(0.6)), ("clipToOutputWhite", e(0))]); + effect_case("ec.color.levelsic", &[("alpha/alphaOutBlack", n(0.3))]); + effect_case("ec.color.levels", &[("redInBlack", n(0.1)), ("greenGamma", n(1.5)), ("blueOutWhite", n(0.7)), ("inWhite", n(0.9))]); + effect_case("ec.color.levels", &[("alphaInWhite", n(0.6)), ("redInWhite", n(0.5)), ("redClipToOutputWhite", e(0))]); + effect_case("ec.color.gammapedestalgain", &[("redGamma", n(1.4)), ("greenPedestal", n(0.1)), ("blueGain", n(0.8))]); + effect_case("ec.color.gammapedestalgain", &[("blackStretch", n(2.0)), ("redGain", n(1.2))]); +} + +#[test] +fn hue_saturation_ranges() { + effect_case("ec.color.huesaturation", &[("redsHue", n(40.0)), ("bluesSaturation", n(-60.0)), ("greensLightness", n(30.0))]); + effect_case( + "ec.color.huesaturation", + &[ + ("hue", n(20.0)), + ("saturation", n(15.0)), + ("yellowsSaturation", n(50.0)), + ("yellowsRangeStart", n(30.0)), + ("yellowsStartFalloff", n(8.0)), + ("cyansLightness", n(-40.0)), + ], + ); +} + +#[test] +fn colour_replacement() { + effect_case("ec.color.photofilter", &[]); + effect_case( + "ec.color.photofilter", + &[("filter", e(effectcraft_effects::PHOTO_FILTER_CUSTOM)), ("color", c(0.2, 0.7, 0.4)), ("density", n(60.0)), ("preserveLuminosity", off())], + ); + effect_case("ec.color.changetocolor", &[("from", c(0.8, 0.4, 0.3)), ("to", c(0.2, 0.5, 0.9)), ("toleranceGroup/hue", n(15.0))]); + effect_case( + "ec.color.changetocolor", + &[("from", c(0.6, 0.6, 0.3)), ("to", c(0.9, 0.2, 0.6)), ("change", e(3)), ("changeBy", e(1)), ("toleranceGroup/hue", n(25.0)), ("softness", n(30.0))], + ); + effect_case("ec.color.changetocolor", &[("from", c(0.6, 0.6, 0.3)), ("change", e(2)), ("toleranceGroup/hue", n(25.0)), ("viewCorrectionMatte", on())]); + effect_case("ec.color.leavecolor", &[("amount", n(80.0)), ("color", c(0.7, 0.5, 0.4)), ("tolerance", n(10.0)), ("edgeSoftness", n(20.0))]); + effect_case( + "ec.color.leavecolor", + &[("amount", n(100.0)), ("color", c(0.8, 0.4, 0.3)), ("matchColors", e(1)), ("tolerance", n(5.0)), ("edgeSoftness", n(30.0))], + ); + effect_case("ec.color.changecolor", &[("hueTransform", n(90.0)), ("colorToChange", c(0.7, 0.5, 0.4)), ("matchingSoftness", n(20.0))]); + effect_case( + "ec.color.changecolor", + &[ + ("lightnessTransform", n(-30.0)), + ("saturationTransform", n(40.0)), + ("matchColors", e(0)), + ("matchingTolerance", n(20.0)), + ("matchingSoftness", n(15.0)), + ], + ); + effect_case("ec.color.changecolor", &[("view", e(1)), ("matchColors", e(2)), ("matchingSoftness", n(25.0)), ("invertColorCorrectionMask", on())]); +} + +#[test] +fn broadcast_balance_limiter() { + effect_case("ec.color.broadcast", &[("maxSignal", n(95.0))]); + effect_case("ec.color.broadcast", &[("locale", e(1)), ("howToMakeSafe", e(1)), ("maxSignal", n(90.0))]); + effect_case("ec.color.colorbalancehls", &[("hue", n(40.0)), ("lightness", n(-20.0)), ("saturation", n(30.0))]); + effect_case("ec.color.colorbalancehls", &[("lightness", n(25.0)), ("saturation", n(-50.0))]); + effect_case("ec.color.videolimiter", &[("clipLevel", e(0))]); + effect_case("ec.color.videolimiter", &[("clipLevel", e(1)), ("clipMethod", e(1)), ("compressionBeforeClipping", e(4))]); + effect_case("ec.color.videolimiter", &[("clipLevel", e(0)), ("clipMethod", e(0)), ("gamutWarning", on())]); +} + +#[test] +fn cc_colour_and_arbitrary_map() { + effect_case("ec.color.cctoner", &[]); + effect_case("ec.color.cctoner", &[("tones", e(2)), ("blend", n(30.0))]); + effect_case("ec.color.cctoner", &[("tones", e(0)), ("shadows", c(0.1, 0.0, 0.3)), ("highlights", c(1.0, 0.9, 0.6))]); + effect_case("ec.color.cccoloroffset", &[("redPhase", n(90.0)), ("bluePhase", n(200.0))]); + effect_case("ec.color.cccoloroffset", &[("greenPhase", n(120.0)), ("overflow", e(1))]); + effect_case("ec.color.cckernel", &[("k1", n(-1.0)), ("k5", n(5.0)), ("k9", n(-1.0)), ("scale", n(0.5)), ("offset", n(0.05))]); + effect_case("ec.color.cckernel", &[("k2", n(1.0)), ("k5", n(0.0)), ("k8", n(-1.0)), ("absolute", on())]); + let map: String = (0..256).map(|i| format!("{}", ((i as f64 / 255.0).powf(0.6) * 255.0).round())).collect::>().join(","); + effect_case("ec.color.psarbitrarymap", &[("map", Value::Str(map.clone()))]); + effect_case("ec.color.psarbitrarymap", &[("map", Value::Str(map)), ("phase", n(40.0)), ("applyPhaseMapToAlpha", on())]); +} + +#[test] +fn automatic_corrections() { + effect_case("ec.color.autolevels", &[]); + effect_case("ec.color.autolevels", &[("blackClip", n(2.0)), ("whiteClip", n(3.0)), ("blend", n(20.0))]); + effect_case("ec.color.autocontrast", &[("blackClip", n(1.0))]); + effect_case("ec.color.autocolor", &[]); + effect_case("ec.color.autocolor", &[("snapNeutralMidtones", on()), ("whiteClip", n(2.0))]); + effect_case("ec.color.equalize", &[]); + effect_case("ec.color.equalize", &[("style", e(1)), ("amount", n(70.0))]); + effect_case("ec.color.equalize", &[("style", e(2))]); +}