mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-09 03:49:12 +00:00
Merge remote-tracking branch 'upstream/main' into aep-import-thirdparty
This commit is contained in:
@@ -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
@@ -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
@@ -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, ×) 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, ×)?;
|
||||
// The source with its masks, without the effects above Timewarp.
|
||||
let fr = fetch_with(ctx, ×, 0)?;
|
||||
let pm = PixelMotion::from(ctx, fr.grid.scale);
|
||||
let mattes = fetch_layer(ctx, "matteLayer", ×, &fr.grid);
|
||||
let warps = fetch_layer(ctx, "warpLayer", ×, &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]
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}))
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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]))];
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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 },
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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")));
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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")),
|
||||
|
||||
@@ -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`).
|
||||
|
||||
@@ -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, ¶ms) {
|
||||
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, ¶ms)? {
|
||||
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, ¶ms)? {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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(¶ms));
|
||||
}
|
||||
}
|
||||
|
||||
/// `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));
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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
@@ -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}`.
|
||||
|
||||
Reference in New Issue
Block a user