Merge remote-tracking branch 'upstream/main' into aep-import-lumetri

This commit is contained in:
vangel76
2026-11-07 19:42:21 +01:00
45 changed files with 2550 additions and 317 deletions
+28
View File
@@ -0,0 +1,28 @@
name: CI (macOS)
# The macOS leg of the gate: `cargo xtask ci` on Apple silicon, through the suite's shared gate
# workflow. The Linux gate (ci.yml) has no real GPU and runs on x86, so GPU tests on Metal and
# results that depend on the platform's math library first ran on a contributor's Mac.
#
# On pushes to main, not on every pull request: macOS runners are scarce. A newer push cancels the
# older run, so main is checked at its latest commit. Pull requests that change this file run it.
on:
push:
branches: [main]
pull_request:
paths: [.github/workflows/ci-macos.yml]
workflow_dispatch:
permissions:
contents: read
concurrency:
group: ci-macos-${{ github.ref }}
cancel-in-progress: true
jobs:
gate:
uses: storytold/craft-common-actions/.github/workflows/gate.yml@main
with:
os: '["macos-15"]'
timeout-minutes: 120
+259 -51
View File
@@ -157,7 +157,8 @@ impl Default for TxScratch {
/// Saved encoder state of a block area (reconstruction, BlockDecoded flags).
struct Snapshot {
planes: [Vec<u16>; 3],
decoded: Box<[[[bool; 35]; 35]; 3]>,
/// BlockDecoded flags of the block area only (the only flags coding inside it can change).
decoded: [[[bool; 16]; 16]; 3],
}
pub(crate) struct Decider<'a> {
@@ -267,6 +268,13 @@ impl<'a> Decider<'a> {
}
}
/// The BlockDecoded cells `mark_decoded` can set for blocks inside (r, c, n4): rows and
/// columns `by + 1 .. by + 1 + step` / `bx + 1 ..` of `decoded[plane]`.
fn decoded_region(plane: usize, r: usize, c: usize, n4: usize) -> (usize, usize, usize) {
let s = (plane > 0) as usize;
((r & 15) >> s, (c & 15) >> s, (n4 >> s).max(1))
}
fn snapshot(&self, r: usize, c: usize, n4: usize) -> Snapshot {
let planes = std::array::from_fn(|p| {
let s = (p > 0) as usize;
@@ -278,7 +286,14 @@ impl<'a> Decider<'a> {
}
v
});
Snapshot { planes, decoded: self.decoded.clone() }
let mut decoded = [[[false; 16]; 16]; 3];
for (p, d) in decoded.iter_mut().enumerate() {
let (by, bx, step) = Self::decoded_region(p, r, c, n4);
for i in 0..step {
d[i][..step].copy_from_slice(&self.decoded[p][by + i + 1][bx + 1..bx + 1 + step]);
}
}
Snapshot { planes, decoded }
}
fn restore(&mut self, snap: &Snapshot, r: usize, c: usize, n4: usize) {
@@ -290,7 +305,12 @@ impl<'a> Decider<'a> {
pl.data[y * pl.stride + x0..y * pl.stride + x0 + n].copy_from_slice(&snap.planes[p][i * n..i * n + n]);
}
}
self.decoded.clone_from(&snap.decoded);
for p in 0..3 {
let (by, bx, step) = Self::decoded_region(p, r, c, n4);
for i in 0..step {
self.decoded[p][by + i + 1][bx + 1..bx + 1 + step].copy_from_slice(&snap.decoded[p][i][..step]);
}
}
}
fn set_mvs(&mut self, r: usize, c: usize, n4: usize, mv: [i32; 2]) {
@@ -588,15 +608,22 @@ impl<'a> Decider<'a> {
let comp = |d: i32| if d == 0 { 0.5 } else { 2.0 + 2.0 * log2_of(lg, (d.abs() / 8) as usize + 1) };
comp(m[0] - pred_mv[0]) + comp(m[1] - pred_mv[1])
};
let cost =
|m: [i32; 2]| -> f64 { self.sad_ref(&refr.planes[0], x0 + (m[1] >> 3), y0 + (m[0] >> 3), c * 4, r * 4, n) as f64 + self.lambda_sad * mv_bits(m) };
// `bound` is the cost to beat: the SAD stops early once the cost cannot be below it (the
// callers only accept a strictly lower cost, so the result is unchanged)
let cost = |m: [i32; 2], bound: f64| -> f64 {
let lb = self.lambda_sad * mv_bits(m);
self.sad_ref(&refr.planes[0], [x0 + (m[1] >> 3), y0 + (m[0] >> 3)], [c * 4, r * 4], n, [lb, bound]) as f64 + lb
};
// positions already evaluated cannot beat `best` again (costs are deterministic)
let mut seen: Vec<[i32; 2]> = Vec::with_capacity(64);
let mut best = ([0, 0], f64::MAX);
for &m in &cands {
let m = [m[0] & !7, m[1] & !7];
if !valid(m) {
if !valid(m) || seen.contains(&m) {
continue;
}
let cst = cost(m);
seen.push(m);
let cst = cost(m, best.1);
if cst < best.1 {
best = (m, cst);
}
@@ -617,10 +644,11 @@ impl<'a> Decider<'a> {
let center = best.0;
for d in [[-step, 0], [step, 0], [0, -step], [0, step]] {
let m = [center[0] + d[0], center[1] + d[1]];
if !valid(m) {
if !valid(m) || seen.contains(&m) {
continue;
}
let cst = cost(m);
seen.push(m);
let cst = cost(m, best.1);
if cst < best.1 {
best = (m, cst);
moved = true;
@@ -631,15 +659,18 @@ impl<'a> Decider<'a> {
}
// half- then quarter-sample refinement (plus the exact neighbour candidates)
let mut buf = vec![0u16; n * n];
let sub_cost = |m: [i32; 2], buf: &mut Vec<u16>| -> f64 {
let sub_cost = |m: [i32; 2], buf: &mut Vec<u16>, bound: f64| -> f64 {
self.search_block(m, c * 4, r * 4, n, buf);
self.sad(0, c * 4, r * 4, n, buf) as f64 + self.lambda_sad * mv_bits(m)
let lb = self.lambda_sad * mv_bits(m);
self.sad_bounded(0, [c * 4, r * 4], n, buf, [lb, bound]) as f64 + lb
};
let mut seen_sub: Vec<[i32; 2]> = Vec::with_capacity(24);
for m in cands {
if m[0] & 7 != 0 || m[1] & 7 != 0 {
let m = [m[0] & !1, m[1] & !1];
if valid(m) {
let cst = sub_cost(m, &mut buf);
if valid(m) && !seen_sub.contains(&m) {
seen_sub.push(m);
let cst = sub_cost(m, &mut buf, best.1);
if cst < best.1 {
best = (m, cst);
}
@@ -650,10 +681,11 @@ impl<'a> Decider<'a> {
let center = best.0;
for d in [[-1, -1], [-1, 0], [-1, 1], [0, -1], [0, 1], [1, -1], [1, 0], [1, 1]] {
let m = [center[0] + d[0] * step, center[1] + d[1] * step];
if !valid(m) {
if !valid(m) || seen_sub.contains(&m) {
continue;
}
let cst = sub_cost(m, &mut buf);
seen_sub.push(m);
let cst = sub_cost(m, &mut buf, best.1);
if cst < best.1 {
best = (m, cst);
}
@@ -739,7 +771,8 @@ impl<'a> Decider<'a> {
}
/// SAD of the source block at (x, y) (luma) against the reference block at (rx, ry).
fn sad_ref(&self, refp: &Plane, rx: i32, ry: i32, x: usize, y: usize, n: usize) -> u64 {
/// Stops after a row once `sad + lb >= bound` (the cost can no longer win).
fn sad_ref(&self, refp: &Plane, [rx, ry]: [i32; 2], [x, y]: [usize; 2], n: usize, [lb, bound]: [f64; 2]) -> u64 {
let (vw, vh) = (self.g.width, self.g.height);
let w = n.min(vw.saturating_sub(x));
let h = n.min(vh.saturating_sub(y));
@@ -759,45 +792,16 @@ impl<'a> Decider<'a> {
sad += (s[j] as i32 - b as i32).unsigned_abs() as u64;
}
}
if sad as f64 + lb >= bound {
return sad;
}
}
sad
}
/// Sum of absolute Hadamard-transformed differences (4x4 or 8x8 sub-blocks) of a prediction
/// against the source, scaled to about the size of the SAD.
fn satd(&self, p: usize, x: usize, y: usize, n: usize, pred: &[u16]) -> u64 {
let src = &self.src[p];
let k = n.min(8);
let mut total = 0u64;
let mut d = [0i32; 64];
for by in (0..n).step_by(k) {
for bx in (0..n).step_by(k) {
for i in 0..k {
let row = &src.row(y + by + i)[x + bx..x + bx + k];
for j in 0..k {
d[i * k + j] = row[j] as i32 - pred[(by + i) * n + bx + j] as i32;
}
}
for i in 0..k {
hadamard(&mut d[i * k..i * k + k]);
}
let mut col = [0i32; 8];
let mut s = 0u64;
for j in 0..k {
for i in 0..k {
col[i] = d[i * k + j];
}
hadamard(&mut col[..k]);
s += col[..k].iter().map(|v| v.unsigned_abs() as u64).sum::<u64>();
}
total += s / (k as u64 / 2);
}
}
total
}
/// SAD of a prediction against the source over the visible part of the block.
fn sad(&self, p: usize, x: usize, y: usize, n: usize, pred: &[u16]) -> u64 {
/// SAD of a prediction against the source over the visible part of the block. Stops after a row
/// once `sad + lb >= bound` (the cost can no longer win).
fn sad_bounded(&self, p: usize, [x, y]: [usize; 2], n: usize, pred: &[u16], [lb, bound]: [f64; 2]) -> u64 {
let (vw, vh) = self.g.visible(p);
let w = n.min(vw.saturating_sub(x));
let h = n.min(vh.saturating_sub(y));
@@ -806,10 +810,60 @@ impl<'a> Decider<'a> {
for i in 0..h {
let row = &src.row(y + i)[x..x + w];
s += row.iter().zip(&pred[i * n..i * n + w]).map(|(&a, &b)| (a as i32 - b as i32).unsigned_abs()).sum::<u32>() as u64;
if s as f64 + lb >= bound {
return s;
}
}
s
}
/// Sum of absolute Hadamard-transformed differences (4x4 or 8x8 sub-blocks) of a prediction
/// against the source, scaled to about the size of the SAD.
fn satd(&self, p: usize, x: usize, y: usize, n: usize, pred: &[u16]) -> u64 {
if n >= 8 { self.satd_k::<8>(p, x, y, n, pred) } else { self.satd_k::<4>(p, x, y, n, pred) }
}
/// `satd` with K x K Hadamard sub-blocks (fixed sizes so the butterflies vectorise).
fn satd_k<const K: usize>(&self, p: usize, x: usize, y: usize, n: usize, pred: &[u16]) -> u64 {
let src = &self.src[p];
let mut total = 0u64;
for by in (0..n).step_by(K) {
for bx in (0..n).step_by(K) {
// d[i] holds row i of the differences
let mut d = [[0i32; K]; K];
for (i, di) in d.iter_mut().enumerate() {
let row = &src.row(y + by + i)[x + bx..x + bx + K];
let pr = &pred[(by + i) * n + bx..(by + i) * n + bx + K];
for j in 0..K {
di[j] = row[j] as i32 - pr[j] as i32;
}
}
// vertical butterflies across rows (all columns at once)
let mut h = 1;
while h < K {
for i0 in (0..K).step_by(2 * h) {
for i in i0..i0 + h {
let (a, b) = (d[i], d[i + h]);
for j in 0..K {
d[i][j] = a[j] + b[j];
d[i + h][j] = a[j] - b[j];
}
}
}
h *= 2;
}
// horizontal butterflies within each row
let mut s = 0u64;
for di in d.iter_mut() {
hadamard(di);
s += di.iter().map(|v| v.unsigned_abs() as u64).sum::<u64>();
}
total += s / (K as u64 / 2);
}
}
total
}
fn sse(&self, p: usize, x: usize, y: usize, n: usize, blk: &[u16]) -> f64 {
let (vw, vh) = self.g.visible(p);
let w = n.min(vw.saturating_sub(x));
@@ -1062,4 +1116,158 @@ mod tests {
assert_eq!(log2_of(t, v).to_bits(), (v as f64).log2().to_bits(), "{v}");
}
}
/// The SATD as it was before the fixed-size version: runtime block size, column gather.
fn satd_ref(src: &Plane, x: usize, y: usize, n: usize, pred: &[u16]) -> u64 {
let k = n.min(8);
let mut total = 0u64;
let mut d = [0i32; 64];
for by in (0..n).step_by(k) {
for bx in (0..n).step_by(k) {
for i in 0..k {
let row = &src.row(y + by + i)[x + bx..x + bx + k];
for j in 0..k {
d[i * k + j] = row[j] as i32 - pred[(by + i) * n + bx + j] as i32;
}
}
for i in 0..k {
hadamard(&mut d[i * k..i * k + k]);
}
let mut col = [0i32; 8];
let mut s = 0u64;
for j in 0..k {
for i in 0..k {
col[i] = d[i * k + j];
}
hadamard(&mut col[..k]);
s += col[..k].iter().map(|v| v.unsigned_abs() as u64).sum::<u64>();
}
total += s / (k as u64 / 2);
}
}
total
}
#[test]
fn satd_fixed_size_matches_runtime_size() {
let g = FrameGeom::new(100, 80);
let mut state = 0x9e37_79b9_7f4a_7c15u64;
let mut next = move || {
state ^= state << 13;
state ^= state >> 7;
state ^= state << 17;
state
};
for bd in [8u32, 10] {
let max = (1u64 << bd) - 1;
let mut mk = |w: usize, h: usize| {
let mut p = Plane::new(w, h);
p.data.iter_mut().for_each(|v| *v = (next() % (max + 1)) as u16);
p
};
let src = [mk(128, 128), mk(64, 64), mk(64, 64)];
let dec = Decider::new(&g, bd, 100, true, &src, None);
for n in [4usize, 8, 16, 32] {
let pred: Vec<u16> = (0..n * n).map(|_| (next() % (max + 1)) as u16).collect();
for (p, x, y) in [(0, 0, 0), (0, 32, 40), (1, 8, 4)] {
assert_eq!(dec.satd(p, x, y, n, &pred), satd_ref(&src[p], x, y, n, &pred), "n {n} plane {p}");
}
}
}
}
fn xorshift(seed: u64) -> impl FnMut() -> u64 {
let mut state = seed;
move || {
state ^= state << 13;
state ^= state >> 7;
state ^= state << 17;
state
}
}
/// A snapshot keeps only the BlockDecoded cells of the block area. Coding inside the block
/// marks sub-blocks of every smaller size; restoring must give back the whole 3x35x35 array
/// as it was, for every block size of a 64x64 superblock and every aligned position.
#[test]
fn snapshot_restore_brings_back_every_blockdecoded_flag() {
let g = FrameGeom::new(256, 256);
let mut next = xorshift(0x2545_f491_4f6c_dd1d);
let src = [Plane::new(256, 256), Plane::new(128, 128), Plane::new(128, 128)];
let mut dec = Decider::new(&g, 8, 100, true, &src, None);
for random_start in [false, true] {
for n4 in [1usize, 2, 4, 8, 16] {
for sb in [0usize, 16] {
for r in (sb..sb + 16).step_by(n4) {
for c in (sb..sb + 16).step_by(n4) {
for plane in dec.decoded.iter_mut() {
for row in plane.iter_mut() {
for v in row.iter_mut() {
*v = random_start && next() & 1 == 1;
}
}
}
let before = dec.decoded.clone();
let snap = dec.snapshot(r, c, n4);
// every sub-block of every size inside the block, as coding marks them
let mut m = n4;
loop {
for rr in (r..r + n4).step_by(m) {
for cc in (c..c + n4).step_by(m) {
for p in 0..3 {
dec.mark_decoded(p, rr, cc, m);
}
}
}
if m == 1 {
break;
}
m /= 2;
}
dec.restore(&snap, r, c, n4);
assert!(dec.decoded == before, "n4 {n4} at ({r}, {c}), random start {random_start}");
}
}
}
}
}
}
/// The bounded SADs return the full SAD whenever the cost can still win (`sad + lb < bound`)
/// and otherwise a partial sum that already loses, so the callers' strict `<` decides the same.
#[test]
fn bounded_sad_stops_only_once_the_cost_cannot_win() {
let g = FrameGeom::new(100, 80);
let mut next = xorshift(0x9e37_79b9_7f4a_7c15);
let mut mk = |w: usize, h: usize| {
let mut p = Plane::new(w, h);
p.data.iter_mut().for_each(|v| *v = (next() % 256) as u16);
p
};
let src = [mk(128, 128), mk(64, 64), mk(64, 64)];
let refp = mk(128, 128);
let mut next = xorshift(0x1234_5678_9abc_def1);
let dec = Decider::new(&g, 8, 100, false, &src, None);
for n in [4usize, 8, 16, 32] {
for (x, y, rx, ry) in [(0usize, 0usize, 0i32, 0i32), (32, 40, 29, 43), (96, 72, 90, 70), (8, 8, -5, -3)] {
let full_ref = dec.sad_ref(&refp, [rx, ry], [x, y], n, [0.0, f64::INFINITY]);
let pred: Vec<u16> = (0..n * n).map(|_| (next() % 256) as u16).collect();
let full_pred = dec.sad_bounded(0, [x, y], n, &pred, [0.0, f64::INFINITY]);
for _ in 0..50 {
let lb = (next() % 64) as f64 * 0.75;
let b_ref = (next() % (2 * full_ref + 2)) as f64;
let b_pred = (next() % (2 * full_pred + 2)) as f64;
let got_ref = dec.sad_ref(&refp, [rx, ry], [x, y], n, [lb, b_ref]);
let got_pred = dec.sad_bounded(0, [x, y], n, &pred, [lb, b_pred]);
for (got, full, bound) in [(got_ref, full_ref, b_ref), (got_pred, full_pred, b_pred)] {
if full as f64 + lb < bound {
assert_eq!(got, full, "n {n} at ({x}, {y}) bound {bound}");
} else {
assert!(got as f64 + lb >= bound && got <= full, "n {n} at ({x}, {y}) bound {bound}");
}
}
}
}
}
}
}
+189 -20
View File
@@ -4,8 +4,10 @@
//! These read the layer at *other* layer times through [`EffectHost::self_at`]: its source with
//! its masks and the effects applied before them, as After Effects renders them (SceneYard:
//! Echo after CC Particle Systems II echoes the fireworks, not the black solid under them;
//! Posterize Time after Turbulent Displace steps the displacement). Without a host (tests,
//! thumbnails) they pass their input through.
//! Posterize Time after Turbulent Displace steps the displacement). Timewarp is the exception:
//! it retimes the layer's source with its masks and ignores the effects above it (After
//! Effects 2026: a red solid with a Gradient Ramp and then Timewarp shows the red solid).
//! Without a host (tests, thumbnails) they pass their input through.
//!
//! Timewarp's Pixel Motion estimates block-matching optical flow between neighbouring frames
//! ([`effectcraft_raster::flow`]); Pixel Motion Blur averages sub-frame samples.
@@ -163,15 +165,21 @@ struct Frames {
imgs: Vec<Image>,
}
/// Fetch the layer (source + masks) at layer times `times`. `None` without a host.
/// Fetch the layer (source + masks + the effects before this one) at layer times `times`.
/// `None` without a host.
fn fetch(ctx: &EffectCtx, times: &[f64]) -> Option<Frames> {
fetch_with(ctx, times, usize::MAX)
}
/// [`fetch`] with the first `effects` effects of the stack only (0: source and masks).
fn fetch_with(ctx: &EffectCtx, times: &[f64], effects: usize) -> Option<Frames> {
let host = ctx.env.host?;
// Dedupe times (sub-microsecond differences are the same frame).
let mut uniq: Vec<i64> = times.iter().map(|t| (t * 1e6).round() as i64).collect();
uniq.sort_unstable();
uniq.dedup();
// Every effect before this one (the host bounds the count to them).
let bufs: Vec<(i64, Option<Buf>)> = uniq.iter().map(|&k| (k, host.self_at(k as f64 / 1e6, usize::MAX))).collect();
let bufs: Vec<(i64, Option<Buf>)> = uniq.iter().map(|&k| (k, host.self_at(k as f64 / 1e6, effects))).collect();
let first = bufs.iter().find_map(|(_, b)| b.as_ref())?;
let scale = first.scale;
// Union of every frame's extent in layer pixels (offset = where layer (0,0) sits).
@@ -261,6 +269,9 @@ fn mean_of(ctx: &EffectCtx, b: Buf, times: &[f64]) -> Buf {
// ---------------------------------------------------------------- Echo
/// Echo: the layer at the current time and `numberOfEchoes` earlier (or later) times
/// `echoTime` apart, each weighted Starting Intensity × Decay^i and combined by the Echo Operator
/// (Add, Maximum, Minimum, Screen, Composite In Back, Composite In Front, Blend).
fn echo(ctx: &EffectCtx, b: Buf) -> Buf {
let n = ctx.params.f("numberOfEchoes").round().clamp(0.0, 255.0) as usize;
let dt = ctx.params.f("echoTime");
@@ -270,7 +281,6 @@ fn echo(ctx: &EffectCtx, b: Buf) -> Buf {
let times: Vec<f64> = (0..=n).map(|i| ctx.time + i as f64 * dt).collect();
let Some(fr) = fetch(ctx, &times) else { return b };
let weights: Vec<f32> = (0..=n).map(|i| start * decay.powi(i as i32)).collect();
let count = (n + 1) as f32;
let w = fr.grid.img.width;
let h = fr.grid.img.height;
let mut out = Image::new(w, h);
@@ -289,23 +299,26 @@ fn echo(ctx: &EffectCtx, b: Buf) -> Buf {
2 => (0..4).for_each(|c| acc[c] = acc[c].min(q[c])),
// Screen
3 => (0..4).for_each(|c| acc[c] = acc[c] + q[c] - acc[c] * q[c]),
// Composite In Back: each later echo goes behind what is there.
// Composite In Back: the layer goes in back, each echo is composited over what
// is there, the oldest on top, with its weight as opacity (After Effects 2026,
// opaque layer, one and three echoes). Whether the weight scales the alpha of a
// transparent layer's echo or fades it is not measured.
4 => {
let ia = 1.0 - acc[3];
(0..4).for_each(|c| acc[c] += q[c] * ia);
}
// Composite In Front: each later echo goes on top.
5 => {
let ia = 1.0 - q[3];
(0..4).for_each(|c| acc[c] = q[c] + acc[c] * ia);
}
// Blend (average) and Add
// Composite In Front: the layer stays in front, each echo goes behind what is
// there (After Effects 2026: an opaque layer hides its echo).
5 => {
let ia = 1.0 - acc[3];
(0..4).for_each(|c| acc[c] += q[c] * ia);
}
// Add, and Blend: After Effects 2026 sums the weighted frames for Blend as well,
// with no normalisation (3 echoes at intensity 1 give 4x the layer; intensity
// 0.5, decay 0.5 and 2 echoes give alpha 0.875).
_ => (0..4).for_each(|c| acc[c] += q[c]),
}
}
if op == 6 {
acc.iter_mut().for_each(|c| *c /= count);
}
if op == 2 && acc[0] == f32::MAX {
acc = [0.0; 4];
}
@@ -851,7 +864,8 @@ pub fn timewarp_plan(ctx: &EffectCtx) -> Option<TimewarpPlan> {
let (instants, times, fps) = timewarp_instants(ctx);
let method = ctx.params.e("method");
let one = method > 0 && ctx.params.b("tuning/buildFromOneImage");
let fr = fetch(ctx, &times)?;
// The source with its masks, without the effects above Timewarp.
let fr = fetch_with(ctx, &times, 0)?;
let pm = PixelMotion::from(ctx, fr.grid.scale);
let mattes = fetch_layer(ctx, "matteLayer", &times, &fr.grid);
let warps = fetch_layer(ctx, "warpLayer", &times, &fr.grid);
@@ -1082,8 +1096,8 @@ mod tests {
None
}
fn self_at(&self, t: f64, effects: usize) -> Option<Buf> {
// Time effects see every effect before them.
assert_eq!(effects, usize::MAX);
// Time effects see every effect before them; Timewarp only the source and masks.
assert!(effects == usize::MAX || effects == 0, "{effects}");
let v = if self.static_layer { 0.4 } else { t as f32 };
Some(Buf { img: Image::filled(6, 4, [v, v, v, 1.0]), offset: [0.0; 2], scale: 1.0 })
}
@@ -1094,6 +1108,32 @@ mod tests {
run_fx(id, vals, Image::filled(6, 4, [0.9, 0.0, 0.0, 1.0]), t, env)
}
/// After Effects 2026: Timewarp retimes the layer's source (with masks) and ignores the
/// effects above it; Echo keeps them.
#[test]
fn timewarp_reads_the_source_without_the_effects_above_it() {
struct Asked(std::sync::Mutex<Vec<usize>>);
impl EffectHost for Asked {
fn layer(&self, _: u64, _: bool) -> Option<LayerPixels> {
None
}
fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option<Vec<f32>> {
None
}
fn self_at(&self, _: f64, effects: usize) -> Option<Buf> {
self.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner).push(effects);
// The source (effects 0) is red, the layer with its effects green.
let c = if effects == 0 { [1.0, 0.0, 0.0, 1.0] } else { [0.0, 1.0, 0.0, 1.0] };
Some(Buf { img: Image::filled(6, 4, c), offset: [0.0; 2], scale: 1.0 })
}
}
let host = Asked(std::sync::Mutex::new(vec![]));
let env = EffectEnv { host: Some(&host), frame_rate: 10.0, comp_time: 1.0, ..Default::default() };
let out = run_fx("ec.time.timewarp", &[("speed", num(25.0))], Image::filled(6, 4, [0.0, 1.0, 0.0, 1.0]), 1.0, env);
assert_eq!(out.img.get(2, 2), [1.0, 0.0, 0.0, 1.0]);
assert!(host.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner).iter().all(|&e| e == 0));
}
#[test]
fn posterize_time_holds_frames() {
let h = Clock { static_layer: false };
@@ -1112,7 +1152,7 @@ mod tests {
let want = 0.8 + 0.5 * 0.7 + 0.25 * 0.6;
assert!((out.img.data[0][0] - want as f32).abs() < 1e-4, "{}", out.img.data[0][0]);
assert_eq!(out.img.data[0][3], 1.0);
// Maximum picks the brightest weighted frame; Blend averages.
// Maximum picks the brightest weighted frame; Blend sums like Add (#3124).
let mut v = vals.to_vec();
v.push(("echoOperator", Value::Enum(1)));
let out = run("ec.time.echo", &v, 0.8, &h);
@@ -1120,7 +1160,136 @@ mod tests {
v.pop();
v.push(("echoOperator", Value::Enum(6)));
let out = run("ec.time.echo", &v, 0.8, &h);
assert!((out.img.data[0][0] - want as f32 / 3.0).abs() < 1e-4);
assert!((out.img.data[0][0] - want as f32).abs() < 1e-4, "{}", out.img.data[0][0]);
}
/// Row y = 130, x = 60..111 of the importer sample's SRC test card (#3124): a black-to-white
/// ramp across 320 px under an opaque white 30 px square whose centre moves from x = 20 at
/// 0 s to x = 300 at 2 s.
struct MovingSquare;
impl EffectHost for MovingSquare {
fn layer(&self, _: u64, _: bool) -> Option<LayerPixels> {
None
}
fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option<Vec<f32>> {
None
}
fn self_at(&self, t: f64, _: usize) -> Option<Buf> {
let centre = 20.0 + 140.0 * t;
let (s0, s1) = (centre - 15.0, centre + 15.0);
let mut img = Image::new(52, 1);
for (i, px) in img.data.iter_mut().enumerate() {
let x = 60.0 + i as f64;
let cover = ((x + 1.0).min(s1) - x.max(s0)).clamp(0.0, 1.0) as f32;
let ramp = ((x + 0.5) / 320.0) as f32;
let v = cover + (1.0 - cover) * ramp;
*px = [v, v, v, 1.0];
}
Some(Buf { img, offset: [0.0; 2], scale: 1.0 })
}
}
/// After Effects 2026 renders of that row at 0.5 s, Echo Time −1/30 s, one echo, Starting
/// Intensity 1, Decay 1 (renders/echo-00 ... echo-10 of the 2026-11-05 importer sample, 32 bpc,
/// saved as 8-bit PNGs that hold floor(value × 25.5)).
#[rustfmt::skip]
const AE_ECHO_ROW: [(u32, &str, [u8; 52]); 6] = [
(0, "Add", [9, 9, 9, 10, 10, 10, 10, 10, 10, 11, 24, 31, 31, 31, 31, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 39, 33, 33, 33, 33, 16, 16, 17, 17, 17, 17, 17]),
(1, "Maximum", [4, 4, 4, 5, 5, 5, 5, 5, 5, 5, 18, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 8, 8, 8, 8, 8, 8, 8]),
(2, "Minimum", [4, 4, 4, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 13, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8]),
(3, "Screen", [8, 8, 8, 9, 9, 9, 9, 9, 9, 9, 20, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 14, 14, 14, 14, 14, 14, 14]),
(4, "Composite In Back", [4, 4, 4, 5, 5, 5, 5, 5, 5, 5, 18, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 13, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8]),
(5, "Composite In Front", [4, 4, 4, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 8, 8, 8, 8, 8, 8, 8]),
];
/// #3124: every operator but Blend against the After Effects row. Composite In Back puts
/// the echo over the layer (the trailing echo hides the square's leading edge), In Front
/// keeps the layer on top (the output is the current frame).
#[test]
fn echo_operators_match_after_effects_at_the_moving_edge() {
let h = MovingSquare;
for (op, name, ae) in AE_ECHO_ROW {
let vals = [("echoTime", num(-1.0 / 30.0)), ("numberOfEchoes", num(1.0)), ("echoOperator", Value::Enum(op))];
let env = EffectEnv { host: Some(&h), frame_rate: 25.0, comp_time: 0.5, ..Default::default() };
let out = run_fx("ec.time.echo", &vals, Image::new(52, 1), 0.5, env);
for (x, want) in ae.iter().enumerate() {
let got = (out.img.data[x][0] * 25.5).floor();
assert!((got - f32::from(*want)).abs() <= 1.0, "{name}: x = {} got {got}, After Effects {want}", 60 + x);
assert_eq!(out.img.data[x][3], 1.0, "{name}: alpha at x = {}", 60 + x);
}
}
}
/// Row y = 130, x = 36..111 of the same test card as read back from 16-bit renders: there the
/// ramp is x / 320 at the pixel's left edge (the 8-bit row above cannot tell that apart).
struct MovingSquareRow16;
impl EffectHost for MovingSquareRow16 {
fn layer(&self, _: u64, _: bool) -> Option<LayerPixels> {
None
}
fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option<Vec<f32>> {
None
}
fn self_at(&self, t: f64, _: usize) -> Option<Buf> {
let centre = 20.0 + 140.0 * t;
let (s0, s1) = (centre - 15.0, centre + 15.0);
let mut img = Image::new(76, 1);
for (i, px) in img.data.iter_mut().enumerate() {
let x = 36.0 + i as f64;
let cover = ((x + 1.0).min(s1) - x.max(s0)).clamp(0.0, 1.0);
let v = (cover + (1.0 - cover) * x / 320.0) as f32;
*px = [v, v, v, 1.0];
}
Some(Buf { img, offset: [0.0; 2], scale: 1.0 })
}
}
/// After Effects 2026 renders of that row at 0.5 s (renders/echo-13 ... echo-16 of the
/// 2026-11-05 importer sample, group 1110-extra, 32 bpc, 16-bit PNGs holding value × 6553.5):
/// operator, Echo Time, Number Of Echoes, Starting Intensity, Decay, alpha code, row codes.
#[rustfmt::skip]
const AE_ECHO_ROW16: [(u32, f64, f64, f64, f64, u16, [u16; 76]); 4] = [
(6, -1.0 / 30.0, 3.0, 1.0, 1.0, 65535, [2949, 3031, 3111, 3193, 3275, 3357, 3439, 3521, 3603, 3685, 3767, 3849, 3931, 4013, 4095, 4177, 4259, 4341, 4423, 4505, 4587, 4669, 4751, 4833, 4915, 10301, 10361, 10423, 10485, 12293, 15809, 15851, 15891, 15933, 19369, 21113, 21135, 21155, 21175, 26205, 26213, 26213, 26213, 26213, 26213, 26213, 26213, 26213, 26213, 26213, 26213, 26213, 26213, 26213, 26213, 21523, 21543, 21565, 21585, 20063, 17039, 17079, 17121, 17161, 14215, 12759, 12819, 12881, 12943, 8607, 8683, 8765, 8847, 8929, 9011, 9093]),
(6, -1.0 / 30.0, 2.0, 0.5, 0.5, 57343, [643, 661, 679, 697, 715, 733, 751, 769, 787, 805, 823, 839, 859, 877, 895, 911, 929, 949, 965, 983, 1001, 1021, 1037, 1055, 1073, 1091, 1109, 1127, 1145, 1381, 1831, 1847, 1861, 1877, 2743, 3183, 3193, 3203, 3213, 5727, 5733, 5733, 5733, 5733, 5733, 5733, 5733, 5733, 5733, 5733, 5733, 5733, 5733, 5733, 5733, 5733, 5733, 5733, 5733, 5541, 5159, 5163, 5165, 5167, 4423, 4051, 4059, 4065, 4073, 1883, 1897, 1917, 1933, 1951, 1969, 1987]),
(4, -0.08, 3.0, 1.0, 0.5, 65535, [737, 757, 777, 797, 819, 1267, 1571, 1589, 1607, 1625, 1643, 1661, 1679, 1697, 1715, 1733, 2229, 2965, 2977, 2991, 3005, 3017, 3031, 3045, 3059, 3071, 3085, 3443, 4833, 4839, 4845, 4853, 4859, 4865, 4873, 4497, 4251, 4261, 4269, 5923, 5927, 5931, 5933, 5935, 5939, 5941, 5517, 4885, 4891, 4899, 4905, 4913, 4919, 4927, 4933, 4941, 4947, 4649, 3443, 3457, 3471, 3485, 3497, 3511, 3525, 3539, 3553, 3567, 3581, 2151, 2169, 2191, 2211, 2231, 2251, 2273]),
(5, -0.08, 3.0, 1.0, 0.5, 65535, [737, 757, 777, 797, 819, 839, 859, 879, 901, 921, 941, 961, 983, 1003, 1023, 1043, 1063, 1085, 1105, 1125, 1145, 1167, 1187, 1207, 1227, 1249, 1269, 1289, 1309, 1331, 1351, 1371, 1391, 1413, 1433, 1453, 1473, 1495, 1515, 6545, 6553, 6553, 6553, 6553, 6553, 6553, 6553, 6553, 6553, 6553, 6553, 6553, 6553, 6553, 6553, 6553, 6553, 6553, 6553, 6553, 6553, 6553, 6553, 6553, 6553, 6553, 6553, 6553, 6553, 2157, 2169, 2191, 2211, 2231, 2251, 2273]),
];
/// #3124: Blend sums the weighted frames like Add (no normalisation, Starting Intensity weights
/// the current frame too), and with three echoes Composite In Back stacks the oldest echo on
/// top while In Front leaves the opaque current frame alone. The alpha is checked as measured.
/// The RGB comparison of the half-transparent Blend row reads the PNG as premultiplied colour
/// (`saveFrameToPng` does not document which it writes); the opaque rows do not depend on it.
#[test]
fn echo_blend_and_three_echo_composites_match_after_effects() {
let h = MovingSquareRow16;
for (op, dt, n, start, decay, alpha, ae) in AE_ECHO_ROW16 {
let vals = [
("echoTime", num(dt)),
("numberOfEchoes", num(n)),
("startingIntensity", num(start)),
("decay", num(decay)),
("echoOperator", Value::Enum(op)),
];
let env = EffectEnv { host: Some(&h), frame_rate: 25.0, comp_time: 0.5, ..Default::default() };
let out = run_fx("ec.time.echo", &vals, Image::new(76, 1), 0.5, env);
// Pixels a square edge passes through: After Effects anti-aliases the card's square a
// little differently from the box coverage above, so they get 24 codes (0.4 %).
let edges: Vec<f64> = (0..=n as usize)
.flat_map(|i| {
let c = 20.0 + 140.0 * (0.5 + i as f64 * dt);
[(c - 15.0).floor(), (c + 15.0).floor()]
})
.collect();
for (x, want) in ae.iter().enumerate() {
let px = out.img.data[x];
let got = px[0] * 6553.5;
let tol = if edges.contains(&(36.0 + x as f64)) { 24.0 } else { 4.0 };
assert!((got - f32::from(*want)).abs() <= tol, "op {op}, n {n}: x = {} got {got}, After Effects {want}", 36 + x);
let a = px[3] * 65535.0;
assert!((a - f32::from(alpha)).abs() <= 2.0, "op {op}, n {n}: alpha at x = {} got {a}, After Effects {alpha}", 36 + x);
}
}
}
#[test]
+1 -1
View File
@@ -327,7 +327,7 @@ impl Prober {
&& self.report_missing_frames
&& let Some(gaps) = crate::sequence::missing_report(&f.sequence)
{
warning = Some(format!("{name}: {gaps} (shown as placeholders)"));
warning = Some(format!("{name}: {gaps} (the previous frame is held)"));
}
// Settings ▸ Import ▸ Interpret Unlabeled Alpha As: images only (a 3D model renders its
// own alpha, and audio and data have none).
+10 -1
View File
@@ -31,7 +31,7 @@ use replace::{has_replace_source, replace_source};
pub(crate) use select::select_only;
use select::{select, select_next, select_prev, select_step};
use switches::{blend, rename, set_comment, set_switch, track_matte};
use timing::{arrange, move_frames, precompose, slip, timing};
use timing::{arrange, move_frames, precompose, slip, timing, unprecompose};
use transform::{has_layer_settings, layer_settings, transform_op};
pub(crate) use transform::{offset_position, transform_default, update_position};
@@ -177,6 +177,15 @@ pub fn specs() -> Vec<CommandSpec> {
has_layers,
precompose
),
cmd!(
"layer.unprecompose",
"Un-precompose",
["Layer"],
None,
"{layer?, force?, dryRun?} → {layers, warnings}: expands the selected pre-comp layer into its composition's layers (copies, in order, with timing and position carried over). A locked pre-comp layer is refused, changing nothing. What would change the picture (the pre-comp layer has a parent, matte, effects, masks, layer styles, a non-default Scale/Rotation/Opacity, an animated transform, time remap/stretch, a blending mode, Video/Audio off, Solo/Guide on, is 3D/collapsing/adjustment, is used by another layer or named in another layer's expression; or the nested comp has cameras, lights, adjustment, 3D, collapsing, soloed or blend-mode / Preserve Transparency layers, preserves another frame rate, has another motion-blur shutter, or has Position expressions the move can't reach) is an error listing the reasons, changing nothing, unless `force`: then it unpacks anyway in one undo step, drops what the pre-comp layer carried, removes other layers' parent/matte/property links to it and lists the reasons as `warnings`. `dryRun` returns {dryRun, warnings} and changes nothing",
has_layers,
unprecompose
),
cmd!(
"layer.addMask",
"New Mask",
+9 -131
View File
@@ -175,23 +175,7 @@ fn uniform_rotation(m: effectcraft_geom::Mat4) -> Option<(effectcraft_geom::Mat4
Some((rotation, factor))
}
/// The turn (degrees) of a rotation about Z alone, or `None` for any other rotation.
fn z_only_turn(correction: effectcraft_geom::Mat4) -> Option<f64> {
let m = correction.0;
let about_z = [m[0][2], m[1][2], m[2][0], m[2][1]].iter().all(|v| v.abs() < 1e-9) && (m[2][2] - 1.0).abs() < 1e-9;
about_z.then(|| m[1][0].atan2(m[0][0]).to_degrees())
}
/// ZYX Euler angles in degrees of a row-major rotation (`Rz * Ry * Rx`), safe at gimbal lock.
fn euler_zyx(r: [[f64; 4]; 4]) -> [f64; 3] {
// atan2 rather than asin: near ±90 degrees asin loses precision, while cos(y) stays exact.
let cy = r[0][0].hypot(r[1][0]);
let y = (-r[2][0]).atan2(cy);
let (x, z) = if cy > 1e-9 { (r[2][1].atan2(r[2][2]), r[1][0].atan2(r[0][0])) } else { ((-r[1][2]).atan2(r[1][1]), 0.0) };
[x.to_degrees(), y.to_degrees(), z.to_degrees()]
}
/// The equivalent ZYX Euler triple nearest `reference`: each axis moved by whole turns, or the
/// The equivalent XYZ Euler triple nearest `reference`: each axis moved by whole turns, or the
/// `(x + 180, 180 - y, z + 180)` form when that is nearer.
fn nearest_euler(euler: [f64; 3], reference: [f64; 3]) -> [f64; 3] {
let near = |v: [f64; 3]| -> [f64; 3] { std::array::from_fn(|i| v[i] + ((reference[i] - v[i]) / 360.0).round() * 360.0) };
@@ -204,136 +188,30 @@ fn nearest_euler(euler: [f64; 3], reference: [f64; 3]) -> [f64; 3] {
/// The Euler Orientation of `correction * Orientation(o)`, in the form nearest `reference`.
fn reexpress_euler(correction: effectcraft_geom::Mat4, o: [f64; 3], reference: [f64; 3]) -> [f64; 3] {
let world = correction * effectcraft_geom::Mat4::orientation(effectcraft_geom::Vec3::from(o));
nearest_euler(euler_zyx(world.0), reference)
let angles = world.orientation_angles();
nearest_euler([angles.x, angles.y, angles.z], reference)
}
/// Largest angle (degrees) a baked Orientation may drift from the exact rotation between its keys.
const BAKE_TOLERANCE_DEGREES: f64 = 0.05;
/// How many times a segment may be halved while baking, and the most keys a property may end up with.
const BAKE_MAX_DEPTH: u32 = 10;
const BAKE_MAX_KEYS: usize = 4096;
/// The angle (degrees) between two rotations.
fn rotation_angle(a: effectcraft_geom::Mat4, b: effectcraft_geom::Mat4) -> f64 {
let trace: f64 = (0..3).flat_map(|i| (0..3).map(move |j| (i, j))).map(|(i, j)| a.0[i][j] * b.0[i][j]).sum();
((trace - 1.0) / 2.0).clamp(-1.0, 1.0).acos().to_degrees()
}
/// Adds keys between `(ta, ea)` and `(tb, eb)` (already converted) wherever the straight Euler blend
/// between them drifts from `correction * Orientation(authored)` by more than the tolerance. `out`
/// never grows past `budget` keys: what the whole property may still add, not what one segment may.
fn bake_between(
correction: effectcraft_geom::Mat4,
authored: &effectcraft_project::Property,
(ta, ea): (Tick, [f64; 3]),
(tb, eb): (Tick, [f64; 3]),
depth: u32,
budget: usize,
out: &mut Vec<effectcraft_keyframe::Keyframe>,
) {
use effectcraft_geom::{Mat4, Vec3};
if depth == 0 || tb.0 - ta.0 < 2 || out.len() >= budget {
return;
}
let tm = Tick(ta.0 + (tb.0 - ta.0) / 2);
let blend = [(ea[0] + eb[0]) / 2.0, (ea[1] + eb[1]) / 2.0, (ea[2] + eb[2]) / 2.0];
let exact = correction * Mat4::orientation(Vec3::from(authored.value_at(tm).as_vec3()));
// Symmetric easing crosses the straight blend at its midpoint. Check both sides too before
// accepting a segment whose original easing will be replaced with a linear blend.
let quarter = (tb.0 - ta.0) / 4;
if [Tick(ta.0 + quarter), tm, Tick(tb.0 - quarter)].into_iter().all(|t| {
let u = (t.0 - ta.0) as f64 / (tb.0 - ta.0) as f64;
let linear = std::array::from_fn(|i| ea[i] + (eb[i] - ea[i]) * u);
let target = correction * Mat4::orientation(Vec3::from(authored.value_at(t).as_vec3()));
rotation_angle(Mat4::orientation(Vec3::from(linear)), target) <= BAKE_TOLERANCE_DEGREES
}) {
return;
}
let em = nearest_euler(euler_zyx(exact.0), blend);
bake_between(correction, authored, (ta, ea), (tm, em), depth - 1, budget, out);
if out.len() >= budget {
return;
}
out.push(effectcraft_keyframe::Keyframe::new(tm, KV::Vec3(em)));
bake_between(correction, authored, (tm, em), (tb, eb), depth - 1, budget, out);
}
/// Re-expresses Orientation as `correction * Orientation`. A Z-only correction shifts Z exactly in the
/// value and every key. For any other correction a static value is converted directly; keys are
/// converted, and keys are added between them wherever the Euler blend between two keys would drift
/// from the exact rotation (the segments become linear, with the original easing baked in).
/// Re-express Orientation as `correction * Orientation`, retaining authored keys and easing.
/// Orientation interpolates along quaternion arcs: a constant left rotation preserves those arcs
/// and their angular speeds, so converting the endpoints preserves the entire eased animation.
fn reexpress_orientation(correction: effectcraft_geom::Mat4, pr: &mut effectcraft_project::Property) {
if let Some(turn) = z_only_turn(correction) {
// No turn (an identity correction) leaves every authored value as it is.
if turn.abs() <= 1e-12 {
return;
}
let shift = |value: &KV| {
let [x, y, z] = value.as_vec3();
KV::Vec3([x, y, z + turn])
};
pr.value = shift(&pr.value);
for key in &mut pr.keys {
key.value = shift(&key.value);
}
if (0..3).all(|row| (0..3).all(|col| (correction.0[row][col] - if row == col { 1.0 } else { 0.0 }).abs() < 1e-12)) {
return;
}
let value = pr.value.as_vec3();
pr.value = KV::Vec3(reexpress_euler(correction, value, value));
if pr.keys.is_empty() {
return;
}
let authored = pr.clone();
// Each key keeps the authored change from the previous key on top of its converted value.
let mut converted: Vec<[f64; 3]> = vec![];
let mut prev: Option<([f64; 3], [f64; 3])> = None;
for key in &authored.keys {
for key in &mut pr.keys {
let o = key.value.as_vec3();
let reference = match prev {
Some((before, after)) => [after[0] + o[0] - before[0], after[1] + o[1] - before[1], after[2] + o[2] - before[2]],
None => o,
};
let n = reexpress_euler(correction, o, reference);
converted.push(n);
key.value = KV::Vec3(n);
prev = Some((o, n));
}
use effectcraft_keyframe::Interp;
let mut keys: Vec<effectcraft_keyframe::Keyframe> = vec![];
let mut after_baked_segment = false;
for (i, key) in authored.keys.iter().enumerate() {
let mut k = key.clone();
k.value = KV::Vec3(converted[i]);
if after_baked_segment {
k.in_interp = Interp::Linear;
k.in_ease.clear();
k.auto_bezier = false;
k.continuous = false;
}
after_baked_segment = false;
let segment = authored.keys.get(i + 1).filter(|_| key.out_interp != Interp::Hold);
let mut between = vec![];
if let Some(next) = segment {
// What the whole property may still add: the keys it will have already (the ones written
// and the authored ones still to come) count against the cap.
let budget = BAKE_MAX_KEYS.saturating_sub(keys.len() + authored.keys.len() - i);
bake_between(correction, &authored, (key.time, converted[i]), (next.time, converted[i + 1]), BAKE_MAX_DEPTH, budget, &mut between);
// With the cap reached the segment can't be checked or subdivided: it keeps its authored easing between
// the converted keys, rather than becoming a straight blend that was never checked against the original.
if between.len() >= budget {
between.clear();
// An eased segment, or one that gained keys, becomes a run of straight blends.
} else if !between.is_empty() || key.out_interp != Interp::Linear || next.in_interp != Interp::Linear {
k.out_interp = Interp::Linear;
k.out_ease.clear();
k.auto_bezier = false;
k.continuous = false;
after_baked_segment = true;
}
}
keys.push(k);
keys.extend(between);
}
pr.keys = keys;
}
/// Correction for a two-node rig's Orientation: after a uniform parent change its aim is
+316
View File
@@ -1,3 +1,5 @@
use effectcraft_project::TrackMatte;
use super::{
EngineError, ItemKind, Label, Layer, LayerId, LayerSource, Result, Session, Tick, Value, b_p, bad, build, f_p, json, layer_mut, layers_p, merge_p,
resolve_layer, str_p, unlocked, with,
@@ -307,3 +309,317 @@ pub(super) fn precompose(s: &mut Session, p: &Value) -> Result<Value> {
}
Ok(json!({"comp": new.0.0, "layer": new.1.0}))
}
/// Why expanding a pre-comp layer in place would change the picture or drop something, one entry
/// per cause. Un-precompose keeps the picture exactly for a pre-comp layer that only places the
/// nested composition (Position/Anchor Point, timing); anything the layer itself adds to the look,
/// and what only holds inside the nested comp, is listed. These are warnings: `force` unpacks
/// anyway and drops it. A lock is not listed here: it always refuses.
fn unprecompose_warnings(comp: &effectcraft_project::Comp, layer: &Layer, nested: &effectcraft_project::Comp) -> Vec<String> {
let mut why = vec![];
if layer.parent.is_some() {
why.push("the layer has a parent".to_string());
}
if layer.track_matte.is_some() {
why.push("the layer has a track matte".to_string());
}
if comp.layers.iter().any(|o| o.id != layer.id && (o.parent == Some(layer.id) || o.track_matte.is_some_and(|m| m.layer == layer.id))) {
why.push("another layer uses it as its parent or track matte (that link is removed)".to_string());
}
if layer.is_3d() || layer.switches.collapse || layer.switches.adjustment || layer.preserve_transparency {
why.push("the layer is 3D, collapses transformations, is an adjustment layer or preserves transparency".to_string());
}
if !layer.switches.video || !layer.switches.audio {
why.push("the layer's Video or Audio switch is off".to_string());
}
if layer.switches.solo || layer.switches.guide {
// Solo hides the comp's other layers and a guide layer is left out of renders; the
// expanded copies would carry neither, so the picture would change.
why.push("the layer's Solo or Guide switch is on".to_string());
}
if comp.layers.iter().any(|o| o.id != layer.id && refers_to(&o.props, layer.id)) {
why.push("a property of another layer refers to it (that reference is cleared)".to_string());
}
why.extend(expression_warnings(comp, layer, nested.layers.len()));
if layer.blend_mode != effectcraft_color::BlendMode::default() {
why.push("the layer has a blending mode".to_string());
}
if layer.stretch != 100.0 || layer.props.prop("timeRemap").is_some() {
why.push("the layer is time-stretched or time-remapped".to_string());
}
if layer.effects().is_some_and(|e| !e.children.is_empty()) {
why.push("the layer has effects".to_string());
}
if layer.masks().is_some_and(|m| !m.children.is_empty()) {
why.push("the layer has masks".to_string());
}
if layer.styles_change_pixels() {
why.push("the layer has layer styles".to_string());
}
if let Some(tr) = layer.transform() {
let at = |m: &str, d: [f64; 3]| tr.get(m).map(|p| p.value.as_vec3()).unwrap_or(d);
let animated = tr.props().any(|p| !p.keys.is_empty() || p.expr.is_some());
let separated = tr.get("positionX").is_some();
if animated || separated {
why.push("its transform is animated, has an expression or has separated dimensions".to_string());
} else {
let (sc, rot, ori) = (at("scale", [100.0; 3]), at("rotation", [0.0; 3]), at("orientation", [0.0; 3]));
let op = tr.get("opacity").map(|p| p.value.as_f64()).unwrap_or(100.0);
if sc[0] != 100.0 || sc[1] != 100.0 || rot[0] != 0.0 || ori != [0.0; 3] || op != 100.0 {
why.push("its Scale, Rotation or Opacity is not at its default".to_string());
}
}
}
if nested.layers.iter().any(|l| l.is_camera() || l.is_light()) {
why.push("the composition has a camera or light".to_string());
}
// Inside the nested comp these composite against the nested stack only; in the parent they
// would act on everything below.
if nested.layers.iter().any(|l| {
l.switches.adjustment || l.switches.collapse || l.is_3d() || l.preserve_transparency || l.blend_mode != effectcraft_color::BlendMode::default()
}) {
why.push("the composition has adjustment, 3D or collapsing layers or layers with a blending mode or Preserve Transparency".to_string());
}
// A solo hides only the nested comp's other layers; a soloed copy would hide the parent's.
if nested.layers.iter().any(|l| l.switches.solo) {
why.push("the composition has a soloed layer".to_string());
}
why.extend(sampling_warnings(comp, nested));
why.extend(position_expression_warnings(layer, nested));
why
}
/// What the nested comp's own frame sampling does that the expanded layers lose: a preserved
/// frame rate other than the parent's (its layers then change only on its own frames), and
/// motion blur with a shutter other than the parent's.
fn sampling_warnings(comp: &effectcraft_project::Comp, nested: &effectcraft_project::Comp) -> Vec<String> {
let mut why = vec![];
if nested.preserve_frame_rate && nested.frame_rate != comp.frame_rate {
why.push("the composition preserves a frame rate other than this one's (its layers would be sampled at this comp's frames)".to_string());
}
let shutter = nested.shutter_angle != comp.shutter_angle || nested.shutter_phase != comp.shutter_phase;
if shutter && nested.layers.iter().any(|l| l.switches.motion_blur) {
why.push("the composition's motion blur shutter differs from this one's".to_string());
}
why
}
/// The move from the pre-comp layer's Position against its Anchor Point, which expansion adds to
/// the Position of the nested comp's unparented layers.
fn placement_offset(layer: &Layer) -> (f64, f64) {
let tr = layer.transform();
let at = |m: &str| tr.and_then(|t| t.get(m)).map(|p| p.value.as_vec3()).unwrap_or([0.0; 3]);
let (anchor, position) = (at("anchor"), at("position"));
(position[0] - anchor[0], position[1] - anchor[1])
}
/// Unparented nested layers whose Position (or a separated axis) has an expression: the move from
/// [`placement_offset`] is added to the stored value and keys, which an expression can ignore.
fn position_expression_warnings(layer: &Layer, nested: &effectcraft_project::Comp) -> Vec<String> {
let (dx, dy) = placement_offset(layer);
if dx == 0.0 && dy == 0.0 {
return vec![];
}
nested
.layers
.iter()
.filter(|l| l.parent.is_none())
.filter(|l| l.transform().is_some_and(|tr| ["position", "positionX", "positionY"].iter().any(|m| tr.get(m).is_some_and(|p| p.expr.is_some()))))
.map(|l| format!("layer “{}” has a Position expression, so the move to the pre-comp layer's Position may not apply to it", l.name))
.collect()
}
/// One warning per property of another layer in `comp` whose expression names `layer`: by its
/// name in quotes, or as `layer(<index>)` with its 1-based index (not on another comp's
/// `comp("…").layer(…)`), and one per expression naming a layer by a number below it while the
/// `added` layers replacing it renumber those. Detection is by text; the expressions are left as
/// they are.
fn expression_warnings(comp: &effectcraft_project::Comp, layer: &Layer, added: usize) -> Vec<String> {
let index = comp.layers.iter().position(|l| l.id == layer.id).map(|i| i.saturating_add(1));
let quoted = [format!("\"{}\"", layer.name), format!("'{}'", layer.name)];
let names_it = |text: &str| quoted.iter().any(|q| text.contains(q.as_str())) || index.is_some_and(|i| indexed_layers(text).contains(&i));
let renumbered = |text: &str| added != 1 && index.is_some_and(|i| indexed_layers(text).iter().any(|n| *n > i));
let mut out = vec![];
for o in comp.layers.iter().filter(|o| o.id != layer.id) {
o.props.walk("", &mut |_, p| {
if p.expr.as_ref().is_some_and(|e| !names_it(&e.text) && renumbered(&e.text)) {
out.push(format!("layer “{}” {} names a layer by number, and the layers below “{}” are renumbered", o.name, p.name, layer.name));
}
if p.expr.as_ref().is_some_and(|e| names_it(&e.text)) {
out.push(format!("layer “{}” {} has an expression that refers to “{}” (it will no longer find it)", o.name, p.name, layer.name));
}
});
}
out
}
/// The indexes expression `text` calls `layer(<index>)` with (spaces allowed) on this comp: bare,
/// `thisComp.layer(…)`, not `comp("Other").layer(…)` or `thisLayer(…)`.
fn indexed_layers(text: &str) -> Vec<usize> {
text.match_indices("layer(")
.filter_map(|(at, _)| {
let before = text.get(..at).unwrap_or("");
let on_other = before.ends_with(").") || before.chars().next_back().is_some_and(|c| c.is_alphanumeric() || c == '_');
if on_other && !before.ends_with("thisComp.") {
return None;
}
let rest = text.get(at + "layer(".len()..).unwrap_or("").trim_start();
let digits: String = rest.chars().take_while(char::is_ascii_digit).collect();
let after = rest.get(digits.len()..).unwrap_or("").trim_start();
digits.parse::<usize>().ok().filter(|_| after.starts_with(')'))
})
.collect()
}
/// Whether any property (value or key) in `props` holds a reference to layer `id`.
fn refers_to(props: &effectcraft_project::PropGroup, id: LayerId) -> bool {
let is = |v: &effectcraft_keyframe::Value| matches!(v, effectcraft_keyframe::Value::Layer(Some(l)) if *l == id.0);
props.children.iter().any(|c| match c {
effectcraft_project::Node::Prop(p) => is(&p.value) || p.keys.iter().any(|k| is(&k.value)),
effectcraft_project::Node::Group(g) => refers_to(g, id),
})
}
/// Clear every reference to layer `id` (`Value::Layer`, value or key) in `props`; references to
/// other layers stay.
fn clear_layer_refs(props: &mut effectcraft_project::PropGroup, id: LayerId) {
let fix = |v: &mut effectcraft_keyframe::Value| {
if matches!(v, effectcraft_keyframe::Value::Layer(Some(l)) if *l == id.0) {
*v = effectcraft_keyframe::Value::Layer(None);
}
};
for c in &mut props.children {
match c {
effectcraft_project::Node::Prop(p) => {
fix(&mut p.value);
for k in &mut p.keys {
fix(&mut k.value);
}
}
effectcraft_project::Node::Group(g) => clear_layer_refs(g, id),
}
}
}
/// The layers of `comp` other than `id` let go of it: parent, track matte and property references
/// to it are removed, so nothing points at a layer that is gone.
fn detach_from(comp: &mut effectcraft_project::Comp, id: LayerId) {
for l in comp.layers.iter_mut().filter(|l| l.id != id) {
if l.parent == Some(id) {
l.parent = None;
}
if l.track_matte.is_some_and(|m| m.layer == id) {
l.track_matte = None;
}
clear_layer_refs(&mut l.props, id);
}
}
/// Replace layer references (`Value::Layer`) found in `props` by `map` (old id → new id), the
/// way copies of a set of layers keep pointing at each other. A reference to a layer outside the
/// set is cleared: it named a layer of the nested comp that is no longer there.
fn remap_layer_values(props: &mut effectcraft_project::PropGroup, map: &[(LayerId, LayerId)]) {
let fix = |v: &mut effectcraft_keyframe::Value| {
if let effectcraft_keyframe::Value::Layer(Some(id)) = v {
*v = effectcraft_keyframe::Value::Layer(map.iter().find(|(o, _)| o.0 == *id).map(|(_, n)| n.0));
}
};
for c in &mut props.children {
match c {
effectcraft_project::Node::Prop(p) => {
fix(&mut p.value);
for k in &mut p.keys {
fix(&mut k.value);
}
}
effectcraft_project::Node::Group(g) => remap_layer_values(g, map),
}
}
}
/// Layer ▸ Un-precompose: expand a selected pre-comp layer back into its composition. The nested
/// comp's layers become copies in this comp at the pre-comp layer's place, in their order and
/// with their keyframes, effects, masks, parenting, mattes, markers and audio; their timing is
/// moved by the pre-comp layer's Start and trimmed to the span it showed, and an Anchor Point
/// different from Position moves the unparented ones by the difference. The nested comp stays in
/// the project. The nested comp's frame no longer clips what the layers draw outside it.
///
/// A locked pre-comp layer is refused, changing nothing. Anything else that would change the
/// picture or drop something (see [`unprecompose_warnings`]) is an error listing the reasons unless
/// `force` is set; with `force` the layer is expanded the same way, what the pre-comp layer itself
/// carried (effects, masks, styles, transform beyond Position, time remap, blending mode, switches,
/// parent, matte) goes with it, and the other layers' parent, matte and property links to it are
/// removed. The result lists the reasons as `warnings`; `dryRun` only lists them.
pub(super) fn unprecompose(s: &mut Session, p: &Value) -> Result<Value> {
let c = "layer.unprecompose";
let (cid, ids) = layers_p(s, p)?;
let [lid] = ids[..] else { return Err(bad(c, "select exactly one pre-comp layer")) };
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
let layer = comp.layer(lid).ok_or(EngineError::NoComp)?.clone();
let LayerSource::Comp { item } = layer.source else { return Err(bad(c, format!("“{}” is not a pre-comp layer", layer.name))) };
if layer.switches.locked {
return Err(bad(c, format!("can't un-precompose “{}”: the layer is locked (unlock it first)", layer.name)));
}
let nested = s.project.comp(item).ok_or_else(|| bad(c, "the layer's composition is missing"))?.clone();
let warnings = unprecompose_warnings(&comp, &layer, &nested);
if b_p(p, "dryRun").unwrap_or(false) {
return Ok(json!({"dryRun": true, "warnings": warnings}));
}
if !warnings.is_empty() && !b_p(p, "force").unwrap_or(false) {
let why = warnings.join("; ");
return Err(bad(
c,
format!(
"can't un-precompose “{}” without changing it: {why} (nothing changed: pass `force` to unpack anyway, or `dryRun` to list the reasons)",
layer.name
),
));
}
let (dx, dy) = placement_offset(&layer);
let shown = (layer.in_point.max(layer.start_time), layer.out_point.min(layer.start_time + nested.duration));
let new = s.edit("Un-precompose", None, |proj, st| {
let mut next = proj.next_id;
let mut copies: Vec<Layer> = nested.layers.iter().cloned().collect();
let mut map = vec![];
for l in &mut copies {
let old = l.id;
crate::cmd::edit::reid(l, &mut next);
map.push((old, l.id));
}
let link = |id: LayerId| map.iter().find(|(o, _)| *o == id).map(|(_, n)| *n);
for l in &mut copies {
l.parent = l.parent.and_then(link);
l.track_matte = l.track_matte.and_then(|m| link(m.layer).map(|layer| TrackMatte { layer, ..m }));
remap_layer_values(&mut l.props, &map);
l.start_time += layer.start_time;
l.in_point = (l.in_point + layer.start_time).clamp(shown.0, shown.1.max(shown.0));
l.out_point = (l.out_point + layer.start_time).clamp(l.in_point, shown.1.max(l.in_point));
if l.parent.is_none()
&& let Some(tr) = l.transform_mut()
{
super::offset_position(tr, dx, dy);
}
}
proj.next_id = next;
let target = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
detach_from(target, lid);
let at = target.layers.iter().position(|l| l.id == lid).unwrap_or(0);
target.layers.retain(|l| l.id != lid);
let mut made = vec![];
for (k, mut l) in copies.into_iter().enumerate() {
l.name = target.unique_layer_name(&l.name);
made.push(l.id);
target.layers.insert((at + k).min(target.layers.len()), l);
}
for m in &nested.markers {
let mut m = m.clone();
m.time += layer.start_time;
target.markers.push(m);
}
// Comp markers are kept in time order (`thisComp.marker.key(n)`, next/previous marker).
target.markers.sort_by_key(|m| m.time);
st.selected_layers = made.clone();
Ok(made)
})?;
Ok(json!({"layers": new.iter().map(|l| l.0).collect::<Vec<_>>(), "warnings": warnings}))
}
+1 -1
View File
@@ -492,7 +492,7 @@ fn camera_tool(s: &mut Session, p: &Value, op: ToolOp, cmd: &str, label: &str) -
let poi = if two {
Vec3::from(get_at(l, "transform/poi", t).map(|v| v.as_vec3()).unwrap_or([w / 2.0, h / 2.0, 0.0]))
} else {
let fwd = (Mat4::orientation(Vec3::from(o)) * Mat4::rotate_y(ry) * Mat4::rotate_x(rx)).apply_vec(vec3(0.0, 0.0, 1.0));
let fwd = (Mat4::orientation(Vec3::from(o)) * Mat4::rotation_xyz(rx, ry, 0.0)).apply_vec(vec3(0.0, 0.0, 1.0));
pos + fwd * zoom
};
let mut rig = Rig { eye: pos, poi, zoom, ortho: false };
+3 -5
View File
@@ -79,12 +79,10 @@ fn norm(a: f64) -> f64 {
(r * 1e6).round() / 1e6
}
/// Orientation angles (X, Y, Z degrees, applied X then Y then Z) of a rotation matrix.
/// Orientation angles (X, Y, Z degrees, [`Mat4::orientation`] order) of a rotation matrix.
pub fn euler(m: Mat4) -> [f64; 3] {
let m = m.0;
let y = (-m[2][0]).clamp(-1.0, 1.0).asin();
let (x, z) = if y.cos().abs() > 1e-6 { (m[2][1].atan2(m[2][2]), m[1][0].atan2(m[0][0])) } else { ((-m[1][2]).atan2(m[1][1]), 0.0) };
[norm(x.to_degrees()), norm(y.to_degrees()), norm(z.to_degrees())]
let o = m.orientation_angles();
[norm(o.x), norm(o.y), norm(o.z)]
}
/// The current view orientation of an environment (from its Front camera).
+1
View File
@@ -1018,6 +1018,7 @@ Layer
Duplicate and Assign Material | material.duplicateAssign
Auto-trace... | layer.autoTrace
Pre-compose... | layer.precompose
Un-precompose | layer.unprecompose
Scene Edit Detection... | layer.sceneEditDetection
Effect
Effect Controls | window.panel {"panel":"effectControls"} | F3, Cmd+Shift+T
+466
View File
@@ -2066,6 +2066,472 @@ fn precompose_unlinks_layers_left_behind_and_numbers_default_names() {
assert_eq!(names, ["Pre-comp 1", "Pre-comp 2"]);
}
fn id_named(s: &Session, name: &str) -> u64 {
s.active_comp().unwrap().layers.iter().find(|l| l.name == name).unwrap_or_else(|| panic!("no layer {name}")).id.0
}
/// A comp with solids A (keyed Position, parent of B), B and C.
fn precomp_scene() -> Session {
let mut s = comp();
for (name, color, pos) in [("A", "#ff0000", [150.0, 100.0]), ("B", "#00ff00", [350.0, 220.0]), ("C", "#0000ff", [500.0, 100.0])] {
let l = solid(&mut s, color);
s.execute("layer.rename", json!({"layers": [l], "name": name})).unwrap();
s.execute("prop.set", json!({"layer": l, "path": "transform/position", "value": pos})).unwrap();
}
let (a, b) = (id_named(&s, "A"), id_named(&s, "B"));
s.execute("prop.addKey", json!({"layer": a, "path": "transform/position", "time": 0, "value": [150, 100]})).unwrap();
s.execute("prop.addKey", json!({"layer": a, "path": "transform/position", "time": 1, "value": [300, 200]})).unwrap();
s.execute("layer.setParent", json!({"layers": [b], "parent": a})).unwrap();
s
}
/// Where A, B and C are drawn at a few times.
fn scene_look(s: &mut Session) -> Vec<[f64; 4]> {
let mut out = vec![];
for t in [0.0, 0.5, 1.0, 2.0] {
s.execute("time.set", json!({"time": t})).unwrap();
for n in ["A", "B", "C"] {
let id = id_named(s, n);
out.push(seen_bounds(s, id));
}
}
out
}
/// Un-precompose (#2918) gives the picture back: precompose A and B, expand the pre-comp layer
/// again, and every layer is drawn where it was at every time, parenting and stacking order are
/// kept, as one undo step.
#[test]
fn unprecompose_restores_what_precompose_nested() {
let mut s = precomp_scene();
let before = scene_look(&mut s);
let order: Vec<String> = s.active_comp().unwrap().layers.iter().map(|l| l.name.clone()).collect();
let (a, b) = (id_named(&s, "A"), id_named(&s, "B"));
let r = s.execute("layer.precompose", json!({"layers": [a, b], "name": "Inner"})).unwrap();
let pre = r["layer"].as_u64().unwrap();
assert_eq!(s.active_comp().unwrap().layers.len(), 2);
let undo = s.history.undo.len();
let r = s.execute("layer.unprecompose", json!({"layers": [pre]})).unwrap();
assert_eq!(r["layers"].as_array().unwrap().len(), 2);
assert_eq!(s.history.undo.len(), undo + 1, "one undo step");
let after: Vec<String> = s.active_comp().unwrap().layers.iter().map(|l| l.name.clone()).collect();
assert_eq!(after, order, "the layers are back in their order");
let (a, b) = (id_named(&s, "A"), id_named(&s, "B"));
assert_eq!(layer(&s, b).parent, Some(effectcraft_project::LayerId(a)), "B is still A's child");
for (w, g) in before.iter().zip(scene_look(&mut s)) {
assert!(w.iter().zip(g).all(|(x, y)| (x - y).abs() < 1e-6), "a layer moved: {w:?} → {g:?}");
}
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(s.active_comp().unwrap().layers.len(), 2, "undo brings the pre-comp layer back");
}
/// A pre-comp layer that was moved or started later places the nested layers the same way after it
/// is expanded: its Position (against the Anchor Point) and its Start carry over.
#[test]
fn unprecompose_carries_the_precomp_layers_position_and_start() {
let mut s = precomp_scene();
let before = scene_look(&mut s);
let (a, b) = (id_named(&s, "A"), id_named(&s, "B"));
let pre = s.execute("layer.precompose", json!({"layers": [a, b], "name": "Inner"})).unwrap()["layer"].as_u64().unwrap();
s.execute("prop.set", json!({"layer": pre, "path": "transform/position", "value": [360, 160]})).unwrap();
s.execute("layer.unprecompose", json!({"layers": [pre]})).unwrap();
// The pre-comp's Anchor Point sat at the comp centre (320, 180): everything moved by (+40, −20).
let after = scene_look(&mut s);
for (i, (w, g)) in before.iter().zip(after).enumerate() {
let n = i % 3;
let (dx, dy) = if n < 2 { (40.0, -20.0) } else { (0.0, 0.0) };
let want = [w[0] + dx, w[1] + dy, w[2] + dx, w[3] + dy];
assert!(want.iter().zip(g).all(|(x, y)| (x - y).abs() < 1e-6), "layer {n}: wanted {want:?}, drawn {g:?}");
}
// Starting later: the nested layers start later by as much, and are trimmed to the span shown.
let mut s = precomp_scene();
let (a, b) = (id_named(&s, "A"), id_named(&s, "B"));
let pre = s.execute("layer.precompose", json!({"layers": [a, b], "name": "Inner"})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.timing", json!({"layers": [pre], "op": "set", "start": 2.0})).unwrap();
let start = layer(&s, pre).start_time;
s.execute("layer.unprecompose", json!({"layers": [pre]})).unwrap();
let a = layer(&s, id_named(&s, "A"));
assert_eq!(a.start_time, start, "the nested layer's time 0 sits at the pre-comp layer's Start");
assert!(a.in_point >= start, "and it doesn't show before it");
}
/// A pre-comp scene: the outer comp, the nested comp and the pre-comp layer (A and B nested).
fn unprecompose_scene() -> (Session, effectcraft_project::ItemId, u64, u64) {
let mut s = precomp_scene();
let (a, b) = (id_named(&s, "A"), id_named(&s, "B"));
let outer = s.active_comp_id().unwrap();
let r = s.execute("layer.precompose", json!({"layers": [a, b], "name": "Inner"})).unwrap();
(s, outer, r["comp"].as_u64().unwrap(), r["layer"].as_u64().unwrap())
}
/// Everything un-precompose could change: every comp (layers and markers) and the item count.
fn whole_project(s: &Session) -> (Vec<effectcraft_project::Comp>, usize) {
(s.project.comps().map(|(_, c)| c.clone()).collect(), s.project.items.len())
}
/// Without `force`, a reason is an error naming it that changes nothing (no undo step), and the
/// dry run lists it; with `force` the layer is unpacked anyway in one undo step, the result lists
/// the reason as a warning, and undo restores the exact project.
fn warned_then_forced(s: &mut Session, pre: u64, what: &str) {
let undo = s.history.undo.len();
let before = whole_project(s);
let e = s.execute("layer.unprecompose", json!({"layers": [pre]})).expect_err(what).to_string();
assert!(e.contains(what) && e.contains("force"), "{what}: {e}");
assert!(whole_project(s) == before, "{what}: nothing changed");
assert_eq!(s.history.undo.len(), undo, "{what}: no undo step");
let dry = s.execute("layer.unprecompose", json!({"layers": [pre], "dryRun": true})).unwrap();
assert!(dry["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap().contains(what)), "{what}: {dry}");
assert!(whole_project(s) == before, "{what}: the dry run changed nothing");
let r = s.execute("layer.unprecompose", json!({"layers": [pre], "force": true})).expect(what);
assert!(r["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap().contains(what)), "{what}: {r}");
assert!(r["layers"].as_array().unwrap().len() >= 2, "{what}: A and B are back");
assert_eq!(s.history.undo.len(), undo + 1, "{what}: one undo step");
let comp = s.active_comp().unwrap();
assert!(comp.layers.iter().all(|l| l.id.0 != pre), "{what}: the pre-comp layer is gone");
for l in &comp.layers {
assert!(l.parent.is_none_or(|p| comp.layer(p).is_some()), "{what}: {} has a dangling parent", l.name);
assert!(l.track_matte.is_none_or(|m| comp.layer(m.layer).is_some()), "{what}: {} has a dangling matte", l.name);
}
scene_look(s);
s.execute("edit.undo", json!({})).unwrap();
assert!(whole_project(s) == before, "{what}: undo restores the exact project");
}
/// What the pre-comp layer itself adds to the look is a warning (#2918): an error listing it
/// without `force`, unpacked anyway with it.
#[test]
fn unprecompose_warns_about_what_the_precomp_layer_itself_adds() {
let fresh = unprecompose_scene;
let (mut s, _, _, pre) = fresh();
s.execute("prop.set", json!({"layer": pre, "path": "transform/opacity", "value": 50})).unwrap();
warned_then_forced(&mut s, pre, "Opacity");
let (mut s, _, _, pre) = fresh();
s.execute("prop.set", json!({"layer": pre, "path": "transform/rotation", "value": 10})).unwrap();
warned_then_forced(&mut s, pre, "Rotation");
let (mut s, _, _, pre) = fresh();
s.execute("prop.addKey", json!({"layer": pre, "path": "transform/scale", "time": 0, "value": [100, 100]})).unwrap();
warned_then_forced(&mut s, pre, "animated");
for switch in ["solo", "guide"] {
let (mut s, _, _, pre) = fresh();
s.execute("layer.setSwitch", json!({"layers": [pre], "switch": switch, "value": true})).unwrap();
warned_then_forced(&mut s, pre, "Solo or Guide");
}
for switch in ["video", "audio"] {
let (mut s, _, _, pre) = fresh();
s.execute("layer.setSwitch", json!({"layers": [pre], "switch": switch, "value": false})).unwrap();
warned_then_forced(&mut s, pre, "Video or Audio");
}
for switch in ["threeD", "collapse", "adjustment", "preserveTransparency"] {
let (mut s, _, _, pre) = fresh();
s.execute("layer.setSwitch", json!({"layers": [pre], "switch": switch, "value": true})).unwrap();
warned_then_forced(&mut s, pre, "3D, collapses");
}
let (mut s, _, _, pre) = fresh();
let c = id_named(&s, "C");
s.execute("layer.setParent", json!({"layers": [pre], "parent": c})).unwrap();
warned_then_forced(&mut s, pre, "has a parent");
let (mut s, _, _, pre) = fresh();
let c = id_named(&s, "C");
s.execute("layer.setTrackMatte", json!({"layer": pre, "matte": c})).unwrap();
warned_then_forced(&mut s, pre, "has a track matte");
let (mut s, _, _, pre) = fresh();
s.execute("effect.apply", json!({"layers": [pre], "effect": "Gaussian Blur"})).unwrap();
warned_then_forced(&mut s, pre, "effects");
let (mut s, _, _, pre) = fresh();
s.execute("layer.addMask", json!({"layer": pre})).unwrap();
warned_then_forced(&mut s, pre, "masks");
let (mut s, _, _, pre) = fresh();
s.execute("layer.style.add", json!({"layers": [pre], "style": "dropShadow"})).unwrap();
warned_then_forced(&mut s, pre, "layer styles");
let (mut s, _, _, pre) = fresh();
s.execute("layer.setBlendMode", json!({"layers": [pre], "mode": "Multiply"})).unwrap();
warned_then_forced(&mut s, pre, "blending mode");
let (mut s, _, _, pre) = fresh();
s.execute("layer.timeStretch", json!({"layers": [pre], "percent": 200.0})).unwrap();
warned_then_forced(&mut s, pre, "time-stretched");
let (mut s, _, _, pre) = fresh();
s.execute("layer.enableTimeRemap", json!({"layers": [pre], "value": true})).unwrap();
warned_then_forced(&mut s, pre, "time-remapped");
// A layer that is not a pre-comp, and a selection of several, are refused, even forced.
let (mut s, _, _, pre) = fresh();
let c = id_named(&s, "C");
assert!(s.execute("layer.unprecompose", json!({"layers": [c], "force": true})).is_err());
assert!(s.execute("layer.unprecompose", json!({"layers": [pre, c], "force": true})).is_err());
}
/// What only holds inside the nested comp (a camera or light, a soloed, 3D, adjustment or
/// Preserve Transparency layer) is a warning too.
#[test]
fn unprecompose_warns_about_what_only_holds_inside_the_nested_comp() {
let inside = |switch: &str, what: &str| {
let (mut s, outer, inner, pre) = unprecompose_scene();
s.execute("comp.open", json!({"comp": inner})).unwrap();
let a = id_named(&s, "A");
s.execute("layer.setSwitch", json!({"layers": [a], "switch": switch, "value": true})).unwrap();
s.execute("comp.open", json!({"comp": outer.0})).unwrap();
warned_then_forced(&mut s, pre, what);
};
inside("solo", "soloed layer");
inside("preserveTransparency", "Preserve Transparency");
inside("adjustment", "adjustment, 3D");
inside("threeD", "adjustment, 3D");
let (mut s, outer, inner, pre) = unprecompose_scene();
s.execute("comp.open", json!({"comp": inner})).unwrap();
s.execute("layer.newLight", json!({})).unwrap();
s.execute("comp.open", json!({"comp": outer.0})).unwrap();
warned_then_forced(&mut s, pre, "camera or light");
}
/// A locked pre-comp layer is the one hard stop: refused with and without `force`, and by the dry
/// run, changing nothing.
#[test]
fn unprecompose_refuses_a_locked_layer_even_forced() {
let (mut s, _, _, pre) = unprecompose_scene();
s.execute("prop.set", json!({"layer": pre, "path": "transform/opacity", "value": 50})).unwrap();
s.execute("layer.setSwitch", json!({"layers": [pre], "switch": "lock", "value": true})).unwrap();
let undo = s.history.undo.len();
let before = whole_project(&s);
for p in [json!({"layers": [pre]}), json!({"layers": [pre], "force": true}), json!({"layers": [pre], "dryRun": true})] {
let e = s.execute("layer.unprecompose", p.clone()).expect_err("locked").to_string();
assert!(e.contains("locked"), "{p}: {e}");
assert!(whole_project(&s) == before, "{p}: nothing changed");
assert_eq!(s.history.undo.len(), undo, "{p}: no undo step");
}
// A clean, locked pre-comp layer is refused too.
let (mut s, _, _, pre) = unprecompose_scene();
s.execute("layer.setSwitch", json!({"layers": [pre], "switch": "lock", "value": true})).unwrap();
assert!(s.execute("layer.unprecompose", json!({"layers": [pre]})).unwrap_err().to_string().contains("locked"));
}
/// Layers that use the pre-comp layer as parent, track matte or in a property lose that link when
/// it is unpacked by force; links to other layers stay, and nothing dangles or crashes.
#[test]
fn unprecompose_forced_drops_the_links_other_layers_had_to_it() {
let (mut s, outer, _, pre) = unprecompose_scene();
let c = id_named(&s, "C");
let d = solid(&mut s, "#ffff00");
s.execute("layer.rename", json!({"layers": [d], "name": "D"})).unwrap();
s.execute("layer.setParent", json!({"layers": [c], "parent": pre})).unwrap();
s.execute("layer.setTrackMatte", json!({"layer": d, "matte": pre})).unwrap();
s.execute("effect.apply", json!({"layers": [c], "effect": "Set Matte"})).unwrap();
s.execute("effect.apply", json!({"layers": [d], "effect": "Set Matte"})).unwrap();
{
let proj = std::sync::Arc::make_mut(&mut s.project);
let comp = proj.comp_mut(outer).unwrap();
let set = |comp: &mut effectcraft_project::Comp, on: u64, to: u64| {
let l = comp.layer_mut(effectcraft_project::LayerId(on)).unwrap();
l.props.walk_mut(&mut |p| {
if matches!(p.value, KV::Layer(_)) {
p.value = KV::Layer(Some(to));
}
});
};
set(comp, c, pre);
set(comp, d, c);
}
let refs = |s: &Session, on: u64| {
let mut out = vec![];
layer(s, on).props.walk("", &mut |_, p| {
if let KV::Layer(v) = p.value {
out.push(v);
}
});
out
};
assert_eq!(refs(&s, c), vec![Some(pre)], "the test set C's reference");
let e = s.execute("layer.unprecompose", json!({"layers": [pre]})).expect_err("used by others").to_string();
assert!(e.contains("parent or track matte") && e.contains("refers to it"), "{e}");
let undo = s.history.undo.len();
let before = whole_project(&s);
let r = s.execute("layer.unprecompose", json!({"layers": [pre], "force": true})).unwrap();
let w = r["warnings"].to_string();
assert!(w.contains("link is removed") && w.contains("reference is cleared"), "{w}");
assert_eq!(layer(&s, c).parent, None, "C lost its parent");
assert!(layer(&s, d).track_matte.is_none(), "D lost its matte");
assert_eq!(refs(&s, c), vec![None], "C's reference to the pre-comp layer is cleared");
assert_eq!(refs(&s, d), vec![Some(c)], "D's reference to C stays");
scene_look(&mut s);
s.execute("edit.undo", json!({})).unwrap();
assert!(whole_project(&s) == before, "undo restores the links");
assert_eq!(s.history.undo.len(), undo);
}
/// Expressions of other layers that name the pre-comp layer (in quotes, or by its index) would no
/// longer find it: one warning per property; an expression naming another layer is not one. Forced,
/// the expressions stay as they are and the comp still renders.
#[test]
fn unprecompose_warns_about_expressions_that_refer_to_it() {
let (mut s, _, _, pre) = unprecompose_scene();
let c = id_named(&s, "C");
let d = solid(&mut s, "#ffff00");
s.execute("layer.rename", json!({"layers": [d], "name": "D"})).unwrap();
let index = s.active_comp().unwrap().layers.iter().position(|l| l.id.0 == pre).unwrap() + 1;
let set = |s: &mut Session, on: u64, path: &str, text: &str| {
s.execute("prop.setExpression", json!({"layer": on, "path": path, "expression": text})).unwrap();
};
set(&mut s, c, "transform/position", "thisComp.layer(\"Inner\").transform.position");
set(&mut s, c, "transform/rotation", &format!("thisComp.layer( {index} ).transform.rotation"));
// Naming another layer, or another comp's layer at the same index, is no reference to it.
set(&mut s, d, "transform/position", "thisComp.layer(\"C\").transform.position");
set(&mut s, d, "transform/rotation", &format!("comp(\"Other\").layer({index}).transform.rotation"));
let dry = s.execute("layer.unprecompose", json!({"layers": [pre], "dryRun": true})).unwrap();
let exprs: Vec<String> =
dry["warnings"].as_array().unwrap().iter().filter_map(|w| w.as_str()).filter(|w| w.contains("expression")).map(str::to_string).collect();
assert_eq!(exprs.len(), 2, "{exprs:?}");
assert!(exprs.iter().all(|w| w.contains("layer “C”") && w.contains("“Inner”") && w.contains("no longer find it")), "{exprs:?}");
assert!(exprs.iter().any(|w| w.contains("Position")) && exprs.iter().any(|w| w.contains("Rotation")), "{exprs:?}");
warned_then_forced(&mut s, pre, "has an expression that refers to “Inner”");
// Forced, the expressions are left as written.
s.execute("layer.unprecompose", json!({"layers": [pre], "force": true})).unwrap();
let text = layer(&s, c).transform().unwrap().get("position").unwrap().expr.clone().unwrap().text;
assert_eq!(text, "thisComp.layer(\"Inner\").transform.position");
scene_look(&mut s);
}
/// An expression naming a layer by a number below the pre-comp layer points at another layer once
/// the nested comp's two layers replace it: a warning. One naming a number above it does not move.
#[test]
fn unprecompose_warns_when_it_renumbers_the_layers_an_expression_names() {
let (mut s, _, _, pre) = unprecompose_scene();
let c = id_named(&s, "C");
let index = s.active_comp().unwrap().layers.iter().position(|l| l.id.0 == pre).unwrap() + 1;
let warned = |s: &mut Session, number: usize| {
let text = format!("thisComp.layer({number}).transform.rotation");
s.execute("prop.setExpression", json!({"layer": c, "path": "transform/rotation", "expression": text})).unwrap();
let dry = s.execute("layer.unprecompose", json!({"layers": [pre], "dryRun": true})).unwrap();
dry["warnings"].as_array().unwrap().iter().filter_map(|w| w.as_str()).any(|w| w.contains("by number"))
};
assert!(warned(&mut s, index + 1), "a number below it");
assert!(!warned(&mut s, index - 1), "a number above it");
}
/// A nested comp that preserves its own frame rate (10 fps in a 30 fps comp) samples its layers on
/// its own frames; expanded, they would change on every frame of the parent: a warning. Without
/// Preserve Frame Rate the nested layers are sampled at the parent's times already: no warning.
#[test]
fn unprecompose_warns_when_the_nested_comp_preserves_another_frame_rate() {
let (mut s, _, inner, pre) = unprecompose_scene();
s.execute("comp.settings", json!({"comp": inner, "frameRate": 10})).unwrap();
let dry = s.execute("layer.unprecompose", json!({"layers": [pre], "dryRun": true})).unwrap();
assert_eq!(dry["warnings"], json!([]), "a different rate alone samples at the parent's times");
s.execute("comp.settings", json!({"comp": inner, "preserveFrameRate": true})).unwrap();
warned_then_forced(&mut s, pre, "preserves a frame rate");
// A motion-blurred nested layer under another shutter would be blurred with the parent's.
let (mut s, outer, inner, pre) = unprecompose_scene();
s.execute("comp.settings", json!({"comp": inner, "shutterAngle": 90})).unwrap();
let dry = s.execute("layer.unprecompose", json!({"layers": [pre], "dryRun": true})).unwrap();
assert_eq!(dry["warnings"], json!([]), "no layer uses motion blur");
s.execute("comp.open", json!({"comp": inner})).unwrap();
let a = id_named(&s, "A");
s.execute("layer.setSwitch", json!({"layers": [a], "switch": "motionBlur", "value": true})).unwrap();
s.execute("comp.open", json!({"comp": outer.0})).unwrap();
warned_then_forced(&mut s, pre, "motion blur shutter");
}
/// The pre-comp layer's move (Position against Anchor Point) is added to the stored Position of
/// unparented nested layers; one whose Position has an expression may ignore it: a warning, only
/// when there is a move.
#[test]
fn unprecompose_warns_about_position_expressions_it_cannot_move() {
let (mut s, outer, inner, pre) = unprecompose_scene();
s.execute("comp.open", json!({"comp": inner})).unwrap();
let a = id_named(&s, "A");
s.execute("prop.setExpression", json!({"layer": a, "path": "transform/position", "expression": "[100, 100]"})).unwrap();
s.execute("comp.open", json!({"comp": outer.0})).unwrap();
let dry = s.execute("layer.unprecompose", json!({"layers": [pre], "dryRun": true})).unwrap();
assert_eq!(dry["warnings"], json!([]), "no move, nothing to lose");
s.execute("prop.set", json!({"layer": pre, "path": "transform/position", "value": [360, 160]})).unwrap();
warned_then_forced(&mut s, pre, "layer “A” has a Position expression");
}
/// Expanded copies keep pointing at each other: a track matte and a layer-valued effect property
/// on a nested layer follow the copy of the layer they named, and the nested comp's markers are
/// moved by the pre-comp layer's Start and merged in time order with the comp's own.
#[test]
fn unprecompose_remaps_mattes_layer_values_and_merges_markers_in_order() {
let (mut s, outer, inner, pre) = unprecompose_scene();
s.execute("comp.open", json!({"comp": inner})).unwrap();
let (a, b) = (id_named(&s, "A"), id_named(&s, "B"));
s.execute("layer.setParent", json!({"layers": [b], "parent": null})).unwrap();
s.execute("layer.setTrackMatte", json!({"layer": b, "matte": a})).unwrap();
s.execute("effect.apply", json!({"layers": [b], "effect": "Set Matte"})).unwrap();
{
let proj = std::sync::Arc::make_mut(&mut s.project);
let l = proj.comp_mut(effectcraft_project::ItemId(inner)).unwrap().layer_mut(effectcraft_project::LayerId(b)).unwrap();
l.props.walk_mut(&mut |p| {
if matches!(p.value, KV::Layer(_)) {
p.value = KV::Layer(Some(a));
}
});
}
for t in [2.5, 0.5] {
s.execute("comp.addMarker", json!({"time": t, "comment": format!("in {t}")})).unwrap();
}
s.execute("comp.open", json!({"comp": outer.0})).unwrap();
s.execute("comp.addMarker", json!({"time": 2.0, "comment": "out"})).unwrap();
s.execute("layer.timing", json!({"layers": [pre], "op": "set", "start": 1.0})).unwrap();
s.execute("layer.unprecompose", json!({"layers": [pre]})).unwrap();
let (a, b) = (layer(&s, id_named(&s, "A")), layer(&s, id_named(&s, "B")));
assert_eq!(b.track_matte.map(|m| m.layer), Some(a.id), "B's matte is the copy of A");
let mut refs = vec![];
b.props.walk("", &mut |_, p| {
if let KV::Layer(v) = p.value {
refs.push(v);
}
});
assert_eq!(refs, vec![Some(a.id.0)], "Set Matte names the copy of A");
let markers: Vec<(f64, String)> = s.active_comp().unwrap().markers.iter().map(|m| (m.time.seconds(), m.comment.clone())).collect();
let want = [(1.5, "in 0.5"), (2.0, "out"), (3.5, "in 2.5")];
assert_eq!(markers.len(), 3, "{markers:?}");
for ((t, c), (wt, wc)) in markers.iter().zip(want) {
assert!((t - wt).abs() < 1e-9 && c == wc, "{markers:?}");
}
scene_look(&mut s);
}
/// Every reason at once, forced: unpacked without a crash, one undo step, undone exactly.
#[test]
fn unprecompose_forced_with_everything_at_once() {
let (mut s, outer, inner, pre) = unprecompose_scene();
let c = id_named(&s, "C");
s.execute("comp.open", json!({"comp": inner})).unwrap();
let a = id_named(&s, "A");
for sw in ["solo", "threeD", "adjustment", "preserveTransparency"] {
s.execute("layer.setSwitch", json!({"layers": [a], "switch": sw, "value": true})).unwrap();
}
s.execute("layer.newCamera", json!({})).unwrap();
s.execute("comp.open", json!({"comp": outer.0})).unwrap();
s.execute("prop.set", json!({"layer": pre, "path": "transform/opacity", "value": 20})).unwrap();
s.execute("prop.addKey", json!({"layer": pre, "path": "transform/rotation", "time": 0, "value": 30})).unwrap();
s.execute("effect.apply", json!({"layers": [pre], "effect": "Gaussian Blur"})).unwrap();
s.execute("layer.addMask", json!({"layer": pre})).unwrap();
s.execute("layer.style.add", json!({"layers": [pre], "style": "dropShadow"})).unwrap();
s.execute("layer.setBlendMode", json!({"layers": [pre], "mode": "Screen"})).unwrap();
s.execute("layer.enableTimeRemap", json!({"layers": [pre], "value": true})).unwrap();
s.execute("layer.timeStretch", json!({"layers": [pre], "percent": -50.0})).unwrap();
for sw in ["solo", "guide", "threeD", "collapse", "adjustment", "preserveTransparency"] {
s.execute("layer.setSwitch", json!({"layers": [pre], "switch": sw, "value": true})).unwrap();
}
s.execute("layer.setSwitch", json!({"layers": [pre], "switch": "video", "value": false})).unwrap();
s.execute("layer.setParent", json!({"layers": [pre], "parent": c})).unwrap();
s.execute("layer.setTrackMatte", json!({"layer": pre, "matte": c})).unwrap();
let undo = s.history.undo.len();
let before = whole_project(&s);
let r = s.execute("layer.unprecompose", json!({"layers": [pre], "force": true})).unwrap();
assert!(r["warnings"].as_array().unwrap().len() >= 10, "{r}");
// The undo history is capped (32 levels by default), so its length may stay; one undo is what
// takes the whole unpack back.
assert!(s.history.undo.len() == undo + 1 || s.history.undo.len() == undo, "one undo step");
assert!(whole_project(&s) != before, "it unpacked");
scene_look(&mut s);
assert!(s.history.redo.is_empty());
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(s.history.redo.len(), 1, "one undo step took it all back");
assert!(whole_project(&s) == before, "undo restores the exact project");
}
/// Quick Set Anchor (#2967): every one of the nine points moves the Anchor Point of a rotated,
/// scaled layer with keyed Position to that point of its content, and the layer looks the same
/// on every frame (Position and its keys are compensated), in one undo step.
+9 -11
View File
@@ -292,7 +292,7 @@ fn parented_point_light_keeps_its_direction_under_a_rotated_null() {
assert!((eye1 - eye0).length() < 1e-6 && (f1 - f0).length() < 1e-6, "{eye0:?} {eye1:?} {f0:?} {f1:?}");
}
/// Keyed Orientation under a Z-only turn is shifted exactly, so the light keeps its direction at
/// Keyed Orientation under a Z-only turn is re-expressed exactly, so the light keeps its direction at
/// every time, between the keys too.
#[test]
fn keyed_orientation_under_a_z_turn_keeps_the_direction_at_every_time() {
@@ -310,8 +310,8 @@ fn keyed_orientation_under_a_z_turn_keeps_the_direction_at_every_time() {
}
}
/// Under a tilted null the keys alone cannot follow (Euler angles are blended between them), so keys are
/// added between them: the light keeps its direction within a small angle at every time.
/// Under a tilted null the re-expressed endpoints preserve the quaternion arc exactly,
/// without adding keys between them.
#[test]
fn keyed_orientation_under_a_tilted_null_follows_between_the_keys() {
{
@@ -326,10 +326,9 @@ fn keyed_orientation_under_a_tilted_null_follows_between_the_keys() {
s.execute("layer.setParent", json!({"layers": [light], "parent": nul})).unwrap();
for (t, b) in times.into_iter().zip(before) {
let a = frame_at(&s, light, t);
// 0.05 degrees is about 9e-4 of a radian on a unit vector.
assert!((a.0 - b.0).length() < 1e-6 && (a.1 - b.1).length() < 2e-3, "at {t:?}: {:?} vs {:?}", a.1, b.1);
assert!((a.0 - b.0).length() < 1e-6 && (a.1 - b.1).length() < 1e-8, "at {t:?}: {:?} vs {:?}", a.1, b.1);
}
assert!(layer_in(&s, cid(&s), light).props.prop("transform/orientation").unwrap().keys.len() > 2, "keys were added between the two");
assert_eq!(layer_in(&s, cid(&s), light).props.prop("transform/orientation").unwrap().keys.len(), 2, "quaternion arcs need no extra keys");
}
}
@@ -351,10 +350,9 @@ fn one_node_light_under_a_mirrored_null_keeps_its_authored_values() {
assert_eq!(v3(&after, "transform/orientation"), v3(&before, "transform/orientation"));
}
/// The cap on keys added by baking is for the whole Orientation property, not for each segment:
/// many strongly eased segments under a tilted parent stay within it.
/// Many strongly eased Orientation segments keep their authored key count and easing.
#[test]
fn baked_orientation_keys_are_capped_across_the_whole_property() {
fn reexpressed_orientation_preserves_key_count_across_the_whole_property() {
let mut s = session();
let light = s.execute("layer.newLight", json!({"kind": "Point", "position": [320, 180, -500]})).unwrap()["layer"].as_u64().unwrap();
let count = 120;
@@ -369,8 +367,8 @@ fn baked_orientation_keys_are_capped_across_the_whole_property() {
let nul = rotated_null(&mut s, [20.0, -35.0, 60.0], 30.0);
s.execute("layer.setParent", json!({"layers": [light], "parent": nul})).unwrap();
let n = layer_in(&s, cid(&s), light).props.prop("transform/orientation").unwrap().keys.len();
assert!(n <= 4096, "{n} keys: the cap is for the property, not per segment");
// the segments the cap left unchecked keep their authored easing instead of turning into straight blends
assert_eq!(n, count, "re-expressing quaternion arcs must not add keys");
// Re-expressed endpoints retain their authored easing.
let orientation = layer_in(&s, cid(&s), light);
let keys = &orientation.props.prop("transform/orientation").unwrap().keys;
let last = keys.iter().rev().nth(1).expect("the penultimate key");
+3 -2
View File
@@ -1,6 +1,6 @@
//! Image sequences (#297): File ▸ Import with the "<format> Sequence" option, picked ranges,
//! folders, Force Alphabetical Order, the import frame rate, Replace Footage, missing frames
//! (placeholders, as in After Effects) and Interpret Footage ▸ Start Timecode.
//! (they hold the previous frame) and Interpret Footage ▸ Start Timecode.
use effectcraft_project::{Footage, FootageKind, ItemId, ItemKind};
use effectcraft_time::{FrameRate, Tick};
@@ -62,12 +62,13 @@ fn one_picked_numbered_file_imports_its_run_as_one_sequence() {
assert_eq!(name, "shot_[0001-0005].png");
assert_eq!((f.kind, f.sequence.len(), f.alphabetical), (FootageKind::Sequence, 4, false));
assert_eq!(f.path, at(&dir, "shot_0001.png"));
// Frames 1–5 at Settings ▸ Import ▸ Sequence Footage (30 fps); frame 3 is a placeholder.
// Frames 1–5 at Settings ▸ Import ▸ Sequence Footage (30 fps); frame 3 holds frame 2.
assert_eq!(f.duration, FrameRate::FPS_30.tick_of(5));
assert_eq!(f.sequence_file(2), None);
// Settings ▸ Import ▸ Report Missing Frames.
let w = r["warnings"][0].as_str().unwrap();
assert!(w.contains("1 missing frame (3)"), "{w}");
assert!(w.contains("previous frame is held") && !w.contains("placeholder"), "{w}");
}
/// Picking every frame of a sequence used to make one item per frame (each a whole sequence on
@@ -431,7 +431,7 @@ fn parenting_animated_orientation_and_nonuniform_parent_keep_authored_pose_bound
}
/// Keyed Orientation under a uniform parent is re-expressed at every key, so the child's world pose
/// and pixels stay put at every time (the parent is Z-only, so the paths between keys stay exact too).
/// and pixels stay put at every time along the quaternion arcs between keys.
#[test]
fn parenting_uniform_parent_re_expresses_keyed_orientation() {
for factor in [120.0, -120.0] {
@@ -544,3 +544,49 @@ fn parenting_camera_to_default_null_preserves_framing() {
assert_eq!(authored(&s, "transform/position"), pos0, "unparenting restores position");
assert_eq!(authored(&s, "transform/poi"), poi0, "unparenting restores point of interest");
}
/// A constant parent rotation preserves the full eased quaternion path, including mixed axes.
#[test]
fn parenting_mixed_axis_eased_orientation_preserves_arcs_and_key_metadata() {
use effectcraft_engine::keyframe::{Ease, Interp};
for factor in [120.0, -120.0] {
let (mut s, p, c) = fixture(true, true);
set(&mut s, p, "transform/scale", json!([factor, factor, factor]));
for (time, value) in [(0.0, json!([-35.0, 48.0, 150.0])), (0.7, json!([62.0, -27.0, -145.0])), (1.6, json!([-12.0, 58.0, 70.0]))] {
command(&mut s, "prop.addKey", json!({"layer":c,"path":"transform/orientation","time":time,"value":value}));
}
let cid = s.active_comp_id().unwrap();
let orientation = Arc::make_mut(&mut s.project).comp_mut(cid).unwrap().layer_mut(LayerId(c)).unwrap().props.prop_mut("transform/orientation").unwrap();
for key in &mut orientation.keys {
key.in_interp = Interp::Bezier;
key.out_interp = Interp::Bezier;
key.in_ease = vec![Ease { speed: 17.0, influence: 0.3 }];
key.out_ease = vec![Ease { speed: 11.0, influence: 0.4 }];
}
orientation.keys[1].auto_bezier = true;
let original = orientation.keys.clone();
let times: Vec<_> = (0..=32).map(|i| Tick::from_seconds_f64(i as f64 * 0.05)).collect();
let before: Vec<_> = times.iter().map(|&t| world(&s, c, t)).collect();
let old_project = s.project.clone();
parent(&mut s, c, Some(p), true);
let converted = &s.active_comp().unwrap().layer(LayerId(c)).unwrap().props.prop("transform/orientation").unwrap().keys;
assert_eq!(converted.len(), original.len());
for (new, old) in converted.iter().zip(&original) {
let mut metadata = new.clone();
metadata.value = old.value.clone();
assert_eq!(&metadata, old, "all authored key metadata must survive");
}
// Rotation matrices represent both q and -q identically, so compare transformed geometry
// rather than Euler representatives or quaternion signs.
for (&t, &matrix) in times.iter().zip(&before) {
assert!(displacement(matrix, world(&s, c, t)) < 1e-8, "pose changed at {t:?}, factor {factor}");
}
parent(&mut s, c, None, true);
for (&t, &matrix) in times.iter().zip(&before) {
assert!(displacement(matrix, world(&s, c, t)) < 1e-8, "unparent changed pose at {t:?}");
}
assert!(s.undo());
assert!(s.undo());
assert!(Arc::ptr_eq(&old_project, &s.project));
}
}
+19 -3
View File
@@ -167,12 +167,28 @@ function normalize(a) {
return l === 0 ? a.map(function () { return 0; }) : div(a, l);
}
// Orientation that points the layer's z axis at `at` with its x axis kept level, in the
// Orientation order (Rx · Ry · Rz) and wrapped to [0, 360), as After Effects returns it.
function lookAt(from, at) {
var d = normalize(sub(__v(at), __v(from)));
var x = d[0], y = d[1], z = d[2] || 0;
var rx = -Math.asin(Math.max(-1, Math.min(1, y))) * 180 / Math.PI;
var ry = Math.atan2(x, z) * 180 / Math.PI;
return [rx, ry, 0];
var p = Math.asin(Math.max(-1, Math.min(1, -y)));
var w = Math.atan2(x, z);
var cw = Math.cos(w), sw = Math.sin(w), cp = Math.cos(p), sp = Math.sin(p);
// The rotation Ry(w) · Rx(p), written as Orientation angles.
var oy = Math.asin(Math.max(-1, Math.min(1, sw * cp)));
var ox, oz;
if (Math.sqrt(cw * cw + sw * sw * sp * sp) > 1e-9) {
ox = Math.atan2(sp, cw * cp);
oz = Math.atan2(-sw * sp, cw);
} else {
ox = Math.atan2(cw * sp, cp);
oz = 0;
}
return [ox, oy, oz].map(function (r) {
var a = ((r * 180 / Math.PI) % 360 + 360) % 360;
return a >= 360 - 1e-9 || Math.abs(a) < 1e-9 ? 0 : a;
});
}
function degreesToRadians(d) { return d * Math.PI / 180; }
+6
View File
@@ -586,6 +586,12 @@ fn angles_and_look_at() {
assert_close!(f.num("degreesToRadians(90)", 0.0), std::f64::consts::FRAC_PI_2);
let o = f.ev(f.a, "transform/orientation", "lookAt([0, 0, 0], [100, 0, 100])", 0.0).unwrap().as_vec3();
assert!(close(o[1], 45.0) && close(o[0], 0.0), "{o:?}");
// After Effects 2026 read-backs of lookAt(position, …) on a layer at [960, 540, 0].
for (at, want) in [([1500.0, 200.0, 300.0], [48.5763, 49.9802, 319.0442]), ([500.0, 900.0, -400.0], [221.9872, 319.4766, 210.3184])] {
let e = format!("lookAt([960, 540, 0], [{}, {}, {}])", at[0], at[1], at[2]);
let o = f.ev(f.a, "transform/orientation", &e, 0.0).unwrap().as_vec3();
assert!((0..3).all(|i| (o[i] - want[i]).abs() < 1e-3), "{e}: {o:?}");
}
}
// ---- interpolation ----------------------------------------------------------------------------
+153 -7
View File
@@ -3,7 +3,8 @@
//!
//! Conventions follow After Effects' public documentation: y grows downwards, angles are degrees
//! clockwise on screen, 3D uses a left-handed system with +z pointing into the screen, and a 3D
//! layer's rotation is Orientation (X, Y, Z) followed by X Rotation, Y Rotation, Z Rotation.
//! layer's rotation is Orientation then X/Y/Z Rotation, each composed as `Rx · Ry · Rz` (Z turns
//! the vector first; measured in After Effects).
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
@@ -321,17 +322,32 @@ impl Mat4 {
let (s, c) = deg.to_radians().sin_cos();
Mat4([[c, -s, 0.0, 0.0], [s, c, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0]])
}
/// Orientation (degrees, X then Y then Z applied to the vector in that order).
/// Orientation (degrees): `Rx(x) · Ry(y) · Rz(z)`, i.e. Z is applied to the vector first,
/// then Y, then X. Measured in After Effects 2026 (Orientation [10,20,30] and X/Y/Z Rotation
/// 10/20/30 on a 3D solid match this order to the anti-aliased edge; the reverse order is
/// 5 % of the frame off).
pub fn orientation(o: Vec3) -> Mat4 {
Mat4::rotate_z(o.z) * Mat4::rotate_y(o.y) * Mat4::rotate_x(o.x)
Mat4::rotate_x(o.x) * Mat4::rotate_y(o.y) * Mat4::rotate_z(o.z)
}
/// The 3D layer transform: layer space → parent space.
/// X, Y and Z Rotation of a 3D layer (degrees), composed like [`Mat4::orientation`].
pub fn rotation_xyz(x: f64, y: f64, z: f64) -> Mat4 {
Mat4::rotate_x(x) * Mat4::rotate_y(y) * Mat4::rotate_z(z)
}
/// The Orientation angles (degrees) of this matrix's rotation part: the inverse of
/// [`Mat4::orientation`] (Y in [-90, 90], X and Z in (-180, 180]; Z = 0 at gimbal lock).
pub fn orientation_angles(&self) -> Vec3 {
let m = &self.0;
let sy = m[0][2].clamp(-1.0, 1.0);
let cy = m[0][0].hypot(m[0][1]);
let y = sy.atan2(cy);
let (x, z) = if cy > 1e-9 { ((-m[1][2]).atan2(m[2][2]), (-m[0][1]).atan2(m[0][0])) } else { (m[2][1].atan2(m[1][1]), 0.0) };
vec3(x.to_degrees(), y.to_degrees(), z.to_degrees())
}
/// The 3D layer transform: layer space → parent space (Orientation, then X/Y/Z Rotation).
pub fn layer_3d(anchor: Vec3, position: Vec3, scale_pct: Vec3, orientation: Vec3, rotation: Vec3) -> Mat4 {
Mat4::translate(position)
* Mat4::orientation(orientation)
* Mat4::rotate_z(rotation.z)
* Mat4::rotate_y(rotation.y)
* Mat4::rotate_x(rotation.x)
* Mat4::rotation_xyz(rotation.x, rotation.y, rotation.z)
* Mat4::scale(scale_pct / 100.0)
* Mat4::translate(-anchor)
}
@@ -448,6 +464,76 @@ pub fn look_at(pos: Vec3, poi: Vec3, extra: Mat4) -> Mat4 {
extra * rot * Mat4::translate(-pos)
}
/// Unit quaternion `[w, x, y, z]` of [`Mat4::orientation`]`(o)`.
fn orientation_quat(o: Vec3) -> [f64; 4] {
let half = |d: f64| (d.to_radians() * 0.5).sin_cos();
let (sx, cx) = half(o.x);
let (sy, cy) = half(o.y);
let (sz, cz) = half(o.z);
// qx · qy · qz (Hamilton product), matching Rx · Ry · Rz.
let (w, x, y, z) = (cx * cy, sx * cy, cx * sy, sx * sy);
[w * cz - z * sz, x * cz + y * sz, y * cz - x * sz, z * cz + w * sz]
}
fn quat_matrix(q: [f64; 4]) -> Mat4 {
let [w, x, y, z] = q;
Mat4([
[1.0 - 2.0 * (y * y + z * z), 2.0 * (x * y - z * w), 2.0 * (x * z + y * w), 0.0],
[2.0 * (x * y + z * w), 1.0 - 2.0 * (x * x + z * z), 2.0 * (y * z - x * w), 0.0],
[2.0 * (x * z - y * w), 2.0 * (y * z + x * w), 1.0 - 2.0 * (x * x + y * y), 0.0],
[0.0, 0.0, 0.0, 1.0],
])
}
/// Angle (degrees, 0..=180) of the shortest rotation between two Orientations.
pub fn orientation_arc_deg(a: Vec3, b: Vec3) -> f64 {
let (p, q) = (orientation_quat(a), orientation_quat(b));
let d = (p[0] * q[0] + p[1] * q[1] + p[2] * q[2] + p[3] * q[3]).abs().clamp(0.0, 1.0);
let arc = 2.0 * d.acos().to_degrees();
if arc.is_finite() { arc } else { 0.0 }
}
/// Orientation between two Orientations at fraction `t`, as After Effects interpolates keyed
/// Orientation: spherical linear interpolation of the two rotations along the shortest arc,
/// written as angles in [0, 360) (measured in After Effects 2026: keys [0,0,0] → [10,20,30] read
/// back [6.2643, 9.3531, 15.2190] half-way, [0,0,0] → [60,80,40] [30.6186, 35.7617, 28.4317],
/// [0,0,0] → [0,0,270] [0, 0, 315], [0,0,0] → [0,0,720] [0, 0, 0]).
pub fn orientation_slerp(a: Vec3, b: Vec3, t: f64) -> Vec3 {
let blend = a + (b - a) * t;
if !t.is_finite() || !blend.x.is_finite() || !blend.y.is_finite() || !blend.z.is_finite() {
return a;
}
let p = orientation_quat(a);
let mut q = orientation_quat(b);
let mut d = p[0] * q[0] + p[1] * q[1] + p[2] * q[2] + p[3] * q[3];
if d < 0.0 {
q = q.map(|c| -c);
d = -d;
}
let d = d.clamp(-1.0, 1.0);
let (ka, kb) = if d > 1.0 - 1e-12 {
(1.0 - t, t)
} else {
let om = d.acos();
let s = om.sin();
(((1.0 - t) * om).sin() / s, (t * om).sin() / s)
};
let mut r = [0.0; 4];
for (i, c) in r.iter_mut().enumerate() {
*c = ka * p[i] + kb * q[i];
}
let n = r.iter().map(|c| c * c).sum::<f64>().sqrt();
if !(n > 1e-12 && n.is_finite()) {
return a;
}
let e = quat_matrix(r.map(|c| c / n)).orientation_angles();
let wrap = |v: f64| {
let w = v.rem_euclid(360.0);
if w >= 360.0 - 1e-9 || w.abs() < 1e-9 { 0.0 } else { w }
};
vec3(wrap(e.x), wrap(e.y), wrap(e.z))
}
#[cfg(test)]
mod tests {
use super::*;
@@ -512,6 +598,66 @@ mod tests {
assert!((i.apply(m.apply(p)) - p).length() < 1e-9);
}
fn near3(a: Vec3, b: [f64; 3], tol: f64) -> bool {
(a.x - b[0]).abs() < tol && (a.y - b[1]).abs() < tol && (a.z - b[2]).abs() < tol
}
#[test]
fn orientation_applies_z_then_y_then_x() {
// Rx · Ry · Rz: the vector is turned about Z first.
let o = Mat4::orientation(vec3(90.0, 0.0, 90.0));
let p = o.apply_vec(vec3(1.0, 0.0, 0.0));
// Rz(90): +x → +y; Rx(90): +y → +z.
assert!((p - vec3(0.0, 0.0, 1.0)).length() < 1e-12, "{p:?}");
let r = Mat4::rotation_xyz(10.0, 20.0, 30.0);
assert_eq!(r, Mat4::orientation(vec3(10.0, 20.0, 30.0)));
}
#[test]
fn orientation_angles_invert_orientation() {
for o in [[10.0, 20.0, 30.0], [-45.0, 80.0, 170.0], [120.0, -30.0, -90.0], [0.0, 90.0, 0.0], [30.0, -90.0, 0.0]] {
let m = Mat4::orientation(Vec3::from(o));
let back = Mat4::orientation(m.orientation_angles());
for r in 0..3 {
for c in 0..3 {
assert!((m.0[r][c] - back.0[r][c]).abs() < 1e-9, "{o:?}");
}
}
}
}
#[test]
fn keyed_orientation_slerps_as_after_effects() {
// After Effects 2026 read-backs of linear Orientation keys (cases 703 and 802).
let a = vec3(0.0, 0.0, 0.0);
let b = vec3(10.0, 20.0, 30.0);
assert!(near3(orientation_slerp(a, b, 0.25), [3.4341, 4.4691, 7.7129], 1e-3));
assert!(near3(orientation_slerp(a, b, 0.5), [6.2643, 9.3531, 15.2190], 1e-3));
assert!(near3(orientation_slerp(a, b, 0.75), [8.4650, 14.5598, 22.6095], 1e-3));
let b = vec3(60.0, 80.0, 40.0);
assert!(near3(orientation_slerp(a, b, 0.25), [16.9201, 16.0688, 16.0629], 1e-3));
assert!(near3(orientation_slerp(a, b, 0.5), [30.6186, 35.7617, 28.4317], 1e-3));
// The shortest way round, written in [0, 360).
assert!(near3(orientation_slerp(a, vec3(0.0, 0.0, 270.0), 0.5), [0.0, 0.0, 315.0], 1e-9));
assert!(near3(orientation_slerp(a, vec3(0.0, 0.0, 270.0), 0.25), [0.0, 0.0, 337.5], 1e-9));
assert!(near3(orientation_slerp(a, vec3(350.0, 0.0, 0.0), 0.5), [355.0, 0.0, 0.0], 1e-9));
assert!(near3(orientation_slerp(a, vec3(0.0, 0.0, 720.0), 0.5), [0.0, 0.0, 0.0], 1e-9));
// The ends are the key rotations.
let k = vec3(200.0, 10.0, 5.0);
let end = Mat4::orientation(orientation_slerp(vec3(0.0, 0.0, 350.0), k, 1.0));
let want = Mat4::orientation(k);
assert!((0..3).all(|r| (0..3).all(|c| (end.0[r][c] - want.0[r][c]).abs() < 1e-9)));
// Equal rotations and hostile input do not blow up.
let whole = Mat4::orientation(orientation_slerp(a, vec3(0.0, 0.0, 360.0), 0.5)).apply_vec(vec3(1.0, 0.0, 0.0));
assert!((whole - vec3(1.0, 0.0, 0.0)).length() < 1e-9 && orientation_arc_deg(a, vec3(0.0, 0.0, 360.0)) < 1e-6);
let nan = orientation_slerp(vec3(f64::NAN, 0.0, 0.0), b, 0.5);
// A non-finite key comes back as given (the first key), not as a blended rotation.
assert!(nan.x.is_nan() && nan.y == 0.0 && nan.z == 0.0, "{nan:?}");
assert!(orientation_slerp(a, b, f64::INFINITY).x.is_finite());
assert!((orientation_arc_deg(a, vec3(0.0, 0.0, 90.0)) - 90.0).abs() < 1e-9);
assert!((orientation_arc_deg(a, vec3(0.0, 0.0, 270.0)) - 90.0).abs() < 1e-9);
}
#[test]
fn default_camera_sees_z0_plane_unscaled() {
let (w, h) = (1920.0, 1080.0);
+3 -2
View File
@@ -601,6 +601,8 @@ fn minimax(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option<GBuf> {
// ---------------------------------------------------------------- Echo / Posterize Time (time_fx.rs)
/// Echo on the GPU (`time_fx::echo`): the frames fetched on the CPU, folded in order by
/// `fxn_echo` into two ping-pong images, then `fxn_echo_fin` clamps the result.
fn echo(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
let n = ctx.params.f("numberOfEchoes").round().clamp(0.0, 255.0) as usize;
let dt = ctx.params.f("echoTime");
@@ -620,8 +622,7 @@ fn echo(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
e.pixels("fxn_echo", &p, a, Some(&frame), o, None);
acc.swap(0, 1);
}
let mut p = with_u0([op, 0, 0, 0]);
p.f[0][0] = (n + 1) as f32;
let p = with_u0([op, 0, 0, 0]);
let img = run(e, "fxn_echo_fin", &p, &acc[0], None, None);
Some(GBuf { img, offset: grid.offset, scale: grid.scale })
}
+6 -7
View File
@@ -407,12 +407,14 @@ fn fxn_echo(@builtin(global_invocation_id) gid: vec3<u32>) {
case 3u: {
acc = acc + q - acc * q;
}
// Composite In Back: each echo over what is there (time_fx.rs echo).
case 4u: {
acc += q * (1.0 - acc.w);
}
case 5u: {
acc = q + acc * (1.0 - q.w);
}
// Composite In Front: each echo behind what is there.
case 5u: {
acc += q * (1.0 - acc.w);
}
default: {
acc += q;
}
@@ -420,7 +422,7 @@ fn fxn_echo(@builtin(global_invocation_id) gid: vec3<u32>) {
textureStore(out, p, acc);
}
// Echo's finish. u[0].x = operator; f[0].x = number of frames.
// Echo's finish. u[0].x = operator. Blend sums like Add (time_fx.rs echo), so nothing divides.
@compute @workgroup_size(16, 16)
fn fxn_echo_fin(@builtin(global_invocation_id) gid: vec3<u32>) {
let p = fxn_pixel(gid);
@@ -429,9 +431,6 @@ fn fxn_echo_fin(@builtin(global_invocation_id) gid: vec3<u32>) {
}
let op = P.u[0].x;
var acc = textureLoad(src, p, 0);
if (op == 6u) {
acc = acc / P.f[0].x;
}
if (op == 2u && acc.x == FXN_MAX) {
acc = vec4<f32>(0.0);
}
+71 -8
View File
@@ -57,6 +57,12 @@ impl Ease {
pub const EASY: Ease = Ease { speed: 0.0, influence: 1.0 / 3.0 };
}
/// Influence of a linear key side in a segment whose other side is eased: the side keeps the
/// segment's average speed with a 16.67 % influence, as After Effects shows it in the Keyframe
/// Velocity dialog and evaluates it (time-remap read-backs: a bezier-out (speed 0, 50 %) into a
/// linear-in from 3.5 to 0.5 over 2 s gives 3.290 at a quarter and 2.657 at half of the segment).
pub const LINEAR_SIDE_INFLUENCE: f64 = 1.0 / 6.0;
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Keyframe {
pub time: Tick,
@@ -408,7 +414,7 @@ fn effective_ease<K: Borrow<Keyframe>>(keys: &[K], i: usize, d: usize, spatial:
/// The ease a key side presents in the Graph Editor and the Keyframe Velocity dialog: stored
/// Bezier eases, auto-Bezier slopes, or the equivalent of a linear side (speed of the straight
/// segment, 33.3 % influence). `None` for hold sides and sides without a neighbour.
/// segment, [`LINEAR_SIDE_INFLUENCE`]). `None` for hold sides and sides without a neighbour.
pub fn side_ease(keys: &[Keyframe], i: usize, d: usize, spatial: bool, out: bool) -> Option<Ease> {
let k = keys.get(i)?;
let j = if out { i + 1 } else { i.checked_sub(1)? };
@@ -429,7 +435,7 @@ pub fn side_ease(keys: &[Keyframe], i: usize, d: usize, spatial: bool, out: bool
let dv = b.get(d).unwrap_or(&0.0) - a.get(d).unwrap_or(&0.0);
if out { dv } else { -dv }
};
return Some(Ease { speed: dv / dur, influence: 1.0 / 3.0 });
return Some(Ease { speed: dv / dur, influence: LINEAR_SIDE_INFLUENCE });
}
Some(effective_ease(keys, i, if is_spatial { 0 } else { d }, is_spatial, out))
}
@@ -546,6 +552,63 @@ fn evaluate_active<K: Borrow<Keyframe>>(keys: &[K], t: Tick, spatial: bool) -> O
Some(segment_value_of(keys, i, t, spatial))
}
/// Evaluate keyed 3D Orientation at `t` as After Effects does: the temporal ease gives the
/// progress through each segment, and the rotation moves along the shortest arc between the two
/// keys ([`effectcraft_geom::orientation_slerp`]) instead of blending each angle. Speeds are
/// degrees per second along that arc. Values other than 3D vectors evaluate as [`evaluate`].
pub fn evaluate_orientation(keys: &[Keyframe], t: Tick) -> Option<Value> {
let first = keys.first()?;
if keys.len() == 1 || t <= first.time {
return Some(first.value.clone());
}
let last = keys.last()?;
if t >= last.time {
return Some(last.value.clone());
}
let i = keys.partition_point(|k| k.time <= t).checked_sub(1)?;
let (a, b) = (keys.get(i)?, keys.get(i + 1)?);
let (Value::Vec3(va), Value::Vec3(vb)) = (&a.value, &b.value) else { return Some(segment_value(keys, i, t, false)) };
if a.out_interp == Interp::Hold {
return Some(a.value.clone());
}
let (t0, t1) = (secs(a.time), secs(b.time));
let dur = (t1 - t0).max(1e-12);
let x = ((secs(t) - t0) / dur).clamp(0.0, 1.0);
// On a key: its own value, as stored.
if x <= 0.0 {
return Some(a.value.clone());
}
let arc = effectcraft_geom::orientation_arc_deg((*va).into(), (*vb).into());
let lin_out = a.out_interp == Interp::Linear;
let lin_in = b.in_interp != Interp::Bezier;
let f = if lin_out && lin_in || arc <= 1e-9 {
x
} else {
// Auto-Bezier keys take the arc speed between their neighbours.
let side = |j: usize, out: bool| -> Ease {
// A linear side keeps the segment's average speed at the same influence as in
// `segment_value` (and a missing key, unreachable here, is treated as one).
let Some(k) = keys.get(j) else { return Ease { speed: arc / dur, influence: LINEAR_SIDE_INFLUENCE } };
let linear = if out { lin_out } else { lin_in };
if linear {
return Ease { speed: arc / dur, influence: LINEAR_SIDE_INFLUENCE };
}
if k.auto_bezier && j > 0 && j + 1 < keys.len() {
let span = (secs(keys[j + 1].time) - secs(keys[j - 1].time)).max(1e-12);
let (Value::Vec3(p), Value::Vec3(n)) = (&keys[j - 1].value, &keys[j + 1].value) else { return Ease { speed: arc / dur, influence: 1.0 / 6.0 } };
return Ease { speed: effectcraft_geom::orientation_arc_deg((*p).into(), (*n).into()) / span, influence: 1.0 / 6.0 };
}
let list = if out { &k.out_ease } else { &k.in_ease };
list.first().copied().unwrap_or_default()
};
let (eo, ei) = (side(i, true), side(i + 1, false));
ease_progress(eo.speed * dur / arc, eo.influence, ei.speed * dur / arc, ei.influence, x)
};
let f = if f.is_finite() { f } else { x };
let o = effectcraft_geom::orientation_slerp((*va).into(), (*vb).into(), f);
Some(Value::Vec3([o.x, o.y, o.z]))
}
/// Value inside segment `i` → `i+1` at time `t`.
pub fn segment_value(keys: &[Keyframe], i: usize, t: Tick, spatial: bool) -> Value {
segment_value_of(keys, i, t, spatial)
@@ -576,8 +639,8 @@ fn segment_value_of<K: Borrow<Keyframe>>(keys: &[K], i: usize, t: Tick, spatial:
let f = if lin_out && lin_in || len <= 1e-12 {
x
} else {
let eo = if lin_out { Ease { speed: len / dur, influence: 1.0 / 3.0 } } else { effective_ease(keys, i, 0, true, true) };
let ei = if lin_in { Ease { speed: len / dur, influence: 1.0 / 3.0 } } else { effective_ease(keys, i + 1, 0, true, false) };
let eo = if lin_out { Ease { speed: len / dur, influence: LINEAR_SIDE_INFLUENCE } } else { effective_ease(keys, i, 0, true, true) };
let ei = if lin_in { Ease { speed: len / dur, influence: LINEAR_SIDE_INFLUENCE } } else { effective_ease(keys, i + 1, 0, true, false) };
ease_progress(eo.speed * dur / len, eo.influence, ei.speed * dur / len, ei.influence, x)
};
// A straight path (both handles on the chord, as auto-Bezier keys on a line have) is the
@@ -596,16 +659,16 @@ fn segment_value_of<K: Borrow<Keyframe>>(keys: &[K], i: usize, t: Tick, spatial:
let cb = b.value.components();
if ca.is_empty() || ca.len() != cb.len() {
// Paths, gradients: ease on dimension 0 as overall progress.
let eo = if lin_out { Ease { speed: 1.0 / dur, influence: 1.0 / 3.0 } } else { effective_ease(keys, i, 0, false, true) };
let ei = if lin_in { Ease { speed: 1.0 / dur, influence: 1.0 / 3.0 } } else { effective_ease(keys, i + 1, 0, false, false) };
let eo = if lin_out { Ease { speed: 1.0 / dur, influence: LINEAR_SIDE_INFLUENCE } } else { effective_ease(keys, i, 0, false, true) };
let ei = if lin_in { Ease { speed: 1.0 / dur, influence: LINEAR_SIDE_INFLUENCE } } else { effective_ease(keys, i + 1, 0, false, false) };
let f = ease_progress(eo.speed * dur, eo.influence, ei.speed * dur, ei.influence, x);
return a.value.lerp(&b.value, f);
}
let mut out = Vec::with_capacity(ca.len());
for d in 0..ca.len() {
let dv = cb[d] - ca[d];
let eo = if lin_out { Ease { speed: dv / dur, influence: 1.0 / 3.0 } } else { effective_ease(keys, i, d, false, true) };
let ei = if lin_in { Ease { speed: dv / dur, influence: 1.0 / 3.0 } } else { effective_ease(keys, i + 1, d, false, false) };
let eo = if lin_out { Ease { speed: dv / dur, influence: LINEAR_SIDE_INFLUENCE } } else { effective_ease(keys, i, d, false, true) };
let ei = if lin_in { Ease { speed: dv / dur, influence: LINEAR_SIDE_INFLUENCE } } else { effective_ease(keys, i + 1, d, false, false) };
// Bezier in (time, value) space directly: P1 = (i0, v0 + s0*i0*dur), P2 = (1-i1, v1 - s1*i1*dur).
let (i0, i1) = (eo.influence.clamp(0.0, 1.0), ei.influence.clamp(0.0, 1.0));
let u = solve_u(0.0, i0, 1.0 - i1, 1.0, x);
+90
View File
@@ -92,6 +92,41 @@ fn maximum_influence_resolves_flat_midpoint_time_curve() {
}
}
/// Keyed Orientation moves along the shortest arc, eased in time, as After Effects 2026 reads
/// it back (cases 802 and 803: keys [0,0,0] at 0 s and [60,80,40] at 2 s).
#[test]
fn keyed_orientation_matches_after_effects() {
let close = |v: Value, e: [f64; 3]| {
let v = v.as_vec3();
assert!((0..3).all(|i| (v[i] - e[i]).abs() < 2e-3), "{v:?} vs {e:?}");
};
let lin = vec![Keyframe::new(s(0.0), Value::Vec3([0.0; 3])), Keyframe::new(s(2.0), Value::Vec3([60.0, 80.0, 40.0]))];
close(evaluate_orientation(&lin, s(0.5)).unwrap(), [16.9201, 16.0688, 16.0629]);
close(evaluate_orientation(&lin, s(1.0)).unwrap(), [30.6186, 35.7617, 28.4317]);
close(evaluate_orientation(&lin, s(2.0)).unwrap(), [60.0, 80.0, 40.0]);
// A middle key reads back as stored (not as another angle triple of the same rotation).
let three =
vec![Keyframe::new(s(0.0), Value::Vec3([0.0; 3])), Keyframe::new(s(1.0), Value::Vec3([0.0, 120.0, 0.0])), Keyframe::new(s(2.0), Value::Vec3([0.0; 3]))];
assert_eq!(evaluate_orientation(&three, s(1.0)), Some(Value::Vec3([0.0, 120.0, 0.0])));
// Easy ease (AE's preset: speed 0, influence 33 %) on both keys.
let ease = |k: Keyframe| {
let mut k = k.eased();
k.in_ease = vec![Ease { speed: 0.0, influence: 0.33 }];
k.out_ease = vec![Ease { speed: 0.0, influence: 0.33 }];
k
};
let eased: Vec<Keyframe> = lin.into_iter().map(ease).collect();
close(evaluate_orientation(&eased, s(0.5)).unwrap(), [11.0974, 9.5750, 10.5916]);
close(evaluate_orientation(&eased, s(1.0)).unwrap(), [30.6186, 35.7617, 28.4317]);
close(evaluate_orientation(&eased, s(1.5)).unwrap(), [47.6915, 65.7590, 40.3338]);
// Hold keys and other value types.
let held = vec![Keyframe::new(s(0.0), Value::Vec3([1.0, 2.0, 3.0])).hold(), Keyframe::new(s(1.0), Value::Vec3([0.0; 3]))];
close(evaluate_orientation(&held, s(0.5)).unwrap(), [1.0, 2.0, 3.0]);
let scalar = vec![Keyframe::new(s(0.0), Value::Scalar(0.0)), Keyframe::new(s(1.0), Value::Scalar(10.0))];
assert_eq!(evaluate_orientation(&scalar, s(0.5)), Some(Value::Scalar(5.0)));
assert_eq!(evaluate_orientation(&[], s(0.5)), None);
}
/// M13.15: agents set gradients as plain JSON, not only the tagged form `get` returns.
#[test]
fn gradients_coerce_from_plain_json() {
@@ -189,6 +224,61 @@ fn bezier_with_linear_speed_matches_linear() {
}
}
/// A linear side next to an eased one keeps the segment's average speed at 16.67 %
/// influence. After Effects time-remap read-backs (a square at x = 50 + 455 * remap): out of
/// a key at 2 s (3.5, speed 0, influence 50 %) into a linear key at 4 s (0.5) the remap is
/// 3.290 at 2.5 s and 2.657 at 3 s (33.3 % gave 3.308 and 2.629).
#[test]
fn a_linear_side_next_to_an_eased_side_matches_after_effects() {
let mut a = Keyframe::new(s(2.0), Value::Scalar(3.5));
a.out_interp = Interp::Bezier;
a.out_ease = vec![Ease { speed: 0.0, influence: 0.5 }];
let keys = vec![a, Keyframe::new(s(4.0), Value::Scalar(0.5))];
for (t, ae) in [(2.5, 3.290), (3.0, 2.657)] {
let v = evaluate(&keys, s(t), false).unwrap().as_f64();
// AE's square edge is read to the pixel: 1 px = 1 / 455 of remap.
assert!((v - ae).abs() < 2.0 / 455.0, "{t}: {v} vs {ae}");
}
// The mirror case: a linear out into an eased in.
let mut b = Keyframe::new(s(4.0), Value::Scalar(0.5));
b.in_interp = Interp::Bezier;
b.in_ease = vec![Ease { speed: 0.0, influence: 0.5 }];
let mirror = vec![Keyframe::new(s(2.0), Value::Scalar(3.5)), b];
let v = evaluate(&mirror, s(3.5), false).unwrap().as_f64();
assert!((v - (4.0 - 3.290)).abs() < 2.0 / 455.0, "{v}");
assert_eq!(side_ease(&keys, 1, 0, false, false).map(|e| e.influence), Some(LINEAR_SIDE_INFLUENCE));
}
/// Keyed Orientation shapes a linear side next to an eased one as a scalar property does
/// (`LINEAR_SIDE_INFLUENCE`): about one axis the shortest arc is the angle itself, so the
/// orientation follows the same curve as a scalar with the same keys (CodeRabbit on #1876).
#[test]
fn orientation_linear_side_next_to_an_eased_side_matches_a_scalar() {
let ease = vec![Ease { speed: 0.0, influence: 0.5 }];
let (mut a, mut sa) = (Keyframe::new(s(2.0), Value::Vec3([0.0, 0.0, 0.0])), Keyframe::new(s(2.0), Value::Scalar(0.0)));
for k in [&mut a, &mut sa] {
k.out_interp = Interp::Bezier;
k.out_ease = ease.clone();
}
let orient = vec![a, Keyframe::new(s(4.0), Value::Vec3([0.0, 0.0, 90.0]))];
let scalar = vec![sa, Keyframe::new(s(4.0), Value::Scalar(90.0))];
// The mirror: a linear out into an eased in.
let (mut b, mut sb) = (Keyframe::new(s(4.0), Value::Vec3([0.0, 0.0, 90.0])), Keyframe::new(s(4.0), Value::Scalar(90.0)));
for k in [&mut b, &mut sb] {
k.in_interp = Interp::Bezier;
k.in_ease = ease.clone();
}
let orient_m = vec![Keyframe::new(s(2.0), Value::Vec3([0.0, 0.0, 0.0])), b];
let scalar_m = vec![Keyframe::new(s(2.0), Value::Scalar(0.0)), sb];
for (o, sc) in [(&orient, &scalar), (&orient_m, &scalar_m)] {
for t in [2.25, 2.5, 3.0, 3.5, 3.75] {
let Some(Value::Vec3(v)) = evaluate_orientation(o, s(t)) else { panic!("orientation at {t}") };
let want = evaluate(sc, s(t), false).unwrap().as_f64();
assert!((v[2] - want).abs() < 1e-6 && v[0].abs() < 1e-9 && v[1].abs() < 1e-9, "{t}: {v:?} vs {want}");
}
}
}
#[test]
fn spatial_linear_constant_speed() {
let keys = vec![Keyframe::new(s(0.0), Value::Vec2([0.0, 0.0])), Keyframe::new(s(1.0), Value::Vec2([300.0, 400.0]))];
+6 -2
View File
@@ -150,11 +150,15 @@ fn segment_ease(keys: &[Keyframe], i: usize, spatial: bool) -> (Vec<f64>, Vec<f6
let side = |k: &Keyframe, out: bool| {
let interp = if out { k.out_interp } else { k.in_interp };
let list = if out { &k.out_ease } else { &k.in_ease };
if interp == Interp::Bezier { list.first().copied().unwrap_or_default() } else { Ease { speed: 1.0 / dur, influence: 1.0 / 3.0 } }
if interp == Interp::Bezier {
list.first().copied().unwrap_or_default()
} else {
Ease { speed: 1.0 / dur, influence: kf::LINEAR_SIDE_INFLUENCE }
}
};
(1.0, Some(side(a, true)), Some(side(b, false)))
};
let lin = Ease { speed: dv / dur, influence: 1.0 / 3.0 };
let lin = Ease { speed: dv / dur, influence: kf::LINEAR_SIDE_INFLUENCE };
let (eo, ei) = (eo.unwrap_or(lin), ei.unwrap_or(lin));
// Crossing time handles must retain the independently specified influences.
let (i0, i1) = (eo.influence.clamp(0.0, 1.0), ei.influence.clamp(0.0, 1.0));
+2 -2
View File
@@ -736,9 +736,9 @@ impl Inner {
match footage.kind {
FootageKind::Sequence if !footage.sequence.is_empty() => {
let i = Self::frame_index(footage.frame_rate, t, footage.sequence_frames(), footage.loop_count);
match footage.sequence_file(i) {
match footage.sequence_file_held(i) {
Some(path) => Loc { key: key(path, 0), media_t: None, float },
// A gap in the numbering: colour bars, as in After Effects.
// No file at or before the frame: colour bars.
None => Loc { key: key(PLACEHOLDER, ((footage.width as i64) << 32) | footage.height as i64), media_t: None, float },
}
}
+6 -8
View File
@@ -710,10 +710,11 @@ fn png_sequence_import() {
assert_eq!(*img, load_png(&first.with_file_name("seq_0004.png")));
}
/// A gap in a sequence's numbering shows colour bars, as in After Effects; in Force
/// Alphabetical Order the files play back to back (#297).
/// A gap in a sequence's numbering holds the previous frame, as After Effects 2026 does (files
/// 1, 2, 4, 5 show frame 2 at frame 3); in Force Alphabetical Order the files play back to back
/// (#297).
#[test]
fn sequence_gaps_show_placeholders() {
fn sequence_gaps_hold_the_previous_frame() {
let dir = fixtures().join(format!("gap-seq-{}", std::process::id()));
std::fs::create_dir_all(&dir).expect("mkdir");
// Frames 1, 2 and 4: frame 3 is missing. Each frame is a flat grey of its number.
@@ -731,11 +732,8 @@ fn sequence_gaps_show_placeholders() {
let at = |f: &Footage, n: i64| pool.frame_at(f, t_of(f.frame_rate, n)).expect("frame");
let level = |img: &Image| (img.get(0, 0)[0] * 255.0).round() as u32;
assert_eq!(level(&at(&f, 1)), 100);
// The placeholder: colour bars across the frame, starting with 75 % white.
let bars = at(&f, 2);
assert_eq!((bars.width, bars.height), (8, 4));
assert_eq!(bars.get(0, 0), [0.75, 0.75, 0.75, 1.0]);
assert_eq!(bars.get(7, 3), [0.0, 0.0, 0.75, 1.0], "blue on the right");
// The gap holds frame 2.
assert_eq!(level(&at(&f, 2)), 100);
assert_eq!(level(&at(&f, 3)), 200);
f.alphabetical = true;
f.sync_sequence_duration();
+7
View File
@@ -127,6 +127,13 @@ impl Property {
if self.keys.is_empty() {
return self.value.clone();
}
// 3D Orientation turns along the shortest arc between its keys, as in After Effects.
// Routed by `match_id`: "orientation" is only used by the Orientation property of 3D
// layers, cameras and lights (`build.rs`), always a `Vec3` of degrees. A future property
// must not reuse that id unless it is a rotation that should slerp the same way.
if self.match_id == "orientation" && matches!(self.value, Value::Vec3(_)) {
return effectcraft_keyframe::evaluate_orientation(&self.keys, t).unwrap_or_else(|| self.value.clone());
}
effectcraft_keyframe::evaluate(&self.keys, t, self.spatial).unwrap_or_else(|| self.value.clone())
}
/// Set the value at `t`: adds/replaces a key when animated, else sets the static value.
+27
View File
@@ -114,6 +114,22 @@ impl Footage {
self.sequence.get(k).filter(|p| frame_number(p) == Some(n)).map(String::as_str)
}
/// The file an image sequence shows at its frame `i`, holding the last existing file across a
/// gap in the numbering (After Effects 2026: files 1, 2, 4, 5 show frame 2 at frame 3).
/// `None` only when no file precedes the frame (or there are no files).
pub fn sequence_file_held(&self, i: i64) -> Option<&str> {
if let Some(p) = self.sequence_file(i) {
return Some(p);
}
let first = self.numbered()?;
let k = self.first_file_from(first.saturating_add(i));
// The preceding number, then the file `sequence_file` picks for it (the first with that
// number), so files sharing a number (`shot_0002`, `shot_002`) don't switch at a gap.
let prev = self.file_number(k.checked_sub(1)?)?;
let j = self.first_file_from(prev);
self.sequence.get(j).filter(|p| frame_number(p) == Some(prev)).map(String::as_str)
}
/// Set an image sequence's duration to its frames at its frame rate (after its files or
/// frame rate changed).
pub fn sync_sequence_duration(&mut self) {
@@ -131,6 +147,17 @@ mod tests {
Footage { kind: FootageKind::Sequence, sequence: numbers.iter().map(|n| format!("C:\\shots\\a_{n:04}.png")).collect(), ..Default::default() }
}
#[test]
fn a_gap_holds_the_file_shown_for_the_preceding_number() {
let mut f = seq(&[]);
f.sequence = ["a_0001.png", "a_0002.png", "a_002.png", "a_0005.png"].map(String::from).to_vec();
assert_eq!(f.sequence_file(1), Some("a_0002.png"));
// Frames 3 and 4 (numbers 3, 4) hold what number 2 shows: the first file with it.
assert_eq!(f.sequence_file_held(2), Some("a_0002.png"));
assert_eq!(f.sequence_file_held(3), Some("a_0002.png"));
assert_eq!(f.sequence_file_held(4), Some("a_0005.png"));
}
#[test]
fn numbers_and_names() {
assert_eq!(split_frame_number("shot_0012"), Some(("shot_", "0012")));
+1 -7
View File
@@ -203,13 +203,7 @@ impl<'a> EvalCtx<'a> {
// Auto-orient (along path / towards camera) replaces Orientation.
let orient = crate::three_d::camera::auto_orient_3d(self, layer, p)
.unwrap_or_else(|| Mat4::orientation(Vec3::from(self.v3(layer, tr, "orientation", [0.0; 3]))));
Mat4::translate(p)
* orient
* Mat4::rotate_z(rz)
* Mat4::rotate_y(ry)
* Mat4::rotate_x(rx)
* Mat4::scale(Vec3::from(scale) / 100.0)
* Mat4::translate(-Vec3::from(anchor))
Mat4::translate(p) * orient * Mat4::rotation_xyz(rx, ry, rz) * Mat4::scale(Vec3::from(scale) / 100.0) * Mat4::translate(-Vec3::from(anchor))
}
/// Layer space → comp (world) space, including parents.
+8 -5
View File
@@ -117,12 +117,15 @@ pub fn basis_view(eye: Vec3, forward: Vec3, down_hint: Vec3) -> Mat4 {
Mat4([[r.x, r.y, r.z, 0.0], [d.x, d.y, d.z, 0.0], [f.x, f.y, f.z, 0.0], [0.0, 0.0, 0.0, 1.0]]) * Mat4::translate(-eye)
}
/// Orientation angles (degrees, AE order X→Y→Z with Z = 0) that point +z along `dir`.
/// Orientation angles (degrees, [`Mat4::orientation`]'s order) that point +z along `dir` with
/// the layer's x axis kept level (no roll): the pitch about x, then the turn about the world's
/// vertical axis, written as Orientation.
pub fn orientation_for(dir: Vec3) -> [f64; 3] {
let d = dir.normalize();
let x = (-d.y).clamp(-1.0, 1.0).asin().to_degrees();
let y = d.x.atan2(d.z).to_degrees();
[norm360(x), norm360(y), 0.0]
let pitch = (-d.y).clamp(-1.0, 1.0).asin().to_degrees();
let yaw = d.x.atan2(d.z).to_degrees();
let o = (Mat4::rotate_y(yaw) * Mat4::rotate_x(pitch)).orientation_angles();
[norm360(o.x), norm360(o.y), norm360(o.z)]
}
fn norm360(a: f64) -> f64 {
@@ -177,7 +180,7 @@ pub fn layer_rotation(ctx: &EvalCtx, layer: &Layer) -> Mat4 {
let rx = ctx.f(layer, tr, "rotationX", 0.0);
let ry = ctx.f(layer, tr, "rotationY", 0.0);
let rz = ctx.f(layer, tr, "rotation", 0.0);
Mat4::orientation(Vec3::from(o)) * Mat4::rotate_z(rz) * Mat4::rotate_y(ry) * Mat4::rotate_x(rx)
Mat4::orientation(Vec3::from(o)) * Mat4::rotation_xyz(rx, ry, rz)
}
/// World matrix of a layer's parent chain (identity when unparented).
+261
View File
@@ -215,3 +215,264 @@ fn layer_cache_is_independent_of_camera_lights_and_view() {
assert_ne!(third, second);
assert!(cache.stats().hits > 0, "the cache was actually used");
}
/// A 1920 × 1080 comp with a full-frame 3D solid whose transform `set` changes (and an optional
/// one-node camera at the default camera's place), as the After Effects reference cases.
fn ae_frame(set: impl Fn(&mut Layer), camera: Option<([f64; 3], [f64; 3])>) -> (Project, ItemId) {
let mut p = Project::default();
let comp = Comp::new(1920, 1080, FrameRate::FPS_25, Tick::from_seconds_f64(5.0));
let cid = p.add_item("C", Label::Sandstone, None, ItemKind::Comp(comp.clone().into()));
let sid = p.add_item("L", Label::Red, None, ItemKind::Solid(Solid { color: [1.0, 0.0, 0.0], width: 1920, height: 1080, pixel_aspect: 1.0 }));
let mut l = build::layer(&mut p, &comp, "L", LayerSource::Solid { item: sid }, (1920, 1080), None);
l.switches.three_d = true;
set(&mut l);
let mut layers = vec![];
if let Some((orientation, rotation)) = camera {
let mut cam = build::layer(&mut p, &comp, "K", LayerSource::Camera, (1920, 1080), None);
cam.auto_orient = AutoOrient::Off;
cam.props.prop_mut("transform/position").unwrap().value = Value::Vec3([960.0, 540.0, -1920.0 * 50.0 / 36.0]);
cam.props.prop_mut("transform/orientation").unwrap().value = Value::Vec3(orientation);
for (m, v) in [("transform/rotationX", rotation[0]), ("transform/rotationY", rotation[1]), ("transform/rotation", rotation[2])] {
cam.props.prop_mut(m).unwrap().value = Value::Scalar(v);
}
cam.props.prop_mut("cameraOptions/zoom").unwrap().value = Value::Scalar(1920.0 * 50.0 / 36.0);
layers.push(cam);
}
layers.push(l);
p.comp_mut(cid).unwrap().layers = layers.into();
(p, cid)
}
/// Whether comp pixel (x, y) shows the solid, from its layer → comp mapping.
fn covers(p: &Project, cid: ItemId, secs: f64, x: u32, y: u32) -> bool {
let c = p.comp(cid).unwrap();
let ctx = EvalCtx::new(p, cid, c, Tick::from_seconds_f64(secs));
let l = c.layers.last().unwrap();
let inv = ctx.layer_to_comp(l).0.inverse().unwrap();
let u = inv.apply(effectcraft_geom::vec2(x as f64 + 0.5, y as f64 + 0.5));
(0.0..=1920.0).contains(&u.x) && (0.0..=1080.0).contains(&u.y)
}
fn check_points(p: &Project, cid: ItemId, secs: f64, case: &str, pts: &[(u32, u32, bool)]) {
for &(x, y, red) in pts {
assert_eq!(covers(p, cid, secs, x, y), red, "case {case}: pixel ({x}, {y}) should be {}", if red { "red" } else { "black" });
}
}
fn set3(l: &mut Layer, m: &str, v: [f64; 3]) {
l.props.prop_mut(m).unwrap().value = Value::Vec3(v);
}
fn set1(l: &mut Layer, m: &str, v: f64) {
l.props.prop_mut(m).unwrap().value = Value::Scalar(v);
}
/// Orientation and X/Y/Z Rotation compose as `Rx · Ry · Rz` (Z turns the layer first), and
/// Orientation comes before the axis rotations, in layers and cameras alike. The points are
/// sampled from After Effects 2026 renders of the synthetic cases (each at least 3 px from an
/// edge; red = the solid covers the pixel), a third of them where the old Z · Y · X order differed.
#[test]
fn rotation_order_matches_after_effects() {
// 488: Orientation [10, 20, 30].
let (p, cid) = ae_frame(|l| set3(l, "transform/orientation", [10.0, 20.0, 30.0]), None);
check_points(
&p,
cid,
1.0,
"488",
&[
(328, 687, false),
(416, 764, false),
(209, 237, true),
(548, 861, false),
(1899, 425, false),
(87, 462, true),
(162, 581, false),
(662, 948, false),
(682, 849, true),
(618, 413, true),
(1380, 181, true),
(605, 378, true),
(1830, 518, true),
(559, 197, true),
(1760, 843, true),
(550, 823, true),
(572, 952, false),
(272, 963, false),
(268, 761, false),
(628, 1073, false),
(218, 721, false),
(256, 807, false),
(225, 881, false),
(167, 637, false),
],
);
// 800: X / Y / Z Rotation 10 / 20 / 30.
let (p, cid) = ae_frame(
|l| {
set1(l, "transform/rotationX", 10.0);
set1(l, "transform/rotationY", 20.0);
set1(l, "transform/rotation", 30.0);
},
None,
);
check_points(
&p,
cid,
1.0,
"800",
&[
(104, 450, true),
(755, 1002, false),
(1407, 149, false),
(271, 66, true),
(288, 54, true),
(113, 393, true),
(204, 174, true),
(1790, 360, false),
(1414, 324, true),
(682, 343, true),
(1147, 153, true),
(345, 446, true),
(1516, 542, true),
(666, 25, true),
(1327, 1065, true),
(778, 267, true),
(1588, 5, false),
(1706, 147, false),
(357, 978, false),
(97, 689, false),
(1483, 79, false),
(433, 848, false),
(255, 802, false),
(636, 1057, false),
],
);
// 801: Orientation [0, 30, 0] with X Rotation 40 (Orientation first).
let (p, cid) = ae_frame(
|l| {
set3(l, "transform/orientation", [0.0, 30.0, 0.0]);
set1(l, "transform/rotationX", 40.0);
},
None,
);
check_points(
&p,
cid,
1.0,
"801",
&[
(1356, 35, true),
(1762, 19, true),
(190, 227, true),
(108, 182, true),
(502, 832, true),
(1539, 203, true),
(722, 812, true),
(1872, 76, true),
(1166, 165, true),
(1640, 380, true),
(629, 619, true),
(1796, 574, true),
(1767, 311, true),
(1432, 421, true),
(1819, 425, true),
(283, 252, true),
(563, 1062, false),
(428, 963, false),
(77, 417, false),
(47, 833, false),
(995, 933, false),
(159, 839, false),
(827, 947, false),
(564, 908, false),
],
);
// 804: a one-node camera with Orientation [4, 6, 30]; 805: the same as X / Y / Z Rotation.
for (case, cam) in [("804", ([4.0, 6.0, 30.0], [0.0; 3])), ("805", ([0.0; 3], [4.0, 6.0, 30.0]))] {
let (p, cid) = ae_frame(|_| {}, Some(cam));
check_points(
&p,
cid,
1.0,
case,
&[
(204, 410, false),
(1077, 38, false),
(60, 492, false),
(1732, 752, true),
(265, 475, false),
(1743, 821, true),
(1417, 1043, true),
(1559, 944, true),
(1280, 310, true),
(702, 984, true),
(1072, 893, true),
(370, 1042, true),
(1353, 570, true),
(1178, 136, true),
(1201, 289, true),
(1241, 605, true),
(75, 337, false),
(71, 138, false),
(352, 24, false),
(1764, 109, false),
(186, 338, false),
(1835, 668, false),
(211, 269, false),
(157, 143, false),
],
);
}
}
/// Keyed Orientation turns along the shortest arc between the keys (case 802: [0, 0, 0] at 0 s
/// to [60, 80, 40] at 2 s, linear; points from the After Effects render at 1 s, a third of them
/// where blending each angle on its own differed).
#[test]
fn keyed_orientation_renders_as_after_effects() {
let (p, cid) = ae_frame(
|l| {
l.props.prop_mut("transform/orientation").unwrap().keys =
vec![Keyframe::new(Tick::ZERO, Value::Vec3([0.0; 3])), Keyframe::new(Tick::from_seconds_f64(2.0), Value::Vec3([60.0, 80.0, 40.0]))];
},
None,
);
check_points(
&p,
cid,
1.0,
"802",
&[
(1880, 472, false),
(1598, 191, false),
(248, 285, true),
(1157, 51, false),
(1628, 129, false),
(357, 37, true),
(1865, 891, true),
(1915, 946, true),
(1728, 1021, true),
(1178, 665, true),
(1303, 631, true),
(1339, 954, true),
(910, 891, true),
(1257, 1002, true),
(1349, 890, true),
(1388, 551, true),
(356, 500, false),
(490, 1058, false),
(524, 829, false),
(73, 29, false),
(393, 769, false),
(1867, 124, false),
(42, 733, false),
(648, 1007, false),
],
);
// The value After Effects reads back half-way.
let c = p.comp(cid).unwrap();
let v = c.layers[0].props.prop("transform/orientation").unwrap().value_at(Tick::from_seconds_f64(1.0)).as_vec3();
let want = [30.6186, 35.7617, 28.4317];
assert!((0..3).all(|i| (v[i] - want[i]).abs() < 1e-3), "{v:?}");
}
+142 -3
View File
@@ -144,6 +144,25 @@ fn add_bias(x: Tensor, b: &Tensor) -> Result<Tensor> {
Ok(x.broadcast_add(&b.reshape((1, b.dim(0)?, 1, 1))?)?)
}
/// `conv_transpose2d` with a 2 x 2 kernel, stride 2, no padding, on tokens `[h * w, c]` (the image
/// laid out row by row) with weight `[c, o, 2, 2]`, as one matrix product plus a pixel shuffle:
/// every input pixel owns its own 2 x 2 output block, so there is no overlap to add up. Returns
/// `[1, o, 2h, 2w]` without the bias. candle's transposed convolution takes about twice as long.
fn conv_transpose_2x2(tokens: &Tensor, h: usize, w: usize, weight: &Tensor) -> Result<Tensor> {
let (c, o, kh, kw) = weight.dims4()?;
if (kh, kw) != (2, 2) || tokens.dims() != [h * w, c] {
return Err(Error::Input(format!("a 2x2 transposed convolution got tokens {:?} for a {c}x{o}x{kh}x{kw} weight on {h}x{w}", tokens.dims())));
}
let y = tokens.matmul(&weight.reshape((c, o * 4))?)?; // [h * w, (o, 2, 2)]
Ok(y.reshape((h, w, o, 2, 2))?.permute((2, 0, 3, 1, 4))?.contiguous()?.reshape((1, o, 2 * h, 2 * w))?)
}
/// The same convolution on an image `[1, c, h, w]`.
fn conv_transpose_2x2_image(x: &Tensor, weight: &Tensor) -> Result<Tensor> {
let (_, c, h, w) = x.dims4()?;
conv_transpose_2x2(&x.reshape((c, h * w))?.t()?.contiguous()?, h, w, weight)
}
impl MaskDecoder {
pub fn load(w: &Weights) -> Result<MaskDecoder> {
let layers = (0..2)
@@ -194,6 +213,22 @@ impl MaskDecoder {
/// no-memory embedding already added when there is no memory), `sparse`: `[1, n, 256]`,
/// `dense`: `[1, 256, g, g]`, `high`: the skip features from [`MaskDecoder::high_res`].
pub fn forward(&self, image: &Tensor, pos: &Tensor, sparse: &Tensor, dense: &Tensor, high: &(Tensor, Tensor), multimask: bool) -> Result<DecoderOutput> {
self.forward_with(image, pos, sparse, dense, high, multimask, true)
}
/// [`MaskDecoder::forward`]; `matmul_upscale` picks the matrix-product transposed convolutions
/// (the normal path) or candle's `conv_transpose2d` (the reference the tests compare with).
#[allow(clippy::too_many_arguments)]
fn forward_with(
&self,
image: &Tensor,
pos: &Tensor,
sparse: &Tensor,
dense: &Tensor,
high: &(Tensor, Tensor),
multimask: bool,
matmul_upscale: bool,
) -> Result<DecoderOutput> {
let (_, c, g, gw) = image.dims4()?;
if c != DIM || g != gw {
return Err(Error::Input(format!("image embedding must be 256 x g x g, got {:?}", image.dims())));
@@ -214,13 +249,19 @@ impl MaskDecoder {
let mask_tokens_out = queries.narrow(1, 2, MASK_TOKENS)?;
let obj_out = queries.narrow(1, 0, 1)?.squeeze(1)?;
let emb = keys.transpose(1, 2)?.contiguous()?.reshape((1, DIM, g, g))?;
let (s0, s1) = high;
let up = add_bias(emb.conv_transpose2d(&self.up1.0, 0, 0, 2, 1)?, &self.up1.1)?;
let up = if matmul_upscale {
// the keys are already the image's tokens, row by row: no transpose needed
conv_transpose_2x2(&keys.reshape((g * g, DIM))?, g, g, &self.up1.0)?
} else {
keys.transpose(1, 2)?.contiguous()?.reshape((1, DIM, g, g))?.conv_transpose2d(&self.up1.0, 0, 0, 2, 1)?
};
let up = add_bias(up, &self.up1.1)?;
let up = (up + s1)?;
let up = self.up_ln.forward(&up.permute((0, 2, 3, 1))?)?.permute((0, 3, 1, 2))?.contiguous()?;
let up = gelu(&up)?;
let up = (add_bias(up.conv_transpose2d(&self.up2.0, 0, 0, 2, 1)?, &self.up2.1)? + s0)?;
let up = if matmul_upscale { conv_transpose_2x2_image(&up, &self.up2.0)? } else { up.conv_transpose2d(&self.up2.0, 0, 0, 2, 1)? };
let up = (add_bias(up, &self.up2.1)? + s0)?;
let up = gelu(&up)?;
let hyper: Vec<Tensor> = self.hyper.iter().enumerate().map(|(i, m)| m.forward(&mask_tokens_out.narrow(1, i, 1)?.squeeze(1)?)).collect::<Result<_>>()?;
@@ -258,3 +299,101 @@ fn dynamic_single(masks: &Tensor, iou: &Tensor) -> Result<(Tensor, Tensor)> {
let best = multi_iou.iter().enumerate().fold((0usize, f32::NEG_INFINITY), |b, (i, v)| if *v > b.1 { (i, *v) } else { b }).0;
Ok((masks.narrow(1, 1 + best, 1)?, iou.narrow(1, 1 + best, 1)?))
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use candle_core::{DType, Device};
use super::*;
fn max_diff(a: &Tensor, b: &Tensor) -> f32 {
assert_eq!(a.dims(), b.dims());
(a - b).unwrap().abs().unwrap().flatten_all().unwrap().max(0).unwrap().to_scalar::<f32>().unwrap()
}
/// The matrix-product form equals `conv_transpose2d` (kernel 2, stride 2, no padding, the only
/// variant the decoder uses) to rounding, for the decoder's two sizes and for odd ones.
#[test]
fn matmul_transposed_convolution_matches_conv_transpose2d() {
let d = &Device::Cpu;
// (in channels, out channels, height, width)
let cases =
[(256, 64, 64, 64), (64, 32, 128, 128), (256, 64, 16, 16), (3, 5, 7, 11), (5, 3, 1, 9), (4, 2, 9, 1), (1, 1, 1, 1), (17, 1, 3, 5), (1, 19, 6, 2)];
for (c, o, h, w) in cases {
for (std, scale) in [(1.0, 0.05), (30.0, 3.0)] {
let x = Tensor::randn(0f32, std, (1, c, h, w), d).unwrap();
let k = Tensor::randn(0f32, scale, (c, o, 2, 2), d).unwrap();
let want = x.conv_transpose2d(&k, 0, 0, 2, 1).unwrap();
let got = conv_transpose_2x2_image(&x, &k).unwrap();
// 1e-6 of the largest output value (at least 1e-6 absolute): float rounding only
let peak = want.abs().unwrap().flatten_all().unwrap().max(0).unwrap().to_scalar::<f32>().unwrap();
let tol = 1e-6 * peak.max(1.0);
let diff = max_diff(&want, &got);
assert!(diff <= tol, "c{c} o{o} {h}x{w} std {std}: max diff {diff:e} > {tol:e}");
}
}
}
#[test]
fn matmul_transposed_convolution_edge_values() {
let d = &Device::Cpu;
// zeros, a single hot pixel and negative values land in the right 2x2 block
let k = Tensor::randn(0f32, 1.0, (3, 2, 2, 2), d).unwrap();
let zero = Tensor::zeros((1, 3, 4, 5), DType::F32, d).unwrap();
assert_eq!(max_diff(&zero.conv_transpose2d(&k, 0, 0, 2, 1).unwrap(), &conv_transpose_2x2_image(&zero, &k).unwrap()), 0.0);
let mut v = vec![0f32; 3 * 4 * 5];
v[2 * 20 + 3 * 5 + 1] = -7.5;
let hot = Tensor::from_vec(v, (1, 3, 4, 5), d).unwrap();
assert!(max_diff(&hot.conv_transpose2d(&k, 0, 0, 2, 1).unwrap(), &conv_transpose_2x2_image(&hot, &k).unwrap()) <= 1e-6);
// other kernels are refused, not computed wrongly
let k3 = Tensor::zeros((3, 2, 3, 3), DType::F32, d).unwrap();
assert!(conv_transpose_2x2_image(&hot, &k3).is_err());
}
/// Random weights for every tensor the decoder asks for, found by loading until nothing is missing.
fn random_decoder() -> MaskDecoder {
let d = &Device::Cpu;
let mut map: HashMap<String, Tensor> = HashMap::new();
for _ in 0..1000 {
match MaskDecoder::load(&Weights::from_map(map.clone()).unwrap()) {
Ok(m) => return m,
Err(Error::Weights(m)) => {
let name = m.split('`').nth(1).unwrap().to_string();
let shape: Vec<usize> = match m.split("expected [").nth(1) {
Some(e) => e.trim_end_matches(']').split(',').map(|s| s.trim().parse().unwrap()).collect(),
None => vec![1],
};
let t = if name.contains("layer_norm") && name.ends_with("weight") {
Tensor::ones(shape, DType::F32, d)
} else {
Tensor::randn(0f32, 0.05, shape, d)
};
map.insert(name, t.unwrap());
}
Err(e) => panic!("{e}"),
}
}
panic!("the decoder kept asking for tensors");
}
#[test]
fn decoder_output_is_the_same_with_either_transposed_convolution() {
let d = &Device::Cpu;
let dec = random_decoder();
let g = 16;
let r = |dims: &[usize]| Tensor::randn(0f32, 1.0, dims, d).unwrap();
let (image, pos, dense) = (r(&[1, DIM, g, g]), r(&[1, DIM, g, g]), r(&[1, DIM, g, g]));
let sparse = r(&[1, 3, DIM]);
let high = dec.high_res(&r(&[1, DIM, 4 * g, 4 * g]), &r(&[1, DIM, 2 * g, 2 * g])).unwrap();
for multimask in [false, true] {
let a = dec.forward_with(&image, &pos, &sparse, &dense, &high, multimask, false).unwrap();
let b = dec.forward_with(&image, &pos, &sparse, &dense, &high, multimask, true).unwrap();
assert!(max_diff(&a.masks, &b.masks) <= 2e-5, "masks differ by {:e}", max_diff(&a.masks, &b.masks));
assert!(max_diff(&a.iou, &b.iou) <= 1e-5);
assert!(max_diff(&a.sam_tokens, &b.sam_tokens) <= 1e-5);
assert!(max_diff(&a.object_score, &b.object_score) <= 1e-5);
}
}
}
+135 -21
View File
@@ -28,6 +28,35 @@ struct Source<'a> {
count: usize,
fetch: RefCell<Frames<'a>>,
sizes: Rc<RefCell<HashMap<usize, (usize, usize)>>>,
/// Prompt frames fetched only to learn their size, kept for the encoder (taken on first use).
held: RefCell<Held>,
}
/// Most pixel data (in bytes) kept from the size check of the prompt frames; frames beyond it are
/// fetched again when the tracker needs them.
const HELD_BYTES: usize = 256 << 20;
#[derive(Default)]
struct Held {
frames: HashMap<usize, Arc<ClipFrame>>,
bytes: usize,
}
impl Held {
fn keep(&mut self, index: usize, f: &Arc<ClipFrame>) {
let bytes = f.rgb.len().saturating_mul(std::mem::size_of::<[f32; 3]>());
let fits = self.bytes.checked_add(bytes).filter(|t| *t <= HELD_BYTES);
if let (Some(total), false) = (fits, self.frames.contains_key(&index)) {
self.frames.insert(index, f.clone());
self.bytes = total;
}
}
fn take(&mut self, index: usize) -> Option<Arc<ClipFrame>> {
let f = self.frames.remove(&index)?;
self.bytes = self.bytes.saturating_sub(f.rgb.len().saturating_mul(std::mem::size_of::<[f32; 3]>()));
Some(f)
}
}
impl FrameSource for Source<'_> {
@@ -41,7 +70,11 @@ impl FrameSource for Source<'_> {
.filter(|_| index < self.count)
.and_then(|offset| self.first.checked_add(offset))
.ok_or_else(|| Error::Input(format!("frame index {index} is outside the clip")))?;
let f = (self.fetch.borrow_mut())(number).ok_or_else(|| Error::Input(format!("frame {number} is not available")))?;
let held = self.held.borrow_mut().take(index);
let f = match held {
Some(f) => f,
None => (self.fetch.borrow_mut())(number).ok_or_else(|| Error::Input(format!("frame {number} is not available")))?,
};
self.sizes.borrow_mut().insert(index, (f.w, f.h));
preprocess(&f.rgb, f.w, f.h)
}
@@ -58,6 +91,36 @@ fn to_frame(m: &FrameMask, size: (usize, usize)) -> Result<TrackedFrame> {
/// Most objects one request may track.
const MAX_OBJECTS: usize = 32;
/// Every prompt as `(object, frame index, prompt)`, in frame order (then object order), after
/// checking each prompt frame and recording its size in `source`. Frame order keeps the tracker's
/// two-frame feature cache useful: all objects prompted on a frame are encoded together, so each
/// prompt frame is encoded once however many objects there are. Objects do not influence each
/// other, so the order of prompts across objects does not change any result.
fn gather_prompts<'r>(req: &'r TrackRequest, source: &Source<'_>) -> Result<Vec<(usize, usize, &'r effectcraft_segment::Prompt)>> {
// Prompt coordinates are in the frame's pixels and the model works at 1024 x 1024, so the
// prompted frames' sizes are needed first.
let mut prompt_frames = vec![];
for (oi, prompts) in req.objects.iter().enumerate() {
for (frame, p) in prompts {
let idx = frame
.checked_sub(req.first)
.and_then(|offset| usize::try_from(offset).ok())
.filter(|i| *i < req.count)
.ok_or_else(|| Error::Input(format!("prompt frame {frame} is outside the clip")))?;
if !source.sizes.borrow().contains_key(&idx) {
let f = (source.fetch.borrow_mut())(*frame).ok_or_else(|| Error::Input(format!("frame {frame} is not available")))?;
validate_frame_size(f.w, f.h, f.rgb.len())?;
source.sizes.borrow_mut().insert(idx, (f.w, f.h));
source.held.borrow_mut().keep(idx, &f);
}
prompt_frames.push((oi, idx, p));
}
}
// stable: prompts of one object on one frame keep their order
prompt_frames.sort_by_key(|(oi, idx, _)| (*idx, *oi));
Ok(prompt_frames)
}
impl Sam2Mask {
fn run_track(&self, req: &TrackRequest, frames: Frames<'_>, cancel: &dyn Fn() -> bool, out: &mut dyn FnMut(usize, i64, TrackedFrame)) -> Result<()> {
if req.count == 0 || req.count > MAX_CLIP {
@@ -68,25 +131,8 @@ impl Sam2Mask {
return Err(Error::Input(format!("tracking needs 1 to {MAX_OBJECTS} objects, each with a prompt on at least one frame")));
}
let sizes = Rc::new(RefCell::new(HashMap::new()));
let source = Source { first: req.first, count: req.count, fetch: RefCell::new(frames), sizes: sizes.clone() };
// Prompt coordinates are in the frame's pixels and the model works at 1024 x 1024, so the
// prompted frames' sizes are needed first.
let mut prompt_frames = vec![];
for (oi, prompts) in req.objects.iter().enumerate() {
for (frame, p) in prompts {
let idx = frame
.checked_sub(req.first)
.and_then(|offset| usize::try_from(offset).ok())
.filter(|i| *i < req.count)
.ok_or_else(|| Error::Input(format!("prompt frame {frame} is outside the clip")))?;
if !sizes.borrow().contains_key(&idx) {
let f = (source.fetch.borrow_mut())(*frame).ok_or_else(|| Error::Input(format!("frame {frame} is not available")))?;
validate_frame_size(f.w, f.h, f.rgb.len())?;
sizes.borrow_mut().insert(idx, (f.w, f.h));
}
prompt_frames.push((oi, idx, p));
}
}
let source = Source { first: req.first, count: req.count, fetch: RefCell::new(frames), sizes: sizes.clone(), held: RefCell::default() };
let prompt_frames = gather_prompts(req, &source)?;
let mut tracker = VideoTracker::new(self.video_model().clone(), Box::new(source))?;
let object_id = |oi: usize| ObjectId(oi as u32 + 1);
// each object's first prompted frame: its pass starts there, forwards and backwards
@@ -174,8 +220,76 @@ mod tests {
#[test]
fn source_rejects_overflowing_and_out_of_range_indices_before_fetching() {
let mut fetch = |_| panic!("invalid indices must not fetch a frame");
let source = Source { first: i64::MAX, count: 2, fetch: RefCell::new(&mut fetch), sizes: Default::default() };
let source = Source { first: i64::MAX, count: 2, fetch: RefCell::new(&mut fetch), sizes: Default::default(), held: RefCell::default() };
assert!(matches!(source.frame(1), Err(Error::Input(_))));
assert!(matches!(source.frame(usize::MAX), Err(Error::Input(_))));
}
fn clip_frame(w: usize, h: usize) -> Arc<ClipFrame> {
Arc::new(ClipFrame { rgb: vec![[0.5, 0.25, 0.75]; w * h], w, h })
}
fn point_prompt() -> effectcraft_segment::Prompt {
effectcraft_segment::Prompt { points: vec![([1.0, 1.0], true)], bbox: None, mask: None }
}
/// Several objects prompted on the same frames: prompts come out frame by frame (so the
/// two-frame feature cache holds each prompt frame for all of its objects), and each prompt
/// frame is fetched from the host once, size check and encoding together.
#[test]
fn prompts_run_frame_major_and_each_prompt_frame_is_fetched_once() {
let frames_prompted = [10i64, 13, 16];
let req = TrackRequest { first: 10, count: 8, objects: (0..4).map(|_| frames_prompted.iter().map(|f| (*f, point_prompt())).collect()).collect() };
let fetched = RefCell::new(vec![]);
let mut fetch = |n: i64| {
fetched.borrow_mut().push(n);
Some(clip_frame(4, 3))
};
let source = Source { first: req.first, count: req.count, fetch: RefCell::new(&mut fetch), sizes: Default::default(), held: RefCell::default() };
let order: Vec<(usize, usize)> = gather_prompts(&req, &source).unwrap().into_iter().map(|(oi, idx, _)| (idx, oi)).collect();
let expected: Vec<(usize, usize)> = [0usize, 3, 6].iter().flat_map(|f| (0..4).map(move |o| (*f, o))).collect();
assert_eq!(order, expected);
assert_eq!(*fetched.borrow(), vec![10, 13, 16], "one size-check fetch per distinct prompt frame");
// the tracker's first look at each prompt frame reuses the frame already fetched
for idx in [0usize, 3, 6] {
source.frame(idx).unwrap();
}
assert_eq!(fetched.borrow().len(), 3, "encoding a prompt frame must not fetch it again");
// a held frame is handed over once; a second look fetches again
source.frame(0).unwrap();
assert_eq!(*fetched.borrow(), vec![10, 13, 16, 10]);
}
#[test]
fn prompts_of_one_object_on_one_frame_keep_their_order() {
let (mut a, mut b) = (point_prompt(), point_prompt());
a.points = vec![([1.0, 1.0], true)];
b.points = vec![([2.0, 2.0], false)];
let req = TrackRequest { first: 0, count: 3, objects: vec![vec![(2, a.clone()), (1, point_prompt()), (2, b.clone())]] };
let mut fetch = |_| Some(clip_frame(2, 2));
let source = Source { first: 0, count: 3, fetch: RefCell::new(&mut fetch), sizes: Default::default(), held: RefCell::default() };
let got = gather_prompts(&req, &source).unwrap();
assert_eq!(got.iter().map(|(_, i, _)| *i).collect::<Vec<_>>(), vec![1, 2, 2]);
assert_eq!(got[1].2.points, a.points);
assert_eq!(got[2].2.points, b.points);
}
#[test]
fn held_frames_are_bounded() {
let mut held = Held::default();
let big = Arc::new(ClipFrame { rgb: vec![[0.0; 3]; HELD_BYTES / 12 + 1], w: 1, h: HELD_BYTES / 12 + 1 });
held.keep(0, &big);
assert!(held.take(0).is_none(), "a frame over the cap is not kept");
let small = clip_frame(4, 4);
held.keep(1, &small);
assert!(held.take(1).is_some());
assert_eq!(held.bytes, 0);
// the cap is cumulative: frames that fit together are kept, the one that breaks it is not
let third = Arc::new(ClipFrame { rgb: vec![[0.0; 3]; HELD_BYTES / 12 / 3 + 1], w: 1, h: HELD_BYTES / 12 / 3 + 1 });
for i in 0..4 {
held.keep(i, &third);
}
assert_eq!(held.frames.len(), 2);
assert!(held.bytes <= HELD_BYTES);
}
}
+2 -2
View File
@@ -90,7 +90,7 @@ fn per_key_methods_keep_the_key_selection_and_report_eases_as_they_play() {
let r = ok(
&mut s,
r#"var comp = app.project.activeItem, p = comp.layer(1).transform.opacity;
// A linear side reports the speed it plays at (33.3 % influence).
// A linear side reports the speed it plays at (16.67 % influence, as After Effects).
var lin = p.keyInTemporalEase(3)[0];
p.setTemporalEaseAtKey(2, [new KeyframeEase(0, 50)], [new KeyframeEase(0, 75)]);
p.setInterpolationTypeAtKey(3, KeyframeInterpolationType.HOLD, KeyframeInterpolationType.HOLD);
@@ -98,7 +98,7 @@ fn per_key_methods_keep_the_key_selection_and_report_eases_as_they_play() {
var sel = comp.selectedProperties;
[lin.speed, lin.influence, p.selectedKeys, sel.length, sel.length ? sel[0].name : ""]"#,
);
assert!(close(r[0].as_f64().unwrap(), -100.0, 1e-9) && close(r[1].as_f64().unwrap(), 100.0 / 3.0, 1e-9), "{r}");
assert!(close(r[0].as_f64().unwrap(), -100.0, 1e-9) && close(r[1].as_f64().unwrap(), 100.0 / 6.0, 1e-9), "{r}");
assert_eq!(r[2], json!([1, 3]), "the selection is the user's");
assert_eq!((r[3].clone(), r[4].clone()), (json!(1), json!("Opacity")));
assert_eq!(s.state.selected_keys, before);
+6 -6
View File
@@ -503,16 +503,16 @@ pub fn umeyama(src: &[V3], dst: &[V3]) -> Option<Similarity> {
Some(Similarity { s, r, t })
}
/// Euler angles (degrees) `[x, y, z]` with `m = Rz(z) Ry(y) Rx(x)` (After Effects' Orientation
/// order: X, then Y, then Z applied to the vector).
/// Euler angles (degrees) `[x, y, z]` with `m = Rx(x) Ry(y) Rz(z)`: After Effects' Orientation
/// order (Z is applied to the vector first, then Y, then X; see `effectcraft_geom::Mat4::orientation`).
pub fn euler_xyz(m: &M3) -> V3 {
let sy = (-m[2][0]).clamp(-1.0, 1.0);
let sy = m[0][2].clamp(-1.0, 1.0);
let y = sy.asin();
let (x, z) = if sy.abs() < 0.999_999 { (m[2][1].atan2(m[2][2]), m[1][0].atan2(m[0][0])) } else { ((-m[1][2]).atan2(m[1][1]), 0.0) };
let (x, z) = if sy.abs() < 0.999_999 { ((-m[1][2]).atan2(m[2][2]), (-m[0][1]).atan2(m[0][0])) } else { (m[2][1].atan2(m[1][1]), 0.0) };
[x.to_degrees(), y.to_degrees(), z.to_degrees()]
}
/// `Rz(z) Ry(y) Rx(x)` from degrees.
/// `Rx(x) Ry(y) Rz(z)` from degrees.
pub fn from_euler_xyz(e: V3) -> M3 {
let (sx, cx) = e[0].to_radians().sin_cos();
let (sy, cy) = e[1].to_radians().sin_cos();
@@ -520,7 +520,7 @@ pub fn from_euler_xyz(e: V3) -> M3 {
let rx = [[1.0, 0.0, 0.0], [0.0, cx, -sx], [0.0, sx, cx]];
let ry = [[cy, 0.0, sy], [0.0, 1.0, 0.0], [-sy, 0.0, cy]];
let rz = [[cz, -sz, 0.0], [sz, cz, 0.0], [0.0, 0.0, 1.0]];
mm(&rz, &mm(&ry, &rx))
mm(&rx, &mm(&ry, &rz))
}
#[cfg(test)]
+1
View File
@@ -10290,6 +10290,7 @@ mod tests {
("panels/comp_settings.rs", include_str!("../panels/comp_settings.rs")),
("panels/content_fill_panel.rs", include_str!("../panels/content_fill_panel.rs")),
("panels/delete_items.rs", include_str!("../panels/delete_items.rs")),
("panels/unprecompose.rs", include_str!("../panels/unprecompose.rs")),
("panels/gradient_editor.rs", include_str!("../panels/gradient_editor.rs")),
("panels/dialogs.rs", include_str!("../panels/dialogs.rs")),
("panels/dialogs_3d.rs", include_str!("../panels/dialogs_3d.rs")),
+2
View File
@@ -156,6 +156,8 @@ pub enum Dialog {
/// Import an After Effects project although some of it cannot be imported exactly?
/// (`panels::aep_losses`).
AepLosses,
/// Un-precompose although it changes the picture or drops something? (`panels::unprecompose`).
Unprecompose,
/// A gradient property's Gradient Editor (`panels::gradient_editor`, `prop.editGradient`).
GradientEditor,
/// Delete a user template from Home? (`panels::templates`).
+4
View File
@@ -284,6 +284,10 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V
if crate::panels::delete_items::guard(app, id, &params) {
return Ok(json!({"dialog": "deleteItems"}));
}
// Un-precompose that would change the picture lists why and asks before unpacking anyway.
if crate::panels::unprecompose::guard(app, id, &params)? {
return Ok(json!({"dialog": "unprecompose"}));
}
// An After Effects import with a path that loses something asks first (the menu's file
// dialog asks in `run_with_picked`).
if params.get("path").is_some() && crate::panels::aep_losses::guard(app, id, &params)? {
+1
View File
@@ -56,6 +56,7 @@ pub mod text_panels;
pub mod text_range_handles;
pub mod timeline;
pub mod tracker;
pub mod unprecompose;
pub mod unsaved;
pub mod viewer;
pub mod viewer_drop;
+3
View File
@@ -71,6 +71,8 @@ pub struct DialogState {
/// Deleting Project items in use: the deletion waiting on the prompt.
pub delete_items: super::delete_items::Pending,
pub aep_losses: super::aep_losses::Pending,
/// Un-precompose with reasons: the unpack waiting on the prompt.
pub unprecompose: super::unprecompose::Pending,
/// The Gradient Editor's draft and the property it edits.
pub gradient: super::gradient_editor::Draft,
/// Preview of the settings dialog being edited (Solid, Camera, Light, Composition Settings).
@@ -323,6 +325,7 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context) {
Dialog::UnsavedChanges => super::unsaved::show(app, ctx, &t),
Dialog::DeleteItems => super::delete_items::show(app, ctx, &t),
Dialog::AepLosses => super::aep_losses::show(app, ctx, &t),
Dialog::Unprecompose => super::unprecompose::show(app, ctx, &t),
Dialog::GradientEditor => super::gradient_editor::show(app, ctx, &t),
Dialog::DeleteTemplate => super::templates::show_delete(app, ctx, &t),
}
+21 -3
View File
@@ -310,13 +310,13 @@ fn interpret_footage_form(s: &effectcraft_engine::Session, proxy: bool, base: &m
m.insert("pointUnits".into(), json!(c.units.id()));
}
}
// After Frame Rate: an image sequence's frames (missing ones show colour bars, as in
// After Effects), then Start Timecode.
// After Frame Rate: an image sequence's frames (a missing one holds the previous frame),
// then Start Timecode.
let mut timing = Vec::new();
if f.kind == effectcraft_engine::project::FootageKind::Sequence {
let gaps = match effectcraft_engine::sequence::missing_report(&f.sequence) {
_ if f.alphabetical => "files in alphabetical order".into(),
Some(gaps) => format!("{gaps} shown as color bars"),
Some(gaps) => format!("{gaps} (the previous frame is held)"),
None => "no missing frames".into(),
};
timing.push(Field::note("sequenceInfo", &format!("Image sequence: {} files, {} frames; {gaps}.", f.sequence.len(), f.sequence_frames())));
@@ -1172,6 +1172,24 @@ mod tests {
}
}
/// Interpret Footage on an image sequence with a gap says the previous frame is held there
/// (what the media pool shows), not colour bars.
#[test]
fn interpret_footage_says_a_sequence_gap_holds_the_previous_frame() {
use effectcraft_engine::project::{Footage, FootageKind, ItemKind};
let mut app = EffectcraftApp::new(effectcraft_engine::Session::default());
let sequence: Vec<String> = ["/m/a_001.png", "/m/a_002.png", "/m/a_005.png"].map(String::from).to_vec();
let footage = Footage { path: sequence[0].clone(), kind: FootageKind::Sequence, sequence, width: 4, height: 4, has_video: true, ..Default::default() };
let project = std::sync::Arc::make_mut(&mut app.session.project);
let item = project.add_item("a_[001-005].png", Default::default(), None, ItemKind::Footage(footage));
app.session.state.project_selection = vec![item];
assert!(open_form(&mut app, "file.interpretFootage", &json!({})));
let note = app.dialog_state.form.fields.iter().find(|f| f.key == "sequenceInfo").map(|f| f.kind.clone());
let Some(FieldKind::Note(text)) = note else { panic!("no sequence note: {note:?}") };
assert!(text.contains("2 missing frames (3–4)") && text.contains("the previous frame is held"), "{text}");
assert!(!text.contains("color bars"), "{text}");
}
/// Interpret Footage shows Cineon Settings for DPX footage only. The converted points show in
/// the footage's Units, and choosing other Units reads the shown values in the old ones.
#[test]
+181
View File
@@ -0,0 +1,181 @@
//! Layer ▸ Un-precompose from the menu: when expanding the pre-comp layer would change the picture
//! or drop something (the command's dry run lists why), the reasons are shown first and the user
//! chooses to cancel or to unpack anyway (`force`). Cancel changes nothing; a pre-comp layer
//! without reasons is unpacked at once, and one that cannot be unpacked at all (locked, not a
//! pre-comp) reports its error without the prompt. Agents running `layer.unprecompose` with
//! `engine.execute` are never asked: they pass `force` or get the error listing the reasons.
//!
//! Automation ids: `dialog.unprecompose.force`, `dialog.unprecompose.cancel`.
use crate::i18n::tr;
use egui::vec2;
use serde_json::{Value, json};
use crate::theme::Tokens;
use crate::{Dialog, EffectcraftApp};
const COMMAND: &str = "layer.unprecompose";
/// The unpack waiting on the prompt: its params and the dry run's reasons.
#[derive(Clone, Debug, Default)]
pub struct Pending {
pub command: Option<(Value, Vec<String>)>,
}
/// Whether command `id` with `params` needs the prompt first. When it does, the prompt opens
/// holding the command and the caller must not run it. `Err` when the dry run fails (a locked
/// layer, a layer that is not a pre-comp, several layers), with the message.
pub fn guard(app: &mut EffectcraftApp, id: &str, params: &Value) -> Result<bool, String> {
if id != COMMAND || params.get("force") == Some(&Value::Bool(true)) || params.get("dryRun") == Some(&Value::Bool(true)) {
return Ok(false);
}
let mut dry = params.as_object().cloned().unwrap_or_default();
dry.insert("dryRun".into(), Value::Bool(true));
let r = app.session.execute(COMMAND, Value::Object(dry)).map_err(|e| e.to_string())?;
let warnings: Vec<String> = r["warnings"].as_array().map(|w| w.iter().filter_map(Value::as_str).map(str::to_string).collect()).unwrap_or_default();
if warnings.is_empty() {
return Ok(false);
}
// The layer the warnings are about, whatever is selected by the time the prompt is answered.
let mut pinned = params.as_object().cloned().unwrap_or_default();
if !pinned.contains_key("layer") && !pinned.contains_key("layers") {
pinned.insert("layers".into(), json!(app.session.state.selected_layers.iter().map(|l| l.0).collect::<Vec<_>>()));
}
if let Some(comp) = app.session.active_comp_id().filter(|_| !pinned.contains_key("comp")) {
pinned.insert("comp".into(), json!(comp.0));
}
app.dialog_state.unprecompose.command = Some((Value::Object(pinned), warnings));
app.dialog = Some(Dialog::Unprecompose);
Ok(true)
}
pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
let Some((_, warnings)) = app.dialog_state.unprecompose.command.clone() else {
app.dialog = None;
return;
};
let mut answer: Option<bool> = None;
super::dialogs::modal_vertical(ctx, "Un-precompose", vec2(520.0, 300.0), t, |ui| {
ui.label(egui::RichText::new(tr("Unpacking this pre-comp layer changes the picture or drops something:")).color(t.tab_text_active));
ui.add_space(6.0);
egui::ScrollArea::vertical().max_height(160.0).show(ui, |ui| {
for w in &warnings {
ui.label(egui::RichText::new(format!("• {w}")).color(t.tab_text_active));
}
});
ui.add_space(4.0);
ui.label(egui::RichText::new(tr("What the pre-comp layer itself carries is dropped. You can undo this.")).color(t.text_dim).size(11.0));
ui.add_space(14.0);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let label = tr("Unpack Anyway");
let r = ui.button(label);
app.auto.add("dialog.unprecompose.force", r.rect, label);
if r.clicked() {
answer = Some(true);
}
let r = ui.button(tr("Cancel"));
app.auto.add("dialog.unprecompose.cancel", r.rect, "Cancel");
if r.clicked() {
answer = Some(false);
}
});
});
if answer.is_none() && ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
answer = Some(false);
}
if let Some(go) = answer {
finish(app, ctx, go);
}
}
/// The prompt's answer: Cancel (`false`) drops the unpack, Unpack Anyway runs it with `force`.
fn finish(app: &mut EffectcraftApp, ctx: &egui::Context, go: bool) {
app.dialog = None;
let Some((params, _)) = app.dialog_state.unprecompose.command.take() else { return };
if go {
// An error is already in the status bar.
let _ = crate::menus::run_engine(app, ctx, COMMAND, forced(&params));
}
}
/// `params` with the user's consent to the reasons the prompt listed.
fn forced(params: &Value) -> Value {
let mut p = params.as_object().cloned().unwrap_or_default();
p.insert("force".into(), json!(true));
Value::Object(p)
}
#[cfg(test)]
mod tests {
use super::*;
use effectcraft_engine::Session;
/// A comp with two solids nested into a pre-comp; the pre-comp layer's id.
fn scene(app: &mut EffectcraftApp) -> u64 {
let s = &mut app.session;
s.execute("comp.new", json!({"name": "Main", "width": 640, "height": 360, "duration": 4.0, "fps": 24.0})).unwrap();
let a = s.execute("layer.newSolid", json!({"color": "#ff0000"})).unwrap()["layer"].as_u64().unwrap();
let b = s.execute("layer.newSolid", json!({"color": "#00ff00"})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.precompose", json!({"layers": [a, b], "name": "Inner"})).unwrap()["layer"].as_u64().unwrap()
}
fn layer_count(s: &Session) -> usize {
s.active_comp().map_or(0, |c| c.layers.len())
}
fn fresh() -> (egui::Context, EffectcraftApp) {
let ctx = egui::Context::default();
let app = EffectcraftApp::new(Session::default());
crate::theme::install(&ctx, &app.tokens, crate::i18n::language(&app));
(ctx, app)
}
#[test]
fn the_menu_asks_and_unpack_anyway_unpacks() {
let (ctx, mut app) = fresh();
let pre = scene(&mut app);
app.session.execute("prop.set", json!({"layer": pre, "path": "transform/opacity", "value": 50})).unwrap();
let params = json!({"layers": [pre]});
let r = crate::menus::invoke(&mut app, &ctx, COMMAND, params.clone()).unwrap();
assert_eq!(r, json!({"dialog": "unprecompose"}));
assert_eq!(app.dialog, Some(Dialog::Unprecompose));
let (_, warnings) = app.dialog_state.unprecompose.command.clone().unwrap();
assert!(warnings.iter().any(|w| w.contains("Opacity")), "{warnings:?}");
// Drawn, the prompt offers both answers under their automation ids.
crate::panels::dialogs::press_key(&mut app, &ctx, egui::Key::A, egui::Modifiers::NONE);
assert!(app.auto.find("dialog.unprecompose.force").is_some() && app.auto.find("dialog.unprecompose.cancel").is_some());
// Escape is Cancel: nothing changes.
crate::panels::dialogs::press_key(&mut app, &ctx, egui::Key::Escape, egui::Modifiers::NONE);
assert!(app.dialog.is_none() && app.dialog_state.unprecompose.command.is_none());
assert_eq!(layer_count(&app.session), 1, "Cancel left the pre-comp layer");
// Unpack Anyway unpacks.
crate::menus::invoke(&mut app, &ctx, COMMAND, params).unwrap();
assert_eq!(app.dialog, Some(Dialog::Unprecompose));
finish(&mut app, &ctx, true);
assert!(app.dialog.is_none());
assert_eq!(layer_count(&app.session), 2, "the two solids are back");
}
#[test]
fn a_clean_precomp_unpacks_without_asking_and_a_locked_one_is_an_error() {
let (ctx, mut app) = fresh();
let pre = scene(&mut app);
assert_eq!(guard(&mut app, COMMAND, &json!({"layers": [pre]})), Ok(false));
crate::menus::invoke(&mut app, &ctx, COMMAND, json!({"layers": [pre]})).unwrap();
assert!(app.dialog.is_none());
assert_eq!(layer_count(&app.session), 2);
let (_, mut app) = fresh();
let pre = scene(&mut app);
app.session.execute("prop.set", json!({"layer": pre, "path": "transform/opacity", "value": 50})).unwrap();
app.session.execute("layer.setSwitch", json!({"layers": [pre], "switch": "lock", "value": true})).unwrap();
let e = guard(&mut app, COMMAND, &json!({"layers": [pre]})).unwrap_err();
assert!(e.contains("locked"), "{e}");
assert!(app.dialog.is_none());
assert_eq!(layer_count(&app.session), 1);
// Consented and dry-run calls, and other commands, are not asked.
assert_eq!(guard(&mut app, COMMAND, &json!({"layers": [pre], "force": true})), Ok(false));
assert_eq!(guard(&mut app, COMMAND, &json!({"layers": [pre], "dryRun": true})), Ok(false));
assert_eq!(guard(&mut app, "layer.precompose", &json!({})), Ok(false));
}
}
+2 -2
View File
@@ -667,8 +667,8 @@ fn new_composition_from_footage_makes_a_comp_of_the_picked_file() {
}
/// #297: a folder of frames dropped on the window arrives as one image sequence, and Interpret
/// Footage shows its frames and Start Timecode (missing frames always show colour bars, as in
/// After Effects: there is no Missing Frames choice). #431: dropped files import as stills, one
/// Footage shows its frames and Start Timecode (a missing frame always holds the previous one:
/// there is no Missing Frames choice). #431: dropped files import as stills, one
/// item each, as in After Effects.
#[test]
fn a_dropped_folder_imports_as_one_sequence_with_sequence_interpretation() {
+26 -2
View File
@@ -261,6 +261,30 @@ build real projects through these interfaces; they are worked examples of everyt
`layer.centerAnchor` skip them and fail when every target is locked ("… is locked"); Select
All and `layer.selectNext` / `selectPrevious` step over them. Unlock with
`layer.setSwitch {"layers": [...], "switch": "lock", "value": false}` or `layer.unlockAll`.
* **Un-precompose**: `layer.unprecompose {layer?, force?, dryRun?}` (Layer ▸ Un-precompose) expands the
selected pre-comp layer into copies of its composition's layers at its place (order, keyframes,
effects, masks, parenting, mattes, markers kept; timing moved by the layer's Start and trimmed to
what it showed; Position minus Anchor Point added to the unparented ones). One undo step; the
nested comp stays in the project; the nested comp's frame no longer clips what its layers draw
outside it. Only a **locked** pre-comp layer is refused (with or without `force`), changing
nothing. Everything that would change the picture or drop something is a warning: the pre-comp
layer has a parent or matte, effects, masks, layer styles, a non-default Scale / Rotation /
Opacity, an animated transform, time remap or stretch, a blending mode, Video or Audio off, Solo
or Guide on, the 3D / collapse / adjustment switch or Preserve Transparency, or is used by another
layer (parent, matte or a layer property), or an expression of another layer names it (by name in
quotes or as `layer(<index>)`; listed per property, the expression is left as written and will
no longer find it); or the nested comp holds a camera, light, adjustment,
3D, collapsing or soloed layer, or a layer with a blending mode or Preserve Transparency, preserves
a frame rate other than the comp's, has a motion-blur shutter other than the comp's (with motion
blur layers), or has an unparented layer with a Position expression while the pre-comp layer's
Position differs from its Anchor Point (the move is added to stored values only). Without
`force` such a call is an error listing every reason and nothing changes; `dryRun: true` returns
`{dryRun, warnings}` without changing anything. With `force: true` it unpacks anyway and returns
`{layers, warnings}`: what the pre-comp layer itself carried (effects, masks, styles, transform
beyond Position, time remap, blending mode, switches, its parent and matte) is dropped, and other
layers' parent, track matte and layer-property links to it are removed. The menu shows the
reasons and asks (Unpack Anyway / Cancel, `dialog.unprecompose.force` /
`dialog.unprecompose.cancel`); a layer without reasons unpacks without asking.
* **Quick Set Anchor**: `layer.quickSetAnchor {point: topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight, layers?}`
(Layer ▸ Transform ▸ Quick Set Anchor) moves the Anchor Point of 2D layers to that point of their
content and shifts Position and its keys so the layers don't move, in one undo step. 3D and
@@ -302,8 +326,8 @@ build real projects through these interfaces; they are worked examples of everyt
file's whole numbered run as one item (several picked frames: that range; a folder: each run in
it, also without `sequence`). Without `"sequence": true` a picked or dropped numbered file imports
as one still, as in After Effects; `"alphabetical": true`: Force Alphabetical Order, the files
play one after another) at `frameRate` or 30 fps (Settings ▸ Import). Missing frames show
colour bars, as in After Effects. `file.interpretFootage {"items":[id], "frameRate":12,
play one after another) at `frameRate` or 30 fps (Settings ▸ Import). A gap in the numbering holds
the previous frame, as in After Effects. `file.interpretFootage {"items":[id], "frameRate":12,
"startTimecode":1001}` conforms them and sets Override Start (the item and layers that ran to its end get the new
length). See [footage.md](footage.md).
* **Colour of footage**: `file.interpretFootage {"items":[id], "preserveRgb":true}` is Interpret
+2 -1
View File
@@ -44,7 +44,7 @@ corpus compares renders, each effect is assumed to differ somewhere, and users k
| Simulation | 18 | 18 | Particle Playground lacks Particle Exploder, text particles, ephemeral mapper; CC Particle World camera fixed (#397) |
| Stylize | 25 | 25 | Glow's halo (coverage of a dim layer's edge) and blur width match After Effects 2026 renders; glows above the threshold match too (each channel 8 × (value − threshold + 0.061), measured at three thresholds and two radii); Composite Original's labels are swapped against After Effects (EffectCraft's On Top adds the glow over the original, which After Effects calls Behind); Motion Tile output size (#369) |
| Text | 2 | 2 | — |
| Time | 8 | 8 | #535 (Pixel Motion Blur, Vector Detail) is closed |
| Time | 8 | 8 | Timewarp retimes the layer's source and masks without the effects above it, as After Effects 2026; #535 (Pixel Motion Blur, Vector Detail) is closed |
| Transition | 17 | 17 | Venetian Blinds matches After Effects 2026 renders (band phase from the layer centre, Gaussian feather on both edges) except within ~6 px of the layer edge (up to 48/255); Radial Wipe's Feather matches After Effects 2026 (a Gaussian of σ = 0.4 × Feather pixels on both edges of the wedge) |
| Utility | 9 | 7 | — |
@@ -71,6 +71,7 @@ the GPU is performance, not parity.
| 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-19 | minor | Timewarp ignores the effects above it (source and masks only), measured against After Effects 2026 |
| 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-19 | minor | Levels: "Off for 32 bpc Color" no longer clips in 32 bpc projects, unclipped values extrapolate below Output Black; Levels (Individual Controls) applies the channel controls before the RGB ones, measured against After Effects 2026 (CPU and GPU) |
+12
View File
@@ -71,6 +71,18 @@ After the fix, all ten tangents equal AE's to the printed 0.001 px, and the two
test `spatial_auto_bezier_tangents_follow_after_effects` checks the rule on other original keys,
including a timing change. AE's output stays local; only the script and these scores are tracked.
## Linear side next to an eased side
Measured on 19 October 2026 with After Effects 2026 (26.2) through a time-remapped precomposition
(a square moving at 455 px per second of source time, read off rendered frames to the pixel): a
segment from a Bezier out side (speed 0, influence 50 %) at 2 s, value 3.5, into a linear key at
4 s, value 0.5, plays 3.290 at 2.5 s and 2.657 at 3 s. EffectCraft gave the linear side the
segment's average speed at 33.3 % influence (3.308 and 2.629, 8 and 13 px off); AE uses the average
speed at 16.67 %, the influence its Keyframe Velocity dialog shows for linear keys. Now the same
square positions to the pixel. `LINEAR_SIDE_INFLUENCE` holds the value for evaluation, the Graph
Editor / velocity dialog sides and the Lottie export; segments that are linear on both sides stay
straight. Regression test: `a_linear_side_next_to_an_eased_side_matches_after_effects`.
## Justify All with the every-line composer
Measured on 19 October 2026 with After Effects 2026 (26.2) on box text (EXPORTER read-back t6):
+2 -2
View File
@@ -47,8 +47,8 @@ Stills of any format EffectCraft reads can form a sequence: PNG, JPEG, TIFF, Ope
WebP. Layered and vector documents (PSD, SVG, PDF) import one by one.
**Missing frames.** Where the numbering skips a number (`shot_0003.png` is missing between
`0002` and `0004`), the sequence shows a placeholder of colour bars for that frame, as After
Effects does, so every other frame stays at its number. Render or copy the missing files into the
`0002` and `0004`), the sequence holds the previous frame (`0002`) there, as After Effects 2026
does, so every other frame stays at its number. Render or copy the missing files into the
folder and choose File ▸ Reload Footage to fill the gaps.
Agents: `file.import {"paths": ["/shots/shot_0001.png"], "sequence": true, "frameRate": 24}`.