M9.3: effect env (masks, layer params, audio, comp timing), sim cache, polyline helpers; module stubs

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-01 21:43:48 -04:00
parent dee16181fe
commit c91af194f2
31 changed files with 549 additions and 15 deletions

View File

@ -621,7 +621,8 @@ mod tests {
for (k, v) in over {
params.values.insert(k.to_string(), v.clone());
}
let ctx = EffectCtx { params: &params, time: 0.0, layer_size: [img.width as f64, img.height as f64], seed: 1, adjustment: false };
let ctx =
EffectCtx { params: &params, time: 0.0, layer_size: [img.width as f64, img.height as f64], seed: 1, adjustment: false, env: Default::default() };
(s.render)(&ctx, Buf { img: img.clone(), offset: [0.0, 0.0], scale: 1.0 })
}

View File

@ -425,7 +425,8 @@ mod tests {
for (k, v) in over {
params.values.insert(k.to_string(), v.clone());
}
let ctx = EffectCtx { params: &params, time: 0.0, layer_size: [img.width as f64, img.height as f64], seed: 1, adjustment: true };
let ctx =
EffectCtx { params: &params, time: 0.0, layer_size: [img.width as f64, img.height as f64], seed: 1, adjustment: true, env: Default::default() };
apply(s, &ctx, Buf { img, offset: [0.0, 0.0], scale: 1.0 }).img
}

View File

@ -0,0 +1,7 @@
//! Channel effects, batch 2.
use crate::EffectSpec;
pub fn specs() -> Vec<EffectSpec> {
Vec::new()
}

View File

@ -886,7 +886,8 @@ mod tests {
for (k, v) in over {
params.values.insert(k.to_string(), v.clone());
}
let ctx = EffectCtx { params: &params, time: 0.0, layer_size: [img.width as f64, img.height as f64], seed: 1, adjustment: false };
let ctx =
EffectCtx { params: &params, time: 0.0, layer_size: [img.width as f64, img.height as f64], seed: 1, adjustment: false, env: Default::default() };
(s.render)(&ctx, Buf { img: img.clone(), offset: [0.0, 0.0], scale: 1.0 }).img
}

View File

@ -0,0 +1,7 @@
//! Color Correction effects, batch 3.
use crate::EffectSpec;
pub fn specs() -> Vec<EffectSpec> {
Vec::new()
}

View File

@ -690,7 +690,7 @@ mod tests {
for (k, v) in set {
params.values.insert(k.to_string(), v.clone());
}
let ctx = EffectCtx { params: &params, time: 0.5, layer_size: ls, seed: 3, adjustment: false };
let ctx = EffectCtx { params: &params, time: 0.5, layer_size: ls, seed: 3, adjustment: false, env: Default::default() };
crate::apply(s, &ctx, Buf { img, offset: [0.0, 0.0], scale: 1.0 })
}

View File

@ -0,0 +1,7 @@
//! Distort effects, batch 3.
use crate::EffectSpec;
pub fn specs() -> Vec<EffectSpec> {
Vec::new()
}

View File

@ -754,7 +754,7 @@ mod tests {
for (k, v) in set {
params.values.insert(k.to_string(), v.clone());
}
let ctx = EffectCtx { params: &params, time: 0.5, layer_size: ls, seed: 3, adjustment: false };
let ctx = EffectCtx { params: &params, time: 0.5, layer_size: ls, seed: 3, adjustment: false, env: Default::default() };
crate::apply(s, &ctx, Buf { img, offset: [0.0, 0.0], scale: 1.0 })
}

View File

@ -0,0 +1,7 @@
//! Generate effects, batch 3.
use crate::EffectSpec;
pub fn specs() -> Vec<EffectSpec> {
Vec::new()
}

View File

@ -617,7 +617,8 @@ mod tests {
for (k, v) in over {
params.values.insert(k.to_string(), v.clone());
}
let ctx = EffectCtx { params: &params, time: 0.0, layer_size: [img.width as f64, img.height as f64], seed: 1, adjustment: false };
let ctx =
EffectCtx { params: &params, time: 0.0, layer_size: [img.width as f64, img.height as f64], seed: 1, adjustment: false, env: Default::default() };
apply(s, &ctx, Buf { img, offset: [0.0, 0.0], scale: 1.0 }).img
}

View File

@ -0,0 +1,7 @@
//! Keying effects, batch 2.
use crate::EffectSpec;
pub fn specs() -> Vec<EffectSpec> {
Vec::new()
}

View File

@ -8,22 +8,36 @@
mod blur2;
mod channel;
mod channel2;
mod color2;
mod color3;
mod color_fx;
mod controls;
mod distort;
mod distort2;
mod distort3;
mod generate;
mod generate2;
mod generate3;
mod keying;
mod keying2;
mod matte;
mod misc;
mod noise;
mod noise2;
mod obsolete;
mod perspective;
mod perspective2;
mod sim;
mod sim2;
mod stylize2;
mod stylize3;
mod textfx;
mod transition;
mod transition2;
pub mod util;
mod utility;
mod utility2;
use std::collections::HashMap;
use std::sync::OnceLock;
@ -122,6 +136,45 @@ impl Buf {
}
}
/// A layer mask at the current time, flattened to a polyline in layer coordinates.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct MaskShape {
pub name: String,
pub points: Vec<[f64; 2]>,
pub closed: bool,
pub inverted: bool,
}
/// Pixels of another layer referenced by a layer parameter: its buffer (layer space, see
/// [`Buf`]) and its source size in layer pixels.
#[derive(Clone, Debug, Default)]
pub struct LayerPixels {
pub buf: Buf,
pub size: [f64; 2],
}
/// Services the renderer offers effects that look beyond their own pixels.
pub trait EffectHost: Sync {
/// Layer `id` of the same composition at the current comp time. `masks_and_effects`
/// selects "Effects & Masks" over "Source". `None` for missing/self/recursive references.
fn layer(&self, id: u64, masks_and_effects: bool) -> Option<LayerPixels>;
/// `frames` stereo sample frames (interleaved L R …) of layer `id`'s audio starting at comp
/// time `start` seconds, at `rate` Hz. `None` when the layer has no audio.
fn audio(&self, id: u64, start: f64, frames: usize, rate: u32) -> Option<Vec<f32>>;
}
/// Extra context the renderer may supply (all optional; `Default` is "nothing known").
#[derive(Clone, Copy, Default)]
pub struct EffectEnv<'a> {
/// The layer's masks (enabled ones, top to bottom).
pub masks: &'a [MaskShape],
pub host: Option<&'a dyn EffectHost>,
/// Composition time in seconds.
pub comp_time: f64,
/// Composition frame rate (0 when unknown).
pub frame_rate: f64,
}
/// What an effect gets to render with.
pub struct EffectCtx<'a> {
pub params: &'a Params,
@ -133,6 +186,20 @@ pub struct EffectCtx<'a> {
pub seed: u32,
/// Rendering for an adjustment layer (operating on the comp below).
pub adjustment: bool,
/// Masks, other layers, audio, comp timing.
pub env: EffectEnv<'a>,
}
impl EffectCtx<'_> {
/// The layer chosen in layer parameter `id`, via the host.
pub fn layer_param(&self, id: &str, masks_and_effects: bool) -> Option<LayerPixels> {
let lid = self.params.get(id)?.as_layer()?;
self.env.host?.layer(lid, masks_and_effects)
}
/// Frame rate for frame-based maths (comp rate, else 30).
pub fn fps(&self) -> f64 {
if self.env.frame_rate > 0.0 { self.env.frame_rate } else { 30.0 }
}
}
pub type RenderFn = fn(&EffectCtx, Buf) -> Buf;
@ -187,6 +254,20 @@ pub fn registry() -> &'static [EffectSpec] {
v.extend(perspective::specs());
v.extend(transition::specs());
v.extend(utility::specs());
v.extend(sim::specs());
v.extend(sim2::specs());
v.extend(distort3::specs());
v.extend(perspective2::specs());
v.extend(utility2::specs());
v.extend(generate3::specs());
v.extend(textfx::specs());
v.extend(stylize3::specs());
v.extend(transition2::specs());
v.extend(channel2::specs());
v.extend(keying2::specs());
v.extend(noise2::specs());
v.extend(color3::specs());
v.extend(obsolete::specs());
v.sort_by(|a, b| a.category.cmp(b.category).then(a.name.cmp(b.name)));
v
})
@ -227,10 +308,39 @@ pub fn apply(spec: &EffectSpec, ctx: &EffectCtx, buf: Buf) -> Buf {
(spec.render)(ctx, buf)
}
/// Test helper: run effect `id` on `img` (scale 1, layer size = image size) with its defaults
/// overridden by `vals`, at layer time `time`, with an optional environment.
#[cfg(test)]
pub(crate) fn run_fx(id: &str, vals: &[(&str, Value)], img: Image, time: f64, env: EffectEnv) -> Buf {
let s = find(id).unwrap_or_else(|| panic!("no effect {id}"));
let mut params = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() };
for (k, v) in vals {
assert!(params.values.contains_key(*k), "{id}: unknown param {k}");
params.values.insert(k.to_string(), v.clone());
}
let ctx = EffectCtx { params: &params, time, layer_size: [img.width as f64, img.height as f64], seed: 1, adjustment: false, env };
apply(s, &ctx, Buf { img, offset: [0.0, 0.0], scale: 1.0 })
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn run_fx_passthrough() {
let img = Image::filled(4, 3, [0.5, 0.25, 0.125, 1.0]);
let out = run_fx("ec.control.slider", &[("slider", num(3.0))], img.clone(), 0.0, EffectEnv::default());
assert_eq!(out.img.data, img.data);
}
#[test]
#[ignore]
fn dump_registry() {
for s in registry() {
println!("{} | {} | {}", s.category, s.name, s.id);
}
}
#[test]
fn registry_is_consistent() {
let r = registry();
@ -259,7 +369,7 @@ mod tests {
}
for s in registry() {
let params = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() };
let ctx = EffectCtx { params: &params, time: 0.5, layer_size: [48.0, 32.0], seed: 1, adjustment: false };
let ctx = EffectCtx { params: &params, time: 0.5, layer_size: [48.0, 32.0], seed: 1, adjustment: false, env: Default::default() };
let out = apply(s, &ctx, Buf { img: img.clone(), offset: [0.0, 0.0], scale: 1.0 });
assert!(!out.img.is_empty(), "{}", s.id);
assert!(out.img.data.iter().all(|p| p[3].is_finite() && p[3] >= -1e-4 && p[3] <= 1.0001), "{} alpha out of range", s.id);

View File

@ -200,7 +200,8 @@ mod tests {
for (k, v) in over {
params.values.insert(k.to_string(), v.clone());
}
let ctx = EffectCtx { params: &params, time: 0.0, layer_size: [img.width as f64, img.height as f64], seed: 1, adjustment: true };
let ctx =
EffectCtx { params: &params, time: 0.0, layer_size: [img.width as f64, img.height as f64], seed: 1, adjustment: true, env: Default::default() };
apply(s, &ctx, Buf { img, offset: [0.0, 0.0], scale: 1.0 }).img
}

View File

@ -531,7 +531,7 @@ mod tests {
for (k, v) in vals {
params.values.insert(k.to_string(), v.clone());
}
let ctx = EffectCtx { params: &params, time, layer_size: [img.width as f64, img.height as f64], seed: 3, adjustment: false };
let ctx = EffectCtx { params: &params, time, layer_size: [img.width as f64, img.height as f64], seed: 3, adjustment: false, env: Default::default() };
crate::apply(s, &ctx, Buf { img, offset: [0.0, 0.0], scale: 1.0 })
}

View File

@ -0,0 +1,7 @@
//! Noise & Grain effects, batch 2.
use crate::EffectSpec;
pub fn specs() -> Vec<EffectSpec> {
Vec::new()
}

View File

@ -0,0 +1,7 @@
//! Obsolete-category effects.
use crate::EffectSpec;
pub fn specs() -> Vec<EffectSpec> {
Vec::new()
}

View File

@ -452,7 +452,8 @@ mod tests {
params.values.insert(ps.id.to_string(), Value::Vec2([f[0] * img.width as f64, f[1] * img.height as f64]));
}
}
let ctx = EffectCtx { params: &params, time: 0.0, layer_size: [img.width as f64, img.height as f64], seed: 1, adjustment: false };
let ctx =
EffectCtx { params: &params, time: 0.0, layer_size: [img.width as f64, img.height as f64], seed: 1, adjustment: false, env: Default::default() };
(s.render)(&ctx, Buf { img: img.clone(), offset: [0.0, 0.0], scale: 1.0 })
}

View File

@ -0,0 +1,7 @@
//! Perspective effects, batch 2.
use crate::EffectSpec;
pub fn specs() -> Vec<EffectSpec> {
Vec::new()
}

View File

@ -0,0 +1,7 @@
//! Simulation effects (particles, physics, liquids).
use crate::EffectSpec;
pub fn specs() -> Vec<EffectSpec> {
Vec::new()
}

View File

@ -0,0 +1,7 @@
//! Simulation effects, part 2.
use crate::EffectSpec;
pub fn specs() -> Vec<EffectSpec> {
Vec::new()
}

View File

@ -698,7 +698,7 @@ mod tests {
for (k, v) in vals {
params.values.insert(k.to_string(), v.clone());
}
let ctx = EffectCtx { params: &params, time, layer_size: [img.width as f64, img.height as f64], seed: 1, adjustment: false };
let ctx = EffectCtx { params: &params, time, layer_size: [img.width as f64, img.height as f64], seed: 1, adjustment: false, env: Default::default() };
crate::apply(s, &ctx, Buf { img, offset: [0.0, 0.0], scale: 1.0 })
}

View File

@ -0,0 +1,7 @@
//! Stylize effects, batch 3.
use crate::EffectSpec;
pub fn specs() -> Vec<EffectSpec> {
Vec::new()
}

View File

@ -0,0 +1,7 @@
//! Text effects (Numbers, Timecode) and the obsolete text generators, drawn with a built-in stroke font.
use crate::EffectSpec;
pub fn specs() -> Vec<EffectSpec> {
Vec::new()
}

View File

@ -486,7 +486,8 @@ mod tests {
for (k, v) in set {
params.values.insert(k.to_string(), v.clone());
}
let ctx = EffectCtx { params: &params, time: 0.0, layer_size: [img.width as f64, img.height as f64], seed: 7, adjustment: false };
let ctx =
EffectCtx { params: &params, time: 0.0, layer_size: [img.width as f64, img.height as f64], seed: 7, adjustment: false, env: Default::default() };
(s.render)(&ctx, Buf { img: img.clone(), offset: [0.0, 0.0], scale: 1.0 }).img
}

View File

@ -0,0 +1,7 @@
//! Transition effects, batch 2.
use crate::EffectSpec;
pub fn specs() -> Vec<EffectSpec> {
Vec::new()
}

View File

@ -427,10 +427,253 @@ pub fn layer_rect(ctx: &crate::EffectCtx, b: &crate::Buf) -> (f64, f64, f64, f64
(b.offset[0], b.offset[1], ctx.layer_size[0] * b.scale, ctx.layer_size[1] * b.scale)
}
/// Resample another layer (a layer parameter) into `b`'s pixel grid. With `stretch` the other
/// layer is scaled to this layer's size ("Stretch to Fit"); otherwise it is centred.
pub fn fit_layer(ctx: &crate::EffectCtx, b: &crate::Buf, other: &crate::LayerPixels, stretch: bool) -> Image {
let ls = ctx.layer_size;
let os = other.size;
let (sx, sy) = if stretch && ls[0] > 0.0 && ls[1] > 0.0 { (os[0] / ls[0], os[1] / ls[1]) } else { (1.0, 1.0) };
let (dx, dy) = if stretch { (0.0, 0.0) } else { ((os[0] - ls[0]) * 0.5, (os[1] - ls[1]) * 0.5) };
let inv = 1.0 / b.scale.max(1e-9);
gen_image(b.img.width, b.img.height, |x, y| {
let px = (x as f64 + 0.5 - b.offset[0]) * inv;
let py = (y as f64 + 0.5 - b.offset[1]) * inv;
let qx = px * sx + dx;
let qy = py * sy + dy;
other.buf.img.sample_bilinear(qx * other.buf.scale + other.buf.offset[0], qy * other.buf.scale + other.buf.offset[1])
})
}
/// The layer chosen in layer parameter `id` fitted to `b` (see [`fit_layer`]), or a copy of
/// `b`'s own pixels when none is chosen / available (After Effects' "self" behaviour).
pub fn layer_or_self(ctx: &crate::EffectCtx, b: &crate::Buf, id: &str, masks_and_effects: bool, stretch: bool) -> Image {
match ctx.layer_param(id, masks_and_effects) {
Some(o) => fit_layer(ctx, b, &o, stretch),
None => b.img.clone(),
}
}
/// A stable hash of an effect instance's evaluated parameters, seed, layer size and buffer
/// scale (cache key for simulations).
pub fn params_key(ctx: &crate::EffectCtx, b: &crate::Buf, salt: u64) -> u64 {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
let mut keys: Vec<&String> = ctx.params.values.keys().collect();
keys.sort();
for k in keys {
k.hash(&mut h);
format!("{:?}", ctx.params.values[k]).hash(&mut h);
}
ctx.seed.hash(&mut h);
ctx.layer_size[0].to_bits().hash(&mut h);
ctx.layer_size[1].to_bits().hash(&mut h);
b.scale.to_bits().hash(&mut h);
salt.hash(&mut h);
h.finish()
}
/// Checkpointed, deterministic, time-seekable simulation cache.
///
/// A simulation is a state advanced in fixed steps from layer time 0. [`SimCache::run`] returns
/// the state after `target` steps, starting from the nearest cached checkpoint at or before it
/// (so scrubbing forward is incremental and seeking anywhere gives the same answer as
/// simulating from scratch). Entries are keyed by [`params_key`]; the least recently used key
/// is evicted beyond `cap` keys.
pub struct SimCache<S> {
inner: std::sync::Mutex<Vec<SimEntry<S>>>,
cap: usize,
}
struct SimEntry<S> {
key: u64,
used: u64,
checkpoints: Vec<(u64, std::sync::Arc<S>)>,
}
/// Checkpoint every this many steps.
pub const SIM_CHECKPOINT: u64 = 15;
impl<S: Clone> SimCache<S> {
pub const fn new(cap: usize) -> SimCache<S> {
SimCache { inner: std::sync::Mutex::new(Vec::new()), cap }
}
/// State after `target` steps. `init` builds step 0; `step(state, i)` advances from step
/// `i` to `i + 1`.
pub fn run(&self, key: u64, target: u64, init: impl FnOnce() -> S, mut step: impl FnMut(&mut S, u64)) -> std::sync::Arc<S> {
use std::sync::Arc;
let start = {
let mut g = self.inner.lock().unwrap_or_else(|e| e.into_inner());
let tick = g.iter().map(|e| e.used).max().unwrap_or(0) + 1;
g.iter_mut().find(|e| e.key == key).and_then(|e| {
e.used = tick;
e.checkpoints.iter().filter(|(s, _)| *s <= target).max_by_key(|(s, _)| *s).map(|(s, st)| (*s, st.clone()))
})
};
let had_start = start.is_some();
let (mut i, mut st) = match start {
Some((s, st)) if s == target => return st,
Some((s, st)) => (s, (*st).clone()),
None => (0, init()),
};
let mut fresh: Vec<(u64, Arc<S>)> = Vec::new();
if !had_start {
fresh.push((0, Arc::new(st.clone())));
}
while i < target {
step(&mut st, i);
i += 1;
if i % SIM_CHECKPOINT == 0 && i != target {
fresh.push((i, Arc::new(st.clone())));
}
}
let out = Arc::new(st);
fresh.push((target, out.clone()));
let mut g = self.inner.lock().unwrap_or_else(|e| e.into_inner());
let tick = g.iter().map(|e| e.used).max().unwrap_or(0) + 1;
let idx = match g.iter().position(|e| e.key == key) {
Some(ix) => ix,
None => {
if g.len() >= self.cap.max(1) {
let old = g.iter().enumerate().min_by_key(|(_, e)| e.used).map(|(ix, _)| ix).unwrap_or(0);
g.remove(old);
}
g.push(SimEntry { key, used: tick, checkpoints: Vec::new() });
g.len() - 1
}
};
let e = &mut g[idx];
e.used = tick;
for (s, st) in fresh {
if !e.checkpoints.iter().any(|(c, _)| *c == s) {
e.checkpoints.push((s, st));
}
}
// Keep memory bounded: drop unaligned (exact-target) checkpoints first, then thin.
if e.checkpoints.len() > 256 {
e.checkpoints.retain(|(s, _)| s % SIM_CHECKPOINT == 0);
e.checkpoints.sort_by_key(|(s, _)| *s);
let mut k = 0;
e.checkpoints.retain(|_| {
k += 1;
k % 2 == 1
});
}
out
}
}
/// Point-in-polygon (even-odd) for a closed polyline.
pub fn point_in_poly(pts: &[[f64; 2]], x: f64, y: f64) -> bool {
let n = pts.len();
if n < 3 {
return false;
}
let mut inside = false;
let mut j = n - 1;
for i in 0..n {
let (a, b) = (pts[i], pts[j]);
if (a[1] > y) != (b[1] > y) && x < (b[0] - a[0]) * (y - a[1]) / (b[1] - a[1]) + a[0] {
inside = !inside;
}
j = i;
}
inside
}
/// Distance from a point to a polyline (closed adds the last→first segment).
pub fn dist_to_poly(pts: &[[f64; 2]], closed: bool, x: f64, y: f64) -> f64 {
let n = pts.len();
if n == 0 {
return f64::INFINITY;
}
if n == 1 {
return ((pts[0][0] - x).powi(2) + (pts[0][1] - y).powi(2)).sqrt();
}
let segs = if closed { n } else { n - 1 };
let mut best = f64::INFINITY;
for i in 0..segs {
let a = pts[i];
let b = pts[(i + 1) % n];
let (dx, dy) = (b[0] - a[0], b[1] - a[1]);
let l2 = dx * dx + dy * dy;
let t = if l2 > 0.0 { (((x - a[0]) * dx + (y - a[1]) * dy) / l2).clamp(0.0, 1.0) } else { 0.0 };
let (px, py) = (a[0] + dx * t, a[1] + dy * t);
best = best.min((px - x).powi(2) + (py - y).powi(2));
}
best.sqrt()
}
/// Total length of a polyline.
pub fn poly_length(pts: &[[f64; 2]], closed: bool) -> f64 {
let n = pts.len();
if n < 2 {
return 0.0;
}
let segs = if closed { n } else { n - 1 };
(0..segs)
.map(|i| {
let (a, b) = (pts[i], pts[(i + 1) % n]);
((b[0] - a[0]).powi(2) + (b[1] - a[1]).powi(2)).sqrt()
})
.sum()
}
/// Point and unit tangent at arc length `s` along a polyline.
pub fn poly_point_at(pts: &[[f64; 2]], closed: bool, s: f64) -> ([f64; 2], [f64; 2]) {
let n = pts.len();
if n == 0 {
return ([0.0; 2], [1.0, 0.0]);
}
if n == 1 {
return (pts[0], [1.0, 0.0]);
}
let segs = if closed { n } else { n - 1 };
let mut rem = s.max(0.0);
for i in 0..segs {
let (a, b) = (pts[i], pts[(i + 1) % n]);
let l = ((b[0] - a[0]).powi(2) + (b[1] - a[1]).powi(2)).sqrt();
if rem <= l || i == segs - 1 {
let t = if l > 0.0 { (rem / l).min(1.0) } else { 0.0 };
let d = if l > 0.0 { [(b[0] - a[0]) / l, (b[1] - a[1]) / l] } else { [1.0, 0.0] };
return ([a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t], d);
}
rem -= l;
}
(pts[n - 1], [1.0, 0.0])
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sim_cache_is_seek_consistent() {
static C: SimCache<u64> = SimCache::new(2);
let step = |s: &mut u64, i: u64| *s = s.wrapping_mul(6364136223846793005).wrapping_add(i);
let direct = *C.run(1, 100, || 7, step);
let fresh: SimCache<u64> = SimCache::new(2);
let a = *fresh.run(1, 40, || 7, step);
let b = *fresh.run(1, 100, || 7, step);
let c = *fresh.run(1, 40, || 7, step);
let d = *fresh.run(1, 77, || 7, step);
let e = *C.run(1, 77, || 7, step);
assert_eq!(b, direct);
assert_eq!(a, c);
assert_eq!(d, e);
}
#[test]
fn poly_helpers() {
let sq = [[0.0, 0.0], [10.0, 0.0], [10.0, 10.0], [0.0, 10.0]];
assert!(point_in_poly(&sq, 5.0, 5.0));
assert!(!point_in_poly(&sq, 15.0, 5.0));
assert!((poly_length(&sq, true) - 40.0).abs() < 1e-9);
assert!((dist_to_poly(&sq, true, 5.0, 5.0) - 5.0).abs() < 1e-9);
let (pt, _) = poly_point_at(&sq, true, 15.0);
assert!((pt[0] - 10.0).abs() < 1e-9 && (pt[1] - 5.0).abs() < 1e-9);
}
#[test]
fn minmax_matches_brute_force() {
let src: Vec<f32> = (0..37).map(|i| ((i * 7919) % 31) as f32).collect();

View File

@ -363,7 +363,8 @@ mod tests {
for (k, v) in vals {
params.values.insert(k.to_string(), v.clone());
}
let ctx = EffectCtx { params: &params, time: 0.0, layer_size: [img.width as f64, img.height as f64], seed: 1, adjustment: false };
let ctx =
EffectCtx { params: &params, time: 0.0, layer_size: [img.width as f64, img.height as f64], seed: 1, adjustment: false, env: Default::default() };
crate::apply(s, &ctx, Buf { img, offset: [0.0, 0.0], scale: 1.0 })
}

View File

@ -0,0 +1,7 @@
//! Utility effects, batch 2.
use crate::EffectSpec;
pub fn specs() -> Vec<EffectSpec> {
Vec::new()
}

View File

@ -454,6 +454,13 @@ impl FootageSource for MediaPool {
}
}
}
fn audio(&self, _item: ItemId, footage: &Footage, t: Tick, frames: usize, rate: u32) -> Option<Vec<f32>> {
if !footage.has_audio || footage.missing {
return None;
}
Some(self.audio_samples(footage, t, frames, rate))
}
}
fn lock<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {

View File

@ -14,7 +14,7 @@ pub mod text;
use std::sync::Arc;
use effectcraft_color::BlendMode;
use effectcraft_effects::{Buf, EffectCtx, Params};
use effectcraft_effects::{Buf, EffectCtx, EffectEnv, EffectHost, LayerPixels, Params};
use effectcraft_geom::{Mat3, vec2};
use effectcraft_project::{Comp, Footage, GroupKind, ItemId, ItemKind, Layer, LayerSource, MatteKind, Node, Project, Quality, Sampling};
pub use effectcraft_raster::Image;
@ -27,6 +27,43 @@ use rayon::prelude::*;
pub trait FootageSource: Send + Sync {
/// The frame of `item` at source time `t`, straight from the file (any size).
fn frame(&self, item: ItemId, footage: &Footage, t: Tick) -> Option<Arc<Image>>;
/// `frames` interleaved stereo sample frames of `item`'s audio from source time `t` at
/// `rate` Hz (`None` when unavailable).
fn audio(&self, _item: ItemId, _footage: &Footage, _t: Tick, _frames: usize, _rate: u32) -> Option<Vec<f32>> {
None
}
}
/// Layer parameters and audio for effects (see [`effectcraft_effects::EffectHost`]).
struct FxHost<'r, 'a, 'c> {
r: &'r Renderer<'a>,
ctx: &'c EvalCtx<'a>,
layer: &'c Layer,
}
impl EffectHost for FxHost<'_, '_, '_> {
fn layer(&self, id: u64, masks_and_effects: bool) -> Option<LayerPixels> {
let other = self.ctx.layer(effectcraft_project::LayerId(id))?;
if other.id == self.layer.id || self.r.depth > 8 {
return None;
}
let sub = Renderer { project: self.r.project, footage: self.r.footage, expr: self.r.expr, opts: self.r.opts, depth: self.r.depth + 1 };
let buf = if masks_and_effects { sub.layer_buf(self.ctx, other)? } else { sub.source(self.ctx, other)? };
let size = source_size(self.r.project, other);
let size = if size.0 == 0 { [self.ctx.comp.width as f64, self.ctx.comp.height as f64] } else { [size.0 as f64, size.1 as f64] };
Some(LayerPixels { buf, size })
}
fn audio(&self, id: u64, start: f64, frames: usize, rate: u32) -> Option<Vec<f32>> {
let other = self.ctx.layer(effectcraft_project::LayerId(id))?;
let LayerSource::Footage { item } = &other.source else { return None };
let ItemKind::Footage(f) = &self.r.project.item(*item)?.kind else { return None };
if !f.has_audio {
return None;
}
let st = other.layer_time(Tick::from_seconds_f64(start));
self.r.footage.audio(*item, f, st, frames, rate)
}
}
/// No footage available (renders footage layers as transparent).
@ -134,6 +171,9 @@ impl<'a> Renderer<'a> {
let lt = layer.layer_time(ctx.time);
let size = source_size(self.project, layer);
let layer_size = if size.0 == 0 { [ctx.comp.width as f64, ctx.comp.height as f64] } else { [size.0 as f64, size.1 as f64] };
let mask_shapes = masks::shapes(ctx, layer);
let host = FxHost { r: self, ctx, layer };
let env = EffectEnv { masks: &mask_shapes, host: Some(&host), comp_time: ctx.time.seconds(), frame_rate: ctx.comp.frame_rate.as_f64() };
for g in fx.groups() {
if !g.enabled {
continue;
@ -141,7 +181,7 @@ impl<'a> Renderer<'a> {
let GroupKind::Effect { effect } = &g.kind else { continue };
let Some(spec) = effectcraft_effects::find(effect) else { continue };
let params = self.effect_params(ctx, layer, g);
let ectx = EffectCtx { params: &params, time: lt.seconds(), layer_size, seed: g.uid as u32, adjustment };
let ectx = EffectCtx { params: &params, time: lt.seconds(), layer_size, seed: g.uid as u32, adjustment, env };
buf = effectcraft_effects::apply(spec, &ectx, buf);
}
buf

View File

@ -39,6 +39,34 @@ fn coverage(ctx: &EvalCtx, layer: &Layer, g: &effectcraft_project::PropGroup, bu
Some(cov)
}
/// The layer's enabled masks at the context time, flattened to polylines in layer space (for
/// effects that use masks as paths: Stroke, Scribble, Inner/Outer Key, Reshape…).
pub fn shapes(ctx: &EvalCtx, layer: &Layer) -> Vec<effectcraft_effects::MaskShape> {
let Some(masks) = layer.masks() else { return Vec::new() };
let mut out = Vec::new();
for g in masks.groups().filter(|g| g.enabled) {
let GroupKind::Mask { inverted, .. } = g.kind else { continue };
let Some(Value::Path(sp)) = ctx.group_value(layer, g, "path") else { continue };
let path = effectcraft_path::to_kurbo(&sp);
let mut pts: Vec<[f64; 2]> = Vec::new();
let mut closed = false;
kurbo::flatten(path.iter(), 0.25, |el| match el {
effectcraft_path::PathEl::MoveTo(p) | effectcraft_path::PathEl::LineTo(p) => {
if pts.last() != Some(&[p.x, p.y]) {
pts.push([p.x, p.y]);
}
}
effectcraft_path::PathEl::ClosePath => closed = true,
_ => {}
});
if closed && pts.len() > 1 && pts.first() == pts.last() {
pts.pop();
}
out.push(effectcraft_effects::MaskShape { name: g.name.clone(), points: pts, closed: closed || sp.closed, inverted });
}
out
}
/// Apply the layer's masks to its buffer.
pub fn apply(ctx: &EvalCtx, layer: &Layer, buf: &mut Buf) {
let Some(masks) = layer.masks() else { return };