diff --git a/Cargo.toml b/Cargo.toml index 9f4e9d17f..2f5618a31 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -71,6 +71,7 @@ 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). 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/apps/effectcraft/src/native_menu.rs b/apps/effectcraft/src/native_menu.rs index 9db2ffe76..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,22 +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; } - let key = native_menu::state_key(app); - if self.state == Some(key) && self.menu.is_some() { + // 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.pacer.applied() && ctx.input(|i| i.pointer.any_down()) { return; } - self.state = Some(key); + 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 => {} + } 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/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) }; diff --git a/crates/effects/src/generate2.rs b/crates/effects/src/generate2.rs index 80d32ce0e..7245ff3a7 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,116 @@ 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)); + } + } } diff --git a/crates/effects/src/migrate.rs b/crates/effects/src/migrate.rs index 0c0dad9d1..47b764347 100644 --- a/crates/effects/src/migrate.rs +++ b/crates/effects/src/migrate.rs @@ -249,9 +249,77 @@ 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, + (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, + } +} + +/// 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 +538,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(); 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); diff --git a/crates/effects/src/roto.rs b/crates/effects/src/roto.rs index a6c80e742..b4b0fbe2d 100644 --- a/crates/effects/src/roto.rs +++ b/crates/effects/src/roto.rs @@ -238,8 +238,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 })) @@ -256,15 +287,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); } /// A second-level store for segmentations, keyed like the memory cache (chain key, scale): the @@ -803,3 +829,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/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); diff --git a/crates/engine/src/commands/render_queue.rs b/crates/engine/src/commands/render_queue.rs index 09a7c7d2f..31083c20c 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 56754e7c1..cc82594c0 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. @@ -67,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. @@ -155,6 +180,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: @@ -238,6 +265,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; } @@ -266,37 +294,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. + // 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, - }; - 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(_) => {} @@ -505,6 +550,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 { @@ -690,3 +736,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())); + } +} diff --git a/crates/engine/src/session_settings.rs b/crates/engine/src/session_settings.rs index 55c8e4154..6a7c2e201 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/export/src/encode.rs b/crates/export/src/encode.rs index e4a45561d..3a58ff565 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 { @@ -264,120 +265,120 @@ 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 matches!(fmt, OutputFormat::ProRes | OutputFormat::Uncompressed) { 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. - 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/lib.rs b/crates/export/src/lib.rs index eda979958..1a75d0976 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -105,8 +105,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 { @@ -173,8 +180,21 @@ 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); - let mut r = export_with(&cx, progress, t0).await; + // 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 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; @@ -187,6 +207,76 @@ pub async fn export_async(job: &Job<'_>, progress: &mut dyn FnMut(&Progress) -> 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); @@ -229,9 +319,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). @@ -259,7 +361,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 { @@ -369,7 +471,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); @@ -415,3 +517,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 a0093f4c7..1a275fd6f 100644 --- a/crates/export/src/pipeline.rs +++ b/crates/export/src/pipeline.rs @@ -2,12 +2,14 @@ //! 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, 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; @@ -84,6 +86,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> { @@ -123,15 +128,122 @@ 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 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())); - let cache = LayerCache::default(); // 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> { @@ -179,6 +291,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 { @@ -198,6 +314,63 @@ impl<'a> Cx<'a> { (self.job.accel.is_some() && self.project.settings.gpu_acceleration).then(|| gpu_inflight(std::env::var(GPU_INFLIGHT_ENV).ok().as_deref())) } + /// 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.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; + } + 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)) @@ -205,6 +378,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, @@ -222,10 +404,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. @@ -358,6 +554,189 @@ 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>), + /// 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> { + /// 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()))?, + 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(); @@ -652,6 +1031,168 @@ 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; + 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 a12bd4e1a..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(); - 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 ca06a3e51..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(); - 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/output_modules.rs b/crates/export/tests/output_modules.rs new file mode 100644 index 000000000..a8a6788e2 --- /dev/null +++ b/crates/export/tests/output_modules.rs @@ -0,0 +1,241 @@ +//! 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::{ExportError, Job, Progress, export, export_modules}; +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) { + 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(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], 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, [(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 = + 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) +} + +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 = 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 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()) +} + +#[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"); + } +} + +/// 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"); + } + } +} 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"); + } +} diff --git a/crates/expr/src/host.rs b/crates/expr/src/host.rs index 87d2d8be8..c3b1041f1 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. @@ -69,6 +73,24 @@ pub enum Req { SourceInfo { comp: u64, layer: u64 }, } +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![]; @@ -366,28 +388,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); @@ -420,13 +451,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 4963810c4..a6d227d74 100644 --- a/crates/expr/src/lib.rs +++ b/crates/expr/src/lib.rs @@ -26,11 +26,19 @@ //! its own value fails at once, and one evaluation (reads included) runs at most [`MAX_RUNS`] //! scripts for at most [`MAX_TIME`]. Text nested deeper than the parser's stack allows is refused //! ([`rewrite::MAX_NESTING`]); a seeded fuzzer (`tests/fuzz.rs`) keeps all of this honest. +//! * **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. 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; mod runtime; mod sample; @@ -40,9 +48,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, Owner, RUNS, 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. @@ -160,7 +173,7 @@ fn evaluate_out( budget_left()?; 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 { @@ -176,31 +189,73 @@ fn evaluate_out( selector, }; // Answer the requests nearly every expression makes up front. - let mut frame = Frame { owner: Some(owner), ..Frame::default() }; + let mut frame = Frame::new(depth); + frame.owner = Some(owner); 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; + 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 { budget_left()?; RUNS.with(|r| r.set(r.get().saturating_add(1))); match runtime::run(text, &begin) { - Run::Done(r) => return r, + Run::Done(r) => { + 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() && !fr.impure && 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; + } 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. @@ -235,3 +290,7 @@ 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..0ac1f7093 --- /dev/null +++ b/crates/expr/src/memo.rs @@ -0,0 +1,180 @@ +//! 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; +/// 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). +#[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, + bytes: usize, +} + +#[derive(Default)] +struct Memo { + entries: HashMap, + 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! { + 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. 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") +} + +/// 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; + } + 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.bytes = m.bytes.saturating_sub(old.bytes); + } + if m.entries.len() >= MAX_ENTRIES || m.bytes.saturating_add(bytes) > MAX_BYTES { + m.entries.clear(); + m.bytes = 0; + } + 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/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 5f6dd0159..b370a7bcd 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, __SOURCE = 15; +var __FOOT = 11, __FDATA = 12, __SAMPLE = 13, __PROJ = 14, __SOURCE = 15, __KEYS = 16; function __begin(t, v, c, l, path, uid, idx, fd, ti, tt, sv) { time = t; @@ -665,7 +665,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; } @@ -677,8 +677,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]; @@ -724,7 +725,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) { @@ -747,14 +750,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 9ec8172aa..195f741c7 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; @@ -23,14 +23,37 @@ pub const LOOP_LIMIT: u64 = 1_000_000; pub const TIMED_OUT: &str = "Error: expression takes too long to evaluate"; /// 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 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, /// 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. @@ -43,16 +66,43 @@ pub struct Owner { pub text: String, } +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) + } + 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! { /// Never dropped: boa's own thread-local heap may be torn down first at thread exit, and /// dropping GC handles after that aborts the process. The OS reclaims it with the thread. 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 TOTAL_RUNS: Cell = const { Cell::new(0) }; /// Script runs since the outermost evaluation on this thread began (nested ones included). pub static RUNS: std::cell::Cell = const { std::cell::Cell::new(0) }; } +/// Script runs on this thread so far (each evaluation runs its script one or more times). +pub fn runs() -> u64 { + TOTAL_RUNS.with(Cell::get) +} + #[cfg(not(target_arch = "wasm32"))] thread_local! { /// When the outermost evaluation on this thread began. @@ -142,7 +192,56 @@ 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::SourceInfo { .. } => Resp::List(vec![s("footage"), n(-1.0), s(""), n(100.0), n(100.0), n(1.0), n(1.0 / 30.0), n(1.0)]), + 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 { @@ -186,21 +285,31 @@ fn native_host(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult Req::Project, 15 => Req::SourceInfo { comp: id(args, 1, ctx)?, layer: id(args, 2, ctx)? }, + 16 => 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()), } } @@ -223,6 +332,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; + Object.defineProperty(D, "now", { value: function now_() { mark(); return now.call(D); }, writable: false, configurable: false }); + 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); }, + }); + 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(); @@ -237,16 +375,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()) @@ -280,6 +420,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(); + } + }); + TOTAL_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 d71db3900..6b686b20f 100644 --- a/crates/expr/src/tests.rs +++ b/crates/expr/src/tests.rs @@ -22,13 +22,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 { @@ -43,7 +43,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())); @@ -87,12 +87,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. @@ -109,7 +109,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:?}"), @@ -126,7 +126,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() } } @@ -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 diff --git a/crates/expr/src/tests_memo.rs b/crates/expr/src/tests_memo.rs new file mode 100644 index 000000000..76419cbea --- /dev/null +++ b/crates/expr/src/tests_memo.rs @@ -0,0 +1,249 @@ +//! 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; + (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); + 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" }); + } +} + +/// `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)")); +} + +/// `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); +} + +/// Clock-only paths must run again, even when two reads return the same millisecond. +#[test] +fn clock_descriptors_and_aliases_are_never_memoized() { + memo::set_off(false); + for text in [ + r#"Object.getOwnPropertyDescriptor(globalThis["Da" + "te"], "now").value()"#, + r#"Object.getOwnPropertyDescriptors(globalThis["Da" + "te"]).now.value()"#, + r#"const D = globalThis["Da" + "te"]; D["now"]()"#, + r#"const D = globalThis["Da" + "te"]; new D().getTime()"#, + ] { + assert!(memo::cacheable(text)); + assert!(eval_simple(text) > 0.0); + let before = script_runs(); + assert!(eval_simple(text) > 0.0); + assert!(script_runs() > before, "clock expression was reused: {text}"); + } +} diff --git a/crates/expr/src/tests_perf.rs b/crates/expr/src/tests_perf.rs new file mode 100644 index 000000000..e2d62cf59 --- /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; + (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/gpu/src/context.rs b/crates/gpu/src/context.rs index 51221abc3..e8658373e 100644 --- a/crates/gpu/src/context.rs +++ b/crates/gpu/src/context.rs @@ -40,7 +40,7 @@ pub const FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba32Float; /// Uploaded layer buffers kept on the GPU by default (bytes; [`GpuContext::set_upload_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; /// Readback staging buffers kept for reuse across sizes (bytes). @@ -107,6 +107,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, @@ -163,7 +165,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; @@ -237,6 +239,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`). @@ -275,6 +289,29 @@ 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, requested: usize) -> bool { + 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(requested).min(requested); + let over_budget = self.bytes > 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); + } + } + over_budget + } } /// The device and everything compiled for it. @@ -878,6 +915,9 @@ impl GpuContext { /// it (a small GPU); the least recently used textures go first. pub fn set_upload_budget(&self, bytes: usize) { self.upload_budget.store(bytes.max(1 << 20), std::sync::atomic::Ordering::Relaxed); + if let Ok(mut uploads) = self.uploads.lock() { + uploads.trim(self.upload_budget()); + } } pub fn upload_budget(&self) -> usize { @@ -939,33 +979,35 @@ impl GpuContext { u.bytes -= old.bytes; } u.bytes += bytes; - // Drop textures of freed buffers, then the least recently used over budget. - let budget = self.upload_budget(); - if u.bytes > 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; - } - } - } - if u.bytes > budget && !self.upload_full_warned.swap(true, std::sync::atomic::Ordering::Relaxed) { - log::warn!( - target: "effectcraft_gpu", - "GPU upload cache full ({} MB): evicting layer textures, which upload again when next used; a frame whose static layers exceed it re-uploads them every frame", - budget >> 20 - ); - } - while u.bytes > 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; - } + let requested = self.upload_budget(); + let budget = u.budget.unwrap_or(requested).min(requested); + if u.trim(requested) && !self.upload_full_warned.swap(true, std::sync::atomic::Ordering::Relaxed) { + log::warn!(target: "effectcraft_gpu", "GPU upload cache full ({} MB): evicting layer textures", budget >> 20); } } 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 and the configured upload limit, 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); + u.trim(self.upload_budget()); + } + 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() { @@ -1386,3 +1428,47 @@ 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); + // Both independently configured limits apply, including an immediate reduction. + g.set_cache_budget(4 << 20); + g.set_upload_budget(2 << 20); + let large_a = buf(8192); + let large_b = buf(8192); + assert!(g.upload_buf(&large_a).is_some() && g.upload_buf(&large_b).is_some()); + assert_eq!(g.upload_stats(), (2, 2 << 20)); + g.set_upload_budget(1 << 20); + assert_eq!(g.upload_stats(), (1, 1 << 20)); + g.set_cache_budget(512 << 10); + assert_eq!(g.upload_stats(), (0, 0)); + } +} diff --git a/crates/host/src/lib.rs b/crates/host/src/lib.rs index 1e5311b09..32891c6a5 100644 --- a/crates/host/src/lib.rs +++ b/crates/host/src/lib.rs @@ -29,6 +29,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() @@ -42,40 +86,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(), - }, - 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() }) } } @@ -639,4 +662,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]); + } + } + } } diff --git a/crates/media/src/layered.rs b/crates/media/src/layered.rs index 0fb40d81c..5aae4f2b9 100644 --- a/crates/media/src/layered.rs +++ b/crates/media/src/layered.rs @@ -40,8 +40,14 @@ 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() +} + /// Whether `bytes` (the first 64 KiB suffice) are a Photoshop or vector file [`probe`] handles. -#[cfg_attr(not(any(unix, windows)), allow(dead_code))] +/// (The probe and pool use [`claims`] and [`crate::open`]; kept as upstream has it.) +#[allow(dead_code)] pub(crate) fn sniff(path: &str, bytes: &[u8]) -> bool { effectcraft_psd::is_psd(bytes) || is_svg(path, bytes) || is_pdf_like(path, bytes) } diff --git a/crates/media/src/lib.rs b/crates/media/src/lib.rs index 04a9d95e3..31dfbccc8 100644 --- a/crates/media/src/lib.rs +++ b/crates/media/src/lib.rs @@ -21,6 +21,7 @@ mod dpx; 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 98c707108..28c844d28 100644 --- a/crates/media/src/pool.rs +++ b/crates/media/src/pool.rs @@ -578,14 +578,15 @@ 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 { self.opened(path).map(|(src, _)| src) } @@ -596,21 +597,14 @@ impl Inner { slot.get_or_init(|| { self.opened.fetch_add(1, Ordering::Relaxed); // Files on disk are read in place (the index, then samples on demand) rather than - // whole; in-memory files (`add_bytes`) and wasm32 builds open from bytes. - let in_memory = lock(&self.files).contains_key(&**path); - let file = std::path::Path::new(&**path); - let in_place = if in_memory { None } else { crate::stream::open_source(file) }; - let in_place = in_place.map(|src| { - let turn = crate::stream::movie_header(file).map_or(Orientation::NoTransforms, |h| crate::probe::video_turn(&h)); - src.map(|src| (src, turn)) - }); - let opened = in_place.unwrap_or_else(|| { - 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(); - let turn = crate::probe::video_turn(&bytes); - filmcraft_codecs::open_bytes(&name, bytes).map(|src| (src, turn)).map_err(MediaError::from) - }) - }); + // whole; in-memory files (`add_bytes`) open from bytes. A movie shot with the camera + // on its side: its header says how it is shown. + let mem = lock(&self.files).get(&**path).cloned(); + let turn = match mem.as_deref() { + Some(bytes) => crate::probe::video_turn(bytes), + None => crate::stream::movie_header(std::path::Path::new(&**path)).map_or(Orientation::NoTransforms, |h| crate::probe::video_turn(&h)), + }; + let opened = crate::open::open_source(path, mem).map(|src| (src, turn)); match opened { Ok(s) => Some(s), Err(e) => { diff --git a/crates/media/src/probe.rs b/crates/media/src/probe.rs index bae714c81..e546e511a 100644 --- a/crates/media/src/probe.rs +++ b/crates/media/src/probe.rs @@ -14,21 +14,49 @@ 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). +/// Standalone audio uses FilmCraft; stills are read whole. pub fn probe(path: impl AsRef) -> Result { let path = path.as_ref(); - // A movie is read in place (its index), not loaded whole. - if let Some(src) = crate::stream::probe_source(path) { - let mut f = footage_from_info(&path.to_string_lossy(), src?.info()); - let turn = crate::stream::movie_header(path).map_or(Orientation::NoTransforms, |h| video_turn(&h)); - turn_footage(&mut f, turn); - return Ok(f); - } - 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) { + // A movie shot with the camera on its side: its header says how it is shown. + let mut f = footage_from_info(&p, src?.info()); + let turn = crate::stream::movie_header(path).map_or(Orientation::NoTransforms, |h| video_turn(&h)); + turn_footage(&mut f, turn); + 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) || image::guess_format(head).is_ok() } /// Probe a 3D model (glTF 2.0 / OBJ / STL / FBX): a [`FootageKind::Model`] item whose duration is its diff --git a/crates/media/src/stream.rs b/crates/media/src/stream.rs index 447e239d1..94ba2bb9a 100644 --- a/crates/media/src/stream.rs +++ b/crates/media/src/stream.rs @@ -6,6 +6,10 @@ //! random-access [`ByteReader`]; [`FileReader`] serves them positional reads from the open file. //! Formats decoded in one go (stills, WAV, FLAC, MP3…) still read the whole file, as before. //! Builds without a native file system (wasm32) keep using in-memory bytes. +//! +//! The pool and probe open movies in place through [`crate::open`]; only [`movie_header`] is +//! used here (movie rotation). +#![allow(dead_code)] use std::path::Path; diff --git a/crates/media/tests/footage.rs b/crates/media/tests/footage.rs index 13ca605e2..96e8415e7 100644 --- a/crates/media/tests/footage.rs +++ b/crates/media/tests/footage.rs @@ -896,6 +896,25 @@ fn conformed_flac_audio_reads_back_like_the_decoder() { let _ = std::fs::remove_dir_all(&folder); } +/// File probing describes media exactly as opening the whole file in memory does, +/// including standalone audio through FilmCraft and indexed movie readers. +#[test] +fn file_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()); + } +} + /// File ▸ Import of an STL part: importable by extension and probed as a 3D model item. #[test] fn stl_imports_as_a_model() { diff --git a/crates/project/src/props.rs b/crates/project/src/props.rs index 6212a4b59..69a3bf6f6 100644 --- a/crates/project/src/props.rs +++ b/crates/project/src/props.rs @@ -267,13 +267,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 { @@ -614,3 +725,165 @@ 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, skip_serializing_if = "Option::is_none")] + label: Option, + #[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, + label: g.label, + 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.label = Some(effectcraft_color::Label::Blue); + 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}"); + } + } +} diff --git a/crates/render/src/cache.rs b/crates/render/src/cache.rs index bae12d1e3..c1241247f 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/disk_cache.rs b/crates/render/src/disk_cache.rs index 4140b7923..889bd6de6 100644 --- a/crates/render/src/disk_cache.rs +++ b/crates/render/src/disk_cache.rs @@ -34,6 +34,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 { @@ -218,9 +223,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, @@ -234,6 +249,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). @@ -304,8 +321,9 @@ 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), + generation: AtomicU64::new(0), }); dc.rescan(); dc.evict(); @@ -354,6 +372,7 @@ impl DiskCache { if let Ok(mut g) = self.index.lock() { *g = idx; } + self.changed(); } fn path(&self, kind: Kind, key: u128) -> PathBuf { @@ -384,6 +403,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() @@ -409,6 +438,7 @@ impl DiskCache { if let Ok(mut g) = self.index.lock() { g.clear(); } + self.changed(); for kind in [Kind::Frame, Kind::Layer, Kind::Matte] { let _ = std::fs::remove_dir_all(self.dir.join(kind.dir())); } @@ -421,6 +451,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) { @@ -467,6 +498,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) { @@ -478,10 +510,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(); } @@ -512,6 +545,7 @@ impl DiskCache { if let Ok(mut g) = self.index.lock() { g.insert(kind, key, data.len() as u64); } + self.changed(); self.evict(); } @@ -522,6 +556,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); @@ -550,12 +587,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)); @@ -591,6 +643,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; } @@ -895,6 +949,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(); diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 41036fe6b..5f9a94558 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; @@ -2392,6 +2393,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; 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/shapes.rs b/crates/render/src/shapes.rs index aa46fa501..68f0930ae 100644 --- a/crates/render/src/shapes.rs +++ b/crates/render/src/shapes.rs @@ -563,6 +563,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 @@ -582,11 +608,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); 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)); +} 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. 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); + } +} diff --git a/crates/text/src/fonts.rs b/crates/text/src/fonts.rs index d555954c9..4687a99a3 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}; @@ -197,11 +198,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() @@ -723,6 +734,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 { @@ -884,6 +911,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/text/src/layout.rs b/crates/text/src/layout.rs index c15032154..815f2786e 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -1028,9 +1028,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). @@ -1045,22 +1124,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 e8ba9d95c..13a9518c5 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -19,7 +19,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}; @@ -49,11 +50,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); @@ -63,7 +65,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(); } @@ -76,7 +78,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). @@ -238,6 +241,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, @@ -324,11 +385,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; } } @@ -367,7 +428,182 @@ 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 { + 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(); + 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) @@ -447,6 +683,112 @@ 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: "".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()); + } + /// #1063 (item 8): After Effects scripts use CR for Source Text line breaks. #[test] fn carriage_returns_create_lines_without_rewriting_source_text() { diff --git a/crates/ui-egui/src/frames.rs b/crates/ui-egui/src/frames.rs index 059bc3a03..c2ef97a8b 100644 --- a/crates/ui-egui/src/frames.rs +++ b/crates/ui-egui/src/frames.rs @@ -150,12 +150,15 @@ 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, /// The playing preview, if any: evictions spare the frames it shows next. playhead: Option, } 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); @@ -208,6 +211,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); } } @@ -455,6 +459,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 { @@ -462,7 +487,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, playhead: None })), + 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, playhead: None })), inflight: Arc::new(Mutex::new(HashSet::new())), queue: Arc::new(Mutex::new(Queue::default())), #[cfg(not(target_arch = "wasm32"))] @@ -485,6 +510,7 @@ impl Default for Frames { keepers: Mutex::default(), remote: None, remote_busy: Arc::default(), + bar: Mutex::default(), } } } @@ -543,15 +569,15 @@ pub fn fit_texture_by(img: egui::ColorImage, max_side: usize, min_k: usize) -> e average_down(&img, k) } -/// A shared frame as texture data (see [`fit_texture_by`]): the frame itself when it needs no -/// averaging, without copying its pixels. The viewer puts every frame a preview shows on screen; -/// copying a 4K frame first held the UI thread for about a millisecond each time (#595). -pub fn fit_shared_texture_by(img: &Arc, max_side: usize, min_k: usize) -> egui::ImageData { +/// [`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`). The viewer puts every frame a preview shows +/// on screen; copying a 4K frame first held the UI thread for about a millisecond each time (#595). +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 egui::ImageData::Color(img.clone()); + return Arc::clone(img); } - average_down(img, k).into() + Arc::new(average_down(img, k)) } /// `img` averaged down by `k` (rounded up at the edges). @@ -749,9 +775,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 } @@ -768,6 +801,19 @@ impl Frames { if src.disk.is_none() && remote.is_none() { return vec![]; } + let oh = opts_hash(opts); + // 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(); + } + // Native: hashing the comp never blocks timeline painting; until its key is ready a worker + // computes it and there are no blue frames (nothing remembered). #[cfg(not(target_arch = "wasm32"))] let content = { let known = self.content_keys.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(&(base.revision, base.comp)).copied(); @@ -780,11 +826,15 @@ impl Frames { #[cfg(target_arch = "wasm32")] let content = content_key(&self.content_keys, &src.project, base.revision, base.comp); let ram: HashSet = self.cached_frames(base).into_iter().collect(); - let oh = opts_hash(opts); - (0..frames.clamp(0, 100_000)) + let v: Vec = (0..frames.clamp(0, 100_000)) .filter(|f| !ram.contains(f)) .filter(|f| contains(disk_cache::frame_key(content, *f, base.scale, base.view ^ 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 } #[cfg(not(target_arch = "wasm32"))] @@ -845,6 +895,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; @@ -1454,6 +1505,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] @@ -1760,6 +1817,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); + } + fn playhead(cur: i64, looping: bool) -> Playhead { Playhead { series: key(1, 0, 0), cur, start: 0, end: 99, step: 1, looping } } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 105a075e7..cf80a7cc9 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -30,6 +30,7 @@ mod pointer_wrap; pub mod prefs_live; pub mod state; pub mod theme; +mod thumb_lru; pub mod widgets; mod workspace_state; @@ -52,6 +53,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 { @@ -483,7 +499,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). @@ -786,6 +806,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()); @@ -1230,8 +1251,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); @@ -1322,12 +1347,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 => { @@ -1353,7 +1385,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 }) { @@ -1371,8 +1403,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 @@ -1969,6 +2004,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; diff --git a/crates/ui-egui/src/native_menu.rs b/crates/ui-egui/src/native_menu.rs index 3e0984494..05554d57d 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}; @@ -330,6 +331,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> { @@ -612,6 +667,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(); diff --git a/crates/ui-egui/src/panels/footage_panel.rs b/crates/ui-egui/src/panels/footage_panel.rs index a9e7cefb2..0f251fb94 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; @@ -28,12 +31,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 { @@ -54,23 +59,20 @@ 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); - 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]) - }), - }; + // 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 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); @@ -121,7 +123,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, ); @@ -154,3 +156,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)); + } + } +} 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 4a0ab6ae7..0598168c8 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 46187fa8c..bfb62a32f 100644 --- a/crates/ui-egui/src/panels/project.rs +++ b/crates/ui-egui/src/panels/project.rs @@ -110,6 +110,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. @@ -118,6 +121,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/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:?}"); + } +} diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index f4bb5a881..fc68d7ade 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -2647,9 +2647,9 @@ 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_shared_texture_by(&img, 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() => { + Some((tex, k, m)) if tex.size() == fitted.size => { tex.set(fitted, opts); (*k, *m) = (key, minify); } diff --git a/crates/ui-egui/src/panels/viewer_tools.rs b/crates/ui-egui/src/panels/viewer_tools.rs index d229f272f..5cc47923a 100644 --- a/crates/ui-egui/src/panels/viewer_tools.rs +++ b/crates/ui-egui/src/panels/viewer_tools.rs @@ -342,12 +342,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. @@ -843,6 +845,21 @@ 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 %"); diff --git a/crates/ui-egui/src/panels/viewers.rs b/crates/ui-egui/src/panels/viewers.rs index 8d2351330..a437cdc01 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,16 @@ 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 321e432e9..ad4afe181 100644 --- a/crates/ui-egui/src/prefs_live.rs +++ b/crates/ui-egui/src/prefs_live.rs @@ -180,10 +180,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) diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index f5a0664c2..60a6b22b3 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -417,10 +417,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() { 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)); + } +}