HDR Highlight Compression: match After Effects' measured transfer curve (#3123)

The effect used a soft knee on the brightest channel and left everything
below the knee unchanged. After Effects compresses the whole range. Fitting
our own 16-bit renders of an HDR ramp and coloured patches (Amount 0, 84,
90, 100) gives:

  Y    = max(0.30 R + 0.59 G + 0.11 B, 0)
  comp = min(c / (1 + Y), 1)                    per channel
  out  = ((1 - t) c^0.4 + t comp^0.4)^(1/0.4)   t = Amount / 100

A grey value x maps to x / (1 + x) at Amount 100. The worst error against AE
drops from 985 to about 2-4 of 65535 at Amounts 100, 90 and 84. Amount 0
stays identical.

CPU and GPU change together. Tests: a reference table of measured values,
GPU parity at Amount 90, and over-range input that exercises the clip at 1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
vangel76
2026-11-05 17:02:26 +01:00
co-authored by Claude Opus 5.5
parent 1786c24523
commit b9182972b2
4 changed files with 101 additions and 29 deletions
+71 -20
View File
@@ -55,29 +55,39 @@ fn overbrights(ctx: &EffectCtx, mut b: Buf) -> Buf {
b
}
/// Soft-knee, hue-preserving compression of the brightest channel into [0, 1).
fn hdr_compress_value(m: f32, knee: f32) -> f32 {
if m <= knee {
return m;
}
let w = 1.0 - knee;
knee + w * (m - knee) / ((m - knee) + w)
/// Luma weights HDR Highlight Compression divides by (measured against After Effects 2026).
const HDR_LUMA: [f32; 3] = [0.30, 0.59, 0.11];
/// Exponent of the domain the Amount blend happens in (out^0.4 is mixed linearly).
const HDR_BLEND_GAMMA: f32 = 0.4;
#[inline]
fn hdr_spow(v: f32, e: f32) -> f32 {
v.signum() * v.abs().powf(e)
}
/// One straight-colour pixel through HDR Highlight Compression at blend `t` (Amount / 100, > 0).
///
/// Measured behaviour (16-bit renders of a 0–4 HDR ramp and coloured patches at Amount 100,
/// 90 and 84): every channel is divided by `1 + Y` (`Y` = 0.30 R + 0.59 G + 0.11 B, so a grey
/// value `x` lands on `x / (1 + x)`), clipped at 1, then mixed with the input as `x^0.4` values.
fn hdr_compress_pixel(c: [f32; 3], t: f32) -> [f32; 3] {
let y = (HDR_LUMA[0] * c[0] + HDR_LUMA[1] * c[1] + HDR_LUMA[2] * c[2]).max(0.0);
let s = 1.0 / (1.0 + y);
let g = HDR_BLEND_GAMMA;
c.map(|x| {
let comp = (x * s).min(1.0);
let mixed = (1.0 - t) * hdr_spow(x, g) + t * hdr_spow(comp, g);
let o = hdr_spow(mixed, 1.0 / g);
if o.is_finite() { o } else { x }
})
}
fn hdr_compression(ctx: &EffectCtx, mut b: Buf) -> Buf {
let t = (ctx.params.f("amount") / 100.0).clamp(0.0, 1.0) as f32;
if t <= 0.0 {
if t <= 0.0 || !t.is_finite() {
return b;
}
let knee = 1.0 - 0.5 * t;
b.img.map_straight(|c| {
let m = c[0].max(c[1]).max(c[2]);
if m <= knee {
return c;
}
let k = hdr_compress_value(m, knee) / m;
c.map(|v| v * k)
});
b.img.map_straight(|c| hdr_compress_pixel(c, t));
b
}
@@ -575,10 +585,51 @@ mod tests {
let mut img = Image::new(2, 1);
img.set(0, 0, [0.2, 0.3, 0.4, 1.0]);
img.set(1, 0, [4.0, 2.0, 1.0, 1.0]);
let out = run("ec.utility.hdrcompression", &[("amount", num(0.0))], img.clone());
assert_eq!(out.img.data, img.data, "Amount 0 leaves the image alone");
let out = run("ec.utility.hdrcompression", &[], img);
assert_eq!(out.img.data[0], [0.2, 0.3, 0.4, 1.0]);
let o = out.img.data[1];
assert!(o[0] <= 1.0 && o[0] > 0.5 && (o[0] / o[1] - 2.0).abs() < 1e-4, "{o:?}");
for o in &out.img.data {
assert!(o.iter().all(|v| v.is_finite() && *v <= 1.0), "{o:?}");
}
// Negative and huge input stays finite.
let mut img = Image::new(2, 1);
img.set(0, 0, [-0.5, 0.2, 3.0, 1.0]);
img.set(1, 0, [1e30, -1e30, 0.0, 1.0]);
let out = run("ec.utility.hdrcompression", &[("amount", num(50.0))], img);
assert!(out.img.data.iter().flatten().all(|v| v.is_finite()), "{:?}", out.img.data);
}
/// Our own measurements of After Effects 2026 (32 bpc, no working space; 16-bit PNG renders
/// decoded back to linear): input pixel, then the output at Amount 100, 90 and 84.
#[test]
fn hdr_compression_matches_measured_reference() {
#[rustfmt::skip]
let table: [([f32; 3], [[f32; 3]; 3]); 9] = [
([0.18005371; 3], [[0.1524; 3], [0.1550; 3], [0.1566; 3]]),
([0.49365234; 3], [[0.3302; 3], [0.3450; 3], [0.3540; 3]]),
([1.0058594; 3], [[0.5014; 3], [0.5427; 3], [0.5683; 3]]),
([2.0058594; 3], [[0.6673; 3], [0.7637; 3], [0.8256; 3]]),
([2.9941406; 3], [[0.7496; 3], [0.8962; 3], [0.9922; 3]]),
([3.9941406; 3], [[0.7998; 3], [0.9929; 3], [1.1213; 3]]),
([0.7998047, 0.099975586, 0.099975586], [[0.6101, 0.0762, 0.0762], [0.6279, 0.0784, 0.0784], [0.6386, 0.0798, 0.0798]]),
([0.099975586, 0.7001953, 0.19995117], [[0.0681, 0.4776, 0.1363], [0.0711, 0.4976, 0.1420], [0.0727, 0.5097, 0.1456]]),
([0.30004883, 0.5, 2.5], [[0.1807, 0.3012, 0.9998], [0.1911, 0.3184, 1.1147], [0.1972, 0.3288, 1.1860]]),
];
let mut img = Image::new(table.len() as u32, 1);
for (x, (c, _)) in table.iter().enumerate() {
img.set(x as u32, 0, [c[0], c[1], c[2], 1.0]);
}
for (i, amount) in [100.0, 90.0, 84.0].into_iter().enumerate() {
let out = run("ec.utility.hdrcompression", &[("amount", num(amount))], img.clone());
for (x, (input, want)) in table.iter().enumerate() {
let got = out.img.data[x];
for c in 0..3 {
let w = want[i][c];
let tol = 2e-3_f32.max(w * 2e-3);
assert!((got[c] - w).abs() <= tol, "amount {amount}, input {input:?}, channel {c}: {} vs AE {w}", got[c]);
}
}
}
}
#[test]
+1 -1
View File
@@ -242,7 +242,7 @@ fn point(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
return Some(b);
}
p.u[0][0] = 2;
p.f[0][0] = 1.0 - 0.5 * t;
p.f[0][0] = t;
}
"ec.utility.ccoverbrights" => {
p.u[0] = [3, pr.e("channel"), 0, 0];
+12 -8
View File
@@ -336,17 +336,21 @@ fn fp2_point(@builtin(global_invocation_id) gid: vec3<u32>) {
o = vec4<f32>(r * px.w, px.w);
}
}
// f[0].x = knee.
// utility::hdr_compress_pixel; f[0].x = blend t (Amount / 100).
case 2u: {
if (px.w > 0.0) {
let c = px.xyz / px.w;
let m = max(max(c.x, c.y), c.z);
let knee = P.f[0].x;
if (m > knee) {
let w = 1.0 - knee;
let k = (knee + w * (m - knee) / ((m - knee) + w)) / m;
o = vec4<f32>(c * k * px.w, px.w);
}
let t = P.f[0].x;
let y = max(0.30 * c.x + 0.59 * c.y + 0.11 * c.z, 0.0);
let comp = min(c * (1.0 / (1.0 + y)), vec3<f32>(1.0));
let g = 0.4;
let mixed = vec3<f32>(
(1.0 - t) * fp2_spow(c.x, g) + t * fp2_spow(comp.x, g),
(1.0 - t) * fp2_spow(c.y, g) + t * fp2_spow(comp.y, g),
(1.0 - t) * fp2_spow(c.z, g) + t * fp2_spow(comp.z, g),
);
let r = vec3<f32>(fp2_spow(mixed.x, 1.0 / g), fp2_spow(mixed.y, 1.0 / g), fp2_spow(mixed.z, 1.0 / g));
o = vec4<f32>(r * px.w, px.w);
}
}
// u[0].y = channel; f[0] = highlight colour.
+17
View File
@@ -184,6 +184,7 @@ fn composites_and_colour() {
effect_case("ec.utility.hdrcompander", &[("mode", e(1)), ("gain", n(3.0)), ("gamma", n(1.5))]);
effect_case("ec.utility.hdrcompression", &[]);
effect_case("ec.utility.hdrcompression", &[("amount", n(40.0))]);
effect_case("ec.utility.hdrcompression", &[("amount", n(90.0))]);
effect_case("ec.utility.growbounds", &[("pixels", n(9.0))]);
effect_case("ec.utility.ccoverbrights", &[]);
for ch in 1..=5 {
@@ -191,6 +192,22 @@ fn composites_and_colour() {
}
}
/// HDR Highlight Compression on over-range input (HDR Compander expanding first), where the
/// compressed channel clips at 1 before the Amount blend.
#[test]
fn hdr_compression_over_range() {
for amount in [100.0, 60.0] {
let mut s = Scene::new(BitDepth::Bpc32);
let bg = s.solid([0.15, 0.1, 0.2], 97, 61);
s.push(bg);
let mut l = s.footage(70, 44);
s.effect(&mut l, "ec.utility.hdrcompander", &[("mode", e(1)), ("gain", n(4.0)), ("gamma", n(1.0))]);
s.effect(&mut l, "ec.utility.hdrcompression", &[("amount", n(amount))]);
s.push(l);
check(&format!("hdr compression over range {amount}"), compare_at(&s, opts(), Tick::ZERO), 0.0);
}
}
#[test]
fn stylize() {
let _flips = allow_llvmpipe_boundary_flips();