Render: memoized audio detection, bounded stretch, preroll, waveform, cache entries, 3D bounds, solids and selector time

* 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.

* address review: audio detection by breadth-first search, private cache folder resolved before the temp check, oversized layers not written, solid cap after rounding, selector static amount, viewport i32 clamp

* address review: private-folder check skips the temp folder itself; override value in expression fallback; assert alias cache opens

* address review: refuse the shared temp folder itself as a disk cache folder

* address review: shared counted selector budget, 30 s preroll, cache hardening (shared temp roots, matte discard, exact LZ4 length, render version 7), orient nesting guard
This commit is contained in:
Dylan M. Taylor authored and GitHub committed 2026-11-12 06:29:56 -05:00
1 parent a287044644
commit 075aa03ebe
9 files changed
+383 -44

No files matched your search

+86 -19
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 = 30.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,22 +44,29 @@ 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 {
let Some(c) = project.comp(comp) else { return false };
depth < MAX_DEPTH
&& c.layers.iter().any(|l| {
l.switches.audio
&& (generates(l)
|| match &l.source {
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),
_ => false,
})
})
// Breadth first, so each comp is looked at once and at its shallowest level: a comp reached
// first through a long chain (past MAX_DEPTH) is still found by a shorter route, and a cycle
// cannot hide a result (nothing is cached as "silent" while it is being explored).
let mut seen = std::collections::HashSet::from([comp]);
let mut level = vec![comp];
for _ in 0..MAX_DEPTH {
let mut next = vec![];
for id in level {
let Some(c) = project.comp(id) else { continue };
for l in c.layers.iter().filter(|l| l.switches.audio) {
match &l.source {
_ if generates(l) => return true,
LayerSource::Footage { item } if matches!(project.item(*item).map(|i| &i.kind), Some(ItemKind::Footage(f)) if f.has_audio && !f.missing) => {
return true;
}
LayerSource::Comp { item } if seen.insert(*item) => next.push(*item),
_ => {}
}
}
}
level = next;
}
walk(project, comp, 0)
false
}
/// Enabled audio effects of a layer (in stack order) with their effect ids.
@@ -157,7 +173,7 @@ impl MixEnv<'_> {
let m = b - a;
// Within ±1 % to ±9900 % (#3247): a stretch of 1e-300 % set in memory read about
// usize::MAX source samples for one block.
let speed = 100.0 / l.effective_stretch();
let speed = (100.0 / l.effective_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 {
@@ -213,7 +229,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);
@@ -342,12 +358,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;
}
@@ -375,6 +393,55 @@ 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 audio_found_by_a_short_route_is_not_hidden_by_a_deep_one() {
let mut p = Project::default();
let base = Comp::new(8, 8, FrameRate::new(25, 1), Tick::from_seconds_f64(1.0));
let mk = |p: &mut Project, layers: Vec<Layer>| {
let mut c = base.clone();
c.layers = layers.into();
p.add_item("C", effectcraft_color::Label::Sandstone, None, ItemKind::Comp(c.into()))
};
let f = Footage { kind: FootageKind::Audio, has_audio: true, ..Default::default() };
let fid = p.add_item("x.wav", effectcraft_color::Label::SeaFoam, None, ItemKind::Footage(f));
let sound = build::layer(&mut p, &base, "s", LayerSource::Footage { item: fid }, (0, 0), None);
let audible = mk(&mut p, vec![sound]);
// A chain of MAX_DEPTH comps ends in `audible` one level past what is searched; the root
// reaches `audible` directly as well, after the chain.
let mut chain = audible;
for _ in 0..MAX_DEPTH {
let l = build::layer(&mut p, &base, "c", LayerSource::Comp { item: chain }, (0, 0), None);
chain = mk(&mut p, vec![l]);
}
assert!(!comp_has_audio(&p, chain), "too deep to be found");
let via_chain = build::layer(&mut p, &base, "chain", LayerSource::Comp { item: chain }, (0, 0), None);
let direct = build::layer(&mut p, &base, "direct", LayerSource::Comp { item: audible }, (0, 0), None);
let root = mk(&mut p, vec![via_chain, direct]);
assert!(comp_has_audio(&p, root));
}
#[test]
fn mixes_layers_with_levels_and_in_out() {
let mut p = Project::default();
+158 -13
View File
@@ -483,12 +483,54 @@ 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;
// Judge the folder as written and as it resolves (a relative path, a link or an alias such as
// /tmp -> /private/tmp can all land in the shared temp folder).
// The shared roots: the process's temp folder (`TMPDIR`) and the system's own, which a cache
// configured by path lands in whatever `TMPDIR` says.
let roots: Vec<PathBuf> = [std::env::temp_dir(), PathBuf::from("/tmp"), PathBuf::from("/var/tmp")]
.into_iter()
.flat_map(|t| {
let real = t.canonicalize().unwrap_or_else(|_| t.clone());
[t, real]
})
.collect();
let given = std::path::absolute(folder).unwrap_or_else(|_| folder.to_path_buf());
let real = folder.canonicalize().unwrap_or_else(|_| given.clone());
// The shared folder itself is not ours to change or to keep a cache in: refused.
if [&given, &real].iter().any(|p| roots.contains(p)) {
return Err(std::io::Error::new(std::io::ErrorKind::PermissionDenied, "the disk cache folder is the shared temp folder"));
}
// Only folders inside it are made private.
if ![&given, &real].iter().any(|p| roots.iter().any(|t| p.starts_with(t) && **p != *t)) {
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 {
@@ -1145,8 +1187,9 @@ impl DiskCache {
/// started at.
/// The buffer is shared, and encoded on the writer thread.
pub fn put_layer(&self, key: u128, buf: &Arc<Buf>, files: &EntryFiles, since: u64) {
// (no room on the disk: refused before the buffer is queued and compressed)
if self.contains(Kind::Layer, key) || !self.accepts_writes() {
// (no room on the disk, or a buffer no entry could hold: refused before it is queued and
// compressed)
if !layer_fits(buf.img.data.len()) || self.contains(Kind::Layer, key) || !self.accepts_writes() {
return;
}
self.count_encode();
@@ -1155,6 +1198,9 @@ impl DiskCache {
/// Write a layer buffer synchronously (tests); a list that can't be stored is not.
pub fn put_layer_now(&self, key: u128, buf: &Buf, files: &ReadFiles) {
if !layer_fits(buf.img.data.len()) {
return;
}
if let Some(files) = EntryFiles::of(files, None) {
self.write_format(Kind::Layer, key, FORMAT_LAYER_FILES, &with_files(&files, encode_layer(buf)));
}
@@ -1163,8 +1209,12 @@ 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()?;
effectcraft_effects::roto::FrameSeg::from_bytes(&raw)
let seg = decompress_checked(&p, None).and_then(|raw| effectcraft_effects::roto::FrameSeg::from_bytes(&raw));
if seg.is_none() {
// A rejected entry would otherwise stay indexed and keep `put_seg` from replacing it.
self.discard(Kind::Matte, seg_key(key, scale));
}
seg
}
/// Queue a segmentation for writing.
@@ -1403,16 +1453,38 @@ 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;
}
// A block that ends short of the size it states is damaged: callers index by that size.
lz4_flex::decompress(p.get(4..)?, size).ok().filter(|raw| raw.len() == size)
}
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 })
}
/// Whether a layer of `pixels` pixels (16 bytes each, decompressed) is within what a cache entry
/// may hold: a larger one could never be read back, so it is not compressed or written.
fn layer_fits(pixels: usize) -> bool {
pixels.checked_mul(16).is_some_and(|b| b <= MAX_ENTRY_BYTES)
}
/// Half floats with their low and high bytes in planes (per channel), then LZ4: the high bytes
/// (sign, exponent) repeat a lot, mixed with the low ones LZ4 finds little.
fn encode_frame_f16(w: u32, h: u32, rgba: &[u16]) -> Vec<u8> {
@@ -1440,10 +1512,7 @@ fn decode_frame_f16(p: &[u8]) -> Option<FrameF16> {
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 n = (w as usize).checked_mul(h as usize)?;
let planes = lz4_flex::decompress_size_prepended(&p[8..]).ok()?;
if planes.len() != n.checked_mul(8)? {
return None;
}
let planes = decompress_checked(&p[8..], Some(n.checked_mul(8)?))?;
let mut rgba = vec![0u16; n * 4];
for (i, px) in rgba.as_chunks_mut::<4>().0.iter_mut().enumerate() {
for (c, v) in px.iter_mut().enumerate() {
@@ -1486,9 +1555,9 @@ 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).checked_mul(h as usize)?;
if planes.len() != n.checked_mul(16)? {
let planes = decompress_checked(&p[32..], Some(n.checked_mul(16)?))?;
if planes.len() != n * 16 {
return None;
}
let mut img = Image::new(w, h);
@@ -1507,7 +1576,7 @@ fn decode_layer(p: &[u8]) -> Option<Buf> {
// ---------------------------------------------------------------- content keys
/// Bump when rendering changes in a way that makes old disk entries wrong.
const RENDER_VERSION: &str = "effectcraft-render-6";
const RENDER_VERSION: &str = "effectcraft-render-7";
/// Feeds the `Debug` form of `v` into the hash, followed by a separator byte.
fn hash_debug(h: &mut Hash128, v: &impl std::fmt::Debug) {
@@ -1619,6 +1688,82 @@ 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-private-temp");
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);
}
#[cfg(unix)]
#[test]
fn the_shared_temp_folder_itself_is_refused_as_a_cache_folder() {
let tmp = std::env::temp_dir();
assert!(DiskCache::open(&tmp, 1 << 20).is_err());
}
#[cfg(unix)]
#[test]
fn a_folder_that_resolves_into_the_temp_folder_is_made_private() {
use std::os::unix::fs::PermissionsExt;
// `<cwd>/link` names a folder in the temp folder only through the link.
let real = tmpdir("alias-real");
std::fs::create_dir_all(&real).unwrap();
let link = std::env::current_dir().unwrap().join(format!("effectcraft-alias-link-{}", std::process::id()));
let _ = std::fs::remove_file(&link);
std::os::unix::fs::symlink(&real, &link).unwrap();
let dc = DiskCache::open(link.join("cache"), 1 << 20).unwrap();
let mode = std::fs::metadata(real.join("cache")).map(|m| m.permissions().mode() & 0o777);
drop(dc);
let _ = std::fs::remove_file(&link);
let _ = std::fs::remove_dir_all(&real);
assert_eq!(mode.unwrap(), 0o700);
}
#[test]
fn layers_too_big_to_read_back_are_not_written() {
assert!(layer_fits(MAX_ENTRY_BYTES / 16));
assert!(!layer_fits(MAX_ENTRY_BYTES / 16 + 1));
assert!(!layer_fits(10_000 * 10_000));
assert!(!layer_fits(usize::MAX));
}
#[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]));
}
#[test]
fn a_block_shorter_than_the_size_it_states_is_rejected() {
// The payload states 100 bytes but holds a valid block of 10: `lz4_flex` returns the 10.
let mut p = 100u32.to_le_bytes().to_vec();
p.extend_from_slice(&lz4_flex::block::compress(&[5u8; 10]));
assert!(decompress_checked(&p, None).is_none());
// A half-float frame whose planes come up short is refused, not indexed past.
let mut f16 = 2u32.to_le_bytes().to_vec();
f16.extend_from_slice(&2u32.to_le_bytes());
f16.extend_from_slice(&32u32.to_le_bytes());
f16.extend_from_slice(&lz4_flex::block::compress(&[1u8; 8]));
assert!(decode_frame_f16(&f16).is_none());
}
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);
+1 -1
View File
@@ -283,7 +283,7 @@ impl<'a> EvalCtx<'a> {
}
impl crate::PropertyOverride {
fn value(&self, layer: &Layer, prop: &Property, time: Tick) -> Value {
pub(crate) fn value(&self, layer: &Layer, prop: &Property, time: Tick) -> Value {
if self.font {
// A Font control merges only the font into the target text, as
// `essential::with_overrides` does.
+26 -2
View File
@@ -56,6 +56,29 @@ 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;
/// The scale and whole-pixel size of a `width`×`height` solid rendered at `scale`, lowered until
/// the rounded size (each side at least one pixel) is within [`MAX_SOLID_PIXELS`].
pub(crate) fn solid_size(width: u32, height: u32, mut scale: f64) -> (f64, u32, u32) {
let size = |s: f64| (((width as f64 * s).ceil() as u32).max(1), ((height as f64 * s).ceil() as u32).max(1));
// Rounding up leaves an elongated solid over the cap after one step; each step shrinks the
// overshoot geometrically, so a few are enough.
for i in 0..64 {
let (w, h) = size(scale);
let area = w as f64 * h as f64;
if area <= MAX_SOLID_PIXELS {
break;
}
// The first step is the exact area ratio; later ones undershoot a little so rounding up
// cannot leave the size stuck just over the cap.
scale *= (MAX_SOLID_PIXELS / area).sqrt() * if i == 0 { 1.0 } else { 0.99 };
}
let (w, h) = size(scale);
(scale, w, h)
}
impl MediaOverride {
/// A Media Replacement of `layer`'s footage by `item`.
pub fn new(layer: LayerId, item: ItemId) -> Self {
@@ -899,8 +922,9 @@ impl<'a> Renderer<'a> {
match &layer.source {
LayerSource::Solid { item } => {
let ItemKind::Solid(sol) = &self.project.item(*item)?.kind else { return None };
let w = ((sol.width as f64 * s).ceil() as u32).max(1);
let h = ((sol.height as f64 * s).ceil() as u32).max(1);
// 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 (s, w, h) = solid_size(sol.width, sol.height, s);
if layer.switches.adjustment {
return Some(Buf { img: Image::filled(w, h, [1.0; 4]), offset: [0.0; 2], scale: s });
}
+14
View File
@@ -2002,3 +2002,17 @@ fn a_copy_of_an_overridden_clip_is_not_served_the_keyed_clips_pixels() {
assert_same(&render(&keyed, Some(&cache)), &render(&keyed, None), "the keyed clip's copy");
assert_same(&render(&overridden, Some(&cache)), &render(&overridden, None), "the overridden clip's copy");
}
#[test]
fn elongated_solids_stay_within_the_pixel_cap_after_rounding() {
use crate::pipeline::solid_size;
// 1 × 100 million at full scale: rounding up used to leave 40 million pixels.
for (w, h) in [(1u32, 100_000_000u32), (100_000_000, 1), (u32::MAX, u32::MAX), (1, u32::MAX)] {
let (s, pw, ph) = solid_size(w, h, 1.0);
assert!(s > 0.0 && f64::from(pw) * f64::from(ph) <= 16.0e6, "{w}x{h} -> {pw}x{ph} at {s}");
}
// A solid within the cap keeps its scale; an over-cap square is lowered by the exact ratio.
assert_eq!(solid_size(4000, 4000, 1.0), (1.0, 4000, 4000));
let (s, pw, ph) = solid_size(5000, 5000, 1.0);
assert!((s - 0.8).abs() < 0.01 && pw * ph <= 16_000_000);
}
+55 -4
View File
@@ -198,6 +198,35 @@ pub fn in_index_units(layer: &Layer, uid: effectcraft_project::Uid) -> bool {
/// Selection of every glyph by an animator's selectors, per dimension.
pub fn animator_selection(ctx: &EvalCtx, layer: &Layer, anim: &PropGroup, lay: &TextLayout) -> Vec<[f64; 3]> {
animator_selection_within(ctx, layer, anim, lay, &SelectorBudget::new())
}
/// The Expression Selector evaluations one layer's animators may run in all: counted, not timed,
/// so the same frame renders the same on every machine.
pub struct SelectorBudget(std::cell::Cell<usize>);
impl SelectorBudget {
pub fn new() -> SelectorBudget {
SelectorBudget(std::cell::Cell::new(MAX_SELECTOR_EVALS))
}
/// Take one evaluation; `false` when none are left.
fn take(&self) -> bool {
let left = self.0.get();
self.0.set(left.saturating_sub(1));
left > 0
}
}
impl Default for SelectorBudget {
fn default() -> Self {
Self::new()
}
}
/// [`animator_selection`] drawing its Expression Selector evaluations from `budget`, which the
/// caller shares across the layer's animators.
pub fn animator_selection_within(ctx: &EvalCtx, layer: &Layer, anim: &PropGroup, lay: &TextLayout, budget: &SelectorBudget) -> Vec<[f64; 3]> {
let sels: Vec<&PropGroup> = anim.sub("selectors").map(|s| s.groups().filter(|g| g.enabled).collect()).unwrap_or_default();
if sels.is_empty() {
// No selectors: every character is fully affected.
@@ -250,13 +279,31 @@ pub fn animator_selection(ctx: &EvalCtx, layer: &Layer, anim: &PropGroup, lay: &
{
log.mark_unkeyed();
}
let stat = ctx.v3(layer, sel, "amount", [100.0; 3]);
// The keyframed amount, read without running its expression (the fallback when the
// expression cannot run); an Essential Property override still applies.
let stat = if prop.has_expression() {
// The expression is not run here, but an Essential Property override still wins over the base value.
match ctx.property_overrides.iter().rev().find(|o| o.layer == layer.id && o.prop == prop.uid) {
Some(o) => o.value(layer, prop, ctx.time),
None => prop.value_at(layer.layer_time(ctx.time)),
}
} else {
ctx.value(layer, prop)
}
.as_vec3();
// One allowance for the whole layer (`budget`, shared by its animators): the
// expression runs per character, so a slow expression on a long text is stopped
// once it has failed or the layer's evaluations pass MAX_SELECTOR_EVALS.
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 r = match ctx.expr.filter(|_| prop.has_expression() && !stopped.get() && budget.take()) {
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))
@@ -282,13 +329,17 @@ pub fn animator_selection(ctx: &EvalCtx, layer: &Layer, anim: &PropGroup, lay: &
acc
}
/// The most Expression Selector evaluations one layer's animators run across all its characters
/// (a text of 32,768 characters under one expression selector, or fewer under several).
const MAX_SELECTOR_EVALS: usize = 32_768;
/// 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()];
let Some(anims) = text.sub("animators") else { return out };
let budget = SelectorBudget::new();
for anim in anims.groups().filter(|g| g.enabled) {
let Some(props) = anim.sub("properties") else { continue };
let sel = animator_selection(ctx, layer, anim, lay);
let sel = animator_selection_within(ctx, layer, anim, lay, &budget);
let get = |m: &str| props.get(m).map(|p| ctx.value(layer, p).components());
let get1 = |m: &str| props.get(m).map(|p| ctx.value(layer, p).as_f64());
let c3 = |v: Vec<f64>, d: f64| [v.first().copied().unwrap_or(d), v.get(1).copied().unwrap_or(d), v.get(2).copied().unwrap_or(d)];
+9
View File
@@ -802,3 +802,12 @@ fn text_notes_the_font_files_it_draws_with() {
assert!(files.is_complete(), "{files:?}");
assert_eq!(files.get(&font.to_string_lossy()), Some(stamp), "{files:?}");
}
#[test]
fn selector_evaluations_are_counted_across_the_layer_not_timed() {
let b = SelectorBudget(std::cell::Cell::new(3));
// Taken by any animator's selectors in turn; the fourth finds none, and stays none.
assert!([b.take(), b.take(), b.take()].iter().all(|t| *t));
assert!(!b.take() && !b.take());
assert_eq!(SelectorBudget::new().0.get(), MAX_SELECTOR_EVALS);
}
+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 (a viewport past i32::MAX is capped there).
let (w, h) = (i64::from(s.width.min(i32::MAX as u32)), i64::from(s.height.min(i32::MAX as u32)));
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;
+27 -1
View File
@@ -302,6 +302,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);
@@ -336,8 +343,27 @@ 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;
}
// Restored when this scope ends, a panic unwinding through it included (the CPU retry
// after one must not start with a raised count).
struct Restore(usize);
impl Drop for Restore {
fn drop(&mut self) {
ORIENT_NESTING.with(|n| n.set(self.0));
}
}
ORIENT_NESTING.with(|n| n.set(nesting + 1));
let restore = Restore(nesting);
let parent = parent_world(ctx, layer);
drop(restore);
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)))
}