From c91af194f2661670719bd8073987521e0828760d Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 21:43:48 -0400 Subject: [PATCH] M9.3: effect env (masks, layer params, audio, comp timing), sim cache, polyline helpers; module stubs Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/blur2.rs | 3 +- crates/effects/src/channel.rs | 3 +- crates/effects/src/channel2.rs | 7 + crates/effects/src/color2.rs | 3 +- crates/effects/src/color3.rs | 7 + crates/effects/src/distort2.rs | 2 +- crates/effects/src/distort3.rs | 7 + crates/effects/src/generate2.rs | 2 +- crates/effects/src/generate3.rs | 7 + crates/effects/src/keying.rs | 3 +- crates/effects/src/keying2.rs | 7 + crates/effects/src/lib.rs | 112 ++++++++++++- crates/effects/src/matte.rs | 3 +- crates/effects/src/noise.rs | 2 +- crates/effects/src/noise2.rs | 7 + crates/effects/src/obsolete.rs | 7 + crates/effects/src/perspective.rs | 3 +- crates/effects/src/perspective2.rs | 7 + crates/effects/src/sim.rs | 7 + crates/effects/src/sim2.rs | 7 + crates/effects/src/stylize2.rs | 2 +- crates/effects/src/stylize3.rs | 7 + crates/effects/src/textfx.rs | 7 + crates/effects/src/transition.rs | 3 +- crates/effects/src/transition2.rs | 7 + crates/effects/src/util.rs | 243 +++++++++++++++++++++++++++++ crates/effects/src/utility.rs | 3 +- crates/effects/src/utility2.rs | 7 + crates/media/src/pool.rs | 7 + crates/render/src/lib.rs | 44 +++++- crates/render/src/masks.rs | 28 ++++ 31 files changed, 549 insertions(+), 15 deletions(-) create mode 100644 crates/effects/src/channel2.rs create mode 100644 crates/effects/src/color3.rs create mode 100644 crates/effects/src/distort3.rs create mode 100644 crates/effects/src/generate3.rs create mode 100644 crates/effects/src/keying2.rs create mode 100644 crates/effects/src/noise2.rs create mode 100644 crates/effects/src/obsolete.rs create mode 100644 crates/effects/src/perspective2.rs create mode 100644 crates/effects/src/sim.rs create mode 100644 crates/effects/src/sim2.rs create mode 100644 crates/effects/src/stylize3.rs create mode 100644 crates/effects/src/textfx.rs create mode 100644 crates/effects/src/transition2.rs create mode 100644 crates/effects/src/utility2.rs diff --git a/crates/effects/src/blur2.rs b/crates/effects/src/blur2.rs index 95853e7c..def9d207 100644 --- a/crates/effects/src/blur2.rs +++ b/crates/effects/src/blur2.rs @@ -621,7 +621,8 @@ mod tests { for (k, v) in over { params.values.insert(k.to_string(), v.clone()); } - let ctx = EffectCtx { params: ¶ms, time: 0.0, layer_size: [img.width as f64, img.height as f64], seed: 1, adjustment: false }; + let ctx = + EffectCtx { params: ¶ms, 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 }) } diff --git a/crates/effects/src/channel.rs b/crates/effects/src/channel.rs index 161fde37..383a95af 100644 --- a/crates/effects/src/channel.rs +++ b/crates/effects/src/channel.rs @@ -425,7 +425,8 @@ mod tests { for (k, v) in over { params.values.insert(k.to_string(), v.clone()); } - let ctx = EffectCtx { params: ¶ms, time: 0.0, layer_size: [img.width as f64, img.height as f64], seed: 1, adjustment: true }; + let ctx = + EffectCtx { params: ¶ms, 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 } diff --git a/crates/effects/src/channel2.rs b/crates/effects/src/channel2.rs new file mode 100644 index 00000000..70516adc --- /dev/null +++ b/crates/effects/src/channel2.rs @@ -0,0 +1,7 @@ +//! Channel effects, batch 2. + +use crate::EffectSpec; + +pub fn specs() -> Vec { + Vec::new() +} diff --git a/crates/effects/src/color2.rs b/crates/effects/src/color2.rs index 136ba00e..8d707c68 100644 --- a/crates/effects/src/color2.rs +++ b/crates/effects/src/color2.rs @@ -886,7 +886,8 @@ mod tests { for (k, v) in over { params.values.insert(k.to_string(), v.clone()); } - let ctx = EffectCtx { params: ¶ms, time: 0.0, layer_size: [img.width as f64, img.height as f64], seed: 1, adjustment: false }; + let ctx = + EffectCtx { params: ¶ms, 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 } diff --git a/crates/effects/src/color3.rs b/crates/effects/src/color3.rs new file mode 100644 index 00000000..cc39ceb9 --- /dev/null +++ b/crates/effects/src/color3.rs @@ -0,0 +1,7 @@ +//! Color Correction effects, batch 3. + +use crate::EffectSpec; + +pub fn specs() -> Vec { + Vec::new() +} diff --git a/crates/effects/src/distort2.rs b/crates/effects/src/distort2.rs index 20eb1bdc..c3496c59 100644 --- a/crates/effects/src/distort2.rs +++ b/crates/effects/src/distort2.rs @@ -690,7 +690,7 @@ mod tests { for (k, v) in set { params.values.insert(k.to_string(), v.clone()); } - let ctx = EffectCtx { params: ¶ms, time: 0.5, layer_size: ls, seed: 3, adjustment: false }; + let ctx = EffectCtx { params: ¶ms, 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 }) } diff --git a/crates/effects/src/distort3.rs b/crates/effects/src/distort3.rs new file mode 100644 index 00000000..4dca9381 --- /dev/null +++ b/crates/effects/src/distort3.rs @@ -0,0 +1,7 @@ +//! Distort effects, batch 3. + +use crate::EffectSpec; + +pub fn specs() -> Vec { + Vec::new() +} diff --git a/crates/effects/src/generate2.rs b/crates/effects/src/generate2.rs index 784f1916..6aa30682 100644 --- a/crates/effects/src/generate2.rs +++ b/crates/effects/src/generate2.rs @@ -754,7 +754,7 @@ mod tests { for (k, v) in set { params.values.insert(k.to_string(), v.clone()); } - let ctx = EffectCtx { params: ¶ms, time: 0.5, layer_size: ls, seed: 3, adjustment: false }; + let ctx = EffectCtx { params: ¶ms, 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 }) } diff --git a/crates/effects/src/generate3.rs b/crates/effects/src/generate3.rs new file mode 100644 index 00000000..e50cf9f5 --- /dev/null +++ b/crates/effects/src/generate3.rs @@ -0,0 +1,7 @@ +//! Generate effects, batch 3. + +use crate::EffectSpec; + +pub fn specs() -> Vec { + Vec::new() +} diff --git a/crates/effects/src/keying.rs b/crates/effects/src/keying.rs index 106a9d2f..c451c751 100644 --- a/crates/effects/src/keying.rs +++ b/crates/effects/src/keying.rs @@ -617,7 +617,8 @@ mod tests { for (k, v) in over { params.values.insert(k.to_string(), v.clone()); } - let ctx = EffectCtx { params: ¶ms, time: 0.0, layer_size: [img.width as f64, img.height as f64], seed: 1, adjustment: false }; + let ctx = + EffectCtx { params: ¶ms, 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 } diff --git a/crates/effects/src/keying2.rs b/crates/effects/src/keying2.rs new file mode 100644 index 00000000..5b514cb9 --- /dev/null +++ b/crates/effects/src/keying2.rs @@ -0,0 +1,7 @@ +//! Keying effects, batch 2. + +use crate::EffectSpec; + +pub fn specs() -> Vec { + Vec::new() +} diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 7dc27fa2..0355bd78 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -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; + /// `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>; +} + +/// 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 { + 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: ¶ms, 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: ¶ms, time: 0.5, layer_size: [48.0, 32.0], seed: 1, adjustment: false }; + let ctx = EffectCtx { params: ¶ms, 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); diff --git a/crates/effects/src/matte.rs b/crates/effects/src/matte.rs index 05dcd451..49914c80 100644 --- a/crates/effects/src/matte.rs +++ b/crates/effects/src/matte.rs @@ -200,7 +200,8 @@ mod tests { for (k, v) in over { params.values.insert(k.to_string(), v.clone()); } - let ctx = EffectCtx { params: ¶ms, time: 0.0, layer_size: [img.width as f64, img.height as f64], seed: 1, adjustment: true }; + let ctx = + EffectCtx { params: ¶ms, 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 } diff --git a/crates/effects/src/noise.rs b/crates/effects/src/noise.rs index 759309e6..8809c1e6 100644 --- a/crates/effects/src/noise.rs +++ b/crates/effects/src/noise.rs @@ -531,7 +531,7 @@ mod tests { for (k, v) in vals { params.values.insert(k.to_string(), v.clone()); } - let ctx = EffectCtx { params: ¶ms, time, layer_size: [img.width as f64, img.height as f64], seed: 3, adjustment: false }; + let ctx = EffectCtx { params: ¶ms, 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 }) } diff --git a/crates/effects/src/noise2.rs b/crates/effects/src/noise2.rs new file mode 100644 index 00000000..bb1953bc --- /dev/null +++ b/crates/effects/src/noise2.rs @@ -0,0 +1,7 @@ +//! Noise & Grain effects, batch 2. + +use crate::EffectSpec; + +pub fn specs() -> Vec { + Vec::new() +} diff --git a/crates/effects/src/obsolete.rs b/crates/effects/src/obsolete.rs new file mode 100644 index 00000000..331686c1 --- /dev/null +++ b/crates/effects/src/obsolete.rs @@ -0,0 +1,7 @@ +//! Obsolete-category effects. + +use crate::EffectSpec; + +pub fn specs() -> Vec { + Vec::new() +} diff --git a/crates/effects/src/perspective.rs b/crates/effects/src/perspective.rs index 2a032eca..014353fc 100644 --- a/crates/effects/src/perspective.rs +++ b/crates/effects/src/perspective.rs @@ -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: ¶ms, time: 0.0, layer_size: [img.width as f64, img.height as f64], seed: 1, adjustment: false }; + let ctx = + EffectCtx { params: ¶ms, 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 }) } diff --git a/crates/effects/src/perspective2.rs b/crates/effects/src/perspective2.rs new file mode 100644 index 00000000..60c61d02 --- /dev/null +++ b/crates/effects/src/perspective2.rs @@ -0,0 +1,7 @@ +//! Perspective effects, batch 2. + +use crate::EffectSpec; + +pub fn specs() -> Vec { + Vec::new() +} diff --git a/crates/effects/src/sim.rs b/crates/effects/src/sim.rs new file mode 100644 index 00000000..dc8d20ed --- /dev/null +++ b/crates/effects/src/sim.rs @@ -0,0 +1,7 @@ +//! Simulation effects (particles, physics, liquids). + +use crate::EffectSpec; + +pub fn specs() -> Vec { + Vec::new() +} diff --git a/crates/effects/src/sim2.rs b/crates/effects/src/sim2.rs new file mode 100644 index 00000000..d8d6ce96 --- /dev/null +++ b/crates/effects/src/sim2.rs @@ -0,0 +1,7 @@ +//! Simulation effects, part 2. + +use crate::EffectSpec; + +pub fn specs() -> Vec { + Vec::new() +} diff --git a/crates/effects/src/stylize2.rs b/crates/effects/src/stylize2.rs index 5eee2b4f..52e0b5c8 100644 --- a/crates/effects/src/stylize2.rs +++ b/crates/effects/src/stylize2.rs @@ -698,7 +698,7 @@ mod tests { for (k, v) in vals { params.values.insert(k.to_string(), v.clone()); } - let ctx = EffectCtx { params: ¶ms, time, layer_size: [img.width as f64, img.height as f64], seed: 1, adjustment: false }; + let ctx = EffectCtx { params: ¶ms, 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 }) } diff --git a/crates/effects/src/stylize3.rs b/crates/effects/src/stylize3.rs new file mode 100644 index 00000000..16a4399e --- /dev/null +++ b/crates/effects/src/stylize3.rs @@ -0,0 +1,7 @@ +//! Stylize effects, batch 3. + +use crate::EffectSpec; + +pub fn specs() -> Vec { + Vec::new() +} diff --git a/crates/effects/src/textfx.rs b/crates/effects/src/textfx.rs new file mode 100644 index 00000000..ad66f957 --- /dev/null +++ b/crates/effects/src/textfx.rs @@ -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 { + Vec::new() +} diff --git a/crates/effects/src/transition.rs b/crates/effects/src/transition.rs index b1ea6074..49e12cd5 100644 --- a/crates/effects/src/transition.rs +++ b/crates/effects/src/transition.rs @@ -486,7 +486,8 @@ mod tests { for (k, v) in set { params.values.insert(k.to_string(), v.clone()); } - let ctx = EffectCtx { params: ¶ms, time: 0.0, layer_size: [img.width as f64, img.height as f64], seed: 7, adjustment: false }; + let ctx = + EffectCtx { params: ¶ms, 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 } diff --git a/crates/effects/src/transition2.rs b/crates/effects/src/transition2.rs new file mode 100644 index 00000000..5f33c4b3 --- /dev/null +++ b/crates/effects/src/transition2.rs @@ -0,0 +1,7 @@ +//! Transition effects, batch 2. + +use crate::EffectSpec; + +pub fn specs() -> Vec { + Vec::new() +} diff --git a/crates/effects/src/util.rs b/crates/effects/src/util.rs index 346d7619..9249ecf9 100644 --- a/crates/effects/src/util.rs +++ b/crates/effects/src/util.rs @@ -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 { + inner: std::sync::Mutex>>, + cap: usize, +} + +struct SimEntry { + key: u64, + used: u64, + checkpoints: Vec<(u64, std::sync::Arc)>, +} + +/// Checkpoint every this many steps. +pub const SIM_CHECKPOINT: u64 = 15; + +impl SimCache { + pub const fn new(cap: usize) -> SimCache { + 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 { + 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)> = 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 = 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 = 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 = (0..37).map(|i| ((i * 7919) % 31) as f32).collect(); diff --git a/crates/effects/src/utility.rs b/crates/effects/src/utility.rs index 2c3eebd0..49b7ed36 100644 --- a/crates/effects/src/utility.rs +++ b/crates/effects/src/utility.rs @@ -363,7 +363,8 @@ mod tests { for (k, v) in vals { params.values.insert(k.to_string(), v.clone()); } - let ctx = EffectCtx { params: ¶ms, time: 0.0, layer_size: [img.width as f64, img.height as f64], seed: 1, adjustment: false }; + let ctx = + EffectCtx { params: ¶ms, 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 }) } diff --git a/crates/effects/src/utility2.rs b/crates/effects/src/utility2.rs new file mode 100644 index 00000000..fcd43649 --- /dev/null +++ b/crates/effects/src/utility2.rs @@ -0,0 +1,7 @@ +//! Utility effects, batch 2. + +use crate::EffectSpec; + +pub fn specs() -> Vec { + Vec::new() +} diff --git a/crates/media/src/pool.rs b/crates/media/src/pool.rs index 37787442..773f78e9 100644 --- a/crates/media/src/pool.rs +++ b/crates/media/src/pool.rs @@ -454,6 +454,13 @@ impl FootageSource for MediaPool { } } } + + fn audio(&self, _item: ItemId, footage: &Footage, t: Tick, frames: usize, rate: u32) -> Option> { + if !footage.has_audio || footage.missing { + return None; + } + Some(self.audio_samples(footage, t, frames, rate)) + } } fn lock(m: &Mutex) -> std::sync::MutexGuard<'_, T> { diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 83acd335..eeca6aae 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -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>; + /// `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> { + 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 { + 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> { + 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: ¶ms, time: lt.seconds(), layer_size, seed: g.uid as u32, adjustment }; + let ectx = EffectCtx { params: ¶ms, time: lt.seconds(), layer_size, seed: g.uid as u32, adjustment, env }; buf = effectcraft_effects::apply(spec, &ectx, buf); } buf diff --git a/crates/render/src/masks.rs b/crates/render/src/masks.rs index 30eea2b3..f2805065 100644 --- a/crates/render/src/masks.rs +++ b/crates/render/src/masks.rs @@ -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 { + 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 };