Curves: a natural cubic spline through the points, as After Effects

After Effects' Curves effect interpolates its points with a natural cubic
spline (zero curvature at the ends), clamped to 0..1: AE 2026 renders of an
RGB curve (0,0) (125,199) (255,255) and a Red curve (0,0) (128,44)
(255,255) (cases 962 / 963, ROUND4) give exactly the natural spline's values
on every grey step (0, 30, 59, 115, 164, 202, 227, 242, 250, 255 and 0, 0, 1,
6, 19, 44, 83, 135, 194, 255). EffectCraft used a monotone (Fritsch-Carlson)
spline: 34 / 67 / 125 / 171 ... on the RGB curve, up to 10/255 off (ROUND4
measured up to 6 levels on real projects); now within 1/255, CPU and GPU (the
same Hermite tables carry the spline's knot slopes). Lumetri's curves and
Glow's Arbitrary Map keep the monotone spline (not measured).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
vangel76
2026-10-19 16:54:31 +02:00
co-authored by Claude Opus 5.5
parent 84ffc8453e
commit d9205fa5f4
3 changed files with 78 additions and 3 deletions
+75 -1
View File
@@ -55,6 +55,17 @@ impl Curve {
/// Parse "x,y x,y …" (separators: whitespace or ';'). Returns None for an identity curve or
/// fewer than two valid points.
pub fn parse(s: &str) -> Option<Curve> {
Self::parse_with(s, false)
}
/// [`Curve::parse`] interpolated as After Effects' Curves effect does: a natural cubic spline
/// through the points, clamped to 0..1 (AE 2026: RGB points (0,0) (125,199) (255,255) and
/// Red (0,0) (128,44) (255,255) map every grey step exactly as the natural spline does).
pub fn parse_natural(s: &str) -> Option<Curve> {
Self::parse_with(s, true)
}
fn parse_with(s: &str, natural: bool) -> Option<Curve> {
let mut pts: Vec<(f32, f32)> = s
.split(|c: char| c.is_whitespace() || c == ';')
.filter(|t| !t.is_empty())
@@ -74,6 +85,12 @@ impl Curve {
let ys: Vec<f32> = pts.iter().map(|p| p.1).collect();
let n = xs.len();
let d: Vec<f32> = (0..n - 1).map(|k| (ys[k + 1] - ys[k]) / (xs[k + 1] - xs[k])).collect();
if natural {
let ms = natural_slopes(&xs, &d);
let mut c = Curve { xs, ys, ms, lut: Vec::new() };
c.lut = (0..=LUT_N).map(|i| c.eval_exact(i as f32 / LUT_N as f32).clamp(0.0, 1.0)).collect();
return Some(c);
}
let mut ms = vec![0.0f32; n];
ms[0] = d[0];
ms[n - 1] = d[n - 2];
@@ -136,6 +153,40 @@ impl Curve {
}
}
/// Knot slopes of the natural cubic spline (zero curvature at both ends) through points with
/// abscissae `xs` and segment slopes `d`; as Hermite tangents they reproduce the spline exactly.
fn natural_slopes(xs: &[f32], d: &[f32]) -> Vec<f32> {
let n = xs.len();
if n < 3 {
return vec![d.first().copied().unwrap_or(0.0); n];
}
let h: Vec<f64> = (0..n - 1).map(|k| f64::from(xs[k + 1] - xs[k])).collect();
let dd: Vec<f64> = d.iter().map(|&v| f64::from(v)).collect();
// Second derivatives M[1..n-1] (M[0] = M[n-1] = 0): tridiagonal system, Thomas algorithm.
let m = n - 2;
let (mut a, mut b, mut c, mut r) = (vec![0.0; m], vec![0.0; m], vec![0.0; m], vec![0.0; m]);
for i in 0..m {
let k = i + 1;
a[i] = h[k - 1];
b[i] = 2.0 * (h[k - 1] + h[k]);
c[i] = h[k];
r[i] = 6.0 * (dd[k] - dd[k - 1]);
}
for i in 1..m {
let w = a[i] / b[i - 1];
b[i] -= w * c[i - 1];
r[i] -= w * r[i - 1];
}
let mut sec = vec![0.0f64; n];
for i in (0..m).rev() {
let next = if i + 1 < m { sec[i + 2] } else { 0.0 };
sec[i + 1] = (r[i] - c[i] * next) / b[i];
}
let mut ms: Vec<f32> = (0..n - 1).map(|k| (dd[k] - h[k] * (2.0 * sec[k] + sec[k + 1]) / 6.0) as f32).collect();
ms.push((dd[n - 2] + h[n - 2] * (sec[n - 2] + 2.0 * sec[n - 1]) / 6.0) as f32);
ms
}
fn ev(c: &Option<Curve>, v: f32) -> f32 {
c.as_ref().map(|c| c.eval(v)).unwrap_or(v)
}
@@ -145,7 +196,7 @@ fn ev(c: &Option<Curve>, v: f32) -> f32 {
/// sorted on load); the default `"0,0 1,1"` is the identity. Colour channels go through the
/// master RGB curve first, then their own curve.
fn curves(ctx: &EffectCtx, mut b: Buf) -> Buf {
let [m, r, g, bl, a] = ["rgb", "red", "green", "blue", "alpha"].map(|id| Curve::parse(ctx.params.s(id)));
let [m, r, g, bl, a] = ["rgb", "red", "green", "blue", "alpha"].map(|id| Curve::parse_natural(ctx.params.s(id)));
if m.is_none() && r.is_none() && g.is_none() && bl.is_none() && a.is_none() {
return b;
}
@@ -1199,6 +1250,29 @@ mod tests {
assert!((clipped.get(0, 0)[0] - 0.05).abs() < 1e-5, "{:?}", clipped.get(0, 0));
}
/// After Effects 2026 (cases 962 / 963): Curves through RGB (0,0) (125,199)
/// (255,255) and Red (0,0) (128,44) (255,255) on grey steps 0..255, 8-bit results.
#[test]
fn curves_follow_a_natural_spline_as_in_after_effects() {
let steps = [0u8, 16, 32, 64, 96, 128, 160, 192, 224, 255];
let mut img = Image::new(10, 1);
for (x, v) in steps.iter().enumerate() {
let v = *v as f32 / 255.0;
img.set(x as u32, 0, [v, v, v, 1.0]);
}
let p = |pts: &[(f32, f32)]| Value::Str(pts.iter().map(|(x, y)| format!("{},{}", x / 255.0, y / 255.0)).collect::<Vec<_>>().join(" "));
let rgb = run("ec.color.curves", &img, &[("rgb", p(&[(0.0, 0.0), (125.0, 199.0), (255.0, 255.0)]))]);
let red = run("ec.color.curves", &img, &[("red", p(&[(0.0, 0.0), (128.0, 44.0), (255.0, 255.0)]))]);
let ae_rgb = [0, 30, 59, 115, 164, 202, 227, 242, 250, 255];
let ae_red = [0, 0, 1, 6, 19, 44, 83, 135, 194, 255];
for x in 0..10 {
let g = (rgb.get(x as i64, 0)[1] * 255.0).round() as i32;
let r = (red.get(x as i64, 0)[0] * 255.0).round() as i32;
assert!((g - ae_rgb[x]).abs() <= 1, "RGB step {}: {g} vs AE {}", steps[x], ae_rgb[x]);
assert!((r - ae_red[x]).abs() <= 1, "Red step {}: {r} vs AE {}", steps[x], ae_red[x]);
}
}
#[test]
fn levels_ic_identity_and_red_only() {
let img = ramp();
+1 -1
View File
@@ -388,7 +388,7 @@ fn transform(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option<GBuf> {
// ---------------------------------------------------------------- per-pixel
fn curves(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
let parsed = ["rgb", "red", "green", "blue", "alpha"].map(|id| effectcraft_effects::Curve::parse(ctx.params.s(id)));
let parsed = ["rgb", "red", "green", "blue", "alpha"].map(|id| effectcraft_effects::Curve::parse_natural(ctx.params.s(id)));
if parsed.iter().all(Option::is_none) {
return Some(b);
}
+2 -1
View File
@@ -30,7 +30,7 @@ corpus compares renders, each effect is assumed to differ somewhere, and users k
| Audio | 13 | 0 | CPU by design (applied in the mixdown) |
| Blur & Sharpen | 16 | 15 | Gaussian Blur interaction and output to be measured first ([fidelity/ae-2026-acceptance.md](fidelity/ae-2026-acceptance.md)) |
| Channel | 13 | 13 | — |
| Color Correction | 40 | 40 | Tint, Tritone and Black & White (sliders and Tint) match After Effects 2026 renders (Rec. 601 luminance); Levels (Individual Controls) matches After Effects 2026 in 32 bpc (channel controls before RGB, "Off for 32 bpc Color" unclipped; plain Levels' channel order not measured); Brightness & Contrast (non-legacy) within about 6/255 of After Effects 2026 ramps (negative settings invert the positive curves); Tint has Swap Colors (#963); ACES 2.0 / OCIO 2.5 (26.5); Apply Color LUT (#514) and 32-bpc blending (#494) are closed |
| Color Correction | 40 | 40 | Tint, Tritone and Black & White (sliders and Tint) match After Effects 2026 renders (Rec. 601 luminance); Levels (Individual Controls) matches After Effects 2026 in 32 bpc (channel controls before RGB, "Off for 32 bpc Color" unclipped; plain Levels' channel order not measured); Brightness & Contrast (non-legacy) within about 6/255 of After Effects 2026 ramps (negative settings invert the positive curves); Curves interpolates a natural cubic spline through its points, as After Effects 2026 (exact on its grey steps); Tint has Swap Colors (#963); ACES 2.0 / OCIO 2.5 (26.5); Apply Color LUT (#514) and 32-bpc blending (#494) are closed |
| Distort | 39 | 35 | Liquify has no viewer brush; #303 (Mesh Warp drag) and #534 (CC Power Pin Unstretch) are closed |
| Expression Controls | 9 | 9 | — |
| Generate | 26 | 25 | 4-Color Gradient's Blend matches After Effects 2026 (weights 1 / (d² + Blend²)); Fill's Opacity is the filled layer's opacity, as After Effects 2026 renders it (with a Fill Mask it still mixes with the original, not measured); Fractal on the CPU (needs f64); #526 (Reset) and #536 (Write-on marks) are closed |
@@ -72,6 +72,7 @@ the GPU is performance, not parity.
| 2026-10-19 | minor | Luma Key and Extract: Rec. 601 luminance (was Rec. 709), measured against After Effects 2026 (CPU and GPU) |
| 2026-10-19 | minor | 4-Color Gradient: each point weighs 1 / (d² + Blend²), measured against After Effects 2026 at Blend 10 / 50 / 100 / 300 (CPU and GPU) |
| 2026-10-19 | minor | Fill without a mask: the layer becomes the fill colour at its alpha × Opacity (was a mix with the original), measured against After Effects 2026 (CPU and GPU) |
| 2026-10-19 | minor | Curves: natural cubic spline through the points, clamped to 0..1 (was a monotone spline), measured against After Effects 2026 (CPU and GPU); Lumetri's curves and Glow's Arbitrary Map keep the monotone spline |
| 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; its sliders take After Effects' ranges (Exposure −100..100, slider −4..4; Offset −2..2, slider −0.5..0.5; Gamma Correction 0.1..10) |
| 2026-10-12 | minor | Parameters 3,014 to 3,017 (effects.md); #303, #514, #526, #533–#536 marked closed instead of pending |