Merge enhance/filmcraft-avi-backend into enhance/avi-import

# Conflicts:
#	crates/export/tests/it/movie_bytes.rs
This commit is contained in:
tylerjenningsw
2026-10-29 18:58:57 -04:00
272 changed files with 13767 additions and 1294 deletions
+5 -4
View File
@@ -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
View File
@@ -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]> {
+20 -2
View File
@@ -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 {
@@ -605,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.
@@ -629,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());
+65 -1
View File
@@ -3,7 +3,7 @@
//! 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<()> {
@@ -63,11 +63,68 @@ 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, None)) && super::has_expression(&layer.props) {
return None;
@@ -76,6 +133,9 @@ impl Dependencies<'_> {
// 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 {
+6 -3
View File
@@ -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()
@@ -903,7 +903,8 @@ 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)
}
@@ -942,13 +943,15 @@ fn comp_content_key_for_renderer(
}
}
}
// 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 {
+64 -10
View File
@@ -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()
@@ -145,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 };
@@ -237,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 {
@@ -248,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)> {
@@ -321,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])
+2 -2
View File
@@ -1,6 +1,6 @@
use super::footage::NoFootage;
use super::opts::RenderOpts;
use super::{EvalCtx, Footage, FrameBlend, FrameRate, Image, ItemId, Layer, LayerSource, Project, Renderer, TICKS_PER_SECOND, Tick, shapes, source_size, text};
use super::{EvalCtx, Footage, FrameBlend, FrameRate, Image, ItemId, Layer, LayerSource, Project, Renderer, TICKS_PER_SECOND, Tick, shapes, text};
/// The layer's frame blending mode, if the comp's Enable Frame Blending switch is on.
/// One field of an interlaced frame as a full frame: the field's lines (`parity` 0 = even /
@@ -132,7 +132,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])
}
}
+11 -2
View File
@@ -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> {
+2 -2
View File
@@ -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 {
+1
View File
@@ -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
View File
@@ -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");
}
}
+141 -16
View File
@@ -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;
@@ -637,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;
@@ -645,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);
@@ -655,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)))),
@@ -836,6 +937,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)
+4 -1
View File
@@ -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 {
+22
View File
@@ -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| {
+250
View File
@@ -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;
@@ -490,6 +491,64 @@ fn particle_world_with_a_birth_rate_expression_is_not_served_from_the_layer_cach
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 {
@@ -1217,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");
}
+37 -1
View File
@@ -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))];
+61 -1
View File
@@ -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
+251
View File
@@ -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);
}
}
+10 -1
View File
@@ -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))
};
+22 -4
View File
@@ -42,7 +42,8 @@ pub struct CharXf {
pub stroke_hsb: [f64; 3],
pub stroke_opacity: f64,
pub stroke_width: f64,
/// Tracking (pixels) 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.
@@ -509,6 +510,7 @@ pub struct PlacedGlyph {
pub fill: [f32; 4],
pub stroke: [f32; 4],
pub stroke_width: f64,
pub join: effectcraft_path::Join,
/// Faux Bold (or synthetic bold for a family without a bold face): how far the outline grows
/// on every side, in character space (0 when not bold).
pub bold: f64,
@@ -694,6 +696,9 @@ 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];
// 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 + vadv[gi];
@@ -804,9 +809,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));
@@ -831,6 +839,7 @@ pub fn text_geom(ctx: &EvalCtx, layer: &Layer) -> Option<TextGeom> {
fill,
stroke,
stroke_width: (base_stroke_w + x.stroke_width).max(0.0),
join: path_join(doc.line_join),
bold: if g.synth_bold { FAUX_BOLD_RADIUS * g.size } else { 0.0 },
apply_fill: st.apply_fill,
char_index: g.char_index,
@@ -846,6 +855,15 @@ pub fn glyph_paths(ctx: &EvalCtx, layer: &Layer) -> Vec<(BezPath, CharXf)> {
text_geom(ctx, layer).map(|t| t.glyphs.iter().map(|g| (g.path(), g.xf)).collect()).unwrap_or_default()
}
fn path_join(j: effectcraft_keyframe::LineJoin) -> effectcraft_path::Join {
use effectcraft_keyframe::LineJoin;
match j {
LineJoin::Miter => effectcraft_path::Join::Miter,
LineJoin::Round => effectcraft_path::Join::Round,
LineJoin::Bevel => effectcraft_path::Join::Bevel,
}
}
/// One paint pass of a glyph: fill (false) or stroke (true).
type Pass = (usize, bool);
@@ -912,7 +930,7 @@ fn raster_pass(g: &PlacedGlyph, stroke: bool, paths: &[BezPath], m: &Mat3, rect:
}
outer
} else if stroke {
let st = StrokeStyle { width: g.stroke_width, join: effectcraft_path::Join::Round, ..Default::default() };
let st = StrokeStyle { width: g.stroke_width, join: g.join, ..Default::default() };
effectcraft_path::stroke_coverage(paths, &st, &mm, w, h)
} else {
grown_coverage(paths, &mm, w, h, bold)
+35 -2
View File
@@ -222,7 +222,7 @@ fn tracking_line_anchor_and_character_offset() {
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(20.0));
let w1 = bounds_of(&glyph_paths(&ctx(&p, cid, 0.0), &p.comp(cid).unwrap().layers[0]));
// 4 chars with 20 px tracking: 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,7 +262,7 @@ 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 with 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, 20.0)));
@@ -504,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);
@@ -567,6 +598,7 @@ fn touching_glyphs_join_without_a_seam() {
fill,
stroke: [0.0; 4],
stroke_width: 0.0,
join: effectcraft_path::Join::Miter,
bold: 0.0,
apply_fill: true,
char_index: 0,
@@ -637,6 +669,7 @@ fn faux_bold_under_a_non_uniform_scale() {
fill: [1.0; 4],
stroke: [0.0; 4],
stroke_width: 0.0,
join: effectcraft_path::Join::Miter,
bold: 2.0,
apply_fill: true,
char_index: 0,
+5 -5
View File
@@ -572,7 +572,7 @@ pub fn extruded_meshes(ctx: &EvalCtx, layer: &Layer, p: &model::extrude::Extrude
groups.push((vec![g.local.clone()], c, g.m, usize::from(!stroke_on_top), g.xf.surface_colors(c)));
}
if g.stroke_width > 0.0 && g.stroke[3] * op > 0.0 {
let st = effectcraft_path::StrokeStyle { width: g.stroke_width, join: effectcraft_path::Join::Round, ..Default::default() };
let st = effectcraft_path::StrokeStyle { width: g.stroke_width, join: g.join, ..Default::default() };
if let Some(region) = crate::shapes::stroke_region(std::slice::from_ref(&g.local), &st) {
let c = [g.stroke[0], g.stroke[1], g.stroke[2], g.stroke[3] * op];
groups.push((vec![region], c, g.m, usize::from(stroke_on_top), g.xf.surface_colors(c)));
@@ -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;
+40 -4
View File
@@ -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, &params);
@@ -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, &params);
@@ -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, &params);
@@ -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, &params);
+17 -9
View File
@@ -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
}
+23 -17
View File
@@ -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
@@ -360,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 }
}
+79 -32
View File
@@ -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()
@@ -863,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)
})
+154 -3
View File
@@ -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)
}
@@ -374,7 +379,7 @@ fn shadow_diffusion_is_a_soft_ramp_not_copies() {
// ---------------------------------------------------------------- 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]
@@ -450,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]