From b07e02e7a38e35a54eefdab00e94db581d116ef1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 17:40:05 +0000 Subject: [PATCH 01/45] G5: cache-blocked 32x32 Plane::transpose Used twice per box_plane/gauss_plane vertical pass; bit-identical to the column-by-column version (tested against it). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HDewEVE9bAYgXzvLq94jnb (cherry picked from commit 8c70c77de0bdcfb214f0dbff26bd1e5cf42899e6) (cherry picked from commit 49971b6d329ad05b956f8382269a5becdc19dae2) --- crates/effects/src/util.rs | 63 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 60 insertions(+), 3 deletions(-) diff --git a/crates/effects/src/util.rs b/crates/effects/src/util.rs index 1527e221f..b32a1e521 100644 --- a/crates/effects/src/util.rs +++ b/crates/effects/src/util.rs @@ -59,12 +59,27 @@ impl Plane { let bot = c + (d - c) * tx; top + (bot - top) * ty } + /// Swap rows and columns, in 32×32 tiles so both the reads and the writes stay in cache. pub fn transpose(&self) -> Plane { + const T: usize = 32; let (w, h) = (self.w, self.h); let mut out = Plane::new(h, w); - out.data.par_chunks_mut(h.max(1)).enumerate().for_each(|(x, col)| { - for y in 0..h { - col[y] = self.data[y * w + x]; + if w == 0 || h == 0 { + return out; + } + // Each task fills `T` output rows (source columns `x0..x0 + T`), one tile of source + // rows at a time. + out.data.par_chunks_mut(h * T).enumerate().for_each(|(bi, cols)| { + let x0 = bi * T; + for y0 in (0..h).step_by(T) { + for (y, src) in self.data.chunks(w).enumerate().skip(y0).take(T) { + let Some(src) = src.get(x0..) else { break }; + for (col, &v) in cols.chunks_mut(h).zip(src) { + if let Some(o) = col.get_mut(y) { + *o = v; + } + } + } } }); out @@ -753,6 +768,48 @@ pub fn poly_point_at(pts: &[[f64; 2]], closed: bool, s: f64) -> ([f64; 2], [f64; mod tests { use super::*; + /// The column-by-column transpose the tiled one replaced. + fn transpose_ref(p: &Plane) -> Plane { + let (w, h) = (p.w, p.h); + let mut out = Plane::new(h, w); + out.data.par_chunks_mut(h.max(1)).enumerate().for_each(|(x, col)| { + for y in 0..h { + col[y] = p.data[y * w + x]; + } + }); + out + } + + #[test] + fn tiled_transpose_matches_reference() { + for (w, h) in [(1, 1), (1, 70), (70, 1), (31, 33), (32, 32), (64, 65), (100, 37), (257, 129), (0, 5), (5, 0)] { + let p = Plane { w, h, data: (0..w * h).map(|i| i as f32 * 0.5 - 3.0).collect() }; + let t = p.transpose(); + assert_eq!(t, transpose_ref(&p), "{w}x{h}"); + assert_eq!(t.transpose(), p, "{w}x{h} round trip"); + } + } + + /// Rough timing: `cargo test -p effectcraft-effects --release --lib transpose_timing -- --ignored --nocapture`. + #[test] + #[ignore] + fn transpose_timing() { + let p = Plane { w: 3840, h: 2160, data: (0..3840 * 2160).map(|i| (i % 977) as f32).collect() }; + let t = std::time::Instant::now(); + for _ in 0..10 { + std::hint::black_box(transpose_ref(&p)); + } + let slow = t.elapsed() / 10; + let t = std::time::Instant::now(); + for _ in 0..10 { + std::hint::black_box(p.transpose()); + } + eprintln!("transpose 3840x2160: {slow:?} -> {:?}", t.elapsed() / 10); + let t = std::time::Instant::now(); + std::hint::black_box(gauss_plane(&p, 8.0, 8.0)); + eprintln!("gauss_plane sigma 8: {:?}", t.elapsed()); + } + #[test] fn sim_cache_is_seek_consistent() { static C: SimCache = SimCache::new(2); From 4b66a1192fbed8840e6c6dfadd8ad3ae88b48f88 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 17:40:05 +0000 Subject: [PATCH 02/45] G5: Median histogram updates inline (generic column visitor) Replaces the per-sample &mut dyn FnMut; bit-identical to the old code (kept as a test oracle). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HDewEVE9bAYgXzvLq94jnb (cherry picked from commit 2b3d2b6494c24fe58795ac9bbf3d3642cbf6ec95) (cherry picked from commit 95ea45633a4e244329396d77468533322c7793e1) --- crates/effects/src/noise.rs | 126 +++++++++++++++++++++++++++++++++--- 1 file changed, 117 insertions(+), 9 deletions(-) diff --git a/crates/effects/src/noise.rs b/crates/effects/src/noise.rs index d39fd6528..36cc7e8c6 100644 --- a/crates/effects/src/noise.rs +++ b/crates/effects/src/noise.rs @@ -343,6 +343,19 @@ fn quant(v: f32) -> u16 { (v.clamp(0.0, 1.0) * (NB - 1) as f32).round() as u16 } +/// Call `f` with each quantised pixel of column `x` in rows `y - r..=y + r` (edges repeated); +/// generic so the per-sample histogram update inlines. +#[inline] +fn median_col(q: &[[u16; 4]], w: usize, h: usize, x: i64, y: usize, r: i64, mut f: impl FnMut([u16; 4])) { + let x = x.clamp(0, w as i64 - 1) as usize; + for dy in -r..=r { + let yy = (y as i64 + dy).clamp(0, h as i64 - 1) as usize; + if let Some(&v) = q.get(yy * w + x) { + f(v); + } + } +} + /// Per-channel median over a (2r+1)² window of the premultiplied channels (values quantised to /// 512 levels in 0..1, edges repeated). pub(crate) fn median_image(img: &Image, r: usize) -> Image { @@ -359,14 +372,8 @@ pub(crate) fn median_image(img: &Image, r: usize) -> Image { let mut hist = vec![0u32; NB * 4]; let mut med = [0usize; 4]; let mut lt = [0usize; 4]; - let at = |x: i64, y: i64| q[y.clamp(0, h as i64 - 1) as usize * w + x.clamp(0, w as i64 - 1) as usize]; - let col = |x: i64, f: &mut dyn FnMut([u16; 4])| { - for dy in -ri..=ri { - f(at(x, y as i64 + dy)); - } - }; for dx in -ri..=ri { - col(dx, &mut |v| { + median_col(&q, w, h, dx, y, ri, |v| { for c in 0..4 { hist[c * NB + v[c] as usize] += 1; } @@ -391,7 +398,7 @@ pub(crate) fn median_image(img: &Image, r: usize) -> Image { if x + 1 == w { break; } - col(x as i64 - ri, &mut |v| { + median_col(&q, w, h, x as i64 - ri, y, ri, |v| { for c in 0..4 { hist[c * NB + v[c] as usize] -= 1; if (v[c] as usize) < med[c] { @@ -399,7 +406,7 @@ pub(crate) fn median_image(img: &Image, r: usize) -> Image { } } }); - col(x as i64 + ri + 1, &mut |v| { + median_col(&q, w, h, x as i64 + ri + 1, y, ri, |v| { for c in 0..4 { hist[c * NB + v[c] as usize] += 1; if (v[c] as usize) < med[c] { @@ -853,6 +860,107 @@ mod tests { } } + /// Median before the per-sample `&mut dyn FnMut` became generic (the oracle). + fn median_image_ref(img: &Image, r: usize) -> Image { + let (w, h) = (img.width as usize, img.height as usize); + if r == 0 || w == 0 || h == 0 { + return img.clone(); + } + let q: Vec<[u16; 4]> = img.data.par_iter().map(|p| [quant(p[0]), quant(p[1]), quant(p[2]), quant(p[3])]).collect(); + let n = (2 * r + 1) * (2 * r + 1); + let half = n / 2; + let ri = r as i64; + let mut out = Image::new(img.width, img.height); + out.rows_mut().for_each(|(y, row)| { + let mut hist = vec![0u32; NB * 4]; + let mut med = [0usize; 4]; + let mut lt = [0usize; 4]; + let at = |x: i64, y: i64| q[y.clamp(0, h as i64 - 1) as usize * w + x.clamp(0, w as i64 - 1) as usize]; + let col = |x: i64, f: &mut dyn FnMut([u16; 4])| { + for dy in -ri..=ri { + f(at(x, y as i64 + dy)); + } + }; + for dx in -ri..=ri { + col(dx, &mut |v| { + for c in 0..4 { + hist[c * NB + v[c] as usize] += 1; + } + }); + } + let rebalance = |hist: &[u32], med: &mut [usize; 4], lt: &mut [usize; 4]| { + for c in 0..4 { + let hc = &hist[c * NB..(c + 1) * NB]; + while lt[c] > half { + med[c] -= 1; + lt[c] -= hc[med[c]] as usize; + } + while lt[c] + hc[med[c]] as usize <= half { + lt[c] += hc[med[c]] as usize; + med[c] += 1; + } + } + }; + rebalance(&hist, &mut med, &mut lt); + for x in 0..w { + row[x] = med.map(|m| m as f32 / (NB - 1) as f32); + if x + 1 == w { + break; + } + col(x as i64 - ri, &mut |v| { + for c in 0..4 { + hist[c * NB + v[c] as usize] -= 1; + if (v[c] as usize) < med[c] { + lt[c] -= 1; + } + } + }); + col(x as i64 + ri + 1, &mut |v| { + for c in 0..4 { + hist[c * NB + v[c] as usize] += 1; + if (v[c] as usize) < med[c] { + lt[c] += 1; + } + } + }); + rebalance(&hist, &mut med, &mut lt); + } + }); + out + } + + #[test] + fn median_is_bit_identical_to_reference() { + let (w, h) = (23u32, 17u32); + let mut img = Image::new(w, h); + for y in 0..h { + for x in 0..w { + let a = effectcraft_raster::hash_noise(x, y, 3); + img.set(x, y, [effectcraft_raster::hash_noise(x, y, 4) * a, 0.5 * a, a * a, a]); + } + } + for r in [1usize, 2, 5, 12, 30] { + assert_eq!(median_image(&img, r), median_image_ref(&img, r), "r={r}"); + } + } + + /// Rough timing: `cargo test -p effectcraft-effects --release --lib median_timing -- --ignored --nocapture`. + #[test] + #[ignore] + fn median_timing() { + let mut img = Image::new(1920, 1080); + for (i, p) in img.data.iter_mut().enumerate() { + let v = effectcraft_raster::hash_noise(i as u32, 0, 1); + *p = [v, v * 0.5, v * 0.25, 1.0]; + } + let t = std::time::Instant::now(); + std::hint::black_box(median_image_ref(&img, 10)); + let slow = t.elapsed(); + let t = std::time::Instant::now(); + std::hint::black_box(median_image(&img, 10)); + eprintln!("median r10 1080p: {slow:?} -> {:?}", t.elapsed()); + } + #[test] fn median_matches_brute_force() { let (w, h) = (9u32, 7u32); From 2f33ab91e3ad38c409d017b0a2cd4f1dab571b13 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 17:40:05 +0000 Subject: [PATCH 03/45] G5: Camera Lens Blur sums iris span counts once per iris Pixel counts were re-summed for every output pixel; output bit-identical. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HDewEVE9bAYgXzvLq94jnb (cherry picked from commit d84d1b1a497eb0c397a85c3a36b6775a247354ba) (cherry picked from commit 33835202ba427d18e3794dc49421794115725d92) --- crates/effects/src/blur2.rs | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/crates/effects/src/blur2.rs b/crates/effects/src/blur2.rs index e4f2b0348..dd08cc299 100644 --- a/crates/effects/src/blur2.rs +++ b/crates/effects/src/blur2.rs @@ -301,6 +301,14 @@ pub fn camera_lens_plain(ctx: &EffectCtx) -> bool { ctx.params.f("irisProperties/diffractionFringe") <= 0.0 && ctx.params.get("blurMap/blurMapLayer").and_then(|v| v.as_layer()).is_none() } +/// Camera Lens Blur iris spans `(dy, x_left, x_right)` and their pixel counts. +struct Iris { + outer: Vec<(i64, i64, i64)>, + inner: Vec<(i64, i64, i64)>, + no: f64, + ni: f64, +} + fn camera_lens_blur(ctx: &EffectCtx, mut b: Buf) -> Buf { let r = ctx.params.f("blurRadius").max(0.0) * b.scale; if r < 0.5 { @@ -406,7 +414,9 @@ fn camera_lens_blur(ctx: &EffectCtx, mut b: Buf) -> Buf { // 0 = an even disc, 100 = a natural halo, 500 = all of it in the ring. let fringe = (ctx.params.f("irisProperties/diffractionFringe") / 500.0).clamp(0.0, 1.0); let (w_in, w_ring) = (1.0 - fringe, 1.0 + 4.0 * fringe); - let iris = |radius: f64| -> (Vec<(i64, i64, i64)>, Vec<(i64, i64, i64)>) { + let count = |s: &[(i64, i64, i64)]| s.iter().map(|(_, l, r)| (r - l + 1) as f64).sum::(); + // The outer and inner (fringe) iris spans of a radius, with their pixel counts summed once. + let iris = |radius: f64| -> Iris { let stretch = |mut s: Vec<(i64, i64, i64)>| { if aspect > 0.0 && (aspect - 1.0).abs() > 1e-6 { for v in s.iter_mut() { @@ -422,18 +432,18 @@ fn camera_lens_blur(ctx: &EffectCtx, mut b: Buf) -> Buf { } else { vec![] }; - (outer, inner) + let (no, ni) = (count(&outer), count(&inner)); + Iris { outer, inner, no, ni } }; - let count = |s: &[(i64, i64, i64)]| s.iter().map(|(_, l, r)| (r - l + 1) as f64).sum::(); // One blurred pixel with the given iris. - let blur_px = |sp: &(Vec<(i64, i64, i64)>, Vec<(i64, i64, i64)>), x: usize, y: usize| -> [f32; 4] { - let (outer, inner) = sp; + let blur_px = |sp: &Iris, x: usize, y: usize| -> [f32; 4] { + let Iris { outer, inner, no, ni } = sp; + let (no, ni) = (*no, *ni); let acc = if inner.is_empty() { - let n = count(outer).max(1.0); + let n = no.max(1.0); span_sum(outer, x, y).map(|v| v / n) } else { let (so, si) = (span_sum(outer, x, y), span_sum(inner, x, y)); - let (no, ni) = (count(outer), count(inner)); let norm = (w_in * ni + w_ring * (no - ni)).max(1e-9); std::array::from_fn(|c| (w_in * si[c] + w_ring * (so[c] - si[c])) / norm) }; From b2b365a3824c06acc21cba14395113b35be91cc2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 17:57:25 +0000 Subject: [PATCH 04/45] G5: Cell Pattern computes each cell's feature point once per frame Bit-identical (oracle test against the per-sample computation); HQ ~7x faster. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013MFSUedLFmbuKi4EawpizQ (cherry picked from commit 89f485e1aadfbe5c221caa93be391faa34a5f5a1) (cherry picked from commit 32281ffc28f978c4397efb6ee02ac9ac38174235) --- crates/effects/src/generate2.rs | 300 +++++++++++++++++++++++++++----- 1 file changed, 252 insertions(+), 48 deletions(-) diff --git a/crates/effects/src/generate2.rs b/crates/effects/src/generate2.rs index 80d32ce0e..4b7a4fcfe 100644 --- a/crates/effects/src/generate2.rs +++ b/crates/effects/src/generate2.rs @@ -74,32 +74,40 @@ pub fn pattern_kind(i: u32) -> u32 { } } -/// Worley distances for point (u, v) in cell units: (F1, F2, hash of the nearest cell). +/// The feature point (x, y in cell units) of cell (gx, gy) and the cell's shade. /// `tile` repeats the cell layout every (columns, rows) cells. -fn worley_r(u: f64, v: f64, disperse: f64, evo: f64, seed: u32, tile: Option<(i64, i64)>, reach: i64) -> (f64, f64, f32) { +#[inline] +fn cell_point(gx: i64, gy: i64, disperse: f64, evo: f64, seed: u32, tile: Option<(i64, i64)>) -> (f64, f64, f32) { + let (tx, ty) = match tile { + Some((nx, ny)) => (gx.rem_euclid(nx), gy.rem_euclid(ny)), + None => (gx, gy), + }; + let (hx, hy) = (tx as i32 as u32, ty as i32 as u32); + let h1 = hash1(hx, hy, seed) as f64; + let h2 = hash1(hx, hy, seed.wrapping_add(1)) as f64; + let h3 = hash1(hx, hy, seed.wrapping_add(2)) as f64; + let ang = 2.0 * PI * (h3 + evo); + let px = gx as f64 + 0.5 + (h1 - 0.5) * disperse + 0.15 * disperse * ang.cos(); + let py = gy as f64 + 0.5 + (h2 - 0.5) * disperse + 0.15 * disperse * ang.sin(); + (px, py, hash1(hx, hy, seed.wrapping_add(3))) +} + +/// Worley distances for point (u, v) in cell units: (F1, F2, shade of the nearest cell), over +/// the cells within `reach` of (u, v)'s, whose feature points come from `point`. +#[inline] +fn worley_r(u: f64, v: f64, reach: i64, point: impl Fn(i64, i64) -> (f64, f64, f32)) -> (f64, f64, f32) { let (cx, cy) = (u.floor() as i64, v.floor() as i64); let mut f1 = f64::INFINITY; let mut f2 = f64::INFINITY; let mut id = 0.0f32; for j in -reach..=reach { for i in -reach..=reach { - let (gx, gy) = (cx + i, cy + j); - let (tx, ty) = match tile { - Some((nx, ny)) => (gx.rem_euclid(nx), gy.rem_euclid(ny)), - None => (gx, gy), - }; - let (hx, hy) = (tx as i32 as u32, ty as i32 as u32); - let h1 = hash1(hx, hy, seed) as f64; - let h2 = hash1(hx, hy, seed.wrapping_add(1)) as f64; - let h3 = hash1(hx, hy, seed.wrapping_add(2)) as f64; - let ang = 2.0 * PI * (h3 + evo); - let px = gx as f64 + 0.5 + (h1 - 0.5) * disperse + 0.15 * disperse * ang.cos(); - let py = gy as f64 + 0.5 + (h2 - 0.5) * disperse + 0.15 * disperse * ang.sin(); + let (px, py, cell_id) = point(cx.wrapping_add(i), cy.wrapping_add(j)); let d = ((px - u).powi(2) + (py - v).powi(2)).sqrt(); if d < f1 { f2 = f1; f1 = d; - id = hash1(hx, hy, seed.wrapping_add(3)); + id = cell_id; } else if d < f2 { f2 = d; } @@ -108,6 +116,46 @@ fn worley_r(u: f64, v: f64, disperse: f64, evo: f64, seed: u32, tile: Option<(i6 (f1, f2, id) } +/// Most cells a [`CellGrid`] holds (24 MB); larger layouts compute points as they go. +const CELL_GRID_MAX: usize = 1 << 20; + +/// The feature points of a rectangle of cells, computed once per frame: an HQ pixel otherwise +/// recomputes 25 cells' points (four hashes and a sine and cosine each) for each of 16 samples. +struct CellGrid { + x0: i64, + y0: i64, + w: usize, + h: usize, + pts: Vec<(f64, f64, f32)>, +} + +impl CellGrid { + /// Cells `x0..=x1` × `y0..=y1`, or `None` past [`CELL_GRID_MAX`]. + fn new(x0: i64, x1: i64, y0: i64, y1: i64, point: impl Fn(i64, i64) -> (f64, f64, f32) + Sync) -> Option { + let w = usize::try_from(x1.checked_sub(x0)?.checked_add(1)?).ok()?; + let h = usize::try_from(y1.checked_sub(y0)?.checked_add(1)?).ok()?; + if w.checked_mul(h)? > CELL_GRID_MAX { + return None; + } + let pts = (0..h) + .into_par_iter() + .flat_map_iter(|j| { + let gy = y0.wrapping_add(j as i64); + let point = &point; + (0..w).map(move |i| point(x0.wrapping_add(i as i64), gy)) + }) + .collect(); + Some(CellGrid { x0, y0, w, h, pts }) + } + + #[inline] + fn get(&self, gx: i64, gy: i64) -> Option<(f64, f64, f32)> { + let i = usize::try_from(gx.checked_sub(self.x0)?).ok().filter(|i| *i < self.w)?; + let j = usize::try_from(gy.checked_sub(self.y0)?).ok().filter(|j| *j < self.h)?; + self.pts.get(j * self.w + i).copied() + } +} + fn cell_value(pattern: u32, f1: f64, f2: f64, id: f32) -> f32 { let (f1, f2) = (f1 as f32, f2 as f32); let e = f2 - f1; @@ -135,45 +183,102 @@ fn overflow(v: f32, mode: u32) -> f32 { } fn cell_pattern(ctx: &EffectCtx, mut b: Buf) -> Buf { - let pattern = pattern_kind(ctx.params.e("cellPattern")); - let hq = (6..=10).contains(&ctx.params.e("cellPattern")); - let invert = ctx.params.b("invert"); - let contrast = ctx.params.f("contrast") as f32 / 100.0; - let ov = ctx.params.e("overflow"); - let disperse = ctx.params.f("disperse").clamp(0.0, 1.5); - let size = (ctx.params.f("size") * b.scale).max(1.0); - let off = b.to_px(ctx.params.v2("offset")); - // Static Plates keep their shades still while the cells move. - let mut evo = ctx.params.f("evolution") / 360.0; - // Cycle Evolution loops the evolution every `cycle` revolutions. - if ctx.params.b("evolutionOptions/cycleEvolution") { - evo = evo.rem_euclid(ctx.params.f("evolutionOptions/cycle").round().max(1.0)); - } - let seed = (ctx.params.f("evolutionOptions/randomSeed") as i64 as u32).wrapping_mul(7919) ^ 0xce11; - let tile = ctx - .params - .b("tilingOptions/enableTiling") - .then(|| (ctx.params.f("tilingOptions/cellsHorizontal").round().max(1.0) as i64, ctx.params.f("tilingOptions/cellsVertical").round().max(1.0) as i64)); - let subs: Vec = if hq { (0..4).map(|k| (k as f64 + 0.5) / 4.0).collect() } else { vec![0.5] }; - let reach = if hq { 2 } else { 1 }; + let cp = CellParams::new(ctx, &b); + let point = |gx: i64, gy: i64| cp.point(gx, gy); + // Every cell the pixels' searches reach: u and v grow with x and y. + let (w, h) = (b.img.width as usize, b.img.height as usize); + let (first, last) = (cp.subs.first().copied().unwrap_or(0.5), cp.subs.last().copied().unwrap_or(0.5)); + let cell = |x: usize, s: f64, o: f64| ((x as f64 + s - o) / cp.size).floor() as i64; + let grid = (w > 0 && h > 0) + .then(|| { + CellGrid::new( + cell(0, first, cp.off.0).saturating_sub(cp.reach), + cell(w - 1, last, cp.off.0).saturating_add(cp.reach), + cell(0, first, cp.off.1).saturating_sub(cp.reach), + cell(h - 1, last, cp.off.1).saturating_add(cp.reach), + point, + ) + }) + .flatten(); map_xy(&mut b.img, |x, y, _| { + let val = cp.shade(x, y, |u, v| match &grid { + Some(g) => worley_r(u, v, cp.reach, |gx, gy| g.get(gx, gy).unwrap_or_else(|| point(gx, gy))), + None => worley_r(u, v, cp.reach, point), + }); + [val, val, val, 1.0] + }); + b +} + +/// Cell Pattern's parameters for one frame. +struct CellParams { + pattern: u32, + invert: bool, + contrast: f32, + ov: u32, + disperse: f64, + size: f64, + off: (f64, f64), + evo: f64, + seed: u32, + tile: Option<(i64, i64)>, + /// Subsample offsets on each axis. + subs: Vec, + /// Cells searched around a sample's own. + reach: i64, +} + +impl CellParams { + fn new(ctx: &EffectCtx, b: &Buf) -> CellParams { + let hq = (6..=10).contains(&ctx.params.e("cellPattern")); + // Static Plates keep their shades still while the cells move. + let mut evo = ctx.params.f("evolution") / 360.0; + // Cycle Evolution loops the evolution every `cycle` revolutions. + if ctx.params.b("evolutionOptions/cycleEvolution") { + evo = evo.rem_euclid(ctx.params.f("evolutionOptions/cycle").round().max(1.0)); + } + CellParams { + pattern: pattern_kind(ctx.params.e("cellPattern")), + invert: ctx.params.b("invert"), + contrast: ctx.params.f("contrast") as f32 / 100.0, + ov: ctx.params.e("overflow"), + disperse: ctx.params.f("disperse").clamp(0.0, 1.5), + size: (ctx.params.f("size") * b.scale).max(1.0), + off: b.to_px(ctx.params.v2("offset")), + evo, + seed: (ctx.params.f("evolutionOptions/randomSeed") as i64 as u32).wrapping_mul(7919) ^ 0xce11, + tile: ctx.params.b("tilingOptions/enableTiling").then(|| { + (ctx.params.f("tilingOptions/cellsHorizontal").round().max(1.0) as i64, ctx.params.f("tilingOptions/cellsVertical").round().max(1.0) as i64) + }), + subs: if hq { (0..4).map(|k| (k as f64 + 0.5) / 4.0).collect() } else { vec![0.5] }, + reach: if hq { 2 } else { 1 }, + } + } + + #[inline] + fn point(&self, gx: i64, gy: i64) -> (f64, f64, f32) { + cell_point(gx, gy, self.disperse, self.evo, self.seed, self.tile) + } + + /// The pixel's shade from Worley samples at its subsamples. + #[inline] + fn shade(&self, x: usize, y: usize, worley: impl Fn(f64, f64) -> (f64, f64, f32)) -> f32 { let mut val = 0.0; - for sy in &subs { - for sx in &subs { - let u = (x as f64 + sx - off.0) / size; - let v = (y as f64 + sy - off.1) / size; - let (f1, f2, id) = worley_r(u, v, disperse, evo, seed, tile, reach); - val += cell_value(pattern, f1, f2, id); + for sy in &self.subs { + for sx in &self.subs { + let u = (x as f64 + sx - self.off.0) / self.size; + let v = (y as f64 + sy - self.off.1) / self.size; + let (f1, f2, id) = worley(u, v); + val += cell_value(self.pattern, f1, f2, id); } } - let mut val = val / (subs.len() * subs.len()) as f32; - val = overflow((val - 0.5) * contrast + 0.5, ov); - if invert { + let mut val = val / (self.subs.len() * self.subs.len()) as f32; + val = overflow((val - 0.5) * self.contrast + 0.5, self.ov); + if self.invert { val = 1.0 - val; } - [val, val, val, 1.0] - }); - b + val + } } // ---------------------------------------------------------------- Ellipse @@ -1534,4 +1639,103 @@ mod tests { assert_ne!(std, hq); assert!(distinct(&hq) > distinct(&std) * 2, "{} vs {}", distinct(&hq), distinct(&std)); } + + /// Worley distances as computed before the per-frame [`CellGrid`]: every cell's point + /// recomputed for every sample. + fn worley_old(u: f64, v: f64, disperse: f64, evo: f64, seed: u32, tile: Option<(i64, i64)>, reach: i64) -> (f64, f64, f32) { + let (cx, cy) = (u.floor() as i64, v.floor() as i64); + let mut f1 = f64::INFINITY; + let mut f2 = f64::INFINITY; + let mut id = 0.0f32; + for j in -reach..=reach { + for i in -reach..=reach { + let (gx, gy) = (cx + i, cy + j); + let (tx, ty) = match tile { + Some((nx, ny)) => (gx.rem_euclid(nx), gy.rem_euclid(ny)), + None => (gx, gy), + }; + let (hx, hy) = (tx as i32 as u32, ty as i32 as u32); + let h1 = hash1(hx, hy, seed) as f64; + let h2 = hash1(hx, hy, seed.wrapping_add(1)) as f64; + let h3 = hash1(hx, hy, seed.wrapping_add(2)) as f64; + let ang = 2.0 * PI * (h3 + evo); + let px = gx as f64 + 0.5 + (h1 - 0.5) * disperse + 0.15 * disperse * ang.cos(); + let py = gy as f64 + 0.5 + (h2 - 0.5) * disperse + 0.15 * disperse * ang.sin(); + let d = ((px - u).powi(2) + (py - v).powi(2)).sqrt(); + if d < f1 { + f2 = f1; + f1 = d; + id = hash1(hx, hy, seed.wrapping_add(3)); + } else if d < f2 { + f2 = d; + } + } + } + (f1, f2, id) + } + + fn cell_ctx_render(set: &[(&str, Value)], buf: Buf) -> (Image, Image) { + let s = crate::find("ec.generate.cellpattern").unwrap(); + let mut params = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() }; + for (k, v) in set { + params.values.insert(k.to_string(), v.clone()); + } + let ctx = EffectCtx { params: ¶ms, time: 0.0, layer_size: [buf.img.width as f64, buf.img.height as f64], seed: 1, adjustment: false, env: Default::default() }; + let cp = CellParams::new(&ctx, &buf); + let mut old = buf.img.clone(); + map_xy(&mut old, |x, y, _| { + let val = cp.shade(x, y, |u, v| worley_old(u, v, cp.disperse, cp.evo, cp.seed, cp.tile, cp.reach)); + [val, val, val, 1.0] + }); + (cell_pattern(&ctx, buf).img, old) + } + + #[test] + fn cell_pattern_grid_matches_per_sample_points_bit_for_bit() { + let sets: Vec> = vec![ + vec![], + vec![("size", num(7.0)), ("disperse", num(1.5)), ("evolution", num(123.0))], + vec![("cellPattern", Value::Enum(9)), ("size", num(11.0)), ("offset", pt(-13.7, 41.2))], + vec![("cellPattern", Value::Enum(10)), ("size", num(1.0)), ("disperse", num(1.2))], + vec![("cellPattern", Value::Enum(7)), ("size", num(5.5)), ("tilingOptions/enableTiling", Value::Bool(true)), ("tilingOptions/cellsHorizontal", num(3.0)), ("tilingOptions/cellsVertical", num(2.0))], + vec![("cellPattern", Value::Enum(6)), ("size", num(9.0)), ("evolutionOptions/randomSeed", num(77.0)), ("offset", pt(1e6, -2e6))], + ]; + for set in &sets { + for (w, h, offset) in [(37, 29, [0.0, 0.0]), (16, 9, [-5.5, 3.25]), (0, 4, [0.0, 0.0])] { + let (new, old) = cell_ctx_render(set, Buf { img: Image::new(w, h), offset, scale: 1.0 }); + let bits = |img: &Image| img.data.iter().map(|p| p.map(f32::to_bits)).collect::>(); + assert!(bits(&new) == bits(&old), "{set:?} {w}x{h}"); + } + } + // Points outside the grid fall back to computing them. + let g = CellGrid::new(0, 1, 0, 1, |x, y| (x as f64, y as f64, 0.5)).unwrap(); + assert_eq!(g.get(1, 1), Some((1.0, 1.0, 0.5))); + assert_eq!(g.get(2, 0), None); + assert_eq!(g.get(-1, 0), None); + assert_eq!(g.get(i64::MIN, i64::MAX), None); + assert!(CellGrid::new(i64::MIN, i64::MAX, 0, 0, |_, _| (0.0, 0.0, 0.0)).is_none()); + } + + /// Rough timing of Cell Pattern HQ (`cargo test -p effectcraft-effects --release --lib + /// cell_pattern_timing -- --ignored --nocapture`). + #[test] + #[ignore] + fn cell_pattern_timing() { + for pat in [1u32, 9] { + let set = [("cellPattern", Value::Enum(pat)), ("size", num(30.0))]; + let t = std::time::Instant::now(); + let (_, _) = cell_ctx_render(&set, Buf { img: Image::new(640, 360), offset: [0.0; 2], scale: 1.0 }); + let both = t.elapsed(); + let t = std::time::Instant::now(); + let s = crate::find("ec.generate.cellpattern").unwrap(); + let mut params = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() }; + for (k, v) in &set { + params.values.insert(k.to_string(), v.clone()); + } + let ctx = EffectCtx { params: ¶ms, time: 0.0, layer_size: [640.0, 360.0], seed: 1, adjustment: false, env: Default::default() }; + let _ = cell_pattern(&ctx, Buf { img: Image::new(640, 360), offset: [0.0; 2], scale: 1.0 }); + let new = t.elapsed(); + println!("pattern {pat} 640x360: new {new:?}, old ≈ {:?}", both.saturating_sub(new)); + } + } } From 563c5236e13afee5d885e3f4efc3a0b284d387d8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 08:54:16 +0000 Subject: [PATCH 05/45] G5: rustfmt the Cell Pattern feature-point cache (cherry picked from commit b7bb2dba1a62405f5712432acf05a6726b713820) (cherry picked from commit 3e3fe2d16aa3c530b3aa084560bd16e38bb0ab8e) --- crates/effects/src/generate2.rs | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/crates/effects/src/generate2.rs b/crates/effects/src/generate2.rs index 4b7a4fcfe..7245ff3a7 100644 --- a/crates/effects/src/generate2.rs +++ b/crates/effects/src/generate2.rs @@ -1680,7 +1680,14 @@ mod tests { for (k, v) in set { params.values.insert(k.to_string(), v.clone()); } - let ctx = EffectCtx { params: ¶ms, time: 0.0, layer_size: [buf.img.width as f64, buf.img.height as f64], seed: 1, adjustment: false, env: Default::default() }; + let ctx = EffectCtx { + params: ¶ms, + time: 0.0, + layer_size: [buf.img.width as f64, buf.img.height as f64], + seed: 1, + adjustment: false, + env: Default::default(), + }; let cp = CellParams::new(&ctx, &buf); let mut old = buf.img.clone(); map_xy(&mut old, |x, y, _| { @@ -1697,7 +1704,13 @@ mod tests { vec![("size", num(7.0)), ("disperse", num(1.5)), ("evolution", num(123.0))], vec![("cellPattern", Value::Enum(9)), ("size", num(11.0)), ("offset", pt(-13.7, 41.2))], vec![("cellPattern", Value::Enum(10)), ("size", num(1.0)), ("disperse", num(1.2))], - vec![("cellPattern", Value::Enum(7)), ("size", num(5.5)), ("tilingOptions/enableTiling", Value::Bool(true)), ("tilingOptions/cellsHorizontal", num(3.0)), ("tilingOptions/cellsVertical", num(2.0))], + vec![ + ("cellPattern", Value::Enum(7)), + ("size", num(5.5)), + ("tilingOptions/enableTiling", Value::Bool(true)), + ("tilingOptions/cellsHorizontal", num(3.0)), + ("tilingOptions/cellsVertical", num(2.0)), + ], vec![("cellPattern", Value::Enum(6)), ("size", num(9.0)), ("evolutionOptions/randomSeed", num(77.0)), ("offset", pt(1e6, -2e6))], ]; for set in &sets { From 7b6430a4c7ad9f96282227c24b99a96244176432 Mon Sep 17 00:00:00 2001 From: Elias Date: Sat, 10 Oct 2026 15:25:40 +0100 Subject: [PATCH 06/45] G5: layer cache packs quantised 8/16 bpc entries (u8/u16, lossless) under budget pressure instead of evicting (cherry picked from commit 3e72db60c3190f079838a9c5163f08bf22caafe4) (cherry picked from commit 28722b50fca3c7ca6121d60aa3c0b3e99de2dda9) --- crates/render/src/cache.rs | 144 ++++++++++++++++++++++++------ crates/render/src/packed.rs | 89 ++++++++++++++++++ crates/render/src/tests_packed.rs | 115 ++++++++++++++++++++++++ 3 files changed, 320 insertions(+), 28 deletions(-) create mode 100644 crates/render/src/packed.rs create mode 100644 crates/render/src/tests_packed.rs diff --git a/crates/render/src/cache.rs b/crates/render/src/cache.rs index 88fa5c7cc..37c2763b7 100644 --- a/crates/render/src/cache.rs +++ b/crates/render/src/cache.rs @@ -25,6 +25,7 @@ use effectcraft_effects::Buf; use effectcraft_project::{ItemKind, Layer, LayerSource, Node, PropGroup}; use crate::eval::EvalCtx; +use crate::packed::PackedBuf; mod dependencies; @@ -42,9 +43,21 @@ pub struct LayerCache { } struct Entry { - buf: Arc, + buf: Stored, + /// Bytes of pixel storage (packed size when packed). bytes: usize, last_use: u64, + /// [`PackedBuf::pack`] declined this buffer (not on an integer grid): don't try again. + unpackable: bool, +} + +/// How an entry keeps its pixels. Entries go in as `f32` buffers (a hit shares the `Arc`, and +/// the GPU compositor keeps their textures while it lives); only when the cache is over budget +/// are the least recently used ones packed (lossless, see [`crate::packed`]) before anything +/// is evicted, and a hit on a packed entry unpacks it back to `f32`. +enum Stored { + Full(Arc), + Packed(Arc), } struct Inner { @@ -154,9 +167,18 @@ impl LayerCache { match g.map.get_mut(&key) { Some(e) => { e.last_use = now; - let b = e.buf.clone(); + let stored = match &e.buf { + Stored::Full(b) => Ok(b.clone()), + Stored::Packed(p) => Err(p.clone()), + }; g.hits += 1; - Some(b) + match stored { + Ok(b) => Some(b), + Err(p) => { + drop(g); + Some(self.unpacked(key, &p)) + } + } } None => { g.misses += 1; @@ -190,29 +212,90 @@ impl LayerCache { fn insert_mem(&self, key: u64, buf: Arc) { let bytes = buf.img.data.len() * std::mem::size_of::(); - let Ok(mut g) = self.inner.lock() else { return }; - if bytes > g.budget / 4 { - return; + { + let Ok(mut g) = self.inner.lock() else { return }; + if bytes > g.budget / 4 { + return; + } + g.clock += 1; + let now = g.clock; + if let Some(old) = g.map.insert(key, Entry { buf: Stored::Full(buf), bytes, last_use: now, unpackable: false }) { + g.bytes -= old.bytes; + } + g.bytes += bytes; } - g.clock += 1; - let now = g.clock; - if let Some(old) = g.map.insert(key, Entry { buf, bytes, last_use: now }) { - g.bytes -= old.bytes; + self.relieve(); + } + + /// A packed entry's pixels as `f32`: unpacked (outside the lock) and kept unpacked, as it is + /// in use again. + fn unpacked(&self, key: u64, p: &Arc) -> Arc { + let buf = Arc::new(p.unpack()); + let bytes = buf.img.data.len() * std::mem::size_of::(); + { + let Ok(mut g) = self.inner.lock() else { return buf }; + let Some(e) = g.map.get_mut(&key) else { return buf }; + match &e.buf { + // Another thread unpacked it first: share its buffer. + Stored::Full(b) => return b.clone(), + Stored::Packed(q) if !Arc::ptr_eq(p, q) => return buf, + Stored::Packed(_) => {} + } + let old = std::mem::replace(&mut e.bytes, bytes); + e.buf = Stored::Full(buf.clone()); + g.bytes = g.bytes.saturating_sub(old) + bytes; } - g.bytes += bytes; - if g.bytes > g.budget { - // Evict least-recently used entries down to 3/4 of the budget. - let target = g.budget / 4 * 3; - let mut by_age: Vec<(u64, u64)> = g.map.iter().map(|(k, e)| (e.last_use, *k)).collect(); + self.relieve(); + buf + } + + /// Over budget: pack the least recently used `f32` entries (outside the lock), then evict + /// least recently used entries down to 3/4 of the budget if that wasn't enough. + fn relieve(&self) { + let picked: Vec<(u64, Arc)> = { + let Ok(mut g) = self.inner.lock() else { return }; + if g.bytes <= g.budget { + return; + } + let excess = g.bytes - g.budget / 4 * 3; + let mut by_age: Vec<(u64, u64)> = + g.map.iter().filter(|(_, e)| matches!(e.buf, Stored::Full(_)) && !e.unpackable).map(|(k, e)| (e.last_use, *k)).collect(); by_age.sort_unstable(); + // Packing saves at least half (16 bpc). + let mut saved = 0; + let mut picked = vec![]; for (_, k) in by_age { - if g.bytes <= target { + if saved >= excess { break; } - if let Some(e) = g.map.remove(&k) { - g.bytes -= e.bytes; + if let Some(e) = g.map.get_mut(&k) + && let Stored::Full(b) = &e.buf + { + saved += e.bytes / 2; + picked.push((k, b.clone())); + // Not picked again by a concurrent call while this one packs it. + e.unpackable = true; } } + picked + }; + let packed: Vec<_> = picked.into_iter().map(|(k, b)| (k, PackedBuf::pack(&b), b)).collect(); + let Ok(mut g) = self.inner.lock() else { return }; + for (k, p, b) in packed { + let Some(e) = g.map.get_mut(&k) else { continue }; + if !matches!(&e.buf, Stored::Full(cur) if Arc::ptr_eq(cur, &b)) { + continue; + } + let Some(p) = p else { continue }; + let bytes = p.bytes(); + let old = std::mem::replace(&mut e.bytes, bytes); + e.buf = Stored::Packed(Arc::new(p)); + e.unpackable = false; + g.bytes = g.bytes.saturating_sub(old) + bytes; + } + if g.bytes > g.budget { + let target = g.budget / 4 * 3; + evict_lru(&mut g, target); } } @@ -226,16 +309,7 @@ impl LayerCache { let Ok(mut g) = self.inner.lock() else { return }; g.budget = budget; if g.bytes > budget { - let mut by_age: Vec<(u64, u64)> = g.map.iter().map(|(k, e)| (e.last_use, *k)).collect(); - by_age.sort_unstable(); - for (_, k) in by_age { - if g.bytes <= budget { - break; - } - if let Some(e) = g.map.remove(&k) { - g.bytes -= e.bytes; - } - } + evict_lru(&mut g, budget); } } @@ -251,6 +325,20 @@ impl LayerCache { } } +/// Evict least recently used entries until at most `target` bytes are kept. +fn evict_lru(g: &mut Inner, target: usize) { + let mut by_age: Vec<(u64, u64)> = g.map.iter().map(|(k, e)| (e.last_use, *k)).collect(); + by_age.sort_unstable(); + for (_, k) in by_age { + if g.bytes <= target { + break; + } + if let Some(e) = g.map.remove(&k) { + g.bytes -= e.bytes; + } + } +} + /// Persistent storage of layer buffers behind a [`LayerCache`] (keys salted per project). pub trait LayerStore: Send + Sync { /// A stored buffer (synchronous: mid-render). diff --git a/crates/render/src/packed.rs b/crates/render/src/packed.rs new file mode 100644 index 000000000..f1224a795 --- /dev/null +++ b/crates/render/src/packed.rs @@ -0,0 +1,89 @@ +//! Lossless compact storage of quantised layer buffers (the [`crate::LayerCache`]). +//! +//! In 8 and 16 bpc projects each layer buffer is clamped and quantised to the bit depth +//! ([`crate::color::Pipe::quantize`]): every premultiplied channel is `k as f32 * (1.0 / levels)` +//! for an integer `k` in `0..=levels` (colour ≤ alpha). Such a buffer can be kept as those +//! integers — `u8` for 255 levels, `u16` for 32768 — a quarter or half of its `f32` size, and +//! unpacks to the identical floats. [`PackedBuf::pack`] checks that every value round-trips bit +//! for bit and declines otherwise (32 bpc, linear-blending copies, unquantised style passes), so +//! packing never changes a pixel. + +use effectcraft_effects::Buf; +use effectcraft_raster::{Image, Px}; +use rayon::prelude::*; + +/// A [`Buf`] whose pixels sit on an integer grid, stored as the grid's integers. +#[derive(Clone, Debug)] +pub(crate) struct PackedBuf { + width: u32, + height: u32, + offset: [f64; 2], + scale: f64, + data: Packed, +} + +#[derive(Clone, Debug)] +enum Packed { + /// 8 bpc: 255 levels. + U8(Vec<[u8; 4]>), + /// 16 bpc: After Effects' 32768 levels. + U16(Vec<[u16; 4]>), +} + +const L8: f32 = 255.0; +const L16: f32 = 32768.0; + +/// `v` as an integer level `k` with `k as f32 * inv` bitwise equal to `v` (`None` off the grid, +/// out of range, negative zero or NaN). +#[inline(always)] +fn level(v: f32, levels: f32, inv: f32) -> Option { + if !(0.0..=1.0).contains(&v) { + return None; + } + // Same rounding as `color::quantize_px` (non-negative, so the cast rounds half up). + let k = (v * levels + 0.5) as u32; + ((k as f32 * inv).to_bits() == v.to_bits()).then_some(k) +} + +fn pack_with(data: &[Px], levels: f32, conv: impl Fn(u32) -> T + Sync + Send) -> Option> { + let inv = 1.0 / levels; + data.par_iter() + .map(|p| { + let k = [level(p[0], levels, inv)?, level(p[1], levels, inv)?, level(p[2], levels, inv)?, level(p[3], levels, inv)?]; + Some(k.map(&conv)) + }) + .collect() +} + +impl PackedBuf { + /// `buf` packed losslessly, or `None` when a value is not on the 8 or 16 bpc grid. + pub(crate) fn pack(buf: &Buf) -> Option { + let px = &buf.img.data; + let data = match pack_with(px, L8, |k| k as u8) { + Some(d) => Packed::U8(d), + None => Packed::U16(pack_with(px, L16, |k| k as u16)?), + }; + Some(PackedBuf { width: buf.img.width, height: buf.img.height, offset: buf.offset, scale: buf.scale, data }) + } + + /// The buffer again (bit-identical to the one packed). + pub(crate) fn unpack(&self) -> Buf { + fn floats + Sync>(d: &[[T; 4]], levels: f32) -> Vec { + let inv = 1.0 / levels; + d.par_iter().map(|p| p.map(|k| k.into() as f32 * inv)).collect() + } + let data = match &self.data { + Packed::U8(d) => floats(d, L8), + Packed::U16(d) => floats(d, L16), + }; + Buf { img: Image { width: self.width, height: self.height, data }, offset: self.offset, scale: self.scale } + } + + /// Bytes of pixel storage. + pub(crate) fn bytes(&self) -> usize { + match &self.data { + Packed::U8(d) => d.len() * 4, + Packed::U16(d) => d.len() * 8, + } + } +} diff --git a/crates/render/src/tests_packed.rs b/crates/render/src/tests_packed.rs new file mode 100644 index 000000000..1ac506024 --- /dev/null +++ b/crates/render/src/tests_packed.rs @@ -0,0 +1,115 @@ +//! Packed layer cache entries (`packed.rs`): lossless for quantised 8/16 bpc buffers, never +//! applied to anything else. + +use std::sync::Arc; + +use crate::packed::PackedBuf; +use crate::{Buf, LayerCache}; +use effectcraft_raster::Image; + +/// Deterministic premultiplied pixels (colour ≤ alpha, some transparent, some opaque). +fn noise(w: u32, h: u32, seed: u32) -> Image { + let mut s = seed.wrapping_mul(2_654_435_761).wrapping_add(1); + let mut next = || { + s ^= s << 13; + s ^= s >> 17; + s ^= s << 5; + (s >> 8) as f32 / (1u32 << 24) as f32 + }; + let mut img = Image::new(w, h); + for (i, p) in img.data.iter_mut().enumerate() { + let a = match i % 7 { + 0 => 0.0, + 1 => 1.0, + _ => next(), + }; + *p = [next() * a, next() * a, next() * a, a]; + } + img +} + +fn buf(img: Image) -> Buf { + Buf { img, offset: [-3.5, 12.25], scale: 0.5 } +} + +fn bits(b: &Buf) -> Vec<[u32; 4]> { + b.img.data.iter().map(|p| p.map(f32::to_bits)).collect() +} + +#[test] +fn quantised_buffers_round_trip_bit_exactly() { + for (levels, px_bytes) in [(255.0, 4), (32768.0, 8)] { + let mut img = noise(37, 23, levels as u32); + crate::color::quantize(&mut img, levels); + let b = buf(img); + let p = PackedBuf::pack(&b).unwrap_or_else(|| panic!("{levels} levels packs")); + assert_eq!(p.bytes(), b.img.data.len() * px_bytes, "{levels} levels"); + let u = p.unpack(); + assert_eq!(bits(&u), bits(&b), "{levels} levels"); + assert_eq!((u.img.width, u.img.height, u.offset, u.scale), (b.img.width, b.img.height, b.offset, b.scale)); + } +} + +#[test] +fn unquantised_buffers_are_not_packed() { + // 32 bpc: off-grid values, over-range values, negative zero. + assert!(PackedBuf::pack(&buf(noise(16, 16, 5))).is_none()); + let mut img = noise(4, 4, 6); + crate::color::quantize(&mut img, 255.0); + img.data[5] = [1.5, 1.0, 1.0, 1.0]; + assert!(PackedBuf::pack(&buf(img.clone())).is_none()); + img.data[5] = [-0.0, 0.0, 0.0, 0.0]; + assert!(PackedBuf::pack(&buf(img)).is_none()); +} + +/// Over budget, quantised entries are packed (counted at their packed size) instead of evicted, +/// and come back bit-identical; float entries are untouched (evicted least recently used). +#[test] +fn cache_packs_under_pressure_and_counts_packed_bytes() { + let f32_bytes = 64 * 64 * 16; + let cache = LayerCache::new(4 * f32_bytes); + let mut kept = vec![]; + for i in 0..6u64 { + let mut img = noise(64, 64, i as u32); + crate::color::quantize(&mut img, 255.0); + let b = Arc::new(buf(img)); + kept.push(bits(&b)); + cache.insert(i, b); + } + let st = cache.stats(); + assert_eq!(st.entries, 6, "nothing evicted"); + // The fifth insert went over budget: the four oldest were packed to u8. + assert_eq!(st.bytes, 4 * f32_bytes / 4 + 2 * f32_bytes, "packed entries count at their packed size"); + let back = cache.get(0).unwrap_or_else(|| panic!("entry 0")); + assert_eq!(bits(&back), kept[0]); + // A hit unpacks the entry for good: the next hit shares the same buffer. + let again = cache.get(0).unwrap_or_else(|| panic!("entry 0")); + assert!(Arc::ptr_eq(&back, &again)); + assert_eq!(cache.stats().bytes, 3 * f32_bytes / 4 + 3 * f32_bytes); + for (i, want) in kept.iter().enumerate() { + assert_eq!(&bits(&cache.get(i as u64).unwrap_or_else(|| panic!("entry {i}"))), want, "entry {i}"); + } + + // 32 bpc: never packed, the same Arc is served back. + let cache = LayerCache::new(4 * f32_bytes); + let bufs: Vec<_> = (0..6u64).map(|i| Arc::new(buf(noise(64, 64, 100 + i as u32)))).collect(); + for (i, b) in bufs.iter().enumerate() { + cache.insert(i as u64, b.clone()); + } + let st = cache.stats(); + assert_eq!(st.bytes, st.entries * f32_bytes); + assert!(st.entries < 6, "evicted, not packed"); + let last = cache.get(5).unwrap_or_else(|| panic!("newest kept")); + assert!(Arc::ptr_eq(&last, &bufs[5])); +} + +/// The renderer's own buffers: an 8 bpc layer's cached content packs and unpacks exactly. +#[test] +fn rendered_8bpc_layer_packs_losslessly() { + let mut img = noise(40, 30, 77); + crate::color::Pipe::of(&effectcraft_project::ProjectSettings::default()).quantize(&mut img); + let b = buf(img); + let p = PackedBuf::pack(&b).unwrap_or_else(|| panic!("default project (8 bpc) packs")); + assert_eq!(p.bytes(), b.img.data.len() * 4); + assert_eq!(bits(&p.unpack()), bits(&b)); +} From 5a9768b6183c2e63da724e5d8dee61b8454b7bff Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 08:51:30 +0000 Subject: [PATCH 07/45] G5: declare the packed layer-cache modules in render's lib.rs (cherry picked from commit eb10f4ce5518fbed2e8530e2e22340da3b3c93d4) (cherry picked from commit c32fcb506354788d159827b27da02ec6871f5f4a) --- crates/render/src/lib.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index f4a5a98a3..52cba190b 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -16,6 +16,7 @@ pub mod disk_cache; use color::Region; pub mod eval; pub mod masks; +mod packed; pub mod passes; pub mod shapes; pub mod styles; @@ -2237,6 +2238,8 @@ mod tests_mask_blur; #[cfg(test)] mod tests_nested; #[cfg(test)] +mod tests_packed; +#[cfg(test)] mod tests_paint; #[cfg(test)] mod tests_time; From fe7e8ec661ece021cfa1df30237a78d6c5803541 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 19:34:36 +0000 Subject: [PATCH 08/45] G5: 3D depth of field gathers only the two bracketing blur levels per pixel progressive_blur blurred the whole image at up to eight radii and kept them all; iris kernels now gather just the two levels around each pixel's radius from one set of row prefix sums (Fast Rectangle's O(1) box levels are unchanged). Bit-exact against the old code (test oracle); ~4x faster at 640x480, 0..24 px. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01H2Vw4ifTRhxWcLfvzW3G9b (cherry picked from commit f157946ff6a09b05046937b78b57228bd8304e7d) (cherry picked from commit e109c7a47329ae4b57f0cbe52b483e27e2ec9bbc) --- crates/render/src/three_d/bokeh.rs | 150 ++++++++++++++++++++++++----- 1 file changed, 127 insertions(+), 23 deletions(-) diff --git a/crates/render/src/three_d/bokeh.rs b/crates/render/src/three_d/bokeh.rs index 12bcbcf37..731877ddb 100644 --- a/crates/render/src/three_d/bokeh.rs +++ b/crates/render/src/three_d/bokeh.rs @@ -146,26 +146,32 @@ fn span_gather(img: &Image, kernels: &[(f32, Vec<(i32, f64, f64)>)], norm: f32) let inv = 1.0 / norm; out.data.par_chunks_mut(w.max(1)).enumerate().for_each(|(y, row)| { for (x, o) in row.iter_mut().enumerate() { - let mut acc = [0.0f32; 4]; - for (wt, spans) in kernels { - for &(dy, x0, x1) in spans { - let sy = y as i64 + dy as i64; - if sy < 0 || sy >= h { - continue; - } - let pr = &pre[sy as usize]; - let (hi, lo) = (prefix_at(pr, x as f64 + x1 + 0.5), prefix_at(pr, x as f64 + x0 + 0.5)); - for c in 0..4 { - acc[c] += (hi[c] - lo[c]) * wt; - } - } - } - *o = acc.map(|v| v * inv); + *o = gather_px(&pre, h, x, y, kernels, inv); } }); out } +/// One output pixel of [`span_gather`] (`pre` from [`row_prefix`], `inv` = 1 / norm). +#[inline] +fn gather_px(pre: &[Vec<[f32; 4]>], h: i64, x: usize, y: usize, kernels: &[(f32, Vec<(i32, f64, f64)>)], inv: f32) -> [f32; 4] { + let mut acc = [0.0f32; 4]; + for (wt, spans) in kernels { + for &(dy, x0, x1) in spans { + let sy = y as i64 + dy as i64; + if sy < 0 || sy >= h { + continue; + } + let Some(pr) = pre.get(sy as usize) else { continue }; + let (hi, lo) = (prefix_at(pr, x as f64 + x1 + 0.5), prefix_at(pr, x as f64 + x0 + 0.5)); + for c in 0..4 { + acc[c] += (hi[c] - lo[c]) * wt; + } + } + } + acc.map(|v| v * inv) +} + /// Boost the highlights of a premultiplied image in place. pub fn boost_highlights(img: &mut Image, h: &Highlight) { if h.gain <= 0.0 { @@ -282,22 +288,48 @@ pub fn level_weight(levels: &[f32], k: usize, r: f32) -> f32 { /// Depth-varying blur: `radius` gives each pixel's blur radius. The image is blurred at up to /// eight radii spanning the range and each pixel interpolates between the two levels around -/// its own radius. +/// its own radius. Iris kernels gather only those two levels at each pixel (a span gather costs +/// the kernel's height per pixel); Fast Rectangle's box blur costs O(1) per pixel, so its levels +/// are blurred whole. pub fn progressive_blur(img: &Image, iris: &Iris, radius: &[f32]) -> Image { let Some(levels) = blur_levels(radius) else { return img.clone() }; if levels.len() == 1 { return bokeh_blur(img, iris, levels[0] as f64); } let (lo, hi, n) = (levels[0], levels[levels.len() - 1], levels.len()); - let blurred: Vec = levels.par_iter().map(|&r| bokeh_blur(img, iris, r as f64)).collect(); - let mut out = Image::new(img.width, img.height); - out.data.par_iter_mut().enumerate().for_each(|(i, o)| { - let r = radius[i]; + // The two levels bracketing a pixel's radius and the blend between them. + let bracket = |i: usize| { + let r = radius.get(i).copied().unwrap_or(0.0); let f = ((r - lo) / (hi - lo) * (n - 1) as f32).clamp(0.0, (n - 1) as f32); let k = (f.floor() as usize).min(n - 2); - let t = f - k as f32; - let (a, b) = (blurred[k].data[i], blurred[k + 1].data[i]); - *o = [0, 1, 2, 3].map(|c| a[c] + (b[c] - a[c]) * t); + (k, f - k as f32) + }; + let lerp = |a: [f32; 4], b: [f32; 4], t: f32| [0, 1, 2, 3].map(|c| a[c] + (b[c] - a[c]) * t); + let mut out = Image::new(img.width, img.height); + if iris.sides == 0 { + let blurred: Vec = levels.par_iter().map(|&r| bokeh_blur(img, iris, r as f64)).collect(); + out.data.par_iter_mut().enumerate().for_each(|(i, o)| { + let (k, t) = bracket(i); + let at = |k: usize| blurred.get(k).and_then(|b| b.data.get(i)).copied().unwrap_or_default(); + *o = lerp(at(k), at(k + 1), t); + }); + return out; + } + // Per level: its kernel and 1 / norm, or `None` where [`bokeh_blur`] leaves the image as is. + let kernels: Vec)>, f32)>> = + levels.iter().map(|&r| kernel_spans(iris, r as f64).map(|(k, norm)| (k, 1.0 / norm))).collect(); + let pre = row_prefix(img); + let (w, h) = (img.width as usize, img.height as i64); + out.data.par_chunks_mut(w.max(1)).enumerate().for_each(|(y, row)| { + for (x, o) in row.iter_mut().enumerate() { + let i = y * w + x; + let (k, t) = bracket(i); + let at = |k: usize| match kernels.get(k) { + Some(Some((ks, inv))) => gather_px(&pre, h, x, y, ks, *inv), + _ => img.data.get(i).copied().unwrap_or_default(), + }; + *o = lerp(at(k), at(k + 1), t); + } }); out } @@ -372,6 +404,78 @@ mod tests { assert!(c.data[0][0] - c.data[0][1] > 0.3, "more saturated: {:?}", c.data[0]); } + /// The previous [`progressive_blur`]: every level blurred over the whole image. + fn progressive_blur_reference(img: &Image, iris: &Iris, radius: &[f32]) -> Image { + let Some(levels) = blur_levels(radius) else { return img.clone() }; + if levels.len() == 1 { + return bokeh_blur(img, iris, levels[0] as f64); + } + let (lo, hi, n) = (levels[0], levels[levels.len() - 1], levels.len()); + let blurred: Vec = levels.iter().map(|&r| bokeh_blur(img, iris, r as f64)).collect(); + let mut out = Image::new(img.width, img.height); + for (i, o) in out.data.iter_mut().enumerate() { + let r = radius[i]; + let f = ((r - lo) / (hi - lo) * (n - 1) as f32).clamp(0.0, (n - 1) as f32); + let k = (f.floor() as usize).min(n - 2); + let t = f - k as f32; + let (a, b) = (blurred[k].data[i], blurred[k + 1].data[i]); + *o = [0, 1, 2, 3].map(|c| a[c] + (b[c] - a[c]) * t); + } + out + } + + #[test] + fn progressive_blur_gathers_only_the_bracketing_levels_bit_exactly() { + let (w, h) = (57u32, 43u32); + let mut img = Image::new(w, h); + for (i, p) in img.data.iter_mut().enumerate() { + let v = ((i * 7919) % 101) as f32 / 100.0; + let a = ((i * 104_729) % 13) as f32 / 12.0; + *p = [v * a, (1.0 - v) * a, 0.5 * a, a]; + } + // Sharp at the top-left, up to 14 px at the bottom-right (eight levels, the lowest below + // the 0.3 px threshold), plus a near-constant map (one level) and a flat sharp one. + let ramp: Vec = (0..(w * h) as usize).map(|i| ((i % w as usize) + (i / w as usize)) as f32 / 98.0 * 14.0).collect(); + let flat: Vec = vec![3.2; (w * h) as usize]; + let sharp: Vec = vec![0.1; (w * h) as usize]; + let irises = [ + Iris::default(), + Iris { sides: 6, ..Default::default() }, + Iris { sides: 5, rotation: 17.0, roundness: -0.4, aspect: 1.7, fringe: 0.0 }, + Iris { sides: 8, roundness: 0.6, aspect: 0.6, fringe: 1.5, ..Default::default() }, + ]; + for iris in &irises { + for radius in [&ramp, &flat, &sharp] { + let new = progressive_blur(&img, iris, radius); + let old = progressive_blur_reference(&img, iris, radius); + assert_eq!(new.data.len(), old.data.len()); + for (i, (a, b)) in new.data.iter().zip(&old.data).enumerate() { + assert!(a.iter().zip(b).all(|(p, q)| p.to_bits() == q.to_bits()), "{iris:?} pixel {i}: {a:?} vs {b:?}"); + } + } + } + } + + #[test] + #[ignore = "timing: cargo test -p effectcraft-render --lib progressive_blur_timing -- --ignored --nocapture"] + fn progressive_blur_timing() { + let (w, h) = (640u32, 480u32); + let mut img = Image::new(w, h); + for (i, p) in img.data.iter_mut().enumerate() { + *p = [((i * 31) % 7) as f32 / 7.0; 4]; + } + let radius: Vec = (0..(w * h) as usize).map(|i| (i / w as usize) as f32 / h as f32 * 24.0).collect(); + let iris = Iris { sides: 6, ..Default::default() }; + let t = std::time::Instant::now(); + let a = progressive_blur_reference(&img, &iris, &radius); + let old = t.elapsed(); + let t = std::time::Instant::now(); + let b = progressive_blur(&img, &iris, &radius); + let new = t.elapsed(); + assert_eq!(a.data, b.data); + println!("progressive_blur 640x480 hexagon 0..24 px: reference {old:?}, gather {new:?}"); + } + #[test] fn progressive_blur_follows_the_radius_map() { // A vertical line, blurred 0 px at the top rising to 8 px at the bottom. From 268ca50316e1ae2550124115780b5c38626efcd5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 19:34:36 +0000 Subject: [PATCH 09/45] G5: skip preparing out-of-run shadow casters when the render casts no shadows Both compositors drop them unused when RenderOpts::shadows() is false (draft without Realtime Shadows); output unchanged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01H2Vw4ifTRhxWcLfvzW3G9b (cherry picked from commit dd4e94f342d123f40607cf0954fcd5956e9f138d) (cherry picked from commit c03b345afa8a709ee0d63e3c2eb7716ab56df318) --- crates/render/src/three_d/compose.rs | 54 ++++++++++++++++++++++++++-- 1 file changed, 52 insertions(+), 2 deletions(-) diff --git a/crates/render/src/three_d/compose.rs b/crates/render/src/three_d/compose.rs index 0d70b32fa..612290171 100644 --- a/crates/render/src/three_d/compose.rs +++ b/crates/render/src/three_d/compose.rs @@ -465,9 +465,10 @@ fn gather<'a>(r: &Renderer<'a>, ctx: &EvalCtx<'a>, run: &[&'a Layer], out: (u32, its }) .collect(); - // Shadow casters outside this run (other 3D layers of the comp). + // Shadow casters outside this run (other 3D layers of the comp); none when this render + // casts no shadows (the compositors would drop them unused). let mut extra: Vec = vec![]; - if lights.iter().any(|l| l.casts_shadows && l.kind != LightKind::Ambient) { + if r.opts.shadows() && lights.iter().any(|l| l.casts_shadows && l.kind != LightKind::Ambient) { let in_run: Vec<_> = run.iter().map(|l| l.id).collect(); extra = ctx .comp @@ -1046,3 +1047,52 @@ pub(crate) fn draw_sky(r: &Renderer, ctx: &EvalCtx, layer: &Layer, canvas: &mut } }); } + +#[cfg(test)] +mod tests { + use effectcraft_color::Label; + use effectcraft_keyframe::Value; + use effectcraft_project::{Comp, ItemKind, LayerSource, Project, Solid, build}; + use effectcraft_time::FrameRate; + + use super::*; + use crate::{NoFootage, RenderOpts}; + + fn set(l: &mut Layer, path: &str, v: Value) { + l.props.prop_mut(path).unwrap_or_else(|| panic!("no {path}")).value = v; + } + + /// Shadow casters outside a run are prepared only when the render casts shadows: a draft + /// render without Realtime Shadows skips them (and draws the same). + #[test] + fn casters_outside_the_run_only_with_shadows() { + let (w, h) = (120u32, 80u32); + let mut p = Project::default(); + let comp = Comp::new(w, h, FrameRate::FPS_30, Tick::from_seconds_f64(1.0)); + let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.clone().into())); + let solid = |p: &mut Project, z: f64, three_d: bool| { + let sid = p.add_item("Solid", Label::Red, None, ItemKind::Solid(Solid { color: [1.0; 3], width: 40, height: 40, pixel_aspect: 1.0 })); + let mut l = build::layer(p, &comp, "Solid", LayerSource::Solid { item: sid }, (40, 40), None); + l.switches.three_d = three_d; + if three_d { + set(&mut l, "transform/position", Value::Vec3([60.0, 40.0, z])); + set(&mut l, "materialOptions/castsShadows", Value::Enum(1)); + } + l + }; + let mut light = build::layer(&mut p, &comp, "Light", LayerSource::Light { kind: LightKind::Parallel }, (w, h), None); + set(&mut light, "transform/position", Value::Vec3([60.0, 40.0, -500.0])); + set(&mut light, "transform/poi", Value::Vec3([60.0, 40.0, 0.0])); + set(&mut light, "lightOptions/castsShadows", Value::Bool(true)); + // Light, a 3D caster, a 2D layer splitting the runs, the 3D receiver (top to bottom). + let layers = vec![light, solid(&mut p, -50.0, true), solid(&mut p, 0.0, false), solid(&mut p, 50.0, true)]; + p.comp_mut(cid).unwrap_or_else(|| panic!("comp")).layers.extend(layers); + let c = p.comp(cid).unwrap_or_else(|| panic!("comp")); + let ctx = EvalCtx::new(&p, cid, c, Tick::ZERO); + let receiver = c.layers.iter().rev().find(|l| l.is_3d()).unwrap_or_else(|| panic!("receiver")); + let extra = |opts: RenderOpts| gather(&Renderer::new(&p, &NoFootage, opts), &ctx, &[receiver], (w, h)).extra.len(); + assert_eq!(extra(RenderOpts::default()), 1, "the caster above the 2D layer"); + assert_eq!(extra(RenderOpts { draft: true, ..Default::default() }), 1, "Realtime Shadows in Draft"); + assert_eq!(extra(RenderOpts { draft: true, draft_shadows: false, ..Default::default() }), 0); + } +} From c66339026d22f91bbe3b5466067993be31630306 Mon Sep 17 00:00:00 2001 From: Elias Date: Sat, 10 Oct 2026 18:10:53 +0100 Subject: [PATCH 10/45] G5: expressions collect every host miss per run, prefetch last evaluation's reads, lazy keyframe lists, geometric shape sourceRectAtTime, memoised own path (cherry picked from commit 2b9518574f501524dd3f03fb742e2f9e77d19965) (cherry picked from commit fead21343e27e57020ff82b3f8158975649b12f5) --- crates/expr/src/host.rs | 60 +++++-- crates/expr/src/lib.rs | 49 ++++-- crates/expr/src/prefetch.rs | 123 +++++++++++++++ crates/expr/src/prelude.js | 20 ++- crates/expr/src/runtime.rs | 128 +++++++++++++-- crates/expr/src/tests.rs | 24 +-- crates/expr/src/tests_perf.rs | 287 ++++++++++++++++++++++++++++++++++ crates/render/src/shapes.rs | 32 +++- 8 files changed, 663 insertions(+), 60 deletions(-) create mode 100644 crates/expr/src/prefetch.rs create mode 100644 crates/expr/src/tests_perf.rs diff --git a/crates/expr/src/host.rs b/crates/expr/src/host.rs index 700937903..5ffde06a5 100644 --- a/crates/expr/src/host.rs +++ b/crates/expr/src/host.rs @@ -2,10 +2,11 @@ //! //! Scripts never hold references into the project. Instead they ask for plain data //! ([`Req`] → [`Resp`]) through one native function. A request that isn't answered yet is -//! recorded as a *miss* and the script is aborted; the evaluator then resolves the misses here -//! (with full access to the project, which may recursively evaluate other expressions), memoizes -//! the answers and runs the script again. Expressions are pure functions of these answers, so -//! re-running is exact, and the runtime stays free of borrowed state. +//! recorded as a *miss* (the run carries on with stand-in answers to collect the rest, and its +//! result is discarded); the evaluator then resolves the misses here (with full access to the +//! project, which may recursively evaluate other expressions), memoizes the answers and runs the +//! script again. Expressions are pure functions of these answers, so re-running is exact, and +//! the runtime stays free of borrowed state. use effectcraft_geom::{Mat3, Mat4}; use effectcraft_keyframe::Value; @@ -39,8 +40,11 @@ pub enum Req { LayerInfo { comp: u64, layer: u64 }, /// `[path, isGroup, name, matchName, numChildren]` or null. Child { comp: u64, layer: u64, group: String, key: Key }, - /// `[name, uid, kind, dims, keyTimes, keyValues, hasExpression, propertyIndex, matchName]`. + /// `[name, uid, kind, dims, numKeys, null, hasExpression, propertyIndex, matchName]`. PropInfo { comp: u64, layer: u64, path: String }, + /// `[keyTimes (comp seconds), keyValues]`: asked only by the keyframe API, so plain value + /// reads don't copy every keyframe. + Keys { comp: u64, layer: u64, path: String }, /// Value at comp time `t`; `pre` = keyframes only (no expression). Value { comp: u64, layer: u64, path: String, t: Secs, pre: bool }, /// `[layerToComp (3×3), compToLayer, layerToWorld (4×4), worldToLayer]`, row-major. @@ -65,6 +69,24 @@ pub enum Req { Project, } +impl Req { + /// The comp time (bits) a request reads at, for requests that depend on time. + pub fn time(&self) -> Option { + match self { + Req::Value { t, .. } | Req::Doc { t, .. } | Req::Xform { t, .. } | Req::SourceRect { t, .. } | Req::Sample { t, .. } => Some(*t), + _ => None, + } + } + /// The same request at comp time `at` (unchanged when it doesn't depend on time). + pub fn at(&self, at: Secs) -> Req { + let mut r = self.clone(); + if let Req::Value { t, .. } | Req::Doc { t, .. } | Req::Xform { t, .. } | Req::SourceRect { t, .. } | Req::Sample { t, .. } = &mut r { + *t = at; + } + r + } +} + /// Parse CSV/TSV text (RFC 4180 quoting) into rows of cells. pub fn parse_delimited(text: &str, sep: char) -> Vec> { let mut rows = vec![]; @@ -362,28 +384,37 @@ impl<'a> Resolver<'a> { Req::PropInfo { comp, layer, path } => { let (l, p) = self.prop(*comp, *layer, path)?; let dims = prop_dims(l, p); - let times = p.keys.iter().map(|k| Resp::Num(l.comp_time(k.time).seconds())).collect(); - let values = p.keys.iter().map(|k| value_resp(&k.value, dims)).collect(); let index = path.rsplit('/').next().and_then(|last| { + // Only a canonical `@uid` segment names a child (`@7`, not `@07` or `@+7`). + let digits = + last.strip_prefix('@').filter(|d| !d.is_empty() && d.bytes().all(|b| b.is_ascii_digit()) && (d.len() == 1 || !d.starts_with('0')))?; + let uid: u64 = digits.parse().ok()?; let parent = path.rsplit_once('/').map(|(g, _)| g); let g = match parent { Some(g) => l.props.group(g)?, None => &l.props, }; - g.children.iter().position(|n| format!("@{}", n.uid()) == last).map(|i| i + 1) + g.children.iter().position(|n| n.uid() == uid).map(|i| i + 1) }); Resp::List(vec![ Resp::Str(p.name.clone()), Resp::Num(p.uid as f64), Resp::Str(kind_name(&p.value).into()), Resp::Num(dims as f64), - Resp::List(times), - Resp::List(values), + Resp::Num(p.keys.len() as f64), + Resp::Null, Resp::Bool(p.has_expression()), Resp::Num(index.unwrap_or(1) as f64), Resp::Str(p.match_id.clone()), ]) } + Req::Keys { comp, layer, path } => { + let (l, p) = self.prop(*comp, *layer, path)?; + let dims = prop_dims(l, p); + let times = p.keys.iter().map(|k| Resp::Num(l.comp_time(k.time).seconds())).collect(); + let values = p.keys.iter().map(|k| value_resp(&k.value, dims)).collect(); + Resp::List(vec![Resp::List(times), Resp::List(values)]) + } Req::Value { comp, layer, path, t, pre } => { let (l, p) = self.prop(*comp, *layer, path)?; let t = f64::from_bits(*t); @@ -416,13 +447,12 @@ impl<'a> Resolver<'a> { effectcraft_path::bounds(&paths).map(|b| [b.y0, b.x0, b.width(), b.height()]).unwrap_or([0.0; 4]) } LayerSource::Shape => { + // The rendered buffer's rectangle, from the geometry alone (no rasterising). let contents = l.props.sub("contents")?; - let buf = effectcraft_render::shapes::render(&ctx, l, contents, 1.0); - if buf.img.width <= 4 { - [0.0; 4] - } else { + match effectcraft_render::shapes::buffer_rect(&ctx, l, contents, 1.0) { // `render` pads its buffer by 2 px on each side. - [2.0 - buf.offset[1], 2.0 - buf.offset[0], buf.img.width as f64 - 4.0, buf.img.height as f64 - 4.0] + Some((w, h, offset)) if w > 4 => [2.0 - offset[1], 2.0 - offset[0], w as f64 - 4.0, h as f64 - 4.0], + _ => [0.0; 4], } } _ => { diff --git a/crates/expr/src/lib.rs b/crates/expr/src/lib.rs index add94b7c8..fa68ac12e 100644 --- a/crates/expr/src/lib.rs +++ b/crates/expr/src/lib.rs @@ -23,11 +23,16 @@ //! * **Runtime**: one boa `Context` per thread with compiled scripts cached by text. Scripts read //! the project through memoized host requests (see [`host`]), so nothing borrowed ever enters //! the JS heap. Expressions reading each other nest up to [`MAX_DEPTH`] levels. +//! * **Cost**: a run that meets unanswered requests keeps going on stand-in answers to collect +//! them all, so an evaluation takes a handful of runs instead of one per request; and each +//! evaluation first answers what the same expression read last time (see [`prefetch`]), so +//! from the second frame on most expressions run exactly once. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] pub mod host; mod noise; +mod prefetch; pub mod rewrite; mod runtime; mod sample; @@ -37,9 +42,14 @@ use effectcraft_project::{ItemId, Layer, LayerId, Project, Property}; use effectcraft_render::{EvalCtx, ExprHost}; use effectcraft_time::Tick; -use host::{Own, Req, Resolver, prop_dims, value_resp}; +use host::{Own, Req, Resolver, prop_dims, secs, value_resp}; use runtime::{Begin, FRAMES, Frame, Run}; +/// Script runs on this thread so far (each evaluation runs its script one or more times). +pub fn script_runs() -> u64 { + runtime::runs() +} + /// Maximum nesting of expressions that read other expressions (cycles stop here). pub const MAX_DEPTH: usize = 16; /// Maximum script re-runs while answering host requests. @@ -117,7 +127,7 @@ fn evaluate_out( } let ctx = EvalCtx { project: ctx.project, comp_id: ctx.comp_id, comp: ctx.comp, time: ctx.time, expr: Some(host), footage: ctx.footage }; let comp = ctx.comp_id.0; - let path = layer.props.path_of(prop.uid).unwrap_or_else(|| format!("@{}", prop.uid)); + let path = prefetch::own_path(layer, prop); let resolver = Resolver { ctx, own: Own { comp, layer, prop, path: path.clone() } }; let current = value_resp(value, prop_dims(layer, prop)); let begin = Begin { @@ -133,29 +143,46 @@ fn evaluate_out( selector, }; // Answer the requests nearly every expression makes up front. - let mut frame = Frame::default(); + let mut frame = Frame::new(depth); for req in [Req::CompInfo { comp }, Req::LayerInfo { comp, layer: layer.id.0 }, Req::PropInfo { comp, layer: layer.id.0, path: path.clone() }] { let r = resolver.resolve(&req); - frame.memo.insert(req, r); + frame.insert(req, r); } FRAMES.with(|f| f.borrow_mut().push(frame)); let _guard = FrameGuard; + // Answer what this expression read last time (pushed first: answers that evaluate other + // expressions nest exactly as they do when a run asks for them). + let key = prefetch::Key::new(text, comp, layer.id.0, prop.uid); + let now = secs(ctx.time.seconds()); + answer(&resolver, depth, prefetch::predicted(&key, now)); for _ in 0..MAX_PASSES { match runtime::run(text, &begin) { - Run::Done(r) => return r, + Run::Done(r) => { + let used = FRAMES.with(|f| f.borrow_mut().get_mut(depth).map(|fr| std::mem::take(&mut fr.used)).unwrap_or_default()); + prefetch::store(key, now, used); + return r; + } Run::Pending => { let misses = FRAMES.with(|f| f.borrow_mut().get_mut(depth).map(|fr| std::mem::take(&mut fr.misses)).unwrap_or_default()); - for m in misses { - // May evaluate other expressions (nested frames above ours). - let r = resolver.resolve(&m); - FRAMES.with(|f| f.borrow_mut().get_mut(depth).map(|fr| fr.memo.insert(m, r))); - } + answer(&resolver, depth, misses); } } } Err("Error: expression needs too many property reads".into()) } +/// Resolve `reqs` not answered yet into the memo of the frame at `depth`. +fn answer(resolver: &Resolver, depth: usize, reqs: Vec) { + for req in reqs { + if FRAMES.with(|f| f.borrow().get(depth).is_none_or(|fr| fr.has(&req))) { + continue; + } + // May evaluate other expressions (nested frames above ours). + let r = resolver.resolve(&req); + FRAMES.with(|f| f.borrow_mut().get_mut(depth).map(|fr| fr.insert(req, r))); + } +} + /// Evaluate `text` as if it were the expression on the property at `path` (e.g. /// `transform/rotation`) of `layer` in `comp`, at comp time `t` seconds. Other properties' /// expressions are evaluated too. @@ -190,3 +217,5 @@ pub fn check_syntax(text: &str) -> Result<(), String> { #[cfg(test)] mod tests; +#[cfg(test)] +mod tests_perf; diff --git a/crates/expr/src/prefetch.rs b/crates/expr/src/prefetch.rs new file mode 100644 index 000000000..5b069f80c --- /dev/null +++ b/crates/expr/src/prefetch.rs @@ -0,0 +1,123 @@ +//! What an expression read last time, to answer before its next run. +//! +//! Every host request a script makes before its answer is known costs a run (see +//! [`crate::host`]). Expressions read nearly the same things on every frame, so each evaluation +//! records the requests its final run made and the next evaluation of the same expression on +//! the same property answers them up front: from the second frame on, most expressions run +//! once. Requests at the evaluation's own time move to the new time; requests at other times +//! (`valueAtTime(time - d)`, `smooth`) can't be predicted exactly and are left to the run. A +//! prediction is only a hint: answering a request the script doesn't ask for costs time but +//! never changes a result, because answers depend only on the request. +//! +//! The logs are per thread and bounded. The property's own path (`@uid/@uid/…`, which the +//! object model and the requests use) is memoised per thread too, checked against the layer on +//! every use. + +use std::cell::RefCell; +use std::collections::HashMap; +use std::hash::{DefaultHasher, Hash, Hasher}; + +use effectcraft_project::{Layer, PropGroup, Property}; + +use crate::host::{Req, Secs}; + +/// Expressions remembered per thread (the map is cleared when full). +const MAX_LOGS: usize = 4096; +/// Longest request list worth remembering. +const MAX_LOG_LEN: usize = 4096; +/// Own paths remembered per thread. +const MAX_PATHS: usize = 16384; + +/// An expression on a property: text (hashed; a collision only costs a wrong prediction), comp, +/// layer and property uid. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct Key { + text: u64, + comp: u64, + layer: u64, + uid: u64, +} + +impl Key { + pub fn new(text: &str, comp: u64, layer: u64, uid: u64) -> Key { + let mut h = DefaultHasher::new(); + text.hash(&mut h); + Key { text: h.finish(), comp, layer, uid } + } +} + +struct Log { + /// The evaluation's own comp time. + t: Secs, + reqs: Vec, +} + +thread_local! { + static LOGS: RefCell> = RefCell::new(HashMap::new()); + static PATHS: RefCell, String)>> = RefCell::new(HashMap::new()); +} + +/// The requests to answer before evaluating `key` at comp time `now`. +pub fn predicted(key: &Key, now: Secs) -> Vec { + LOGS.with(|l| { + let l = l.borrow(); + let Some(log) = l.get(key) else { return vec![] }; + log.reqs + .iter() + .filter_map(|r| match r.time() { + None => Some(r.clone()), + Some(t) if t == log.t => Some(r.at(now)), + Some(_) if log.t == now => Some(r.clone()), + Some(_) => None, + }) + .collect() + }) +} + +/// Remember the requests the final run of `key` at comp time `t` made. +pub fn store(key: Key, t: Secs, reqs: Vec) { + if reqs.len() > MAX_LOG_LEN { + return; + } + LOGS.with(|l| { + let mut l = l.borrow_mut(); + if l.len() >= MAX_LOGS && !l.contains_key(&key) { + l.clear(); + } + l.insert(key, Log { t, reqs }); + }); +} + +/// Whether the `@uid` chain leads from `g` to a property with the chain's last uid. +fn chain_ok(g: &PropGroup, chain: &[u64]) -> bool { + let Some((last, init)) = chain.split_last() else { return false }; + let mut g = g; + for u in init { + match g.children.iter().find(|c| c.uid() == *u).and_then(|c| c.as_group()) { + Some(next) => g = next, + None => return false, + } + } + g.children.iter().any(|c| c.uid() == *last && c.as_prop().is_some()) +} + +/// `layer.props.path_of(prop.uid)` (or `@uid` when the property isn't in the layer's tree), +/// memoised: the remembered path is used only while it still leads to the property. +pub fn own_path(layer: &Layer, prop: &Property) -> String { + let key = (layer.id.0, prop.uid); + let hit = + PATHS.with(|m| m.borrow().get(&key).filter(|(chain, _)| chain.last() == Some(&prop.uid) && chain_ok(&layer.props, chain)).map(|(_, p)| p.clone())); + if let Some(p) = hit { + return p; + } + let Some(path) = layer.props.path_of(prop.uid) else { return format!("@{}", prop.uid) }; + let chain: Vec = path.split('/').filter_map(|s| s.strip_prefix('@')?.parse().ok()).collect(); + PATHS.with(|m| { + let mut m = m.borrow_mut(); + if m.len() >= MAX_PATHS { + m.clear(); + } + m.insert(key, (chain, path.clone())); + }); + path +} diff --git a/crates/expr/src/prelude.js b/crates/expr/src/prelude.js index 5756365af..1ed4407ef 100644 --- a/crates/expr/src/prelude.js +++ b/crates/expr/src/prelude.js @@ -18,7 +18,7 @@ var selectorValue = [100, 100, 100]; // Request ops (must match runtime.rs). var __COMP = 0, __COMPNAME = 1, __LAYER = 2, __LINFO = 3, __CHILD = 4, __PINFO = 5, __VALUE = 6, __XFORM = 7, __RECT = 8, __MARKERS = 9, __DOC = 10; -var __FOOT = 11, __FDATA = 12, __SAMPLE = 13, __PROJ = 14; +var __FOOT = 11, __FDATA = 12, __SAMPLE = 13, __PROJ = 14, __KEYS = 15; function __begin(t, v, c, l, path, uid, idx, fd, ti, tt, sv) { time = t; @@ -657,7 +657,7 @@ function __mkGroup(c, l, info) { // Properties ------------------------------------------------------------------------------------ function Prop(c, l, path) { - this.__c = c; this.__l = l; this.__p = path; this.__i = null; + this.__c = c; this.__l = l; this.__p = path; this.__i = null; this.__k = null; this.__self = __S !== null && c === __S.c && l === __S.l && path === __S.path; } @@ -669,8 +669,9 @@ function __avg(vals) { function __loop(p, out, type, n, dur) { type = type === undefined ? 'cycle' : String(type).toLowerCase(); - var times = p.__info[4], N = times.length, t = time; + var N = p.__info[4], t = time; if (N === 0) return p.value; + var times = p.__keys[0]; var tStart, tEnd, h = 1e-3, k, ph; if (out) { tEnd = times[N - 1]; @@ -716,7 +717,9 @@ Prop.prototype = { get __info() { return this.__i || (this.__i = __h(__PINFO, this.__c, this.__l, this.__p)); }, get name() { return this.__info[0]; }, get __uid() { return this.__info[1]; }, - get numKeys() { return this.__info[4].length; }, + // `[times, values]`, asked for only by the keyframe API (plain reads don't copy the keys). + get __keys() { return this.__k || (this.__k = __h(__KEYS, this.__c, this.__l, this.__p)); }, + get numKeys() { return this.__info[4]; }, get propertyIndex() { return this.__info[7]; }, get matchName() { return this.__info[8]; }, __wrap: function (v) { @@ -739,14 +742,15 @@ Prop.prototype = { speedAtTime: function (t) { return length(this.velocityAtTime(t)); }, get speed() { return this.speedAtTime(time); }, key: function (n) { - var info = this.__info, N = info[4].length; + var N = this.__info[4]; if (!(n >= 1 && n <= N)) __err('Key index ' + n + ' out of range (property has ' + N + ' keys)'); n = Math.floor(n); - return { time: info[4][n - 1], value: this.__wrap(info[5][n - 1]), index: n }; + var keys = this.__keys; + return { time: keys[0][n - 1], value: this.__wrap(keys[1][n - 1]), index: n }; }, nearestKey: function (t) { - var times = this.__info[4]; - if (times.length === 0) __err('Property has no keyframes'); + if (this.__info[4] === 0) __err('Property has no keyframes'); + var times = this.__keys[0]; var best = 0; for (var i = 1; i < times.length; i++) if (Math.abs(times[i] - t) < Math.abs(times[best] - t)) best = i; return this.key(best + 1); diff --git a/crates/expr/src/runtime.rs b/crates/expr/src/runtime.rs index bb841e6dc..8c9955e08 100644 --- a/crates/expr/src/runtime.rs +++ b/crates/expr/src/runtime.rs @@ -1,7 +1,7 @@ //! The per-thread boa runtime: one `Context` with the prelude loaded, a compiled-script cache, //! the native functions, and the request/miss bookkeeping (see [`crate::host`]). -use std::cell::RefCell; +use std::cell::{Cell, RefCell}; use std::collections::HashMap; use std::mem::ManuallyDrop; @@ -18,12 +18,47 @@ const PENDING: &str = "\u{1}effectcraft: pending host request"; const MAX_SCRIPTS: usize = 4096; /// The part of boa's stack-size limit error the runtime recovers from (see [`run`]). const STACK_LIMIT: &str = "maximum stack size"; +/// Loop iterations per call frame a script may run. +const LOOP_LIMIT: u64 = 10_000_000; +/// Loop iterations per call frame once a run is only collecting misses (see [`native_host`]). +const SPECULATIVE_LOOP_LIMIT: u64 = 10_000; +/// Misses one run collects before it stops. +const MAX_MISSES: usize = 512; +/// Nesting depth from which runs stop at their first miss again: stand-in answers can ask for +/// things the script never reads, and inside a chain of expressions reading each other (a +/// cycle, until [`crate::MAX_DEPTH`] stops it) that extra work would multiply per level. +const SPECULATE_DEPTH: usize = 4; + +/// A memoized host answer, stamped with the last run that asked for it. +pub struct Answer { + resp: Resp, + run: u32, +} /// Memoized host answers and pending misses of one expression evaluation. #[derive(Default)] pub struct Frame { - pub memo: HashMap, + pub memo: HashMap, pub misses: Vec, + /// The requests the current run asked for (each once, in order). After the final run this + /// is exactly what the expression read, which its next evaluation prefetches. + pub used: Vec, + run: u32, + /// Whether a run carries on past a miss (see [`native_host`]). + speculate: bool, +} + +impl Frame { + /// The frame of an evaluation nested `depth` levels deep. + pub fn new(depth: usize) -> Frame { + Frame { speculate: depth < SPECULATE_DEPTH, ..Frame::default() } + } + pub fn insert(&mut self, req: Req, resp: Resp) { + self.memo.insert(req, Answer { resp, run: 0 }); + } + pub fn has(&self, req: &Req) -> bool { + self.memo.contains_key(req) + } } thread_local! { @@ -32,6 +67,13 @@ thread_local! { static RUNTIME: RefCell>>> = const { RefCell::new(None) }; /// Evaluation stack (nested when one expression reads another's value). pub static FRAMES: RefCell> = const { RefCell::new(Vec::new()) }; + /// Script runs on this thread (for tests and perf readouts). + static RUNS: Cell = const { Cell::new(0) }; +} + +/// Script runs on this thread so far (each evaluation runs its script one or more times). +pub fn runs() -> u64 { + RUNS.with(Cell::get) } struct Runtime { @@ -117,7 +159,55 @@ fn to_js(r: &Resp, ctx: &mut Context) -> JsValue { } } -/// `__h(op, …)`: answer a host request from the memo, or record a miss and abort. +/// A stand-in answer for a request that isn't answered yet, shaped like the real one so the +/// rest of the script keeps running and reports its other misses in the same run. Results of a +/// run with misses are thrown away, so stand-ins never reach a result. +fn placeholder(req: &Req) -> Resp { + let n = Resp::Num; + let s = |x: &str| Resp::Str(x.into()); + // Ids that name nothing (`-1` reads back as `u64::MAX`), so follow-up requests answer null. + let none = n(-1.0); + let ident = |k: usize| Resp::List((0..k * k).map(|i| n(if i % (k + 1) == 0 { 1.0 } else { 0.0 })).collect()); + match req { + Req::CompInfo { .. } => { + Resp::List(vec![s(""), n(100.0), n(100.0), n(1.0), n(1.0 / 30.0), n(0.0), n(1.0), n(0.0), Resp::List(vec![n(0.0), n(0.0), n(0.0), n(1.0)])]) + } + Req::CompByName { .. } | Req::LayerLookup { .. } => none, + Req::LayerInfo { .. } => Resp::List(vec![ + s(""), + n(1.0), + n(0.0), + n(1.0), + n(0.0), + n(100.0), + n(100.0), + Resp::Null, + Resp::Bool(true), + Resp::Bool(false), + s(""), + Resp::Bool(true), + ]), + Req::Child { .. } => Resp::List(vec![s("\u{1}"), Resp::Bool(true), s(""), s(""), n(0.0), n(1.0)]), + Req::PropInfo { .. } => Resp::List(vec![s(""), n(0.0), s("number"), n(1.0), n(0.0), Resp::Null, Resp::Bool(false), n(1.0), s("")]), + Req::Keys { .. } => Resp::List(vec![Resp::List(vec![]), Resp::List(vec![])]), + Req::Value { .. } => n(0.0), + Req::Xform { .. } => Resp::List(vec![ident(3), ident(3), ident(4), ident(4)]), + Req::SourceRect { .. } | Req::Sample { .. } => Resp::List(vec![n(0.0); 4]), + Req::Markers { .. } => Resp::List(vec![]), + Req::Project => Resp::List(vec![n(8.0), Resp::Bool(false), s("")]), + Req::Doc { .. } | Req::Footage { .. } | Req::FootageData { .. } => Resp::Null, + } +} + +/// `__h(op, …)`: answer a host request from the memo, or record a miss. +/// +/// A miss used to abort the run, so a script reading N things not yet known ran N+1 times (and +/// loops over layers ran O(N²) work). Now the first miss switches the run to collecting: the +/// miss gets a [`placeholder`] answer and the script carries on, recording every further miss, +/// until it ends, fails, or reaches [`MAX_MISSES`] (which aborts as before). Loops are capped at +/// [`SPECULATIVE_LOOP_LIMIT`] meanwhile, so a stand-in can't spin a loop for long. The run's +/// result is discarded either way; the evaluator answers the misses and runs again. Evaluations +/// nested [`SPECULATE_DEPTH`] deep abort at their first miss, as every run used to. fn native_host(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { let op = num(args, 0, ctx)? as i64; let req = match op { @@ -160,21 +250,31 @@ fn native_host(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult Req::Project, + 15 => Req::Keys { comp: id(args, 1, ctx)?, layer: id(args, 2, ctx)?, path: string(args, 3, ctx)? }, _ => return Err(JsNativeError::typ().with_message("unknown host request").into()), }; let found = FRAMES.with(|f| { let mut f = f.borrow_mut(); let frame = f.last_mut()?; - match frame.memo.get(&req) { - Some(r) => Some(r.clone()), - None => { - frame.misses.push(req); - None + if let Some(a) = frame.memo.get_mut(&req) { + if a.run != frame.run { + a.run = frame.run; + frame.used.push(req); } + return Some(to_js(&a.resp, ctx)); } + let stop = !frame.speculate || frame.misses.len() >= MAX_MISSES; + let stand_in = (!stop).then(|| to_js(&placeholder(&req), ctx)); + if frame.misses.len() < MAX_MISSES && !frame.misses.contains(&req) { + frame.misses.push(req); + } + if stand_in.is_some() { + ctx.runtime_limits_mut().set_loop_iteration_limit(SPECULATIVE_LOOP_LIMIT); + } + stand_in }); match found { - Some(r) => Ok(to_js(&r, ctx)), + Some(v) => Ok(v), None => Err(JsNativeError::error().with_message(PENDING).into()), } } @@ -209,7 +309,7 @@ impl Runtime { fn new() -> Result { let mut ctx = Context::default(); let limits = ctx.runtime_limits_mut(); - limits.set_loop_iteration_limit(10_000_000); + limits.set_loop_iteration_limit(LOOP_LIMIT); limits.set_recursion_limit(400); let natives: [(JsString, usize, fn(&JsValue, &[JsValue], &mut Context) -> JsResult); 4] = [ (js_string!("__h"), 6, native_host), @@ -254,6 +354,14 @@ impl Runtime { Err(e) => return Run::Done(Err(e)), }; let ctx = &mut self.ctx; + ctx.runtime_limits_mut().set_loop_iteration_limit(LOOP_LIMIT); + FRAMES.with(|f| { + if let Some(fr) = f.borrow_mut().last_mut() { + fr.run = fr.run.wrapping_add(1).max(1); + fr.used.clear(); + } + }); + RUNS.with(|r| r.set(r.get().saturating_add(1))); let args = [ JsValue::from(b.time), to_js(b.value, ctx), diff --git a/crates/expr/src/tests.rs b/crates/expr/src/tests.rs index 133d32b53..c1b38cf52 100644 --- a/crates/expr/src/tests.rs +++ b/crates/expr/src/tests.rs @@ -20,13 +20,13 @@ const OPA: &str = "transform/opacity"; /// 4. "Shape": shape layer with "Rectangle 1" (60×30 rect path, red fill) /// /// plus an "Other" comp with one layer "Inner". -struct Fx { - p: Project, - cid: ItemId, - a: LayerId, - b: LayerId, - title: LayerId, - shape: LayerId, +pub(crate) struct Fx { + pub(crate) p: Project, + pub(crate) cid: ItemId, + pub(crate) a: LayerId, + pub(crate) b: LayerId, + pub(crate) title: LayerId, + pub(crate) shape: LayerId, } fn solid(p: &mut Project, comp: &Comp, name: &str, color: [f32; 3], w: u32, h: u32) -> Layer { @@ -41,7 +41,7 @@ fn group_push(p: &mut Project, f: impl FnOnce(&mut Ids) -> PropGroup) -> PropGro g } -fn fx() -> Fx { +pub(crate) fn fx() -> Fx { let mut p = Project::default(); let comp = Comp::new(200, 100, FrameRate::FPS_30, Tick::from_seconds_f64(10.0)); let cid = p.add_item("Main", Label::Sandstone, None, ItemKind::Comp(comp.clone().into())); @@ -85,12 +85,12 @@ fn fx() -> Fx { Fx { p, cid, a: ids.0, b: ids.1, title: ids.2, shape: ids.3 } } -fn secs(s: f64) -> Tick { +pub(crate) fn secs(s: f64) -> Tick { Tick::from_seconds_f64(s) } impl Fx { - fn ev(&self, layer: LayerId, path: &str, text: &str, t: f64) -> Result { + pub(crate) fn ev(&self, layer: LayerId, path: &str, text: &str, t: f64) -> Result { eval_standalone(&self.p, self.cid, layer, path, text, t) } /// Scalar result on layer A's rotation. @@ -107,7 +107,7 @@ impl Fx { Err(e) => panic!("{text}: {e}"), } } - fn text(&self, text: &str, t: f64) -> String { + pub(crate) fn text(&self, text: &str, t: f64) -> String { match self.ev(self.title, "text/sourceText", text, t) { Ok(Value::Text(d)) => d.text, Ok(v) => panic!("{text}: not text: {v:?}"), @@ -124,7 +124,7 @@ impl Fx { let l = self.p.comp_mut(self.cid).unwrap().layer_mut(layer).unwrap(); l.props.prop_mut(path).unwrap().expr = Some(Expression { text: text.into(), enabled: true }); } - fn layer_mut(&mut self, layer: LayerId) -> &mut Layer { + pub(crate) fn layer_mut(&mut self, layer: LayerId) -> &mut Layer { self.p.comp_mut(self.cid).unwrap().layer_mut(layer).unwrap() } } diff --git a/crates/expr/src/tests_perf.rs b/crates/expr/src/tests_perf.rs new file mode 100644 index 000000000..0bf288fbe --- /dev/null +++ b/crates/expr/src/tests_perf.rs @@ -0,0 +1,287 @@ +//! Evaluation cost: host-request re-runs, prefetching and identical results. + +use effectcraft_color::Label; +use effectcraft_keyframe::Value; +use effectcraft_project::build; +use effectcraft_project::{Comp, ItemKind, LayerId, LayerSource, Project, Solid}; +use effectcraft_time::{FrameRate, Tick}; + +use crate::script_runs; +use crate::tests::{Fx, fx}; + +const ROT: &str = "transform/rotation"; +const POS: &str = "transform/position"; + +/// Representative expressions as `(layer, property, text)`, evaluated at [`TIMES`]. +fn representative(f: &Fx) -> Vec<(LayerId, &'static str, &'static str)> { + vec![ + (f.a, POS, "wiggle(2, 30)"), + (f.a, ROT, "wiggle(3, 45, 2, 0.6)"), + (f.a, ROT, "temporalWiggle(3, 0.2)"), + (f.b, ROT, "thisComp.layer(\"A\").transform.rotation.valueAtTime(time - 0.25)"), + (f.b, ROT, "thisComp.layer(\"A\").effect(\"Slider Control\")(\"Slider\") * 2 + time"), + (f.b, ROT, "var s = 0; for (var i = 1; i <= thisComp.numLayers; i++) s += thisComp.layer(i).transform.position[0]; s"), + (f.b, POS, "var r = thisComp.layer(\"Shape\").sourceRectAtTime(); [r.left, r.top, r.width + r.height]"), + (f.b, POS, "var r = thisComp.layer(\"Title\").sourceRectAtTime(time); [r.left + r.top, r.width, r.height]"), + (f.b, ROT, "linear(time, 0, 2, 0, 100) + ease(time, 0.5, 3, 10, 20) + easeIn(time, 0, 1, 0, 5)"), + (f.b, POS, "ease(time, 0, 2, [0, 0], [100, 50])"), + (f.a, ROT, "smooth(0.3, 5)"), + (f.a, ROT, "loopOut('pingpong') + velocity / 10"), + (f.a, ROT, "nearestKey(time).time + key(2).value + numKeys"), + (f.b, POS, "thisComp.layer(\"B\").toComp([0, 0]) + thisComp.layer(\"A\").fromWorld([5, 5, 0])"), + (f.b, ROT, "thisComp.layer(\"A\").transform.rotation.velocityAtTime(time) + thisComp.layer(index - 1).rotation.speed"), + ] +} + +const TIMES: [f64; 4] = [0.0, 0.5, 1.37, 4.2]; + +fn results(f: &Fx) -> Vec { + let mut out = vec![]; + for (l, path, text) in representative(f) { + for t in TIMES { + out.push(match f.ev(l, path, text, t) { + Ok(Value::Scalar(x)) => format!("{x:?}"), + Ok(v) => format!("{:?}", v.components()), + Err(e) => format!("error {e}"), + }); + } + } + out +} + +/// [`results`] before host requests were collected per run and prefetched (bit-exact). +const BASELINE: [&str; 60] = [ + "[68.50842270250163, 33.29488516071788, 0.0]", + "[43.81508809180965, 40.95415032598344, 0.0]", + "[79.42061191939588, 46.08444637322505, 0.0]", + "[29.260151628712812, 53.695929085154, 0.0]", + "-50.91808396830139", + "52.73313232371497", + "126.18531029086893", + "106.44917641653214", + "0.0", + "52.19685849914966", + "90.0", + "90.0", + "0.0", + "22.5", + "90.0", + "90.0", + "84.0", + "84.5", + "85.37", + "88.2", + "270.0", + "270.0", + "270.0", + "270.0", + "[-31.0, -16.0, 94.0]", + "[-31.0, -16.0, 94.0]", + "[-31.0, -16.0, 94.0]", + "[-31.0, -16.0, 94.0]", + "[-25.5859375, 90.859375, 29.5703125]", + "[-25.5859375, 90.859375, 29.5703125]", + "[-25.5859375, 90.859375, 29.5703125]", + "[-25.5859375, 90.859375, 29.5703125]", + "10.0", + "36.875", + "86.29023616", + "125.0", + "[0.0, 0.0, 0.0]", + "[15.625, 7.8125, 0.0]", + "[76.483675, 38.2418375, 0.0]", + "[100.0, 50.0, 0.0]", + "4.05", + "45.0", + "90.0", + "90.0", + "4.5", + "54.00000000000034", + "56.7", + "18.0", + "92.0", + "92.0", + "93.0", + "93.0", + "[45.0, 5.0, 0.0]", + "[33.431457505076196, 47.07106781186547, 0.0]", + "[55.0, 85.0, 0.0]", + "[55.0, 85.0, 0.0]", + "90.0", + "180.00000000000682", + "0.0", + "0.0", +]; + +#[test] +fn representative_results_are_unchanged() { + let f = fx(); + // First pass: every expression's first time runs without a prediction, later times with one. + assert_eq!(results(&f), BASELINE); + // Second pass: every evaluation is predicted (and some predictions are for another time). + assert_eq!(results(&f), BASELINE); + // A fresh thread starts without predictions. + let fresh = std::thread::spawn(move || results(&fx())).join().unwrap(); + assert_eq!(fresh, BASELINE); +} + +/// Script runs of one evaluation (on this thread). +fn runs_of(f: &Fx, layer: LayerId, path: &str, text: &str, t: f64) -> (u64, Value) { + let before = script_runs(); + let v = f.ev(layer, path, text, t).unwrap(); + (script_runs() - before, v) +} + +#[test] +fn chained_lookups_run_a_bounded_number_of_times() { + let f = fx(); + let text = "thisComp.layer(\"A\").effect(\"Slider Control\")(\"Slider\") + thisComp.layer(\"A\").transform.rotation"; + // Each step of the chain depends on the one before: one run per step, the first time. + let (first, v) = runs_of(&f, f.b, ROT, text, 0.5); + assert_eq!(v.as_f64(), 42.0 + 45.0); + assert!(first <= 6, "first evaluation ran {first} times"); + // Later frames answer everything up front: one run. + for t in [0.6, 0.7, 3.0] { + let (n, v) = runs_of(&f, f.b, ROT, text, t); + assert_eq!(n, 1, "at {t}"); + assert_eq!(v.as_f64(), 42.0 + (t * 90.0).min(90.0)); + } +} + +/// A comp of `n` solids at x = 1, 2, …, n. +fn many(n: usize) -> (Project, effectcraft_project::ItemId, Vec) { + let mut p = Project::default(); + let comp = Comp::new(200, 100, FrameRate::FPS_30, Tick::from_seconds_f64(10.0)); + let cid = p.add_item("Main", Label::Sandstone, None, ItemKind::Comp(comp.clone().into())); + let mut layers = vec![]; + for i in 0..n { + let sid = p.add_item(&format!("S{i}"), Label::Red, None, ItemKind::Solid(Solid { color: [1.0; 3], width: 10, height: 10, pixel_aspect: 1.0 })); + let mut l = build::layer(&mut p, &comp, &format!("L{}", i + 1), LayerSource::Solid { item: sid }, (10, 10), None); + l.props.prop_mut(POS).unwrap().value = Value::Vec3([(i + 1) as f64, 0.0, 0.0]); + layers.push(l); + } + let ids = layers.iter().map(|l| l.id).collect(); + p.comp_mut(cid).unwrap().layers = layers.into(); + (p, cid, ids) +} + +#[test] +fn a_loop_over_layers_runs_a_constant_number_of_times() { + let text = "var s = 0; for (var i = 1; i <= thisComp.numLayers; i++) s += thisComp.layer(i).transform.position[0]; s"; + let mut counts = vec![]; + for n in [10usize, 40] { + let (p, cid, ids) = many(n); + let ev = |t: f64| { + let before = script_runs(); + let v = crate::eval_standalone(&p, cid, ids[0], ROT, text, t).unwrap().as_f64(); + (script_runs() - before, v) + }; + let expected = (n * (n + 1) / 2) as f64; + let (first, v) = ev(0.0); + assert_eq!(v, expected); + let (second, v) = ev(0.5); + assert_eq!(v, expected); + assert_eq!(second, 1, "{n} layers: later frames run once"); + counts.push(first); + } + // Every layer's lookups are collected in the same runs: the count doesn't grow with the + // number of layers (it was about 4 runs per layer). + assert_eq!(counts[0], counts[1], "runs for 10 and 40 layers: {counts:?}"); + assert!(counts[1] <= 8, "runs: {counts:?}"); +} + +#[test] +fn time_shifted_reads_cost_at_most_one_extra_run() { + let f = fx(); + let _ = runs_of(&f, f.a, ROT, "smooth(0.3, 5)", 0.2); + for t in [0.3, 0.4, 2.5] { + let (n, _) = runs_of(&f, f.a, ROT, "smooth(0.3, 5)", t); + assert!(n <= 2, "at {t}: {n} runs"); + } +} + +#[test] +fn a_stale_prediction_never_changes_the_result() { + let mut f = fx(); + let text = "thisComp.layer(\"A\").transform.position[0]"; + assert_eq!(f.ev(f.b, ROT, text, 0.0).unwrap().as_f64(), 50.0); + // Rename so "A" now names B (at x = 100): the prediction still looks up the old answers. + let (a, b) = (f.a, f.b); + f.layer_mut(a).name = "Z".into(); + f.layer_mut(b).name = "A".into(); + assert_eq!(f.ev(f.b, ROT, text, 0.1).unwrap().as_f64(), 100.0); + // Branches that change with time read different things than last time. + let text = "time < 1 ? thisComp.layer(\"Z\").transform.position[1] : thisComp.layer(\"A\").effect(1)(1)"; + for (t, want) in [(0.0, 40.0), (2.0, 0.0), (0.5, 40.0)] { + assert_eq!(f.ev(f.b, ROT, text, t).map(|v| v.as_f64()).unwrap_or(0.0), want, "at {t}"); + } +} + +#[test] +fn stand_in_answers_never_reach_a_result() { + let f = fx(); + // While a run collects misses the slider reads as a stand-in 0; the result uses 42. + let text = + "var n = 0; while (n < 1e8 && thisComp.layer(\"A\").effect(\"Slider Control\")(\"Slider\").value == 0) n++; n + thisComp.layer(\"A\").effect(1)(1)"; + assert_eq!(f.ev(f.b, ROT, text, 0.0).unwrap().as_f64(), 42.0); + // A script that catches errors still sees only real answers. + let text = "var r; try { r = thisComp.layer(\"A\").name + thisComp.layer(2).name; } catch (e) { r = 'caught'; } r"; + assert_eq!(f.text(text, 0.0), "AB"); + // A missing layer is an error, not a stand-in. + assert!(f.ev(f.b, ROT, "thisComp.layer(\"Nope\").transform.rotation", 0.0).is_err()); +} + +#[test] +fn keyframe_api_reads_keys_lazily() { + let f = fx(); + for (text, want) in [ + ("thisComp.layer(\"A\").transform.rotation.numKeys", 2.0), + ("thisComp.layer(\"A\").transform.rotation.key(2).time", 1.0), + ("thisComp.layer(\"A\").transform.rotation.key(2).value", 90.0), + ("thisComp.layer(\"A\").transform.rotation.nearestKey(0.9).index", 2.0), + ("thisComp.layer(\"B\").transform.rotation.numKeys", 0.0), + ("thisComp.layer(\"B\").transform.rotation.loopOut()", 0.0), + ] { + assert_eq!(f.ev(f.b, ROT, text, 0.0).unwrap().as_f64(), want, "{text}"); + } + assert!(f.ev(f.b, ROT, "thisComp.layer(\"B\").transform.rotation.nearestKey(0).time", 0.0).is_err()); +} + +#[test] +fn shape_source_rect_matches_the_rendered_buffer() { + use effectcraft_project::build::Ids; + use effectcraft_render::EvalCtx; + let f = fx(); + let comp = f.p.comp(f.cid).unwrap().clone(); + let shapes: Vec Vec>> = vec![ + Box::new(|_| vec![]), + Box::new(|ids| { + let items = vec![build::shape_rect(ids, [60.3, 30.7], [5.2, -3.1], 4.0), build::shape_fill(ids, [1.0, 0.0, 0.0, 1.0])]; + vec![build::shape_group(ids, "R", items)] + }), + Box::new(|ids| { + let star = vec![build::shape_star(ids, true, 5.0, [10.0, 10.0], 40.0, 20.0), build::shape_stroke(ids, [0.0, 0.0, 1.0, 1.0], 7.5)]; + let ellipse = vec![build::shape_ellipse(ids, [33.0, 17.0], [-80.5, 20.25]), build::shape_fill(ids, [0.0, 1.0, 0.0, 1.0])]; + vec![build::shape_group(ids, "S", star), build::shape_group(ids, "E", ellipse)] + }), + Box::new(|ids| { + let items = vec![build::shape_rect(ids, [0.5, 0.5], [0.0, 0.0], 0.0), build::shape_fill(ids, [1.0; 4])]; + vec![build::shape_group(ids, "Tiny", items)] + }), + ]; + for (i, make) in shapes.iter().enumerate() { + let mut p = f.p.clone(); + let mut next = p.next_id; + let groups = make(&mut Ids(&mut next)); + p.next_id = next; + let mut l = build::layer(&mut p, &comp, "Shape", LayerSource::Shape, (200, 100), None); + l.props.sub_mut("contents").unwrap().children = groups.into_iter().map(Into::into).collect(); + let c = p.comp(f.cid).unwrap(); + let ctx = EvalCtx::new(&p, f.cid, c, Tick::from_seconds_f64(0.5)); + let contents = l.props.sub("contents").unwrap(); + let buf = effectcraft_render::shapes::render(&ctx, &l, contents, 1.0); + let rendered = if buf.img.width <= 4 { None } else { Some((buf.img.width, buf.img.height, buf.offset)) }; + let geometric = effectcraft_render::shapes::buffer_rect(&ctx, &l, contents, 1.0).filter(|(w, _, _)| *w > 4); + assert_eq!(geometric, rendered, "shape {i}"); + } +} diff --git a/crates/render/src/shapes.rs b/crates/render/src/shapes.rs index e3cbca4a1..ab36209e2 100644 --- a/crates/render/src/shapes.rs +++ b/crates/render/src/shapes.rs @@ -534,6 +534,32 @@ fn visual_bounds(d: &Draw, paths: &[BezPath]) -> Option { /// Maximum number of coverage pixels rasterised concurrently (bounds peak memory). const BATCH_PIXELS: usize = 8 << 20; +/// Pixel size and offset of the buffer [`render`] makes for drawing bounds `b` at scale `s`. +fn extent(b: kurbo::Rect, s: f64) -> (u32, u32, [f64; 2]) { + // Keep buffers sane for runaway geometry. + let b = b.intersect(kurbo::Rect::new(-20000.0, -20000.0, 20000.0, 20000.0)); + let w = ((b.width() * s).ceil() as u32 + 4).clamp(1, 16384); + let h = ((b.height() * s).ceil() as u32 + 4).clamp(1, 16384); + (w, h, [-b.x0 * s + 2.0, -b.y0 * s + 2.0]) +} + +/// The `(width, height, offset)` of the buffer [`render`] would make, without rasterising +/// (`sourceRectAtTime`); `None` when nothing draws (`render`'s empty 1×1 buffer). +pub fn buffer_rect(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, s: f64) -> Option<(u32, u32, [f64; 2])> { + let mut arena = Vec::new(); + let (draws, _) = collect(ctx, layer, contents, &mut arena); + let b = draws + .iter() + .rev() + .filter(|d| d.opacity > 0.0) + .filter_map(|d| { + let paths: Vec = d.slots.iter().filter_map(|&i| arena.get(i).cloned()).filter(|p| !p.elements().is_empty()).collect(); + draw_bounds(d, &paths) + }) + .reduce(|a, b| a.union(b))?; + Some(extent(b, s)) +} + /// Render a shape layer's contents into a layer-space buffer at scale `s`. /// /// Each draw rasterises and composites only its own pixel rectangle; coverage masks of @@ -553,11 +579,7 @@ pub fn render(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, s: f64) -> Buf }) .collect(); let Some(b) = resolved.iter().map(|r| r.2).reduce(|a, b| a.union(b)) else { return Buf { img: Image::new(1, 1), offset: [0.0, 0.0], scale: s } }; - // Keep buffers sane for runaway geometry. - let b = b.intersect(kurbo::Rect::new(-20000.0, -20000.0, 20000.0, 20000.0)); - let w = ((b.width() * s).ceil() as u32 + 4).clamp(1, 16384); - let h = ((b.height() * s).ceil() as u32 + 4).clamp(1, 16384); - let offset = [-b.x0 * s + 2.0, -b.y0 * s + 2.0]; + let (w, h, offset) = extent(b, s); let m = Mat3::translate(vec2(offset[0], offset[1])) * Mat3::scale(vec2(s, s)); let px_rect = |r: kurbo::Rect| -> Option { let x0 = (r.x0 * s + offset[0]).floor().max(0.0); From 980765369ecd789b7b3406833b7035d83295f4b3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 19:39:40 +0000 Subject: [PATCH 11/45] G5: expression results shared between evaluations, validated by every answer the last run read A per-thread memo keyed by script, comp, layer, property, comp time and the __begin inputs. A hit answers the recorded requests at the evaluation's own depth and returns the stored result only when every answer is bit-identical, so edits can never serve a stale result; a slider read by N layers runs once per frame. Scripts using Math.random/Date are not stored. Tests compare against fresh runs on a thread with the memo off. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VzreW5cLMczbUzegSKxcUv (cherry picked from commit ff5e217be7ab8d67c7bb075834773e271e828df0) (cherry picked from commit 2f03926b50782eac70e0091419b84529f9b61d89) --- crates/expr/src/lib.rs | 37 ++++++- crates/expr/src/memo.rs | 163 +++++++++++++++++++++++++++++ crates/expr/src/runtime.rs | 7 ++ crates/expr/src/tests_memo.rs | 192 ++++++++++++++++++++++++++++++++++ 4 files changed, 395 insertions(+), 4 deletions(-) create mode 100644 crates/expr/src/memo.rs create mode 100644 crates/expr/src/tests_memo.rs diff --git a/crates/expr/src/lib.rs b/crates/expr/src/lib.rs index fa68ac12e..73be4991d 100644 --- a/crates/expr/src/lib.rs +++ b/crates/expr/src/lib.rs @@ -26,11 +26,14 @@ //! * **Cost**: a run that meets unanswered requests keeps going on stand-in answers to collect //! them all, so an evaluation takes a handful of runs instead of one per request; and each //! evaluation first answers what the same expression read last time (see [`prefetch`]), so -//! from the second frame on most expressions run exactly once. +//! from the second frame on most expressions run exactly once. Results are shared between +//! evaluations with the same inputs while everything they read answers the same (see [`memo`]), +//! so a controller read by many layers runs once per frame. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] pub mod host; +mod memo; mod noise; mod prefetch; pub mod rewrite; @@ -150,15 +153,39 @@ fn evaluate_out( } FRAMES.with(|f| f.borrow_mut().push(frame)); let _guard = FrameGuard; - // Answer what this expression read last time (pushed first: answers that evaluate other - // expressions nest exactly as they do when a run asks for them). let key = prefetch::Key::new(text, comp, layer.id.0, prop.uid); let now = secs(ctx.time.seconds()); + // The same inputs evaluated before (by another layer reading this property, or another + // frame): when everything that run read answers the same now, so does the result. + let memo_key = memo::Key::new(text, &begin); + if let Some(reqs) = memo::recorded(&memo_key, text, &begin) { + answer(&resolver, depth, reqs.clone()); + let hit = FRAMES.with(|f| { + let f = f.borrow(); + let fr = f.get(depth)?; + memo::check(&memo_key, |q, r| fr.answer(q).is_some_and(|a| memo::same(a, r))) + }); + if let Some(out) = hit { + prefetch::store(key, now, reqs); + return Ok(out); + } + } + // Answer what this expression read last time (pushed first: answers that evaluate other + // expressions nest exactly as they do when a run asks for them). answer(&resolver, depth, prefetch::predicted(&key, now)); for _ in 0..MAX_PASSES { match runtime::run(text, &begin) { Run::Done(r) => { - let used = FRAMES.with(|f| f.borrow_mut().get_mut(depth).map(|fr| std::mem::take(&mut fr.used)).unwrap_or_default()); + let (used, resps) = FRAMES.with(|f| { + let mut f = f.borrow_mut(); + let Some(fr) = f.get_mut(depth) else { return (vec![], None) }; + let used = std::mem::take(&mut fr.used); + let resps = if r.is_ok() && memo::cacheable(text) { fr.take_answers(&used) } else { None }; + (used, resps) + }); + if let (Ok(out), Some(resps)) = (&r, resps) { + memo::store(memo_key, text, &begin, used.clone(), resps, out.clone()); + } prefetch::store(key, now, used); return r; } @@ -218,4 +245,6 @@ pub fn check_syntax(text: &str) -> Result<(), String> { #[cfg(test)] mod tests; #[cfg(test)] +mod tests_memo; +#[cfg(test)] mod tests_perf; diff --git a/crates/expr/src/memo.rs b/crates/expr/src/memo.rs new file mode 100644 index 000000000..420ac4aa5 --- /dev/null +++ b/crates/expr/src/memo.rs @@ -0,0 +1,163 @@ +//! Expression results shared between evaluations. +//! +//! A controller slider read by 100 layers used to have its own expression run 100 times per +//! frame (and per motion-blur sample): each evaluation memoised only its own host answers. This +//! memo keeps, per thread, the result of each successful evaluation together with every host +//! answer its final run read. An evaluation with the same inputs (script, property, comp time, +//! keyframed value, `__begin` arguments) first answers those recorded requests and, when every +//! answer is bit-for-bit the one recorded, returns the recorded result without running the +//! script. Expressions are pure functions of their inputs and host answers (see +//! [`crate::host`]): given the same answers a run makes the same requests and returns the same +//! result, so a hit is exact and no edit can make it stale. The answers are resolved at the +//! evaluation's own nesting depth, as a run would, so reads cut short by +//! [`crate::MAX_DEPTH`] are re-checked too. +//! +//! What a hit saves is the script run: resolving the answers is work the prefetch does anyway. +//! Scripts that read something besides their answers (`Math.random`, `Date`) are never stored. +//! The memo is per thread (frames render in parallel without sharing it), bounded, and cleared +//! when full. + +use std::cell::RefCell; +use std::collections::HashMap; +use std::hash::{DefaultHasher, Hash, Hasher}; + +use crate::host::{Req, Resp, Secs}; +use crate::runtime::{Begin, Out}; + +/// Results remembered per thread. +const MAX_ENTRIES: usize = 8192; +/// Recorded answers per thread (all entries together). +const MAX_ANSWERS: usize = 1 << 20; +/// Most answers worth recording for one result. +const MAX_DEPS: usize = 4096; + +/// The inputs of one evaluation that hash cheaply (the script text is hashed; the entry keeps +/// the full text and the rest of [`Begin`] to compare). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct Key { + text: u64, + comp: u64, + layer: u64, + uid: u64, + time: Secs, + index: Secs, + frame_duration: Secs, + text_sel: [Secs; 2], + selector: [Secs; 3], +} + +impl Key { + pub fn new(text: &str, b: &Begin) -> Key { + let mut h = DefaultHasher::new(); + text.hash(&mut h); + let bits = f64::to_bits; + Key { + text: h.finish(), + comp: b.comp, + layer: b.layer, + uid: b.uid, + time: bits(b.time), + index: bits(b.index), + frame_duration: bits(b.frame_duration), + text_sel: b.text_sel.map(bits), + selector: b.selector.map(bits), + } + } +} + +struct Entry { + text: Box, + path: Box, + value: Resp, + /// What the final run read, in order, with the answers it got. + reqs: Vec, + resps: Vec, + out: Out, +} + +#[derive(Default)] +struct Memo { + entries: HashMap, + answers: usize, +} + +thread_local! { + static MEMO: RefCell = RefCell::new(Memo::default()); + /// Tests turn the memo off to compute the oracle. + #[cfg(test)] + static OFF: std::cell::Cell = const { std::cell::Cell::new(false) }; +} + +#[cfg(test)] +fn off() -> bool { + OFF.with(std::cell::Cell::get) +} +#[cfg(not(test))] +fn off() -> bool { + false +} + +/// Turn the memo off (or back on) on this thread. +#[cfg(test)] +pub fn set_off(v: bool) { + OFF.with(|o| o.set(v)); +} + +/// Whether a script's result may be remembered: it reads nothing but its inputs and answers. +pub fn cacheable(text: &str) -> bool { + !off() && !text.contains("Math.random") && !text.contains("Date") +} + +/// Bit-for-bit equality (`-0` ≠ `0`, and `NaN` equals itself), so a hit never changes a result +/// a script could tell apart. +pub fn same(a: &Resp, b: &Resp) -> bool { + match (a, b) { + (Resp::Null, Resp::Null) => true, + (Resp::Bool(x), Resp::Bool(y)) => x == y, + (Resp::Num(x), Resp::Num(y)) => x.to_bits() == y.to_bits(), + (Resp::Str(x), Resp::Str(y)) => x == y, + (Resp::List(x), Resp::List(y)) => x.len() == y.len() && x.iter().zip(y).all(|(a, b)| same(a, b)), + _ => false, + } +} + +/// The requests a remembered evaluation with these inputs read, to answer before [`check`]. +pub fn recorded(key: &Key, text: &str, b: &Begin) -> Option> { + if off() { + return None; + } + MEMO.with(|m| { + let m = m.borrow(); + let e = m.entries.get(key)?; + (*e.text == *text && *e.path == *b.path && same(&e.value, b.value)).then(|| e.reqs.clone()) + }) +} + +/// The remembered result when `answered(req, resp)` holds for every recorded request and its +/// recorded answer (it must not evaluate anything: answer the requests first). +pub fn check(key: &Key, answered: impl Fn(&Req, &Resp) -> bool) -> Option { + MEMO.with(|m| { + let m = m.borrow(); + let e = m.entries.get(key)?; + e.reqs.iter().zip(&e.resps).all(|(q, r)| answered(q, r)).then(|| e.out.clone()) + }) +} + +/// Remember a successful evaluation and what its final run read. +pub fn store(key: Key, text: &str, b: &Begin, reqs: Vec, resps: Vec, out: Out) { + if reqs.len() != resps.len() || reqs.len() > MAX_DEPS || !cacheable(text) { + return; + } + MEMO.with(|m| { + let mut m = m.borrow_mut(); + if let Some(old) = m.entries.remove(&key) { + m.answers = m.answers.saturating_sub(old.reqs.len()); + } + if m.entries.len() >= MAX_ENTRIES || m.answers.saturating_add(reqs.len()) > MAX_ANSWERS { + m.entries.clear(); + m.answers = 0; + } + m.answers = m.answers.saturating_add(reqs.len()); + m.entries.insert(key, Entry { text: text.into(), path: b.path.into(), value: b.value.clone(), reqs, resps, out }); + }); +} diff --git a/crates/expr/src/runtime.rs b/crates/expr/src/runtime.rs index 8c9955e08..80b681bbb 100644 --- a/crates/expr/src/runtime.rs +++ b/crates/expr/src/runtime.rs @@ -59,6 +59,13 @@ impl Frame { pub fn has(&self, req: &Req) -> bool { self.memo.contains_key(req) } + pub fn answer(&self, req: &Req) -> Option<&Resp> { + self.memo.get(req).map(|a| &a.resp) + } + /// Move the answers to `reqs` (each listed once) out of the memo. + pub fn take_answers(&mut self, reqs: &[Req]) -> Option> { + reqs.iter().map(|q| self.memo.remove(q).map(|a| a.resp)).collect() + } } thread_local! { diff --git a/crates/expr/src/tests_memo.rs b/crates/expr/src/tests_memo.rs new file mode 100644 index 000000000..60f747973 --- /dev/null +++ b/crates/expr/src/tests_memo.rs @@ -0,0 +1,192 @@ +//! The shared result memo: identical results to fresh runs (the memo off is the oracle), across +//! edits, cycles and interleaved times; and the frame timing of a controller rig (run +//! `rig_timing` with `--ignored --nocapture`). + +use std::time::Instant; + +use effectcraft_color::Label; +use effectcraft_keyframe::Value; +use effectcraft_project::build; +use effectcraft_project::{Comp, Expression, ItemId, ItemKind, Layer, LayerSource, Project, Solid}; +use effectcraft_render::{EvalCtx, ExprHost}; +use effectcraft_time::{FrameRate, Tick}; + +use crate::{Expressions, memo, script_runs}; + +const ROT: &str = "transform/rotation"; +const OPA: &str = "transform/opacity"; + +/// "Ctrl" (slider `wiggle(2, 20)`), `n` layers whose rotation reads the slider, and "Sum" whose +/// opacity loops over every layer's rotation. +pub(crate) fn rig(n: usize) -> (Project, ItemId) { + let mut p = Project::default(); + let comp = Comp::new(200, 100, FrameRate::FPS_30, Tick::from_seconds_f64(10.0)); + let cid = p.add_item("Main", Label::Sandstone, None, ItemKind::Comp(comp.clone().into())); + let solid = |p: &mut Project, name: &str| { + let sid = p.add_item(name, Label::Red, None, ItemKind::Solid(Solid { color: [1.0; 3], width: 10, height: 10, pixel_aspect: 1.0 })); + build::layer(p, &comp, name, LayerSource::Solid { item: sid }, (10, 10), None) + }; + let mut ctrl = solid(&mut p, "Ctrl"); + let spec = effectcraft_effects::find("ec.control.slider").unwrap(); + let mut next = p.next_id; + let mut slider = effectcraft_effects::instantiate(spec, &mut build::Ids(&mut next), "Slider Control", [5.0, 5.0]); + p.next_id = next; + let s = slider.prop_mut("slider").unwrap(); + s.value = Value::Scalar(10.0); + s.expr = Some(Expression { text: "wiggle(2, 20)".into(), enabled: true }); + ctrl.props.sub_mut("effects").unwrap().children.push(slider.into()); + let mut sum = solid(&mut p, "Sum"); + sum.props.prop_mut(OPA).unwrap().expr = Some(Expression { + text: "var s = 0; for (var i = 3; i <= thisComp.numLayers; i++) s += thisComp.layer(i).transform.rotation; clamp(s / 100, 0, 100)".into(), + enabled: true, + }); + let mut layers = vec![ctrl, sum]; + for i in 0..n { + let mut l = solid(&mut p, &format!("L{i}")); + l.props.prop_mut(ROT).unwrap().expr = + Some(Expression { text: "thisComp.layer(\"Ctrl\").effect(\"Slider Control\")(\"Slider\") * index + time".into(), enabled: true }); + layers.push(l); + } + p.comp_mut(cid).unwrap().layers = layers.into(); + (p, cid) +} + +/// Every expression of the rig at comp time `t` (rotations, then the sum), as one frame does. +pub(crate) fn frame(p: &Project, cid: ItemId, t: f64) -> Vec { + let host = Expressions; + let c = p.comp(cid).unwrap(); + let mut ctx = EvalCtx::new(p, cid, c, Tick::from_seconds_f64(t)); + ctx.expr = Some(&host as &dyn ExprHost); + let mut out = vec![]; + for l in c.layers.iter().skip(2) { + out.push(ctx.value(l, l.props.prop(ROT).unwrap()).as_f64()); + } + let sum = c.layers.get(1).unwrap(); + out.push(ctx.value(sum, sum.props.prop(OPA).unwrap()).as_f64()); + out +} + +#[test] +#[ignore = "timing readout"] +fn rig_timing() { + let (p, cid) = rig(100); + let _ = frame(&p, cid, 0.0); + let frames = 30; + let runs = script_runs(); + let start = Instant::now(); + for f in 1..=frames { + let _ = frame(&p, cid, f as f64 / 30.0); + } + let ms = start.elapsed().as_secs_f64() * 1000.0 / frames as f64; + let runs = (script_runs() - runs) as f64 / frames as f64; + println!("rig of 100 layers: {ms:.2} ms/frame, {runs:.0} script runs/frame"); +} + +/// `f` on a thread with the memo off (the oracle: every evaluation runs its script), leaving +/// this thread's memo as it was. +fn fresh(f: impl FnOnce() -> T + Send) -> T { + std::thread::scope(|s| { + s.spawn(|| { + memo::set_off(true); + f() + }) + .join() + .unwrap() + }) +} + +fn bits(v: &[f64]) -> Vec { + v.iter().map(|x| x.to_bits()).collect() +} + +/// [`frame`] with the memo (kept across calls) equals [`frame`] run fresh, bit for bit. +fn assert_same(p: &Project, cid: ItemId, t: f64, what: &str) { + let got = frame(p, cid, t); + let want = fresh(|| frame(p, cid, t)); + assert_eq!(bits(&got), bits(&want), "{what} at {t}: {got:?} vs {want:?}"); +} + +fn layer<'a>(p: &'a mut Project, cid: ItemId, name: &str) -> &'a mut Layer { + let c = p.comp_mut(cid).unwrap(); + let id = c.layer_by_name(name).unwrap().id; + c.layer_mut(id).unwrap() +} + +#[test] +fn shared_results_match_fresh_runs() { + let (p, cid) = rig(12); + // Repeated and interleaved times, as motion-blur samples and scrubbing ask for them. + for t in [0.0, 0.5, 0.5 + 1.0 / 120.0, 0.5, 1.0, 0.5 + 1.0 / 120.0, 0.0, 7.25] { + assert_same(&p, cid, t, "rig"); + } + // A slider read by every layer runs once per frame, not once per reader. + let _ = frame(&p, cid, 2.0); + let before = script_runs(); + let _ = frame(&p, cid, 3.0); + assert_eq!(script_runs() - before, 12 + 2, "one run per layer, one for the slider, one for the sum"); +} + +#[test] +fn edits_are_never_answered_from_the_memo() { + let (mut p, cid) = rig(6); + let t = 0.4; + assert_same(&p, cid, t, "before"); + fn slider(p: &mut Project, cid: ItemId) -> &mut effectcraft_project::Property { + let l = layer(p, cid, "Ctrl"); + l.props.sub_mut("effects").unwrap().children.first_mut().unwrap().as_group_mut().unwrap().prop_mut("slider").unwrap() + } + let edits: Vec<(&str, Box)> = vec![ + ("slider value", Box::new(|p| slider(p, cid).value = Value::Scalar(11.0))), + ("slider expression", Box::new(|p| slider(p, cid).expr = Some(Expression { text: "wiggle(3, 20)".into(), enabled: true }))), + ("slider expression off", Box::new(|p| slider(p, cid).expr.as_mut().unwrap().enabled = false)), + ("slider expression on", Box::new(|p| slider(p, cid).expr.as_mut().unwrap().enabled = true)), + ("rename the controller", Box::new(|p| layer(p, cid, "Ctrl").name = "Boss".into())), + ("rename it back", Box::new(|p| layer(p, cid, "Boss").name = "Ctrl".into())), + ("reorder layers", Box::new(|p| p.comp_mut(cid).unwrap().layers.swap(2, 4))), + ("move a layer in time", Box::new(|p| layer(p, cid, "L1").start_time = Tick::from_seconds_f64(0.25))), + ("a reader's expression", Box::new(|p| layer(p, cid, "L2").props.prop_mut(ROT).unwrap().expr.as_mut().unwrap().text = "time * 7".into())), + ("a reader's keyframed value", Box::new(|p| layer(p, cid, "L3").props.prop_mut(ROT).unwrap().value = Value::Scalar(-0.0))), + ("delete a layer", Box::new(|p| drop(p.comp_mut(cid).unwrap().layers.remove(3)))), + ]; + for (what, edit) in edits { + edit(&mut p); + assert_same(&p, cid, t, what); + assert_same(&p, cid, t, what); + } +} + +#[test] +fn negative_zero_is_a_different_answer() { + let (mut p, cid) = rig(2); + let read = "var x = thisComp.layer(\"Ctrl\").transform.rotation.value; x === 0 && 1 / x < 0 ? 1 : 2"; + layer(&mut p, cid, "L0").props.prop_mut(ROT).unwrap().expr = Some(Expression { text: read.into(), enabled: true }); + assert_eq!(frame(&p, cid, 0.0).first(), Some(&2.0)); + layer(&mut p, cid, "Ctrl").props.prop_mut(ROT).unwrap().value = Value::Scalar(-0.0); + assert_eq!(frame(&p, cid, 0.0).first(), Some(&1.0)); + assert_same(&p, cid, 0.0, "-0"); +} + +#[test] +fn cycles_match_fresh_runs() { + let (mut p, cid) = rig(3); + let set = + |p: &mut Project, name: &str, text: &str| layer(p, cid, name).props.prop_mut(ROT).unwrap().expr = Some(Expression { text: text.into(), enabled: true }); + set(&mut p, "L0", "thisComp.layer(\"L1\").transform.rotation + 1"); + set(&mut p, "L1", "thisComp.layer(\"L0\").transform.rotation + 2"); + set(&mut p, "L2", "thisComp.layer(\"L2\").transform.rotation + 3"); + for t in [0.0, 0.0, 1.0, 0.0] { + assert_same(&p, cid, t, "cycle"); + } + // Evaluating the other way round first nests differently; the answers still agree. + p.comp_mut(cid).unwrap().layers.swap(2, 3); + for t in [0.0, 1.0] { + assert_same(&p, cid, t, "cycle, reversed"); + } +} + +#[test] +fn impure_scripts_are_not_remembered() { + assert!(!memo::cacheable("Math.random() * 10")); + assert!(!memo::cacheable("new Date().getSeconds()")); + assert!(memo::cacheable("random(10)")); +} From e0d24eafa718d29fa92912c4d78d9a5224d54403 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 19:59:29 +0000 Subject: [PATCH 12/45] G5: bound the shared expression memo by answer bytes; rig timing compares memo off/on Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VzreW5cLMczbUzegSKxcUv (cherry picked from commit de244f33c3e6e43e987ed87b34f876849dfdce6b) (cherry picked from commit 2d3f69061c065cef6ad8c5cd3c2f374fdfafd457) --- crates/expr/src/memo.rs | 33 ++++++++++++++++++++++++--------- crates/expr/src/tests_memo.rs | 21 ++++++++++++--------- 2 files changed, 36 insertions(+), 18 deletions(-) diff --git a/crates/expr/src/memo.rs b/crates/expr/src/memo.rs index 420ac4aa5..aa3d487e2 100644 --- a/crates/expr/src/memo.rs +++ b/crates/expr/src/memo.rs @@ -26,10 +26,11 @@ use crate::runtime::{Begin, Out}; /// Results remembered per thread. const MAX_ENTRIES: usize = 8192; -/// Recorded answers per thread (all entries together). -const MAX_ANSWERS: usize = 1 << 20; -/// Most answers worth recording for one result. +/// Approximate bytes of recorded answers per thread (all entries together). +const MAX_BYTES: usize = 32 << 20; +/// Most answers, and approximate bytes of answers, worth recording for one result. const MAX_DEPS: usize = 4096; +const MAX_ENTRY_BYTES: usize = 1 << 20; /// The inputs of one evaluation that hash cheaply (the script text is hashed; the entry keeps /// the full text and the rest of [`Begin`] to compare). @@ -73,12 +74,22 @@ struct Entry { reqs: Vec, resps: Vec, out: Out, + bytes: usize, } #[derive(Default)] struct Memo { entries: HashMap, - answers: usize, + bytes: usize, +} + +/// Approximate heap size of an answer. +fn weight(r: &Resp) -> usize { + 16 + match r { + Resp::Str(s) => s.len(), + Resp::List(items) => items.iter().map(weight).fold(0, usize::saturating_add), + _ => 0, + } } thread_local! { @@ -148,16 +159,20 @@ pub fn store(key: Key, text: &str, b: &Begin, reqs: Vec, resps: Vec, if reqs.len() != resps.len() || reqs.len() > MAX_DEPS || !cacheable(text) { return; } + let bytes = resps.iter().map(weight).fold(weight(b.value), usize::saturating_add); + if bytes > MAX_ENTRY_BYTES { + return; + } MEMO.with(|m| { let mut m = m.borrow_mut(); if let Some(old) = m.entries.remove(&key) { - m.answers = m.answers.saturating_sub(old.reqs.len()); + m.bytes = m.bytes.saturating_sub(old.bytes); } - if m.entries.len() >= MAX_ENTRIES || m.answers.saturating_add(reqs.len()) > MAX_ANSWERS { + if m.entries.len() >= MAX_ENTRIES || m.bytes.saturating_add(bytes) > MAX_BYTES { m.entries.clear(); - m.answers = 0; + m.bytes = 0; } - m.answers = m.answers.saturating_add(reqs.len()); - m.entries.insert(key, Entry { text: text.into(), path: b.path.into(), value: b.value.clone(), reqs, resps, out }); + m.bytes = m.bytes.saturating_add(bytes); + m.entries.insert(key, Entry { text: text.into(), path: b.path.into(), value: b.value.clone(), reqs, resps, out, bytes }); }); } diff --git a/crates/expr/src/tests_memo.rs b/crates/expr/src/tests_memo.rs index 60f747973..f62338d10 100644 --- a/crates/expr/src/tests_memo.rs +++ b/crates/expr/src/tests_memo.rs @@ -70,16 +70,19 @@ pub(crate) fn frame(p: &Project, cid: ItemId, t: f64) -> Vec { #[ignore = "timing readout"] fn rig_timing() { let (p, cid) = rig(100); - let _ = frame(&p, cid, 0.0); - let frames = 30; - let runs = script_runs(); - let start = Instant::now(); - for f in 1..=frames { - let _ = frame(&p, cid, f as f64 / 30.0); + for off in [true, false] { + memo::set_off(off); + let _ = frame(&p, cid, 0.0); + let frames = 150; + let runs = script_runs(); + let start = Instant::now(); + for f in 1..=frames { + let _ = frame(&p, cid, f as f64 / 30.0); + } + let ms = start.elapsed().as_secs_f64() * 1000.0 / frames as f64; + let runs = (script_runs() - runs) as f64 / frames as f64; + println!("rig of 100 layers, memo {}: {ms:.2} ms/frame, {runs:.0} script runs/frame", if off { "off" } else { "on" }); } - let ms = start.elapsed().as_secs_f64() * 1000.0 / frames as f64; - let runs = (script_runs() - runs) as f64 / frames as f64; - println!("rig of 100 layers: {ms:.2} ms/frame, {runs:.0} script runs/frame"); } /// `f` on a thread with the memo off (the oracle: every evaluation runs its script), leaving From 802abc2f6f3485519f73604eddaa2890704f5830 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 09:27:55 +0000 Subject: [PATCH 13/45] G5: expression perf tests assign Vec directly (no Layers type upstream) (cherry picked from commit e9b042e9fe6f6f52b9ec92c9a084f169a75148f0) (cherry picked from commit 5e1cc2390fb972da376bd6425ed0f9f4bd5c5ddb) --- crates/expr/src/tests_memo.rs | 2 +- crates/expr/src/tests_perf.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/expr/src/tests_memo.rs b/crates/expr/src/tests_memo.rs index f62338d10..6ddf9c295 100644 --- a/crates/expr/src/tests_memo.rs +++ b/crates/expr/src/tests_memo.rs @@ -47,7 +47,7 @@ pub(crate) fn rig(n: usize) -> (Project, ItemId) { Some(Expression { text: "thisComp.layer(\"Ctrl\").effect(\"Slider Control\")(\"Slider\") * index + time".into(), enabled: true }); layers.push(l); } - p.comp_mut(cid).unwrap().layers = layers.into(); + p.comp_mut(cid).unwrap().layers = layers; (p, cid) } diff --git a/crates/expr/src/tests_perf.rs b/crates/expr/src/tests_perf.rs index 0bf288fbe..e2d62cf59 100644 --- a/crates/expr/src/tests_perf.rs +++ b/crates/expr/src/tests_perf.rs @@ -161,7 +161,7 @@ fn many(n: usize) -> (Project, effectcraft_project::ItemId, Vec) { layers.push(l); } let ids = layers.iter().map(|l| l.id).collect(); - p.comp_mut(cid).unwrap().layers = layers.into(); + p.comp_mut(cid).unwrap().layers = layers; (p, cid, ids) } From cd870b76bd39fd90442d7790b5161670a56641a4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 17:36:50 +0000 Subject: [PATCH 14/45] G5: cache finished text geometry and share glyph outlines Glyph outlines are read under a RwLock and transformed straight from the shared Arc (no clone). layout_doc caches its finished TextLayout keyed by the shaped layout, styles and box, and both that cache and the shaping cache are byte-bounded LRUs (64 / 32 MiB) instead of 512 entries. Output is identical to the previous path (oracle test over varied documents). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TZCme2FE1XWV7FWbjyAxaW (cherry picked from commit 65bfe27ffad2a50cbc6a4e6ab2585ec950aec9a1) (cherry picked from commit 4dbb6368649e5651bbec70a49de4c5fb0be73188) --- crates/text/src/layout.rs | 103 +++++++++-- crates/text/src/lib.rs | 367 +++++++++++++++++++++++++++++++++++++- 2 files changed, 445 insertions(+), 25 deletions(-) diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 5dd8df8bf..552839b48 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -1030,9 +1030,88 @@ pub fn layout_rich_uncached(text: &str, runs: &[(Range, TextStyle)], para lay } -fn cache() -> &'static Mutex)>> { - static C: OnceLock)>>> = OnceLock::new(); - C.get_or_init(Default::default) +/// A least-recently-used cache keyed by a 64-bit content hash and bounded by an estimate of the +/// bytes its values hold (not by entry count, so many small layouts fit where a few huge ones +/// would not). Overflow evicts the oldest entries down to three quarters of the budget, which +/// keeps the sort that finds them rare. +pub(crate) struct ByteLru { + map: HashMap>, + bytes: usize, + budget: usize, + clock: u64, +} + +struct LruEntry { + tick: u64, + bytes: usize, + value: V, +} + +impl ByteLru { + pub(crate) fn new(budget: usize) -> Self { + ByteLru { map: HashMap::new(), bytes: 0, budget, clock: 0 } + } + + /// The value under `key`, marked as just used. + pub(crate) fn get(&mut self, key: u64) -> Option<&V> { + self.clock = self.clock.wrapping_add(1); + let e = self.map.get_mut(&key)?; + e.tick = self.clock; + Some(&e.value) + } + + /// Store `value` (an estimated `bytes` large) under `key`, replacing what was there. A value + /// larger than the whole budget is not kept. + pub(crate) fn insert(&mut self, key: u64, value: V, bytes: usize) { + if let Some(old) = self.map.remove(&key) { + self.bytes = self.bytes.saturating_sub(old.bytes); + } + if bytes > self.budget { + return; + } + self.clock = self.clock.wrapping_add(1); + self.map.insert(key, LruEntry { tick: self.clock, bytes, value }); + self.bytes = self.bytes.saturating_add(bytes); + if self.bytes > self.budget { + let target = self.budget / 4 * 3; + let mut ages: Vec<(u64, u64)> = self.map.iter().map(|(k, e)| (e.tick, *k)).collect(); + ages.sort_unstable(); + for (_, k) in ages { + if self.bytes <= target { + break; + } + if let Some(e) = self.map.remove(&k) { + self.bytes = self.bytes.saturating_sub(e.bytes); + } + } + } + } + + #[cfg(test)] + pub(crate) fn len(&self) -> usize { + self.map.len() + } + + #[cfg(test)] + pub(crate) fn bytes(&self) -> usize { + self.bytes + } +} + +impl Layout { + /// Rough heap + inline size, for cache budgets. + pub(crate) fn approx_bytes(&self) -> usize { + let lines: usize = self.lines.iter().map(|l| std::mem::size_of::() + l.carets.len() * std::mem::size_of::<(usize, f32)>()).sum(); + std::mem::size_of::() + self.glyphs.len() * std::mem::size_of::() + lines + self.underlines.len() * std::mem::size_of::<[f32; 4]>() + } +} + +/// Shaped layouts: 32 MiB holds tens of thousands of typical titles (a few KiB each). +const LAYOUT_CACHE_BYTES: usize = 32 << 20; + +fn cache() -> &'static Mutex>> { + static C: OnceLock>>> = OnceLock::new(); + C.get_or_init(|| Mutex::new(ByteLru::new(LAYOUT_CACHE_BYTES))) } /// Lay out `text` in one style (cached by text and styles). @@ -1047,22 +1126,12 @@ pub fn layout_rich(text: &str, runs: &[(Range, TextStyle)], paras: &[Para runs.hash(&mut h); paras.hash(&mut h); let key = h.finish(); - static CLOCK: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); - let now = CLOCK.fetch_add(1, std::sync::atomic::Ordering::Relaxed); - if let Some(e) = cache().lock().unwrap_or_else(|e| e.into_inner()).get_mut(&key) { - e.0 = now; - return e.1.clone(); + if let Some(l) = cache().lock().unwrap_or_else(|e| e.into_inner()).get(key) { + return l.clone(); } let l = Arc::new(layout_rich_uncached(text, runs, paras)); - let mut c = cache().lock().unwrap_or_else(|e| e.into_inner()); - if c.len() >= 512 { - // evict the oldest quarter - let mut ages: Vec = c.values().map(|v| v.0).collect(); - ages.sort_unstable(); - let cut = ages[ages.len() / 4]; - c.retain(|_, v| v.0 > cut); - } - c.insert(key, (now, l.clone())); + let bytes = l.approx_bytes(); + cache().lock().unwrap_or_else(|e| e.into_inner()).insert(key, l.clone(), bytes); l } diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 21bb12b19..c3a5d73b8 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -17,7 +17,8 @@ pub mod variable; pub use kurbo; use std::collections::HashMap; -use std::sync::{Arc, Mutex, OnceLock}; +use std::hash::{Hash, Hasher}; +use std::sync::{Arc, Mutex, OnceLock, RwLock}; use effectcraft_keyframe::{BaselineOption, CharStyle, Composer, Direction, Justify, Kerning, ParaStyle, TextDoc}; pub use fonts::{FaceId, Resolved, families, resolve}; @@ -47,11 +48,12 @@ impl OutlinePen for Pen { } } -/// Glyph outline in font units (y up), cached. +/// Glyph outline in font units (y up), cached. Reads share a read lock, so text laid out on +/// several threads doesn't queue on every glyph; outlines are shared, never copied. pub(crate) fn outline_units(face: FaceId, gid: u32) -> Option> { - static C: OnceLock>>>> = OnceLock::new(); + static C: OnceLock>>>> = OnceLock::new(); let c = C.get_or_init(Default::default); - if let Some(v) = c.lock().unwrap_or_else(|e| e.into_inner()).get(&(face, gid)) { + if let Some(v) = c.read().unwrap_or_else(|e| e.into_inner()).get(&(face, gid)) { return v.clone(); } let f = fonts::face(face); @@ -61,7 +63,7 @@ pub(crate) fn outline_units(face: FaceId, gid: u32) -> Option> { g.draw(DrawSettings::unhinted(Size::unscaled(), LocationRef::default()), &mut pen).ok()?; Some(Arc::new(pen.0)) }); - let mut m = c.lock().unwrap_or_else(|e| e.into_inner()); + let mut m = c.write().unwrap_or_else(|e| e.into_inner()); if m.len() > 50_000 { m.clear(); } @@ -74,7 +76,8 @@ pub(crate) fn outline_units(face: FaceId, gid: u32) -> Option> { pub fn glyph_outline(g: &Glyph) -> BezPath { let units = if g.variations == 0 { outline_units(g.face, g.id) } else { variable::outline_units_at(g.face, g.id, &variable::coords(g.variations)) }; let Some(units) = units else { return BezPath::new() }; - glyph_affine(g) * (*units).clone() + // Transformed straight from the shared outline (one allocation, no copy first). + glyph_affine(g) * &*units } /// Font units → the glyph's outline in pixels (size, horizontal / vertical scale, faux italic). @@ -236,6 +239,64 @@ pub fn layout_doc(doc: &TextDoc) -> TextLayout { let width = doc.box_size.map(|b| if doc.vertical { b[1] } else { b[0] } as f32); let paras: Vec = doc.paras().iter().map(|p| paragraph_style(p, width)).collect(); let lay = layout::layout_rich(text, &runs, ¶s); + let key = DocKey { + lay: lay.clone(), + styles, + vertical: doc.vertical, + box_size: doc.box_size.map(|b| b.map(f64::to_bits)), + box_pos: doc.box_pos.map(f64::to_bits), + }; + let hash = key.hash(); + let hit = doc_cache().lock().unwrap_or_else(|e| e.into_inner()).get(hash).filter(|(k, _)| *k == key).map(|(_, v)| v.clone()); + if let Some(out) = hit { + return (*out).clone(); + } + let out = build_doc(doc, text, byte_of_char, key.styles.clone(), lay); + let bytes = out.approx_bytes(); + let out = Arc::new(out); + doc_cache().lock().unwrap_or_else(|e| e.into_inner()).insert(hash, (key, out.clone()), bytes); + (*out).clone() +} + +/// Everything [`build_doc`] reads besides the text, which the shaped layout is keyed by. +struct DocKey { + /// The cached shaping; a re-shape (after eviction) is a different `Arc` and misses. + lay: Arc, + styles: Vec, + vertical: bool, + box_size: Option<[u64; 2]>, + box_pos: [u64; 2], +} + +impl PartialEq for DocKey { + fn eq(&self, o: &Self) -> bool { + Arc::ptr_eq(&self.lay, &o.lay) && self.vertical == o.vertical && self.box_size == o.box_size && self.box_pos == o.box_pos && self.styles == o.styles + } +} + +impl DocKey { + fn hash(&self) -> u64 { + let mut h = std::collections::hash_map::DefaultHasher::new(); + (Arc::as_ptr(&self.lay) as usize).hash(&mut h); + // Debug prints every field (floats round-trip exactly); equality is checked on a hit. + format!("{:?}", self.styles).hash(&mut h); + (self.vertical, self.box_size, self.box_pos).hash(&mut h); + h.finish() + } +} + +/// Finished text geometry (glyph outlines in layer space): 64 MiB. +const DOC_CACHE_BYTES: usize = 64 << 20; + +#[allow(clippy::type_complexity)] +fn doc_cache() -> &'static Mutex)>> { + static C: OnceLock)>>> = OnceLock::new(); + C.get_or_init(|| Mutex::new(layout::ByteLru::new(DOC_CACHE_BYTES))) +} + +/// [`layout_doc`] past shaping: glyph outlines, indices and bounds in layer space. +fn build_doc(doc: &TextDoc, text: &str, byte_of_char: Vec, styles: Vec, lay: Arc) -> TextLayout { + let nchars = byte_of_char.len().saturating_sub(1); let to_layer = match (doc.vertical, doc.box_size) { (false, Some(_)) => Affine::translate((doc.box_pos[0], doc.box_pos[1])), (false, None) => Affine::IDENTITY, @@ -322,11 +383,11 @@ pub fn layout_doc(doc: &TextDoc) -> TextLayout { let rc = ROT90 * c; Affine::translate((rc.x - c.x, rc.y - c.y)) }; - path = t * path; + path.apply_affine(t); outline_xf = t * outline_xf; } else { // Roman characters turn on their side with the column. - path = ROT90 * path; + path.apply_affine(ROT90); outline_xf = ROT90 * outline_xf; } } @@ -365,7 +426,190 @@ pub fn layout_doc(doc: &TextDoc) -> TextLayout { out } +#[cfg(test)] +mod reference { + //! The text geometry path before caching (outline cloned then transformed, layout rebuilt on + //! every call): the oracle the cached [`super::layout_doc`] must match exactly. + use super::*; + + fn glyph_outline(g: &Glyph) -> BezPath { + let units = if g.variations == 0 { outline_units(g.face, g.id) } else { variable::outline_units_at(g.face, g.id, &variable::coords(g.variations)) }; + let Some(units) = units else { return BezPath::new() }; + glyph_affine(g) * (*units).clone() + } + + pub(crate) fn layout_doc(doc: &TextDoc) -> TextLayout { + // Keep Source Text editable as typed; only substitute the displayed digit glyphs. + // Character indices stay stable even though the display string has different UTF-8 offsets. + let displayed = match doc.digit_set { + DigitSet::Hindi => std::borrow::Cow::Owned( + doc.text.chars().map(|c| if c.is_ascii_digit() { char::from_u32(0x0660 + c as u32 - '0' as u32).unwrap_or(c) } else { c }).collect::(), + ), + DigitSet::Default | DigitSet::Arabic => std::borrow::Cow::Borrowed(doc.text.as_str()), + }; + let text = displayed.as_ref(); + let byte_of_char: Vec = text.char_indices().map(|(b, _)| b).chain(std::iter::once(text.len())).collect(); + let nchars = byte_of_char.len() - 1; + let mut runs = Vec::new(); + let mut styles = Vec::new(); + let mut ci = 0; + for r in doc.runs() { + let a = byte_of_char[ci.min(nchars)]; + ci += r.len; + let b = byte_of_char[ci.min(nchars)]; + let mut runtime_style = text_style(&r.style); + runtime_style.vertical = doc.vertical && !r.style.tate_chu_yoko; + runs.push((a..b, runtime_style)); + styles.push(r.style); + } + let width = doc.box_size.map(|b| if doc.vertical { b[1] } else { b[0] } as f32); + let paras: Vec = doc.paras().iter().map(|p| paragraph_style(p, width)).collect(); + let lay = layout::layout_rich(text, &runs, ¶s); + let to_layer = match (doc.vertical, doc.box_size) { + (false, Some(_)) => Affine::translate((doc.box_pos[0], doc.box_pos[1])), + (false, None) => Affine::IDENTITY, + (true, Some(b)) => Affine::translate((doc.box_pos[0] + b[0], doc.box_pos[1])) * ROT90, + (true, None) => ROT90, + }; + let mut out = TextLayout { lines: lay.lines.len(), to_layer, vertical: doc.vertical, ..Default::default() }; + // Map byte clusters → char/word indices. + let mut char_of_byte = vec![0usize; text.len() + 1]; + let mut word_of_char = Vec::new(); + let mut nospace_of_char = Vec::new(); + let mut words = 0usize; + let mut in_word = false; + let mut nospace = 0usize; + for (ci, (b, ch)) in text.char_indices().enumerate() { + for k in b..b + ch.len_utf8() { + char_of_byte[k] = ci; + } + let space = ch.is_whitespace(); + if !space && !in_word { + words += 1; + } + in_word = !space; + word_of_char.push(words.saturating_sub(1)); + nospace_of_char.push(nospace); + if !space { + nospace += 1; + } + } + char_of_byte[text.len()] = nchars; + out.chars = nchars; + out.chars_no_space = nospace; + out.words = words; + let mut b = [f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY]; + for (li, line) in lay.lines.iter().enumerate() { + let o = to_layer * Point::new(line.x as f64, line.baseline as f64); + out.line_boxes.push([o.x, o.y, line.width as f64, (line.ascent + line.descent) as f64]); + // Vertical type: Tate-Chu-Yoko groups (consecutive glyphs of tcy runs) take one em of + // the column; everything after them moves up by the rest of their width. + let tcy = |gi: usize| doc.vertical && styles.get(lay.glyphs[gi].run).is_some_and(|s| s.tate_chu_yoko); + let mut shift = 0.0f64; + let mut group: Option<(f64, f64, f64)> = None; // (start x, width, em) in layout space + let line_glyphs: Vec = line.glyphs.clone().collect(); + for (k, gi) in line_glyphs.iter().copied().enumerate() { + let g = &lay.glyphs[gi]; + let ci = char_of_byte.get(g.cluster).copied().unwrap_or(0); + let src = text[g.cluster..].chars().next().unwrap_or(' '); + let st = styles.get(g.run); + let ch = if st.is_some_and(|s| s.all_caps) { src.to_uppercase().next().unwrap_or(src) } else { src }; + let mut path = glyph_outline(g); + let mut outline_xf = glyph_affine(g); + let next_x = lay.glyphs.get(gi + 1).filter(|_| line.glyphs.contains(&(gi + 1))).map(|n| n.x).unwrap_or(line.x + line.width); + let advance = (next_x - g.x) as f64; + let mut origin = to_layer * Point::new(g.x as f64 - shift, g.y as f64); + if doc.vertical && tcy(gi) { + // Start of a group: measure it. + if group.is_none() { + let mut w = 0.0; + for &gj in line_glyphs[k..].iter().take_while(|&&gj| tcy(gj)) { + let nx = lay.glyphs.get(gj + 1).filter(|_| line.glyphs.contains(&(gj + 1))).map(|n| n.x).unwrap_or(line.x + line.width); + w += (nx - lay.glyphs[gj].x) as f64; + } + group = Some((g.x as f64, w, g.size as f64)); + } + let (x0, w, em) = group.unwrap_or((g.x as f64, advance, g.size as f64)); + // Upright and horizontal, centred across the column in an em-high cell. + let centre = to_layer * Point::new(x0 - shift + em / 2.0, g.y as f64 - 0.35 * em); + origin = Point::new(centre.x - w / 2.0 + (g.x as f64 - x0), centre.y + 0.35 * em); + if line_glyphs.get(k + 1).is_none_or(|&n| !tcy(n)) { + shift += w - em; + group = None; + } + } else if doc.vertical { + let upright = is_cjk(src) || st.is_some_and(|s| s.vertical_roman_upright); + if upright { + // Anchor vertical forms using their own vertical origin and horizontal + // advance, not the next glyph's pen (which includes tracking/justification). + let font = fonts::face(g.face); + let metrics = is_cjk(src).then(|| font.vertical_origin(g.id, g.size)).flatten(); + let t = if let Some((width, top)) = metrics { + Affine::translate((0.35 * g.size as f64 - width * g.h_scale as f64 / 2.0, top * g.v_scale as f64)) + } else { + let c = Point::new(advance / 2.0, -0.35 * g.size as f64); + let rc = ROT90 * c; + Affine::translate((rc.x - c.x, rc.y - c.y)) + }; + path = t * path; + outline_xf = t * outline_xf; + } else { + // Roman characters turn on their side with the column. + path = ROT90 * path; + outline_xf = ROT90 * outline_xf; + } + } + if let Some(r) = (!path.elements().is_empty()).then(|| kurbo::Shape::bounding_box(&path)) { + b[0] = b[0].min(origin.x + r.x0); + b[1] = b[1].min(origin.y + r.y0); + b[2] = b[2].max(origin.x + r.x1); + b[3] = b[3].max(origin.y + r.y1); + } + out.glyphs.push(CharGlyph { + path, + origin, + advance, + char_index: ci, + char_index_no_space: nospace_of_char.get(ci).copied().unwrap_or(0), + word_index: word_of_char.get(ci).copied().unwrap_or(0), + line_index: li, + is_space: src.is_whitespace(), + ch, + synth_bold: g.synth_bold, + size: g.size as f64, + run: g.run, + face: g.face, + gid: g.id, + outline_xf, + variations: variable::coords(g.variations).to_vec(), + }); + } + } + if b[0].is_finite() { + out.bounds = b; + } + out.styles = styles; + out.byte_of_char = byte_of_char; + out.layout = lay; + out + } +} + impl TextLayout { + /// Rough size (the shared paragraph layout included), for cache budgets. + fn approx_bytes(&self) -> usize { + let glyphs: usize = self + .glyphs + .iter() + .map(|g| std::mem::size_of::() + std::mem::size_of_val(g.path.elements()) + g.variations.iter().map(|v| 32 + v.0.len()).sum::()) + .sum(); + std::mem::size_of::() + + glyphs + + self.line_boxes.len() * 32 + + self.styles.len() * std::mem::size_of::() + + self.byte_of_char.len() * std::mem::size_of::() + + self.layout.approx_bytes() + } /// Character index of byte offset `b`. pub fn char_of_byte(&self, b: usize) -> usize { self.byte_of_char.partition_point(|x| *x < b).min(self.chars) @@ -445,6 +689,113 @@ pub fn char_glyph(doc: &TextDoc, ch: char) -> (BezPath, f64) { mod tests { use super::*; + /// Varied documents: point and paragraph text, several lines and paragraphs, kerning pairs, + /// sizes, families, mixed runs, caps, faux styles, vertical type and Hindi digits. + fn geometry_docs() -> Vec { + let mut v = Vec::new(); + v.push(TextDoc::plain("AVAWAY To. Wa Yo")); + v.push(TextDoc { text: "First line\nSecond, longer line\n\nfourth".into(), size: 48.0, ..TextDoc::plain("") }); + v.push(TextDoc { + text: "Paragraph text wraps across the box width when it gets long enough to break.".into(), + box_size: Some([220.0, 300.0]), + box_pos: [-110.0, -150.0], + justify: Justify::JustifyLastLeft, + ..TextDoc::plain("") + }); + for (font, size) in [("Noto Serif", 13.0), ("JetBrains Mono", 72.5), ("Inter", 200.0)] { + v.push(TextDoc { text: "Kerning: AV, To, LT, 1/2 fi fl".into(), font: font.into(), size, tracking: 25.0, ..TextDoc::plain("") }); + } + let mut mixed = TextDoc::plain("Mixed Styles here and there"); + mixed.apply_style(0..5, |s| { + s.size = 90.0; + s.faux_italic = true; + }); + mixed.apply_style(6..12, |s| { + s.font = "Noto Serif".into(); + s.all_caps = true; + s.h_scale = 140.0; + s.baseline_shift = 6.0; + }); + mixed.apply_style(13..18, |s| { + s.kerning = Kerning::Optical; + s.v_scale = 60.0; + }); + v.push(mixed); + v.push(TextDoc { text: "縦書き Roman 12".into(), vertical: true, ..TextDoc::plain("") }); + v.push(TextDoc { text: "縦書きの段落テキスト abc".into(), vertical: true, box_size: Some([120.0, 200.0]), ..TextDoc::plain("") }); + v.push(TextDoc { text: "Total 1234".into(), digit_set: DigitSet::Hindi, justify: Justify::Center, ..TextDoc::plain("") }); + v.push(TextDoc { text: "".into(), ..TextDoc::plain("") }); + v + } + + #[test] + fn cached_layout_doc_matches_the_uncached_reference() { + for doc in geometry_docs() { + let want = format!("{:?}", reference::layout_doc(&doc)); + // First call builds and caches; the second is a hit. + assert_eq!(format!("{:?}", layout_doc(&doc)), want, "{:?}", doc.text); + assert_eq!(format!("{:?}", layout_doc(&doc)), want, "{:?} (cached)", doc.text); + } + } + + #[test] + fn layout_doc_cache_tells_apart_styles_shaping_ignores() { + // Fill and stroke don't change shaping, but `styles` carries them out. + let a = TextDoc::plain("Colour"); + let mut b = a.clone(); + b.apply_style_all(|s| s.fill = [0.1, 0.2, 0.3, 1.0]); + assert_eq!(layout_doc(&a).styles[0].fill, a.base_style().fill); + assert_eq!(layout_doc(&b).styles[0].fill, [0.1, 0.2, 0.3, 1.0]); + // The box position moves geometry without re-shaping. + let p = TextDoc { box_size: Some([300.0, 100.0]), box_pos: [0.0, 0.0], ..TextDoc::plain("Boxed") }; + let q = TextDoc { box_pos: [40.0, 0.0], ..p.clone() }; + let (lp, lq) = (layout_doc(&p), layout_doc(&q)); + assert_eq!(lq.glyphs[0].origin.x - lp.glyphs[0].origin.x, 40.0); + } + + #[test] + fn byte_lru_stays_within_its_budget_and_keeps_recent_entries() { + let mut c = layout::ByteLru::new(1000); + for k in 0..100u64 { + c.insert(k, k, 30); + // Keep entry 0 hot. + assert_eq!(c.get(0), Some(&0)); + assert!(c.bytes() <= 1000); + } + assert_eq!(c.get(0), Some(&0)); + assert_eq!(c.get(99), Some(&99)); + assert_eq!(c.get(1), None); + assert!(c.len() <= 34); + // Replacing an entry re-counts its bytes; an oversized value isn't kept. + c.insert(99, 7, 500); + assert_eq!(c.get(99), Some(&7)); + assert!(c.bytes() <= 1000); + c.insert(5000, 1, 2000); + assert_eq!(c.get(5000), None); + } + + #[test] + #[ignore = "timing, run by hand: cargo test -p effectcraft-text --release --lib -- --ignored --nocapture timing"] + fn layout_doc_timing() { + let docs = geometry_docs(); + let n = 200; + let t = std::time::Instant::now(); + for _ in 0..n { + for d in &docs { + std::hint::black_box(reference::layout_doc(d)); + } + } + let old = t.elapsed(); + let t = std::time::Instant::now(); + for _ in 0..n { + for d in &docs { + std::hint::black_box(layout_doc(d)); + } + } + let new = t.elapsed(); + eprintln!("layout_doc x{}: uncached {old:?}, cached {new:?}", n * docs.len()); + } + #[test] fn layout_counts_and_bounds() { let doc = TextDoc { text: "Hello big world".into(), size: 100.0, ..Default::default() }; From 229d0cfdea1fa163abec52945de318d444111259 Mon Sep 17 00:00:00 2001 From: Elias Date: Sat, 10 Oct 2026 16:38:03 +0100 Subject: [PATCH 15/45] G5: font files are read once for all their faces (.ttc collections) and the UI shares the text engine's bytes instead of copying fallback fonts (cherry picked from commit 23dffa985d6e19b4b68b53cd5e0f5048311f7365) (cherry picked from commit 42924af2c387aed3eac86f1fe9ec9578132d2402) --- crates/text/src/fonts.rs | 48 ++++++++++++++++++++++++++++++++++++- crates/ui-egui/src/theme.rs | 9 ++++--- 2 files changed, 53 insertions(+), 4 deletions(-) diff --git a/crates/text/src/fonts.rs b/crates/text/src/fonts.rs index 2f7311c58..a9ba2259f 100644 --- a/crates/text/src/fonts.rs +++ b/crates/text/src/fonts.rs @@ -195,11 +195,21 @@ impl Face { .get_or_init(|| match &self.info.data { FaceData::Static(b) => Some(FaceBytes::Static(b)), FaceData::Shared(b) => Some(FaceBytes::Shared(b.clone())), - FaceData::File(p) => std::fs::read(p).ok().map(|v| FaceBytes::Shared(Arc::new(v))), + FaceData::File(p) => file_bytes(p).map(FaceBytes::Shared), }) .as_ref() .map(FaceBytes::get) } + /// The face's file for the rest of the program, shared rather than copied: for UI font + /// systems that take `'static` data. Faces in the database are never unloaded, so this keeps + /// alive nothing they don't. + pub fn static_bytes(&self) -> Option<&'static [u8]> { + self.bytes()?; + match self.bytes.get()?.as_ref()? { + FaceBytes::Static(b) => Some(*b), + FaceBytes::Shared(b) => Some(Box::leak(Box::new(b.clone())).as_slice()), + } + } /// The parsed font (None if the file is missing or unreadable). pub fn font(&self) -> Option> { FontRef::from_index(self.bytes()?, self.info.index).ok() @@ -695,6 +705,22 @@ fn families_for(s: FallbackScript) -> &'static [&'static str] { } } +/// The contents of font file `p`, read once while any face uses them: the faces of a +/// collection (`.ttc`: a dozen CJK weights in one file of tens of megabytes) each read the whole +/// file. +fn file_bytes(p: &Path) -> Option>> { + static FILES: OnceLock>>>> = OnceLock::new(); + let files = FILES.get_or_init(Default::default); + let mut map = files.lock().unwrap_or_else(|e| e.into_inner()); + if let Some(b) = map.get(p).and_then(std::sync::Weak::upgrade) { + return Some(b); + } + let b = Arc::new(std::fs::read(p).ok()?); + map.retain(|_, w| w.strong_count() > 0); + map.insert(p.to_path_buf(), Arc::downgrade(&b)); + Some(b) +} + /// Whether a not-yet-loaded face covers `c`, reading its file without keeping the bytes (the /// last-resort search may look at every installed font once). fn file_covers(f: &Face, c: char) -> bool { @@ -832,6 +858,26 @@ fn system_fallback(c: char, style: &str) -> Option { mod tests { use super::*; + /// The faces of one font file (a `.ttc` collection) share one read of it, and the UI gets + /// those bytes rather than a copy. + #[test] + fn faces_of_one_file_share_its_bytes() { + let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/test-font-bytes"); + std::fs::create_dir_all(&dir).unwrap(); + let p = dir.join("collection.ttc"); + std::fs::write(&p, b"not really a font").unwrap(); + let a = file_bytes(&p).unwrap(); + let b = file_bytes(&p).unwrap(); + assert!(Arc::ptr_eq(&a, &b)); + drop((a, b)); + std::fs::write(&p, b"edited").unwrap(); + assert_eq!(file_bytes(&p).unwrap().as_slice(), b"edited", "read again once nothing holds it"); + let face = face(FaceId::default()); + let bytes = face.static_bytes().unwrap(); + assert!(std::ptr::eq(bytes.as_ptr(), face.bytes().unwrap().as_ptr()), "shared, not copied"); + let _ = std::fs::remove_file(&p); + } + #[test] fn extra_font_dirs_come_from_a_path_list() { assert!(extra_dirs(None).is_empty()); diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 953bf66d9..8b90f50f7 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -410,10 +410,13 @@ fn install_script_fallbacks(family_fonts: &mut FontDefinitions, language: &str) if !face.has_char(probe) { continue; // nothing installed covers this script } - let Some(font) = face.font() else { continue }; + if face.font().is_none() { + continue; + } + let Some(bytes) = face.static_bytes() else { continue }; seen.push(id); - // Use the already-read, parsed bytes rather than reading a font file twice. - let mut data = FontData::from_owned(font.data().as_bytes().to_vec()); + // Share the text engine's bytes rather than reading or copying a font file again. + let mut data = FontData::from_static(bytes); data.index = face.info.index; family_fonts.font_data.insert(name.to_string(), Arc::new(data)); for family in family_fonts.families.values_mut() { From cf21c61c8ab91d3874d16404f26f727ef72b72f9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 09:07:32 +0000 Subject: [PATCH 16/45] G5: import what the shared font-file cache needs in fonts.rs (cherry picked from commit f61830766970fa32780dce700b792e006e8dfd7a) (cherry picked from commit d9befa89326fea5ff9daac73e162da1ceca2c242) --- crates/text/src/fonts.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/text/src/fonts.rs b/crates/text/src/fonts.rs index a9ba2259f..1dc17b3be 100644 --- a/crates/text/src/fonts.rs +++ b/crates/text/src/fonts.rs @@ -9,8 +9,9 @@ //! face lacks: bundled faces first, then the system's usual family for the character's script //! (CJK), then any installed face that covers it. -use std::path::PathBuf; -use std::sync::{Arc, OnceLock, RwLock}; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex, OnceLock, RwLock}; use skrifa::instance::{LocationRef, Size}; use skrifa::{FontRef, MetadataProvider}; From 5e152f46ef5f798546ef39fdd1dbb8a8132fa231 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 09:27:55 +0000 Subject: [PATCH 17/45] G5: text layout oracle without the digit-set substitution (not on this branch) (cherry picked from commit 1b4f841f1408ba2db9dd438f28ad7d0ca58b3e98) (cherry picked from commit bfb97cd337acb6fbda890290f356ee9ba775de8f) --- crates/text/src/lib.rs | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index c3a5d73b8..a63dea210 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -439,15 +439,7 @@ mod reference { } pub(crate) fn layout_doc(doc: &TextDoc) -> TextLayout { - // Keep Source Text editable as typed; only substitute the displayed digit glyphs. - // Character indices stay stable even though the display string has different UTF-8 offsets. - let displayed = match doc.digit_set { - DigitSet::Hindi => std::borrow::Cow::Owned( - doc.text.chars().map(|c| if c.is_ascii_digit() { char::from_u32(0x0660 + c as u32 - '0' as u32).unwrap_or(c) } else { c }).collect::(), - ), - DigitSet::Default | DigitSet::Arabic => std::borrow::Cow::Borrowed(doc.text.as_str()), - }; - let text = displayed.as_ref(); + let text = doc.text.as_str(); let byte_of_char: Vec = text.char_indices().map(|(b, _)| b).chain(std::iter::once(text.len())).collect(); let nchars = byte_of_char.len() - 1; let mut runs = Vec::new(); @@ -723,7 +715,6 @@ mod tests { v.push(mixed); v.push(TextDoc { text: "縦書き Roman 12".into(), vertical: true, ..TextDoc::plain("") }); v.push(TextDoc { text: "縦書きの段落テキスト abc".into(), vertical: true, box_size: Some([120.0, 200.0]), ..TextDoc::plain("") }); - v.push(TextDoc { text: "Total 1234".into(), digit_set: DigitSet::Hindi, justify: Justify::Center, ..TextDoc::plain("") }); v.push(TextDoc { text: "".into(), ..TextDoc::plain("") }); v } From c5bb661bf702b788a7be5d4fab963591748a1aa3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 17:41:12 +0000 Subject: [PATCH 18/45] G5: stream property-tree nodes when loading projects `Node` was an internally tagged serde enum, so serde buffered every node subtree into its private `Content` tree before converting it. A hand-written `Deserialize` reads the `"node"` tag when it comes first (serialization always writes it first) and streams the rest of the map into `Property`/`PropGroup`; a tag found later falls back to buffering. Serialization is unchanged (still derived). Parsing a 20.9 MB synthetic layer array (200 layers x 5 animated props x 200 keys) drops from 226 ms to 72 ms in release. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0123prcPx5zRkZvD52XuVKJE (cherry picked from commit cd480a0fc91f34a3724ff2f988534e6d73424627) (cherry picked from commit 5614422f9159d4692b84d95173ba5c2e4d0baf9f) --- crates/project/src/props.rs | 271 +++++++++++++++++++++++++++++++++++- 1 file changed, 270 insertions(+), 1 deletion(-) diff --git a/crates/project/src/props.rs b/crates/project/src/props.rs index f1d5149d3..098022b86 100644 --- a/crates/project/src/props.rs +++ b/crates/project/src/props.rs @@ -263,13 +263,124 @@ fn yes() -> bool { true } -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[derive(Clone, Debug, PartialEq, Serialize)] #[serde(tag = "node")] pub enum Node { Prop(Property), Group(PropGroup), } +// Hand-written so the common case streams: serde's derive for an internally tagged enum buffers +// every node (and so, nested, nearly the whole file) into its private `Content` tree before +// converting it. Serialization always writes `"node"` first, so read it and hand the rest of the +// map straight to the variant; only a tag found later falls back to buffering (as JSON values). +impl<'de> Deserialize<'de> for Node { + fn deserialize>(d: D) -> Result { + d.deserialize_any(NodeVisitor) + } +} + +const NODE_VARIANTS: &[&str] = &["Prop", "Group"]; + +#[derive(Clone, Copy)] +enum NodeTag { + Prop, + Group, +} + +impl<'de> Deserialize<'de> for NodeTag { + fn deserialize>(d: D) -> Result { + struct V; + impl serde::de::Visitor<'_> for V { + type Value = NodeTag; + fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + f.write_str("variant identifier") + } + fn visit_str(self, s: &str) -> Result { + match s { + "Prop" => Ok(NodeTag::Prop), + "Group" => Ok(NodeTag::Group), + _ => Err(E::unknown_variant(s, NODE_VARIANTS)), + } + } + fn visit_u64(self, i: u64) -> Result { + match i { + 0 => Ok(NodeTag::Prop), + 1 => Ok(NodeTag::Group), + _ => Err(E::invalid_value(serde::de::Unexpected::Unsigned(i), &"variant index 0 <= i < 2")), + } + } + } + d.deserialize_identifier(V) + } +} + +/// A map key: the tag, or any other field (kept for the buffering fallback). +enum NodeKey { + Tag, + Other(String), +} + +impl<'de> Deserialize<'de> for NodeKey { + fn deserialize>(d: D) -> Result { + struct V; + impl serde::de::Visitor<'_> for V { + type Value = NodeKey; + fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + f.write_str("a field name") + } + fn visit_str(self, s: &str) -> Result { + Ok(if s == "node" { NodeKey::Tag } else { NodeKey::Other(s.to_owned()) }) + } + } + d.deserialize_identifier(V) + } +} + +struct NodeVisitor; + +impl<'de> serde::de::Visitor<'de> for NodeVisitor { + type Value = Node; + fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + f.write_str("internally tagged enum Node") + } + fn visit_map>(self, mut map: A) -> Result { + use serde::de::Error; + use serde::de::value::MapAccessDeserializer; + let Some(first) = map.next_key::()? else { return Err(A::Error::missing_field("node")) }; + let mut rest = serde_json::Map::new(); + let tag = match first { + // Fast path: stream the remaining fields into the variant. + NodeKey::Tag => { + return match map.next_value::()? { + NodeTag::Prop => Property::deserialize(MapAccessDeserializer::new(map)).map(Node::Prop), + NodeTag::Group => PropGroup::deserialize(MapAccessDeserializer::new(map)).map(Node::Group), + }; + } + NodeKey::Other(k) => { + rest.insert(k, map.next_value()?); + let mut tag = None; + while let Some(k) = map.next_key::()? { + match k { + NodeKey::Tag if tag.is_some() => return Err(A::Error::duplicate_field("node")), + NodeKey::Tag => tag = Some(map.next_value::()?), + NodeKey::Other(k) => { + rest.insert(k, map.next_value()?); + } + } + } + tag.ok_or_else(|| A::Error::missing_field("node"))? + } + }; + let rest = serde_json::Value::Object(rest); + match tag { + NodeTag::Prop => Property::deserialize(rest).map(Node::Prop), + NodeTag::Group => PropGroup::deserialize(rest).map(Node::Group), + } + .map_err(A::Error::custom) + } +} + impl Node { pub fn uid(&self) -> Uid { match self { @@ -604,3 +715,161 @@ impl From for Node { Node::Group(g) } } + +#[cfg(test)] +mod node_serde_tests { + use super::*; + use effectcraft_keyframe::ShapePath; + + /// `Node` as serde derives it (the reference the hand-written `Deserialize` must match). + #[derive(Debug, Serialize, Deserialize)] + #[serde(tag = "node")] + enum RefNode { + Prop(Property), + Group(RefGroup), + } + + #[derive(Debug, Serialize, Deserialize)] + struct RefGroup { + uid: Uid, + #[serde(rename = "match")] + match_id: String, + name: String, + #[serde(default)] + kind: GroupKind, + #[serde(default = "yes")] + enabled: bool, + #[serde(default)] + children: Vec, + } + + impl From for Node { + fn from(n: RefNode) -> Node { + match n { + RefNode::Prop(p) => Node::Prop(p), + RefNode::Group(g) => Node::Group(PropGroup { + uid: g.uid, + match_id: g.match_id, + name: g.name, + kind: g.kind, + enabled: g.enabled, + children: g.children.into_iter().map(Node::from).collect(), + }), + } + } + } + + fn tree() -> Node { + let mut pos = Property::new(2, "position", "Position", Value::Vec2([1.0, 2.5])).spatial(); + pos.set_animated(true, Tick::ZERO); + pos.set_value_at(Tick::from_seconds_f64(1.0), Value::Vec2([3.0, -4.0])); + pos.expr = Some(Expression { text: "wiggle(2, 5)".into(), enabled: true }); + let blur = Property::new(3, "blurriness", "Blurriness", Value::Scalar(0.1 + 0.2)).with_ui(ParamUi::Slider { + min: 0.0, + max: 1e3, + slider_min: 0.0, + slider_max: 50.0, + decimals: 1, + }); + let path = Property::new(5, "path", "Mask Path", Value::Path(ShapePath::rect([0.0, 0.0], 10.0, 20.0))); + let mut mask = PropGroup::new(4, "mask", "Mask 1"); + mask.kind = GroupKind::Mask { + mode: MaskMode::Subtract, + inverted: true, + color: [1, 2, 3], + locked: false, + motion_blur: MaskMotionBlur::Off, + feather_falloff: FeatherFalloff::Linear, + roto_bezier: true, + }; + mask.children.push(path.into()); + let mut fx = PropGroup::new(6, "ec.blur.gaussian", "Gaussian Blur"); + fx.kind = GroupKind::Effect { effect: "ec.blur.gaussian".into() }; + fx.enabled = false; + fx.children.push(blur.into()); + let mut root = PropGroup::new(1, "root", "Root"); + root.children.extend([pos.into(), mask.into(), fx.into(), PropGroup::new(7, "empty", "Empty").into()]); + root.into() + } + + #[test] + fn node_serializes_like_the_derive_and_reads_back() { + let n = tree(); + let json = serde_json::to_string(&n).unwrap(); + let reference: RefNode = serde_json::from_str(&json).unwrap(); + assert_eq!(serde_json::to_string(&reference).unwrap(), json); + assert_eq!(serde_json::to_string_pretty(&reference).unwrap(), serde_json::to_string_pretty(&n).unwrap()); + let back: Node = serde_json::from_str(&json).unwrap(); + assert_eq!(back, n); + assert_eq!(Node::from(reference), n); + // Pretty files and in-memory JSON values (the control channel) read the same. + assert_eq!(serde_json::from_str::(&serde_json::to_string_pretty(&n).unwrap()).unwrap(), n); + assert_eq!(serde_json::from_value::(serde_json::to_value(&n).unwrap()).unwrap(), n); + } + + /// Moves `"node"` to the end of every object that has it (the buffering fallback). + fn tag_last(v: &mut serde_json::Value) { + match v { + serde_json::Value::Object(o) => { + if let Some(t) = o.remove("node") { + o.insert("node".into(), t); + } + o.values_mut().for_each(tag_last); + } + serde_json::Value::Array(a) => a.iter_mut().for_each(tag_last), + _ => {} + } + } + + #[test] + fn node_reads_the_tag_anywhere_and_ignores_unknown_fields() { + let n = tree(); + let mut v = serde_json::to_value(&n).unwrap(); + tag_last(&mut v); + let text = serde_json::to_string(&v).unwrap(); + assert!(!text.starts_with(r#"{"node""#), "{text}"); + assert_eq!(serde_json::from_str::(&text).unwrap(), n); + assert_eq!(serde_json::from_value::(v).unwrap(), n); + + let prop = |s: &str| serde_json::from_str::(s).unwrap(); + let p = Property::new(9, "opacity", "Opacity", Value::Scalar(50.0)); + let first = r#"{"node":"Prop","uid":9,"match":"opacity","name":"Opacity","value":{"t":"Scalar","v":50.0}}"#; + let middle = r#"{"uid":9,"match":"opacity","node":"Prop","name":"Opacity","value":{"t":"Scalar","v":50.0}}"#; + let last = r#"{"uid":9,"match":"opacity","name":"Opacity","value":{"t":"Scalar","v":50.0},"node":"Prop"}"#; + let unknown = r#"{"node":"Prop","future":[1,{"x":2}],"uid":9,"match":"opacity","name":"Opacity","value":{"t":"Scalar","v":50.0}}"#; + let unknown_late = r#"{"future":{"node":"Group"},"uid":9,"match":"opacity","name":"Opacity","node":"Prop","value":{"t":"Scalar","v":50.0}}"#; + // Missing fields with defaults read back as them (`hold_only` is false when absent). + let mut p_saved = p.clone(); + p_saved.hold_only = false; + for s in [first, middle, last, unknown, unknown_late] { + assert_eq!(prop(s), Node::Prop(p_saved.clone()), "{s}"); + assert_eq!(serde_json::from_str::(s).map(Node::from).unwrap(), Node::Prop(p_saved.clone()), "{s}"); + } + let g = PropGroup::new(8, "transform", "Transform"); + for s in [ + r#"{"node":"Group","uid":8,"match":"transform","name":"Transform"}"#, + r#"{"uid":8,"match":"transform","name":"Transform","node":"Group"}"#, + r#"{"uid":8,"node":"Group","match":"transform","name":"Transform","kind":{"kind":"Plain"},"enabled":true,"children":[]}"#, + ] { + assert_eq!(prop(s), Node::Group(g.clone()), "{s}"); + } + } + + #[test] + fn node_errors_like_the_derive() { + for (s, want) in [ + (r#"{"uid":9}"#, "missing field `node`"), + (r#"{}"#, "missing field `node`"), + (r#"{"node":"Layer","uid":9}"#, "unknown variant `Layer`"), + (r#"{"uid":9,"node":"Layer"}"#, "unknown variant `Layer`"), + (r#"{"node":"Prop","uid":9}"#, "missing field `match`"), + (r#"{"uid":9,"node":"Group"}"#, "missing field `match`"), + (r#"{"uid":9,"node":"Prop","node":"Prop"}"#, "duplicate field `node`"), + (r#"[1,2]"#, "invalid type"), + (r#""Prop""#, "invalid type"), + ] { + let e = serde_json::from_str::(s).unwrap_err().to_string(); + assert!(e.contains(want), "{s}: {e}"); + } + } +} From b0fb8d9a31d5fa788a1e0407f8125bc003874fd0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 20:06:03 +0000 Subject: [PATCH 19/45] G5: skip the effect-instance copy on open when nothing needs upgrading migrate::is_current is a read-only check mirroring upgrade_instance; current instances return early instead of being deep-cloned for the comparison. A test checks it never skips a needed upgrade over per-parameter variants of every registered effect. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Kqn7byVXrwKLfhCti44RYL (cherry picked from commit ca89dc726445caf5b062692526d90f724267f9eb) (cherry picked from commit ff825b728a968c9284b25d427c98aba33c727bbf) --- crates/effects/src/migrate.rs | 121 ++++++++++++++++++++++++++++++++++ 1 file changed, 121 insertions(+) diff --git a/crates/effects/src/migrate.rs b/crates/effects/src/migrate.rs index 0c0dad9d1..f9c6c10bf 100644 --- a/crates/effects/src/migrate.rs +++ b/crates/effects/src/migrate.rs @@ -249,9 +249,76 @@ fn is_numeric_ui(u: &ParamUi) -> bool { matches!(u, ParamUi::Number | ParamUi::Slider { .. } | ParamUi::Percent | ParamUi::Angle | ParamUi::Pixels) } +/// Read-only [`prop_at`]. +fn prop_ref<'a>(g: &'a PropGroup, id: &str) -> Option<&'a Property> { + match id.split_once('/') { + None => g.get(id), + Some((first, rest)) => prop_ref(g.sub(first)?, rest), + } +} + +/// Whether [`refresh`] would leave `pr` as it is. +fn refresh_is_no_op(pr: &Property, ps: &ParamSpec, spec: &EffectSpec) -> bool { + if USER_EDITED_CATEGORIES.contains(&spec.category) { + return true; + } + if pr.name != ps.name { + return false; + } + match (&pr.ui, &ps.ui) { + // Equal option lists: no value is remapped. + (ParamUi::Popup { .. }, ParamUi::Popup { .. }) => pr.ui == ps.ui, + (_, ParamUi::Hidden) => pr.ui == ParamUi::Hidden, + (a, b) if std::mem::discriminant(a) == std::mem::discriminant(b) || is_numeric_ui(a) && is_numeric_ui(b) => pr.ui == ps.ui, + _ => true, + } +} + +/// Whether the saved instance `g` of `spec` is already up to date: [`upgrade_instance`] would +/// leave it as it is. Read-only and allocation-free for most effects, so opening a project of +/// current instances neither copies nor rewrites them. Errs on the side of `false` (the upgrade +/// then runs and decides). +pub fn is_current(spec: &EffectSpec, g: &PropGroup) -> bool { + // Card Wipe's old Back Layer popup. + if spec.id == "ec.transition.cardwipe" && g.get("backLayer").is_some_and(|pr| matches!(pr.ui, ParamUi::Popup { .. })) { + return false; + } + for ps in &spec.params { + // Missing (to be added, or moved from an old id or the top level). + let Some(pr) = prop_ref(g, ps.id) else { return false }; + if !refresh_is_no_op(pr, ps, spec) { + return false; + } + if matches!(ps.ui, ParamUi::Layer) { + let sid = layer_source_id(ps.id); + if !spec.params.iter().any(|q| q.id == sid) && prop_ref(g, &sid).is_none() { + return false; + } + } + } + // Twirl-down group names (see `upgrade_instance`'s `names`); deeper than any effect nests + // counts as not current. + fn names_current(g: &PropGroup, depth: usize) -> bool { + g.groups().all(|sg| { + let n = group_name(&sg.match_id); + depth < 32 && (n == sg.match_id || sg.name == n) && names_current(sg, depth + 1) + }) + } + if !USER_EDITED_CATEGORIES.contains(&spec.category) && spec.params.iter().any(|p| p.id.contains('/')) && !names_current(g, 0) { + return false; + } + true +} + /// Bring the saved instance `g` of `spec` up to date (see the module docs). `layer_size` sizes /// point defaults of added parameters. Returns whether anything changed. pub fn upgrade_instance(spec: &EffectSpec, g: &mut PropGroup, ids: &mut Ids, layer_size: [f64; 2]) -> bool { + // Most instances are current: skip the copy kept to compare against. + !is_current(spec, g) && upgrade_full(spec, g, ids, layer_size) +} + +/// [`upgrade_instance`] without the [`is_current`] shortcut. +fn upgrade_full(spec: &EffectSpec, g: &mut PropGroup, ids: &mut Ids, layer_size: [f64; 2]) -> bool { let before = g.clone(); // Card Wipe's Back Layer was a None / Self popup; it is a layer parameter now, with "Self" // kept in the hidden Back Layer Is Self switch. @@ -470,6 +537,60 @@ mod tests { } } + #[test] + fn is_current_never_skips_a_needed_upgrade() { + fn rename_groups(g: &mut PropGroup) { + for c in &mut g.children { + if let Node::Group(sg) = c { + sg.name = "Old Group".into(); + rename_groups(sg); + } + } + } + let mut next = 1; + for spec in crate::registry() { + let fresh = instantiate(spec, &mut Ids(&mut next), spec.name, [100.0, 50.0]); + assert!(is_current(spec, &fresh), "{} fresh instance not current", spec.id); + // Old saves: each parameter missing, renamed, with other UI hints, or at top level; + // twirl-downs renamed. + let mut variants = vec![]; + for ps in &spec.params { + let mut g = fresh.clone(); + take_at(&mut g, ps.id); + variants.push(g); + let mut g = fresh.clone(); + if let Some(pr) = take_at(&mut g, ps.id) { + g.children.push(pr.into()); + variants.push(g); + } + let uis = [ParamUi::Hidden, ParamUi::Number, ParamUi::Checkbox, ParamUi::Popup { options: vec!["A".into(), "B".into()] }]; + for ui in uis { + let mut g = fresh.clone(); + if let Some(pr) = prop_at(&mut g, ps.id) { + pr.ui = ui; + } + variants.push(g); + } + let mut g = fresh.clone(); + if let Some(pr) = prop_at(&mut g, ps.id) { + pr.name = "Old Name".into(); + if let ParamUi::Popup { options } = &mut pr.ui { + options.reverse(); + } + } + variants.push(g); + } + let mut g = fresh.clone(); + rename_groups(&mut g); + variants.push(g); + for g in variants { + let current = is_current(spec, &g); + let changed = upgrade_full(spec, &mut g.clone(), &mut Ids(&mut next), [100.0, 50.0]); + assert!(!(current && changed), "{}: is_current skipped an upgrade", spec.id); + } + } + } + #[test] fn upgrade_adds_missing_params_refreshes_names_and_remaps_popups() { let spec = find("ec.blur.gaussian").unwrap(); From 0788f60ccec0a3b36eb876c3f7fadee5d6422b64 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 03:33:29 +0000 Subject: [PATCH 20/45] =?UTF-8?q?Fix:=20is=5Fcurrent=20sees=20upstream's?= =?UTF-8?q?=20Hidden/Text=20=E2=86=92=20File=20parameter=20upgrade=20(#514?= =?UTF-8?q?),=20so=20opening=20a=20project=20upgrades=20old=20Apply=20Colo?= =?UTF-8?q?r=20LUT=20/=20OCIO=20instances?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YAHHERF9trzfV2uSdYVBc6 (cherry picked from commit 0b083de13aaa94d81d8206a5e192549b3cd4abd2) (cherry picked from commit 25363912822c4d5b64fd76a56962059c627f6b4d) --- crates/effects/src/migrate.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/effects/src/migrate.rs b/crates/effects/src/migrate.rs index f9c6c10bf..47b764347 100644 --- a/crates/effects/src/migrate.rs +++ b/crates/effects/src/migrate.rs @@ -269,6 +269,7 @@ fn refresh_is_no_op(pr: &Property, ps: &ParamSpec, spec: &EffectSpec) -> bool { // Equal option lists: no value is remapped. (ParamUi::Popup { .. }, ParamUi::Popup { .. }) => pr.ui == ps.ui, (_, ParamUi::Hidden) => pr.ui == ParamUi::Hidden, + (ParamUi::Hidden | ParamUi::Text, ParamUi::File { .. }) => false, (a, b) if std::mem::discriminant(a) == std::mem::discriminant(b) || is_numeric_ui(a) && is_numeric_ui(b) => pr.ui == ps.ui, _ => true, } From 6e18ebc5d061e9e6edcf57e1a0551c3cc1028f64 Mon Sep 17 00:00:00 2001 From: Elias Date: Sat, 10 Oct 2026 17:02:25 +0100 Subject: [PATCH 21/45] G5: export honours the Memory & CPU budget: job layer cache sized by it, frames in flight bounded by frame bytes (cherry picked from commit 936c5217ff2ba465d83d59b438710d0becbda16e) (cherry picked from commit 9df172b77230fadd2fdd2fd5ee23c3b69f511523) --- crates/engine/src/commands/render_queue.rs | 1 + crates/engine/src/commands/timeline.rs | 1 + crates/engine/src/render_queue.rs | 8 ++++ crates/export/src/encode.rs | 2 +- crates/export/src/lib.rs | 46 +++++++++++++++++++--- crates/export/src/pipeline.rs | 2 +- crates/export/src/webm.rs | 2 +- crates/export/src/webm_av1.rs | 2 +- crates/host/src/lib.rs | 1 + 9 files changed, 56 insertions(+), 9 deletions(-) diff --git a/crates/engine/src/commands/render_queue.rs b/crates/engine/src/commands/render_queue.rs index d167df9aa..36309adc6 100644 --- a/crates/engine/src/commands/render_queue.rs +++ b/crates/engine/src/commands/render_queue.rs @@ -805,6 +805,7 @@ fn save_current_preview(s: &mut Session, p: &Value) -> Result { storage: s.storage_quota.as_deref(), label: "Save Current Preview".into(), nested_switches: s.prefs.general.switches_affect_nested_comps, + memory_budget: Some(s.prefs.cache_budgets(s.sys_memory).layer), }; let r = exporter.export(&job, &mut |_, _| true).map_err(EngineError::Other)?; s.toast(format!("Saved preview to {}", r.path)); diff --git a/crates/engine/src/commands/timeline.rs b/crates/engine/src/commands/timeline.rs index 633e73c5a..6a28846db 100644 --- a/crates/engine/src/commands/timeline.rs +++ b/crates/engine/src/commands/timeline.rs @@ -124,6 +124,7 @@ fn prerender(s: &Session, cid: ItemId, lid: LayerId, path: &str) -> std::result: storage: None, label: format!("Pre-render {}", l.name), nested_switches: s.prefs.general.switches_affect_nested_comps, + memory_budget: Some(s.prefs.cache_budgets(s.sys_memory).layer), }; let r = exporter.export(&job, &mut |_, _| true)?; Ok(Prerendered { path: r.path, width: comp.width, height: comp.height, frame_rate: comp.frame_rate, duration: end - start }) diff --git a/crates/engine/src/render_queue.rs b/crates/engine/src/render_queue.rs index 666a56cca..61c70af7e 100644 --- a/crates/engine/src/render_queue.rs +++ b/crates/engine/src/render_queue.rs @@ -33,6 +33,9 @@ pub struct ExportJob<'a> { pub label: String, /// Settings ▸ General ▸ Switches Affect Nested Comps. pub nested_switches: bool, + /// Memory for the export's layer cache and frames in flight (the Memory & CPU layer cache + /// budget, see [`crate::prefs::Prefs::cache_budgets`]); `None`: the exporter's default. + pub memory_budget: Option, } /// What an export wrote. @@ -155,6 +158,8 @@ struct Work { items: Vec>, /// Settings ▸ General ▸ Switches Affect Nested Comps. nested_switches: bool, + /// See [`ExportJob::memory_budget`]. + memory_budget: usize, } /// Where a relative render output goes when there is no default folder and the project is unsaved: @@ -233,6 +238,7 @@ pub(crate) async fn run_items( storage: s.storage_quota.clone(), items, nested_switches: s.prefs.general.switches_affect_nested_comps, + memory_budget: s.prefs.cache_budgets(s.sys_memory).layer, }; run_work(work, shared, notify).await; } @@ -276,6 +282,7 @@ async fn run_work(w: Work, shared: &JobShared, notify: &mut dyn FnMut(&JobShared storage: w.storage.as_deref(), label: format!("#{index} {name} (output module {})", mi + 1), nested_switches: w.nested_switches, + memory_budget: Some(w.memory_budget), }; let rr = w .exporter @@ -500,6 +507,7 @@ impl Session { storage: self.storage_quota.clone(), items, nested_switches: self.prefs.general.switches_affect_nested_comps, + memory_budget: self.prefs.cache_budgets(self.sys_memory).layer, }; let shared = Arc::new(JobShared::default()); if wait { diff --git a/crates/export/src/encode.rs b/crates/export/src/encode.rs index 098a8c11a..a65c65dde 100644 --- a/crates/export/src/encode.rs +++ b/crates/export/src/encode.rs @@ -263,7 +263,7 @@ pub(crate) async fn movie(job: &Cx<'_>, comp: &Comp, w: u32, h: u32, st: &mut St _ => Box::new(ProRes::new(w, h, job.output.prores_profile, if channels == Channels::Alpha { Channels::Rgb } else { channels })), }; let brand = if fmt == OutputFormat::ProRes { Brand::Mov } else { Brand::Mp4 }; - let batch = batch_size(); + let batch = batch_size(job, comp); let render = |_: u64, img: effectcraft_raster::Image| -> Vec { job.pixels(&img, comp, channels, w, h) }; // Encode the first batch before creating tracks: encoders finalise their config on frame 1. diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs index 5d8405c0d..0bdef6e87 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -103,8 +103,15 @@ pub struct JobOptions<'a> { pub storage: Option<&'a dyn StorageQuota>, /// Overflow folders, in order. pub overflow: Vec, + /// Memory for the job's layer cache and for frames rendering at once (bytes; `None`: + /// [`DEFAULT_MEMORY_BUDGET`]). The app passes its Memory & CPU layer cache budget (after + /// RAM Reserved for Other Applications). + pub memory_budget: Option, } +/// [`JobOptions::memory_budget`] when the caller sets none. +pub const DEFAULT_MEMORY_BUDGET: usize = 1 << 30; + /// Progress after a batch of frames. #[derive(Clone, Copy, Debug, Default)] pub struct Progress { @@ -225,9 +232,21 @@ impl State<'_> { } } -/// Frames per parallel batch. -pub(crate) fn batch_size() -> u64 { - rayon::current_num_threads().clamp(2, 16) as u64 +/// Frames per parallel batch for `comp` at the Render Settings' resolution, bounded by the +/// job's memory budget ([`frames_in_flight`]). +pub(crate) fn batch_size(cx: &Cx, comp: &Comp) -> u64 { + let s = cx.settings.resolution.clamp(0.01, 4.0); + let (w, h) = ((comp.width as f64 * s).ceil() as u32, (comp.height as f64 * s).ceil() as u32); + frames_in_flight(w, h, cx.options.memory_budget.unwrap_or(DEFAULT_MEMORY_BUDGET)) +} + +/// Frames rendering at once: one per core (2–16), fewer when that many `w × h` frames would +/// take more than `budget` bytes in flight (a 4K frame and the canvases and layer buffers +/// rendering it take about four `f32` frames, ~530 MB). +pub(crate) fn frames_in_flight(w: u32, h: u32, budget: usize) -> u64 { + let threads = rayon::current_num_threads().clamp(2, 16) as u64; + let frame = (w as u64).saturating_mul(h as u64).saturating_mul(16 * 4).max(1); + (budget as u64 / frame).clamp(1, threads) } /// Crop/pad 8-bit RGBA to `w`×`h` (codec rounding). @@ -255,7 +274,7 @@ async fn sequence(cx: &Cx<'_>, comp: &Comp, w: u32, h: u32, st: &mut State<'_>) Channels::Rgba if !fmt.supports_alpha() => Channels::Rgb, c => c, }; - let batch = batch_size(); + let batch = batch_size(cx, comp); let mut bytes = 0u64; let mut i = 0; while i < st.total { @@ -365,7 +384,7 @@ async fn gif_export(job: &Cx<'_>, comp: &Comp, w: u32, h: u32, st: &mut State<'_ let cs_per_frame = 100.0 * rate.den as f64 / rate.num as f64; let mut clock = 0.0f64; let mut emitted = 0u64; - let batch = batch_size(); + let batch = batch_size(job, comp); let mut i = 0; while i < st.total { let end = (i + batch).min(st.total); @@ -411,3 +430,20 @@ mod tests { assert_eq!(OutputFormat::Gif.coded_size(1, 101), (1, 101)); } } + +#[cfg(test)] +mod batch_tests { + /// Frames rendering at once are bounded by the memory budget: 4K frames get fewer than HD. + #[test] + fn batch_size_follows_frame_bytes_and_budget() { + let threads = rayon::current_num_threads().clamp(2, 16) as u64; + let gib = 1 << 30; + // ~530 MB per 4K frame in flight: two fit 1 GiB. + assert_eq!(super::frames_in_flight(3840, 2160, gib), 2.min(threads)); + assert_eq!(super::frames_in_flight(1920, 1080, gib), 8.min(threads)); + // Always at least one, never more than the threads. + assert_eq!(super::frames_in_flight(8192, 8192, 64 << 20), 1); + assert_eq!(super::frames_in_flight(16, 16, gib), threads); + assert_eq!(super::frames_in_flight(0, 0, 0), 1); + } +} diff --git a/crates/export/src/pipeline.rs b/crates/export/src/pipeline.rs index 7b451726d..32295209a 100644 --- a/crates/export/src/pipeline.rs +++ b/crates/export/src/pipeline.rs @@ -73,7 +73,7 @@ impl<'a> Cx<'a> { let mut project = job.project.clone(); apply_overrides(&mut project, job); let frame_times = (job.options.log == RenderLog::PlusPerFrameInfo).then(|| Mutex::new(Vec::new())); - let cache = LayerCache::default(); + let cache = LayerCache::new(job.options.memory_budget.unwrap_or(crate::DEFAULT_MEMORY_BUDGET)); // With deferred readbacks, buffers computed from a missed pass's placeholders are not kept. cache.set_gate(job.accel.and_then(|a| a.miss_gate())); Cx { job, project, cache, frame_times, overflowed: Mutex::new(vec![]), written: Mutex::new(vec![]) } diff --git a/crates/export/src/webm.rs b/crates/export/src/webm.rs index a12bd4e1a..983c4c8b8 100644 --- a/crates/export/src/webm.rs +++ b/crates/export/src/webm.rs @@ -252,7 +252,7 @@ pub(crate) async fn webm(job: &Cx<'_>, comp: &Comp, w: u32, h: u32, st: &mut Sta } }; - let batch = batch_size(); + let batch = batch_size(job, comp); let mut i = 0; while i < total { let end = (i + batch).min(total); diff --git a/crates/export/src/webm_av1.rs b/crates/export/src/webm_av1.rs index ca06a3e51..a32b2589f 100644 --- a/crates/export/src/webm_av1.rs +++ b/crates/export/src/webm_av1.rs @@ -132,7 +132,7 @@ pub(crate) async fn webm_av1(job: &Cx<'_>, comp: &Comp, w: u32, h: u32, st: &mut } }; - let batch = batch_size(); + let batch = batch_size(job, comp); let mut i = 0; let flush_blocks = |blocks: &mut Vec, file: &mut crate::out::Out, seg_len: &mut u64| -> Result<()> { if !blocks.is_empty() { diff --git a/crates/host/src/lib.rs b/crates/host/src/lib.rs index cf5661a15..ced0db716 100644 --- a/crates/host/src/lib.rs +++ b/crates/host/src/lib.rs @@ -56,6 +56,7 @@ impl Exporter for FileExporter { label: job.label.clone(), storage: job.storage, overflow: job.project.render_prefs.overflow_folders.clone(), + memory_budget: job.memory_budget, }, nested_switches: job.nested_switches, }; From f2d96cb7f085a2cc4bcc27a8111b485023630efc Mon Sep 17 00:00:00 2001 From: Elias Date: Sat, 10 Oct 2026 15:57:34 +0100 Subject: [PATCH 22/45] G5: export renders the next batch while the current one encodes (movie, WebM VP9/AV1) Cx::batches hands batches in order from a scoped renderer thread through a rendezvous channel (at most the batch being encoded + the one rendered next). Serial in passes, on wasm and on rayon workers. Output bytes unchanged. Rebased for upstream: keeps upstream's audio start-tick fix and the budget-sized batch_size(job, comp); tests/pipelined.rs is its own test binary. (cherry picked from commit 6159698ef31edfa77c62ccbae39599418b907758) Co-Authored-By: Claude (cherry picked from commit fa3f7993be048105866a8bfb8710875eeffffeb0) (cherry picked from commit bca439c350ba353aa9813dcdd7228bdd3b589319) --- crates/export/src/encode.rs | 207 ++++++++++++++++--------------- crates/export/src/pipeline.rs | 175 ++++++++++++++++++++++++++ crates/export/src/webm.rs | 66 +++++----- crates/export/src/webm_av1.rs | 36 +++--- crates/export/tests/pipelined.rs | 160 ++++++++++++++++++++++++ 5 files changed, 491 insertions(+), 153 deletions(-) create mode 100644 crates/export/tests/pipelined.rs diff --git a/crates/export/src/encode.rs b/crates/export/src/encode.rs index a65c65dde..28e2ac87c 100644 --- a/crates/export/src/encode.rs +++ b/crates/export/src/encode.rs @@ -10,6 +10,7 @@ use effectcraft_time::{FrameRate, Tick}; use filmcraft_isobmff::{Brand, FourCc, Mp4Writer, PcmConfig, SampleEntry, TrackConfig, WriteSample, WriterOptions}; use rayon::prelude::*; +use crate::pipeline::Batches; use crate::{Cx, ExportError, Report, Result, State, batch_size, wants_audio}; fn enc(e: impl std::fmt::Display) -> ExportError { @@ -266,117 +267,117 @@ pub(crate) async fn movie(job: &Cx<'_>, comp: &Comp, w: u32, h: u32, st: &mut St let batch = batch_size(job, comp); let render = |_: u64, img: effectcraft_raster::Image| -> Vec { job.pixels(&img, comp, channels, w, h) }; - // Encode the first batch before creating tracks: encoders finalise their config on frame 1. - let first_end = batch.min(st.total); - let first: Vec> = job.frames(comp, (0..first_end).collect(), render).await; - let mut pending = Vec::new(); - for (k, px) in first.iter().enumerate() { - pending.extend(venc.encode(px, k as u64)?); - } - drop(first); - st.advance(first_end)?; - - let path = job.place(job.path, 1); - let mut file = job.create(&path)?; - let opts = WriterOptions::new(brand); - let movie_ts = opts.movie_timescale.max(1) as i128; - let mut mux: Writer = Mp4Writer::new(&mut file, opts).map_err(mux_err)?; - let mut vcfg = TrackConfig::new(venc.sample_entry(), rate.num as u32); - if let Some(start) = venc.media_start() { - // An explicit edit: the convenience `media_start` edit would subtract the B-frame delay - // from the media duration and drop the last frame. - let frames_ts = st.total as i128 * rate.den as i128; - let seg = (frames_ts * movie_ts + rate.num as i128 / 2) / rate.num as i128; - vcfg.edits = vec![filmcraft_isobmff::Edit { segment_duration: seg as u64, media_time: start, media_rate: 0x10000 }]; - } - let vt = mux.add_track(vcfg).map_err(mux_err)?; - let sr = job.output.audio_sample_rate.clamp(8_000, 192_000); - let with_audio = wants_audio(job); - let chans = if job.output.audio_channels == 1 { 1u32 } else { 2 }; - let afmt = job.output.audio_format; - let mut audio = None; - if with_audio { - let (a, entry, start) = if brand == Brand::Mp4 { - let e = filmcraft_aac::Encoder::new(filmcraft_aac::EncoderConfig::cbr(sr, chans as usize, 160_000 * chans)).map_err(enc)?; - let entry = SampleEntry::aac(e.audio_specific_config(), chans, sr); - let priming = e.priming_samples() as i64; - (Audio::Aac(Box::new(e)), entry, Some(priming)) - } else { - let (bits, float) = match afmt { - AudioFormat::S16 => (16, false), - AudioFormat::S24 => (24, false), - AudioFormat::F32 => (32, true), - }; - let pcm = PcmConfig { bits, float, big_endian: false, signed: true, channels: chans, sample_rate: sr as f64 }; - (Audio::Pcm, SampleEntry::pcm(pcm), None) - }; - let mut c = TrackConfig::new(entry, sr); - c.media_start = start; - let at = mux.add_track(c).map_err(mux_err)?; - audio = Some((a, at)); - } - let (span_start, span_end) = job.settings.span(comp); - let mut cursor = span_start.to_units_floor(sr as i64); + // The next batch renders while this one encodes and muxes. + let total = st.total; + job.batches(comp, total, batch, render, async |batches: &mut Batches<'_, '_, Vec>| -> Result { + // Encode the first batch before creating tracks: encoders finalise their config on frame 1. + let first_end = batch.min(total); + let first: Vec> = batches.next().await?.map(|(_, _, f)| f).unwrap_or_default(); + let mut pending = Vec::new(); + for (k, px) in first.iter().enumerate() { + pending.extend(venc.encode(px, k as u64)?); + } + drop(first); + st.advance(first_end)?; - let write_video = |mux: &mut Writer, ps: Vec| -> Result<()> { - for p in ps { - mux.write_sample(vt, WriteSample { data: &p.data, duration: rate.den as u32, composition_offset: p.cto, is_sync: p.key }).map_err(mux_err)?; + let path = job.place(job.path, 1); + let mut file = job.create(&path)?; + let opts = WriterOptions::new(brand); + let movie_ts = opts.movie_timescale.max(1) as i128; + let mut mux: Writer = Mp4Writer::new(&mut file, opts).map_err(mux_err)?; + let mut vcfg = TrackConfig::new(venc.sample_entry(), rate.num as u32); + if let Some(start) = venc.media_start() { + // An explicit edit: the convenience `media_start` edit would subtract the B-frame delay + // from the media duration and drop the last frame. + let frames_ts = st.total as i128 * rate.den as i128; + let seg = (frames_ts * movie_ts + rate.num as i128 / 2) / rate.num as i128; + vcfg.edits = vec![filmcraft_isobmff::Edit { segment_duration: seg as u64, media_time: start, media_rate: 0x10000 }]; } - Ok(()) - }; - let mut write_audio_until = |mux: &mut Writer, until: Tick, cursor: &mut i64| -> Result<()> { - let Some((a, at)) = audio.as_mut() else { return Ok(()) }; - let end = until.to_units_floor(sr as i64); - if end <= *cursor { - return Ok(()); + let vt = mux.add_track(vcfg).map_err(mux_err)?; + let sr = job.output.audio_sample_rate.clamp(8_000, 192_000); + let with_audio = wants_audio(job); + let chans = if job.output.audio_channels == 1 { 1u32 } else { 2 }; + let afmt = job.output.audio_format; + let mut audio = None; + if with_audio { + let (a, entry, start) = if brand == Brand::Mp4 { + let e = filmcraft_aac::Encoder::new(filmcraft_aac::EncoderConfig::cbr(sr, chans as usize, 160_000 * chans)).map_err(enc)?; + let entry = SampleEntry::aac(e.audio_specific_config(), chans, sr); + let priming = e.priming_samples() as i64; + (Audio::Aac(Box::new(e)), entry, Some(priming)) + } else { + let (bits, float) = match afmt { + AudioFormat::S16 => (16, false), + AudioFormat::S24 => (24, false), + AudioFormat::F32 => (32, true), + }; + let pcm = PcmConfig { bits, float, big_endian: false, signed: true, channels: chans, sample_rate: sr as f64 }; + (Audio::Pcm, SampleEntry::pcm(pcm), None) + }; + let mut c = TrackConfig::new(entry, sr); + c.media_start = start; + let at = mux.add_track(c).map_err(mux_err)?; + audio = Some((a, at)); } - let n = (end - *cursor) as usize; - let start = Tick::from_units(*cursor, i64::from(sr)); - let start = if start.to_units_floor(i64::from(sr)) < *cursor { Tick(start.0.saturating_add(1)) } else { start }; - let buf = mix(job, start, n, sr); - *cursor = end; - match a { - Audio::Aac(e) => { - let planar = deinterleave(&buf, chans as usize); - let refs: Vec<&[f32]> = planar.iter().map(Vec::as_slice).collect(); - for au in e.encode(&refs) { - mux.write_sample(*at, WriteSample { data: &au, duration: 1024, composition_offset: 0, is_sync: true }).map_err(mux_err)?; + let (span_start, span_end) = job.settings.span(comp); + let mut cursor = span_start.to_units_floor(sr as i64); + + let write_video = |mux: &mut Writer, ps: Vec| -> Result<()> { + for p in ps { + mux.write_sample(vt, WriteSample { data: &p.data, duration: rate.den as u32, composition_offset: p.cto, is_sync: p.key }).map_err(mux_err)?; + } + Ok(()) + }; + let mut write_audio_until = |mux: &mut Writer, until: Tick, cursor: &mut i64| -> Result<()> { + let Some((a, at)) = audio.as_mut() else { return Ok(()) }; + let end = until.to_units_floor(sr as i64); + if end <= *cursor { + return Ok(()); + } + let n = (end - *cursor) as usize; + let start = Tick::from_units(*cursor, i64::from(sr)); + let start = if start.to_units_floor(i64::from(sr)) < *cursor { Tick(start.0.saturating_add(1)) } else { start }; + let buf = mix(job, start, n, sr); + *cursor = end; + match a { + Audio::Aac(e) => { + let planar = deinterleave(&buf, chans as usize); + let refs: Vec<&[f32]> = planar.iter().map(Vec::as_slice).collect(); + for au in e.encode(&refs) { + mux.write_sample(*at, WriteSample { data: &au, duration: 1024, composition_offset: 0, is_sync: true }).map_err(mux_err)?; + } + } + Audio::Pcm => { + let pcm = pcm_bytes(&buf, afmt, false); + mux.write_sample(*at, WriteSample { data: &pcm, duration: n as u32, composition_offset: 0, is_sync: true }).map_err(mux_err)?; } } - Audio::Pcm => { - let pcm = pcm_bytes(&buf, afmt, false); - mux.write_sample(*at, WriteSample { data: &pcm, duration: n as u32, composition_offset: 0, is_sync: true }).map_err(mux_err)?; + Ok(()) + }; + let frame_end_time = |k: u64| -> Tick { if k >= total { span_end } else { job.settings.frame_time(comp, k) } }; + write_video(&mut mux, pending)?; + write_audio_until(&mut mux, frame_end_time(first_end), &mut cursor)?; + while let Some((i, end, frames)) = batches.next().await? { + for (k, px) in frames.iter().enumerate() { + let ps = venc.encode(px, i + k as u64)?; + write_video(&mut mux, ps)?; } + write_audio_until(&mut mux, frame_end_time(end), &mut cursor)?; + st.advance(end - i)?; } - Ok(()) - }; - let total = st.total; - let frame_end_time = |k: u64| -> Tick { if k >= total { span_end } else { job.settings.frame_time(comp, k) } }; - write_video(&mut mux, pending)?; - write_audio_until(&mut mux, frame_end_time(first_end), &mut cursor)?; - let mut i = first_end; - while i < total { - let end = (i + batch).min(total); - let frames: Vec> = job.frames(comp, (i..end).collect(), render).await; - for (k, px) in frames.iter().enumerate() { - let ps = venc.encode(px, i + k as u64)?; - write_video(&mut mux, ps)?; - } - write_audio_until(&mut mux, frame_end_time(end), &mut cursor)?; - st.advance(end - i)?; - i = end; - } - write_video(&mut mux, venc.flush()?)?; - write_audio_until(&mut mux, span_end, &mut cursor)?; - if let Some((Audio::Aac(e), at)) = audio.as_mut() { - for au in e.flush() { - mux.write_sample(*at, WriteSample { data: &au, duration: 1024, composition_offset: 0, is_sync: true }).map_err(mux_err)?; + write_video(&mut mux, venc.flush()?)?; + write_audio_until(&mut mux, span_end, &mut cursor)?; + if let Some((Audio::Aac(e), at)) = audio.as_mut() { + for au in e.flush() { + mux.write_sample(*at, WriteSample { data: &au, duration: 1024, composition_offset: 0, is_sync: true }).map_err(mux_err)?; + } } - } - let out = mux.finish().map_err(mux_err)?; - out.flush().map_err(mux_err)?; - let bytes = file.finish()?; - Ok(Report { path, frames: 0, width: w, height: h, seconds: 0.0, bytes, audio: with_audio, log: None, overflow: vec![] }) + let out = mux.finish().map_err(mux_err)?; + out.flush().map_err(mux_err)?; + let bytes = file.finish()?; + Ok(Report { path, frames: 0, width: w, height: h, seconds: 0.0, bytes, audio: with_audio, log: None, overflow: vec![] }) + }) + .await } #[cfg(test)] diff --git a/crates/export/src/pipeline.rs b/crates/export/src/pipeline.rs index 32295209a..457d72802 100644 --- a/crates/export/src/pipeline.rs +++ b/crates/export/src/pipeline.rs @@ -136,6 +136,59 @@ impl<'a> Cx<'a> { ks.into_par_iter().map(|k| f(k, self.frame(comp, k))).collect() } + /// Output frames `0..total` in batches of `batch` frames, each frame mapped by `f` + /// (encoding), handed to `body` in order through [`Batches::next`]. On native targets the + /// next batch renders on a scoped thread while `body` encodes the current one: at most two + /// batches are held, the one being encoded and the one rendered next (handed over through a + /// rendezvous channel). In passes (deferred readbacks), on the web and on a rayon worker (it + /// must not block on frames its own pool renders) batches render one after another. + /// + /// When `body` returns (done, an encode error or a stop) the renderer stops at its next + /// frame; a renderer that stops early (a panic) makes [`Batches::next`] an error. + pub async fn batches( + &self, + comp: &Comp, + total: u64, + batch: u64, + f: impl Fn(u64, Image) -> T + Sync + Send, + body: impl AsyncFnOnce(&mut Batches<'_, '_, T>) -> crate::Result, + ) -> crate::Result { + let batch = batch.max(1); + if self.deferred() || cfg!(target_arch = "wasm32") || rayon::current_thread_index().is_some() { + let f: &(dyn Fn(u64, Image) -> T + Sync + Send) = &f; + return body(&mut Batches { next: 0, total, batch, src: Source::Serial { cx: self, comp, f } }).await; + } + use rayon::prelude::*; + use std::sync::atomic::{AtomicBool, Ordering}; + let stop = AtomicBool::new(false); + std::thread::scope(|s| { + let (tx, rx) = std::sync::mpsc::sync_channel::>(0); + let (stop, f) = (&stop, &f); + let renderer = s.spawn(move || { + let mut i = 0; + while i < total && !stop.load(Ordering::Relaxed) { + let end = i.saturating_add(batch).min(total); + // Frames not started once the encode side has stopped are skipped. + let frames: Option> = (i..end).into_par_iter().map(|k| (!stop.load(Ordering::Relaxed)).then(|| f(k, self.frame(comp, k)))).collect(); + let Some(frames) = frames else { break }; + if tx.send(frames).is_err() { + break; + } + i = end; + } + }); + let mut batches = Batches { next: 0, total, batch, src: Source::Thread(rx) }; + let r = effectcraft_render::passes::block_on(body(&mut batches)); + // Stop the renderer: skip its remaining frames and fail its next hand-off. + stop.store(true, Ordering::Relaxed); + drop(batches); + match (r, renderer.join()) { + (Err(_), Err(_)) => Err(crate::ExportError::Encode("rendering failed".into())), + (r, _) => r, + } + }) + } + /// Output frame `i`: sampled (fields, pulldown), at the colour depth, cropped and resized. pub fn frame(&self, comp: &Comp, i: u64) -> Image { effectcraft_render::passes::block_on(self.frame_async(comp, i)) @@ -296,6 +349,37 @@ impl<'a> Cx<'a> { } } +/// The batches of [`Cx::batches`], in order. +pub(crate) struct Batches<'c, 'a, T> { + next: u64, + total: u64, + batch: u64, + src: Source<'c, 'a, T>, +} + +enum Source<'c, 'a, T> { + /// Rendered when asked for ([`Cx::frames`]). + Serial { cx: &'c Cx<'a>, comp: &'c Comp, f: &'c (dyn Fn(u64, Image) -> T + Sync + Send) }, + /// Rendered ahead on the renderer thread. + Thread(std::sync::mpsc::Receiver>), +} + +impl Batches<'_, '_, T> { + /// The next batch: its output frames `start..end` and their mapped frames; `None` after the last. + pub async fn next(&mut self) -> crate::Result)>> { + if self.next >= self.total { + return Ok(None); + } + let (i, end) = (self.next, self.next.saturating_add(self.batch).min(self.total)); + let frames = match &self.src { + Source::Serial { cx, comp, f } => cx.frames(comp, (i..end).collect(), *f).await, + Source::Thread(rx) => rx.recv().map_err(|_| crate::ExportError::Encode("rendering stopped".into()))?, + }; + self.next = end; + Ok(Some((i, end, frames))) + } +} + /// Weave two fields: the dominant field's lines (even lines when `upper_first`) from `first`. pub(crate) fn interleave(first: &Image, second: &Image, upper_first: bool) -> Image { let mut out = first.clone(); @@ -418,6 +502,97 @@ mod tests { assert!(stats.hits >= 9, "{stats:?}"); } + /// Run `f` with the job of a `frames`-frame comp of one solid. + fn with_cx(frames: u64, f: impl FnOnce(&Cx, &Comp) -> R) -> R { + use effectcraft_color::Label; + use effectcraft_project::render_queue::{OutputFormat, OutputModule, RenderSettings}; + use effectcraft_project::{ItemKind, LayerSource, Solid, build}; + use effectcraft_render::NoFootage; + use effectcraft_time::FrameRate; + let mut p = Project::default(); + let mut comp = Comp::new(16, 16, FrameRate::FPS_25, FrameRate::FPS_25.tick_of(frames as i64)); + let sid = p.add_item("Solid", Label::Red, None, ItemKind::Solid(Solid { color: [1.0, 0.0, 0.0], width: 8, height: 8, pixel_aspect: 1.0 })); + comp.layers.push(build::layer(&mut p, &comp, "Solid", LayerSource::Solid { item: sid }, (8, 8), None)); + let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.into())); + let (settings, output) = (RenderSettings::default(), OutputModule::for_format(OutputFormat::H264)); + let job = Job { + project: &p, + footage: &NoFootage, + expr: None, + accel: None, + comp: cid, + settings: &settings, + output: &output, + path: "/c/x.mp4", + sink: None, + nested_switches: true, + options: Default::default(), + }; + let cx = Cx::new(&job); + let comp = cx.comp().unwrap(); + f(&cx, comp) + } + + #[test] + fn an_encode_error_stops_the_renderer() { + // G5 B1: the next batch renders while one encodes. An encode error returns at once; + // the renderer stops after at most the batch it had in flight (no hang on the hand-off). + use std::sync::atomic::{AtomicU64, Ordering}; + let batch = 4; + let rendered = AtomicU64::new(0); + let r = with_cx(batch * 10, |cx, comp| { + let render = |k: u64, _| { + rendered.fetch_add(1, Ordering::Relaxed); + k + }; + effectcraft_render::passes::block_on(cx.batches(comp, batch * 10, batch, render, async |b: &mut Batches<'_, '_, u64>| -> crate::Result<()> { + let (i, end, ks) = b.next().await?.unwrap(); + assert_eq!((i, end, ks), (0, batch, vec![0, 1, 2, 3])); + Err(crate::ExportError::Encode("boom".into())) + })) + }); + assert!(matches!(r, Err(crate::ExportError::Encode(ref e)) if e == "boom"), "{r:?}"); + let n = rendered.load(Ordering::Relaxed); + assert!(n <= batch * 2, "rendered {n} frames after the encoder failed"); + } + + #[test] + fn batches_arrive_in_order_and_a_failed_render_is_an_error() { + let batch = 3; + let got = with_cx(10, |cx, comp| { + effectcraft_render::passes::block_on(cx.batches( + comp, + 10, + batch, + |k, _| k, + async |b: &mut Batches<'_, '_, u64>| -> crate::Result)>> { + let mut v = vec![]; + while let Some(x) = b.next().await? { + v.push(x); + } + Ok(v) + }, + )) + }); + assert_eq!(got.unwrap(), vec![(0, 3, vec![0, 1, 2]), (3, 6, vec![3, 4, 5]), (6, 9, vec![6, 7, 8]), (9, 10, vec![9])]); + // A renderer that dies (a panic in the second batch) fails the export instead of + // ending it early with a truncated file, and does not hang. + let r = with_cx(10, |cx, comp| { + let render = |k: u64, _| { + assert!(k != 4, "render failure"); + k + }; + effectcraft_render::passes::block_on(cx.batches(comp, 10, batch, render, async |b: &mut Batches<'_, '_, u64>| -> crate::Result { + let mut n = 0; + while let Some((_, _, ks)) = b.next().await? { + n += ks.len() as u64; + } + Ok(n) + })) + }); + assert!(matches!(r, Err(crate::ExportError::Encode(_))), "{r:?}"); + } + #[test] fn interleave_takes_alternate_lines() { let a = Image::filled(3, 4, [1.0, 0.0, 0.0, 1.0]); diff --git a/crates/export/src/webm.rs b/crates/export/src/webm.rs index 983c4c8b8..00b99a602 100644 --- a/crates/export/src/webm.rs +++ b/crates/export/src/webm.rs @@ -19,6 +19,7 @@ use effectcraft_project::render_queue::{AudioFormat, Channels}; use effectcraft_time::{TICKS_PER_SECOND, Tick}; use crate::encode::{mix, pcm_bytes}; +use crate::pipeline::Batches; use crate::{Cx, Report, Result, State, batch_size, io, wants_audio}; pub(crate) const OPUS_RATE: u32 = 48_000; @@ -252,40 +253,39 @@ pub(crate) async fn webm(job: &Cx<'_>, comp: &Comp, w: u32, h: u32, st: &mut Sta } }; - let batch = batch_size(job, comp); - let mut i = 0; - while i < total { - let end = (i + batch).min(total); - // Frames render in parallel; the VP9 streams (inter-coded) encode in order, colour and - // alpha side by side. - let pixels: Vec> = - job.frames(comp, (i..end).collect(), |_, img| job.pixels(&img, comp, if alpha { Channels::Rgba } else { colour_channels }, w, h)).await; - let mut frames: Vec = Vec::with_capacity(pixels.len()); - for (j, px) in pixels.iter().enumerate() { - let (color, a) = rayon::join( - || { - let (y, u, vv) = effectcraft_vp9enc::rgba_to_yuv420(px, w, h, false); - color_enc.encode_frame(&y, &u, &vv, false) - }, - || { - alpha_enc.as_mut().map(|ea| { - let (y, u, vv) = effectcraft_vp9enc::alpha_to_yuv420(px, w, h); - ea.encode_frame(&y, &u, &vv, false).data - }) - }, - ); - let k = i + j as u64; - frames.push(Frame { ms: frame_ms(k), track: 1, key: color.key, prev_ms: (k > 0).then(|| frame_ms(k - 1)), data: color.data, alpha: a }); + // Frames render in parallel, the next batch while this one encodes; the VP9 streams + // (inter-coded) encode in order, colour and alpha side by side. + let render = |_: u64, img: effectcraft_raster::Image| job.pixels(&img, comp, if alpha { Channels::Rgba } else { colour_channels }, w, h); + job.batches(comp, total, batch_size(job, comp), render, async |batches: &mut Batches<'_, '_, Vec>| -> Result<()> { + while let Some((i, end, pixels)) = batches.next().await? { + let mut frames: Vec = Vec::with_capacity(pixels.len()); + for (j, px) in pixels.iter().enumerate() { + let (color, a) = rayon::join( + || { + let (y, u, vv) = effectcraft_vp9enc::rgba_to_yuv420(px, w, h, false); + color_enc.encode_frame(&y, &u, &vv, false) + }, + || { + alpha_enc.as_mut().map(|ea| { + let (y, u, vv) = effectcraft_vp9enc::alpha_to_yuv420(px, w, h); + ea.encode_frame(&y, &u, &vv, false).data + }) + }, + ); + let k = i + j as u64; + frames.push(Frame { ms: frame_ms(k), track: 1, key: color.key, prev_ms: (k > 0).then(|| frame_ms(k - 1)), data: color.data, alpha: a }); + } + drop(pixels); + let t_end = if end >= total { span_end } else { job.settings.frame_time(comp, end) }; + audio_until(t_end, &mut frames, end >= total); + let c = cluster(&mut frames); + file.write_all(&c).map_err(io)?; + seg_len += c.len() as u64; + st.advance(end - i)?; } - drop(pixels); - let t_end = if end >= total { span_end } else { job.settings.frame_time(comp, end) }; - audio_until(t_end, &mut frames, end >= total); - let c = cluster(&mut frames); - file.write_all(&c).map_err(io)?; - seg_len += c.len() as u64; - st.advance(end - i)?; - i = end; - } + Ok(()) + }) + .await?; if total == 0 { let mut frames = vec![]; audio_until(span_end, &mut frames, true); diff --git a/crates/export/src/webm_av1.rs b/crates/export/src/webm_av1.rs index a32b2589f..432140923 100644 --- a/crates/export/src/webm_av1.rs +++ b/crates/export/src/webm_av1.rs @@ -12,6 +12,7 @@ use effectcraft_time::{TICKS_PER_SECOND, Tick}; use crate::encode::VideoEncoder; use crate::encode::mix; +use crate::pipeline::Batches; use crate::webm::{CLUSTER, EBML, INFO, OPUS_RATE, SEGMENT, TRACK_ENTRY, TRACKS, el, el_float, el_str, el_uint, id_bytes, opus_encoder, vint}; use crate::{Cx, Report, Result, State, batch_size, io, wants_audio}; @@ -132,8 +133,6 @@ pub(crate) async fn webm_av1(job: &Cx<'_>, comp: &Comp, w: u32, h: u32, st: &mut } }; - let batch = batch_size(job, comp); - let mut i = 0; let flush_blocks = |blocks: &mut Vec, file: &mut crate::out::Out, seg_len: &mut u64| -> Result<()> { if !blocks.is_empty() { let c = cluster(blocks); @@ -144,23 +143,26 @@ pub(crate) async fn webm_av1(job: &Cx<'_>, comp: &Comp, w: u32, h: u32, st: &mut Ok(()) }; let mut blocks = vec![]; - while i < total { - let end = (i + batch).min(total); - // RGB, or the alpha matte alone (Channels: Alpha); AV1 in WebM carries no alpha track. - let channels = if job.output.channels == Channels::Alpha { Channels::Alpha } else { Channels::Rgb }; - let frames: Vec> = job.frames(comp, (i..end).collect(), |_, img| job.pixels(&img, comp, channels, w, h)).await; - for (j, px) in frames.iter().enumerate() { - let k = i + j as u64; - for p in venc.encode(px, k)? { - blocks.push(Block { ms: frame_ms(k), track: 1, key: p.key, data: p.data }); + // RGB, or the alpha matte alone (Channels: Alpha); AV1 in WebM carries no alpha track. + let channels = if job.output.channels == Channels::Alpha { Channels::Alpha } else { Channels::Rgb }; + // The next batch renders while this one encodes. + let render = |_: u64, img: effectcraft_raster::Image| job.pixels(&img, comp, channels, w, h); + job.batches(comp, total, batch_size(job, comp), render, async |batches: &mut Batches<'_, '_, Vec>| -> Result<()> { + while let Some((i, end, frames)) = batches.next().await? { + for (j, px) in frames.iter().enumerate() { + let k = i + j as u64; + for p in venc.encode(px, k)? { + blocks.push(Block { ms: frame_ms(k), track: 1, key: p.key, data: p.data }); + } } + let t_end = if end >= total { span_end } else { job.settings.frame_time(comp, end) }; + audio_until(t_end, &mut blocks, end >= total); + flush_blocks(&mut blocks, &mut file, &mut seg_len)?; + st.advance(end - i)?; } - let t_end = if end >= total { span_end } else { job.settings.frame_time(comp, end) }; - audio_until(t_end, &mut blocks, end >= total); - flush_blocks(&mut blocks, &mut file, &mut seg_len)?; - st.advance(end - i)?; - i = end; - } + Ok(()) + }) + .await?; if total == 0 { audio_until(span_end, &mut blocks, true); flush_blocks(&mut blocks, &mut file, &mut seg_len)?; diff --git a/crates/export/tests/pipelined.rs b/crates/export/tests/pipelined.rs new file mode 100644 index 000000000..1bf962498 --- /dev/null +++ b/crates/export/tests/pipelined.rs @@ -0,0 +1,160 @@ +//! Movie and WebM exports render the next batch of frames while the current one encodes (G5 +//! B1). The output must be the bytes of the serial path (render a batch, encode it, render the +//! next), and an error or a stop on the encode side must end the export without hanging. +//! +//! The serial path: an export started on a rayon worker renders its batches one after another +//! (a worker must not block on frames rendered by its own pool). A pool of the global pool's +//! size gives the same batch size, so both runs cut the same clusters and audio blocks. + +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use effectcraft_color::Label; +use effectcraft_export::render_queue::{AudioOutput, Channels, OutputFormat, OutputModule, RenderSettings, TimeSpan, WebmVideoCodec}; +use effectcraft_export::{ExportError, Job, Progress, Result, export}; +use effectcraft_keyframe::{Keyframe, Value}; +use effectcraft_media::MediaPool; +use effectcraft_project::{Comp, ItemId, ItemKind, LayerSource, Project, Solid, build}; +use effectcraft_time::{FrameRate, Tick}; + +/// Frames per batch of the export path (`batch_size`). +fn batch() -> u64 { + rayon::current_num_threads().clamp(2, 16) as u64 +} + +fn tone(path: &std::path::Path, secs: f64) { + let sr = 48_000u32; + let n = (secs * sr as f64) as usize; + let mut data = Vec::with_capacity(n * 4); + for i in 0..n { + let s = (0.4 * (2.0 * std::f64::consts::PI * 330.0 * i as f64 / sr as f64).sin() * 32767.0).round() as i16; + data.extend_from_slice(&s.to_le_bytes()); + data.extend_from_slice(&(s / 2).to_le_bytes()); + } + let mut w = Vec::new(); + w.extend_from_slice(b"RIFF"); + w.extend_from_slice(&(36 + data.len() as u32).to_le_bytes()); + w.extend_from_slice(b"WAVEfmt "); + w.extend_from_slice(&16u32.to_le_bytes()); + w.extend_from_slice(&1u16.to_le_bytes()); + w.extend_from_slice(&2u16.to_le_bytes()); + w.extend_from_slice(&sr.to_le_bytes()); + w.extend_from_slice(&(sr * 4).to_le_bytes()); + w.extend_from_slice(&4u16.to_le_bytes()); + w.extend_from_slice(&16u16.to_le_bytes()); + w.extend_from_slice(b"data"); + w.extend_from_slice(&(data.len() as u32).to_le_bytes()); + w.extend_from_slice(&data); + std::fs::write(path, w).expect("write wav"); +} + +/// 64×48 @ 10 fps over three and a bit batches: a half-transparent solid moving across, and a +/// tone (audio blocks are cut at batch ends). +fn project(name: &str) -> (Project, ItemId, u64) { + let frames = batch() * 3 + 1; + let d = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/test-out/export/pipelined").join(name); + std::fs::create_dir_all(&d).expect("mkdir"); + let wav = d.join("tone.wav"); + tone(&wav, frames as f64 / 10.0 + 1.0); + let mut p = Project::default(); + let mut comp = Comp::new(64, 48, FrameRate::new(10, 1), Tick::from_seconds_f64(frames as f64 / 10.0)); + comp.background = [0.1, 0.2, 0.4]; + let sid = p.add_item("Solid", Label::Red, None, ItemKind::Solid(Solid { color: [1.0, 0.5, 0.0], width: 24, height: 24, pixel_aspect: 1.0 })); + let mut l = build::layer(&mut p, &comp, "Solid", LayerSource::Solid { item: sid }, (24, 24), None); + let end = Tick::from_seconds_f64(frames as f64 / 10.0); + l.props.prop_mut("transform/position").expect("position").keys = + vec![Keyframe::new(Tick::ZERO, Value::Vec3([0.0, 10.0, 0.0])), Keyframe::new(end, Value::Vec3([64.0, 40.0, 0.0]))]; + l.props.prop_mut("transform/opacity").expect("opacity").keys = + vec![Keyframe::new(Tick::ZERO, Value::Scalar(30.0)), Keyframe::new(end, Value::Scalar(100.0))]; + comp.layers.push(l); + let f = effectcraft_media::probe(&wav).expect("probe wav"); + let fid = p.add_item("tone.wav", Label::SeaFoam, None, ItemKind::Footage(f)); + let l = build::layer(&mut p, &comp, "tone", LayerSource::Footage { item: fid }, (0, 0), None); + comp.layers.push(l); + let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.into())); + (p, cid, frames) +} + +/// Export into memory; `stop_after` makes the progress callback stop once that many frames are done. +fn run(p: &Project, cid: ItemId, om: &OutputModule, stop_after: Option) -> (Result, Vec, Vec) { + let pool = MediaPool::new(); + let settings = RenderSettings { time_span: TimeSpan::LengthOfComp, ..Default::default() }; + let bytes = Arc::new(Mutex::new(Vec::new())); + let out = bytes.clone(); + let sink = move |_: &str, data: Vec| *out.lock().unwrap() = data; + let job = Job { + project: p, + footage: &pool, + expr: None, + accel: None, + comp: cid, + settings: &settings, + output: om, + path: "/mem/out", + sink: Some(&sink), + options: Default::default(), + nested_switches: true, + }; + let mut calls = vec![]; + let r = export(&job, &mut |pr| { + calls.push(*pr); + stop_after.is_none_or(|n| pr.done < n) + }); + let data = std::mem::take(&mut *bytes.lock().unwrap()); + (r, data, calls) +} + +fn modules() -> Vec<(&'static str, OutputModule)> { + let with_audio = |f| OutputModule { audio: AudioOutput::On, ..OutputModule::for_format(f) }; + vec![ + ("h264", with_audio(OutputFormat::H264)), + ("prores", with_audio(OutputFormat::ProRes)), + ("webm-vp9-alpha", OutputModule { channels: Channels::Rgba, ..with_audio(OutputFormat::WebM) }), + ("webm-av1", OutputModule { webm_codec: WebmVideoCodec::Av1, ..with_audio(OutputFormat::WebM) }), + ] +} + +#[test] +fn pipelined_exports_match_the_serial_path() { + let (p, cid, frames) = project("same"); + let serial_pool = rayon::ThreadPoolBuilder::new().num_threads(rayon::current_num_threads()).build().unwrap(); + for (name, om) in modules() { + let (r, piped, calls) = run(&p, cid, &om, None); + let r = r.unwrap_or_else(|e| panic!("{name}: {e}")); + assert_eq!(r.frames, frames, "{name}"); + assert!(r.audio, "{name}"); + // Progress after every batch, in order, as before. + let done: Vec = calls.iter().map(|c| c.done).collect(); + let want: Vec = std::iter::once(0).chain((1..=frames.div_ceil(batch())).map(|b| (b * batch()).min(frames))).collect(); + assert_eq!(done, want, "{name}: progress"); + let (r2, serial, _) = serial_pool.install(|| run(&p, cid, &om, None)); + r2.unwrap_or_else(|e| panic!("{name} serial: {e}")); + let _ = std::fs::write(std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/test-out/export/pipelined/same").join(name), &piped); + assert!(piped.len() > 1000, "{name}: {} bytes", piped.len()); + assert!(piped == serial, "{name}: pipelined output differs from the serial path ({} vs {} bytes)", piped.len(), serial.len()); + } +} + +/// Run `f` on its own thread; fail instead of hanging. +fn within(what: &str, f: impl FnOnce() -> T + Send + 'static) -> T { + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let _ = tx.send(f()); + }); + rx.recv_timeout(Duration::from_secs(300)).unwrap_or_else(|_| panic!("{what}: export hung")) +} + +#[test] +fn a_stop_on_the_encode_side_ends_the_export() { + // The callback stops after the first batch: the encode side returns while the renderer has + // the next batch ready (blocked on the hand-off) or in flight. + for (name, om) in modules() { + let calls = within(name, move || { + let (p, cid, _) = project("stop"); + let (r, _, calls) = run(&p, cid, &om, Some(1)); + assert!(matches!(r, Err(ExportError::Cancelled)), "{name}: {r:?}"); + calls + }); + assert_eq!(calls.last().map(|c| c.done), Some(batch()), "{name}: stopped after the first batch"); + } +} From f40f95dabfded1e3a2914900b6a93204d2ff1ea7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 17:38:07 +0000 Subject: [PATCH 23/45] G5: later output modules of a render queue item continue from the previous module's layer cache A Render Queue item with N output modules exported each module as a fresh job: a project clone and an empty layer cache per module, so static and transform-only layers rendered N times. A finished export now keeps its overridden project and layer cache (one slot, dropped after 3 s or when the next job takes it); the next export takes it only when it renders the same frames: equal project, same comp, Render Settings equal but for the time span, same Switches Affect Nested Comps, memory budget, footage source, expression host and accelerator. Layer buffers are content-keyed, so files are byte-identical to each module exported alone (new output_modules test: H.264, PNG sequence, ProRes). Failed or cancelled exports keep nothing; the web keeps nothing (no timer to drop it). Progress, cancellation and error semantics of the queue are unchanged and now documented at the module loop. tests/output_modules.rs is its own test binary upstream (no tests/export/main.rs). (cherry picked from commit f3f683c7adcc1fd39f4fb79a86e71005263edd8a) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ldtkj4yRfYi6j6CwRrde11 (cherry picked from commit 508f9b983694724031519126685a4f8e0c83a12a) (cherry picked from commit 4733a7cb5af151b88cf6b87d7c64b6e898dc7cbf) --- crates/engine/src/render_queue.rs | 6 +- crates/export/src/lib.rs | 9 +- crates/export/src/pipeline.rs | 179 +++++++++++++++++++++++++- crates/export/tests/output_modules.rs | 89 +++++++++++++ 4 files changed, 278 insertions(+), 5 deletions(-) create mode 100644 crates/export/tests/output_modules.rs diff --git a/crates/engine/src/render_queue.rs b/crates/engine/src/render_queue.rs index 61c70af7e..46ed01689 100644 --- a/crates/engine/src/render_queue.rs +++ b/crates/engine/src/render_queue.rs @@ -267,7 +267,11 @@ async fn run_work(w: Work, shared: &JobShared, notify: &mut dyn FnMut(&JobShared } notify(shared); // Every output module encodes the same frames (Composition ▸ Add Output Module); the - // first module's file is the item's output. + // first module's file is the item's output. Modules export one after another with the + // same project and session objects, so the exporter can continue each from the previous + // module's render (`effectcraft-export` keeps its layer cache). Each module reports its + // own progress (0..total again), a stop cancels the module being written (earlier + // modules' files stay), and the first failing module fails the item and skips the rest. let mut r: Result = Err("no output module".into()); for (mi, (mitem, path)) in modules.iter().enumerate() { let name = w.project.item(mitem.comp).map(|i| i.name.clone()).unwrap_or_default(); diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs index 0bdef6e87..a368ecd2b 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -178,7 +178,9 @@ pub fn export(job: &Job, progress: &mut dyn FnMut(&Progress) -> bool) -> Result< /// batches as in [`export`]. pub async fn export_async(job: &Job<'_>, progress: &mut dyn FnMut(&Progress) -> bool) -> Result { let t0 = Instant::now(); - let cx = Cx::new(job); + // Another output module of the same Render Queue item renders the same frames: continue + // from its layer cache (see `pipeline::Warm`). + let cx = Cx::resume(job); let mut r = export_with(&cx, progress, t0).await; if matches!(r, Err(ExportError::Cancelled)) { cx.remove_cancelled_outputs()?; @@ -189,6 +191,11 @@ pub async fn export_async(job: &Job<'_>, progress: &mut dyn FnMut(&Progress) -> rep.log = log; rep.overflow = cx.overflowed.lock().unwrap_or_else(|e| e.into_inner()).clone(); } + // A finished job leaves its layer cache for the next module; a failed or cancelled one + // (the item stops there) drops it. + if r.is_ok() { + cx.keep_warm(); + } r } diff --git a/crates/export/src/pipeline.rs b/crates/export/src/pipeline.rs index 457d72802..ff7e05e0f 100644 --- a/crates/export/src/pipeline.rs +++ b/crates/export/src/pipeline.rs @@ -5,9 +5,9 @@ use std::sync::Mutex; use effectcraft_project::render_queue::{ - AlphaMode, Channels, ColorDepth, CurrentOrOff, EffectsMode, FrameSample, RenderLog, ResizeQuality, SwitchOverride, log_path, + AlphaMode, Channels, ColorDepth, CurrentOrOff, EffectsMode, FrameSample, RenderLog, RenderSettings, ResizeQuality, SwitchOverride, TimeSpan, log_path, }; -use effectcraft_project::{Comp, Project}; +use effectcraft_project::{Comp, ItemId, Project}; use effectcraft_raster::Image; use effectcraft_render::{Backend, LayerCache, RenderOpts, Renderer}; use effectcraft_time::Tick; @@ -68,12 +68,114 @@ pub(crate) fn apply_overrides(p: &mut Project, job: &Job) { } } +/// What decides a job's overridden project and layer buffers: jobs with equal keys (and equal +/// projects) render the same frames. The time span is left out (segments of one output, output +/// modules cut at different segment frames); so is everything per output module (format, crop, +/// resize, channels), which applies after rendering. +#[derive(PartialEq)] +struct WarmKey { + comp: ItemId, + settings: RenderSettings, + nested_switches: bool, + budget: usize, + /// Addresses of the job's footage source, expression host and accelerator: the same + /// session's objects. + footage: usize, + expr: Option, + accel: Option, +} + +impl WarmKey { + fn of(job: &Job) -> WarmKey { + WarmKey { + comp: job.comp, + settings: RenderSettings { time_span: TimeSpan::LengthOfComp, ..job.settings.clone() }, + nested_switches: job.nested_switches, + budget: job.options.memory_budget.unwrap_or(crate::DEFAULT_MEMORY_BUDGET), + footage: job.footage as *const dyn effectcraft_render::FootageSource as *const () as usize, + expr: job.expr.map(|e| e as *const dyn effectcraft_render::ExprHost as *const () as usize), + accel: job.accel.map(|a| a as *const dyn effectcraft_render::Accelerator as *const () as usize), + } + } +} + +/// A finished job's project (as its Render Settings render it) and layer cache, kept for the +/// next job that renders the same frames: a Render Queue item exports its output modules one +/// after another, each a job of its own, and every module after the first finds the layers the +/// first rendered in the cache instead of rendering them again. Layer buffers are keyed by +/// their content (see [`LayerCache`]), so a warm cache gives the frames a cold one gives. +struct Warm { + key: WarmKey, + /// The job's project before the overrides: the next job's must be equal. + source: Project, + project: Project, + cache: LayerCache, +} + +/// How long a finished job's [`Warm`] waits for the next job (the next output module starts +/// as soon as the previous one returns); then it is dropped with its memory. +const WARM_FOR: std::time::Duration = std::time::Duration::from_secs(3); + +/// The one kept [`Warm`], with its generation (for the timer that drops it) and when it was kept. +static WARM: Mutex> = Mutex::new(None); +static WARM_GENERATION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + +fn warm_slot() -> std::sync::MutexGuard<'static, Option<(u64, Instant, Warm)>> { + WARM.lock().unwrap_or_else(std::sync::PoisonError::into_inner) +} + +/// The kept [`Warm`] when `job` renders the same frames as the job that left it. +fn take_warm(job: &Job) -> Option { + let (_, at, warm) = warm_slot().take()?; + (at.elapsed() < WARM_FOR && warm.key == WarmKey::of(job) && warm.source == *job.project).then_some(warm) +} + impl<'a> Cx<'a> { pub fn new(job: &'a Job<'a>) -> Cx<'a> { let mut project = job.project.clone(); apply_overrides(&mut project, job); - let frame_times = (job.options.log == RenderLog::PlusPerFrameInfo).then(|| Mutex::new(Vec::new())); let cache = LayerCache::new(job.options.memory_budget.unwrap_or(crate::DEFAULT_MEMORY_BUDGET)); + Cx::with(job, project, cache) + } + + /// [`Cx::new`], continuing from the job that rendered the same frames just before, if any + /// (its project and layer cache, see [`Warm`]). + pub fn resume(job: &'a Job<'a>) -> Cx<'a> { + match take_warm(job) { + Some(w) => Cx::with(job, w.project, w.cache), + None => Cx::new(job), + } + } + + /// Keep this finished job's project and layer cache for the next job ([`Cx::resume`]) for + /// [`WARM_FOR`]. Not on the web, where nothing would drop them after that. + pub fn keep_warm(self) { + if cfg!(target_arch = "wasm32") { + return; + } + let warm = Warm { key: WarmKey::of(self.job), source: self.job.project.clone(), project: self.project, cache: self.cache }; + let generation = WARM_GENERATION.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + // (the job kept before, if no job took it, goes now) + let old = warm_slot().replace((generation, Instant::now(), warm)); + drop(old); + // Dropped after `WARM_FOR` unless a job took it (or kept its own) by then. + let expire = move || { + let mut slot = warm_slot(); + let kept = if slot.as_ref().is_some_and(|(g, _, _)| *g == generation) { slot.take() } else { None }; + drop(slot); + drop(kept); + }; + let later = move || { + std::thread::sleep(WARM_FOR); + expire(); + }; + if std::thread::Builder::new().name("export-warm".into()).spawn(later).is_err() { + expire(); + } + } + + fn with(job: &'a Job<'a>, project: Project, cache: LayerCache) -> Cx<'a> { + let frame_times = (job.options.log == RenderLog::PlusPerFrameInfo).then(|| Mutex::new(Vec::new())); // With deferred readbacks, buffers computed from a missed pass's placeholders are not kept. cache.set_gate(job.accel.and_then(|a| a.miss_gate())); Cx { job, project, cache, frame_times, overflowed: Mutex::new(vec![]), written: Mutex::new(vec![]) } @@ -502,6 +604,77 @@ mod tests { assert!(stats.hits >= 9, "{stats:?}"); } + #[test] + fn the_next_output_module_continues_from_the_layer_cache() { + // G5: a Render Queue item with two output modules rendered every layer twice. The + // second module's job takes the first one's layer cache when it renders the same frames. + use effectcraft_color::Label; + use effectcraft_project::render_queue::{OutputFormat, OutputModule, RenderSettings}; + use effectcraft_project::{ItemKind, LayerSource, Solid, build}; + use effectcraft_time::FrameRate; + let mut p = Project::default(); + let mut comp = Comp::new(32, 24, FrameRate::FPS_25, FrameRate::FPS_25.tick_of(4)); + let sid = p.add_item("Solid", Label::Red, None, ItemKind::Solid(Solid { color: [1.0, 0.0, 0.0], width: 8, height: 8, pixel_aspect: 1.0 })); + comp.layers.push(build::layer(&mut p, &comp, "Solid", LayerSource::Solid { item: sid }, (8, 8), None)); + let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.into())); + // (two pools: a job takes the kept one only with the same footage source) + let (footage, other_footage) = (effectcraft_media::MediaPool::new(), effectcraft_media::MediaPool::new()); + let settings = RenderSettings::default(); + let (png, h264) = (OutputModule::for_format(OutputFormat::PngSequence), OutputModule::for_format(OutputFormat::H264)); + let sink = |_: &str, _: Vec| {}; + fn job<'a>( + p: &'a Project, + cid: ItemId, + settings: &'a RenderSettings, + output: &'a OutputModule, + footage: &'a dyn effectcraft_render::FootageSource, + ) -> Job<'a> { + Job { + project: p, + footage, + expr: None, + accel: None, + comp: cid, + settings, + output, + path: "/mem/x", + sink: None, + nested_switches: true, + options: Default::default(), + } + } + let mut first = job(&p, cid, &settings, &png, &footage); + first.sink = Some(&sink); + // Other tests of this binary don't export, so the kept job is this one's. + let r = crate::export(&first, &mut |_| true); + assert!(r.is_ok(), "{r:?}"); + // A job rendering other frames does not take it (and the kept job is gone either way). + let half = RenderSettings { resolution: 0.5, ..RenderSettings::default() }; + let mut edited = p.clone(); + if let Some(c) = edited.comp_mut(cid) { + c.background = [0.0, 1.0, 0.0]; + } + for other in [job(&p, cid, &half, &h264, &footage), job(&edited, cid, &settings, &h264, &footage), job(&p, cid, &settings, &h264, &other_footage)] { + assert!(crate::export(&first, &mut |_| true).is_ok()); + assert!(take_warm(&other).is_none()); + } + assert!(crate::export(&first, &mut |_| true).is_ok()); + let second = job(&p, cid, &settings, &h264, &footage); + let warm = take_warm(&second).expect("the first module's job is kept"); + assert!(take_warm(&second).is_none(), "taken once"); + let cx = Cx::with(&second, warm.project, warm.cache); + let misses = cx.cache.stats().misses; + assert!(misses > 0); + let comp = cx.comp().unwrap(); + for i in 0..4 { + let img = cx.frame(comp, i); + let mut uncached = cx.renderer(); + uncached.cache = None; + assert_eq!(img.data, uncached.comp_frame(cid, settings.frame_time(comp, i)).data, "frame {i}"); + } + assert_eq!(cx.cache.stats().misses, misses, "the second module rendered no layer again"); + } + /// Run `f` with the job of a `frames`-frame comp of one solid. fn with_cx(frames: u64, f: impl FnOnce(&Cx, &Comp) -> R) -> R { use effectcraft_color::Label; diff --git a/crates/export/tests/output_modules.rs b/crates/export/tests/output_modules.rs new file mode 100644 index 000000000..2898c323b --- /dev/null +++ b/crates/export/tests/output_modules.rs @@ -0,0 +1,89 @@ +//! A Render Queue item with several output modules exports each module as a job of its own, +//! back to back. A module's job continues from the layer cache of the module before it when +//! both render the same frames (G5): the files must be the bytes of each module exported alone. + +use std::collections::BTreeMap; +use std::sync::{Arc, Mutex}; + +use effectcraft_color::Label; +use effectcraft_export::render_queue::{OutputFormat, OutputModule, RenderSettings, TimeSpan}; +use effectcraft_export::{Job, export}; +use effectcraft_keyframe::{Keyframe, Value}; +use effectcraft_media::MediaPool; +use effectcraft_project::build::{self, Ids}; +use effectcraft_project::{Comp, ItemId, ItemKind, Layer, LayerSource, Project, Solid}; +use effectcraft_time::{FrameRate, Tick}; + +fn solid(p: &mut Project, comp: &Comp, color: [f32; 3], w: u32, h: u32) -> Layer { + let sid = p.add_item("Solid", Label::Red, None, ItemKind::Solid(Solid { color, width: w, height: h, pixel_aspect: 1.0 })); + build::layer(p, comp, "Solid", LayerSource::Solid { item: sid }, (w, h), None) +} + +/// 96×64 @ 10 fps, 12 frames: a blurred static solid (its buffer is cached) under a smaller solid +/// moving across. +fn project() -> (Project, ItemId) { + let mut p = Project::default(); + let mut comp = Comp::new(96, 64, FrameRate::new(10, 1), Tick::from_seconds_f64(1.2)); + comp.background = [0.1, 0.2, 0.4]; + let mut bg = solid(&mut p, &comp, [0.9, 0.6, 0.1], 64, 40); + let spec = effectcraft_effects::find("ec.blur.gaussian").expect("gaussian blur"); + let mut next = p.next_id; + let mut fx = effectcraft_effects::instantiate(spec, &mut Ids(&mut next), spec.name, [64.0, 40.0]); + p.next_id = next; + fx.prop_mut("blurriness").expect("blurriness").value = Value::Scalar(12.0); + bg.props.sub_mut("effects").expect("effects").children.push(fx.into()); + let mut fg = solid(&mut p, &comp, [0.2, 0.9, 0.3], 16, 16); + fg.props.prop_mut("transform/position").expect("position").keys = + vec![Keyframe::new(Tick::ZERO, Value::Vec3([0.0, 20.0, 0.0])), Keyframe::new(Tick::from_seconds_f64(1.2), Value::Vec3([96.0, 44.0, 0.0]))]; + comp.layers.push(fg); + comp.layers.push(bg); + let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.into())); + (p, cid) +} + +/// Export one module into memory with `footage`: the files it wrote, by path. +fn run(p: &Project, cid: ItemId, footage: &MediaPool, om: &OutputModule, path: &str) -> BTreeMap> { + let settings = RenderSettings { time_span: TimeSpan::LengthOfComp, ..Default::default() }; + let files = Arc::new(Mutex::new(BTreeMap::new())); + let out = files.clone(); + let sink = move |path: &str, data: Vec| { + out.lock().unwrap().insert(path.to_string(), data); + }; + let job = Job { + project: p, + footage, + expr: None, + accel: None, + comp: cid, + settings: &settings, + output: om, + path, + sink: Some(&sink), + options: Default::default(), + nested_switches: true, + }; + let r = export(&job, &mut |_| true); + assert!(r.is_ok(), "{path}: {r:?}"); + std::mem::take(&mut *files.lock().unwrap()) +} + +#[test] +fn every_output_module_writes_the_bytes_it_writes_alone() { + let (p, cid) = project(); + let modules = [ + (OutputModule::for_format(OutputFormat::H264), "/mem/out.mp4"), + (OutputModule::for_format(OutputFormat::PngSequence), "/mem/out_[#####].png"), + (OutputModule::for_format(OutputFormat::ProRes), "/mem/out.mov"), + ]; + // Alone: each with a footage pool of its own, so no job continues from another's cache. + let pools: Vec = modules.iter().map(|_| MediaPool::new()).collect(); + let alone: Vec<_> = modules.iter().zip(&pools).map(|((om, path), pool)| run(&p, cid, pool, om, path)).collect(); + assert_eq!(alone[1].len(), 12, "one PNG per frame"); + // As one item: the same pool, back to back (modules after the first find the cache warm). + let pool = MediaPool::new(); + for ((om, path), alone) in modules.iter().zip(&alone) { + let item = run(&p, cid, &pool, om, path); + assert!(!item.is_empty()); + assert!(item == *alone, "{path} differs from the module exported alone"); + } +} From d7ce7d36f0062ed5c86d5bf81f769354f22a4d77 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 19:35:41 +0000 Subject: [PATCH 24/45] G5: render each frame once for output modules that share it export_modules / export_modules_async export one Render Queue item's output modules: modules rendering the same frames (same project, comp, Render Settings incl. time span, session objects, memory budget, Crop and Resize) export at once from one render of each frame, each with its own encoder and muxer on a thread of its own; the rest export alone. Batches keep the per-module partition (batch_size) so files stay byte-identical, the next batch renders while modules encode, and a batch goes to one module at a time. Results, progress, stop and failure behave as modules exported one after another. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011kdPxhtproheZmiPj5p3ML (cherry picked from commit b697b255621c8840e63eb73106299e9a8fe29f1a) (cherry picked from commit ccb2002dd2f8145babb07cc677d29efb991e6597) --- crates/export/src/lib.rs | 88 ++++++++++- crates/export/src/pipeline.rs | 201 +++++++++++++++++++++++++- crates/export/tests/output_modules.rs | 188 +++++++++++++++++++++--- 3 files changed, 449 insertions(+), 28 deletions(-) diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs index a368ecd2b..f1ceedc8c 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -181,7 +181,18 @@ pub async fn export_async(job: &Job<'_>, progress: &mut dyn FnMut(&Progress) -> // Another output module of the same Render Queue item renders the same frames: continue // from its layer cache (see `pipeline::Warm`). let cx = Cx::resume(job); - let mut r = export_with(&cx, progress, t0).await; + let r = run(&cx, progress, t0).await; + // A finished job leaves its layer cache for the next module; a failed or cancelled one + // (the item stops there) drops it. + if r.is_ok() { + cx.keep_warm(); + } + r +} + +/// Export `cx`'s job: a cancelled job removes the files it opened; any other writes its render log. +pub(crate) async fn run(cx: &Cx<'_>, progress: &mut dyn FnMut(&Progress) -> bool, t0: Instant) -> Result { + let mut r = export_with(cx, progress, t0).await; if matches!(r, Err(ExportError::Cancelled)) { cx.remove_cancelled_outputs()?; return r; @@ -191,14 +202,79 @@ pub async fn export_async(job: &Job<'_>, progress: &mut dyn FnMut(&Progress) -> rep.log = log; rep.overflow = cx.overflowed.lock().unwrap_or_else(|e| e.into_inner()).clone(); } - // A finished job leaves its layer cache for the next module; a failed or cancelled one - // (the item stops there) drops it. - if r.is_ok() { - cx.keep_warm(); - } r } +/// Export the output modules of one Render Queue item (blocking): see [`export_modules_async`]. +pub fn export_modules(jobs: &[Job], progress: &mut dyn FnMut(usize, &Progress) -> bool) -> Vec> { + effectcraft_render::passes::block_on(export_modules_async(jobs, progress)) +} + +/// Export the output modules of one Render Queue item, with the result of [`export`] for each +/// module run one after another: the files are those of each module exported alone, and the +/// results stop at the first module that fails or is cancelled (the modules after it are not +/// run). `progress` hears each module's progress with its index; returning `false` cancels that +/// module. +/// +/// Modules that render the same frames ([`pipeline::shares_frames`]) export at once from one +/// render of each frame ([`pipeline::fan_out`]), each with its own encoder and muxer; the others +/// export alone, continuing from the previous module's layer cache. A module of such a group +/// that fails cancels the modules after it (their files are removed; with a sink, a module that +/// finished first has already handed its files over), and a stop cancels every module of the +/// group being written. Frames render in passes (deferred readbacks), on the web and on a rayon +/// worker one module at a time. +pub async fn export_modules_async(jobs: &[Job<'_>], progress: &mut dyn FnMut(usize, &Progress) -> bool) -> Vec> { + let n = jobs.len(); + let together = !cfg!(target_arch = "wasm32") && rayon::current_thread_index().is_none(); + let mut done: Vec> = (0..n).map(|_| None).collect(); + // The first module (by index) that failed or was cancelled: the item stops there. + let failed = std::sync::atomic::AtomicUsize::new(n); + let stop_at = |failed: &std::sync::atomic::AtomicUsize| failed.load(std::sync::atomic::Ordering::SeqCst); + for i in 0..n { + let Some(job) = jobs.get(i) else { break }; + if i >= stop_at(&failed) || done.get(i).is_some_and(Option::is_some) { + continue; + } + let shares = |j: &&Job| together && !effectcraft_render::passes::is_deferred(j.accel) && pipeline::shares_frames(job, j); + let group: Vec<(usize, &Job)> = std::iter::once((i, job)) + .chain(jobs.iter().enumerate().skip(i + 1).filter(|(j, o)| *j < stop_at(&failed) && done.get(*j).is_some_and(Option::is_none) && shares(o))) + .collect(); + let outcomes = if group.len() > 1 { + pipeline::fan_out(&group, &failed, progress) + } else { + let t0 = Instant::now(); + let cx = Cx::resume(job); + let r = run(&cx, &mut |p| progress(i, p), t0).await; + let written = cx.written(); + if r.is_ok() { + cx.keep_warm(); + } + vec![(r, written)] + }; + for ((index, _), outcome) in group.iter().zip(outcomes) { + if outcome.0.is_err() { + failed.fetch_min(*index, std::sync::atomic::Ordering::SeqCst); + } + if let Some(slot) = done.get_mut(*index) { + *slot = Some(outcome); + } + } + } + // Modules after the first failure that finished in a group before it leave no files, as if + // they never ran. + let stop = stop_at(&failed); + for (_, written) in done.iter().skip(stop.saturating_add(1)).flatten() { + for path in written { + if let Err(e) = std::fs::remove_file(path) + && e.kind() != std::io::ErrorKind::NotFound + { + log::warn!("export: cannot remove {path}: {e}"); + } + } + } + done.into_iter().take(stop.saturating_add(1)).map_while(|o| o.map(|(r, _)| r)).collect() +} + async fn export_with(cx: &Cx<'_>, progress: &mut dyn FnMut(&Progress) -> bool, t0: Instant) -> Result { let comp = cx.comp().ok_or(ExportError::NoComp)?; let total = cx.settings.frame_count(comp); diff --git a/crates/export/src/pipeline.rs b/crates/export/src/pipeline.rs index ff7e05e0f..0b9058e52 100644 --- a/crates/export/src/pipeline.rs +++ b/crates/export/src/pipeline.rs @@ -2,7 +2,9 @@ //! guide layers, frame blending, motion blur), field rendering with 3:2 pulldown, colour depth, //! the Output Module's crop and resize, alpha handling, storage overflow and the render log. -use std::sync::Mutex; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::mpsc::{Receiver, Sender, SyncSender}; +use std::sync::{Arc, Mutex}; use effectcraft_project::render_queue::{ AlphaMode, Channels, ColorDepth, CurrentOrOff, EffectsMode, FrameSample, RenderLog, RenderSettings, ResizeQuality, SwitchOverride, TimeSpan, log_path, @@ -29,6 +31,9 @@ pub(crate) struct Cx<'a> { /// Files written to an overflow folder. pub overflowed: Mutex>, written: Mutex>, + /// Frames rendered once for several output modules ([`fan_out`]): this module's job takes + /// them from here instead of rendering them. + feed: Option, } impl<'a> std::ops::Deref for Cx<'a> { @@ -178,7 +183,12 @@ impl<'a> Cx<'a> { let frame_times = (job.options.log == RenderLog::PlusPerFrameInfo).then(|| Mutex::new(Vec::new())); // With deferred readbacks, buffers computed from a missed pass's placeholders are not kept. cache.set_gate(job.accel.and_then(|a| a.miss_gate())); - Cx { job, project, cache, frame_times, overflowed: Mutex::new(vec![]), written: Mutex::new(vec![]) } + Cx { job, project, cache, frame_times, overflowed: Mutex::new(vec![]), written: Mutex::new(vec![]), feed: None } + } + + /// The files this job opened on disk (removed when a later step of the item cancels it). + pub fn written(&self) -> Vec { + self.written.lock().unwrap_or_else(std::sync::PoisonError::into_inner).clone() } pub fn comp(&self) -> Option<&Comp> { @@ -226,6 +236,10 @@ impl<'a> Cx<'a> { /// Output frames `ks`, each mapped by `f` (encoding): rendered in parallel batches, or one /// by one in passes when readbacks are deferred ([`Cx::deferred`]). pub async fn frames(&self, comp: &Comp, ks: Vec, f: impl Fn(u64, Image) -> T + Sync + Send) -> Vec { + // (a renderer that stopped early leaves the frames to this job) + if let Some(out) = self.feed.as_ref().and_then(|feed| self.fed(feed, comp, &ks, &f)) { + return out; + } if self.deferred() { let mut out = Vec::with_capacity(ks.len()); for k in ks { @@ -256,6 +270,10 @@ impl<'a> Cx<'a> { body: impl AsyncFnOnce(&mut Batches<'_, '_, T>) -> crate::Result, ) -> crate::Result { let batch = batch.max(1); + if self.feed.is_some() { + let f: &(dyn Fn(u64, Image) -> T + Sync + Send) = &f; + return body(&mut Batches { next: 0, total, batch, src: Source::Fed { cx: self, comp, f } }).await; + } if self.deferred() || cfg!(target_arch = "wasm32") || rayon::current_thread_index().is_some() { let f: &(dyn Fn(u64, Image) -> T + Sync + Send) = &f; return body(&mut Batches { next: 0, total, batch, src: Source::Serial { cx: self, comp, f } }).await; @@ -298,6 +316,15 @@ impl<'a> Cx<'a> { /// [`Cx::frame`] as a future (deferred readbacks: the frame renders in passes). pub async fn frame_async(&self, comp: &Comp, i: u64) -> Image { + let (img, secs) = self.frame_timed(comp, i).await; + if let Some(ft) = &self.frame_times { + ft.lock().unwrap_or_else(|e| e.into_inner()).push((i, secs)); + } + img + } + + /// [`Cx::frame_async`] and the seconds it took, not logged. + async fn frame_timed(&self, comp: &Comp, i: u64) -> (Image, f64) { let t0 = Instant::now(); let img = match self.job.settings.sample(comp, i) { FrameSample::Progressive(t) => self.render_at(t).await, @@ -315,10 +342,24 @@ impl<'a> Cx<'a> { img.quantize(levels); } let img = self.crop_resize(img); + (img, t0.elapsed().as_secs_f64()) + } + + /// Output frames `ks`, each mapped by `f`, from the next batch of the feed (copies of the + /// shared frames; a frame the batch lacks renders here). `None` when the renderer stopped. + fn fed(&self, feed: &Feed, comp: &Comp, ks: &[u64], f: &(dyn Fn(u64, Image) -> T + Sync + Send)) -> Option> { + use rayon::prelude::*; + let got = feed.rx.lock().unwrap_or_else(std::sync::PoisonError::into_inner).recv().ok()?; + let shared = |k: u64| k.checked_sub(got.start).and_then(|j| usize::try_from(j).ok()).and_then(|j| got.frames.get(j)); if let Some(ft) = &self.frame_times { - ft.lock().unwrap_or_else(|e| e.into_inner()).push((i, t0.elapsed().as_secs_f64())); + let mut ft = ft.lock().unwrap_or_else(|e| e.into_inner()); + ft.extend(ks.iter().filter_map(|&k| shared(k).map(|(_, secs)| (k, *secs)))); } - img + let out = ks.par_iter().map(|&k| f(k, shared(k).map_or_else(|| self.frame(comp, k), |(img, _)| img.clone()))).collect(); + drop(got); + // The renderer hands the batch to the next module (or renders on) once this one has its copy. + let _ = feed.ack.send(()); + Some(out) } /// Output Module ▸ Crop, then Resize. @@ -464,6 +505,8 @@ enum Source<'c, 'a, T> { Serial { cx: &'c Cx<'a>, comp: &'c Comp, f: &'c (dyn Fn(u64, Image) -> T + Sync + Send) }, /// Rendered ahead on the renderer thread. Thread(std::sync::mpsc::Receiver>), + /// Rendered once for several output modules ([`fan_out`]) and mapped here. + Fed { cx: &'c Cx<'a>, comp: &'c Comp, f: &'c (dyn Fn(u64, Image) -> T + Sync + Send) }, } impl Batches<'_, '_, T> { @@ -476,12 +519,162 @@ impl Batches<'_, '_, T> { let frames = match &self.src { Source::Serial { cx, comp, f } => cx.frames(comp, (i..end).collect(), *f).await, Source::Thread(rx) => rx.recv().map_err(|_| crate::ExportError::Encode("rendering stopped".into()))?, + Source::Fed { cx, comp, f } => { + let ks: Vec = (i..end).collect(); + let feed = cx.feed.as_ref().ok_or_else(|| crate::ExportError::Encode("rendering stopped".into()))?; + cx.fed(feed, comp, &ks, *f).ok_or_else(|| crate::ExportError::Encode("rendering stopped".into()))? + } }; self.next = end; Ok(Some((i, end, frames))) } } +/// One batch rendered for the output modules of a [`fan_out`]: output frames `start..` and the +/// seconds each took. +struct Rendered { + start: u64, + frames: Arc>, +} + +/// A module's end of a [`fan_out`]: the batches, in order, and the acknowledgement that it has +/// copied one. Dropped when the module's job ends. +struct Feed { + rx: Mutex>, + ack: Sender<()>, + /// Modules still taking frames (the renderer stops when none is left). + live: Arc, +} + +impl Drop for Feed { + fn drop(&mut self) { + self.live.fetch_sub(1, Ordering::Relaxed); + } +} + +/// Whether `a` and `b` render the same output frames, so one render can feed both +/// ([`fan_out`]): the same project, comp, Render Settings (time span included), session objects +/// and memory budget (which sets the batches, and so the files' layout), Crop and Resize. Format, +/// channels and the rest of the Output Module apply to the rendered frames. Audio-only formats +/// render no frames; a sequence skipping existing files renders only some. +pub(crate) fn shares_frames(a: &Job, b: &Job) -> bool { + use effectcraft_project::render_queue::OutputFormat; + let renders = |j: &Job| !matches!(j.output.format, OutputFormat::Wav | OutputFormat::Aiff) && !(j.output.format.is_sequence() && j.settings.skip_existing); + renders(a) + && renders(b) + && WarmKey::of(a) == WarmKey::of(b) + && a.settings == b.settings + && a.output.crop == b.output.crop + && a.output.resize == b.output.resize + && (std::ptr::eq(a.project, b.project) || a.project == b.project) +} + +/// What a module's job returned, and the files it opened on disk. +pub(crate) type Outcome = (crate::Result, Vec); + +/// Export output modules that render the same frames ([`shares_frames`]; `(index in the item, +/// job)`) at once: every frame renders once, on a renderer thread with the first module's layer +/// cache, and is handed to each module's job, which maps (converts, encodes) and writes it on a +/// thread of its own with its own encoder and muxer. The next batch renders while the modules +/// encode this one, as in [`Cx::batches`]; a batch goes to one module at a time and the next +/// renders once every module has copied it, so the frames in flight are one rendering batch or +/// one shared batch and its copies (plus each module's mapped batch). +/// +/// `progress` hears each module's progress on the caller's thread, with its index; returning +/// `false` cancels that module. A module that fails (not cancelled) lowers `failed` to its index, +/// and the modules after it are cancelled at their next batch, as if they never started (the +/// item stops at the first failing module); modules before it run to the end. +pub(crate) fn fan_out(group: &[(usize, &Job<'_>)], failed: &AtomicUsize, progress: &mut dyn FnMut(usize, &crate::Progress) -> bool) -> Vec { + let Some(&(_, lead)) = group.first() else { return vec![] }; + let rcx = Cx::resume(lead); + let live = Arc::new(AtomicUsize::new(group.len())); + let mut feeds = Vec::with_capacity(group.len()); + let mut ends = Vec::with_capacity(group.len()); + for _ in group { + let (tx, rx) = std::sync::mpsc::sync_channel::(0); + let (ack, acks) = std::sync::mpsc::channel::<()>(); + feeds.push(Feed { rx: Mutex::new(rx), ack, live: live.clone() }); + ends.push(Some((tx, acks))); + } + let (req_tx, req_rx) = std::sync::mpsc::channel::<(usize, crate::Progress, Sender)>(); + let outcomes: Vec = std::thread::scope(|s| { + let (rcx, live) = (&rcx, &*live); + let renderer = s.spawn(move || render_for(rcx, ends, live)); + let modules: Vec<_> = group + .iter() + .zip(feeds) + .map(|(&(index, job), feed)| { + let req_tx = req_tx.clone(); + let module = s.spawn(move || { + let mut cx = Cx::new(job); + cx.feed = Some(feed); + let (reply_tx, reply_rx) = std::sync::mpsc::channel::(); + let mut relay = |p: &crate::Progress| req_tx.send((index, *p, reply_tx.clone())).is_ok() && reply_rx.recv().unwrap_or(false); + let r = effectcraft_render::passes::block_on(crate::run(&cx, &mut relay, Instant::now())); + // (the renderer skips this module from now on) + cx.feed = None; + if matches!(&r, Err(e) if !matches!(e, crate::ExportError::Cancelled)) { + failed.fetch_min(index, Ordering::SeqCst); + } + (r, cx.written()) + }); + (index, module) + }) + .collect(); + drop(req_tx); + while let Ok((index, p, reply)) = req_rx.recv() { + let go = progress(index, &p) && failed.load(Ordering::SeqCst) > index; + let _ = reply.send(go); + } + let outcomes = modules + .into_iter() + .map(|(index, m)| { + m.join().unwrap_or_else(|_| { + failed.fetch_min(index, Ordering::SeqCst); + (Err(crate::ExportError::Encode("export failed".into())), vec![]) + }) + }) + .collect(); + let _ = renderer.join(); + outcomes + }); + // A finished group leaves its layer cache for the next module or group (see `Warm`). + if outcomes.iter().all(|(r, _)| r.is_ok()) { + rcx.keep_warm(); + } + outcomes +} + +/// The renderer of a [`fan_out`]: the job's output frames in batches (as [`crate::batch_size`] +/// sets them for each module alone), each handed to every module still taking frames. +fn render_for(cx: &Cx, mut ends: Vec, Receiver<()>)>>, live: &AtomicUsize) { + use rayon::prelude::*; + let Some(comp) = cx.comp() else { return }; + let total = cx.settings.frame_count(comp); + let batch = crate::batch_size(cx, comp).max(1); + let mut i = 0; + while i < total && live.load(Ordering::Relaxed) > 0 { + let end = i.saturating_add(batch).min(total); + // Frames not started once every module has stopped are skipped. + let frames: Option> = (i..end) + .into_par_iter() + .map(|k| (live.load(Ordering::Relaxed) > 0).then(|| effectcraft_render::passes::block_on(cx.frame_timed(comp, k)))) + .collect(); + let Some(frames) = frames else { break }; + let frames = Arc::new(frames); + for slot in ends.iter_mut() { + let Some((tx, ack)) = slot else { continue }; + if tx.send(Rendered { start: i, frames: frames.clone() }).is_err() || ack.recv().is_err() { + *slot = None; + } + } + if ends.iter().all(Option::is_none) { + break; + } + i = end; + } +} + /// Weave two fields: the dominant field's lines (even lines when `upper_first`) from `first`. pub(crate) fn interleave(first: &Image, second: &Image, upper_first: bool) -> Image { let mut out = first.clone(); diff --git a/crates/export/tests/output_modules.rs b/crates/export/tests/output_modules.rs index 2898c323b..a8a6788e2 100644 --- a/crates/export/tests/output_modules.rs +++ b/crates/export/tests/output_modules.rs @@ -1,13 +1,14 @@ //! A Render Queue item with several output modules exports each module as a job of its own, //! back to back. A module's job continues from the layer cache of the module before it when //! both render the same frames (G5): the files must be the bytes of each module exported alone. +//! With `export_modules` such modules export at once from one render of each frame. use std::collections::BTreeMap; use std::sync::{Arc, Mutex}; use effectcraft_color::Label; use effectcraft_export::render_queue::{OutputFormat, OutputModule, RenderSettings, TimeSpan}; -use effectcraft_export::{Job, export}; +use effectcraft_export::{ExportError, Job, Progress, export, export_modules}; use effectcraft_keyframe::{Keyframe, Value}; use effectcraft_media::MediaPool; use effectcraft_project::build::{self, Ids}; @@ -22,15 +23,24 @@ fn solid(p: &mut Project, comp: &Comp, color: [f32; 3], w: u32, h: u32) -> Layer /// 96×64 @ 10 fps, 12 frames: a blurred static solid (its buffer is cached) under a smaller solid /// moving across. fn project() -> (Project, ItemId) { + project_sized(96, 64, false) +} + +/// [`project`] at `w`×`h`; `animate`: the blur animates too (every frame renders it again). +fn project_sized(w: u32, h: u32, animate: bool) -> (Project, ItemId) { let mut p = Project::default(); - let mut comp = Comp::new(96, 64, FrameRate::new(10, 1), Tick::from_seconds_f64(1.2)); + let mut comp = Comp::new(w, h, FrameRate::new(10, 1), Tick::from_seconds_f64(1.2)); comp.background = [0.1, 0.2, 0.4]; - let mut bg = solid(&mut p, &comp, [0.9, 0.6, 0.1], 64, 40); + let mut bg = solid(&mut p, &comp, [0.9, 0.6, 0.1], w * 2 / 3, h * 5 / 8); let spec = effectcraft_effects::find("ec.blur.gaussian").expect("gaussian blur"); let mut next = p.next_id; - let mut fx = effectcraft_effects::instantiate(spec, &mut Ids(&mut next), spec.name, [64.0, 40.0]); + let mut fx = effectcraft_effects::instantiate(spec, &mut Ids(&mut next), spec.name, [(w * 2 / 3) as f64, (h * 5 / 8) as f64]); p.next_id = next; fx.prop_mut("blurriness").expect("blurriness").value = Value::Scalar(12.0); + if animate { + fx.prop_mut("blurriness").expect("blurriness").keys = + vec![Keyframe::new(Tick::ZERO, Value::Scalar(4.0)), Keyframe::new(Tick::from_seconds_f64(1.2), Value::Scalar(40.0))]; + } bg.props.sub_mut("effects").expect("effects").children.push(fx.into()); let mut fg = solid(&mut p, &comp, [0.2, 0.9, 0.3], 16, 16); fg.props.prop_mut("transform/position").expect("position").keys = @@ -41,27 +51,24 @@ fn project() -> (Project, ItemId) { (p, cid) } +fn job<'a>(p: &'a Project, cid: ItemId, footage: &'a MediaPool, settings: &'a RenderSettings, om: &'a OutputModule, path: &'a str) -> Job<'a> { + Job { project: p, footage, expr: None, accel: None, comp: cid, settings, output: om, path, sink: None, options: Default::default(), nested_switches: true } +} + +fn settings() -> RenderSettings { + RenderSettings { time_span: TimeSpan::LengthOfComp, ..Default::default() } +} + /// Export one module into memory with `footage`: the files it wrote, by path. fn run(p: &Project, cid: ItemId, footage: &MediaPool, om: &OutputModule, path: &str) -> BTreeMap> { - let settings = RenderSettings { time_span: TimeSpan::LengthOfComp, ..Default::default() }; + let settings = settings(); let files = Arc::new(Mutex::new(BTreeMap::new())); let out = files.clone(); let sink = move |path: &str, data: Vec| { out.lock().unwrap().insert(path.to_string(), data); }; - let job = Job { - project: p, - footage, - expr: None, - accel: None, - comp: cid, - settings: &settings, - output: om, - path, - sink: Some(&sink), - options: Default::default(), - nested_switches: true, - }; + let mut job = job(p, cid, footage, &settings, om, path); + job.sink = Some(&sink); let r = export(&job, &mut |_| true); assert!(r.is_ok(), "{path}: {r:?}"); std::mem::take(&mut *files.lock().unwrap()) @@ -87,3 +94,148 @@ fn every_output_module_writes_the_bytes_it_writes_alone() { assert!(item == *alone, "{path} differs from the module exported alone"); } } + +/// Every module of a list exports from one render: the files are still those of each alone. +#[test] +fn modules_exported_together_write_the_bytes_they_write_alone() { + let (p, cid) = project(); + let mut alpha = OutputModule::for_format(OutputFormat::PngSequence); + alpha.channels = effectcraft_export::render_queue::Channels::Rgba; + let mut cropped = OutputModule::for_format(OutputFormat::H264); + cropped.crop.enabled = true; + cropped.crop.left = 8; + let modules = [ + (OutputModule::for_format(OutputFormat::H264), "/mem/all.mp4"), + (alpha, "/mem/all_[#####].png"), + (OutputModule::for_format(OutputFormat::ProRes), "/mem/all.mov"), + (OutputModule::for_format(OutputFormat::WebM), "/mem/all.webm"), + (OutputModule::for_format(OutputFormat::Gif), "/mem/all.gif"), + // Cropped: renders other frames, so it exports on its own. + (cropped, "/mem/all_cropped.mp4"), + ]; + let pools: Vec = modules.iter().map(|_| MediaPool::new()).collect(); + let alone: Vec<_> = modules.iter().zip(&pools).map(|((om, path), pool)| run(&p, cid, pool, om, path)).collect(); + let settings = settings(); + let pool = MediaPool::new(); + let files = Arc::new(Mutex::new(BTreeMap::new())); + let out = files.clone(); + let sink = move |path: &str, data: Vec| { + out.lock().unwrap().insert(path.to_string(), data); + }; + let jobs: Vec = modules + .iter() + .map(|(om, path)| { + let mut j = job(&p, cid, &pool, &settings, om, path); + j.sink = Some(&sink); + j + }) + .collect(); + let mut last = vec![0u64; jobs.len()]; + let rs = export_modules(&jobs, &mut |m, pr: &Progress| { + assert!(pr.done >= last[m] && pr.total == 12, "module {m}: {pr:?}"); + last[m] = pr.done; + true + }); + assert_eq!(rs.len(), modules.len()); + assert!(rs.iter().all(|r| r.as_ref().is_ok_and(|r| r.frames == 12)), "{rs:?}"); + assert!(last.iter().all(|d| *d == 12), "every module reported all its frames: {last:?}"); + let files = files.lock().unwrap(); + for ((_, path), alone) in modules.iter().zip(&alone) { + for (f, bytes) in alone { + assert!(files.get(f) == Some(bytes), "{path}: {f} differs from the module exported alone"); + } + } + assert_eq!(files.len(), alone.iter().map(BTreeMap::len).sum::()); +} + +fn temp_dir(name: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!("effectcraft-modules-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +/// A module that fails stops the item there, as when modules export one after another: the +/// modules before it finish, the ones after it leave no files. +#[test] +fn a_failing_module_stops_the_modules_after_it() { + let (p, cid) = project(); + let dir = temp_dir("fail"); + std::fs::write(dir.join("file"), b"x").unwrap(); + let path = |n: &str| dir.join(n).to_string_lossy().to_string(); + let (a, b, c) = (path("a_[#####].png"), path("file/b.mp4"), path("c.mov")); + let settings = settings(); + let pool = MediaPool::new(); + let oms = [OutputFormat::PngSequence, OutputFormat::H264, OutputFormat::ProRes].map(OutputModule::for_format); + let jobs = [job(&p, cid, &pool, &settings, &oms[0], &a), job(&p, cid, &pool, &settings, &oms[1], &b), job(&p, cid, &pool, &settings, &oms[2], &c)]; + let rs = export_modules(&jobs, &mut |_, _| true); + assert_eq!(rs.len(), 2, "{rs:?}"); + assert!(rs[0].is_ok() && matches!(rs[1], Err(ExportError::Io(_))), "{rs:?}"); + assert!(dir.join("a_00000.png").exists() && dir.join("a_00011.png").exists()); + assert!(!dir.join("c.mov").exists(), "the module after the failing one left its file"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// A stop cancels the modules being written: their files are removed, and the item stops there. +#[test] +fn a_stop_cancels_the_modules_being_written() { + let (p, cid) = project(); + let dir = temp_dir("stop"); + let path = |n: &str| dir.join(n).to_string_lossy().to_string(); + let (a, b, c) = (path("a.mp4"), path("b_[#####].png"), path("c.mov")); + let settings = settings(); + let pool = MediaPool::new(); + let oms = [OutputFormat::H264, OutputFormat::PngSequence, OutputFormat::ProRes].map(OutputModule::for_format); + let jobs = [job(&p, cid, &pool, &settings, &oms[0], &a), job(&p, cid, &pool, &settings, &oms[1], &b), job(&p, cid, &pool, &settings, &oms[2], &c)]; + // Module 1 is stopped after its first batch; the others would go on. + let rs = export_modules(&jobs, &mut |m, pr| !(m == 1 && pr.done > 0)); + assert_eq!(rs.len(), 2, "{rs:?}"); + assert!(rs[0].is_ok() && matches!(rs[1], Err(ExportError::Cancelled)), "{rs:?}"); + assert!(dir.join("a.mp4").exists()); + let left: Vec<_> = std::fs::read_dir(&dir).unwrap().filter_map(|e| e.ok()?.file_name().into_string().ok()).filter(|f| f != "a.mp4").collect(); + assert!(left.is_empty(), "cancelled modules left files: {left:?}"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Seconds to export 2 and 3 modules of a 1280×720 comp (an animated blur: every frame renders) one after another (each continuing from +/// the previous module's layer cache) and together (`export_modules`). Run with `--ignored +/// --nocapture`. +#[test] +#[ignore] +fn time_modules_alone_and_together() { + let (p, cid) = project_sized(1280, 720, true); + let settings = settings(); + let fmts = [OutputFormat::H264, OutputFormat::ProRes, OutputFormat::PngSequence]; + let oms = fmts.map(OutputModule::for_format); + let paths = ["/mem/t.mp4", "/mem/t.mov", "/mem/t_[#####].png"]; + let sink = |_: &str, _: Vec| {}; + for n in [2, 3] { + for round in 0..2 { + let pool = MediaPool::new(); + let jobs: Vec = (0..n) + .map(|i| { + let mut j = job(&p, cid, &pool, &settings, &oms[i], paths[i]); + j.sink = Some(&sink); + j + }) + .collect(); + let t = std::time::Instant::now(); + for j in &jobs { + assert!(export(j, &mut |_| true).is_ok()); + } + let alone = t.elapsed().as_secs_f64(); + let pool = MediaPool::new(); + let jobs: Vec = (0..n) + .map(|i| { + let mut j = job(&p, cid, &pool, &settings, &oms[i], paths[i]); + j.sink = Some(&sink); + j + }) + .collect(); + let t = std::time::Instant::now(); + assert!(export_modules(&jobs, &mut |_, _| true).iter().all(Result::is_ok)); + let together = t.elapsed().as_secs_f64(); + println!("{n} modules (round {round}): one after another {alone:.3} s, together {together:.3} s"); + } + } +} From 8e4aa92a41ad7ec30ec146b20ede06b166e28c65 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 19:43:28 +0000 Subject: [PATCH 25/45] G5: hand an item's output modules to the exporter at once Exporter::export_modules_async takes every output module of a queue item; the default exports them one after another as before, so an exporter can render the frames they share once. The item's progress shows the module furthest behind; result, stop and failure handling are unchanged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011kdPxhtproheZmiPj5p3ML (cherry picked from commit 6c73d53c0f282d01ff8bee170fa87006a9ef55df) (cherry picked from commit 335c8de9e35f7552c9ae772ca5e20b370fadcac8) --- crates/engine/src/render_queue.rs | 173 ++++++++++++++++++++++++------ 1 file changed, 138 insertions(+), 35 deletions(-) diff --git a/crates/engine/src/render_queue.rs b/crates/engine/src/render_queue.rs index 46ed01689..d726230b4 100644 --- a/crates/engine/src/render_queue.rs +++ b/crates/engine/src/render_queue.rs @@ -70,6 +70,28 @@ pub trait Exporter: Send + Sync { fn export_async<'a>(&'a self, job: &'a ExportJob<'a>, progress: &'a mut dyn FnMut(u64, u64) -> bool) -> LocalFuture<'a, Result> { Box::pin(async move { self.export(job, progress) }) } + /// Export the output modules of one queue item, as [`Exporter::export_async`] one after + /// another: the results stop at the first module that fails or is cancelled (the modules + /// after it are not run). `progress(module, done, total)` reports each module's progress. + /// The default runs them one by one; an exporter may render frames modules share once. + fn export_modules_async<'a>( + &'a self, + jobs: &'a [ExportJob<'a>], + progress: &'a mut dyn FnMut(usize, u64, u64) -> bool, + ) -> LocalFuture<'a, Vec>> { + Box::pin(async move { + let mut out = Vec::with_capacity(jobs.len()); + for (mi, job) in jobs.iter().enumerate() { + let r = self.export_async(job, &mut |done, total| progress(mi, done, total)).await; + let stop = r.is_err(); + out.push(r); + if stop { + break; + } + } + out + }) + } } /// A boxed future that stays on its thread. @@ -267,42 +289,54 @@ async fn run_work(w: Work, shared: &JobShared, notify: &mut dyn FnMut(&JobShared } notify(shared); // Every output module encodes the same frames (Composition ▸ Add Output Module); the - // first module's file is the item's output. Modules export one after another with the - // same project and session objects, so the exporter can continue each from the previous - // module's render (`effectcraft-export` keeps its layer cache). Each module reports its - // own progress (0..total again), a stop cancels the module being written (earlier - // modules' files stay), and the first failing module fails the item and skips the rest. + // first module's file is the item's output. The exporter exports them as one after + // another with the same project and session objects (`effectcraft-export` renders frames + // modules share once). Each module reports its own progress (0..total again; modules + // exporting at once show the one furthest behind), a stop cancels the modules being + // written (earlier modules' files stay), and the first failing module fails the item + // and skips the rest. + let jobs: Vec = modules + .iter() + .enumerate() + .map(|(mi, (mitem, path))| { + let name = w.project.item(mitem.comp).map(|i| i.name.clone()).unwrap_or_default(); + let index = w.project.render_queue.iter().position(|i| i.id == mitem.id).map_or(0, |i| i + 1); + ExportJob { + project: &w.project, + footage: w.footage.as_ref(), + expr: w.expr.as_deref(), + accel: w.accel.as_deref(), + item: mitem, + path, + storage: w.storage.as_deref(), + label: format!("#{index} {name} (output module {})", mi + 1), + nested_switches: w.nested_switches, + memory_budget: Some(w.memory_budget), + } + }) + .collect(); + let mut seen: Vec> = vec![None; jobs.len()]; + let results = w + .exporter + .export_modules_async(&jobs, &mut |mi, done, total| { + if let Some(s) = seen.get_mut(mi) { + *s = Some((done, total)); + } + let (done, total) = seen.iter().flatten().filter(|(d, t)| d < t).min_by_key(|(d, _)| *d).copied().unwrap_or((done, total)); + { + let mut s = lock(&shared.state); + s.done = done; + s.total = total; + s.elapsed = t0.elapsed().as_secs_f64(); + s.item_elapsed = t1.elapsed().as_secs_f64(); + s.remaining = (done > 0).then(|| s.item_elapsed / done as f64 * total.saturating_sub(done) as f64); + } + notify(shared); + !shared.cancel.load(Ordering::Relaxed) + }) + .await; let mut r: Result = Err("no output module".into()); - for (mi, (mitem, path)) in modules.iter().enumerate() { - let name = w.project.item(mitem.comp).map(|i| i.name.clone()).unwrap_or_default(); - let index = w.project.render_queue.iter().position(|i| i.id == mitem.id).map_or(0, |i| i + 1); - let job = ExportJob { - project: &w.project, - footage: w.footage.as_ref(), - expr: w.expr.as_deref(), - accel: w.accel.as_deref(), - item: mitem, - path, - storage: w.storage.as_deref(), - label: format!("#{index} {name} (output module {})", mi + 1), - nested_switches: w.nested_switches, - memory_budget: Some(w.memory_budget), - }; - let rr = w - .exporter - .export_async(&job, &mut |done, total| { - { - let mut s = lock(&shared.state); - s.done = done; - s.total = total; - s.elapsed = t0.elapsed().as_secs_f64(); - s.item_elapsed = t1.elapsed().as_secs_f64(); - s.remaining = (done > 0).then(|| s.item_elapsed / done as f64 * total.saturating_sub(done) as f64); - } - notify(shared); - !shared.cancel.load(Ordering::Relaxed) - }) - .await; + for (mi, rr) in results.into_iter().enumerate() { match rr { Ok(res) if mi == 0 => r = Ok(res), Ok(_) => {} @@ -690,3 +724,72 @@ impl Session { self.active_comp_id().or(sel).ok_or(crate::EngineError::NoComp) } } + +#[cfg(test)] +mod tests { + use std::sync::{Arc, Mutex}; + + use effectcraft_project::render_queue::{OutputFormat, RenderStatus}; + use serde_json::json; + + use super::{ExportJob, ExportResult, Exporter, LocalFuture}; + + /// Exports an item's output modules in one call, at once (progress interleaved, the later + /// module ahead); fails modules whose path contains "fail". + struct Together { + calls: Mutex>, + } + + impl Exporter for Together { + fn formats(&self) -> Vec { + OutputFormat::ALL.to_vec() + } + fn export(&self, _: &ExportJob, _: &mut dyn FnMut(u64, u64) -> bool) -> Result { + Err("modules export together".into()) + } + fn export_modules_async<'a>( + &'a self, + jobs: &'a [ExportJob<'a>], + progress: &'a mut dyn FnMut(usize, u64, u64) -> bool, + ) -> LocalFuture<'a, Vec>> { + Box::pin(async move { + self.calls.lock().unwrap().push(jobs.len()); + for k in 0..=2 { + for mi in (0..jobs.len()).rev() { + assert!(progress(mi, k, 2)); + } + } + let mut out = vec![]; + for j in jobs { + let failed = j.path.contains("fail"); + out.push(if failed { Err("boom".into()) } else { Ok(ExportResult { path: j.path.to_string(), ..Default::default() }) }); + if failed { + break; + } + } + out + }) + } + } + + #[test] + fn an_item_hands_all_its_output_modules_to_the_exporter() { + // G5: the exporter gets every output module of an item at once (it renders shared + // frames once); the first module's file is the item's output, the first failure fails it. + let mut s = crate::Session::default(); + s.execute("file.openDemoProject", json!({})).unwrap(); + let ex = Arc::new(Together { calls: Mutex::new(vec![]) }); + s.exporter = Some(ex.clone()); + s.execute("renderQueue.add", json!({"output": "/tmp/rqm/main.mp4"})).unwrap(); + s.execute("render.addOutputModule", json!({"index": 1, "format": "png"})).unwrap(); + s.execute("renderQueue.add", json!({"output": "/tmp/rqm/second.mp4"})).unwrap(); + s.execute("render.addOutputModule", json!({"index": 2, "format": "prores"})).unwrap(); + s.execute("renderQueue.setOutput", json!({"index": 2, "module": 2, "path": "/tmp/rqm/fail.mov"})).unwrap(); + s.execute("renderQueue.render", json!({})).unwrap(); + assert_eq!(*ex.calls.lock().unwrap(), [2, 2]); + let q = &s.project.render_queue; + assert_eq!(q[0].status, RenderStatus::Done); + assert!(q[0].last_output.as_deref().is_some_and(|p| p.ends_with("main.mp4")), "{:?}", q[0].last_output); + assert_eq!(q[1].status, RenderStatus::Failed("boom".into())); + } +} From c945c8f76d62cd6308635c30fa5992dd4306863f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 22:09:52 +0000 Subject: [PATCH 26/45] G5: the host exporter renders each frame once for an item's output modules FileExporter overrides Exporter::export_modules_async with effectcraft_export::export_modules_async (same job construction, progress, cancellation and error mapping as export_async), so the app fans one render of each frame out to every output module that shares it. Tests: byte identity with each module alone and one footage request per frame for 2 and 3 modules, a stop mid-way, a failing module, and an ignored timing test. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Fwx5KNPtB2EbV1TCwq4puC (cherry picked from commit 49ca5ebcf92f122ba663757778448a0de4d002d7) (cherry picked from commit f3e743c117d247ca51345d1d7f3e19f290160a80) --- crates/host/src/lib.rs | 305 ++++++++++++++++++++++++++++++++++++----- 1 file changed, 271 insertions(+), 34 deletions(-) diff --git a/crates/host/src/lib.rs b/crates/host/src/lib.rs index ced0db716..70677b323 100644 --- a/crates/host/src/lib.rs +++ b/crates/host/src/lib.rs @@ -27,6 +27,50 @@ pub struct FileExporter { pub sink: Option>, } +impl FileExporter { + /// The `effectcraft-export` job of a queue item's output module. + fn job<'a>(&'a self, job: &'a ExportJob<'a>) -> effectcraft_export::Job<'a> { + effectcraft_export::Job { + project: job.project, + footage: job.footage, + expr: job.expr, + accel: job.accel, + comp: job.item.comp, + settings: &job.item.settings, + output: &job.item.output, + path: job.path, + sink: self.sink.as_deref(), + options: effectcraft_export::JobOptions { + log: job.item.log, + label: job.label.clone(), + storage: job.storage, + overflow: job.project.render_prefs.overflow_folders.clone(), + memory_budget: job.memory_budget, + }, + nested_switches: job.nested_switches, + } + } +} + +/// An export's result as the Render Queue reports it (a stop is [`CANCELLED`](effectcraft_engine::render_queue::CANCELLED)). +fn result(r: effectcraft_export::Result) -> Result { + match r { + Ok(r) => Ok(ExportResult { + path: r.path, + frames: r.frames, + width: r.width, + height: r.height, + bytes: r.bytes, + seconds: r.seconds, + audio: r.audio, + log: r.log, + overflow: r.overflow, + }), + Err(effectcraft_export::ExportError::Cancelled) => Err(effectcraft_engine::render_queue::CANCELLED.into()), + Err(e) => Err(e.to_string()), + } +} + impl Exporter for FileExporter { fn formats(&self) -> Vec { effectcraft_export::available_formats() @@ -40,41 +84,19 @@ impl Exporter for FileExporter { job: &'a ExportJob<'a>, progress: &'a mut dyn FnMut(u64, u64) -> bool, ) -> effectcraft_engine::render_queue::LocalFuture<'a, Result> { + Box::pin(async move { result(effectcraft_export::export_async(&self.job(job), &mut |p| progress(p.done, p.total)).await) }) + } + + /// Modules that render the same frames export at once from one render of each frame + /// ([`effectcraft_export::export_modules_async`]). + fn export_modules_async<'a>( + &'a self, + jobs: &'a [ExportJob<'a>], + progress: &'a mut dyn FnMut(usize, u64, u64) -> bool, + ) -> effectcraft_engine::render_queue::LocalFuture<'a, Vec>> { Box::pin(async move { - let j = effectcraft_export::Job { - project: job.project, - footage: job.footage, - expr: job.expr, - accel: job.accel, - comp: job.item.comp, - settings: &job.item.settings, - output: &job.item.output, - path: job.path, - sink: self.sink.as_deref(), - options: effectcraft_export::JobOptions { - log: job.item.log, - label: job.label.clone(), - storage: job.storage, - overflow: job.project.render_prefs.overflow_folders.clone(), - memory_budget: job.memory_budget, - }, - nested_switches: job.nested_switches, - }; - match effectcraft_export::export_async(&j, &mut |p| progress(p.done, p.total)).await { - Ok(r) => Ok(ExportResult { - path: r.path, - frames: r.frames, - width: r.width, - height: r.height, - bytes: r.bytes, - seconds: r.seconds, - audio: r.audio, - log: r.log, - overflow: r.overflow, - }), - Err(effectcraft_export::ExportError::Cancelled) => Err(effectcraft_engine::render_queue::CANCELLED.into()), - Err(e) => Err(e.to_string()), - } + let js: Vec = jobs.iter().map(|j| self.job(j)).collect(); + effectcraft_export::export_modules_async(&js, &mut |mi, p| progress(mi, p.done, p.total)).await.into_iter().map(result).collect() }) } } @@ -648,4 +670,219 @@ mod tests { assert!(close(v3(&s, "transform/position"), [300.0 - d, 180.0, -800.0]), "{:?}", v3(&s, "transform/position")); assert!(close(v3(&s, "transform/poi"), [300.0, 180.0, 0.0]), "{:?}", v3(&s, "transform/poi")); } + + /// Footage drawn per frame that counts the frames the renderer asks for; a `gate` makes + /// the first request at frame 3 or later wait until the test lets it go. + #[derive(Default)] + struct Counted { + asked: std::sync::Mutex>, + gate: Option<(std::sync::mpsc::Sender<()>, std::sync::Mutex>)>, + gated: std::sync::atomic::AtomicBool, + } + + impl effectcraft_engine::render::FootageSource for Counted { + fn frame( + &self, + _: effectcraft_project::ItemId, + _: &effectcraft_project::Footage, + t: effectcraft_time::Tick, + ) -> Option> { + let f = (t.seconds() * 10.0).round().max(0.0) as u32; + *self.asked.lock().unwrap().entry(f).or_default() += 1; + if let Some((reached, go)) = &self.gate + && f >= 3 + && !self.gated.swap(true, std::sync::atomic::Ordering::SeqCst) + { + reached.send(()).unwrap(); + go.lock().unwrap().recv().unwrap(); + } + let mut img = effectcraft_raster::Image::new(64, 36); + for y in 0..36 { + for x in 0..64 { + let v = ((x + y * 2 + f * 7) % 64) as f32 / 63.0; + img.set(x, y, [v, 1.0 - v, (f as f32 / 12.0).min(1.0), 1.0]); + } + } + Some(std::sync::Arc::new(img)) + } + } + + /// A host session with a 64×36, 10 fps, 1.2 s comp showing `footage` and a queue item writing + /// `modules` (format, path; the first is the item's own output module). + fn modules_session(footage: std::sync::Arc, modules: &[(&str, String)]) -> effectcraft_engine::Session { + let mut s = super::session(); + s.execute("comp.new", json!({"name": "Modules", "width": 64, "height": 36, "frameRate": 10, "duration": 1.2})).unwrap(); + let cid = s.active_comp_id().unwrap(); + s.edit("clip", None, |p, _| { + let f = effectcraft_project::Footage { + path: "counted.mov".into(), + kind: effectcraft_project::FootageKind::Video, + width: 64, + height: 36, + frame_rate: effectcraft_time::FrameRate::new(10, 1), + duration: effectcraft_time::Tick::from_seconds_f64(1.2), + has_video: true, + codec: "SYN".into(), + ..Default::default() + }; + let item = p.add_item("clip", effectcraft_engine::color::Label::Aqua, None, effectcraft_project::ItemKind::Footage(f)); + let comp = p.comp(cid).unwrap().clone(); + let l = effectcraft_project::build::layer(p, &comp, "Clip", effectcraft_project::LayerSource::Footage { item }, (64, 36), None); + p.comp_mut(cid).unwrap().layers.insert(0, l); + Ok(()) + }) + .unwrap(); + s.footage = footage; + let (fmt, path) = &modules[0]; + s.execute("renderQueue.add", json!({"comp": cid.0, "format": fmt, "output": path})).unwrap(); + for (fmt, path) in &modules[1..] { + s.execute("render.addOutputModule", json!({"index": 1, "format": fmt, "output": path})).unwrap(); + } + s + } + + fn modules_dir(name: &str) -> std::path::PathBuf { + let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/test-out/host-modules").join(name); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + /// The files in `dir` by name. + fn files(dir: &std::path::Path) -> std::collections::BTreeMap> { + std::fs::read_dir(dir).unwrap().map(|e| e.unwrap()).map(|e| (e.file_name().into_string().unwrap(), std::fs::read(e.path()).unwrap())).collect() + } + + const MODULES: [(&str, &str); 3] = [("h264", "m.mp4"), ("prores", "m.mov"), ("png", "m_[##].png")]; + + /// G5: the host exporter hands an item's output modules to `export_modules_async`: every + /// frame renders once for all of them, and each file holds the bytes of its module exported + /// alone. + #[test] + fn output_modules_render_each_frame_once_and_write_the_bytes_they_write_alone() { + for n in [2, 3] { + let mut alone = std::collections::BTreeMap::new(); + let mut asked_alone = 0; + for (fmt, name) in &MODULES[..n] { + let dir = modules_dir(&format!("alone-{n}-{fmt}")); + let footage = std::sync::Arc::new(Counted::default()); + let mut s = modules_session(footage.clone(), &[(fmt, dir.join(name).to_string_lossy().to_string())]); + s.execute("renderQueue.render", json!({"wait": true})).unwrap(); + s.poll_render(); + assert_eq!(s.project.render_queue[0].status.label(), "Done", "{fmt}"); + asked_alone += footage.asked.lock().unwrap().values().sum::(); + alone.extend(files(&dir)); + } + let dir = modules_dir(&format!("together-{n}")); + let footage = std::sync::Arc::new(Counted::default()); + let modules: Vec<(&str, String)> = MODULES[..n].iter().map(|(f, name)| (*f, dir.join(name).to_string_lossy().to_string())).collect(); + let mut s = modules_session(footage.clone(), &modules); + s.execute("renderQueue.render", json!({"wait": true})).unwrap(); + s.poll_render(); + let it = &s.project.render_queue[0]; + assert_eq!(it.status.label(), "Done", "{n} modules"); + assert!(it.last_output.as_deref().is_some_and(|o| o.ends_with("m.mp4")), "{:?}", it.last_output); + let asked = footage.asked.lock().unwrap().clone(); + assert_eq!(asked.keys().copied().collect::>(), (0..12).collect::>(), "{n} modules: {asked:?}"); + assert!(asked.values().all(|c| *c == 1), "{n} modules: every frame renders once: {asked:?}"); + assert_eq!(asked_alone, 12 * n as u32, "each module alone renders every frame"); + let together = files(&dir); + assert_eq!(together.keys().collect::>(), alone.keys().collect::>()); + for (f, bytes) in &alone { + assert!(together.get(f) == Some(bytes), "{n} modules: {f} differs from the module exported alone"); + } + } + } + + /// A stop mid-way cancels the modules being written: the item is User Stopped and none of + /// its modules leave files. + #[test] + fn stopping_output_modules_removes_their_files() { + let dir = modules_dir("stop"); + let (reached, wait) = std::sync::mpsc::channel(); + let (go, gate) = std::sync::mpsc::channel(); + let footage = std::sync::Arc::new(Counted { gate: Some((reached, std::sync::Mutex::new(gate))), ..Default::default() }); + let modules: Vec<(&str, String)> = MODULES.iter().map(|(f, name)| (*f, dir.join(name).to_string_lossy().to_string())).collect(); + let mut s = modules_session(footage.clone(), &modules); + s.execute("renderQueue.render", json!({"wait": false})).unwrap(); + wait.recv_timeout(std::time::Duration::from_secs(120)).unwrap(); + s.execute("renderQueue.stop", json!({})).unwrap(); + go.send(()).unwrap(); + let t0 = std::time::Instant::now(); + while s.is_rendering() { + assert!(t0.elapsed().as_secs() < 120, "render did not stop"); + std::thread::sleep(std::time::Duration::from_millis(5)); + } + s.poll_render(); + assert_eq!(s.project.render_queue[0].status.label(), "User Stopped"); + assert!(footage.asked.lock().unwrap().len() < 12, "the render stopped mid-way"); + assert!(files(&dir).is_empty(), "stopped modules left files: {:?}", files(&dir).keys()); + } + + /// A module that cannot write fails the item: the modules before it finish, the ones after + /// it leave no files. + #[test] + fn a_failing_output_module_fails_the_item() { + let dir = modules_dir("fail"); + std::fs::write(dir.join("file"), b"x").unwrap(); + let modules: Vec<(&str, String)> = [("png", "a_[##].png"), ("h264", "file/b.mp4"), ("prores", "c.mov")] + .iter() + .map(|(f, name)| (*f, dir.join(name).to_string_lossy().to_string())) + .collect(); + let mut s = modules_session(std::sync::Arc::new(Counted::default()), &modules); + s.execute("renderQueue.render", json!({"wait": true})).unwrap(); + s.poll_render(); + let status = s.project.render_queue[0].status.clone(); + assert!(matches!(status, effectcraft_engine::project::render_queue::RenderStatus::Failed(_)), "{status:?}"); + let left = files(&dir); + assert!(left.contains_key("a_00.png") && left.contains_key("a_11.png"), "{:?}", left.keys()); + assert!(!left.contains_key("c.mov"), "the module after the failing one left its file"); + } + + /// The default `Exporter::export_modules_async`: modules one after another. + struct OneByOne(super::FileExporter); + + impl effectcraft_engine::Exporter for OneByOne { + fn formats(&self) -> Vec { + self.0.formats() + } + fn export(&self, job: &effectcraft_engine::ExportJob, progress: &mut dyn FnMut(u64, u64) -> bool) -> Result { + self.0.export(job, progress) + } + } + + /// Seconds to render a 2- and 3-module item of the demo comp at half resolution with the + /// modules one after another (the default) and with the host exporter. Run with `--ignored + /// --nocapture`. + #[test] + #[ignore] + fn time_output_modules() { + let dir = modules_dir("time"); + for n in [2, 3] { + for round in 0..2 { + let mut t = [0.0; 2]; + for (k, together) in [false, true].into_iter().enumerate() { + let mut s = super::session(); + if !together { + s.exporter = Some(std::sync::Arc::new(OneByOne(super::FileExporter::default()))); + } + s.execute("file.openDemoProject", json!({})).unwrap(); + let (fmt, name) = MODULES[0]; + let span = json!({"timeSpan": "custom", "start": 0.0, "end": 2.0, "resolution": 0.5}); + let mut p = json!({"format": fmt, "output": dir.join(name).to_string_lossy()}); + p.as_object_mut().unwrap().extend(span.as_object().unwrap().clone()); + s.execute("renderQueue.add", p).unwrap(); + for (fmt, name) in &MODULES[1..n] { + s.execute("render.addOutputModule", json!({"index": 1, "format": fmt, "output": dir.join(name).to_string_lossy()})).unwrap(); + } + let t0 = std::time::Instant::now(); + s.execute("renderQueue.render", json!({"wait": true})).unwrap(); + t[k] = t0.elapsed().as_secs_f64(); + s.poll_render(); + assert_eq!(s.project.render_queue[0].status.label(), "Done"); + } + println!("{n} modules (round {round}): one after another {:.3} s, host exporter {:.3} s ({:.2}x)", t[0], t[1], t[0] / t[1]); + } + } + } } From f3a8f1ed78c1f27fca4f99690624650b5b04e553 Mon Sep 17 00:00:00 2001 From: Elias Date: Sat, 10 Oct 2026 15:31:33 +0100 Subject: [PATCH 27/45] G5: open movies through a file reader (index now, samples on demand) and probe audio/movies from headers, not whole-file reads and decodes (cherry picked from commit 2deafbd0e915c7c03dc27c31062665b5939c53c9) (cherry picked from commit f679c3556c2f6fd821d28aa2f0a266c930795ca2) --- Cargo.lock | 1 + Cargo.toml | 2 + crates/media/Cargo.toml | 1 + crates/media/src/layered.rs | 5 ++ crates/media/src/lib.rs | 1 + crates/media/src/open.rs | 158 ++++++++++++++++++++++++++++++++++ crates/media/src/pool.rs | 17 ++-- crates/media/src/probe.rs | 99 ++++++++++++++++++++- crates/media/tests/footage.rs | 20 +++++ 9 files changed, 292 insertions(+), 12 deletions(-) create mode 100644 crates/media/src/open.rs diff --git a/Cargo.lock b/Cargo.lock index ba84c75f5..fdcc91883 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1437,6 +1437,7 @@ dependencies = [ "log", "rayon", "serde_json", + "symphonia", "thiserror 2.0.21", ] diff --git a/Cargo.toml b/Cargo.toml index 38ba10a0e..b807d3f0b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -71,6 +71,8 @@ image = { version = "0.25", default-features = false, features = ["png", "jpeg"] # media codecs + containers: FilmCraft's pure-Rust crates, pinned to a revision (plan/adr/0001). # Only `crates/media`, `crates/export` and `crates/interchange` may depend on these. +# Header-only probing of standalone audio files (the same build FilmCraft decodes them with; MPL-2.0). +symphonia = { version = "0.5.5", default-features = false, features = ["aac", "mp1", "mp2", "mp3", "flac", "alac", "pcm", "wav", "ogg", "vorbis", "isomp4"] } filmcraft-codecs = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" } filmcraft-media = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" } filmcraft-frame = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" } diff --git a/crates/media/Cargo.toml b/crates/media/Cargo.toml index 0d7803fde..609fc57de 100644 --- a/crates/media/Cargo.toml +++ b/crates/media/Cargo.toml @@ -27,6 +27,7 @@ exr = { workspace = true } gif = { workspace = true } image = { workspace = true, features = ["gif", "webp", "tiff", "bmp", "exr"] } rayon = { workspace = true } +symphonia = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } log = { workspace = true } diff --git a/crates/media/src/layered.rs b/crates/media/src/layered.rs index e65aa7934..4f63b8eb5 100644 --- a/crates/media/src/layered.rs +++ b/crates/media/src/layered.rs @@ -40,6 +40,11 @@ fn is_pdf_like(path: &str, bytes: &[u8]) -> bool { effectcraft_pdf::sniff(bytes).is_some() && (effectcraft_pdf::EXTENSIONS.contains(&ext.as_str()) || bytes.starts_with(b"%PDF")) } +/// Whether [`probe`] takes a file starting with `head` (Photoshop, SVG, PDF / Illustrator / EPS). +pub(crate) fn claims(path: &str, head: &[u8]) -> bool { + effectcraft_psd::is_psd(head) || is_svg(path, head) || effectcraft_pdf::sniff(head).is_some() +} + /// Footage for a Photoshop or vector file (`None` for other formats). pub(crate) fn probe(path: &str, bytes: &[u8]) -> Result> { if effectcraft_psd::is_psd(bytes) { diff --git a/crates/media/src/lib.rs b/crates/media/src/lib.rs index 000c16245..2cfb6e963 100644 --- a/crates/media/src/lib.rs +++ b/crates/media/src/lib.rs @@ -19,6 +19,7 @@ mod convert; pub mod exr_channels; mod gif_anim; mod layered; +mod open; pub use layered::vector_doc; mod pool; mod probe; diff --git a/crates/media/src/open.rs b/crates/media/src/open.rs new file mode 100644 index 000000000..a4edc7717 --- /dev/null +++ b/crates/media/src/open.rs @@ -0,0 +1,158 @@ +//! Opening movie and audio files without holding them in memory. +//! +//! Containers FilmCraft can read through a [`ByteReader`] (MP4/MOV, Matroska/WebM, MXF, Ogg, MPEG +//! TS/PS) are opened from the file itself: only their index is read now, and samples are read at +//! their offsets when they are decoded. Formats FilmCraft decodes in one go (WAV, MP3, FLAC, AIFF) +//! are read whole, once, into a single buffer. + +use std::sync::Arc; + +use filmcraft_media::SharedSource; +use filmcraft_media::reader::{ByteReader, HEAD_LEN, MemReader, SharedReader}; + +use crate::{MediaError, Result}; + +/// The file name of `path` (FilmCraft's openers sniff by its extension). +pub(crate) fn file_name(path: &str) -> String { + std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string()) +} + +/// A file read at any offset (positional reads, no shared cursor, so decoders on several threads +/// read at once). +#[cfg(not(target_arch = "wasm32"))] +pub(crate) struct FileReader { + #[cfg(any(unix, windows))] + file: std::fs::File, + #[cfg(not(any(unix, windows)))] + file: std::sync::Mutex, + len: u64, +} + +#[cfg(not(target_arch = "wasm32"))] +impl FileReader { + pub(crate) fn open(path: &str) -> std::io::Result { + let file = std::fs::File::open(path)?; + let len = file.metadata()?.len(); + #[cfg(not(any(unix, windows)))] + let file = std::sync::Mutex::new(file); + Ok(Self { file, len }) + } +} + +#[cfg(not(target_arch = "wasm32"))] +impl ByteReader for FileReader { + fn len(&self) -> u64 { + self.len + } + #[cfg(unix)] + fn read_at(&self, offset: u64, buf: &mut [u8]) -> std::io::Result<()> { + std::os::unix::fs::FileExt::read_exact_at(&self.file, buf, offset) + } + #[cfg(windows)] + fn read_at(&self, offset: u64, mut buf: &mut [u8]) -> std::io::Result<()> { + let mut at = offset; + while !buf.is_empty() { + match std::os::windows::fs::FileExt::seek_read(&self.file, buf, at) { + Ok(0) => return Err(std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "read past end of file")), + Ok(n) => { + let rest = std::mem::take(&mut buf); + buf = rest.get_mut(n..).unwrap_or_default(); + at = at.saturating_add(n as u64); + } + Err(e) if e.kind() == std::io::ErrorKind::Interrupted => {} + Err(e) => return Err(e), + } + } + Ok(()) + } + #[cfg(not(any(unix, windows)))] + fn read_at(&self, offset: u64, buf: &mut [u8]) -> std::io::Result<()> { + use std::io::{Read, Seek, SeekFrom}; + let mut f = self.file.lock().unwrap_or_else(|e| e.into_inner()); + f.seek(SeekFrom::Start(offset))?; + f.read_exact(buf) + } +} + +/// The first bytes of `r` (for sniffing). +pub(crate) fn head(r: &dyn ByteReader) -> std::io::Result> { + filmcraft_media::reader::read_range(r, 0, HEAD_LEN) +} + +/// All of `r` in one allocation: `Vec` → `Arc<[u8]>` copies, and a whole movie held twice at +/// once is what this module avoids. +pub(crate) fn read_whole(r: &dyn ByteReader) -> std::io::Result> { + let n = usize::try_from(r.len()).map_err(|_| std::io::Error::new(std::io::ErrorKind::OutOfMemory, "file too large"))?; + let mut all: Arc<[u8]> = std::iter::repeat_n(0u8, n).collect(); + let Some(buf) = Arc::get_mut(&mut all) else { return Err(std::io::Error::other("buffer is shared")) }; + r.read_at(0, buf)?; + Ok(all) +} + +/// Read a whole file into one `Arc<[u8]>` (a single allocation, no `Vec` copy). +#[cfg(not(target_arch = "wasm32"))] +pub(crate) fn read_file(path: &str) -> Result> { + let io = |e: std::io::Error| MediaError::Io(format!("{path}: {e}")); + let r = FileReader::open(path).map_err(io)?; + read_whole(&r).map_err(io) +} + +#[cfg(target_arch = "wasm32")] +pub(crate) fn read_file(path: &str) -> Result> { + std::fs::read(path).map(Into::into).map_err(|e| MediaError::Io(format!("{path}: {e}"))) +} + +/// Try FilmCraft's reader openers (containers read by their index): `None` when none of them +/// takes the file. +pub(crate) fn open_with_reader(name: &str, head: &[u8], reader: &SharedReader) -> Option> { + filmcraft_codecs::reader_openers().into_iter().find_map(|o| o(name, head, reader)).map(|r| r.map_err(MediaError::from)) +} + +/// Open the movie or audio file `path`: from `mem` when its contents are in memory, otherwise +/// from disk through a file reader (containers keep only their index in memory), else read whole. +pub(crate) fn open_source(path: &str, mem: Option>) -> Result { + let name = file_name(path); + let io = |e: std::io::Error| MediaError::Io(format!("{path}: {e}")); + let reader: SharedReader = match &mem { + Some(b) => Arc::new(MemReader(b.clone())), + #[cfg(not(target_arch = "wasm32"))] + None => Arc::new(FileReader::open(path).map_err(io)?), + #[cfg(target_arch = "wasm32")] + None => Arc::new(MemReader(read_file(path)?)), + }; + let head = head(&*reader).map_err(io)?; + if let Some(r) = open_with_reader(&name, &head, &reader) { + return r; + } + let bytes = match mem { + Some(b) => b, + None => read_whole(&*reader).map_err(io)?, + }; + drop(reader); + filmcraft_codecs::open_bytes(&name, bytes).map_err(MediaError::from) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn file_reader_reads_ranges_and_whole_files() { + let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/test-media-open"); + std::fs::create_dir_all(&dir).unwrap(); + let p = dir.join("bytes.bin"); + let data: Vec = (0..200_000u32).map(|i| (i * 7) as u8).collect(); + std::fs::write(&p, &data).unwrap(); + let path = p.to_string_lossy(); + let r = FileReader::open(&path).unwrap(); + assert_eq!(r.len(), data.len() as u64); + let mut b = [0u8; 5]; + r.read_at(100_000, &mut b).unwrap(); + assert_eq!(&b, &data[100_000..100_005]); + assert_eq!(r.read_at(199_998, &mut b).unwrap_err().kind(), std::io::ErrorKind::UnexpectedEof); + assert_eq!(head(&r).unwrap(), &data[..HEAD_LEN]); + assert_eq!(&*read_file(&path).unwrap(), &data[..]); + assert!(read_file(&dir.join("missing.bin").to_string_lossy()).is_err()); + let _ = std::fs::remove_file(&p); + } +} diff --git a/crates/media/src/pool.rs b/crates/media/src/pool.rs index ce980c563..731d3aa79 100644 --- a/crates/media/src/pool.rs +++ b/crates/media/src/pool.rs @@ -563,22 +563,21 @@ impl Inner { if let Some(b) = lock(&self.files).get(path) { return Ok(b.clone()); } - std::fs::read(path).map(Into::into).map_err(|e| MediaError::Io(format!("{path}: {e}"))) + crate::open::read_file(path) } /// The opened movie/audio source for `path`. The first caller opens it, outside the map's - /// lock (reading and parsing a large file takes a moment; FLAC, MP3, Ogg and AIFF files are - /// decoded whole); callers asking for the same file meanwhile wait for that instead of opening - /// it again: a preview's frame workers, audio feeder, conform and waveform threads each - /// decoded a long FLAC in full at once (#408). + /// lock (parsing a large file takes a moment; FLAC, MP3 and AIFF files are decoded whole); + /// callers asking for the same file meanwhile wait for that instead of opening it again: a + /// preview's frame workers, audio feeder, conform and waveform threads each decoded a long + /// FLAC in full at once (#408). Containers are read through the file (index now, samples on + /// demand), not held in memory. fn source(&self, path: &Arc) -> Option { let slot = lock(&self.sources).entry(path.clone()).or_default().clone(); slot.get_or_init(|| { self.opened.fetch_add(1, Ordering::Relaxed); - let opened = self.read(path).and_then(|bytes| { - let name = std::path::Path::new(&**path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); - filmcraft_codecs::open_bytes(&name, bytes).map_err(MediaError::from) - }); + let mem = lock(&self.files).get(&**path).cloned(); + let opened = crate::open::open_source(path, mem); match opened { Ok(s) => Some(s), Err(e) => { diff --git a/crates/media/src/probe.rs b/crates/media/src/probe.rs index 6e8718fdf..09560f671 100644 --- a/crates/media/src/probe.rs +++ b/crates/media/src/probe.rs @@ -13,14 +13,107 @@ pub const DEFAULT_SEQUENCE_RATE: FrameRate = FrameRate::FPS_30; /// Probe one file. Image sequences are made by the engine, which groups numbered stills /// (`effectcraft_engine::sequence`) and probes their first frame here. +/// +/// Movies are probed from their index (read through the file, not loaded whole) and standalone +/// compressed audio from its packet headers (not decoded); stills are read whole. pub fn probe(path: impl AsRef) -> Result { let path = path.as_ref(); - let bytes = std::fs::read(path).map_err(|e| MediaError::Io(format!("{}: {e}", path.display())))?; + let p = path.to_string_lossy(); if crate::MODEL_EXTENSIONS.contains(&ext_of(path).as_str()) { + let bytes = crate::open::read_file(&p)?; let dir = path.parent().map(Path::to_path_buf).unwrap_or_default(); - return probe_model(&path.to_string_lossy(), &bytes, &|uri| std::fs::read(dir.join(uri)).ok()); + return probe_model(&p, &bytes, &|uri| std::fs::read(dir.join(uri)).ok()); } - probe_bytes(&path.to_string_lossy(), bytes.into()) + #[cfg(not(target_arch = "wasm32"))] + { + use filmcraft_media::reader::SharedReader; + let io = |e: std::io::Error| MediaError::Io(format!("{}: {e}", path.display())); + let file = crate::open::FileReader::open(&p).map_err(io)?; + let head = crate::open::head(&file).map_err(io)?; + if !is_still(&p, &head) { + let name = crate::open::file_name(&p); + let reader: SharedReader = Arc::new(file); + if let Some(src) = crate::open::open_with_reader(&name, &head, &reader) { + return Ok(footage_from_info(&p, src?.info())); + } + if let Some(f) = probe_audio_headers(path, &name) { + return Ok(f); + } + let bytes = crate::open::read_whole(&*reader).map_err(io)?; + return probe_bytes(&p, bytes); + } + let bytes = crate::open::read_whole(&file).map_err(io)?; + probe_bytes(&p, bytes) + } + #[cfg(target_arch = "wasm32")] + probe_bytes(&p, crate::open::read_file(&p)?) +} + +/// Whether [`probe_bytes`] takes a file starting with `head` as a still (or a layered / vector +/// document) rather than handing it to FilmCraft. +#[cfg_attr(target_arch = "wasm32", allow(dead_code))] +fn is_still(path: &str, head: &[u8]) -> bool { + let ext = ext_of(Path::new(path)); + crate::STILL_EXTENSIONS.contains(&ext.as_str()) || crate::layered::claims(path, head) || crate::avif::sniff(head) || image::guess_format(head).is_ok() +} + +/// A standalone MP3 / FLAC / Ogg / AIFF file described from its headers and packet framing, +/// without decoding it (FilmCraft decodes these whole when they are opened): `None` when the file +/// isn't one, or doesn't read cleanly (then it is probed by opening it, as before). +/// +/// The duration is the sum of the packets' durations, which is what decoding them yields: FLAC's +/// header gives it directly. +#[cfg(not(target_arch = "wasm32"))] +fn probe_audio_headers(path: &Path, name: &str) -> Option { + use symphonia::core::codecs::CODEC_TYPE_FLAC; + use symphonia::core::formats::FormatOptions; + use symphonia::core::io::MediaSourceStream; + use symphonia::core::meta::MetadataOptions; + use symphonia::core::probe::Hint; + let ext = ext_of(path); + if !matches!(ext.as_str(), "mp3" | "mp2" | "flac" | "ogg" | "oga" | "aif" | "aiff" | "aifc") { + return None; + } + let file = std::fs::File::open(path).ok()?; + let size = file.metadata().ok()?.len(); + let mss = MediaSourceStream::new(Box::new(file), Default::default()); + let mut hint = Hint::new(); + hint.with_extension(&ext); + let mut format = symphonia::default::get_probe().format(&hint, mss, &FormatOptions::default(), &MetadataOptions::default()).ok()?.format; + let track = format.default_track()?.clone(); + let rate = track.codec_params.sample_rate?; + let frames = match track.codec_params.n_frames { + Some(n) if n > 0 && track.codec_params.codec == CODEC_TYPE_FLAC => n, + _ => { + let mut n = 0u64; + loop { + match format.next_packet() { + Ok(pkt) if pkt.track_id() == track.id => n = n.saturating_add(pkt.dur), + Ok(_) => {} + Err(symphonia::core::errors::Error::IoError(e)) if e.kind() == std::io::ErrorKind::UnexpectedEof => break, + // A damaged stream: open it the usual way (decoding skips what it can't read). + Err(_) => return None, + } + } + n + } + }; + let channels = track.codec_params.channels.map(|c| c.count() as u32)?; + if frames == 0 || rate == 0 || channels == 0 { + return None; + } + let codec = symphonia::default::get_codecs().get_codec(track.codec_params.codec).map(|d| d.short_name.to_uppercase()).unwrap_or_else(|| ext.to_uppercase()); + let info = filmcraft_media::MediaInfo { + name: name.to_string(), + kind: filmcraft_media::MediaKind::AudioOnly, + duration: filmcraft_time::Tick::from_units(i64::try_from(frames).ok()?, i64::from(rate)), + video: None, + audio: Some(filmcraft_media::AudioStreamInfo { sample_rate: rate, channels, codec, bits_per_sample: track.codec_params.bits_per_sample }), + container: ext.to_uppercase(), + start_timecode: None, + file_size: Some(size), + }; + Some(footage_from_info(&path.to_string_lossy(), &info)) } /// Probe a 3D model (glTF 2.0 / OBJ): a [`FootageKind::Model`] item whose duration is its diff --git a/crates/media/tests/footage.rs b/crates/media/tests/footage.rs index d26b1a1d1..dda67341b 100644 --- a/crates/media/tests/footage.rs +++ b/crates/media/tests/footage.rs @@ -674,3 +674,23 @@ fn conformed_flac_audio_reads_back_like_the_decoder() { assert_eq!(pool.audio_samples(&f, at, 4800, 48_000), decoded); let _ = std::fs::remove_dir_all(&folder); } + +/// Probing reads standalone audio from its headers and packet framing (no decode) and movies +/// from their index (through the file): it describes them exactly as opening the whole file in +/// memory does. +#[test] +fn header_probe_matches_a_whole_file_probe() { + let Some(flac) = sine_flac(5, 44_100, 2, "s16") else { return }; + let mp3 = ffmpeg_fixture("sine_3s.mp3", &["-f", "lavfi", "-i", "sine=frequency=440:sample_rate=44100:duration=3", "-c:a", "libmp3lame", "-b:a", "128k"]); + let mp3_cbr_no_tag = ffmpeg_fixture( + "sine_2s_notag.mp3", + &["-f", "lavfi", "-i", "sine=frequency=440:sample_rate=48000:duration=2", "-c:a", "libmp3lame", "-write_xing", "0"], + ); + let files = [Some(flac), mp3, mp3_cbr_no_tag, h264_mp4(), vp9_webm(), tone_wav(), alpha_png()]; + for p in files.into_iter().flatten() { + let fast = probe(&p).expect("probe"); + let bytes: Arc<[u8]> = std::fs::read(&p).expect("read").into(); + let whole = effectcraft_media::probe_bytes(&p.to_string_lossy(), bytes).expect("probe bytes"); + assert_eq!(fast, whole, "{}", p.display()); + } +} From e10400e53df550bf463afe4d4700c7026a32767e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 11:13:35 +0000 Subject: [PATCH 28/45] G5: header probe without the AVIF sniff (no avif module upstream) (cherry picked from commit c534678c3034f68a1bd49574f9f36f0d480b8010) (cherry picked from commit 4a05bd39b6b0599fd7197bda2cd61229a0dfe471) --- crates/media/src/probe.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/media/src/probe.rs b/crates/media/src/probe.rs index 09560f671..06765b539 100644 --- a/crates/media/src/probe.rs +++ b/crates/media/src/probe.rs @@ -54,7 +54,7 @@ pub fn probe(path: impl AsRef) -> Result { #[cfg_attr(target_arch = "wasm32", allow(dead_code))] fn is_still(path: &str, head: &[u8]) -> bool { let ext = ext_of(Path::new(path)); - crate::STILL_EXTENSIONS.contains(&ext.as_str()) || crate::layered::claims(path, head) || crate::avif::sniff(head) || image::guess_format(head).is_ok() + crate::STILL_EXTENSIONS.contains(&ext.as_str()) || crate::layered::claims(path, head) || image::guess_format(head).is_ok() } /// A standalone MP3 / FLAC / Ogg / AIFF file described from its headers and packet framing, From bcc35f29989386c4097cf350f8e3a548482c2256 Mon Sep 17 00:00:00 2001 From: Elias Date: Sat, 10 Oct 2026 17:02:24 +0100 Subject: [PATCH 29/45] G5: GPU upload cache drops textures of freed layer buffers on every upload; upload/pool budgets follow the Memory & CPU layer budget (cherry picked from commit 39144159402080543af219845c85df3decd5af24) (cherry picked from commit 9cae1d27166b4cf9527f370501701ea0e405e98c) --- crates/gpu/src/context.rs | 110 +++++++++++++++++++++++++++++++------- crates/ui-egui/src/lib.rs | 7 ++- 2 files changed, 98 insertions(+), 19 deletions(-) diff --git a/crates/gpu/src/context.rs b/crates/gpu/src/context.rs index 788757bfe..2927f2c9b 100644 --- a/crates/gpu/src/context.rs +++ b/crates/gpu/src/context.rs @@ -37,10 +37,10 @@ const EXT_ENTRIES: &[&str] = &["classic3d"]; /// 8/16 bpc are emulated by clamping and quantising, as on the CPU). pub const FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba32Float; -/// Uploaded layer buffers kept on the GPU (bytes). +/// Uploaded layer buffers kept on the GPU (bytes), at most (see [`GpuContext::set_cache_budget`]). const UPLOAD_BUDGET: usize = 1536 << 20; -/// Working textures kept for reuse once their last image is dropped (bytes). +/// Working textures kept for reuse once their last image is dropped (bytes), at most. const POOL_BUDGET: usize = 768 << 20; /// A GPU image: premultiplied RGBA f32 in a texture (cheap to clone; immutable once written). @@ -102,6 +102,8 @@ pub(crate) struct Pool { open: BTreeSet, next: u64, bytes: usize, + /// Byte budget of the free textures (`None`: [`POOL_BUDGET`]). + budget: Option, /// Working textures reused / newly allocated (diagnostics, tests). hits: u64, misses: u64, @@ -148,7 +150,7 @@ impl Pool { fn make_free(&mut self, t: wgpu::Texture) { let b = tex_bytes(&t); - if self.bytes + b > POOL_BUDGET { + if self.bytes + b > self.budget.unwrap_or(POOL_BUDGET) { return; } self.bytes += b; @@ -191,6 +193,18 @@ impl Pool { self.free.clear(); self.bytes = 0; } + + /// Keep at most `budget` bytes of free textures from now on (drops the excess now). + fn set_budget(&mut self, budget: usize) { + self.budget = Some(budget); + for list in self.free.values_mut() { + while self.bytes > budget { + let Some(t) = list.pop() else { break }; + self.bytes = self.bytes.saturating_sub(tex_bytes(&t)); + } + } + self.free.retain(|_, l| !l.is_empty()); + } } /// Uniform parameter block shared by every kernel (see `shaders/common.wgsl`). @@ -229,6 +243,27 @@ struct Uploads { map: HashMap, bytes: usize, clock: u64, + /// Byte budget (`None`: [`UPLOAD_BUDGET`]). + budget: Option, +} + +impl Uploads { + /// Drop textures whose layer buffer is gone (evicted from the layer cache, an older frame's), + /// then the least recently used over budget. + fn trim(&mut self) { + let before = self.map.len(); + self.map.retain(|_, e| e.buf.strong_count() > 0); + if self.map.len() != before { + self.bytes = self.map.values().map(|e| e.bytes).sum(); + } + let budget = self.budget.unwrap_or(UPLOAD_BUDGET); + while self.bytes > budget { + let Some(k) = self.map.iter().min_by_key(|(_, e)| e.last_use).map(|(k, _)| *k) else { break }; + if let Some(e) = self.map.remove(&k) { + self.bytes = self.bytes.saturating_sub(e.bytes); + } + } + } } /// The device and everything compiled for it. @@ -872,25 +907,31 @@ impl GpuContext { u.bytes -= old.bytes; } u.bytes += bytes; - // Drop textures of freed buffers, then the least recently used over budget. - if u.bytes > UPLOAD_BUDGET { - let dead: Vec = u.map.iter().filter(|(_, e)| e.buf.strong_count() == 0).map(|(k, _)| *k).collect(); - for k in dead { - if let Some(e) = u.map.remove(&k) { - u.bytes -= e.bytes; - } - } - } - while u.bytes > UPLOAD_BUDGET { - let Some(k) = u.map.iter().min_by_key(|(_, e)| e.last_use).map(|(k, _)| *k) else { break }; - if let Some(e) = u.map.remove(&k) { - u.bytes -= e.bytes; - } - } + u.trim(); } Some(img) } + /// Size the GPU-side caches after the layer cache's memory budget (Memory & CPU settings, + /// after RAM Reserved for Other Applications): uploaded layer textures mirror cached layer + /// buffers, so they get at most the same budget (capped at [`UPLOAD_BUDGET`]), and free + /// working textures half of it (capped at [`POOL_BUDGET`]). On unified memory (Apple + /// silicon) both come out of system RAM. + pub fn set_cache_budget(&self, layer_cache: usize) { + if let Ok(mut u) = self.uploads.lock() { + u.budget = Some(layer_cache.min(UPLOAD_BUDGET)); + u.trim(); + } + if let Ok(mut p) = self.pool.lock() { + p.set_budget((layer_cache / 2).min(POOL_BUDGET)); + } + } + + /// Uploaded layer textures kept: (count, bytes) (diagnostics, tests). + pub fn upload_stats(&self) -> (usize, usize) { + self.uploads.lock().map(|u| (u.map.len(), u.bytes)).unwrap_or_default() + } + /// Forget uploaded layer textures. pub fn clear_uploads(&self) { if let Ok(mut u) = self.uploads.lock() { @@ -1301,3 +1342,36 @@ impl<'g> Enc<'g> { pub(crate) fn mip_levels(w: u32, h: u32) -> u32 { u32::BITS - w.max(h).max(1).leading_zeros() } + +#[cfg(test)] +mod upload_budget_tests { + use super::*; + + fn buf(w: u32) -> Arc { + Arc::new(Buf { img: Image::filled(w, 8, [0.5, 0.25, 0.125, 0.5]), offset: [0.0; 2], scale: 1.0 }) + } + + /// Textures of layer buffers that are gone are dropped at the next upload, not only once the + /// upload budget overflows; the budget follows the layer cache's. + #[test] + fn dead_uploads_are_purged_and_budget_follows_prefs() { + crate::tests::hold_gpu_lock(); + let Some(g) = GpuContext::headless() else { return }; + let a = buf(8); + let b = buf(16); + assert!(g.upload_buf(&a).is_some() && g.upload_buf(&b).is_some()); + assert_eq!(g.upload_stats(), (2, (8 + 16) * 8 * 16)); + drop(a); + let c = buf(4); + assert!(g.upload_buf(&c).is_some()); + assert_eq!(g.upload_stats(), (2, (16 + 4) * 8 * 16), "a's texture dropped"); + // A budget below b + c keeps the most recently used. + g.set_cache_budget(4 * 8 * 16 + 100); + assert_eq!(g.upload_stats(), (1, 4 * 8 * 16)); + // Re-uploading the same live buffer is a hit. + let ups = g.transfer_stats().uploads; + assert!(g.upload_buf(&c).is_some()); + assert_eq!(g.transfer_stats().uploads, ups); + drop(b); + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index a0e05e007..d30fafdb1 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -468,7 +468,11 @@ impl EffectcraftApp { ctx.set_zoom_factor(p.appearance.ui_scale as f32 / 100.0); self.ui.cache_when_idle = p.previews.cache_frames_when_idle; self.ui.viewer.fast_preview = p.previews.fast_previews; - self.frames.set_budget(p.cache_budgets(self.session.sys_memory).preview); + let budgets = p.cache_budgets(self.session.sys_memory); + self.frames.set_budget(budgets.preview); + if let Some(g) = &self.gpu { + g.context().set_cache_budget(budgets.layer); + } } /// Change settings from the UI (applied next frame and saved). @@ -741,6 +745,7 @@ impl EffectcraftApp { } } log::info!("GPU compositor: {}", effectcraft_engine::render::Accelerator::name(&g)); + g.context().set_cache_budget(self.session.prefs.cache_budgets(self.session.sys_memory).layer); self.session.accel = Some(Arc::new(g.clone())); self.gpu = Some(g); self.wgpu = Some(rs.clone()); From d572c29ccb18b856e58090e07823faa0f0813e59 Mon Sep 17 00:00:00 2001 From: Elias Date: Sat, 10 Oct 2026 17:02:25 +0100 Subject: [PATCH 30/45] G5: Roto Brush segmentation cache follows the memory budget; disk-cache write queue bounded by bytes (cherry picked from commit 45c75c383cf4572ef4c7d790b8aa0c154bb86f34) (cherry picked from commit e07abb847fbd520b3efdff9b4f19cfb37a77548d) --- crates/effects/src/roto.rs | 63 ++++++++++++++++++++++++--- crates/engine/src/session_settings.rs | 2 + crates/render/src/disk_cache.rs | 58 ++++++++++++++++++++++-- 3 files changed, 113 insertions(+), 10 deletions(-) diff --git a/crates/effects/src/roto.rs b/crates/effects/src/roto.rs index d514e80a1..9460835c5 100644 --- a/crates/effects/src/roto.rs +++ b/crates/effects/src/roto.rs @@ -231,8 +231,39 @@ struct SegCache { bytes: usize, } +/// Bytes of segmentations kept at most (until [`set_cache_budget`]). const CACHE_BYTES: usize = 768 << 20; +static CACHE_BUDGET: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(CACHE_BYTES); + +/// Keep at most `bytes` of segmentations (at most [`CACHE_BYTES`]); the oldest go now if the +/// cache holds more. The app sizes it after its Memory & CPU budgets. +pub fn set_cache_budget(bytes: usize) { + CACHE_BUDGET.store(bytes.min(CACHE_BYTES), std::sync::atomic::Ordering::Relaxed); + let mut c = cache().lock().unwrap_or_else(|e| e.into_inner()); + trim(&mut c); +} + +/// Segmentations kept: (count, bytes). +pub fn cache_stats() -> (usize, usize) { + let c = cache().lock().unwrap_or_else(|e| e.into_inner()); + (c.map.len(), c.bytes) +} + +fn seg_bytes(s: &FrameSeg) -> usize { + s.matte.len() + s.refine.len() +} + +fn trim(c: &mut SegCache) { + let budget = CACHE_BUDGET.load(std::sync::atomic::Ordering::Relaxed); + while c.bytes > budget { + let Some(old) = c.order.pop_front() else { break }; + if let Some(s) = c.map.remove(&old) { + c.bytes = c.bytes.saturating_sub(seg_bytes(&s)); + } + } +} + fn cache() -> &'static Mutex { static C: OnceLock> = OnceLock::new(); C.get_or_init(|| Mutex::new(SegCache { map: HashMap::new(), order: VecDeque::new(), bytes: 0 })) @@ -249,15 +280,10 @@ pub fn store(key: u64, scale: f64, seg: Arc) { if c.map.contains_key(&k) { return; } - c.bytes += seg.matte.len() + seg.refine.len(); + c.bytes += seg_bytes(&seg); c.map.insert(k, seg); c.order.push_back(k); - while c.bytes > CACHE_BYTES { - let Some(old) = c.order.pop_front() else { break }; - if let Some(s) = c.map.remove(&old) { - c.bytes -= s.matte.len() + s.refine.len(); - } - } + trim(&mut c); } /// Drop the cached segmentations of these chain keys (every scale). @@ -761,3 +787,26 @@ mod tests { assert_eq!(f(¶ms, "rotoBrushPropagation/motionDamping"), 20.0); } } + +#[cfg(test)] +mod budget_tests { + use super::*; + + /// The segmentation cache follows the budget it is given (oldest dropped first) and never + /// exceeds 768 MB. + #[test] + fn segmentation_cache_follows_its_budget() { + let seg = |n: usize| Arc::new(FrameSeg { w: n, h: 1, matte: vec![0; n], refine: vec![0; n], ..Default::default() }); + let base = 0x5eed_0000_0000_u64; + for i in 0..4 { + store(base + i, 1.0, seg(1000)); + } + set_cache_budget(2500); + assert!(cache_stats().1 <= 2500); + assert!(cached(base + 3, 1.0).is_some(), "the newest stays"); + assert!(cached(base, 1.0).is_none(), "the oldest went"); + set_cache_budget(usize::MAX); + assert_eq!(CACHE_BUDGET.load(std::sync::atomic::Ordering::Relaxed), CACHE_BYTES); + forget((0..4).map(|i| base + i)); + } +} diff --git a/crates/engine/src/session_settings.rs b/crates/engine/src/session_settings.rs index 34f24786a..34752a076 100644 --- a/crates/engine/src/session_settings.rs +++ b/crates/engine/src/session_settings.rs @@ -77,6 +77,8 @@ impl Session { let b = self.prefs.cache_budgets(self.sys_memory); self.layer_cache.set_budget(b.layer); self.footage.set_cache_budget(b.media); + // Roto Brush segmentations (process-wide): half the layer cache's budget, at most 768 MB. + effectcraft_effects::roto::set_cache_budget(b.layer / 2); } /// Take the latest reading of the system's memory and start the next one in the background diff --git a/crates/render/src/disk_cache.rs b/crates/render/src/disk_cache.rs index da46708e7..9c7062528 100644 --- a/crates/render/src/disk_cache.rs +++ b/crates/render/src/disk_cache.rs @@ -32,6 +32,11 @@ const MAGIC: &[u8; 4] = b"ECDC"; /// Pending writes before new ones are dropped. #[cfg(not(target_arch = "wasm32"))] const QUEUE_LIMIT: usize = 64; +/// Bytes of pending (compressed) writes before new ones are dropped: a 4K `f32` layer can +/// compress to tens of MB, so 64 of them could hold gigabytes. A single larger write still +/// goes when nothing else is pending. +#[cfg(not(target_arch = "wasm32"))] +const QUEUE_BYTES: usize = 256 << 20; #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub enum Kind { @@ -212,9 +217,19 @@ enum Job { struct Queue { jobs: std::collections::VecDeque, + /// Payload bytes in `jobs`. + bytes: usize, busy: bool, } +impl Queue { + /// No room for another write of `bytes` (see [`QUEUE_LIMIT`], [`QUEUE_BYTES`]). + #[cfg(not(target_arch = "wasm32"))] + fn full(&self, bytes: usize) -> bool { + self.jobs.len() >= QUEUE_LIMIT || (!self.jobs.is_empty() && self.bytes.saturating_add(bytes) > QUEUE_BYTES) + } +} + pub struct DiskCache { dir: PathBuf, index: Mutex, @@ -298,7 +313,7 @@ impl DiskCache { evictions: AtomicU64::new(0), dropped: 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(), bytes: 0, busy: false }), Condvar::new())), min_layer_ms: AtomicU64::new(30), }); dc.rescan(); @@ -415,6 +430,7 @@ impl DiskCache { #[cfg(target_arch = "wasm32")] { q.jobs.clear(); + q.bytes = 0; } while !q.jobs.is_empty() || q.busy { q = match cv.wait(q) { @@ -472,10 +488,11 @@ impl DiskCache { { let (m, cv) = &*self.queue; let Ok(mut q) = m.lock() else { return }; - if q.jobs.len() >= QUEUE_LIMIT { + if q.full(payload.len()) { self.dropped.fetch_add(1, Ordering::Relaxed); return; } + q.bytes += payload.len(); q.jobs.push_back(Job::Write(kind, key, payload)); cv.notify_all(); } @@ -544,12 +561,27 @@ impl DiskCache { /// Queue a layer buffer for writing. pub fn put_layer(&self, key: u128, buf: &Buf) { - if self.contains(Kind::Layer, key) { + if self.contains(Kind::Layer, key) || self.queue_full() { return; } self.enqueue(Kind::Layer, key, encode_layer(buf)); } + /// The write queue has no room (checked before encoding a layer, which costs a pass over + /// its pixels and a compressed copy). + fn queue_full(&self) -> bool { + #[cfg(not(target_arch = "wasm32"))] + { + let full = self.queue.0.lock().map(|q| q.full(0)).unwrap_or(true); + if full { + self.dropped.fetch_add(1, Ordering::Relaxed); + } + full + } + #[cfg(target_arch = "wasm32")] + false + } + /// Write a layer buffer synchronously (tests). pub fn put_layer_now(&self, key: u128, buf: &Buf) { self.write_now(Kind::Layer, key, &encode_layer(buf)); @@ -569,6 +601,8 @@ fn writer(dc: std::sync::Weak, q: Arc<(Mutex, Condvar)>) { let Ok(mut g) = m.lock() else { return }; loop { if let Some(j) = g.jobs.pop_front() { + let Job::Write(_, _, p) = &j; + g.bytes = g.bytes.saturating_sub(p.len()); g.busy = true; break j; } @@ -855,6 +889,24 @@ mod tests { Buf { img, offset: [1.5, -2.25], scale: 0.5 } } + /// The write queue is bounded by bytes as well as by count; one oversized write still goes + /// when nothing else is pending. + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn write_queue_is_bounded_by_bytes() { + let mut q = Queue { jobs: Default::default(), bytes: 0, busy: false }; + assert!(!q.full(QUEUE_BYTES * 2), "empty: one big write goes"); + q.jobs.push_back(Job::Write(Kind::Layer, 1, vec![])); + q.bytes = QUEUE_BYTES - 10; + assert!(!q.full(10)); + assert!(q.full(11)); + q.bytes = 0; + for k in 1..QUEUE_LIMIT { + q.jobs.push_back(Job::Write(Kind::Layer, k as u128, vec![])); + } + assert!(q.full(0), "count limit"); + } + #[test] fn index_evicts_least_recently_used() { let mut ix = DiskIndex::default(); From 9a0e7d6b7b38943e90806141a2b96382df4625bc Mon Sep 17 00:00:00 2001 From: Elias Date: Sat, 10 Oct 2026 17:03:17 +0100 Subject: [PATCH 31/45] G5: viewers upload shared frames without copying their pixels; passive viewers share the comp instead of cloning it every frame (cherry picked from commit c0b5490ba68ba0654d65fdeab37f1cce39bec311) (cherry picked from commit 1bdb90a876600470922330fc4f211ccca486f024) --- crates/ui-egui/src/frames.rs | 23 ++++++++++++++++++++++- crates/ui-egui/src/panels/viewer.rs | 2 +- crates/ui-egui/src/panels/viewers.rs | 6 +++--- crates/ui-egui/src/prefs_live.rs | 4 ++-- 4 files changed, 28 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/frames.rs b/crates/ui-egui/src/frames.rs index ccffeb9f9..da85ce057 100644 --- a/crates/ui-egui/src/frames.rs +++ b/crates/ui-egui/src/frames.rs @@ -460,11 +460,26 @@ pub fn fit_texture(img: egui::ColorImage, max_side: usize) -> egui::ColorImage { /// [`fit_texture`] averaging down by at least `min_k` (see [`fit_factor`]). pub fn fit_texture_by(img: egui::ColorImage, max_side: usize, min_k: usize) -> egui::ColorImage { - let [w, h] = img.size; let k = fit_factor(img.size, max_side, min_k); if k == 1 { return img; } + average_down(&img, k) +} + +/// [`fit_texture_by`] for a shared frame: one that fits is handed to egui as it is (no copy of +/// its pixels; egui uploads straight from the `Arc`). +pub fn fit_texture_shared(img: &Arc, max_side: usize, min_k: usize) -> Arc { + let k = fit_factor(img.size, max_side, min_k); + if k == 1 { + return Arc::clone(img); + } + Arc::new(average_down(img, k)) +} + +/// `img` averaged down by `k` (rounded up at the edges). +fn average_down(img: &egui::ColorImage, k: usize) -> egui::ColorImage { + let [w, h] = img.size; let (fw, fh) = (w.div_ceil(k), h.div_ceil(k)); let px: Vec = (0..fw * fh) .into_par_iter() @@ -1188,6 +1203,12 @@ mod tests { assert_eq!(fit_texture(img.clone(), 5).pixels, img.pixels); let mixed = fit_texture(egui::ColorImage::new([2, 1], vec![w, b]), 1); assert_eq!(mixed.pixels, vec![egui::Color32::from_rgba_premultiplied(128, 128, 128, 255)]); + // A shared frame that fits goes to egui as it is (no pixel copy); one that doesn't is + // averaged down exactly like an owned one. + let shared = Arc::new(img.clone()); + assert!(Arc::ptr_eq(&fit_texture_shared(&shared, 5, 1), &shared)); + assert_eq!(fit_texture_shared(&shared, 2, 1).pixels, out.pixels); + assert_eq!(fit_texture_shared(&shared, 5, 2).pixels, fit_texture_by(img, 5, 2).pixels); } #[test] diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index 5fe7697c9..6f66061d6 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -2389,7 +2389,7 @@ fn show_frame(app: &mut EffectcraftApp, ctx: &egui::Context, key: crate::frames: FrameImage::Cpu(img) => { // Frames wider or taller than the GPU's texture limit go up averaged down too (#201). let max = crate::frames::max_texture_side(ctx); - let fitted = crate::frames::fit_texture_by((*img).clone(), max, minify); + let fitted = crate::frames::fit_texture_shared(&img, max, minify); match &mut app.viewer_tex { Some((tex, k, m)) if tex.size() == fitted.size => { tex.set(fitted, opts); diff --git a/crates/ui-egui/src/panels/viewers.rs b/crates/ui-egui/src/panels/viewers.rs index 8d2351330..a63e7342d 100644 --- a/crates/ui-egui/src/panels/viewers.rs +++ b/crates/ui-egui/src/panels/viewers.rs @@ -91,7 +91,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, id: u32, rect: Rect) { p.rect_filled(rect, 0.0, t.pasteboard); let resp = ui.interact(rect, egui::Id::new(("passive-viewer", id)), Sense::click()).on_hover_text(tr("Click to make this viewer active")); app.auto.add(&format!("viewers.{id}"), rect, "Composition viewer (click to make it active)"); - match comp_of(app, id).and_then(|cid| Some((cid, app.session.project.comp(cid)?.clone()))) { + match comp_of(app, id).and_then(|cid| Some((cid, app.session.project.comp_arc(cid)?))) { Some((cid, c)) => { let (cw, ch) = ((c.width.max(1) as f64 * c.pixel_aspect.max(0.01)) as f32, c.height.max(1) as f32); let fit = ((rect.width() - 24.0) / cw).min((rect.height() - 24.0) / ch).max(0.01); @@ -164,12 +164,12 @@ fn set_texture( (FrameImage::Cpu(img), Some((_, PassiveTexture::Cpu(mut h)))) if h.size() == crate::frames::fitted_size(img.size, crate::frames::max_texture_side(ctx)) => { - h.set(crate::frames::fit_texture((*img).clone(), crate::frames::max_texture_side(ctx)), opts); + h.set(crate::frames::fit_texture_shared(&img, crate::frames::max_texture_side(ctx), 1), opts); PassiveTexture::Cpu(h) } (FrameImage::Cpu(img), old) => { free(app, old.map(|(_, t)| t)); - PassiveTexture::Cpu(crate::frames::load_fitted(ctx, format!("viewer-{id}"), (*img).clone(), opts)) + PassiveTexture::Cpu(ctx.load_texture(format!("viewer-{id}"), crate::frames::fit_texture_shared(&img, crate::frames::max_texture_side(ctx), 1), opts)) } (FrameImage::Gpu(f), old) => { let Some(rs) = &app.wgpu else { return }; diff --git a/crates/ui-egui/src/prefs_live.rs b/crates/ui-egui/src/prefs_live.rs index b18d3e874..acaa2b7af 100644 --- a/crates/ui-egui/src/prefs_live.rs +++ b/crates/ui-egui/src/prefs_live.rs @@ -175,10 +175,10 @@ fn video_preview(app: &mut EffectcraftApp, ctx: &egui::Context) { let opts = crate::panels::viewer::zoom_texture_options(app.session.prefs.viewer_zoom_smooth()); let t = match prev { Some((_, mut t)) => { - t.set(crate::frames::fit_texture((*img).clone(), crate::frames::max_texture_side(ctx)), opts); + t.set(crate::frames::fit_texture_shared(&img, crate::frames::max_texture_side(ctx), 1), opts); t } - None => crate::frames::load_fitted(ctx, "video-preview", (*img).clone(), opts), + None => ctx.load_texture("video-preview", crate::frames::fit_texture_shared(&img, crate::frames::max_texture_side(ctx), 1), opts), }; ctx.data_mut(|d| d.insert_temp(tex_id, (k, t.clone()))); Some(t) From 896f92792e9972f7d506c531c66b2b77f419d0de Mon Sep 17 00:00:00 2001 From: Elias Date: Sat, 10 Oct 2026 17:03:18 +0100 Subject: [PATCH 32/45] G5: a playing preview repaints when its next frame is due, not at the display refresh rate (cherry picked from commit f171eb58fd8d3178b1b207d8ae8da5a04a10ab68) (cherry picked from commit f5efa7b0659e8dcc4a700a9275fa6d2daec9a152) --- crates/ui-egui/src/lib.rs | 58 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 54 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index d30fafdb1..d870d36c3 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -51,6 +51,21 @@ const SCREENSHOT_TIMEOUT_S: f64 = 4.0; /// [`EffectcraftApp::editing`]). const EDIT_QUIET: f64 = 0.3; +/// The longest a playing preview waits for its next UI frame (the meters, the end of the +/// preview, a frame that finished rendering without waking the UI). +const PLAYBACK_MAX_WAIT: std::time::Duration = std::time::Duration::from_millis(50); + +/// The repaint delay for a playing preview whose next frame is due in `secs`: the UI rebuilds +/// at the preview's frame rate instead of the display's refresh rate. A hair late rather than +/// early (an early wake changes nothing and needs another), never a sub-millisecond spin. +fn until_next_frame(secs: f64) -> std::time::Duration { + const MIN: f64 = 0.001; + if !secs.is_finite() { + return PLAYBACK_MAX_WAIT; + } + std::time::Duration::from_secs_f64((secs + 0.0005).clamp(MIN, PLAYBACK_MAX_WAIT.as_secs_f64())) +} + /// Modal dialogs. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Dialog { @@ -1180,8 +1195,12 @@ impl EffectcraftApp { self.playback.start_frame = p.first; self.session.set_time(fr.tick_of(p.first)); self.start_audio(fr.tick_of(p.first)); + ctx.request_repaint(); + } else { + // Each finished frame wakes the UI (`Frames::release`) to queue the next ones; + // this is only the fallback. + ctx.request_repaint_after(PLAYBACK_MAX_WAIT); } - ctx.request_repaint(); return; } let step = plan.map_or(1, |p| p.step); @@ -1269,12 +1288,19 @@ impl EffectcraftApp { self.playback.shown_at.clear(); self.playback.start_wall = now; self.playback.start_frame = cur; + ctx.request_repaint(); + return; } - ctx.request_repaint(); + // Wake when the audible sample reaches the next frame (not at the display's refresh + // rate): a 24 fps preview rebuilds the UI 24 times a second, not 120. + let next = fr.tick_of(target.saturating_add(step.max(1))).seconds(); + ctx.request_repaint_after(until_next_frame(next - audio::sample_time(a.clock(), a.rate).seconds())); return; } let elapsed = now - self.playback.start_wall; let n = ((elapsed * self.playback.fps) / step as f64).floor() as i64; + // When the wall clock reaches frame `n + 1` (the next UI frame that changes anything). + let next_due = self.playback.start_wall + (n.saturating_add(1) as f64 * step as f64) / self.playback.fps - now; let target = match plan { Some(p) => p.frame_after(self.playback.start_frame, n), None => { @@ -1300,7 +1326,7 @@ impl EffectcraftApp { if !video { // Audio-only preview without an audio device: the clock runs, the time stays. self.playback.audio_frame = Some(target); - ctx.request_repaint(); + ctx.request_repaint_after(until_next_frame(next_due)); return; } if self.frames.is_cached(&FrameKey { frame: target, ..series }) { @@ -1318,8 +1344,11 @@ impl EffectcraftApp { } self.playback.start_wall = now; self.playback.start_frame = fr.frame_at(self.session.time()); + // Held at this frame: each finished frame wakes the UI (`Frames::release`). + ctx.request_repaint_after(until_next_frame(step as f64 / self.playback.fps)); + return; } - ctx.request_repaint(); + ctx.request_repaint_after(until_next_frame(next_due)); } /// While the project keeps changing (an edit within [`EDIT_QUIET`] seconds), how long until @@ -1886,6 +1915,27 @@ fn overflows_screen(size: egui::Vec2, monitor: egui::Vec2) -> bool { size.x > monitor.x || size.y > monitor.y - TASKBAR } +#[cfg(test)] +mod playback_pacing_tests { + use super::{PLAYBACK_MAX_WAIT, until_next_frame}; + use std::time::Duration; + + #[test] + fn a_playing_preview_wakes_when_its_next_frame_is_due_not_every_display_refresh() { + // 24 fps: the next frame is due in 41.7 ms, so no UI frame before then (not 120 Hz). + let d = until_next_frame(1.0 / 24.0); + assert!(d >= Duration::from_secs_f64(1.0 / 24.0) && d < Duration::from_millis(43), "{d:?}"); + // Late, overdue or nonsense waits stay bounded: no busy spin, no sleep past the cap. + for overdue in [-1.0, 0.0] { + let d = until_next_frame(overdue); + assert!(d >= Duration::from_micros(999) && d <= Duration::from_millis(1), "{d:?}"); + } + assert_eq!(until_next_frame(2.0), PLAYBACK_MAX_WAIT); + assert_eq!(until_next_frame(f64::NAN), PLAYBACK_MAX_WAIT); + assert_eq!(until_next_frame(f64::INFINITY), PLAYBACK_MAX_WAIT); + } +} + #[cfg(test)] mod fit_window_tests { use super::overflows_screen; From 070417fba1f1218c3a6d2fe02750a435ebe5560e Mon Sep 17 00:00:00 2001 From: Elias Date: Sat, 10 Oct 2026 18:32:00 +0100 Subject: [PATCH 33/45] G5: viewer channel/exposure transform runs in parallel (Tier E) (cherry picked from commit 8b84e206c0f47e834b82de32984e788aa3ad37e0) (cherry picked from commit b32ab5e1074be1a006d0617d43b07aed4d6df8f5) --- crates/ui-egui/src/panels/viewer_tools.rs | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/panels/viewer_tools.rs b/crates/ui-egui/src/panels/viewer_tools.rs index 772d0769a..9746c0655 100644 --- a/crates/ui-egui/src/panels/viewer_tools.rs +++ b/crates/ui-egui/src/panels/viewer_tools.rs @@ -341,12 +341,14 @@ pub(crate) fn showing_snapshot(app: &EffectcraftApp, ctx: &egui::Context) -> boo /// Show Channel and exposure, then the display colour conversion (View ▸ Use Display Color /// Management / Simulate Output). fn transformed(img: &egui::ColorImage, ch: Channel, colorized: bool, stops: f32, dc: Option) -> egui::ColorImage { - let mut px: Vec<[u8; 4]> = img.pixels.iter().map(|c| c.to_array()).collect(); + use rayon::prelude::*; + let mut px: Vec<[u8; 4]> = img.pixels.par_iter().map(|c| c.to_array()).collect(); if let Some(dc) = dc.filter(|_| matches!(ch, Channel::Rgb | Channel::RgbStraight)) { dc.apply(&mut px); } - vw::display_transform(&mut px, ch, colorized, stops); - egui::ColorImage::new(img.size, px.into_iter().map(|a| Color32::from_rgba_premultiplied(a[0], a[1], a[2], a[3])).collect()) + // Per pixel, so in parallel bands (each band builds the exposure table: 256 entries). + px.par_chunks_mut(1 << 16).for_each(|band| vw::display_transform(band, ch, colorized, stops)); + egui::ColorImage::new(img.size, px.into_par_iter().map(|a| Color32::from_rgba_premultiplied(a[0], a[1], a[2], a[3])).collect()) } /// The display conversion's inputs, for the texture caches. @@ -814,6 +816,20 @@ fn bottom_bar_contents(app: &mut EffectcraftApp, ui: &mut egui::Ui, bar: Rect, z mod tests { use super::*; + /// Show Channel / exposure in parallel bands is the same as in one pass. + #[test] + fn display_transform_in_bands_matches_one_pass() { + let n = (1 << 16) * 2 + 77; + let px: Vec = (0..n).map(|i| Color32::from_rgba_premultiplied((i % 200) as u8, (i % 97) as u8, (i % 31) as u8, 200 + (i % 50) as u8)).collect(); + let img = egui::ColorImage::new([n, 1], px); + for (ch, stops) in [(Channel::Rgb, 1.5), (Channel::Red, -0.5), (Channel::Alpha, 0.0), (Channel::RgbStraight, 2.0)] { + let mut one: Vec<[u8; 4]> = img.pixels.iter().map(|c| c.to_array()).collect(); + vw::display_transform(&mut one, ch, true, stops); + let got: Vec<[u8; 4]> = super::transformed(&img, ch, true, stops, None).pixels.iter().map(|c| c.to_array()).collect(); + assert!(got == one, "{ch:?}"); + } + } + #[test] fn bar_labels_match_after_effects() { assert_eq!(magnification_label(136.08), "136.1 %"); From 352496699d5b8ddc8334e6c2935474fd4777fc7d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 11:44:34 +0000 Subject: [PATCH 34/45] G5: rustfmt the viewer upload and channel-transform changes (cherry picked from commit 026bce565b8a841ff0587125bebb9425babbb4da) (cherry picked from commit 0134eaca8c0b814ddb983b25b0e9468eb2a55f54) --- crates/ui-egui/src/panels/viewer_tools.rs | 3 ++- crates/ui-egui/src/panels/viewers.rs | 6 +++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/panels/viewer_tools.rs b/crates/ui-egui/src/panels/viewer_tools.rs index 9746c0655..368cacaec 100644 --- a/crates/ui-egui/src/panels/viewer_tools.rs +++ b/crates/ui-egui/src/panels/viewer_tools.rs @@ -820,7 +820,8 @@ mod tests { #[test] fn display_transform_in_bands_matches_one_pass() { let n = (1 << 16) * 2 + 77; - let px: Vec = (0..n).map(|i| Color32::from_rgba_premultiplied((i % 200) as u8, (i % 97) as u8, (i % 31) as u8, 200 + (i % 50) as u8)).collect(); + let px: Vec = + (0..n).map(|i| Color32::from_rgba_premultiplied((i % 200) as u8, (i % 97) as u8, (i % 31) as u8, 200 + (i % 50) as u8)).collect(); let img = egui::ColorImage::new([n, 1], px); for (ch, stops) in [(Channel::Rgb, 1.5), (Channel::Red, -0.5), (Channel::Alpha, 0.0), (Channel::RgbStraight, 2.0)] { let mut one: Vec<[u8; 4]> = img.pixels.iter().map(|c| c.to_array()).collect(); diff --git a/crates/ui-egui/src/panels/viewers.rs b/crates/ui-egui/src/panels/viewers.rs index a63e7342d..a437cdc01 100644 --- a/crates/ui-egui/src/panels/viewers.rs +++ b/crates/ui-egui/src/panels/viewers.rs @@ -169,7 +169,11 @@ fn set_texture( } (FrameImage::Cpu(img), old) => { free(app, old.map(|(_, t)| t)); - PassiveTexture::Cpu(ctx.load_texture(format!("viewer-{id}"), crate::frames::fit_texture_shared(&img, crate::frames::max_texture_side(ctx), 1), opts)) + PassiveTexture::Cpu(ctx.load_texture( + format!("viewer-{id}"), + crate::frames::fit_texture_shared(&img, crate::frames::max_texture_side(ctx), 1), + opts, + )) } (FrameImage::Gpu(f), old) => { let Some(rs) = &app.wgpu else { return }; From cd34b61095cc6fb880fda0f12af094ed979a3b0b Mon Sep 17 00:00:00 2001 From: Elias Date: Sat, 10 Oct 2026 17:03:18 +0100 Subject: [PATCH 35/45] G5: bound thumbnail textures (Media Browser, Project panel, Home) with an LRU (cherry picked from commit fe11be646689fd580dc1722150988c6d92213587) (cherry picked from commit 65e2c8055258f446a52e7ec335186140fe65ba17) --- crates/ui-egui/src/panels/home.rs | 7 +++ crates/ui-egui/src/panels/media_panels.rs | 5 +++ crates/ui-egui/src/panels/project.rs | 5 +++ crates/ui-egui/src/thumb_lru.rs | 54 +++++++++++++++++++++++ 4 files changed, 71 insertions(+) create mode 100644 crates/ui-egui/src/thumb_lru.rs diff --git a/crates/ui-egui/src/panels/home.rs b/crates/ui-egui/src/panels/home.rs index 46bbb44fc..7605dd59c 100644 --- a/crates/ui-egui/src/panels/home.rs +++ b/crates/ui-egui/src/panels/home.rs @@ -190,6 +190,9 @@ pub fn capture_thumbnail(app: &mut EffectcraftApp) { app.home_thumb_saved = Some(key); } +/// Project thumbnails kept on the Home screen (more than the recent list shows). +const MAX_HOME_THUMBS: usize = 64; + fn thumb_texture(app: &mut EffectcraftApp, ctx: &egui::Context, path: &str) -> Option { if let Some(t) = app.home_thumbs.get(path) { return t.clone(); @@ -201,6 +204,10 @@ fn thumb_texture(app: &mut EffectcraftApp, ctx: &egui::Context, path: &str) -> O .and_then(|c| c.read(&thumb_name(path))) .and_then(|t| decode_thumb(&t)) .map(|img| ctx.load_texture(format!("home-thumb-{path}"), img, egui::TextureOptions::LINEAR)); + // Bounded: projects that left the recent list don't keep their thumbnails. + if app.home_thumbs.len() >= MAX_HOME_THUMBS { + app.home_thumbs.clear(); + } app.home_thumbs.insert(path.to_string(), tex.clone()); tex } diff --git a/crates/ui-egui/src/panels/media_panels.rs b/crates/ui-egui/src/panels/media_panels.rs index cd6dca5df..35a05dc9d 100644 --- a/crates/ui-egui/src/panels/media_panels.rs +++ b/crates/ui-egui/src/panels/media_panels.rs @@ -25,10 +25,14 @@ use crate::{EffectcraftApp, widgets}; type Thumb = Option<(egui::TextureHandle, [u32; 2])>; +/// File thumbnails kept (128 px, ~64 KB each); the least recently shown go first. +const MAX_THUMBS: usize = 256; + /// Thumbnail of a file (cached per path; `None` while not decoded or not decodable). fn thumbnail(app: &EffectcraftApp, ctx: &egui::Context, path: &str, budget: &mut u32) -> Thumb { let id = egui::Id::new(("mb-thumb", path)); if let Some(t) = ctx.data(|d| d.get_temp::(id)) { + crate::thumb_lru::touch::(ctx, "mb-thumb", id, MAX_THUMBS); return t; } if *budget == 0 { @@ -58,6 +62,7 @@ fn thumbnail(app: &EffectcraftApp, ctx: &egui::Context, path: &str, budget: &mut Some((tex, [w, h])) })(); ctx.data_mut(|d| d.insert_temp(id, thumb.clone())); + crate::thumb_lru::touch::(ctx, "mb-thumb", id, MAX_THUMBS); thumb } diff --git a/crates/ui-egui/src/panels/project.rs b/crates/ui-egui/src/panels/project.rs index 21f842976..b309ab440 100644 --- a/crates/ui-egui/src/panels/project.rs +++ b/crates/ui-egui/src/panels/project.rs @@ -109,6 +109,9 @@ pub fn drop_folder(project: &effectcraft_engine::project::Project, row: Option)>>>>); +/// Item thumbnails kept (192 px); the least recently shown go first. +const MAX_THUMBS: usize = 64; + /// Thumbnail of a comp (its current time) or footage (first frame), cached per item/revision. /// Rendered on a background thread (inline on wasm32): until the new one lands the previous /// thumbnail stays up, so selecting a big comp or editing it never stalls the panel. @@ -117,6 +120,8 @@ fn thumbnail(app: &EffectcraftApp, ctx: &egui::Context, it: &Item) -> Option = ctx.data(|d| d.get_temp(key)); + // Thumbnails of items selected long ago (or deleted) don't stay for the whole session. + crate::thumb_lru::touch::<(u64, egui::TextureHandle)>(ctx, "proj-thumb", key, MAX_THUMBS); let mut tex = cached.as_ref().map(|(_, t)| t.clone()); let have = cached.as_ref().map(|(r, _)| *r); // A finished render: upload it. diff --git a/crates/ui-egui/src/thumb_lru.rs b/crates/ui-egui/src/thumb_lru.rs new file mode 100644 index 000000000..95b0a7906 --- /dev/null +++ b/crates/ui-egui/src/thumb_lru.rs @@ -0,0 +1,54 @@ +//! Bounded thumbnail caches kept in egui's temp data (Media Browser, Project panel): past a +//! cap, the least recently used thumbnail is dropped (its texture freed), so browsing many +//! folders or selecting many items doesn't keep every texture for the rest of the session. + +use std::collections::VecDeque; + +/// Note that the temp entry `id` (of type `T`) of the cache `list` was used this frame; past +/// `cap` entries, the least recently used ones are removed from `ctx`'s data. +pub(crate) fn touch(ctx: &egui::Context, list: &'static str, id: egui::Id, cap: usize) { + let order_id = egui::Id::new(("thumb-lru", list)); + ctx.data_mut(|d| { + let order = d.get_temp_mut_or_default::>(order_id); + // The newest at the back; a thumbnail shown again moves there. + if order.back() != Some(&id) { + order.retain(|i| *i != id); + order.push_back(id); + } + let mut gone = Vec::new(); + while order.len() > cap.max(1) { + if let Some(old) = order.pop_front() { + gone.push(old); + } + } + for old in gone { + d.remove::(old); + } + }); +} + +#[cfg(test)] +mod tests { + use super::touch; + + #[test] + fn the_least_recently_used_thumbnail_goes_past_the_cap() { + let ctx = egui::Context::default(); + let id = |i: u32| egui::Id::new(("t", i)); + let put = |i: u32| { + ctx.data_mut(|d| d.insert_temp(id(i), i)); + touch::(&ctx, "test", id(i), 2); + }; + let has = |i: u32| ctx.data(|d| d.get_temp::(id(i))).is_some(); + put(1); + put(2); + // 1 is used again, so 2 is now the oldest. + touch::(&ctx, "test", id(1), 2); + put(3); + assert!(has(1) && has(3) && !has(2)); + // Another list's entries are kept apart. + ctx.data_mut(|d| d.insert_temp(id(9), 9u32)); + touch::(&ctx, "other", id(9), 1); + assert!(has(1) && has(3) && has(9)); + } +} From 593d59ae97b8fcfa72ea99eaea1da9f1a44b3413 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 11:13:35 +0000 Subject: [PATCH 36/45] G5: declare the thumbnail LRU module in ui-egui's lib.rs (cherry picked from commit 7a08a4f5fee6e4dbe885c51325c9e660476ae464) (cherry picked from commit 11330157b2bb0af953196f1de9ea0c9fd1b8f410) --- crates/ui-egui/src/lib.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index d870d36c3..d5936d270 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -29,6 +29,7 @@ mod pointer_wrap; pub mod prefs_live; pub mod state; pub mod theme; +mod thumb_lru; pub mod widgets; mod workspace_state; From 9458c645af570bae66a2b3d9b389c2f72c206feb Mon Sep 17 00:00:00 2001 From: Elias Date: Sat, 10 Oct 2026 18:32:00 +0100 Subject: [PATCH 37/45] G5: Timeline cache bar remembers its frame lists per cache generation (A9) cached_frames and disk_frames scanned the RAM cache and hashed a disk key per comp frame (with mutex locks) on every UI frame. RAM and disk caches now count changes, and the lists are kept per series until the frames, project or caches change. (cherry picked from commit 8c0b9972faf30b3da6fe5670ce20d766b5ac89fb) (cherry picked from commit c8bf68cc9c1240922a5f2e19215c0a6ef5f90436) --- crates/render/src/disk_cache.rs | 20 +++++++ crates/ui-egui/src/frames.rs | 93 +++++++++++++++++++++++++++++++-- 2 files changed, 109 insertions(+), 4 deletions(-) diff --git a/crates/render/src/disk_cache.rs b/crates/render/src/disk_cache.rs index 9c7062528..fb5ab2493 100644 --- a/crates/render/src/disk_cache.rs +++ b/crates/render/src/disk_cache.rs @@ -243,6 +243,8 @@ pub struct DiskCache { queue: Arc<(Mutex, Condvar)>, /// Layer buffers that took less than this to render are not written (ms). min_layer_ms: AtomicU64, + /// See [`Self::generation`]. + generation: AtomicU64, } /// FNV-1a 128-bit (content keys). @@ -315,6 +317,7 @@ impl DiskCache { tmp_counter: AtomicU64::new(0), queue: Arc::new((Mutex::new(Queue { jobs: Default::default(), bytes: 0, busy: false }), Condvar::new())), min_layer_ms: AtomicU64::new(30), + generation: AtomicU64::new(0), }); dc.rescan(); dc.evict(); @@ -363,6 +366,7 @@ impl DiskCache { if let Ok(mut g) = self.index.lock() { *g = idx; } + self.changed(); } fn path(&self, kind: Kind, key: u128) -> PathBuf { @@ -393,6 +397,16 @@ impl DiskCache { self.index.lock().is_ok_and(|g| g.contains(kind, key)) } + /// Changes whenever an entry is added or removed (so callers can cache what they derive + /// from [`Self::contains`], like the Timeline's cache bar). + pub fn generation(&self) -> u64 { + self.generation.load(Ordering::Relaxed) + } + + fn changed(&self) { + self.generation.fetch_add(1, Ordering::Relaxed); + } + /// Keys of one kind (unordered). pub fn keys(&self, kind: Kind) -> Vec { self.index.lock().map(|g| g.keys(kind)).unwrap_or_default() @@ -418,6 +432,7 @@ impl DiskCache { if let Ok(mut g) = self.index.lock() { g.clear(); } + self.changed(); for kind in [Kind::Frame, Kind::Layer] { let _ = std::fs::remove_dir_all(self.dir.join(kind.dir())); } @@ -477,6 +492,7 @@ impl DiskCache { if let Ok(mut g) = self.index.lock() { g.remove(kind, key); } + self.changed(); } fn enqueue(&self, kind: Kind, key: u128, payload: Vec) { @@ -523,6 +539,7 @@ impl DiskCache { if let Ok(mut g) = self.index.lock() { g.insert(kind, key, data.len() as u64); } + self.changed(); self.evict(); } @@ -533,6 +550,9 @@ impl DiskCache { Ok(mut g) => g.evict(max), Err(_) => return, }; + if !victims.is_empty() { + self.changed(); + } for (kind, key) in victims { let _ = std::fs::remove_file(self.path(kind, key)); self.evictions.fetch_add(1, Ordering::Relaxed); diff --git a/crates/ui-egui/src/frames.rs b/crates/ui-egui/src/frames.rs index da85ce057..48277e599 100644 --- a/crates/ui-egui/src/frames.rs +++ b/crates/ui-egui/src/frames.rs @@ -118,10 +118,13 @@ struct Cache { budget: usize, /// Bytes of GPU frames to keep at most ([`GPU_BUDGET`]). gpu_budget: usize, + /// Changes whenever a frame comes or goes (see [`Frames::disk_frames`]). + generation: u64, } impl Cache { fn insert(&mut self, k: FrameKey, img: FrameImage) { + self.generation = self.generation.wrapping_add(1); let (sz, gpu) = (img.bytes(), img.is_gpu()); if let Some(old) = self.map.insert(k, img) { self.forget(&old); @@ -160,6 +163,7 @@ impl Cache { } fn remove(&mut self, k: &FrameKey) { if let Some(i) = self.map.remove(k) { + self.generation = self.generation.wrapping_add(1); self.forget(&i); } } @@ -381,6 +385,27 @@ pub struct Frames { /// Where frames render instead of the UI thread (wasm32), and how many it is rendering. remote: Option>, remote_busy: Arc>, + /// The cache bar's frames, remembered while nothing they depend on changes (the bar is drawn + /// every UI frame). + bar: Mutex, +} + +/// See [`Frames::cached_frames`] and [`Frames::disk_frames`]: (series with frame 0, what else +/// the answer depends on, the answer), most recent last. +#[derive(Default)] +struct CacheBar { + ram: Vec<(FrameKey, u64, Vec)>, + disk: Vec<(FrameKey, [u64; 5], Vec)>, +} + +/// Series remembered by [`CacheBar`] (the Timeline, Preview panel and playback ask for one or two). +const BAR_SERIES: usize = 4; + +fn remember(v: &mut Vec<(FrameKey, K, Vec)>, series: FrameKey, k: K, frames: &[i64]) { + if v.len() >= BAR_SERIES { + v.remove(0); + } + v.push((series, k, frames.to_vec())); } impl Default for Frames { @@ -388,7 +413,7 @@ impl Default for Frames { #[cfg(not(target_arch = "wasm32"))] let threads = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(4).clamp(2, 16); Frames { - cache: Arc::new(Mutex::new(Cache { map: HashMap::new(), order: VecDeque::new(), bytes: 0, gpu_bytes: 0, budget: 3 << 30, gpu_budget: GPU_BUDGET })), + cache: Arc::new(Mutex::new(Cache { map: HashMap::new(), order: VecDeque::new(), bytes: 0, gpu_bytes: 0, budget: 3 << 30, gpu_budget: GPU_BUDGET, generation: 0 })), inflight: Arc::new(Mutex::new(HashSet::new())), queue: Arc::new(Mutex::new(Queue::default())), #[cfg(not(target_arch = "wasm32"))] @@ -409,6 +434,7 @@ impl Default for Frames { keepers: Mutex::default(), remote: None, remote_busy: Arc::default(), + bar: Mutex::default(), } } } @@ -670,9 +696,16 @@ impl Frames { /// Cached frame numbers of the series of `base` (its comp content, scale, view and render /// options) — for the cache bar. pub fn cached_frames(&self, base: &FrameKey) -> Vec { + let series = FrameKey { frame: 0, ..*base }; let Ok(c) = self.cache.lock() else { return vec![] }; + let mut bar = self.bar.lock().unwrap_or_else(|e| e.into_inner()); + if let Some((_, _, v)) = bar.ram.iter().find(|(s, g, _)| *s == series && *g == c.generation) { + return v.clone(); + } let mut v: Vec = c.map.keys().filter(|k| k.same_series(base)).map(|k| k.frame).collect(); v.sort_unstable(); + bar.ram.retain(|(s, ..)| *s != series); + remember(&mut bar.ram, series, c.generation, &v); v } @@ -688,12 +721,27 @@ impl Frames { if src.disk.is_none() && remote.is_none() { return vec![]; } - let ram: HashSet = self.cached_frames(base).into_iter().collect(); let oh = opts_hash(opts); - (0..frames.clamp(0, 100_000)) + // Remembered until the frames in RAM or on disk, the project or the series change (the + // remote disk cache gets its frames with the RAM cache's). + let series = FrameKey { frame: 0, ..*base }; + let ram_generation = self.cache.lock().map(|c| c.generation).unwrap_or(0); + let k = [base.revision, frames as u64, oh, ram_generation, src.disk.as_ref().map_or(0, |dc| dc.generation())]; + if let Ok(bar) = self.bar.lock() + && let Some((_, _, v)) = bar.disk.iter().find(|(s, g, _)| *s == series && *g == k) + { + return v.clone(); + } + let ram: HashSet = self.cached_frames(base).into_iter().collect(); + let v: Vec = (0..frames.clamp(0, 100_000)) .filter(|f| !ram.contains(f)) .filter(|f| contains(frame_disk_key(&self.content_keys, &src.project, &FrameKey { frame: *f, ..*base }, oh))) - .collect() + .collect(); + if let Ok(mut bar) = self.bar.lock() { + bar.disk.retain(|(s, ..)| *s != series); + remember(&mut bar.disk, series, k, &v); + } + v } /// Change the RAM preview cache budget (Settings ▸ Memory & CPU), evicting the oldest @@ -715,6 +763,7 @@ impl Frames { } if let Ok(mut c) = self.cache.lock() { c.map.clear(); + c.generation = c.generation.wrapping_add(1); c.order.clear(); c.bytes = 0; c.gpu_bytes = 0; @@ -1427,6 +1476,42 @@ mod tests { assert!(f.is_cached(&key(1, 2, 0)) && f.is_cached(&key(1, 3, 0))); } + /// The cache bar's frame lists are remembered, and follow frames coming and going. + #[test] + fn cache_bar_follows_the_cache() { + let f = Frames::default(); + insert(&f, key(1, 3, 0)); + assert_eq!(f.cached_frames(&key(1, 0, 0)), vec![3]); + assert_eq!(f.cached_frames(&key(1, 9, 0)), vec![3], "any frame of the series"); + insert(&f, key(1, 1, 0)); + assert_eq!(f.cached_frames(&key(1, 0, 0)), vec![1, 3]); + f.cache.lock().unwrap().remove(&key(1, 3, 0)); + assert_eq!(f.cached_frames(&key(1, 0, 0)), vec![1]); + f.clear(); + assert!(f.cached_frames(&key(1, 0, 0)).is_empty()); + // Disk: a frame written to the disk cache shows; one read back into RAM doesn't. + let dir = std::env::temp_dir().join(format!("effectcraft-cache-bar-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let dc = DiskCache::open(&dir, 1 << 30).unwrap(); + let src = RenderSource { + project: Arc::default(), + footage: Arc::new(effectcraft_engine::render::NoFootage), + expr: None, + layer_cache: Arc::default(), + gpu: None, + gpu_display: false, + disk: Some(dc.clone()), + }; + let opts = RenderOpts::default(); + assert!(f.disk_frames(&src, &key(1, 0, 0), 5, &opts).is_empty()); + let k = frame_disk_key(&f.content_keys, &src.project, &key(1, 2, 0), opts_hash(&opts)); + dc.put_frame_now(k, 1, 1, &[0, 0, 0, 255]); + assert_eq!(f.disk_frames(&src, &key(1, 0, 0), 5, &opts), vec![2]); + insert(&f, key(1, 2, 0)); + assert!(f.disk_frames(&src, &key(1, 0, 0), 5, &opts).is_empty()); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn frames_are_keyed_by_content_and_series_are_kept_apart() { let f = Frames::default(); From 9be92071b6d842c110e98137a167208545c4b76f Mon Sep 17 00:00:00 2001 From: Elias Date: Sat, 10 Oct 2026 18:32:00 +0100 Subject: [PATCH 38/45] G5: Lumetri Scopes share their cached scope and read the shown frame while playing (A8) (cherry picked from commit 2119dd79f9e68869c0dbf94cf1e3219451783428) (cherry picked from commit a25de99e7f59013ab9dcd0bbc3fe5e925460e03b) --- crates/ui-egui/src/panels/scopes_panel.rs | 70 +++++++++++++++++++++-- 1 file changed, 65 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/panels/scopes_panel.rs b/crates/ui-egui/src/panels/scopes_panel.rs index 6665164e2..10a07115e 100644 --- a/crates/ui-egui/src/panels/scopes_panel.rs +++ b/crates/ui-egui/src/panels/scopes_panel.rs @@ -4,7 +4,11 @@ //! maths is `effectcraft_raster::scopes`; agents read the same numbers with `scopes.analyze`. use crate::i18n::tr; +use std::sync::Arc; + use effectcraft_engine::commands::panels_cmds::scope_frame; +use effectcraft_engine::project::ItemId; +use effectcraft_engine::raster::Image; use effectcraft_engine::raster::scopes::{self, ColorStandard, Scope, ScopeKind, ScopeOpts}; use egui::{Align2, Color32, Rect, Stroke, pos2, vec2}; use serde::{Deserialize, Serialize}; @@ -31,7 +35,6 @@ impl Default for ScopesState { const STANDARDS: [&str; 3] = ["Rec. 601", "Rec. 709", "Rec. 2020"]; -#[derive(Clone)] struct Cached { key: (u64, u64, i64, String, u32), scope: Scope, @@ -95,21 +98,35 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { height: if kind.is_vectorscope() { size } else { (plot.height() as u32).clamp(32, 512) }, }; let time = app.session.time(); - let key = (app.session.revision, cid.0, time.0, format!("{}{}{}{}{}x{}", st.scope, st.standard, st.float, st.clamp, o.width, o.height), 0); let ctx = ui.ctx().clone(); let id = egui::Id::new("scopes-cache"); - let cached: Option = ctx.data(|d| d.get_temp(id)); + // Shared, not copied: the scope's planes are megabytes. + let cached: Option> = ctx.data(|d| d.get_temp(id)); + let opts_key = format!("{}{}{}{}{}x{}", st.scope, st.standard, st.float, st.clamp, o.width, o.height); + // While playing, the scopes read the frame on screen (the comp isn't rendered again on the + // UI thread for every frame); paused, the comp frame at the current time in float. + let shown = app.playback.playing.then(|| playing_frame(app, &ctx, cid)).flatten(); + let key = match &shown { + Some(img) => (app.session.revision, cid.0, Arc::as_ptr(img) as usize as i64, opts_key, 1), + None => (app.session.revision, cid.0, time.0, opts_key, 0), + }; let cached = match cached { Some(c) if c.key == key => c, + // Playing without a frame to read: the last scopes stay up. + Some(c) if app.playback.playing && shown.is_none() && (c.key.1, &c.key.3) == (key.1, &key.3) => c, _ => { - let Some(frame) = scope_frame(&app.session, cid, time, 480) else { return }; + let frame = match &shown { + Some(img) => app.session.project.comp(cid).map(|c| frame_of(img, c.background, 480)), + None => scope_frame(&app.session, cid, time, 480), + }; + let Some(frame) = frame else { return }; let scope = scopes::compute(&frame, kind, &o); let tex = (kind != ScopeKind::Histogram).then(|| { let mono = matches!(kind, ScopeKind::WaveformLuma | ScopeKind::VectorscopeYuv | ScopeKind::VectorscopeHls); let img = egui::ColorImage::from_rgba_unmultiplied([scope.width as usize, scope.height as usize], &tint(&scope, mono)); ctx.load_texture("lumetri-scope", img, egui::TextureOptions::LINEAR) }); - let c = Cached { key, scope, tex }; + let c = Arc::new(Cached { key, scope, tex }); ctx.data_mut(|d| d.insert_temp(id, c.clone())); c } @@ -122,6 +139,32 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } } +/// The viewer's frame of `comp` while it plays, when it shows the whole comp (no region of +/// interest or Extended Viewer area). +fn playing_frame(app: &EffectcraftApp, ctx: &egui::Context, comp: ItemId) -> Option> { + let c = app.session.project.comp(comp)?; + let (_, k) = app.viewer_shown.as_ref()?; + let whole = [0.0, 0.0, c.width as f64, c.height as f64]; + (k.comp == comp.0 && super::viewer_tools::shown_region(ctx, [c.width as f32, c.height as f32]) == whole).then(|| app.viewer_image.clone()).flatten() +} + +/// What [`scope_frame`] gives, from a viewer frame (premultiplied 8-bit): over the comp +/// background `bg`, sampled down to at most `max_side` pixels on the long side. +fn frame_of(img: &egui::ColorImage, bg: [f32; 3], max_side: usize) -> Image { + let [w, h] = img.size; + let k = w.max(h).div_ceil(max_side.max(1)).max(1); + let (ow, oh) = (w.div_ceil(k), h.div_ceil(k)); + let mut out = Image::new(ow as u32, oh as u32); + for (i, px) in out.data.iter_mut().enumerate() { + let (x, y) = ((i % ow.max(1)) * k, (i / ow.max(1)) * k); + let Some(c) = img.pixels.get(y * w + x) else { continue }; + let [r, g, b, a] = c.to_array().map(|v| v as f32 / 255.0); + let t = 1.0 - a; + *px = [r + bg[0] * t, g + bg[1] * t, b + bg[2] * t, 1.0]; + } + out +} + /// Trace colours: green-tinted mono for luma/vectorscope, per-channel colours otherwise. fn tint(s: &Scope, mono: bool) -> Vec { let n = s.width as usize * s.height as usize; @@ -198,3 +241,20 @@ fn histogram(p: &egui::Painter, plot: Rect, s: &Scope) { } } } + +#[cfg(test)] +mod tests { + use super::*; + + /// A viewer frame reads as `scope_frame` would: composited over the background, at most + /// `max_side` on the long side. + #[test] + fn viewer_frames_read_over_the_background() { + let px = vec![egui::Color32::from_rgba_premultiplied(128, 0, 0, 128); 1000 * 500]; + let img = egui::ColorImage::new([1000, 500], px); + let f = frame_of(&img, [0.0, 0.0, 1.0], 480); + assert_eq!((f.width, f.height), (334, 167)); + let p = f.data[0]; + assert!((p[0] - 128.0 / 255.0).abs() < 1e-6 && p[1] == 0.0 && (p[2] - 127.0 / 255.0).abs() < 1e-6 && p[3] == 1.0, "{p:?}"); + } +} From c920bbc16c804b077042f3dd732f70b00c67f60e Mon Sep 17 00:00:00 2001 From: Elias Date: Sat, 10 Oct 2026 18:32:00 +0100 Subject: [PATCH 39/45] G5: Footage panel borrows its footage and decodes once per frame (Tier E) (cherry picked from commit 2f7e6eb6bd0782f9545b3f2f23b9d1eee22eed33) (cherry picked from commit bf01e783acbf4b0f99892deebe55618ba613f1d6) --- crates/ui-egui/src/panels/footage_panel.rs | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/panels/footage_panel.rs b/crates/ui-egui/src/panels/footage_panel.rs index a86514646..197fb9d0a 100644 --- a/crates/ui-egui/src/panels/footage_panel.rs +++ b/crates/ui-egui/src/panels/footage_panel.rs @@ -23,12 +23,14 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { app.auto.add("footage.empty", rect, "Footage panel (empty)"); return; }; - let Some(item) = app.session.project.item(view.item).cloned() else { + // Borrowed from a project snapshot, not copied (an image sequence lists every frame's file). + let project = app.session.project.clone(); + let Some(item) = project.item(view.item) else { app.session.state.footage_panel = None; return; }; let footage = match &item.kind { - ItemKind::Footage(f) => Some(f.clone()), + ItemKind::Footage(f) => Some(f), _ => None, }; let (dur, fr) = match &footage { @@ -49,10 +51,12 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { p.rect_filled(Rect::from_min_max(pos2(rect.min.x, ruler.min.y - 4.0), rect.max), 0.0, t.panel_bg); // Canvas. let area = Rect::from_min_max(pos2(rect.min.x, top.max.y), pos2(rect.max.x, ruler.min.y - 4.0)); - if let Some(f) = &footage + if let Some(f) = footage && f.has_video { - let key = (view.item.0, view.time.0, app.session.revision); + // Decoded once per footage frame (playback here advances the time every UI frame). + let frame = if f.kind == FootageKind::Still { 0 } else { f.frame_rate.frame_at(view.time) }; + let key = (view.item.0, frame, app.session.revision); let tid = egui::Id::new("footage-panel-tex"); let cached: Option<((u64, i64, u64), egui::TextureHandle, [u32; 2])> = ctx.data(|d| d.get_temp(tid)); let tex = match cached { From c6ab7536089da234d8de184966a86a5d3c8b8fb2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 19:47:25 +0000 Subject: [PATCH 40/45] G5: Footage panel decodes its frame on a worker thread, averaged down to the panel size, keyed on item + frame + interpretation Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015Gh8SBi8MuyzgBSJ2f5n7W (cherry picked from commit f3e5438180d808302e8a3a977ee9535aa55d1740) (cherry picked from commit 6b053c67cb38b0a14179625fcbfde3fc1e2ca782) --- crates/ui-egui/src/panels/footage_panel.rs | 140 ++++++++++++++++++--- 1 file changed, 125 insertions(+), 15 deletions(-) diff --git a/crates/ui-egui/src/panels/footage_panel.rs b/crates/ui-egui/src/panels/footage_panel.rs index 197fb9d0a..e5d58c809 100644 --- a/crates/ui-egui/src/panels/footage_panel.rs +++ b/crates/ui-egui/src/panels/footage_panel.rs @@ -4,10 +4,13 @@ //! Everything runs `footage.*` commands (state in `EditorState::footage_panel`). use crate::i18n::{tr, tr_args}; -use effectcraft_engine::project::{FootageKind, ItemKind}; +use effectcraft_engine::project::{Footage, FootageKind, ItemId, ItemKind}; +use effectcraft_engine::render::FootageSource; use effectcraft_engine::time::Tick; use egui::{Align2, Color32, Rect, Sense, Stroke, pos2, vec2}; use serde_json::json; +use std::hash::Hasher; +use std::sync::{Arc, OnceLock}; use super::panel_kit as kit; use crate::EffectcraftApp; @@ -54,22 +57,17 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { if let Some(f) = footage && f.has_video { - // Decoded once per footage frame (playback here advances the time every UI frame). + // Decoded once per footage frame (playback here advances the time every UI frame), on a + // worker thread and averaged down to the panel's size; the last frame stays up meanwhile. let frame = if f.kind == FootageKind::Still { 0 } else { f.frame_rate.frame_at(view.time) }; - let key = (view.item.0, frame, app.session.revision); - let tid = egui::Id::new("footage-panel-tex"); - let cached: Option<((u64, i64, u64), egui::TextureHandle, [u32; 2])> = ctx.data(|d| d.get_temp(tid)); - let tex = match cached { - Some((k, tex, size)) if k == key => Some((tex, size)), - _ => app.session.footage.frame(view.item, f, view.time).map(|img| { - let ci = egui::ColorImage::from_rgba_unmultiplied([img.width as usize, img.height as usize], &img.to_rgba8()); - let tex = crate::frames::load_fitted(&ctx, "footage-panel", ci, egui::TextureOptions::LINEAR); - ctx.data_mut(|d| d.insert_temp(tid, (key, tex.clone(), [img.width, img.height]))); - (tex, [img.width, img.height]) - }), - }; + let fit = fitted_area(area); + let ppp = ctx.pixels_per_point(); + let fs = ((fit.x / f.width.max(1) as f32).min(fit.y / f.height.max(1) as f32) * ppp).max(1e-6); + let k = ((1.0 / fs).floor() as usize).max(1); + let key = FrameKey { item: view.item.0, frame, footage: footage_fingerprint(&ctx, &project, view.item.0, f), k }; + let tex = frame_texture(app, &ctx, key, view.time); if let Some((tex, [w, h])) = tex { - let s = ((area.width() - 20.0) / w as f32).min((area.height() - 20.0) / h as f32).max(0.01); + let s = (fit.x / w as f32).min(fit.y / h as f32).max(0.01); let r = Rect::from_center_size(area.center(), vec2(w as f32 * s, h as f32 * s)); super::viewer::checker(&p.with_clip_rect(area), r); p.image(tex.id(), r, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); @@ -153,3 +151,115 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { kit::exec(app, "footage.overlayEdit", json!({})); } } + +/// The room the frame is fitted into (the canvas less a 10 pt margin). +fn fitted_area(area: Rect) -> egui::Vec2 { + vec2(area.width() - 20.0, area.height() - 20.0) +} + +/// What the panel's texture shows: an item's frame, decoded from the footage as interpreted +/// (`footage`, a fingerprint), averaged down by `k`. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +struct FrameKey { + item: u64, + frame: i64, + footage: u64, + k: usize, +} + +/// A frame's pixels, averaged down, and its size in the file (`None`: not decodable). +type FramePixels = Option<(egui::ColorImage, [u32; 2])>; + +/// A frame being decoded on a worker thread (set when done). +type FrameJob = (FrameKey, Arc>); + +/// The texture shown and what it shows (`None`: that frame could not be decoded). +type Shown = (FrameKey, Option<(egui::TextureHandle, [u32; 2])>); + +/// Hashes what is written to it (fingerprints a value's `Debug` form without building it). +struct HashWriter(std::collections::hash_map::DefaultHasher); + +impl std::fmt::Write for HashWriter { + fn write_str(&mut self, s: &str) -> std::fmt::Result { + self.0.write(s.as_bytes()); + Ok(()) + } +} + +/// A fingerprint of `f` (its file and interpretation), worked out once per project snapshot: +/// replacing or reinterpreting footage decodes again, other edits don't. +fn footage_fingerprint(ctx: &egui::Context, project: &Arc, item: u64, f: &Footage) -> u64 { + let id = egui::Id::new("footage-panel-fingerprint"); + let snap = (Arc::as_ptr(project) as usize, item); + if let Some((s, fp)) = ctx.data(|d| d.get_temp::<((usize, u64), u64)>(id)) + && s == snap + { + return fp; + } + let mut w = HashWriter(std::collections::hash_map::DefaultHasher::new()); + let _ = std::fmt::Write::write_fmt(&mut w, format_args!("{f:?}")); + let fp = w.0.finish(); + ctx.data_mut(|d| d.insert_temp(id, (snap, fp))); + fp +} + +/// Decode frame `key` of footage `f` at `time` and average it down by `key.k`. +fn decode_frame(source: &dyn FootageSource, item: ItemId, f: &Footage, time: Tick, key: FrameKey) -> FramePixels { + let img = source.frame(item, f, time)?; + let ci = egui::ColorImage::from_rgba_unmultiplied([img.width as usize, img.height as usize], &img.to_rgba8()); + Some((crate::frames::fit_texture_by(ci, usize::MAX, key.k), [img.width, img.height])) +} + +/// The texture for frame `key` (at `time`): uploaded when its worker is done, the frame shown +/// before it meanwhile (as long as it is the same item's). Decoding never runs on the UI thread +/// (on the web, which has no threads, it does). +fn frame_texture(app: &EffectcraftApp, ctx: &egui::Context, key: FrameKey, time: Tick) -> Option<(egui::TextureHandle, [u32; 2])> { + let tid = egui::Id::new("footage-panel-tex"); + let jid = egui::Id::new("footage-panel-job"); + if let Some((jkey, job)) = ctx.data(|d| d.get_temp::(jid)) + && let Some(done) = job.get() + { + let tex = done.as_ref().map(|(ci, size)| (crate::frames::load_fitted(ctx, "footage-panel", ci.clone(), egui::TextureOptions::LINEAR), *size)); + ctx.data_mut(|d| { + d.insert_temp::(tid, (jkey, tex)); + d.remove::(jid); + }); + } + let shown: Option = ctx.data(|d| d.get_temp(tid)); + let running = ctx.data(|d| d.get_temp::(jid)).is_some(); + if !running && shown.as_ref().is_none_or(|(k, _)| *k != key) { + start_decode(app, ctx, key, time); + } + shown.filter(|(k, _)| k.item == key.item).and_then(|(_, tex)| tex) +} + +/// Start decoding frame `key` on a worker thread (in place on the web). +fn start_decode(app: &EffectcraftApp, ctx: &egui::Context, key: FrameKey, time: Tick) { + let jid = egui::Id::new("footage-panel-job"); + let job: Arc> = Arc::default(); + let (project, source) = (app.session.project.clone(), app.session.footage.clone()); + let run = move |job: &OnceLock| { + let px = match project.item(ItemId(key.item)).map(|i| &i.kind) { + Some(ItemKind::Footage(f)) => decode_frame(&*source, ItemId(key.item), f, time, key), + _ => None, + }; + let _ = job.set(px); + }; + ctx.data_mut(|d| d.insert_temp::(jid, (key, job.clone()))); + #[cfg(target_arch = "wasm32")] + { + run(&job); + ctx.request_repaint(); + } + #[cfg(not(target_arch = "wasm32"))] + { + let ctx2 = ctx.clone(); + let spawned = std::thread::Builder::new().name("ec-footage-panel".into()).spawn(move || { + run(&job); + ctx2.request_repaint(); + }); + if spawned.is_err() { + ctx.data_mut(|d| d.remove::(jid)); + } + } +} From ccb6bc30b88d7534d8797c50d8a69612d3a65481 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 11:44:34 +0000 Subject: [PATCH 41/45] G5: Footage panel timecode takes the borrowed item (clippy needless_borrow) (cherry picked from commit 9ff7208ba18657fa813aa7bbcfb3f8de5f2e29e8) (cherry picked from commit 3848d2eedcaa294c97c0cd3cbd3df2824e4edc1d) --- crates/ui-egui/src/panels/footage_panel.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/panels/footage_panel.rs b/crates/ui-egui/src/panels/footage_panel.rs index e5d58c809..2ee608339 100644 --- a/crates/ui-egui/src/panels/footage_panel.rs +++ b/crates/ui-egui/src/panels/footage_panel.rs @@ -118,7 +118,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { p.text( pos2(x, bar.center().y), Align2::LEFT_CENTER, - effectcraft_engine::commands::footage_panel::timecode(Some(&item), fr, fr.frame_at(view.time)), + effectcraft_engine::commands::footage_panel::timecode(Some(item), fr, fr.frame_at(view.time)), Tokens::mono(12.0), t.timecode, ); From 66d7342394192ab79d121f60695dbd8664adf22f Mon Sep 17 00:00:00 2001 From: Elias Date: Sat, 10 Oct 2026 18:32:01 +0100 Subject: [PATCH 42/45] G5: native menu catches up after a drag instead of rebuilding every frame of it (Tier E) (cherry picked from commit d1c5fcab9bd1ca40d0db7f2a7fd74c5c7a1b4f82) (cherry picked from commit 90693188256145f7d67e6a7bc352f847a1f29e38) --- apps/effectcraft/src/native_menu.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/effectcraft/src/native_menu.rs b/apps/effectcraft/src/native_menu.rs index 9db2ffe76..785bb08d7 100644 --- a/apps/effectcraft/src/native_menu.rs +++ b/apps/effectcraft/src/native_menu.rs @@ -67,6 +67,11 @@ impl NativeBar { if in_window { return; } + // While a button is held (a drag edits on every frame, each a new revision) the menu, + // which can't be opened meanwhile, catches up on release. + if self.menu.is_some() && self.state.is_some() && ctx.input(|i| i.pointer.any_down()) { + return; + } let key = native_menu::state_key(app); if self.state == Some(key) && self.menu.is_some() { return; From f7b1deeb6bc8360399aa5947b8f3605b2fa9324f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 19:47:25 +0000 Subject: [PATCH 43/45] G5: native menu updates paced to 4 per second and pushed only when the spec changes Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015Gh8SBi8MuyzgBSJ2f5n7W (cherry picked from commit 7c4e305fe97e5b008e61900208a6761e507ee0a4) (cherry picked from commit d4703e63417606ecd0ae40f0260d3e8356eb06bc) --- apps/effectcraft/src/native_menu.rs | 35 +++++++------ crates/ui-egui/src/native_menu.rs | 77 ++++++++++++++++++++++++++++- 2 files changed, 97 insertions(+), 15 deletions(-) diff --git a/apps/effectcraft/src/native_menu.rs b/apps/effectcraft/src/native_menu.rs index 785bb08d7..683216e09 100644 --- a/apps/effectcraft/src/native_menu.rs +++ b/apps/effectcraft/src/native_menu.rs @@ -2,16 +2,17 @@ //! [`effectcraft_ui_egui::native_menu`]'s spec of the engine's After Effects menu tree. //! //! Each frame [`NativeBar::update`] runs menu activations (the same command ids as the in-window -//! bar), and when [`native_menu::state_key`] changed it rebuilds the spec and pushes labels, -//! enabled and check state into the existing items; a structural change (Open Recent, a new -//! shortcut preset) rebuilds the menu. Settings ▸ Appearance ▸ Use In-Window Menu Bar on macOS +//! bar), and when [`native_menu::state_key`] changed (at most every +//! [`native_menu::MENU_REFRESH_INTERVAL`]) it rebuilds the spec and, when that differs, pushes +//! labels, enabled and check state into the existing items; a structural change (Open Recent, a +//! new shortcut preset) rebuilds the menu. Settings ▸ Appearance ▸ Use In-Window Menu Bar on macOS //! swaps back to the in-window bar. use std::collections::HashMap; use std::sync::mpsc::{Receiver, channel}; use effectcraft_ui_egui::EffectcraftApp; -use effectcraft_ui_egui::native_menu::{self, NativeMenu, NativeNode, Role}; +use effectcraft_ui_egui::native_menu::{self, MenuPacer, NativeMenu, NativeNode, Pace, Role}; use muda::{CheckMenuItem, Menu, MenuEvent, PredefinedMenuItem, Submenu}; /// The `muda` accelerator for a spec accelerator string. @@ -24,7 +25,7 @@ pub struct NativeBar { items: HashMap, spec: NativeMenu, structure: u64, - state: Option, + pacer: MenuPacer, events: Receiver, /// The in-window-bar setting last applied (None: not yet). in_window: Option, @@ -38,7 +39,7 @@ impl NativeBar { let _ = tx.send(e); ctx.request_repaint(); })); - NativeBar { menu: None, items: HashMap::new(), spec: NativeMenu::default(), structure: 0, state: None, events: rx, in_window: None } + NativeBar { menu: None, items: HashMap::new(), spec: NativeMenu::default(), structure: 0, pacer: MenuPacer::default(), events: rx, in_window: None } } /// Run activations and keep the native menu in step with the app (call once per frame). @@ -53,7 +54,7 @@ impl NativeBar { } self.items.clear(); } else { - self.state = None; + self.pacer.reset(); self.structure = 0; } } @@ -62,27 +63,33 @@ impl NativeBar { log::warn!("menu: {err}"); } // Check items toggle themselves when clicked: resync with the app. - self.state = None; + self.pacer.reset(); } if in_window { return; } // While a button is held (a drag edits on every frame, each a new revision) the menu, // which can't be opened meanwhile, catches up on release. - if self.menu.is_some() && self.state.is_some() && ctx.input(|i| i.pointer.any_down()) { + if self.menu.is_some() && self.pacer.applied() && ctx.input(|i| i.pointer.any_down()) { return; } - let key = native_menu::state_key(app); - if self.state == Some(key) && self.menu.is_some() { - return; + if self.menu.is_none() { + self.pacer.reset(); + } + match self.pacer.check(native_menu::state_key(app), ctx.input(|i| i.time)) { + Pace::Current => return, + Pace::Later(wait) => { + ctx.request_repaint_after(std::time::Duration::try_from_secs_f64(wait).unwrap_or_default()); + return; + } + Pace::Now => {} } - self.state = Some(key); let spec = native_menu::build(app); let structure = spec.structure(); if self.menu.is_none() || structure != self.structure { self.install(&spec); self.structure = structure; - } else { + } else if spec != self.spec { self.refresh(&spec); } self.spec = spec; diff --git a/crates/ui-egui/src/native_menu.rs b/crates/ui-egui/src/native_menu.rs index e727b9f93..2de188a66 100644 --- a/crates/ui-egui/src/native_menu.rs +++ b/crates/ui-egui/src/native_menu.rs @@ -11,7 +11,8 @@ //! //! Item ids are positional (`m.3.0.2`), stable while the menu's structure stays the same; the //! app rebuilds the native menu when [`NativeMenu::structure`] changes and otherwise only pushes -//! label / enabled / checked updates, and only when [`state_key`] changes. +//! label / enabled / checked updates, and only when [`state_key`] changes — at most every +//! [`MENU_REFRESH_INTERVAL`] ([`MenuPacer`]). use std::hash::{Hash, Hasher}; @@ -319,6 +320,60 @@ pub fn state_key(app: &EffectcraftApp) -> u64 { h.finish() } +/// The shortest time between two native menu updates (edits that change the document on every +/// frame, playback, scrubbing, bring it up to date about 4 times a second). +pub const MENU_REFRESH_INTERVAL: f64 = 0.25; + +/// What [`MenuPacer::check`] decided. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Pace { + /// The menu shows this state already. + Current, + /// Update the menu now. + Now, + /// The state changed, but the last update was too recent: check again after this many seconds. + Later(f64), +} + +/// Paces native menu updates: one when [`state_key`] changes, at most every +/// [`MENU_REFRESH_INTERVAL`], with the last change always applied. +#[derive(Clone, Debug, Default)] +pub struct MenuPacer { + applied: Option, + last: Option, +} + +impl MenuPacer { + /// Whether to update the menu for state `key` at `now` (seconds, e.g. `egui::InputState::time`). + /// [`Pace::Now`] counts as applied. + pub fn check(&mut self, key: u64, now: f64) -> Pace { + if self.applied == Some(key) { + return Pace::Current; + } + if let Some(last) = self.last { + let wait = last + MENU_REFRESH_INTERVAL - now; + // `now` going backwards (a new clock) never holds the menu back. + if wait > 0.0 && wait <= MENU_REFRESH_INTERVAL { + return Pace::Later(wait); + } + } + self.applied = Some(key); + self.last = Some(now); + Pace::Now + } + + /// Whether the menu shows some state (it was updated since the last [`Self::reset`]). + pub fn applied(&self) -> bool { + self.applied.is_some() + } + + /// Forget the applied state: the next [`Self::check`] updates right away (after a menu click, + /// whose check item toggled itself, or a new menu). + pub fn reset(&mut self) { + *self = Self::default(); + } +} + /// Commands that act on a focused text field instead (Edit menu while typing): the egui event to /// send in their place. fn text_field_event(command: &str, clipboard: Option) -> Option> { @@ -520,6 +575,26 @@ mod tests { assert!(spec2.items().iter().any(|i| i.command == "file.openRecent" && i.label == "Recent Test.ecproj")); } + #[test] + fn menu_updates_are_paced() { + let mut p = MenuPacer::default(); + assert!(!p.applied()); + assert_eq!(p.check(1, 10.0), Pace::Now); + assert!(p.applied()); + assert_eq!(p.check(1, 10.01), Pace::Current); + // A change right after an update waits for the rest of the interval… + let Pace::Later(wait) = p.check(2, 10.1) else { panic!("not paced") }; + assert!((wait - 0.15).abs() < 1e-9); + assert_eq!(p.check(3, 10.2), Pace::Later(10.0 + MENU_REFRESH_INTERVAL - 10.2)); + // …then applies the latest state. + assert_eq!(p.check(3, 10.25), Pace::Now); + assert_eq!(p.check(3, 10.3), Pace::Current); + // A clock that went backwards doesn't hold it back; a reset updates at once. + assert_eq!(p.check(4, 1.0), Pace::Now); + p.reset(); + assert_eq!(p.check(4, 1.01), Pace::Now); + } + #[test] fn activation_dispatches_the_menu_command() { let mut app = app(); From 15ae8ce7a9080e5f8cd85b84acf6f42aabb16f84 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 01:38:20 +0000 Subject: [PATCH 44/45] fix(expr): fan-out budget test uses reads the result memo can't fold Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MMnX8KooFZRPByLioEuyso --- crates/expr/src/tests.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/crates/expr/src/tests.rs b/crates/expr/src/tests.rs index ee7e07a1b..05dd11154 100644 --- a/crates/expr/src/tests.rs +++ b/crates/expr/src/tests.rs @@ -408,9 +408,12 @@ fn an_expression_needing_its_own_value_fails_fast() { #[test] fn fanning_out_reads_stop_at_a_budget() { // Two layers reading each other twice at other times: no cycle (the times differ), but - // 2^16 evaluations at `MAX_DEPTH`; the evaluation stops after `MAX_RUNS` script runs. + // 2^16 evaluations at `MAX_DEPTH`; the evaluation stops after `MAX_RUNS` script runs. The two + // reads shift and scale time differently, so no two paths meet at the same time and the + // shared result memo can't fold the tree (with `t + a` / `t + b` it collapses to a few + // hundred distinct evaluations and finishes, exactly). let mut f = fx(); - let text = |other: &str| format!("var r = thisComp.layer('{other}').transform.rotation; r.valueAtTime(time + 0.001) + r.valueAtTime(time + 0.002)"); + let text = |other: &str| format!("var r = thisComp.layer('{other}').transform.rotation; r.valueAtTime(time + 0.001) + r.valueAtTime(time * 1.001 + 0.002)"); f.set_expr(f.a, ROT, &text("B")); f.set_expr(f.b, ROT, &text("A")); // Take the clock out of the race: on a loaded machine the time budget could otherwise stop From 04b2d4af3edbafc4805fbbd8dc74ba1b8299b172 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 13:16:44 +0000 Subject: [PATCH 45/45] Expressions: the memo skips any run that calls Math.random or Date, however it reaches them Review on #856: the memo skipped scripts only by text (Math.random, Date), so Math["random"]() or an alias got around it. Math.random and Date (construct, call, Date.now, and through Date.prototype.constructor) are now wrapped at start-up so a call marks the evaluation impure, and impure runs aren't remembered; the text check stays as a cheap first filter. Test: impure_calls_are_caught_however_they_are_spelled. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014d65YdNxQmjRkbXRtP2NwH --- crates/expr/src/lib.rs | 2 +- crates/expr/src/memo.rs | 4 +++- crates/expr/src/runtime.rs | 36 ++++++++++++++++++++++++++++++++++- crates/expr/src/tests_memo.rs | 36 +++++++++++++++++++++++++++++++++++ 4 files changed, 75 insertions(+), 3 deletions(-) diff --git a/crates/expr/src/lib.rs b/crates/expr/src/lib.rs index 43a4f3103..a6d227d74 100644 --- a/crates/expr/src/lib.rs +++ b/crates/expr/src/lib.rs @@ -226,7 +226,7 @@ fn evaluate_out( let mut f = f.borrow_mut(); let Some(fr) = f.get_mut(depth) else { return (vec![], None) }; let used = std::mem::take(&mut fr.used); - let resps = if r.is_ok() && memo::cacheable(text) { fr.take_answers(&used) } else { None }; + let resps = if r.is_ok() && !fr.impure && memo::cacheable(text) { fr.take_answers(&used) } else { None }; (used, resps) }); if let (Ok(out), Some(resps)) = (&r, resps) { diff --git a/crates/expr/src/memo.rs b/crates/expr/src/memo.rs index aa3d487e2..0ac1f7093 100644 --- a/crates/expr/src/memo.rs +++ b/crates/expr/src/memo.rs @@ -114,7 +114,9 @@ pub fn set_off(v: bool) { OFF.with(|o| o.set(v)); } -/// Whether a script's result may be remembered: it reads nothing but its inputs and answers. +/// Whether a script's result may be remembered: it reads nothing but its inputs and answers. A +/// cheap first check by text; a run that reaches `Math.random` or `Date` any other way is caught +/// as it calls them (`Frame::impure`). pub fn cacheable(text: &str) -> bool { !off() && !text.contains("Math.random") && !text.contains("Date") } diff --git a/crates/expr/src/runtime.rs b/crates/expr/src/runtime.rs index e76f1fc1f..d725a36ff 100644 --- a/crates/expr/src/runtime.rs +++ b/crates/expr/src/runtime.rs @@ -51,6 +51,9 @@ pub struct Frame { speculate: bool, /// What this frame evaluates: an evaluation that needs itself is a cycle. pub owner: Option, + /// A run called `Math.random` or `Date` (however the script reached them): its result can't + /// be remembered. + pub impure: bool, } /// One expression evaluation: the property (comp, layer, uid), the comp time and the text. @@ -327,6 +330,35 @@ fn native_noise(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult JsResult { + FRAMES.with(|f| { + if let Some(fr) = f.borrow_mut().last_mut() { + fr.impure = true; + } + }); + Ok(JsValue::undefined()) +} + +/// Wraps the sources of nondeterminism so any call marks the evaluation impure, by property +/// access, alias or prototype (`Math["random"]`, `const M = Math; M.random()`, +/// `Date.prototype.constructor.now()`), not only by spelling. `__impure` is then hidden. +const IMPURE: &str = r#"(function () { + const mark = __impure; + const rnd = Math.random; + Object.defineProperty(Math, "random", { value: function random() { mark(); return rnd(); }, writable: false, configurable: false }); + const D = Date; + const now = D.now; + const P = new Proxy(D, { + construct(t, a, n) { mark(); return Reflect.construct(t, a, n === P ? D : n); }, + apply(t, self, a) { mark(); return Reflect.apply(t, self, a); }, + get(t, k, r) { return k === "now" ? function now_() { mark(); return now.call(D); } : Reflect.get(t, k, t); }, + }); + Object.defineProperty(D.prototype, "constructor", { value: P, writable: false, configurable: false }); + Object.defineProperty(globalThis, "Date", { value: P, writable: false, configurable: false }); + delete globalThis.__impure; +})();"#; + /// `__rnd(layerIndex, uid, userSeed, timeless, time, counter)`. fn native_random(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { let timeless = arg(args, 3).to_boolean(); @@ -341,16 +373,18 @@ impl Runtime { let limits = ctx.runtime_limits_mut(); limits.set_loop_iteration_limit(LOOP_LIMIT); limits.set_recursion_limit(400); - let natives: [(JsString, usize, fn(&JsValue, &[JsValue], &mut Context) -> JsResult); 4] = [ + let natives: [(JsString, usize, fn(&JsValue, &[JsValue], &mut Context) -> JsResult); 5] = [ (js_string!("__h"), 6, native_host), (js_string!("__wig"), 8, native_wiggle), (js_string!("__noise"), 3, native_noise), (js_string!("__rnd"), 6, native_random), + (js_string!("__impure"), 0, native_impure), ]; for (name, len, f) in natives { ctx.register_global_callable(name, len, NativeFunction::from_fn_ptr(f)).map_err(|e| e.to_string())?; } ctx.eval(Source::from_bytes(PRELUDE)).map_err(|e| format!("expression prelude: {e}"))?; + ctx.eval(Source::from_bytes(IMPURE)).map_err(|e| format!("expression prelude: {e}"))?; let global = ctx.global_object(); let func = |name: JsString, ctx: &mut Context| -> Result { global.get(name, ctx).ok().and_then(|v| v.as_object()).ok_or_else(|| "expression prelude: missing function".to_string()) diff --git a/crates/expr/src/tests_memo.rs b/crates/expr/src/tests_memo.rs index 6ddf9c295..1ca1746be 100644 --- a/crates/expr/src/tests_memo.rs +++ b/crates/expr/src/tests_memo.rs @@ -193,3 +193,39 @@ fn impure_scripts_are_not_remembered() { assert!(!memo::cacheable("new Date().getSeconds()")); assert!(memo::cacheable("random(10)")); } + +/// `text` as the rotation expression of a layer of [`rig`], evaluated at time 0. +fn eval_simple(text: &str) -> f64 { + let (mut p, cid) = rig(1); + layer(&mut p, cid, "L0").props.prop_mut(ROT).unwrap().expr = Some(Expression { text: text.into(), enabled: true }); + let host = Expressions; + let c = p.comp(cid).unwrap(); + let mut ctx = EvalCtx::new(&p, cid, c, Tick::ZERO); + ctx.expr = Some(&host as &dyn ExprHost); + let l = c.layer_by_name("L0").unwrap(); + ctx.value(l, l.props.prop(ROT).unwrap()).as_f64() +} + +/// Review on #856: scripts that reach `Math.random` or `Date` without spelling them are not +/// remembered either: each evaluation gets a fresh value. +#[test] +fn impure_calls_are_caught_however_they_are_spelled() { + for (i, text) in [ + r#"Math["random"]() * 1e9"#, + r#"const M = Math; M["ran" + "dom"]() * 1e9"#, + r#"globalThis["Da" + "te"]["now"]() + Math["random"]()"#, + r#"const D = globalThis["Da" + "te"]; new D().getTime() + Math["random"]()"#, + r#"(new (Object.getPrototypeOf(new globalThis["Da" + "te"]()).constructor)()).getTime() + Math["random"]()"#, + ] + .into_iter() + .enumerate() + { + assert!(memo::cacheable(text), "{i}: the text check alone misses it"); + let a = eval_simple(text); + let b = eval_simple(text); + assert_ne!(a, b, "{i}: {text} was remembered"); + } + // Pure scripts are still remembered, and `__impure` isn't reachable. + assert_eq!(eval_simple("1 + 2"), eval_simple("1 + 2")); + assert_eq!(eval_simple("typeof __impure === 'undefined' ? 1 : 0"), 1.0); +}