From b9182972b2101c9ed86b63c620fc7c51b569bd87 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Thu, 5 Nov 2026 17:02:26 +0100 Subject: [PATCH 1/4] 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(); From af7044d7db041fefeb913fc989776def6174ddb0 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Thu, 5 Nov 2026 17:39:00 +0100 Subject: [PATCH 2/4] HDR Highlight Compression: check the curve at Amount 25, 50 and 75 (#3123) New After Effects 2026 renders of the same EXR at Amount 25, 50 and 75 (our own 1110-extra sample, 16-bit PNGs) confirm the transfer curve that was fitted at 84, 90 and 100. The worst channel error over all pixels is 4.0, 5.5 and 4.3 of 65535, against 997, 2415 and 3867 for the old soft knee. The code is unchanged. The measured-reference test now covers all six Amounts. Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/utility.rs | 36 ++++++++++++++++++++++------------- 1 file changed, 23 insertions(+), 13 deletions(-) diff --git a/crates/effects/src/utility.rs b/crates/effects/src/utility.rs index 2448733b0..65de35cf6 100644 --- a/crates/effects/src/utility.rs +++ b/crates/effects/src/utility.rs @@ -68,7 +68,7 @@ fn hdr_spow(v: f32, e: f32) -> f32 { /// 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 +/// 90, 84, 75, 50 and 25): 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); @@ -600,26 +600,36 @@ mod tests { } /// 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. + /// decoded back to linear): input pixel, then the output at Amount 100, 90, 84, 75, 50 and 25. #[test] fn hdr_compression_matches_measured_reference() { + const AMOUNTS: [f64; 6] = [100.0, 90.0, 84.0, 75.0, 50.0, 25.0]; #[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 table: [([f32; 3], [[f32; 3]; 6]); 9] = [ + ([0.18005371; 3], [[0.1524; 3], [0.1550; 3], [0.1566; 3], [0.1590; 3], [0.1660; 3], [0.1729; 3]]), + ([0.49365234; 3], [[0.3302; 3], [0.3450; 3], [0.3540; 3], [0.3674; 3], [0.4073; 3], [0.4492; 3]]), + ([1.0058594; 3], [[0.5014; 3], [0.5427; 3], [0.5683; 3], [0.6085; 3], [0.7280; 3], [0.8602; 3]]), + ([2.0058594; 3], [[0.6673; 3], [0.7637; 3], [0.8256; 3], [0.9232; 3], [1.2292; 3], [1.5892; 3]]), + ([2.9941406; 3], [[0.7496; 3], [0.8962; 3], [0.9922; 3], [1.1465; 3], [1.6475; 3], [2.2604; 3]]), + ([3.9941406; 3], [[0.7998; 3], [0.9929; 3], [1.1213; 3], [1.3303; 3], [2.0312; 3], [2.9141; 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.6547, 0.0817, 0.0817], [0.7012, 0.0877, 0.0877], [0.7496, 0.0937, 0.0937], + ]), + ([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.0754, 0.5287, 0.1508], [0.0832, 0.5829, 0.1662], [0.0912, 0.6397, 0.1825], + ]), + ([0.30004883, 0.5, 2.5], [ + [0.1807, 0.3012, 0.9998], [0.1911, 0.3184, 1.1147], [0.1972, 0.3288, 1.1860], + [0.2071, 0.3453, 1.2995], [0.2359, 0.3931, 1.6484], [0.2669, 0.4444, 2.0482], + ]), ]; 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() { + for (i, amount) in AMOUNTS.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]; From 3a9188e0fb6192e84c141799d25abb48c2ffb5b0 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Thu, 5 Nov 2026 18:11:29 +0100 Subject: [PATCH 3/4] HDR Highlight Compression on the GPU: a NaN Amount keeps the layer, as on the CPU Doc comments on the touched helpers. Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/utility.rs | 3 +++ crates/gpu/src/fx_pixel2.rs | 5 ++++- crates/gpu/src/tests/fx_pixel2.rs | 2 ++ 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/crates/effects/src/utility.rs b/crates/effects/src/utility.rs index 65de35cf6..667b90242 100644 --- a/crates/effects/src/utility.rs +++ b/crates/effects/src/utility.rs @@ -60,6 +60,7 @@ 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; +/// `v^e` with the sign of `v` kept (negative input stays negative, as on the GPU). #[inline] fn hdr_spow(v: f32, e: f32) -> f32 { v.signum() * v.abs().powf(e) @@ -82,6 +83,8 @@ fn hdr_compress_pixel(c: [f32; 3], t: f32) -> [f32; 3] { }) } +/// HDR Highlight Compression: every straight-colour pixel through [`hdr_compress_pixel`] at +/// Amount / 100; Amount 0 or NaN returns the layer unchanged. 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 || !t.is_finite() { diff --git a/crates/gpu/src/fx_pixel2.rs b/crates/gpu/src/fx_pixel2.rs index 1fe1fec72..5b20976f4 100644 --- a/crates/gpu/src/fx_pixel2.rs +++ b/crates/gpu/src/fx_pixel2.rs @@ -215,6 +215,8 @@ fn composite_mode(m: u32) -> BlendMode { } } +/// The per-pixel effects of `fp2_point` (Cineon, HDR Compander and Highlight Compression, CC +/// Overbrights, CC Composite, Color Link): their parameters packed for the kernel, or `None`. fn point(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option { let pr = ctx.params; let mut p = Params::default(); @@ -238,7 +240,8 @@ fn point(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option { } "ec.utility.hdrcompression" => { let t = (pr.f("amount") / 100.0).clamp(0.0, 1.0) as f32; - if t <= 0.0 { + // A NaN Amount keeps the layer, as on the CPU. + if t <= 0.0 || !t.is_finite() { return Some(b); } p.u[0][0] = 2; diff --git a/crates/gpu/src/tests/fx_pixel2.rs b/crates/gpu/src/tests/fx_pixel2.rs index 9660592e7..16f2bd392 100644 --- a/crates/gpu/src/tests/fx_pixel2.rs +++ b/crates/gpu/src/tests/fx_pixel2.rs @@ -185,6 +185,8 @@ fn composites_and_colour() { effect_case("ec.utility.hdrcompression", &[]); effect_case("ec.utility.hdrcompression", &[("amount", n(40.0))]); effect_case("ec.utility.hdrcompression", &[("amount", n(90.0))]); + // A NaN Amount keeps the layer (the CPU returns it unchanged). + effect_case("ec.utility.hdrcompression", &[("amount", n(f64::NAN))]); effect_case("ec.utility.growbounds", &[("pixels", n(9.0))]); effect_case("ec.utility.ccoverbrights", &[]); for ch in 1..=5 { From 0a103c055c93c8a7316017cb043ca4a71cc0f845 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Thu, 5 Nov 2026 19:31:17 +0100 Subject: [PATCH 4/4] HDR Highlight Compression test: 20 PNG codes up to 1, 0.1 % above The reference test now checks the bound the PR states in the code space of the renders (the sRGB curve x 6553.5) instead of a linear 2e-3, which allowed about 28 codes at the dark end. Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/utility.rs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/crates/effects/src/utility.rs b/crates/effects/src/utility.rs index 667b90242..e87c6d3ff 100644 --- a/crates/effects/src/utility.rs +++ b/crates/effects/src/utility.rs @@ -602,6 +602,11 @@ mod tests { assert!(out.img.data.iter().flatten().all(|v| v.is_finite()), "{:?}", out.img.data); } + /// A linear value up to 1 as a code of After Effects' 16-bit PNG renders: the sRGB curve × 6553.5. + fn png_code(x: f32) -> f32 { + (if x <= 0.003_130_8 { 12.92 * x } else { 1.055 * x.powf(1.0 / 2.4) - 0.055 }) * 6553.5 + } + /// 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, 84, 75, 50 and 25. #[test] @@ -638,8 +643,10 @@ mod tests { 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]); + // Up to 1: within 20 codes of the 16-bit PNG (the sRGB curve × 6553.5) the + // renders were read from. Above 1 the PNG's lookup table is not known: 0.1 %. + let ok = if w <= 1.0 { (png_code(got[c]) - png_code(w)).abs() <= 20.0 } else { (got[c] - w).abs() <= w * 1e-3 }; + assert!(ok, "amount {amount}, input {input:?}, channel {c}: {} vs AE {w}", got[c]); } } }