mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-30 21:16:52 -06:00
Merge main into #2300; Cineon conversion kept in footage_frame_raw (main routes proxies through footage_frame), frame-blend cache key hashes both premul colour and Cineon settings, spec strings combined
This commit is contained in:
@@ -184,8 +184,9 @@ impl<'a> Renderer<'a> {
|
||||
pub fn layer_aux(&self, ctx: &EvalCtx, layer: &Layer) -> Option<Arc<effectcraft_raster::AuxChannels>> {
|
||||
match &layer.source {
|
||||
LayerSource::Footage { item } => {
|
||||
let ItemKind::Footage(f) = &self.project.item(*item)?.kind else { return None };
|
||||
let a = self.footage.aux(*item, f, ctx.source_time(layer))?;
|
||||
let item = ctx.media_item(layer, *item);
|
||||
let ItemKind::Footage(f) = &self.project.item(item)?.kind else { return None };
|
||||
let a = self.footage.aux(item, f, ctx.source_time(layer))?;
|
||||
// Aux pixels per layer pixel (the layer is the footage at its nominal size). The
|
||||
// planes are only copied when that changes the scale.
|
||||
let scale = a.width as f64 / f.width as f64;
|
||||
@@ -332,8 +333,8 @@ impl<'a> Renderer<'a> {
|
||||
}
|
||||
|
||||
/// Draw a collapsed precomp layer into `canvas` on the CPU.
|
||||
pub fn draw_collapsed_cpu(&self, ctx: &EvalCtx<'a>, layer: &Layer, item: ItemId, canvas: &mut Image) {
|
||||
self.draw_collapsed(ctx, layer, item, canvas, false);
|
||||
pub fn draw_collapsed_cpu(&self, ctx: &EvalCtx<'a>, layer: &'a Layer, collapsed: &crate::Collapsed, canvas: &mut Image) {
|
||||
self.draw_collapsed(ctx, layer, collapsed, canvas, false);
|
||||
}
|
||||
|
||||
/// The layer's finished buffers (styles included) in the blending space.
|
||||
|
||||
+82
-23
@@ -4,10 +4,13 @@
|
||||
//! (Effect > Audio, see [`effectcraft_effects::audio_fx`]) run on each layer's audio before
|
||||
//! its levels; Backwards plays the layer's [in, out) span reversed.
|
||||
//!
|
||||
//! Callers mix in short blocks (one video frame, or an audio device buffer). Effects and levels
|
||||
//! are evaluated once per block. To make the result independent of block boundaries, layer
|
||||
//! effects are run from a cleared state over a pre-roll of earlier audio and only the block's
|
||||
//! tail is kept — so preview playback and export produce identical samples.
|
||||
//! Callers mix in short blocks (one video frame, an audio device buffer, or a second of export).
|
||||
//! Effect parameters are evaluated once per block. Animated Audio Levels, and animated effects
|
||||
//! that work sample by sample (Stereo Mixer), are evaluated on a grid of comp samples every
|
||||
//! millisecond instead, so keyframed fades ramp smoothly whatever the block size. To make the
|
||||
//! result independent of block boundaries, layer effects are run from a cleared state over a
|
||||
//! pre-roll of earlier audio and only the block's tail is kept — so preview playback and export
|
||||
//! produce identical samples.
|
||||
//!
|
||||
//! Time-stretched layers are resampled nearest-sample; reversed (negative stretch) layers are
|
||||
//! silent for now.
|
||||
@@ -85,6 +88,20 @@ fn units_ceil(t: Tick, rate: u32) -> i64 {
|
||||
(n.div_euclid(d) + i128::from(n.rem_euclid(d) != 0)) as i64
|
||||
}
|
||||
|
||||
/// Samples between evaluations of animated levels and per-sample effects: one millisecond, on
|
||||
/// a grid of comp sample indices so the result does not depend on where a block starts.
|
||||
fn param_step(rate: u32) -> i64 {
|
||||
i64::from(rate / 1000).max(1)
|
||||
}
|
||||
|
||||
/// Does any property of the group change over time?
|
||||
fn animated(g: &effectcraft_project::PropGroup) -> bool {
|
||||
g.children.iter().any(|c| match c {
|
||||
Node::Prop(p) => p.is_animated() || p.has_expression(),
|
||||
Node::Group(sub) => animated(sub),
|
||||
})
|
||||
}
|
||||
|
||||
fn sample_tick(i: i64, rate: u32) -> Tick {
|
||||
// Keep the requested sample when the rate does not divide the tick clock.
|
||||
let t = Tick::from_units(i, i64::from(rate));
|
||||
@@ -189,21 +206,70 @@ impl MixEnv<'_> {
|
||||
/// `i0 .. i0 + n`.
|
||||
fn layer_audio(&self, l: &Layer, i0: i64, n: usize) -> Vec<f32> {
|
||||
let t = sample_tick(i0, self.rate);
|
||||
let fx: Vec<(&str, Params)> = audio_effect_groups(l).map(|(id, g)| (id, self.params(l, g, t))).collect();
|
||||
let fx: Vec<(&str, &effectcraft_project::PropGroup, Params)> = audio_effect_groups(l).map(|(id, g)| (id, g, self.params(l, g, t))).collect();
|
||||
if fx.is_empty() {
|
||||
return self.layer_raw(l, i0, n, false);
|
||||
}
|
||||
let reversed = fx.iter().any(|(id, _)| audio_fx::reverses(id));
|
||||
let pre_s: f64 = fx.iter().map(|(id, p)| audio_fx::preroll(id, p)).sum();
|
||||
let reversed = fx.iter().any(|(id, _, _)| audio_fx::reverses(id));
|
||||
let pre_s: f64 = fx.iter().map(|(id, _, p)| audio_fx::preroll(id, p)).sum();
|
||||
let pre = (pre_s * self.rate as f64).ceil() as usize;
|
||||
let mut buf = self.layer_raw(l, i0 - pre as i64, n + pre, reversed);
|
||||
let start = l.layer_time(sample_tick(i0 - pre as i64, self.rate)).seconds();
|
||||
for (id, p) in &fx {
|
||||
audio_fx::process(id, p, &mut buf, self.rate, start);
|
||||
let b0 = i0 - pre as i64;
|
||||
let mut buf = self.layer_raw(l, b0, n + pre, reversed);
|
||||
let start = l.layer_time(sample_tick(b0, self.rate)).seconds();
|
||||
for (id, g, p) in &fx {
|
||||
if audio_fx::per_sample(id) && animated(g) {
|
||||
self.process_stepped(l, id, g, &mut buf, b0);
|
||||
} else {
|
||||
audio_fx::process(id, p, &mut buf, self.rate, start);
|
||||
}
|
||||
}
|
||||
buf.drain(..pre * 2);
|
||||
buf
|
||||
}
|
||||
|
||||
/// Run per-sample effect `id` over `buf` (comp samples from `b0`) in pieces of the parameter
|
||||
/// grid, each with the parameters at its first sample.
|
||||
fn process_stepped(&self, l: &Layer, id: &str, g: &effectcraft_project::PropGroup, buf: &mut [f32], b0: i64) {
|
||||
let step = param_step(self.rate);
|
||||
let frames = buf.len() / 2;
|
||||
let mut k = 0usize;
|
||||
while k < frames {
|
||||
let i = b0.saturating_add(k as i64);
|
||||
let next = i.div_euclid(step).saturating_add(1).saturating_mul(step);
|
||||
let end = usize::try_from(next.saturating_sub(b0)).unwrap_or(frames).clamp(k + 1, frames);
|
||||
let t = sample_tick(i, self.rate);
|
||||
if let Some(piece) = buf.get_mut(k * 2..end * 2) {
|
||||
audio_fx::process(id, &self.params(l, g, t), piece, self.rate, l.layer_time(t).seconds());
|
||||
}
|
||||
k = end;
|
||||
}
|
||||
}
|
||||
|
||||
/// Linear gains (left, right) of the layer's Audio Levels for comp samples `i0 .. i0 + n`.
|
||||
/// Animated levels are evaluated on the parameter grid and interpolated in dB in between.
|
||||
fn level_gains(&self, l: &Layer, i0: i64, n: usize) -> Vec<[f32; 2]> {
|
||||
let Some(pr) = l.props.sub("audio").and_then(|g| g.get("levels")) else { return vec![[1.0, 1.0]; n] };
|
||||
let db = |i: i64| self.ctx(sample_tick(i, self.rate)).value(l, pr).as_vec2();
|
||||
let gain = |[a, b]: [f64; 2]| [db_to_gain(a), db_to_gain(b)];
|
||||
if !pr.is_animated() && !pr.has_expression() {
|
||||
return vec![gain(db(i0)); n];
|
||||
}
|
||||
let step = param_step(self.rate);
|
||||
let end = i0.saturating_add(n as i64);
|
||||
let mut out = Vec::with_capacity(n);
|
||||
let mut k = i0.div_euclid(step).saturating_mul(step);
|
||||
let mut lo = db(k);
|
||||
while k < end {
|
||||
let hi = db(k.saturating_add(step));
|
||||
for i in k.max(i0)..k.saturating_add(step).min(end) {
|
||||
let f = (i - k) as f64 / step as f64;
|
||||
out.push(gain([lo[0] + (hi[0] - lo[0]) * f, lo[1] + (hi[1] - lo[1]) * f]));
|
||||
}
|
||||
k = k.saturating_add(step);
|
||||
lo = hi;
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared inputs for composition audio mixing, including nested compositions.
|
||||
@@ -234,25 +300,18 @@ fn mix_into(sources: &AudioSources<'_>, comp_id: ItemId, start: Tick, out: &mut
|
||||
if a >= b {
|
||||
continue;
|
||||
}
|
||||
let t_a = sample_tick(s0 + a as i64, rate);
|
||||
let (ll, lr) = levels(&env, l, t_a);
|
||||
if ll == 0.0 && lr == 0.0 {
|
||||
let gains = env.level_gains(l, s0 + a as i64, b - a);
|
||||
if gains.iter().all(|g| *g == [0.0, 0.0]) {
|
||||
continue;
|
||||
}
|
||||
let buf = env.layer_audio(l, s0 + a as i64, b - a);
|
||||
for (i, f) in buf.as_chunks::<2>().0.iter().enumerate() {
|
||||
out[(a + i) * 2] += f[0] * ll;
|
||||
out[(a + i) * 2 + 1] += f[1] * lr;
|
||||
for (i, (f, g)) in buf.as_chunks::<2>().0.iter().zip(&gains).enumerate() {
|
||||
out[(a + i) * 2] += f[0] * g[0];
|
||||
out[(a + i) * 2 + 1] += f[1] * g[1];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn levels(env: &MixEnv, l: &Layer, t: Tick) -> (f32, f32) {
|
||||
let Some(g) = l.props.sub("audio") else { return (1.0, 1.0) };
|
||||
let [a, b] = env.ctx(t).v2(l, g, "levels", [0.0, 0.0]);
|
||||
(db_to_gain(a), db_to_gain(b))
|
||||
}
|
||||
|
||||
/// A min/max peak summary of interleaved stereo audio for waveform drawing: one entry per
|
||||
/// `bin` sample frames, `[min L, max L, min R, max R]`.
|
||||
pub fn peak_summary(samples: &[f32], bin: usize) -> Vec<[f32; 4]> {
|
||||
|
||||
+41
-10
@@ -479,6 +479,19 @@ fn group_has(g: &PropGroup, match_id: &str) -> bool {
|
||||
|
||||
/// Feed every property of `g` that can change over time (keyframes or expression) as its value
|
||||
/// at the context time. The static structure is hashed separately.
|
||||
/// Mix the collapsed-instance overrides that target `id` into a cache key. They replace values
|
||||
/// without editing the layer, so a key without them would reuse another instance's buffer.
|
||||
fn hash_overrides(h: &mut KeyHasher, ctx: &EvalCtx, id: effectcraft_project::LayerId) {
|
||||
for o in ctx.property_overrides.iter().filter(|o| o.layer == id) {
|
||||
o.prop.hash(h);
|
||||
o.font.hash(h);
|
||||
hash_debug(h, &o.value);
|
||||
}
|
||||
for m in ctx.media_overrides.iter().filter(|m| m.layer == id) {
|
||||
m.item.hash(h);
|
||||
}
|
||||
}
|
||||
|
||||
fn hash_values(h: &mut KeyHasher, ctx: &EvalCtx, layer: &Layer, g: &PropGroup) {
|
||||
for c in &g.children {
|
||||
match c {
|
||||
@@ -497,10 +510,11 @@ fn hash_values(h: &mut KeyHasher, ctx: &EvalCtx, layer: &Layer, g: &PropGroup) {
|
||||
/// time, or `None` when the layer must not be cached (movie footage, precomps, cameras, adjustment
|
||||
/// layers…).
|
||||
pub fn layer_key(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, blur: bool) -> Option<u64> {
|
||||
// Time effects see property values at other times. Keyframes are part of the hashed
|
||||
// structure (and the layer time is folded in), but expressions may read other layers at
|
||||
// those times, which the key cannot see: such layers are not cached.
|
||||
if reads_other_times(layer) && layer.props.children.iter().any(|c| matches!(c, Node::Group(g) if g.match_id != "transform" && has_expression(g))) {
|
||||
// Time effects (and CC Particle World's Birth Rate history) see property values at other
|
||||
// times. Keyframes are part of the hashed structure (and the layer time is folded in), but
|
||||
// expressions may read other layers at those times, which the key cannot see: such layers
|
||||
// are not cached.
|
||||
if history_unkeyable(layer, None) {
|
||||
return None;
|
||||
}
|
||||
key_with(ctx, layer, scale, draft, blur, false)
|
||||
@@ -510,7 +524,7 @@ pub fn layer_key(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, blur: bo
|
||||
/// effects (see `Renderer::layer_input`). Unlike [`layer_key`], footage layers are cached here
|
||||
/// (keyed by item and source time), since Time effects read many neighbouring frames.
|
||||
pub fn input_key(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, blur: bool, effects: usize) -> Option<u64> {
|
||||
if layer.switches.adjustment {
|
||||
if layer.switches.adjustment || history_unkeyable(layer, Some(effects)) {
|
||||
return None;
|
||||
}
|
||||
let base = key_any(ctx, layer, scale, draft, blur, true, Some(effects))?;
|
||||
@@ -519,14 +533,26 @@ pub fn input_key(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, blur: bo
|
||||
Some(h.finish())
|
||||
}
|
||||
|
||||
/// Does the layer run an effect that reads the layer at other times (Echo, Timewarp…)?
|
||||
fn reads_other_times(layer: &Layer) -> bool {
|
||||
/// Does the layer run (among its first `effects` effects, all when `None`) an effect that reads
|
||||
/// the layer at other times (Echo, Timewarp…) or its own parameters' history (CC Particle
|
||||
/// World's Birth Rate, #1373)?
|
||||
fn reads_other_times(layer: &Layer, effects: Option<usize>) -> bool {
|
||||
layer.switches.effects
|
||||
&& layer.effects().is_some_and(|fx| {
|
||||
fx.groups().any(|g| g.enabled && matches!(&g.kind, effectcraft_project::GroupKind::Effect { effect } if effect.starts_with("ec.time.")))
|
||||
fx.groups().take(effects.unwrap_or(usize::MAX)).any(|g| {
|
||||
g.enabled
|
||||
&& matches!(&g.kind, effectcraft_project::GroupKind::Effect { effect }
|
||||
if effect.starts_with("ec.time.") || effectcraft_effects::reads_param_history(effect))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// The layer reads values at other times (see [`reads_other_times`]) and has expressions
|
||||
/// (outside Transform), whose results at those times the key can't see.
|
||||
fn history_unkeyable(layer: &Layer, effects: Option<usize>) -> bool {
|
||||
reads_other_times(layer, effects) && layer.props.children.iter().any(|c| matches!(c, Node::Group(g) if g.match_id != "transform" && has_expression(g)))
|
||||
}
|
||||
|
||||
/// A still image (no proxy): its layer is cached like a text or shape layer, so the GPU
|
||||
/// compositor uploads it once, not every frame (#1812: four 21120 px plates re-uploaded by every
|
||||
/// preview worker ran the GPU out of memory). Movies and sequences stay in the media pool only.
|
||||
@@ -592,8 +618,9 @@ fn key_any(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, blur: bool, fo
|
||||
hash_debug(&mut h, s);
|
||||
}
|
||||
LayerSource::Text | LayerSource::Shape => {}
|
||||
LayerSource::Footage { item } if footage || still_footage(ctx, *item) => {
|
||||
let it = ctx.project.item(*item)?;
|
||||
LayerSource::Footage { item } if footage || still_footage(ctx, ctx.media_item(layer, *item)) => {
|
||||
let item = ctx.media_item(layer, *item);
|
||||
let it = ctx.project.item(item)?;
|
||||
let ItemKind::Footage(f) = &it.kind else { return None };
|
||||
hash_debug(&mut h, f);
|
||||
// A proxy (and its Use Proxy switch) changes the pixels.
|
||||
@@ -616,6 +643,10 @@ fn key_any(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, blur: bool, fo
|
||||
scale.to_bits().hash(&mut h);
|
||||
draft.hash(&mut h);
|
||||
layer.id.hash(&mut h);
|
||||
// Collapsed-instance overrides replace values without editing the nested layer, so mix them
|
||||
// into the key: a Font override, effect parameter or any other source-affecting override
|
||||
// must not reuse another instance's processed buffer.
|
||||
hash_overrides(&mut h, ctx, layer.id);
|
||||
hash_debug(&mut h, &layer.source);
|
||||
// (Audio, Lock and Shy don't change pixels.)
|
||||
hash_debug(&mut h, &layer.switches.pixels());
|
||||
|
||||
+68
-4
@@ -3,11 +3,11 @@
|
||||
//! cyclic/over-budget graphs or expressions sampled at unknown times.
|
||||
use super::{KeyHasher, hash_debug, hash_values};
|
||||
use crate::EvalCtx;
|
||||
use effectcraft_project::{ItemId, ItemKind, Layer, LayerId, LayerSource, Node, PropGroup};
|
||||
use effectcraft_project::{ItemId, ItemKind, Layer, LayerId, LayerSource, LightKind, Node, PropGroup};
|
||||
use std::hash::Hash;
|
||||
|
||||
pub(super) fn hash(h: &mut KeyHasher, ctx: &EvalCtx, layer: &Layer, effects: Option<usize>) -> Option<()> {
|
||||
let mut deps = Dependencies { h, path: vec![(ctx.comp_id, layer.id)], remaining: 1024, other_times: super::reads_other_times(layer) };
|
||||
let mut deps = Dependencies { h, path: vec![(ctx.comp_id, layer.id)], remaining: 1024, other_times: super::reads_other_times(layer, None) };
|
||||
deps.effects(ctx, layer, effects)
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ struct Dependencies<'a> {
|
||||
impl Dependencies<'_> {
|
||||
fn effects(&mut self, ctx: &EvalCtx, layer: &Layer, limit: Option<usize>) -> Option<()> {
|
||||
let previous = self.other_times;
|
||||
self.other_times |= super::reads_other_times(layer);
|
||||
self.other_times |= super::reads_other_times(layer, None);
|
||||
if layer.switches.effects
|
||||
&& let Some(fx) = layer.effects()
|
||||
{
|
||||
@@ -63,19 +63,79 @@ impl Dependencies<'_> {
|
||||
Some(())
|
||||
}
|
||||
|
||||
/// What a referenced 3D model or primitive layer's solo render depends on: the layer itself
|
||||
/// (geometry, materials, switches, in/out), its animated values and transform at the time,
|
||||
/// the mesh item, the comp's camera (with its depth of field) and its lights at the time
|
||||
/// (the lights are off in draft 3D, and the renderer setting changes the pixels), the model's
|
||||
/// layer time while it plays an animation clip, and the in/out gate (outside the range the
|
||||
/// read is transparent). An Environment light's image can be any layer's render: with one on
|
||||
/// the read is not cached. Comp size and render scale are in the owner's key already.
|
||||
fn soloed_model(&mut self, ctx: &EvalCtx, layer: &Layer) -> Option<()> {
|
||||
self.remaining = self.remaining.checked_sub(1)?;
|
||||
// Expressions sampled at other times: not cacheable, as in the ordinary path.
|
||||
if (self.other_times || super::reads_other_times(layer, None)) && super::has_expression(&layer.props) {
|
||||
return None;
|
||||
}
|
||||
hash_debug(self.h, layer);
|
||||
hash_values(self.h, ctx, layer, &layer.props);
|
||||
hash_debug(self.h, &ctx.world_matrix(layer));
|
||||
if let Some(item) = layer.source.item() {
|
||||
hash_debug(self.h, &ctx.project.item(item));
|
||||
}
|
||||
// The solo render goes through the comp's camera and lights. The camera's depth of
|
||||
// field blurs it (focus, aperture, iris, highlights) — none of that is in the
|
||||
// projection hashed above.
|
||||
hash_debug(self.h, &ctx.camera());
|
||||
hash_debug(self.h, &crate::three_d::active_camera(ctx).dof);
|
||||
hash_debug(self.h, &crate::three_d::light::lights_at(ctx));
|
||||
hash_debug(self.h, &ctx.comp.draft_3d);
|
||||
hash_debug(self.h, &ctx.comp.renderer);
|
||||
// An Environment light's image lights Advanced 3D models (its source can be any
|
||||
// layer's render, or any environment layer) — too open-ended to hash: while one is
|
||||
// on (and lighting: Draft 3D lights nothing), don't cache the read.
|
||||
if ctx.comp.renderer == effectcraft_project::Renderer::Advanced3D
|
||||
&& !ctx.comp.draft_3d
|
||||
&& ctx.comp.layers.iter().any(|l| {
|
||||
l.is_light() && l.switches.video && l.is_active_at(ctx.time) && matches!(l.source, LayerSource::Light { kind: LightKind::Environment })
|
||||
})
|
||||
{
|
||||
return None;
|
||||
}
|
||||
// A model with an animation clip selected is posed at its layer time (Animation Speed
|
||||
// and Looping are static, hashed above): every time is its own entry. Without one the
|
||||
// pose is the same at every time: keep the static key.
|
||||
if layer.props.sub("geometryOptions").map_or(0, |g| ctx.e(layer, g, "animation")) != 0 {
|
||||
layer.layer_time(ctx.time).0.hash(self.h);
|
||||
}
|
||||
// Outside the layer's in/out range the read is transparent: fold the gate in, so a
|
||||
// static model renders once inside it and again when it leaves.
|
||||
layer.is_active_at(ctx.time).hash(self.h);
|
||||
Some(())
|
||||
}
|
||||
|
||||
fn layer(&mut self, ctx: &EvalCtx, layer: &Layer) -> Option<()> {
|
||||
let id = (ctx.comp_id, layer.id);
|
||||
if self.path.len() > crate::MAX_FX_DEPTH || self.path.contains(&id) {
|
||||
return None;
|
||||
}
|
||||
// A referenced 3D model or primitive layer renders through the comp's camera and lights
|
||||
// (an effect layer parameter reads it that way — Create 3D Layer Instance): hash what
|
||||
// moves its pixels, so the owner keeps a cache entry that changes when any of it does.
|
||||
if layer.source.is_model() {
|
||||
self.soloed_model(ctx, layer)?;
|
||||
return Some(());
|
||||
}
|
||||
self.remaining = self.remaining.checked_sub(1)?;
|
||||
if (self.other_times || super::reads_other_times(layer)) && super::has_expression(&layer.props) {
|
||||
if (self.other_times || super::reads_other_times(layer, None)) && super::has_expression(&layer.props) {
|
||||
return None;
|
||||
}
|
||||
self.path.push(id);
|
||||
// Include transforms/parents as well as pixels: selected-layer anchors and
|
||||
// continuously rasterized sources can depend on them.
|
||||
hash_debug(self.h, layer);
|
||||
// A referenced layer's static values can be overridden by the collapsed instance without
|
||||
// editing it (`hash_values` only sees animated ones), so the owner's key needs them too.
|
||||
super::hash_overrides(self.h, ctx, layer.id);
|
||||
hash_values(self.h, ctx, layer, &layer.props);
|
||||
hash_debug(self.h, &ctx.world_matrix(layer));
|
||||
// A cached owner never runs this layer's effects, so the comp camera and lights its
|
||||
@@ -88,6 +148,10 @@ impl Dependencies<'_> {
|
||||
}
|
||||
match layer.source {
|
||||
LayerSource::Solid { item } | LayerSource::Footage { item } | LayerSource::Comp { item } => {
|
||||
let item = match layer.source {
|
||||
LayerSource::Footage { .. } => ctx.media_item(layer, item),
|
||||
_ => item,
|
||||
};
|
||||
let source = ctx.project.item(item);
|
||||
hash_debug(self.h, &source);
|
||||
if let Some(source) = source {
|
||||
|
||||
@@ -721,7 +721,7 @@ pub fn read_layer_entry(data: &[u8]) -> Option<Buf> {
|
||||
/// The disk key of a segmentation: its chain key and scale.
|
||||
fn seg_key(key: u64, scale: f64) -> u128 {
|
||||
let mut h = Hash128::default();
|
||||
h.write(b"segmentation-v1");
|
||||
h.write(b"segmentation-v2");
|
||||
h.write_u64(key);
|
||||
h.write_u64(scale.to_bits());
|
||||
h.finish()
|
||||
@@ -841,7 +841,7 @@ fn decode_layer(p: &[u8]) -> Option<Buf> {
|
||||
// ---------------------------------------------------------------- content keys
|
||||
|
||||
/// Bump when rendering changes in a way that makes old disk entries wrong.
|
||||
const RENDER_VERSION: &str = "effectcraft-render-2";
|
||||
const RENDER_VERSION: &str = "effectcraft-render-3";
|
||||
|
||||
fn hash_debug(h: &mut Hash128, v: &impl std::fmt::Debug) {
|
||||
h.write(format!("{v:?}").as_bytes());
|
||||
@@ -903,16 +903,26 @@ pub fn footage_salt_with(project: &effectcraft_project::Project, virtual_id: Opt
|
||||
/// Content key of a composition's pixels: project render settings, the comp and everything it
|
||||
/// uses (nested comps, footage, solids, their proxies and Use Proxy switches, footage and proxy
|
||||
/// files' size and time). With expressions in play (which can read any comp) the whole project
|
||||
/// counts. Switches that don't change pixels (Audio, Lock, Shy, Hide Shy Layers) don't count.
|
||||
/// counts. Switches that don't change pixels (Audio, Lock, Shy, Hide Shy Layers) don't count, and
|
||||
/// neither do markers unless an expression mentions them.
|
||||
pub fn comp_content_key(project: &effectcraft_project::Project, comp: effectcraft_project::ItemId) -> u128 {
|
||||
comp_content_key_with(project, comp, None)
|
||||
}
|
||||
|
||||
/// [`comp_content_key`] with [`VirtualFileId`] for paths `std::fs::metadata` cannot describe.
|
||||
pub fn comp_content_key_with(project: &effectcraft_project::Project, comp: effectcraft_project::ItemId, virtual_id: Option<VirtualFileId<'_>>) -> u128 {
|
||||
comp_content_key_for_renderer(project, comp, virtual_id, RENDER_VERSION)
|
||||
}
|
||||
|
||||
fn comp_content_key_for_renderer(
|
||||
project: &effectcraft_project::Project,
|
||||
comp: effectcraft_project::ItemId,
|
||||
virtual_id: Option<VirtualFileId<'_>>,
|
||||
renderer: &str,
|
||||
) -> u128 {
|
||||
use effectcraft_project::{ItemKind, LayerSource};
|
||||
let mut h = Hash128::default();
|
||||
h.write(RENDER_VERSION.as_bytes());
|
||||
h.write(renderer.as_bytes());
|
||||
hash_debug(&mut h, &project.settings);
|
||||
let mut seen = std::collections::BTreeSet::new();
|
||||
let mut stack = vec![comp];
|
||||
@@ -933,13 +943,15 @@ pub fn comp_content_key_with(project: &effectcraft_project::Project, comp: effec
|
||||
}
|
||||
}
|
||||
}
|
||||
// Markers are pixels only to the expressions that read them (#1083).
|
||||
let markers = project.reads_markers();
|
||||
let ids: Vec<effectcraft_project::ItemId> = if expressions { project.items.keys().copied().collect() } else { seen.into_iter().collect() };
|
||||
for id in ids {
|
||||
let Some(it) = project.item(id) else { continue };
|
||||
h.write_u64(id.0);
|
||||
h.write(it.name.as_bytes());
|
||||
match &it.kind {
|
||||
ItemKind::Comp(c) => hash_debug(&mut h, &c.pixel_form()),
|
||||
ItemKind::Comp(c) => hash_debug(&mut h, &c.pixel_form_with(markers)),
|
||||
k => hash_debug(&mut h, k),
|
||||
}
|
||||
if let ItemKind::Footage(f) = &it.kind {
|
||||
@@ -1070,6 +1082,28 @@ mod tests {
|
||||
assert!(dc.get_frame(42).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn previous_renderer_frames_are_not_reused() {
|
||||
use effectcraft_project::{Comp, ItemKind, Project};
|
||||
use effectcraft_time::{FrameRate, Tick};
|
||||
|
||||
let mut project = Project::default();
|
||||
let comp = Comp::new(2, 2, FrameRate::FPS_30, Tick::from_seconds_f64(1.0));
|
||||
let id = project.add_item("Cached composition", effectcraft_color::Label::None, None, ItemKind::Comp(comp.into()));
|
||||
let key = frame_key(comp_content_key(&project, id), 0, 1000, 0);
|
||||
let previous_key = frame_key(comp_content_key_for_renderer(&project, id, None, "effectcraft-render-2"), 0, 1000, 0);
|
||||
let dir = tmpdir("previous-renderer");
|
||||
{
|
||||
let cache = DiskCache::open(&dir, 1 << 30).unwrap();
|
||||
cache.put_frame_now(previous_key, 2, 2, &[9; 16]);
|
||||
}
|
||||
let cache = DiskCache::open(&dir, 1 << 30).unwrap();
|
||||
assert_eq!(cache.get_frame(previous_key).unwrap().rgba, vec![9; 16]);
|
||||
assert!(cache.get_frame(key).is_none());
|
||||
cache.put_frame_now(key, 2, 2, &[17; 16]);
|
||||
assert_eq!(cache.get_frame(key).unwrap().rgba, vec![17; 16]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persists_across_restarts_and_clears() {
|
||||
let dir = tmpdir("persist");
|
||||
|
||||
+71
-11
@@ -39,17 +39,33 @@ pub struct EvalCtx<'a> {
|
||||
/// Footage frames for expressions that read rendered pixels (`sampleImage`): the renderer's
|
||||
/// own source while rendering; `None` renders footage as transparent.
|
||||
pub footage: Option<&'a dyn crate::FootageSource>,
|
||||
/// Property values overridden by the Essential Properties of a collapsed precomp instance.
|
||||
pub property_overrides: &'a [crate::PropertyOverride],
|
||||
/// Media Replacement values overridden by the Essential Properties of a collapsed precomp
|
||||
/// instance.
|
||||
pub media_overrides: &'a [crate::MediaOverride],
|
||||
}
|
||||
|
||||
impl<'a> EvalCtx<'a> {
|
||||
pub fn new(project: &'a Project, comp_id: ItemId, comp: &'a Comp, time: Tick) -> EvalCtx<'a> {
|
||||
EvalCtx { project, comp_id, comp, time, expr: None, footage: None }
|
||||
EvalCtx { project, comp_id, comp, time, expr: None, footage: None, property_overrides: &[], media_overrides: &[] }
|
||||
}
|
||||
pub fn at(&self, time: Tick) -> EvalCtx<'a> {
|
||||
EvalCtx { time, ..*self }
|
||||
}
|
||||
/// Value of a property at the context time (keyframes, then expression).
|
||||
pub fn value(&self, layer: &Layer, prop: &Property) -> Value {
|
||||
// A collapsed precomp instance's Essential Property override replaces the base value;
|
||||
// its expression (if any) still runs, and sees the overrides of this context.
|
||||
if let Some(o) = self.property_overrides.iter().rev().find(|o| o.layer == layer.id && o.prop == prop.uid) {
|
||||
let v = o.value(layer, prop, self.time);
|
||||
if prop.has_expression()
|
||||
&& let Some(h) = self.expr
|
||||
{
|
||||
return h.eval(self, layer, prop, &v).unwrap_or(v);
|
||||
}
|
||||
return v;
|
||||
}
|
||||
// Separated Position reads as the combination of X/Y/Z Position.
|
||||
if prop.match_id == "position"
|
||||
&& let Some(tr) = layer.transform()
|
||||
@@ -60,7 +76,13 @@ impl<'a> EvalCtx<'a> {
|
||||
let y = tr.get("positionY").map(|p| self.value(layer, p).as_f64()).unwrap_or(0.0);
|
||||
return Value::Vec3([self.value(layer, px).as_f64(), y, z]);
|
||||
}
|
||||
let lt = layer.layer_time(self.time);
|
||||
self.value_at_layer_time(layer, prop, layer.layer_time(self.time))
|
||||
}
|
||||
/// [`Self::value`] with the keyframes read at layer time `lt` exactly, rather than at the
|
||||
/// context time mapped through the layer's start and stretch (which rounds under a
|
||||
/// fractional stretch: a Hold key at `lt` could read its previous value). An expression
|
||||
/// still runs at the context time.
|
||||
pub fn value_at_layer_time(&self, layer: &Layer, prop: &Property, lt: Tick) -> Value {
|
||||
let v = prop.value_at(lt);
|
||||
if prop.has_expression()
|
||||
&& let Some(h) = self.expr
|
||||
@@ -139,13 +161,15 @@ impl<'a> EvalCtx<'a> {
|
||||
/// Source pixel aspect relative to the comp's: non-square footage (or solids, or precomps
|
||||
/// with another pixel aspect) is stretched horizontally by this factor in the comp.
|
||||
pub fn par_ratio(&self, layer: &Layer) -> f64 {
|
||||
let par = match &layer.source {
|
||||
LayerSource::Footage { item } | LayerSource::Solid { item } | LayerSource::Comp { item } => match self.project.item(*item).map(|i| &i.kind) {
|
||||
Some(effectcraft_project::ItemKind::Footage(f)) => f.pixel_aspect,
|
||||
Some(effectcraft_project::ItemKind::Solid(s)) => s.pixel_aspect,
|
||||
Some(effectcraft_project::ItemKind::Comp(c)) => c.pixel_aspect,
|
||||
_ => 1.0,
|
||||
},
|
||||
let item = match &layer.source {
|
||||
LayerSource::Footage { item } => Some(self.media_item(layer, *item)),
|
||||
LayerSource::Solid { item } | LayerSource::Comp { item } => Some(*item),
|
||||
_ => None,
|
||||
};
|
||||
let par = match item.and_then(|item| self.project.item(item)).map(|i| &i.kind) {
|
||||
Some(effectcraft_project::ItemKind::Footage(f)) => f.pixel_aspect,
|
||||
Some(effectcraft_project::ItemKind::Solid(s)) => s.pixel_aspect,
|
||||
Some(effectcraft_project::ItemKind::Comp(c)) => c.pixel_aspect,
|
||||
_ => 1.0,
|
||||
};
|
||||
let r = par / if self.comp.pixel_aspect > 0.0 { self.comp.pixel_aspect } else { 1.0 };
|
||||
@@ -231,9 +255,27 @@ impl<'a> EvalCtx<'a> {
|
||||
(Mat3([[m[0][0], m[0][1], m[0][3]], [m[1][0], m[1][1], m[1][3]], [0.0, 0.0, 1.0]]), 0.0)
|
||||
}
|
||||
}
|
||||
/// The footage item a layer uses, honouring this instance's Media Replacement override.
|
||||
pub fn media_item(&self, layer: &Layer, item: ItemId) -> ItemId {
|
||||
self.media_overrides.iter().rev().find(|m| m.layer == layer.id).map_or(item, |m| m.item)
|
||||
}
|
||||
|
||||
/// Size of a layer's source in layer pixels, honouring Media Replacement.
|
||||
pub fn source_size(&self, layer: &Layer) -> (u32, u32) {
|
||||
let item = match &layer.source {
|
||||
LayerSource::Footage { item } => Some(self.media_item(layer, *item)),
|
||||
LayerSource::Comp { item } | LayerSource::Solid { item } => Some(*item),
|
||||
_ => None,
|
||||
};
|
||||
match layer.source {
|
||||
LayerSource::Null => (100, 100),
|
||||
_ => item.and_then(|item| self.project.item(item)).and_then(|i| i.dimensions()).unwrap_or((0, 0)),
|
||||
}
|
||||
}
|
||||
|
||||
/// [`effect_bounds`] of a layer of this comp.
|
||||
pub fn effect_bounds(&self, layer: &Layer) -> ([f64; 2], [f64; 2]) {
|
||||
effect_bounds(self.project, self.comp, layer)
|
||||
effect_bounds_of(self.comp, self.source_size(layer))
|
||||
}
|
||||
/// Effect space (see [`effect_bounds`]) → comp matrix.
|
||||
pub fn effect_to_comp(&self, layer: &Layer) -> Mat3 {
|
||||
@@ -242,6 +284,20 @@ impl<'a> EvalCtx<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::PropertyOverride {
|
||||
fn value(&self, layer: &Layer, prop: &Property, time: Tick) -> Value {
|
||||
if self.font {
|
||||
// A Font control merges only the font into the target text, as
|
||||
// `essential::with_overrides` does.
|
||||
let mut value = prop.value_at(layer.layer_time(time));
|
||||
effectcraft_project::essential::merge_font(&mut value, &self.value);
|
||||
value
|
||||
} else {
|
||||
self.value.clone()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The protected regions of a comp (Responsive Design — Time markers), merged, sorted and
|
||||
/// clamped to the comp, in seconds.
|
||||
pub fn protected_regions(comp: &Comp) -> Vec<(f64, f64)> {
|
||||
@@ -315,7 +371,11 @@ pub fn responsive_source_time(comp: &Comp, stretch: f64, elapsed: f64) -> Option
|
||||
/// surrounds. Effects work in **effect space**, measured from that corner (After Effects' layer
|
||||
/// space for effect points: a shape layer's default effect point is its bounds' centre).
|
||||
pub fn effect_bounds(project: &Project, comp: &Comp, layer: &Layer) -> ([f64; 2], [f64; 2]) {
|
||||
match source_size(project, layer) {
|
||||
effect_bounds_of(comp, source_size(project, layer))
|
||||
}
|
||||
|
||||
fn effect_bounds_of(comp: &Comp, size: (u32, u32)) -> ([f64; 2], [f64; 2]) {
|
||||
match size {
|
||||
(0, _) => {
|
||||
let (w, h) = (comp.width as f64, comp.height as f64);
|
||||
([w, h], [-w / 2.0, -h / 2.0])
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
use super::footage::NoFootage;
|
||||
use super::opts::RenderOpts;
|
||||
use super::{
|
||||
Arc, EvalCtx, Footage, FrameBlend, FrameRate, Image, ItemId, Layer, LayerSource, Project, Renderer, TICKS_PER_SECOND, Tick, shapes, source_size, text,
|
||||
};
|
||||
use super::{Arc, EvalCtx, Footage, FrameBlend, FrameRate, Image, ItemId, Layer, LayerSource, Project, Renderer, TICKS_PER_SECOND, Tick, shapes, text};
|
||||
use rayon::prelude::*;
|
||||
|
||||
/// The layer's frame blending mode, if the comp's Enable Frame Blending switch is on.
|
||||
@@ -152,7 +150,7 @@ pub fn content_bounds(ctx: &EvalCtx, layer: &effectcraft_project::Layer) -> Opti
|
||||
LayerSource::Shape => layer.props.sub("contents").and_then(|c| shapes::content_bounds(ctx, layer, c)).map(|r| [r.x0, r.y0, r.x1, r.y1]),
|
||||
LayerSource::Camera | LayerSource::Light { .. } => None,
|
||||
_ => {
|
||||
let (w, h) = source_size(ctx.project, layer);
|
||||
let (w, h) = ctx.source_size(layer);
|
||||
(w > 0 && h > 0).then_some([0.0, 0.0, w as f64, h as f64])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,6 +72,14 @@ impl EffectHost for FxHost<'_, '_, '_> {
|
||||
return None;
|
||||
}
|
||||
let sub = self.r.nested();
|
||||
// A 3D model or primitive layer has no 2D source of its own: an effect referencing one
|
||||
// reads it as After Effects does — the layer rendered on its own through the comp's
|
||||
// camera (Create 3D Layer Instance's effect), in the layer's effect space (the
|
||||
// comp, centred on the layer's anchor).
|
||||
if other.source.is_model() {
|
||||
let buf = sub.solo_3d(self.ctx, other);
|
||||
return Some(self.pixels(other, buf, false));
|
||||
}
|
||||
// The cached layer buffer is shared; effects get their own copy.
|
||||
let buf = if masks_and_effects { (*sub.content_buf(self.ctx, other)?).clone() } else { sub.source(self.ctx, other)? };
|
||||
Some(self.pixels(other, buf, true))
|
||||
@@ -90,12 +98,13 @@ impl EffectHost for FxHost<'_, '_, '_> {
|
||||
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 };
|
||||
let item = self.ctx.media_item(other, *item);
|
||||
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)
|
||||
self.r.footage.audio(item, f, st, frames, rate)
|
||||
}
|
||||
|
||||
fn self_at(&self, layer_time: f64, effects: usize) -> Option<Buf> {
|
||||
@@ -118,6 +127,19 @@ impl EffectHost for FxHost<'_, '_, '_> {
|
||||
Some(effectcraft_effects::flatten_params(g, &mut |pr| ctx.value(self.layer, pr)))
|
||||
}
|
||||
|
||||
fn param_at(&self, effect_index: usize, id: &str, layer_time: f64) -> Option<effectcraft_keyframe::Value> {
|
||||
// Only the one property: a simulation samples it at every step. Its keyframes are read
|
||||
// at the requested layer time exactly (mapping it to comp time and back rounds under a
|
||||
// fractional stretch, so a Hold key there could read its previous value).
|
||||
let p = effect_param(self.layer, effect_index, id)?;
|
||||
let lt = Tick::from_seconds_f64(layer_time);
|
||||
Some(self.ctx.at(self.layer.comp_time(lt)).value_at_layer_time(self.layer, p, lt))
|
||||
}
|
||||
|
||||
fn param_is_static(&self, effect_index: usize, id: &str) -> bool {
|
||||
effect_param(self.layer, effect_index, id).is_some_and(|p| p.keys.is_empty() && !p.has_expression())
|
||||
}
|
||||
|
||||
fn comp_scene(&self) -> Option<effectcraft_effects::CompScene> {
|
||||
Some(comp_scene(self.ctx, self.layer)?.rebased(self.origin))
|
||||
}
|
||||
@@ -140,6 +162,26 @@ impl EffectHost for FxHost<'_, '_, '_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Parameter `id` (a flattened id: match ids joined by `/`) of effect `effect_index` in
|
||||
/// `layer`'s stack.
|
||||
fn effect_param<'a>(layer: &'a Layer, effect_index: usize, id: &str) -> Option<&'a effectcraft_project::Property> {
|
||||
let mut g = layer.effects()?.groups().nth(effect_index)?;
|
||||
let mut path = id.split('/').peekable();
|
||||
while let Some(m) = path.next() {
|
||||
if path.peek().is_none() {
|
||||
return g.children.iter().find_map(|c| match c {
|
||||
effectcraft_project::Node::Prop(p) if p.match_id == m => Some(p),
|
||||
_ => None,
|
||||
});
|
||||
}
|
||||
g = g.children.iter().find_map(|c| match c {
|
||||
effectcraft_project::Node::Group(sg) if sg.match_id == m => Some(sg),
|
||||
_ => None,
|
||||
})?;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The Essential Properties overrides of a precomp layer, evaluated at the context time.
|
||||
pub fn essential_overrides(ctx: &EvalCtx, layer: &Layer) -> Vec<effectcraft_project::essential::Override> {
|
||||
use effectcraft_project::essential;
|
||||
|
||||
@@ -2,7 +2,7 @@ use super::frames::adaptive_samples;
|
||||
use super::opts::{LayerTiming, Through};
|
||||
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,
|
||||
MatteKind, ParallelIterator, Quality, Region, Renderer, Sampling, Tick, WarpOpts, cache, color, composite_warp_changed, masks, styles, vec2,
|
||||
};
|
||||
|
||||
/// Blend policy for placing a layer, including optional output-depth quantization.
|
||||
@@ -52,7 +52,7 @@ impl<'a> Renderer<'a> {
|
||||
}
|
||||
}
|
||||
let t0 = web_time::Instant::now();
|
||||
let size = source_size(self.project, layer);
|
||||
let size = ctx.source_size(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 st = styles::render(ctx, layer, &content, layer_size);
|
||||
if let Some(t) = timing {
|
||||
|
||||
@@ -60,6 +60,7 @@ pub use layers::{Placement, StyledLayer};
|
||||
pub(crate) use opts::Collapse3d;
|
||||
pub use opts::{LayerTiming, RenderOpts};
|
||||
use opts::{MAX_THROUGH, Through};
|
||||
pub use pipeline::{Collapsed, MediaOverride, PropertyOverride};
|
||||
|
||||
pub struct Renderer<'a> {
|
||||
pub project: &'a Project,
|
||||
|
||||
+239
-19
@@ -5,7 +5,7 @@ use effectcraft_geom::{Mat3, vec2};
|
||||
use effectcraft_keyframe::Value;
|
||||
use effectcraft_path::{FillRule, Join};
|
||||
use effectcraft_project::{FeatherFalloff, GroupKind, Layer, MaskMode, MaskMotionBlur};
|
||||
use effectcraft_raster::{Mask, box_blur, gaussian_blur};
|
||||
use effectcraft_raster::{Mask, box_blur};
|
||||
use rayon::prelude::*;
|
||||
|
||||
use crate::eval::EvalCtx;
|
||||
@@ -60,40 +60,213 @@ fn coverage(ctx: &EvalCtx, layer: &Layer, g: &effectcraft_project::PropGroup, bu
|
||||
coverage_at(ctx, layer, g, buf)
|
||||
}
|
||||
|
||||
/// The largest feather taken, in buffer pixels (larger and non-finite values are clamped).
|
||||
const MAX_FEATHER_PX: f64 = 1.0e6;
|
||||
/// Smooth falloff (After Effects' default), measured on After Effects 2026 renders (feather 100,
|
||||
/// 300 and 1000; red value along rows, columns and the corner diagonal of a feathered rect mask):
|
||||
/// the edge is blurred separably by three box passes, each `2r + 1` pixels wide with
|
||||
/// `r = round(SMOOTH_BOX_RADIUS * feather)`. That is a quadratic B-spline reaching about
|
||||
/// 1.11 x feather either side of the path (finite support; a Gaussian of any width misses the
|
||||
/// measured profile by 3/255 or more, this kernel by less than 1/255).
|
||||
const SMOOTH_BOX_RADIUS: f64 = 0.37;
|
||||
/// Up to this box radius (buffer pixels) a feather is blurred at the buffer's resolution. A wider
|
||||
/// one is blurred on a grid coarser per axis, about `COARSE_RADIUS` grid pixels per box: a wide
|
||||
/// blur is smooth, so the coarse grid loses nothing visible, and the padding stays about
|
||||
/// 3 x `COARSE_RADIUS` grid pixels. (Box passes cost the same at any radius; the coarse grid
|
||||
/// bounds the padded area, i.e. memory.)
|
||||
const FULL_RES_RADIUS: usize = 48;
|
||||
const COARSE_RADIUS: usize = 32;
|
||||
/// The largest padded grid (pixels). A grid that would be bigger (a huge buffer) is coarsened
|
||||
/// (`grid`) until it fits, so the padding, and with it the feather of a mask edge outside the
|
||||
/// buffer, is never dropped.
|
||||
const MAX_GRID_PX: usize = 1 << 26;
|
||||
/// The coarsest factor `grid` tries before giving up on the padding (unreachable for any buffer
|
||||
/// that fits in memory: 1024 x 1024 buffer pixels per grid pixel).
|
||||
const MAX_COARSEN: usize = 1024;
|
||||
/// Padding for a variable-width feather, which is rasterised at the buffer's resolution.
|
||||
const MAX_VARIABLE_PAD: usize = 256;
|
||||
|
||||
/// One axis of the grid a feathered mask is rasterised and blurred on: `step` buffer pixels per
|
||||
/// grid pixel, `pad` grid pixels on each side of the buffer, and the box radii in grid pixels.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
struct Axis {
|
||||
step: usize,
|
||||
pad: usize,
|
||||
/// Radii of the box passes (Smooth: three; Linear: one, the others 0).
|
||||
radii: [usize; 3],
|
||||
}
|
||||
|
||||
impl Axis {
|
||||
/// `feather` in buffer pixels. The pad covers the blur's whole support, so the coverage of
|
||||
/// the path outside the buffer reaches into it as on an unbounded plane.
|
||||
fn new(feather: f64, linear: bool, full_res_only: bool) -> Axis {
|
||||
let f = if feather.is_finite() { feather.clamp(0.0, MAX_FEATHER_PX) } else { 0.0 };
|
||||
let none = Axis { step: 1, pad: 0, radii: [0; 3] };
|
||||
if f <= 0.0 {
|
||||
return none;
|
||||
}
|
||||
let cap = |pad: usize| if full_res_only { pad.min(MAX_VARIABLE_PAD) } else { pad };
|
||||
if linear {
|
||||
// Linear falloff (not measured against After Effects): one box pass, a straight ramp
|
||||
// `feather` pixels wide across the edge, as before. Not quite unchanged: a ramp wider
|
||||
// than about `4 x FULL_RES_RADIUS` pixels is now blurred on the coarse grid and
|
||||
// resampled bilinearly (a smooth ramp, so the difference is a rounding of the ramp).
|
||||
let r = (f / 2.0).round().max(1.0) as usize;
|
||||
let step = if full_res_only || r <= 2 * FULL_RES_RADIUS { 1 } else { r.div_ceil(2 * COARSE_RADIUS) };
|
||||
let rg = ((r as f64 / step as f64).round() as usize).max(1);
|
||||
return Axis { step, pad: cap(rg), radii: [rg, 0, 0] };
|
||||
}
|
||||
let r = (SMOOTH_BOX_RADIUS * f).round() as usize;
|
||||
if r == 0 {
|
||||
return none;
|
||||
}
|
||||
if full_res_only || r <= FULL_RES_RADIUS {
|
||||
return Axis { step: 1, pad: cap(3 * r + 1), radii: [r; 3] };
|
||||
}
|
||||
// On a coarse grid the three windows (2 rg + 1 grid pixels each) should still add up to
|
||||
// 3 (2r + 1) buffer pixels: of a few steps take the one that comes closest, and spread
|
||||
// the radii over the passes.
|
||||
let want = 3 * (2 * r + 1);
|
||||
let s0 = r.div_ceil(COARSE_RADIUS);
|
||||
let fit = |s: usize| -> (usize, usize) {
|
||||
let sum = ((want as f64 / s as f64 - 3.0) / 2.0).round().max(0.0) as usize;
|
||||
let got = (2 * sum + 3).saturating_mul(s);
|
||||
(sum, got.abs_diff(want))
|
||||
};
|
||||
let (step, sum) = (s0..=s0 + (s0 / 2).min(8)).map(|s| (s, fit(s))).min_by_key(|&(s, (_, e))| (e, s)).map_or((s0, fit(s0).0), |(s, (sum, _))| (s, sum));
|
||||
let radii = [0, 1, 2].map(|i| sum / 3 + usize::from(i < sum % 3));
|
||||
Axis { step, pad: sum + 1, radii }
|
||||
}
|
||||
|
||||
fn blurs(&self) -> bool {
|
||||
self.radii.iter().any(|&r| r > 0)
|
||||
}
|
||||
|
||||
/// The same blur on a grid `k` times coarser: radii and padding scaled down (a blurring
|
||||
/// axis keeps at least one grid pixel of radius), the padding still covering the support.
|
||||
fn coarser(self, k: usize) -> Axis {
|
||||
if k <= 1 {
|
||||
return self;
|
||||
}
|
||||
let blurs = self.blurs();
|
||||
let mut radii = self.radii.map(|r| (r as f64 / k as f64).round() as usize);
|
||||
if blurs && radii.iter().all(|&r| r == 0) {
|
||||
radii[0] = 1;
|
||||
}
|
||||
let support = radii.iter().fold(0usize, |a, &r| a.saturating_add(r)).saturating_add(1);
|
||||
let pad = if self.pad == 0 { 0 } else { self.pad.div_ceil(k).max(support) };
|
||||
Axis { step: self.step.saturating_mul(k), pad, radii }
|
||||
}
|
||||
|
||||
/// Grid pixels for `n` buffer pixels.
|
||||
fn len(&self, n: u32) -> Option<usize> {
|
||||
(n as usize).div_ceil(self.step).checked_add(self.pad.checked_mul(2)?)
|
||||
}
|
||||
}
|
||||
|
||||
/// Resamples a grid coverage (`gw` wide) back to the `w x h` buffer: bilinear between the grid
|
||||
/// pixel centres (an exact copy when both steps are 1).
|
||||
fn crop(grid: &Mask, ax: Axis, ay: Axis, w: u32, h: u32) -> Mask {
|
||||
let (gw, gh) = (grid.width as usize, grid.height as usize);
|
||||
let mut out = Mask::new(w, h, 0.0);
|
||||
let at = |x: usize, y: usize| grid.data.get(y.saturating_mul(gw).saturating_add(x)).copied().unwrap_or(0.0);
|
||||
// Grid coordinate of a buffer pixel centre, as (index, fraction) on that axis.
|
||||
let map = |i: u32, a: Axis, n: usize| -> (usize, usize, f32) {
|
||||
let u = (i as f64 + 0.5) / a.step as f64 + a.pad as f64 - 0.5;
|
||||
let u = u.clamp(0.0, n.saturating_sub(1) as f64);
|
||||
let i0 = u.floor() as usize;
|
||||
(i0, (i0 + 1).min(n.saturating_sub(1)), (u - i0 as f64) as f32)
|
||||
};
|
||||
let xs: Vec<(usize, usize, f32)> = (0..w).map(|x| map(x, ax, gw)).collect();
|
||||
out.data.par_chunks_mut(w.max(1) as usize).enumerate().for_each(|(y, row)| {
|
||||
let (y0, y1, fy) = map(y as u32, ay, gh);
|
||||
for (o, &(x0, x1, fx)) in row.iter_mut().zip(&xs) {
|
||||
let top = at(x0, y0) + (at(x1, y0) - at(x0, y0)) * fx;
|
||||
let bottom = at(x0, y1) + (at(x1, y1) - at(x0, y1)) * fx;
|
||||
*o = top + (bottom - top) * fy;
|
||||
}
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
fn coverage_at(ctx: &EvalCtx, layer: &Layer, g: &effectcraft_project::PropGroup, buf: &Buf) -> Option<Mask> {
|
||||
let Value::Path(sp) = ctx.group_value(layer, g, "path")? else { return None };
|
||||
let path = effectcraft_path::to_kurbo(&sp);
|
||||
let (w, h) = (buf.img.width, buf.img.height);
|
||||
let m = Mat3::translate(vec2(buf.offset[0], buf.offset[1])) * Mat3::scale(vec2(buf.scale, buf.scale));
|
||||
let exp = ctx.f(layer, g, "expansion", 0.0);
|
||||
let linear = matches!(g.kind, GroupKind::Mask { feather_falloff: FeatherFalloff::Linear, .. });
|
||||
let feather = ctx.v2(layer, g, "feather", [0.0; 2]);
|
||||
let feather = [feather[0] * buf.scale, feather[1] * buf.scale];
|
||||
Some(feathered(&sp, &m, (buf.img.width, buf.img.height), exp, feather, linear, MAX_GRID_PX))
|
||||
}
|
||||
|
||||
/// The padded grid for a `w x h` buffer: the axes as given, or coarsened together (by 2, 4, …)
|
||||
/// until the grid has at most `max_px` pixels. `None` without a blur (or, unreachably, when even
|
||||
/// the coarsest grid is too big).
|
||||
fn grid(w: u32, h: u32, ax: Axis, ay: Axis, max_px: usize) -> Option<(u32, u32, Axis, Axis)> {
|
||||
if !(ax.blurs() || ay.blurs()) {
|
||||
return None;
|
||||
}
|
||||
let mut k = 1usize;
|
||||
while k <= MAX_COARSEN {
|
||||
let (cx, cy) = (ax.coarser(k), ay.coarser(k));
|
||||
if let (Some(gw), Some(gh)) = (cx.len(w), cy.len(h))
|
||||
&& gw.checked_mul(gh).is_some_and(|n| n <= max_px)
|
||||
&& let (Ok(gw), Ok(gh)) = (u32::try_from(gw), u32::try_from(gh))
|
||||
{
|
||||
return Some((gw, gh, cx, cy));
|
||||
}
|
||||
k = k.saturating_mul(2);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Coverage of the mask path `sp` (layer -> buffer `m`) on a `w x h` buffer, expanded by `exp`
|
||||
/// and feathered by `feather` buffer pixels per axis, on a padded grid of at most `max_px` pixels.
|
||||
fn feathered(sp: &effectcraft_keyframe::ShapePath, m: &Mat3, (w, h): (u32, u32), exp: f64, feather: [f64; 2], linear: bool, max_px: usize) -> Mask {
|
||||
let path = effectcraft_path::to_kurbo(sp);
|
||||
// Variable-width feather (Mask Feather tool points): expansion and the per-point feather
|
||||
// are applied together from the signed distance to the path.
|
||||
let variable = !sp.feather.is_empty();
|
||||
// The feather blurs the mask's own edge, also where the path lies outside the layer (After
|
||||
// Effects: a feathered mask just below a layer still fades into it). So a feathered mask is
|
||||
// rasterised on a grid padded by the blur's reach, blurred there and cropped to the buffer.
|
||||
// A variable feather scales its point radii by the mean scale: it stays at full resolution
|
||||
// (unless the grid has to be coarsened to fit `max_px`).
|
||||
let (ax, ay) = (Axis::new(feather[0], linear, variable), Axis::new(feather[1], linear, variable));
|
||||
let blurred = ax.blurs() || ay.blurs();
|
||||
let grid = grid(w, h, ax, ay, max_px);
|
||||
// Without the grid (no blur, or, unreachably, a grid too big even at the coarsest step):
|
||||
// blurred within the buffer at its resolution.
|
||||
let unpadded = |f: f64| Axis { pad: 0, ..Axis::new(f, linear, true) };
|
||||
let (gw, gh, gx, gy) = grid.unwrap_or_else(|| (w, h, unpadded(feather[0]), unpadded(feather[1])));
|
||||
// Buffer pixels -> grid pixels.
|
||||
let to_grid = Mat3::scale(vec2(1.0 / gx.step as f64, 1.0 / gy.step as f64)) * Mat3::translate(vec2((gx.pad * gx.step) as f64, (gy.pad * gy.step) as f64));
|
||||
let mg = to_grid * *m;
|
||||
let mut cov = if variable {
|
||||
effectcraft_path::feather::variable_feather_coverage(&sp, &m, w, h, exp, linear)
|
||||
effectcraft_path::feather::variable_feather_coverage(sp, &mg, gw, gh, exp, linear)
|
||||
} else if exp.abs() > 0.01 {
|
||||
// Expansion offsets the outline itself (round joins). Offset Paths' geometry stays right
|
||||
// when the expansion exceeds a curvature radius, where a fill + stroke blend leaves a
|
||||
// ring around an uncovered middle (growing) or islands (shrinking).
|
||||
let grown = effectcraft_path::offset::offset_path(&path, exp, Join::Round, 4.0);
|
||||
effectcraft_path::fill_coverage(std::slice::from_ref(&grown), &m, w, h, FillRule::NonZero)
|
||||
effectcraft_path::fill_coverage(std::slice::from_ref(&grown), &mg, gw, gh, FillRule::NonZero)
|
||||
} else {
|
||||
effectcraft_path::fill_coverage(std::slice::from_ref(&path), &m, w, h, FillRule::NonZero)
|
||||
effectcraft_path::fill_coverage(std::slice::from_ref(&path), &mg, gw, gh, FillRule::NonZero)
|
||||
};
|
||||
let feather = ctx.v2(layer, g, "feather", [0.0; 2]);
|
||||
if feather[0] > 0.0 || feather[1] > 0.0 {
|
||||
let img = cov.to_image();
|
||||
let b = if linear {
|
||||
// One box pass: a straight ramp `feather` pixels wide across the edge.
|
||||
let r = |f: f64| ((f * buf.scale / 2.0).round() as usize).max(if f > 0.0 { 1 } else { 0 });
|
||||
box_blur(&img, r(feather[0]), r(feather[1]), 1, false)
|
||||
} else {
|
||||
gaussian_blur(&img, feather[0] * buf.scale / 2.0, feather[1] * buf.scale / 2.0, false)
|
||||
};
|
||||
cov = Mask::from_alpha(&b);
|
||||
if blurred {
|
||||
// Separable box passes (the corner of a feathered rect is the product of its edges).
|
||||
let mut img = cov.to_image();
|
||||
for (rx, ry) in gx.radii.into_iter().zip(gy.radii) {
|
||||
if rx > 0 || ry > 0 {
|
||||
img = box_blur(&img, rx, ry, 1, false);
|
||||
}
|
||||
}
|
||||
cov = Mask::from_alpha(&img);
|
||||
}
|
||||
Some(cov)
|
||||
if grid.is_some() {
|
||||
cov = crop(&cov, gx, gy, w, h);
|
||||
}
|
||||
cov
|
||||
}
|
||||
|
||||
/// The layer's enabled masks at the context time, flattened to polylines in layer space (for
|
||||
@@ -133,7 +306,9 @@ pub fn apply(ctx: &EvalCtx, layer: &Layer, buf: &mut Buf, blur: MaskBlur) -> boo
|
||||
if list.is_empty() {
|
||||
return false;
|
||||
}
|
||||
// Masks may extend past the source: grow the buffer to cover them when feather/expansion pads.
|
||||
// Masks may extend past the source: the buffer keeps its size (pixels outside the layer stay
|
||||
// empty), but a feathered mask is blurred on a padded grid, so the feather of an edge outside
|
||||
// the buffer still reaches into it (`coverage_at`).
|
||||
let n = (buf.img.width * buf.img.height) as usize;
|
||||
let first_sub = matches!(list[0].kind, GroupKind::Mask { mode: MaskMode::Subtract, .. });
|
||||
let mut acc = vec![if first_sub { 1.0f32 } else { 0.0 }; n];
|
||||
@@ -169,3 +344,48 @@ pub fn apply(ctx: &EvalCtx, layer: &Layer, buf: &mut Buf, blur: MaskBlur) -> boo
|
||||
buf.img.mul_mask(&acc);
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use effectcraft_keyframe::ShapePath;
|
||||
|
||||
/// CodeRabbit on #1874: an 8192 x 8192 layer with a 40 px Smooth feather needs a padded
|
||||
/// grid larger than `MAX_GRID_PX`; it used to fall back to the unpadded buffer, so an Add mask
|
||||
/// just outside the layer covered nothing and the whole layer vanished. The grid is now
|
||||
/// coarsened instead and keeps a pad that covers the blur.
|
||||
#[test]
|
||||
fn a_grid_too_big_is_coarsened_and_keeps_its_padding() {
|
||||
let a = Axis::new(40.0, false, false);
|
||||
assert!(a.len(8192).zip(a.len(8192)).is_some_and(|(x, y)| x * y > MAX_GRID_PX), "the full-resolution grid is over the limit");
|
||||
let (gw, gh, gx, gy) = grid(8192, 8192, a, a, MAX_GRID_PX).expect("a padded grid");
|
||||
assert!((gw as usize) * (gh as usize) <= MAX_GRID_PX);
|
||||
for g in [gx, gy] {
|
||||
assert!(g.step > 1 && g.pad > 0 && g.blurs(), "{g:?}");
|
||||
// The pad covers the blur's support (the three boxes' radii) in buffer pixels.
|
||||
assert!(g.pad * g.step >= g.radii.iter().sum::<usize>() * g.step, "{g:?}");
|
||||
}
|
||||
// A grid that fits is not touched.
|
||||
assert_eq!(grid(200, 100, a, a, MAX_GRID_PX).map(|g| g.2), Some(a));
|
||||
}
|
||||
|
||||
/// The same failure at a testable size: a 200 x 100 buffer, a 40 px feather and a grid limit
|
||||
/// just below the full-resolution padded grid. The Add mask lies just right of the buffer
|
||||
/// (x 210..260); its feather still fades into the layer, close to the full-resolution result.
|
||||
#[test]
|
||||
fn a_coarsened_grid_still_feathers_a_mask_outside_the_buffer() {
|
||||
let sp = ShapePath::rect([235.0, 50.0], 50.0, 200.0);
|
||||
let m = Mat3::IDENTITY;
|
||||
let full = feathered(&sp, &m, (200, 100), 0.0, [40.0, 40.0], false, MAX_GRID_PX);
|
||||
let a = Axis::new(40.0, false, false);
|
||||
let limit = a.len(200).zip(a.len(100)).map_or(0, |(x, y)| x * y) - 1;
|
||||
let coarse = feathered(&sp, &m, (200, 100), 0.0, [40.0, 40.0], false, limit);
|
||||
let at = |c: &Mask, x: usize, y: usize| c.data.get(y * 200 + x).copied().unwrap_or(-1.0);
|
||||
assert!(at(&full, 199, 50) > 0.25, "full resolution: {}", at(&full, 199, 50));
|
||||
assert!(at(&coarse, 199, 50) > 0.2, "the soft fade survives the coarse grid: {}", at(&coarse, 199, 50));
|
||||
for x in [150, 170, 180, 190, 199] {
|
||||
assert!((at(&coarse, x, 50) - at(&full, x, 50)).abs() < 0.05, "x {x}: coarse {} full {}", at(&coarse, x, 50), at(&full, x, 50));
|
||||
}
|
||||
assert!(at(&coarse, 100, 50) < 0.01, "far from the mask stays clear");
|
||||
}
|
||||
}
|
||||
|
||||
+150
-19
@@ -7,6 +7,8 @@ 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,
|
||||
};
|
||||
use effectcraft_keyframe::Value;
|
||||
use effectcraft_project::LayerId;
|
||||
|
||||
/// Coordinate space and stack range shared by CPU and accelerated effect evaluation.
|
||||
#[derive(Clone, Copy)]
|
||||
@@ -16,6 +18,88 @@ pub(crate) struct EffectStack {
|
||||
pub origin: [f64; 2],
|
||||
}
|
||||
|
||||
/// A property value an Essential Properties override gives a layer of a collapsed precomp.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PropertyOverride {
|
||||
pub(crate) layer: LayerId,
|
||||
pub(crate) prop: u64,
|
||||
pub(crate) value: Value,
|
||||
/// A Font control changes only the font of the target text, not its text.
|
||||
pub(crate) font: bool,
|
||||
}
|
||||
|
||||
/// A Media Replacement an Essential Properties override gives a footage layer of a collapsed
|
||||
/// precomp.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct MediaOverride {
|
||||
pub(crate) layer: LayerId,
|
||||
pub(crate) item: ItemId,
|
||||
}
|
||||
|
||||
impl MediaOverride {
|
||||
/// A Media Replacement of `layer`'s footage by `item`.
|
||||
pub fn new(layer: LayerId, item: ItemId) -> Self {
|
||||
MediaOverride { layer, item }
|
||||
}
|
||||
}
|
||||
|
||||
/// A collapsed precomp layer: the nested comp and the instance overrides that apply to it.
|
||||
pub struct Collapsed {
|
||||
pub item: ItemId,
|
||||
pub(crate) property_overrides: Vec<PropertyOverride>,
|
||||
pub(crate) media_overrides: Vec<MediaOverride>,
|
||||
}
|
||||
|
||||
/// The property and media overrides of a collapsed precomp layer. `None` when its Essential
|
||||
/// Properties group or the source comp's definitions are missing (the flattened path then
|
||||
/// preserves the old behaviour).
|
||||
fn collapsed_instance(ctx: &EvalCtx, layer: &Layer, item: ItemId) -> Option<(Vec<PropertyOverride>, Vec<MediaOverride>)> {
|
||||
use effectcraft_project::essential::{self, ControlType, EgKind};
|
||||
let over = essential::overridden(layer);
|
||||
if over.is_empty() {
|
||||
return Some((vec![], vec![]));
|
||||
}
|
||||
let g = essential::group(layer)?;
|
||||
let eg = ctx.project.comp(item)?.essential.as_ref()?;
|
||||
let mut property = vec![];
|
||||
let mut fonts: Vec<PropertyOverride> = vec![];
|
||||
let mut media = vec![];
|
||||
g.walk("", &mut |_, p| {
|
||||
if !over.contains(&p.uid) {
|
||||
return;
|
||||
}
|
||||
let Some(control) = essential::control_of(&p.match_id) else { return };
|
||||
let Some(ctl) = eg.resolve(control) else { return };
|
||||
let value = ctx.value(layer, p);
|
||||
match &ctl.kind {
|
||||
EgKind::Property { .. } => {
|
||||
let font = ctl.as_type == Some(ControlType::Font);
|
||||
for (layer, prop) in ctl.kind.targets() {
|
||||
let o = PropertyOverride { layer, prop, value: value.clone(), font };
|
||||
if font { fonts.push(o) } else { property.push(o) }
|
||||
}
|
||||
}
|
||||
EgKind::Media { layer: target } => {
|
||||
let Value::Scalar(id) = value else { return };
|
||||
let item = ItemId(id.max(0.0) as u64);
|
||||
if matches!(ctx.project.item(item).map(|i| &i.kind), Some(ItemKind::Footage(_))) {
|
||||
media.push(MediaOverride { layer: *target, item });
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
});
|
||||
// A Font control merges into the text its instance shows, after a whole-value (Text) override
|
||||
// of the same property, as `essential::with_overrides` does: keep one entry per property.
|
||||
for f in fonts {
|
||||
match property.iter_mut().rev().find(|o| o.layer == f.layer && o.prop == f.prop) {
|
||||
Some(o) => effectcraft_project::essential::merge_font(&mut o.value, &f.value),
|
||||
None => property.push(f),
|
||||
}
|
||||
}
|
||||
Some((property, media))
|
||||
}
|
||||
|
||||
impl<'a> Renderer<'a> {
|
||||
pub fn new(project: &'a Project, footage: &'a dyn FootageSource, opts: RenderOpts) -> Renderer<'a> {
|
||||
Renderer {
|
||||
@@ -122,8 +206,10 @@ impl<'a> Renderer<'a> {
|
||||
|
||||
/// The nested comp of a precomp layer whose transformations collapse into this comp: the
|
||||
/// Collapse Transformations switch is on and the layer has no masks, effects or layer
|
||||
/// styles (those force a flattened render of the precomp, as in After Effects).
|
||||
pub fn collapsed(&self, ctx: &EvalCtx, layer: &Layer) -> Option<ItemId> {
|
||||
/// styles (those force a flattened render of the precomp, as in After Effects). Essential
|
||||
/// Properties overrides travel with it and are applied as the nested layers are evaluated,
|
||||
/// so the nested comp is not rasterised at its frame.
|
||||
pub fn collapsed(&self, ctx: &EvalCtx, layer: &Layer) -> Option<Collapsed> {
|
||||
if !layer.switches.collapse || layer.switches.adjustment || self.depth > 12 {
|
||||
return None;
|
||||
}
|
||||
@@ -140,24 +226,37 @@ impl<'a> Renderer<'a> {
|
||||
if styles::active(ctx, layer) {
|
||||
return None;
|
||||
}
|
||||
// Instance overrides (Essential Properties) need the nested comp rendered on its own.
|
||||
if !effectcraft_project::essential::overridden(layer).is_empty() {
|
||||
// A composition proxy or frame blending is drawn by the flattened path (`precomp_source`);
|
||||
// collapsing renders the nested comp directly and would ignore both.
|
||||
if self.proxy_for(*item).is_some() || frame_blend_mode(ctx, layer) != FrameBlend::Off {
|
||||
return None;
|
||||
}
|
||||
Some(*item)
|
||||
let (property_overrides, media_overrides) = collapsed_instance(ctx, layer, *item)?;
|
||||
Some(Collapsed { item: *item, property_overrides, media_overrides })
|
||||
}
|
||||
|
||||
/// A renderer and context that draw a collapsed precomp's layers straight into this comp:
|
||||
/// their transforms are concatenated with the precomp layer's (one resample, no
|
||||
/// rasterisation at the precomp's bounds), their opacity multiplied by `opacity`, and their
|
||||
/// 3D layers use this comp's camera and lights.
|
||||
pub fn collapse_into(&self, ctx: &EvalCtx<'a>, layer: &'a Layer, item: ItemId, opacity: f32) -> Option<(Renderer<'a>, EvalCtx<'a>)> {
|
||||
pub fn collapse_into<'p>(&self, ctx: &EvalCtx<'a>, layer: &'a Layer, collapsed: &'p Collapsed, opacity: f32) -> Option<(Renderer<'p>, EvalCtx<'p>)>
|
||||
where
|
||||
'a: 'p,
|
||||
{
|
||||
let item = collapsed.item;
|
||||
let nc = self.project.comp(item)?;
|
||||
let t = ctx.nested_time(layer);
|
||||
if !nc.covers(t) {
|
||||
return None;
|
||||
}
|
||||
let nctx = EvalCtx { comp_id: item, comp: nc, time: t, ..*ctx };
|
||||
let nctx = EvalCtx {
|
||||
comp_id: item,
|
||||
comp: nc,
|
||||
time: t,
|
||||
property_overrides: &collapsed.property_overrides,
|
||||
media_overrides: &collapsed.media_overrides,
|
||||
..*ctx
|
||||
};
|
||||
let (l2c, _) = ctx.layer_to_comp(layer);
|
||||
let outer = Some(self.outer.map_or(l2c, |o| o * l2c));
|
||||
let world = ctx.world_matrix(layer);
|
||||
@@ -189,19 +288,19 @@ impl<'a> Renderer<'a> {
|
||||
/// that then takes the precomp layer's track matte, Preserve Transparency and blend mode.
|
||||
/// Returns the region left to re-quantise (nested layers drawn straight into `canvas`
|
||||
/// quantise their own footprints).
|
||||
pub(crate) fn draw_collapsed(&self, ctx: &EvalCtx<'a>, layer: &Layer, item: ItemId, canvas: &mut Image, blank: bool) -> Region {
|
||||
pub(crate) fn draw_collapsed(&self, ctx: &EvalCtx<'a>, layer: &'a Layer, collapsed: &Collapsed, canvas: &mut Image, blank: bool) -> Region {
|
||||
let opacity = ctx.opacity(layer) as f32 * self.opacity_mul;
|
||||
if opacity <= 0.0 {
|
||||
return Region::Empty;
|
||||
}
|
||||
let matte = layer.track_matte.is_some_and(|tm| tm.layer != layer.id && ctx.comp.layer(tm.layer).is_some());
|
||||
if layer.blend_mode == BlendMode::Normal && !matte && !layer.preserve_transparency {
|
||||
if let Some((sub, nctx)) = self.collapse_into(ctx, layer, item, opacity) {
|
||||
if let Some((sub, nctx)) = self.collapse_into(ctx, layer, collapsed, opacity) {
|
||||
sub.draw_comp(&nctx, canvas, blank);
|
||||
}
|
||||
return Region::Empty;
|
||||
}
|
||||
let Some((sub, nctx)) = self.collapse_into(ctx, layer, item, 1.0) else { return Region::Empty };
|
||||
let Some((sub, nctx)) = self.collapse_into(ctx, layer, collapsed, 1.0) else { return Region::Empty };
|
||||
let mut iso = Image::new(canvas.width, canvas.height);
|
||||
sub.draw_comp(&nctx, &mut iso, true);
|
||||
self.composite_iso(ctx, layer, iso, canvas, opacity);
|
||||
@@ -230,6 +329,7 @@ impl<'a> Renderer<'a> {
|
||||
let continuous = match layer.source {
|
||||
LayerSource::Text | LayerSource::Shape => true,
|
||||
LayerSource::Footage { item } => {
|
||||
let item = ctx.media_item(layer, item);
|
||||
layer.switches.collapse && matches!(self.project.item(item).map(|i| &i.kind), Some(ItemKind::Footage(f)) if is_vector_footage(f))
|
||||
}
|
||||
_ => false,
|
||||
@@ -267,7 +367,7 @@ impl<'a> Renderer<'a> {
|
||||
}
|
||||
|
||||
pub(crate) fn ctx(&self, comp_id: ItemId, comp: &'a Comp, t: Tick) -> EvalCtx<'a> {
|
||||
EvalCtx { project: self.project, comp_id, comp, time: t, expr: self.expr, footage: Some(self.footage) }
|
||||
EvalCtx { project: self.project, comp_id, comp, time: t, expr: self.expr, footage: Some(self.footage), property_overrides: &[], media_overrides: &[] }
|
||||
}
|
||||
|
||||
/// This renderer with another accelerator (or none): the passes of
|
||||
@@ -406,7 +506,7 @@ impl<'a> Renderer<'a> {
|
||||
blank = false;
|
||||
} else {
|
||||
let changed = match self.collapsed(ctx, visible[i]) {
|
||||
Some(item) => self.draw_collapsed(ctx, visible[i], item, canvas, blank),
|
||||
Some(collapsed) => self.draw_collapsed(ctx, visible[i], &collapsed, canvas, blank),
|
||||
None => self.draw_layer(ctx, visible[i], canvas, blank),
|
||||
};
|
||||
i += 1;
|
||||
@@ -594,8 +694,10 @@ impl<'a> Renderer<'a> {
|
||||
if self.project.comp_contains(*item, ctx.comp_id) {
|
||||
return None;
|
||||
}
|
||||
// A composition proxy (a rendered still or movie) stands in for the comp.
|
||||
// Nothing before the nested comp starts or after it ends.
|
||||
// A composition proxy (a rendered still or movie) stands in for the comp. It is
|
||||
// decoded like footage: its Separate Fields, Invert Alpha and Interpret As Linear
|
||||
// Light settings and the layer's Frame Blending apply. Nothing before the nested
|
||||
// comp starts or after it ends.
|
||||
let lt = ctx.nested_time(layer);
|
||||
if !self.project.comp(*item).is_some_and(|nc| nc.covers(lt)) {
|
||||
return None;
|
||||
@@ -603,7 +705,7 @@ impl<'a> Renderer<'a> {
|
||||
if let Some(pf) = self.proxy_for(*item)
|
||||
&& let Some(nc) = self.project.comp(*item)
|
||||
{
|
||||
let img = cineon_frame(self.footage.frame_with(*item, pf, lt, None, self.pipe.float_encoding())?, pf);
|
||||
let img = self.footage_frame(ctx, layer, *item, pf, lt, None)?;
|
||||
return Some(self.footage_buf(&img, pf, nc.width, nc.height));
|
||||
}
|
||||
// Essential Properties overrides render the nested comp with this instance's
|
||||
@@ -635,7 +737,8 @@ impl<'a> Renderer<'a> {
|
||||
Some(Buf { img: sub.comp_frame(*item, lt), offset: [0.0; 2], scale: bs })
|
||||
}
|
||||
LayerSource::Footage { item } => {
|
||||
let it = self.project.item(*item)?;
|
||||
let item = ctx.media_item(layer, *item);
|
||||
let it = self.project.item(item)?;
|
||||
let ItemKind::Footage(f) = &it.kind else { return None };
|
||||
if !f.has_video {
|
||||
return None;
|
||||
@@ -643,7 +746,7 @@ impl<'a> Renderer<'a> {
|
||||
// Continuously rasterised vector footage: drawn at the on-screen scale.
|
||||
if layer.switches.collapse && is_vector_footage(f) {
|
||||
let k = self.raster_scale(ctx, layer);
|
||||
if let Some(img) = self.footage.vector_frame(*item, f, k) {
|
||||
if let Some(img) = self.footage.vector_frame(item, f, k) {
|
||||
let mut buf = Buf { img: (*img).clone(), offset: [0.0; 2], scale: k };
|
||||
if let Some(c) = self.pipe.media_in(f) {
|
||||
color::convert(&mut buf.img, &c);
|
||||
@@ -653,10 +756,10 @@ impl<'a> Renderer<'a> {
|
||||
}
|
||||
let lt = ctx.source_time(layer);
|
||||
// The proxy is decoded instead, at its own size, and fills the footage's frame.
|
||||
let proxy = self.proxy_for(*item);
|
||||
let proxy = self.proxy_for(item);
|
||||
let pf = proxy.unwrap_or(f);
|
||||
let size = self.reduced_size(f.width, f.height).filter(|_| proxy.is_none());
|
||||
let img = self.footage_frame(ctx, layer, *item, pf, lt, size)?;
|
||||
let img = self.footage_frame(ctx, layer, item, pf, lt, size)?;
|
||||
Some(self.footage_buf(&img, pf, f.width, f.height))
|
||||
}
|
||||
LayerSource::Text => Some(self.authored(text::render(ctx, layer, self.raster_scale(ctx, layer)))),
|
||||
@@ -738,6 +841,10 @@ impl<'a> Renderer<'a> {
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
(0x00f8_a3e5_u64, item, i, (w as f32).to_bits(), mode as u8, f.frame_rate.num, f.frame_rate.den, &f.path, float).hash(&mut h);
|
||||
(f.alpha as u8).hash(&mut h);
|
||||
if f.alpha == effectcraft_project::AlphaMode::Premultiplied {
|
||||
f.premul_color.map(f32::to_bits).hash(&mut h);
|
||||
}
|
||||
if f.cineon_applies() {
|
||||
let c = &f.cineon;
|
||||
[c.converted_black, c.converted_white, c.black, c.white, c.gamma, c.rolloff].map(f32::to_bits).hash(&mut h);
|
||||
@@ -836,6 +943,30 @@ impl<'a> Renderer<'a> {
|
||||
self.source(ctx, layer)
|
||||
}
|
||||
|
||||
/// A 3D model or primitive layer rendered on its own, through the comp's camera: what an
|
||||
/// effect layer parameter reads when it references one, as After Effects, which renders 3D
|
||||
/// layers through the active camera. The comp's pixels at this render's scale; the layer's
|
||||
/// blend mode, track matte and video switch don't apply (a source read, not a composite —
|
||||
/// the solo clone of a 3D track matte, see `three_d::adv`).
|
||||
pub(crate) fn solo_3d(&self, ctx: &EvalCtx, layer: &Layer) -> Buf {
|
||||
let s = self.opts.scale;
|
||||
let w = ((ctx.comp.width as f64 * s).ceil() as u32).max(1);
|
||||
let h = ((ctx.comp.height as f64 * s).ceil() as u32).max(1);
|
||||
let mut canvas = Image::new(w, h);
|
||||
// Outside the layer's in/out points there is nothing to read: the copy is transparent
|
||||
// there, like any other layer reference (the 3D pipelines don't gate on the range).
|
||||
if !layer.is_active_at(ctx.time) {
|
||||
return Buf { img: canvas, offset: [0.0; 2], scale: s };
|
||||
}
|
||||
let mut solo = layer.clone();
|
||||
solo.track_matte = None;
|
||||
solo.blend_mode = effectcraft_color::BlendMode::Normal;
|
||||
solo.preserve_transparency = false;
|
||||
solo.switches.video = true;
|
||||
three_d::compose::draw_run(self, ctx, &[&solo], &mut canvas);
|
||||
Buf { img: canvas, offset: [0.0; 2], scale: s }
|
||||
}
|
||||
|
||||
/// Layer pixels after source → masks → effects (no layer styles).
|
||||
pub fn content_buf(&self, ctx: &EvalCtx, layer: &Layer) -> Option<Arc<Buf>> {
|
||||
self.content_buf_timed(ctx, layer, None).map(|(b, _)| b)
|
||||
|
||||
@@ -396,7 +396,10 @@ fn collect(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, arena: &mut Vec<B
|
||||
}
|
||||
for i in order {
|
||||
let m = step(i as f64 + offset);
|
||||
let t = if n > 1 { i as f32 / (n - 1) as f32 } else { 0.0 };
|
||||
// Copy i's opacity moves 1 / Copies of the way from Start to End Opacity per
|
||||
// copy, so the last copy stops one step short of End Opacity (After Effects
|
||||
// 2026, case 709: 3 copies, End Opacity 40 give 100 %, 80 %, 60 %).
|
||||
let t = if copies > 0.0 { (i as f64 / copies) as f32 } else { 0.0 };
|
||||
let opk = so + (eo - so) * t;
|
||||
let partial = if i + 1 == n && copies.fract() > 0.0 { copies.fract() as f32 } else { 1.0 };
|
||||
for d in &base_draws {
|
||||
|
||||
@@ -150,6 +150,28 @@ fn operators_below_repeater_act_on_copies() {
|
||||
assert!((coverage(&img) - 3.0 * 24.0 * 24.0).abs() < 30.0, "{}", coverage(&img));
|
||||
}
|
||||
|
||||
/// After Effects 2026 (case 709): a 400 × 200 rectangle with Fill, then a Repeater of 3 copies
|
||||
/// (Position 150, 60, Rotation 10, Scale 90 / 80, End Opacity 40) gives the copies 100 %, 80 %
|
||||
/// and 60 % opacity (EffectCraft gave 70 % and 40 %). The points are comp pixels where AE shows
|
||||
/// only the last copy, only the middle one, and both.
|
||||
#[test]
|
||||
fn repeater_copy_opacity_matches_after_effects() {
|
||||
let (p, cid) = shape_comp_sized(1920, 1080, |ids| {
|
||||
let mut rep = build::shape_repeater(ids, 3.0, [150.0, 60.0]);
|
||||
set(&mut rep, "transform/rotation", Value::Scalar(10.0));
|
||||
set(&mut rep, "transform/scale", Value::Vec2([90.0, 80.0]));
|
||||
set(&mut rep, "transform/endOpacity", Value::Scalar(40.0));
|
||||
let items = vec![build::shape_rect(ids, [400.0, 200.0], [0.0, 0.0], 0.0), build::shape_fill(ids, [0.0, 1.0, 0.0, 1.0]), rep];
|
||||
vec![build::shape_group(ids, "Group 1", items)]
|
||||
});
|
||||
let img = frame(&p, cid);
|
||||
// AE's green over its red layer, 153, 153, 204, 235 and 255 of 255.
|
||||
for (x, y, want) in [(1360, 700, 0.6), (1300, 620, 0.6), (1220, 560, 0.8), (1180, 630, 0.92), (1100, 600, 1.0)] {
|
||||
let a = alpha(&img, x, y);
|
||||
assert!((a - want).abs() < 2.0 / 255.0, "({x}, {y}): {a} vs AE {want}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeater_then_merge_unites_copies() {
|
||||
let (p, cid) = shape_comp(|ids| {
|
||||
|
||||
@@ -16,6 +16,7 @@ mod frame_blend;
|
||||
mod guides;
|
||||
mod mask_blur;
|
||||
mod mask_expansion;
|
||||
mod mask_feather;
|
||||
mod motion_tile;
|
||||
mod nested;
|
||||
mod paint;
|
||||
@@ -346,6 +347,208 @@ fn particle_world_follows_the_comp_camera() {
|
||||
assert!(b < a - 20.0, "panning the camera right moves the particles left: {a} -> {b}");
|
||||
}
|
||||
|
||||
/// A black solid with CC Particle World whose Birth Rate is 3 until layer time `cut`, then 0 a
|
||||
/// frame later (`None`: 3 throughout); the layer starts at comp time `start`, stretched `stretch` %.
|
||||
fn burst_scene(cut: Option<f64>, start: f64, stretch: f64) -> (Project, ItemId) {
|
||||
burst_scene_keys(cut, start, stretch, false)
|
||||
}
|
||||
|
||||
/// [`burst_scene`]; `hold`: Hold keys, 3 until `cut` and 0 from `cut` exactly.
|
||||
fn burst_scene_keys(cut: Option<f64>, start: f64, stretch: f64, hold: bool) -> (Project, ItemId) {
|
||||
let (mut p, cid, comp) = setup();
|
||||
let mut l = solid(&mut p, &comp, [0.0, 0.0, 0.0], 200, 100);
|
||||
let vals = [("birthRate", Value::Scalar(3.0)), ("longevity", Value::Scalar(1.0)), ("particle/particleType", Value::Enum(2))];
|
||||
add_effect(&mut p, &mut l, "ec.sim.ccparticleworld", [200.0, 100.0], &vals);
|
||||
if let Some(cut) = cut {
|
||||
let fx = l.props.sub_mut("effects").unwrap().children.iter_mut().find_map(|c| match c {
|
||||
effectcraft_project::Node::Group(g) => Some(g),
|
||||
_ => None,
|
||||
});
|
||||
let fx = fx.unwrap();
|
||||
let rate = fx.prop_mut("birthRate").unwrap();
|
||||
let key = |t: f64, v: f64| Keyframe::new(Tick::from_seconds_f64(t), Value::Scalar(v));
|
||||
rate.keys = if hold { vec![key(0.0, 3.0).hold(), key(cut, 0.0).hold()] } else { vec![key(0.0, 3.0), key(cut, 3.0), key(cut + 1.0 / 30.0, 0.0)] };
|
||||
}
|
||||
l.start_time = Tick::from_seconds_f64(start);
|
||||
l.in_point = l.start_time;
|
||||
l.out_point = Tick::from_seconds_f64(4.0);
|
||||
l.stretch = stretch;
|
||||
p.comp_mut(cid).unwrap().layers = vec![l];
|
||||
(p, cid)
|
||||
}
|
||||
|
||||
/// Total light in a frame.
|
||||
fn light(img: &crate::Image) -> f32 {
|
||||
img.data.iter().map(|p| p[0] + p[1] + p[2]).sum()
|
||||
}
|
||||
|
||||
/// #1373: a Birth Rate keyed down to 0 stops new births; the particles already born live on
|
||||
/// until their Longevity runs out (they vanished the frame the rate reached 0).
|
||||
#[test]
|
||||
fn particle_world_keeps_a_burst_after_birth_rate_drops_to_zero() {
|
||||
let (keyed, cid) = burst_scene(Some(0.5), 0.0, 100.0);
|
||||
let (constant, _) = burst_scene(None, 0.0, 100.0);
|
||||
let at = Tick::from_seconds_f64;
|
||||
assert_same(&render_cached(&keyed, cid, at(0.4), None), &render_cached(&constant, cid, at(0.4), None), "during the burst");
|
||||
let (k, c) = (light(&render_cached(&keyed, cid, at(0.9), None)), light(&render_cached(&constant, cid, at(0.9), None)));
|
||||
assert!(k > 1.0 && k < c, "the burst is still drawn, with no new births: {k} vs {c}");
|
||||
// Longevity 1 s (± 20 %): gone by 1.9 s.
|
||||
assert_eq!(light(&render_cached(&keyed, cid, at(1.9), None)), 0.0);
|
||||
}
|
||||
|
||||
/// The burst's history: moving an earlier key changes a later frame whose Birth Rate is the
|
||||
/// same (0), cached or not, and offset or stretched layers sample their own layer times.
|
||||
#[test]
|
||||
fn particle_world_birth_rate_history_with_the_layer_cache_and_time_mapping() {
|
||||
let at = Tick::from_seconds_f64;
|
||||
let (long, cid) = burst_scene(Some(0.5), 0.0, 100.0);
|
||||
let (short, _) = burst_scene(Some(0.25), 0.0, 100.0);
|
||||
let cache = crate::LayerCache::default();
|
||||
let a = render_cached(&long, cid, at(0.9), Some(&cache));
|
||||
let b = render_cached(&short, cid, at(0.9), Some(&cache));
|
||||
assert_same(&b, &render_cached(&short, cid, at(0.9), None), "cached render after an earlier key moved");
|
||||
assert!(light(&b) > 1.0 && light(&b) < light(&a), "a shorter burst: {} vs {}", light(&b), light(&a));
|
||||
let plain = render_cached(&long, cid, at(0.9), None);
|
||||
let (late, _) = burst_scene(Some(0.5), 0.5, 100.0);
|
||||
assert_same(&render_cached(&late, cid, at(1.4), None), &plain, "a layer starting at 0.5 s");
|
||||
let (slow, _) = burst_scene(Some(0.5), 0.0, 200.0);
|
||||
assert_same(&render_cached(&slow, cid, at(1.8), None), &plain, "a layer stretched 200 %");
|
||||
}
|
||||
|
||||
/// The Birth Rate history is read at exact layer times: under a fractional stretch, mapping a
|
||||
/// layer time to comp time and back rounds down a tick, so a Hold cut at 0.5 s read its
|
||||
/// previous value at the step starting there (six extra births).
|
||||
#[test]
|
||||
fn particle_world_reads_a_hold_cut_at_its_exact_layer_time_under_any_stretch() {
|
||||
use crate::{EffectHost, FxHost, NoFootage, RenderOpts, Renderer};
|
||||
let plain = {
|
||||
let (p, cid) = burst_scene_keys(Some(0.5), 0.0, 100.0, true);
|
||||
render_cached(&p, cid, Tick::from_seconds_f64(0.9), None)
|
||||
};
|
||||
assert!(light(&plain) > 1.0, "the burst is drawn");
|
||||
for stretch in [33.3, 133.33, 200.0] {
|
||||
let (p, cid) = burst_scene_keys(Some(0.5), 0.0, stretch, true);
|
||||
let c = p.comp(cid).unwrap();
|
||||
let l = &c.layers[0];
|
||||
let ctx = crate::eval::EvalCtx::new(&p, cid, c, Tick::ZERO);
|
||||
let renderer = Renderer::new(&p, &NoFootage, RenderOpts::default());
|
||||
let host = FxHost { r: &renderer, ctx: &ctx, layer: l, origin: [0.0; 2], index: Default::default(), original: None };
|
||||
let rate = |t: f64| host.param_at(0, "birthRate", t).unwrap().as_f64();
|
||||
assert_eq!((rate(0.5 - 1.0 / 60.0), rate(0.5)), (3.0, 0.0), "stretch {stretch} %");
|
||||
assert!(!host.param_is_static(0, "birthRate"));
|
||||
let t = l.comp_time(Tick::from_seconds_f64(0.9));
|
||||
assert_same(&render_cached(&p, cid, t, None), &plain, &format!("stretch {stretch} %"));
|
||||
}
|
||||
}
|
||||
|
||||
/// An expression that reads a Controller layer's Opacity (÷ 25) before layer time 0.5 s and is
|
||||
/// 0 after (a stand-in for the expression engine).
|
||||
struct ControllerRate;
|
||||
|
||||
impl crate::eval::ExprHost for ControllerRate {
|
||||
fn eval(&self, ctx: &crate::eval::EvalCtx, layer: &effectcraft_project::Layer, _: &effectcraft_project::Property, _: &Value) -> Result<Value, String> {
|
||||
if layer.layer_time(ctx.time).seconds() >= 0.5 {
|
||||
return Ok(Value::Scalar(0.0));
|
||||
}
|
||||
let c = ctx.comp.layers.iter().find(|l| l.name == "Controller").ok_or("no controller")?;
|
||||
let opacity = c.props.prop("transform/opacity").ok_or("no opacity")?;
|
||||
Ok(Value::Scalar(ctx.value(c, opacity).as_f64() / 25.0))
|
||||
}
|
||||
}
|
||||
|
||||
/// A Birth Rate expression reading another layer: changing that layer changes the history but
|
||||
/// not the current rate (0) nor this layer's properties, which is all the layer cache key sees.
|
||||
/// CC Particle World layers with expressions therefore aren't layer cached.
|
||||
#[test]
|
||||
fn particle_world_with_a_birth_rate_expression_is_not_served_from_the_layer_cache() {
|
||||
let scene = |opacity: f64| {
|
||||
let (mut p, cid) = burst_scene(None, 0.0, 100.0);
|
||||
let comp = p.comp(cid).unwrap().clone();
|
||||
let mut ctl = solid(&mut p, &comp, [0.0, 0.0, 0.0], 10, 10);
|
||||
ctl.name = "Controller".into();
|
||||
ctl.switches.video = false;
|
||||
ctl.props.prop_mut("transform/opacity").unwrap().value = Value::Scalar(opacity);
|
||||
let c = p.comp_mut(cid).unwrap();
|
||||
let fx = c.layers[0].props.sub_mut("effects").unwrap().children.iter_mut().find_map(|n| match n {
|
||||
effectcraft_project::Node::Group(g) => Some(g),
|
||||
_ => None,
|
||||
});
|
||||
fx.unwrap().prop_mut("birthRate").unwrap().expr = Some(effectcraft_project::Expression { text: "controller".into(), enabled: true });
|
||||
c.layers.push(ctl);
|
||||
(p, cid)
|
||||
};
|
||||
let render = |p: &Project, cid: ItemId, cache: Option<&crate::LayerCache>| {
|
||||
let mut r = crate::Renderer::new(p, &crate::NoFootage, crate::RenderOpts::default());
|
||||
r.expr = Some(&ControllerRate);
|
||||
r.cache = cache;
|
||||
r.comp_frame(cid, Tick::from_seconds_f64(0.9))
|
||||
};
|
||||
let ((many, cid), (few, _)) = (scene(75.0), scene(25.0));
|
||||
let cache = crate::LayerCache::default();
|
||||
let a = render(&many, cid, Some(&cache));
|
||||
let b = render(&few, cid, Some(&cache));
|
||||
assert_same(&b, &render(&few, cid, None), "cached render after the controller changed");
|
||||
assert!(light(&b) > 1.0 && light(&b) < light(&a), "fewer births: {} vs {}", light(&b), light(&a));
|
||||
}
|
||||
|
||||
/// A grey solid (CC Mr. Mercury shades its blobs from it) with effect `id` (CC Particle Systems
|
||||
/// II, CC Mr. Mercury): Birth Rate `rate`,
|
||||
/// or keyed 3 until 0.5 s and 0 a frame later (`None`); Longevity 1 s.
|
||||
fn system_scene(id: &str, rate: Option<f64>) -> (Project, ItemId) {
|
||||
let (mut p, cid, comp) = setup();
|
||||
let mut l = solid(&mut p, &comp, [0.5, 0.5, 0.5], 200, 100);
|
||||
let vals = [("birthRate", Value::Scalar(rate.unwrap_or(3.0))), ("longevity", Value::Scalar(1.0))];
|
||||
add_effect(&mut p, &mut l, id, [200.0, 100.0], &vals);
|
||||
if rate.is_none() {
|
||||
let fx = l.props.sub_mut("effects").unwrap().children.iter_mut().find_map(|c| match c {
|
||||
effectcraft_project::Node::Group(g) => Some(g),
|
||||
_ => None,
|
||||
});
|
||||
let key = |t: f64, v: f64| Keyframe::new(Tick::from_seconds_f64(t), Value::Scalar(v));
|
||||
fx.unwrap().prop_mut("birthRate").unwrap().keys = vec![key(0.0, 3.0), key(0.5, 3.0), key(0.5 + 1.0 / 30.0, 0.0)];
|
||||
}
|
||||
p.comp_mut(cid).unwrap().layers = vec![l];
|
||||
(p, cid)
|
||||
}
|
||||
|
||||
/// #1373 for CC Particle Systems II and CC Mr. Mercury through the renderer: a keyed burst
|
||||
/// draws as the constant rate during it, still draws after the rate drops to 0 (unlike a rate
|
||||
/// of 0 throughout), and draws as nothing born once Longevity has run out.
|
||||
#[test]
|
||||
fn particle_systems_ii_and_mr_mercury_keep_a_burst_after_birth_rate_drops_to_zero() {
|
||||
let at = Tick::from_seconds_f64;
|
||||
for id in ["ec.sim.ccparticlesystems2", "ec.sim.ccmrmercury"] {
|
||||
let (keyed, cid) = system_scene(id, None);
|
||||
let (constant, _) = system_scene(id, Some(3.0));
|
||||
let (none, _) = system_scene(id, Some(0.0));
|
||||
assert_same(&render_cached(&keyed, cid, at(0.4), None), &render_cached(&constant, cid, at(0.4), None), &format!("{id}: during the burst"));
|
||||
let (k, z) = (render_cached(&keyed, cid, at(0.9), None), render_cached(&none, cid, at(0.9), None));
|
||||
assert!((light(&k) - light(&z)).abs() > 1.0, "{id}: the burst is still drawn: {} vs {}", light(&k), light(&z));
|
||||
assert_same(&render_cached(&keyed, cid, at(1.9), None), &render_cached(&none, cid, at(1.9), None), &format!("{id}: gone after Longevity"));
|
||||
}
|
||||
}
|
||||
|
||||
/// A Birth Rate expression on any of the stepped particle systems keeps the layer out of the
|
||||
/// layer cache (its key can't see what the expression returned at earlier times); without one
|
||||
/// the layer is cached.
|
||||
#[test]
|
||||
fn stepped_particle_layers_with_a_birth_rate_expression_are_not_layer_cached() {
|
||||
for id in ["ec.sim.ccparticleworld", "ec.sim.ccparticlesystems2", "ec.sim.ccmrmercury"] {
|
||||
let (mut p, cid) = system_scene(id, Some(2.0));
|
||||
let key = |p: &Project| {
|
||||
let c = p.comp(cid).unwrap();
|
||||
crate::cache::layer_key(&crate::eval::EvalCtx::new(p, cid, c, Tick::from_seconds_f64(0.9)), &c.layers[0], 1.0, false, false)
|
||||
};
|
||||
assert!(key(&p).is_some(), "{id}: cached without an expression");
|
||||
let fx = p.comp_mut(cid).unwrap().layers[0].props.sub_mut("effects").unwrap().children.iter_mut().find_map(|c| match c {
|
||||
effectcraft_project::Node::Group(g) => Some(g),
|
||||
_ => None,
|
||||
});
|
||||
fx.unwrap().prop_mut("birthRate").unwrap().expr = Some(effectcraft_project::Expression { text: "time < 0.5 ? 3 : 0".into(), enabled: true });
|
||||
assert!(key(&p).is_none(), "{id}: not cached with a Birth Rate expression");
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------ layer cache
|
||||
|
||||
fn render_cached(p: &Project, cid: ItemId, t: Tick, cache: Option<&crate::LayerCache>) -> crate::Image {
|
||||
@@ -1073,3 +1276,194 @@ fn workers_rendering_a_layer_at_once_share_one_buffer() {
|
||||
assert!(bufs.iter().all(|b| std::sync::Arc::ptr_eq(b, &bufs[0])), "one shared buffer");
|
||||
assert_eq!(cache.stats().entries, 1);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------ 3D layer instances
|
||||
|
||||
/// A red triangle in the Z = 0 plane (the engine instance3d tests' glTF, buffer embedded as a
|
||||
/// data URI), parsed in memory: a FootageSource that knows one model item.
|
||||
const TRIANGLE: &str = r#"{
|
||||
"asset": {"version": "2.0"},
|
||||
"scenes": [{"nodes": [0]}],
|
||||
"nodes": [{"mesh": 0}],
|
||||
"meshes": [{"primitives": [{"attributes": {"POSITION": 0, "NORMAL": 1}, "material": 0}]}],
|
||||
"materials": [{"pbrMetallicRoughness": {"baseColorFactor": [1.0, 0.0, 0.0, 1.0]}}],
|
||||
"buffers": [{"byteLength": 72, "uri": "data:application/octet-stream;base64,AACAvwAAgL8AAAAAAACAPwAAgL8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/"}],
|
||||
"bufferViews": [
|
||||
{"buffer": 0, "byteOffset": 0, "byteLength": 36},
|
||||
{"buffer": 0, "byteOffset": 36, "byteLength": 36}
|
||||
],
|
||||
"accessors": [
|
||||
{"bufferView": 0, "componentType": 5126, "type": "VEC3", "count": 3, "min": [-1.0, -1.0, 0.0], "max": [1.0, 1.0, 0.0]},
|
||||
{"bufferView": 1, "componentType": 5126, "type": "VEC3", "count": 3}
|
||||
]
|
||||
}"#;
|
||||
|
||||
struct SoloModels;
|
||||
|
||||
impl crate::FootageSource for SoloModels {
|
||||
fn frame(&self, _: ItemId, _: &effectcraft_project::Footage, _: Tick) -> Option<std::sync::Arc<crate::Image>> {
|
||||
None
|
||||
}
|
||||
fn model(&self, _: ItemId, f: &effectcraft_project::Footage) -> Option<std::sync::Arc<effectcraft_model::Model>> {
|
||||
effectcraft_model::load(&f.path, TRIANGLE.as_bytes(), &|_| None).ok().map(std::sync::Arc::new)
|
||||
}
|
||||
}
|
||||
|
||||
/// A red triangle 3D model, a camera panned right by `pan` px and, on top of the stack, the
|
||||
/// copy: a solid with a 3D Layer Instance effect referencing the model.
|
||||
fn instance_scene(pan: f64) -> (Project, ItemId) {
|
||||
let (mut p, cid, comp) = setup();
|
||||
// Models are Advanced 3D (layer.newModel switches a Classic comp over).
|
||||
p.comp_mut(cid).unwrap().renderer = effectcraft_project::Renderer::Advanced3D;
|
||||
let mid = p.add_item(
|
||||
"Triangle",
|
||||
Label::Red,
|
||||
None,
|
||||
ItemKind::Footage(effectcraft_project::Footage {
|
||||
path: "Triangle.gltf".into(),
|
||||
kind: effectcraft_project::FootageKind::Model,
|
||||
frame_rate: FrameRate::FPS_30,
|
||||
codec: "glTF".into(),
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
let model = build::layer(&mut p, &comp, "Triangle", LayerSource::Model { item: mid }, (200, 100), None);
|
||||
let mut cam = build::layer(&mut p, &comp, "Camera", LayerSource::Camera, (200, 100), None);
|
||||
for path in ["transform/position", "transform/poi"] {
|
||||
let pr = cam.props.prop_mut(path).unwrap();
|
||||
let Value::Vec3(v) = pr.value else { panic!("{path}: {:?}", pr.value) };
|
||||
pr.value = Value::Vec3([v[0] + pan, v[1], v[2]]);
|
||||
}
|
||||
let mut owner = solid(&mut p, &comp, [1.0, 1.0, 1.0], 200, 100);
|
||||
add_effect_200(&mut p, &mut owner, "ec.3d.instance3d", &[("layer", Value::Layer(Some(model.id.0)))]);
|
||||
p.comp_mut(cid).unwrap().layers = vec![cam, owner, model];
|
||||
(p, cid)
|
||||
}
|
||||
|
||||
fn render_models(p: &Project, cid: ItemId, t: Tick, cache: Option<&crate::LayerCache>) -> crate::Image {
|
||||
let mut r = crate::Renderer::new(p, &SoloModels, crate::RenderOpts::default());
|
||||
r.cache = cache;
|
||||
r.comp_frame(cid, t)
|
||||
}
|
||||
|
||||
/// The coverage-weighted centre of the triangle (x): where its drawn pixels are, lit or not.
|
||||
fn cover_x(img: &crate::Image) -> f32 {
|
||||
let (mut m, mut sx) = (0.0, 0.0);
|
||||
for y in 0..img.height {
|
||||
for x in 0..img.width {
|
||||
let a = img.get(x as i64, y as i64)[3];
|
||||
m += a;
|
||||
sx += a * x as f32;
|
||||
}
|
||||
}
|
||||
assert!(m > 1.0, "the triangle is drawn");
|
||||
sx / m
|
||||
}
|
||||
|
||||
/// Create 3D Layer Instance: the copy (the owner) is cached, and its key covers what the
|
||||
/// referenced model's solo render depends on — the camera, and the model's in/out gate.
|
||||
#[test]
|
||||
fn an_instance_of_a_model_is_cached_and_follows_the_camera() {
|
||||
let t = Tick::from_seconds_f64(1.0);
|
||||
let (p, cid) = instance_scene(0.0);
|
||||
let (q, _) = instance_scene(40.0);
|
||||
let key_at = |p: &Project, t: Tick| {
|
||||
let c = p.comp(cid).unwrap();
|
||||
crate::cache::layer_key(&crate::EvalCtx::new(p, cid, c, t), &c.layers[1], 1.0, false, false).unwrap()
|
||||
};
|
||||
assert_ne!(key_at(&p, t), key_at(&q, t), "moving the camera changes the copy's key");
|
||||
// The same layer at a later time, outside its in/out range: the read turns transparent.
|
||||
let mut out = p.clone();
|
||||
out.comp_mut(cid).unwrap().layers[2].out_point = Tick::from_seconds_f64(0.5);
|
||||
assert_ne!(key_at(&out, Tick::from_seconds_f64(0.25)), key_at(&out, t), "leaving the model's in/out range changes the copy's key");
|
||||
// Two renders of one time: the second is served from the cache.
|
||||
let cache = crate::LayerCache::default();
|
||||
let here = render_models(&p, cid, t, Some(&cache));
|
||||
let again = render_models(&p, cid, t, Some(&cache));
|
||||
assert_same(&again, &render_models(&p, cid, t, None), "the second render is the cached one");
|
||||
assert!(cache.stats().hits > 0, "the copy was served from the cache");
|
||||
// Panning the camera: the copy (and the model) move with it.
|
||||
let panned = render_models(&q, cid, t, Some(&cache));
|
||||
assert_same(&panned, &render_models(&q, cid, t, None), "cached render after the camera moved");
|
||||
let (a, b) = (cover_x(&here), cover_x(&panned));
|
||||
assert!(b < a - 20.0, "panning the camera right moves the copy left: {a} -> {b}");
|
||||
}
|
||||
|
||||
/// The camera's depth of field blurs the solo render (focus, aperture, iris, highlights) —
|
||||
/// none of it is in the projection matrix: changing any of it must change the copy's key, or
|
||||
/// it would serve the un-blurred pixels.
|
||||
#[test]
|
||||
fn the_copy_key_covers_the_cameras_depth_of_field() {
|
||||
let t = Tick::from_seconds_f64(1.0);
|
||||
let (p, cid) = instance_scene(0.0);
|
||||
let set_dof = |p: &Project, on: bool, focus: f64, aperture: f64| {
|
||||
let mut q = p.clone();
|
||||
let cam = &mut q.comp_mut(cid).unwrap().layers[0];
|
||||
for (path, v) in
|
||||
[("cameraOptions/dof", Value::Bool(on)), ("cameraOptions/focusDistance", Value::Scalar(focus)), ("cameraOptions/aperture", Value::Scalar(aperture))]
|
||||
{
|
||||
cam.props.prop_mut(path).unwrap().value = v;
|
||||
}
|
||||
q
|
||||
};
|
||||
let key_at = |p: &Project| {
|
||||
let c = p.comp(cid).unwrap();
|
||||
crate::cache::layer_key(&crate::EvalCtx::new(p, cid, c, t), &c.layers[1], 1.0, false, false).unwrap()
|
||||
};
|
||||
let ka = key_at(&set_dof(&p, true, 500.0, 20.0));
|
||||
let kb = key_at(&set_dof(&p, true, 500.0, 40.0)); // aperture
|
||||
let kc = key_at(&set_dof(&p, true, 700.0, 20.0)); // focus
|
||||
let kd = key_at(&set_dof(&p, false, 500.0, 20.0)); // the switch off
|
||||
assert!(ka != kb && ka != kc && ka != kd && kb != kc, "aperture, focus and the switch each change the key");
|
||||
}
|
||||
|
||||
/// A model with an animation clip selected is posed at its layer time: every time is its own
|
||||
/// entry. Without one (the default) the pose is the same at every time: one key inside the
|
||||
/// in/out range, as before.
|
||||
#[test]
|
||||
fn the_copy_key_covers_an_animated_models_layer_time() {
|
||||
let (p, cid) = instance_scene(0.0);
|
||||
let (t0, t1) = (Tick::from_seconds_f64(0.5), Tick::from_seconds_f64(1.0));
|
||||
let key_at = |p: &Project, t: Tick| {
|
||||
let c = p.comp(cid).unwrap();
|
||||
crate::cache::layer_key(&crate::EvalCtx::new(p, cid, c, t), &c.layers[1], 1.0, false, false).unwrap()
|
||||
};
|
||||
assert_eq!(key_at(&p, t0), key_at(&p, t1), "a model without a clip keeps one key across times");
|
||||
let mut q = p.clone();
|
||||
q.comp_mut(cid).unwrap().layers[2].props.prop_mut("geometryOptions/animation").unwrap().value = Value::Enum(1);
|
||||
assert_ne!(key_at(&q, t0), key_at(&q, t1), "an animated model's key follows its layer time");
|
||||
// Two times and back: each is rendered fresh, none served another time's pixels.
|
||||
let cache = crate::LayerCache::default();
|
||||
let first = render_models(&q, cid, t0, Some(&cache));
|
||||
let second = render_models(&q, cid, t1, Some(&cache));
|
||||
assert_eq!(cache.stats().hits, 0, "the second time is not served the first time's pixels");
|
||||
assert_same(&second, &render_models(&q, cid, t1, None), "the second time is a fresh render");
|
||||
let again = render_models(&q, cid, t0, Some(&cache));
|
||||
assert_eq!(cache.stats().hits, 1, "the first time is served on the way back");
|
||||
assert_same(&again, &first, "the way back is the first time's pixels");
|
||||
}
|
||||
|
||||
/// An Environment light's image lights Advanced 3D models — its source can be any layer's
|
||||
/// render: with one on, the read is not cached, so changes to it cannot reuse stale pixels.
|
||||
#[test]
|
||||
fn an_environment_light_makes_the_copy_uncached() {
|
||||
let (p, cid) = instance_scene(0.0);
|
||||
let mut q = p.clone();
|
||||
let comp = q.comp(cid).unwrap().clone();
|
||||
let env = build::layer(&mut q, &comp, "Env", LayerSource::Light { kind: effectcraft_project::LightKind::Environment }, (200, 100), None);
|
||||
q.comp_mut(cid).unwrap().layers.insert(0, env);
|
||||
let t = Tick::from_seconds_f64(1.0);
|
||||
let key_at = |p: &Project| {
|
||||
let c = p.comp(cid).unwrap();
|
||||
crate::cache::layer_key(&crate::EvalCtx::new(p, cid, c, t), &c.layers[2], 1.0, false, false)
|
||||
};
|
||||
assert!(key_at(&q).is_none(), "with an Environment light the read is not cached");
|
||||
let cache = crate::LayerCache::default();
|
||||
let a = render_models(&q, cid, t, Some(&cache));
|
||||
let b = render_models(&q, cid, t, Some(&cache));
|
||||
assert_same(&a, &b, "two fresh renders agree");
|
||||
assert_eq!(cache.stats().hits, 0, "nothing is served from the cache");
|
||||
// The light off (not lighting): the cache comes back.
|
||||
q.comp_mut(cid).unwrap().layers[0].switches.video = false;
|
||||
assert!(key_at(&q).is_some(), "the light off, the read is cached again");
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use effectcraft_keyframe::Value;
|
||||
use effectcraft_keyframe::{Keyframe, Value};
|
||||
use effectcraft_project::build::{self, Ids};
|
||||
use effectcraft_project::{Comp, Footage, FootageKind, ItemId, ItemKind, LayerSource, Project, Solid};
|
||||
use effectcraft_time::{FrameRate, Tick};
|
||||
@@ -148,6 +148,42 @@ fn stereo_mixer_pans() {
|
||||
assert!(m.chunks(2).all(|f| f[0].abs() < 1e-6 && (f[1] - 0.5).abs() < 1e-6), "{:?}", &m[..4]);
|
||||
}
|
||||
|
||||
/// Key `prop` of layer 0 (`effect`: in its first effect, else in Audio) from `a` at 0 s to `b` at 1 s.
|
||||
fn key(p: &mut Project, cid: ItemId, effect: bool, prop: &str, a: Value, b: Value) {
|
||||
let l = &mut p.comp_mut(cid).unwrap().layers[0];
|
||||
let g = if effect { l.props.sub_mut("effects").unwrap().children[0].as_group_mut().unwrap() } else { l.props.sub_mut("audio").unwrap() };
|
||||
g.get_mut(prop).unwrap().keys = vec![Keyframe::new(Tick::ZERO, a), Keyframe::new(Tick::from_seconds_f64(1.0), b)];
|
||||
}
|
||||
|
||||
/// Left samples of a mix of constant 0.5 audio over the layer's 1 s, in blocks of `block` samples.
|
||||
fn left_ramp(p: &Project, cid: ItemId, block: usize) -> Vec<f32> {
|
||||
fn half(_: f64) -> (f32, f32) {
|
||||
(0.5, 0.5)
|
||||
}
|
||||
let mut m = vec![];
|
||||
for k in 0..SR as usize / block {
|
||||
m.extend(mix(p, &Signal(half), cid, (k * block) as f64 / SR as f64, block));
|
||||
}
|
||||
m.iter().step_by(2).copied().collect()
|
||||
}
|
||||
|
||||
/// Keyframed Audio Levels and Stereo Mixer levels ramp smoothly inside a block (export mixes one
|
||||
/// second at a time), instead of holding the value at the block's first sample.
|
||||
#[test]
|
||||
fn animated_levels_ramp_within_a_block() {
|
||||
let (mut levels, lc) = comp_with(None, &[], false);
|
||||
key(&mut levels, lc, false, "levels", Value::Vec2([-60.0, -60.0]), Value::Vec2([0.0, 0.0]));
|
||||
let (mut mixer, mc) = comp_with(Some("ec.audio.stereomixer"), &[], false);
|
||||
key(&mut mixer, mc, true, "leftLevel", Value::Scalar(0.0), Value::Scalar(100.0));
|
||||
for (name, p, cid, mid) in [("levels", &levels, lc, 0.5 * 10f32.powf(-1.5)), ("stereo mixer", &mixer, mc, 0.25)] {
|
||||
let one = left_ramp(p, cid, SR as usize);
|
||||
assert!(one.windows(2).all(|w| w[1] >= w[0]), "{name}: not monotonic");
|
||||
assert!(one[100] < one[4000] && one[4000] < one[7900], "{name}: {} {} {}", one[100], one[4000], one[7900]);
|
||||
assert!((one[4000] / mid - 1.0).abs() < 0.05, "{name}: {} at 0.5 s, want {mid}", one[4000]);
|
||||
assert_eq!(one, left_ramp(p, cid, 320), "{name}: block size changes the result");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tone_makes_a_solid_audible_at_its_frequency() {
|
||||
let mut vals: Vec<(&str, Value)> = vec![("frequency1", Value::Scalar(500.0))];
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
use effectcraft_color::{BlendMode, Label};
|
||||
use effectcraft_keyframe::{TextDoc, Value};
|
||||
use effectcraft_project::build::{self, Ids};
|
||||
use effectcraft_project::{BitDepth, Comp, ItemId, ItemKind, Layer, LayerSource, Project, Quality, Solid};
|
||||
use effectcraft_project::{BitDepth, Comp, FrameBlend, ItemId, ItemKind, Layer, LayerSource, Project, Quality, Solid};
|
||||
use effectcraft_time::{FrameRate, Tick};
|
||||
|
||||
use crate::{EvalCtx, Image, NoFootage, RenderOpts, Renderer, render_frame};
|
||||
@@ -110,6 +110,66 @@ fn masks_on_a_collapsed_layer_force_a_flattened_render() {
|
||||
assert!(max_diff(&a, &b) < 1e-4, "{}", max_diff(&a, &b));
|
||||
}
|
||||
|
||||
/// #1747: the flattened path honours a composition proxy and frame blending; a collapsed layer
|
||||
/// renders the nested comp directly and ignores both, so such a layer must not collapse.
|
||||
#[test]
|
||||
fn proxy_and_frame_blending_keep_a_precomp_on_the_flattened_path() {
|
||||
let (mut p, cid) = scene(true, false);
|
||||
let id = p.comp(cid).unwrap().layers[0].id;
|
||||
let collapses = |p: &Project| {
|
||||
let r = Renderer::new(p, &NoFootage, RenderOpts::default());
|
||||
let ctx = EvalCtx::new(p, cid, p.comp(cid).unwrap(), Tick::ZERO);
|
||||
r.collapsed(&ctx, ctx.comp.layer(id).unwrap()).is_some()
|
||||
};
|
||||
assert!(collapses(&p), "a plain precomp collapses");
|
||||
// Frame blending needs both the comp's switch and the layer's.
|
||||
p.comp_mut(cid).unwrap().enable_frame_blending = true;
|
||||
p.comp_mut(cid).unwrap().layers[0].switches.frame_blend = FrameBlend::FrameMix;
|
||||
assert!(!collapses(&p), "frame blending keeps the flattened path");
|
||||
p.comp_mut(cid).unwrap().enable_frame_blending = false;
|
||||
p.comp_mut(cid).unwrap().layers[0].switches.frame_blend = FrameBlend::Off;
|
||||
// A composition proxy stands in for the precomp's pixels.
|
||||
let LayerSource::Comp { item } = p.comp(cid).unwrap().layers[0].source else { panic!("a precomp layer") };
|
||||
let proxy =
|
||||
effectcraft_project::Proxy { footage: effectcraft_project::Footage { has_video: true, width: 32, height: 32, ..Default::default() }, enabled: true };
|
||||
p.item_mut(item).unwrap().proxy = Some(Box::new(proxy));
|
||||
assert!(!collapses(&p), "a proxy keeps the flattened path");
|
||||
}
|
||||
|
||||
/// #1747: an effect on B that reads A must not reuse its cached buffer when two collapsed
|
||||
/// instances give A different static overrides (`hash_values` only sees animated values).
|
||||
#[test]
|
||||
fn referenced_layer_overrides_change_the_owner_cache_key() {
|
||||
let mut p = project();
|
||||
let comp = Comp::new(100, 100, FrameRate::FPS_30, Tick::from_seconds_f64(2.0));
|
||||
let a = solid(&mut p, &comp, [1.0, 1.0, 1.0], (40, 40), [30.0, 50.0, 0.0]);
|
||||
let aid = a.id;
|
||||
let mut b = solid(&mut p, &comp, [1.0, 1.0, 1.0], (100, 100), [50.0, 50.0, 0.0]);
|
||||
let bid = b.id;
|
||||
let mut next = p.next_id;
|
||||
let mut ids = Ids(&mut next);
|
||||
let spec = effectcraft_effects::find("ec.channel.setmatte").unwrap();
|
||||
let mut fx = effectcraft_effects::instantiate(spec, &mut ids, spec.name, [100.0, 100.0]);
|
||||
fx.prop_mut("takeMatteFromLayer").unwrap().value = Value::Layer(Some(a.id.0));
|
||||
p.next_id = next;
|
||||
b.props.sub_mut("effects").unwrap().children.push(fx.into());
|
||||
let mut c = comp;
|
||||
c.layers = vec![b, a];
|
||||
let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(c.into()));
|
||||
let comp = p.comp(cid).unwrap();
|
||||
let b = comp.layer(bid).unwrap();
|
||||
let opacity = comp.layer(aid).unwrap().props.prop("transform/opacity").unwrap().uid;
|
||||
let key = |value: f64| {
|
||||
let overrides = [crate::PropertyOverride { layer: aid, prop: opacity, value: Value::Scalar(value), font: false }];
|
||||
let ctx =
|
||||
EvalCtx { project: &p, comp_id: cid, comp, time: Tick::ZERO, expr: None, footage: None, property_overrides: &overrides, media_overrides: &[] };
|
||||
crate::cache::layer_key(&ctx, b, 1.0, false, false)
|
||||
};
|
||||
let (opaque, half) = (key(100.0), key(50.0));
|
||||
assert!(opaque.is_some(), "the owner is cacheable");
|
||||
assert_ne!(opaque, half, "A's static override changes B's key");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collapsed_3d_layers_join_the_parent_scene() {
|
||||
// Parent: a red 3D solid at z = 0 above (in the stack) a collapsed 3D precomp holding a
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
//! A feathered mask blurs its own edge, also where the path lies outside the layer: the feather
|
||||
//! of a mask partly or wholly outside the layer reaches into it (After Effects), instead of the
|
||||
//! coverage stopping at the layer edge.
|
||||
|
||||
use effectcraft_color::Label;
|
||||
use effectcraft_keyframe::{ShapePath, Value};
|
||||
use effectcraft_project::build::{self, Ids};
|
||||
use effectcraft_project::{BitDepth, Comp, FeatherFalloff, GroupKind, ItemId, ItemKind, Layer, LayerSource, MaskMode, Node, Project, Solid};
|
||||
use effectcraft_time::{FrameRate, Tick};
|
||||
|
||||
use crate::render_frame;
|
||||
|
||||
fn solid(p: &mut Project, comp: &Comp, color: [f32; 3], w: u32, h: u32) -> Layer {
|
||||
let sid = p.add_item("Solid", Label::Red, None, ItemKind::Solid(Solid { color, width: w, height: h, pixel_aspect: 1.0 }));
|
||||
build::layer(p, comp, "Solid", LayerSource::Solid { item: sid }, (w, h), None)
|
||||
}
|
||||
|
||||
fn add_mask(p: &mut Project, l: &mut Layer, rect: ShapePath, feather: [f64; 2], linear: bool) {
|
||||
let mut next = p.next_id;
|
||||
let mut m = build::mask(&mut Ids(&mut next), "Mask 1", rect, MaskMode::Add, [255, 255, 0]);
|
||||
p.next_id = next;
|
||||
m.get_mut("feather").unwrap().value = Value::Vec2(feather);
|
||||
if linear && let GroupKind::Mask { feather_falloff, .. } = &mut m.kind {
|
||||
*feather_falloff = FeatherFalloff::Linear;
|
||||
}
|
||||
l.props.sub_mut("masks").unwrap().children.push(Node::Group(m));
|
||||
}
|
||||
|
||||
/// Coverage at signed distance `d` inside an edge blurred by three box passes `w` pixels wide
|
||||
/// (the Smooth falloff: an Irwin-Hall n = 3 distribution function, support +-1.5 w).
|
||||
fn edge(d: f64, w: f64) -> f64 {
|
||||
let t = (d / w + 1.5).clamp(0.0, 3.0);
|
||||
if t < 1.0 {
|
||||
t * t * t / 6.0
|
||||
} else if t < 2.0 {
|
||||
(-2.0 * t * t * t + 9.0 * t * t - 9.0 * t + 3.0) / 6.0
|
||||
} else {
|
||||
1.0 - (3.0 - t).powi(3) / 6.0
|
||||
}
|
||||
}
|
||||
|
||||
/// The box window of the Smooth falloff for `feather` pixels: `2 round(0.37 feather) + 1`.
|
||||
fn window(feather: f64) -> f64 {
|
||||
2.0 * (0.37 * feather).round() + 1.0
|
||||
}
|
||||
|
||||
/// Coverage at pixel centre `x` of the span `a..b` feathered by `feather` (one axis).
|
||||
fn span(x: f64, a: f64, b: f64, feather: f64) -> f64 {
|
||||
let w = window(feather);
|
||||
edge(x - a, w) + edge(b - x, w) - 1.0
|
||||
}
|
||||
|
||||
/// A white 200×100 solid (32 bpc) with one Add mask.
|
||||
fn white_with_mask(rect: ShapePath, feather: [f64; 2], linear: bool, scale: f64) -> effectcraft_raster::Image {
|
||||
let mut p = Project::default();
|
||||
p.settings.bit_depth = BitDepth::Bpc32;
|
||||
let comp = Comp::new(200, 100, FrameRate::FPS_30, Tick::from_seconds_f64(1.0));
|
||||
let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.clone().into()));
|
||||
let mut l = solid(&mut p, &comp, [1.0; 3], 200, 100);
|
||||
add_mask(&mut p, &mut l, rect, feather, linear);
|
||||
p.comp_mut(cid).unwrap().layers.push(l);
|
||||
render_frame(&p, cid, Tick::ZERO, scale)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_feathered_mask_outside_a_solid_reaches_into_it() {
|
||||
// The mask spans x 210..260, right of the layer (x 0..200); feather 40 = three 31 px boxes.
|
||||
let img = white_with_mask(ShapePath::rect([235.0, 50.0], 50.0, 200.0), [40.0, 40.0], false, 1.0);
|
||||
for x in [150i64, 170, 180, 199] {
|
||||
let want = span(x as f64 + 0.5, 210.0, 260.0, 40.0) * span(50.5, -50.0, 150.0, 40.0);
|
||||
let got = img.get(x, 50)[3] as f64;
|
||||
assert!((got - want).abs() < 0.01, "x {x}: alpha {got}, the box kernel gives {want}");
|
||||
}
|
||||
assert!(img.get(199, 50)[3] > 0.25, "the feather reaches the layer edge");
|
||||
// Finite support: nothing beyond 1.5 windows (46.5 px) from the edge.
|
||||
assert!(img.get(160, 50)[3] == 0.0 && img.get(100, 50)[3] == 0.0);
|
||||
// Without feather the mask hides the layer.
|
||||
let hard = white_with_mask(ShapePath::rect([235.0, 50.0], 50.0, 200.0), [0.0, 0.0], false, 1.0);
|
||||
assert!(hard.data.iter().all(|p| p[3] == 0.0));
|
||||
// A linear falloff ramps over the feather width: 20 px either side of the edge at x 210.
|
||||
let lin = white_with_mask(ShapePath::rect([235.0, 50.0], 50.0, 200.0), [40.0, 40.0], true, 1.0);
|
||||
let a = lin.get(199, 50)[3];
|
||||
assert!((a - 0.24).abs() < 0.04, "linear: {a}");
|
||||
assert!(lin.get(185, 50)[3] < 0.01);
|
||||
// At half resolution the same layer pixel gets the same coverage.
|
||||
let half = white_with_mask(ShapePath::rect([235.0, 50.0], 50.0, 200.0), [40.0, 40.0], false, 0.5);
|
||||
assert!((half.get(99, 25)[3] - img.get(198, 50)[3]).abs() < 0.02, "{} {}", half.get(99, 25)[3], img.get(198, 50)[3]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_feathered_mask_larger_than_the_layer_keeps_the_layer_edges() {
|
||||
// The mask covers the layer and 100 px around it: the layer edges are inside the mask and
|
||||
// stay opaque (they used to fade, as if the layer edge were the mask edge).
|
||||
let img = white_with_mask(ShapePath::rect([100.0, 50.0], 400.0, 300.0), [40.0, 40.0], false, 1.0);
|
||||
for (x, y) in [(0i64, 0i64), (0, 50), (199, 99), (100, 0)] {
|
||||
assert!(img.get(x, y)[3] > 0.99, "({x}, {y}): {}", img.get(x, y)[3]);
|
||||
}
|
||||
// A mask edge inside the layer is feathered as before: half coverage on the edge.
|
||||
let inner = white_with_mask(ShapePath::rect([100.0, 50.0], 100.0, 400.0), [40.0, 40.0], false, 1.0);
|
||||
assert!((inner.get(50, 50)[3] - 0.5).abs() < 0.03, "{}", inner.get(50, 50)[3]);
|
||||
assert!(inner.get(100, 50)[3] > 0.98);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn huge_and_broken_feathers_stay_bounded() {
|
||||
for f in [1.0e5, 1.0e9, f64::INFINITY, f64::NAN, -5.0] {
|
||||
let img = white_with_mask(ShapePath::rect([235.0, 50.0], 50.0, 200.0), [f, f], false, 1.0);
|
||||
assert!(img.data.iter().all(|p| p[3].is_finite() && (0.0..=1.0).contains(&p[3])), "feather {f}");
|
||||
let img = white_with_mask(ShapePath::rect([235.0, 50.0], 50.0, 200.0), [f, 10.0], true, 1.0);
|
||||
assert!(img.data.iter().all(|p| p[3].is_finite() && (0.0..=1.0).contains(&p[3])), "linear feather {f}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Case 398 of the .aep corpus, built in EffectCraft's own model: a 1920×1080 comp at 8 bpc, a
|
||||
/// red solid, above it an adjustment layer of comp size with a Gradient Ramp at its defaults and
|
||||
/// one Add mask entirely below the layer (1920×200 centred at [960, 1250]) with feather 1000.
|
||||
fn case_398(with_mask: bool) -> (Project, ItemId) {
|
||||
let mut p = Project::default();
|
||||
assert_eq!(p.settings.bit_depth, BitDepth::Bpc8, "After Effects' default depth");
|
||||
let comp = Comp::new(1920, 1080, FrameRate::FPS_30, Tick::from_seconds_f64(5.0));
|
||||
let cid = p.add_item("C", Label::Sandstone, None, ItemKind::Comp(comp.clone().into()));
|
||||
let red = solid(&mut p, &comp, [1.0, 0.0, 0.0], 1920, 1080);
|
||||
let mut adj = solid(&mut p, &comp, [1.0; 3], 1920, 1080);
|
||||
adj.switches.adjustment = true;
|
||||
let spec = effectcraft_effects::find("ec.generate.gradientramp").unwrap();
|
||||
let mut next = p.next_id;
|
||||
let ramp = effectcraft_effects::instantiate(spec, &mut Ids(&mut next), spec.name, [1920.0, 1080.0]);
|
||||
p.next_id = next;
|
||||
adj.props.sub_mut("effects").unwrap().children.push(ramp.into());
|
||||
if with_mask {
|
||||
add_mask(&mut p, &mut adj, ShapePath::rect([960.0, 1250.0], 1920.0, 200.0), [1000.0, 1000.0], false);
|
||||
}
|
||||
let c = p.comp_mut(cid).unwrap();
|
||||
c.layers.push(adj);
|
||||
c.layers.push(red);
|
||||
(p, cid)
|
||||
}
|
||||
|
||||
fn rgb8(img: &effectcraft_raster::Image, x: i64, y: i64) -> [i32; 3] {
|
||||
let px = img.get(x, y);
|
||||
[0, 1, 2].map(|c| (px[c].clamp(0.0, 1.0) * 255.0).round() as i32)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn case_398_a_feathered_mask_below_an_adjustment_layer_reaches_into_it() {
|
||||
let t = Tick::from_seconds_f64(1.0);
|
||||
let (p, cid) = case_398(true);
|
||||
let img = render_frame(&p, cid, t, 1.0);
|
||||
// The ramp alone (no mask): what the adjustment layer mixes in.
|
||||
let (q, qid) = case_398(false);
|
||||
let ramp = render_frame(&q, qid, t, 1.0);
|
||||
// After Effects 2026, 8 bpc, the render of 398 at 1 s.
|
||||
let ae = [
|
||||
((960, 0), [255, 0, 0]),
|
||||
((960, 300), [254, 0, 0]),
|
||||
((960, 540), [250, 5, 5]),
|
||||
((960, 800), [248, 20, 20]),
|
||||
((960, 1000), [252, 40, 40]),
|
||||
((960, 1079), [255, 47, 47]),
|
||||
((100, 1079), [255, 29, 29]),
|
||||
];
|
||||
let mut report = Vec::new();
|
||||
for ((x, y), want_ae) in ae {
|
||||
let got = rgb8(&img, x, y);
|
||||
// 1. The coverage is the mask's Smooth falloff (three 741 px boxes for feather 1000),
|
||||
// blurred from the mask edge outside the layer (on the coarse grid); the result is the
|
||||
// red mixed toward the ramp by it.
|
||||
let c = span(x as f64 + 0.5, 0.0, 1920.0, 1000.0) * span(y as f64 + 0.5, 1150.0, 1350.0, 1000.0);
|
||||
let r = ramp.get(x, y);
|
||||
let want = [0, 1, 2].map(|k| {
|
||||
let red = if k == 0 { 1.0 } else { 0.0 };
|
||||
((red + (r[k] as f64 - red) * c) * 255.0).round() as i32
|
||||
});
|
||||
for k in 0..3 {
|
||||
assert!((got[k] - want[k]).abs() <= 2, "({x}, {y}): {got:?}, the box coverage {c:.4} gives {want:?}");
|
||||
}
|
||||
// 2. After Effects: within 2 levels per channel.
|
||||
for k in 0..3 {
|
||||
assert!((got[k] - want_ae[k]).abs() <= 2, "({x}, {y}): EffectCraft {got:?}, After Effects {want_ae:?}");
|
||||
}
|
||||
report.push(format!("({x},{y}) ec {got:?} ae {want_ae:?}"));
|
||||
}
|
||||
println!("398: {}", report.join("; "));
|
||||
// The mask covers nothing of the layer itself, but its feather does: the top stays (nearly) red.
|
||||
assert!(rgb8(&img, 960, 0)[0] >= 253 && rgb8(&img, 960, 0)[1] == 0);
|
||||
assert!(rgb8(&img, 960, 1079)[1] > 25, "the feather reaches the bottom edge");
|
||||
}
|
||||
|
||||
/// After Effects 2026 (sRGB, 32 bpc): a red 1920x1080 solid with a 1000x600 Add mask centred at
|
||||
/// [960, 540] (left edge at x 460), feather `f` on both axes; coverage along the row y 540 from
|
||||
/// x 160 to 760 in steps of 20 (8-bit values / 255).
|
||||
const AE_FEATHER_100: [(i64, f64); 11] =
|
||||
[(360, 0.0), (380, 0.016), (400, 0.059), (420, 0.153), (440, 0.31), (460, 0.506), (480, 0.698), (500, 0.855), (520, 0.945), (540, 0.988), (560, 1.0)];
|
||||
const AE_FEATHER_300: [(i64, f64); 31] = [
|
||||
(160, 0.0),
|
||||
(180, 0.004),
|
||||
(200, 0.008),
|
||||
(220, 0.012),
|
||||
(240, 0.024),
|
||||
(260, 0.035),
|
||||
(280, 0.055),
|
||||
(300, 0.078),
|
||||
(320, 0.11),
|
||||
(340, 0.149),
|
||||
(360, 0.192),
|
||||
(380, 0.247),
|
||||
(400, 0.306),
|
||||
(420, 0.369),
|
||||
(440, 0.435),
|
||||
(460, 0.502),
|
||||
(480, 0.569),
|
||||
(500, 0.635),
|
||||
(520, 0.698),
|
||||
(540, 0.757),
|
||||
(560, 0.808),
|
||||
(580, 0.851),
|
||||
(600, 0.89),
|
||||
(620, 0.922),
|
||||
(640, 0.945),
|
||||
(660, 0.965),
|
||||
(680, 0.976),
|
||||
(700, 0.988),
|
||||
(720, 0.992),
|
||||
(740, 0.996),
|
||||
(760, 1.0),
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn the_smooth_feather_falloff_matches_after_effects() {
|
||||
for (f, ae) in [(100.0, &AE_FEATHER_100[..]), (300.0, &AE_FEATHER_300[..])] {
|
||||
let mut p = Project::default();
|
||||
p.settings.bit_depth = BitDepth::Bpc32;
|
||||
let comp = Comp::new(1920, 1080, FrameRate::FPS_30, Tick::from_seconds_f64(1.0));
|
||||
let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.clone().into()));
|
||||
let mut l = solid(&mut p, &comp, [1.0, 0.0, 0.0], 1920, 1080);
|
||||
add_mask(&mut p, &mut l, ShapePath::rect([960.0, 540.0], 1000.0, 600.0), [f, f], false);
|
||||
p.comp_mut(cid).unwrap().layers.push(l);
|
||||
let img = render_frame(&p, cid, Tick::ZERO, 1.0);
|
||||
let mut worst = 0.0f64;
|
||||
for &(x, want) in ae {
|
||||
let got = img.get(x, 540)[3] as f64;
|
||||
// Tolerance 2/255 on the 8-bit After Effects values.
|
||||
assert!((got - want).abs() <= 2.0 / 255.0, "feather {f}, x {x}: {got:.4}, After Effects {want}");
|
||||
worst = worst.max((got - want).abs());
|
||||
}
|
||||
// The corner is the product of its two edges (a separable blur): 0.25 for both feathers.
|
||||
let corner = img.get(460, 240)[3] as f64;
|
||||
assert!((corner - 0.251).abs() <= 2.0 / 255.0, "feather {f}: corner {corner}");
|
||||
println!("feather {f}: largest deviation from After Effects {:.2}/255", worst * 255.0);
|
||||
}
|
||||
}
|
||||
@@ -134,7 +134,16 @@ fn input_keys_ignore_later_effects() {
|
||||
}
|
||||
let key = |p: &Project| {
|
||||
let comp = p.comp(cid).unwrap();
|
||||
let ctx = crate::EvalCtx { project: p, comp_id: cid, comp, time: Tick::from_seconds_f64(0.5), expr: None, footage: None };
|
||||
let ctx = crate::EvalCtx {
|
||||
project: p,
|
||||
comp_id: cid,
|
||||
comp,
|
||||
time: Tick::from_seconds_f64(0.5),
|
||||
expr: None,
|
||||
footage: None,
|
||||
property_overrides: &[],
|
||||
media_overrides: &[],
|
||||
};
|
||||
let l = &comp.layers[0];
|
||||
(crate::cache::input_key(&ctx, l, 1.0, false, false, 1), crate::cache::input_key(&ctx, l, 1.0, false, false, 2))
|
||||
};
|
||||
|
||||
@@ -42,7 +42,8 @@ pub struct CharXf {
|
||||
pub stroke_hsb: [f64; 3],
|
||||
pub stroke_opacity: f64,
|
||||
pub stroke_width: f64,
|
||||
/// Tracking (1/1000 em) added before / after the character.
|
||||
/// Tracking (pixels: an animator's Tracking Amount, as After Effects) added before / after
|
||||
/// the character.
|
||||
pub track_before: f64,
|
||||
pub track_after: f64,
|
||||
/// Line Anchor (percent) and the selection weight it was set with.
|
||||
@@ -694,10 +695,12 @@ pub fn text_geom(ctx: &EvalCtx, layer: &Layer) -> Option<TextGeom> {
|
||||
let (mut la, mut la_k) = (50.0 * f64::from(align_index(justify)), 0.0);
|
||||
for gi in li_start..li_end {
|
||||
let x = &xfs[gi];
|
||||
let em = lay.styles.get(lay.glyphs[gi].run).map_or(doc.size, |s| s.size) / 1000.0;
|
||||
pen += x.track_before * em;
|
||||
// An animator's Tracking Amount is in pixels, whatever the font size (After Effects
|
||||
// 2026: Amount 30 adds 30 px between selected characters at 80 px and at 40 px), unlike
|
||||
// the Character panel's tracking in 1/1000 em.
|
||||
pen += x.track_before;
|
||||
shift[gi] = pen;
|
||||
pen += x.track_after * em + vadv[gi];
|
||||
pen += x.track_after + vadv[gi];
|
||||
if x.line_anchor_k > la_k {
|
||||
la = x.line_anchor;
|
||||
la_k = x.line_anchor_k;
|
||||
@@ -805,9 +808,12 @@ pub fn text_geom(ctx: &EvalCtx, layer: &Layer) -> Option<TextGeom> {
|
||||
};
|
||||
// Outline relative to the pivot.
|
||||
let local = kurbo::Affine::translate((o[0] - pivot[0], o[1] - pivot[1])) * outline;
|
||||
let char_m = Mat4::rotate_z(x.rotation)
|
||||
// Rx · Ry · Rz (Z turns first): the order After Effects gives a 3D layer's X / Y / Z
|
||||
// Rotation too. #1876 moves layers to it with a helper, `Mat4::rotation_xyz`; once both
|
||||
// land, this should call that helper (one convention, one place).
|
||||
let char_m = Mat4::rotate_x(x.rotation_x)
|
||||
* Mat4::rotate_y(x.rotation_y)
|
||||
* Mat4::rotate_x(x.rotation_x)
|
||||
* Mat4::rotate_z(x.rotation)
|
||||
* Mat4::from_mat3_affine(&Mat3::skew_deg(-x.skew, x.skew_axis))
|
||||
* Mat4::scale(Vec3::from(x.scale) / 100.0)
|
||||
* Mat4::translate(-Vec3::from(x.anchor));
|
||||
|
||||
@@ -220,9 +220,9 @@ fn tracking_line_anchor_and_character_offset() {
|
||||
animator(&mut p, &mut l, &["tracking", "lineAnchor"], &["range"]);
|
||||
p.comp_mut(cid).unwrap().layers.push(l);
|
||||
let w0 = bounds_of(&glyph_paths(&ctx(&p, cid, 0.0), &p.comp(cid).unwrap().layers[0]));
|
||||
set(&mut p.comp_mut(cid).unwrap().layers[0], "text/animators/#1/properties/tracking", Value::Scalar(500.0));
|
||||
set(&mut p.comp_mut(cid).unwrap().layers[0], "text/animators/#1/properties/tracking", Value::Scalar(20.0));
|
||||
let w1 = bounds_of(&glyph_paths(&ctx(&p, cid, 0.0), &p.comp(cid).unwrap().layers[0]));
|
||||
// 4 chars × 500/1000 em × 40 px: the last glyph moves ~ 3.5 × 20 px further (before & after).
|
||||
// Tracking Amount 20 (pixels, as After Effects): the last glyph moves ~ 3.5 × 20 px further (before & after).
|
||||
assert!((w1.width() - w0.width() - 3.0 * 20.0).abs() < 1.5, "{} {}", w0.width(), w1.width());
|
||||
assert!((w1.x0 - w0.x0 - 10.0).abs() < 1.0, "first char shifts by half its tracking: {} {}", w0.x0, w1.x0);
|
||||
// Line Anchor 100%: tracking grows to the left.
|
||||
@@ -262,10 +262,10 @@ fn tracking_grows_from_each_paragraphs_alignment() {
|
||||
p.comp_mut(cid).unwrap().layers.push(l);
|
||||
glyph_paths(&ctx(&p, cid, 0.0), &p.comp(cid).unwrap().layers[0])
|
||||
};
|
||||
// 4 chars × 500/1000 em × 40 px: 20 px of tracking each, half before and half after.
|
||||
// Tracking Amount 20 (pixels): 20 px of tracking each, half before and half after.
|
||||
for (justify, x0, centre, x1) in [(Justify::Left, 10.0, 40.0, 70.0), (Justify::Center, -30.0, 0.0, 30.0), (Justify::Right, -70.0, -40.0, -10.0)] {
|
||||
let d = TextDoc { justify, ..doc("ABCD", 40.0) };
|
||||
let (w0, w1) = (bounds_of(&tracked(d.clone(), 0.0)), bounds_of(&tracked(d, 500.0)));
|
||||
let (w0, w1) = (bounds_of(&tracked(d.clone(), 0.0)), bounds_of(&tracked(d, 20.0)));
|
||||
let got = [w1.x0 - w0.x0, w1.center().x - w0.center().x, w1.x1 - w0.x1];
|
||||
assert!(got.iter().zip([x0, centre, x1]).all(|(g, w)| (g - w).abs() < 1.0), "{justify:?}: moved {got:?}, want {:?}", [x0, centre, x1]);
|
||||
}
|
||||
@@ -277,11 +277,36 @@ fn tracking_grows_from_each_paragraphs_alignment() {
|
||||
let (a, b): (Vec<_>, Vec<_>) = paths.into_iter().partition(|g| bounds_of(std::slice::from_ref(g)).center().y < 10.0);
|
||||
[bounds_of(&a), bounds_of(&b)]
|
||||
};
|
||||
let ([a0, b0], [a1, b1]) = (lines(tracked(d.clone(), 0.0)), lines(tracked(d, 500.0)));
|
||||
let ([a0, b0], [a1, b1]) = (lines(tracked(d.clone(), 0.0)), lines(tracked(d, 20.0)));
|
||||
assert!((a1.x0 - a0.x0 - 10.0).abs() < 1.0, "the left paragraph grows right: {} {}", a0.x0, a1.x0);
|
||||
assert!((b1.x1 - b0.x1 + 10.0).abs() < 1.0, "the right paragraph grows left: {} {}", b0.x1, b1.x1);
|
||||
}
|
||||
|
||||
/// #1798: After Effects adds Tracking Amount in absolute pixels between neighbouring characters,
|
||||
/// independent of font size.
|
||||
#[test]
|
||||
fn text_animator_tracking_amount_uses_pixels_independent_of_font_size() {
|
||||
let width_diff = |font_size: f64, amount: f64| {
|
||||
let (mut p, cid, comp) = setup(2000, 400);
|
||||
let mut l = text_layer(&mut p, &comp, doc("ABCDE", font_size));
|
||||
animator(&mut p, &mut l, &["tracking"], &["range"]);
|
||||
set(&mut l, "text/animators/#1/properties/tracking", Value::Scalar(amount));
|
||||
p.comp_mut(cid).unwrap().layers.push(l);
|
||||
let w = bounds_of(&glyph_paths(&ctx(&p, cid, 0.0), &p.comp(cid).unwrap().layers[0])).width();
|
||||
let (mut p0, cid0, comp0) = setup(2000, 400);
|
||||
let l0 = text_layer(&mut p0, &comp0, doc("ABCDE", font_size));
|
||||
p0.comp_mut(cid0).unwrap().layers.push(l0);
|
||||
let w0 = bounds_of(&glyph_paths(&ctx(&p0, cid0, 0.0), &p0.comp(cid0).unwrap().layers[0])).width();
|
||||
w - w0
|
||||
};
|
||||
// 5 characters = 4 gaps. In After Effects, Tracking Amount 100 adds 100 px per gap = 400 px total width delta,
|
||||
// regardless of whether font size is 50 px or 200 px.
|
||||
let diff_50 = width_diff(50.0, 100.0);
|
||||
let diff_200 = width_diff(200.0, 100.0);
|
||||
assert!((diff_50 - 400.0).abs() < 1.0, "font_size 50 px width delta: {diff_50}");
|
||||
assert!((diff_200 - 400.0).abs() < 1.0, "font_size 200 px width delta: {diff_200}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anchor_grouping_line_rotates_around_line_centre() {
|
||||
let (mut p, cid, comp) = setup(600, 300);
|
||||
@@ -479,6 +504,37 @@ fn per_character_3d_projects_characters() {
|
||||
assert!(b[2] - b[0] < flat[2] - flat[0] && b[2] > b[0] + 10, "{b:?} {flat:?}");
|
||||
}
|
||||
|
||||
/// After Effects 2026 (case 931: "H", 400 px, per-character 3D, X / Y / Z Rotation 40 / 30 /
|
||||
/// 20): a character turns like a 3D layer, Rx · Ry · Rz (Z first), so its matrix is the same
|
||||
/// product as a layer's X / Y / Z Rotation. The render then matches AE (3 of 5184 grid points
|
||||
/// off at edges; with Rz · Ry · Rx 23).
|
||||
#[test]
|
||||
fn per_character_rotation_turns_z_first_as_a_3d_layer() {
|
||||
let (mut p, cid, comp) = setup(400, 300);
|
||||
let mut l = text_layer(&mut p, &comp, doc("H", 80.0));
|
||||
l.switches.three_d = true;
|
||||
animator(&mut p, &mut l, &["rotation"], &["range"]);
|
||||
let mut next = p.next_id;
|
||||
let mut extra = build::text_anim_props(&mut Ids(&mut next), "rotation", true);
|
||||
p.next_id = next;
|
||||
let props: &mut PropGroup = l.props.group_mut("text/animators/#1/properties").unwrap();
|
||||
props.children.retain(|c| c.match_id() != "rotation");
|
||||
props.children.extend(extra.drain(..).map(Into::into));
|
||||
for (id, v) in [("rotationX", 40.0), ("rotationY", 30.0), ("rotation", 20.0)] {
|
||||
props.get_mut(id).unwrap().value = Value::Scalar(v);
|
||||
}
|
||||
l.props.sub_mut("text").unwrap().get_mut("perChar3d").unwrap().value = Value::Bool(true);
|
||||
p.comp_mut(cid).unwrap().layers.push(l);
|
||||
let geom = text_geom(&ctx(&p, cid, 0.0), &p.comp(cid).unwrap().layers[0]).unwrap();
|
||||
let g = &geom.glyphs[0];
|
||||
let want = Mat4::rotate_x(40.0) * Mat4::rotate_y(30.0) * Mat4::rotate_z(20.0);
|
||||
for r in 0..3 {
|
||||
for c in 0..3 {
|
||||
assert!((g.m.0[r][c] - want.0[r][c]).abs() < 1e-9, "row {r} col {c}: {:?} vs {:?}", g.m.0, want.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keyframed_selector_animates() {
|
||||
let (mut p, cid, comp) = setup(400, 200);
|
||||
|
||||
@@ -857,8 +857,8 @@ fn emit_layer<'a>(r: &Renderer<'a>, ctx: &EvalCtx<'a>, layer: &Layer, b: &mut Bu
|
||||
if layer.environment || layer.switches.adjustment || ctx.opacity(layer) <= 0.0 || !layer.has_video() {
|
||||
return;
|
||||
}
|
||||
if let Some(item) = r.collapsed(ctx, layer) {
|
||||
collapsed_layer(r, ctx, layer, item, b);
|
||||
if let Some(collapsed) = r.collapsed(ctx, layer) {
|
||||
collapsed_layer(r, ctx, layer, &collapsed, b);
|
||||
} else if layer.source.is_model() {
|
||||
model_layer(r, ctx, layer, b);
|
||||
} else if matches!(layer.source, LayerSource::Text | LayerSource::Shape)
|
||||
@@ -874,9 +874,9 @@ fn emit_layer<'a>(r: &Renderer<'a>, ctx: &EvalCtx<'a>, layer: &Layer, b: &mut Bu
|
||||
/// (meshes, extrusions, cards) placed by the precomp layer's world transform, so they
|
||||
/// intersect, occlude and shadow the parent's layers. Nested 2D layers lie on the precomp
|
||||
/// layer's plane; nested lights and cameras are ignored (the parent's light the scene).
|
||||
fn collapsed_layer<'a>(r: &Renderer<'a>, ctx: &EvalCtx<'a>, layer: &Layer, item: effectcraft_project::ItemId, b: &mut Builder) {
|
||||
fn collapsed_layer<'a>(r: &Renderer<'a>, ctx: &EvalCtx<'a>, layer: &Layer, collapsed: &crate::Collapsed, b: &mut Builder) {
|
||||
let op = ctx.opacity(layer) as f32 * r.opacity_mul();
|
||||
let Some((sub, nctx)) = r.collapse_into(ctx, layer, item, op) else { return };
|
||||
let Some((sub, nctx)) = r.collapse_into(ctx, layer, collapsed, op) else { return };
|
||||
let saved = b.outer;
|
||||
b.outer = saved * ctx.world_matrix(layer);
|
||||
let t = nctx.time;
|
||||
|
||||
@@ -228,6 +228,25 @@ fn tap_hash(x: i32, y: i32) -> u32 {
|
||||
h
|
||||
}
|
||||
|
||||
/// The jitter of tap (`i`, `j`), each axis in [−0.5, 0.5): [`tap_hash`] values at the four
|
||||
/// corners of the receiver's 1/8-texel lattice cell (`cx`, `cy`), blended bilinearly by the
|
||||
/// receiver's position in the cell (`wx`, `wy`). The jitter is continuous in the receiver's
|
||||
/// map position, so a position that differs between backends in its last bits moves the
|
||||
/// taps by an amount proportional to that difference and does not switch them to another
|
||||
/// cell's jitter.
|
||||
fn tap_jitter(cx: i32, cy: i32, wx: f32, wy: f32, i: i32, j: i32) -> (f32, f32) {
|
||||
let corner = |dx: i32, dy: i32| -> (f32, f32) {
|
||||
let h = tap_hash(cx.wrapping_add(dx).wrapping_add(i.wrapping_mul(7919)), cy.wrapping_add(dy).wrapping_add(j.wrapping_mul(7919)));
|
||||
(((h & 0xFFFF) as f32) * (1.0 / 65536.0) - 0.5, (((h >> 16) & 0xFFFF) as f32) * (1.0 / 65536.0) - 0.5)
|
||||
};
|
||||
let (c00, c10, c01, c11) = (corner(0, 0), corner(1, 0), corner(0, 1), corner(1, 1));
|
||||
let ax = c00.0 + (c10.0 - c00.0) * wx;
|
||||
let bx = c01.0 + (c11.0 - c01.0) * wx;
|
||||
let ay = c00.1 + (c10.1 - c00.1) * wx;
|
||||
let by = c01.1 + (c11.1 - c01.1) * wx;
|
||||
(ax + (bx - ax) * wy, ay + (by - ay) * wy)
|
||||
}
|
||||
|
||||
/// Upper bound of the PCF grid's taps per axis. The grid grows with the reach so taps
|
||||
/// stay about one texel apart for moderate penumbrae; the cap bounds the per-fragment
|
||||
/// cost of very wide ones (bilinear taps still overlap and the jitter dithers the rest).
|
||||
@@ -243,8 +262,9 @@ const PLANE_SLOPE_MAX: f32 = 64.0;
|
||||
///
|
||||
/// PCF over an N×N tap grid (5 ≤ N ≤ `PCF_MAX_TAPS`) spread over a reach derived
|
||||
/// from `radius` (the wanted penumbra in output pixels) divided by the receiver's
|
||||
/// texel footprint, each tap jittered by an integer hash of the receiver's
|
||||
/// sub-texel cell and the tap index, and each tap comparing against the four
|
||||
/// texel footprint, each tap jittered by [`tap_jitter`] (an integer hash of the
|
||||
/// receiver's sub-texel lattice and the tap index, blended across the receiver's
|
||||
/// lattice cell), and each tap comparing against the four
|
||||
/// nearest texel centres with bilinear weights. The jitter detaches the tap grid
|
||||
/// from the texel axes (axis-aligned binary taps are what stair-step a straight
|
||||
/// shadow edge), and keying it below texel size keeps the dither finer than an
|
||||
@@ -312,13 +332,13 @@ pub fn shadow_factor(m: &ShadowMap, texels: &[f32], p: [f32; 3], ng: [f32; 3], n
|
||||
let n = (reach.ceil() as i32 + 1).clamp(5, PCF_MAX_TAPS);
|
||||
let step = reach / (n - 1) as f32;
|
||||
let half = (n - 1) as f32 * 0.5;
|
||||
let (cx, cy) = ((u * 8.0).floor() as i32, (v * 8.0).floor() as i32);
|
||||
let (gu, gv) = (u * 8.0, v * 8.0);
|
||||
let (cx, cy) = (gu.floor() as i32, gv.floor() as i32);
|
||||
let (wx, wy) = (gu - gu.floor(), gv - gv.floor());
|
||||
let mut lit = 0.0;
|
||||
for j in 0..n {
|
||||
for i in 0..n {
|
||||
let h = tap_hash(cx.wrapping_add(i.wrapping_mul(7919)), cy.wrapping_add(j.wrapping_mul(7919)));
|
||||
let jx = ((h & 0xFFFF) as f32) * (1.0 / 65536.0) - 0.5;
|
||||
let jy = (((h >> 16) & 0xFFFF) as f32) * (1.0 / 65536.0) - 0.5;
|
||||
let (jx, jy) = tap_jitter(cx, cy, wx, wy, i, j);
|
||||
let (su, sv) = (u + ((i as f32 - half) + jx) * step, v + ((j as f32 - half) + jy) * step);
|
||||
// Texel (x, y) holds the depth at its centre (x + 0.5, y + 0.5); each of the
|
||||
// four is compared against the receiver plane at that centre.
|
||||
|
||||
@@ -370,7 +370,16 @@ fn extruded_text_renders_as_a_mesh() {
|
||||
set(l, "geometryOptions/bevelStyle", Value::Enum(1));
|
||||
set(l, "transform/rotationY", Value::Scalar(40.0));
|
||||
});
|
||||
let ctx = crate::EvalCtx { project: &p, comp_id: cid, comp: p.comp(cid).unwrap(), time: Tick::ZERO, expr: None, footage: None };
|
||||
let ctx = crate::EvalCtx {
|
||||
project: &p,
|
||||
comp_id: cid,
|
||||
comp: p.comp(cid).unwrap(),
|
||||
time: Tick::ZERO,
|
||||
expr: None,
|
||||
footage: None,
|
||||
property_overrides: &[],
|
||||
media_overrides: &[],
|
||||
};
|
||||
let l = &ctx.comp.layers[0];
|
||||
let params = scene::extrusion_params(&ctx, l).unwrap();
|
||||
let meshes = scene::extruded_meshes(&ctx, l, ¶ms);
|
||||
@@ -409,7 +418,16 @@ fn shape_surface_items_colour_the_extruded_surfaces() {
|
||||
set(l, "geometryOptions/extrusionDepth", Value::Scalar(40.0));
|
||||
set(l, "transform/position", Value::Vec3([100.0, 100.0, 0.0]));
|
||||
});
|
||||
let ctx = crate::EvalCtx { project: &p, comp_id: cid, comp: p.comp(cid).unwrap(), time: Tick::ZERO, expr: None, footage: None };
|
||||
let ctx = crate::EvalCtx {
|
||||
project: &p,
|
||||
comp_id: cid,
|
||||
comp: p.comp(cid).unwrap(),
|
||||
time: Tick::ZERO,
|
||||
expr: None,
|
||||
footage: None,
|
||||
property_overrides: &[],
|
||||
media_overrides: &[],
|
||||
};
|
||||
let l = &ctx.comp.layers[0];
|
||||
let params = scene::extrusion_params(&ctx, l).unwrap();
|
||||
let meshes = scene::extruded_meshes(&ctx, l, ¶ms);
|
||||
@@ -445,7 +463,16 @@ fn text_animators_colour_the_extruded_surfaces() {
|
||||
set(l, "geometryOptions/extrusionDepth", Value::Scalar(30.0));
|
||||
set(l, "geometryOptions/bevelStyle", Value::Enum(1));
|
||||
});
|
||||
let ctx = crate::EvalCtx { project: &p, comp_id: cid, comp: p.comp(cid).unwrap(), time: Tick::ZERO, expr: None, footage: None };
|
||||
let ctx = crate::EvalCtx {
|
||||
project: &p,
|
||||
comp_id: cid,
|
||||
comp: p.comp(cid).unwrap(),
|
||||
time: Tick::ZERO,
|
||||
expr: None,
|
||||
footage: None,
|
||||
property_overrides: &[],
|
||||
media_overrides: &[],
|
||||
};
|
||||
let l = &ctx.comp.layers[0];
|
||||
let params = scene::extrusion_params(&ctx, l).unwrap();
|
||||
let meshes = scene::extruded_meshes(&ctx, l, ¶ms);
|
||||
@@ -643,7 +670,16 @@ fn extruded_strokes_render_as_bevelled_meshes() {
|
||||
set(l, "geometryOptions/extrusionDepth", Value::Scalar(20.0));
|
||||
set(l, "geometryOptions/bevelStyle", Value::Enum(1));
|
||||
});
|
||||
let ctx = crate::EvalCtx { project: &p, comp_id: cid, comp: p.comp(cid).unwrap(), time: Tick::ZERO, expr: None, footage: None };
|
||||
let ctx = crate::EvalCtx {
|
||||
project: &p,
|
||||
comp_id: cid,
|
||||
comp: p.comp(cid).unwrap(),
|
||||
time: Tick::ZERO,
|
||||
expr: None,
|
||||
footage: None,
|
||||
property_overrides: &[],
|
||||
media_overrides: &[],
|
||||
};
|
||||
let l = &ctx.comp.layers[0];
|
||||
let params = scene::extrusion_params(&ctx, l).unwrap();
|
||||
let meshes = scene::extruded_meshes(&ctx, l, ¶ms);
|
||||
@@ -887,6 +923,26 @@ fn penumbra_dithers_along_a_straight_edge() {
|
||||
assert!(distinct(&within) >= 4, "coverage inside one texel takes {} distinct values", distinct(&within));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn penumbra_jitter_is_continuous_across_its_lattice() {
|
||||
// Receiver pairs 1/5000 texel either side of a line of the 1/8-texel jitter lattice,
|
||||
// across u and across v, inside the penumbra. The GPU places a receiver within a few
|
||||
// ulps of the CPU, so coverage that jumps at a lattice line differs between the two
|
||||
// backends there; both sides of each line must agree within one 8-bit level.
|
||||
let (m, texels) = wall_map(64, 1.0, 6.0);
|
||||
let f = |p: [f32; 2]| shade::shadow_factor(&m, &texels, [p[0], p[1], 13.5], FACING, 1.0);
|
||||
let mut worst = 0.0f32;
|
||||
for k in 0..16 {
|
||||
let line = k as f32 * 0.125;
|
||||
for (a, b) in [([-16.0 + line - 2e-4, 0.3], [-16.0 + line + 2e-4, 0.3]), ([-16.3, line - 2e-4], [-16.3, line + 2e-4])] {
|
||||
let (fa, fb) = (f(a), f(b));
|
||||
assert!(fa > 0.05 && fa < 0.95, "{a:?} lies in the penumbra: {fa}");
|
||||
worst = worst.max((fa - fb).abs());
|
||||
}
|
||||
}
|
||||
assert!(worst < 1.0 / 255.0, "coverage jumps by {worst} across a jitter lattice line");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn penumbra_width_is_independent_of_map_resolution() {
|
||||
// One wall (edge at world x = −16) in a 64-texel map at 1 texel per world unit and
|
||||
|
||||
@@ -289,16 +289,24 @@ pub fn progressive_blur(img: &Image, iris: &Iris, radius: &[f32]) -> Image {
|
||||
return bokeh_blur(img, iris, levels[0] as f64);
|
||||
}
|
||||
let (lo, hi, n) = (levels[0], levels[levels.len() - 1], levels.len());
|
||||
let blurred: Vec<Image> = levels.par_iter().map(|&r| bokeh_blur(img, iris, r as f64)).collect();
|
||||
// A pixel blends only the two levels around its radius, so the levels are made in order and
|
||||
// only that pair is held: peak memory stays a few images whatever the level count, rather
|
||||
// than one image per level. Each level's blur is parallel over its rows.
|
||||
let mut out = Image::new(img.width, img.height);
|
||||
out.data.par_iter_mut().enumerate().for_each(|(i, o)| {
|
||||
let r = radius[i];
|
||||
let f = ((r - lo) / (hi - lo) * (n - 1) as f32).clamp(0.0, (n - 1) as f32);
|
||||
let k = (f.floor() as usize).min(n - 2);
|
||||
let t = f - k as f32;
|
||||
let (a, b) = (blurred[k].data[i], blurred[k + 1].data[i]);
|
||||
*o = [0, 1, 2, 3].map(|c| a[c] + (b[c] - a[c]) * t);
|
||||
});
|
||||
let mut below = bokeh_blur(img, iris, lo as f64);
|
||||
for (k, &r) in levels.iter().enumerate().skip(1) {
|
||||
let above = bokeh_blur(img, iris, r as f64);
|
||||
out.data.par_iter_mut().enumerate().for_each(|(i, o)| {
|
||||
let f = ((radius[i] - lo) / (hi - lo) * (n - 1) as f32).clamp(0.0, (n - 1) as f32);
|
||||
let kk = (f.floor() as usize).min(n - 2);
|
||||
if kk + 1 == k {
|
||||
let t = f - kk as f32;
|
||||
let (a, b) = (below.data[i], above.data[i]);
|
||||
*o = [0, 1, 2, 3].map(|c| a[c] + (b[c] - a[c]) * t);
|
||||
}
|
||||
});
|
||||
below = above;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
|
||||
@@ -37,7 +37,8 @@ pub struct Dof {
|
||||
impl Dof {
|
||||
/// Circle-of-confusion diameter (comp pixels) for a point at camera depth `z`.
|
||||
pub fn coc(&self, z: f64) -> f64 {
|
||||
if z <= NEAR || self.focus <= 0.0 {
|
||||
// No defocus can be measured against a non-finite focus or depth, so it counts as in focus.
|
||||
if !z.is_finite() || z <= NEAR || !self.focus.is_finite() || self.focus <= 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
self.aperture * (z - self.focus).abs() / z * self.blur_level
|
||||
@@ -241,6 +242,53 @@ fn path_tangent(ctx: &EvalCtx, layer: &Layer) -> Option<Vec3> {
|
||||
.filter(|k| **k <= ctx.time)
|
||||
.find_map(|&k| dir(pos(k) - pos(k - dt)))
|
||||
.or_else(|| keys.iter().filter(|k| **k > ctx.time).find_map(|&k| dir(pos(k + dt) - pos(k))))
|
||||
.or_else(|| {
|
||||
// An expression has no keys to fall back on: sample it instead.
|
||||
let expr = dims.iter().any(|m| tr.get(m).is_some_and(|p| p.has_expression()));
|
||||
expr.then(|| sampled_tangent(ctx, layer, &pos, dt, true).or_else(|| sampled_tangent(ctx, layer, &pos, dt, false))).flatten()
|
||||
})
|
||||
}
|
||||
|
||||
/// Samples spent on each phase of [`sampled_tangent`]: it runs per layer per frame.
|
||||
const MOTION_SEARCH_STEPS: u32 = 12;
|
||||
|
||||
/// Direction of the motion nearest to the context time, before it (`earlier`) or after it, for a
|
||||
/// position with no keyframes to fall back on (an expression). Steps 1, 2, 4, ... frames away
|
||||
/// (within the layer's in/out points) until the position differs from the current one, then
|
||||
/// bisects down to `dt` the moment the motion stopped (or started), so the heading kept is the
|
||||
/// one at the end of the motion whichever frame is shown.
|
||||
fn sampled_tangent(ctx: &EvalCtx, layer: &Layer, pos: &dyn Fn(effectcraft_time::Tick) -> Vec3, dt: effectcraft_time::Tick, earlier: bool) -> Option<Vec3> {
|
||||
let here = pos(ctx.time);
|
||||
let moved = |t| (pos(t) - here).length() > 1e-9;
|
||||
let limit = if earlier { layer.in_point.min(ctx.time) } else { layer.out_point.max(ctx.time) };
|
||||
let mut step = ctx.comp.frame_duration().max(dt);
|
||||
let (mut still, mut away) = (ctx.time, None);
|
||||
for _ in 0..MOTION_SEARCH_STEPS {
|
||||
let t = if earlier { (ctx.time - step).max(limit) } else { (ctx.time + step).min(limit) };
|
||||
if moved(t) {
|
||||
away = Some(t);
|
||||
break;
|
||||
}
|
||||
still = t;
|
||||
if t == limit {
|
||||
break;
|
||||
}
|
||||
step = step + step;
|
||||
}
|
||||
let mut away = away?;
|
||||
for _ in 0..MOTION_SEARCH_STEPS {
|
||||
if (still - away).abs() <= dt {
|
||||
break;
|
||||
}
|
||||
let mid = away + (still - away).mul_ratio(1, 2);
|
||||
if moved(mid) {
|
||||
away = mid;
|
||||
} else {
|
||||
still = mid;
|
||||
}
|
||||
}
|
||||
let d = if earlier { here - pos(away) } else { pos(away) - here };
|
||||
(d.length() > 1e-9 && d.length().is_finite()).then(|| d.normalize())
|
||||
}
|
||||
|
||||
/// Auto-Orient ▸ Orient Along Path for 2D layers: extra Z rotation in degrees.
|
||||
@@ -313,22 +361,27 @@ pub fn layer_camera(ctx: &EvalCtx, cam: &Layer) -> CameraState {
|
||||
let (eye, fwd, down) = layer_frame(ctx, cam);
|
||||
let opts = cam.props.sub("cameraOptions");
|
||||
let zoom = opts.map(|g| ctx.f(cam, g, "zoom", 1000.0)).unwrap_or(1000.0).max(1e-3);
|
||||
let dof = opts.filter(|g| ctx.b(cam, g, "dof")).map(|g| Dof {
|
||||
focus: ctx.f(cam, g, "focusDistance", zoom),
|
||||
aperture: ctx.f(cam, g, "aperture", default_aperture(w)).max(0.0),
|
||||
blur_level: ctx.f(cam, g, "blurLevel", 100.0).max(0.0) / 100.0,
|
||||
iris: super::bokeh::Iris {
|
||||
sides: super::bokeh::Iris::sides_of_shape(ctx.e(cam, g, "irisShape")),
|
||||
rotation: ctx.f(cam, g, "irisRotation", 0.0),
|
||||
roundness: ctx.f(cam, g, "irisRoundness", 0.0) / 100.0,
|
||||
aspect: ctx.f(cam, g, "irisAspectRatio", 1.0).max(0.01),
|
||||
fringe: ctx.f(cam, g, "irisDiffractionFringe", 0.0).max(0.0) / 100.0,
|
||||
},
|
||||
highlight: super::bokeh::Highlight {
|
||||
gain: ctx.f(cam, g, "highlightGain", 0.0).clamp(0.0, 100.0) / 100.0,
|
||||
threshold: ctx.f(cam, g, "highlightThreshold", 255.0).clamp(0.0, 255.0) / 255.0,
|
||||
saturation: ctx.f(cam, g, "highlightSaturation", 0.0).clamp(0.0, 100.0) / 100.0,
|
||||
},
|
||||
let dof = opts.filter(|g| ctx.b(cam, g, "dof")).map(|g| {
|
||||
// A non-finite Focus Distance would make every blur radius non-finite; use the value the
|
||||
// property defaults to (the zoom plane) instead.
|
||||
let focus = ctx.f(cam, g, "focusDistance", zoom);
|
||||
Dof {
|
||||
focus: if focus.is_finite() { focus } else { zoom },
|
||||
aperture: ctx.f(cam, g, "aperture", default_aperture(w)).max(0.0),
|
||||
blur_level: ctx.f(cam, g, "blurLevel", 100.0).max(0.0) / 100.0,
|
||||
iris: super::bokeh::Iris {
|
||||
sides: super::bokeh::Iris::sides_of_shape(ctx.e(cam, g, "irisShape")),
|
||||
rotation: ctx.f(cam, g, "irisRotation", 0.0),
|
||||
roundness: ctx.f(cam, g, "irisRoundness", 0.0) / 100.0,
|
||||
aspect: ctx.f(cam, g, "irisAspectRatio", 1.0).max(0.01),
|
||||
fringe: ctx.f(cam, g, "irisDiffractionFringe", 0.0).max(0.0) / 100.0,
|
||||
},
|
||||
highlight: super::bokeh::Highlight {
|
||||
gain: ctx.f(cam, g, "highlightGain", 0.0).clamp(0.0, 100.0) / 100.0,
|
||||
threshold: ctx.f(cam, g, "highlightThreshold", 255.0).clamp(0.0, 255.0) / 255.0,
|
||||
saturation: ctx.f(cam, g, "highlightSaturation", 0.0).clamp(0.0, 100.0) / 100.0,
|
||||
},
|
||||
}
|
||||
});
|
||||
CameraState { view: basis_view(eye, fwd, down), eye, zoom, ortho: false, dof }
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ use std::sync::Arc;
|
||||
use effectcraft_color::{BlendMode, blend_pixel};
|
||||
use effectcraft_effects::Buf;
|
||||
use effectcraft_geom::{Mat3, Mat4, Vec3, vec3};
|
||||
use effectcraft_project::{Layer, LightKind, MatteKind};
|
||||
use effectcraft_project::{Layer, LightKind, MatteKind, limits};
|
||||
use effectcraft_raster::{Image, Px, hash_noise};
|
||||
use effectcraft_time::Tick;
|
||||
use rayon::prelude::*;
|
||||
@@ -195,13 +195,13 @@ 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>> {
|
||||
pub(crate) fn prepare<'a>(r: &Renderer, ctx: &EvalCtx<'_>, 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, PlaneInput { buf, local: Mat4::IDENTITY })
|
||||
}
|
||||
|
||||
/// Prepare a layer as planes: one per character for per-character 3D text, else one.
|
||||
pub(crate) fn prepare_all<'a>(r: &Renderer, ctx: &EvalCtx<'a>, layer: &'a Layer, order: usize, out: (u32, u32), in_run: bool) -> Vec<Item<'a>> {
|
||||
pub(crate) fn prepare_all<'a>(r: &Renderer, ctx: &EvalCtx<'_>, layer: &'a Layer, order: usize, out: (u32, u32), in_run: bool) -> Vec<Item<'a>> {
|
||||
if crate::text::per_char_3d(ctx, layer) {
|
||||
return crate::text::per_char_planes(ctx, layer, r.opts.scale)
|
||||
.into_iter()
|
||||
@@ -218,7 +218,7 @@ struct PlaneInput {
|
||||
}
|
||||
|
||||
/// 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>> {
|
||||
fn prepare_with<'a>(r: &Renderer, ctx: &EvalCtx<'_>, 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;
|
||||
@@ -287,12 +287,45 @@ fn prepare_with<'a>(r: &Renderer, ctx: &EvalCtx<'a>, layer: &'a Layer, order: us
|
||||
}
|
||||
|
||||
/// Blur radius (buffer pixels) of every buffer pixel of a plane at `world` (layer → world), from
|
||||
/// its camera depth: the circle of confusion over the on-screen size of one layer pixel there.
|
||||
fn dof_radii(cam: &CameraState, dof: &super::camera::Dof, world: Mat4, buf: &Buf) -> Vec<f32> {
|
||||
dof_radii_at(cam, dof, world, (buf.img.width as usize, buf.img.height as usize), buf.offset, buf.scale)
|
||||
/// its camera depth: the circle of confusion over the on-screen size of one layer pixel there, at
|
||||
/// most `cap` ([`dof_radius_cap`]).
|
||||
fn dof_radii(cam: &CameraState, dof: &super::camera::Dof, world: Mat4, buf: &Buf, cap: f64) -> Vec<f32> {
|
||||
dof_radii_at(cam, dof, world, (buf.img.width as usize, buf.img.height as usize), buf.offset, buf.scale, cap)
|
||||
}
|
||||
|
||||
fn dof_radii_at(cam: &CameraState, dof: &super::camera::Dof, world: Mat4, (w, h): (usize, usize), offset: [f64; 2], scale: f64) -> Vec<f32> {
|
||||
/// Pixels a padded blur buffer may have: the ~16 million layer buffers are held to
|
||||
/// ([`crate::Renderer::raster_scale`]), or twice the buffer when that is larger, so a big buffer
|
||||
/// can still be blurred. The blur holds a few images of this size at once whatever its level
|
||||
/// count ([`super::bokeh::progressive_blur`]), so blurring a small plane costs no more memory than
|
||||
/// blurring a large layer.
|
||||
const DOF_PAD_BUDGET: f64 = 16.0e6;
|
||||
|
||||
/// Comp pixels per buffer pixel below which a plane isn't blurred. Such a plane covers almost no
|
||||
/// screen area (a 0%-scaled layer, one at an extreme distance), while its radius in buffer
|
||||
/// pixels grows without bound as it shrinks: blurring it would cost the most for the least.
|
||||
const DOF_MIN_SCREEN_SCALE: f64 = 1e-3;
|
||||
|
||||
/// The largest blur radius (buffer pixels) of a `w`×`h` buffer: the one whose padded copy just
|
||||
/// fits [`DOF_PAD_BUDGET`]. The circle of confusion has no bound of its own (a focus distance far
|
||||
/// beyond the scene, a near-zero layer scale), so memory sets the limit; ordinary bokeh stays far
|
||||
/// below it and is never clamped.
|
||||
pub(super) fn dof_radius_cap(dof: &super::camera::Dof, w: u32, h: u32) -> f64 {
|
||||
let (w, h) = (w as f64, h as f64);
|
||||
let budget = DOF_PAD_BUDGET.max(2.0 * w * h);
|
||||
// (w + 2p)·(h + 2p) = budget, solved for the padding p.
|
||||
let pad = (((w - h) * (w - h) + 4.0 * budget).sqrt() - (w + h)) / 4.0;
|
||||
((pad - 2.0) / iris_stretch(dof)).max(0.0)
|
||||
}
|
||||
|
||||
pub(super) fn dof_radii_at(
|
||||
cam: &CameraState,
|
||||
dof: &super::camera::Dof,
|
||||
world: Mat4,
|
||||
(w, h): (usize, usize),
|
||||
offset: [f64; 2],
|
||||
scale: f64,
|
||||
cap: f64,
|
||||
) -> Vec<f32> {
|
||||
let s = scale.max(1e-9);
|
||||
let sx = world.apply_vec(vec3(1.0, 0.0, 0.0)).length().max(1e-9);
|
||||
let at = |x: f64, y: f64| cam.depth(world.apply(vec3((x - offset[0]) / s, (y - offset[1]) / s, 0.0)));
|
||||
@@ -306,31 +339,48 @@ fn dof_radii_at(cam: &CameraState, dof: &super::camera::Dof, world: Mat4, (w, h)
|
||||
if z <= super::camera::NEAR {
|
||||
continue;
|
||||
}
|
||||
// coc · ½ / (zoom / z · sx) · scale
|
||||
*o = (dof.coc(z) * 0.5 * z / (cam.zoom * sx) * s) as f32;
|
||||
// coc · ½ / (zoom / z · sx) · scale. Infinity clamps to the cap like any large radius,
|
||||
// so the blur keeps growing with defocus; only NaN, which has no defocus, stays sharp.
|
||||
let r = dof.coc(z) * 0.5 * z / (cam.zoom * sx) * s;
|
||||
*o = if r.is_nan() || cam.zoom * sx / (z * s) < DOF_MIN_SCREEN_SCALE { 0.0 } else { r.min(cap) as f32 };
|
||||
}
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
/// How much the iris' aspect ratio stretches a blur radius along its longer axis.
|
||||
fn iris_stretch(dof: &super::camera::Dof) -> f64 {
|
||||
let a = dof.iris.aspect.clamp(0.01, 100.0).sqrt();
|
||||
a.max(1.0 / a)
|
||||
}
|
||||
|
||||
/// The padding [`dof_blur`] gives a buffer whose largest blur radius is `rmax`.
|
||||
fn dof_pad(dof: &super::camera::Dof, rmax: f64) -> u32 {
|
||||
let a = dof.iris.aspect.clamp(0.01, 100.0).sqrt().max(1.0 / dof.iris.aspect.clamp(0.01, 100.0).sqrt());
|
||||
(rmax * a).ceil() as u32 + 2
|
||||
((rmax * iris_stretch(dof)).ceil() as u32).saturating_add(2)
|
||||
}
|
||||
|
||||
/// The size of `img` padded by `pad` on every side, or `None` past [`limits::MAX_SIZE`] on a side:
|
||||
/// [`DOF_PAD_BUDGET`] bounds the area, so a long thin buffer could still outgrow a side.
|
||||
fn padded_size(img: &Image, pad: u32) -> Option<(u32, u32)> {
|
||||
let grow = |n: u32| n.saturating_add(pad.saturating_mul(2));
|
||||
let (w, h) = (grow(img.width), grow(img.height));
|
||||
(w <= limits::MAX_SIZE && h <= limits::MAX_SIZE).then_some((w, h))
|
||||
}
|
||||
|
||||
/// [`dof_blur`] planned for an accelerator: the padding, radius map and levels, and the padded
|
||||
/// buffer's layer-space bounds. `None` when nothing blurs.
|
||||
fn dof_plan(cam: &CameraState, dof: &super::camera::Dof, world: Mat4, buf: &Buf) -> Option<(PlaneDof, [f64; 4])> {
|
||||
let radii = dof_radii(cam, dof, world, buf);
|
||||
let cap = dof_radius_cap(dof, buf.img.width, buf.img.height);
|
||||
let radii = dof_radii(cam, dof, world, buf, cap);
|
||||
let rmax = radii.iter().copied().fold(0.0f32, f32::max) as f64;
|
||||
if rmax <= 0.3 {
|
||||
return None;
|
||||
}
|
||||
let pad = dof_pad(dof, rmax);
|
||||
let (w, h) = (buf.img.width as usize + 2 * pad as usize, buf.img.height as usize + 2 * pad as usize);
|
||||
let (wp, hp) = padded_size(&buf.img, pad)?;
|
||||
let (w, h) = (wp as usize, hp as usize);
|
||||
let offset = [buf.offset[0] + pad as f64, buf.offset[1] + pad as f64];
|
||||
let radius = dof_radii_at(cam, dof, world, (w, h), offset, buf.scale);
|
||||
let radius = dof_radii_at(cam, dof, world, (w, h), offset, buf.scale, cap);
|
||||
let levels = super::bokeh::blur_levels(&radius)?;
|
||||
let s = buf.scale.max(1e-9);
|
||||
let bounds = [-offset[0] / s, -offset[1] / s, (w as f64 - offset[0]) / s, (h as f64 - offset[1]) / s];
|
||||
@@ -339,16 +389,20 @@ fn dof_plan(cam: &CameraState, dof: &super::camera::Dof, world: Mat4, buf: &Buf)
|
||||
|
||||
/// Depth of field for one plane: highlights boosted, then a bokeh-shaped blur by depth.
|
||||
fn dof_blur(cam: &CameraState, dof: &super::camera::Dof, world: Mat4, buf: Arc<Buf>) -> Arc<Buf> {
|
||||
let radii = dof_radii(cam, dof, world, &buf);
|
||||
let cap = dof_radius_cap(dof, buf.img.width, buf.img.height);
|
||||
let radii = dof_radii(cam, dof, world, &buf, cap);
|
||||
let rmax = radii.iter().copied().fold(0.0f32, f32::max) as f64;
|
||||
if rmax <= 0.3 {
|
||||
return buf;
|
||||
}
|
||||
let pad = dof_pad(dof, rmax);
|
||||
if padded_size(&buf.img, pad).is_none() {
|
||||
return buf;
|
||||
}
|
||||
let mut padded = buf.img.padded(pad);
|
||||
super::bokeh::boost_highlights(&mut padded, &dof.highlight);
|
||||
let pbuf = Buf { img: padded, offset: [buf.offset[0] + pad as f64, buf.offset[1] + pad as f64], scale: buf.scale };
|
||||
let pr = dof_radii(cam, dof, world, &pbuf);
|
||||
let pr = dof_radii(cam, dof, world, &pbuf, cap);
|
||||
// The (cached, shared) layer buffer stays untouched; the blurred copy is per frame.
|
||||
Arc::new(Buf { img: super::bokeh::progressive_blur(&pbuf.img, &dof.iris, &pr), offset: pbuf.offset, scale: pbuf.scale })
|
||||
}
|
||||
@@ -449,8 +503,8 @@ fn gather<'a>(r: &Renderer<'a>, ctx: &EvalCtx<'a>, run: &[&'a Layer], out: (u32,
|
||||
.flat_map_iter(|(i, l)| {
|
||||
// Stack order with room for a collapsed precomp's layers in between.
|
||||
let i = i * 1024;
|
||||
if let Some(item) = r.collapsed(ctx, l) {
|
||||
return collapsed_items(r, ctx, l, item, i, out);
|
||||
if let Some(collapsed) = r.collapsed(ctx, l) {
|
||||
return collapsed_items(r, ctx, l, &collapsed, i, out);
|
||||
}
|
||||
let mut its = prepare_all(r, ctx, l, i, out, true);
|
||||
if !its.is_empty() {
|
||||
@@ -483,20 +537,13 @@ fn gather<'a>(r: &Renderer<'a>, ctx: &EvalCtx<'a>, run: &[&'a Layer], out: (u32,
|
||||
/// 3D space (world = precomp layer's world × nested world), depth-sorted with the run, so they
|
||||
/// intersect the parent's 3D layers; nested 2D layers lie on the precomp layer's plane in stack
|
||||
/// order. (Nested adjustment layers are skipped here.)
|
||||
fn collapsed_items<'a>(
|
||||
r: &Renderer<'a>,
|
||||
ctx: &EvalCtx<'a>,
|
||||
layer: &'a Layer,
|
||||
item: effectcraft_project::ItemId,
|
||||
order: usize,
|
||||
out: (u32, u32),
|
||||
) -> Vec<Item<'a>> {
|
||||
fn collapsed_items<'a>(r: &Renderer<'a>, ctx: &EvalCtx<'a>, layer: &'a Layer, collapsed: &crate::Collapsed, order: usize, out: (u32, u32)) -> Vec<Item<'a>> {
|
||||
let op = ctx.opacity(layer) as f32 * r.opacity_mul();
|
||||
let Some((sub, nctx)) = r.collapse_into(ctx, layer, item, op) else { return vec![] };
|
||||
let Some((sub, nctx)) = r.collapse_into(ctx, layer, collapsed, op) else { return vec![] };
|
||||
let t = nctx.time;
|
||||
let any_solo = nctx.comp.any_solo();
|
||||
let nested: Vec<&'a Layer> = nctx
|
||||
.comp
|
||||
let Some(nc) = r.project.comp(collapsed.item) else { return vec![] };
|
||||
let any_solo = nc.any_solo();
|
||||
let nested: Vec<&'a Layer> = nc
|
||||
.layers
|
||||
.iter()
|
||||
.rev()
|
||||
@@ -645,6 +692,13 @@ fn shade_texel(it: &Item, g: &Geo, u: f64, v: f64, t: Px, lights: &[LightState],
|
||||
[lit[0] * a, lit[1] * a, lit[2] * a, a]
|
||||
}
|
||||
|
||||
/// Samples per side of the Shadow Diffusion kernel for a penumbra radius of `r_px` caster pixels:
|
||||
/// odd, at least 3, and enough that neighbouring samples are at most 1.5 pixels apart, up to 15.
|
||||
pub(crate) fn shadow_taps(r_px: f64) -> u32 {
|
||||
let n = ((2.0 * r_px / 1.5).ceil() as u32).clamp(3, 15);
|
||||
n | 1
|
||||
}
|
||||
|
||||
/// RGB transmittance of light `l` reaching `p` past caster `c` (1 = unoccluded).
|
||||
fn occlusion(c: &Item, p: Vec3, l: &LightState) -> [f32; 3] {
|
||||
const FAR: f64 = 1.0e6;
|
||||
@@ -672,11 +726,17 @@ fn occlusion(c: &Item, p: Vec3, l: &LightState) -> [f32; 3] {
|
||||
if r * c.buf.scale < 0.5 {
|
||||
px = c.texel(hit.x, hit.y);
|
||||
} else {
|
||||
for j in -1..=1 {
|
||||
for i in -1..=1 {
|
||||
let q = c.texel(hit.x + i as f64 * r * 0.66, hit.y + j as f64 * r * 0.66);
|
||||
// Box filter over the penumbra square: n × n cell centres, dense enough that a wide
|
||||
// diffusion blurs the caster instead of stamping copies of it (#2444).
|
||||
let n = shadow_taps(r * c.buf.scale);
|
||||
let step = 2.0 * r / n as f64;
|
||||
let h = (n / 2) as i32;
|
||||
let w = 1.0 / (n * n) as f32;
|
||||
for j in -h..=h {
|
||||
for i in -h..=h {
|
||||
let q = c.texel(hit.x + f64::from(i) * step, hit.y + f64::from(j) * step);
|
||||
for k in 0..4 {
|
||||
px[k] += q[k] / 9.0;
|
||||
px[k] += q[k] * w;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -850,7 +910,7 @@ pub(crate) fn aux_pass(r: &Renderer, ctx: &EvalCtx) -> effectcraft_raster::AuxCh
|
||||
if nv[2] > 0.0 {
|
||||
nv = nv.map(|v| -v);
|
||||
}
|
||||
let (sw, sh) = crate::source_size(r.project, it.layer);
|
||||
let (sw, sh) = ctx.source_size(it.layer);
|
||||
let size = if sw == 0 { [ctx.comp.width as f64, ctx.comp.height as f64] } else { [sw as f64, sh as f64] };
|
||||
(number(it.layer), source_id(it.layer), crypto_hash(&it.layer.name), crypto_hash(&source_name(it.layer)), nv, size)
|
||||
})
|
||||
|
||||
@@ -13,10 +13,15 @@ const W: u32 = 200;
|
||||
const H: u32 = 100;
|
||||
|
||||
fn setup() -> (Project, ItemId, Comp) {
|
||||
setup_sized(W, H)
|
||||
}
|
||||
|
||||
/// [`setup`] with a `w`×`h` comp.
|
||||
fn setup_sized(w: u32, h: u32) -> (Project, ItemId, Comp) {
|
||||
let mut p = Project::default();
|
||||
// Exact float maths; 8/16 bpc quantisation has its own tests (tests_color).
|
||||
p.settings.bit_depth = effectcraft_project::BitDepth::Bpc32;
|
||||
let comp = Comp::new(W, H, FrameRate::FPS_30, Tick::from_seconds_f64(2.0));
|
||||
let comp = Comp::new(w, h, FrameRate::FPS_30, Tick::from_seconds_f64(2.0));
|
||||
let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.clone().into()));
|
||||
(p, cid, comp)
|
||||
}
|
||||
@@ -344,10 +349,37 @@ fn shadow_darkness_and_accepts_off() {
|
||||
assert!(close(v, 0.5, 1e-3), "no shadow accepted: {v}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shadow_diffusion_is_a_soft_ramp_not_copies() {
|
||||
// #2444: a wide Shadow Diffusion stamped 3 × 3 copies of the caster, so the penumbra stepped
|
||||
// through three flat levels instead of ramping smoothly from lit to shadowed.
|
||||
let (mut p, cid, comp) = setup();
|
||||
let mut recv = solid3(&mut p, &comp, [1.0, 1.0, 1.0], 400, 200, [100.0, 50.0, 100.0]);
|
||||
no_specular(&mut recv);
|
||||
let mut caster = solid3(&mut p, &comp, [1.0, 1.0, 1.0], 100, 200, [40.0, 50.0, 0.0]);
|
||||
set(&mut caster, "materialOptions/castsShadows", Value::Enum(2)); // Only: invisible caster
|
||||
let mut l = light(&mut p, &comp, LightKind::Parallel, [100.0, 50.0, -500.0]);
|
||||
set(&mut l, "transform/poi", Value::Vec3([100.0, 50.0, 0.0]));
|
||||
set(&mut l, "lightOptions/castsShadows", Value::Bool(true));
|
||||
// 100 px to the caster over the parallel light's 1000: a 15 px penumbra radius.
|
||||
set(&mut l, "lightOptions/shadowDiffusion", Value::Scalar(150.0));
|
||||
push(&mut p, cid, l);
|
||||
push(&mut p, cid, caster);
|
||||
push(&mut p, cid, recv);
|
||||
let img = render(&p, cid);
|
||||
let row: Vec<f32> = (0..i64::from(W)).map(|x| img.get(x, 50)[0]).collect();
|
||||
// The row crosses both vertical edges of the caster's shadow: lit, ramp, shadowed, ramp, lit.
|
||||
assert!(row.iter().any(|v| *v < 0.01) && close(row[W as usize - 1], 0.5, 1e-3), "shadow and light: {row:?}");
|
||||
let ramp = row.iter().filter(|v| **v > 0.02 && **v < 0.48).count();
|
||||
assert!(ramp >= 16, "two penumbras several pixels wide: {row:?}");
|
||||
let step = row.windows(2).map(|w| (w[1] - w[0]).abs()).fold(0.0f32, f32::max);
|
||||
assert!(step < 0.08, "no jumps between copies of the caster (largest step {step}): {row:?}");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- cameras
|
||||
|
||||
fn camera(p: &mut Project, comp: &Comp) -> Layer {
|
||||
build::layer(p, comp, "Camera", LayerSource::Camera, (W, H), None)
|
||||
build::layer(p, comp, "Camera", LayerSource::Camera, (comp.width, comp.height), None)
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -423,9 +455,155 @@ fn depth_of_field_blurs_out_of_focus_layers() {
|
||||
assert!(b.get(66, 50)[3] > 0.01);
|
||||
}
|
||||
|
||||
/// A non-finite Focus Distance doesn't reach the blur: the camera focuses on the property's
|
||||
/// default plane (`zoom`) instead.
|
||||
#[test]
|
||||
fn dof_falls_back_when_focus_distance_is_not_finite() {
|
||||
for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
|
||||
let (mut p, cid, comp) = setup();
|
||||
let mut cam = camera(&mut p, &comp);
|
||||
set(&mut cam, "cameraOptions/dof", Value::Bool(true));
|
||||
set(&mut cam, "cameraOptions/aperture", Value::Scalar(60.0));
|
||||
set(&mut cam, "cameraOptions/focusDistance", Value::Scalar(bad));
|
||||
let s = solid3(&mut p, &comp, [1.0, 1.0, 1.0], 60, 60, [100.0, 50.0, 0.0]);
|
||||
push(&mut p, cid, cam);
|
||||
push(&mut p, cid, s);
|
||||
let cp = p.comp(cid).unwrap();
|
||||
let ctx = crate::EvalCtx::new(&p, cid, cp, Tick::ZERO);
|
||||
let cs = crate::three_d::camera::layer_camera(&ctx, &cp.layers[0]);
|
||||
let f = cs.dof.unwrap().focus;
|
||||
assert!((f - zoom()).abs() < 1e-9, "{bad}: evaluated to {f}, expected the fallback to zoom {}", zoom());
|
||||
// The z = 0 plane is in focus: hard edge, finite pixels.
|
||||
let img = render(&p, cid);
|
||||
assert!(img.get(71, 50)[3] > 0.99 && img.get(69, 50)[3] < 0.01, "{bad}: the edge should be hard");
|
||||
assert!(img.data.iter().all(|px| px.iter().all(|c| c.is_finite())), "{bad}: non-finite pixels");
|
||||
}
|
||||
}
|
||||
|
||||
/// The Classic 3D blur stays bounded for degenerate inputs: a 0%-scaled 3D layer off the focal
|
||||
/// plane (e.g. the first frame of a scale-in) or a huge but finite Focus Distance asks for a
|
||||
/// radius of billions of buffer pixels, whose padded buffer can't be allocated.
|
||||
#[test]
|
||||
fn dof_blur_radius_is_bounded_for_degenerate_inputs() {
|
||||
let scene = |focus: f64, scale: f64| {
|
||||
let (mut p, cid, comp) = setup();
|
||||
let mut cam = camera(&mut p, &comp);
|
||||
set(&mut cam, "cameraOptions/dof", Value::Bool(true));
|
||||
set(&mut cam, "cameraOptions/aperture", Value::Scalar(60.0));
|
||||
set(&mut cam, "cameraOptions/focusDistance", Value::Scalar(focus));
|
||||
let mut s = solid3(&mut p, &comp, [1.0, 1.0, 1.0], 60, 60, [100.0, 50.0, 0.0]);
|
||||
set(&mut s, "transform/scale", Value::Vec3([scale, scale, 100.0]));
|
||||
push(&mut p, cid, cam);
|
||||
push(&mut p, cid, s);
|
||||
render(&p, cid)
|
||||
};
|
||||
let finite = |img: &effectcraft_raster::Image| img.data.iter().all(|px| px.iter().all(|c| c.is_finite()));
|
||||
// A 0%-scaled layer 10 px off the focal plane: an ordinary defocus times 1/sx (sx = 1e-9).
|
||||
assert!(finite(&scene(zoom() + 10.0, 0.0)), "zero-scaled: non-finite pixels");
|
||||
// Huge but finite Focus Distances (typed in, or a large expression result): the blur is as
|
||||
// large as the buffer budget allows, never refused (which would render the layer sharp).
|
||||
// 1e307 overflows the circle of confusion itself to infinity: still the largest blur.
|
||||
for focus in [1.0e12, 1.0e307] {
|
||||
let img = scene(focus, 100.0);
|
||||
assert!(finite(&img), "focus {focus:e}: non-finite pixels");
|
||||
assert!(img.get(100, 50)[3] < 0.5, "focus {focus:e}: the layer should be spread out, not sharp ({})", img.get(100, 50)[3]);
|
||||
}
|
||||
}
|
||||
|
||||
/// The blur radius map of a plane: a 0%-scaled plane is degenerate on screen and isn't blurred;
|
||||
/// any other radius stays within the buffer budget's cap.
|
||||
#[test]
|
||||
fn dof_radii_skip_degenerate_planes_and_respect_the_cap() {
|
||||
let radii = |focus: f64, scale: f64| {
|
||||
let (mut p, cid, comp) = setup();
|
||||
let mut cam = camera(&mut p, &comp);
|
||||
set(&mut cam, "cameraOptions/dof", Value::Bool(true));
|
||||
set(&mut cam, "cameraOptions/aperture", Value::Scalar(60.0));
|
||||
set(&mut cam, "cameraOptions/focusDistance", Value::Scalar(focus));
|
||||
let mut s = solid3(&mut p, &comp, [1.0, 1.0, 1.0], 60, 60, [100.0, 50.0, 0.0]);
|
||||
set(&mut s, "transform/scale", Value::Vec3([scale, scale, 100.0]));
|
||||
push(&mut p, cid, cam);
|
||||
push(&mut p, cid, s);
|
||||
let cp = p.comp(cid).unwrap();
|
||||
let ctx = crate::EvalCtx::new(&p, cid, cp, Tick::ZERO);
|
||||
let cs = super::camera::layer_camera(&ctx, &cp.layers[0]);
|
||||
let dof = cs.dof.unwrap();
|
||||
let cap = super::compose::dof_radius_cap(&dof, 60, 60);
|
||||
(super::compose::dof_radii_at(&cs, &dof, ctx.world_matrix(&cp.layers[1]), (60, 60), [0.0; 2], 1.0, cap), cap)
|
||||
};
|
||||
let (r, _) = radii(zoom() + 10.0, 0.0);
|
||||
assert!(r.iter().all(|&r| r == 0.0), "a 0%-scaled plane is not blurred");
|
||||
let (r, cap) = radii(1.0e12, 100.0);
|
||||
assert!(cap > 1000.0, "a 60×60 buffer may blur by well over 1000 px: {cap}");
|
||||
assert!(r.iter().all(|&r| r as f64 <= cap && r as f64 > cap - 1.0), "huge focus: every radius at the cap {cap}");
|
||||
}
|
||||
|
||||
/// Two layers that look the same on screen (400×100 at 100%, 1600×400 at 25%) at the same depth
|
||||
/// blur by the same amount on screen: the radius bound must not depend on the layer's scale.
|
||||
#[test]
|
||||
fn dof_blur_is_independent_of_layer_scale() {
|
||||
let ramp = |w: u32, h: u32, scale: f64| {
|
||||
let (mut p, cid, comp) = setup_sized(800, 400);
|
||||
let zoom = 800.0 * 50.0 / 36.0;
|
||||
let mut cam = camera(&mut p, &comp);
|
||||
set(&mut cam, "cameraOptions/dof", Value::Bool(true));
|
||||
set(&mut cam, "cameraOptions/aperture", Value::Scalar(200.0));
|
||||
// coc = aperture·|z − f| / z: a 40 px comp radius on the z = 0 plane (depth = zoom).
|
||||
set(&mut cam, "cameraOptions/focusDistance", Value::Scalar(zoom + 2.0 * 40.0 * zoom / 200.0));
|
||||
let mut s = solid3(&mut p, &comp, [1.0, 1.0, 1.0], w, h, [400.0, 200.0, 0.0]);
|
||||
set(&mut s, "transform/scale", Value::Vec3([scale, scale, 100.0]));
|
||||
push(&mut p, cid, cam);
|
||||
push(&mut p, cid, s);
|
||||
let img = render(&p, cid);
|
||||
soft_count(&img, (0..400).map(|x| (x, 200)))
|
||||
};
|
||||
let (full, quarter) = (ramp(400, 100, 100.0), ramp(1600, 400, 25.0));
|
||||
assert!(full > 60, "the out-of-focus edge should be soft: {full}");
|
||||
assert!(full.abs_diff(quarter) <= 2, "same on-screen layer, same depth: {full} px at 100% vs {quarter} px at 25%");
|
||||
}
|
||||
|
||||
/// The same frame at Full and Half resolution shows the same blur (in comp pixels). The radius
|
||||
/// (~100 px) is large so that any bound that depends on the resolution would show.
|
||||
#[test]
|
||||
fn dof_blur_matches_across_render_resolutions() {
|
||||
let (mut p, cid, comp) = setup_sized(800, 400);
|
||||
let zoom = 800.0 * 50.0 / 36.0;
|
||||
let mut cam = camera(&mut p, &comp);
|
||||
set(&mut cam, "cameraOptions/dof", Value::Bool(true));
|
||||
set(&mut cam, "cameraOptions/aperture", Value::Scalar(200.0));
|
||||
set(&mut cam, "cameraOptions/focusDistance", Value::Scalar(2.0 * zoom));
|
||||
let s = solid3(&mut p, &comp, [1.0, 1.0, 1.0], 400, 100, [400.0, 200.0, 0.0]);
|
||||
push(&mut p, cid, cam);
|
||||
push(&mut p, cid, s);
|
||||
let ramp = |scale: f64| {
|
||||
let img = render_frame(&p, cid, Tick::ZERO, scale);
|
||||
let (w, h) = (img.width as i64, img.height as i64);
|
||||
soft_count(&img, (0..w / 2).map(|x| (x, h / 2))) as f64 / scale
|
||||
};
|
||||
let (full, half) = (ramp(1.0), ramp(0.5));
|
||||
assert!(full > 150.0, "a ~100 px radius must not be clamped: {full} comp px");
|
||||
assert!((full - half).abs() <= 4.0, "Full {full} vs Half {half} comp px");
|
||||
}
|
||||
|
||||
/// `Dof::coc` treats a non-finite focus or depth as in focus.
|
||||
#[test]
|
||||
fn dof_coc_is_zero_for_non_finite_inputs() {
|
||||
let d = |focus: f64| super::camera::Dof { focus, aperture: 50.0, blur_level: 1.0, iris: Default::default(), highlight: Default::default() };
|
||||
for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
|
||||
assert_eq!(d(bad).coc(500.0), 0.0, "focus {bad}");
|
||||
assert_eq!(d(400.0).coc(bad), 0.0, "depth {bad}");
|
||||
}
|
||||
assert!(d(400.0).coc(500.0) > 0.0);
|
||||
}
|
||||
|
||||
/// Pixels among `pts` whose alpha is strictly between 0.05 and 0.95.
|
||||
fn soft_count(img: &effectcraft_raster::Image, pts: impl Iterator<Item = (i64, i64)>) -> usize {
|
||||
pts.filter(|&(x, y)| (0.05..0.95).contains(&img.get(x, y)[3])).count()
|
||||
}
|
||||
|
||||
/// Pixels in column `x` whose alpha is strictly between 0.05 and 0.95.
|
||||
fn soft_px(img: &effectcraft_raster::Image, x: i64) -> usize {
|
||||
(0..img.height as i64).filter(|&y| (0.05..0.95).contains(&img.get(x, y)[3])).count()
|
||||
soft_count(img, (0..img.height as i64).map(|y| (x, y)))
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -8,6 +8,7 @@ use effectcraft_project::{AutoOrient, Comp, ItemId, ItemKind, Layer, LayerSource
|
||||
use effectcraft_time::{FrameRate, Tick};
|
||||
|
||||
use super::active_camera;
|
||||
use super::camera::auto_orient_2d;
|
||||
use crate::EvalCtx;
|
||||
|
||||
fn setup() -> (Project, ItemId, Comp) {
|
||||
@@ -78,6 +79,69 @@ fn orient_along_path_keeps_the_last_heading_at_and_after_the_end() {
|
||||
}
|
||||
}
|
||||
|
||||
/// #1650: the same end-of-path flip when Position has no keyframes of its own and is driven by
|
||||
/// an expression (e.g. `pointOnPath()` fed by a keyframed slider on another layer): once the
|
||||
/// motion stops there are no Position keys to fall back on, and the heading snapped to 0 degrees.
|
||||
#[test]
|
||||
fn orient_along_path_keeps_the_last_heading_when_position_is_an_expression() {
|
||||
/// Stand-in for an expression: holds, moves 100 px left over 0.5..1 s, then holds again (like
|
||||
/// a slider going from 0% to 100%).
|
||||
struct LeftThenHold;
|
||||
impl crate::ExprHost for LeftThenHold {
|
||||
fn eval(&self, ctx: &EvalCtx, _: &Layer, prop: &effectcraft_project::Property, value: &Value) -> Result<Value, String> {
|
||||
if prop.match_id != "position" {
|
||||
return Ok(value.clone());
|
||||
}
|
||||
let s = ((ctx.time.seconds() - 0.5) * 2.0).clamp(0.0, 1.0);
|
||||
Ok(Value::Vec3([150.0 - 100.0 * s, 50.0, 0.0]))
|
||||
}
|
||||
}
|
||||
for three_d in [false, true] {
|
||||
let (mut p, cid, comp) = setup();
|
||||
let mut l = solid(&mut p, &comp, three_d, [150.0, 50.0, 0.0]);
|
||||
l.auto_orient = AutoOrient::AlongPath;
|
||||
let pos = l.props.prop_mut("transform/position").unwrap();
|
||||
assert!(pos.keys.is_empty(), "the fixture must have no Position keyframes");
|
||||
pos.expr = Some(effectcraft_project::Expression { text: "left then hold".into(), enabled: true });
|
||||
p.comp_mut(cid).unwrap().layers.push(l);
|
||||
let c = p.comp(cid).unwrap();
|
||||
for secs in [0.2, 0.75, 1.0, 1.0 + 1.0 / 30.0, 1.2, 1.9] {
|
||||
let ctx = EvalCtx { expr: Some(&LeftThenHold), ..EvalCtx::new(&p, cid, c, Tick::from_seconds_f64(secs)) };
|
||||
let x_axis = ctx.world_matrix(&c.layers[0]).apply_vec(vec3(1.0, 0.0, 0.0));
|
||||
// Moving left (-x): the layer's x axis points left before the motion starts and after it stops.
|
||||
assert!((x_axis.x + 1.0).abs() < 1e-6 && x_axis.y.abs() < 1e-6 && x_axis.z.abs() < 1e-6, "3d={three_d} t={secs}: {x_axis:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// #1650 on a curved path: the heading kept once an expression stops moving is the one at the end
|
||||
/// of the motion, the same on every later frame, not whatever earlier point a coarse search lands on.
|
||||
#[test]
|
||||
fn orient_along_a_curved_expression_path_keeps_the_end_heading() {
|
||||
/// Quarter circle over 0..1 s: heading 0 degrees at the start, 90 degrees (down) at the end.
|
||||
struct Arc;
|
||||
impl crate::ExprHost for Arc {
|
||||
fn eval(&self, ctx: &EvalCtx, _: &Layer, prop: &effectcraft_project::Property, value: &Value) -> Result<Value, String> {
|
||||
if prop.match_id != "position" {
|
||||
return Ok(value.clone());
|
||||
}
|
||||
let a = ctx.time.seconds().clamp(0.0, 1.0) * std::f64::consts::FRAC_PI_2;
|
||||
Ok(Value::Vec3([100.0 * a.sin(), 100.0 - 100.0 * a.cos(), 0.0]))
|
||||
}
|
||||
}
|
||||
let (mut p, cid, comp) = setup();
|
||||
let mut l = solid(&mut p, &comp, false, [0.0, 0.0, 0.0]);
|
||||
l.auto_orient = AutoOrient::AlongPath;
|
||||
l.props.prop_mut("transform/position").unwrap().expr = Some(effectcraft_project::Expression { text: "arc".into(), enabled: true });
|
||||
p.comp_mut(cid).unwrap().layers.push(l);
|
||||
let c = p.comp(cid).unwrap();
|
||||
for frame in [31, 33, 37, 45, 59] {
|
||||
let ctx = EvalCtx { expr: Some(&Arc), ..EvalCtx::new(&p, cid, c, Tick::from_seconds_f64(frame as f64 / 30.0)) };
|
||||
let deg = auto_orient_2d(&ctx, &c.layers[0]);
|
||||
assert!((deg - 90.0).abs() < 0.5, "frame {frame}: {deg}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layers_parented_to_a_camera_follow_its_rotation() {
|
||||
let (mut p, cid, comp) = setup();
|
||||
|
||||
Reference in New Issue
Block a user