From 710ffd9f7e921320f899ccbab2edd4f63b36be1b Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 19 Oct 2026 02:15:53 +0200 Subject: [PATCH] Shapes: Repeater copy opacity steps 1 / Copies toward End Opacity, as After Effects After Effects gives Repeater copy i the opacity Start + (End - Start) * i / Copies, so the last copy stops one step short of End Opacity. EffectCraft reached End Opacity at the last copy (i / (Copies - 1)). Measured in AE 2026 (case 709: 3 copies, End Opacity 40): copies at 100 / 80 / 60 % (EffectCraft 100 / 70 / 40); the overlap of the two copies 92 % (EffectCraft 82 %). Now within 2/255 at the sampled pixels. Co-Authored-By: Claude Opus 5.5 --- crates/render/src/shapes.rs | 5 ++++- crates/render/src/shapes/tests.rs | 22 ++++++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/crates/render/src/shapes.rs b/crates/render/src/shapes.rs index f8de5a290..641382440 100644 --- a/crates/render/src/shapes.rs +++ b/crates/render/src/shapes.rs @@ -395,7 +395,10 @@ fn collect(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, arena: &mut Vec 1 { i as f32 / (n - 1) as f32 } else { 0.0 }; + // Copy i's opacity moves 1 / Copies of the way from Start to End Opacity per + // copy, so the last copy stops one step short of End Opacity (After Effects + // 2026, case 709: 3 copies, End Opacity 40 give 100 %, 80 %, 60 %). + let t = if copies > 0.0 { (i as f64 / copies) as f32 } else { 0.0 }; let opk = so + (eo - so) * t; let partial = if i + 1 == n && copies.fract() > 0.0 { copies.fract() as f32 } else { 1.0 }; for d in &base_draws { diff --git a/crates/render/src/shapes/tests.rs b/crates/render/src/shapes/tests.rs index 1dbf67a7a..17591f310 100644 --- a/crates/render/src/shapes/tests.rs +++ b/crates/render/src/shapes/tests.rs @@ -150,6 +150,28 @@ fn operators_below_repeater_act_on_copies() { assert!((coverage(&img) - 3.0 * 24.0 * 24.0).abs() < 30.0, "{}", coverage(&img)); } +/// After Effects 2026 (case 709): a 400 × 200 rectangle with Fill, then a Repeater of 3 copies +/// (Position 150, 60, Rotation 10, Scale 90 / 80, End Opacity 40) gives the copies 100 %, 80 % +/// and 60 % opacity (EffectCraft gave 70 % and 40 %). The points are comp pixels where AE shows +/// only the last copy, only the middle one, and both. +#[test] +fn repeater_copy_opacity_matches_after_effects() { + let (p, cid) = shape_comp_sized(1920, 1080, |ids| { + let mut rep = build::shape_repeater(ids, 3.0, [150.0, 60.0]); + set(&mut rep, "transform/rotation", Value::Scalar(10.0)); + set(&mut rep, "transform/scale", Value::Vec2([90.0, 80.0])); + set(&mut rep, "transform/endOpacity", Value::Scalar(40.0)); + let items = vec![build::shape_rect(ids, [400.0, 200.0], [0.0, 0.0], 0.0), build::shape_fill(ids, [0.0, 1.0, 0.0, 1.0]), rep]; + vec![build::shape_group(ids, "Group 1", items)] + }); + let img = frame(&p, cid); + // AE's green over its red layer, 153, 153, 204, 235 and 255 of 255. + for (x, y, want) in [(1360, 700, 0.6), (1300, 620, 0.6), (1220, 560, 0.8), (1180, 630, 0.92), (1100, 600, 1.0)] { + let a = alpha(&img, x, y); + assert!((a - want).abs() < 2.0 / 255.0, "({x}, {y}): {a} vs AE {want}"); + } +} + #[test] fn repeater_then_merge_unites_copies() { let (p, cid) = shape_comp(|ids| {