mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-24 10:09:32 -06:00
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:
co-authored by
Claude Opus 5.5
parent
70c1349cbc
commit
c7060e2d75
@@ -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];
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user