From c6b1ba0f244edd8855f8a526560c8fe959d40269 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 3 Nov 2026 15:02:16 +0000 Subject: [PATCH] Render: memoized audio detection, bounded stretch, preroll, waveform, cache entries, 3D bounds, solids and selector time comp_has_audio walked a precomp again for every layer using it; each comp is answered once. A tiny stored stretch made one audio block fetch a vast source range; playback speed is capped at 100x. Effect preroll is capped at 10 s. Waveforms stop where the audio ends and summarise at most a day. Disk cache entries are decompressed only when their stated size matches what their dimensions need (and at most 1 GiB). Advanced 3D triangle bounds are clamped on both sides before narrowing. Solids larger than 16 Mpx are made at a lower scale. A camera-facing layer's parent recursion is bounded. A layer's Expression Selectors share one time allowance. A disk cache folder under the temp folder is made private and may not be a link. --- crates/render/src/audio.rs | 61 ++++++++++++++++--- crates/render/src/disk_cache.rs | 78 +++++++++++++++++++++++-- crates/render/src/pipeline.rs | 7 +++ crates/render/src/text.rs | 15 ++++- crates/render/src/three_d/adv/raster.rs | 11 ++-- crates/render/src/three_d/camera.rs | 19 +++++- 6 files changed, 172 insertions(+), 19 deletions(-) diff --git a/crates/render/src/audio.rs b/crates/render/src/audio.rs index e2861340c..9916d0bea 100644 --- a/crates/render/src/audio.rs +++ b/crates/render/src/audio.rs @@ -24,6 +24,15 @@ use crate::{EvalCtx, ExprHost, FootageSource}; const MAX_DEPTH: usize = 16; +/// The fastest a layer's audio plays (a Stretch of 1%). +const MAX_SPEED: f64 = 100.0; + +/// The most effect preroll (history before a block) mixed, in seconds, across a layer's effects. +const MAX_PREROLL_SECONDS: f64 = 10.0; + +/// The longest footage a waveform summarises (a day). +const MAX_PEAK_SECONDS: i64 = 24 * 3600; + /// `frames` stereo sample frames of `comp`'s mix from comp time `start` at `rate` Hz, /// interleaved (L R L R …). pub fn mix_comp(project: &Project, footage: &dyn FootageSource, expr: Option<&dyn ExprHost>, comp: ItemId, start: Tick, frames: usize, rate: u32) -> Vec { @@ -35,7 +44,19 @@ pub fn mix_comp(project: &Project, footage: &dyn FootageSource, expr: Option<&dy /// Whether the comp has anything audible (footage with audio, a sound generator, or a precomp /// that has). pub fn comp_has_audio(project: &Project, comp: ItemId) -> bool { - fn walk(project: &Project, comp: ItemId, depth: usize) -> bool { + // Each comp is answered once: a comp used by many layers at each of several levels was + // walked again for every use. + fn walk(project: &Project, comp: ItemId, depth: usize, seen: &mut std::collections::HashMap) -> bool { + if let Some(&r) = seen.get(&comp) { + return r; + } + // In progress: a cycle back into it adds nothing. + seen.insert(comp, false); + let r = walk_once(project, comp, depth, seen); + seen.insert(comp, r); + r + } + fn walk_once(project: &Project, comp: ItemId, depth: usize, seen: &mut std::collections::HashMap) -> bool { let Some(c) = project.comp(comp) else { return false }; depth < MAX_DEPTH && c.layers.iter().any(|l| { @@ -45,12 +66,12 @@ pub fn comp_has_audio(project: &Project, comp: ItemId) -> bool { LayerSource::Footage { item } => { matches!(project.item(*item).map(|i| &i.kind), Some(ItemKind::Footage(f)) if f.has_audio && !f.missing) } - LayerSource::Comp { item } => walk(project, *item, depth + 1), + LayerSource::Comp { item } => walk(project, *item, depth + 1, seen), _ => false, }) }) } - walk(project, comp, 0) + walk(project, comp, 0, &mut std::collections::HashMap::new()) } /// Enabled audio effects of a layer (in stack order) with their effect ids. @@ -155,7 +176,9 @@ impl MixEnv<'_> { return out; } let m = b - a; - let speed = 100.0 / l.stretch; + // A tiny stretch would fetch a vast source range for one block: past MAX_SPEED the layer + // plays at that speed. + let speed = (100.0 / l.stretch).min(MAX_SPEED); let src_n = ((m as f64 * speed).ceil() as usize).max(1); let lt = l.layer_time(sample_tick(i0 + a as i64, rate)); let buf = match &l.source { @@ -211,7 +234,7 @@ impl MixEnv<'_> { return self.layer_raw(l, i0, n, false); } let reversed = fx.iter().any(|(id, _, _)| audio_fx::reverses(id)); - let pre_s: f64 = fx.iter().map(|(id, _, p)| audio_fx::preroll(id, p)).sum(); + let pre_s: f64 = fx.iter().map(|(id, _, p)| audio_fx::preroll(id, p)).sum::().clamp(0.0, MAX_PREROLL_SECONDS); let pre = (pre_s * self.rate as f64).ceil() as usize; let b0 = i0 - pre as i64; let mut buf = self.layer_raw(l, b0, n + pre, reversed); @@ -339,12 +362,14 @@ pub fn footage_peaks(footage: &dyn FootageSource, item: ItemId, f: &effectcraft_ let bin = (64usize).max(4000 / bins_per_sec as usize); let rate = bin as u32 * bins_per_sec; let chunk = bin * bins_per_sec as usize; - let total = units_ceil(duration, rate).max(0) as usize; + // The stated duration comes from the project: at most a day is summarised, and the summary + // ends where the audio does. + let total = units_ceil(duration, rate).clamp(0, MAX_PEAK_SECONDS.saturating_mul(rate as i64)) as usize; let mut out = Vec::with_capacity(total / bin + 1); let mut i = 0usize; while i < total { let n = chunk.min(total - i); - let buf = footage.audio(item, f, sample_tick(i as i64, rate), n, rate).unwrap_or_else(|| vec![0.0; n * 2]); + let Some(buf) = footage.audio(item, f, sample_tick(i as i64, rate), n, rate) else { break }; out.extend(peak_summary(&buf, bin)); i += n; } @@ -372,6 +397,28 @@ mod tests { } } + #[test] + fn audio_detection_answers_each_comp_once() { + // Twenty levels, each comp holding two layers of the one below: 2^20 walks without a + // memo, and all silent. A cycle is answered too. + let mut p = Project::default(); + let mk = |p: &mut Project, layers: Vec| { + let mut c = Comp::new(8, 8, FrameRate::new(25, 1), Tick::from_seconds_f64(1.0)); + c.layers = layers.into(); + p.add_item("C", effectcraft_color::Label::Sandstone, None, ItemKind::Comp(c.into())) + }; + let base = Comp::new(8, 8, FrameRate::new(25, 1), Tick::from_seconds_f64(1.0)); + let mut below = mk(&mut p, vec![]); + for _ in 0..20 { + let a = build::layer(&mut p, &base, "a", LayerSource::Comp { item: below }, (0, 0), None); + let b = build::layer(&mut p, &base, "b", LayerSource::Comp { item: below }, (0, 0), None); + below = mk(&mut p, vec![a, b]); + } + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || tx.send(comp_has_audio(&p, below)).ok()); + assert!(!rx.recv_timeout(std::time::Duration::from_secs(20)).expect("detection finishes")); + } + #[test] fn mixes_layers_with_levels_and_in_out() { let mut p = Project::default(); diff --git a/crates/render/src/disk_cache.rs b/crates/render/src/disk_cache.rs index 9cf5694f0..9c302f1f2 100644 --- a/crates/render/src/disk_cache.rs +++ b/crates/render/src/disk_cache.rs @@ -299,12 +299,36 @@ pub fn default_folder() -> PathBuf { if let Some(x) = std::env::var_os("XDG_CACHE_HOME") { return PathBuf::from(x).join("effectcraft/disk-cache"); } - home.map(|h| h.join(".cache/effectcraft/disk-cache")).unwrap_or_else(|| std::env::temp_dir().join("effectcraft-disk-cache")) + // Without a home folder: a per-user folder in the shared temp folder, made private by `open`. + let user = std::env::var("USER").or_else(|_| std::env::var("USERNAME")).unwrap_or_default(); + home.map(|h| h.join(".cache/effectcraft/disk-cache")).unwrap_or_else(|| std::env::temp_dir().join(format!("effectcraft-disk-cache-{user}"))) +} + +/// A cache folder in the shared temp folder must be ours alone: not a link someone placed there, +/// and private (setting the mode fails unless we own it), so another user cannot redirect the +/// cache's writes and clean-up or read its files. +#[cfg(unix)] +fn make_private(folder: &Path) -> std::io::Result<()> { + use std::os::unix::fs::PermissionsExt; + if !folder.starts_with(std::env::temp_dir()) { + return Ok(()); + } + if std::fs::symlink_metadata(folder)?.file_type().is_symlink() { + return Err(std::io::Error::new(std::io::ErrorKind::PermissionDenied, "the disk cache folder is a link")); + } + std::fs::set_permissions(folder, std::fs::Permissions::from_mode(0o700)) +} + +#[cfg(not(unix))] +fn make_private(_: &Path) -> std::io::Result<()> { + Ok(()) } impl DiskCache { /// Open (or create) the cache in `folder`, holding at most `max_bytes`. pub fn open(folder: impl AsRef, max_bytes: u64) -> std::io::Result> { + std::fs::create_dir_all(folder.as_ref())?; + make_private(folder.as_ref())?; let dir = folder.as_ref().join(VERSION); std::fs::create_dir_all(&dir)?; let dc = Arc::new(DiskCache { @@ -624,7 +648,7 @@ impl DiskCache { /// A segmentation (chain key, scale) written by [`DiskCache::put_seg`]. pub fn get_seg(&self, key: u64, scale: f64) -> Option { let p = self.read(Kind::Matte, seg_key(key, scale))?; - let raw = lz4_flex::decompress_size_prepended(&p).ok()?; + let raw = decompress_checked(&p, None)?; effectcraft_effects::roto::FrameSeg::from_bytes(&raw) } @@ -778,13 +802,28 @@ fn encode_frame(w: u32, h: u32, rgba: &[u8]) -> Vec { out } +/// The most bytes one cache entry decompresses to (a 16384² frame of 8-bit RGBA): the size an +/// entry states is checked against it, and against what the entry's dimensions need, before +/// anything is allocated. +const MAX_ENTRY_BYTES: usize = 1 << 30; + +/// An LZ4 payload with its stated size prepended, decompressed only when that size is +/// `expected` (any size up to [`MAX_ENTRY_BYTES`] when `None`). +fn decompress_checked(p: &[u8], expected: Option) -> Option> { + let size = u32::from_le_bytes(p.get(..4)?.try_into().ok()?) as usize; + if size > MAX_ENTRY_BYTES || expected.is_some_and(|e| e != size) { + return None; + } + lz4_flex::decompress(p.get(4..)?, size).ok() +} + fn decode_frame(p: &[u8]) -> Option { if p.len() < 8 { return None; } let w = u32::from_le_bytes(p[0..4].try_into().ok()?); let h = u32::from_le_bytes(p[4..8].try_into().ok()?); - let rgba = lz4_flex::decompress_size_prepended(&p[8..]).ok()?; + let rgba = decompress_checked(&p[8..], Some((w as usize).checked_mul(h as usize)?.checked_mul(4)?))?; (rgba.len() == w as usize * h as usize * 4).then_some(Frame8 { width: w, height: h, rgba }) } @@ -820,8 +859,8 @@ fn decode_layer(p: &[u8]) -> Option { let f = |i: usize| -> Option { Some(f64::from_le_bytes(p[i..i + 8].try_into().ok()?)) }; let offset = [f(8)?, f(16)?]; let scale = f(24)?; - let planes = lz4_flex::decompress_size_prepended(&p[32..]).ok()?; - let n = w as usize * h as usize; + let n = (w as usize).checked_mul(h as usize)?; + let planes = decompress_checked(&p[32..], Some(n.checked_mul(16)?))?; if planes.len() != n * 16 { return None; } @@ -980,6 +1019,35 @@ pub fn frame_key(content: u128, frame: i64, scale_milli: u32, view: u64) -> u128 mod tests { use super::*; + #[cfg(unix)] + #[test] + fn a_cache_folder_in_the_temp_folder_is_made_private_and_never_a_link() { + use std::os::unix::fs::PermissionsExt; + let d = tmpdir("private"); + let dc = DiskCache::open(&d, 1 << 20).unwrap(); + assert_eq!(std::fs::metadata(&d).unwrap().permissions().mode() & 0o777, 0o700); + drop(dc); + let link = tmpdir("link"); + std::os::unix::fs::symlink(&d, &link).unwrap(); + assert!(DiskCache::open(&link, 1 << 20).is_err()); + let _ = std::fs::remove_file(&link); + let _ = std::fs::remove_dir_all(&d); + } + + #[test] + fn an_entry_stating_a_size_its_dimensions_do_not_need_is_not_decompressed() { + // A 2×2 frame whose payload states 3 GiB: refused before any allocation. + let mut p = 2u32.to_le_bytes().to_vec(); + p.extend_from_slice(&2u32.to_le_bytes()); + p.extend_from_slice(&(3u32 << 30).to_le_bytes()); + p.extend_from_slice(&[0; 16]); + assert!(decode_frame(&p).is_none()); + assert!(decompress_checked(&p[8..], None).is_none()); + // A well-formed entry still decodes. + let ok = encode_frame(2, 2, &[7; 16]); + assert_eq!(decode_frame(&ok).map(|f| f.rgba), Some(vec![7; 16])); + } + fn tmpdir(name: &str) -> PathBuf { let d = std::env::temp_dir().join(format!("effectcraft-disk-cache-{name}-{}", std::process::id())); let _ = std::fs::remove_dir_all(&d); diff --git a/crates/render/src/pipeline.rs b/crates/render/src/pipeline.rs index a4927144f..a296a6df2 100644 --- a/crates/render/src/pipeline.rs +++ b/crates/render/src/pipeline.rs @@ -36,6 +36,9 @@ pub struct MediaOverride { pub(crate) item: ItemId, } +/// The most pixels a solid layer's buffer holds (as the other layer buffers are held to). +const MAX_SOLID_PIXELS: f64 = 16.0e6; + impl MediaOverride { /// A Media Replacement of `layer`'s footage by `item`. pub fn new(layer: LayerId, item: ItemId) -> Self { @@ -690,6 +693,10 @@ impl<'a> Renderer<'a> { match &layer.source { LayerSource::Solid { item } => { let ItemKind::Solid(sol) = &self.project.item(*item)?.kind else { return None }; + // One colour: a solid larger than MAX_SOLID_PIXELS is made at a lower scale (the + // buffer carries it) instead of allocating gigabytes for a uniform plane. + let area = (sol.width as f64 * s) * (sol.height as f64 * s); + let s = if area > MAX_SOLID_PIXELS { s * (MAX_SOLID_PIXELS / area).sqrt() } else { s }; let w = ((sol.width as f64 * s).ceil() as u32).max(1); let h = ((sol.height as f64 * s).ceil() as u32).max(1); if layer.switches.adjustment { diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index 19c17d53f..91bf49c17 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -244,12 +244,21 @@ pub fn animator_selection(ctx: &EvalCtx, layer: &Layer, anim: &PropGroup, lay: & "expressionSelector" => { let Some(prop) = sel.get("amount") else { continue }; let stat = ctx.v3(layer, sel, "amount", [100.0; 3]); + // One allowance for the whole layer: the expression runs per character, each run + // with its own time limit, so a slow expression on a long text is stopped once it + // has failed or the layer's evaluations pass MAX_SELECTOR_TIME. + let start = web_time::Instant::now(); + let stopped = std::cell::Cell::new(false); Box::new(move |i, n, prev| { let pv = prev.map(|x| x * 100.0); // Without an expression engine: the static amount scales the selection. let fallback = [0, 1, 2].map(|d| stat[d] * prev[d]); - let r = match ctx.expr.filter(|_| prop.has_expression()) { - Some(h) => h.eval_text_selector(ctx, layer, prop, i, n, pv).unwrap_or(fallback), + let live = !stopped.get() && start.elapsed() < MAX_SELECTOR_TIME; + let r = match ctx.expr.filter(|_| live && prop.has_expression()) { + Some(h) => h.eval_text_selector(ctx, layer, prop, i, n, pv).unwrap_or_else(|_| { + stopped.set(true); + fallback + }), None => fallback, }; r.map(|x| (x / 100.0).clamp(-1.0, 1.0)) @@ -275,6 +284,8 @@ pub fn animator_selection(ctx: &EvalCtx, layer: &Layer, anim: &PropGroup, lay: & acc } +/// The most time one layer's Expression Selectors may take across all its characters. +const MAX_SELECTOR_TIME: std::time::Duration = std::time::Duration::from_secs(2); /// Evaluate animators for every glyph. pub fn char_transforms(ctx: &EvalCtx, layer: &Layer, text: &PropGroup, lay: &TextLayout) -> Vec { let mut out = vec![CharXf::default(); lay.glyphs.len()]; diff --git a/crates/render/src/three_d/adv/raster.rs b/crates/render/src/three_d/adv/raster.rs index 1c37a6267..c027c5f95 100644 --- a/crates/render/src/three_d/adv/raster.rs +++ b/crates/render/src/three_d/adv/raster.rs @@ -126,11 +126,14 @@ fn setup(s: &Scene, clip: &[[f32; 4]], range: std::ops::Range) -> Vec= bbox[2] || bbox[1] >= bbox[3] { continue; diff --git a/crates/render/src/three_d/camera.rs b/crates/render/src/three_d/camera.rs index a927e2ceb..84c686135 100644 --- a/crates/render/src/three_d/camera.rs +++ b/crates/render/src/three_d/camera.rs @@ -299,6 +299,13 @@ pub fn auto_orient_2d(ctx: &EvalCtx, layer: &Layer) -> f64 { path_tangent(ctx, layer).map(|t| t.y.atan2(t.x).to_degrees()).unwrap_or(0.0) } +/// The most camera-facing layers whose parents are evaluated inside one another. +const MAX_ORIENT_NESTING: usize = 8; + +thread_local! { + static ORIENT_NESTING: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + /// Whether `id` is `layer` or one of its parents. fn in_parent_chain(ctx: &EvalCtx, layer: &Layer, id: effectcraft_project::LayerId) -> bool { let mut cur = Some(layer); @@ -333,8 +340,18 @@ pub fn auto_orient_3d(ctx: &EvalCtx, layer: &Layer, pos: Vec3) -> Option { if ctx.comp.active_camera(ctx.time).is_some_and(|c| in_parent_chain(ctx, c, layer.id)) { return None; } + // The parent's world matrix evaluates the parent's own orientation: a parent cycle + // of camera-facing layers would recurse without end, and a chain of them is walked + // again from every link. Past MAX_ORIENT_NESTING the layer keeps its Orientation. + let nesting = ORIENT_NESTING.with(|n| n.get()); + if nesting >= MAX_ORIENT_NESTING { + return None; + } + ORIENT_NESTING.with(|n| n.set(nesting + 1)); + let parent = parent_world(ctx, layer); + ORIENT_NESTING.with(|n| n.set(nesting)); let eye = active_camera(ctx).eye; - let e = parent_world(ctx, layer).inverse().unwrap_or(Mat4::IDENTITY).apply(eye); + let e = parent.inverse().unwrap_or(Mat4::IDENTITY).apply(eye); let d = pos - e; (d.length() > 1e-9).then(|| basis_rotation(d, vec3(0.0, 1.0, 0.0))) }