Merge pull request #702 from vbasky/fix/681-brightness-contrast

Match After Effects non-legacy Brightness & Contrast
This commit is contained in:
Brandon Thomas committed 2026-10-12 03:03:02 -04:00
commit 27e095e60e
2 files changed
+57 -14

No files matched your search

+42 -6
View File
@@ -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];
+15 -8
View File
@@ -621,18 +621,25 @@ fn pointwise(@builtin(global_invocation_id) gid: vec3<u32>) {
if (P.u[0].y == 1u) {
px = vec4<f32>(max((c + br - 0.5) * k + 0.5, vec3<f32>(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<f32>(o * a, a);