mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-24 10:09:32 -06:00
Merge #1872: Color effects and keys render as in After Effects (Tint, Tritone, Black & White, Exposure, Levels, Brightness & Contrast, Curves, Luma Key, Extract, 4-Color Gradient, Fill) (@vangel76)
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -201,7 +252,7 @@ impl Lv {
|
||||
if clip_w {
|
||||
t = t.min(1.0);
|
||||
}
|
||||
self.ob + (self.ow - self.ob) * t.max(0.0).powf(1.0 / self.g.max(0.01))
|
||||
self.ob + (self.ow - self.ob) * crate::color_fx::levels_gamma(t, self.g.max(0.01))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -217,7 +268,10 @@ fn levels_ic(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
return b;
|
||||
}
|
||||
let clip = crate::color_fx::levels_clip(ctx);
|
||||
map_ca(&mut b.img, |c, a| ([0, 1, 2].map(|i| lv[i + 1].apply(lv[0].apply(c[i], clip), clip)), lv[4].apply(a, clip).clamp(0.0, 1.0)));
|
||||
// Each channel's own controls first, then the RGB controls: After Effects 2026 with RGB
|
||||
// (0.1, 0.9, gamma 1.5, 0.05, 0.95) and Green Input White 0.8 maps grey 32/255 to green
|
||||
// 52/255 (RGB first would give 45).
|
||||
map_ca(&mut b.img, |c, a| ([0, 1, 2].map(|i| lv[0].apply(lv[i + 1].apply(c[i], clip), clip)), lv[4].apply(a, clip).clamp(0.0, 1.0)));
|
||||
b
|
||||
}
|
||||
|
||||
@@ -1139,6 +1193,86 @@ mod tests {
|
||||
assert!((red.data[0][0] - 1.0).abs() < 1e-4 && (red.data[0][1] - 0.5).abs() < 1e-6);
|
||||
}
|
||||
|
||||
/// After Effects 2026, 32 bpc project, Levels (Individual Controls) with RGB Input Black 0.1,
|
||||
/// Input White 0.9, Gamma 1.5, Output Black 0.05, Output White 0.95, Red Gamma 0.8, Green
|
||||
/// Input White 0.8, Blue Output Black 0.2, both clips "Off for 32 bpc Color", on grey steps:
|
||||
/// the channel controls run first, nothing clips (8-bit captures clamp the results).
|
||||
#[test]
|
||||
fn levels_ic_matches_after_effects_in_32_bpc() {
|
||||
let steps = [0, 16, 32, 64, 96, 128, 160, 192, 224, 255];
|
||||
let ae = [
|
||||
[0, 0, 70],
|
||||
[0, 0, 88],
|
||||
[0, 52, 104],
|
||||
[61, 108, 132],
|
||||
[102, 150, 158],
|
||||
[138, 187, 181],
|
||||
[171, 220, 203],
|
||||
[203, 250, 223],
|
||||
[233, 255, 243],
|
||||
[255, 255, 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 vals = [
|
||||
("rgb/rgbInBlack", Value::Scalar(0.1)),
|
||||
("rgb/rgbInWhite", Value::Scalar(0.9)),
|
||||
("rgb/rgbGamma", Value::Scalar(1.5)),
|
||||
("rgb/rgbOutBlack", Value::Scalar(0.05)),
|
||||
("rgb/rgbOutWhite", Value::Scalar(0.95)),
|
||||
("red/redGamma", Value::Scalar(0.8)),
|
||||
("green/greenInWhite", Value::Scalar(0.8)),
|
||||
("blue/blueOutBlack", Value::Scalar(0.2)),
|
||||
];
|
||||
let s = find("ec.color.levelsic").unwrap();
|
||||
let mut params = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() };
|
||||
for (k, v) in &vals {
|
||||
params.values.insert(k.to_string(), v.clone());
|
||||
}
|
||||
let render = |float_depth: bool| {
|
||||
let env = crate::EffectEnv { float_depth, ..Default::default() };
|
||||
let ctx = EffectCtx { params: ¶ms, time: 0.0, layer_size: [10.0, 1.0], seed: 1, adjustment: false, env };
|
||||
(s.render)(&ctx, Buf { img: img.clone(), offset: [0.0, 0.0], scale: 1.0 }).img
|
||||
};
|
||||
let out = render(true);
|
||||
for (x, want) in ae.iter().enumerate() {
|
||||
let p = out.get(x as i64, 0);
|
||||
for c in 0..3 {
|
||||
let got = p[c].clamp(0.0, 1.0) * 255.0;
|
||||
assert!((got - want[c] as f32).abs() <= 2.0, "step {} channel {c}: {got} vs AE {}", steps[x], want[c]);
|
||||
}
|
||||
}
|
||||
// In 8 / 16 bpc "Off for 32 bpc Color" clips: black stops at Output Black.
|
||||
let clipped = render(false);
|
||||
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();
|
||||
|
||||
+266
-32
@@ -14,12 +14,27 @@ fn mix(a: [f32; 3], b: [f32; 3], t: f32) -> [f32; 3] {
|
||||
[a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t]
|
||||
}
|
||||
|
||||
/// Rec. 601 luminance on the working-space values (sRGB working space, not linearised: red
|
||||
/// 76/255, green 150/255, blue 29/255), as After Effects 2026 renders Tint, Tritone, Luma Key and
|
||||
/// Extract. Tritone, case 718: a red solid with Blend With Original 25 gives (132, 23, 46), from
|
||||
/// luminance 0.299; Rec. 709's 0.213 gave (113, 16, 49); on a grey ramp both weightings agree
|
||||
/// (case 815). Luma Key and Extract: Key Out Darker / Extract Black Point at 65, 166 and 24 on
|
||||
/// pure red, green and blue keep red (601: 76, 709: 54) and blue (29 / 18) and key out green
|
||||
/// (150 / 182). The GPU kernels (`pointwise` op 1, `fx_color` op 4, `fxk_key_luma`) mirror it.
|
||||
pub(crate) fn luma_601(c: [f32; 3]) -> f32 {
|
||||
LUMA_601[0] * c[0] + LUMA_601[1] * c[1] + LUMA_601[2] * c[2]
|
||||
}
|
||||
|
||||
/// The Rec. 601 luma weights of [`luma_601`]. The WGSL kernels repeat them as literals; the GPU
|
||||
/// test `shader_constants_match_the_cpu` checks that they are these.
|
||||
pub const LUMA_601: [f32; 3] = [0.299, 0.587, 0.114];
|
||||
|
||||
fn tint(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let black = ctx.params.color("black");
|
||||
let white = ctx.params.color("white");
|
||||
let amt = ctx.params.f("amount") as f32 / 100.0;
|
||||
b.img.map_straight(|c| {
|
||||
let l = luminance(c[0], c[1], c[2]).clamp(0.0, 1.0);
|
||||
let l = luma_601(c).clamp(0.0, 1.0);
|
||||
let t = [black[0] + (white[0] - black[0]) * l, black[1] + (white[1] - black[1]) * l, black[2] + (white[2] - black[2]) * l];
|
||||
mix(c, t, amt)
|
||||
});
|
||||
@@ -32,7 +47,7 @@ fn tritone(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let sh = ctx.params.color("shadows");
|
||||
let blend = 1.0 - ctx.params.f("blend") as f32 / 100.0;
|
||||
b.img.map_straight(|c| {
|
||||
let l = luminance(c[0], c[1], c[2]).clamp(0.0, 1.0);
|
||||
let l = luma_601(c).clamp(0.0, 1.0);
|
||||
let t = if l < 0.5 {
|
||||
mix([sh[0], sh[1], sh[2]], [mid[0], mid[1], mid[2]], l * 2.0)
|
||||
} else {
|
||||
@@ -49,18 +64,22 @@ fn tritone(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
/// power toward white (`1-(1-v)^e`) or black (`v^e`); contrast uses `g = 1 + 0.53 c` instead of
|
||||
/// the old `2^(2c)` (that crushed 0.25 to ~8/255 at Contrast 100; AE is ~40/255). Values above 1
|
||||
/// pass through. The GPU kernel mirrors it.
|
||||
///
|
||||
/// Negative Brightness and Contrast are the inverse curves of the positive ones (After Effects
|
||||
/// 2026 ramps: Brightness −40 is Brightness +40 inverted within 2.6/255, Contrast −50 likewise),
|
||||
/// so they use the reciprocal exponents: Brightness −40 was 20/255 off AE, now 6.2.
|
||||
pub fn bc_modern(v: f32, b: f32, c: f32) -> f32 {
|
||||
if v >= 1.0 {
|
||||
return v;
|
||||
}
|
||||
let mut v = v.max(0.0);
|
||||
if b > 0.0 {
|
||||
v = 1.0 - (1.0 - v).powf(2f32.powf(1.24 * b));
|
||||
} else if b < 0.0 {
|
||||
v = v.powf(2f32.powf(-1.32 * b));
|
||||
if b != 0.0 {
|
||||
let e = 2f32.powf(1.24 * b.abs());
|
||||
v = 1.0 - (1.0 - v).powf(if b > 0.0 { e } else { 1.0 / e });
|
||||
}
|
||||
if c != 0.0 {
|
||||
let g = (1.0 + 0.53 * c).max(0.05);
|
||||
let g = (1.0 + 0.53 * c.abs()).max(0.05);
|
||||
let g = if c > 0.0 { g } else { 1.0 / g };
|
||||
v = if v < 0.5 { 0.5 * (2.0 * v).powf(g) } else { 1.0 - 0.5 * (2.0 - 2.0 * v).max(0.0).powf(g) };
|
||||
}
|
||||
v
|
||||
@@ -197,16 +216,32 @@ fn hue_saturation(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
pub const LEVELS_CLIP: [&str; 3] = ["Off", "On", "Off for 32 bpc Color"];
|
||||
|
||||
/// Whether Levels clips below Input Black / above Input White. "Off for 32 bpc Color" clips
|
||||
/// (effects can't see the project's bit depth; 8/16 bpc behaviour). The hidden `noClip` switch
|
||||
/// of projects saved before the two popups existed turns both off.
|
||||
/// in 8 and 16 bpc projects and not in 32 bpc ones (After Effects 2026, Levels (Individual
|
||||
/// Controls) in a 32 bpc project: values below Input Black end below Output Black). The hidden
|
||||
/// `noClip` switch of projects saved before the two popups existed turns both off.
|
||||
pub fn levels_clip(ctx: &EffectCtx) -> (bool, bool) {
|
||||
if ctx.params.b("noClip") {
|
||||
return (false, false);
|
||||
}
|
||||
let on = |id: &str| ctx.params.get(id).is_none_or(|v| v.as_enum() != 0);
|
||||
let on = |id: &str| levels_clip_on(ctx, id);
|
||||
(on("clipToOutputBlack"), on("clipToOutputWhite"))
|
||||
}
|
||||
|
||||
/// One Clip To Output popup: 0 Off, 1 On, 2 Off for 32 bpc Color (missing: clip).
|
||||
pub fn levels_clip_on(ctx: &EffectCtx, id: &str) -> bool {
|
||||
match ctx.params.get(id).map(|v| v.as_enum()) {
|
||||
Some(0) => false,
|
||||
Some(2) => !ctx.env.float_depth,
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Levels' gamma on the normalised input `t`: unclipped values below Input Black extrapolate
|
||||
/// (the curve mirrored through 0) instead of stopping at Output Black.
|
||||
pub fn levels_gamma(t: f32, g: f32) -> f32 {
|
||||
if t >= 0.0 { t.powf(1.0 / g) } else { -(-t).powf(1.0 / g) }
|
||||
}
|
||||
|
||||
/// Levels' Channel popup.
|
||||
pub const LEVELS_CHANNELS: [&str; 5] = ["RGB", "Red", "Green", "Blue", "Alpha"];
|
||||
|
||||
@@ -232,8 +267,7 @@ fn level(v: f32, [ib, iw, g, ob, ow]: [f32; 5], (clip_b, clip_w): (bool, bool))
|
||||
if clip_w {
|
||||
t = t.min(1.0);
|
||||
}
|
||||
let t = t.max(0.0).powf(1.0 / g);
|
||||
ob + (ow - ob) * t
|
||||
ob + (ow - ob) * levels_gamma(t, g)
|
||||
}
|
||||
|
||||
const LEVELS_IDENTITY: [f32; 5] = [0.0, 1.0, 1.0, 0.0, 1.0];
|
||||
@@ -276,7 +310,7 @@ pub fn levels_channel_settings(ctx: &EffectCtx) -> [([f32; 5], (bool, bool)); 4]
|
||||
let ids = levels_channel_ids(i + 1);
|
||||
let v: [f32; 5] = std::array::from_fn(|k| ctx.params.get(&ids[k]).map(|v| v.as_f64() as f32).unwrap_or(LEVELS_IDENTITY[k]));
|
||||
let pre = LEVELS_CHANNEL_PREFIX[i];
|
||||
let on = |id: String| ctx.params.get(&id).is_none_or(|v| v.as_enum() != 0);
|
||||
let on = |id: String| levels_clip_on(ctx, &id);
|
||||
let clip = if ctx.params.b("noClip") { (false, false) } else { (on(format!("{pre}ClipToOutputBlack")), on(format!("{pre}ClipToOutputWhite"))) };
|
||||
([v[0], v[1], v[2].max(0.01), v[3], v[4]], clip)
|
||||
})
|
||||
@@ -304,6 +338,18 @@ pub fn exposure_settings(ctx: &EffectCtx) -> [(f32, f32, f32); 3] {
|
||||
}
|
||||
}
|
||||
|
||||
/// The power curve Exposure's own linear-light conversion uses (Bypass Linear Light Conversion
|
||||
/// off). After Effects decodes with a pure 2.2 power, not the piecewise sRGB curve: Offset 0.0005
|
||||
/// on black measures 8/255 there, which 2.2 predicts (8.06) and sRGB does not (1.65).
|
||||
/// `kernels.wgsl` repeats it as a literal; the GPU test `shader_constants_match_the_cpu` checks it.
|
||||
pub const EXPOSURE_LINEAR_GAMMA: f32 = 2.2;
|
||||
|
||||
/// Exposure: per channel, `(value · 2^exposure + offset)^(1 / gamma)`, in linear light unless
|
||||
/// Bypass Linear Light Conversion is on. The conversion is the effect's own pure 2.2 power curve
|
||||
/// (see [`EXPOSURE_LINEAR_GAMMA`]), applied whatever the project's working space, also when it
|
||||
/// is already linear (Linearize Working Space), as before. Negative values (32 bpc, or a
|
||||
/// negative Offset) go through the curves mirrored, so a negative Offset darkens smoothly past
|
||||
/// black and the default settings leave every value unchanged.
|
||||
fn exposure(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let s = exposure_settings(ctx);
|
||||
let bypass = ctx.params.b("bypassLinearLight");
|
||||
@@ -313,9 +359,9 @@ fn exposure(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
if bypass {
|
||||
return mirror_pow(c[i] * e + off, 1.0 / g);
|
||||
}
|
||||
let lin = effectcraft_color::srgb_to_linear(c[i].max(0.0));
|
||||
let lin = mirror_pow(c[i], EXPOSURE_LINEAR_GAMMA);
|
||||
let o = mirror_pow(lin * e + off, 1.0 / g);
|
||||
effectcraft_color::linear_to_srgb(o.abs()).copysign(o)
|
||||
mirror_pow(o, 1.0 / EXPOSURE_LINEAR_GAMMA)
|
||||
})
|
||||
});
|
||||
b
|
||||
@@ -333,6 +379,37 @@ pub fn mirror_pow(v: f32, p: f32) -> f32 {
|
||||
/// It used to offset the luminance by the HSL saturation, which is near 1 for any tinted
|
||||
/// near-black pixel: the faint colour noise of compressed footage's shadows came out as bright
|
||||
/// and black blocks (#1463), and 32-bpc overbrights divided by zero.
|
||||
/// Black & White's Tint: the tint colour's hue and saturation at the gray's luminosity, as a
|
||||
/// Color blend does (luminosity with weights 0.3 / 0.59 / 0.11, out-of-range channels pulled
|
||||
/// back toward the gray). After Effects 2026 with Tint Color [1, 0.5, 0]: gray 0.1 (a red solid
|
||||
/// at Reds 10, case 717) → (43, 21, 0), gray 0.6 (yellow at defaults, case 825) → (255, 129, 2),
|
||||
/// white → white; the old hue / saturation / lightness mix gave (51, 26, 0) for the first.
|
||||
/// The luminosity weights of [`bw_tint`] (a Color blend's). `fx_color.wgsl` repeats them; the GPU
|
||||
/// test `shader_constants_match_the_cpu` checks that they are these.
|
||||
pub const BW_TINT_LUMA: [f32; 3] = [0.3, 0.59, 0.11];
|
||||
|
||||
pub fn bw_tint(tint: [f32; 3], gray: f32) -> [f32; 3] {
|
||||
let lum = |c: [f32; 3]| BW_TINT_LUMA[0] * c[0] + BW_TINT_LUMA[1] * c[1] + BW_TINT_LUMA[2] * c[2];
|
||||
if !gray.is_finite() || gray <= 0.0 {
|
||||
return [0.0; 3];
|
||||
}
|
||||
if gray >= 1.0 {
|
||||
return [gray; 3];
|
||||
}
|
||||
let d = gray - lum(tint);
|
||||
let c = tint.map(|v| v + d);
|
||||
let l = lum(c);
|
||||
let (n, x) = (c[0].min(c[1]).min(c[2]), c[0].max(c[1]).max(c[2]));
|
||||
let mut o = c;
|
||||
if n < 0.0 && l - n > 1e-9 {
|
||||
o = o.map(|v| l + (v - l) * l / (l - n));
|
||||
}
|
||||
if x > 1.0 && x - l > 1e-9 {
|
||||
o = o.map(|v| l + (v - l) * (1.0 - l) / (x - l));
|
||||
}
|
||||
o
|
||||
}
|
||||
|
||||
fn black_white(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let w = [ctx.params.f("reds"), ctx.params.f("yellows"), ctx.params.f("greens"), ctx.params.f("cyans"), ctx.params.f("blues"), ctx.params.f("magentas")]
|
||||
.map(|v| v as f32 / 100.0);
|
||||
@@ -347,13 +424,7 @@ fn black_white(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let wt = w[i] + (w[(i + 1) % 6] - w[i]) * f;
|
||||
let (max, min) = (c[0].max(c[1]).max(c[2]), c[0].min(c[1]).min(c[2]));
|
||||
let gray = (min + (max - min) * wt).max(0.0);
|
||||
if tint_on {
|
||||
let (th, ts, _) = rgb_to_hsl(tc[0], tc[1], tc[2]);
|
||||
let (r, g, bl) = hsl_to_rgb(th, ts, gray.min(1.0));
|
||||
[r, g, bl]
|
||||
} else {
|
||||
[gray, gray, gray]
|
||||
}
|
||||
if tint_on { bw_tint([tc[0], tc[1], tc[2]], gray) } else { [gray, gray, gray] }
|
||||
});
|
||||
b
|
||||
}
|
||||
@@ -418,15 +489,12 @@ fn fill(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
});
|
||||
return b;
|
||||
}
|
||||
// Without masks the layer becomes the fill colour, and Opacity is the layer's opacity: After
|
||||
// Effects 2026 renders Fill (0, 0.5, 1) at Opacity 50 % as that colour at half alpha, with
|
||||
// nothing of the original showing through (it is not a mix with the original).
|
||||
b.img.data.iter_mut().for_each(|px| {
|
||||
let a = if invert { 1.0 - px[3] } else { px[3] };
|
||||
let f = [c[0] * a, c[1] * a, c[2] * a];
|
||||
for i in 0..3 {
|
||||
px[i] += (f[i] - px[i]) * opacity;
|
||||
}
|
||||
if invert {
|
||||
px[3] += (a - px[3]) * opacity;
|
||||
}
|
||||
let a = if invert { 1.0 - px[3] } else { px[3] } * opacity;
|
||||
*px = [c[0] * a, c[1] * a, c[2] * a, a];
|
||||
});
|
||||
b
|
||||
}
|
||||
@@ -969,11 +1037,13 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
"Exposure",
|
||||
{
|
||||
let mut v = vec![p("channels", "Channels", Value::Enum(0), popup(&["Master", "Individual Channels"]))];
|
||||
// After Effects' ranges (valid, slider, decimals). Gamma values below 0.1 from
|
||||
// older projects or Photoshop imports (0.01..9.99) are kept and still render.
|
||||
let group = |ids: [&'static str; 3]| {
|
||||
[
|
||||
p(ids[0], "Exposure", num(0.0), slider(-20.0, 20.0, -5.0, 5.0, 2)),
|
||||
p(ids[0], "Exposure", num(0.0), slider(-100.0, 100.0, -4.0, 4.0, 2)),
|
||||
p(ids[1], "Offset", num(0.0), slider(-2.0, 2.0, -0.5, 0.5, 4)),
|
||||
p(ids[2], "Gamma Correction", num(1.0), slider(0.01, 9.99, 0.1, 3.0, 2)),
|
||||
p(ids[2], "Gamma Correction", num(1.0), slider(0.1, 10.0, 0.1, 10.0, 2)),
|
||||
]
|
||||
};
|
||||
v.extend(group(["master/exposure", "master/offset", "master/gamma"]));
|
||||
@@ -1173,6 +1243,17 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// After Effects 2026 renders of full-frame solids with Tint at its defaults (sRGB working
|
||||
/// space, not linearised, 32 bpc, 8-bit capture): Rec. 601 weights on the encoded values.
|
||||
#[test]
|
||||
fn tint_defaults_match_after_effects_greys() {
|
||||
for (c, want) in [([1.0, 0.0, 0.0], 76.0), ([0.0, 1.0, 0.0], 150.0), ([0.0, 0.0, 1.0], 29.0), ([0.5, 0.5, 0.5], 128.0)] {
|
||||
let o = px("ec.color.tint", &[], [c[0], c[1], c[2], 1.0]);
|
||||
let want = want / 255.0;
|
||||
assert!(close(o, [want, want, want, 1.0], 1.0 / 255.0), "{c:?}: {o:?} vs {want}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colorama_reads_get_phase_from() {
|
||||
// Same intensity, different red: Intensity gives one colour, Red gives two.
|
||||
@@ -1227,6 +1308,75 @@ mod tests {
|
||||
assert!(go(0.0, 100.0, 64) >= 30);
|
||||
}
|
||||
|
||||
/// After Effects 2026: Tritone Highlights [1, 1, 0.8], Midtones [0.6, 0.2, 0.2], Shadows
|
||||
/// [0, 0, 0.3]. On a red solid with Blend With Original 25 (case 718) AE gives (132, 23, 46);
|
||||
/// on a grey ramp (case 815) levels 0, 63.9, 127.6, 191.4 give (0, 0, 77), (77, 26, 64),
|
||||
/// (153, 51, 51), (204, 153, 128).
|
||||
#[test]
|
||||
fn tritone_matches_after_effects() {
|
||||
let colours = [
|
||||
("highlights", Value::Color([1.0, 1.0, 0.8, 1.0])),
|
||||
("midtones", Value::Color([0.6, 0.2, 0.2, 1.0])),
|
||||
("shadows", Value::Color([0.0, 0.0, 0.3, 1.0])),
|
||||
];
|
||||
let near = |o: [f32; 4], ae: [f32; 3]| (0..3).all(|i| (o[i] * 255.0 - ae[i]).abs() <= 1.0);
|
||||
let mut red = colours.to_vec();
|
||||
red.push(("blend", num(25.0)));
|
||||
let o = px("ec.color.tritone", &red, [1.0, 0.0, 0.0, 1.0]);
|
||||
assert!(near(o, [132.0, 23.0, 46.0]), "{:?}", o.map(|v| v * 255.0));
|
||||
for (level, ae) in [(0.1f32, [0.0, 0.0, 77.0]), (63.9, [77.0, 26.0, 64.0]), (127.6, [153.0, 51.0, 51.0]), (191.4, [204.0, 153.0, 128.0])] {
|
||||
let v = level / 255.0;
|
||||
let o = px("ec.color.tritone", &colours, [v, v, v, 1.0]);
|
||||
assert!(near(o, ae), "level {level}: {:?} vs {ae:?}", o.map(|v| v * 255.0));
|
||||
}
|
||||
}
|
||||
|
||||
/// After Effects 2026, Tint Color [1, 0.5, 0]: a red solid at Reds 10 (case 717) and yellow
|
||||
/// and white solids at the default sliders (case 825).
|
||||
#[test]
|
||||
fn black_white_tint_matches_after_effects() {
|
||||
let tint = [("tint", Value::Bool(true)), ("tintColor", Value::Color([1.0, 0.5, 0.0, 1.0]))];
|
||||
let near = |o: [f32; 4], ae: [f32; 3]| (0..3).all(|i| (o[i] * 255.0 - ae[i]).abs() <= 1.5);
|
||||
let mut reds = tint.to_vec();
|
||||
reds.push(("reds", num(10.0)));
|
||||
let o = px("ec.color.blackwhite", &reds, [1.0, 0.0, 0.0, 1.0]);
|
||||
assert!(near(o, [43.0, 21.0, 0.0]), "{:?}", o.map(|v| v * 255.0));
|
||||
let o = px("ec.color.blackwhite", &tint, [1.0, 1.0, 0.0, 1.0]);
|
||||
assert!(near(o, [255.0, 129.0, 2.0]), "{:?}", o.map(|v| v * 255.0));
|
||||
let o = px("ec.color.blackwhite", &tint, [1.0, 1.0, 1.0, 1.0]);
|
||||
assert!(near(o, [255.0, 255.0, 255.0]), "{:?}", o.map(|v| v * 255.0));
|
||||
// Hostile grays stay finite.
|
||||
for g in [f32::NAN, -1.0, 0.0, 4.0] {
|
||||
assert!(bw_tint([1.0, 0.5, 0.0], g).iter().all(|v| v.is_finite()));
|
||||
}
|
||||
}
|
||||
|
||||
/// After Effects 2026 ramps (RENDERER round 2, session R8): negative Brightness and Contrast
|
||||
/// are the inverses of the positive curves. (brightness, contrast, input, AE 8-bit)
|
||||
#[test]
|
||||
fn negative_brightness_and_contrast_invert_the_positive_curves() {
|
||||
let go = |b: f64, c: f64, x: u8| {
|
||||
let v = x as f32 / 255.0;
|
||||
(px("ec.color.brightnesscontrast", &[("brightness", num(b)), ("contrast", num(c))], [v, v, v, 1.0])[0] * 255.0).round() as i32
|
||||
};
|
||||
let samples: &[(f64, f64, u8, u8)] = &[
|
||||
(-40.0, 0.0, 64, 50),
|
||||
(-40.0, 0.0, 128, 99),
|
||||
(-40.0, 0.0, 191, 155),
|
||||
(-40.0, 0.0, 234, 207),
|
||||
(0.0, -50.0, 21, 28),
|
||||
(0.0, -50.0, 64, 76),
|
||||
(0.0, -50.0, 191, 179),
|
||||
(0.0, -50.0, 234, 227),
|
||||
(40.0, 0.0, 128, 162),
|
||||
(0.0, 50.0, 64, 52),
|
||||
];
|
||||
for &(b, c, x, ae) in samples {
|
||||
let got = go(b, c, x);
|
||||
assert!((got - ae as i32).abs() <= 7, "B{b} C{c} in {x}: got {got} AE {ae}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #1463: Black & White's sliders set the gray of each pure hue and blend between
|
||||
/// neighbouring hues; neutral pixels keep their value, overbright ones too, and an overbright
|
||||
/// colour stays finite (it used to divide by zero).
|
||||
@@ -1321,6 +1471,66 @@ mod tests {
|
||||
assert!((neg[0] + 0.5).abs() < 1e-4, "{neg:?}");
|
||||
}
|
||||
|
||||
/// After Effects' Exposure ranges for all twelve sliders; a saved value outside them (a gamma
|
||||
/// of 0.05, valid before, as in Photoshop) survives loading and still renders.
|
||||
#[test]
|
||||
fn exposure_has_after_effects_ranges_and_keeps_old_values() {
|
||||
use effectcraft_project::build::Ids;
|
||||
let spec = crate::find("ec.color.exposure").unwrap();
|
||||
let want = [(-100.0, 100.0, -4.0, 4.0, 2), (-2.0, 2.0, -0.5, 0.5, 4), (0.1, 10.0, 0.1, 10.0, 2)];
|
||||
let sliders: Vec<_> = spec
|
||||
.params
|
||||
.iter()
|
||||
.filter_map(|p| match p.ui {
|
||||
ParamUi::Slider { min, max, slider_min, slider_max, decimals } => Some((p.id, (min, max, slider_min, slider_max, decimals))),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(sliders.len(), 12);
|
||||
for (i, (id, r)) in sliders.iter().enumerate() {
|
||||
assert_eq!(*r, want[i % 3], "{id}");
|
||||
}
|
||||
let mut next = 1;
|
||||
let mut g = crate::instantiate(spec, &mut Ids(&mut next), spec.name, [10.0, 10.0]);
|
||||
let gamma = g.prop_mut("master/gamma").unwrap();
|
||||
gamma.value = num(0.05);
|
||||
gamma.ui = slider(0.01, 9.99, 0.1, 3.0, 2);
|
||||
crate::migrate::upgrade_instance(spec, &mut g, &mut Ids(&mut next), [10.0, 10.0]);
|
||||
let gamma = g.prop_mut("master/gamma").unwrap();
|
||||
assert_eq!(gamma.value, num(0.05));
|
||||
assert!(matches!(gamma.ui, ParamUi::Slider { min, .. } if min == 0.1));
|
||||
let o = px("ec.color.exposure", &[("master/gamma", num(0.05)), ("bypassLinearLight", Value::Bool(true))], [0.5, 0.5, 0.5, 1.0]);
|
||||
assert!((o[0] - 0.5f32.powf(20.0)).abs() < 1e-9, "{o:?}");
|
||||
}
|
||||
|
||||
/// Exposure on a red [1, 0, 0] solid against After Effects 2026 renders (sRGB working space,
|
||||
/// not linearised, 32 bpc, 8-bit captures): the linear-light conversion is a pure 2.2 power.
|
||||
/// The piecewise sRGB curve gave 2 / 7 / 39 for the three smallest Offsets.
|
||||
#[test]
|
||||
fn exposure_matches_after_effects_gamma_2_2() {
|
||||
let red = [1.0, 0.0, 0.0, 1.0];
|
||||
let code = |o: [f32; 4], i: usize| o[i] * 255.0;
|
||||
// Master Offset only: G and B carry the curve; R goes over 1 (255 in the 8-bit capture).
|
||||
for (offset, ae) in [(0.0005, 8.0), (0.002, 15.0), (0.02, 43.0), (0.1, 90.0)] {
|
||||
let o = px("ec.color.exposure", &[("master/offset", num(offset))], red);
|
||||
for i in [1, 2] {
|
||||
assert!((code(o, i) - ae).abs() <= 1.0, "offset {offset}: {} vs AE {ae}", code(o, i));
|
||||
}
|
||||
assert!(o[0] >= 1.0, "{o:?}");
|
||||
}
|
||||
// Exposure −2.25: AE 126; with Bypass Linear Light Conversion (gain on encoded values) 54.
|
||||
let o = px("ec.color.exposure", &[("master/exposure", num(-2.25))], red);
|
||||
assert!((code(o, 0) - 126.0).abs() <= 1.0 && o[1] == 0.0, "{o:?}");
|
||||
let o = px("ec.color.exposure", &[("master/exposure", num(-2.25)), ("bypassLinearLight", Value::Bool(true))], red);
|
||||
assert!((code(o, 0) - 54.0).abs() <= 1.0 && o[1] == 0.0, "{o:?}");
|
||||
// Default settings leave 32-bpc values alone, negative and over-range ones included.
|
||||
let hdr = [-0.25, 0.5, 2.0, 1.0];
|
||||
assert!(close(px("ec.color.exposure", &[], hdr), hdr, 1e-5));
|
||||
// A negative Offset pushes black below zero, mirrored, rather than clipping or NaN.
|
||||
let o = px("ec.color.exposure", &[("master/offset", num(-0.02))], [0.0, 0.0, 0.0, 1.0]);
|
||||
assert!((o[0] + 0.02f32.powf(1.0 / 2.2)).abs() < 1e-5, "{o:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn change_to_color_change_modes_tolerances_and_matte() {
|
||||
let red = [0.8, 0.1, 0.1, 1.0];
|
||||
@@ -1462,6 +1672,30 @@ mod tests {
|
||||
assert_eq!(plain.get(10, 5), [1.0, 0.0, 0.0, 1.0]);
|
||||
}
|
||||
|
||||
/// After Effects 2026: Fill (0, 0.5, 1) at Opacity 50 % on an opaque layer (a Gradient Ramp
|
||||
/// on a red solid) renders premultiplied (0, 0.251, 0.502) at alpha 0.502 everywhere: the
|
||||
/// fill colour at half the layer's alpha, nothing of the ramp left.
|
||||
#[test]
|
||||
fn fill_opacity_is_the_layers_opacity_as_in_after_effects() {
|
||||
let mut img = Image::new(16, 4);
|
||||
for x in 0..16 {
|
||||
let v = x as f32 / 15.0;
|
||||
for y in 0..4 {
|
||||
img.set(x, y, [v, 0.0, 0.0, 1.0]);
|
||||
}
|
||||
}
|
||||
let vals = [("color", Value::Color([0.0, 0.5, 1.0, 1.0])), ("opacity", num(50.0))];
|
||||
let out = run_fx("ec.generate.fill", &vals, img, 0.0, EffectEnv::default()).img;
|
||||
for x in [0, 7, 15] {
|
||||
let p = out.get(x, 2);
|
||||
let ae = [0.0, 0.251, 0.502, 0.502];
|
||||
assert!(p.iter().zip(ae).all(|(v, a)| (v - a).abs() < 1.0 / 255.0), "x {x}: {p:?} vs AE {ae:?}");
|
||||
}
|
||||
// A half-transparent pixel ends at a quarter alpha, still the fill colour.
|
||||
let half = run_fx("ec.generate.fill", &vals, Image::filled(2, 2, [0.2, 0.2, 0.2, 0.5]), 0.0, EffectEnv::default()).img;
|
||||
assert_eq!(half.get(0, 0), [0.0, 0.125, 0.25, 0.25]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colorama_palettes_modify_selection_masking_and_add_phase() {
|
||||
let grey = [0.5, 0.5, 0.5, 1.0];
|
||||
|
||||
@@ -112,10 +112,19 @@ pub(crate) fn gen_blend(o: Px, g: Px, mode: u32) -> Px {
|
||||
}
|
||||
}
|
||||
|
||||
/// 4-Color Gradient's weights are inverse squared distances softened by Blend²: each point
|
||||
/// weighs 1 / (d² + Blend²) (pixels). Fitted to After Effects 2026 renders at Blend 10, 50, 100
|
||||
/// and 300 (power 2.00 at every Blend, softening 0 / 2367 / 9817 / 90127 ≈ Blend²; mean error
|
||||
/// 0.27/255, max 0.9). Returns Blend² in buffer pixels, never 0.
|
||||
pub fn four_color_softening(blend: f64, scale: f64) -> f64 {
|
||||
let b = blend.max(0.0) * scale;
|
||||
(b * b).max(1e-6)
|
||||
}
|
||||
|
||||
fn four_color(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let pts: Vec<((f64, f64), [f32; 4])> =
|
||||
(1..=4).map(|i| (b.to_px(ctx.params.v2(&format!("positionsColors/point{i}"))), ctx.params.color(&format!("positionsColors/color{i}")))).collect();
|
||||
let blend = ctx.params.f("blend").max(1.0);
|
||||
let k = four_color_softening(ctx.params.f("blend"), b.scale);
|
||||
let jitter = (ctx.params.f("jitter") / 100.0).clamp(0.0, 1.0) as f32;
|
||||
let op = ctx.params.f("opacity") as f32 / 100.0;
|
||||
let mode = ctx.params.e("blendingMode");
|
||||
@@ -126,7 +135,7 @@ fn four_color(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let mut wsum = 0.0;
|
||||
for ((qx, qy), c) in &pts {
|
||||
let d2 = (x as f64 + 0.5 - qx).powi(2) + (y as f64 + 0.5 - qy).powi(2);
|
||||
let w = 1.0 / (d2 / (blend * 100.0) + 1e-6).powf(1.5);
|
||||
let w = 1.0 / (d2 + k);
|
||||
for i in 0..3 {
|
||||
acc[i] += c[i] as f64 * w;
|
||||
}
|
||||
@@ -423,6 +432,59 @@ mod generator_tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// After Effects 2026, 4-Color Gradient defaults (yellow, green, magenta, blue at 10 % / 90 %
|
||||
/// of a 1920 × 1080 layer) at Blend 100 and 300, here on a tenth-size layer with Blend 10
|
||||
/// and 30 (the weights scale with the layer): colours at AE pixels (10x + 5, 10y + 5).
|
||||
#[test]
|
||||
fn four_color_blend_matches_after_effects() {
|
||||
let pts = [
|
||||
("positionsColors/point1", [19.2, 10.8]),
|
||||
("positionsColors/point2", [172.8, 10.8]),
|
||||
("positionsColors/point3", [19.2, 97.2]),
|
||||
("positionsColors/point4", [172.8, 97.2]),
|
||||
];
|
||||
let cols = [
|
||||
("positionsColors/color1", [1.0, 1.0, 0.0, 1.0]),
|
||||
("positionsColors/color2", [0.0, 1.0, 0.0, 1.0]),
|
||||
("positionsColors/color3", [1.0, 0.0, 1.0, 1.0]),
|
||||
("positionsColors/color4", [0.0, 0.0, 1.0, 1.0]),
|
||||
];
|
||||
let ae: [(f64, [(i64, i64, [f32; 3]); 5]); 2] = [
|
||||
(
|
||||
10.0,
|
||||
[
|
||||
(30, 20, [249.0, 240.0, 15.0]),
|
||||
(150, 30, [20.0, 208.0, 47.0]),
|
||||
(60, 90, [213.0, 56.0, 199.0]),
|
||||
(100, 10, [115.0, 177.0, 78.0]),
|
||||
(10, 50, [238.0, 145.0, 110.0]),
|
||||
],
|
||||
),
|
||||
(
|
||||
30.0,
|
||||
[
|
||||
(30, 20, [236.0, 214.0, 41.0]),
|
||||
(150, 30, [30.0, 189.0, 66.0]),
|
||||
(60, 90, [203.0, 67.0, 188.0]),
|
||||
(100, 10, [117.0, 173.0, 82.0]),
|
||||
(10, 50, [233.0, 140.0, 115.0]),
|
||||
],
|
||||
),
|
||||
];
|
||||
for (blend, samples) in ae {
|
||||
let mut vals: Vec<(&str, Value)> = pts.iter().map(|(k, v)| (*k, Value::Vec2(*v))).collect();
|
||||
vals.extend(cols.iter().map(|(k, v)| (*k, Value::Color(*v))));
|
||||
vals.push(("blend", num(blend)));
|
||||
let o = run_fx("ec.generate.fourcolor", &vals, Image::filled(192, 108, [1.0, 0.0, 0.0, 1.0]), 0.0, EffectEnv::default());
|
||||
for (x, y, want) in samples {
|
||||
let p = o.img.get(x, y);
|
||||
for c in 0..3 {
|
||||
assert!((p[c] * 255.0 - want[c]).abs() <= 3.0, "blend {blend} at ({x}, {y}) channel {c}: {} vs AE {}", p[c] * 255.0, want[c]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn four_color_none_stays_inside_the_layer_alpha() {
|
||||
// #1381: like the Gradient Ramp, the gradient fills a shape or text, not its bounding box.
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
//! matte clean-up. Written from public behaviour descriptions and the standard colour-difference /
|
||||
//! blue-screen matting literature (Vlahos; Smith & Blinn, "Blue Screen Matting").
|
||||
|
||||
use effectcraft_color::{luminance, rgb_to_hsl, srgb_to_linear};
|
||||
use effectcraft_color::{rgb_to_hsl, srgb_to_linear};
|
||||
use effectcraft_keyframe::Value;
|
||||
use effectcraft_project::ParamUi;
|
||||
use effectcraft_raster::{Image, gaussian_blur};
|
||||
use rayon::prelude::*;
|
||||
|
||||
use crate::color_fx::luma_601;
|
||||
use crate::util::{Plane, gauss_plane, morph_frac, premul, unpremul};
|
||||
use crate::{Buf, EffectCtx, EffectSpec, col, num, p, popup, slider};
|
||||
|
||||
@@ -135,46 +136,23 @@ fn color_key(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
|
||||
// ---- Luma Key ----
|
||||
|
||||
/// Luma Key as After Effects 2026 keys a grey ramp (cases 812-814, 719): Key Out Brighter and
|
||||
/// Darker cut hard at Threshold and ignore Tolerance; Key Out Similar removes, and Dissimilar
|
||||
/// keeps, a hard band of Threshold ± Tolerance (levels of 255). Edge Thin and Edge Feather
|
||||
/// then soften the cut.
|
||||
fn luma_key(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let kind = ctx.params.e("keyType");
|
||||
let thr = ctx.params.f("threshold") as f32 / 255.0;
|
||||
let tol = ctx.params.f("tolerance") as f32 / 255.0;
|
||||
let m = matte_of(&b.img, |c, _| {
|
||||
let l = luminance(c[0], c[1], c[2]);
|
||||
match kind {
|
||||
0 => {
|
||||
if tol > 0.0 {
|
||||
((thr + tol - l) / tol).clamp(0.0, 1.0)
|
||||
} else if l > thr {
|
||||
0.0
|
||||
} else {
|
||||
1.0
|
||||
}
|
||||
}
|
||||
2 => {
|
||||
if (l - thr).abs() <= tol {
|
||||
0.0
|
||||
} else {
|
||||
1.0
|
||||
}
|
||||
}
|
||||
3 => {
|
||||
if (l - thr).abs() <= tol {
|
||||
1.0
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if tol > 0.0 {
|
||||
((l - (thr - tol)) / tol).clamp(0.0, 1.0)
|
||||
} else if l < thr {
|
||||
0.0
|
||||
} else {
|
||||
1.0
|
||||
}
|
||||
}
|
||||
}
|
||||
let l = luma_601([c[0], c[1], c[2]]);
|
||||
let keep = match kind {
|
||||
0 => l <= thr,
|
||||
2 => (l - thr).abs() > tol,
|
||||
3 => (l - thr).abs() <= tol,
|
||||
_ => l >= thr,
|
||||
};
|
||||
if keep { 1.0 } else { 0.0 }
|
||||
});
|
||||
let m = thin_feather(m, ctx.params.f("edgeThin") * b.scale, ctx.params.f("edgeFeather") * b.scale);
|
||||
apply_matte(&mut b.img, &m, 0);
|
||||
@@ -334,7 +312,7 @@ fn extract(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
2 => c[1],
|
||||
3 => c[2],
|
||||
4 => a,
|
||||
_ => luminance(c[0], c[1], c[2]),
|
||||
_ => luma_601([c[0], c[1], c[2]]),
|
||||
};
|
||||
let mut k = 1.0f32;
|
||||
if v < bp {
|
||||
@@ -802,6 +780,68 @@ mod tests {
|
||||
assert_eq!(out.get(1, 0)[3], 1.0);
|
||||
}
|
||||
|
||||
/// After Effects 2026 (cases 812-814): Luma Key on a vertical black-to-white ramp (row y
|
||||
/// has level (y + 0.5) / 1080 × 255), Threshold 140, Tolerance 20. EffectCraft ramped the
|
||||
/// matte over the tolerance (case 719: a 50 % grey kept 80 % of its alpha where AE keys it
|
||||
/// out).
|
||||
#[test]
|
||||
fn luma_key_matches_after_effects() {
|
||||
let ramp = || {
|
||||
let mut img = Image::new(1, 1080);
|
||||
for y in 0..1080 {
|
||||
let v = (y as f32 + 0.5) / 1080.0;
|
||||
img.set(0, y, [v, v, v, 1.0]);
|
||||
}
|
||||
img
|
||||
};
|
||||
let key = |kind: u32, thin: f64, feather: f64| {
|
||||
let set =
|
||||
[("keyType", Value::Enum(kind)), ("threshold", num(140.0)), ("tolerance", num(20.0)), ("edgeThin", num(thin)), ("edgeFeather", num(feather))];
|
||||
let o = run("ec.key.luma", &set, ramp());
|
||||
(0..1080).map(|y| o.get(0, y)[3]).collect::<Vec<f32>>()
|
||||
};
|
||||
// Key Out Darker (AE 2 = EffectCraft 1): keyed up to row 592, kept from 593.
|
||||
let d = key(1, 0.0, 0.0);
|
||||
assert!(d[..593].iter().all(|a| *a == 0.0) && d[593..].iter().all(|a| *a == 1.0), "{:?}", &d[588..598]);
|
||||
// Key Out Similar: AE keys rows 509..=677 (levels 120.3..160.0) and keeps the rest; the
|
||||
// rows at the band's ends sit within 0.1 level of 120 / 160, so they may go either way.
|
||||
let s = key(2, 0.0, 0.0);
|
||||
assert!(
|
||||
(0..1080).filter(|y| !(507..=510).contains(y) && !(676..=679).contains(y)).all(|y| (s[y] == 0.0) == (509..=677).contains(&y)),
|
||||
"{:?} {:?}",
|
||||
&s[505..512],
|
||||
&s[674..681]
|
||||
);
|
||||
// Key Out Brighter and Dissimilar are the complements.
|
||||
let b = key(0, 0.0, 0.0);
|
||||
assert!(b[595] == 0.0 && b[590] == 1.0);
|
||||
let ds = key(3, 0.0, 0.0);
|
||||
assert!(ds[600] == 1.0 && ds[400] == 0.0 && ds[700] == 0.0);
|
||||
// A 50 % grey at Darker 140 / 20 is keyed out entirely (case 719).
|
||||
let o =
|
||||
run("ec.key.luma", &[("keyType", Value::Enum(1)), ("threshold", num(140.0)), ("tolerance", num(20.0))], Image::filled(2, 2, [0.5, 0.5, 0.5, 1.0]));
|
||||
assert_eq!(o.get(0, 0)[3], 0.0);
|
||||
// Edge Thin 1, Edge Feather 2.5 (case 814): AE's alpha rises over rows 591..596.
|
||||
let f = key(1, 1.0, 2.5);
|
||||
for (y, ae) in [(590, 0.0), (591, 0.026), (592, 0.139), (593, 0.357), (594, 0.635), (595, 0.868), (596, 0.97), (597, 1.0)] {
|
||||
assert!((f[y] - ae).abs() < 0.2, "row {y}: {} vs AE {ae}", f[y]);
|
||||
}
|
||||
}
|
||||
|
||||
/// After Effects 2026: Key Out Darker and Extract's Black Point at 65 / 166 / 24 on pure
|
||||
/// red / green / blue keep red and blue and key out green: Rec. 601 luminance (Rec. 709
|
||||
/// would key out red and blue and keep green).
|
||||
#[test]
|
||||
fn luma_key_and_extract_use_rec601_as_after_effects() {
|
||||
for (c, thr, kept) in [([1.0, 0.0, 0.0], 65.0, true), ([0.0, 1.0, 0.0], 166.0, false), ([0.0, 0.0, 1.0], 24.0, true)] {
|
||||
let img = Image::filled(2, 1, [c[0], c[1], c[2], 1.0]);
|
||||
let key = run("ec.key.luma", &[("keyType", Value::Enum(1)), ("threshold", num(thr))], img.clone());
|
||||
let ext = run("ec.key.extract", &[("blackPoint", num(thr))], img);
|
||||
let want = if kept { 1.0 } else { 0.0 };
|
||||
assert_eq!((key.get(0, 0)[3], ext.get(0, 0)[3]), (want, want), "{c:?} at {thr}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_keeps_range() {
|
||||
let mut img = Image::new(3, 1);
|
||||
|
||||
@@ -70,11 +70,11 @@ use std::collections::HashMap;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
pub use card3d::{CompLight, CompScene};
|
||||
pub use color_fx::{COLORAMA_PRESETS, ColoramaPalette};
|
||||
pub use color_fx::{
|
||||
HUESAT_CHANNELS, HUESAT_RANGES, LEVELS_CHANNELS, PHOTO_FILTER_CUSTOM, PHOTO_FILTERS, exposure_settings, fill_uses_masks, huesat_ranges_identity,
|
||||
levels_channel_ids, levels_channel_settings, levels_channels_identity, levels_clip,
|
||||
BW_TINT_LUMA, EXPOSURE_LINEAR_GAMMA, HUESAT_CHANNELS, HUESAT_RANGES, LEVELS_CHANNELS, LUMA_601, PHOTO_FILTER_CUSTOM, PHOTO_FILTERS, exposure_settings,
|
||||
fill_uses_masks, huesat_ranges_identity, levels_channel_ids, levels_channel_settings, levels_channels_identity, levels_clip,
|
||||
};
|
||||
pub use color_fx::{COLORAMA_PRESETS, ColoramaPalette};
|
||||
pub use color2::{Curve, auto_correct_settings, color_link_mode, color_link_sample, equalize_tables, luma_clip_points, shadow_highlight_amounts};
|
||||
pub use color3::OffsetCurve;
|
||||
pub use distort::transform_shutter;
|
||||
@@ -91,7 +91,7 @@ pub use sim3::{PgBlit, PgPlan, playground_plan, playground_state};
|
||||
pub use stylize2::strobe_on;
|
||||
// CPU helpers the GPU kernels share (effectcraft-gpu).
|
||||
pub use blur2::{camera_lens_plain, camera_lens_spans};
|
||||
pub use generate::gen_mode;
|
||||
pub use generate::{four_color_softening, gen_mode};
|
||||
pub use generate2::pattern_kind as cell_pattern_kind;
|
||||
pub use generate2::{BoltSeg, Contour, Ghost, RadioPlan, Wave, flare_ghosts, lightning_segments, radio_plan};
|
||||
pub use generate3::{
|
||||
@@ -406,6 +406,8 @@ pub struct EffectEnv<'a> {
|
||||
pub working_space: Option<effectcraft_color::ColorSpace>,
|
||||
/// Working-space pixels are linear light (Linearize Working Space).
|
||||
pub working_linear: bool,
|
||||
/// The project works in 32 bpc float (Levels' "Off for 32 bpc Color" clips only below it).
|
||||
pub float_depth: bool,
|
||||
/// The composition's shutter (angle and phase in degrees, samples per frame) when both the
|
||||
/// comp's and the layer's motion blur switches are on (Transform's Use Composition's
|
||||
/// Shutter Angle).
|
||||
|
||||
@@ -252,6 +252,7 @@ pub(crate) fn chain_key(chain: &[FxStep], buf: &Buf, levels: Option<f32>) -> u12
|
||||
k.u64(e.effect_index as u64);
|
||||
k.debug(&e.working_space);
|
||||
k.u64(e.working_linear as u64);
|
||||
k.u64(e.float_depth as u64);
|
||||
k.debug(&e.shutter);
|
||||
}
|
||||
k.finish()
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -90,11 +90,10 @@ fn point(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
|
||||
"ec.color.blackwhite" => {
|
||||
let w = ["reds", "yellows", "greens", "cyans", "blues", "magentas"].map(|k| f(k) / 100.0);
|
||||
let tc = pr.color("tintColor");
|
||||
let (th, ts, _) = rgb_to_hsl(tc[0], tc[1], tc[2]);
|
||||
p.u[0] = [3, pr.b("tint") as u32, 0, 0];
|
||||
p.f[0] = [w[0], w[1], w[2], w[3]];
|
||||
p.f[1] = [w[4], w[5], 0.0, 0.0];
|
||||
p.f[2] = [th, ts, 0.0, 0.0];
|
||||
p.f[2] = [tc[0], tc[1], tc[2], 0.0];
|
||||
}
|
||||
"ec.color.tritone" => {
|
||||
p.u[0][0] = 4;
|
||||
|
||||
@@ -353,10 +353,10 @@ fn four_color(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
|
||||
p.f[i] = [q.0 as f32, q.1 as f32, 0.0, 0.0];
|
||||
p.f[4 + i] = ctx.params.color(&format!("positionsColors/color{}", i + 1));
|
||||
}
|
||||
let blend = ctx.params.f("blend").max(1.0);
|
||||
let k = effectcraft_effects::four_color_softening(ctx.params.f("blend"), b.scale);
|
||||
let jitter = (ctx.params.f("jitter") / 100.0).clamp(0.0, 1.0) as f32;
|
||||
p.u[0] = [gen_mode(ctx.params.e("blendingMode")), ctx.seed, 0, 0];
|
||||
p.f[8] = [(blend * 100.0) as f32, jitter, ctx.params.f("opacity") as f32 / 100.0, 0.0];
|
||||
p.f[8] = [k as f32, jitter, ctx.params.f("opacity") as f32 / 100.0, 0.0];
|
||||
run(e, "gen_fourcolor", &p, b, None, None)
|
||||
}
|
||||
|
||||
|
||||
@@ -100,11 +100,36 @@ fn fxc_sc_weights(c0: vec3<f32>) -> array<f32, 9> {
|
||||
return w;
|
||||
}
|
||||
|
||||
// Black & White's Tint (effects::color_fx::bw_tint): the tint's hue and saturation at the
|
||||
// gray's luminosity (Color blend, weights 0.3 / 0.59 / 0.11, channels clipped toward the gray).
|
||||
// The weights are effects::BW_TINT_LUMA (checked by the test shader_constants_match_the_cpu).
|
||||
fn fxc_bw_tint(tint: vec3<f32>, gray: f32) -> vec3<f32> {
|
||||
if (!(gray > 0.0)) {
|
||||
return vec3<f32>(0.0);
|
||||
}
|
||||
if (gray >= 1.0) {
|
||||
return vec3<f32>(gray);
|
||||
}
|
||||
let w = vec3<f32>(0.3, 0.59, 0.11);
|
||||
let c = tint + vec3<f32>(gray - dot(tint, w));
|
||||
let l = dot(c, w);
|
||||
let n = min(min(c.x, c.y), c.z);
|
||||
let x = max(max(c.x, c.y), c.z);
|
||||
var o = c;
|
||||
if (n < 0.0 && l - n > 1e-9) {
|
||||
o = vec3<f32>(l) + (o - vec3<f32>(l)) * l / (l - n);
|
||||
}
|
||||
if (x > 1.0 && x - l > 1e-9) {
|
||||
o = vec3<f32>(l) + (o - vec3<f32>(l)) * (1.0 - l) / (x - l);
|
||||
}
|
||||
return o;
|
||||
}
|
||||
|
||||
// u[0].x = op:
|
||||
// 1 Color Balance f[0] = shadows, f[1] = midtones, f[2] = highlights; u[0].y = preserve luminosity
|
||||
// 2 Vibrance f[0] = (vibrance, saturation)
|
||||
// 3 Black & White f[0] = (reds, yellows, greens, cyans), f[1] = (blues, magentas);
|
||||
// u[0].y = tint; f[2] = (tint hue, tint saturation)
|
||||
// u[0].y = tint; f[2] = tint colour
|
||||
// 4 Tritone f[0] = highlights, f[1] = midtones, f[2] = shadows; f[3].x = 1 − blend
|
||||
// 5 Colorama u[0].y = phase mode, u[0].z = interpolate; f[0] = (cycles, shift, 1 − blend)
|
||||
// 6 Channel Mixer f[0..3] = output rows (r, g, b, const); u[0].y = monochrome
|
||||
@@ -159,7 +184,7 @@ fn fxc_point(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
let gray = max(mn + (mx - mn) * wt, 0.0);
|
||||
var o = vec3<f32>(gray);
|
||||
if (P.u[0].y != 0u) {
|
||||
o = hsl_to_rgb(P.f[2].x, P.f[2].y, min(gray, 1.0));
|
||||
o = fxc_bw_tint(P.f[2].xyz, gray);
|
||||
}
|
||||
px = vec4<f32>(o * a, a);
|
||||
}
|
||||
@@ -167,7 +192,9 @@ fn fxc_point(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
case 4u: {
|
||||
if (a > 0.0) {
|
||||
let c = px.xyz / a;
|
||||
let l = clamp(luminance(c), 0.0, 1.0);
|
||||
// Rec. 601 weights, as effects::color_fx::tritone: effects::LUMA_601 (checked by
|
||||
// the test shader_constants_match_the_cpu).
|
||||
let l = clamp(dot(c, vec3<f32>(0.299, 0.587, 0.114)), 0.0, 1.0);
|
||||
var t: vec3<f32>;
|
||||
if (l < 0.5) {
|
||||
t = fxc_mix(P.f[2].xyz, P.f[1].xyz, l * 2.0);
|
||||
|
||||
@@ -500,7 +500,7 @@ fn gen_fourcolor(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
let dx = f32(p.x) + 0.5 - q.x;
|
||||
let dy = f32(p.y) + 0.5 - q.y;
|
||||
let d2 = dx * dx + dy * dy;
|
||||
let w = 1.0 / pow(d2 / bl + 1e-6, 1.5);
|
||||
let w = 1.0 / (d2 + bl);
|
||||
acc += P.f[4 + i].xyz * w;
|
||||
wsum += w;
|
||||
}
|
||||
|
||||
@@ -62,6 +62,12 @@ fn fxk_range_components(space: u32, c: vec3<f32>) -> vec3<f32> {
|
||||
}
|
||||
|
||||
// keying::matte_levels.
|
||||
// Luma Key / Extract luminance: Rec. 601, effects::LUMA_601 (checked by the test
|
||||
// shader_constants_match_the_cpu).
|
||||
fn fxk_key_luma(c: vec3<f32>) -> f32 {
|
||||
return dot(c, vec3<f32>(0.299, 0.587, 0.114));
|
||||
}
|
||||
|
||||
fn fxk_matte_levels(v: f32, in_b: f32, in_w: f32, gamma: f32, out_b: f32, out_w: f32) -> f32 {
|
||||
var t = clamp((v - in_b / 255.0) / max((in_w - in_b) / 255.0, 1e-4), 0.0, 1.0);
|
||||
t = powz(t, 1.0 / max(gamma, 0.01));
|
||||
@@ -195,27 +201,17 @@ fn fxk_point(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
o = vec4<f32>(select(1.0, 0.0, d <= f0.w));
|
||||
}
|
||||
case 2u: {
|
||||
let l = luminance(c);
|
||||
let l = fxk_key_luma(c);
|
||||
let thr = f0.x;
|
||||
let tol = f0.y;
|
||||
var m: f32;
|
||||
// As effects::keying::luma_key: Brighter / Darker cut hard at the threshold
|
||||
// (Tolerance only shapes Similar / Dissimilar), as After Effects.
|
||||
switch u0.y {
|
||||
case 0u: {
|
||||
if (tol > 0.0) {
|
||||
m = clamp((thr + tol - l) / tol, 0.0, 1.0);
|
||||
} else {
|
||||
m = select(1.0, 0.0, l > thr);
|
||||
}
|
||||
}
|
||||
case 0u: { m = select(1.0, 0.0, l > thr); }
|
||||
case 2u: { m = select(1.0, 0.0, abs(l - thr) <= tol); }
|
||||
case 3u: { m = select(0.0, 1.0, abs(l - thr) <= tol); }
|
||||
default: {
|
||||
if (tol > 0.0) {
|
||||
m = clamp((l - (thr - tol)) / tol, 0.0, 1.0);
|
||||
} else {
|
||||
m = select(1.0, 0.0, l < thr);
|
||||
}
|
||||
}
|
||||
default: { m = select(1.0, 0.0, l < thr); }
|
||||
}
|
||||
o = vec4<f32>(m);
|
||||
}
|
||||
@@ -241,7 +237,7 @@ fn fxk_point(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
case 2u: { v = c.y; }
|
||||
case 3u: { v = c.z; }
|
||||
case 4u: { v = a; }
|
||||
default: { v = luminance(c); }
|
||||
default: { v = fxk_key_luma(c); }
|
||||
}
|
||||
v = 255.0 * v;
|
||||
let bp = f0.x;
|
||||
|
||||
@@ -20,6 +20,14 @@ fn fxt_rem360(x: f32) -> f32 {
|
||||
}
|
||||
|
||||
// Levels (Individual Controls) channel `i` (data: 5 × (ib, iw, g, ob, ow, identity)).
|
||||
// color_fx::levels_gamma: unclipped values below 0 mirror the curve.
|
||||
fn fxt_gamma(t: f32, g: f32) -> f32 {
|
||||
if (t >= 0.0) {
|
||||
return powz(t, 1.0 / g);
|
||||
}
|
||||
return -powz(-t, 1.0 / g);
|
||||
}
|
||||
|
||||
fn fxt_lv(i: u32, v: f32, clip_b: bool, clip_w: bool) -> f32 {
|
||||
let o = i * 6u;
|
||||
if (data[o + 5u] != 0.0) {
|
||||
@@ -32,7 +40,7 @@ fn fxt_lv(i: u32, v: f32, clip_b: bool, clip_w: bool) -> f32 {
|
||||
if (clip_w) {
|
||||
t = min(t, 1.0);
|
||||
}
|
||||
return data[o + 3u] + (data[o + 4u] - data[o + 3u]) * powz(max(t, 0.0), 1.0 / max(data[o + 2u], 0.01));
|
||||
return data[o + 3u] + (data[o + 4u] - data[o + 3u]) * fxt_gamma(t, max(data[o + 2u], 0.01));
|
||||
}
|
||||
|
||||
// color_fx::level (data offset o: ib, iw, g, ob, ow, clip black, clip white).
|
||||
@@ -44,8 +52,7 @@ fn fxt_level(o: u32, v: f32) -> f32 {
|
||||
if (data[o + 6u] != 0.0) {
|
||||
t = min(t, 1.0);
|
||||
}
|
||||
t = powz(max(t, 0.0), 1.0 / data[o + 2u]);
|
||||
return data[o + 3u] + (data[o + 4u] - data[o + 3u]) * t;
|
||||
return data[o + 3u] + (data[o + 4u] - data[o + 3u]) * fxt_gamma(t, data[o + 2u]);
|
||||
}
|
||||
|
||||
fn fxt_level_ident(o: u32) -> bool {
|
||||
@@ -177,7 +184,7 @@ fn fxt_point(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
let aa = max(a, 0.0);
|
||||
var nc: vec3<f32>;
|
||||
for (var i = 0u; i < 3u; i++) {
|
||||
nc[i] = fxt_lv(i + 1u, fxt_lv(0u, c[i], cb, cw), cb, cw);
|
||||
nc[i] = fxt_lv(0u, fxt_lv(i + 1u, c[i], cb, cw), cb, cw);
|
||||
}
|
||||
let na = clamp(clamp(fxt_lv(4u, aa, cb, cw), 0.0, 1.0), 0.0, 1.0);
|
||||
o = vec4<f32>(nc * na, na);
|
||||
|
||||
@@ -605,7 +605,9 @@ fn pointwise(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
case 1u: {
|
||||
if (a > 0.0) {
|
||||
let c = straight(px);
|
||||
let l = clamp(luminance(c), 0.0, 1.0);
|
||||
// Rec. 601 weights, as After Effects: effects::LUMA_601 (checked by the test
|
||||
// shader_constants_match_the_cpu).
|
||||
let l = clamp(0.299 * c.x + 0.587 * c.y + 0.114 * c.z, 0.0, 1.0);
|
||||
let blk = P.f[0].xyz;
|
||||
let wht = P.f[1].xyz;
|
||||
let t = blk + (wht - blk) * l;
|
||||
@@ -623,14 +625,20 @@ fn pointwise(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
} else if (P.u[0].y == 2u) {
|
||||
// Brightness & Contrast tone curves (effects crate bc_modern, AE-fitted #681).
|
||||
var o = c;
|
||||
let g = max(1.0 + 0.53 * ct, 0.05);
|
||||
// Negative settings invert the positive curves (reciprocal exponents).
|
||||
var g = max(1.0 + 0.53 * abs(ct), 0.05);
|
||||
if (ct < 0.0) {
|
||||
g = 1.0 / g;
|
||||
}
|
||||
var e = exp2(1.24 * abs(br));
|
||||
if (br < 0.0) {
|
||||
e = 1.0 / e;
|
||||
}
|
||||
for (var i = 0; i < 3; i++) {
|
||||
if (c[i] < 1.0) {
|
||||
var v = max(c[i], 0.0);
|
||||
if (br > 0.0) {
|
||||
v = 1.0 - powz(1.0 - v, exp2(1.24 * br));
|
||||
} else if (br < 0.0) {
|
||||
v = powz(v, exp2(-1.32 * br));
|
||||
if (br != 0.0) {
|
||||
v = 1.0 - powz(1.0 - v, e);
|
||||
}
|
||||
if (ct != 0.0) {
|
||||
if (v < 0.5) {
|
||||
@@ -702,7 +710,12 @@ fn pointwise(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
if (P.u[0].z != 0u) {
|
||||
t = min(t, 1.0);
|
||||
}
|
||||
t = powz(max(t, 0.0), 1.0 / g);
|
||||
// color_fx::levels_gamma: unclipped values below 0 mirror the curve.
|
||||
if (t >= 0.0) {
|
||||
t = powz(t, 1.0 / g);
|
||||
} else {
|
||||
t = -powz(-t, 1.0 / g);
|
||||
}
|
||||
o[i] = ob + (ow - ob) * t;
|
||||
}
|
||||
px = vec4<f32>(o * a, a);
|
||||
@@ -713,14 +726,16 @@ fn pointwise(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
let c = straight(px);
|
||||
var o = vec3<f32>(0.0);
|
||||
for (var i = 0; i < 3; i++) {
|
||||
// Per-channel gain, offset, gamma; negative values are mirrored.
|
||||
// Per-channel gain, offset, gamma; negative values are mirrored. The
|
||||
// linear-light conversion is a pure 2.2 power: effects::EXPOSURE_LINEAR_GAMMA
|
||||
// (checked by the test shader_constants_match_the_cpu).
|
||||
if (P.u[0].y != 0u) {
|
||||
let x = c[i] * P.f[0][i] + P.f[1][i];
|
||||
o[i] = sign(x) * powz(abs(x), 1.0 / P.f[2][i]);
|
||||
} else {
|
||||
let lin = srgb_to_linear(max(c[i], 0.0));
|
||||
let lin = sign(c[i]) * powz(abs(c[i]), 2.2);
|
||||
let x = lin * P.f[0][i] + P.f[1][i];
|
||||
o[i] = sign(x) * linear_to_srgb(powz(abs(x), 1.0 / P.f[2][i]));
|
||||
o[i] = sign(x) * powz(powz(abs(x), 1.0 / P.f[2][i]), 1.0 / 2.2);
|
||||
}
|
||||
}
|
||||
px = vec4<f32>(o * a, a);
|
||||
@@ -753,17 +768,13 @@ fn pointwise(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
let c = P.f[0];
|
||||
let invert = P.u[0].y != 0u;
|
||||
let opacity = P.f[1].x;
|
||||
// The fill colour at the layer's (or inverted) alpha times Opacity (color_fx::fill).
|
||||
var fa = px.w;
|
||||
if (invert) {
|
||||
fa = 1.0 - px.w;
|
||||
}
|
||||
let f = c.xyz * fa;
|
||||
let rgb = px.xyz + (f - px.xyz) * opacity;
|
||||
var alpha = px.w;
|
||||
if (invert) {
|
||||
alpha = px.w + (fa - px.w) * opacity;
|
||||
}
|
||||
px = vec4<f32>(rgb, alpha);
|
||||
fa = fa * opacity;
|
||||
px = vec4<f32>(c.xyz * fa, fa);
|
||||
}
|
||||
case 8u: {
|
||||
// effects::color2::map_ca: transparent pixels only change through the alpha curve.
|
||||
|
||||
@@ -880,6 +880,52 @@ fn effects_whose_kernels_failed_render_on_the_cpu() {
|
||||
}
|
||||
}
|
||||
|
||||
/// The GPU Tint gives After Effects 2026's greys for full-frame solids at its defaults (sRGB
|
||||
/// working space, not linearised): Rec. 601 weights, as the CPU (`color_fx::luma_601`).
|
||||
#[test]
|
||||
fn gpu_tint_defaults_match_after_effects_greys() {
|
||||
let Some(g) = gpu() else {
|
||||
// Visible skip (EFFECTCRAFT_REQUIRE_GPU=1 makes a missing adapter fail instead).
|
||||
eprintln!("gpu_tint_defaults_match_after_effects_greys: skipped: no GPU adapter");
|
||||
return;
|
||||
};
|
||||
for (col, want) in [([1.0, 0.0, 0.0], 76.0), ([0.0, 1.0, 0.0], 150.0), ([0.0, 0.0, 1.0], 29.0), ([0.5, 0.5, 0.5], 128.0)] {
|
||||
let mut s = Scene::new(BitDepth::Bpc32);
|
||||
s.p.settings.working_space = Some(ColorSpace::Srgb);
|
||||
let mut l = s.solid(col, 97, 61);
|
||||
s.effect(&mut l, "ec.color.tint", &[]);
|
||||
s.push(l);
|
||||
let mut r = Renderer::new(&s.p, &Pattern, RenderOpts { backend: Backend::Gpu, ..opts() });
|
||||
r.accel = Some(g);
|
||||
let o = g.render(&r, s.cid, Tick::ZERO).expect("the GPU renders the frame").get(48, 30);
|
||||
let want = want / 255.0;
|
||||
assert!(o[..3].iter().all(|v| (v - want).abs() <= 1.0 / 255.0), "{col:?}: {o:?} vs {want}");
|
||||
}
|
||||
}
|
||||
|
||||
/// WGSL has no way to read the CPU's constants, so the kernels repeat them as literals; this
|
||||
/// ties each literal to its Rust source (runs without a GPU): Rec. 601 luma for Tint (`pointwise`
|
||||
/// op 1), Tritone and Luma Key / Extract, Black & White's tint weights and Exposure's 2.2 power.
|
||||
#[test]
|
||||
fn shader_constants_match_the_cpu() {
|
||||
use effectcraft_effects::{BW_TINT_LUMA, EXPOSURE_LINEAR_GAMMA, LUMA_601};
|
||||
let kernels = include_str!("shaders/kernels.wgsl");
|
||||
let color = include_str!("shaders/fx_color.wgsl");
|
||||
let key = include_str!("shaders/fx_key.wgsl");
|
||||
let [r, g, b] = LUMA_601;
|
||||
let dot = format!("dot(c, vec3<f32>({r}, {g}, {b}))");
|
||||
assert!(kernels.contains(&format!("clamp({r} * c.x + {g} * c.y + {b} * c.z, 0.0, 1.0)")), "Tint luma in kernels.wgsl is not {LUMA_601:?}");
|
||||
assert!(color.contains(&format!("clamp({dot}, 0.0, 1.0)")), "Tritone luma in fx_color.wgsl is not {LUMA_601:?}");
|
||||
assert!(key.contains(&format!("return {dot};")), "fxk_key_luma in fx_key.wgsl is not {LUMA_601:?}");
|
||||
let [r, g, b] = BW_TINT_LUMA;
|
||||
assert!(color.contains(&format!("let w = vec3<f32>({r}, {g}, {b});")), "fxc_bw_tint weights are not {BW_TINT_LUMA:?}");
|
||||
let gamma = EXPOSURE_LINEAR_GAMMA;
|
||||
assert!(
|
||||
kernels.contains(&format!("powz(abs(c[i]), {gamma})")) && kernels.contains(&format!("1.0 / {gamma})")),
|
||||
"Exposure's power in kernels.wgsl is not {gamma}"
|
||||
);
|
||||
}
|
||||
|
||||
/// A layer whose bounds change every frame reads back at a new size each time: the staging
|
||||
/// buffers kept for reuse stay within budget instead of growing for the whole render (#2081).
|
||||
#[test]
|
||||
|
||||
@@ -1184,6 +1184,7 @@ impl<'a> Renderer<'a> {
|
||||
effect_index: 0,
|
||||
working_space: self.pipe.space,
|
||||
working_linear: self.pipe.linear,
|
||||
float_depth: self.pipe.levels.is_none(),
|
||||
shutter: self.mb_on(ctx, layer).then_some((ctx.comp.shutter_angle, ctx.comp.shutter_phase, ctx.comp.motion_blur_samples)),
|
||||
};
|
||||
// Video effects in stack order (index, group, spec); disabled and audio effects skipped.
|
||||
|
||||
@@ -254,6 +254,22 @@ fn effects_run_in_pipeline() {
|
||||
assert!(c[1] > 0.99 && c[0] < 0.01, "{c:?}");
|
||||
}
|
||||
|
||||
/// After Effects 2026, sRGB working space (not linearised), Tint at its defaults on full-frame
|
||||
/// solids: the effect sees the encoded solid colour and weights it Rec. 601.
|
||||
#[test]
|
||||
fn tint_in_an_srgb_working_space_matches_after_effects_greys() {
|
||||
for (col, want) in [([1.0, 0.0, 0.0], 76.0), ([0.0, 1.0, 0.0], 150.0), ([0.0, 0.0, 1.0], 29.0), ([0.5, 0.5, 0.5], 128.0)] {
|
||||
let (mut p, cid, comp) = setup();
|
||||
p.settings.working_space = Some(effectcraft_color::ColorSpace::Srgb);
|
||||
let mut l = solid(&mut p, &comp, col, 200, 100);
|
||||
add_effect(&mut p, &mut l, "ec.color.tint", [200.0, 100.0], &[]);
|
||||
p.comp_mut(cid).unwrap().layers.push(l);
|
||||
let o = render_frame(&p, cid, Tick::ZERO, 1.0).get(100, 50);
|
||||
let want = want / 255.0;
|
||||
assert!(o[..3].iter().all(|v| (v - want).abs() <= 1.0 / 255.0), "{col:?}: {o:?} vs {want}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Card Dance's Comp Camera through the default comp camera reproduces a 2D layer in place.
|
||||
#[test]
|
||||
fn card_dance_comp_camera_matches_the_default_view() {
|
||||
|
||||
+16
-5
@@ -15,7 +15,7 @@ After Effects', and does the preset library exist. The per-effect catalogue is t
|
||||
| Parameter names, order, popups, units, defaults | aligned in M9.12 | Agent-checked against After Effects' documentation, not against a live dump |
|
||||
| On the GPU | **280** (92%) | Measured: registry flags; GPU vs CPU agreement tested per effect |
|
||||
| 32-bit float, no clamping | 306 | Measured: registry flags |
|
||||
| **Rendered output matches After Effects** | **unmeasured** | No effect is compared with an After Effects render yet. `effectcraft_effects::fidelity::compare_frames` exists for the corpus ([fidelity/README.md](fidelity/README.md)); simulation effects are pinned by golden hashes of our own output (M13.27) |
|
||||
| **Rendered output matches After Effects** | **partly measured** | Some colour effects are checked against After Effects 2026 renders of our own test projects: Tint, Tritone, Black & White, Exposure, Levels, Curves, Luma Key, Extract, 4-Color Gradient and Fill within 1–3/255, Brightness & Contrast up to 6.2/255 (Color Correction, Generate and Keying rows below); every other effect is unmeasured. `effectcraft_effects::fidelity::compare_frames` exists for the corpus ([fidelity/README.md](fidelity/README.md)); simulation effects are pinned by golden hashes of our own output (M13.27) |
|
||||
| Animation presets | **54** original presets (8 text-animator, 23 Transitions, 23 Backgrounds) against After Effects' **621** `.ffx` presets | Measured: `crates/engine/presets/text_animators.json`, `crates/engine/presets/transitions.json`, `crates/engine/presets/backgrounds.json`; the After Effects `Presets` folder |
|
||||
| 26.x additions | Curl Noise (26.3) present (#463 open); effect colour labels (26.5) present in Effect Controls only, not in the Timeline; the rebuilt Effect Controls (26.5) missing | Release notes |
|
||||
|
||||
@@ -30,15 +30,15 @@ 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 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) close to After Effects 2026 ramps, not identical (up to 6.2/255 off; the test allows 7/255) (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 | Fractal on the CPU (needs f64); #526 (Reset) and #536 (Write-on marks) are closed |
|
||||
| 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 |
|
||||
| Immersive Video | 12 | 12 | — |
|
||||
| Keying | 12 | 12 | Key Cleaner approximated; #533 (wire-removal Frame Offset) is closed |
|
||||
| Keying | 12 | 12 | Luma Key and Extract key on Rec. 601 luminance, as After Effects 2026; Key Cleaner approximated; #533 (wire-removal Frame Offset) is closed |
|
||||
| Matte | 6 | 4 | Mocha shape reads our documented JSON, not Mocha's format |
|
||||
| Noise & Grain | 13 | 12 | Curl Noise (#463); Match Grain on the CPU |
|
||||
| Obsolete | 9 | 9 | — |
|
||||
| Obsolete | 9 | 9 | Luma Key matches After Effects 2026 on a grey ramp (hard cut, Tolerance only for Similar / Dissimilar) |
|
||||
| Paint | 1 | 0 | Tablet pressure not read ([hardware-parity.md](hardware-parity.md)) |
|
||||
| Perspective | 10 | 9 | Chromatic Aberrations pan/tilt crash (#580) |
|
||||
| Simulation | 18 | 18 | Particle Playground lacks Particle Exploder, text particles, ephemeral mapper; CC Particle World camera fixed (#397) |
|
||||
@@ -64,6 +64,17 @@ the GPU is performance, not parity.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-19 | minor | Tritone uses Rec. 601 luminance, measured against After Effects 2026 renders of a red solid and a grey ramp (CPU and GPU) |
|
||||
| 2026-10-19 | minor | Black & White's Tint keeps the gray's luminosity (Color blend), measured against After Effects 2026 renders (CPU and GPU) |
|
||||
| 2026-10-19 | minor | Levels: "Off for 32 bpc Color" no longer clips in 32 bpc projects, unclipped values extrapolate below Output Black; Levels (Individual Controls) applies the channel controls before the RGB ones, measured against After Effects 2026 (CPU and GPU) |
|
||||
| 2026-10-19 | minor | Brightness & Contrast: negative Brightness / Contrast are the inverse curves of the positive ones (Brightness −40: 20/255 → 6/255 off After Effects 2026; still up to 6.2/255 off, not the same pixels), CPU and GPU |
|
||||
| 2026-10-19 | minor | Luma Key: Key Out Brighter / Darker cut hard at Threshold, measured against After Effects 2026 renders of a ramp (CPU and GPU) |
|
||||
| 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 |
|
||||
| 2026-10-12 | minor | Effect colour labels (26.5) added in Effect Controls |
|
||||
| 2026-10-12 | minor | Tint's Swap Colors button and agent-drivable command (#963); undo/redo, instance targeting, animated layer-time edits and invalid-input tests |
|
||||
|
||||
Reference in New Issue
Block a user