Fill: Opacity is the filled layer's opacity, as After Effects

Without a Fill Mask, After Effects turns the layer into the fill colour and
applies Opacity to its alpha; nothing of the original shows through. AE 2026
renders Fill (0, 0.5, 1) at Opacity 50 % on a Gradient Ramp over a red solid
as premultiplied (0, 0.251, 0.502) at alpha 0.502 at every pixel; EffectCraft
mixed the fill with the ramp (mean difference 95.5/255), now within 1/255,
CPU and GPU. Invert fills the transparent part the same way. With a Fill
Mask the mix with the original is unchanged (not measured). Lottie's fill
effect (colour, alpha x opacity) now plays the same in EffectCraft.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
vangel76
2026-10-19 16:16:12 +02:00
co-authored by Claude Opus 5.5
parent d7854b84a3
commit 84ffc8453e
3 changed files with 34 additions and 16 deletions
+29 -8
View File
@@ -480,15 +480,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
}
@@ -1666,6 +1663,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];
+3 -7
View File
@@ -766,17 +766,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.
+2 -1
View File
@@ -33,7 +33,7 @@ corpus compares renders, each effect is assumed to differ somewhere, and users k
| 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 |
| 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²)); 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 | 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 |
@@ -71,6 +71,7 @@ the GPU is performance, not parity.
| 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-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 |