Exposure: gamma 2.2 linear-light conversion, as After Effects

With Bypass Linear Light Conversion off, Exposure decoded and re-encoded
with the piecewise sRGB curve. After Effects 2026 renders (sRGB working
space, 32 bpc) of Offset 0.0005 / 0.002 / 0.02 / 0.1 on black measure
8 / 15 / 43 / 90, which a pure 2.2 power predicts (8.06 / 15.13 / 43.08 /
89.54) and the sRGB curve does not (1.65 / 6.59 / 38.68 / 89.04).

The effect now uses its own 2.2 power curve on the CPU and in the GPU
kernel; the shared colour functions and other effects are unchanged. As
before, the conversion applies whatever the working space, also a
linearised one. Negative values go through both curves mirrored (input
was clamped at 0 before), so defaults are an identity for 32-bpc values
and a negative Offset darkens past black without NaN.

New test against the AE values; existing Exposure tests still hold.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
vangel76
2026-10-19 16:14:26 +02:00
co-authored by Claude Opus 5.5
parent 70c1349cbc
commit c7060e2d75
3 changed files with 46 additions and 5 deletions
+41 -2
View File
@@ -314,6 +314,17 @@ pub fn exposure_settings(ctx: &EffectCtx) -> [(f32, f32, f32); 3] {
}
}
/// The power curve Exposure's own linear-light conversion uses (Bypass Linear Light Conversion
/// off). After Effects decodes with a pure 2.2 power, not the piecewise sRGB curve: Offset 0.0005
/// on black measures 8/255 there, which 2.2 predicts (8.06) and sRGB does not (1.65).
pub const EXPOSURE_LINEAR_GAMMA: f32 = 2.2;
/// Exposure: per channel, `(value · 2^exposure + offset)^(1 / gamma)`, in linear light unless
/// Bypass Linear Light Conversion is on. The conversion is the effect's own pure 2.2 power curve
/// (see [`EXPOSURE_LINEAR_GAMMA`]), applied whatever the project's working space, also when it
/// is already linear (Linearize Working Space), as before. Negative values (32 bpc, or a
/// negative Offset) go through the curves mirrored, so a negative Offset darkens smoothly past
/// black and the default settings leave every value unchanged.
fn exposure(ctx: &EffectCtx, mut b: Buf) -> Buf {
let s = exposure_settings(ctx);
let bypass = ctx.params.b("bypassLinearLight");
@@ -323,9 +334,9 @@ fn exposure(ctx: &EffectCtx, mut b: Buf) -> Buf {
if bypass {
return mirror_pow(c[i] * e + off, 1.0 / g);
}
let lin = effectcraft_color::srgb_to_linear(c[i].max(0.0));
let lin = mirror_pow(c[i], EXPOSURE_LINEAR_GAMMA);
let o = mirror_pow(lin * e + off, 1.0 / g);
effectcraft_color::linear_to_srgb(o.abs()).copysign(o)
mirror_pow(o, 1.0 / EXPOSURE_LINEAR_GAMMA)
})
});
b
@@ -1406,6 +1417,34 @@ mod tests {
assert!((neg[0] + 0.5).abs() < 1e-4, "{neg:?}");
}
/// Exposure on a red [1, 0, 0] solid against After Effects 2026 renders (sRGB working space,
/// not linearised, 32 bpc, 8-bit captures): the linear-light conversion is a pure 2.2 power.
/// The piecewise sRGB curve gave 2 / 7 / 39 for the three smallest Offsets.
#[test]
fn exposure_matches_after_effects_gamma_2_2() {
let red = [1.0, 0.0, 0.0, 1.0];
let code = |o: [f32; 4], i: usize| o[i] * 255.0;
// Master Offset only: G and B carry the curve; R goes over 1 (255 in the 8-bit capture).
for (offset, ae) in [(0.0005, 8.0), (0.002, 15.0), (0.02, 43.0), (0.1, 90.0)] {
let o = px("ec.color.exposure", &[("master/offset", num(offset))], red);
for i in [1, 2] {
assert!((code(o, i) - ae).abs() <= 1.0, "offset {offset}: {} vs AE {ae}", code(o, i));
}
assert!(o[0] >= 1.0, "{o:?}");
}
// Exposure −2.25: AE 126; with Bypass Linear Light Conversion (gain on encoded values) 54.
let o = px("ec.color.exposure", &[("master/exposure", num(-2.25))], red);
assert!((code(o, 0) - 126.0).abs() <= 1.0 && o[1] == 0.0, "{o:?}");
let o = px("ec.color.exposure", &[("master/exposure", num(-2.25)), ("bypassLinearLight", Value::Bool(true))], red);
assert!((code(o, 0) - 54.0).abs() <= 1.0 && o[1] == 0.0, "{o:?}");
// Default settings leave 32-bpc values alone, negative and over-range ones included.
let hdr = [-0.25, 0.5, 2.0, 1.0];
assert!(close(px("ec.color.exposure", &[], hdr), hdr, 1e-5));
// A negative Offset pushes black below zero, mirrored, rather than clipping or NaN.
let o = px("ec.color.exposure", &[("master/offset", num(-0.02))], [0.0, 0.0, 0.0, 1.0]);
assert!((o[0] + 0.02f32.powf(1.0 / 2.2)).abs() < 1e-5, "{o:?}");
}
#[test]
fn change_to_color_change_modes_tolerances_and_matte() {
let red = [0.8, 0.1, 0.1, 1.0];
+4 -3
View File
@@ -714,14 +714,15 @@ fn pointwise(@builtin(global_invocation_id) gid: vec3<u32>) {
let c = straight(px);
var o = vec3<f32>(0.0);
for (var i = 0; i < 3; i++) {
// Per-channel gain, offset, gamma; negative values are mirrored.
// Per-channel gain, offset, gamma; negative values are mirrored. The
// linear-light conversion is a pure 2.2 power (effects crate exposure).
if (P.u[0].y != 0u) {
let x = c[i] * P.f[0][i] + P.f[1][i];
o[i] = sign(x) * powz(abs(x), 1.0 / P.f[2][i]);
} else {
let lin = srgb_to_linear(max(c[i], 0.0));
let lin = sign(c[i]) * powz(abs(c[i]), 2.2);
let x = lin * P.f[0][i] + P.f[1][i];
o[i] = sign(x) * linear_to_srgb(powz(abs(x), 1.0 / P.f[2][i]));
o[i] = sign(x) * powz(powz(abs(x), 1.0 / P.f[2][i]), 1.0 / 2.2);
}
}
px = vec4<f32>(o * a, a);
+1
View File
@@ -67,6 +67,7 @@ the GPU is performance, not parity.
| 2026-10-19 | minor | Tritone uses Rec. 601 luminance, measured against After Effects 2026 renders of a red solid and a grey ramp (CPU and GPU) |
| 2026-10-19 | minor | Black & White's Tint keeps the gray's luminosity (Color blend), measured against After Effects 2026 renders (CPU and GPU) |
| 2026-10-18 | minor | Tint uses Rec. 601 luminance weights, measured against After Effects 2026 renders of red, green, blue and grey solids (CPU and GPU) |
| 2026-10-18 | minor | Exposure converts to linear light with a pure 2.2 power curve, as measured in After Effects (Offset 0.0005 / 0.002 / 0.02 / 0.1 on black: 8 / 15 / 43 / 90), not the piecewise sRGB curve |
| 2026-10-12 | minor | Parameters 3,014 to 3,017 (effects.md); #303, #514, #526, #533–#536 marked closed instead of pending |
| 2026-10-12 | minor | Effect colour labels (26.5) added in Effect Controls |
| 2026-10-12 | minor | Tint's Swap Colors button and agent-drivable command (#963); undo/redo, instance targeting, animated layer-time edits and invalid-input tests |