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