From b9182972b2101c9ed86b63c620fc7c51b569bd87 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Thu, 5 Nov 2026 17:02:26 +0100 Subject: [PATCH] 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 --- crates/effects/src/utility.rs | 91 +++++++++++++++++++++------ crates/gpu/src/fx_pixel2.rs | 2 +- crates/gpu/src/shaders/fx_pixel2.wgsl | 20 +++--- crates/gpu/src/tests/fx_pixel2.rs | 17 +++++ 4 files changed, 101 insertions(+), 29 deletions(-) diff --git a/crates/effects/src/utility.rs b/crates/effects/src/utility.rs index e41b42e99..2448733b0 100644 --- a/crates/effects/src/utility.rs +++ b/crates/effects/src/utility.rs @@ -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] diff --git a/crates/gpu/src/fx_pixel2.rs b/crates/gpu/src/fx_pixel2.rs index a62cbaff0..1fe1fec72 100644 --- a/crates/gpu/src/fx_pixel2.rs +++ b/crates/gpu/src/fx_pixel2.rs @@ -242,7 +242,7 @@ fn point(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option { 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]; diff --git a/crates/gpu/src/shaders/fx_pixel2.wgsl b/crates/gpu/src/shaders/fx_pixel2.wgsl index 583225299..2944f03b2 100644 --- a/crates/gpu/src/shaders/fx_pixel2.wgsl +++ b/crates/gpu/src/shaders/fx_pixel2.wgsl @@ -336,17 +336,21 @@ fn fp2_point(@builtin(global_invocation_id) gid: vec3) { o = vec4(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(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(1.0)); + let g = 0.4; + let mixed = vec3( + (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(fp2_spow(mixed.x, 1.0 / g), fp2_spow(mixed.y, 1.0 / g), fp2_spow(mixed.z, 1.0 / g)); + o = vec4(r * px.w, px.w); } } // u[0].y = channel; f[0] = highlight colour. diff --git a/crates/gpu/src/tests/fx_pixel2.rs b/crates/gpu/src/tests/fx_pixel2.rs index 4a7886afe..9660592e7 100644 --- a/crates/gpu/src/tests/fx_pixel2.rs +++ b/crates/gpu/src/tests/fx_pixel2.rs @@ -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();