Merge commit 'refs/check/ecb-2300-main' into feat/cineon-settings-ecb

# Conflicts:
#	crates/project/src/tests.rs
This commit is contained in:
dotCooCoo committed 2026-10-28 23:06:13 -07:00
commit ab3d264541
89 files changed
+4353 -492

No files matched your search

Generated
+12
View File
@@ -1086,6 +1086,17 @@ dependencies = [
"thiserror 2.0.21",
]
[[package]]
name = "craft-quit"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
dependencies = [
"dispatch2",
"egui",
"objc2 0.6.5",
"objc2-app-kit 0.3.2",
]
[[package]]
name = "craft-ui"
version = "0.1.0"
@@ -1822,6 +1833,7 @@ name = "effectcraft-ui-egui"
version = "0.18.0"
dependencies = [
"chrono",
"craft-quit",
"craft-ui",
"craft-update",
"effectcraft-color",
+1
View File
@@ -13,6 +13,7 @@ repository = "https://github.com/storytold/effectcraft"
[workspace.dependencies]
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.1.0", default-features = false, features = ["read"] }
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
craft-quit = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["egui"] }
# internal
effectcraft-time = { path = "crates/time" }
+4
View File
@@ -151,6 +151,10 @@ impl Services for WebServices {
fn is_dir(&self, _path: &str) -> bool {
false
}
/// The table's keys are the paths themselves.
fn full_path(&self, path: &str) -> Option<String> {
Some(path.to_string())
}
}
/// Media import from the table (the bytes are also handed to the media pool, which decodes them).
+10 -8
View File
@@ -365,14 +365,11 @@ fn main() -> eframe::Result {
}))
}),
);
match &result {
Ok(()) => log::info!("the window closed"),
Err(e) => {
if on_gl {
launch_guard::opengl_failed(effectcraft_host::config_dir().as_deref());
}
startup_failed(e, !on_gl && cfg!(not(target_os = "macos")));
if let Err(e) = &result {
if on_gl {
launch_guard::opengl_failed(effectcraft_host::config_dir().as_deref());
}
startup_failed(e, !on_gl && cfg!(not(target_os = "macos")));
}
result
}
@@ -455,7 +452,8 @@ struct Desktop {
}
impl eframe::App for Desktop {
/// The desktop host's logic tick: update the macOS menu bar, run the shared app tick and close the IME pass.
/// The desktop host's logic tick: update the macOS menu bar, run the shared app tick, close the IME pass, show the
/// file dialogs asked for and, last, hand a close to the platform quit.
fn logic(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) {
#[cfg(target_os = "macos")]
self.menu.update(&mut self.app, ctx);
@@ -464,6 +462,7 @@ impl eframe::App for Desktop {
if let Some(asked) = &self.file_dialogs {
sheet_dialogs::show_asked(asked, frame, ctx);
}
effectcraft_ui_egui::panels::unsaved::quit_on_close(ctx);
}
fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {
@@ -484,6 +483,9 @@ impl eframe::App for Desktop {
}
fn on_exit(&mut self) {
// On macOS the app quits through `-[NSApplication terminate:]`, and the process ends
// before `eframe::run_native` returns (#341).
log::info!("the window is closing");
self.launch.exited();
self.window.save(self.app.session.config.as_deref());
self.app.on_exit();
+92 -41
View File
@@ -122,6 +122,38 @@ struct TxResult {
bits: f64,
}
/// The output and scratch buffers of one reconstruction, each `n * n` entries long.
struct ReconBuffers<'a> {
deq: &'a mut [i32],
resid: &'a mut [i32],
recon: &'a mut [u16],
}
/// Outcome of trying one transform block; the levels and the reconstruction stay in `TxScratch`.
#[derive(Clone, Copy)]
struct TxEval {
nonzero: bool,
dist: f64,
bits: f64,
}
/// Buffers of the transform path, sized for the largest coded transform block (32x32). Only the
/// first `n * n` entries are used; they are reused across candidates instead of being allocated.
struct TxScratch {
res: [i32; 1024],
coef: [f32; 1024],
levels: [i32; 1024],
deq: [i32; 1024],
resid: [i32; 1024],
recon: [u16; 1024],
}
impl Default for TxScratch {
fn default() -> Self {
TxScratch { res: [0; 1024], coef: [0.0; 1024], levels: [0; 1024], deq: [0; 1024], resid: [0; 1024], recon: [0; 1024] }
}
}
/// Saved encoder state of a block area (reconstruction, BlockDecoded flags).
struct Snapshot {
planes: [Vec<u16>; 3],
@@ -146,6 +178,8 @@ pub(crate) struct Decider<'a> {
lambda_sad: f64,
/// Half-sample interpolations of the reference luma (visible area): (8, 0), (0, 8), (8, 8).
half: [Vec<u16>; 3],
/// Transform buffers, taken out while a block is being tried (see `code_tx`).
scratch: Option<Box<TxScratch>>,
}
const MODE_BITS_INTRA: f64 = 3.0;
@@ -181,6 +215,7 @@ impl<'a> Decider<'a> {
Some(rf) => [(8, 0), (0, 8), (8, 8)].map(|(fx, fy)| subpel_plane(&rf.planes[0], g.width, g.height, fx, fy, bd)),
None => Default::default(),
},
scratch: None,
}
}
@@ -339,8 +374,10 @@ impl<'a> Decider<'a> {
let log2 = (n4 * 4).trailing_zeros();
let (sbr, sbc) = ((r & 15) as isize, (c & 15) as isize);
let bd = self.bd;
let mut pred = vec![0u16; 32 * 32];
let mut best_pred = vec![0u16; 32 * 32];
let mut pred = [0u16; 32 * 32];
let mut best_pred = [0u16; 32 * 32];
let mut best_levels = [0i32; 32 * 32];
let mut best_recon = [0u16; 32 * 32];
let n = 1usize << log2;
// luma
let e = IntraEdge {
@@ -358,26 +395,26 @@ impl<'a> Decider<'a> {
let ed = edges(&self.rec[0], &e);
// modes ranked by SATD, then a rate-distortion choice of mode and transform type among
// the best three
let mut ranked: Vec<(f64, usize)> = INTRA_MODES_USED
.iter()
.map(|&mode| {
predict_intra(&ed, &e, mode, &mut pred);
let sad = self.satd(0, c * 4, r * 4, n, &pred[..n * n]) as f64;
(sad + self.lambda_sad * if mode == DC_PRED { 1.0 } else { MODE_BITS_INTRA }, mode)
})
.collect();
let mut ranked: [(f64, usize); INTRA_MODES_USED.len()] = INTRA_MODES_USED.map(|mode| {
predict_intra(&ed, &e, mode, &mut pred);
let sad = self.satd(0, c * 4, r * 4, n, &pred[..n * n]) as f64;
(sad + self.lambda_sad * if mode == DC_PRED { 1.0 } else { MODE_BITS_INTRA }, mode)
});
ranked.sort_by(|a, b| a.0.total_cmp(&b.0));
let tx_y = tx_size_for(log2);
let mut best: Option<(f64, usize, usize, TxResult, Option<Vec<u16>>)> = None;
let mut best: Option<(f64, usize, usize, TxEval, bool)> = None;
let mut sc = self.scratch.take().unwrap_or_default();
for &(_, mode) in ranked.iter().take(3) {
predict_intra(&ed, &e, mode, &mut pred);
let mut types = vec![DCT_DCT];
let mut types = [DCT_DCT; 2];
let mut n_types = 1;
let alt = MODE_TO_TXFM[mode] as usize;
if alt != DCT_DCT && tx_in_set(tx_set(tx_y, false), false, alt) {
types.push(alt);
types[1] = alt;
n_types = 2;
}
for &t in &types {
let (res, recon) = self.try_tx(TransformBlock { plane: 0, x: c * 4, y: r * 4, log2, tx_type: t, intra: true }, &pred[..n * n]);
for &t in &types[..n_types] {
let (res, has_recon) = self.try_tx(&mut sc, TransformBlock { plane: 0, x: c * 4, y: r * 4, log2, tx_type: t, intra: true }, &pred[..n * n]);
let type_bits = if !res.nonzero || tx_set(tx_y, false) == TX_SET_DCTONLY {
0.0
} else if t == DCT_DCT {
@@ -389,18 +426,24 @@ impl<'a> Decider<'a> {
let cost = res.dist + self.lambda * (res.bits + type_bits + mode_bits);
if best.as_ref().is_none_or(|b| cost < b.0) {
best_pred[..n * n].copy_from_slice(&pred[..n * n]);
best = Some((cost, mode, t, res, recon));
best_levels[..n * n].copy_from_slice(&sc.levels[..n * n]);
if has_recon {
best_recon[..n * n].copy_from_slice(&sc.recon[..n * n]);
}
best = Some((cost, mode, t, res, has_recon));
}
}
}
self.scratch = Some(sc);
// Every ranked mode tries DCT_DCT, so `best` is always set; without a candidate the block
// can't be coded intra (an infinite cost keeps it from being chosen).
let Some((_, y_mode, tx_type_y, ty, recon)) = best else {
let Some((_, y_mode, tx_type_y, ty, has_recon)) = best else {
let leaf =
Leaf { bsize, is_inter: false, y_mode: DC_PRED, uv_mode: DC_PRED, tx_type_y: DCT_DCT, mv: [0, 0], skip: true, coefs: Default::default() };
return (leaf, f64::INFINITY);
};
self.put(0, c * 4, r * 4, n, recon.as_deref().unwrap_or(&best_pred[..n * n]));
self.put(0, c * 4, r * 4, n, if has_recon { &best_recon[..n * n] } else { &best_pred[..n * n] });
let ty = TxResult { levels: best_levels[..n * n].to_vec(), nonzero: ty.nonzero, dist: ty.dist, bits: ty.bits };
let tx_type_y = if ty.nonzero { tx_type_y } else { DCT_DCT };
self.mark_decoded(0, r, c, n4);
// chroma (one mode for both planes)
@@ -792,32 +835,36 @@ impl<'a> Decider<'a> {
/// over the prediction `pred`; the reconstruction is written to `rec`.
fn code_tx(&mut self, block: TransformBlock, pred: &[u16]) -> TxResult {
let TransformBlock { plane: p, x, y, log2, .. } = block;
let (res, recon) = self.try_tx(block, pred);
let mut sc = self.scratch.take().unwrap_or_default();
let (res, has_recon) = self.try_tx(&mut sc, block, pred);
let n = 1usize << log2;
self.put(p, x, y, n, recon.as_deref().unwrap_or(pred));
res
self.put(p, x, y, n, if has_recon { &sc.recon[..n * n] } else { pred });
let levels = sc.levels[..n * n].to_vec();
self.scratch = Some(sc);
TxResult { levels, nonzero: res.nonzero, dist: res.dist, bits: res.bits }
}
/// Transform and quantise one block; returns the result and its reconstruction (None: the
/// prediction) without writing it.
fn try_tx(&self, block: TransformBlock, pred: &[u16]) -> (TxResult, Option<Vec<u16>>) {
/// Transform and quantise one block without writing it. The levels are left in `sc.levels`;
/// the flag tells whether `sc.recon` holds the reconstruction (false: it is the prediction).
fn try_tx(&self, sc: &mut TxScratch, block: TransformBlock, pred: &[u16]) -> (TxEval, bool) {
let TransformBlock { plane: p, x, y, log2, tx_type, intra } = block;
let n = 1usize << log2;
let nn = n * n;
let tx_sz = tx_size_for(log2);
let bd = self.bd;
let src = &self.src[p];
let mut res = vec![0i32; n * n];
let TxScratch { res, coef, levels, deq, resid, recon } = sc;
let (res, coef, levels) = (&mut res[..nn], &mut coef[..nn], &mut levels[..nn]);
for i in 0..n {
let row = &src.row(y + i)[x..x + n];
for j in 0..n {
res[i * n + j] = row[j] as i32 - pred[i * n + j] as i32;
}
}
let mut coef = vec![0f32; n * n];
forward_2d(&res, &mut coef, n, tx_type);
forward_2d(res, coef, n, tx_type);
// dead-zone quantiser: rounding offset below one half
let rnd = if intra { 0.38f32 } else { 0.30f32 };
let mut levels = vec![0i32; n * n];
levels.fill(0);
let mut nonzero = false;
for (k, (&cf, l)) in coef.iter().zip(levels.iter_mut()).enumerate() {
let q = if k == 0 { self.dc_q } else { self.ac_q };
@@ -829,23 +876,26 @@ impl<'a> Decider<'a> {
}
let zero_dist = self.sse(p, x, y, n, pred);
if !nonzero {
return (TxResult { levels, nonzero, dist: zero_dist, bits: 1.0 }, None);
return (TxEval { nonzero, dist: zero_dist, bits: 1.0 }, false);
}
let recon = self.reconstruct(&levels, tx_sz, tx_type, pred, n, bd);
let dist = self.sse(p, x, y, n, &recon);
let bits = estimate_bits(&levels, tx_sz);
let recon = &mut recon[..nn];
self.reconstruct(levels, ReconBuffers { deq: &mut deq[..nn], resid: &mut resid[..nn], recon }, tx_sz, tx_type, pred, bd);
let dist = self.sse(p, x, y, n, recon);
let bits = estimate_bits(levels, tx_sz);
if dist + self.lambda * bits >= zero_dist + self.lambda {
levels.iter_mut().for_each(|l| *l = 0);
return (TxResult { levels, nonzero: false, dist: zero_dist, bits: 1.0 }, None);
levels.fill(0);
return (TxEval { nonzero: false, dist: zero_dist, bits: 1.0 }, false);
}
(TxResult { levels, nonzero: true, dist, bits }, Some(recon))
(TxEval { nonzero: true, dist, bits }, true)
}
/// Dequantisation (7.12.3) and the inverse transform, added to the prediction.
fn reconstruct(&self, levels: &[i32], tx_sz: usize, tx_type: usize, pred: &[u16], n: usize, bd: u32) -> Vec<u16> {
/// Dequantisation (7.12.3) and the inverse transform, added to the prediction; the result is
/// written to `recon`. `deq` and `resid` are scratch buffers of `n * n` entries.
fn reconstruct(&self, levels: &[i32], bufs: ReconBuffers, tx_sz: usize, tx_type: usize, pred: &[u16], bd: u32) {
let ReconBuffers { deq, resid, recon } = bufs;
let denom: i64 = if tx_sz == TX_32X32 { 2 } else { 1 };
let lim = 1i64 << (7 + bd);
let mut deq = vec![0i32; n * n];
deq.fill(0);
for (k, (&l, d)) in levels.iter().zip(deq.iter_mut()).enumerate() {
if l == 0 {
continue;
@@ -855,10 +905,11 @@ impl<'a> Decider<'a> {
let mag = (dq.abs() & 0xFF_FFFF) / denom;
*d = (if dq < 0 { -mag } else { mag }).clamp(-lim, lim - 1) as i32;
}
let mut resid = vec![0i32; n * n];
inverse_2d(&deq, &mut resid, tx_sz, tx_type, bd);
inverse_2d(deq, resid, tx_sz, tx_type, bd);
let max = (1i32 << bd) - 1;
pred.iter().zip(&resid).map(|(&p, &r)| (p as i32 + r).clamp(0, max) as u16).collect()
for ((o, &p), &r) in recon.iter_mut().zip(pred).zip(resid.iter()) {
*o = (p as i32 + r).clamp(0, max) as u16;
}
}
}
+3 -1
View File
@@ -409,11 +409,13 @@ impl Encoder {
};
let bd = self.cfg.bit_depth as u32;
let mut cache: Vec<(u32, u64)> = vec![(0, sse(&rec[0]))];
// one working copy of the luma plane for all candidate levels
let mut planes = [rec[0].clone(), Plane::new(0, 0), Plane::new(0, 0)];
let mut eval = |lvl: u32| -> u64 {
if let Some(&(_, e)) = cache.iter().find(|(l, _)| *l == lvl) {
return e;
}
let mut planes = [rec[0].clone(), Plane::new(0, 0), Plane::new(0, 0)];
planes[0].data.copy_from_slice(&rec[0].data);
loop_filter(&mut planes, info, [lvl; 4], bd, true, sb_row_step);
let e = sse(&planes[0]);
cache.push((lvl, e));
+11 -5
View File
@@ -70,6 +70,8 @@ pub(crate) struct TileWriter<'a> {
left_dc: [Vec<u8>; 3],
/// LoopfilterTxSizes per plane (row stride mi_cols + 32).
lf_tx: [Vec<u8>; 3],
/// Scratch for the levels coded so far in `coeffs`.
quant: Vec<i32>,
// current block
r: usize,
c: usize,
@@ -122,6 +124,7 @@ impl<'a> TileWriter<'a> {
left_level: std::array::from_fn(|_| vec![0; g.mi_rows + 64]),
left_dc: std::array::from_fn(|_| vec![0; g.mi_rows + 64]),
lf_tx: std::array::from_fn(|_| vec![0; (g.mi_cols + 32) * (g.mi_rows + 32)]),
quant: Vec::new(),
r: 0,
c: 0,
bsize: 0,
@@ -189,10 +192,10 @@ impl<'a> TileWriter<'a> {
self.sw.symbol(partition, cdf);
} else if has_cols || has_rows {
assert!(split, "blocks crossing the frame edge are split");
let pcdf: Vec<u16> = match bsl {
2 => self.cdf.partition_w16[ctx].to_vec(),
3 => self.cdf.partition_w32[ctx].to_vec(),
_ => self.cdf.partition_w64[ctx].to_vec(),
let pcdf: &[u16] = match bsl {
2 => &self.cdf.partition_w16[ctx],
3 => &self.cdf.partition_w32[ctx],
_ => &self.cdf.partition_w64[ctx],
};
let pr = |k: usize| pcdf[k] as i32 - if k == 0 { 0 } else { pcdf[k - 1] as i32 };
let psum = if has_cols {
@@ -493,7 +496,9 @@ impl<'a> TileWriter<'a> {
let height = TX_HEIGHT[tx_sz] as usize;
let width = 1usize << bwl;
// Quant[] as the decoder sees it while parsing: the levels coded so far (capped)
let mut quant = vec![0i32; width * height];
let mut quant = std::mem::take(&mut self.quant);
quant.clear();
quant.resize(width * height, 0);
for c in (0..eob).rev() {
let pos = scan[c] as usize;
let a = levels[pos].unsigned_abs().min(15) as i32;
@@ -518,6 +523,7 @@ impl<'a> TileWriter<'a> {
}
quant[pos] = a;
}
self.quant = quant;
for c in 0..eob {
let pos = scan[c] as usize;
let v = levels[pos];
+217 -34
View File
@@ -275,27 +275,70 @@ pub fn transform_shutter(ctx: &EffectCtx) -> Option<(f64, f64, u32)> {
(angle > 0.0).then_some((angle, phase, n.clamp(2, 64)))
}
fn transform_matrix(pr: &crate::Params, b: &Buf) -> Mat3 {
let anchor = b.to_px(pr.v2("anchor"));
let pos = b.to_px(pr.v2("position"));
/// The Transform effect's matrix in buffer pixels (`to_px` maps layer pixels into the buffer),
/// as After Effects 2026 draws it (cases 723, 819, 820): `position + K · R · S · (p − anchor)`
/// with the skew `K = rot(−axis) · shearY(tan skew) · rot(axis)` applied after the rotation.
pub fn transform_matrix_px(pr: &crate::Params, to_px: impl Fn([f64; 2]) -> (f64, f64)) -> Mat3 {
let anchor = to_px(pr.v2("anchor"));
let pos = to_px(pr.v2("position"));
let sh = pr.f("scaleHeight");
let sw = if pr.b("uniform") { sh } else { pr.f("scaleWidth") };
let axis = pr.f("skewAxis");
let k = pr.f("skew").clamp(-85.0, 85.0).to_radians().tan();
let k = if k.is_finite() { k } else { 0.0 };
let skew = Mat3::rotate_deg(-axis) * Mat3([[1.0, 0.0, 0.0], [k, 1.0, 0.0], [0.0, 0.0, 1.0]]) * Mat3::rotate_deg(axis);
Mat3::translate(vec2(pos.0, pos.1))
* skew
* Mat3::rotate_deg(pr.f("rotation"))
* Mat3::skew_deg(pr.f("skew"), pr.f("skewAxis"))
* Mat3::scale(vec2(sw / 100.0, sh / 100.0))
* Mat3::translate(vec2(-anchor.0, -anchor.1))
}
fn transform_matrix(pr: &crate::Params, b: &Buf) -> Mat3 {
transform_matrix_px(pr, |p| b.to_px(p))
}
/// The transparent margins (left, top, right, bottom) a `w` x `h` buffer needs so its warp by
/// any of `ms` stays on it: After Effects draws Transform's result outside the layer's bounds
/// (case 723). Each side grows by its own overshoot; the grown buffer is capped at 16 384 px a
/// side and 16 Mpx in all (the margins shrink in proportion), so a layer moved far away costs
/// a bounded allocation.
pub fn transform_grow(ms: &[Mat3], w: u32, h: u32) -> [u32; 4] {
const SIDE: f64 = 16_384.0;
const AREA: f64 = 16_777_216.0;
let (wf, hf) = (w as f64, h as f64);
let mut m = [0.0f64; 4];
for mat in ms {
for (x, y) in [(0.0, 0.0), (wf, 0.0), (0.0, hf), (wf, hf)] {
let q = mat.apply(vec2(x, y));
if q.x.is_finite() && q.y.is_finite() {
m = [m[0].max(-q.x), m[1].max(-q.y), m[2].max(q.x - wf), m[3].max(q.y - hf)];
}
}
}
let m = m.map(|v| v.ceil().max(0.0));
let fit = |len: f64, a: f64, b: f64| -> f64 {
let room = (SIDE - len).max(0.0);
if a + b > room && a + b > 0.0 { room / (a + b) } else { 1.0 }
};
let kx = fit(wf, m[0], m[2]);
let ky = fit(hf, m[1], m[3]);
let area = |k: f64| (wf + (m[0] + m[2]) * kx * k) * (hf + (m[1] + m[3]) * ky * k);
let k = if area(1.0) > AREA && area(1.0) > wf * hf {
// Shrink both margins by the same factor until the area fits.
let (mut lo, mut hi) = (0.0f64, 1.0f64);
for _ in 0..40 {
let mid = (lo + hi) / 2.0;
if area(mid) > AREA { hi = mid } else { lo = mid }
}
lo
} else {
1.0
};
[m[0] * kx * k, m[1] * ky * k, m[2] * kx * k, m[3] * ky * k].map(|v| v.floor().clamp(0.0, SIDE) as u32)
}
fn transform(ctx: &EffectCtx, mut b: Buf) -> Buf {
let anchor = b.to_px(ctx.params.v2("anchor"));
let pos = b.to_px(ctx.params.v2("position"));
let uniform = ctx.params.b("uniform");
let sh = ctx.params.f("scaleHeight");
let sw = if uniform { sh } else { ctx.params.f("scaleWidth") };
let skew = ctx.params.f("skew");
let skew_axis = ctx.params.f("skewAxis");
let rot = ctx.params.f("rotation");
let opacity = ctx.params.f("opacity") as f32 / 100.0;
let sampling = if ctx.params.e("sampling") == 1 { effectcraft_raster::Sampling::Bicubic } else { effectcraft_raster::Sampling::Bilinear };
// Motion blur: the transform at Samples instants across the shutter, averaged (the
@@ -304,33 +347,35 @@ fn transform(ctx: &EffectCtx, mut b: Buf) -> Buf {
&& let Some(host) = ctx.env.host
{
let fd = 1.0 / ctx.fps();
let mut acc = Image::new(b.img.width, b.img.height);
let mut count = 0.0f32;
for k in 0..n {
let t = ctx.time + (phase + angle * k as f64 / (n - 1) as f64) / 360.0 * fd;
let Some(pr) = host.params_at(t) else { continue };
let mut one = Image::new(b.img.width, b.img.height);
let op = pr.f("opacity") as f32 / 100.0;
effectcraft_raster::composite_warp(
&mut one,
&b.img,
&transform_matrix(&pr, &b),
&effectcraft_raster::WarpOpts { opacity: op, sampling, ..Default::default() },
);
acc.data.iter_mut().zip(&one.data).for_each(|(a, o)| (0..4).for_each(|c| a[c] += o[c]));
count += 1.0;
}
if count > 0.0 {
let samples: Vec<crate::Params> = (0..n).filter_map(|k| host.params_at(ctx.time + (phase + angle * k as f64 / (n - 1) as f64) / 360.0 * fd)).collect();
if !samples.is_empty() {
if !ctx.adjustment {
let ms: Vec<Mat3> = samples.iter().map(|pr| transform_matrix(pr, &b)).collect();
b.pad_sides(transform_grow(&ms, b.img.width, b.img.height));
}
let mut acc = Image::new(b.img.width, b.img.height);
for pr in &samples {
let mut one = Image::new(b.img.width, b.img.height);
let op = pr.f("opacity") as f32 / 100.0;
effectcraft_raster::composite_warp(
&mut one,
&b.img,
&transform_matrix(pr, &b),
&effectcraft_raster::WarpOpts { opacity: op, sampling, ..Default::default() },
);
acc.data.iter_mut().zip(&one.data).for_each(|(a, o)| (0..4).for_each(|c| a[c] += o[c]));
}
let count = samples.len() as f32;
acc.data.iter_mut().for_each(|p| p.iter_mut().for_each(|v| *v /= count));
b.img = acc;
return b;
}
}
let m = Mat3::translate(vec2(pos.0, pos.1))
* Mat3::rotate_deg(rot)
* Mat3::skew_deg(skew, skew_axis)
* Mat3::scale(vec2(sw / 100.0, sh / 100.0))
* Mat3::translate(vec2(-anchor.0, -anchor.1));
if !ctx.adjustment {
let m = transform_matrix(ctx.params, &b);
b.pad_sides(transform_grow(&[m], b.img.width, b.img.height));
}
let m = transform_matrix(ctx.params, &b);
let mut out = Image::new(b.img.width, b.img.height);
effectcraft_raster::composite_warp(&mut out, &b.img, &m, &effectcraft_raster::WarpOpts { opacity, sampling, ..Default::default() });
b.img = out;
@@ -576,6 +621,144 @@ mod tests {
}
}
/// After Effects 2026 (cases 723, 819, 820): a grey 960 × 540 solid centred in a 1920 × 1080
/// comp with Transform. The skew applies after the rotation, and the result is drawn outside
/// the layer's bounds. Points are comp pixels sampled from AE's renders (at least 3 px from
/// an edge; true = the solid covers it), half where the old order and clipping differed.
#[test]
fn transform_matches_after_effects() {
let solid = Image::filled(960, 540, [0.5, 0.5, 0.5, 1.0]);
let p2 = |x: f64, y: f64| Value::Vec2([x, y]);
let cases: [(&str, Vec<(&str, Value)>, &[(u32, u32, bool)]); 3] = [
(
"723",
vec![
("anchor", p2(400.0, 200.0)),
("position", p2(500.0, 300.0)),
("uniform", Value::Bool(false)),
("scaleHeight", num(80.0)),
("scaleWidth", num(120.0)),
("skew", num(10.0)),
("skewAxis", num(20.0)),
("rotation", num(30.0)),
],
&[
(1465, 954, true),
(1127, 821, true),
(1378, 846, true),
(1416, 878, true),
(1486, 850, true),
(492, 467, true),
(706, 239, true),
(1402, 1021, true),
(518, 440, true),
(1218, 882, true),
(454, 448, true),
(1473, 920, true),
(1048, 654, true),
(1050, 486, true),
(1060, 736, true),
(932, 804, true),
(1074, 562, true),
(653, 591, true),
(1353, 466, false),
(88, 1021, false),
(1855, 886, false),
(1796, 401, false),
(497, 913, false),
(1445, 515, false),
],
),
(
"819",
vec![("skew", num(15.0)), ("skewAxis", num(60.0))],
&[
(465, 367, true),
(554, 793, false),
(481, 529, true),
(1451, 599, true),
(428, 575, true),
(432, 600, true),
(463, 444, true),
(759, 803, false),
(445, 684, true),
(1411, 757, true),
(915, 784, false),
(480, 737, true),
(758, 620, true),
(975, 705, true),
(1289, 376, true),
(575, 420, true),
(1015, 507, true),
(1377, 448, true),
(1679, 171, false),
(194, 435, false),
(413, 159, false),
(327, 741, false),
(1824, 138, false),
(1093, 29, false),
],
),
(
"820",
vec![
("skew", num(15.0)),
("skewAxis", num(60.0)),
("rotation", num(-25.0)),
("uniform", Value::Bool(false)),
("scaleWidth", num(130.0)),
("scaleHeight", num(70.0)),
("anchor", p2(100.0, 50.0)),
],
&[
(1674, 566, true),
(1425, 317, false),
(1788, 278, true),
(1530, 390, true),
(1531, 652, true),
(1589, 545, true),
(1890, 441, true),
(1724, 400, true),
(1657, 511, true),
(1895, 261, true),
(1697, 344, true),
(933, 840, true),
(1413, 467, true),
(1295, 691, true),
(1353, 548, true),
(1293, 548, true),
(1103, 551, true),
(973, 758, true),
(446, 484, false),
(1009, 420, false),
(144, 761, false),
(1812, 111, false),
(1241, 10, false),
(900, 854, false),
],
),
];
for (case, set, pts) in cases {
let o = run("ec.distort.transform", &set, solid.clone());
for &(x, y, inside) in pts {
// Comp pixel → layer pixel (the solid's top-left sits at 480, 270) → buffer.
let (bx, by) = o.to_px([x as f64 + 0.5 - 480.0, y as f64 + 0.5 - 270.0]);
let a = if bx >= 0.0 && by >= 0.0 && bx < o.img.width as f64 && by < o.img.height as f64 { o.img.get(bx as i64, by as i64)[3] } else { 0.0 };
assert_eq!(a > 0.5, inside, "case {case}: comp pixel ({x}, {y}) alpha {a}");
}
}
// A huge scale stays within the buffer cap.
let o = run("ec.distort.transform", &[("scaleHeight", num(100_000.0))], Image::filled(8, 8, [1.0; 4]));
assert!(o.img.width <= 16_384 && o.img.height <= 16_384);
// A layer moved far right grows the buffer on that side only, within 16 Mpx.
let o = run("ec.distort.transform", &[("position", p2(960.0 + 2000.0, 540.0))], Image::filled(1920, 1080, [1.0; 4]));
assert_eq!((o.offset[0], o.img.height), (0.0, 1080), "only the right side grows");
assert!(o.img.width >= 1920 + 2000 && o.img.width as u64 * o.img.height as u64 <= 16_777_216, "{} x {}", o.img.width, o.img.height);
assert!(o.img.get(2000 + 960, 540)[3] > 0.99 && o.img.get(10, 540)[3] < 0.01);
let o = run("ec.distort.transform", &[("position", p2(40_000.0, 30_000.0))], Image::filled(1920, 1080, [1.0; 4]));
assert!(o.img.width as u64 * o.img.height as u64 <= 16_777_216, "{} x {}", o.img.width, o.img.height);
}
#[test]
fn transform_sampling_bicubic_differs_from_bilinear() {
let im = img(32, 32);
+27 -3
View File
@@ -78,7 +78,7 @@ pub use color_fx::{
pub use color_fx::{COLORAMA_PRESETS, ColoramaPalette};
pub use color2::{Curve, auto_correct_settings, color_link_mode, color_link_sample, equalize_tables, luma_clip_points, shadow_highlight_amounts};
pub use color3::OffsetCurve;
pub use distort::transform_shutter;
pub use distort::{transform_grow, transform_matrix_px, transform_shutter};
pub use distort2::{MAGNIFY_MODES, bezier_grid, parse_mesh};
pub use distort3::{RESHAPE_POINTS, ReshapeSetup, SmearSetup, cc_smear_pad, reshape_setup, smear_setup, warp_inverse_map};
pub use distort4::liquify_mesh;
@@ -86,7 +86,10 @@ use effectcraft_keyframe::Value;
use effectcraft_project::build::Ids;
use effectcraft_project::{GroupKind, ParamUi, PropGroup, Property};
pub use effectcraft_raster::{AuxChannels, Image};
pub use misc::{INVERT_ALPHA, INVERT_CHANNELS, glow_ab_t, glow_operation};
pub use misc::{
GLOW_GAIN, GLOW_KNEE, INVERT_ALPHA, INVERT_CHANNELS, glow_ab_t, glow_operation, glow_sigma, radial_wipe_sigma, radial_wipe_wedge, venetian_cover,
venetian_origin,
};
pub use sim::particle_state;
pub use sim3::{PgBlit, PgPlan, playground_plan, playground_state};
pub use stylize2::strobe_on;
@@ -104,7 +107,7 @@ pub use transition::place_layer;
// effectcraft-gpu fx_noise.
pub use keying2::{InnerOuterPlan, inner_outer_plan};
pub use noise::remove_grain_level;
pub use noise3::{FractalGpu, fractal_gpu};
pub use noise3::{FractalGpu, NOISE_CELL, fractal_gpu};
pub use stylize3::{BumpLight, bump_layer_id};
pub use textfx::{TextLayer, text_layer};
pub use time_fx::{PixelMotion, TimewarpPlan, TwStep, pmb_plan, posterized_time, time_frames, timewarp_crop, timewarp_plan};
@@ -287,6 +290,27 @@ impl Buf {
self.offset[0] += origin[0] * self.scale;
self.offset[1] += origin[1] * self.scale;
}
/// Grow by transparent pixels: `sides` = left, top, right, bottom. Content keeps its place.
pub fn pad_sides(&mut self, sides: [u32; 4]) {
let [l, t, r, b] = sides;
if l == 0 && t == 0 && r == 0 && b == 0 {
return;
}
let (Some(w), Some(h)) = (self.img.width.checked_add(l).and_then(|v| v.checked_add(r)), self.img.height.checked_add(t).and_then(|v| v.checked_add(b)))
else {
return;
};
let mut out = Image::new(w, h);
let (sw, ow) = (self.img.width as usize, w as usize);
for y in 0..self.img.height as usize {
let at = (y + t as usize) * ow + l as usize;
let (Some(src), Some(dst)) = (self.img.data.get(y * sw..(y + 1) * sw), out.data.get_mut(at..at + sw)) else { continue };
dst.copy_from_slice(src);
}
self.img = out;
self.offset[0] += l as f64;
self.offset[1] += t as f64;
}
/// Grow by `pad` transparent pixels on every side.
pub fn pad(&mut self, pad: u32) {
if pad == 0 {
+311 -20
View File
@@ -142,6 +142,28 @@ pub fn glow_ab_t(l: f32, looping: u32, loops: f32, phase: f32, mid: f32) -> f32
t.max(0.0).powf(0.5f32.ln() / m.ln())
}
/// Glow's blur: a Gaussian of σ = 0.384 × Glow Radius. Fitted to After Effects 2026 (cases
/// 807 and 808, Glow Radius 10 and 30 on a grey solid): the edge profile of the glow's
/// coverage is an erfc step of that σ within 1.7/255 (it was σ = radius / 2).
pub fn glow_sigma(radius: f64) -> f64 {
(radius * 0.384).max(0.5)
}
/// Glow's bright pass for the original colours, per channel of the straight colour `v`:
/// `8 × (v − threshold + 0.0611)`, nothing below. Fitted to After Effects 2026 renders of grey
/// 0.65 / 0.8 / 0.9 / 1, orange and light-blue solids at Threshold 40 / 60 / 80 % and Radius 10
/// / 30 (36 channel values, exact to the 8-bit capture): the glow is linear in each channel,
/// its gain is the same at every threshold and radius, and channels below the threshold add
/// nothing (an orange solid glows red only).
pub fn glow_bright_channel(v: f32, thr: f32) -> f32 {
(GLOW_GAIN * (v - thr + GLOW_KNEE)).max(0.0)
}
/// [`glow_bright_channel`]'s gain and threshold offset. `kernels.wgsl` repeats both as literals;
/// the GPU test `glow_constants_match_the_cpu` checks that they are these.
pub const GLOW_GAIN: f32 = 8.0;
pub const GLOW_KNEE: f32 = 0.0611;
/// Glow Operation options (blend modes the glow is applied with).
pub const GLOW_OPERATIONS: [&str; 17] = [
"Normal",
@@ -205,23 +227,31 @@ fn glow(ctx: &EffectCtx, mut b: Buf) -> Buf {
// Glow Based On: brightness from the colours, or from the alpha channel.
let l = if alpha_based { a } else { luminance(px[0] / a, px[1] / a, px[2] / a) };
let k = ((l - thr) / (1.0 - thr).max(1e-3)).clamp(0.0, 1.0);
// Based on the colour channels, the glow's coverage is the layer's whole alpha, below
// the threshold too: After Effects darkens what lies under a dim layer's edge (glow_alpha).
let ga = if alpha_based { k * a } else { a };
if let Some(map) = &arbitrary {
let t = glow_ab_t(l, looping, loops, phase, mid);
let c = map.iter().map(|cv| cv.as_ref().map_or(t, |cv| cv.eval(t))).collect::<Vec<f32>>();
*px = [c[0] * k * a, c[1] * k * a, c[2] * k * a, k * a];
*px = [c[0] * k * a, c[1] * k * a, c[2] * k * a, ga];
} else if use_colors {
let t = glow_ab_t(l, looping, loops, phase, mid);
let c = [ca[0] + (cb[0] - ca[0]) * t, ca[1] + (cb[1] - ca[1]) * t, ca[2] + (cb[2] - ca[2]) * t];
*px = [c[0] * k * a, c[1] * k * a, c[2] * k * a, k * a];
*px = [c[0] * k * a, c[1] * k * a, c[2] * k * a, ga];
} else if alpha_based {
*px = [px[0] * k, px[1] * k, px[2] * k, ga];
} else {
*px = px.map(|v| v * k);
// Original colours: each channel of the straight colour against the threshold.
let c = [px[0] / a, px[1] / a, px[2] / a].map(|v| glow_bright_channel(v, thr) * a);
*px = [c[0], c[1], c[2], ga];
}
});
let s = (radius / 2.0).max(0.5);
let s = glow_sigma(radius);
let blurred = gaussian_blur(&bright, s * kx, s * ky, false);
let mode = ctx.params.e("operation");
b.img.data.par_iter_mut().zip(blurred.data.par_iter()).for_each(|(o, g)| {
let g = g.map(|v| v * intensity);
// Intensity scales the glow's colour; with the colour channels its coverage stays.
let g = [g[0] * intensity, g[1] * intensity, g[2] * intensity, if alpha_based { g[3] * intensity } else { g[3] }];
match mode {
// Behind: the glow shows only where the original is transparent.
1 => {
@@ -525,22 +555,67 @@ fn linear_wipe(ctx: &EffectCtx, mut b: Buf) -> Buf {
b
}
/// Standard normal CDF Φ (Abramowitz & Stegun 7.1.26 erf, |error| < 1.5e-7).
fn norm_cdf(z: f64) -> f64 {
let x = z.abs() / std::f64::consts::SQRT_2;
let t = 1.0 / (1.0 + 0.327_591_1 * x);
let poly = t * (0.254_829_592 + t * (-0.284_496_736 + t * (1.421_413_741 + t * (-1.453_152_027 + t * 1.061_405_429))));
let erf = 1.0 - poly * (-x * x).exp();
if z >= 0.0 { 0.5 * (1.0 + erf) } else { 0.5 * (1.0 - erf) }
}
/// Radial Wipe's wiped wedge relative to the Start Angle: (first ray, span), degrees clockwise
/// from up. Clockwise (0) wipes from the start angle on, Counterclockwise (1) back from it,
/// Both (2) half each way.
pub fn radial_wipe_wedge(wipe: u32, done: f64) -> (f64, f64) {
let span = done.clamp(0.0, 1.0) * 360.0;
match wipe {
1 => (-span, span),
2 => (-span / 2.0, span),
_ => (0.0, span),
}
}
/// Radial Wipe's feather: a Gaussian of σ = 0.4 × Feather pixels across both edges of the wedge
/// (After Effects 2026, Feather 20: the edge profile is the same width in pixels at every
/// radius, σ = 8.0 px, mean error 0.0002 over the frame).
pub fn radial_wipe_sigma(feather: f64) -> f64 {
feather.max(0.0) * 0.4
}
/// Visible fraction of a pixel at `(ux, uy)` from the centre (y down) for a wiped wedge from
/// `ray0` (absolute degrees clockwise from up) over `span` degrees: the signed distance to the
/// wedge's nearer edge, through the feather's Gaussian.
pub fn radial_wipe_alpha(ux: f64, uy: f64, ray0: f64, span: f64, sigma: f64) -> f64 {
let a = (ux.atan2(-uy).to_degrees() - ray0).rem_euclid(360.0);
let wiped = a < span;
let ray_dist = |deg: f64| {
let (s, c) = deg.to_radians().sin_cos();
// Ray direction (sin, −cos) in y-down pixels.
let along = ux * s - uy * c;
if along > 0.0 { (ux * c + uy * s).abs() } else { ux.hypot(uy) }
};
let d = ray_dist(ray0).min(ray_dist(ray0 + span));
let sd = if wiped { -d } else { d };
if sigma < 1e-3 {
return if wiped { 0.0 } else { 1.0 };
}
norm_cdf(sd / sigma)
}
fn radial_wipe(ctx: &EffectCtx, mut b: Buf) -> Buf {
let done = ctx.params.f("completion") / 100.0;
let done = (ctx.params.f("completion") / 100.0).clamp(0.0, 1.0);
if done <= 0.0 {
return b;
}
let start = ctx.params.f("startAngle");
let c = b.to_px(ctx.params.v2("center"));
let dir = ctx.params.e("wipe");
let feather = (ctx.params.f("feather")).max(0.01);
let (w0, span) = radial_wipe_wedge(ctx.params.e("wipe"), done);
let sigma = radial_wipe_sigma(ctx.params.f("feather")) * b.scale;
let full = done >= 1.0;
b.img.rows_mut().for_each(|(y, row)| {
for (x, px) in row.iter_mut().enumerate() {
let a = ((x as f64 + 0.5 - c.0).atan2(-(y as f64 + 0.5 - c.1)).to_degrees() - start).rem_euclid(360.0);
// Both: the wipe opens in both directions from the start angle at once.
let (a, edge) = match dir {
1 => (360.0 - a, done * 360.0),
2 => (a.min(360.0 - a), done * 180.0),
_ => (a, done * 360.0),
};
let k = ((a - edge) / feather + 0.5).clamp(0.0, 1.0) as f32;
let k = if full { 0.0 } else { radial_wipe_alpha(x as f64 + 0.5 - c.0, y as f64 + 0.5 - c.1, start + w0, span, sigma) as f32 };
for ch in px.iter_mut() {
*ch *= k;
}
@@ -549,16 +624,49 @@ fn radial_wipe(ctx: &EffectCtx, mut b: Buf) -> Buf {
b
}
/// Venetian Blinds: how much of a pixel at phase `d` (0..`width`) a band of `done × width`
/// covers, its two edges softened by a Gaussian of σ = Feather / 2 (σ in the units of `width`).
/// Fitted to After Effects 2026 (Completion 40, Direction 30, Width 50, Feather 5 on 960 × 540
/// and 480 × 270 layers): band from phase 0 to done × width, both edges Gaussian, within 6/255.
pub fn venetian_cover(d: f64, width: f64, done: f64, sigma: f64) -> f64 {
let band = done * width;
if sigma < 1e-3 {
return if d < band { 1.0 } else { 0.0 };
}
// Wider than a few blinds the stripes blur into an even veil.
if sigma > 8.0 * width {
return done.clamp(0.0, 1.0);
}
let n = ((4.0 * sigma / width).ceil() as i64 + 1).clamp(1, 40);
let mut t = 0.0;
for k in -n..=n {
let u = d + k as f64 * width;
t += norm_cdf(u / sigma) - norm_cdf((u - band) / sigma);
}
t.clamp(0.0, 1.0)
}
/// Venetian Blinds' phase origin in layer pixels: the layer centre plus 0.6 px (fitted to the
/// AE renders above; the stripes are measured from the layer's centre, not its corner).
pub fn venetian_origin(layer_size: [f64; 2]) -> [f64; 2] {
[layer_size[0] / 2.0 + 0.6, layer_size[1] / 2.0 + 0.6]
}
fn venetian(ctx: &EffectCtx, mut b: Buf) -> Buf {
let done = ctx.params.f("completion") / 100.0;
let done = (ctx.params.f("completion") / 100.0).clamp(0.0, 1.0);
if done <= 0.0 {
return b;
}
let ang = ctx.params.f("direction").to_radians();
let width = (ctx.params.f("width") * b.scale).max(1.0);
let feather = (ctx.params.f("feather") * b.scale).max(0.001);
let sigma = ctx.params.f("feather").max(0.0) * b.scale / 2.0;
let (dx, dy) = (ang.cos(), ang.sin());
let (ox, oy) = b.to_px(venetian_origin(ctx.layer_size));
b.img.rows_mut().for_each(|(y, row)| {
let ry = (y as f64 + 0.5 - oy) * dy;
for (x, px) in row.iter_mut().enumerate() {
let d = ((x as f64 + 0.5) * dx + (y as f64 + 0.5) * dy).rem_euclid(width);
let k = ((d - done * width) / feather + 0.5).clamp(0.0, 1.0) as f32;
let d = ((x as f64 + 0.5 - ox) * dx + ry).rem_euclid(width);
let k = (1.0 - venetian_cover(d, width, done, sigma)) as f32;
for c in px.iter_mut() {
*c *= k;
}
@@ -807,6 +915,189 @@ mod tests {
img
}
/// After Effects 2026 (cases 524, 807, 808, 809): Glow on a 50 % grey solid, which is below
/// the 60 % threshold, still covers a blurred copy of the layer's alpha with black, so the
/// red layer underneath darkens outside the edge. The halo is the same at Intensity 1 and 3
/// and with Composite Original On Top or Behind; its width follows Glow Radius.
#[test]
fn glow_halo_matches_after_effects() {
let grey = effectcraft_raster::Image::filled(200, 120, [0.5, 0.5, 0.5, 1.0]);
// AE's red under the halo at 0.5, 1.5, 3.5, 5.5 and 7.5 px outside the left edge
// (radius 10) and at 0.5, 5.5, 10.5, 20.5 px (radius 30); halo alpha = 1 − red.
let r10 = [(0.5, 140.0), (1.5, 165.0), (3.5, 208.0), (5.5, 234.0), (7.5, 248.0)];
let r30 = [(0.5, 132.0), (5.5, 173.0), (10.5, 208.0), (20.5, 245.0)];
for (radius, intensity, operation, profile) in [(10.0, 1.0, 1, &r10[..]), (10.0, 3.0, 1, &r10[..]), (10.0, 1.0, 0, &r10[..]), (30.0, 1.0, 1, &r30[..])]
{
let set = [
("based", Value::Enum(1)),
("threshold", num(60.0)),
("radius", num(radius)),
("intensity", num(intensity)),
("operation", Value::Enum(operation)),
];
let o = run_fx("ec.stylize.glow", &set, grey.clone(), 0.0, EffectEnv::default());
for &(d, red) in profile {
// Layer x = −d (pixel centre), mid-height; over AE's red: R = 1 − α, colour 0.
let (bx, by) = o.to_px([-d, 60.0]);
let p = o.img.get(bx.floor() as i64, by.floor() as i64);
let ae = 1.0 - red / 255.0;
assert!((p[3] - ae).abs() < 3.0 / 255.0, "radius {radius} intensity {intensity} op {operation} at {d} px: alpha {} vs AE {ae}", p[3]);
assert!(p[0].abs() < 1e-6, "the halo is black: {p:?}");
}
}
}
/// After Effects 2026, Glow at Threshold 60 %, Radius 10, Intensity 0.25 on 200 × 200
/// solids over red: the glow 1.5 px outside the left edge, premultiplied (the green / blue
/// channels of the render, where the red background adds nothing), and its alpha 0.353.
#[test]
fn glow_brightness_matches_after_effects() {
// (solid colour, channel, AE glow value 0..255)
let cases = [
([0.65, 0.65, 0.65], 1, 20.0),
([0.8, 0.8, 0.8], 1, 47.0),
([0.9, 0.9, 0.9], 1, 65.0),
([1.0, 1.0, 1.0], 1, 83.0),
([1.0, 0.5, 0.0], 1, 0.0),
([0.2, 0.6, 1.0], 1, 11.0),
([0.2, 0.6, 1.0], 2, 83.0),
];
for (c, ch, ae) in cases {
let img = effectcraft_raster::Image::filled(200, 200, [c[0], c[1], c[2], 1.0]);
let set = [("threshold", num(60.0)), ("radius", num(10.0)), ("intensity", num(0.25))];
let o = run_fx("ec.stylize.glow", &set, img, 0.0, EffectEnv::default());
let (bx, by) = o.to_px([-1.5, 100.0]);
let p = o.img.get(bx.floor() as i64, by.floor() as i64);
assert!((p[ch] * 255.0 - ae).abs() <= 2.5, "{c:?} channel {ch}: {} vs AE {ae}", p[ch] * 255.0);
assert!((p[3] - 0.353).abs() < 3.0 / 255.0, "{c:?}: alpha {}", p[3]);
}
// Intensity scales the colour linearly: white at Intensity 1 is 3.7 × its alpha.
let white = effectcraft_raster::Image::filled(200, 200, [1.0, 1.0, 1.0, 1.0]);
let o = run_fx("ec.stylize.glow", &[("threshold", num(60.0)), ("radius", num(10.0))], white, 0.0, EffectEnv::default());
let (bx, by) = o.to_px([-1.5, 100.0]);
let p = o.img.get(bx.floor() as i64, by.floor() as i64);
assert!((p[1] / p[3] - 3.69).abs() < 0.05, "{p:?}");
}
/// After Effects 2026: Radial Blur keeps the layer's edges. Zoom 20 on a flat grey 960 × 540
/// solid (case 713) leaves it flat and opaque to the edge (EffectCraft faded the edges toward
/// transparency, letting the red layer below show through); on a full-frame horizontal ramp
/// (case 829, centre [960, 540]) row 540 reads 2/255 at x = 2 and 253/255 at x = 1917.
#[test]
fn radial_blur_keeps_the_layer_edges_as_after_effects() {
let zoom = |c: [f64; 2]| [("amount", num(20.0)), ("type", Value::Enum(1)), ("center", Value::Vec2(c))];
let grey = effectcraft_raster::Image::filled(960, 540, [0.5, 0.5, 0.5, 1.0]);
let o = run_fx("ec.blur.radial", &zoom([600.0, 300.0]), grey, 0.0, EffectEnv::default());
for (x, y) in [(0, 270), (1, 270), (959, 270), (480, 0), (480, 539), (0, 0), (959, 539)] {
let p = o.img.get(x, y);
assert!((p[3] - 1.0).abs() < 1e-4 && (p[0] - 0.5).abs() < 1e-4, "({x}, {y}): {p:?}");
}
let mut ramp = effectcraft_raster::Image::new(1920, 1080);
ramp.rows_mut().for_each(|(_, row)| {
for (x, px) in row.iter_mut().enumerate() {
let v = (x as f32 + 0.5) / 1920.0;
*px = [v, v, v, 1.0];
}
});
let o = run_fx("ec.blur.radial", &zoom([960.0, 540.0]), ramp, 0.0, EffectEnv::default());
for (x, ae) in [(2, 2.0), (1917, 253.0)] {
let p = o.img.get(x, 540);
assert!((p[0] * 255.0 - ae).abs() <= 2.0 && (p[3] - 1.0).abs() < 1e-4, "x {x}: {p:?} vs AE {ae}");
}
}
/// After Effects 2026, Venetian Blinds Completion 40, Direction 30, Width 50, Feather 5 on
/// an opaque 480 × 270 layer: alpha (0..255) along row y = 135. The transparent band starts
/// at the layer centre's phase, both edges are Gaussian (σ = Feather / 2).
#[test]
fn venetian_blinds_match_after_effects() {
let img = effectcraft_raster::Image::filled(480, 270, [0.5, 0.5, 0.5, 1.0]);
let vals = [("completion", Value::Scalar(40.0)), ("direction", Value::Scalar(30.0)), ("width", Value::Scalar(50.0)), ("feather", Value::Scalar(5.0))];
let o = run_fx("ec.transition.venetian", &vals, img, 0.0, EffectEnv::default());
let ae = [
(200, 5.0),
(203, 47.0),
(206, 145.0),
(209, 228.0),
(212, 253.0),
(224, 255.0),
(236, 238.0),
(239, 168.0),
(242, 66.0),
(245, 10.0),
(251, 0.0),
(257, 2.0),
(260, 31.0),
];
for (x, a) in ae {
let got = o.img.get(x, 135)[3] * 255.0;
assert!((got - a).abs() <= 6.0, "x {x}: alpha {got} vs AE {a}");
}
// No completion leaves the layer; full completion clears it; no feather is a hard edge.
let full = [("completion", Value::Scalar(100.0)), ("feather", Value::Scalar(5.0))];
let img = effectcraft_raster::Image::filled(40, 20, [0.5, 0.5, 0.5, 1.0]);
assert!(run_fx("ec.transition.venetian", &full, img.clone(), 0.0, EffectEnv::default()).img.data.iter().all(|p| p[3] < 1e-4));
assert_eq!(run_fx("ec.transition.venetian", &[("completion", Value::Scalar(0.0))], img.clone(), 0.0, EffectEnv::default()).img, img);
let hard = run_fx(
"ec.transition.venetian",
&[("completion", Value::Scalar(50.0)), ("width", Value::Scalar(10.0)), ("feather", Value::Scalar(0.0))],
img,
0.0,
EffectEnv::default(),
);
assert!(hard.img.data.iter().all(|p| p[3] == 0.0 || p[3] == 1.0));
// A feather far wider than the blinds is an even veil.
assert_eq!(venetian_cover(3.0, 10.0, 0.5, 1e9), 0.5);
}
/// After Effects 2026, Radial Wipe Completion 40, Start Angle 30, Wipe Counterclockwise,
/// Feather 20, centre (800, 500): visible fraction at pixel offsets from the centre on both
/// edges (8-bit captures of dark steps, so ±0.05).
#[test]
fn radial_wipe_feather_matches_after_effects() {
let img = effectcraft_raster::Image::filled(400, 300, [1.0, 1.0, 1.0, 1.0]);
let vals = [
("completion", Value::Scalar(40.0)),
("startAngle", Value::Scalar(30.0)),
("center", Value::Vec2([200.0, 150.0])),
("wipe", Value::Enum(1)),
("feather", Value::Scalar(20.0)),
];
let o = run_fx("ec.transition.radialwipe", &vals, img.clone(), 0.0, EffectEnv::default());
let ae = [
((-50, 34), 0.922),
((-52, 30), 0.797),
((-54, 26), 0.609),
((-55, 24), 0.5),
((-56, 22), 0.406),
((-57, 19), 0.266),
((-58, 15), 0.125),
((-104, 60), 0.951),
((-108, 53), 0.732),
((-111, 45), 0.341),
((-114, 37), 0.062),
((22, -56), 0.156),
((26, -54), 0.312),
((30, -52), 0.531),
((34, -50), 0.729),
((37, -47), 0.875),
((53, -108), 0.177),
((67, -99), 0.875),
];
for ((dx, dy), a) in ae {
let got = o.img.get(200 + dx, 150 + dy)[3];
assert!((got - a).abs() < 0.05, "offset ({dx}, {dy}): {got} vs AE {a}");
}
// 0 % leaves the layer, 100 % clears it, Feather 0 is a hard edge.
let at = |c: f64, f: f64| {
let v = [("completion", Value::Scalar(c)), ("feather", Value::Scalar(f))];
run_fx("ec.transition.radialwipe", &v, img.clone(), 0.0, EffectEnv::default()).img
};
assert_eq!(at(0.0, 20.0), img);
assert!(at(100.0, 20.0).data.iter().all(|p| p[3] == 0.0));
assert!(at(50.0, 0.0).data.iter().all(|p| p[3] == 0.0 || p[3] == 1.0));
}
#[test]
fn glow_operation_and_arbitrary_map() {
// Pixels addressed in layer space (Glow pads its buffer).
+28 -3
View File
@@ -60,6 +60,11 @@ pub const BLEND_NORMAL: u32 = 1;
/// Index of "Soft Linear" in [`NOISE_TYPES`] (the default, and the GPU path's interpolation).
pub const NOISE_SOFT_LINEAR: u32 = 2;
/// Lattice cell of Fractal / Turbulent Noise in pixels per percent of Scale: After Effects 2026
/// draws Block noise at Scale 100 in 66.67 px cells (and Soft Linear noise has the matching
/// correlation length), not 100 px.
pub const NOISE_CELL: f64 = 2.0 / 3.0;
fn spec(id: &'static str, name: &'static str, params: Vec<ParamSpec>, render: crate::RenderFn) -> EffectSpec {
EffectSpec { id, name, category: "Noise & Grain", params, render, gpu: false, float: true }
}
@@ -236,7 +241,7 @@ impl Fractal {
overflow: pr.e("overflow"),
invert: pr.b("invert"),
rot: rot.sin_cos(),
size: [(sw * b.scale).max(1.0) as f32, (sh * b.scale).max(1.0) as f32],
size: [(sw * NOISE_CELL * b.scale).max(1.0) as f32, (sh * NOISE_CELL * b.scale).max(1.0) as f32],
origin: [ox as f32, oy as f32],
center: [cx as f32, cy as f32],
perspective: pr.b("transform/perspectiveOffset"),
@@ -535,14 +540,34 @@ mod tests {
#[test]
fn noise_types_differ_and_block_is_piecewise_constant() {
let a = run("ec.noise.fractal", &[("noiseType", Value::Enum(0)), ("complexity", num(1.0)), ("transform/scale", num(10.0))]);
// Block noise: neighbours inside one 10 px cell share a value.
let a = run("ec.noise.fractal", &[("noiseType", Value::Enum(0)), ("complexity", num(1.0)), ("transform/scale", num(15.0))]);
// Block noise: neighbours inside one 10 px cell (Scale 15) share a value.
assert_eq!(a.get(4, 4), a.get(5, 5));
let others: Vec<Image> = (1..4).map(|t| run("ec.noise.fractal", &[("noiseType", Value::Enum(t)), ("transform/scale", num(10.0))])).collect();
assert_ne!(others[0], others[1]);
assert_ne!(others[1], others[2]);
}
/// After Effects 2026 (R9): Block noise at Scale 100, Complexity 1 changes value every
/// 66.67 px along a row.
#[test]
fn block_noise_cells_are_two_thirds_of_scale_as_in_after_effects() {
let img = run_fx(
"ec.noise.fractal",
&[("noiseType", Value::Enum(0)), ("complexity", num(1.0)), ("blendingMode", Value::Enum(BLEND_NONE))],
Image::filled(400, 8, [0.0, 0.0, 0.0, 1.0]),
0.0,
EffectEnv::default(),
)
.img;
let changes: Vec<i64> = (1..400).filter(|&x| img.get(x, 4) != img.get(x - 1, 4)).collect();
assert!(changes.len() >= 4, "{changes:?}");
for w in changes.windows(2) {
let d = (w[1] - w[0]) as f64;
assert!((d - 200.0 / 3.0).abs() <= 1.0, "cell width {d}: {changes:?}");
}
}
#[test]
fn scale_width_height_apply_without_uniform_scaling() {
let base = &[("complexity", num(1.0)), ("transform/scale", num(8.0))];
+2
View File
@@ -17,6 +17,8 @@ pub mod content_fill;
mod create;
mod edit;
mod effect;
pub use effect::CryptoProbeJob;
pub(crate) use effect::{CryptoProbeSlot, crypto_probe_job, crypto_probe_ready, keep_crypto_probe, kept_crypto_probe};
pub mod essential;
pub mod expr_tools;
mod file;
+8 -2
View File
@@ -72,9 +72,9 @@ pub fn parse_stamp(v: &Value, fps: f64) -> Option<Stamp> {
}
// ISO 8601: date [T| ] time [zone].
if s.len() >= 10 && s.as_bytes()[4] == b'-' && s.as_bytes()[7] == b'-' {
let (y, m, d) = (s[0..4].parse::<i64>().ok()?, s[5..7].parse::<i64>().ok()?, s[8..10].parse::<i64>().ok()?);
let (y, m, d) = (s.get(0..4)?.parse::<i64>().ok()?, s.get(5..7)?.parse::<i64>().ok()?, s.get(8..10)?.parse::<i64>().ok()?);
let mut secs = days_from_civil(y, m, d)? as f64 * 86_400.0;
let rest = s[10..].trim_start_matches(['T', 't', ' ']);
let rest = s.get(10..)?.trim_start_matches(['T', 't', ' ']);
if !rest.is_empty() {
// Zone: Z or ±hh:mm / ±hhmm.
let (time, zone) = match rest.find(['Z', 'z', '+']).or_else(|| rest.rfind('-')) {
@@ -229,6 +229,12 @@ pub fn specs() -> Vec<CommandSpec> {
mod tests {
use super::*;
#[test]
fn multibyte_date_is_rejected() {
assert_eq!(parse_stamp(&json!("2026-05-0é"), 25.0), None);
assert_eq!(parse_stamp(&json!("2026-05-01é"), 25.0), None);
}
#[test]
fn parses_timestamps() {
let p = |v: Value| parse_stamp(&v, 25.0).unwrap();
+2
View File
@@ -847,10 +847,12 @@ pub(crate) fn disk_stats(s: &mut Session, _: &Value) -> Result<Value> {
"layers": dc.keys(effectcraft_render::disk_cache::Kind::Layer).len(),
"bytes": st.bytes,
"maxBytes": st.max_bytes,
"limitBytes": st.limit_bytes,
"hits": st.hits,
"misses": st.misses,
"writes": st.writes,
"evictions": st.evictions,
"failed": st.failed,
}))
}
+310 -25
View File
@@ -190,6 +190,7 @@ fn pick_color(s: &mut Session, p: &Value) -> Result<Value> {
/// Cryptomatte layer, with the aux channels (for the manifest) and the effect's Selection.
struct CryptoHit {
at: (effectcraft_project::ItemId, LayerId, Tick),
probe: std::sync::Arc<CryptoProbe>,
aux: std::sync::Arc<effectcraft_raster::AuxChannels>,
layer: String,
hash: u32,
@@ -210,13 +211,135 @@ impl CryptoHit {
}
}
/// The ID under `{x, y}` (effect space, which is layer space on footage and precomp layers) for
/// the Cryptomatte effect `{layer?, effect?}`, from the aux channels its render reads. The point
/// snaps to the centre of the pixel the effect's input buffer samples in a render at `scale`
/// (the viewer's resolution).
fn crypto_hit(s: &Session, p: &Value, cmd: &str) -> Result<Option<CryptoHit>> {
/// What a pick at one frame needs besides the point: the aux channels the Cryptomatte effect
/// reads, its layer, and the effect's input buffer grid (scale, offset). Building it can render
/// (a precomp's ID pass, the effects before the Cryptomatte effect); a point lookup in it is a
/// few array reads. The session keeps the last one (see [`Session::crypto_probe_job`]).
pub struct CryptoProbe {
key: ProbeKey,
grid: std::result::Result<ProbeGrid, String>,
}
#[derive(Clone)]
struct ProbeGrid {
aux: std::sync::Arc<effectcraft_raster::AuxChannels>,
layer: String,
scale: f64,
offset: [f64; 2],
}
/// What a probe depends on: the project revision, the effect (and its index), the frame, the
/// scale and the render prefs ([`Session::render_opts`]: nested switches, draft shadows).
#[derive(Clone, Copy, PartialEq, Debug)]
struct ProbeKey {
revision: u64,
at: (effectcraft_project::ItemId, LayerId, Uid, usize),
frame: ProbeFrame,
scale: f64,
prefs: (bool, bool),
}
/// The frame a probe reads. On footage whose masks and effects up to the Cryptomatte effect
/// don't change over time, its source frame: a still has one, held or remapped frames share
/// theirs. Otherwise the comp time.
#[derive(Clone, Copy, PartialEq, Debug)]
enum ProbeFrame {
Source(i64),
Comp(Tick),
}
/// The session's kept probe, and the generation of the latest request: a build stores its
/// result only while no newer request or edit came since.
#[derive(Default)]
pub struct CryptoProbeSlot {
probe: Option<std::sync::Arc<CryptoProbe>>,
want: Option<ProbeKey>,
generation: u64,
/// Jobs run (each on its thread), and those that built (not cancelled before they began).
jobs: u64,
builds: u64,
}
impl CryptoProbeSlot {
/// Drop the kept probe and cancel builds in flight.
pub(crate) fn clear(&mut self) {
(self.probe, self.want) = (None, None);
self.generation += 1;
}
pub(crate) fn kept(&self) -> bool {
self.probe.is_some()
}
pub(crate) fn builds(&self) -> (u64, u64) {
(self.jobs, self.builds)
}
}
/// A probe built off the UI thread into the session's slot.
pub type CryptoProbeJob = Box<dyn FnOnce() + Send>;
const NO_CRYPTO: &str = "this layer has no Cryptomatte channels (a multi-layer OpenEXR with Cryptomatte, or a 3D precomp)";
impl CryptoProbe {
/// `None` when `live` turns false between its stages (a newer request or an edit since):
/// a cancelled build stops early and lets go of what it read.
fn build(r: &effectcraft_render::Renderer, key: ProbeKey, time: Tick, live: &dyn Fn() -> bool) -> Option<CryptoProbe> {
let (cid, lid, _, pos) = key.at;
let build = || -> std::result::Result<Option<ProbeGrid>, String> {
let comp = r.project.comp(cid).ok_or("no composition")?;
let layer = comp.layer(lid).ok_or("no such layer")?;
let g = layer.effects().and_then(|fx| fx.groups().nth(pos)).ok_or("no such effect")?;
let ctx = effectcraft_render::EvalCtx { project: r.project, comp_id: cid, comp, time, expr: r.expr, footage: None };
let params = effectcraft_effects::flatten_params(g, &mut |pr| ctx.value(layer, pr));
let want = effectcraft_effects::CRYPTO_LAYERS.get(params.e("layer") as usize).copied().unwrap_or("CryptoAsset");
let aux = r.layer_aux(&ctx, layer).ok_or(NO_CRYPTO)?;
if !live() {
return Ok(None);
}
let layer_name = aux.resolve_crypto_layer(want).ok_or(NO_CRYPTO)?;
// The grid of the effect's input buffer (footage keeps native pixels from 3/4 scale
// up, proxies have their own size, earlier effects can pad it), rendered and cached.
if !live() {
return Ok(None);
}
let b = r.layer_input(&ctx, layer, pos).ok_or("this layer has no pixels")?;
Ok(Some(ProbeGrid { aux, layer: layer_name, scale: b.scale, offset: b.offset }))
};
// On another thread no command guard runs: a panic becomes the probe's error.
let grid = crate::guard::guarded("Reading the Cryptomatte IDs", || Ok(build())).unwrap_or_else(|e| Err(e.to_string()));
Some(CryptoProbe { key, grid: grid.transpose()? })
}
}
/// Whether a property in `g` other than `skip` changes over time (keyframes or an expression).
fn moves(g: &PropGroup, skip: Option<Uid>) -> bool {
g.children.iter().any(|c| match c {
effectcraft_project::Node::Prop(p) => Some(p.uid) != skip && (p.is_animated() || p.has_expression()),
effectcraft_project::Node::Group(g) => moves(g, skip),
})
}
/// [`ProbeFrame`] of the effect at index `pos` of `layer`.
fn probe_frame(ctx: &effectcraft_render::EvalCtx, layer: &Layer, pos: usize) -> ProbeFrame {
let comp = ProbeFrame::Comp(ctx.time);
let effectcraft_project::LayerSource::Footage { item } = layer.source else { return comp };
let Some(effectcraft_project::ItemKind::Footage(f)) = ctx.project.item(item).map(|i| &i.kind) else { return comp };
let fx = layer.effects().into_iter().flat_map(|fx| fx.groups().take(pos + 1));
let time_fx = |g: &PropGroup| matches!(&g.kind, GroupKind::Effect { effect } if effect.starts_with("ec.time."));
let still = f.kind == effectcraft_project::FootageKind::Still;
let blend = !still && ctx.comp.enable_frame_blending && layer.switches.frame_blend != effectcraft_project::FrameBlend::Off;
// The Cryptomatte effect's own Selection isn't read by the probe: animated, it keeps it.
let own = |i: usize, g: &PropGroup| if i == pos { g.prop("selection").map(|p| p.uid) } else { None };
if blend || layer.masks().is_some_and(|m| moves(m, None)) || fx.enumerate().any(|(i, g)| moves(g, own(i, g)) || time_fx(g)) {
return comp;
}
ProbeFrame::Source(if still { 0 } else { effectcraft_render::frame_position(ctx.source_time(layer), f.frame_rate).0 })
}
/// The Cryptomatte effect `{layer?, effect?, scale?, time?}` names: its probe key, the comp
/// time, its layer, group and Selection property.
#[allow(clippy::type_complexity)]
fn probe_key<'a>(s: &'a Session, p: &Value, cmd: &str) -> Result<(ProbeKey, Tick, &'a Layer, &'a PropGroup, &'a effectcraft_project::Property)> {
let (cid, lid, fx_uid) = find_fx(s, p, cmd)?;
let (Some(x), Some(y)) = (f_p(p, "x"), f_p(p, "y")) else { return Err(bad(cmd, "missing `x` / `y` (layer pixels)")) };
let scale = f_p(p, "scale").unwrap_or(1.0);
if !(scale.is_finite() && scale > 0.0 && scale <= 1.0) {
return Err(bad(cmd, "`scale` is the preview's resolution factor, above 0 and at most 1"));
@@ -227,30 +350,175 @@ fn crypto_hit(s: &Session, p: &Value, cmd: &str) -> Result<Option<CryptoHit>> {
let pos = fx.groups().position(|g| g.uid == fx_uid).ok_or_else(|| bad(cmd, "no such effect"))?;
let g = fx.groups().nth(pos).ok_or_else(|| bad(cmd, "no such effect"))?;
let prop = g.prop("selection").filter(|_| g.match_id == "ec.3d.cryptomatte").ok_or_else(|| bad(cmd, "not a Cryptomatte effect"))?;
let t = super::time_p(s, p, Some(comp));
let ctx = effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref(), footage: None };
let params = effectcraft_effects::flatten_params(g, &mut |pr| ctx.value(layer, pr));
let r = s.renderer(effectcraft_render::RenderOpts { scale, ..Default::default() });
let aux = r.layer_aux(&ctx, layer);
let want = effectcraft_effects::CRYPTO_LAYERS.get(params.e("layer") as usize).copied().unwrap_or("CryptoAsset");
let Some((aux, layer_name)) = aux.and_then(|a| a.resolve_crypto_layer(want).map(|l| (a, l))) else {
return Err(bad(cmd, "this layer has no Cryptomatte channels (a multi-layer OpenEXR with Cryptomatte, or a 3D precomp)"));
let time = super::time_p(s, p, Some(comp));
let ctx = effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time, expr: s.expr.as_deref(), footage: None };
let frame = probe_frame(&ctx, layer, pos);
let o = s.render_opts(Default::default());
let prefs = (o.nested_switches, o.draft_shadows);
Ok((ProbeKey { revision: s.revision, at: (cid, lid, fx_uid, pos), frame, scale, prefs }, time, layer, g, prop))
}
/// The kept probe if it answers for the current revision, for [`keep_crypto_probe`] (taken
/// before an undo, redo or history jump).
pub(crate) fn kept_crypto_probe(s: &Session) -> Option<std::sync::Arc<CryptoProbe>> {
s.crypto_probe.lock().ok()?.probe.clone().filter(|p| p.key.revision == s.revision)
}
/// After an undo, redo or history jump from `before` (which dropped the probe): `kept`, the
/// probe from before, still answers when only Cryptomatte Selections changed, which the probe
/// doesn't read (undoing a pick, as the pick itself keeps it).
pub(crate) fn keep_crypto_probe(s: &Session, kept: Option<std::sync::Arc<CryptoProbe>>, before: &effectcraft_project::Project) {
let Some(p) = kept else { return };
if !only_crypto_selections_differ(before, &s.project) {
return;
}
if let Ok(mut slot) = s.crypto_probe.lock()
&& slot.probe.is_none()
{
slot.probe = Some(std::sync::Arc::new(CryptoProbe { key: ProbeKey { revision: s.revision, ..p.key }, grid: p.grid.clone() }));
}
}
/// Whether projects `a` and `b` differ at most in the Selections of Cryptomatte effects.
fn only_crypto_selections_differ(a: &effectcraft_project::Project, b: &effectcraft_project::Project) -> bool {
let mut x = a.clone();
for (cid, it) in x.items.iter_mut() {
let effectcraft_project::ItemKind::Comp(c) = &mut it.kind else { continue };
let sels: Vec<(LayerId, Uid)> = c
.layers
.iter()
.flat_map(|l| {
l.effects()
.into_iter()
.flat_map(|fx| fx.groups())
.filter(|g| g.match_id == "ec.3d.cryptomatte")
.filter_map(move |g| g.get("selection").map(|p| (l.id, p.uid)))
})
.collect();
if sels.is_empty() {
continue;
}
let Some(bc) = b.comp(*cid) else { return false };
let c = std::sync::Arc::make_mut(c);
for (lid, uid) in sels {
let Some(src) = bc.layer(lid).and_then(|l| l.props.find(uid)) else { return false };
if let Some(dst) = c.layer_mut(lid).and_then(|l| l.props.find_mut(uid)) {
*dst = src.clone();
}
}
}
x.same_content(b)
}
/// The session's probe when it has `key`'s.
fn cached_probe(s: &Session, key: ProbeKey) -> Option<std::sync::Arc<CryptoProbe>> {
s.crypto_probe.lock().ok()?.probe.clone().filter(|p| p.key == key)
}
/// [`Session::crypto_probe_ready`].
pub(crate) fn crypto_probe_ready(s: &Session, p: &Value) -> Result<bool> {
let (key, ..) = probe_key(s, p, "effect.cryptomatteAt")?;
Ok(cached_probe(s, key).is_some())
}
/// [`Session::crypto_probe_job`].
pub(crate) fn crypto_probe_job(s: &Session, p: &Value) -> Result<Option<CryptoProbeJob>> {
let (key, time, ..) = probe_key(s, p, "effect.cryptomatteAt")?;
let generation = {
let mut slot = s.crypto_probe.lock().map_err(|_| EngineError::Other("the Cryptomatte probe is unavailable".into()))?;
if slot.probe.as_ref().is_some_and(|p| p.key == key) {
return Ok(None);
}
// A request for another probe cancels the one in flight.
if slot.want != Some(key) {
(slot.want, slot.generation) = (Some(key), slot.generation + 1);
}
slot.generation
};
// The grid of the effect's input buffer (footage keeps native pixels from 3/4 scale up,
// proxies have their own size, earlier effects can pad it), rendered and cached.
let b = r.layer_input(&ctx, layer, pos).ok_or_else(|| bad(cmd, "this layer has no pixels"))?;
let (project, footage, expr, cache, accel, slot) =
(s.project.clone(), s.footage.clone(), s.expr.clone(), s.layer_cache.clone(), s.accel.clone(), s.crypto_probe.clone());
let opts = s.render_opts(effectcraft_render::RenderOpts { scale: key.scale, ..Default::default() });
// The slot while this is still the newest request.
fn current(slot: &std::sync::Mutex<CryptoProbeSlot>, generation: u64) -> Option<std::sync::MutexGuard<'_, CryptoProbeSlot>> {
slot.lock().ok().filter(|s| s.generation == generation)
}
Ok(Some(Box::new(move || {
match slot.lock() {
Ok(mut s) if s.generation == generation => (s.jobs, s.builds) = (s.jobs + 1, s.builds + 1),
Ok(mut s) => return s.jobs += 1,
Err(_) => return,
}
let mut r = effectcraft_render::Renderer::new(&project, footage.as_ref(), opts);
r.expr = expr.as_deref();
r.cache = Some(&cache);
r.accel = accel.as_deref();
// Checked between its stages too: a superseded build stops at the next one.
let Some(probe) = CryptoProbe::build(&r, key, time, &|| current(&slot, generation).is_some()) else { return };
let probe = std::sync::Arc::new(probe);
// Only the newest request's result, and never over a newer probe.
if let Some(mut s) = current(&slot, generation)
&& s.probe.as_ref().is_none_or(|p| p.key.revision <= key.revision)
{
s.probe = Some(probe);
}
})))
}
/// What a pick finds at a point.
enum Hit {
/// `probeOnly` and the probe isn't built yet.
Pending,
Nothing,
Id(CryptoHit),
}
/// The ID under `{x, y}` (effect space, which is layer space on footage and precomp layers) for
/// the Cryptomatte effect `{layer?, effect?}`, from the aux channels its render reads. The point
/// snaps to the centre of the pixel the effect's input buffer samples in a render at `scale`
/// (the viewer's resolution). Without a kept probe it builds one, unless `probeOnly`.
fn crypto_hit(s: &Session, p: &Value, cmd: &str) -> Result<Hit> {
let (key, time, layer, g, prop) = probe_key(s, p, cmd)?;
let (Some(x), Some(y)) = (f_p(p, "x"), f_p(p, "y")) else { return Err(bad(cmd, "missing `x` / `y` (layer pixels)")) };
let probe = match cached_probe(s, key) {
Some(probe) => probe,
None if b_p(p, "probeOnly").unwrap_or(false) => return Ok(Hit::Pending),
None => {
let r = s.renderer(effectcraft_render::RenderOpts { scale: key.scale, ..Default::default() });
if let Ok(mut slot) = s.crypto_probe.lock() {
slot.clear();
slot.builds += 1;
}
// (Never cancelled here: nothing else runs on this thread.)
let Some(probe) = CryptoProbe::build(&r, key, time, &|| true).map(std::sync::Arc::new) else { return Ok(Hit::Pending) };
if let Ok(mut slot) = s.crypto_probe.lock() {
slot.probe = Some(probe.clone());
}
probe
}
};
let b = probe.grid.as_ref().map_err(|e| bad(cmd, e.clone()))?;
let (bs, o) = (b.scale, b.offset);
// The centre of its pixel under the point, at the aux point the effect reads there.
let [ax, ay] = effectcraft_effects::aux_point(bs, o, [(x * bs + o[0]).floor() + 0.5, (y * bs + o[1]).floor() + 0.5]);
let Some(hash) = aux.index_at(ax, ay).and_then(|i| aux.crypto_id_at(&layer_name, i)) else { return Ok(None) };
let (selection, driven) = (params.s("selection").to_string(), prop.expr.as_ref().is_some_and(|e| e.enabled));
Ok(Some(CryptoHit { at: (cid, lid, t), aux, layer: layer_name, hash, selection, prop: prop.uid, driven }))
let Some(hash) = b.aux.index_at(ax, ay).and_then(|i| b.aux.crypto_id_at(&b.layer, i)) else { return Ok(Hit::Nothing) };
let (cid, lid, ..) = key.at;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let ctx = effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time, expr: s.expr.as_deref(), footage: None };
let selection = effectcraft_effects::flatten_params(g, &mut |pr| ctx.value(layer, pr)).s("selection").to_string();
let driven = prop.expr.as_ref().is_some_and(|e| e.enabled);
let (aux, layer_name) = (b.aux.clone(), b.layer.clone());
Ok(Hit::Id(CryptoHit { at: (cid, lid, time), probe: probe.clone(), aux, layer: layer_name, hash, selection, prop: prop.uid, driven }))
}
/// `effect.cryptomatteAt`: the ID under a point, for the viewer's hover hint. The first call per
/// frame renders the effect's input to learn its pixel grid (cached afterwards).
/// frame renders the effect's input to learn its pixel grid (kept afterwards); with `probeOnly`
/// it answers `{pending: true}` instead.
fn crypto_at(s: &mut Session, p: &Value) -> Result<Value> {
Ok(crypto_hit(s, p, "effect.cryptomatteAt")?.map_or(Value::Null, |h| h.reply(&h.selection)))
Ok(match crypto_hit(s, p, "effect.cryptomatteAt")? {
Hit::Pending => json!({"pending": true}),
Hit::Nothing => Value::Null,
Hit::Id(h) => h.reply(&h.selection),
})
}
/// The Cryptomatte eyedropper: replace the effect's Selection with the ID under a point, add it
@@ -264,7 +532,11 @@ fn pick_crypto(s: &mut Session, p: &Value) -> Result<Value> {
"remove" => PickMode::Remove,
m => return Err(bad(cmd, format!("unknown `mode` {m:?} (replace, add or remove)"))),
};
let h = crypto_hit(s, p, cmd)?.ok_or_else(|| bad(cmd, "no Cryptomatte ID there (empty or outside the layer)"))?;
let h = match crypto_hit(s, p, cmd)? {
Hit::Id(h) => h,
Hit::Pending => return Ok(json!({"pending": true, "changed": false})),
Hit::Nothing => return Err(bad(cmd, "no Cryptomatte ID there (empty or outside the layer)")),
};
if h.driven {
return Err(bad(cmd, "the Selection is driven by an expression; disable it to pick"));
}
@@ -274,6 +546,19 @@ fn pick_crypto(s: &mut Session, p: &Value) -> Result<Value> {
if changed {
let (cid, lid, t) = h.at;
s.execute("prop.set", json!({"comp": cid.0, "layer": lid.0, "prop": h.prop, "value": sel, "time": t.seconds(), "timeBase": "comp"}))?;
// The step says what made it (Edit ▸ Undo Pick Cryptomatte, the History panel), not
// "Change Property" like any other edit. (A changed Selection is always a new step.)
if let Some(step) = s.history.undo.last_mut() {
step.0 = "Pick Cryptomatte".into();
}
// Only the Selection changed, which the probe doesn't read: the next hover keeps it.
// (The edit dropped it; a probe built since is newer.)
if let Ok(mut slot) = s.crypto_probe.lock()
&& slot.probe.is_none()
{
let key = ProbeKey { revision: s.revision, ..h.probe.key };
slot.probe = Some(std::sync::Arc::new(CryptoProbe { key, grid: h.probe.grid.clone() }));
}
}
let mut out = h.reply(&sel);
out["selection"] = json!(sel);
@@ -732,14 +1017,14 @@ pub fn specs() -> Vec<CommandSpec> {
"Pick Cryptomatte",
[],
None,
"{layer?, effect?: index|uid|name, x, y (effect space), mode?: replace|add|remove (default replace), scale? (preview resolution, default 1), time?} → {name, hash, layer, selection, changed, selected} — edits the Cryptomatte effect's Selection with the ID under the point",
"{layer?, effect?: index|uid|name, x, y (effect space), mode?: replace|add|remove (default replace), scale? (preview resolution, default 1), time?, probeOnly?} → {name, hash, layer, selection, changed, selected} | {pending} — edits the Cryptomatte effect's Selection with the ID under the point",
has_layers,
pick_crypto
),
crate::query!(
"effect.cryptomatteAt",
"Cryptomatte At",
"{layer?, effect?: index|uid|name, x, y (effect space), scale?, time?} → {name, hash, layer, selected} | null — the Cryptomatte ID under the point",
"{layer?, effect?: index|uid|name, x, y (effect space), scale?, time?, probeOnly?: bool (don't build the probe)} → {name, hash, layer, selected} | {pending} | null — the Cryptomatte ID under the point",
crypto_at
),
cmd!("effect.applyLast", "Last Effect", ["Effect"], Some("Cmd+Alt+Shift+E"), "{layers?}", has_layers, last),
+33 -2
View File
@@ -430,6 +430,20 @@ fn collect_files(s: &mut Session, p: &Value) -> Result<Value> {
/// array or JSON lines of `{"command": id, "params": {…}}` (also accepts the control channel's
/// `{"method": "engine.execute", "params": {"id", "params"}}`).
fn run_script(s: &mut Session, p: &Value) -> Result<Value> {
// File ▸ Scripts ▸ Recent Script Files ▸ <script> (0 is the most recent).
if let Some(i) = p.get("recent") {
let i = i.as_u64().ok_or_else(|| bad("file.runScript", format!("`recent` must be an index, not {i}")))?;
let script = match s.prefs.recent_scripts.get(usize::try_from(i).unwrap_or(usize::MAX)) {
Some(x) => x.clone(),
None if s.prefs.recent_scripts.is_empty() => return Err(bad("file.runScript", "no recent script files")),
None => return Err(bad("file.runScript", format!("no recent script file #{i}"))),
};
let q = match script {
crate::prefs::RecentScript::Path(path) => json!({"path": path}),
crate::prefs::RecentScript::Name(name) => json!({"name": name}),
};
return run_script(s, &q);
}
// File ▸ Scripts ▸ <installed or sample script>.
if let Some(name) = str_p(p, "name")
&& p.get("path").is_none()
@@ -446,15 +460,22 @@ fn run_script(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad("file.runScript", JSXBIN));
}
let bytes = s.services.read_file(path).map_err(|e| EngineError::Other(format!("cannot read {path}: {e}")))?;
let full = s.services.full_path(path);
let code = String::from_utf8_lossy(&bytes).to_string();
let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string());
let out = run_js(s, &code, path, false)?;
return report_js(s, &name, out);
let result = report_js(s, &name, out)?;
if let Some(full) = full {
remember_script(s, crate::prefs::RecentScript::Path(full));
}
return Ok(result);
}
let mut script_path = None;
let steps: Vec<Value> = match (p.get("steps"), str_p(p, "path")) {
(Some(Value::Array(a)), _) => a.clone(),
(_, Some(path)) => {
let bytes = s.services.read_file(path).map_err(|e| EngineError::Other(format!("cannot read {path}: {e}")))?;
script_path = s.services.full_path(path);
let text = String::from_utf8_lossy(&bytes).to_string();
match serde_json::from_str::<Value>(&text) {
Ok(Value::Array(a)) => a,
@@ -487,9 +508,19 @@ fn run_script(s: &mut Session, p: &Value) -> Result<Value> {
let r = r.map_err(|e| EngineError::Other(format!("step {} ({id}): {e}", i + 1)))?;
results.push(r);
}
if let Some(path) = script_path {
remember_script(s, crate::prefs::RecentScript::Path(path));
}
Ok(json!({"steps": results.len(), "results": results}))
}
/// Remember a script that runs for File ▸ Scripts ▸ Recent Script Files.
pub(super) fn remember_script(s: &mut Session, script: crate::prefs::RecentScript) {
s.prefs.push_recent_script(script);
s.save_prefs();
s.prefs_revision += 1;
}
/// `.jsx` / `.js` (and `.jsxbin`, rejected) are JavaScript; `.json` / `.jsonl` / other files are
/// command scripts.
fn is_js_script(path: &str) -> bool {
@@ -1168,7 +1199,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Run Script File...",
["File", "Scripts"],
None,
"{path (.jsx/.js JavaScript, or a .jsonl/.json command script) | name (an installed or sample script, see file.scripts.list) | steps: [{command, params}]}",
"{path (.jsx/.js JavaScript, or a .jsonl/.json command script) | name (an installed or sample script, see file.scripts.list) | recent: index (File ▸ Scripts ▸ Recent Script Files, 0 = the most recent) | steps: [{command, params}]}",
always,
run_script
),
+176 -3
View File
@@ -235,13 +235,17 @@ fn uninstall(s: &mut Session, p: &Value) -> Result<Value> {
}
/// Run an installed or bundled script by name (File ▸ Scripts ▸ `<name>`). A ScriptUI panel run
/// this way gets `this` = a floating palette-like panel window.
/// this way gets `this` = a floating palette-like panel window. A script listed under File ▸
/// Scripts runs before a panel of the same name.
pub(crate) fn run_named(s: &mut Session, name: &str) -> Result<Value> {
let (panel, code) = script_code(s, name, None).ok_or_else(|| bad("file.runScript", format!("no installed or bundled script `{name}`")))?;
let found = script_code(s, name, Some(false)).or_else(|| script_code(s, name, None));
let (panel, code) = found.ok_or_else(|| bad("file.runScript", format!("no installed or bundled script `{name}`")))?;
if panel {
return open_panel(s, &json!({"name": name}));
}
super::file_more::run_js_named(s, &code, name)
let result = super::file_more::run_js_named(s, &code, name)?;
super::file_more::remember_script(s, crate::prefs::RecentScript::Name(name.to_string()));
Ok(result)
}
/// The code a ScriptUI panel runs with: `this` is a dockable Panel titled after the script.
@@ -553,6 +557,175 @@ mod tests {
let _ = std::fs::remove_dir_all(&root);
}
/// File ▸ Scripts ▸ Recent Script Files: the ten scripts run last, newest first, kept in the
/// settings; Cmd+Alt+Shift+D runs the first one again.
#[test]
fn recent_script_files_run_again() {
use crate::prefs::{MAX_RECENT_SCRIPTS, PREFS_FILE, Prefs, RecentScript};
let (mut s, root) = session_in_tmp("recent");
assert_eq!(crate::menus::dynamic(&s, "recentScripts", &Default::default()), (vec![], Some("No Recent Script Files")));
assert!(bad_params(&s.execute("file.runScript", json!({"recent": 0}))));
assert_eq!(s.shortcuts().shortcut_of("file.runScript", &json!({"recent": 0})), Some("Cmd+Alt+Shift+D"));
let tool = root.join("Tool.jsx");
std::fs::write(&tool, "tool").unwrap();
let tool = tool.to_string_lossy().into_owned();
s.execute("file.runScript", json!({"path": tool})).unwrap();
s.execute("file.runScript", json!({"name": "Rename Layers.jsx"})).unwrap();
// A ScriptUI panel opened from File ▸ Scripts is not a recent script file.
std::fs::write(root.join("settings").join(PANELS_DIR).join("Panel.jsx"), "panel").unwrap();
s.execute("file.runScript", json!({"name": "Panel.jsx"})).unwrap();
let (path, name) = (RecentScript::Path(tool.clone()), RecentScript::Name("Rename Layers.jsx".into()));
assert_eq!(s.prefs.recent_scripts, [name.clone(), path.clone()]);
let (e, _) = crate::menus::dynamic(&s, "recentScripts", &Default::default());
assert_eq!(e.iter().map(|e| e.label.as_str()).collect::<Vec<_>>(), ["Rename Layers.jsx", "Tool.jsx"]);
assert_eq!((e[1].command.as_str(), &e[1].params), ("file.runScript", &json!({"recent": 1})));
// Running one again runs that file and moves it to the top.
assert_eq!(s.execute("file.runScript", json!({"recent": 1})).unwrap()["ran"], "tool");
assert_eq!(s.prefs.recent_scripts, [path.clone(), name.clone()]);
let sample = BUNDLED.iter().find(|b| b.0 == "Rename Layers.jsx").unwrap().3;
assert_eq!(s.execute("file.runScript", json!({"recent": 1})).unwrap()["ran"], sample);
assert!(bad_params(&s.execute("file.runScript", json!({"recent": 2}))));
assert!(bad_params(&s.execute("file.runScript", json!({"recent": "Tool.jsx"}))));
// A file named like a bundled script runs again as that file.
let local = root.join("Rename Layers.jsx");
std::fs::write(&local, "local").unwrap();
s.execute("file.runScript", json!({"path": local.to_string_lossy()})).unwrap();
assert_eq!(s.execute("file.runScript", json!({"recent": 0})).unwrap()["ran"], "local");
assert_eq!(s.execute("file.runScript", json!({"recent": 1})).unwrap()["ran"], sample);
// Ten at most, saved with the settings.
let mut last = String::new();
for i in 0..MAX_RECENT_SCRIPTS + 2 {
let f = root.join(format!("Step {i}.jsonl"));
std::fs::write(&f, "").unwrap();
last = f.to_string_lossy().into_owned();
s.execute("file.runScript", json!({"path": last})).unwrap();
}
assert_eq!(s.prefs.recent_scripts.len(), MAX_RECENT_SCRIPTS);
assert_eq!(s.prefs.recent_scripts[0], RecentScript::Path(last));
let saved = std::fs::read_to_string(root.join("settings").join(PREFS_FILE)).unwrap();
assert_eq!(Prefs::from_json(&saved).recent_scripts, s.prefs.recent_scripts);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn recent_script_history_uses_the_selected_script() {
let (mut s, root) = session_in_tmp("recent-panel-collision");
let name = "Rename Layers.jsx";
std::fs::write(root.join("settings").join(PANELS_DIR).join(name), "panel").unwrap();
assert!(super::scripts(&s).iter().any(|entry| entry.name == name && !entry.panel));
assert!(script_code(&s, name, None).unwrap().0);
let sample = BUNDLED.iter().find(|b| b.0 == name).unwrap().3;
assert_eq!(s.execute("file.runScript", json!({"name": name})).unwrap()["ran"], sample);
assert!(super::open_panel_names(&s).is_empty(), "the listed script runs instead of the panel");
assert_eq!(s.prefs.recent_scripts, [crate::prefs::RecentScript::Name(name.to_string())]);
let _ = std::fs::remove_dir_all(root);
}
#[test]
fn recent_script_history_keeps_successful_entries_when_scripts_fail() {
fn fail_or_echo(_: &mut Session, request: &ScriptRequest) -> Value {
if request.code == "fail" { json!({"error": {"message": "script failed"}}) } else { json!({"ran": request.code}) }
}
let (mut s, root) = session_in_tmp("recent-failed");
s.script = Some(fail_or_echo);
let good = root.join("good.jsonl");
std::fs::write(&good, "[]").unwrap();
s.execute("file.runScript", json!({"path": good})).unwrap();
let remembered = s.prefs.recent_scripts.clone();
for (name, code) in [("parse.jsonl", "{"), ("step.jsonl", r#"{"command":"does.not.exist"}"#), ("missing-command.jsonl", "{}"), ("runtime.jsx", "fail")]
{
let path = root.join(name);
std::fs::write(&path, code).unwrap();
assert!(s.execute("file.runScript", json!({"path": path})).is_err(), "{name}");
assert_eq!(s.prefs.recent_scripts, remembered, "{name}");
}
std::fs::write(root.join("settings").join(SCRIPTS_DIR).join("Failed.jsx"), "fail").unwrap();
assert!(s.execute("file.runScript", json!({"name": "Failed.jsx"})).is_err());
assert_eq!(s.prefs.recent_scripts, remembered);
let _ = std::fs::remove_dir_all(root);
}
/// Reads relative paths from a working directory that a test can change.
struct InFolder(std::sync::Mutex<PathBuf>);
impl InFolder {
fn at(&self, path: &str) -> PathBuf {
self.0.lock().unwrap().join(path)
}
}
impl crate::Services for InFolder {
fn read_file(&self, path: &str) -> std::io::Result<Vec<u8>> {
std::fs::read(self.at(path))
}
fn write_file(&self, path: &str, data: &[u8]) -> std::io::Result<()> {
std::fs::write(self.at(path), data)
}
fn full_path(&self, path: &str) -> Option<String> {
Some(self.at(path).to_string_lossy().into_owned())
}
}
/// Reads from a folder but can't make paths absolute.
struct NoFullPath(PathBuf);
impl crate::Services for NoFullPath {
fn read_file(&self, path: &str) -> std::io::Result<Vec<u8>> {
std::fs::read(self.0.join(path))
}
fn write_file(&self, path: &str, data: &[u8]) -> std::io::Result<()> {
std::fs::write(self.0.join(path), data)
}
fn full_path(&self, _path: &str) -> Option<String> {
None
}
}
/// A script whose path can't be made absolute still runs, and Recent Script Files does not
/// keep its relative path.
#[test]
fn unresolved_script_paths_are_not_remembered() {
let (mut s, root) = session_in_tmp("recent-unresolved");
std::fs::write(root.join("Tool.jsx"), "a").unwrap();
std::fs::write(root.join("steps.jsonl"), "[]").unwrap();
s.services = Arc::new(NoFullPath(root.clone()));
assert_eq!(s.execute("file.runScript", json!({"path": "Tool.jsx"})).unwrap()["ran"], "a");
s.execute("file.runScript", json!({"path": "steps.jsonl"})).unwrap();
assert!(s.prefs.recent_scripts.is_empty(), "{:?}", s.prefs.recent_scripts);
let _ = std::fs::remove_dir_all(&root);
}
/// A script run from a relative path runs the same file again from Recent Script Files after
/// the working directory changes, and two scripts with the same file name show their paths.
#[test]
fn recent_script_files_keep_the_file_that_ran() {
let (mut s, root) = session_in_tmp("recent-relative");
let (a, b) = (root.join("a"), root.join("b"));
for (dir, code) in [(&a, "a"), (&b, "b")] {
std::fs::create_dir_all(dir).unwrap();
std::fs::write(dir.join("Tool.jsx"), code).unwrap();
}
let services = Arc::new(InFolder(std::sync::Mutex::new(a.clone())));
s.services = services.clone();
assert_eq!(s.execute("file.runScript", json!({"path": "Tool.jsx"})).unwrap()["ran"], "a");
*services.0.lock().unwrap() = b.clone();
assert_eq!(s.execute("file.runScript", json!({"recent": 0})).unwrap()["ran"], "a");
let (e, _) = crate::menus::dynamic(&s, "recentScripts", &Default::default());
assert_eq!(e.iter().map(|e| e.label.as_str()).collect::<Vec<_>>(), ["Tool.jsx"]);
assert_eq!(s.execute("file.runScript", json!({"path": "Tool.jsx"})).unwrap()["ran"], "b");
let (e, _) = crate::menus::dynamic(&s, "recentScripts", &Default::default());
let (a, b) = (a.join("Tool.jsx").to_string_lossy().into_owned(), b.join("Tool.jsx").to_string_lossy().into_owned());
assert_eq!(e.iter().map(|e| e.label.as_str()).collect::<Vec<_>>(), [b.as_str(), a.as_str()]);
let _ = std::fs::remove_dir_all(&root);
}
/// Native script paths are kept absolute.
#[test]
fn native_full_paths_are_absolute() {
use crate::Services;
assert!(crate::FsServices.full_path("Tool.jsx").is_some_and(|p| std::path::Path::new(&p).is_absolute()));
}
#[test]
fn only_scripts_are_read_or_removed() {
// A settings file in the Scripts folder that is not a script (.jsx / .js).
+2
View File
@@ -345,6 +345,7 @@ impl Session {
let undo: Vec<(String, Arc<Project>)> = (0..k).map(|i| (a.label[new_line[i + 1]].clone(), a.state[new_line[i]].clone())).collect();
let redo: Vec<(String, Arc<Project>)> = new_line[k + 1..].iter().rev().map(|&i| (a.label[i].clone(), a.state[i].clone())).collect();
let edited = crate::commands::text_edit::active_editor_snapshot(self);
let probe = crate::commands::kept_crypto_probe(self);
let cur = std::mem::replace(&mut self.project, a.state[target].clone());
self.keep_view_settings(&cur);
self.history.undo = undo;
@@ -353,6 +354,7 @@ impl Session {
self.history.merge_key = None;
self.sanitize_after_replacement(edited.as_ref());
self.bump();
crate::commands::keep_crypto_probe(self, probe, &cur);
true
}
}
+8
View File
@@ -162,6 +162,10 @@ pub struct Session {
pub journal: Vec<(String, Value)>,
/// Processed-layer pixels reused across frames and edits (content-keyed, never stale).
pub layer_cache: Arc<LayerCache>,
/// The last Cryptomatte pick probe (see [`Session::crypto_probe_job`]). Public only so that
/// other crates can build a `Session { .. }`; its type is crate-private.
#[doc(hidden)]
pub crypto_probe: Arc<std::sync::Mutex<commands::CryptoProbeSlot>>,
/// The persistent disk cache (Settings ▸ Media & Disk Cache), when enabled.
pub disk_cache: Option<Arc<effectcraft_render::disk_cache::DiskCache>>,
/// Settings (Preferences).
@@ -202,6 +206,8 @@ pub struct Session {
pub key_move: Option<KeyMove>,
/// Reads the system's memory in the background for [`Session::memory_tick`].
pub memory_watch: sysinfo::MemoryWatch,
/// Reads the disk cache's free disk space in the background for [`Session::memory_tick`].
pub disk_watch: sysinfo::DiskWatch,
/// Switches Affect Nested Comps as last applied (a change empties the layer cache).
pub applied_nested_switches: Option<bool>,
/// Runs renders and analyses off the UI thread where there are no threads (the web app's
@@ -310,6 +316,7 @@ impl Default for Session {
events: vec![],
journal: vec![],
layer_cache: Arc::new(LayerCache::default()),
crypto_probe: Default::default(),
disk_cache: None,
prefs: prefs::Prefs::default(),
prefs_revision: 0,
@@ -327,6 +334,7 @@ impl Default for Session {
sys_memory: None,
key_move: None,
memory_watch: Default::default(),
disk_watch: Default::default(),
applied_nested_switches: None,
offload: None,
offloaded: vec![],
+25 -9
View File
@@ -61,6 +61,12 @@ fn file_name(p: &str) -> String {
std::path::Path::new(p).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| p.to_string())
}
/// The labels of a recent-files list: `names[i]`, or the whole of `full[i]` when another entry
/// has the same name, so entries for two files named `Tool.jsx` tell them apart.
fn distinct_labels(names: Vec<String>, full: &[String]) -> Vec<String> {
names.iter().zip(full).map(|(n, f)| if names.iter().filter(|m| *m == n).count() > 1 { f.clone() } else { n.clone() }).collect()
}
fn dyn_entry(label: String, command: &str, params: Value) -> MenuEntry {
MenuEntry { label, command: command.into(), params, shortcut: None, alt_shortcuts: vec![] }
}
@@ -72,26 +78,34 @@ fn holder_of(s: &Session, keys: &str) -> Option<String> {
}
/// The entries of a `@dynamic:<name>` node, and the disabled placeholder shown when there are
/// none: `recentProjects`, `recentFootage`, `recentPresets`, `history` (undo steps, newest
/// none: `recentProjects`, `recentFootage`, `recentPresets`, `recentScripts` (File ▸ Scripts ▸
/// Recent Script Files), `history` (undo steps, newest
/// first), `view3dShortcuts`, `workspaceShortcuts`, `savedWorkspaces` (Window ▸ Workspace) and
/// `openViewers` (open compositions other than the active one), `scripts` (File ▸ Scripts) and
/// `scriptPanels` (ScriptUI panels).
pub fn dynamic(s: &Session, name: &str, cx: &DynCtx) -> (Vec<MenuEntry>, Option<&'static str>) {
use serde_json::json;
let indexed = |list: &[String], cmd: &str, stem: bool| -> Vec<MenuEntry> {
list.iter()
.enumerate()
.map(|(i, p)| {
let n =
if stem { std::path::Path::new(p).file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| p.clone()) } else { file_name(p) };
dyn_entry(n, cmd, json!({"index": i}))
})
.collect()
let names = list
.iter()
.map(
|p| if stem { std::path::Path::new(p).file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| p.clone()) } else { file_name(p) },
)
.collect();
distinct_labels(names, list).into_iter().enumerate().map(|(i, n)| dyn_entry(n, cmd, json!({"index": i}))).collect()
};
match name {
"recentProjects" => (indexed(&s.prefs.recent_projects, "file.openRecent", false), Some("No Recent Projects")),
"recentFootage" => (indexed(&s.prefs.recent_footage, "file.importRecent", false), Some("No Recent Footage")),
"recentPresets" => (indexed(&s.prefs.recent_presets, "anim.applyRecentPreset", true), Some("No Recent Presets")),
"recentScripts" => {
let full: Vec<String> = s.prefs.recent_scripts.iter().map(|r| r.text().to_string()).collect();
let names = full.iter().map(|p| file_name(p)).collect();
(
distinct_labels(names, &full).into_iter().enumerate().map(|(i, n)| dyn_entry(n, "file.runScript", json!({"recent": i}))).collect(),
Some("No Recent Script Files"),
)
}
"history" => (
s.history.undo.iter().rev().enumerate().map(|(i, (l, _))| dyn_entry(format!("Undo {l}"), "edit.history", json!({"steps": i + 1}))).collect(),
Some("No History"),
@@ -576,6 +590,8 @@ File
Install Script File... | file.installScript
Install ScriptUI Panel... | file.installScriptUIPanel
Run Script File... | file.runScript
Recent Script Files
@dynamic:recentScripts
---
Create Proxy
Still... | file.createProxy {"kind":"still"}
+7
View File
@@ -37,6 +37,13 @@ pub trait Services: Send + Sync {
fn is_dir(&self, path: &str) -> bool {
std::path::Path::new(path).is_dir()
}
/// `path` as a list of recent files keeps it (File ▸ Scripts ▸ Recent Script Files): a
/// relative path is made absolute against the working directory, so it opens the same file
/// after the working directory changes. `None` when the path can't be made absolute (the
/// working directory can't be read); the script is then not added to the list.
fn full_path(&self, path: &str) -> Option<String> {
std::path::absolute(path).ok().map(|p| p.to_string_lossy().into_owned())
}
}
/// Native filesystem.
+14 -3
View File
@@ -91,6 +91,8 @@ impl Session {
pub fn bump(&mut self) {
self.revision += 1;
// A Cryptomatte probe is keyed by the revision: an older one never answers again.
self.drop_crypto_probe();
// Back at the saved state (undo, redo, a history jump or a rolled-back batch).
if Arc::ptr_eq(&self.project, &self.saved_project) {
self.saved_revision = self.revision;
@@ -166,12 +168,14 @@ impl Session {
pub fn undo(&mut self) -> bool {
let Some((label, p)) = self.history.undo.pop() else { return false };
let edited = commands::text_edit::active_editor_snapshot(self);
let probe = commands::kept_crypto_probe(self);
let cur = std::mem::replace(&mut self.project, p);
self.keep_view_settings(&cur);
self.history.redo.push((label, cur));
self.history.merge_key = None;
self.sanitize_after_replacement(edited.as_ref());
self.bump();
commands::keep_crypto_probe(self, probe, &cur);
self.history.redo.push((label, cur));
true
}
@@ -182,12 +186,14 @@ impl Session {
pub fn redo(&mut self) -> bool {
let Some((label, p)) = self.history.redo.pop() else { return false };
let edited = commands::text_edit::active_editor_snapshot(self);
let probe = commands::kept_crypto_probe(self);
let cur = std::mem::replace(&mut self.project, p);
self.keep_view_settings(&cur);
self.history.undo.push((label, cur));
self.history.merge_key = None;
self.sanitize_after_replacement(edited.as_ref());
self.bump();
commands::keep_crypto_probe(self, probe, &cur);
self.history.undo.push((label, cur));
true
}
@@ -262,7 +268,9 @@ impl Session {
/// (renamed / regrouped / added parameters, reordered popups; see
/// [`effectcraft_effects::migrate`]), and a project from before
/// [`effectcraft_project::SCHEMA_EFFECT_SPACE`] into effect space
/// ([`effectcraft_effects::migrate::to_effect_space`]). Stamps the current schema.
/// ([`effectcraft_effects::migrate::to_effect_space`]). Shape path operations get the
/// properties added since ([`effectcraft_project::build::upgrade_shape_contents`]). Stamps the
/// current schema.
pub fn upgrade_effects(p: &mut Project) {
let to_effect_space = p.schema < effectcraft_project::SCHEMA_EFFECT_SPACE;
// Effect bounds per layer: (size, top-left in layer coordinates).
@@ -285,6 +293,9 @@ pub fn upgrade_effects(p: &mut Project) {
Some(id) => bounds.get(&(*cid, effectcraft_project::LayerId(id))).map_or([0.0; 2], |b| shift(b.1)),
None => d,
};
if let Some(contents) = l.props.sub_mut("contents") {
effectcraft_project::build::upgrade_shape_contents(&mut effectcraft_project::build::Ids(&mut next), contents);
}
let Some(fx) = l.props.sub_mut("effects") else { continue };
for n in &mut fx.children {
let effectcraft_project::Node::Group(g) = n else { continue };
+44 -3
View File
@@ -1,4 +1,4 @@
use super::{EngineError, Image, ItemId, RenderOpts, Renderer, Result, Session, ThumbnailJob, Tick};
use super::{EngineError, Image, ItemId, RenderOpts, Renderer, Result, Session, ThumbnailJob, Tick, Value, commands};
impl Session {
/// Render a comp frame with the session's footage + expression hosts.
@@ -9,14 +9,54 @@ impl Session {
/// The renderer [`Session::render`] uses: the session's footage, expressions, layer cache and
/// accelerator, and the render prefs (nested switches, draft shadows) over `opts`.
pub fn renderer(&self, opts: RenderOpts) -> Renderer<'_> {
let opts = RenderOpts { nested_switches: self.prefs.general.switches_affect_nested_comps, draft_shadows: self.prefs.three_d.realtime_shadows, ..opts };
let mut r = Renderer::new(&self.project, self.footage.as_ref(), opts);
let mut r = Renderer::new(&self.project, self.footage.as_ref(), self.render_opts(opts));
r.expr = self.expr.as_deref();
r.cache = Some(&self.layer_cache);
r.accel = self.accel.as_deref();
r
}
/// `opts` with the render prefs (nested switches, draft shadows), as [`Session::renderer`].
pub fn render_opts(&self, opts: RenderOpts) -> RenderOpts {
RenderOpts { nested_switches: self.prefs.general.switches_affect_nested_comps, draft_shadows: self.prefs.three_d.realtime_shadows, ..opts }
}
/// Whether `effect.cryptomatteAt` with params `p` answers from the kept probe (a few array
/// reads; `probeOnly` queries answer `pending` until then). Changes nothing: ask it every
/// frame, and start [`Session::crypto_probe_job`] only when a build will actually run.
pub fn crypto_probe_ready(&self, p: &Value) -> Result<bool> {
commands::crypto_probe_ready(self, p)
}
/// For the Cryptomatte eyedropper: `None` when `effect.cryptomatteAt` with params `p` answers
/// from the kept probe, else a job that builds that probe (it may render a precomp's ID pass)
/// off the UI thread, for the next query to use. Asking for a job supersedes a build in
/// flight for another probe (it then stores nothing): ask only to run it.
pub fn crypto_probe_job(&self, p: &Value) -> Result<Option<commands::CryptoProbeJob>> {
commands::crypto_probe_job(self, p)
}
/// Let go of the kept Cryptomatte probe (and its aux channels), and cancel a build in
/// flight: the eyedropper was disarmed. Edits, footage changes and Open / New do it too.
pub fn drop_crypto_probe(&self) {
if let Ok(mut slot) = self.crypto_probe.lock() {
slot.clear();
}
}
/// Whether a Cryptomatte probe is kept, and how many were built so far (for tests).
#[doc(hidden)]
pub fn crypto_probe_stats(&self) -> (bool, u64) {
self.crypto_probe.lock().map_or((false, 0), |s| (s.kept(), s.builds().1))
}
/// How many probe jobs ran so far, built or cancelled before they began (for tests: each
/// is a thread in the eyedropper).
#[doc(hidden)]
pub fn crypto_probe_jobs(&self) -> u64 {
self.crypto_probe.lock().map_or(0, |s| s.builds().0)
}
/// Render a comp frame (comp time `t`) as opaque 8-bit RGBA over the comp background, scaled
/// so the longest side is at most `max_side` pixels (0 = full size). Returns (width, height, rgba).
pub fn render_rgba8(&self, comp: ItemId, t: Tick, max_side: u32) -> Result<(u32, u32, Vec<u8>)> {
@@ -95,5 +135,6 @@ impl Session {
self.footage.forget(p);
}
self.layer_cache.clear();
self.drop_crypto_probe();
}
}
+5 -1
View File
@@ -28,6 +28,7 @@ pub use choices::{APPEARANCE_MODES, DARK_THEMES, LANGUAGES, LIGHT_THEMES, THEMES
pub use dialog::{Item, Kind, Page, pages, pages_json, todo_keys};
pub use document::{migrate, page_id, section_key};
pub use queries::{CacheBudgets, WEB_LAYER_CACHE, WEB_MEDIA_CACHE, mask_color};
pub use recent::{MAX_RECENT_SCRIPTS, RecentScript};
pub use sections::{
Appearance, Audio, AutoSave, CompPreset, CompositionPrefs, Disk, Export, FacePrefs, General, Grids, Import, LabelPref, Memory, Previews, ProjectPrefs,
RotoPrefs, Scripting, Startup, ThreeD, TypePrefs, Video,
@@ -35,7 +36,7 @@ pub use sections::{
use sections::{Extra, default_labels, lenient_comp_presets};
/// Current settings layout version.
pub const PREFS_VERSION: u32 = 2;
pub const PREFS_VERSION: u32 = 3;
/// Settings file name in the config store.
pub const PREFS_FILE: &str = "prefs.json";
@@ -74,6 +75,8 @@ pub struct Prefs {
/// Animation ▸ Recent Animation Presets (`.ecpreset` paths or built-in preset ids), newest
/// first.
pub recent_presets: Vec<String>,
/// File ▸ Scripts ▸ Recent Script Files, newest first, at most [`MAX_RECENT_SCRIPTS`].
pub recent_scripts: Vec<RecentScript>,
/// Character panel: recently used font families, newest first.
pub recent_fonts: Vec<String>,
/// Effects & Presets: the starred effects' ids, in the order starred.
@@ -117,6 +120,7 @@ impl Default for Prefs {
recent_projects: vec![],
recent_footage: vec![],
recent_presets: vec![],
recent_scripts: vec![],
recent_fonts: vec![],
effects_favorites: vec![],
preview: crate::preview::PreviewSettings::default(),
+11 -1
View File
@@ -1,5 +1,5 @@
use super::choices::{APPEARANCE_MODES, DARK_THEMES, LANGUAGES, LIGHT_THEMES, THEMES, WINDOW_GRAPHICS, choices, is_choice, theme_family};
use super::recent::MAX_EFFECTS_FAVORITES;
use super::recent::{MAX_EFFECTS_FAVORITES, MAX_RECENT_SCRIPTS};
use super::sections::{CompPreset, default_labels, parse_hex};
use super::{Appearance, General, Map, PREFS_VERSION, Prefs, Startup, Value, json};
@@ -7,6 +7,8 @@ use super::{Appearance, General, Map, PREFS_VERSION, Prefs, Startup, Value, json
///
/// - version 0/1 (early builds) kept a flat object: `undoLevels`, `autoSaveEnabled`,
/// `autoSaveMinutes`, `autoSaveVersions`, `labelNames` (array) and `theme` at the top level.
/// - version 2 defaulted the disk cache to 100 GB, more than most laptops have free; the stored
/// old default becomes the new one (a size the user chose is kept).
pub fn migrate(mut v: Value) -> Value {
let Some(obj) = v.as_object_mut() else { return json!({}) };
let version = obj.get("version").and_then(Value::as_u64).unwrap_or(0);
@@ -34,6 +36,12 @@ pub fn migrate(mut v: Value) -> Value {
obj.insert("labels".into(), serde_json::to_value(labels).unwrap_or_default());
}
}
if version < 3
&& let Some(d) = obj.get_mut("disk").and_then(Value::as_object_mut)
&& d.get("diskCacheMaxGb").and_then(Value::as_u64) == Some(100)
{
d.insert("diskCacheMaxGb".into(), json!(20));
}
// Settings saved before appearance modes had one theme: keep showing it, as a fixed Dark or
// Light mode, instead of switching established users to Auto.
if let Some(a) = obj.get_mut("appearance").and_then(Value::as_object_mut)
@@ -138,6 +146,7 @@ impl Prefs {
self.grids.grid_spacing = self.grids.grid_spacing.clamp(1.0, 10_000.0);
let n = self.general.recent_items as usize;
self.recent_projects.truncate(n);
self.recent_scripts.truncate(MAX_RECENT_SCRIPTS);
}
/// The value at a dotted key (`general.undoLevels`, `labels.3.color`); `""` = everything.
@@ -203,6 +212,7 @@ impl Prefs {
recent_projects: std::mem::take(&mut self.recent_projects),
recent_footage: std::mem::take(&mut self.recent_footage),
recent_presets: std::mem::take(&mut self.recent_presets),
recent_scripts: std::mem::take(&mut self.recent_scripts),
recent_fonts: std::mem::take(&mut self.recent_fonts),
effects_favorites: std::mem::take(&mut self.effects_favorites),
preview: std::mem::take(&mut self.preview),
@@ -1,3 +1,5 @@
use serde::{Deserialize, Serialize};
use super::Prefs;
impl Prefs {
@@ -45,6 +47,34 @@ impl Prefs {
pub fn push_recent_preset(&mut self, path: &str) {
push_mru(&mut self.recent_presets, path, self.general.recent_items as usize);
}
/// Remember a script that ran for File ▸ Scripts ▸ Recent Script Files.
pub fn push_recent_script(&mut self, script: RecentScript) {
self.recent_scripts.retain(|x| *x != script);
self.recent_scripts.insert(0, script);
self.recent_scripts.truncate(MAX_RECENT_SCRIPTS);
}
}
/// Most scripts File ▸ Scripts ▸ Recent Script Files lists.
pub const MAX_RECENT_SCRIPTS: usize = 10;
/// A File ▸ Scripts ▸ Recent Script Files entry, stored as the `file.runScript` parameter that
/// ran it: `{"path": …}` for a script file, `{"name": …}` for an installed or bundled script.
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum RecentScript {
Path(String),
Name(String),
}
impl RecentScript {
/// The file's path or the script's name.
pub fn text(&self) -> &str {
match self {
RecentScript::Path(s) | RecentScript::Name(s) => s,
}
}
}
/// Most effects Effects & Presets keeps starred (a hand-edited settings file can hold more).
+1 -1
View File
@@ -179,7 +179,7 @@ page!(Audio {
page!(Disk {
disk_cache_enabled: bool = true,
disk_cache_max_gb: u32 = 100,
disk_cache_max_gb: u32 = 20,
disk_cache_folder: String = String::new(),
media_cache_folder: String = String::new(),
conformed_media_folder: String = String::new(),
+25 -1
View File
@@ -82,7 +82,9 @@ impl Session {
/// Take the latest reading of the system's memory and start the next one in the background
/// (desktop frontends call this every few seconds; it never waits for the system), then
/// re-apply the cache budgets when the reading changes them. Returns the effective budgets.
/// The disk cache's free disk space is read the same way (it keeps a reserve free).
pub fn memory_tick(&mut self) -> crate::prefs::CacheBudgets {
self.poll_disk_space();
let before = self.prefs.cache_budgets(self.sys_memory);
self.sys_memory = self.memory_watch.latest().or(self.sys_memory);
self.memory_watch.poll();
@@ -98,6 +100,19 @@ impl Session {
after
}
/// Read the disk cache's free disk space in the background and apply it there (the reserve
/// it keeps free): the free space and the cache's size are taken at the same moment, and the
/// reader holds its own cache, so a reading never reaches another folder's.
fn poll_disk_space(&mut self) {
if let Some(dc) = &self.disk_cache {
let dc = dc.clone();
self.disk_watch.poll_with("ec-disk-space", move || {
dc.set_disk_space(crate::sysinfo::disk_space(dc.folder()));
Some(())
});
}
}
/// Apply Settings ▸ Media & Disk Cache ▸ Disk Cache: open (or close) the persistent cache
/// and attach it to the layer cache. Without a folder setting the platform cache folder is
/// used, but only by frontends with a settings store (headless sessions stay off disk).
@@ -113,6 +128,7 @@ impl Session {
// (no file system in the browser: the disk cache stays off there)
let Some(folder) = folder.filter(|_| d.disk_cache_enabled && !cfg!(target_arch = "wasm32")) else {
self.disk_cache = None;
self.disk_watch = Default::default();
self.layer_cache.set_disk(None);
return;
};
@@ -126,17 +142,25 @@ impl Session {
c.set_max_bytes(max);
c.clone()
}
// A new folder: the free-space reading was for the old one (a reader still running
// holds the old watcher's state, so it can't write into the new one).
_ => match DiskCache::open(&folder, max) {
Ok(c) => c,
Ok(c) => {
self.disk_watch = Default::default();
c
}
Err(e) => {
log::warn!("disk cache at {}: {e}", folder.display());
self.disk_cache = None;
self.disk_watch = Default::default();
self.layer_cache.set_disk(None);
return;
}
},
};
self.disk_cache = Some(dc.clone());
// The reserve applies from the start (a new folder's watcher is idle).
self.poll_disk_space();
let salt = self.refresh_disk_salt(true).unwrap_or_default();
self.layer_cache.set_disk(Some((dc.clone(), salt)));
// Roto Brush / Object Matte segmentations persist there too (held weakly: switching the
+15
View File
@@ -105,6 +105,21 @@ pub fn bindables(ui: &[UiCommand]) -> Vec<Bindable> {
&mut out,
);
}
// File ▸ Scripts ▸ Recent Script Files lists its scripts at run time; the first one, the most
// recent, has a key.
let recent = json!({"recent": 0});
push(
Bindable {
key: binding_key("file.runScript", &recent),
command: "file.runScript".into(),
params: recent,
label: "Run Most Recent Script File".into(),
path: vec!["File".into(), "Scripts".into(), "Recent Script Files".into()],
scope: APP_SCOPE.into(),
defaults: normalize("Cmd+Alt+Shift+D").into_iter().collect(),
},
&mut out,
);
for c in crate::command_specs() {
if !c.journal || entries.iter().any(|(_, e)| e.command == c.id) {
continue;
+126 -22
View File
@@ -1,11 +1,12 @@
//! Host information for Settings ▸ Memory & CPU (RAM reserved for other applications, reducing
//! caches when the system is low on memory) and Help ▸ System Compatibility Report.
//! caches when the system is low on memory), the disk cache's free-space reserve and Help ▸
//! System Compatibility Report.
//!
//! Pure Rust and dependency-free: the values come from the operating system's own reporting
//! (`/proc/meminfo` on Linux, `sysctl`/`vm_stat` on macOS, `wmic`/PowerShell on Windows). Every
//! query may fail (sandboxes, the web): callers treat `None` as "unknown" and keep their
//! configured budgets. The periodic reading behind the cache budgets runs on a background
//! thread ([`MemoryWatch`]): on Windows it starts PowerShell, which takes about a second.
//! (`/proc/meminfo` on Linux, `sysctl`/`vm_stat` on macOS, `df` on Unix, `wmic`/PowerShell on
//! Windows). Every query may fail (sandboxes, the web): callers treat `None` as "unknown" and
//! keep their configured budgets. The periodic readings behind the cache budgets run on a
//! background thread ([`Watch`]): on Windows they start PowerShell, which takes about a second.
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, PoisonError};
@@ -27,47 +28,74 @@ impl SysMemory {
}
}
/// The system's memory, read in the background: [`MemoryWatch::poll`] starts a reading (unless
/// one is running) and [`MemoryWatch::latest`] returns the last one without waiting.
#[derive(Clone, Default)]
pub struct MemoryWatch {
latest: Arc<Mutex<Option<SysMemory>>>,
/// A system value read in the background: [`Watch::poll_with`] starts a reading (unless one is
/// running) and [`Watch::latest`] returns the last one without waiting.
#[derive(Clone)]
pub struct Watch<T> {
latest: Arc<Mutex<Option<T>>>,
busy: Arc<AtomicBool>,
}
impl MemoryWatch {
/// Start a new reading in the background (no threads on the web: read inline, it is
/// unknown there anyway).
pub fn poll(&self) {
impl<T> Default for Watch<T> {
fn default() -> Self {
Watch { latest: Arc::new(Mutex::new(None)), busy: Arc::new(AtomicBool::new(false)) }
}
}
/// The system's memory ([`memory`]).
pub type MemoryWatch = Watch<SysMemory>;
/// The disk cache's free-space reading ([`disk_space`]): the reader applies it to its cache
/// itself, so only the busy guard is used.
pub type DiskWatch = Watch<()>;
impl<T: Copy + Send + 'static> Watch<T> {
/// Start a new reading in the background on a thread called `name` (no threads on the web:
/// read inline, it is unknown there anyway).
pub fn poll_with(&self, name: &str, read: impl FnOnce() -> Option<T> + Send + 'static) {
if self.busy.swap(true, Ordering::AcqRel) {
return;
}
let (latest, busy) = (self.latest.clone(), self.busy.clone());
let read = move || {
if let Some(m) = memory() {
*latest.lock().unwrap_or_else(PoisonError::into_inner) = Some(m);
}
// A failed reading clears the last one: a stale value is worse than none.
*latest.lock().unwrap_or_else(PoisonError::into_inner) = read();
busy.store(false, Ordering::Release);
};
#[cfg(not(target_arch = "wasm32"))]
if std::thread::Builder::new().name("ec-memory".into()).spawn(read).is_err() {
if std::thread::Builder::new().name(name.into()).spawn(read).is_err() {
self.busy.store(false, Ordering::Release);
}
#[cfg(target_arch = "wasm32")]
read();
{
let _ = name;
read();
}
}
/// The last reading (`None` until one finished).
pub fn latest(&self) -> Option<SysMemory> {
/// The last reading (`None` until one finished or when the last one failed).
pub fn latest(&self) -> Option<T> {
*self.latest.lock().unwrap_or_else(PoisonError::into_inner)
}
}
impl MemoryWatch {
/// Start a new reading of the system's memory in the background.
pub fn poll(&self) {
self.poll_with("ec-memory", memory);
}
}
#[cfg(not(target_arch = "wasm32"))]
#[allow(dead_code)]
fn run(cmd: &str, args: &[&str]) -> Option<String> {
run_env(cmd, args, &[])
}
#[cfg(not(target_arch = "wasm32"))]
#[allow(dead_code)]
fn run_env(cmd: &str, args: &[&str], env: &[(&str, &std::ffi::OsStr)]) -> Option<String> {
let mut c = std::process::Command::new(cmd);
c.args(args);
c.args(args).envs(env.iter().copied());
// A console program started from the windowed app would flash a console window.
#[cfg(target_os = "windows")]
std::os::windows::process::CommandExt::creation_flags(&mut c, 0x0800_0000); // CREATE_NO_WINDOW
@@ -139,6 +167,46 @@ pub fn memory() -> Option<SysMemory> {
None
}
/// Parse `df -Pk` output: the disk's total and available bytes.
pub fn parse_df(text: &str) -> Option<(u64, u64)> {
let line = text.lines().skip(1).find(|l| !l.trim().is_empty())?;
let f: Vec<&str> = line.split_whitespace().collect();
// A full disk into its root-reserved blocks reports negative numbers (Linux): none free.
let num = |v: &str| match v.strip_prefix('-') {
Some(d) => d.parse::<u64>().ok().map(|_| 0),
None => v.parse::<u64>().ok(),
};
// Size, used, available and capacity follow the device name (which may contain spaces).
let i = (0..f.len().saturating_sub(3))
.find(|&i| f.get(i + 3).is_some_and(|c| c.ends_with('%')) && f.get(i..i + 3).is_some_and(|n| n.iter().all(|v| num(v).is_some())))?;
let kb = |j: usize| f.get(j).and_then(|v| num(v)).map(|v| v.saturating_mul(1024));
Some((kb(i)?, kb(i + 2)?))
}
/// Total and available bytes of the disk holding `path`, when the system reports them.
pub fn disk_space(path: &std::path::Path) -> Option<(u64, u64)> {
#[cfg(any(target_os = "linux", target_os = "macos", target_os = "freebsd"))]
{
return parse_df(&run("df", &["-Pk", &path.to_string_lossy()])?);
}
#[cfg(target_os = "windows")]
{
// The path goes through the environment: no quoting.
let text = run_env(
"powershell",
&["-NoProfile", "-Command", "$d=[IO.DriveInfo]::new($env:EC_DISK_PATH); \"$($d.TotalSize) $($d.AvailableFreeSpace)\""],
&[("EC_DISK_PATH", path.as_os_str())],
)?;
let mut it = text.split_whitespace().filter_map(|v| v.parse::<u64>().ok());
return Some((it.next()?, it.next()?));
}
#[allow(unreachable_code)]
{
let _ = path;
None
}
}
/// CPU model name.
pub fn cpu_name() -> Option<String> {
#[cfg(target_os = "linux")]
@@ -225,6 +293,42 @@ mod tests {
assert_eq!(m.available, 1000);
}
#[test]
fn a_failed_reading_clears_the_last_one() {
let w = Watch::<u64>::default();
let wait = || {
for _ in 0..500 {
if !w.busy.load(Ordering::Acquire) {
return;
}
std::thread::sleep(std::time::Duration::from_millis(2));
}
panic!("the reading never finished");
};
w.poll_with("ec-test", || Some(7));
wait();
assert_eq!(w.latest(), Some(7));
w.poll_with("ec-test", || None);
wait();
assert_eq!(w.latest(), None);
}
#[test]
fn parses_df() {
let linux = "Filesystem 1024-blocks Used Available Capacity Mounted on\n/dev/nvme0n1p2 488281250 123456789 364824461 26% /\n";
assert_eq!(parse_df(linux), Some((488_281_250 * 1024, 364_824_461 * 1024)));
// macOS: a device name with spaces and a mount point with spaces.
let mac = "Filesystem 1024-blocks Used Available Capacity Mounted on\nmap auto_home 0 0 0 100% /System/Volumes/Data/home\n";
assert_eq!(parse_df(mac), Some((0, 0)));
let spaced = "Filesystem 1024-blocks Used Available Capacity Mounted on\n//nas/My Share 1000 400 600 40% /Volumes/My Share\n";
assert_eq!(parse_df(spaced), Some((1000 * 1024, 600 * 1024)));
// A full disk into its reserved blocks: nothing available.
let full = "Filesystem 1024-blocks Used Available Capacity Mounted on\n/dev/sda2 1000 1010 -10 101% /\n";
assert_eq!(parse_df(full), Some((1000 * 1024, 0)));
assert_eq!(parse_df("Filesystem 1024-blocks\n"), None);
assert_eq!(parse_df(""), None);
}
#[test]
fn parses_proc_meminfo_and_vm_stat() {
let m = parse_meminfo("MemTotal: 16000000 kB\nMemFree: 1000 kB\nMemAvailable: 8000000 kB\n").unwrap();
+451
View File
@@ -334,3 +334,454 @@ fn pick_matches_the_render_on_precomps_with_and_without_preserve_resolution() {
assert!(bad.is_empty(), "preserve {preserve}: {} mismatches:\n{}", bad.len(), bad.join("\n"));
}
}
/// `w` × `h` footage whose Cryptomatte has an ID per 37 × 29 px cell (`c<col>_<row>`, all in the
/// manifest), one aux pixel per layer pixel. Built once, as the media pool keeps decoded aux.
struct Cells(u32, u32, std::sync::OnceLock<Arc<AuxChannels>>);
impl Cells {
fn name(x: usize, y: usize) -> String {
format!("c{}_{}", x / 37, y / 29)
}
}
impl FootageSource for Cells {
fn frame(&self, _: ItemId, f: &Footage, _: Tick) -> Option<Arc<Image>> {
Some(Arc::new(Image::new(f.width, f.height)))
}
fn aux(&self, _: ItemId, _: &Footage, _: Tick) -> Option<Arc<AuxChannels>> {
Some(self.2.get_or_init(|| Arc::new(self.build())).clone())
}
}
impl Cells {
fn build(&self) -> AuxChannels {
let (w, h) = (self.0 as usize, self.1 as usize);
let ids = (0..w * h).map(|i| crypto_float(crypto_hash(&Cells::name(i % w, i / w)))).collect();
let mut aux = AuxChannels::new(w as u32, h as u32, 1.0);
aux.channels = vec![("CryptoObject00.R".into(), ids), ("CryptoObject00.G".into(), vec![1.0; w * h])];
let names = (0..w.div_ceil(37)).flat_map(|x| (0..h.div_ceil(29)).map(move |y| Cells::name(x * 37, y * 29)));
aux.manifests = vec![("CryptoObject".into(), names.map(|n| (n.clone(), crypto_hash(&n))).collect())];
aux
}
}
struct CellsProbe(u32, u32);
impl crate::Importer for CellsProbe {
fn probe(&self, path: &str) -> Result<Footage, String> {
// `…_proxy` is a half-size proxy (rounded up); `…still…` a still image.
let (width, height) = if path.contains("proxy") { (self.0.div_ceil(2), self.1.div_ceil(2)) } else { (self.0, self.1) };
let (frame_rate, duration) = (FrameRate::from_f64(24.0), Tick::from_seconds_f64(2.0));
let kind = if path.contains("still") { FootageKind::Still } else { FootageKind::Video };
Ok(Footage { path: path.into(), kind, width, height, has_video: true, frame_rate, duration, ..Default::default() })
}
}
fn cells_session(w: u32, h: u32, blur: bool) -> Session {
cells_session_at(w, h, blur, "/renders/cells.exr")
}
fn cells_session_at(w: u32, h: u32, blur: bool, path: &str) -> Session {
let mut s = Session { footage: Arc::new(Cells(w, h, Default::default())), importer: Some(Arc::new(CellsProbe(w, h))), ..Default::default() };
let item = s.execute("file.import", json!({"paths": [path]})).unwrap()["items"][0].clone();
s.execute("comp.new", json!({"name": "C", "width": w, "height": h, "frameRate": 24, "duration": 2})).unwrap();
s.execute("layer.addItem", json!({"item": item})).unwrap();
if blur {
s.execute_checked("effect.apply", json!({"effect": "Gaussian Blur"})).unwrap();
s.execute_checked("prop.set", json!({"path": "effects/#1/blurriness", "value": 40.0})).unwrap();
}
s.execute_checked("effect.apply", json!({"effect": "Cryptomatte"})).unwrap();
s
}
/// Hover latency of `effect.cryptomatteAt` (dev profile): the first call after the viewer's frame
/// and the next ones, on footage (1080p, 4K, 4K behind a blur) and a 1080p 3D precomp. Run with
/// `--ignored --nocapture`; it measures, it doesn't assert.
#[test]
#[ignore]
fn hover_latency() {
let mut cases: Vec<(&str, Session)> = vec![
("footage 1920x1080", cells_session(1920, 1080, false)),
("footage 3840x2160", cells_session(3840, 2160, false)),
("footage 3840x2160 + blur 40", cells_session(3840, 2160, true)),
];
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Outer", "width": 1920, "height": 1080, "frameRate": 24, "duration": 2})).unwrap();
let a = s.execute("layer.newSolid", json!({"name": "A", "width": 1920, "height": 1080})).unwrap()["layer"].clone();
let b = s.execute("layer.newSolid", json!({"name": "B", "width": 400, "height": 1080})).unwrap()["layer"].clone();
s.execute("layer.setSwitch", json!({"layers": [a, b], "switch": "threeD", "value": true})).unwrap();
s.execute("layer.precompose", json!({"layers": [a, b], "name": "Inner"})).unwrap();
s.execute("layer.select", json!({"layers": [s.active_comp().unwrap().layers[0].id.0]})).ok();
s.execute_checked("effect.apply", json!({"effect": "Cryptomatte"})).unwrap();
cases.push(("3D precomp 1920x1080", s));
for (name, mut s) in cases {
let cid = s.active_comp_id().unwrap();
let _ = s.render(cid, Tick::ZERO, effectcraft_render::RenderOpts::default());
let ms: Vec<String> = (0..6)
.map(|i| {
let t0 = std::time::Instant::now();
let r = s.execute("effect.cryptomatteAt", json!({"effect": "Cryptomatte", "x": 100.0 + 40.0 * i as f64, "y": 500.0})).unwrap();
assert!(!r.is_null(), "{name}");
format!("{:.3}", t0.elapsed().as_secs_f64() * 1e3)
})
.collect();
eprintln!("HOVER {name}: first {} ms, then {} ms", ms[0], ms[1..].join(" / "));
}
}
/// The hover's probe: built off the thread that asks, kept while only the Selection changes,
/// rebuilt when anything it reads changes, and a job finished for an older state is not used.
#[test]
fn hover_probe_is_built_by_a_job_kept_across_picks_and_dropped_on_other_edits() {
let mut s = session();
let q = json!({"effect": 1, "x": 0.5, "y": 1.5});
let job = s.crypto_probe_job(&q).unwrap().expect("nothing built yet");
std::thread::spawn(job).join().unwrap();
assert!(s.crypto_probe_job(&q).unwrap().is_none(), "built");
assert_eq!(s.execute("effect.cryptomatteAt", q.clone()).unwrap()["name"], "left");
// Another scale or time is another probe.
assert!(s.crypto_probe_job(&json!({"effect": 1, "x": 0.5, "y": 1.5, "scale": 0.5})).unwrap().is_some());
assert!(s.crypto_probe_job(&json!({"effect": 1, "x": 0.5, "y": 1.5, "time": 1.0})).unwrap().is_some());
// A pick edits only the Selection: the probe stays.
pick(&mut s, 0.5, "replace");
assert!(s.crypto_probe_job(&q).unwrap().is_none(), "kept across the pick");
assert_eq!(s.execute("effect.cryptomatteAt", q.clone()).unwrap()["selected"], true);
// Other edits rebuild it, and the answer follows them.
s.execute_checked("layer.enableTimeRemap", json!({})).unwrap();
s.execute_checked("prop.set", json!({"path": "timeRemap", "value": 1.5})).unwrap();
let stale = s.crypto_probe_job(&q).unwrap().expect("time remap changed it");
assert_eq!(s.execute("effect.cryptomatteAt", q.clone()).unwrap()["name"], "late");
// A job that finishes after the next edit doesn't answer for it.
s.execute_checked("prop.set", json!({"path": "timeRemap", "value": 0.5})).unwrap();
stale();
assert!(s.crypto_probe_job(&q).unwrap().is_some(), "stale job result is not used");
assert_eq!(s.execute("effect.cryptomatteAt", q.clone()).unwrap()["name"], "left");
// Errors are kept too, and still errors.
s.execute("layer.newSolid", json!({"name": "S"})).unwrap();
s.execute_checked("effect.apply", json!({"effect": "Cryptomatte"})).unwrap();
let solid = json!({"effect": 1, "x": 1, "y": 1});
s.crypto_probe_job(&solid).unwrap().expect("solid")();
assert!(s.crypto_probe_job(&solid).unwrap().is_none());
assert!(s.execute("effect.cryptomatteAt", solid).unwrap_err().to_string().contains("no Cryptomatte channels"));
}
/// Picks on 1080p, 4K and odd-sized footage, at the viewer's resolutions and on a half-size proxy,
/// name the ID the rendered matte shows under the point (points next to cell edges).
#[test]
fn picks_match_the_render_at_1080p_4k_odd_sizes_and_on_proxies() {
for (w, h) in [(1920, 1080), (3840, 2160), (1919, 1079)] {
let mut s = cells_session(w, h, false);
let (wl, hl) = (f64::from(w), f64::from(h));
let xs = [36.6, 37.2, 0.4, wl / 2.0 + 0.3, wl - 0.6, wl - 37.4];
let ys = [28.7, 29.1, hl - 0.5, hl / 3.0];
let mut bad = mismatches(&mut s, &[1.0, 0.5, 0.33, 0.25], xs.iter().copied(), &ys);
let item = *s.project.items.iter().find(|(_, i)| matches!(i.kind, effectcraft_project::ItemKind::Footage(_))).unwrap().0;
s.execute("file.setProxy", json!({"item": item.0, "path": "/renders/cells_proxy.exr"})).unwrap();
bad.extend(mismatches(&mut s, &[1.0, 0.5, 0.33, 0.25], xs.iter().copied(), &ys).into_iter().map(|b| format!("proxy {b}")));
assert!(bad.is_empty(), "{w}x{h}: {} mismatches:\n{}", bad.len(), bad.join("\n"));
}
}
/// The kept probe, and the aux channels it holds, go when the eyedropper is disarmed, when its
/// layer or effect is deleted, on Open / New, and when its footage changes.
#[test]
fn hover_probe_is_released_on_disarm_delete_open_and_footage_changes() {
let q = json!({"effect": "Cryptomatte", "x": 100.0, "y": 100.0});
let ends: [(&str, fn(&mut Session)); 5] = [
("disarm", |s| s.drop_crypto_probe()),
("layer delete", |s| {
let lid = s.active_comp().unwrap().layers[0].id.0;
s.execute_checked("edit.clear", json!({"layers": [lid]})).unwrap();
}),
("effect delete", |s| {
s.execute_checked("effect.remove", json!({"effect": "Cryptomatte"})).unwrap();
}),
("open / new", |s| s.replace_project(effectcraft_project::Project::default(), None)),
("footage change", |s| {
let f = s.project.items.values().find_map(|i| match &i.kind {
effectcraft_project::ItemKind::Footage(f) => Some(f.clone()),
_ => None,
});
s.forget_decoded(&f.unwrap());
}),
];
for (what, end) in ends {
let mut s = cells_session(640, 360, false);
let aux = s.footage.aux(ItemId(0), &Footage::default(), Tick::ZERO).unwrap();
let unpinned = Arc::strong_count(&aux);
assert!(!s.execute("effect.cryptomatteAt", q.clone()).unwrap().is_null());
assert!(s.crypto_probe_stats().0 && Arc::strong_count(&aux) > unpinned, "{what}: kept");
end(&mut s);
assert!(!s.crypto_probe_stats().0, "{what}: the probe is gone");
assert_eq!(Arc::strong_count(&aux), unpinned, "{what}: its aux channels are let go");
}
}
/// A probe is keyed by the frame it reads: on a still, or footage held by Time Remap, every comp
/// time shares one; each footage frame has its own, and so does each comp time once something
/// before the Cryptomatte effect is animated.
#[test]
fn hover_probe_is_keyed_by_the_source_frame() {
let builds = |s: &mut Session, times: &[f64]| -> Vec<u64> {
let b = s.crypto_probe_stats().1;
times
.iter()
.map(|t| {
let r = s.execute("effect.cryptomatteAt", json!({"effect": "Cryptomatte", "x": 40.0, "y": 30.0, "time": t})).unwrap();
assert_eq!(r["name"], "c1_1");
s.crypto_probe_stats().1 - b
})
.collect()
};
let f = 1.0 / 24.0;
let mut s = cells_session_at(320, 180, false, "/renders/still.exr");
assert_eq!(builds(&mut s, &[0.0, f, 0.5, 1.9]), [1, 1, 1, 1], "a still: one probe");
let mut s = cells_session(320, 180, false);
assert_eq!(builds(&mut s, &[0.0, f * 0.4, f, f * 1.5, 0.0]), [1, 1, 2, 2, 3], "footage: a probe per frame");
s.execute_checked("layer.enableTimeRemap", json!({})).unwrap();
s.execute_checked("prop.toggleAnimation", json!({"path": "timeRemap", "value": false})).unwrap();
s.execute_checked("prop.set", json!({"path": "timeRemap", "value": 0.5})).unwrap();
assert_eq!(builds(&mut s, &[0.0, f, 1.2]), [1, 1, 1], "held by Time Remap: one source frame");
let mut s = cells_session_at(320, 180, true, "/renders/still.exr");
assert_eq!(builds(&mut s, &[0.0, f]), [1, 1], "a still blur");
s.execute_checked("prop.addKey", json!({"path": "effects/#1/blurriness", "time": 0.0, "value": 2.0})).unwrap();
s.execute_checked("prop.addKey", json!({"path": "effects/#1/blurriness", "time": 1.0, "value": 6.0})).unwrap();
assert_eq!(builds(&mut s, &[0.0, f, f]), [1, 2, 2], "an animated blur before it: per comp time");
}
/// Probes of two Cryptomatte effects (on two footage layers) at the same revision and frame
/// don't answer for each other.
#[test]
fn hover_probes_are_per_layer_and_effect() {
/// `…late…` files have the IDs [`Crypto`] has from 1 s.
struct ByFile;
impl FootageSource for ByFile {
fn frame(&self, _: ItemId, f: &Footage, _: Tick) -> Option<Arc<Image>> {
Some(Arc::new(Image::new(f.width, f.height)))
}
fn aux(&self, _: ItemId, f: &Footage, t: Tick) -> Option<Arc<AuxChannels>> {
Some(Arc::new(aux_at(if f.path.contains("late") { Tick::from_seconds_f64(1.0) } else { t })))
}
}
let mut s = Session { footage: Arc::new(ByFile), importer: Some(Arc::new(Probe)), ..Default::default() };
let items = s.execute("file.import", json!({"paths": ["/renders/crypto.exr", "/renders/late.exr"]})).unwrap()["items"].clone();
s.execute("comp.new", json!({"name": "C", "width": 8, "height": 4, "frameRate": 24, "duration": 2})).unwrap();
let layers: Vec<Value> = (0..2).map(|i| s.execute("layer.addItem", json!({"item": items[i]})).unwrap()["layer"].clone()).collect();
for l in &layers {
s.execute_checked("effect.apply", json!({"layers": [l], "effect": "Cryptomatte"})).unwrap();
}
let on = |l: &Value| json!({"layer": l, "effect": 1, "x": 0.5, "y": 1.5});
for (l, want) in [(&layers[0], "left"), (&layers[1], "late"), (&layers[0], "left")] {
assert_eq!(s.execute("effect.cryptomatteAt", on(l)).unwrap()["name"], want);
}
}
/// [`Crypto`]'s IDs, counting the reads, and holding them while `hold` is set (a build caught
/// in the middle).
#[derive(Default)]
struct Gate {
reads: std::sync::atomic::AtomicUsize,
hold: std::sync::atomic::AtomicBool,
entered: std::sync::atomic::AtomicBool,
/// Picture frames read (the effect's input render).
frames: std::sync::atomic::AtomicUsize,
}
impl FootageSource for Gate {
fn frame(&self, _: ItemId, f: &Footage, _: Tick) -> Option<Arc<Image>> {
self.frames.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
Some(Arc::new(Image::new(f.width, f.height)))
}
fn aux(&self, _: ItemId, _: &Footage, t: Tick) -> Option<Arc<AuxChannels>> {
use std::sync::atomic::Ordering::SeqCst;
self.reads.fetch_add(1, SeqCst);
self.entered.store(true, SeqCst);
while self.hold.load(SeqCst) {
std::thread::sleep(std::time::Duration::from_millis(1));
}
Some(Arc::new(aux_at(t)))
}
}
/// Only the newest request's build is kept: a job that finishes late doesn't evict a newer probe,
/// a job superseded before it runs doesn't build, one superseded (or outdated by an edit) while
/// it builds stores nothing, and `probeOnly` never builds on the asking thread.
#[test]
fn hover_probe_builds_store_only_the_newest_and_never_build_for_probe_only() {
use std::sync::atomic::Ordering::SeqCst;
let gate = Arc::new(Gate::default());
let mut s = session();
s.footage = gate.clone();
let at = |t: f64| json!({"effect": 1, "x": 0.5, "y": 1.5, "time": t});
let reads = || gate.reads.load(SeqCst);
// A late job for another frame doesn't evict the probe built since.
let job0 = s.crypto_probe_job(&at(0.0)).unwrap().unwrap();
s.execute("effect.cryptomatteAt", at(1.0)).unwrap();
assert!(s.crypto_probe_job(&at(1.0)).unwrap().is_none());
job0();
assert!(s.crypto_probe_job(&at(1.0)).unwrap().is_none(), "the late job evicted the newer probe");
// A request for another probe cancels the earlier job: it doesn't even build.
let (old, new) = (s.crypto_probe_job(&at(0.5)).unwrap().unwrap(), s.crypto_probe_job(&at(0.25)).unwrap().unwrap());
let before = reads();
old();
assert_eq!(reads(), before, "a superseded job doesn't build");
new();
assert!(reads() > before);
assert!(s.crypto_probe_job(&at(0.25)).unwrap().is_none(), "the newest request's probe is kept");
// The same request again doesn't cancel the job in flight.
let (a, b) = (s.crypto_probe_job(&at(1.5)).unwrap().unwrap(), s.crypto_probe_job(&at(1.5)).unwrap().unwrap());
a();
drop(b);
assert!(s.crypto_probe_job(&at(1.5)).unwrap().is_none());
// Superseded, or outdated by an edit, while it builds: it stores nothing.
for edit in [false, true] {
let job = s.crypto_probe_job(&at(0.75)).unwrap().unwrap();
gate.hold.store(true, SeqCst);
gate.entered.store(false, SeqCst);
let run = std::thread::spawn(job);
while !gate.entered.load(SeqCst) {
std::thread::sleep(std::time::Duration::from_millis(1));
}
if edit {
s.execute_checked("prop.set", json!({"path": "effects/#1/matteOnly", "value": true})).unwrap();
} else {
drop(s.crypto_probe_job(&at(0.8)).unwrap());
}
gate.hold.store(false, SeqCst);
run.join().unwrap();
let stored = if edit { s.crypto_probe_stats().0 } else { s.crypto_probe_job(&at(0.75)).unwrap().is_none() };
assert!(!stored, "edit {edit}: the outdated build was stored");
}
// `probeOnly`: pending without a probe, and nothing built.
let before = reads();
let mut q = at(0.0);
q["probeOnly"] = json!(true);
assert_eq!(s.execute("effect.cryptomatteAt", q.clone()).unwrap(), json!({"pending": true}));
q["mode"] = json!("add");
assert_eq!(s.execute("effect.pickCryptomatte", q.clone()).unwrap()["pending"], true);
assert_eq!(reads(), before, "nothing built for probeOnly");
s.crypto_probe_job(&q).unwrap().unwrap()();
assert_eq!(s.execute("effect.pickCryptomatte", q.clone()).unwrap()["selection"], "left");
}
/// A build cancelled while it reads the IDs (a newer request, disarming, an edit, another
/// project) stops before it renders the effect's input, so the next build isn't kept waiting.
#[test]
fn a_cancelled_build_stops_before_rendering_its_input() {
use std::sync::atomic::Ordering::SeqCst;
let cancels: [(&str, fn(&mut Session)); 4] = [
("newer request", |s| drop(s.crypto_probe_job(&json!({"effect": 1, "x": 0.5, "y": 1.5, "time": 1.5})).unwrap())),
("disarm", |s| s.drop_crypto_probe()),
("edit", |s| drop(s.execute_checked("prop.set", json!({"path": "effects/#1/matteOnly", "value": true})).unwrap())),
("project switch", |s| s.replace_project(effectcraft_project::Project::default(), None)),
];
for (what, cancel) in [("not cancelled", None), ("newer request", Some(0)), ("disarm", Some(1)), ("edit", Some(2)), ("project switch", Some(3))] {
let gate = Arc::new(Gate::default());
let mut s = session();
s.footage = gate.clone();
let job = s.crypto_probe_job(&json!({"effect": 1, "x": 0.5, "y": 1.5, "time": 0.5})).unwrap().unwrap();
gate.hold.store(true, SeqCst);
let run = std::thread::spawn(job);
while !gate.entered.load(SeqCst) {
std::thread::sleep(std::time::Duration::from_millis(1));
}
if let Some(k) = cancel {
cancels[k].1(&mut s);
}
gate.hold.store(false, SeqCst);
run.join().unwrap();
let rendered = gate.frames.load(SeqCst) > 0;
assert_eq!(rendered, cancel.is_none(), "{what}: the input was rendered: {rendered}");
}
}
/// The probe doesn't read the Cryptomatte effect's own Selection: keyframed, a still keeps one
/// probe for every comp time (an animated effect before it still makes one per time).
#[test]
fn an_animated_selection_keeps_one_probe_on_a_still() {
let mut s = cells_session_at(320, 180, false, "/renders/still.exr");
for (t, v) in [(0.0, "c1_1"), (1.0, "c2_2")] {
s.execute_checked("prop.addKey", json!({"path": "effects/#1/selection", "time": t, "value": v})).unwrap();
}
let b = s.crypto_probe_stats().1;
for t in [0.0, 1.0 / 24.0, 0.5, 1.5] {
assert_eq!(s.execute("effect.cryptomatteAt", json!({"effect": "Cryptomatte", "x": 40.0, "y": 30.0, "time": t})).unwrap()["name"], "c1_1");
}
assert_eq!(s.crypto_probe_stats().1 - b, 1, "one probe for the still");
}
/// A panic while a probe is built on another thread becomes the probe's error: the job returns,
/// the hover reports it, and it isn't retried for that frame.
#[test]
fn a_panicking_probe_build_becomes_an_error() {
struct Broken;
impl FootageSource for Broken {
fn frame(&self, _: ItemId, f: &Footage, _: Tick) -> Option<Arc<Image>> {
Some(Arc::new(Image::new(f.width, f.height)))
}
fn aux(&self, _: ItemId, _: &Footage, _: Tick) -> Option<Arc<AuxChannels>> {
panic!("a broken EXR reader")
}
}
let mut s = Session { footage: Arc::new(Broken), importer: Some(Arc::new(Probe)), ..Default::default() };
let item = s.execute("file.import", json!({"paths": ["/renders/crypto.exr"]})).unwrap()["items"][0].clone();
s.execute("comp.new", json!({"name": "C", "width": 8, "height": 4, "frameRate": 24, "duration": 2})).unwrap();
s.execute("layer.addItem", json!({"item": item})).unwrap();
s.execute_checked("effect.apply", json!({"effect": "Cryptomatte"})).unwrap();
let q = json!({"effect": 1, "x": 0.5, "y": 1.5, "probeOnly": true});
let job = s.crypto_probe_job(&q).unwrap().unwrap();
assert!(std::thread::spawn(job).join().is_ok(), "the job caught the panic");
assert!(s.crypto_probe_job(&q).unwrap().is_none(), "the error is kept");
let e = s.execute("effect.cryptomatteAt", q).unwrap_err().to_string();
assert!(e.contains("Reading the Cryptomatte IDs failed") && e.contains("a broken EXR reader"), "{e}");
}
/// Asking whether the probe is built changes nothing: asked for other frames while a build runs
/// (the eyedropper's hover while a click waits), it doesn't cancel that build.
#[test]
fn asking_whether_a_probe_is_ready_cancels_no_build() {
let s = session();
let at = |t: f64| json!({"effect": 1, "x": 0.5, "y": 1.5, "time": t, "probeOnly": true});
let job = s.crypto_probe_job(&at(0.0)).unwrap().unwrap();
for t in [1.0, 0.5, 1.0, 0.25] {
assert!(!s.crypto_probe_ready(&at(t)).unwrap());
}
job();
assert!(s.crypto_probe_ready(&at(0.0)).unwrap(), "the build was cancelled");
assert!(s.crypto_probe_job(&at(0.0)).unwrap().is_none());
}
/// The probe renders with the render prefs (nested switches, draft shadows): changing them asks
/// for a new one.
#[test]
fn a_render_prefs_change_asks_for_a_new_probe() {
let mut s = session();
let q = json!({"effect": 1, "x": 0.5, "y": 1.5});
s.crypto_probe_job(&q).unwrap().unwrap()();
assert!(s.crypto_probe_ready(&q).unwrap());
s.prefs.three_d.realtime_shadows = !s.prefs.three_d.realtime_shadows;
assert!(!s.crypto_probe_ready(&q).unwrap(), "draft shadows changed");
s.crypto_probe_job(&q).unwrap().unwrap()();
s.prefs.general.switches_affect_nested_comps = !s.prefs.general.switches_affect_nested_comps;
assert!(!s.crypto_probe_ready(&q).unwrap(), "nested switches changed");
}
/// A pick is an undo step named for it, and undoing, redoing or jumping over picks keeps the
/// probe (only the Selection changed); undoing any other edit doesn't.
#[test]
fn undoing_picks_keeps_the_probe_and_the_step_says_pick() {
let mut s = session();
let q = json!({"effect": 1, "x": 0.5, "y": 1.5, "probeOnly": true});
s.crypto_probe_job(&q).unwrap().unwrap()();
let before = crate::history::state_id(&s.project);
pick(&mut s, 0.5, "replace");
assert_eq!(s.history.undo.last().unwrap().0, "Pick Cryptomatte");
pick(&mut s, 4.5, "add");
for (what, cmd) in [("undo", "edit.undo"), ("undo", "edit.undo"), ("redo", "edit.redo")] {
s.execute_checked(cmd, json!({})).unwrap();
assert!(s.crypto_probe_ready(&q).unwrap(), "{what} {}: probe dropped", selection(&s));
}
assert_eq!(s.execute("effect.cryptomatteAt", q.clone()).unwrap()["selected"], true);
assert!(s.goto_history(&before) && selection(&s).is_empty());
assert!(s.crypto_probe_ready(&q).unwrap(), "history jump: probe dropped");
s.execute_checked("prop.set", json!({"path": "transform/opacity", "value": 50})).unwrap();
s.crypto_probe_job(&q).unwrap().unwrap()();
s.execute_checked("edit.undo", json!({})).unwrap();
assert!(!s.crypto_probe_ready(&q).unwrap(), "an undone opacity change needs a new probe");
}
+1 -1
View File
@@ -35,7 +35,7 @@ fn disk_cache_settings_persistence_and_invalidation() {
let mut s = session(&dir);
let st = s.execute_checked("cache.diskStats", json!({})).unwrap();
assert_eq!(st["enabled"], json!(true));
assert_eq!(st["maxBytes"], json!(100u64 << 30));
assert_eq!(st["maxBytes"], json!(20u64 << 30));
let cid = project(&mut s);
let t = s.time();
let first = s.render(cid, t, RenderOpts::default());
+5
View File
@@ -708,6 +708,11 @@ fn dynamic_menus_and_window_viewer_labels() {
s.prefs.push_recent("/p/One.ecproj");
let (e, _) = crate::menus::dynamic(&s, "recentProjects", &Default::default());
assert_eq!((e[0].label.as_str(), e[0].command.as_str()), ("One.ecproj", "file.openRecent"));
// Two projects with the same file name show their paths.
s.prefs.push_recent("/q/One.ecproj");
s.prefs.push_recent("/p/Two.ecproj");
let (e, _) = crate::menus::dynamic(&s, "recentProjects", &Default::default());
assert_eq!(e.iter().map(|e| e.label.as_str()).collect::<Vec<_>>(), ["Two.ecproj", "/q/One.ecproj", "/p/One.ecproj"]);
assert_eq!(crate::menus::dynamic(&s, "history", &Default::default()).0.len(), s.history.undo.len());
let a = solid(&mut s, "#ff0000");
s.state.selected_layers = vec![LayerId(a)];
+25
View File
@@ -280,6 +280,31 @@ fn toolbar_fill_and_stroke_paint_selected_shape_layers() {
let _ = s.render(cid, s.time(), Default::default());
}
/// A Wiggle Paths saved without Spatial Phase gets the property, at 0°, after Temporal Phase when
/// the project opens.
#[test]
fn opening_a_wiggle_paths_without_spatial_phase_adds_it() {
let mut s = Session::default();
s.execute("comp.new", json!({"width": 400, "height": 300, "duration": 2})).unwrap();
let sh = s.execute("layer.newShape", json!({"kind": "rect", "fill": "#ff0000"})).unwrap()["layer"].as_u64().unwrap();
let uid = s.execute("layer.addShapeItem", json!({"layer": sh, "kind": "wiggle"})).unwrap()["uid"].as_u64().unwrap();
let cid = s.active_comp_id().unwrap();
let mut old = effectcraft_project::Project::from_json(&s.project.to_file_json().unwrap()).unwrap();
let l = old.comp_mut(cid).unwrap().layer_mut(effectcraft_project::LayerId(sh)).unwrap();
let g = l.props.find_group_mut(uid).unwrap();
g.children.retain(|c| c.match_id() != "spatialPhase");
assert_eq!(g.children.len(), 7);
let next = old.next_id;
s.replace_project(old, None);
let l = s.active_comp().unwrap().layer(effectcraft_project::LayerId(sh)).unwrap().clone();
let g = l.props.find_group(uid).unwrap();
let ids: Vec<&str> = g.children.iter().map(|c| c.match_id()).collect();
assert_eq!(ids, ["size", "detail", "points", "speed", "correlation", "phase", "spatialPhase", "seed"]);
let sp = g.get("spatialPhase").unwrap();
assert_eq!((sp.name.as_str(), &sp.value), ("Spatial Phase", &effectcraft_keyframe::Value::Scalar(0.0)));
assert!(sp.uid >= next && s.project.next_id > sp.uid, "a fresh uid: {} (next {next})", sp.uid);
}
/// The toolbar's Fill Options / Stroke Options change a selected shape layer's paint kind (#732):
/// None switches the fill off, a gradient turns the Fill into a Gradient Fill that keeps its
/// opacity, Solid Color turns it back, and a Stroke kind adds a stroke where there is none.
+8
View File
@@ -71,6 +71,14 @@ fn prefs_migrate_old_layout_and_keep_unknown_keys() {
assert_eq!(Prefs::from_json("not json"), Prefs::default());
}
#[test]
fn prefs_migrate_old_disk_cache_default() {
// The old 100 GB default becomes 20 GB; a size the user chose stays (#2551).
assert_eq!(Prefs::from_json(r#"{"version": 2, "disk": {"diskCacheMaxGb": 100}}"#).disk.disk_cache_max_gb, 20);
assert_eq!(Prefs::from_json(r#"{"version": 2, "disk": {"diskCacheMaxGb": 50}}"#).disk.disk_cache_max_gb, 50);
assert_eq!(Prefs::from_json(r#"{"version": 3, "disk": {"diskCacheMaxGb": 100}}"#).disk.disk_cache_max_gb, 100);
}
#[test]
fn prefs_set_validates_keys_types_and_ranges() {
let mut p = Prefs::default();
+39 -59
View File
@@ -33,18 +33,29 @@ pub(crate) enum Pixels {
Yuv420(Yuv420),
}
/// BT.709 limited-range 8-bit 4:2:0 planes ([`rgba_to_yuv420`]).
pub(crate) struct Yuv420 {
y: Vec<u8>,
u: Vec<u8>,
v: Vec<u8>,
/// BT.709 limited-range 8-bit 4:2:0 planes ([`rgba_to_yuv420`], or Y then U and V from the
/// accelerator).
pub(crate) enum Yuv420 {
Planes { y: Vec<u8>, u: Vec<u8>, v: Vec<u8> },
Packed(Vec<u8>),
}
impl Yuv420 {
fn from_rgba(rgba: &[u8], w: usize, h: usize) -> Yuv420 {
let (mut y, mut u, mut v) = (vec![], vec![], vec![]);
rgba_to_yuv420(rgba, w, h, &mut y, &mut u, &mut v);
Yuv420 { y, u, v }
Yuv420::Planes { y, u, v }
}
fn slices(&self, w: usize, h: usize) -> (&[u8], &[u8], &[u8]) {
match self {
Yuv420::Planes { y, u, v } => (y, u, v),
Yuv420::Packed(p) => {
let (cw, ch) = (w.div_ceil(2), h.div_ceil(2));
let (y, uv) = p.split_at(w * h);
let (u, v) = uv.split_at(cw * ch);
(y, u, v)
}
}
}
}
@@ -102,18 +113,22 @@ impl VideoEncoder for H264 {
SampleEntry::avc(cfg, self.w as u16, self.h as u16)
}
fn encode(&mut self, rgba: &[u8], index: u64) -> Result<Vec<Packet>> {
let mut planes = Yuv420 { y: std::mem::take(&mut self.y), u: std::mem::take(&mut self.u), v: std::mem::take(&mut self.v) };
rgba_to_yuv420(rgba, self.w as usize, self.h as usize, &mut planes.y, &mut planes.u, &mut planes.v);
let (mut y, mut u, mut v) = (std::mem::take(&mut self.y), std::mem::take(&mut self.u), std::mem::take(&mut self.v));
rgba_to_yuv420(rgba, self.w as usize, self.h as usize, &mut y, &mut u, &mut v);
let planes = Yuv420::Planes { y, u, v };
let r = self.encode_yuv420(&planes, index);
(self.y, self.u, self.v) = (planes.y, planes.u, planes.v);
if let Yuv420::Planes { y, u, v } = planes {
(self.y, self.u, self.v) = (y, u, v);
}
r
}
fn takes_yuv420(&self) -> bool {
true
}
fn encode_yuv420(&mut self, p: &Yuv420, index: u64) -> Result<Vec<Packet>> {
let cw = (self.w as usize).div_ceil(2);
let frame = filmcraft_h264enc::YuvFrame { y: &p.y, u: &p.u, v: &p.v, y_stride: self.w as usize, uv_stride: cw };
let (w, cw) = (self.w as usize, (self.w as usize).div_ceil(2));
let (y, u, v) = p.slices(w, self.h as usize);
let frame = filmcraft_h264enc::YuvFrame { y, u, v, y_stride: w, uv_stride: cw };
let ps = self.enc.try_encode(&frame, index as i64 * self.rate.den).map_err(enc)?;
Ok(self.packets(ps))
}
@@ -126,51 +141,7 @@ impl VideoEncoder for H264 {
}
}
/// `v / 255` for every 8-bit `v` (the same values as dividing).
pub(crate) const UNIT8: [f32; 256] = {
let mut t = [0.0; 256];
let mut i = 0;
while i < 256 {
t[i] = i as f32 / 255.0;
i += 1;
}
t
};
/// `v.round()` for `0 <= v < 2^23`, the range the colour conversions round in: truncation and
/// the fraction are exact there, so this is `f32::round` without its libm call.
#[inline(always)]
pub(crate) fn round_pos(v: f32) -> f32 {
let t = v as u32 as f32;
if v - t >= 0.5 { t + 1.0 } else { t }
}
/// BT.709 limited-range 8-bit 4:2:0 from RGBA8 (2×2 chroma average; alpha ignored).
pub(crate) fn rgba_to_yuv420(rgba: &[u8], w: usize, h: usize, y: &mut Vec<u8>, u: &mut Vec<u8>, v: &mut Vec<u8>) {
let (cw, ch) = (w.div_ceil(2), h.div_ceil(2));
y.resize(w * h, 0);
u.resize(cw * ch, 0);
v.resize(cw * ch, 0);
y.par_chunks_mut(w * 2).zip(u.par_chunks_mut(cw).zip(v.par_chunks_mut(cw))).enumerate().for_each(|(cy, (yr, (ur, vr)))| {
let mut acc = vec![(0f32, 0f32, 0f32); cw];
for (dy, yrow) in yr.chunks_exact_mut(w).enumerate() {
let src = &rgba[(cy * 2 + dy) * w * 4..][..w * 4];
for (x, (s, yo)) in src.as_chunks::<4>().0.iter().zip(yrow.iter_mut()).enumerate() {
let (r, g, b) = (UNIT8[s[0] as usize], UNIT8[s[1] as usize], UNIT8[s[2] as usize]);
let l = 0.2126 * r + 0.7152 * g + 0.0722 * b;
*yo = round_pos(16.0 + 219.0 * l).clamp(1.0, 254.0) as u8;
let a = &mut acc[x / 2];
a.0 += (b - l) / 1.8556;
a.1 += (r - l) / 1.5748;
a.2 += 1.0;
}
}
for (cx, a) in acc.iter().enumerate() {
ur[cx] = round_pos(128.0 + 224.0 * a.0 / a.2).clamp(1.0, 254.0) as u8;
vr[cx] = round_pos(128.0 + 224.0 * a.1 / a.2).clamp(1.0, 254.0) as u8;
}
});
}
pub(crate) use effectcraft_raster::yuv::rgba_to_yuv420;
// ---------------------------------------------------------------- ProRes
@@ -326,6 +297,13 @@ pub(crate) async fn movie(job: &Cx<'_>, comp: &Comp, w: u32, h: u32, st: &mut St
let px = job.pixels(&img, comp, channels, w, h);
if yuv { Pixels::Yuv420(Yuv420::from_rgba(&px, w as usize, h as usize)) } else { Pixels::Rgba(px) }
};
// H.264 frames the accelerator can convert itself come back as planes (12 MB instead of
// 133 MB of RGBA f32 read back at 4K); everything else renders and converts as before.
// Deferred readbacks (a browser worker) keep their pass-based path: no blocking there.
let gpu_yuv = yuv && channels == Channels::Rgb && !job.deferred();
let batch_frames = async |ks: Vec<u64>| -> Vec<Pixels> {
if gpu_yuv { job.h264_frames(comp, ks, w, h, |p| Pixels::Yuv420(Yuv420::Packed(p)), render) } else { job.frames(comp, ks, render).await }
};
let encode = |venc: &mut Box<dyn VideoEncoder>, px: &Pixels, index: u64| match px {
Pixels::Rgba(rgba) => venc.encode(rgba, index),
Pixels::Yuv420(planes) => venc.encode_yuv420(planes, index),
@@ -337,10 +315,10 @@ pub(crate) async fn movie(job: &Cx<'_>, comp: &Comp, w: u32, h: u32, st: &mut St
// Encode the first batch before creating tracks: encoders finalise their config on frame 1.
// Each batch encodes while the next one renders.
let first_end = batch.min(total);
let first: Vec<Pixels> = job.frames(comp, (0..first_end).collect(), render).await;
let first: Vec<Pixels> = batch_frames((0..first_end).collect()).await;
let next_end = (first_end + batch).min(total);
let (pending, mut ready) = job
.frames_beside(beside, comp, (first_end..next_end).collect(), render, || -> Result<Vec<Packet>> {
.beside(beside, batch_frames((first_end..next_end).collect()), || -> Result<Vec<Packet>> {
let mut pending = Vec::new();
for (k, px) in first.iter().enumerate() {
pending.extend(encode(&mut venc, px, k as u64)?);
@@ -435,7 +413,7 @@ pub(crate) async fn movie(job: &Cx<'_>, comp: &Comp, w: u32, h: u32, st: &mut St
let next_end = (end + batch).min(total);
let frames = std::mem::take(&mut ready);
let (done, next) = job
.frames_beside(beside, comp, (end..next_end).collect(), render, || -> Result<()> {
.beside(beside, batch_frames((end..next_end).collect()), || -> Result<()> {
for (k, px) in frames.iter().enumerate() {
let ps = encode(&mut venc, px, i + k as u64)?;
write_video(&mut mux, ps)?;
@@ -463,6 +441,8 @@ pub(crate) async fn movie(job: &Cx<'_>, comp: &Comp, w: u32, h: u32, st: &mut St
#[cfg(test)]
mod tests {
use effectcraft_raster::yuv::{UNIT8, round_pos};
use super::*;
/// The conversion as first written (per-pixel division and `f32::round`).
+71 -2
View File
@@ -235,6 +235,7 @@ impl<'a> Cx<'a> {
/// [`Cx::frames`] for `ks` while `encode` runs beside them on `beside`'s threads, so the
/// render threads don't wait for the (largely sequential) encoder of the previous batch.
/// With deferred readbacks, or where no encode thread could be started, `encode` runs first.
#[cfg(test)]
pub async fn frames_beside<T: Send, A: Send>(
&self,
beside: &Beside,
@@ -242,13 +243,24 @@ impl<'a> Cx<'a> {
ks: Vec<u64>,
f: impl Fn(u64, Image) -> T + Sync + Send,
encode: impl FnOnce() -> A + Send,
) -> crate::Result<(A, Vec<T>)> {
self.beside(beside, self.frames(comp, ks, f), encode).await
}
/// Run `encode` on `beside`'s threads while `frames` renders the next batch. With deferred
/// readbacks, or where no encode thread could be started, `encode` runs first.
pub async fn beside<T: Send, A: Send>(
&self,
beside: &Beside,
frames: impl std::future::Future<Output = Vec<T>>,
encode: impl FnOnce() -> A + Send,
) -> crate::Result<(A, Vec<T>)> {
match &beside.0 {
Some(pool) if !self.deferred() => {
let mut done = None;
let frames = pool.in_place_scope(|s| {
s.spawn(|_| done = Some(std::panic::catch_unwind(std::panic::AssertUnwindSafe(encode))));
effectcraft_render::passes::block_on(self.frames(comp, ks, f))
effectcraft_render::passes::block_on(frames)
});
match done {
Some(Ok(a)) => Ok((a, frames)),
@@ -261,7 +273,7 @@ impl<'a> Cx<'a> {
}
_ => {
let a = encode();
Ok((a, self.frames(comp, ks, f).await))
Ok((a, frames.await))
}
}
}
@@ -304,6 +316,63 @@ impl<'a> Cx<'a> {
img
}
/// Output frame `i` as the H.264 export's 4:2:0 planes at `w`×`h`, rendered and converted
/// by the accelerator ([`effectcraft_render::Accelerator::comp_frame_yuv420`]). Only for
/// frames that need nothing done after the render (progressive, at the project's depth, no
/// crop or resize, rendered at exactly `w`×`h`). `None`: render it with [`Cx::frame`].
pub fn yuv420_frame(&self, comp: &Comp, i: u64, w: u32, h: u32) -> Option<Vec<u8>> {
if self.deferred() {
return None;
}
let FrameSample::Progressive(t) = self.job.settings.sample(comp, i) else { return None };
let depth = self.job.settings.color_depth;
if depth != ColorDepth::Current && depth.resolve(self.project.settings.bit_depth).levels().is_some() {
return None;
}
let om = self.job.output;
let scale = self.job.settings.resolution.clamp(0.01, 4.0);
let size = |n: u32| ((n as f64 * scale).round() as u32).max(1);
if om.resize.enabled || (size(comp.width), size(comp.height)) != (w, h) || om.crop.edges(w, h, scale) != (0, 0, 0, 0) {
return None;
}
let t0 = Instant::now();
let r = self.renderer();
let planes = r.active_accel()?.comp_frame_yuv420(&r, self.job.comp, t, comp.background)?;
let (cw, ch) = (w.div_ceil(2) as usize, h.div_ceil(2) as usize);
if planes.len() != w as usize * h as usize + 2 * cw * ch {
return None;
}
if let Some(ft) = &self.frame_times {
ft.lock().unwrap_or_else(|e| e.into_inner()).push((i, t0.elapsed().as_secs_f64()));
}
Some(planes)
}
/// Output frames `ks` for the H.264 encoder: the accelerator's planes
/// ([`Cx::yuv420_frame`]) where it can, otherwise `fallback` of the frame rendered as usual.
pub(crate) fn h264_frames<T: Send>(
&self,
comp: &Comp,
ks: Vec<u64>,
w: u32,
h: u32,
planes: impl Fn(Vec<u8>) -> T + Sync + Send,
fallback: impl Fn(u64, Image) -> T + Sync + Send,
) -> Vec<T> {
// As in `frames`: once the GPU has stopped for good, the rest render in parallel.
let gpu_live = || self.job.accel.is_none_or(|a| a.available());
map_frames_while(
ks,
self.gpu_cap(),
gpu_live,
|k| self.yuv420_frame(comp, k, w, h).ok_or_else(|| self.frame(comp, k)),
|k, r| match r {
Ok(p) => planes(p),
Err(img) => fallback(k, img),
},
)
}
/// Output Module ▸ Crop, then Resize.
fn crop_resize(&self, img: Image) -> Image {
let om = self.job.output;
+180
View File
@@ -0,0 +1,180 @@
//! H.264 export with an accelerator that converts frames to 4:2:0 itself
//! (`Accelerator::comp_frame_yuv420`): the file is the same as the CPU conversion's, and frames
//! the accelerator can't take (odd sizes, crop) fall back to the RGBA path.
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicUsize, Ordering};
use effectcraft_color::Label;
use effectcraft_export::render_queue::{OutputFormat, OutputModule, RenderSettings, TimeSpan};
use effectcraft_export::{Job, export};
use effectcraft_project::{Comp, ItemId, ItemKind, LayerSource, Project, Solid, build};
use effectcraft_raster::Image;
use effectcraft_render::{Accelerator, NoFootage, Renderer};
use effectcraft_time::{FrameRate, Tick};
/// Renders on the CPU; converts to 4:2:0 with the export's own function when `yuv` is set.
/// `deferred`: claims deferred readbacks (a browser worker's GPU), whose frames render in passes.
/// `stop_after`: the device stops for good after that many planes (`available` turns false).
struct Fake {
yuv: bool,
yuv_frames: AtomicUsize,
deferred: bool,
stop_after: usize,
/// Frames asking for planes right now, and the most at once after the stop.
asking: AtomicUsize,
most_after_stop: AtomicUsize,
}
impl Fake {
fn new(yuv: bool, deferred: bool, stop_after: usize) -> Self {
Fake { yuv, yuv_frames: AtomicUsize::new(0), deferred, stop_after, asking: AtomicUsize::new(0), most_after_stop: AtomicUsize::new(0) }
}
}
impl Accelerator for Fake {
fn name(&self) -> String {
"fake".into()
}
fn comp_frame(&self, r: &Renderer, comp: ItemId, t: Tick) -> Option<Image> {
Some(r.comp_frame_cpu(comp, t))
}
fn comp_frame_yuv420(&self, r: &Renderer, comp: ItemId, t: Tick, bg: [f32; 3]) -> Option<Vec<u8>> {
if !self.yuv {
return None;
}
if self.yuv_frames.fetch_add(1, Ordering::SeqCst) >= self.stop_after {
self.yuv_frames.fetch_sub(1, Ordering::SeqCst);
// Stopped: every frame still asks first, so asks that overlap are frames rendering at once.
let now = self.asking.fetch_add(1, Ordering::SeqCst) + 1;
self.most_after_stop.fetch_max(now, Ordering::SeqCst);
std::thread::sleep(std::time::Duration::from_millis(30));
self.asking.fetch_sub(1, Ordering::SeqCst);
return None;
}
Some(effectcraft_raster::yuv::yuv420_over(&r.comp_frame_cpu(comp, t), bg))
}
fn available(&self) -> bool {
self.yuv_frames.load(Ordering::SeqCst) < self.stop_after
}
fn miss_gate(&self) -> Option<std::sync::Arc<std::sync::atomic::AtomicBool>> {
self.deferred.then(Default::default)
}
fn supports_effect(&self, _id: &str) -> bool {
false
}
fn effects(&self, _chain: &[effectcraft_render::FxStep], _buf: &effectcraft_effects::Buf, _levels: Option<f32>) -> Option<effectcraft_effects::Buf> {
None
}
}
fn out_dir(name: &str) -> PathBuf {
let d = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/test-out/export").join(name);
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).expect("mkdir");
d
}
fn project() -> (Project, ItemId) {
let mut p = Project::default();
p.settings.gpu_acceleration = true;
let mut comp = Comp::new(64, 48, FrameRate::new(10, 1), Tick::from_seconds_f64(0.6));
comp.background = [0.1, 0.1, 0.4];
let sid = p.add_item("Red", Label::Red, None, ItemKind::Solid(Solid { color: [1.0, 0.2, 0.0], width: 30, height: 20, pixel_aspect: 1.0 }));
let mut l = build::layer(&mut p, &comp, "Red", LayerSource::Solid { item: sid }, (30, 20), None);
l.in_point = comp.frame_rate.tick_of(2);
comp.layers.push(l);
let cid = p.add_item("Comp 1", Label::Sandstone, None, ItemKind::Comp(comp.into()));
(p, cid)
}
fn run(p: &Project, cid: ItemId, accel: Option<&dyn Accelerator>, s: &RenderSettings, path: &Path) -> Vec<u8> {
let om = OutputModule { bitrate_kbps: 4000, ..OutputModule::for_format(OutputFormat::H264) };
let path = path.to_string_lossy().to_string();
let job = Job {
project: p,
footage: &NoFootage,
expr: None,
accel,
comp: cid,
settings: s,
output: &om,
path: &path,
sink: None,
options: Default::default(),
nested_switches: true,
};
export(&job, &mut |_| true).expect("export");
std::fs::read(&path).expect("read")
}
#[test]
fn accelerator_planes_encode_like_the_cpu_conversion() {
let d = out_dir("h264-gpu-yuv");
let (mut p, cid) = project();
// Across more than one 16-frame batch so the second batch renders beside the encoder.
p.comp_mut(cid).unwrap().duration = Tick::from_seconds_f64(2.0);
let s = RenderSettings { time_span: TimeSpan::LengthOfComp, ..Default::default() };
let yuv = Fake::new(true, false, usize::MAX);
let rgba = Fake::new(false, false, usize::MAX);
let yuv_mp4 = d.join("yuv.mp4");
let a = run(&p, cid, Some(&yuv), &s, &yuv_mp4);
let b = run(&p, cid, Some(&rgba), &s, &d.join("rgba.mp4"));
let c = run(&p, cid, None, &s, &d.join("cpu.mp4"));
assert_eq!(yuv.yuv_frames.load(Ordering::SeqCst), 20, "every frame came from the accelerator");
assert!(a == b && b == c, "the three files differ");
let f = effectcraft_media::probe(&yuv_mp4).expect("probe yuv.mp4");
assert_eq!((f.width, f.height), (64, 48));
assert_eq!(f.frame_rate.frame_at(f.duration - Tick(1)) + 1, 20);
let pool = effectcraft_media::MediaPool::new();
for k in 0..20 {
let img = pool.frame_at(&f, f.frame_rate.tick_of(k)).expect("decode frame");
assert_eq!((img.width, img.height), (64, 48));
}
}
#[test]
fn frames_the_accelerator_cannot_take_fall_back_to_rgba() {
let d = out_dir("h264-gpu-yuv-fallback");
let (p, cid) = project();
let base = RenderSettings { time_span: TimeSpan::LengthOfComp, ..Default::default() };
// Renders at 62×47, encodes at 62×46 (the codec size): planes of the render would not fit.
let odd = RenderSettings { resolution: 31.0 / 32.0, ..base.clone() };
let yuv = Fake::new(true, false, usize::MAX);
let a = run(&p, cid, Some(&yuv), &odd, &d.join("yuv.mp4"));
let c = run(&p, cid, None, &odd, &d.join("cpu.mp4"));
assert_eq!(yuv.yuv_frames.load(Ordering::SeqCst), 0);
assert!(a == c);
}
/// With deferred readbacks the export keeps rendering frames in passes (`Cx::frames`), never
/// through the blocking per-frame path the planes use.
#[test]
fn deferred_accelerators_keep_the_pass_based_path() {
let d = out_dir("h264-gpu-yuv-deferred");
let (p, cid) = project();
let s = RenderSettings { time_span: TimeSpan::LengthOfComp, ..Default::default() };
let deferred = Fake::new(true, true, usize::MAX);
let a = run(&p, cid, Some(&deferred), &s, &d.join("deferred.mp4"));
let c = run(&p, cid, None, &s, &d.join("cpu.mp4"));
assert_eq!(deferred.yuv_frames.load(Ordering::SeqCst), 0, "no planes asked for");
assert!(a == c);
}
/// An accelerator that stops for good mid-export (`available` false): the frames left render
/// on the CPU side by side, not one at a time under the GPU's frames-in-flight cap (as
/// `Cx::frames` does), and the file is still the CPU's.
#[test]
fn frames_after_the_accelerator_stops_render_on_the_cpu() {
let d = out_dir("h264-gpu-yuv-stops");
let (p, cid) = project();
let s = RenderSettings { time_span: TimeSpan::LengthOfComp, ..Default::default() };
let stops = Fake::new(true, false, 1);
// Enough threads for frames to overlap wherever the machine runs it.
let pool = rayon::ThreadPoolBuilder::new().num_threads(4).build().unwrap();
let a = pool.install(|| run(&p, cid, Some(&stops), &s, &d.join("stops.mp4")));
let c = run(&p, cid, None, &s, &d.join("cpu.mp4"));
assert_eq!(stops.yuv_frames.load(Ordering::SeqCst), 1, "planes only until the stop");
assert!(stops.most_after_stop.load(Ordering::SeqCst) > 1, "frames after the stop rendered one at a time");
assert!(a == c);
}
+1
View File
@@ -1,6 +1,7 @@
//! The crate's integration tests, one binary (each module is a former `tests/<name>.rs`).
mod audio_sample_positions;
mod h264_gpu_yuv;
mod hevc_av1_roundtrip;
#[cfg(not(target_arch = "wasm32"))]
mod mov_audio_sample_positions;
+4
View File
@@ -126,6 +126,10 @@ const LIGHT_BYTES: usize = 6 * 16;
/// Composite a prepared Classic 3D run over `canvas`. `None` when it cannot run here (the
/// caller draws it on the CPU).
pub(crate) fn draw_run(e: &mut Enc, run: &Run3d, canvas: &GpuImage) -> Option<GpuImage> {
// FXC (DirectX 12) may fail to build the kernel (#613): the caller renders the run on the CPU.
if !e.g.has_kernel("classic3d") {
return None;
}
let (w, h) = (canvas.width, canvas.height);
let boxes: Vec<[i64; 4]> = run.planes.iter().filter(|p| p.draw).map(|p| p.bbox).collect();
if boxes.is_empty() {
+85 -10
View File
@@ -30,7 +30,8 @@ const ENTRIES: &[&str] = &[
const EFFECT_ENTRIES: &[&str] = &["box_h", "box_v", "directional", "glow_bright", "glow_combine", "shadow_make", "shadow_combine", "pointwise"];
/// Entry points that also bind group 1 (four read-only storage buffers; see
/// [`Enc::dispatch_ext`]).
/// [`Enc::dispatch_ext`]). Like the effect kernels, one that fails to build (FXC on DirectX 12
/// can't unroll `classic3d`'s loops, #613) leaves its work to the CPU.
const EXT_ENTRIES: &[&str] = &["classic3d"];
/// Pixel format of every working texture: premultiplied RGBA, 32-bit float (32 bpc headroom;
@@ -384,6 +385,12 @@ pub struct GpuContext {
pub(crate) particle_states: crate::particles::StatesCell,
/// Deferred readbacks (a browser worker's WebGPU device), see [`crate::deferred`].
pub(crate) deferred: Option<Arc<crate::deferred::Deferred>>,
/// The adapter is a software rasteriser (llvmpipe, WARP): its float maths follows the
/// CPU's exactly, so the YUV tests hold it to exact parity.
#[cfg_attr(not(test), allow(dead_code))]
pub(crate) software: bool,
/// Export frame → YUV 4:2:0 kernel (built on first use).
pub(crate) yuv: std::sync::OnceLock<Result<crate::yuv::YuvPipe, String>>,
readbacks: Arc<crate::readback::Readbacks>,
}
@@ -437,7 +444,7 @@ pub(crate) fn init_resource<T>(device: &wgpu::Device, label: &str, create: impl
/// Browser scope completion is asynchronous. Preserve the existing browser constructor;
/// never block its event loop or introduce a guard across an asynchronous boundary here.
#[cfg(target_arch = "wasm32")]
fn init_resource<T>(_device: &wgpu::Device, _label: &str, create: impl FnOnce() -> T) -> Result<T, String> {
pub(crate) fn init_resource<T>(_device: &wgpu::Device, _label: &str, create: impl FnOnce() -> T) -> Result<T, String> {
Ok(create())
}
@@ -616,15 +623,21 @@ impl GpuContext {
})
};
// The compositor's own kernels are required; an effect kernel that doesn't build is
// left out, and its effects render on the CPU (see `Enc::take_missing`).
let mut pipelines = ENTRIES
.iter()
.chain(crate::adv3d::SKY_KERNELS)
.map(|e| (e, &layout))
.chain(EXT_ENTRIES.iter().map(|e| (e, &layout_ext)))
.map(|(e, layout)| Ok((*e, build(e, layout)?)))
.collect::<Result<HashMap<_, _>, String>>()?;
// left out, and its effects render on the CPU (see `Enc::take_missing`). So is a
// missing Classic 3D kernel: its 3D runs render on the CPU.
let mut pipelines = ENTRIES.iter().chain(crate::adv3d::SKY_KERNELS).map(|e| Ok((*e, build(e, &layout)?))).collect::<Result<HashMap<_, _>, String>>()?;
let mut unavailable = vec![];
for e in EXT_ENTRIES {
match build(e, &layout_ext) {
Ok(p) => {
pipelines.insert(*e, p);
}
Err(why) => {
log::warn!("gpu {name}: kernel {e} unavailable, its 3D layers render on the CPU: {why}");
unavailable.push((*e, why));
}
}
}
for e in EFFECT_ENTRIES
.iter()
.chain(crate::fx_color::KERNELS)
@@ -731,6 +744,8 @@ impl GpuContext {
transfers: Default::default(),
zeros: Default::default(),
adv3d: std::sync::OnceLock::new(),
yuv: std::sync::OnceLock::new(),
software: info.device_type == wgpu::DeviceType::Cpu,
adv3d_raster: adv3d_unsupported.is_none(),
particles: Default::default(),
particle_states: Default::default(),
@@ -746,6 +761,18 @@ impl GpuContext {
self.readbacks.check()
}
/// Export frame → YUV 4:2:0 kernel, initialized once and `None` when shader or pipeline creation fails.
pub(crate) fn yuv_pipe(&self) -> Option<&crate::yuv::YuvPipe> {
self.yuv
.get_or_init(|| {
crate::yuv::YuvPipe::new(&self.device).inspect_err(|e| {
log::warn!(target: "effectcraft_gpu", "yuv420 kernel unavailable: {e}");
})
})
.as_ref()
.ok()
}
/// A notification hook for the owner of a shared device's error/loss handlers.
/// Invoke it with diagnostic context when the host observes a device failure.
/// It retires this context and settles pending readbacks without replacing any
@@ -907,6 +934,11 @@ impl GpuContext {
&self.unavailable
}
/// Whether kernel `entry` was built (an effect or Classic 3D kernel can be missing, #613).
pub(crate) fn has_kernel(&self, entry: &str) -> bool {
self.pipelines.contains_key(entry)
}
/// Leave kernel `entry` out, as if it had failed to build (tests of the CPU fallback).
#[cfg(test)]
pub(crate) fn drop_kernel(&mut self, entry: &'static str) {
@@ -914,6 +946,49 @@ impl GpuContext {
self.unavailable.push((entry, "dropped by a test".into()));
}
/// A copy of this context sharing the same [`wgpu::Device`] and [`wgpu::Queue`] with
/// kernel `entry` left out, as if it had failed to build (tests of the CPU fallback
/// without opening a second device on `llvmpipe`).
#[cfg(test)]
pub(crate) fn without_kernel(&self, entry: &'static str) -> GpuContext {
let mut pipelines = self.pipelines.clone();
pipelines.remove(entry);
let mut unavailable = self.unavailable.clone();
unavailable.push((entry, "dropped by a test".into()));
GpuContext {
device: self.device.clone(),
queue: self.queue.clone(),
name: self.name.clone(),
max_dim: self.max_dim,
median_kernel: self.median_kernel,
bgl: self.bgl.clone(),
bgl_ext: self.bgl_ext.clone(),
pipelines,
unavailable,
display_bgl: self.display_bgl.clone(),
display: self.display.clone(),
display_mip: self.display_mip.clone(),
dummy_tex: self.dummy_tex.clone(),
dummy: self.dummy.clone(),
dummy_buf: self.dummy_buf.clone(),
uploads: Mutex::new(Uploads::default()),
upload_budget: std::sync::atomic::AtomicUsize::new(UPLOAD_BUDGET),
upload_full_warned: Default::default(),
pool: self.pool.clone(),
staging: Default::default(),
transfers: Default::default(),
zeros: Default::default(),
adv3d: std::sync::OnceLock::new(),
adv3d_raster: self.adv3d_raster,
particles: Default::default(),
particle_states: Default::default(),
deferred: self.deferred.clone(),
readbacks: self.readbacks.clone(),
software: self.software,
yuv: std::sync::OnceLock::new(),
}
}
/// Bytes of readback staging buffers kept for reuse (tests).
#[cfg(test)]
pub(crate) fn staging_bytes(&self) -> u64 {
+39 -13
View File
@@ -2,7 +2,6 @@
//! radii, parameter conversions), with the pixel loops as compute kernels.
use effectcraft_effects::{Buf, EffectCtx};
use effectcraft_geom::{Mat3, vec2};
use effectcraft_raster::Sampling;
use effectcraft_render::FxStep;
@@ -27,6 +26,24 @@ impl GBuf {
(p[0] * self.scale + self.offset[0], p[1] * self.scale + self.offset[1])
}
/// Buf::pad_sides: grow by transparent pixels (left, top, right, bottom).
pub fn pad_sides(&mut self, e: &mut Enc, sides: [u32; 4]) -> Option<()> {
let [l, t, r, b] = sides;
if l == 0 && t == 0 && r == 0 && b == 0 {
return Some(());
}
let (w, h) = (self.img.width.checked_add(l)?.checked_add(r)?, self.img.height.checked_add(t)?.checked_add(b)?);
if !e.g.fits(w, h) {
return None;
}
let out = e.image(w, h);
e.copy_into(&self.img, &out, l, t);
self.img = out;
self.offset[0] += l as f64;
self.offset[1] += t as f64;
Some(())
}
/// Buf::pad: grow by `pad` transparent pixels on every side.
pub fn pad(&mut self, e: &mut Enc, pad: u32) -> Option<()> {
if pad == 0 {
@@ -339,10 +356,11 @@ fn glow(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option<GBuf> {
p.f[2] = cb;
let bright = e.image(b.img.width, b.img.height);
e.pixels("glow_bright", &p, &b.img, None, &bright, None);
let s = (radius / 2.0).max(0.5);
let s = effectcraft_effects::glow_sigma(radius);
let blurred = gaussian_blur(e, &bright, s * kx, s * ky, false);
let mut p = Params::default();
p.u[0][0] = ctx.params.e("operation").min(2);
p.u[0][1] = (ctx.params.e("based") == 0) as u32;
p.f[0][0] = intensity;
let out = e.scratch(b.img.width, b.img.height);
e.pixels("glow_combine", &p, &b.img, Some(&blurred), &out, None);
@@ -384,16 +402,14 @@ fn transform(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option<GBuf> {
if ctx.env.host.is_some() && effectcraft_effects::transform_shutter(ctx).is_some() {
return None;
}
let anchor = b.to_px(ctx.params.v2("anchor"));
let pos = b.to_px(ctx.params.v2("position"));
let sh = ctx.params.f("scaleHeight");
let sw = if ctx.params.b("uniform") { sh } else { ctx.params.f("scaleWidth") };
let opacity = ctx.params.f("opacity") as f32 / 100.0;
let m = Mat3::translate(vec2(pos.0, pos.1))
* Mat3::rotate_deg(ctx.params.f("rotation"))
* Mat3::skew_deg(ctx.params.f("skew"), ctx.params.f("skewAxis"))
* Mat3::scale(vec2(sw / 100.0, sh / 100.0))
* Mat3::translate(vec2(-anchor.0, -anchor.1));
// As distort::transform: After Effects' matrix, drawn outside the layer's bounds.
if !ctx.adjustment {
let m = effectcraft_effects::transform_matrix_px(ctx.params, |p| b.to_px(p));
let sides = effectcraft_effects::transform_grow(&[m], b.img.width, b.img.height);
b.pad_sides(e, sides)?;
}
let m = effectcraft_effects::transform_matrix_px(ctx.params, |p| b.to_px(p));
let sampling = if ctx.params.e("sampling") == 1 { Sampling::Bicubic } else { Sampling::Bilinear };
let empty = e.image(b.img.width, b.img.height);
b.img = ops::warp(e, &empty, &b.img, &m, ops::WarpOptions { sampling, mode: effectcraft_color::BlendMode::Normal, opacity, seed: 0 }, None);
@@ -543,9 +559,19 @@ fn pointwise(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
let sub = (f("subSettings/subScaling") / 100.0).clamp(0.1, 1.0);
p.u[0] = [10, kind, octaves.ceil() as u32, f("evolutionOptions/seed") as u32 ^ 0x51ed];
p.u[1] = [pr.b("invert") as u32, pr.e("overflow"), mode, 0];
p.f[0] = [f("contrast") as f32 / 100.0, f("brightness") as f32 / 100.0, (sw * b.scale).max(1.0) as f32, (octaves - octaves.floor()) as f32];
p.f[0] = [
f("contrast") as f32 / 100.0,
f("brightness") as f32 / 100.0,
(sw * effectcraft_effects::NOISE_CELL * b.scale).max(1.0) as f32,
(octaves - octaves.floor()) as f32,
];
p.f[1] = [ox as f32, oy as f32, sr, cr];
p.f[2] = [f("evolution") as f32 / 360.0, f("blend") as f32 / 100.0, (f("opacity") as f32 / 100.0).clamp(0.0, 1.0), (sh * b.scale).max(1.0) as f32];
p.f[2] = [
f("evolution") as f32 / 360.0,
f("blend") as f32 / 100.0,
(f("opacity") as f32 / 100.0).clamp(0.0, 1.0),
(sh * effectcraft_effects::NOISE_CELL * b.scale).max(1.0) as f32,
];
p.f[3] = [(f("subSettings/subInfluence") as f32 / 100.0).clamp(0.0, 1.0), 1.0 / sub as f32, 0.0, 0.0];
}
_ => return None,
+31 -33
View File
@@ -207,55 +207,53 @@ fn linear_wipe(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
}
fn venetian(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
let done = ctx.params.f("completion") / 100.0;
let done = (ctx.params.f("completion") / 100.0).clamp(0.0, 1.0);
if done <= 0.0 {
return Some(b);
}
let ang = ctx.params.f("direction").to_radians();
let width = (ctx.params.f("width") * b.scale).max(1.0);
let feather = (ctx.params.f("feather") * b.scale).max(0.001);
let sigma = ctx.params.f("feather").max(0.0) * b.scale / 2.0;
// The CPU's even veil and term count (effects::venetian_cover).
if sigma > 8.0 * width {
return None;
}
let (dx, dy) = (ang.cos(), ang.sin());
let mut data = Vec::with_capacity((6 * b.img.width + 2 * b.img.height) as usize);
let (ox, oy) = b.to_px(effectcraft_effects::venetian_origin(ctx.layer_size));
let mut data = Vec::with_capacity((4 * b.img.width + 2 * b.img.height) as usize);
for x in 0..b.img.width {
let cx = ((x as f64 + 0.5) * dx).rem_euclid(width);
data.extend(hl(cx - done * width));
data.extend(hl(cx - width - done * width));
let cx = ((x as f64 + 0.5 - ox) * dx).rem_euclid(width);
data.extend(hl(cx));
data.extend(hl(cx - width));
}
for y in 0..b.img.height {
data.extend(hl(((y as f64 + 0.5) * dy).rem_euclid(width)));
data.extend(hl(((y as f64 + 0.5 - oy) * dy).rem_euclid(width)));
}
let mut p = Params::default();
p.u[0][0] = 6 * b.img.width;
p.f[0][0] = feather as f32;
p.u[0][0] = 4 * b.img.width;
p.u[0][1] = if sigma < 1e-3 { 0 } else { ((4.0 * sigma / width).ceil() as u32 + 1).clamp(1, 40) };
p.f[0] = [width as f32, (done * width) as f32, sigma as f32, 0.0];
run(e, "gen_venetian", &p, b, None, Some(&data))
}
fn radial_wipe(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
let done = ctx.params.f("completion") / 100.0;
let done = (ctx.params.f("completion") / 100.0).clamp(0.0, 1.0);
if done <= 0.0 {
return Some(b);
}
let start = ctx.params.f("startAngle");
let c = b.to_px(ctx.params.v2("center"));
let dir = ctx.params.e("wipe");
let feather = ctx.params.f("feather").max(0.01);
let edge = if dir == 2 { done * 180.0 } else { done * 360.0 };
// Rotations: the start angle, and the start angle ± the edge (where the wipe's edge lies).
let rots = [start, start + edge, start - edge].map(|r| r.to_radians().sin_cos());
let mut data = Vec::with_capacity(9 * (b.img.width + b.img.height) as usize);
for x in 0..b.img.width {
let ux = x as f64 + 0.5 - c.0;
for (s, co) in rots {
data.extend(hl(ux * co));
data.push((ux * s) as f32);
}
}
for y in 0..b.img.height {
let uy = -(y as f64 + 0.5 - c.1);
for (s, co) in rots {
data.extend(hl(uy * s));
data.push((uy * co) as f32);
}
}
let (w0, span) = effectcraft_effects::radial_wipe_wedge(ctx.params.e("wipe"), done);
let sigma = effectcraft_effects::radial_wipe_sigma(ctx.params.f("feather")) * b.scale;
let ray0 = start + w0;
let (s0, c0) = ray0.to_radians().sin_cos();
let (s1, c1) = (ray0 + span).to_radians().sin_cos();
let mut p = Params::default();
p.u[0] = [9 * b.img.width, dir.min(2), 0, 0];
p.f[0] = [edge as f32, feather as f32, 0.0, 0.0];
run(e, "gen_radial_wipe", &p, b, None, Some(&data))
p.u[0] = [(done >= 1.0) as u32, (sigma < 1e-3) as u32, 0, 0];
p.f[0] = [c.0 as f32, c.1 as f32, sigma.max(1e-3) as f32, ray0.rem_euclid(360.0) as f32];
p.f[1] = [s0 as f32, c0 as f32, s1 as f32, c1 as f32];
p.f[2][0] = span as f32;
run(e, "gen_radial_wipe", &p, b, None, None)
}
fn gradient_wipe(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
+16 -14
View File
@@ -937,11 +937,12 @@ pub(crate) fn glow(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option<GBuf> {
p.f[2] = ctx.params.color("colorB");
e.pixels("glow_bright", &p, &b.img, None, &bright, None);
}
let s = (radius / 2.0).max(0.5);
let s = effectcraft_effects::glow_sigma(radius);
let blurred = gaussian_blur(e, &bright, s * kx, s * ky, false);
let operation = ctx.params.e("operation");
let glow_op = effectcraft_effects::glow_operation(ctx);
let mut p = Params::default();
p.u[0][1] = (ctx.params.e("based") == 0) as u32;
p.f[0][0] = intensity;
let out = e.scratch(b.img.width, b.img.height);
if operation == 0 && glow_op != BlendMode::Add {
@@ -963,15 +964,7 @@ pub(crate) fn transform_blur(ctx: &EffectCtx) -> bool {
/// distort::transform_matrix.
fn transform_matrix(pr: &effectcraft_effects::Params, b: &GBuf) -> Mat3 {
let anchor = b.to_px(pr.v2("anchor"));
let pos = b.to_px(pr.v2("position"));
let sh = pr.f("scaleHeight");
let sw = if pr.b("uniform") { sh } else { pr.f("scaleWidth") };
Mat3::translate(vec2(pos.0, pos.1))
* Mat3::rotate_deg(pr.f("rotation"))
* Mat3::skew_deg(pr.f("skew"), pr.f("skewAxis"))
* Mat3::scale(vec2(sw / 100.0, sh / 100.0))
* Mat3::translate(vec2(-anchor.0, -anchor.1))
effectcraft_effects::transform_matrix_px(pr, |p| b.to_px(p))
}
/// distort::transform's motion blur: the transform at Samples instants across the shutter,
@@ -981,15 +974,24 @@ pub(crate) fn transform(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option<GBu
let host = ctx.env.host?;
let sampling = if ctx.params.e("sampling") == 1 { Sampling::Bicubic } else { Sampling::Bilinear };
let fd = 1.0 / ctx.fps();
let samples: Vec<effectcraft_effects::Params> =
(0..n).filter_map(|k| host.params_at(ctx.time + (phase + angle * k as f64 / (n - 1) as f64) / 360.0 * fd)).collect();
if samples.is_empty() {
// The CPU renders the frame sharp.
return None;
}
if !ctx.adjustment {
let ms: Vec<Mat3> = samples.iter().map(|pr| transform_matrix(pr, &b)).collect();
let sides = effectcraft_effects::transform_grow(&ms, b.img.width, b.img.height);
b.pad_sides(e, sides)?;
}
let (w, h) = (b.img.width, b.img.height);
let mut acc = e.image(w, h);
let mut count = 0u32;
for k in 0..n {
let t = ctx.time + (phase + angle * k as f64 / (n - 1) as f64) / 360.0 * fd;
let Some(pr) = host.params_at(t) else { continue };
for pr in &samples {
let op = pr.f("opacity") as f32 / 100.0;
let empty = e.image(w, h);
let one = ops::warp(e, &empty, &b.img, &transform_matrix(&pr, &b), ops::WarpOptions { sampling, mode: BlendMode::Normal, opacity: op, seed: 0 }, None);
let one = ops::warp(e, &empty, &b.img, &transform_matrix(pr, &b), ops::WarpOptions { sampling, mode: BlendMode::Normal, opacity: op, seed: 0 }, None);
let sum = e.image(w, h);
e.pixels("fxs_add", &Params::default(), &acc, Some(&one), &sum, None);
acc = sum;
+20
View File
@@ -83,6 +83,7 @@ mod ops;
mod particles;
mod readback;
mod walk;
mod yuv;
use std::sync::Arc;
@@ -339,6 +340,25 @@ impl Accelerator for Gpu {
img
}
fn comp_frame_yuv420(&self, r: &Renderer, comp: ItemId, t: Tick, bg: [f32; 3]) -> Option<Vec<u8>> {
self.ctx.check_health().ok()?;
if !self.ctx.can_readback() || self.ctx.deferred.is_some() {
return None;
}
// Sizes the kernel can't take fall back before any GPU work, not after a render.
let (w, h) = walk::frame_size(r, comp, t)?;
if !yuv::supported(w, h) || !yuv::fits_limits(w, h, &self.ctx.device.limits()) {
return None;
}
let yp = self.ctx.yuv_pipe()?;
let mut e = Enc::new(&self.ctx);
let img = walk::render(&mut e, r, comp, t)?;
let planes = yuv::convert(&mut e, yp, &img, bg)?;
// A device fault seen while the frame recorded makes the planes untrustworthy.
self.ctx.check_health().ok()?;
Some(planes)
}
fn supports_effect(&self, id: &str) -> bool {
self.ctx.check_health().is_ok() && self.ctx.can_readback() && effects::supports(id)
}
+7 -9
View File
@@ -255,16 +255,14 @@ fn occlusion(c: u32, p: vec3<f32>, l: u32) -> vec3<f32> {
}
let tr = pl.mat1.y;
let dark = li.shadow.x;
var o = vec3<f32>(1.0);
for (var k = 0; k < 3; k++) {
var st = 0.0;
if (px.w > 0.0) {
st = clamp(px[k] / px.w, 0.0, 1.0);
}
let through = (1.0 - alpha) + alpha * tr * st;
o[k] = 1.0 - dark * (1.0 - through);
// Per channel, as whole vectors: FXC (DirectX 12) can't write a vector component through a
// loop index without unrolling every loop around the call, and fails (#613).
var st = vec3<f32>(0.0);
if (px.w > 0.0) {
st = clamp(px.xyz / px.w, vec3<f32>(0.0), vec3<f32>(1.0));
}
return o;
let through = (1.0 - alpha) + alpha * tr * st;
return 1.0 - dark * (1.0 - through);
}
fn shadow_of(it: u32, l: u32, p: vec3<f32>) -> vec3<f32> {
+58 -46
View File
@@ -42,6 +42,15 @@ fn gen_sum(a: vec2<f32>, b: vec2<f32>) -> f32 {
return (a.x + b.x) + (a.y + b.y);
}
// Standard normal CDF (Abramowitz & Stegun 7.1.26 erf; effects::misc::norm_cdf).
fn gen_norm_cdf(z: f32) -> f32 {
let x = abs(z) * 0.70710678;
let t = 1.0 / (1.0 + 0.3275911 * x);
let poly = t * (0.254829592 + t * (-0.284496736 + t * (1.421413741 + t * (-1.453152027 + t * 1.061405429))));
let erf = 1.0 - poly * exp(-x * x);
return select(0.5 * (1.0 - erf), 0.5 * (1.0 + erf), z >= 0.0);
}
// ---------------------------------------------------------------- Radial Blur (raster::radial_blur)
// u[0] = (zoom, samples); f[0] = (cx, cy); data = per sample (sin, cos) (spin) or k (zoom).
@@ -67,7 +76,10 @@ fn gen_radial_blur(@builtin(global_invocation_id) gid: vec3<u32>) {
let cs = data[2u * i + 1u];
s = vec2<f32>(c.x + v.x * cs - v.y * sn, c.y + v.x * sn + v.y * cs);
}
acc += sample_bilinear(src, s.x, s.y);
// Edge samples repeat (raster::radial_blur).
let hi = vec2<f32>(f32(dims.x), f32(dims.y)) - vec2<f32>(0.5);
let sc = clamp(s, vec2<f32>(0.5), max(hi, vec2<f32>(0.5)));
acc += sample_bilinear(src, sc.x, sc.y);
}
textureStore(out, p, acc / f32(n));
}
@@ -235,9 +247,9 @@ fn gen_linear_wipe(@builtin(global_invocation_id) gid: vec3<u32>) {
textureStore(out, p, textureLoad(src, p, 0) * k);
}
// Venetian Blinds. Per column (6 floats): (cx − done·w), (cx − w − done·w), (cx − w) as
// (hi, lo), with cx = ((x + 0.5)·dx) mod w; per row (2 floats): ((y + 0.5)·dy) mod w.
// u[0].x = row table offset; f[0].x = feather.
// Venetian Blinds (effects::venetian_cover). Per column (4 floats): cx, (cx − w) as (hi, lo),
// with cx = ((x + 0.5 − ox)·dx) mod w; per row (2 floats): ((y + 0.5 − oy)·dy) mod w.
// u[0] = (row table offset, Gaussian terms per side, 0 = hard edge); f[0] = (w, band, σ).
@compute @workgroup_size(16, 16)
fn gen_venetian(@builtin(global_invocation_id) gid: vec3<u32>) {
let dims = out_dims();
@@ -245,34 +257,33 @@ fn gen_venetian(@builtin(global_invocation_id) gid: vec3<u32>) {
if (p.x >= dims.x || p.y >= dims.y) {
return;
}
let col = 6u * u32(p.x);
let col = 4u * u32(p.x);
let row = gen_hl(P.u[0].x + 2u * u32(p.y));
// (d mod w) wraps once the column and row terms reach w.
let wrapped = gen_sum(gen_hl(col + 4u), row) >= 0.0;
var t: f32;
if (wrapped) {
t = gen_sum(gen_hl(col + 2u), row);
} else {
t = gen_sum(gen_hl(col), row);
var d = gen_sum(gen_hl(col), row);
if (gen_sum(gen_hl(col + 2u), row) >= 0.0) {
d = gen_sum(gen_hl(col + 2u), row);
}
let k = clamp(t / P.f[0].x + 0.5, 0.0, 1.0);
textureStore(out, p, textureLoad(src, p, 0) * k);
let w = P.f[0].x;
let band = P.f[0].y;
let sigma = P.f[0].z;
let n = i32(P.u[0].y);
var t = 0.0;
if (n == 0) {
t = select(0.0, 1.0, d < band);
} else {
for (var k = -n; k <= n; k++) {
let u = d + f32(k) * w;
t += gen_norm_cdf(u / sigma) - gen_norm_cdf((u - band) / sigma);
}
}
let cover = clamp(t, 0.0, 1.0);
textureStore(out, p, textureLoad(src, p, 0) * (1.0 - cover));
}
// Angle (degrees, clockwise from up) of the pixel vector rotated by rotation `r` (0 = start,
// 1 = start + edge, 2 = start − edge), from the per-column / per-row tables of Radial Wipe.
fn gen_psi(r: u32, x: u32, y: u32) -> f32 {
let c = 9u * x + 3u * r;
let w = P.u[0].x + 9u * y + 3u * r;
// x' = ux·cos R − uy·sin R (cancels at the edge), y' = uy·cos R + ux·sin R.
let xr = (data[c] - data[w]) + (data[c + 1u] - data[w + 1u]);
let yr = data[w + 2u] + data[c + 2u];
return atan2(xr, yr) * GEN_DEG;
}
// Radial Wipe. data: per column 9 floats (for each rotation: ux·cos R (hi, lo), ux·sin R),
// per row 9 floats (uy·sin R (hi, lo), uy·cos R); u[0] = (row offset, wipe);
// f[0] = (edge, feather).
// Radial Wipe (effects::misc::radial_wipe_alpha). u[0] = (fully wiped, hard edge);
// f[0] = (centre x, centre y, σ, first ray in degrees); f[1] = (sin, cos) of both rays;
// f[2].x = span in degrees.
@compute @workgroup_size(16, 16)
fn gen_radial_wipe(@builtin(global_invocation_id) gid: vec3<u32>) {
let dims = out_dims();
@@ -280,30 +291,31 @@ fn gen_radial_wipe(@builtin(global_invocation_id) gid: vec3<u32>) {
if (p.x >= dims.x || p.y >= dims.y) {
return;
}
let x = u32(p.x);
let y = u32(p.y);
let dir = P.u[0].y;
let edge = P.f[0].x;
var a = gen_psi(0u, x, y);
if (a < 0.0) {
a += 360.0;
}
var base: f32;
if (dir == 0u || (dir == 2u && a <= 180.0)) {
base = a - edge;
if (abs(base) < 90.0) {
base = gen_psi(1u, x, y);
}
} else {
base = (360.0 - a) - edge;
if (abs(base) < 90.0) {
base = -gen_psi(2u, x, y);
var k = 0.0;
if (P.u[0].x == 0u) {
let ux = f32(p.x) + 0.5 - P.f[0].x;
let uy = f32(p.y) + 0.5 - P.f[0].y;
var a = atan2(ux, -uy) * GEN_DEG - P.f[0].w;
a = a - 360.0 * floor(a / 360.0);
let wiped = a < P.f[2].x;
let d = min(gen_ray_dist(ux, uy, P.f[1].x, P.f[1].y), gen_ray_dist(ux, uy, P.f[1].z, P.f[1].w));
if (P.u[0].y != 0u) {
k = select(1.0, 0.0, wiped);
} else {
k = gen_norm_cdf(select(d, -d, wiped) / P.f[0].z);
}
}
let k = clamp(base / P.f[0].y + 0.5, 0.0, 1.0);
textureStore(out, p, textureLoad(src, p, 0) * k);
}
// Distance from (ux, uy) to the ray from the centre in direction (s, −c) (y down).
fn gen_ray_dist(ux: f32, uy: f32, s: f32, c: f32) -> f32 {
if (ux * s - uy * c > 0.0) {
return abs(ux * c + uy * s);
}
return length(vec2<f32>(ux, uy));
}
// Gradient Wipe: aux = the placed gradient layer. u[0].x = invert; f[0] = (done, softness).
@compute @workgroup_size(16, 16)
fn gen_gradient_wipe(@builtin(global_invocation_id) gid: vec3<u32>) {
+13 -3
View File
@@ -922,18 +922,28 @@ fn fxs_glow_map(@builtin(global_invocation_id) gid: vec3<u32>) {
let k = clamp((l - thr) / max(1.0 - thr, 1e-3), 0.0, 1.0);
let t = glow_ab_t(l);
let c = vec3<f32>(curve_eval(P.f[1].x, t), curve_eval(P.f[1].y, t), curve_eval(P.f[1].z, t));
textureStore(out, p, vec4<f32>(c * k * a, k * a));
// Based on the colour channels the glow covers the layer's whole alpha (effects::misc::glow).
var ga = a;
if (P.u[0].y != 0u) {
ga = k * a;
}
textureStore(out, p, vec4<f32>(c * k * a, ga));
}
// On Top with another Glow Operation: blend_pixel(mode, original, glow · intensity, 0).
// aux = the blurred glow; u[0].x = blend mode id; f[0].x = intensity
// aux = the blurred glow; u[0].x = blend mode id; u[0].y = based on alpha (intensity also
// scales the glow's alpha); f[0].x = intensity
@compute @workgroup_size(16, 16)
fn fxs_glow_op(@builtin(global_invocation_id) gid: vec3<u32>) {
let p = vec2<i32>(gid.xy);
if (!fxs_inside(p)) {
return;
}
let g = textureLoad(aux, p, 0) * P.f[0].x;
let gs = textureLoad(aux, p, 0);
var g = vec4<f32>(gs.xyz * P.f[0].x, gs.w);
if (P.u[0].y != 0u) {
g.w = gs.w * P.f[0].x;
}
var o = blend_pixel(P.u[0].x, textureLoad(src, p, 0), g, 0.0);
o.w = clamp(o.w, 0.0, 1.0);
textureStore(out, p, o);
+20 -4
View File
@@ -380,19 +380,31 @@ fn glow_bright(@builtin(global_invocation_id) gid: vec3<u32>) {
l = a;
}
let k = clamp((l - thr) / max(1.0 - thr, 1e-3), 0.0, 1.0);
// Based on the colour channels the glow covers the layer's whole alpha (effects::misc::glow).
var ga = a;
if (P.u[0].y != 0u) {
ga = k * a;
}
if (P.u[0].x != 0u) {
let t = glow_ab_t(l);
let ca = P.f[1].xyz;
let cb = P.f[2].xyz;
let c = ca + (cb - ca) * t;
textureStore(out, p, vec4<f32>(c * k * a, k * a));
textureStore(out, p, vec4<f32>(c * k * a, ga));
} else if (P.u[0].y != 0u) {
textureStore(out, p, vec4<f32>(px.xyz * k, ga));
} else {
textureStore(out, p, px * k);
// Original colours, per channel (effects::misc::glow_bright_channel): gain 8.0 is
// effects::GLOW_GAIN, 0.0611 is effects::GLOW_KNEE (checked by the test
// glow_constants_match_the_cpu).
let c = max(8.0 * (px.xyz / a - vec3<f32>(thr - 0.0611)), vec3<f32>(0.0));
textureStore(out, p, vec4<f32>(c * a, ga));
}
}
// Glow composite: original `src` with the blurred glow `aux`. u[0].x = 0 on top, 1 behind,
// 2 none (glow only); f[0].x = intensity.
// 2 none (glow only); u[0].y = based on alpha (intensity also scales the glow's alpha);
// f[0].x = intensity.
@compute @workgroup_size(16, 16)
fn glow_combine(@builtin(global_invocation_id) gid: vec3<u32>) {
let dims = out_dims();
@@ -401,7 +413,11 @@ fn glow_combine(@builtin(global_invocation_id) gid: vec3<u32>) {
return;
}
var o = textureLoad(src, p, 0);
let g = textureLoad(aux, p, 0) * P.f[0].x;
let gs = textureLoad(aux, p, 0);
var g = vec4<f32>(gs.xyz * P.f[0].x, gs.w);
if (P.u[0].y != 0u) {
g.w = gs.w * P.f[0].x;
}
if (P.u[0].x == 2u) {
o = g;
} else if (P.u[0].x == 1u) {
+96
View File
@@ -0,0 +1,96 @@
// Finished frame → 8-bit BT.709 limited-range 4:2:0 (Y, U, V planes packed 4 bytes per word),
// composited over the comp background: the GPU twin of `Image::to_rgba8_over` followed by
// export's `rgba_to_yuv420`, operation for operation (same order of float operations, the same
// rounding: `as u8` truncation of `x * 255 + 0.5`, `f32::round` half away from zero).
struct YP {
// width, height, chroma width, Y words
size: vec4<u32>,
bg: vec4<f32>,
}
@group(0) @binding(0) var<uniform> Y: YP;
@group(0) @binding(1) var frame: texture_2d<f32>;
@group(0) @binding(2) var<storage, read_write> outw: array<u32>;
// NaN by its bits: drivers may assume arithmetic never sees NaN.
fn is_nan(x: f32) -> bool {
return (bitcast<u32>(x) & 0x7fffffffu) > 0x7f800000u;
}
// `(c.clamp(0, 1) * 255 + 0.5) as u8`; the CPU's `as u8` turns NaN into 0.
fn q8(c: f32, nan: bool) -> f32 {
if (nan) {
return 0.0;
}
return floor(clamp(c, 0.0, 1.0) * 255.0 + 0.5);
}
// f32::round for x >= 0 (half away from zero).
fn round_pos(x: f32) -> f32 {
let f = floor(x);
return select(f, f + 1.0, x - f >= 0.5);
}
fn px(x: u32, y: u32) -> vec3<f32> {
let p = textureLoad(frame, vec2<i32>(i32(x), i32(y)), 0);
// A NaN alpha makes every channel NaN on the CPU (k is NaN).
let na = is_nan(p.w);
let k = select(1.0 - clamp(p.w, 0.0, 1.0), 0.0, na);
let c = vec3<f32>(q8(p.x + Y.bg.x * k, na || is_nan(p.x)), q8(p.y + Y.bg.y * k, na || is_nan(p.y)), q8(p.z + Y.bg.z * k, na || is_nan(p.z)));
return c / 255.0;
}
fn luma(c: vec3<f32>) -> f32 {
return 0.2126 * c.x + 0.7152 * c.y + 0.0722 * c.z;
}
fn y8(l: f32) -> u32 {
return u32(clamp(round_pos(16.0 + 219.0 * l), 1.0, 254.0));
}
// One invocation: 4 chroma samples = 8 × 2 pixels.
@compute @workgroup_size(8, 8)
fn yuv420(@builtin(global_invocation_id) gid: vec3<u32>) {
let w = Y.size.x;
let h = Y.size.y;
let cw = Y.size.z;
if (gid.x * 4u >= cw || gid.y * 2u >= h) {
return;
}
let x0 = gid.x * 8u;
let cy = gid.y;
var uw = 0u;
var vw = 0u;
var yw = array<u32, 4>(0u, 0u, 0u, 0u);
for (var q = 0u; q < 4u; q++) {
// Accumulate in the CPU's order: row 0 left, right; row 1 left, right.
var a0 = 0.0;
var a1 = 0.0;
for (var dy = 0u; dy < 2u; dy++) {
for (var dx = 0u; dx < 2u; dx++) {
let x = x0 + q * 2u + dx;
let c = px(x, cy * 2u + dy);
let l = luma(c);
let byte = q * 2u + dx;
yw[dy * 2u + byte / 4u] |= y8(l) << (8u * (byte % 4u));
a0 += (c.z - l) / 1.8556;
a1 += (c.x - l) / 1.5748;
}
}
let u = u32(clamp(round_pos(128.0 + 224.0 * a0 / 4.0), 1.0, 254.0));
let v = u32(clamp(round_pos(128.0 + 224.0 * a1 / 4.0), 1.0, 254.0));
uw |= u << (8u * q);
vw |= v << (8u * q);
}
let r0 = (cy * 2u * w + x0) / 4u;
let r1 = ((cy * 2u + 1u) * w + x0) / 4u;
outw[r0] = yw[0];
outw[r0 + 1u] = yw[1];
outw[r1] = yw[2];
outw[r1 + 1u] = yw[3];
let cwords = cw / 4u * (h / 2u);
let ci = (cy * cw) / 4u + gid.x;
outw[Y.size.w + ci] = uw;
outw[Y.size.w + cwords + ci] = vw;
}
+10
View File
@@ -958,6 +958,16 @@ fn effects_whose_kernels_failed_render_on_the_cpu() {
}
}
/// WGSL cannot read the CPU's constants, so Glow's bright pass repeats them as literals; this
/// ties them to `effects::GLOW_GAIN` / `GLOW_KNEE` (runs without a GPU).
#[test]
fn glow_constants_match_the_cpu() {
use effectcraft_effects::{GLOW_GAIN, GLOW_KNEE};
let kernels = include_str!("shaders/kernels.wgsl");
let want = format!("max({GLOW_GAIN:?} * (px.xyz / a - vec3<f32>(thr - {GLOW_KNEE:?})), vec3<f32>(0.0))");
assert!(kernels.contains(&want), "Glow's bright pass in kernels.wgsl is not `{want}`");
}
/// The GPU Tint gives After Effects 2026's greys for full-frame solids at its defaults (sRGB
/// working space, not linearised): Rec. 601 weights, as the CPU (`color_fx::luma_601`).
#[test]
+12 -7
View File
@@ -148,23 +148,28 @@ fn opengl_adapters_get_cpu_compositing_at_once() {
assert!(t.elapsed() < std::time::Duration::from_secs(1), "{:?}", t.elapsed());
}
/// #613: FXC (DirectX 12's default shader compiler) builds Classic 3D, so the compositor starts
/// on DirectX 12 with its 3D kernel, and initialization leaves the caller's error scope and the
/// host's error handler untouched. Ignored by default: FXC compiles every kernel, which takes
/// minutes. Skips where there is no DirectX 12 adapter.
#[test]
#[cfg(target_os = "windows")]
#[ignore = "requires the reproduced Windows DX12/FXC pointwise compiler failure; no adapter skips"]
fn reproduced_fxc_pointwise_failure_returns_error_without_escaping_or_notifying_host() {
#[ignore = "slow: FXC compiles every kernel"]
fn fxc_builds_classic3d_and_initialization_errors_stay_scoped() {
let mut desc = wgpu::InstanceDescriptor::new_without_display_handle();
desc.backends = wgpu::Backends::DX12;
desc.backend_options.dx12.shader_compiler = wgpu::Dx12Compiler::Fxc;
let (adapter, device, queue) = native_device(desc, true).expect("required DX12 device");
let Some((adapter, device, queue)) = native_device(desc, false) else { return };
assert_eq!(adapter.get_info().backend, wgpu::Backend::Dx12);
let host = count_host_errors(&device);
let outer = device.push_error_scope(wgpu::ErrorFilter::Validation);
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| GpuContext::new(&adapter, device.clone(), queue)));
let error = match result.expect("FXC compiler failure must not escape the constructor") {
Err(error) => error,
Ok(_) => panic!("the platform-specific FXC failure no longer reproduces; reassess this diagnostic"),
let ctx = match result.expect("an FXC compiler failure must not escape the constructor") {
Ok(ctx) => ctx,
Err(error) => panic!("the compositor starts on DirectX 12 with FXC: {}", brief(error)),
};
assert!(error.contains("GPU initialization (pointwise)") && error.contains("FXC D3DCompile"), "{}", brief(error));
let missing: Vec<&str> = ctx.unavailable_kernels().iter().map(|(e, _)| *e).collect();
assert!(ctx.has_kernel("classic3d"), "FXC builds classic3d (unavailable: {missing:?})");
assert!(pollster::block_on(outer.pop()).is_none(), "constructor must drain its own scopes and leave the caller's scope clean");
assert_eq!(host.load(Ordering::SeqCst), 0);
valid_pipeline(&device);
+32 -2
View File
@@ -11,7 +11,7 @@ use effectcraft_project::{BitDepth, Comp, ItemKind, Layer, LayerSource, LightKin
use effectcraft_render::{Backend, RenderOpts, Renderer};
use effectcraft_time::{FrameRate, Tick};
use crate::tests::{Pattern, Scene, check, compare_at, gpu, n, opts, set};
use crate::tests::{Pattern, Scene, check, compare_at, diff, gpu, n, opts, set, tolerance};
const W: u32 = 160;
const H: u32 = 100;
@@ -209,6 +209,10 @@ fn the_gpu_draws_classic_3d_runs_itself() {
assert_eq!(prep.planes.iter().filter(|p| p.draw).count(), 3);
let mut e = crate::context::Enc::new(g.context());
let canvas = e.image(W, H);
if !g.context().has_kernel("classic3d") {
// FXC (DirectX 12) can't build the kernel (#613); the CPU fallback is tested below.
return;
}
let out = crate::classic3d::draw_run(&mut e, &prep, &canvas).expect("the kernel draws the run");
let img = e.download(&out).unwrap();
let mut cpu = effectcraft_raster::Image::new(W, H);
@@ -253,7 +257,9 @@ fn bokeh_depth_of_field_runs_on_the_gpu() {
assert!(prep.planes.iter().filter(|p| p.draw).all(|p| p.dof.is_some()), "{label}: depth of field deferred to the GPU");
let mut e = crate::context::Enc::new(g.context());
let canvas = e.image(W, H);
assert!(crate::classic3d::draw_run(&mut e, &prep, &canvas).is_some(), "{label}: drawn on the GPU");
if g.context().has_kernel("classic3d") {
assert!(crate::classic3d::draw_run(&mut e, &prep, &canvas).is_some(), "{label}: drawn on the GPU");
}
}
}
}
@@ -288,3 +294,27 @@ fn two_d_layer_with_a_three_d_track_matte() {
}
}
}
/// #613: FXC on DirectX 12 fails to build `classic3d` (it can't unroll its loops). The
/// compositor still starts without it, lists it, and 3D runs render on the CPU, matching the
/// CPU render.
#[test]
fn classic_3d_runs_render_on_the_cpu_without_the_kernel() {
let Some(base) = gpu() else {
return;
};
let ctx = base.context().without_kernel("classic3d");
assert!(!ctx.has_kernel("classic3d"));
let g = crate::Gpu::from_context(ctx);
for depth in [BitDepth::Bpc8, BitDepth::Bpc32] {
let mut s = crossing(depth, BlendMode::Normal);
camera(&mut s, [30.0, 20.0, -260.0], None);
let cpu = Renderer::new(&s.p, &Pattern, RenderOpts { backend: Backend::Cpu, ..opts() }).comp_frame_cpu(s.cid, Tick::ZERO);
let mut r = Renderer::new(&s.p, &Pattern, RenderOpts { backend: Backend::Gpu, ..opts() });
r.accel = Some(&g);
let img = g.render(&r, s.cid, Tick::ZERO).expect("the GPU renders the frame");
let mut d = diff(&cpu, &img, tolerance(depth));
d.quantized = depth != BitDepth::Bpc32;
check(&format!("classic 3D without its kernel {depth:?}"), Some(d), 0.0);
}
}
+6
View File
@@ -17,6 +17,12 @@ use effectcraft_time::Tick;
use crate::context::{Enc, GpuImage};
use crate::ops;
/// The size [`render`] renders `comp_id` at, without rendering it.
pub(crate) fn frame_size(r: &Renderer, comp_id: ItemId, t: Tick) -> Option<(u32, u32)> {
let ctx = r.eval_ctx(comp_id, t)?;
Some(effectcraft_render::frame_size(ctx.comp.width, ctx.comp.height, r.opts.scale))
}
/// Render a comp frame into a GPU image (the canvas in the output space, as
/// `Renderer::comp_frame_cpu` returns it). `None` when something cannot run here (the caller
/// renders on the CPU instead).
+403
View File
@@ -0,0 +1,403 @@
//! Export frames converted on the device to the H.264 encoder's input (8-bit BT.709 limited
//! range 4:2:0, see `shaders/yuv.wgsl`): a 4K frame reads back 12 MB of planes instead of
//! 133 MB of RGBA f32, and the CPU skips `to_rgba8_over` + `rgba_to_yuv420`.
use wgpu::util::DeviceExt;
use crate::context::{Enc, GpuImage};
pub(crate) struct YuvPipe {
bgl: wgpu::BindGroupLayout,
pipe: wgpu::ComputePipeline,
}
impl YuvPipe {
pub(crate) fn new(device: &wgpu::Device) -> Result<YuvPipe, String> {
crate::context::init_resource(device, "yuv420", || {
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("yuv420"),
source: wgpu::ShaderSource::Wgsl(include_str!("shaders/yuv.wgsl").into()),
});
let storage =
wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Storage { read_only: false }, has_dynamic_offset: false, min_binding_size: None };
let bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("yuv420"),
entries: &[
wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::COMPUTE,
ty: wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Uniform, has_dynamic_offset: false, min_binding_size: None },
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 1,
visibility: wgpu::ShaderStages::COMPUTE,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: false },
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
},
wgpu::BindGroupLayoutEntry { binding: 2, visibility: wgpu::ShaderStages::COMPUTE, ty: storage, count: None },
],
});
let layout =
device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { label: Some("yuv420"), bind_group_layouts: &[Some(&bgl)], immediate_size: 0 });
let pipe = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
label: Some("yuv420"),
layout: Some(&layout),
module: &module,
entry_point: Some("yuv420"),
compilation_options: Default::default(),
cache: None,
});
YuvPipe { bgl, pipe }
})
}
}
/// The frame sizes the kernel handles: width a multiple of 8 (4 chroma samples per
/// invocation), height even.
pub(crate) fn supported(w: u32, h: u32) -> bool {
w > 0 && h > 0 && w.is_multiple_of(8) && h.is_multiple_of(2)
}
/// Bytes of the planes: Y `w×h`, then U and V `w/2 × h/2`.
pub(crate) fn planes_len(w: u32, h: u32) -> u64 {
w as u64 * h as u64 + 2 * ((w / 2) as u64 * (h / 2) as u64)
}
/// Whether the [`planes_len`] storage and staging buffers for `w×h` fit within `limits`.
pub(crate) fn fits_limits(w: u32, h: u32, limits: &wgpu::Limits) -> bool {
let size = planes_len(w, h);
size <= limits.max_storage_buffer_binding_size && size <= limits.max_buffer_size
}
/// Record the conversion of `img` over `bg` into a new `MAP_READ` buffer of
/// [`planes_len`] bytes and submit it. `None`: the size is not [`supported`], exceeds the
/// device's buffer limits, or buffer allocation failed.
pub(crate) fn record(e: &mut Enc, yp: &YuvPipe, img: &GpuImage, bg: [f32; 3]) -> Option<wgpu::Buffer> {
let (w, h) = (img.width, img.height);
let g = e.g;
if !supported(w, h) || !fits_limits(w, h, &g.device.limits()) {
return None;
}
let cw = w / 2;
let size = planes_len(w, h);
let mut params = [0u8; 32];
// w * h fits in u32: both are at most the texture limit (16384).
for (i, v) in [w, h, cw, w * h / 4].into_iter().enumerate() {
params[i * 4..i * 4 + 4].copy_from_slice(&v.to_le_bytes());
}
for (i, v) in [bg[0], bg[1], bg[2], 1.0].into_iter().enumerate() {
params[16 + i * 4..16 + i * 4 + 4].copy_from_slice(&v.to_le_bytes());
}
let (out, stg, ub) = match crate::context::init_resource(&g.device, "yuv420 buffers", || {
let out = g.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("yuv420 out"),
size,
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC,
mapped_at_creation: false,
});
let stg = g.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("yuv420 staging"),
size,
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let ub = g.device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("yuv420 params"),
contents: &params,
usage: wgpu::BufferUsages::UNIFORM,
});
(out, stg, ub)
}) {
Ok(buffers) => buffers,
Err(err) => {
log::warn!(target: "effectcraft_gpu", "yuv420 buffers: {err}");
return None;
}
};
let view = img.texture.create_view(&Default::default());
let group = g.device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("yuv420"),
layout: &yp.bgl,
entries: &[
wgpu::BindGroupEntry { binding: 0, resource: ub.as_entire_binding() },
wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::TextureView(&view) },
wgpu::BindGroupEntry { binding: 2, resource: out.as_entire_binding() },
],
});
{
let enc = e.encoder();
{
let mut pass = enc.begin_compute_pass(&wgpu::ComputePassDescriptor { label: Some("yuv420"), timestamp_writes: None });
pass.set_pipeline(&yp.pipe);
pass.set_bind_group(0, &group, &[]);
pass.dispatch_workgroups((w / 8).div_ceil(8), (h / 2).div_ceil(8), 1);
}
enc.copy_buffer_to_buffer(&out, 0, &stg, 0, size);
}
e.submit();
Some(stg)
}
/// Convert `img` over `bg` and read the planes back, waiting for the device.
pub(crate) fn convert(e: &mut Enc, yp: &YuvPipe, img: &GpuImage, bg: [f32; 3]) -> Option<Vec<u8>> {
let stg = record(e, yp, img, bg)?;
let n = planes_len(img.width, img.height) as usize;
e.g.read_buffer(&stg, n, n, 1).map_err(|err| log::error!(target: "effectcraft_gpu", "yuv420 readback: {err}")).ok()
}
#[cfg(test)]
pub(crate) mod tests {
use effectcraft_raster::Image;
/// The export's conversion (`to_rgba8_over` + `rgba_to_yuv420`), the reference the kernel
/// is held to.
pub(crate) fn cpu(img: &Image, bg: [f32; 3]) -> Vec<u8> {
effectcraft_raster::yuv::yuv420_over(img, bg)
}
/// Premultiplied noise with some out-of-range values, opaque pixels, exact 8-bit levels and
/// a few NaN and infinite components (the CPU writes 0 for NaN).
pub(crate) fn noise(w: u32, h: u32, seed: u32) -> Image {
let mut img = Image::new(w, h);
let mut s = seed | 1;
let mut rnd = || {
s ^= s << 13;
s ^= s >> 17;
s ^= s << 5;
s as f32 / u32::MAX as f32
};
for (i, p) in img.data.iter_mut().enumerate() {
let a = if i % 7 == 0 { 1.0 } else { rnd() };
*p = if i % 97 == 3 {
[f32::NAN, 0.5, f32::INFINITY, a]
} else if i % 101 == 5 {
[0.2, 0.3, 0.4, f32::NAN]
} else if i % 103 == 7 {
[f32::NEG_INFINITY, 0.1, 0.9, f32::INFINITY]
} else if i % 5 == 0 {
let q = (i % 256) as f32 / 255.0;
[q, 1.0 - q, q * 0.5, 1.0]
} else {
[rnd() * a * 1.1 - 0.02, rnd() * a, rnd() * a, a]
};
}
img
}
/// Check `got` against the CPU's `want`. Software adapters (llvmpipe, WARP) must match
/// exactly. A hardware driver may contract multiply-adds or divide less exactly, which
/// moves a value near a rounding boundary by one level (seen on an RTX 3090 under Vulkan):
/// there every byte must be within one level and at most `budget` of them off (about 2–3×
/// the rate measured on the 3090). A wrong formula, rounding mode or NaN rule fails either
/// way on software adapters.
pub(crate) fn assert_parity(g: &crate::Gpu, got: &[u8], want: &[u8], budget: f64, what: &str) {
assert_eq!(got.len(), want.len(), "{what}: length");
let diff: Vec<usize> = (0..got.len()).filter(|&i| got[i] != want[i]).collect();
let max = diff.iter().map(|&i| (got[i] as i32 - want[i] as i32).abs()).max().unwrap_or(0);
eprintln!("{what} on {}: {} of {} bytes differ (max {max})", g.ctx.name, diff.len(), got.len());
if g.ctx.software {
assert!(diff.is_empty(), "{what}: a software adapter must match exactly, first diffs at {:?}", &diff[..diff.len().min(10)]);
return;
}
assert!(max <= 1, "{what}: a byte is {max} levels off, first diffs at {:?}", &diff[..diff.len().min(10)]);
assert!(diff.len() as f64 <= budget * got.len() as f64, "{what}: {} of {} bytes off by one", diff.len(), got.len());
}
#[test]
fn yuv420_matches_cpu() {
let Some(g) = crate::tests::gpu() else { return };
let yp = super::YuvPipe::new(&g.ctx.device).expect("yuv420 pipe");
for (w, h, bg) in [(264u32, 150u32, [0.05, 0.1, 0.2]), (8, 2, [1.0, 1.0, 1.0]), (1928, 6, [0.0, 0.0, 0.0])] {
let img = noise(w, h, w ^ (h << 16));
let src = g.ctx.upload_image(&img).expect("upload");
let got = super::convert(&mut crate::context::Enc::new(&g.ctx), &yp, &src, bg).expect("readback");
assert_parity(g, &got, &cpu(&img, bg), 0.00005, &format!("noise {w}x{h}"));
}
}
#[test]
fn unsupported_sizes_are_declined() {
assert!(super::supported(8, 2));
for (w, h) in [(0, 2), (8, 0), (12, 2), (8, 3), (7, 4)] {
assert!(!super::supported(w, h), "{w}x{h}");
}
assert_eq!(super::planes_len(8, 2), 16 + 2 * 4);
}
#[test]
fn sizes_exceeding_buffer_limits_are_declined() {
let def = wgpu::Limits::default();
assert!(super::fits_limits(3840, 2160, &def));
// 16384×16384 planes are 384 MiB, over default max_storage_buffer_binding_size (128 MiB)
// and max_buffer_size (256 MiB).
assert!(!super::fits_limits(16384, 16384, &def));
let size = super::planes_len(64, 64);
assert!(super::fits_limits(64, 64, &wgpu::Limits { max_storage_buffer_binding_size: size, max_buffer_size: size, ..def.clone() }));
assert!(!super::fits_limits(64, 64, &wgpu::Limits { max_storage_buffer_binding_size: size - 1, max_buffer_size: size, ..def.clone() }));
assert!(!super::fits_limits(64, 64, &wgpu::Limits { max_storage_buffer_binding_size: size, max_buffer_size: size - 1, ..def.clone() }));
let Some(_) = crate::tests::gpu() else { return };
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).expect("adapter");
let required_limits = wgpu::Limits { max_storage_buffer_binding_size: 4096, max_buffer_size: 4096, ..def };
let (device, queue) = pollster::block_on(adapter.request_device(&crate::context::device_descriptor(required_limits))).expect("limited device");
let g = crate::Gpu::new(&adapter, device, queue).expect("limited Gpu");
let yp = super::YuvPipe::new(&g.ctx.device).expect("yuv420 pipe");
let src = g.ctx.upload_image(&noise(64, 64, 1)).expect("upload");
assert!(super::convert(&mut crate::context::Enc::new(&g.ctx), &yp, &src, [0.0; 3]).is_none());
assert!(g.ctx.check_health().is_ok());
}
#[test]
fn unsupported_sizes_are_not_converted() {
let Some(g) = crate::tests::gpu() else { return };
let yp = super::YuvPipe::new(&g.ctx.device).expect("yuv420 pipe");
let src = g.ctx.upload_image(&noise(12, 4, 3)).expect("upload");
assert!(super::convert(&mut crate::context::Enc::new(&g.ctx), &yp, &src, [0.0; 3]).is_none());
}
}
#[cfg(test)]
mod accel_tests {
use effectcraft_color::Label;
use effectcraft_project::{BitDepth, Comp, ItemKind};
use effectcraft_render::{Accelerator, Backend, RenderOpts, Renderer};
use effectcraft_time::{FrameRate, Tick};
use crate::tests::{Pattern, Scene, opts};
fn scene(w: u32, h: u32) -> Scene {
let mut s = Scene::new(BitDepth::Bpc8);
let comp = Comp::new(w, h, FrameRate::FPS_30, Tick::from_seconds_f64(2.0));
s.cid = s.p.add_item("Comp yuv", Label::Sandstone, None, ItemKind::Comp(comp.clone().into()));
s.comp = comp;
let l = s.footage(w + 7, h + 3);
s.push(l);
let sol = s.solid([0.9, 0.2, 0.4], w / 2, h / 3);
s.push(sol);
s
}
/// The accelerator's YUV frame is the export's CPU conversion of the very frame
/// `comp_frame` returns.
#[test]
fn comp_frame_yuv420_is_the_cpu_conversion_of_comp_frame() {
let Some(g) = crate::tests::gpu() else { return };
let s = scene(96, 60);
let mut r = Renderer::new(&s.p, &Pattern, RenderOpts { backend: Backend::Gpu, ..opts() });
r.accel = Some(g);
let bg = [0.2, 0.3, 0.4];
let rgba = g.comp_frame(&r, s.cid, Tick::ZERO).expect("rgba frame");
let yuv = g.comp_frame_yuv420(&r, s.cid, Tick::ZERO, bg).expect("yuv frame");
super::tests::assert_parity(g, &yuv, &super::tests::cpu(&rgba, bg), 0.006, "comp frame");
}
#[test]
fn odd_sized_comps_fall_back_to_the_cpu() {
let Some(g) = crate::tests::gpu() else { return };
let s = scene(97, 61);
let mut r = Renderer::new(&s.p, &Pattern, RenderOpts { backend: Backend::Gpu, ..opts() });
r.accel = Some(g);
assert!(g.comp_frame(&r, s.cid, Tick::ZERO).is_some());
assert!(g.comp_frame_yuv420(&r, s.cid, Tick::ZERO, [0.0; 3]).is_none());
}
#[test]
fn comp_frame_yuv420_falls_back_when_pipeline_init_failed() {
let Some(_) = crate::tests::gpu() else { return };
let g = crate::Gpu::try_headless().expect("headless Gpu");
let _ = g.ctx.yuv.set(Err("simulated yuv420 init failure".into()));
let s = scene(96, 60);
let mut r = Renderer::new(&s.p, &Pattern, RenderOpts { backend: Backend::Gpu, ..opts() });
r.accel = Some(&g);
assert!(g.comp_frame_yuv420(&r, s.cid, Tick::ZERO, [0.0; 3]).is_none());
}
}
#[cfg(test)]
mod tie_tests {
use effectcraft_raster::Image;
fn lum(r: u8, g: u8, b: u8) -> f32 {
let (r, g, b) = (r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0);
0.2126 * r + 0.7152 * g + 0.0722 * b
}
fn px(p: [u8; 3]) -> [f32; 4] {
[p[0] as f32 / 255.0, p[1] as f32 / 255.0, p[2] as f32 / 255.0, 1.0]
}
/// Opaque pixels whose Y (or whose 2×2 block's U or V) lands exactly on .5 in the CPU's f32
/// math: a kernel that rounds ties the other way moves most of them.
#[test]
fn rounding_ties_match_cpu() {
let mut ties: Vec<[u8; 3]> = vec![];
for r in 0..=255u8 {
for g in 0..=255u8 {
for b in 0..=255u8 {
let x = 16.0 + 219.0 * lum(r, g, b);
if x - x.floor() == 0.5 {
ties.push([r, g, b]);
}
}
}
}
assert!(!ties.is_empty());
let mut blocks: Vec<[[u8; 3]; 4]> = vec![];
let mut s = 12345u32;
let mut rnd = || {
s ^= s << 13;
s ^= s >> 17;
s ^= s << 5;
(s >> 24) as u8
};
// Bounded so the search stays quick in debug builds (a few hundred blocks).
for _ in 0..8_000_000 {
let blk = [[rnd(), rnd(), rnd()], [rnd(), rnd(), rnd()], [rnd(), rnd(), rnd()], [rnd(), rnd(), rnd()]];
let (mut a0, mut a1) = (0f32, 0f32);
for p in blk {
let l = lum(p[0], p[1], p[2]);
a0 += (p[2] as f32 / 255.0 - l) / 1.8556;
a1 += (p[0] as f32 / 255.0 - l) / 1.5748;
}
let u = 128.0 + 224.0 * a0 / 4.0;
let v = 128.0 + 224.0 * a1 / 4.0;
if u - u.floor() == 0.5 || v - v.floor() == 0.5 {
blocks.push(blk);
}
}
eprintln!("Y tie pixels: {}, chroma tie blocks: {}", ties.len(), blocks.len());
assert!(!blocks.is_empty());
let Some(g) = crate::tests::gpu() else { return };
let yp = super::YuvPipe::new(&g.ctx.device).expect("yuv420 pipe");
let w = 1024usize;
let h = (ties.len().div_ceil(w) + 1) & !1;
let mut img = Image::new(w as u32, h as u32);
for (i, p) in img.data.iter_mut().enumerate() {
*p = px(ties[i % ties.len()]);
}
let src = g.ctx.upload_image(&img).expect("upload");
let got = super::convert(&mut crate::context::Enc::new(&g.ctx), &yp, &src, [0.0; 3]).expect("readback");
super::tests::assert_parity(g, &got, &super::tests::cpu(&img, [0.0; 3]), 0.02, "Y ties");
let cw = 512usize;
let rows = blocks.len().div_ceil(cw);
let mut img = Image::new((cw * 2) as u32, (rows * 2) as u32);
for cy in 0..rows {
for cx in 0..cw {
let b = blocks[(cy * cw + cx) % blocks.len()];
for (k, p) in b.iter().enumerate() {
let (x, y) = (cx * 2 + k % 2, cy * 2 + k / 2);
img.data[y * cw * 2 + x] = px(*p);
}
}
}
let src = g.ctx.upload_image(&img).expect("upload");
let got = super::convert(&mut crate::context::Enc::new(&g.ctx), &yp, &src, [0.0; 3]).expect("readback");
super::tests::assert_parity(g, &got, &super::tests::cpu(&img, [0.0; 3]), 0.06, "chroma ties");
}
}
+35 -10
View File
@@ -533,12 +533,15 @@ pub struct WiggleParams {
pub correlation: f64,
/// Temporal phase in degrees (360° = one wiggle period).
pub phase_deg: f64,
/// Spatial phase in degrees: slides each subpath's pattern of displacements along its points
/// (360° = once around the subpath).
pub spatial_phase_deg: f64,
pub seed: f64,
}
impl Default for WiggleParams {
fn default() -> Self {
WiggleParams { size: 10.0, detail: 10.0, smooth: true, speed: 2.0, correlation: 50.0, phase_deg: 0.0, seed: 0.0 }
WiggleParams { size: 10.0, detail: 10.0, smooth: true, speed: 2.0, correlation: 50.0, phase_deg: 0.0, spatial_phase_deg: 0.0, seed: 0.0 }
}
}
@@ -568,9 +571,16 @@ fn noise(seed: u64, idx: u64, t: f64) -> f64 {
v.clamp(-1.0, 1.0)
}
/// Catmull-Rom interpolation between `p1` (f = 0) and `p2` (f = 1).
fn catmull_rom(p0: f64, p1: f64, p2: f64, p3: f64, f: f64) -> f64 {
0.5 * (2.0 * p1 + (-p0 + p2) * f + (2.0 * p0 - 5.0 * p1 + 4.0 * p2 - p3) * f * f + (-p0 + 3.0 * p1 - 3.0 * p2 + p3) * f * f * f)
}
/// Wiggle Paths: subdivides the paths (`detail` points per segment) and displaces every point by
/// smooth random noise of up to `size` pixels that evolves over `time_s` seconds at `speed`
/// wiggles per second. Deterministic for a given seed and time.
/// wiggles per second. The spatial phase moves each point's own share of the noise along the
/// subpath, and a fraction of a point is interpolated between its neighbors. Deterministic for a
/// given seed and time.
pub fn wiggle(paths: &[BezPath], w: &WiggleParams, time_s: f64) -> Vec<BezPath> {
if w.size == 0.0 {
return paths.to_vec();
@@ -580,6 +590,7 @@ pub fn wiggle(paths: &[BezPath], w: &WiggleParams, time_s: f64) -> Vec<BezPath>
let t = time_s * w.speed + w.phase_deg / 360.0;
let corr = (w.correlation / 100.0).clamp(0.0, 1.0);
let shared = Vec2::new(noise(seed, u64::MAX, t), noise(seed, u64::MAX - 1, t));
let turns = if w.spatial_phase_deg.is_finite() { (w.spatial_phase_deg / 360.0).rem_euclid(1.0) } else { 0.0 };
let mut idx: u64 = 0;
paths
.iter()
@@ -587,14 +598,28 @@ pub fn wiggle(paths: &[BezPath], w: &WiggleParams, time_s: f64) -> Vec<BezPath>
let mut out = BezPath::new();
for sub in subs(p) {
let s = resample(&sub, m);
let disp: Vec<Vec2> = s
.iter()
.map(|_| {
let own = Vec2::new(noise(seed, idx * 2, t + idx as f64 * 0.37), noise(seed, idx * 2 + 1, t + idx as f64 * 0.61));
idx += 1;
(shared * corr + own * (1.0 - corr)) * w.size
})
.collect();
let n = s.len();
let first = idx;
let own = |j: usize| {
let i = first + j as u64;
Vec2::new(noise(seed, i * 2, t + i as f64 * 0.37), noise(seed, i * 2 + 1, t + i as f64 * 0.61))
};
let along = |j: usize| {
if turns == 0.0 {
return own(j);
}
let u = (j as f64 + turns * n as f64).rem_euclid(n as f64);
let k = u.floor();
let f = u - k;
let k = k as usize % n;
if f == 0.0 {
return own(k);
}
let (p0, p1, p2, p3) = (own((k + n - 1) % n), own(k), own((k + 1) % n), own((k + 2) % n));
Vec2::new(catmull_rom(p0.x, p1.x, p2.x, p3.x, f).clamp(-1.0, 1.0), catmull_rom(p0.y, p1.y, p2.y, p3.y, f).clamp(-1.0, 1.0))
};
let disp: Vec<Vec2> = (0..n).map(|j| (shared * corr + along(j) * (1.0 - corr)) * w.size).collect();
idx += n as u64;
emit(&s, &disp, sub.closed, w.smooth, 1.0 / 3.0, &mut out);
}
out
+32
View File
@@ -317,6 +317,38 @@ fn wiggle_full_correlation_translates() {
assert_eq!(zero[0], s[0]);
}
/// Spatial Phase moves the pattern of displacements along the path: 360° is once around, and a
/// whole number of points' worth gives each point the displacement of the point that far ahead.
#[test]
fn wiggle_spatial_phase_moves_the_pattern_along_the_path() {
let s = [sq(0.0, 0.0, 100.0)];
let w = WiggleParams { detail: 3.0, correlation: 0.0, smooth: false, ..Default::default() };
// Displacements are proportional to Size: twice the size less once the size.
let disp = |w: WiggleParams| -> Vec<Vec2> {
let a = on_curve(&wiggle(&s, &w, 0.4)[0]);
let b = on_curve(&wiggle(&s, &WiggleParams { size: w.size * 2.0, ..w }, 0.4)[0]);
a.iter().zip(&b).map(|(a, b)| *b - *a).take(16).collect()
};
let d0 = disp(w);
let n = d0.len();
assert_eq!(n, 16);
for k in [1, 5] {
let dk = disp(WiggleParams { spatial_phase_deg: 360.0 * k as f64 / n as f64, ..w });
for j in 0..n {
let e = d0[(j + k) % n];
assert!(close(dk[j].x, e.x, 1e-9) && close(dk[j].y, e.y, 1e-9), "{k} ahead, point {j}: {:?} vs {e:?}", dk[j]);
}
}
// Whole turns either way leave the path as it is.
let base = wiggle(&s, &w, 0.4);
assert_eq!(wiggle(&s, &WiggleParams { spatial_phase_deg: 360.0, ..w }, 0.4), base);
assert_eq!(wiggle(&s, &WiggleParams { spatial_phase_deg: -720.0, ..w }, 0.4), base);
// Half a point ahead, the displacements change and stay within Size.
let half = disp(WiggleParams { spatial_phase_deg: 180.0 / n as f64, ..w });
assert!(half.iter().zip(&d0).any(|(h, d)| (*h - *d).hypot() > 1e-3));
assert!(half.iter().all(|h| h.x.abs() <= 10.0 + 1e-9 && h.y.abs() <= 10.0 + 1e-9));
}
// ---------------------------------------------------------------- merge / booleans
fn inclusion_exclusion(a: BezPath, b: BezPath) {
+28 -1
View File
@@ -4,7 +4,7 @@ use effectcraft_color::{BlendMode, Label};
use effectcraft_keyframe::{Gradient, ShapePath, TextDoc, Value};
use effectcraft_time::Tick;
use crate::props::{GroupKind, MaskMode, ParamUi, PropGroup, Property};
use crate::props::{GroupKind, MaskMode, Node, ParamUi, PropGroup, Property};
use crate::{Comp, Layer, LayerId, LayerSource, LightKind, Project, Switches};
/// Id allocator borrowing the project's counter.
@@ -594,6 +594,7 @@ pub fn shape_simple_op(ids: &mut Ids, kind: &str) -> Option<PropGroup> {
.with(ids.prop("speed", "Wiggles/Second", Value::Scalar(2.0)))
.with(ids.prop("correlation", "Correlation", Value::Scalar(50.0)).with_ui(ParamUi::Percent))
.with(ids.prop("phase", "Temporal Phase", Value::Scalar(0.0)).with_ui(ParamUi::Angle))
.with(wiggle_spatial_phase(ids))
.with(ids.prop("seed", "Random Seed", Value::Scalar(0.0))),
"merge" => indexed(ids.group("merge", "Merge Paths 1")).with(ids.prop("mode", "Mode", Value::Enum(0)).with_ui(popup(&[
"Merge",
@@ -607,6 +608,32 @@ pub fn shape_simple_op(ids: &mut Ids, kind: &str) -> Option<PropGroup> {
Some(g)
}
/// Wiggle Paths' Spatial Phase.
fn wiggle_spatial_phase(ids: &mut Ids) -> Property {
ids.prop("spatialPhase", "Spatial Phase", Value::Scalar(0.0)).with_ui(ParamUi::Angle)
}
/// Adds the properties that shape path operations have gained since a project was saved (Wiggle
/// Paths' Spatial Phase), at their defaults, to the operations in `contents` and its nested
/// groups. Returns whether anything was added. It walks the nested groups with a work list and
/// does not recurse.
pub fn upgrade_shape_contents(ids: &mut Ids, contents: &mut PropGroup) -> bool {
let mut changed = false;
let mut todo = vec![contents];
while let Some(group) = todo.pop() {
for n in &mut group.children {
let Node::Group(g) = n else { continue };
if g.match_id == "wiggle" && !g.children.iter().any(|c| c.match_id() == "spatialPhase") {
let at = g.children.iter().position(|c| c.match_id() == "phase").map_or(g.children.len(), |i| i + 1);
g.children.insert(at, wiggle_spatial_phase(ids).into());
changed = true;
}
todo.push(g);
}
}
changed
}
// ---------------------------------------------------------------- camera / light / 3D
pub fn camera_options(ids: &mut Ids, zoom: f64) -> PropGroup {
+32
View File
@@ -333,3 +333,35 @@ fn cineon_settings_convert_log_code_values() {
assert_eq!(serde_json::from_str::<Footage>(&saved).unwrap().cineon, on);
assert_eq!(serde_json::from_value::<Footage>(plain).unwrap().cineon, CineonSettings::default());
}
/// A Wiggle Paths 100 000 groups deep gets Spatial Phase on a 2 MiB stack.
#[test]
fn upgrading_deeply_nested_shape_groups_fits_a_small_stack() {
let n = 100_000;
let mut next = 1;
let mut contents = build::shape_simple_op(&mut Ids(&mut next), "wiggle").unwrap();
contents.children.retain(|c| c.match_id() != "spatialPhase");
for _ in 0..n {
let uid = Ids(&mut next).alloc();
contents = crate::PropGroup::new(uid, "group", "Group 1").with(contents);
}
let h = std::thread::Builder::new()
.stack_size(2 << 20)
.spawn(move || {
let added = build::upgrade_shape_contents(&mut Ids(&mut next), &mut contents);
(added, contents)
})
.unwrap();
let (added, mut g) = h.join().unwrap();
assert!(added);
// One level at a time: dropping the whole tree would recurse as deep as it is nested.
let mut depth = 0;
while g.match_id == "group" {
let Some(crate::Node::Group(child)) = g.children.pop() else { panic!("a nested group") };
g = child;
depth += 1;
}
assert_eq!(depth, n);
let ids: Vec<&str> = g.children.iter().map(|c| c.match_id()).collect();
assert_eq!(ids, ["size", "detail", "points", "speed", "correlation", "phase", "spatialPhase", "seed"]);
}
+3 -1
View File
@@ -247,7 +247,9 @@ pub fn radial_blur(img: &Image, center: (f64, f64), amount: f64, zoom: bool) ->
let (s, c) = (t * amount).to_radians().sin_cos();
(center.0 + vx * c - vy * s, center.1 + vx * s + vy * c)
};
let p = img.sample_bilinear(sx, sy);
// Samples outside the image repeat its edge: After Effects' Radial Blur keeps a
// layer's edges (cases 713, 829) instead of fading them toward transparency.
let p = img.sample_bilinear(sx.clamp(0.5, (img.width as f64 - 0.5).max(0.5)), sy.clamp(0.5, (img.height as f64 - 0.5).max(0.5)));
for ch in 0..4 {
acc[ch] += p[ch];
}
+1
View File
@@ -12,6 +12,7 @@ pub mod flow;
pub mod inpaint;
pub mod scopes;
pub mod warp;
pub mod yuv;
use effectcraft_color::{BlendMode, blend_pixel};
use rayon::prelude::*;
+60
View File
@@ -0,0 +1,60 @@
//! 8-bit BT.709 limited-range 4:2:0, the H.264 export's input. Shared by the export and by the
//! GPU compositor's tests, which hold its kernel to this function.
use rayon::prelude::*;
/// `v / 255` for every 8-bit `v` (the same values as dividing).
pub const UNIT8: [f32; 256] = {
let mut t = [0.0; 256];
let mut i = 0;
while i < 256 {
t[i] = i as f32 / 255.0;
i += 1;
}
t
};
/// `v.round()` for `0 <= v < 2^23`, the range the colour conversions round in: truncation and
/// the fraction are exact there, so this is `f32::round` without its libm call.
#[inline(always)]
pub fn round_pos(v: f32) -> f32 {
let t = v as u32 as f32;
if v - t >= 0.5 { t + 1.0 } else { t }
}
/// BT.709 limited-range 8-bit 4:2:0 from RGBA8 (2×2 chroma average; alpha ignored).
pub fn rgba_to_yuv420(rgba: &[u8], w: usize, h: usize, y: &mut Vec<u8>, u: &mut Vec<u8>, v: &mut Vec<u8>) {
let (cw, ch) = (w.div_ceil(2), h.div_ceil(2));
y.resize(w * h, 0);
u.resize(cw * ch, 0);
v.resize(cw * ch, 0);
y.par_chunks_mut(w * 2).zip(u.par_chunks_mut(cw).zip(v.par_chunks_mut(cw))).enumerate().for_each(|(cy, (yr, (ur, vr)))| {
let mut acc = vec![(0f32, 0f32, 0f32); cw];
for (dy, yrow) in yr.chunks_exact_mut(w).enumerate() {
let src = &rgba[(cy * 2 + dy) * w * 4..][..w * 4];
for (x, (s, yo)) in src.as_chunks::<4>().0.iter().zip(yrow.iter_mut()).enumerate() {
let (r, g, b) = (UNIT8[s[0] as usize], UNIT8[s[1] as usize], UNIT8[s[2] as usize]);
let l = 0.2126 * r + 0.7152 * g + 0.0722 * b;
*yo = round_pos(16.0 + 219.0 * l).clamp(1.0, 254.0) as u8;
let a = &mut acc[x / 2];
a.0 += (b - l) / 1.8556;
a.1 += (r - l) / 1.5748;
a.2 += 1.0;
}
}
for (cx, a) in acc.iter().enumerate() {
ur[cx] = round_pos(128.0 + 224.0 * a.0 / a.2).clamp(1.0, 254.0) as u8;
vr[cx] = round_pos(128.0 + 224.0 * a.1 / a.2).clamp(1.0, 254.0) as u8;
}
});
}
/// The H.264 export's planes of `img` over `bg` (opaque): Y, then U and V, as
/// [`crate::Image::to_rgba8_over`] + [`rgba_to_yuv420`] produce them.
pub fn yuv420_over(img: &crate::Image, bg: [f32; 3]) -> Vec<u8> {
let (mut y, mut u, mut v) = (vec![], vec![], vec![]);
rgba_to_yuv420(&img.to_rgba8_over(bg), img.width as usize, img.height as usize, &mut y, &mut u, &mut v);
y.extend_from_slice(&u);
y.extend_from_slice(&v);
y
}
+8
View File
@@ -46,6 +46,14 @@ pub trait Accelerator: Send + Sync {
/// Render a top-level comp frame (what [`Renderer::comp_frame`] returns). `None` = not
/// handled (the CPU renders it).
fn comp_frame(&self, r: &Renderer, comp: ItemId, t: Tick) -> Option<Image>;
/// Render a top-level comp frame like [`Self::comp_frame`], composite it over `bg` and
/// convert it to the H.264 export's 8-bit BT.709 limited-range 4:2:0 (Y plane, then U and V
/// at half size; see `effectcraft_raster::yuv`). Every byte is within one level of the CPU
/// conversion of the same frame (a driver's arithmetic may move a value near a rounding
/// boundary). `None` = not handled (the caller renders and converts on the CPU).
fn comp_frame_yuv420(&self, _r: &Renderer, _comp: ItemId, _t: Tick, _bg: [f32; 3]) -> Option<Vec<u8>> {
None
}
/// The accelerator implements effect `id` with the CPU effect's semantics.
fn supports_effect(&self, id: &str) -> bool;
/// Run a chain of supported effects on `buf`, clamping and quantising to `levels` after
+150 -19
View File
@@ -12,9 +12,11 @@
//! `FrameSeg::to_bytes` (keyed by their content-keyed chain key and scale, so they are
//! reused whenever the same frames, settings and prompt come back, also after a restart). Every file ends in a checksum; damaged files read as misses and
//! are deleted.
//! * Size limit with least-recently-used eviction. The in-memory index is rebuilt from the
//! folder when the cache opens (file modification times order it), so the cache survives
//! restarts; reads refresh a file's time.
//! * Size limit with least-recently-used eviction, down to 90% of the limit so the eviction scan
//! runs once per tenth of the limit written, not on every write. The limit shrinks to keep
//! free space on the cache's disk ([`DiskCache::set_disk_space`]). The in-memory index is
//! rebuilt from the folder when the cache opens (file modification times order it), so the
//! cache survives restarts; reads refresh a file's time.
//! * Safe concurrent access: writes go to a unique temporary file that is renamed into place
//! (atomic on every desktop OS), so readers in this or another process never see a partial
//! entry; a missing or damaged file is just a miss. Writes run on a background thread with a
@@ -22,7 +24,7 @@
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Condvar, Mutex};
use effectcraft_effects::Buf;
@@ -75,11 +77,15 @@ pub struct DiskStats {
pub entries: usize,
pub bytes: u64,
pub max_bytes: u64,
/// The limit in effect: `max_bytes`, or less when the disk is short of free space.
pub limit_bytes: u64,
pub hits: u64,
pub misses: u64,
pub writes: u64,
pub evictions: u64,
pub dropped: u64,
/// Writes that failed (a full disk, permissions).
pub failed: u64,
}
struct Meta {
@@ -157,15 +163,17 @@ impl DiskIndex {
v.into_iter().map(|e| e.1).collect()
}
/// Remove least recently used entries until the total fits `max` bytes; returns them (the
/// caller deletes their files).
/// When the total exceeds `max` bytes, remove least recently used entries down to 90% of
/// it (so the sort runs once per tenth of `max` written, not on every write); returns them
/// (the caller deletes their files).
pub fn evict(&mut self, max: u64) -> Vec<(Kind, u128)> {
if self.total <= max {
return vec![];
}
let target = max - max / 10;
let mut out = vec![];
for k in self.lru_order() {
if self.total <= max {
if self.total <= target {
break;
}
self.remove(k.0, k.1);
@@ -225,11 +233,16 @@ pub struct DiskCache {
dir: PathBuf,
index: Mutex<DiskIndex>,
max_bytes: AtomicU64,
/// The size free disk space allows (`u64::MAX` = unknown), see [`DiskCache::set_disk_space`].
disk_cap: AtomicU64,
/// Free space is limiting the cache (warned once per episode).
space_limited: AtomicBool,
hits: AtomicU64,
misses: AtomicU64,
writes: AtomicU64,
evictions: AtomicU64,
dropped: AtomicU64,
failed: AtomicU64,
tmp_counter: AtomicU64,
queue: Arc<(Mutex<Queue>, Condvar)>,
/// Layer buffers that took less than this to render are not written (ms).
@@ -298,16 +311,21 @@ impl DiskCache {
dir,
index: Mutex::new(DiskIndex::default()),
max_bytes: AtomicU64::new(max_bytes),
disk_cap: AtomicU64::new(u64::MAX),
space_limited: AtomicBool::new(false),
hits: AtomicU64::new(0),
misses: AtomicU64::new(0),
writes: AtomicU64::new(0),
evictions: AtomicU64::new(0),
dropped: AtomicU64::new(0),
failed: AtomicU64::new(0),
tmp_counter: AtomicU64::new(0),
queue: Arc::new((Mutex::new(Queue { jobs: Default::default(), busy: false }), Condvar::new())),
queue: Arc::new((Mutex::new(Queue { jobs: Default::default(), busy: cfg!(not(target_arch = "wasm32")) }), Condvar::new())),
min_layer_ms: AtomicU64::new(30),
});
dc.rescan();
// Shrinking a large cache deletes many files: the writer thread does it.
#[cfg(target_arch = "wasm32")]
dc.evict();
#[cfg(not(target_arch = "wasm32"))]
{
@@ -371,6 +389,32 @@ impl DiskCache {
self.evict();
}
/// The limit in effect: the setting, or less when the disk is short of free space.
pub fn limit_bytes(&self) -> u64 {
self.max_bytes().min(self.disk_cap.load(Ordering::Relaxed))
}
/// The cache's disk: `(total, available)` bytes, `None` = unknown. The cache leaves at least
/// 2 GB or 5% of the disk free, whichever is more (evicting when it must).
pub fn set_disk_space(&self, space: Option<(u64, u64)>) {
let cap = match space {
// (a disk reported as 0 bytes, such as an automount map, is unknown)
Some((total, available)) if total > 0 => {
let reserve = (2u64 << 30).max(total / 20);
let used = self.index.lock().map(|g| g.total()).unwrap_or_default();
// The cache's own files count as free: below the reserve it gives space back.
used.saturating_add(available).saturating_sub(reserve)
}
_ => u64::MAX,
};
self.disk_cap.store(cap, Ordering::Relaxed);
let limited = cap < self.max_bytes();
if self.space_limited.swap(limited, Ordering::Relaxed) != limited && limited {
log::warn!("disk cache at {}: little free disk space, limiting the cache to {} MB", self.folder().display(), cap >> 20);
}
self.evict();
}
/// Layer buffers faster to render than this are not worth a disk write.
pub fn set_min_layer_ms(&self, ms: u64) {
self.min_layer_ms.store(ms, Ordering::Relaxed);
@@ -395,11 +439,13 @@ impl DiskCache {
entries,
bytes,
max_bytes: self.max_bytes(),
limit_bytes: self.limit_bytes(),
hits: self.hits.load(Ordering::Relaxed),
misses: self.misses.load(Ordering::Relaxed),
writes: self.writes.load(Ordering::Relaxed),
evictions: self.evictions.load(Ordering::Relaxed),
dropped: self.dropped.load(Ordering::Relaxed),
failed: self.failed.load(Ordering::Relaxed),
}
}
@@ -494,30 +540,44 @@ impl DiskCache {
}
let path = self.path(kind, key);
let Some(parent) = path.parent() else { return };
if std::fs::create_dir_all(parent).is_err() {
if let Err(e) = std::fs::create_dir_all(parent) {
self.write_failed(&e);
return;
}
let data = seal(kind, payload);
let n = self.tmp_counter.fetch_add(1, Ordering::Relaxed);
let tmp = parent.join(format!("{key:032x}.tmp.{}.{n}", std::process::id()));
if std::fs::write(&tmp, &data).is_err() {
let _ = std::fs::remove_file(&tmp);
// An entry that cannot fit would be evicted straight away.
if data.len() as u64 > self.limit_bytes() {
return;
}
if std::fs::rename(&tmp, &path).is_err() {
// Make room first, so the cache never exceeds its limit (or the free-space reserve).
self.evict_to(self.limit_bytes().saturating_sub(data.len() as u64));
let n = self.tmp_counter.fetch_add(1, Ordering::Relaxed);
let tmp = parent.join(format!("{key:032x}.tmp.{}.{n}", std::process::id()));
if let Err(e) = std::fs::write(&tmp, &data).and_then(|()| std::fs::rename(&tmp, &path)) {
let _ = std::fs::remove_file(&tmp);
self.write_failed(&e);
return;
}
self.writes.fetch_add(1, Ordering::Relaxed);
if let Ok(mut g) = self.index.lock() {
g.insert(kind, key, data.len() as u64);
}
self.evict();
}
/// Count a failed write; the first one is logged (a full disk shouldn't go unnoticed).
fn write_failed(&self, e: &std::io::Error) {
if self.failed.fetch_add(1, Ordering::Relaxed) == 0 {
log::warn!("disk cache at {}: a write failed: {e}", self.folder().display());
}
}
/// Remove least recently used entries until the cache fits its limit.
fn evict(&self) {
let max = self.max_bytes();
self.evict_to(self.limit_bytes());
}
/// Remove least recently used entries until the cache fits `max` bytes.
fn evict_to(&self, max: u64) {
let victims = match self.index.lock() {
Ok(mut g) => g.evict(max),
Err(_) => return,
@@ -586,6 +646,15 @@ impl DiskCache {
#[cfg(not(target_arch = "wasm32"))]
fn writer(dc: std::sync::Weak<DiskCache>, q: Arc<(Mutex<Queue>, Condvar)>) {
let (m, cv) = &*q;
// The eviction at open (a lower limit than the folder holds), off the opening thread;
// `busy` starts set, so `flush` waits for it.
if let Some(cache) = dc.upgrade() {
cache.evict();
}
if let Ok(mut g) = m.lock() {
g.busy = false;
}
cv.notify_all();
loop {
let job = {
let Ok(mut g) = m.lock() else { return };
@@ -1030,9 +1099,9 @@ mod tests {
let per = dc.stats().bytes / 6;
// Touch 0 so it is the most recently used.
assert!(dc.get_layer(0).is_some());
dc.set_max_bytes(per * 3 + per / 2);
dc.set_max_bytes(per * 3 + per * 3 / 4);
let st = dc.stats();
assert!(st.bytes <= per * 3 + per / 2, "{st:?}");
assert!(st.bytes <= per * 3 + per * 3 / 4, "{st:?}");
assert_eq!(st.entries, 3);
assert!(dc.contains(Kind::Layer, 0), "recently used survives");
assert!(!dc.contains(Kind::Layer, 1) && !dc.contains(Kind::Layer, 2) && !dc.contains(Kind::Layer, 3));
@@ -1042,7 +1111,69 @@ mod tests {
assert_eq!(files, 3);
// New writes keep evicting.
dc.put_layer_now(99, &buf(9, 64, 64));
assert!(dc.stats().bytes <= per * 3 + per / 2);
assert!(dc.stats().bytes <= per * 3 + per * 3 / 4);
}
#[test]
fn eviction_goes_down_to_ninety_percent_in_lru_order() {
let mut ix = DiskIndex::default();
for k in 0..10u128 {
ix.insert(Kind::Layer, k, 100);
}
assert!(ix.touch(Kind::Layer, 0));
// Over 950: down to 855, least recently used first.
ix.insert(Kind::Layer, 10, 100);
assert_eq!(ix.evict(950), vec![(Kind::Layer, 1), (Kind::Layer, 2), (Kind::Layer, 3)]);
assert_eq!(ix.total(), 800);
// The slack absorbs the next write without another scan.
ix.insert(Kind::Layer, 11, 100);
assert!(ix.evict(950).is_empty());
}
#[test]
fn free_disk_space_caps_the_limit() {
let dir = tmpdir("space");
let dc = DiskCache::open(&dir, 1 << 30).unwrap();
for k in 0..4u128 {
dc.put_layer_now(k, &buf(k as u32, 64, 64));
}
let used = dc.stats().bytes;
// Unknown space: the setting alone.
dc.set_disk_space(None);
assert_eq!(dc.stats().limit_bytes, 1 << 30);
// A disk reported as 0 bytes is unknown.
dc.set_disk_space(Some((0, 0)));
assert_eq!(dc.stats().limit_bytes, 1 << 30);
// 100 GB disk, 5 GB reserved, exactly 5 GB free: the cache may not grow.
dc.set_disk_space(Some((100 << 30, 5 << 30)));
let st = dc.stats();
assert_eq!((st.max_bytes, st.limit_bytes), (1 << 30, used));
dc.put_layer_now(9, &buf(9, 64, 64));
let st = dc.stats();
assert!(st.bytes <= st.limit_bytes && st.bytes <= used - used / 10 && st.evictions > 0, "{st:?}");
assert!(dc.contains(Kind::Layer, 9), "the new entry is the most recently used");
// Below the reserve the cache gives its space back, and skips writes that can't fit.
dc.set_disk_space(Some((100 << 30, 4 << 30)));
dc.put_layer_now(10, &buf(10, 64, 64));
let st = dc.stats();
assert_eq!((st.limit_bytes, st.entries, st.bytes), (0, 0, 0), "{st:?}");
// Plenty of space again: back to the setting.
dc.set_disk_space(Some((100 << 30, 50 << 30)));
assert_eq!(dc.stats().limit_bytes, 1 << 30);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn failed_writes_are_counted() {
let dir = tmpdir("failed");
let dc = DiskCache::open(&dir, 1 << 30).unwrap();
// A file where the layers folder should be: every layer write fails.
std::fs::write(dir.join(VERSION).join(Kind::Layer.dir()), b"x").unwrap();
dc.put_layer_now(1, &buf(1, 8, 8));
dc.put_layer_now(2, &buf(2, 8, 8));
let st = dc.stats();
assert_eq!((st.failed, st.writes, st.entries), (2, 0, 0));
let _ = std::fs::remove_dir_all(&dir);
}
fn walk(d: &Path) -> usize {
+1
View File
@@ -324,6 +324,7 @@ fn collect(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, arena: &mut Vec<B
speed: ctx.f(layer, g, "speed", 2.0),
correlation: ctx.f(layer, g, "correlation", 50.0),
phase_deg: ctx.f(layer, g, "phase", 0.0),
spatial_phase_deg: ctx.f(layer, g, "spatialPhase", 0.0),
seed: ctx.f(layer, g, "seed", 0.0),
};
let t = layer.layer_time(ctx.time).seconds();
+18
View File
@@ -258,6 +258,24 @@ fn round_corners_clip_corner_pixels() {
assert!((coverage(&img) - std::f32::consts::PI * 400.0).abs() < 15.0, "{}", coverage(&img));
}
/// Wiggle Paths' Spatial Phase changes the drawn shape; a whole turn draws it as at 0°.
#[test]
fn wiggle_paths_spatial_phase_renders() {
let render = |phase: f64| {
let (p, cid) = shape_comp(|ids| {
vec![
build::shape_ellipse(ids, [60.0, 60.0], [0.0, 0.0]),
op(ids, "wiggle", &[("size", Value::Scalar(8.0)), ("correlation", Value::Scalar(0.0)), ("spatialPhase", Value::Scalar(phase))]),
build::shape_fill(ids, RED),
]
});
render_frame(&p, cid, Tick::from_seconds_f64(0.5), 1.0).data
};
let zero = render(0.0);
assert!(render(90.0) != zero, "Spatial Phase 90° draws the shape of 0°");
assert!(render(360.0) == zero, "Spatial Phase 360° draws another shape than 0°");
}
#[test]
fn wiggle_paths_vary_over_time_deterministically() {
let (p, cid) = shape_comp(|ids| {
+8
View File
@@ -209,6 +209,14 @@ const SHAPE_PROPS: &[(&str, &str, &str)] = &[
("twist", "angle", "ADBE Vector Twist Angle"),
("twist", "center", "ADBE Vector Twist Center"),
("merge", "mode", "ADBE Vector Merge Type"),
("wiggle", "size", "ADBE Vector Roughen Size"),
("wiggle", "detail", "ADBE Vector Roughen Detail"),
("wiggle", "points", "ADBE Vector Roughen Points"),
("wiggle", "speed", "ADBE Vector Temporal Freq"),
("wiggle", "correlation", "ADBE Vector Correlation"),
("wiggle", "phase", "ADBE Vector Temporal Phase"),
("wiggle", "spatialPhase", "ADBE Vector Spatial Phase"),
("wiggle", "seed", "ADBE Vector Random Seed"),
];
/// Effects: our effect id → AE match name (the documented ones; the rest keep our id).
+38
View File
@@ -270,6 +270,44 @@ fn an_undefined_name_mentioned_earlier_reports_its_use_in_the_reading_function()
assert_eq!(e.line, Some(6), "{e:?}");
}
/// Scripts reach Wiggle Paths' properties, Spatial Phase included, by their match names.
#[test]
fn wiggle_paths_properties_by_match_name() {
let mut s = session();
let o = ok(
&mut s,
r#"
var c = app.project.items.addComp("W", 200, 200, 1, 2, 30);
var sh = c.layers.addShape();
var w = sh.property("ADBE Root Vectors Group").addProperty("ADBE Vector Filter - Roughen");
w.property("ADBE Vector Spatial Phase").setValue(45);
var names = [];
for (var i = 1; i <= w.numProperties; i++) names.push(w.property(i).matchName);
[w.matchName, w.property("ADBE Vector Spatial Phase").name, names]
"#,
);
assert_eq!(
o.result,
json!([
"ADBE Vector Filter - Roughen",
"Spatial Phase",
[
"ADBE Vector Roughen Size",
"ADBE Vector Roughen Detail",
"ADBE Vector Roughen Points",
"ADBE Vector Temporal Freq",
"ADBE Vector Correlation",
"ADBE Vector Temporal Phase",
"ADBE Vector Spatial Phase",
"ADBE Vector Random Seed"
]
])
);
let (_, c) = comp_named(&s, "W");
let sh = c.layers.iter().find(|l| l.props.sub("contents").is_some()).unwrap();
assert_eq!(sh.props.prop("contents/wiggle/spatialPhase").unwrap().value, KV::Scalar(45.0));
}
#[test]
fn match_names_and_lookups() {
let mut s = session();
+21 -3
View File
@@ -510,12 +510,30 @@ fn parse_css(css: &str, sheet: &mut StyleSheet) -> Result<(), Error> {
let mut rest = css.as_str();
while let Some(open) = rest.find('{') {
let sel = rest[..open].trim();
if sel.starts_with('@') {
// Skip the whole at-rule block, matching nested braces by depth.
let mut depth = 0usize;
let mut end = None;
for (i, c) in rest[open..].char_indices() {
match c {
'{' => depth = depth.saturating_add(1),
'}' => {
depth = depth.saturating_sub(1);
if depth == 0 {
end = Some(open + i + 1);
break;
}
}
_ => {}
}
}
let Some(end) = end else { break };
rest = &rest[end..];
continue;
}
let Some(close) = rest[open..].find('}') else { break };
let body = &rest[open + 1..open + close];
rest = &rest[open + close + 1..];
if sel.starts_with('@') {
continue;
}
let mut decls = parse_decls(body);
sheet.decls = sheet.decls.saturating_add(decls.len());
if sheet.decls > MAX_CSS_DECLARATIONS {
+12
View File
@@ -422,3 +422,15 @@ fn font_size_percentage_resolves_against_the_parent_font_size() {
assert!(matches!(shapes[0].2.geom, Geom::Rect { w, .. } if w == expected), "{body}: {:?}", shapes[0].2.geom);
}
}
#[test]
fn at_rule_blocks_are_skipped_by_brace_depth() {
let mut sheet = StyleSheet::default();
parse_css("@media print { .a{fill:#ff0000} .b{fill:#0000ff} } .c{fill:red}", &mut sheet).unwrap();
assert_eq!(sheet.rules.len(), 1);
assert_eq!(sheet.rules[0].classes, vec!["c".to_string()]);
let mut sheet = StyleSheet::default();
parse_css("@media x { @supports y { .c{} } .d{} } .e{fill:red}", &mut sheet).unwrap();
assert_eq!(sheet.rules.len(), 1);
assert_eq!(sheet.rules[0].classes, vec!["e".to_string()]);
}
+51 -1
View File
@@ -67,8 +67,9 @@ pub struct Track2D {
}
impl Track2D {
/// Exclusive end frame, saturated when the track exceeds the frame index range.
pub fn end(&self) -> u32 {
self.start + self.pts.len() as u32
u32::try_from(self.pts.len()).ok().and_then(|n| self.start.checked_add(n)).unwrap_or(u32::MAX)
}
/// Position on frame `k` when the track covers it.
pub fn at(&self, k: u32) -> Option<[f64; 2]> {
@@ -483,3 +484,52 @@ pub fn point_color(id: u32) -> [f32; 3] {
// Bright, slightly desaturated.
[0.25 + 0.75 * r, 0.25 + 0.75 * g, 0.25 + 0.75 * b]
}
#[cfg(test)]
mod validation_tests {
use super::*;
fn tracks() -> CameraTracks {
CameraTracks { size: [1920.0, 1080.0], factor: 1.0, frame_duration: 1.0 / 30.0, frames: 3, ..Default::default() }
}
#[test]
fn track_end_saturates_instead_of_wrapping() {
assert_eq!(Track2D { start: u32::MAX, pts: vec![[0.0; 2]], ..Default::default() }.end(), u32::MAX);
assert_eq!(Track2D { start: 3, pts: vec![[0.0; 2]; 2], ..Default::default() }.end(), 5);
}
#[test]
fn solve_rejects_large_frame_count_before_allocating() {
let t = CameraTracks { frames: u32::MAX, ..tracks() };
let error = solve(&t, &SolveSettings::default(), None).unwrap_err();
assert!(error.0.contains("frame or track count"));
}
#[test]
fn solve_rejects_overflowing_and_out_of_clip_tracks() {
for start in [u32::MAX, 2] {
let t = CameraTracks { tracks: vec![Track2D { start, pts: vec![[0.0; 2]; 3], ..Default::default() }], ..tracks() };
let error = solve(&t, &SolveSettings::default(), None).unwrap_err();
assert!(error.0.contains("frame range"));
}
}
#[test]
fn solve_rejects_non_finite_track_positions() {
let t = CameraTracks { tracks: vec![Track2D { pts: vec![[f32::NAN, 0.0]; 3], ..Default::default() }], ..tracks() };
let error = solve(&t, &SolveSettings::default(), None).unwrap_err();
assert!(error.0.contains("finite"));
}
#[test]
fn solve_rejects_invalid_dimensions_timing_and_scale() {
for t in [
CameraTracks { size: [f64::NAN, 1080.0], ..tracks() },
CameraTracks { frame_duration: 0.0, ..tracks() },
CameraTracks { factor: f64::INFINITY, ..tracks() },
] {
assert!(solve(&t, &SolveSettings::default(), None).is_err());
}
}
}
+166 -18
View File
@@ -10,9 +10,9 @@
use rayon::prelude::*;
use super::Distortion;
use super::geometry::Pose;
use super::linalg::{M3, V3, add, cholesky_solve, dot, inv3, mm, mtv, mv, rodrigues, scale, skew, sub};
use super::{Distortion, SolveError};
/// A camera in the adjustment.
#[derive(Clone, Copy, Debug, PartialEq)]
@@ -52,6 +52,7 @@ pub struct BaOpts {
pub fixed: Vec<bool>,
/// Huber threshold (pixels).
pub huber: f64,
/// Maximum LM iterations (at most 1000).
pub max_iter: usize,
/// Adjust the shared radial distortion `(k1, k2)` too.
pub distortion: bool,
@@ -76,21 +77,89 @@ fn huber(e2: f64, k: f64) -> f64 {
if e2 <= k * k { e2 } else { 2.0 * k * e2.sqrt() - k * k }
}
/// Residual (pixels) of one observation, `None` behind the camera.
// Bound the temporary systems before any input-sized allocation. Includes dense matrices
// for the main system, trial copies and both Rayon phases, plus per-point/observation data.
const MAX_BA_BYTES: usize = 256 * 1024 * 1024;
const MAX_BA_CAMERAS: usize = 4096;
const MAX_BA_POINTS: usize = 200_000;
const MAX_BA_OBSERVATIONS: usize = 1_000_000;
fn valid_camera(c: &BaCam) -> bool {
c.f.is_finite()
&& c.f > 0.0
&& c.pose.r.iter().flatten().chain(&c.pose.c).all(|v| v.is_finite())
&& [c.dist.k1, c.dist.k2, c.dist.radius].iter().all(|v| v.is_finite())
}
fn validate(cams: &[BaCam], pts: &[V3], obs: &[BaObs], o: &BaOpts) -> Result<(), SolveError> {
if cams.len() > MAX_BA_CAMERAS || pts.len() > MAX_BA_POINTS || obs.len() > MAX_BA_OBSERVATIONS {
return Err(SolveError("bundle adjustment input exceeds the supported size".into()));
}
if !o.huber.is_finite() || o.huber <= 0.0 || o.max_iter > 1000 || matches!(o.focal, FocalMode::PerCamera(k) if !k.is_finite() || k < 0.0) {
return Err(SolveError("invalid bundle adjustment options".into()));
}
if cams.iter().any(|c| !valid_camera(c)) || pts.iter().flatten().any(|v| !v.is_finite()) {
return Err(SolveError("bundle adjustment cameras and points must be finite, with positive focal lengths".into()));
}
for ob in obs {
if cams.get(ob.cam as usize).is_none() || pts.get(ob.pt as usize).is_none() {
return Err(SolveError("bundle adjustment observation index is out of range".into()));
}
if ob.uv.iter().any(|v| !v.is_finite()) {
return Err(SolveError("bundle adjustment observations must be finite".into()));
}
}
Ok(())
}
fn matrix_len(n: usize, npts: usize, nobs: usize) -> Result<usize, SolveError> {
let threads = rayon::current_num_threads();
let build_workers = if n <= 700 && npts > 64 { threads.clamp(1, 16) } else { 0 };
let schur_workers = if n <= 1200 && npts >= 64 && threads > 1 { threads.clamp(1, 8) } else { 0 };
// The two worker phases do not coexist; allow the larger phase plus main/trial buffers.
let copies = 4 + build_workers.max(schur_workers);
let len = n.checked_mul(n);
let bytes = len
.and_then(|v| v.checked_add(n))
.and_then(|v| v.checked_mul(size_of::<f64>()))
.and_then(|v| v.checked_mul(copies))
.and_then(|v| npts.checked_mul(320).and_then(|p| v.checked_add(p)))
.and_then(|v| nobs.checked_mul(2 * 9 * size_of::<(usize, V3)>() + size_of::<BaObs>() + 2 * size_of::<u32>()).and_then(|p| v.checked_add(p)));
match (len, bytes) {
(Some(len), Some(bytes)) if bytes <= MAX_BA_BYTES => Ok(len),
_ => Err(SolveError("bundle adjustment exceeds its 256 MiB workspace budget; use fewer cameras or observations".into())),
}
}
/// Residual (pixels), or `None` for an invalid or behind-camera projection.
pub fn residual(c: &BaCam, x: V3, uv: [f64; 2]) -> Option<[f64; 2]> {
if !valid_camera(c) || x.iter().chain(&uv).any(|v| !v.is_finite()) {
return None;
}
project_residual(c, x, uv)
}
fn project_residual(c: &BaCam, x: V3, uv: [f64; 2]) -> Option<[f64; 2]> {
let p = c.pose.to_cam(x);
if p[2] <= 1e-9 {
if p[2] <= 1e-9 || p.iter().any(|v| !v.is_finite()) {
return None;
}
let q = c.dist.distort([c.f * p[0] / p[2], c.f * p[1] / p[2]]);
Some([q[0] - uv[0], q[1] - uv[1]])
let r = [q[0] - uv[0], q[1] - uv[1]];
r.iter().all(|v| v.is_finite()).then_some(r)
}
/// Total robust cost.
pub fn cost(cams: &[BaCam], pts: &[V3], obs: &[BaObs], o: &BaOpts) -> f64 {
/// Total robust cost. Rejects invalid inputs before starting parallel evaluation.
pub fn cost(cams: &[BaCam], pts: &[V3], obs: &[BaObs], o: &BaOpts) -> Result<f64, SolveError> {
validate(cams, pts, obs, o)?;
let c = validated_cost(cams, pts, obs, o);
if c.is_finite() { Ok(c) } else { Err(SolveError("bundle adjustment cost is not finite".into())) }
}
fn validated_cost(cams: &[BaCam], pts: &[V3], obs: &[BaObs], o: &BaOpts) -> f64 {
let c: f64 = obs
.par_iter()
.map(|ob| match residual(&cams[ob.cam as usize], pts[ob.pt as usize], ob.uv) {
.map(|ob| match cams.get(ob.cam as usize).zip(pts.get(ob.pt as usize)).and_then(|(c, x)| project_residual(c, *x, ob.uv)) {
Some(r) => huber(r[0] * r[0] + r[1] * r[1], o.huber),
None => BEHIND,
})
@@ -198,15 +267,21 @@ fn add_focal_prior(cams: &[BaCam], o: &BaOpts, l: &Layout, s_mat: &mut [f64], bc
}
/// Run Levenberg–Marquardt. `cams` and `pts` are updated in place.
pub fn adjust(cams: &mut [BaCam], pts: &mut [V3], obs: &[BaObs], o: &BaOpts) -> BaReport {
/// Invalid inputs or workspaces exceeding 256 MiB return an error before mutation.
pub fn adjust(cams: &mut [BaCam], pts: &mut [V3], obs: &[BaObs], o: &BaOpts) -> Result<BaReport, SolveError> {
validate(cams, pts, obs, o)?;
let l = layout(cams.len(), o);
let matrix_len = matrix_len(l.n, pts.len(), obs.len())?;
let npts = pts.len();
// Observations by point.
let mut by_pt: Vec<Vec<u32>> = vec![vec![]; npts];
for (i, ob) in obs.iter().enumerate() {
by_pt[ob.pt as usize].push(i as u32);
}
let mut cur = cost(cams, pts, obs, o);
let mut cur = validated_cost(cams, pts, obs, o);
if !cur.is_finite() {
return Err(SolveError("bundle adjustment cost is not finite".into()));
}
let initial = cur;
let t0 = web_time::Instant::now();
let mut tries = 0usize;
@@ -214,7 +289,7 @@ pub fn adjust(cams: &mut [BaCam], pts: &mut [V3], obs: &[BaObs], o: &BaOpts) ->
let mut lambda = 1e-4;
let mut iters = 0;
if l.n == 0 && o.fix_points {
return BaReport { initial_cost: initial, cost: cur, iterations: 0 };
return Ok(BaReport { initial_cost: initial, cost: cur, iterations: 0 });
}
let n = l.n;
let parallel = n <= 700;
@@ -297,12 +372,12 @@ pub fn adjust(cams: &mut [BaCam], pts: &mut [V3], obs: &[BaObs], o: &BaOpts) ->
let parts: Vec<(Vec<f64>, Vec<f64>, Vec<PtSys>)> = (0..chunks)
.into_par_iter()
.map(|t| {
let (mut s, mut b) = (vec![0.0; n * n], vec![0.0; n]);
let (mut s, mut b) = (vec![0.0; matrix_len], vec![0.0; n]);
let v = (t * per..((t + 1) * per).min(npts)).map(|j| build_point(j, &mut s, &mut b)).collect();
(s, b, v)
})
.collect();
let mut s = vec![0.0; n * n];
let mut s = vec![0.0; matrix_len];
let mut b = vec![0.0; n];
let mut sys = Vec::with_capacity(npts);
for (ps, pb, pv) in parts {
@@ -316,7 +391,7 @@ pub fn adjust(cams: &mut [BaCam], pts: &mut [V3], obs: &[BaObs], o: &BaOpts) ->
}
(s, b, sys)
} else {
let mut s = vec![0.0; n * n];
let mut s = vec![0.0; matrix_len];
let mut b = vec![0.0; n];
let sys = (0..npts).map(|j| build_point(j, &mut s, &mut b)).collect();
(s, b, sys)
@@ -376,7 +451,7 @@ pub fn adjust(cams: &mut [BaCam], pts: &mut [V3], obs: &[BaObs], o: &BaOpts) ->
let parts: Vec<(Vec<f64>, Vec<f64>)> = (0..chunks)
.into_par_iter()
.map(|t| {
let (mut ds, mut dr) = (vec![0.0; n * n], vec![0.0; n]);
let (mut ds, mut dr) = (vec![0.0; matrix_len], vec![0.0; n]);
reduce(t * per..((t + 1) * per).min(npts), &mut ds, &mut dr);
(ds, dr)
})
@@ -430,7 +505,11 @@ pub fn adjust(cams: &mut [BaCam], pts: &mut [V3], obs: &[BaObs], o: &BaOpts) ->
*x = add(*x, mv(vi, b));
});
}
let c2 = cost(&nc, &np, obs, o);
if nc.iter().any(|c| !valid_camera(c)) || np.iter().flatten().any(|v| !v.is_finite()) {
lambda *= 4.0;
continue;
}
let c2 = validated_cost(&nc, &np, obs, o);
if c2.is_finite() && c2 < cur {
cams.copy_from_slice(&nc);
pts.copy_from_slice(&np);
@@ -449,7 +528,7 @@ pub fn adjust(cams: &mut [BaCam], pts: &mut [V3], obs: &[BaObs], o: &BaOpts) ->
t0.elapsed().as_secs_f64()
);
}
return BaReport { initial_cost: initial, cost: cur, iterations: iters };
return Ok(BaReport { initial_cost: initial, cost: cur, iterations: iters });
}
break;
}
@@ -462,7 +541,7 @@ pub fn adjust(cams: &mut [BaCam], pts: &mut [V3], obs: &[BaObs], o: &BaOpts) ->
if dbg {
eprintln!("ba n={} pts={} obs={} iters={iters} tries={tries} {:.2}s cost {initial:.1} -> {cur:.1}", l.n, npts, obs.len(), t0.elapsed().as_secs_f64());
}
BaReport { initial_cost: initial, cost: cur, iterations: iters }
Ok(BaReport { initial_cost: initial, cost: cur, iterations: iters })
}
#[cfg(test)]
@@ -470,6 +549,75 @@ mod tests {
use super::*;
use crate::camtrack::geometry::Rng;
fn test_camera() -> BaCam {
BaCam { pose: Pose::IDENTITY, f: 900.0, dist: Distortion::NONE }
}
#[test]
fn cost_rejects_invalid_observation_indices() {
for (cam, pt) in [(1, 0), (0, 1)] {
let obs = [BaObs { cam, pt, uv: [0.0; 2] }];
assert!(cost(&[test_camera()], &[[0.0, 0.0, 5.0]], &obs, &BaOpts::default()).is_err());
}
}
#[test]
fn adjust_rejects_invalid_observation_without_mutating_inputs() {
for (cam, pt) in [(1, 0), (0, 1)] {
let mut cams = [test_camera()];
let mut pts = [[0.0, 0.0, 5.0]];
let before = (cams, pts);
let obs = [BaObs { cam, pt, uv: [0.0; 2] }];
assert!(adjust(&mut cams, &mut pts, &obs, &BaOpts::default()).is_err());
assert_eq!((cams, pts), before);
}
}
#[test]
fn residual_rejects_non_finite_inputs_and_projection_overflow() {
let cam = test_camera();
assert!(residual(&cam, [0.0, 0.0, f64::NAN], [0.0; 2]).is_none());
assert!(residual(&cam, [0.0, 0.0, 5.0], [f64::INFINITY, 0.0]).is_none());
for bad in [
BaCam { f: f64::NAN, ..cam },
BaCam { dist: Distortion { k1: f64::INFINITY, ..Distortion::NONE }, ..cam },
BaCam { pose: Pose { c: [f64::NAN; 3], ..Pose::IDENTITY }, ..cam },
BaCam { pose: Pose { r: [[f64::INFINITY; 3]; 3], ..Pose::IDENTITY }, ..cam },
] {
assert!(residual(&bad, [0.0, 0.0, 5.0], [0.0; 2]).is_none());
}
assert!(residual(&cam, [f64::MAX, 0.0, 1.0], [0.0; 2]).is_none());
}
#[test]
fn adjustment_rejects_non_finite_points_observations_and_options() {
let cam = test_camera();
let pts = [[0.0, 0.0, 5.0]];
let obs = [BaObs { cam: 0, pt: 0, uv: [0.0; 2] }];
assert!(cost(&[cam], &[[f64::NAN, 0.0, 5.0]], &obs, &BaOpts::default()).is_err());
assert!(cost(&[cam], &pts, &[BaObs { uv: [f64::NAN; 2], ..obs[0] }], &BaOpts::default()).is_err());
for opts in [
BaOpts { huber: f64::NAN, ..Default::default() },
BaOpts { huber: 0.0, ..Default::default() },
BaOpts { focal: FocalMode::PerCamera(f64::INFINITY), ..Default::default() },
BaOpts { max_iter: usize::MAX, ..Default::default() },
] {
let mut trial_pts = pts;
assert!(adjust(&mut [cam], &mut trial_pts, &obs, &opts).is_err());
}
}
#[test]
fn matrix_workspace_is_checked_and_bounded_before_allocation() {
assert!(matrix_len(usize::MAX, 0, 0).is_err());
assert!(matrix_len(1, usize::MAX, 0).is_err());
assert!(matrix_len(1, 0, usize::MAX).is_err());
let mut cams = vec![test_camera(); 1000];
assert!(adjust(&mut cams, &mut [], &[], &BaOpts::default()).is_err());
assert!(matrix_len(6, 1000, 1000).is_ok());
assert!(matrix_len(955, 900, 160 * 900).is_ok());
}
#[test]
fn bundle_adjustment_converges_from_noise() {
let mut rng = Rng(99);
@@ -499,7 +647,7 @@ mod tests {
let mut fixed = vec![false; 6];
fixed[0] = true;
fixed[1] = true;
let rep = adjust(&mut cams, &mut pts, &obs, &BaOpts { fixed, max_iter: 50, ..Default::default() });
let rep = adjust(&mut cams, &mut pts, &obs, &BaOpts { fixed, max_iter: 50, ..Default::default() }).unwrap();
let rms = (rep.cost / obs.len() as f64).sqrt();
assert!(rms < 1e-4, "{rep:?}");
}
+11
View File
@@ -247,6 +247,9 @@ pub fn reproj2(pose: &Pose, x: V3, o: [f64; 2]) -> Option<f64> {
/// Pick the essential decomposition with the most points in front of both cameras; returns
/// the second camera (the first is the identity) and that count.
pub fn select_pose(cands: &[(M3, V3)], x1: &[[f64; 2]], x2: &[[f64; 2]]) -> Option<(Pose, usize)> {
if x1.len() != x2.len() || x1.is_empty() || x1.iter().chain(x2).flatten().any(|v| !v.is_finite()) {
return None;
}
let mut best: Option<(Pose, usize)> = None;
for (r, t) in cands {
let p2 = Pose::from_rt(*r, *t);
@@ -384,6 +387,14 @@ mod tests {
[c[0] / c[2], c[1] / c[2]]
}
#[test]
fn pose_selection_rejects_mismatched_or_non_finite_correspondences() {
let candidates = [(I3, [0.1, 0.0, 0.0])];
assert!(select_pose(&candidates, &[[0.0; 2]], &[]).is_none());
assert!(select_pose(&candidates, &[], &[[0.0; 2]]).is_none());
assert!(select_pose(&candidates, &[[f64::NAN, 0.0]], &[[0.0; 2]]).is_none());
}
#[test]
fn essential_recovers_relative_pose() {
let (pts, p2) = scene();
+72 -24
View File
@@ -42,13 +42,54 @@ struct Data {
noise_bits: std::sync::atomic::AtomicU64,
}
// Input budgets apply before building the duplicated per-frame/per-track lookup tables.
const MAX_SOLVE_FRAMES: usize = 100_000;
const MAX_SOLVE_TRACKS: usize = 200_000;
const MAX_SOLVE_OBSERVATIONS: usize = 2_000_000;
fn validate_tracks(t: &CameraTracks) -> Result<(), SolveError> {
if t.frames as usize > MAX_SOLVE_FRAMES || t.tracks.len() > MAX_SOLVE_TRACKS {
return err("camera tracks exceed the supported frame or track count");
}
if t.size.iter().any(|v| !v.is_finite() || *v <= 0.0)
|| !t.factor.is_finite()
|| t.factor < 0.0
|| !t.start.is_finite()
|| !t.frame_duration.is_finite()
|| t.frame_duration <= 0.0
{
return err("camera tracks have invalid dimensions, timing or analysis scale");
}
let mut count = 0usize;
for tr in &t.tracks {
count = count
.checked_add(tr.pts.len())
.filter(|n| *n <= MAX_SOLVE_OBSERVATIONS)
.ok_or_else(|| SolveError("camera tracks exceed the supported observation count".into()))?;
let end = u32::try_from(tr.pts.len()).ok().and_then(|n| tr.start.checked_add(n));
if end.is_none_or(|end| end > t.frames) {
return err("camera track extends beyond the clip's frame range");
}
if tr.pts.iter().flatten().any(|v| !v.is_finite()) {
return err("camera track positions must be finite");
}
}
Ok(())
}
impl Data {
fn new(t: &CameraTracks, deleted: &[u32]) -> Data {
fn new(t: &CameraTracks, deleted: &[u32]) -> Result<Data, SolveError> {
validate_tracks(t)?;
if deleted.len() > MAX_SOLVE_TRACKS {
return err("too many deleted camera track ids");
}
let n = t.frames as usize;
let c = [t.size[0] * 0.5, t.size[1] * 0.5];
let del: HashSet<u32> = deleted.iter().copied().collect();
let mut tracks = vec![];
let mut by_frame = vec![vec![]; n];
let mut by_frame = Vec::new();
by_frame.try_reserve_exact(n).map_err(|_| SolveError("not enough memory for camera track frames".into()))?;
by_frame.resize_with(n, Vec::new);
for tr in t.tracks.iter().filter(|tr| !del.contains(&tr.id) && tr.pts.len() >= 3) {
let ti = tracks.len() as u32;
let pts: Vec<[f64; 2]> = tr.pts.iter().map(|p| [p[0] as f64 - c[0], p[1] as f64 - c[1]]).collect();
@@ -60,7 +101,7 @@ impl Data {
tracks.push((tr.id, tr.start as usize, pts));
}
let noise = (0.5 * t.factor).max(0.5);
Data { n, w: t.size[0], tracks, by_frame, noise_bits: std::sync::atomic::AtomicU64::new(noise.to_bits()) }
Ok(Data { n, w: t.size[0], tracks, by_frame, noise_bits: std::sync::atomic::AtomicU64::new(noise.to_bits()) })
}
fn at(&self, ti: u32, k: usize) -> Option<[f64; 2]> {
let (_, s, p) = &self.tracks[ti as usize];
@@ -174,10 +215,10 @@ impl Recon {
}
/// Bundle-adjust the cameras of `frames` (the first stays fixed) and their points.
fn adjust(&mut self, d: &Data, frames: &[usize], cfg: &Cfg, focal: FocalMode, iters: usize) -> f64 {
fn adjust(&mut self, d: &Data, frames: &[usize], cfg: &Cfg, focal: FocalMode, iters: usize) -> Result<f64, SolveError> {
let (mut cams, mut pts, obs, ids) = self.observations(d, frames);
if obs.is_empty() {
return f64::INFINITY;
return Ok(f64::INFINITY);
}
let mut fixed = vec![false; cams.len()];
fixed[0] = true;
@@ -186,14 +227,14 @@ impl Recon {
&mut pts,
&obs,
&BaOpts { focal, fix_points: false, fix_centers: cfg.kind == Kind::Tripod, fixed, huber: d.huber(), max_iter: iters, distortion: cfg.distortion },
);
)?;
for (k, c) in frames.iter().zip(&cams) {
self.cams.insert(*k, *c);
}
for (ti, x) in ids.iter().zip(&pts) {
self.pts.insert(*ti, *x);
}
rep.cost
Ok(rep.cost)
}
/// Mark observations above the inlier threshold as outliers; drop points left with fewer
@@ -302,8 +343,12 @@ impl Recon {
break;
}
let mut cams = vec![start];
bundle::adjust(&mut cams, &mut pts.clone(), &obs, &opts(d.inlier_thr() * 4.0));
bundle::adjust(&mut cams, &mut pts.clone(), &obs, &opts(d.huber()));
if bundle::adjust(&mut cams, &mut pts.clone(), &obs, &opts(d.inlier_thr() * 4.0)).is_err() {
return false;
}
if bundle::adjust(&mut cams, &mut pts.clone(), &obs, &opts(d.huber())).is_err() {
return false;
}
let inl = obs.iter().filter(|o| bundle::residual(&cams[0], pts[o.pt as usize], o.uv).is_some_and(|r| r[0] * r[0] + r[1] * r[1] <= thr2)).count();
if best.as_ref().is_none_or(|b| inl > b.0) {
best = Some((inl, cams[0]));
@@ -317,7 +362,9 @@ impl Recon {
let keep: Vec<BaObs> =
obs.iter().filter(|o| bundle::residual(&cam, pts[o.pt as usize], o.uv).is_some_and(|r| r[0] * r[0] + r[1] * r[1] <= thr2)).copied().collect();
let mut cams = vec![cam];
bundle::adjust(&mut cams, &mut pts.clone(), &keep, &opts(d.huber()));
if bundle::adjust(&mut cams, &mut pts.clone(), &keep, &opts(d.huber())).is_err() {
return false;
}
for o in &obs {
let ok = bundle::residual(&cams[0], pts[o.pt as usize], o.uv).is_some_and(|r| r[0] * r[0] + r[1] * r[1] <= thr2);
if !ok {
@@ -471,10 +518,11 @@ fn grow(d: &Data, kfs: &[usize], j: usize, init: &(Pose, HashMap<u32, V3>), f: f
r.pts = init.1.clone();
let inner = |r: &mut Recon, frames: &[usize]| {
let focal = if cfg.per_frame_f && frames.len() > 2 { FocalMode::PerCamera(F_PRIOR) } else { FocalMode::Fixed };
r.adjust(d, frames, cfg, focal, 10);
r.adjust(d, frames, cfg, focal, 10)?;
r.reject(d);
Ok::<(), SolveError>(())
};
inner(&mut r, &[a, b]);
inner(&mut r, &[a, b]).ok()?;
let order: Vec<usize> = kfs[1..j].iter().chain(&kfs[j + 1..]).copied().collect();
let mut done = 0;
for k in order {
@@ -491,11 +539,11 @@ fn grow(d: &Data, kfs: &[usize], j: usize, init: &(Pose, HashMap<u32, V3>), f: f
done += 1;
let frames: Vec<usize> = r.cams.keys().copied().collect();
if done % 3 == 0 || frames.len() <= 6 {
inner(&mut r, &frames);
inner(&mut r, &frames).ok()?;
}
}
let frames: Vec<usize> = r.cams.keys().copied().collect();
inner(&mut r, &frames);
inner(&mut r, &frames).ok()?;
(r.cams.len() >= 2 && r.pts.len() >= 8).then_some(r)
}
@@ -531,10 +579,10 @@ fn grow_tripod(d: &Data, kfs: &[usize], f: f64, cfg: &Cfg, cancel: Option<&Atomi
r.triangulate(d, Kind::Tripod, 0.0, 20.0);
let frames: Vec<usize> = r.cams.keys().copied().collect();
let focal = if cfg.per_frame_f && frames.len() > 2 { FocalMode::PerCamera(F_PRIOR) } else { FocalMode::Fixed };
r.adjust(d, &frames, cfg, focal, 15);
r.adjust(d, &frames, cfg, focal, 15).ok()?;
r.reject(d);
r.triangulate(d, Kind::Tripod, 0.0, 1.0);
r.adjust(d, &frames, cfg, focal, 15);
r.adjust(d, &frames, cfg, focal, 15).ok()?;
(r.cams.len() >= 2 && r.pts.len() >= 8).then_some(r)
}
@@ -684,7 +732,7 @@ pub fn solve(tracks: &CameraTracks, s: &SolveSettings, cancel: Option<&AtomicBoo
for pass in 0..2 {
let und = (pass > 0).then(|| undistorted(tracks, &dist));
let c = solve_core(und.as_ref().unwrap_or(tracks), s, cancel)?;
let d = Data::new(tracks, &s.deleted);
let d = Data::new(tracks, &s.deleted)?;
d.set_noise(c.d.noise());
let mut r = c.r;
for cam in r.cams.values_mut() {
@@ -699,10 +747,10 @@ pub fn solve(tracks: &CameraTracks, s: &SolveSettings, cancel: Option<&AtomicBoo
// The pinhole solve's outliers include the distorted frame edges: start from all
// observations (the Huber loss keeps real outliers in check), then reject again.
r.outliers.clear();
r.adjust(&d, &frames, &cfg, c.focal, 30);
r.adjust(&d, &frames, &cfg, c.focal, 30)?;
r.outliers.clear();
r.reject(&d);
r.adjust(&d, &frames, &cfg, c.focal, 30);
r.adjust(&d, &frames, &cfg, c.focal, 30)?;
dist = r.cams[&frames[0]].dist;
for cam in r.cams.values_mut() {
cam.dist = dist;
@@ -742,7 +790,7 @@ pub fn solve(tracks: &CameraTracks, s: &SolveSettings, cancel: Option<&AtomicBoo
}
fn solve_core(tracks: &CameraTracks, s: &SolveSettings, cancel: Option<&AtomicBool>) -> Result<Core, SolveError> {
let d = Data::new(tracks, &s.deleted);
let d = Data::new(tracks, &s.deleted)?;
if d.n < 2 {
return err("the clip must be at least two frames long");
}
@@ -848,7 +896,7 @@ fn solve_core(tracks: &CameraTracks, s: &SolveSettings, cancel: Option<&AtomicBo
lap("tripod");
// Keyframes: adjust with the focal length free, reject, re-triangulate, adjust again.
let kf_frames: Vec<usize> = r.cams.keys().copied().collect();
r.adjust(&d, &kf_frames, &cfg, focal, 40);
r.adjust(&d, &kf_frames, &cfg, focal, 40)?;
r.reject(&d);
// Re-estimate the tracking noise from the inlier residuals (robustly, from their median:
// for 2D Gaussian noise of deviation σ the median distance is 1.1774 σ), then tighten the
@@ -876,9 +924,9 @@ fn solve_core(tracks: &CameraTracks, s: &SolveSettings, cancel: Option<&AtomicBo
r.reject(&d);
}
r.triangulate(&d, kind, 1.0, 1.0);
r.adjust(&d, &kf_frames, &cfg, focal, 40);
r.adjust(&d, &kf_frames, &cfg, focal, 40)?;
r.reject(&d);
r.adjust(&d, &kf_frames, &cfg, focal, 20);
r.adjust(&d, &kf_frames, &cfg, focal, 20)?;
if cancelled() {
return err("cancelled");
}
@@ -910,7 +958,7 @@ fn solve_core(tracks: &CameraTracks, s: &SolveSettings, cancel: Option<&AtomicBo
let all: Vec<usize> = r.cams.keys().copied().collect();
r.reject(&d);
if all.len() <= 160 {
r.adjust(&d, &all, &cfg, focal, 25);
r.adjust(&d, &all, &cfg, focal, 25)?;
r.reject(&d);
} else {
// Points only (cameras fixed) would be cheap but the keyframe solution is already
+79 -11
View File
@@ -186,9 +186,16 @@ fn to_layer(c: [f64; 2], b: f64, roll_deg: f64, q: [f64; 2]) -> [f64; 2] {
/// Layer pixels → face frame.
fn to_face(c: [f64; 2], b: f64, roll_deg: f64, p: [f64; 2]) -> [f64; 2] {
face_mapper(c, b, roll_deg)(p)
}
/// [`to_face`] with the rotation worked out once, for mapping many points.
fn face_mapper(c: [f64; 2], b: f64, roll_deg: f64) -> impl Fn([f64; 2]) -> [f64; 2] {
let (sn, cs) = roll_deg.to_radians().sin_cos();
let d = [(p[0] - c[0]) / b, (p[1] - c[1]) / b];
[cs * d[0] + sn * d[1], -sn * d[0] + cs * d[1]]
move |p| {
let d = [(p[0] - c[0]) / b, (p[1] - c[1]) / b];
[cs * d[0] + sn * d[1], -sn * d[0] + cs * d[1]]
}
}
/// A synthetic face's landmarks in layer pixels.
@@ -604,6 +611,44 @@ impl Window {
}
}
/// Per pixel of a `w × h` window: not skin and inside `poly` (even-odd rule, same comparisons as
/// [`crate::mask::inside_polygon`] at `layer(i, j)`). A scanline crosses the polygon at the same
/// edges for every pixel of a row, so the crossings are found once per row and each pixel's
/// parity follows from how many lie right of it.
fn non_skin_inside(poly: &[[f64; 2]], skin: &[bool], layer: impl Fn(usize, usize) -> [f64; 2], w: usize, h: usize) -> Vec<bool> {
let mut feat = vec![false; w * h];
let n = poly.len();
if n < 3 {
return feat;
}
let mut xs: Vec<f64> = Vec::with_capacity(n);
for j in 0..h {
let y = layer(0, j)[1];
xs.clear();
let mut prev = poly[n - 1];
for &cur in poly {
let (a, b) = (cur, prev);
if (a[1] > y) != (b[1] > y) {
xs.push((b[0] - a[0]) * (y - a[1]) / (b[1] - a[1]) + a[0]);
}
prev = cur;
}
if xs.is_empty() {
continue;
}
xs.sort_by(f64::total_cmp);
let mut left = 0;
for i in 0..w {
let x = layer(i, j)[0];
while left < xs.len() && xs[left] <= x {
left += 1;
}
feat[j * w + i] = (xs.len() - left) % 2 == 1 && !skin[j * w + i];
}
}
feat
}
fn median(v: &mut [f32]) -> f32 {
v.sort_by(f32::total_cmp);
v[v.len() / 2]
@@ -784,18 +829,12 @@ impl FaceTracker {
let (w, h) = (win.w, win.h);
// Non-skin components inside the face outline (shrunk a little: anti-aliased edges).
let inner: Vec<[f64; 2]> = outline.iter().map(|p| [center[0] + 0.9 * (p[0] - center[0]), center[1] + 0.9 * (p[1] - center[1])]).collect();
let mut feat = vec![false; w * h];
for j in 0..h {
for i in 0..w {
if !skin[j * w + i] && crate::mask::inside_polygon(&inner, win.layer(frame, i, j)) {
feat[j * w + i] = true;
}
}
}
let feat = non_skin_inside(&inner, skin, |i, j| win.layer(frame, i, j), w, h);
let min_area = (0.0006 * b * b).max(2.0) as usize;
let mut seen = vec![false; w * h];
let mut blobs: Vec<Blob> = vec![];
let skin_luma = self.skin.med[0] as f64;
let to_face = face_mapper(center, b, roll);
for j0 in 0..h {
for i0 in 0..w {
if !feat[j0 * w + i0] || seen[j0 * w + i0] {
@@ -805,7 +844,7 @@ impl FaceTracker {
seen[j0 * w + i0] = true;
let mut pts = vec![];
while let Some((i, j)) = st.pop() {
let q = to_face(center, b, roll, win.layer(frame, i, j));
let q = to_face(win.layer(frame, i, j));
pts.push((q, skin_luma - luma[j * w + i] as f64));
for dj in -1i64..=1 {
for di in -1i64..=1 {
@@ -1193,6 +1232,35 @@ mod tests {
assert!(err < 0.06 * 50.0, "max error {err}");
}
#[test]
fn row_crossings_match_the_point_in_polygon_test() {
let (w, h) = (97, 83);
let off = [13.0, 7.0];
let layer = |i: usize, j: usize| [(20 + i) as f64 + 0.5 - off[0] + 0.25, (11 + j) as f64 + 0.5 - off[1]];
let star: Vec<[f64; 2]> = (0..48)
.map(|k| {
let a = k as f64 / 48.0 * std::f64::consts::TAU;
let r = 30.0 + 9.0 * (5.0 * a).sin() + 3.0 * (k % 3) as f64;
[50.0 + a.cos() * r, 40.0 + a.sin() * r * 1.3]
})
.collect();
// Vertices on pixel-centre rows and columns exercise the tie cases.
let tied: Vec<[f64; 2]> = vec![[10.0, 5.0], [70.0, 5.0], [70.0, 60.5], [40.0, 30.5], [10.0, 60.5], [10.0, 5.0]];
for poly in [star, tied, vec![[1.0, 1.0], [5.0, 5.0]]] {
let skin: Vec<bool> = (0..w * h).map(|i| (i * 7 + i / w) % 5 == 0).collect();
let got = non_skin_inside(&poly, &skin, layer, w, h);
let mut inside = 0;
for j in 0..h {
for i in 0..w {
let want = !skin[j * w + i] && crate::mask::inside_polygon(&poly, layer(i, j));
assert_eq!(got[j * w + i], want, "({i}, {j})");
inside += want as usize;
}
}
assert!(inside > 0 || poly.len() < 3);
}
}
#[test]
fn tracks_a_moving_rotating_synthetic_face() {
let (w, h) = (320, 240);
+1
View File
@@ -11,6 +11,7 @@ workspace = true
[dependencies]
craft-ui = { workspace = true }
craft-quit = { workspace = true, features = ["egui"] }
effectcraft-time = { workspace = true }
effectcraft-geom = { workspace = true }
effectcraft-color = { workspace = true }
+3 -1
View File
@@ -150,13 +150,15 @@ impl eframe::App for EffectcraftApp {
}
}
/// The logic-only tick: run `tick` and close the IME pass with `finish_ime_logic`. It does not read pass-time
/// The logic-only tick: run `tick`, close the IME pass with `finish_ime_logic` and, last, hand a close to the
/// platform quit (`panels::unsaved::quit_on_close`). It does not read pass-time
/// input state (events, pointer and time are stale here), only the viewport information eframe refreshed.
fn logic(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) {
// Never `ctx.input(|i| i.time)` here: `Context::run_logic` leaves events, pointer state and
// time at the previous full pass's values (only the viewport information is refreshed).
self.tick(ctx, frame);
self.finish_ime_logic(ctx);
crate::panels::unsaved::quit_on_close(ctx);
}
/// Draw one UI pass through `draw`.
+2
View File
@@ -198,6 +198,8 @@ impl EffectcraftApp {
self.command_inbox = Some(rx);
}
// A close request (the window's button, Quit, `app.quit`) must be answered while hidden.
// An answered file dialog can change the project, so the answers above run first. The host
// calls `panels::unsaved::quit_on_close` last in its `logic`.
panels::unsaved::on_close_requested(self, ctx);
let title = panels::unsaved::window_title(self);
if title != self.window_title {
+11
View File
@@ -469,6 +469,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str, &str)] = &[
("file.installScript", "Install Script File...", "安装脚本文件..."),
("file.installScriptUIPanel", "Install ScriptUI Panel...", "安装 ScriptUI 面板..."),
("file.runScript", "Run Script File...", "运行脚本文件..."),
("", "Recent Script Files", "最近的脚本文件"),
("", "Create Proxy", "创建代理"),
("file.createProxy", "Still...", "静止图像..."),
("file.createProxy", "Movie...", "影片..."),
@@ -1104,6 +1105,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str, &str)] = &[
("file.installScript", "Install Script File...", "安裝指令碼檔案..."),
("file.installScriptUIPanel", "Install ScriptUI Panel...", "安裝 ScriptUI 面板..."),
("file.runScript", "Run Script File...", "執行指令碼檔案..."),
("", "Recent Script Files", "最近使用的指令碼檔案"),
("", "Create Proxy", "建立代理"),
("file.createProxy", "Still...", "靜態影像..."),
("file.createProxy", "Movie...", "影片..."),
@@ -1736,6 +1738,7 @@ const JAPANESE: &[(&str, &str, &str)] = &[
("file.installScript", "Install Script File...", "スクリプトファイルをインストール..."),
("file.installScriptUIPanel", "Install ScriptUI Panel...", "ScriptUIパネルをインストール..."),
("file.runScript", "Run Script File...", "スクリプトファイルを実行..."),
("", "Recent Script Files", "最近使用したスクリプトファイル"),
("", "Create Proxy", "プロキシを作成"),
("file.createProxy", "Still...", "静止画..."),
("file.createProxy", "Movie...", "ムービー..."),
@@ -2371,6 +2374,7 @@ const UKRAINIAN: &[(&str, &str, &str)] = &[
("file.installScript", "Install Script File...", "Встановити файл скрипту..."),
("file.installScriptUIPanel", "Install ScriptUI Panel...", "Встановити панель ScriptUI..."),
("file.runScript", "Run Script File...", "Виконати файл скрипту..."),
("", "Recent Script Files", "Нещодавні файли скриптів"),
("", "Create Proxy", "Створити проксі"),
("file.createProxy", "Still...", "Зображення..."),
("file.createProxy", "Movie...", "Відео..."),
@@ -3007,6 +3011,7 @@ const SPANISH: &[(&str, &str, &str)] = &[
("file.installScript", "Install Script File...", "Instalar archivo de script..."),
("file.installScriptUIPanel", "Install ScriptUI Panel...", "Instalar panel de ScriptUI..."),
("file.runScript", "Run Script File...", "Ejecutar archivo de script..."),
("", "Recent Script Files", "Archivos de script recientes"),
("", "Create Proxy", "Crear proxy"),
("file.createProxy", "Still...", "Imagen fija..."),
("file.createProxy", "Movie...", "Película..."),
@@ -3641,6 +3646,7 @@ const PORTUGUESE: &[(&str, &str, &str)] = &[
("file.installScript", "Install Script File...", "Instalar arquivo de script..."),
("file.installScriptUIPanel", "Install ScriptUI Panel...", "Instalar painel ScriptUI..."),
("file.runScript", "Run Script File...", "Executar arquivo de script..."),
("", "Recent Script Files", "Arquivos de script recentes"),
("", "Create Proxy", "Criar proxy"),
("file.createProxy", "Still...", "Imagem fixa..."),
("file.createProxy", "Movie...", "Filme..."),
@@ -4283,6 +4289,7 @@ const RUSSIAN: &[(&str, &str, &str)] = &[
("file.installScript", "Install Script File...", "Установить файл скрипта..."),
("file.installScriptUIPanel", "Install ScriptUI Panel...", "Установить панель ScriptUI..."),
("file.runScript", "Run Script File...", "Выполнить файл скрипта..."),
("", "Recent Script Files", "Недавние файлы скриптов"),
("", "Create Proxy", "Создать суррогат"),
("file.createProxy", "Still...", "Стоп-кадр..."),
("file.createProxy", "Movie...", "Видео..."),
@@ -4917,6 +4924,7 @@ const FRENCH: &[(&str, &str, &str)] = &[
("file.installScript", "Install Script File...", "Installer un fichier de script..."),
("file.installScriptUIPanel", "Install ScriptUI Panel...", "Installer un panneau ScriptUI..."),
("file.runScript", "Run Script File...", "Exécuter un fichier de script..."),
("", "Recent Script Files", "Fichiers de script récents"),
("", "Create Proxy", "Créer un proxy"),
("file.createProxy", "Still...", "Image fixe..."),
("file.createProxy", "Movie...", "Vidéo..."),
@@ -5551,6 +5559,7 @@ const GERMAN: &[(&str, &str, &str)] = &[
("file.installScript", "Install Script File...", "Skriptdatei installieren..."),
("file.installScriptUIPanel", "Install ScriptUI Panel...", "ScriptUI-Bedienfeld installieren..."),
("file.runScript", "Run Script File...", "Skriptdatei ausführen..."),
("", "Recent Script Files", "Zuletzt verwendete Skriptdateien"),
("", "Create Proxy", "Proxy erstellen"),
("file.createProxy", "Still...", "Standbild..."),
("file.createProxy", "Movie...", "Film..."),
@@ -6185,6 +6194,7 @@ const CZECH: &[(&str, &str, &str)] = &[
("file.installScript", "Install Script File...", "Nainstalovat soubor skriptu..."),
("file.installScriptUIPanel", "Install ScriptUI Panel...", "Nainstalovat panel ScriptUI..."),
("file.runScript", "Run Script File...", "Spustit soubor skriptu..."),
("", "Recent Script Files", "Nedávné soubory skriptů"),
("", "Create Proxy", "Vytvořit zástupný soubor"),
("file.createProxy", "Still...", "Statický snímek..."),
("file.createProxy", "Movie...", "Video..."),
@@ -6816,6 +6826,7 @@ const GEORGIAN: &[(&str, &str, &str)] = &[
("file.installScript", "Install Script File...", "სკრიპტის ფაილის დაყენება..."),
("file.installScriptUIPanel", "Install ScriptUI Panel...", "ScriptUI პანელის დაყენება..."),
("file.runScript", "Run Script File...", "სკრიპტის ფაილის გაშვება..."),
("", "Recent Script Files", "ბოლო სკრიპტის ფაილები"),
("", "Create Proxy", "პროქსის შექმნა"),
("file.createProxy", "Still...", "სტატიკური კადრი..."),
("file.createProxy", "Movie...", "ვიდეო..."),
+1
View File
@@ -160,6 +160,7 @@ fn menu_nodes(
"recentProjects" => app.session.prefs.recent_projects.get(i).cloned(),
"recentFootage" => app.session.prefs.recent_footage.get(i).cloned(),
"recentPresets" => app.session.prefs.recent_presets.get(i).cloned(),
"recentScripts" => app.session.prefs.recent_scripts.get(i).map(|r| r.text().to_string()),
_ => None,
};
let r = ui.add_enabled(entry_enabled(app, e), egui::Button::new((gutter(entry_checked(app, e) == Some(true)), e.label.as_str())));
+1 -1
View File
@@ -548,7 +548,7 @@ pub(super) fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) ->
"file.importProject" => ("path", Ask::Open(&["ecproj", "ecprojx"])),
"file.importTimeline" => ("path", Ask::Open(&["xml", "fcpxml", "otio", "edl", "aaf", "omf"])),
"render.saveCurrentPreview" => ("path", Ask::Save("Preview.mp4")),
"file.runScript" if params.get("name").is_none() => ("path", Ask::Open(&["jsx", "js", "jsonl", "json", "txt"])),
"file.runScript" if params.get("name").is_none() && params.get("recent").is_none() => ("path", Ask::Open(&["jsx", "js", "jsonl", "json", "txt"])),
"file.installScript" | "file.installScriptUIPanel" => ("path", Ask::Open(&["jsx", "js"])),
"effect.plugins.load" if params.get("folder").is_none() => ("path", Ask::Open(&["wasm", "wat"])),
"file.replaceFootage" => ("path", Ask::Import),
+19
View File
@@ -299,6 +299,25 @@ fn save_dialogs_offer_the_file_extension() {
assert_eq!(*asked.borrow(), ["project Frame.png"]);
}
/// File ▸ Scripts ▸ Recent Script Files and its key run a script again without a file dialog.
#[test]
fn recent_script_files_run_without_a_dialog() {
let mut app = EffectcraftApp::new(effectcraft_engine::Session::default());
let ctx = egui::Context::default();
let dir = std::env::temp_dir().join(format!("ec-recent-script-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let script = dir.join("New Comp.jsonl");
std::fs::write(&script, r#"{"command": "comp.new", "params": {"name": "From Script", "width": 64, "height": 36, "frameRate": 30, "duration": 1}}"#)
.unwrap();
app.session.execute("file.runScript", json!({"path": script.to_string_lossy()})).unwrap();
let (m, k, id, params) =
bindings(&app.session).into_iter().find(|b| b.2 == "file.runScript" && b.3 == json!({"recent": 0})).expect("the most recent script has a key");
assert_eq!((m, k), parse_shortcut("Cmd+Alt+Shift+D").unwrap());
assert_eq!(invoke(&mut app, &ctx, &id, params).unwrap()["steps"], 1);
assert_eq!(app.session.project.items.values().filter(|i| i.name.starts_with("From Script")).count(), 2);
let _ = std::fs::remove_dir_all(&dir);
}
/// #511: with a host's non-blocking dialogs, a command's file dialog opens without running
/// a nested event loop inside the frame (a drag over the window then aborted the app on
/// macOS); the command runs on a later frame with the answer, and a cancelled one not at all.
+1
View File
@@ -340,6 +340,7 @@ pub fn state_key(app: &EffectcraftApp) -> u64 {
s.journal.last().map(|j| &j.0).hash(&mut h);
s.prefs.recent_projects.hash(&mut h);
(&s.prefs.recent_footage, &s.prefs.recent_presets).hash(&mut h);
s.prefs.recent_scripts.hash(&mut h);
s.history.undo.iter().for_each(|(l, _)| l.hash(&mut h));
s.state.open_comps.hash(&mut h);
s.active_comp_id().and_then(|c| s.state.views3d.get(&c)).map(|v| format!("{:?}", v.current)).hash(&mut h);
+104
View File
@@ -58,6 +58,15 @@ pub fn on_close_requested(app: &mut EffectcraftApp, ctx: &egui::Context) {
}
}
/// On macOS a close that nothing held open quits through `-[NSApplication terminate:]` instead:
/// closing the window while AppKit's run loop is running crashes on a Mac with a Touch Bar
/// (#341). `on_exit` still runs. A desktop host calls it last in `logic`, after
/// `EffectcraftApp::tick` (which runs [`on_close_requested`]) and its own work. Returns whether
/// that quit was scheduled; other platforms close the window as before.
pub fn quit_on_close(ctx: &egui::Context) -> bool {
craft_quit::on_close_request(ctx)
}
/// The project's display name: its file name, or `Untitled Project.ecproj`.
pub fn project_name(app: &EffectcraftApp) -> String {
app.session
@@ -207,6 +216,24 @@ mod tests {
out.viewport_output.get(&egui::ViewportId::ROOT).is_some_and(|v| v.commands.contains(&egui::ViewportCommand::CancelClose))
}
/// One frame of the dialogs with the window's close button pressed, through
/// [`on_close_requested`] and then [`quit_on_close`] as the app runs them; returns whether the
/// close was canceled and whether the platform quit was scheduled.
fn close_frame(app: &mut EffectcraftApp, ctx: &egui::Context) -> (bool, bool) {
let mut raw = egui::RawInput::default();
raw.viewports.entry(egui::ViewportId::ROOT).or_default().events.push(egui::ViewportEvent::Close);
let mut quits = false;
let mut out = ctx.run_ui(raw, |ui| {
app.auto.begin_frame();
on_close_requested(app, ui.ctx());
quits = quit_on_close(ui.ctx());
crate::panels::dialogs::show(app, ui.ctx());
});
out.textures_delta.clear();
let canceled = out.viewport_output.get(&egui::ViewportId::ROOT).is_some_and(|v| v.commands.contains(&egui::ViewportCommand::CancelClose));
(canceled, quits)
}
fn modified(app: &mut EffectcraftApp) {
app.session.execute("comp.new", json!({"name": "Main", "width": 64, "height": 64})).unwrap();
assert!(app.session.is_dirty());
@@ -340,4 +367,81 @@ mod tests {
assert!(!app.session.is_dirty());
assert!(!frame(&mut app, &ctx, true));
}
/// Closing the window while AppKit's run loop is running crashes on a Mac with a Touch Bar
/// (#341). On macOS the close is canceled and the app quits through
/// `-[NSApplication terminate:]`. Other platforms close the window.
#[test]
fn closing_a_saved_project_quits_through_the_platform_on_macos() {
let (mut app, ctx) = app();
let macos = cfg!(target_os = "macos");
assert_eq!(close_frame(&mut app, &ctx), (macos, macos));
assert!(app.dialog.is_none(), "nothing to ask");
}
#[test]
fn closing_a_modified_project_asks_before_the_platform_quit() {
let (mut app, ctx) = app();
modified(&mut app);
assert_eq!(close_frame(&mut app, &ctx), (true, false), "the prompt holds the window and nothing quits");
assert_eq!(app.dialog, Some(Dialog::UnsavedChanges));
answer_with(&mut app, &ctx, Answer::Cancel);
assert!(app.session.is_dirty() && !app.dialog_state.unsaved.quitting);
assert_eq!(close_frame(&mut app, &ctx), (true, false), "asked again after Cancel");
answer_with(&mut app, &ctx, Answer::Discard);
let macos = cfg!(target_os = "macos");
assert_eq!(close_frame(&mut app, &ctx), (macos, macos), "Don't Save quits");
}
/// While the window is minimized or hidden, eframe runs `App::logic` without `App::ui` and
/// still handles a close: `logic` asks about a modified project.
#[test]
fn a_close_while_only_logic_runs_asks_about_a_modified_project() {
let (mut app, ctx) = app();
modified(&mut app);
let mut raw = egui::RawInput::default();
raw.viewports.entry(egui::ViewportId::ROOT).or_default().events.push(egui::ViewportEvent::Close);
let mut frame = eframe::Frame::_new_kittest();
let out = ctx.run_logic(&raw, |ctx| eframe::App::logic(&mut app, ctx, &mut frame));
let canceled = out.viewport_commands.get(&egui::ViewportId::ROOT).is_some_and(|c| c.contains(&egui::ViewportCommand::CancelClose));
assert!(canceled, "the close is canceled");
assert_eq!(app.dialog, Some(Dialog::UnsavedChanges));
}
/// A file dialog answered in the frame that closes the window runs its command before the
/// close decision, so a change it makes is asked about.
#[test]
fn a_dialog_answer_runs_before_the_close_decision() {
let (mut app, ctx) = app();
assert!(!app.session.is_dirty());
let answer = crate::DialogAnswer { command: "comp.new".into(), key: "name", params: json!({"width": 64, "height": 64}), paths: vec!["Main".into()] };
app.dialog_answers.0.send(answer).unwrap();
let mut raw = egui::RawInput::default();
raw.viewports.entry(egui::ViewportId::ROOT).or_default().events.push(egui::ViewportEvent::Close);
let mut frame = eframe::Frame::_new_kittest();
let out = ctx.run_logic(&raw, |ctx| eframe::App::logic(&mut app, ctx, &mut frame));
let canceled = out.viewport_commands.get(&egui::ViewportId::ROOT).is_some_and(|c| c.contains(&egui::ViewportCommand::CancelClose));
assert!(app.session.is_dirty(), "the answer ran");
assert!(canceled, "the close is canceled");
assert_eq!(app.dialog, Some(Dialog::UnsavedChanges));
}
/// The tick makes the close decision and leaves the platform quit to the host, which runs it
/// last in `logic`, after its own work (the desktop host shows the file dialogs asked for).
/// On macOS `logic` cancels the close and quits; other platforms close the window.
#[test]
fn the_platform_quit_runs_after_the_tick() {
let (mut app, ctx) = app();
let mut raw = egui::RawInput::default();
raw.viewports.entry(egui::ViewportId::ROOT).or_default().events.push(egui::ViewportEvent::Close);
let frame = eframe::Frame::_new_kittest();
let canceled =
|out: &egui::LogicOutput| out.viewport_commands.get(&egui::ViewportId::ROOT).is_some_and(|c| c.contains(&egui::ViewportCommand::CancelClose));
let out = ctx.run_logic(&raw, |ctx| app.tick(ctx, &frame));
assert!(!canceled(&out), "the tick leaves a saved project's close to the host");
let mut frame = eframe::Frame::_new_kittest();
let out = ctx.run_logic(&raw, |ctx| eframe::App::logic(&mut app, ctx, &mut frame));
assert_eq!(canceled(&out), cfg!(target_os = "macos"), "logic quits through the platform on macOS");
assert!(app.dialog.is_none(), "nothing to ask");
}
}
+7
View File
@@ -115,5 +115,12 @@ fn a_lost_device_draws_nothing_autosaves_and_closes() {
let saved = h.state().session.autosave.last_path.clone().expect("unsaved changes auto-saved");
assert!(std::path::Path::new(&saved).is_file(), "{saved}");
assert_eq!(*told.borrow(), vec![Some(Ok(saved))], "the user is told once, with the recovery copy");
// The close arrives: on macOS the app quits through `-[NSApplication terminate:]` instead of
// closing the window while AppKit's run loop is running (#341).
h.input_mut().viewports.entry(egui::ViewportId::ROOT).or_default().events.push(egui::ViewportEvent::Close);
h.step();
let kept = h.output().viewport_output.get(&egui::ViewportId::ROOT).is_some_and(|v| v.commands.contains(&egui::ViewportCommand::CancelClose));
assert_eq!(kept, cfg!(target_os = "macos"), "the platform quit closes the window on macOS");
assert_eq!(told.borrow().len(), 1);
let _ = std::fs::remove_dir_all(&dir);
}
+169 -5
View File
@@ -72,13 +72,18 @@ impl FrameFilter<'_> {
// A transform edge is not filtered in the last odd chroma column.
let odd_last = pass == 1 && sx == 1 && mi_cols & 1 == 1 && edge & 1 == 1;
let (px, py) = (x >> sx, y >> sy);
for l in 0..n {
let mut apply = [false; LANES];
for (l, a) in apply.iter_mut().enumerate().take(n) {
let is_tx_edge = tx_ok && !(odd_last && x + (l << sx) + 8 >= mi_cols * 8);
if !(is_block_edge || is_tx_edge) {
continue;
*a = is_block_edge || is_tx_edge;
}
if apply.iter().any(|&a| a) {
let pos = py * stride + px;
if pass == 0 {
filter_group::<false>(&mut p.rec, pos, stride, 1, apply, wide, lim);
} else {
filter_group::<true>(&mut p.rec, pos, 1, stride, apply, wide, lim);
}
let (pos, step) = if pass == 0 { ((py + l) * stride + px, 1) } else { (py * stride + px + l, stride) };
filter_lane(&mut p.rec, pos, step, wide, lim);
}
i += 8;
}
@@ -86,8 +91,121 @@ impl FrameFilter<'_> {
}
}
const LANES: usize = 8;
/// [`filter_lane`] on the 8 lanes of a group at once (lane `l` has its q0 at `pos + l * along`;
/// samples across the edge are `step` apart; `ROWS`: `along` is 1), for lanes whose `apply` is
/// set. Every lane is computed with both filters and the result selected per lane (0 / -1
/// masks), so the arithmetic vectorizes; lanes left alone are written back unchanged.
#[inline(always)]
fn filter_group<const ROWS: bool>(d: &mut [u8], pos: usize, along: usize, step: usize, apply: [bool; LANES], wide: bool, lim: Limits) {
type T = i16;
let mut v = [[0 as T; LANES]; 8];
let start = pos - 4 * step;
if ROWS {
for (k, vk) in v.iter_mut().enumerate() {
let Some(row) = d.get(start + k * step..).and_then(|r| r.first_chunk::<LANES>()) else { return };
*vk = row.map(|s| s as T);
}
} else {
for l in 0..LANES {
let Some(line) = d.get(start + l * along..).and_then(|r| r.first_chunk::<8>()) else { return };
for k in 0..8 {
v[k][l] = line[k] as T;
}
}
}
let (limit, blimit, thresh) = (lim.limit as T, lim.blimit as T, lim.thresh as T);
let le = |a: T, b: T| -((a <= b) as T);
let sel = |m: T, a: T, b: T| (a & m) | (b & !m);
let (p3, p2, p1, p0, q0, q1, q2, q3) = (&v[0], &v[1], &v[2], &v[3], &v[4], &v[5], &v[6], &v[7]);
let apply: [T; LANES] = apply.map(|a| -(a as T));
let can_flat = -(wide as T);
let mut mask = [0 as T; LANES];
let mut hev = [0 as T; LANES];
let mut flat = [0 as T; LANES];
for l in 0..LANES {
let d10 = (p1[l] - p0[l]).abs();
let e10 = (q1[l] - q0[l]).abs();
mask[l] = apply[l]
& le((p3[l] - p2[l]).abs(), limit)
& le((p2[l] - p1[l]).abs(), limit)
& le(d10, limit)
& le(e10, limit)
& le((q2[l] - q1[l]).abs(), limit)
& le((q3[l] - q2[l]).abs(), limit)
& le((p0[l] - q0[l]).abs() * 2 + (p1[l] - q1[l]).abs() / 2, blimit);
hev[l] = !(le(d10, thresh) & le(e10, thresh));
flat[l] = can_flat
& mask[l]
& le(d10, 1)
& le(e10, 1)
& le((p2[l] - p0[l]).abs(), 1)
& le((q2[l] - q0[l]).abs(), 1)
& le((p3[l] - p0[l]).abs(), 1)
& le((q3[l] - q0[l]).abs(), 1);
}
if mask.iter().fold(0, |a, &m| a | m) == 0 {
return;
}
let mut out = v;
let c = |x: T| x.clamp(-128, 127);
for l in 0..LANES {
let (ps1, ps0, qs0, qs1) = (p1[l] - 128, p0[l] - 128, q0[l] - 128, q1[l] - 128);
let filter = c((c(ps1 - qs1) & hev[l]) + 3 * (qs0 - ps0));
let f1 = c(filter + 4) >> 3;
let f2 = c(filter + 3) >> 3;
let f = (f1 + 1) >> 1;
let narrow = mask[l] & !flat[l];
let outer = narrow & !hev[l];
out[4][l] = sel(narrow, c(qs0 - f1) + 128, out[4][l]);
out[3][l] = sel(narrow, c(ps0 + f2) + 128, out[3][l]);
out[5][l] = sel(outer, c(qs1 - f) + 128, out[5][l]);
out[2][l] = sel(outer, c(ps1 + f) + 128, out[2][l]);
}
if flat.iter().fold(0, |a, &f| a | f) != 0 {
// 8-tap: positions p2..q2 (indices 1..7), each the rounded mean of a 7-wide window
// (indices clamped to 0..8) plus itself, as a sliding sum.
let s = |k: isize| &v[k.clamp(0, 7) as usize];
let mut sum = [0 as T; LANES];
for j in -2isize..=4 {
let a = s(j);
for l in 0..LANES {
sum[l] += a[l];
}
}
for k in 1usize..7 {
let ki = k as isize;
let (cur, add, sub) = (s(ki), s(ki + 4), s(ki - 3));
for l in 0..LANES {
let w8 = (sum[l] + cur[l] + 4) >> 3;
out[k][l] = sel(flat[l], w8, out[k][l]);
sum[l] += add[l] - sub[l];
}
}
}
if ROWS {
for (k, ok) in out.iter().enumerate().take(7).skip(1) {
if let Some(row) = d.get_mut(start + k * step..).and_then(|r| r.first_chunk_mut::<LANES>()) {
for l in 0..LANES {
row[l] = ok[l] as u8;
}
}
}
} else {
for l in 0..LANES {
if let Some(line) = d.get_mut(start + l * along..).and_then(|r| r.first_chunk_mut::<8>()) {
for k in 1..7 {
line[k] = out[k][l] as u8;
}
}
}
}
}
/// Filter mask, narrow (4-tap) and 8-tap flat filters of spec 8.8.5 across one edge position;
/// `pos` is q0, samples across the edge are `step` apart.
#[cfg(test)]
fn filter_lane(d: &mut [u8], pos: usize, step: usize, wide: bool, lim: Limits) {
let at = |k: isize| d[(pos as isize + k * step as isize) as usize] as i32;
let (p3, p2, p1, p0) = (at(-4), at(-3), at(-2), at(-1));
@@ -133,3 +251,49 @@ fn filter_lane(d: &mut [u8], pos: usize, step: usize, wide: bool, lim: Limits) {
set(-2, c(ps1 + f) + 128);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn group_matches_per_lane_filter() {
let mut seed = 0x1234_5678u32;
let mut rnd = || {
seed ^= seed << 13;
seed ^= seed >> 17;
seed ^= seed << 5;
seed
};
for case in 0..20000 {
let stride = 24;
let base: Vec<u8> = {
let flatish = case % 3 == 0;
let c = (rnd() % 256) as i32;
(0..stride * 24)
.map(|_| if flatish { (c + (rnd() % 3) as i32 - 1).clamp(0, 255) as u8 } else { (c + (rnd() % 40) as i32 - 20).clamp(0, 255) as u8 })
.collect()
};
let l = (rnd() % 64) as i32;
let lim = Limits { limit: l.max(1), blimit: 2 * (l + 2) + l.max(1), thresh: l >> 4 };
let wide = rnd() & 1 == 1;
let apply: [bool; LANES] = std::array::from_fn(|_| rnd() % 4 != 0);
for pass in 0..2 {
let (mut a, mut b) = (base.clone(), base.clone());
let pos = 8 * stride + 8;
for (l, &ap) in apply.iter().enumerate() {
if ap {
let (p, st) = if pass == 0 { (pos + l * stride, 1) } else { (pos + l, stride) };
filter_lane(&mut a, p, st, wide, lim);
}
}
if pass == 0 {
filter_group::<false>(&mut b, pos, stride, 1, apply, wide, lim);
} else {
filter_group::<true>(&mut b, pos, 1, stride, apply, wide, lim);
}
assert_eq!(a, b, "case {case} pass {pass}");
}
}
}
}
+13 -6
View File
@@ -15,7 +15,7 @@ After Effects', and does the preset library exist. The per-effect catalogue is t
| Parameter names, order, popups, units, defaults | aligned in M9.12 | Agent-checked against After Effects' documentation, not against a live dump |
| On the GPU | **280** (92%) | Measured: registry flags; GPU vs CPU agreement tested per effect |
| 32-bit float, no clamping | 306 | Measured: registry flags |
| **Rendered output matches After Effects** | **partly measured** | Some colour effects are checked against After Effects 2026 renders of our own test projects: Tint, Tritone, Black & White, Exposure, Levels, Curves, Luma Key, Extract, 4-Color Gradient and Fill within 1–3/255, Brightness & Contrast up to 6.2/255 (Color Correction, Generate and Keying rows below); every other effect is unmeasured. `effectcraft_effects::fidelity::compare_frames` exists for the corpus ([fidelity/README.md](fidelity/README.md)); simulation effects are pinned by golden hashes of our own output (M13.27) |
| **Rendered output matches After Effects** | **partly measured** | Some colour effects are checked against After Effects 2026 renders of our own test projects: Tint, Tritone, Black & White, Exposure, Levels, Curves, Luma Key, Extract, 4-Color Gradient and Fill within 1–3/255, Brightness & Contrast up to 6.2/255 (Color Correction, Generate and Keying rows below); Radial Blur, Glow, Venetian Blinds, Radial Wipe, Fractal / Turbulent Noise and Transform are checked the same way (rows below); every other effect is unmeasured. `effectcraft_effects::fidelity::compare_frames` exists for the corpus ([fidelity/README.md](fidelity/README.md)); simulation effects are pinned by golden hashes of our own output (M13.27) |
| Animation presets | **54** original presets (8 text-animator, 23 Transitions, 23 Backgrounds) against After Effects' **621** `.ffx` presets | Measured: `crates/engine/presets/text_animators.json`, `crates/engine/presets/transitions.json`, `crates/engine/presets/backgrounds.json`; the After Effects `Presets` folder |
| 26.x additions | Curl Noise (26.3) present (#463 open); effect colour labels (26.5) present in Effect Controls only, not in the Timeline; the rebuilt Effect Controls (26.5) missing | Release notes |
@@ -28,24 +28,24 @@ corpus compares renders, each effect is assumed to differ somewhere, and users k
|---|---:|---:|---|
| 3D Channel | 8 | 8 | Cryptomatte selection can't be set in the UI and the GPU path is slow (#473; quoted names with commas fixed); multi-layer EXR decode shared (#474 open: f16 storage and cache sizing remain) |
| Audio | 13 | 0 | CPU by design (applied in the mixdown) |
| Blur & Sharpen | 16 | 15 | Gaussian Blur interaction and output to be measured first ([fidelity/ae-2026-acceptance.md](fidelity/ae-2026-acceptance.md)) |
| Blur & Sharpen | 16 | 15 | Radial Blur keeps the layer edges as After Effects 2026 (713 now pixel-identical); Gaussian Blur interaction and output to be measured first ([fidelity/ae-2026-acceptance.md](fidelity/ae-2026-acceptance.md)) |
| Channel | 13 | 13 | — |
| Color Correction | 40 | 40 | Tint, Tritone and Black & White (sliders and Tint) match After Effects 2026 renders (Rec. 601 luminance); Levels (Individual Controls) matches After Effects 2026 in 32 bpc (channel controls before RGB, "Off for 32 bpc Color" unclipped; plain Levels' channel order not measured); Brightness & Contrast (non-legacy) close to After Effects 2026 ramps, not identical (up to 6.2/255 off; the test allows 7/255) (negative settings invert the positive curves); Curves interpolates a natural cubic spline through its points, as After Effects 2026 (exact on its grey steps); Tint has Swap Colors (#963); ACES 2.0 / OCIO 2.5 (26.5); Apply Color LUT (#514) and 32-bpc blending (#494) are closed |
| Distort | 39 | 35 | Liquify has no viewer brush; #303 (Mesh Warp drag) and #534 (CC Power Pin Unstretch) are closed |
| Distort | 39 | 35 | Transform matches After Effects 2026 renders (skew after rotation, drawn outside the layer); Liquify has no viewer brush; #303 (Mesh Warp drag) and #534 (CC Power Pin Unstretch) are closed |
| Expression Controls | 9 | 9 | — |
| Generate | 26 | 25 | 4-Color Gradient's Blend matches After Effects 2026 (weights 1 / (d² + Blend²)); Fill's Opacity is the filled layer's opacity, as After Effects 2026 renders it (with a Fill Mask it still mixes with the original, not measured); Fractal on the CPU (needs f64); #526 (Reset) and #536 (Write-on marks) are closed |
| Immersive Video | 12 | 12 | — |
| Keying | 12 | 12 | Luma Key and Extract key on Rec. 601 luminance, as After Effects 2026; Key Cleaner approximated; #533 (wire-removal Frame Offset) is closed |
| Matte | 6 | 4 | Mocha shape reads our documented JSON, not Mocha's format |
| Noise & Grain | 13 | 12 | Curl Noise (#463); Match Grain on the CPU |
| Noise & Grain | 13 | 12 | Fractal / Turbulent Noise lattice matches After Effects 2026 (Scale 100 = 66.7 px cells); the noise values themselves differ (seeds) and Soft Linear contrast is about 20 % lower than AE's; Curl Noise (#463); Match Grain on the CPU |
| Obsolete | 9 | 9 | Luma Key matches After Effects 2026 on a grey ramp (hard cut, Tolerance only for Similar / Dissimilar) |
| Paint | 1 | 0 | Tablet pressure not read ([hardware-parity.md](hardware-parity.md)) |
| Perspective | 10 | 9 | Chromatic Aberrations pan/tilt crash (#580) |
| Simulation | 18 | 18 | Particle Playground lacks Particle Exploder, text particles, ephemeral mapper; CC Particle World camera fixed (#397) |
| Stylize | 25 | 25 | Motion Tile output size (#369) |
| 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 |
| Transition | 17 | 17 | — |
| 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 | — |
Third-party bundles in After Effects (Cineware, Mocha AE) are out of scope; Keylight is
@@ -64,6 +64,13 @@ the GPU is performance, not parity.
| Date | Change | Summary |
|---|---|---|
| 2026-10-19 | minor | Glow (original colours): bright pass per channel, 8 × (value − threshold + 0.061), measured against After Effects 2026 (CPU and GPU) |
| 2026-10-19 | minor | Glow (colour channels): the glow covers the layer's blurred alpha, blur σ = 0.384 × radius, measured against After Effects 2026 renders (CPU and GPU) |
| 2026-10-19 | minor | Radial Blur repeats the edge instead of sampling transparency outside the layer, measured against After Effects 2026 renders (CPU; Radial Blur has no GPU path) |
| 2026-10-19 | minor | Venetian Blinds: stripes measured from the layer centre, both band edges Gaussian (σ = Feather / 2), measured against After Effects 2026 (CPU and GPU) |
| 2026-10-19 | minor | Radial Wipe: Feather is in pixels on both edges of the wiped wedge (was degrees on the moving edge only), measured against After Effects 2026 (CPU and GPU) |
| 2026-10-19 | minor | Fractal / Turbulent Noise: lattice cell = Scale × 2/3 px (Block noise every 66.7 px at Scale 100, as After Effects 2026), CPU and GPU |
| 2026-10-19 | minor | Transform: After Effects' skew order and output beyond the layer bounds, measured against After Effects 2026 renders of three cases (CPU and GPU) |
| 2026-10-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) |
+1
View File
@@ -14,6 +14,7 @@ Guide):
| Effect plug-ins (C/C++ SDK, `.aex` / `.plugin`) | [Effect plug-ins](#effect-plug-ins): WebAssembly modules (plug-in API v1) or Rust; the `Plug-ins` folder next to the settings loads at start-up | Different ABI: After Effects SDK plug-ins can't run (G8) |
| Scripts folder: its scripts are listed in File ▸ Scripts | `Scripts/` in the settings folder (File ▸ Scripts ▸ Install Script File… copies a script there), listed in File ▸ Scripts with the bundled samples | Same |
| File ▸ Scripts ▸ Run Script File… (`.jsx`, `.jsxbin`) | `.jsx` / `.js` | Gap: `.jsxbin` (an undocumented binary encoding) |
| File ▸ Scripts ▸ Recent Script Files: the 10 most recently run scripts; Cmd+Option+Shift+D (Ctrl+Alt+Shift+D on Windows) reruns one | File ▸ Scripts ▸ Recent Script Files lists the 10 scripts run last (script files and File ▸ Scripts entries, newest first, kept in the settings); Cmd+Alt+Shift+D runs the most recent one again (`file.runScript {"recent": 0}`, a key that can be changed in Edit ▸ Keyboard Shortcuts) | Same |
| ExtendScript preprocessor directives (`#include`, `#target`, `#targetengine`) | `#include` / `//@include` / `#includepath` inline files relative to the script (once each, depth-limited, file gate applies); `#target`, `#targetengine`, `#strict` are ignored | Same |
| `Scripts/Startup` and `Scripts/Shutdown`: run in alphabetical order when the app starts and quits | Not run | Gap |
| `Scripts/ScriptUI Panels`: listed at the bottom of the Window menu, dockable like the app's own panels, `this` is the panel | `Scripts/ScriptUI Panels/` in the settings folder (File ▸ Scripts ▸ Install ScriptUI Panel… copies one there), listed at the bottom of the Window menu, docked like any panel, `this` is the panel; the panels open when the app quits open again at the next launch | Same. Where a panel was docked isn't kept: workspace layouts aren't saved between launches |
+1
View File
@@ -27,6 +27,7 @@ our own scripts; no real-world script library has been run against both apps.
| `#include`, `#target` directives | yes | `#include` / `//@include` / `#includepath` inline files relative to the including script (once each, nested at most 32 deep, under the scripts' file gate; errors name the included file and line); `#target`, `#targetengine`, `#strict` are ignored | good | `tests_include.rs` (#759); installed scripts and panels have no folder of their own, so they include from the project's folder or absolute paths |
| `.jsxbin` | yes | no | out of scope | No public specification |
| Keyboard shortcuts for scripts | yes | no | missing | plugins.md |
| Recent Script Files | File ▸ Scripts ▸ Recent Script Files (10 scripts), a key to rerun | Same list, kept in the settings; Cmd+Alt+Shift+D reruns the most recent script | same | `recent_script_files_run_again` ([plugins.md](plugins.md)) |
| Command-line rendering `aerender` | yes | `effectcraft-cli render`, `script`, `exec` | same purpose | `apps/effectcraft-cli` |
| Essential Graphics scripting hooks | `addToMotionGraphicsTemplate…` | yes (M13.7) | good | — |
| Agent control (MCP, control channel, every command) | none | yes | beyond | [agents.md](agents.md), [control-protocol.md](control-protocol.md); `list_effects` omits parameter types and ranges (#441) |