mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-26 02:57:34 -06:00
Black & White: Tint keeps the gray's luminosity, as After Effects
After Effects tints Black & White's gray like a Color blend: the tint colour's hue and saturation at the gray's luminosity (weights 0.3 / 0.59 / 0.11, channels outside 0..1 pulled back toward the gray). EffectCraft used the gray as HSL lightness. Measured in AE 2026, Tint Color [1, 0.5, 0]: a red solid at Reds 10 (case 717) AE (43, 21, 0), EffectCraft was (51, 26, 0), now (43, 21, 0); a yellow solid at the default sliders (gray 0.6, case 825) AE (255, 129, 2), now (255, 129, 3); white stays white. The six sliders already matched AE on eight colours (cases 816, 817). CPU and GPU. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
960f41d50b
commit
70c1349cbc
@@ -343,6 +343,33 @@ 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.
|
||||
pub fn bw_tint(tint: [f32; 3], gray: f32) -> [f32; 3] {
|
||||
let lum = |c: [f32; 3]| 0.3 * c[0] + 0.59 * c[1] + 0.11 * 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);
|
||||
@@ -357,13 +384,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
|
||||
}
|
||||
@@ -1248,9 +1269,6 @@ mod tests {
|
||||
assert!(go(0.0, 100.0, 64) >= 30);
|
||||
}
|
||||
|
||||
/// #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).
|
||||
/// 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),
|
||||
@@ -1274,6 +1292,29 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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()));
|
||||
}
|
||||
}
|
||||
|
||||
/// #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).
|
||||
#[test]
|
||||
fn black_white_sliders_set_the_gray_of_each_hue() {
|
||||
let bw = |vals: &[(&str, Value)], c: [f32; 3]| px("ec.color.blackwhite", vals, [c[0], c[1], c[2], 1.0]);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -100,11 +100,35 @@ 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).
|
||||
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 +183,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);
|
||||
}
|
||||
|
||||
@@ -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 and Tritone match After Effects 2026 renders (Rec. 601 luminance); 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); 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 |
|
||||
@@ -65,6 +65,7 @@ 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-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-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 |
|
||||
|
||||
Reference in New Issue
Block a user