mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-12 21:32:52 +00:00
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:
1 parent
a287044644
commit
075aa03ebe
9 files changed
+383
-44
No files matched your search
+86
-19
@@ -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
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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)];
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user