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.
This commit is contained in:
Dylan M. Taylor
2026-11-03 15:02:16 +00:00
parent 7870b5916a
commit c6b1ba0f24
6 changed files with 172 additions and 19 deletions
+54 -7
View File
@@ -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<f32> {
@@ -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<ItemId, bool>) -> 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<ItemId, bool>) -> 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::<f64>().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<Layer>| {
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();
+73 -5
View File
@@ -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<Path>, max_bytes: u64) -> std::io::Result<Arc<DiskCache>> {
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<effectcraft_effects::roto::FrameSeg> {
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<u8> {
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<usize>) -> Option<Vec<u8>> {
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<Frame8> {
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<Buf> {
let f = |i: usize| -> Option<f64> { 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);
+7
View File
@@ -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 {
+13 -2
View File
@@ -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<CharXf> {
let mut out = vec![CharXf::default(); lay.glyphs.len()];
+7 -4
View File
@@ -126,11 +126,14 @@ fn setup(s: &Scene, clip: &[[f32; 4]], range: std::ops::Range<usize>) -> Vec<STr
x1 = x1.max(v.0[0]);
y1 = y1.max(v.0[1]);
}
// Clamped on both sides to the viewport before narrowing to i32, so a far-off
// coordinate cannot wrap into a huge pixel range.
let (w, h) = (s.width as i64, s.height as i64);
let bbox = [
(x0.floor() as i64).max(0) as i32,
(y0.floor() as i64).max(0) as i32,
(x1.ceil() as i64).min(s.width as i64) as i32,
(y1.ceil() as i64).min(s.height as i64) as i32,
(x0.floor() as i64).clamp(0, w) as i32,
(y0.floor() as i64).clamp(0, h) as i32,
(x1.ceil() as i64).clamp(0, w) as i32,
(y1.ceil() as i64).clamp(0, h) as i32,
];
if bbox[0] >= bbox[2] || bbox[1] >= bbox[3] {
continue;
+18 -1
View File
@@ -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<usize> = 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<Mat4> {
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)))
}