From a81c98d33cd5fb86448b3f1c3c282e84f24c6957 Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Sun, 11 Oct 2026 14:31:10 +1100 Subject: [PATCH] Match After Effects non-legacy Brightness & Contrast. The modern curve used g = 2^(2c), so Contrast 100 crushed 0.25 to about 8/255 while AE is about 40/255. Brightness is now a pin-black-and-white power toward white or black, then a milder S-curve g = 1 + 0.53 c, fitted to kasper9n's AE grey ramps. Legacy linear mode is unchanged. The GPU kernel matches the CPU. --- crates/effects/src/color_fx.rs | 48 +++++++++++++++++++++++++---- crates/gpu/src/shaders/kernels.wgsl | 23 +++++++++----- 2 files changed, 57 insertions(+), 14 deletions(-) diff --git a/crates/effects/src/color_fx.rs b/crates/effects/src/color_fx.rs index 0a6663a5..e39902d7 100644 --- a/crates/effects/src/color_fx.rs +++ b/crates/effects/src/color_fx.rs @@ -43,16 +43,27 @@ fn tritone(ctx: &EffectCtx, mut b: Buf) -> Buf { b } -/// Brightness & Contrast without Use Legacy: tone curves that keep black and white fixed (no -/// clipping). Brightness bends the curve with a power (`b` in -1.5..1.5), contrast is an S -/// curve around mid grey (`c` in -1..1). Values above 1 pass through. The GPU kernel mirrors it. +/// Brightness & Contrast without Use Legacy: pin black and white, then a mild S-curve. +/// +/// Fitted to After Effects non-legacy grey ramps (#681, kasper9n oracles). Brightness is a +/// power toward white (`1-(1-v)^e`) or black (`v^e`); contrast uses `g = 1 + 0.53 c` instead of +/// the old `2^(2c)` (that crushed 0.25 to ~8/255 at Contrast 100; AE is ~40/255). Values above 1 +/// pass through. The GPU kernel mirrors it. pub fn bc_modern(v: f32, b: f32, c: f32) -> f32 { if v >= 1.0 { return v; } - let v = v.max(0.0).powf(2f32.powf(-b)); - let g = 2f32.powf(2.0 * c); - if v < 0.5 { 0.5 * (2.0 * v).powf(g) } else { 1.0 - 0.5 * (2.0 - 2.0 * v).max(0.0).powf(g) } + let mut v = v.max(0.0); + if b > 0.0 { + v = 1.0 - (1.0 - v).powf(2f32.powf(1.24 * b)); + } else if b < 0.0 { + v = v.powf(2f32.powf(-1.32 * b)); + } + if c != 0.0 { + let g = (1.0 + 0.53 * c).max(0.05); + v = if v < 0.5 { 0.5 * (2.0 * v).powf(g) } else { 1.0 - 0.5 * (2.0 - 2.0 * v).max(0.0).powf(g) }; + } + v } fn brightness_contrast(ctx: &EffectCtx, mut b: Buf) -> Buf { @@ -1181,6 +1192,31 @@ mod tests { assert!((bc(&legacy, 0.0) - 0.5).abs() < 1e-5); } + /// After Effects non-legacy ramps (kasper9n, #681). B0 C-100 matched B0 C-50 and is omitted. + #[test] + fn brightness_contrast_modern_matches_ae_ramp_samples() { + let u8 = |v: f32| (v * 255.0).round() as i32; + let go = |b: f64, c: f64, x: u8| u8(px("ec.color.brightnesscontrast", &[("brightness", num(b)), ("contrast", num(c))], [x as f32 / 255.0, x as f32 / 255.0, x as f32 / 255.0, 1.0])[0]); + // (brightness, contrast, input, AE 8-bit) + let samples: &[(f64, f64, u8, u8)] = &[ + (0.0, 100.0, 64, 40), + (0.0, 100.0, 128, 129), + (0.0, 100.0, 192, 217), + (0.0, 50.0, 64, 53), + (0.0, 50.0, 192, 205), + (0.0, -50.0, 64, 77), + (100.0, 0.0, 128, 209), + (50.0, 0.0, 128, 171), + (106.0, -18.0, 128, 214), + ]; + for &(b, c, x, ae) in samples { + let got = go(b, c, x); + assert!((got - ae as i32).abs() <= 10, "B{b} C{c} in {x}: got {got} AE {ae}"); + } + // Old g=4 S-curve put Contrast 100 at ~8/255 here. + assert!(go(0.0, 100.0, 64) >= 30); + } + #[test] fn levels_clips_black_and_white_independently() { let lv = |vals: &[(&str, Value)], v: f32| px("ec.color.levels", vals, [v, v, v, 1.0])[0]; diff --git a/crates/gpu/src/shaders/kernels.wgsl b/crates/gpu/src/shaders/kernels.wgsl index 1e1ad167..15ace4fa 100644 --- a/crates/gpu/src/shaders/kernels.wgsl +++ b/crates/gpu/src/shaders/kernels.wgsl @@ -621,18 +621,25 @@ fn pointwise(@builtin(global_invocation_id) gid: vec3) { if (P.u[0].y == 1u) { px = vec4(max((c + br - 0.5) * k + 0.5, vec3(0.0)) * a, a); } else if (P.u[0].y == 2u) { - // Brightness & Contrast tone curves (effects crate bc_modern). + // Brightness & Contrast tone curves (effects crate bc_modern, AE-fitted #681). var o = c; - let pb = exp2(-br); - let g = exp2(2.0 * ct); + let g = max(1.0 + 0.53 * ct, 0.05); for (var i = 0; i < 3; i++) { if (c[i] < 1.0) { - let v = powz(max(c[i], 0.0), pb); - if (v < 0.5) { - o[i] = 0.5 * powz(2.0 * v, g); - } else { - o[i] = 1.0 - 0.5 * powz(max(2.0 - 2.0 * v, 0.0), g); + var v = max(c[i], 0.0); + if (br > 0.0) { + v = 1.0 - powz(1.0 - v, exp2(1.24 * br)); + } else if (br < 0.0) { + v = powz(v, exp2(-1.32 * br)); } + if (ct != 0.0) { + if (v < 0.5) { + v = 0.5 * powz(2.0 * v, g); + } else { + v = 1.0 - 0.5 * powz(max(2.0 - 2.0 * v, 0.0), g); + } + } + o[i] = v; } } px = vec4(o * a, a);