Merge origin/main into fix/issue-2092-camera-focus-crash

Conflicts were main's new code next to this branch's additions; both kept:
- crates/effects/src/lib.rs: main's Buf::pad_sides stays as main wrote it
  (Transform's transform_grow bounds that padding itself); Buf::pad keeps
  this branch's effective-pad clamp.
- crates/raster/src/lib.rs: main's quantize_px/round_half_up and this
  branch's MAX_PADDED_SIDE (still equal to limits::MAX_SIZE on main).
This commit is contained in:
Christopher Linnett
2026-10-29 18:44:49 +08:00
296 changed files with 14150 additions and 5853 deletions
+13 -3
View File
@@ -2,7 +2,9 @@ use super::frames::content_bounds;
use super::fx_host::MAX_FX_DEPTH;
use super::layers::StyledLayer;
use super::opts::LayerTiming;
use super::*;
use super::{
Arc, AutoPick, Buf, EffectCtx, EvalCtx, Image, ItemId, ItemKind, Layer, LayerSource, Mat3, MatteKind, Renderer, Tick, cache, color, masks, three_d, vec2,
};
/// Which compositor renders a frame (Project Settings ▸ Video Rendering and Effects).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
@@ -44,6 +46,14 @@ pub trait Accelerator: Send + Sync {
/// Render a top-level comp frame (what [`Renderer::comp_frame`] returns). `None` = not
/// handled (the CPU renders it).
fn comp_frame(&self, r: &Renderer, comp: ItemId, t: Tick) -> Option<Image>;
/// Render a top-level comp frame like [`Self::comp_frame`], composite it over `bg` and
/// convert it to the H.264 export's 8-bit BT.709 limited-range 4:2:0 (Y plane, then U and V
/// at half size; see `effectcraft_raster::yuv`). Every byte is within one level of the CPU
/// conversion of the same frame (a driver's arithmetic may move a value near a rounding
/// boundary). `None` = not handled (the caller renders and converts on the CPU).
fn comp_frame_yuv420(&self, _r: &Renderer, _comp: ItemId, _t: Tick, _bg: [f32; 3]) -> Option<Vec<u8>> {
None
}
/// The accelerator implements effect `id` with the CPU effect's semantics.
fn supports_effect(&self, id: &str) -> bool;
/// Run a chain of supported effects on `buf`, clamping and quantising to `levels` after
@@ -283,7 +293,7 @@ impl<'a> Renderer<'a> {
/// layers).
pub fn run_effects_on(&self, ctx: &EvalCtx, layer: &Layer, adjustment: bool, target: &mut dyn FxTarget) {
// `target` is used as it is: the comp below for adjustment layers (the callers).
self.run_effect_stack(ctx, layer, adjustment, usize::MAX, None, [0.0; 2], target);
self.run_effect_stack(ctx, layer, crate::pipeline::EffectStack { adjustment, limit: usize::MAX, origin: [0.0; 2] }, None, target);
}
/// [`Renderer::run_effects_on`] for a (non-adjustment) layer's own pixels: `target` holds
@@ -291,7 +301,7 @@ impl<'a> Renderer<'a> {
/// there. Returns the effect-space origin in layer coordinates (rebase by its negation).
pub fn run_layer_effects_on(&self, ctx: &EvalCtx, layer: &Layer, target: &mut dyn FxTarget) -> [f64; 2] {
let origin = ctx.effect_bounds(layer).1;
self.run_effect_stack(ctx, layer, false, usize::MAX, None, origin, target);
self.run_effect_stack(ctx, layer, crate::pipeline::EffectStack { adjustment: false, limit: usize::MAX, origin }, None, target);
origin
}
+11 -15
View File
@@ -25,7 +25,7 @@ const MAX_DEPTH: usize = 16;
/// 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> {
let mut out = vec![0.0f32; frames * 2];
mix_into(project, footage, expr, comp, start, rate, &mut out, 1.0, 1.0, 0);
mix_into(&AudioSources { project, footage, expr, rate }, comp, start, &mut out, 0);
out
}
@@ -151,7 +151,7 @@ impl MixEnv<'_> {
return out;
}
let mut sub = vec![0.0f32; src_n * 2];
mix_into(self.project, self.footage, self.expr, *item, lt, rate, &mut sub, 1.0, 1.0, self.depth + 1);
mix_into(&AudioSources { project: self.project, footage: self.footage, expr: self.expr, rate }, *item, lt, &mut sub, self.depth + 1);
sub
}
_ => return out,
@@ -206,19 +206,16 @@ impl MixEnv<'_> {
}
}
#[allow(clippy::too_many_arguments)]
fn mix_into(
project: &Project,
footage: &dyn FootageSource,
expr: Option<&dyn ExprHost>,
comp_id: ItemId,
start: Tick,
/// Shared inputs for composition audio mixing, including nested compositions.
struct AudioSources<'a> {
project: &'a Project,
footage: &'a dyn FootageSource,
expr: Option<&'a dyn ExprHost>,
rate: u32,
out: &mut [f32],
gl: f32,
gr: f32,
depth: usize,
) {
}
fn mix_into(sources: &AudioSources<'_>, comp_id: ItemId, start: Tick, out: &mut [f32], depth: usize) {
let AudioSources { project, footage, expr, rate } = *sources;
let Some(comp) = project.comp(comp_id) else { return };
if depth >= MAX_DEPTH || rate == 0 {
return;
@@ -239,7 +236,6 @@ fn mix_into(
}
let t_a = sample_tick(s0 + a as i64, rate);
let (ll, lr) = levels(&env, l, t_a);
let (ll, lr) = (ll * gl, lr * gr);
if ll == 0.0 && lr == 0.0 {
continue;
}
-1
View File
@@ -580,7 +580,6 @@ fn key_with(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, blur: bool, f
/// `blur`: the layer is motion blurred in this render (`Renderer::mb_on`). `effects`: key only
/// the first that many effects (a layer's input, see [`input_key`]).
#[allow(clippy::too_many_arguments)]
fn key_any(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, blur: bool, footage: bool, effects: Option<usize>) -> Option<u64> {
let mut h = KeyHasher(0xcbf2_9ce4_8422_2325);
ctx.comp.frame_rate.as_f64().to_bits().hash(&mut h);
+45 -23
View File
@@ -21,7 +21,7 @@
use effectcraft_color::{ColorSpace, Conversion};
use effectcraft_project::{Footage, HdrMode, ProjectSettings};
use effectcraft_raster::Image;
use effectcraft_raster::{Image, quantize_px};
use rayon::prelude::*;
/// The colour pipeline of a project (see the module docs).
@@ -218,27 +218,6 @@ pub fn quantize(img: &mut Image, levels: f32) {
quantize_rect(img, PxRect { x0: 0, y0: 0, x1: img.width, y1: img.height }, levels);
}
/// Clamp (colour ≤ alpha ≤ 1, NaN → 0) and round half up; values are non-negative after the
/// clamp, so the integer conversion rounds like `round()` and vectorises.
#[allow(clippy::manual_clamp)] // max/min map NaN to 0; clamp would keep NaN
#[inline(always)]
fn quantize_px(p: &mut [f32; 4], levels: f32, inv: f32) {
let a = round_half_up(p[3].max(0.0).min(1.0) * levels) * inv;
let hi = [a, a, a, a];
for c in 0..3 {
p[c] = round_half_up(p[c].max(0.0).min(hi[c]) * levels) * inv;
}
p[3] = a;
}
/// `v` (0..=65 535) rounded half up. The same either way; the fastest on each target: x86-64's
/// baseline converts to integers in one instruction but has no `floor` (SSE4.1), while in wasm
/// `floor` is one instruction and converting back from `u32` several (about 1.5× faster there).
#[inline(always)]
fn round_half_up(v: f32) -> f32 {
if cfg!(target_arch = "wasm32") { (v + 0.5).floor() } else { (v + 0.5) as u32 as f32 }
}
/// [`quantize`] restricted to a rectangle (already clamped to the image), row-parallel.
fn quantize_rect(img: &mut Image, r: PxRect, levels: f32) {
if r.x0 >= r.x1 || r.y0 >= r.y1 {
@@ -248,7 +227,17 @@ fn quantize_rect(img: &mut Image, r: PxRect, levels: f32) {
let w = img.width as usize;
let (x0, x1) = (r.x0 as usize, r.x1 as usize);
img.data[r.y0 as usize * w..r.y1 as usize * w].par_chunks_mut(w).for_each(|row| {
for p in &mut row[x0..x1] {
// Runs of all-zero pixels (+0.0 in every channel) quantise to themselves: skip them a
// cache line at a time (sparse shape, precomp and glow buffers are mostly empty).
let (blocks, rest) = row[x0..x1].as_chunks_mut::<4>();
for b in blocks {
if b.as_flattened().iter().fold(0, |a, v| a | v.to_bits()) != 0 {
for p in b {
quantize_px(p, levels, inv);
}
}
}
for p in rest {
quantize_px(p, levels, inv);
}
});
@@ -307,6 +296,39 @@ pub fn output_hdr(img: &mut Image, to_linear: Option<Conversion>, mode: HdrMode,
#[cfg(test)]
mod tests {
use super::*;
/// Skipping all-zero runs leaves every pixel as quantising it one by one does, bit for bit
/// (negative zeros and lone pixels in empty runs included).
#[test]
fn quantize_skips_only_zero_runs() {
let mut img = Image::new(23, 7);
for (i, p) in img.data.iter_mut().enumerate() {
*p = match i % 11 {
0 => [0.3, -0.2, 0.71, 0.9],
3 => [-0.0, 0.0, 0.0, 0.0],
5 => [0.0, 0.0, 0.0, -0.0],
7 => [1.7, 0.4, f32::NAN, 0.5],
_ => [0.0; 4],
};
}
for levels in [255.0f32, 32_768.0] {
let mut want = img.clone();
for p in &mut want.data {
quantize_px(p, levels, 1.0 / levels);
}
for r in [PxRect { x0: 0, y0: 0, x1: 23, y1: 7 }, PxRect { x0: 3, y0: 1, x1: 20, y1: 6 }] {
let mut got = img.clone();
quantize_rect(&mut got, r, levels);
for (i, (g, p)) in got.data.iter().zip(&img.data).enumerate() {
let (x, y) = (i as u32 % 23, i as u32 / 23);
let w = if x >= r.x0 && x < r.x1 && y >= r.y0 && y < r.y1 { want.data[i] } else { *p };
assert_eq!(g.map(f32::to_bits), w.map(f32::to_bits), "{levels} {r:?} px ({x}, {y})");
}
}
}
}
/// Both forms `round_half_up` picks between agree on everything quantising feeds it:
/// 0..=levels, exact halves included.
#[test]
+150 -19
View File
@@ -12,9 +12,11 @@
//! `FrameSeg::to_bytes` (keyed by their content-keyed chain key and scale, so they are
//! reused whenever the same frames, settings and prompt come back, also after a restart). Every file ends in a checksum; damaged files read as misses and
//! are deleted.
//! * Size limit with least-recently-used eviction. The in-memory index is rebuilt from the
//! folder when the cache opens (file modification times order it), so the cache survives
//! restarts; reads refresh a file's time.
//! * Size limit with least-recently-used eviction, down to 90% of the limit so the eviction scan
//! runs once per tenth of the limit written, not on every write. The limit shrinks to keep
//! free space on the cache's disk ([`DiskCache::set_disk_space`]). The in-memory index is
//! rebuilt from the folder when the cache opens (file modification times order it), so the
//! cache survives restarts; reads refresh a file's time.
//! * Safe concurrent access: writes go to a unique temporary file that is renamed into place
//! (atomic on every desktop OS), so readers in this or another process never see a partial
//! entry; a missing or damaged file is just a miss. Writes run on a background thread with a
@@ -22,7 +24,7 @@
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Condvar, Mutex};
use effectcraft_effects::Buf;
@@ -75,11 +77,15 @@ pub struct DiskStats {
pub entries: usize,
pub bytes: u64,
pub max_bytes: u64,
/// The limit in effect: `max_bytes`, or less when the disk is short of free space.
pub limit_bytes: u64,
pub hits: u64,
pub misses: u64,
pub writes: u64,
pub evictions: u64,
pub dropped: u64,
/// Writes that failed (a full disk, permissions).
pub failed: u64,
}
struct Meta {
@@ -157,15 +163,17 @@ impl DiskIndex {
v.into_iter().map(|e| e.1).collect()
}
/// Remove least recently used entries until the total fits `max` bytes; returns them (the
/// caller deletes their files).
/// When the total exceeds `max` bytes, remove least recently used entries down to 90% of
/// it (so the sort runs once per tenth of `max` written, not on every write); returns them
/// (the caller deletes their files).
pub fn evict(&mut self, max: u64) -> Vec<(Kind, u128)> {
if self.total <= max {
return vec![];
}
let target = max - max / 10;
let mut out = vec![];
for k in self.lru_order() {
if self.total <= max {
if self.total <= target {
break;
}
self.remove(k.0, k.1);
@@ -225,11 +233,16 @@ pub struct DiskCache {
dir: PathBuf,
index: Mutex<DiskIndex>,
max_bytes: AtomicU64,
/// The size free disk space allows (`u64::MAX` = unknown), see [`DiskCache::set_disk_space`].
disk_cap: AtomicU64,
/// Free space is limiting the cache (warned once per episode).
space_limited: AtomicBool,
hits: AtomicU64,
misses: AtomicU64,
writes: AtomicU64,
evictions: AtomicU64,
dropped: AtomicU64,
failed: AtomicU64,
tmp_counter: AtomicU64,
queue: Arc<(Mutex<Queue>, Condvar)>,
/// Layer buffers that took less than this to render are not written (ms).
@@ -298,16 +311,21 @@ impl DiskCache {
dir,
index: Mutex::new(DiskIndex::default()),
max_bytes: AtomicU64::new(max_bytes),
disk_cap: AtomicU64::new(u64::MAX),
space_limited: AtomicBool::new(false),
hits: AtomicU64::new(0),
misses: AtomicU64::new(0),
writes: AtomicU64::new(0),
evictions: AtomicU64::new(0),
dropped: AtomicU64::new(0),
failed: AtomicU64::new(0),
tmp_counter: AtomicU64::new(0),
queue: Arc::new((Mutex::new(Queue { jobs: Default::default(), busy: false }), Condvar::new())),
queue: Arc::new((Mutex::new(Queue { jobs: Default::default(), busy: cfg!(not(target_arch = "wasm32")) }), Condvar::new())),
min_layer_ms: AtomicU64::new(30),
});
dc.rescan();
// Shrinking a large cache deletes many files: the writer thread does it.
#[cfg(target_arch = "wasm32")]
dc.evict();
#[cfg(not(target_arch = "wasm32"))]
{
@@ -371,6 +389,32 @@ impl DiskCache {
self.evict();
}
/// The limit in effect: the setting, or less when the disk is short of free space.
pub fn limit_bytes(&self) -> u64 {
self.max_bytes().min(self.disk_cap.load(Ordering::Relaxed))
}
/// The cache's disk: `(total, available)` bytes, `None` = unknown. The cache leaves at least
/// 2 GB or 5% of the disk free, whichever is more (evicting when it must).
pub fn set_disk_space(&self, space: Option<(u64, u64)>) {
let cap = match space {
// (a disk reported as 0 bytes, such as an automount map, is unknown)
Some((total, available)) if total > 0 => {
let reserve = (2u64 << 30).max(total / 20);
let used = self.index.lock().map(|g| g.total()).unwrap_or_default();
// The cache's own files count as free: below the reserve it gives space back.
used.saturating_add(available).saturating_sub(reserve)
}
_ => u64::MAX,
};
self.disk_cap.store(cap, Ordering::Relaxed);
let limited = cap < self.max_bytes();
if self.space_limited.swap(limited, Ordering::Relaxed) != limited && limited {
log::warn!("disk cache at {}: little free disk space, limiting the cache to {} MB", self.folder().display(), cap >> 20);
}
self.evict();
}
/// Layer buffers faster to render than this are not worth a disk write.
pub fn set_min_layer_ms(&self, ms: u64) {
self.min_layer_ms.store(ms, Ordering::Relaxed);
@@ -395,11 +439,13 @@ impl DiskCache {
entries,
bytes,
max_bytes: self.max_bytes(),
limit_bytes: self.limit_bytes(),
hits: self.hits.load(Ordering::Relaxed),
misses: self.misses.load(Ordering::Relaxed),
writes: self.writes.load(Ordering::Relaxed),
evictions: self.evictions.load(Ordering::Relaxed),
dropped: self.dropped.load(Ordering::Relaxed),
failed: self.failed.load(Ordering::Relaxed),
}
}
@@ -494,30 +540,44 @@ impl DiskCache {
}
let path = self.path(kind, key);
let Some(parent) = path.parent() else { return };
if std::fs::create_dir_all(parent).is_err() {
if let Err(e) = std::fs::create_dir_all(parent) {
self.write_failed(&e);
return;
}
let data = seal(kind, payload);
let n = self.tmp_counter.fetch_add(1, Ordering::Relaxed);
let tmp = parent.join(format!("{key:032x}.tmp.{}.{n}", std::process::id()));
if std::fs::write(&tmp, &data).is_err() {
let _ = std::fs::remove_file(&tmp);
// An entry that cannot fit would be evicted straight away.
if data.len() as u64 > self.limit_bytes() {
return;
}
if std::fs::rename(&tmp, &path).is_err() {
// Make room first, so the cache never exceeds its limit (or the free-space reserve).
self.evict_to(self.limit_bytes().saturating_sub(data.len() as u64));
let n = self.tmp_counter.fetch_add(1, Ordering::Relaxed);
let tmp = parent.join(format!("{key:032x}.tmp.{}.{n}", std::process::id()));
if let Err(e) = std::fs::write(&tmp, &data).and_then(|()| std::fs::rename(&tmp, &path)) {
let _ = std::fs::remove_file(&tmp);
self.write_failed(&e);
return;
}
self.writes.fetch_add(1, Ordering::Relaxed);
if let Ok(mut g) = self.index.lock() {
g.insert(kind, key, data.len() as u64);
}
self.evict();
}
/// Count a failed write; the first one is logged (a full disk shouldn't go unnoticed).
fn write_failed(&self, e: &std::io::Error) {
if self.failed.fetch_add(1, Ordering::Relaxed) == 0 {
log::warn!("disk cache at {}: a write failed: {e}", self.folder().display());
}
}
/// Remove least recently used entries until the cache fits its limit.
fn evict(&self) {
let max = self.max_bytes();
self.evict_to(self.limit_bytes());
}
/// Remove least recently used entries until the cache fits `max` bytes.
fn evict_to(&self, max: u64) {
let victims = match self.index.lock() {
Ok(mut g) => g.evict(max),
Err(_) => return,
@@ -586,6 +646,15 @@ impl DiskCache {
#[cfg(not(target_arch = "wasm32"))]
fn writer(dc: std::sync::Weak<DiskCache>, q: Arc<(Mutex<Queue>, Condvar)>) {
let (m, cv) = &*q;
// The eviction at open (a lower limit than the folder holds), off the opening thread;
// `busy` starts set, so `flush` waits for it.
if let Some(cache) = dc.upgrade() {
cache.evict();
}
if let Ok(mut g) = m.lock() {
g.busy = false;
}
cv.notify_all();
loop {
let job = {
let Ok(mut g) = m.lock() else { return };
@@ -1030,9 +1099,9 @@ mod tests {
let per = dc.stats().bytes / 6;
// Touch 0 so it is the most recently used.
assert!(dc.get_layer(0).is_some());
dc.set_max_bytes(per * 3 + per / 2);
dc.set_max_bytes(per * 3 + per * 3 / 4);
let st = dc.stats();
assert!(st.bytes <= per * 3 + per / 2, "{st:?}");
assert!(st.bytes <= per * 3 + per * 3 / 4, "{st:?}");
assert_eq!(st.entries, 3);
assert!(dc.contains(Kind::Layer, 0), "recently used survives");
assert!(!dc.contains(Kind::Layer, 1) && !dc.contains(Kind::Layer, 2) && !dc.contains(Kind::Layer, 3));
@@ -1042,7 +1111,69 @@ mod tests {
assert_eq!(files, 3);
// New writes keep evicting.
dc.put_layer_now(99, &buf(9, 64, 64));
assert!(dc.stats().bytes <= per * 3 + per / 2);
assert!(dc.stats().bytes <= per * 3 + per * 3 / 4);
}
#[test]
fn eviction_goes_down_to_ninety_percent_in_lru_order() {
let mut ix = DiskIndex::default();
for k in 0..10u128 {
ix.insert(Kind::Layer, k, 100);
}
assert!(ix.touch(Kind::Layer, 0));
// Over 950: down to 855, least recently used first.
ix.insert(Kind::Layer, 10, 100);
assert_eq!(ix.evict(950), vec![(Kind::Layer, 1), (Kind::Layer, 2), (Kind::Layer, 3)]);
assert_eq!(ix.total(), 800);
// The slack absorbs the next write without another scan.
ix.insert(Kind::Layer, 11, 100);
assert!(ix.evict(950).is_empty());
}
#[test]
fn free_disk_space_caps_the_limit() {
let dir = tmpdir("space");
let dc = DiskCache::open(&dir, 1 << 30).unwrap();
for k in 0..4u128 {
dc.put_layer_now(k, &buf(k as u32, 64, 64));
}
let used = dc.stats().bytes;
// Unknown space: the setting alone.
dc.set_disk_space(None);
assert_eq!(dc.stats().limit_bytes, 1 << 30);
// A disk reported as 0 bytes is unknown.
dc.set_disk_space(Some((0, 0)));
assert_eq!(dc.stats().limit_bytes, 1 << 30);
// 100 GB disk, 5 GB reserved, exactly 5 GB free: the cache may not grow.
dc.set_disk_space(Some((100 << 30, 5 << 30)));
let st = dc.stats();
assert_eq!((st.max_bytes, st.limit_bytes), (1 << 30, used));
dc.put_layer_now(9, &buf(9, 64, 64));
let st = dc.stats();
assert!(st.bytes <= st.limit_bytes && st.bytes <= used - used / 10 && st.evictions > 0, "{st:?}");
assert!(dc.contains(Kind::Layer, 9), "the new entry is the most recently used");
// Below the reserve the cache gives its space back, and skips writes that can't fit.
dc.set_disk_space(Some((100 << 30, 4 << 30)));
dc.put_layer_now(10, &buf(10, 64, 64));
let st = dc.stats();
assert_eq!((st.limit_bytes, st.entries, st.bytes), (0, 0, 0), "{st:?}");
// Plenty of space again: back to the setting.
dc.set_disk_space(Some((100 << 30, 50 << 30)));
assert_eq!(dc.stats().limit_bytes, 1 << 30);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn failed_writes_are_counted() {
let dir = tmpdir("failed");
let dc = DiskCache::open(&dir, 1 << 30).unwrap();
// A file where the layers folder should be: every layer write fails.
std::fs::write(dir.join(VERSION).join(Kind::Layer.dir()), b"x").unwrap();
dc.put_layer_now(1, &buf(1, 8, 8));
dc.put_layer_now(2, &buf(2, 8, 8));
let st = dc.stats();
assert_eq!((st.failed, st.writes, st.entries), (2, 0, 0));
let _ = std::fs::remove_dir_all(&dir);
}
fn walk(d: &Path) -> usize {
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{Arc, AtomicBool, Footage, Image, ItemId, Ordering, Tick};
/// How a [`FootageSource`] hands over float footage (OpenEXR, float TIFF), whose values are
/// linear light: encoded with a curve, as the compositor takes footage, or as stored. Other
+1 -1
View File
@@ -1,6 +1,6 @@
use super::footage::NoFootage;
use super::opts::RenderOpts;
use super::*;
use super::{EvalCtx, Footage, FrameBlend, FrameRate, Image, ItemId, Layer, LayerSource, Project, Renderer, TICKS_PER_SECOND, Tick, shapes, source_size, text};
/// The layer's frame blending mode, if the comp's Enable Frame Blending switch is on.
/// One field of an interlaced frame as a full frame: the field's lines (`parity` 0 = even /
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{Arc, Buf, EffectHost, EvalCtx, ItemKind, Layer, LayerPixels, LayerSource, Params, Renderer, Tick, three_d};
/// Layer parameters and audio for effects (see [`effectcraft_effects::EffectHost`]). What it
/// hands out is in effect space (see [`effect_bounds`]): the other layer's for its pixels, the
+51 -8
View File
@@ -1,6 +1,16 @@
use super::frames::adaptive_samples;
use super::opts::{LayerTiming, Through};
use super::*;
use super::{
Arc, BlendMode, Buf, Comp, EvalCtx, Image, IndexedParallelIterator, IntoParallelRefIterator, IntoParallelRefMutIterator, Layer, LayerSource, Mat3,
MatteKind, ParallelIterator, Quality, Region, Renderer, Sampling, Tick, WarpOpts, cache, color, composite_warp_changed, masks, source_size, styles, vec2,
};
/// Blend policy for placing a layer, including optional output-depth quantization.
struct PlacementBlend {
mode: BlendMode,
opacity: f32,
quantize: Option<f32>,
}
/// A motion-blur shutter: the comp's, or for a collapsed precomp's layers the outermost
/// containing comp's (they are drawn into its frames).
@@ -342,11 +352,17 @@ impl<'a> Renderer<'a> {
/// Draw a processed layer into `target` (transform, motion blur) with `mode`/`opacity`.
pub(crate) fn place(&self, ctx: &EvalCtx, layer: &Layer, buf: &Buf, target: &mut Image, mode: BlendMode, opacity: f32) {
self.place_changed(ctx, layer, buf, target, PlacementBlend { mode, opacity, quantize: None });
}
/// [`Self::place`], returning the target pixels it changed when the warp tracked them (then
/// also quantised to `quantize` levels; see [`composite_warp_changed`]).
fn place_changed(&self, ctx: &EvalCtx, layer: &Layer, buf: &Buf, target: &mut Image, blend: PlacementBlend) -> Option<[u32; 4]> {
let PlacementBlend { mode, opacity, quantize } = blend;
let pl = self.placement(ctx, layer, buf);
let opts = WarpOpts { sampling: pl.sampling, opacity, mode, seed: pl.seed, clip: None };
if let [m] = pl.matrices.as_slice() {
composite_warp(target, &buf.img, m, &opts);
return;
return composite_warp_changed(target, &buf.img, m, &opts, quantize);
}
// Sub-samples sum straight into one buffer (each warp is row-parallel).
let mut acc = Image::new(target.width, target.height);
@@ -355,6 +371,7 @@ impl<'a> Renderer<'a> {
effectcraft_raster::accumulate_warp(&mut acc, &buf.img, m, opts.sampling, k);
}
target.blend_from(&acc, mode, opacity, pl.seed);
None
}
/// Draw a 2D layer into `canvas`; returns the region it may have changed.
@@ -377,8 +394,14 @@ impl<'a> Renderer<'a> {
let st = self.styled_to_blend(st);
let t1 = web_time::Instant::now();
let changed = if st.plain {
self.composite_layer(ctx, layer, &st.body, canvas, opacity);
self.footprint(ctx, layer, &st.body, opacity, blank)
// Pixels the warp tracks are quantised as it blends them (nothing left to do); the
// footprint is still needed when it doesn't, or when no pixel needs quantising.
let footprint = self.footprint(ctx, layer, &st.body, opacity, blank);
let quantize = if footprint == Region::Empty { None } else { self.pipe.levels };
match self.composite_layer(ctx, layer, &st.body, canvas, opacity, quantize) {
Some(_) if quantize.is_some() => Region::Empty,
drawn => narrow(footprint, drawn),
}
} else {
self.composite_styled(ctx, layer, &st, canvas, opacity);
Region::Full
@@ -432,16 +455,18 @@ impl<'a> Renderer<'a> {
});
}
fn composite_layer(&self, ctx: &EvalCtx, layer: &Layer, buf: &Buf, canvas: &mut Image, opacity: f32) {
/// Composite a plain layer; returns the canvas pixels it changed when they are known (then
/// also quantised to `quantize` levels).
fn composite_layer(&self, ctx: &EvalCtx, layer: &Layer, buf: &Buf, canvas: &mut Image, opacity: f32, quantize: Option<f32>) -> Option<[u32; 4]> {
let matte = layer.track_matte.is_some_and(|tm| tm.layer != layer.id && ctx.comp.layer(tm.layer).is_some());
if !matte && !layer.preserve_transparency {
self.place(ctx, layer, buf, canvas, layer.blend_mode, opacity);
return;
return self.place_changed(ctx, layer, buf, canvas, PlacementBlend { mode: layer.blend_mode, opacity, quantize });
}
// Render in isolation, then matte / preserve transparency, then blend.
let mut iso = Image::new(canvas.width, canvas.height);
self.place(ctx, layer, buf, &mut iso, BlendMode::Normal, 1.0);
self.composite_iso(ctx, layer, iso, canvas, opacity);
None
}
/// Apply the track matte / preserve transparency to an isolated layer render, then blend.
@@ -556,3 +581,21 @@ pub struct Placement {
/// Dissolve noise seed.
pub seed: u32,
}
/// The part of `footprint` a warp actually changed (`drawn`, see [`composite_warp_changed`]):
/// pixels it left alone are still quantised.
fn narrow(footprint: Region, drawn: Option<[u32; 4]>) -> Region {
let Some([x0, y0, x1, y1]) = drawn else { return footprint };
if x0 >= x1 || y0 >= y1 {
return Region::Empty;
}
let r = color::PxRect { x0, y0, x1, y1 };
match footprint {
Region::Empty => Region::Empty,
Region::Full => Region::Rect(r),
Region::Rect(f) => {
let q = color::PxRect { x0: r.x0.max(f.x0), y0: r.y0.max(f.y0), x1: r.x1.min(f.x1), y1: r.y1.min(f.y1) };
if q.x0 >= q.x1 || q.y0 >= q.y1 { Region::Empty } else { Region::Rect(q) }
}
}
}
+1 -1
View File
@@ -32,7 +32,7 @@ use effectcraft_effects::{Buf, EffectCtx, EffectEnv, EffectHost, LayerPixels, Pa
use effectcraft_geom::{Mat3, Mat4, vec2};
use effectcraft_project::{Comp, Footage, FootageKind, FrameBlend, GroupKind, ItemId, ItemKind, Layer, LayerSource, MatteKind, Project, Quality, Sampling};
pub use effectcraft_raster::Image;
use effectcraft_raster::{WarpOpts, composite_warp};
use effectcraft_raster::{WarpOpts, composite_warp_changed};
use effectcraft_time::{FrameRate, TICKS_PER_SECOND, Tick};
pub use eval::{EvalCtx, ExprHost, effect_bounds, source_size};
use rayon::prelude::*;
+1 -1
View File
@@ -1,5 +1,5 @@
use super::accel::Backend;
use super::*;
use super::{Comp, EvalCtx, ItemId, Layer, Mat4, Tick, three_d};
#[derive(Clone, Copy, Debug, serde::Serialize, serde::Deserialize)]
#[serde(default, rename_all = "camelCase")]
+18 -19
View File
@@ -3,7 +3,18 @@ use super::footage::{FootageSource, is_vector_footage};
use super::frames::{blend_frames, content_bounds, field_frame, frame_blend_mode, frame_position, frame_size, interpret_pixels, roi_inside};
use super::fx_host::{FxHost, essential_overrides};
use super::opts::{Collapse3d, LayerTiming, MAX_THROUGH, RenderOpts, Through};
use super::*;
use super::{
Arc, AutoKey, AutoPick, BlendMode, Buf, Comp, EffectCtx, EffectEnv, EvalCtx, Footage, FootageKind, FrameBlend, FrameRate, GroupKind, Image, ItemId,
ItemKind, Layer, LayerCache, LayerSource, Params, Project, Quality, Region, Renderer, Tick, cache, color, masks, shapes, styles, text, three_d, vec2,
};
/// Coordinate space and stack range shared by CPU and accelerated effect evaluation.
#[derive(Clone, Copy)]
pub(crate) struct EffectStack {
pub adjustment: bool,
pub limit: usize,
pub origin: [f64; 2],
}
impl<'a> Renderer<'a> {
pub fn new(project: &'a Project, footage: &'a dyn FootageSource, opts: RenderOpts) -> Renderer<'a> {
@@ -423,26 +434,16 @@ impl<'a> Renderer<'a> {
// The comp below an adjustment layer is not in its layer space: it stays as it is.
let origin = if adjustment { [0.0; 2] } else { ctx.effect_bounds(layer).1 };
buf.rebase(origin);
let mut out = self.effect_space_stack(ctx, layer, buf, adjustment, limit, timing, origin);
let mut out = self.effect_space_stack(ctx, layer, buf, EffectStack { adjustment, limit, origin }, timing);
out.rebase([-origin[0], -origin[1]]);
out
}
/// [`Renderer::apply_effects_timed`] on a buffer already in effect space (measured from
/// `origin`, in layer coordinates); the result stays in effect space.
#[allow(clippy::too_many_arguments)]
fn effect_space_stack(
&self,
ctx: &EvalCtx,
layer: &Layer,
buf: Buf,
adjustment: bool,
limit: usize,
timing: Option<&mut Vec<(String, f64)>>,
origin: [f64; 2],
) -> Buf {
fn effect_space_stack(&self, ctx: &EvalCtx, layer: &Layer, buf: Buf, stack: EffectStack, timing: Option<&mut Vec<(String, f64)>>) -> Buf {
let mut t = CpuFx { accel: self.active_accel(), levels: self.pipe.levels, buf: Some(buf) };
self.run_effect_stack(ctx, layer, adjustment, limit, timing, origin, &mut t);
self.run_effect_stack(ctx, layer, stack, timing, &mut t);
t.buf.unwrap_or_else(|| Buf { img: Image::new(1, 1), offset: [0.0; 2], scale: self.opts.scale })
}
@@ -450,17 +451,15 @@ impl<'a> Renderer<'a> {
/// in layer coordinates): runs of GPU-capable effects go to [`FxTarget::gpu`] (when an
/// accelerator is active and supports them), the rest to [`FxTarget::cpu`] one by one
/// (quantised to the bit depth after each).
#[allow(clippy::too_many_arguments)]
pub(crate) fn run_effect_stack(
&self,
ctx: &EvalCtx,
layer: &Layer,
adjustment: bool,
limit: usize,
stack: EffectStack,
mut timing: Option<&mut Vec<(String, f64)>>,
origin: [f64; 2],
target: &mut dyn FxTarget,
) {
let EffectStack { adjustment, limit, origin } = stack;
if !layer.switches.effects {
return;
}
@@ -873,7 +872,7 @@ impl<'a> Renderer<'a> {
let origin = ctx.effect_bounds(layer).1;
buf.rebase(origin);
if effects > 0 {
buf = self.effect_space_stack(ctx, layer, buf, false, effects, None, origin);
buf = self.effect_space_stack(ctx, layer, buf, EffectStack { adjustment: false, limit: effects, origin }, None);
}
let buf = Arc::new(buf);
// The buffer the cache keeps (another worker's, if it kept one first).
+10 -9
View File
@@ -211,16 +211,16 @@ fn collect(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, arena: &mut Vec<B
}
"star" => {
let star = ctx.e(layer, g, "type") == 0;
let p = effectcraft_path::polystar(
let p = effectcraft_path::polystar(effectcraft_path::Polystar {
star,
ctx.f(layer, g, "points", 5.0),
ctx.v2(layer, g, "position", [0.0; 2]),
ctx.f(layer, g, "rotation", 0.0),
ctx.f(layer, g, "innerRadius", 50.0),
ctx.f(layer, g, "outerRadius", 100.0),
ctx.f(layer, g, "innerRoundness", 0.0),
ctx.f(layer, g, "outerRoundness", 0.0),
);
points: ctx.f(layer, g, "points", 5.0),
pos: ctx.v2(layer, g, "position", [0.0; 2]),
rotation: ctx.f(layer, g, "rotation", 0.0),
inner: ctx.f(layer, g, "innerRadius", 50.0),
outer: ctx.f(layer, g, "outerRadius", 100.0),
inner_round: ctx.f(layer, g, "innerRoundness", 0.0),
outer_round: ctx.f(layer, g, "outerRoundness", 0.0),
});
push_shape(ctx, layer, g, p, arena, &mut live);
}
"path" => {
@@ -324,6 +324,7 @@ fn collect(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, arena: &mut Vec<B
speed: ctx.f(layer, g, "speed", 2.0),
correlation: ctx.f(layer, g, "correlation", 50.0),
phase_deg: ctx.f(layer, g, "phase", 0.0),
spatial_phase_deg: ctx.f(layer, g, "spatialPhase", 0.0),
seed: ctx.f(layer, g, "seed", 0.0),
};
let t = layer.layer_time(ctx.time).seconds();
+18
View File
@@ -258,6 +258,24 @@ fn round_corners_clip_corner_pixels() {
assert!((coverage(&img) - std::f32::consts::PI * 400.0).abs() < 15.0, "{}", coverage(&img));
}
/// Wiggle Paths' Spatial Phase changes the drawn shape; a whole turn draws it as at 0°.
#[test]
fn wiggle_paths_spatial_phase_renders() {
let render = |phase: f64| {
let (p, cid) = shape_comp(|ids| {
vec![
build::shape_ellipse(ids, [60.0, 60.0], [0.0, 0.0]),
op(ids, "wiggle", &[("size", Value::Scalar(8.0)), ("correlation", Value::Scalar(0.0)), ("spatialPhase", Value::Scalar(phase))]),
build::shape_fill(ids, RED),
]
});
render_frame(&p, cid, Tick::from_seconds_f64(0.5), 1.0).data
};
let zero = render(0.0);
assert!(render(90.0) != zero, "Spatial Phase 90° draws the shape of 0°");
assert!(render(360.0) == zero, "Spatial Phase 360° draws another shape than 0°");
}
#[test]
fn wiggle_paths_vary_over_time_deterministically() {
let (p, cid) = shape_comp(|ids| {
+53 -31
View File
@@ -287,11 +287,13 @@ pub fn char_transforms(ctx: &EvalCtx, layer: &Layer, text: &PropGroup, lay: &Tex
let pos = get("position").map(|v| c3(v, 0.0));
let anchor = get("anchor").map(|v| c3(v, 0.0));
let scale = get("scale").map(|v| c3(v, 100.0));
let rot = get1("rotation");
let rx = get1("rotationX");
let ry = get1("rotationY");
let skew = get1("skew");
let skew_axis = get1("skewAxis");
let angles = CharacterAngles {
rotation: get1("rotation"),
rotation_x: get1("rotationX"),
rotation_y: get1("rotationY"),
skew: get1("skew"),
skew_axis: get1("skewAxis"),
};
let op = get1("opacity");
let fill = props.get("fillColor").map(|p| ctx.value(layer, p).as_color());
let stroke = props.get("strokeColor").map(|p| ctx.value(layer, p).as_color());
@@ -338,21 +340,7 @@ pub fn char_transforms(ctx: &EvalCtx, layer: &Layer, text: &PropGroup, lay: &Tex
c.scale[d] *= 1.0 + (s[d] / 100.0 - 1.0) * k3[d];
}
}
if let Some(r) = rot {
c.rotation += r * k;
}
if let Some(r) = rx {
c.rotation_x += r * k;
}
if let Some(r) = ry {
c.rotation_y += r * k;
}
if let Some(s) = skew {
c.skew += s * k;
}
if let Some(a) = skew_axis {
c.skew_axis = a;
}
angles.apply(c, k);
if let Some(o) = op {
c.opacity *= 1.0 + (o / 100.0 - 1.0) * k;
}
@@ -381,17 +369,7 @@ pub fn char_transforms(ctx: &EvalCtx, layer: &Layer, text: &PropGroup, lay: &Tex
if let Some(w) = sw {
c.stroke_width += w * k;
}
if let Some(t) = tracking {
let t = t * k;
match tracking_type {
1 => c.track_before += t,
2 => c.track_after += t,
_ => {
c.track_before += t * 0.5;
c.track_after += t * 0.5;
}
}
}
apply_tracking(c, tracking, tracking_type, k);
if let Some(a) = line_anchor
&& k.abs() >= c.line_anchor_k
{
@@ -432,6 +410,50 @@ pub fn char_transforms(ctx: &EvalCtx, layer: &Layer, text: &PropGroup, lay: &Tex
out
}
/// Optional animator orientation properties; an omitted property leaves the glyph unchanged.
struct CharacterAngles {
rotation: Option<f64>,
rotation_x: Option<f64>,
rotation_y: Option<f64>,
skew: Option<f64>,
skew_axis: Option<f64>,
}
impl CharacterAngles {
fn apply(&self, c: &mut CharXf, k: f64) {
if let Some(r) = self.rotation {
c.rotation += r * k;
}
if let Some(r) = self.rotation_x {
c.rotation_x += r * k;
}
if let Some(r) = self.rotation_y {
c.rotation_y += r * k;
}
if let Some(s) = self.skew {
c.skew += s * k;
}
if let Some(a) = self.skew_axis {
c.skew_axis = a;
}
}
}
/// Distribute selected tracking before and/or after a glyph according to Tracking Type.
fn apply_tracking(c: &mut CharXf, tracking: Option<f64>, tracking_type: u32, k: f64) {
if let Some(t) = tracking {
let t = t * k;
match tracking_type {
1 => c.track_before += t,
2 => c.track_after += t,
_ => {
c.track_before += t * 0.5;
c.track_after += t * 0.5;
}
}
}
}
/// Character Offset / Character Value substitution (None = unchanged).
pub fn substitute(ch: char, x: &CharXf) -> Option<char> {
if ch.is_whitespace() {
+13 -2
View File
@@ -82,9 +82,20 @@ pub fn f_schlick(f0: [f32; 3], v_h: f32) -> [f32; 3] {
[f0[0] + (1.0 - f0[0]) * k, f0[1] + (1.0 - f0[1]) * k, f0[2] + (1.0 - f0[2]) * k]
}
/// Surface parameters used by the direct-light BRDF after texture sampling.
#[derive(Clone, Copy)]
pub struct BrdfSurface {
pub base: [f32; 3],
pub metallic: f32,
pub roughness: f32,
pub specular: f32,
pub diffuse: f32,
}
/// Cook–Torrance BRDF × N·L × π for one light: (diffuse colour factor, specular colour).
/// Returned as the reflected fraction of the light's irradiance.
pub fn brdf(base: [f32; 3], metallic: f32, rough: f32, spec_k: f32, diff_k: f32, n: [f32; 3], v: [f32; 3], l: [f32; 3]) -> [f32; 3] {
pub fn brdf(surface: BrdfSurface, n: [f32; 3], v: [f32; 3], l: [f32; 3]) -> [f32; 3] {
let BrdfSurface { base, metallic, roughness: rough, specular: spec_k, diffuse: diff_k } = surface;
let n_l = dot(n, l);
if n_l <= 0.0 {
return [0.0; 3];
@@ -484,7 +495,7 @@ pub fn shade(s: &Scene, f: &Frag) -> Option<[f32; 4]> {
continue;
}
let sh = if m.receives_shadows { light_shadow(s, l, f.pos, ng, n_l) } else { 1.0 };
let b = brdf(rgb, metallic, rough, m.specular_k, m.diffuse_k, n, v, to_l);
let b = brdf(BrdfSurface { base: rgb, metallic, roughness: rough, specular: m.specular_k, diffuse: m.diffuse_k }, n, v, to_l);
for c in 0..3 {
out[c] += b[c] * l.color[c] * k * sh;
}
+3 -3
View File
@@ -16,7 +16,7 @@ fn brdf_matches_cook_torrance_formula() {
let v = shade::norm([0.3, 0.1, 1.0]);
let l = shade::norm([-0.4, 0.2, 1.0]);
let (base, metal, rough) = ([0.8, 0.4, 0.2], 0.3f32, 0.45f32);
let got = shade::brdf(base, metal, rough, 1.0, 1.0, n, v, l);
let got = shade::brdf(shade::BrdfSurface { base, metallic: metal, roughness: rough, specular: 1.0, diffuse: 1.0 }, n, v, l);
let h = shade::norm([v[0] + l[0], v[1] + l[1], v[2] + l[2]]);
let (nv, nl, nh, vh) = (shade::dot(n, v), shade::dot(n, l), shade::dot(n, h), shade::dot(v, h));
let a2 = (rough * rough).powi(2);
@@ -30,9 +30,9 @@ fn brdf_matches_cook_torrance_formula() {
assert!((got[c] - expect).abs() < 1e-5, "channel {c}: {} vs {expect}", got[c]);
}
// No light from behind the surface.
assert_eq!(shade::brdf(base, metal, rough, 1.0, 1.0, n, v, [0.0, 0.0, -1.0]), [0.0; 3]);
assert_eq!(shade::brdf(shade::BrdfSurface { base, metallic: metal, roughness: rough, specular: 1.0, diffuse: 1.0 }, n, v, [0.0, 0.0, -1.0]), [0.0; 3]);
// A white rough dielectric lit head-on reflects about its base colour (π folded in).
let w = shade::brdf([1.0; 3], 0.0, 1.0, 1.0, 1.0, n, n, n);
let w = shade::brdf(shade::BrdfSurface { base: [1.0; 3], metallic: 0.0, roughness: 1.0, specular: 1.0, diffuse: 1.0 }, n, n, n);
assert!(w[0] > 0.95 && w[0] < 1.05, "{w:?}");
}
+10 -13
View File
@@ -197,7 +197,7 @@ fn mb_samples(r: &Renderer, ctx: &EvalCtx, layer: &Layer) -> usize {
/// Prepare a layer (buffer, geometry, material). `None` when it shows nothing.
pub(crate) fn prepare<'a>(r: &Renderer, ctx: &EvalCtx<'a>, layer: &'a Layer, order: usize, out: (u32, u32), in_run: bool) -> Option<Item<'a>> {
let buf = r.blend_layer_buf(ctx, layer)?;
prepare_with(r, ctx, layer, order, out, in_run, buf, Mat4::IDENTITY)
prepare_with(r, ctx, layer, order, out, in_run, PlaneInput { buf, local: Mat4::IDENTITY })
}
/// Prepare a layer as planes: one per character for per-character 3D text, else one.
@@ -205,24 +205,21 @@ pub(crate) fn prepare_all<'a>(r: &Renderer, ctx: &EvalCtx<'a>, layer: &'a Layer,
if crate::text::per_char_3d(ctx, layer) {
return crate::text::per_char_planes(ctx, layer, r.opts.scale)
.into_iter()
.filter_map(|(buf, m)| prepare_with(r, ctx, layer, order, out, in_run, r.to_blend(Arc::new(buf), None), m))
.filter_map(|(buf, m)| prepare_with(r, ctx, layer, order, out, in_run, PlaneInput { buf: r.to_blend(Arc::new(buf), None), local: m }))
.collect();
}
prepare(r, ctx, layer, order, out, in_run).into_iter().collect()
}
/// Prepare a plane showing `buf`, whose space maps into the layer through `local`.
#[allow(clippy::too_many_arguments)]
fn prepare_with<'a>(
r: &Renderer,
ctx: &EvalCtx<'a>,
layer: &'a Layer,
order: usize,
out: (u32, u32),
in_run: bool,
mut buf: Arc<Buf>,
/// A processed plane and its mapping into the layer's coordinate system.
struct PlaneInput {
buf: Arc<Buf>,
local: Mat4,
) -> Option<Item<'a>> {
}
/// Prepare the plane's geometry, motion samples and material.
fn prepare_with<'a>(r: &Renderer, ctx: &EvalCtx<'a>, layer: &'a Layer, order: usize, out: (u32, u32), in_run: bool, plane: PlaneInput) -> Option<Item<'a>> {
let PlaneInput { mut buf, local } = plane;
if buf.img.is_empty() {
return None;
}