Transform effect: skew after the rotation and drawn outside the layer, as After Effects

After Effects draws the Transform effect as position + K * R * S * (p - anchor)
with the skew K = rot(-axis) * shearY(tan skew) * rot(axis) applied after the
rotation, and keeps what lands outside the layer's bounds. EffectCraft skewed
before rotating (shearX about the axis) and clipped to the layer.

Measured in AE 2026 on a grey 960x540 solid (cases 723, 819, 820): 723 (anchor,
position, scale 120/80, skew 10 axis 20, rotation 30, opacity 70) differed in
11.25 % of the frame and now in 0.065 % (edges, max 16/255), CPU and GPU alike;
819 / 820 (skew 15 axis 60, rotation -25, scale 130/70, anchor) match to the
edge. The buffer grows on each side by that side's overshoot (also for the
motion-blur samples), capped at 16 384 px a side and 16 Mpx in all so a layer
moved far away costs a bounded allocation (regression test); adjustment layers
keep their size.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
vangel76
2026-10-19 16:25:44 +02:00
co-authored by Claude Opus 5.5
parent 60cf195236
commit d37c087975
5 changed files with 280 additions and 59 deletions
+217 -34
View File
@@ -275,27 +275,70 @@ pub fn transform_shutter(ctx: &EffectCtx) -> Option<(f64, f64, u32)> {
(angle > 0.0).then_some((angle, phase, n.clamp(2, 64)))
}
fn transform_matrix(pr: &crate::Params, b: &Buf) -> Mat3 {
let anchor = b.to_px(pr.v2("anchor"));
let pos = b.to_px(pr.v2("position"));
/// The Transform effect's matrix in buffer pixels (`to_px` maps layer pixels into the buffer),
/// as After Effects 2026 draws it (cases 723, 819, 820): `position + K · R · S · (p − anchor)`
/// with the skew `K = rot(−axis) · shearY(tan skew) · rot(axis)` applied after the rotation.
pub fn transform_matrix_px(pr: &crate::Params, to_px: impl Fn([f64; 2]) -> (f64, f64)) -> Mat3 {
let anchor = to_px(pr.v2("anchor"));
let pos = to_px(pr.v2("position"));
let sh = pr.f("scaleHeight");
let sw = if pr.b("uniform") { sh } else { pr.f("scaleWidth") };
let axis = pr.f("skewAxis");
let k = pr.f("skew").clamp(-85.0, 85.0).to_radians().tan();
let k = if k.is_finite() { k } else { 0.0 };
let skew = Mat3::rotate_deg(-axis) * Mat3([[1.0, 0.0, 0.0], [k, 1.0, 0.0], [0.0, 0.0, 1.0]]) * Mat3::rotate_deg(axis);
Mat3::translate(vec2(pos.0, pos.1))
* skew
* Mat3::rotate_deg(pr.f("rotation"))
* Mat3::skew_deg(pr.f("skew"), pr.f("skewAxis"))
* Mat3::scale(vec2(sw / 100.0, sh / 100.0))
* Mat3::translate(vec2(-anchor.0, -anchor.1))
}
fn transform_matrix(pr: &crate::Params, b: &Buf) -> Mat3 {
transform_matrix_px(pr, |p| b.to_px(p))
}
/// The transparent margins (left, top, right, bottom) a `w` x `h` buffer needs so its warp by
/// any of `ms` stays on it: After Effects draws Transform's result outside the layer's bounds
/// (case 723). Each side grows by its own overshoot; the grown buffer is capped at 16 384 px a
/// side and 16 Mpx in all (the margins shrink in proportion), so a layer moved far away costs
/// a bounded allocation.
pub fn transform_grow(ms: &[Mat3], w: u32, h: u32) -> [u32; 4] {
const SIDE: f64 = 16_384.0;
const AREA: f64 = 16_777_216.0;
let (wf, hf) = (w as f64, h as f64);
let mut m = [0.0f64; 4];
for mat in ms {
for (x, y) in [(0.0, 0.0), (wf, 0.0), (0.0, hf), (wf, hf)] {
let q = mat.apply(vec2(x, y));
if q.x.is_finite() && q.y.is_finite() {
m = [m[0].max(-q.x), m[1].max(-q.y), m[2].max(q.x - wf), m[3].max(q.y - hf)];
}
}
}
let m = m.map(|v| v.ceil().max(0.0));
let fit = |len: f64, a: f64, b: f64| -> f64 {
let room = (SIDE - len).max(0.0);
if a + b > room && a + b > 0.0 { room / (a + b) } else { 1.0 }
};
let kx = fit(wf, m[0], m[2]);
let ky = fit(hf, m[1], m[3]);
let area = |k: f64| (wf + (m[0] + m[2]) * kx * k) * (hf + (m[1] + m[3]) * ky * k);
let k = if area(1.0) > AREA && area(1.0) > wf * hf {
// Shrink both margins by the same factor until the area fits.
let (mut lo, mut hi) = (0.0f64, 1.0f64);
for _ in 0..40 {
let mid = (lo + hi) / 2.0;
if area(mid) > AREA { hi = mid } else { lo = mid }
}
lo
} else {
1.0
};
[m[0] * kx * k, m[1] * ky * k, m[2] * kx * k, m[3] * ky * k].map(|v| v.floor().clamp(0.0, SIDE) as u32)
}
fn transform(ctx: &EffectCtx, mut b: Buf) -> Buf {
let anchor = b.to_px(ctx.params.v2("anchor"));
let pos = b.to_px(ctx.params.v2("position"));
let uniform = ctx.params.b("uniform");
let sh = ctx.params.f("scaleHeight");
let sw = if uniform { sh } else { ctx.params.f("scaleWidth") };
let skew = ctx.params.f("skew");
let skew_axis = ctx.params.f("skewAxis");
let rot = ctx.params.f("rotation");
let opacity = ctx.params.f("opacity") as f32 / 100.0;
let sampling = if ctx.params.e("sampling") == 1 { effectcraft_raster::Sampling::Bicubic } else { effectcraft_raster::Sampling::Bilinear };
// Motion blur: the transform at Samples instants across the shutter, averaged (the
@@ -304,33 +347,35 @@ fn transform(ctx: &EffectCtx, mut b: Buf) -> Buf {
&& let Some(host) = ctx.env.host
{
let fd = 1.0 / ctx.fps();
let mut acc = Image::new(b.img.width, b.img.height);
let mut count = 0.0f32;
for k in 0..n {
let t = ctx.time + (phase + angle * k as f64 / (n - 1) as f64) / 360.0 * fd;
let Some(pr) = host.params_at(t) else { continue };
let mut one = Image::new(b.img.width, b.img.height);
let op = pr.f("opacity") as f32 / 100.0;
effectcraft_raster::composite_warp(
&mut one,
&b.img,
&transform_matrix(&pr, &b),
&effectcraft_raster::WarpOpts { opacity: op, sampling, ..Default::default() },
);
acc.data.iter_mut().zip(&one.data).for_each(|(a, o)| (0..4).for_each(|c| a[c] += o[c]));
count += 1.0;
}
if count > 0.0 {
let samples: Vec<crate::Params> = (0..n).filter_map(|k| host.params_at(ctx.time + (phase + angle * k as f64 / (n - 1) as f64) / 360.0 * fd)).collect();
if !samples.is_empty() {
if !ctx.adjustment {
let ms: Vec<Mat3> = samples.iter().map(|pr| transform_matrix(pr, &b)).collect();
b.pad_sides(transform_grow(&ms, b.img.width, b.img.height));
}
let mut acc = Image::new(b.img.width, b.img.height);
for pr in &samples {
let mut one = Image::new(b.img.width, b.img.height);
let op = pr.f("opacity") as f32 / 100.0;
effectcraft_raster::composite_warp(
&mut one,
&b.img,
&transform_matrix(pr, &b),
&effectcraft_raster::WarpOpts { opacity: op, sampling, ..Default::default() },
);
acc.data.iter_mut().zip(&one.data).for_each(|(a, o)| (0..4).for_each(|c| a[c] += o[c]));
}
let count = samples.len() as f32;
acc.data.iter_mut().for_each(|p| p.iter_mut().for_each(|v| *v /= count));
b.img = acc;
return b;
}
}
let m = Mat3::translate(vec2(pos.0, pos.1))
* Mat3::rotate_deg(rot)
* Mat3::skew_deg(skew, skew_axis)
* Mat3::scale(vec2(sw / 100.0, sh / 100.0))
* Mat3::translate(vec2(-anchor.0, -anchor.1));
if !ctx.adjustment {
let m = transform_matrix(ctx.params, &b);
b.pad_sides(transform_grow(&[m], b.img.width, b.img.height));
}
let m = transform_matrix(ctx.params, &b);
let mut out = Image::new(b.img.width, b.img.height);
effectcraft_raster::composite_warp(&mut out, &b.img, &m, &effectcraft_raster::WarpOpts { opacity, sampling, ..Default::default() });
b.img = out;
@@ -576,6 +621,144 @@ mod tests {
}
}
/// After Effects 2026 (cases 723, 819, 820): a grey 960 × 540 solid centred in a 1920 × 1080
/// comp with Transform. The skew applies after the rotation, and the result is drawn outside
/// the layer's bounds. Points are comp pixels sampled from AE's renders (at least 3 px from
/// an edge; true = the solid covers it), half where the old order and clipping differed.
#[test]
fn transform_matches_after_effects() {
let solid = Image::filled(960, 540, [0.5, 0.5, 0.5, 1.0]);
let p2 = |x: f64, y: f64| Value::Vec2([x, y]);
let cases: [(&str, Vec<(&str, Value)>, &[(u32, u32, bool)]); 3] = [
(
"723",
vec![
("anchor", p2(400.0, 200.0)),
("position", p2(500.0, 300.0)),
("uniform", Value::Bool(false)),
("scaleHeight", num(80.0)),
("scaleWidth", num(120.0)),
("skew", num(10.0)),
("skewAxis", num(20.0)),
("rotation", num(30.0)),
],
&[
(1465, 954, true),
(1127, 821, true),
(1378, 846, true),
(1416, 878, true),
(1486, 850, true),
(492, 467, true),
(706, 239, true),
(1402, 1021, true),
(518, 440, true),
(1218, 882, true),
(454, 448, true),
(1473, 920, true),
(1048, 654, true),
(1050, 486, true),
(1060, 736, true),
(932, 804, true),
(1074, 562, true),
(653, 591, true),
(1353, 466, false),
(88, 1021, false),
(1855, 886, false),
(1796, 401, false),
(497, 913, false),
(1445, 515, false),
],
),
(
"819",
vec![("skew", num(15.0)), ("skewAxis", num(60.0))],
&[
(465, 367, true),
(554, 793, false),
(481, 529, true),
(1451, 599, true),
(428, 575, true),
(432, 600, true),
(463, 444, true),
(759, 803, false),
(445, 684, true),
(1411, 757, true),
(915, 784, false),
(480, 737, true),
(758, 620, true),
(975, 705, true),
(1289, 376, true),
(575, 420, true),
(1015, 507, true),
(1377, 448, true),
(1679, 171, false),
(194, 435, false),
(413, 159, false),
(327, 741, false),
(1824, 138, false),
(1093, 29, false),
],
),
(
"820",
vec![
("skew", num(15.0)),
("skewAxis", num(60.0)),
("rotation", num(-25.0)),
("uniform", Value::Bool(false)),
("scaleWidth", num(130.0)),
("scaleHeight", num(70.0)),
("anchor", p2(100.0, 50.0)),
],
&[
(1674, 566, true),
(1425, 317, false),
(1788, 278, true),
(1530, 390, true),
(1531, 652, true),
(1589, 545, true),
(1890, 441, true),
(1724, 400, true),
(1657, 511, true),
(1895, 261, true),
(1697, 344, true),
(933, 840, true),
(1413, 467, true),
(1295, 691, true),
(1353, 548, true),
(1293, 548, true),
(1103, 551, true),
(973, 758, true),
(446, 484, false),
(1009, 420, false),
(144, 761, false),
(1812, 111, false),
(1241, 10, false),
(900, 854, false),
],
),
];
for (case, set, pts) in cases {
let o = run("ec.distort.transform", &set, solid.clone());
for &(x, y, inside) in pts {
// Comp pixel → layer pixel (the solid's top-left sits at 480, 270) → buffer.
let (bx, by) = o.to_px([x as f64 + 0.5 - 480.0, y as f64 + 0.5 - 270.0]);
let a = if bx >= 0.0 && by >= 0.0 && bx < o.img.width as f64 && by < o.img.height as f64 { o.img.get(bx as i64, by as i64)[3] } else { 0.0 };
assert_eq!(a > 0.5, inside, "case {case}: comp pixel ({x}, {y}) alpha {a}");
}
}
// A huge scale stays within the buffer cap.
let o = run("ec.distort.transform", &[("scaleHeight", num(100_000.0))], Image::filled(8, 8, [1.0; 4]));
assert!(o.img.width <= 16_384 && o.img.height <= 16_384);
// A layer moved far right grows the buffer on that side only, within 16 Mpx.
let o = run("ec.distort.transform", &[("position", p2(960.0 + 2000.0, 540.0))], Image::filled(1920, 1080, [1.0; 4]));
assert_eq!((o.offset[0], o.img.height), (0.0, 1080), "only the right side grows");
assert!(o.img.width >= 1920 + 2000 && o.img.width as u64 * o.img.height as u64 <= 16_777_216, "{} x {}", o.img.width, o.img.height);
assert!(o.img.get(2000 + 960, 540)[3] > 0.99 && o.img.get(10, 540)[3] < 0.01);
let o = run("ec.distort.transform", &[("position", p2(40_000.0, 30_000.0))], Image::filled(1920, 1080, [1.0; 4]));
assert!(o.img.width as u64 * o.img.height as u64 <= 16_777_216, "{} x {}", o.img.width, o.img.height);
}
#[test]
fn transform_sampling_bicubic_differs_from_bilinear() {
let im = img(32, 32);
+22 -1
View File
@@ -77,7 +77,7 @@ pub use color_fx::{
};
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;
pub use distort::{transform_grow, transform_matrix_px, transform_shutter};
pub use distort2::{MAGNIFY_MODES, bezier_grid, parse_mesh};
pub use distort3::{RESHAPE_POINTS, ReshapeSetup, SmearSetup, cc_smear_pad, reshape_setup, smear_setup, warp_inverse_map};
pub use distort4::liquify_mesh;
@@ -286,6 +286,27 @@ impl Buf {
self.offset[0] += origin[0] * self.scale;
self.offset[1] += origin[1] * self.scale;
}
/// Grow by transparent pixels: `sides` = left, top, right, bottom. Content keeps its place.
pub fn pad_sides(&mut self, sides: [u32; 4]) {
let [l, t, r, b] = sides;
if l == 0 && t == 0 && r == 0 && b == 0 {
return;
}
let (Some(w), Some(h)) = (self.img.width.checked_add(l).and_then(|v| v.checked_add(r)), self.img.height.checked_add(t).and_then(|v| v.checked_add(b)))
else {
return;
};
let mut out = Image::new(w, h);
let (sw, ow) = (self.img.width as usize, w as usize);
for y in 0..self.img.height as usize {
let at = (y + t as usize) * ow + l as usize;
let (Some(src), Some(dst)) = (self.img.data.get(y * sw..(y + 1) * sw), out.data.get_mut(at..at + sw)) else { continue };
dst.copy_from_slice(src);
}
self.img = out;
self.offset[0] += l as f64;
self.offset[1] += t as f64;
}
/// Grow by `pad` transparent pixels on every side.
pub fn pad(&mut self, pad: u32) {
if pad == 0 {
+25 -10
View File
@@ -2,7 +2,6 @@
//! radii, parameter conversions), with the pixel loops as compute kernels.
use effectcraft_effects::{Buf, EffectCtx};
use effectcraft_geom::{Mat3, vec2};
use effectcraft_raster::Sampling;
use effectcraft_render::FxStep;
@@ -27,6 +26,24 @@ impl GBuf {
(p[0] * self.scale + self.offset[0], p[1] * self.scale + self.offset[1])
}
/// Buf::pad_sides: grow by transparent pixels (left, top, right, bottom).
pub fn pad_sides(&mut self, e: &mut Enc, sides: [u32; 4]) -> Option<()> {
let [l, t, r, b] = sides;
if l == 0 && t == 0 && r == 0 && b == 0 {
return Some(());
}
let (w, h) = (self.img.width.checked_add(l)?.checked_add(r)?, self.img.height.checked_add(t)?.checked_add(b)?);
if !e.g.fits(w, h) {
return None;
}
let out = e.image(w, h);
e.copy_into(&self.img, &out, l, t);
self.img = out;
self.offset[0] += l as f64;
self.offset[1] += t as f64;
Some(())
}
/// Buf::pad: grow by `pad` transparent pixels on every side.
pub fn pad(&mut self, e: &mut Enc, pad: u32) -> Option<()> {
if pad == 0 {
@@ -370,16 +387,14 @@ fn transform(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option<GBuf> {
if ctx.env.host.is_some() && effectcraft_effects::transform_shutter(ctx).is_some() {
return None;
}
let anchor = b.to_px(ctx.params.v2("anchor"));
let pos = b.to_px(ctx.params.v2("position"));
let sh = ctx.params.f("scaleHeight");
let sw = if ctx.params.b("uniform") { sh } else { ctx.params.f("scaleWidth") };
let opacity = ctx.params.f("opacity") as f32 / 100.0;
let m = Mat3::translate(vec2(pos.0, pos.1))
* Mat3::rotate_deg(ctx.params.f("rotation"))
* Mat3::skew_deg(ctx.params.f("skew"), ctx.params.f("skewAxis"))
* Mat3::scale(vec2(sw / 100.0, sh / 100.0))
* Mat3::translate(vec2(-anchor.0, -anchor.1));
// As distort::transform: After Effects' matrix, drawn outside the layer's bounds.
if !ctx.adjustment {
let m = effectcraft_effects::transform_matrix_px(ctx.params, |p| b.to_px(p));
let sides = effectcraft_effects::transform_grow(&[m], b.img.width, b.img.height);
b.pad_sides(e, sides)?;
}
let m = effectcraft_effects::transform_matrix_px(ctx.params, |p| b.to_px(p));
let sampling = if ctx.params.e("sampling") == 1 { Sampling::Bicubic } else { Sampling::Bilinear };
let empty = e.image(b.img.width, b.img.height);
b.img = ops::warp(e, &empty, &b.img, &m, sampling, effectcraft_color::BlendMode::Normal, opacity, 0, None);
+14 -13
View File
@@ -970,15 +970,7 @@ pub(crate) fn transform_blur(ctx: &EffectCtx) -> bool {
/// distort::transform_matrix.
fn transform_matrix(pr: &effectcraft_effects::Params, b: &GBuf) -> Mat3 {
let anchor = b.to_px(pr.v2("anchor"));
let pos = b.to_px(pr.v2("position"));
let sh = pr.f("scaleHeight");
let sw = if pr.b("uniform") { sh } else { pr.f("scaleWidth") };
Mat3::translate(vec2(pos.0, pos.1))
* Mat3::rotate_deg(pr.f("rotation"))
* Mat3::skew_deg(pr.f("skew"), pr.f("skewAxis"))
* Mat3::scale(vec2(sw / 100.0, sh / 100.0))
* Mat3::translate(vec2(-anchor.0, -anchor.1))
effectcraft_effects::transform_matrix_px(pr, |p| b.to_px(p))
}
/// distort::transform's motion blur: the transform at Samples instants across the shutter,
@@ -988,15 +980,24 @@ pub(crate) fn transform(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option<GBu
let host = ctx.env.host?;
let sampling = if ctx.params.e("sampling") == 1 { Sampling::Bicubic } else { Sampling::Bilinear };
let fd = 1.0 / ctx.fps();
let samples: Vec<effectcraft_effects::Params> =
(0..n).filter_map(|k| host.params_at(ctx.time + (phase + angle * k as f64 / (n - 1) as f64) / 360.0 * fd)).collect();
if samples.is_empty() {
// The CPU renders the frame sharp.
return None;
}
if !ctx.adjustment {
let ms: Vec<Mat3> = samples.iter().map(|pr| transform_matrix(pr, &b)).collect();
let sides = effectcraft_effects::transform_grow(&ms, b.img.width, b.img.height);
b.pad_sides(e, sides)?;
}
let (w, h) = (b.img.width, b.img.height);
let mut acc = e.image(w, h);
let mut count = 0u32;
for k in 0..n {
let t = ctx.time + (phase + angle * k as f64 / (n - 1) as f64) / 360.0 * fd;
let Some(pr) = host.params_at(t) else { continue };
for pr in &samples {
let op = pr.f("opacity") as f32 / 100.0;
let empty = e.image(w, h);
let one = ops::warp(e, &empty, &b.img, &transform_matrix(&pr, &b), sampling, BlendMode::Normal, op, 0, None);
let one = ops::warp(e, &empty, &b.img, &transform_matrix(pr, &b), sampling, BlendMode::Normal, op, 0, None);
let sum = e.image(w, h);
e.pixels("fxs_add", &Params::default(), &acc, Some(&one), &sum, None);
acc = sum;
+2 -1
View File
@@ -31,7 +31,7 @@ corpus compares renders, each effect is assumed to differ somewhere, and users k
| Blur & Sharpen | 16 | 15 | Radial Blur keeps the layer edges as After Effects 2026 (713 now pixel-identical); 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 |
| Distort | 39 | 35 | Liquify has no viewer brush; #303 (Mesh Warp drag) and #534 (CC Power Pin Unstretch) are closed |
| Distort | 39 | 35 | Transform matches After Effects 2026 renders (skew after rotation, drawn outside the layer); 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 |
| Immersive Video | 12 | 12 | — |
@@ -70,6 +70,7 @@ the GPU is performance, not parity.
| 2026-10-19 | minor | Venetian Blinds: stripes measured from the layer centre, both band edges Gaussian (σ = Feather / 2), measured against After Effects 2026 (CPU and GPU) |
| 2026-10-19 | minor | Radial Wipe: Feather is in pixels on both edges of the wiped wedge (was degrees on the moving edge only), measured against After Effects 2026 (CPU and GPU) |
| 2026-10-19 | minor | Fractal / Turbulent Noise: lattice cell = Scale × 2/3 px (Block noise every 66.7 px at Scale 100, as After Effects 2026), CPU and GPU |
| 2026-10-19 | minor | Transform: After Effects' skew order and output beyond the layer bounds, measured against After Effects 2026 renders of three cases (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 |
| 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 |