mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-30 04:57:54 -06:00
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:
co-authored by
Claude Opus 5.5
parent
60cf195236
commit
d37c087975
+217
-34
@@ -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);
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user