Merge upstream/main into rfx-masks-shapes

render: upstream moved the tests_*.rs modules into crates/render/src/tests/; the branch's
tests_mask_feather.rs now lives at tests/mask_feather.rs and is registered in tests.rs.
path: rect() keeps the branch's explicit-outline version with an added NaN guard on the
roundness clamp bound (main's fix for the same panic).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
vangel76
2026-10-27 16:57:13 +01:00
co-authored by Claude Sonnet 5.5
812 changed files with 67230 additions and 44816 deletions
+255
View File
@@ -0,0 +1,255 @@
//! Audio effects through the mixdown (the path preview playback and export share).
use std::sync::Arc;
use effectcraft_keyframe::Value;
use effectcraft_project::build::{self, Ids};
use effectcraft_project::{Comp, Footage, FootageKind, ItemId, ItemKind, LayerSource, Project, Solid};
use effectcraft_time::{FrameRate, Tick};
use crate::audio::{comp_has_audio, footage_peaks, mix_comp, peak_summary};
use crate::{FootageSource, Image};
const SR: u32 = 8000;
/// Synthetic footage audio as a function of source time (seconds) → (L, R).
struct Signal(fn(f64) -> (f32, f32));
impl FootageSource for Signal {
fn frame(&self, _: ItemId, _: &Footage, _: Tick) -> Option<Arc<Image>> {
None
}
fn audio(&self, _: ItemId, _: &Footage, start: Tick, frames: usize, rate: u32) -> Option<Vec<f32>> {
let t0 = start.seconds();
Some(
(0..frames)
.flat_map(|i| {
let t = t0 + i as f64 / rate as f64;
let (l, r) = if (0.0..2.0).contains(&t) { (self.0)(t) } else { (0.0, 0.0) };
[l, r]
})
.collect(),
)
}
}
fn audio_footage() -> Footage {
Footage {
path: "x.wav".into(),
kind: FootageKind::Audio,
width: 0,
height: 0,
pixel_aspect: 1.0,
frame_rate: FrameRate::new(25, 1),
native_rate: None,
duration: Tick::from_seconds_f64(2.0),
has_video: false,
has_audio: true,
alpha: Default::default(),
premul_color: [0.0; 3],
loop_count: 1,
codec: String::new(),
missing: false,
sequence: vec![],
color_profile: None,
..Default::default()
}
}
/// A 2 s comp with one audio layer (in 0, out 1 s) carrying effect `id` with `vals`.
fn comp_with(id: Option<&str>, vals: &[(&str, Value)], solid: bool) -> (Project, ItemId) {
let mut p = Project::default();
let comp = Comp::new(64, 64, FrameRate::new(25, 1), Tick::from_seconds_f64(2.0));
let src = if solid {
let sid = p.add_item("S", effectcraft_color::Label::Red, None, ItemKind::Solid(Solid { color: [1.0; 3], width: 64, height: 64, pixel_aspect: 1.0 }));
LayerSource::Solid { item: sid }
} else {
LayerSource::Footage { item: p.add_item("x.wav", effectcraft_color::Label::SeaFoam, None, ItemKind::Footage(audio_footage())) }
};
let mut l = build::layer(&mut p, &comp, "a", src, (64, 64), None);
l.out_point = Tick::from_seconds_f64(1.0);
if let Some(id) = id {
let spec = effectcraft_effects::find(id).unwrap();
let mut next = p.next_id;
let mut g = effectcraft_effects::instantiate(spec, &mut Ids(&mut next), spec.name, [64.0, 64.0]);
p.next_id = next;
for (k, v) in vals {
g.prop_mut(k).unwrap_or_else(|| panic!("{k}")).value = v.clone();
}
l.props.sub_mut("effects").unwrap().children.push(g.into());
}
let mut comp = comp;
comp.layers = vec![l];
let cid = p.add_item("C", effectcraft_color::Label::Sandstone, None, ItemKind::Comp(comp.into()));
(p, cid)
}
fn mix(p: &Project, src: &dyn FootageSource, cid: ItemId, from: f64, frames: usize) -> Vec<f32> {
mix_comp(p, src, None, cid, Tick::from_seconds_f64(from), frames, SR)
}
fn ramp(t: f64) -> (f32, f32) {
(t as f32, -(t as f32))
}
fn rms(m: &[f32]) -> f32 {
(m.iter().map(|v| v * v).sum::<f32>() / m.len() as f32).sqrt()
}
#[test]
fn backwards_reverses_the_layer_span() {
let (p, cid) = comp_with(Some("ec.audio.backwards"), &[], false);
let m = mix(&p, &Signal(ramp), cid, 0.0, SR as usize);
// Comp time t plays source time ≈ 1 − t.
for i in [0usize, 2000, 4000, 7999] {
let t = i as f64 / SR as f64;
assert!((m[i * 2] as f64 - (1.0 - t)).abs() < 2.0 / SR as f64, "i={i}: {}", m[i * 2]);
}
let (p, cid) = comp_with(None, &[], false);
let m = mix(&p, &Signal(ramp), cid, 0.0, SR as usize);
assert!((m[4000 * 2] - 0.5).abs() < 1e-3);
}
#[test]
fn high_low_pass_attenuates() {
fn hi(t: f64) -> (f32, f32) {
let v = (2.0 * std::f64::consts::PI * 3000.0 * t).sin() as f32;
(v, v)
}
let vals = [("filterOptions", Value::Enum(1)), ("cutoffFrequency", Value::Scalar(200.0))];
let (p, cid) = comp_with(Some("ec.audio.highlowpass"), &vals, false);
let r = rms(&mix(&p, &Signal(hi), cid, 0.5, 800));
assert!(r < 0.02, "{r}");
let (p, cid) = comp_with(None, &[], false);
let r = rms(&mix(&p, &Signal(hi), cid, 0.5, 800));
assert!(r > 0.6, "{r}");
}
#[test]
fn delay_offsets_across_blocks() {
fn click(t: f64) -> (f32, f32) {
if (t * SR as f64).round() as i64 == 2000 { (1.0, 1.0) } else { (0.0, 0.0) }
}
let vals = [("delayTime", Value::Scalar(100.0)), ("feedback", Value::Scalar(0.0))];
let (p, cid) = comp_with(Some("ec.audio.delay"), &vals, false);
// The block starts after the click (0.25 s): the echo at 0.35 s still comes from pre-roll.
let m = mix(&p, &Signal(click), cid, 0.3, 800);
let i = 2800 - 2400;
assert!((m[i * 2] - 0.375).abs() < 1e-5, "{}", m[i * 2]);
assert_eq!(m.iter().filter(|v| v.abs() > 1e-6).count(), 2);
}
#[test]
fn stereo_mixer_pans() {
fn left(_: f64) -> (f32, f32) {
(0.5, 0.0)
}
let (p, cid) = comp_with(Some("ec.audio.stereomixer"), &[("leftPan", Value::Scalar(100.0))], false);
let m = mix(&p, &Signal(left), cid, 0.2, 10);
assert!(m.chunks(2).all(|f| f[0].abs() < 1e-6 && (f[1] - 0.5).abs() < 1e-6), "{:?}", &m[..4]);
}
#[test]
fn tone_makes_a_solid_audible_at_its_frequency() {
let mut vals: Vec<(&str, Value)> = vec![("frequency1", Value::Scalar(500.0))];
for k in ["frequency2", "frequency3", "frequency4", "frequency5"] {
vals.push((k, Value::Scalar(0.0)));
}
let (p, cid) = comp_with(Some("ec.audio.tone"), &vals, true);
assert!(comp_has_audio(&p, cid));
let m = mix(&p, &Signal(ramp), cid, 0.0, SR as usize);
let left: Vec<f32> = m.iter().step_by(2).copied().collect();
let ups = left.windows(2).filter(|w| w[0] <= 0.0 && w[1] > 0.0).count();
assert!((ups as i64 - 500).abs() <= 1, "{ups}");
// The out point (1 s) silences it.
let after = mix(&p, &Signal(ramp), cid, 1.0, 100);
assert!(after.iter().all(|v| *v == 0.0));
let (p, cid) = comp_with(None, &[], true);
assert!(!comp_has_audio(&p, cid));
}
/// Mixing in video-frame blocks gives the same samples as one long block (pre-roll makes
/// effects stateless across calls), so preview and export agree.
#[test]
fn blocks_match_one_long_mix() {
fn noise(t: f64) -> (f32, f32) {
let x = ((t * 12345.678).sin() * 43758.5453).fract() as f32;
(x, -x)
}
for id in [
"ec.audio.reverb",
"ec.audio.basstreble",
"ec.audio.flangechorus",
"ec.audio.modulator",
"ec.audio.delay",
"ec.audio.parametriceq",
"ec.audio.compressor",
"ec.audio.gate",
"ec.audio.distortion",
] {
let vals: Vec<(&str, Value)> = match id {
"ec.audio.basstreble" => vec![("bass", Value::Scalar(50.0))],
"ec.audio.parametriceq" => vec![("band1BoostCut", Value::Scalar(9.0))],
_ => vec![],
};
let (p, cid) = comp_with(Some(id), &vals, false);
let long = mix(&p, &Signal(noise), cid, 0.2, 1600);
let mut blocks = vec![];
for k in 0..5 {
blocks.extend(mix(&p, &Signal(noise), cid, 0.2 + k as f64 * 0.04, 320));
}
let worst = long.iter().zip(&blocks).map(|(a, b)| (a - b).abs()).fold(0.0f32, f32::max);
assert!(worst < 2e-3, "{id}: {worst}");
}
}
#[test]
fn peak_summary_bins() {
let s: Vec<f32> = (0..10).flat_map(|i| [i as f32 * 0.1, -(i as f32) * 0.1]).collect();
let p = peak_summary(&s, 4);
assert_eq!(p.len(), 3);
assert!(p[0][0].abs() < 1e-6 && (p[0][1] - 0.3).abs() < 1e-6);
assert!((p[1][2] + 0.7).abs() < 1e-6 && (p[1][3] + 0.4).abs() < 1e-6);
let f = audio_footage();
let peaks = footage_peaks(&Signal(ramp), ItemId(1), &f, Tick::from_seconds_f64(2.0), 100);
assert_eq!(peaks.len(), 200);
assert!((peaks[150][1] - 1.51).abs() < 0.01, "{:?}", peaks[150]);
}
/// Audio Spectrum reads its Audio Layer's samples at the frame time, so the layer cache keys it
/// by time: a cached frame after the sound starts matches an uncached render (#209).
#[test]
fn audio_spectrum_follows_time_through_the_layer_cache() {
fn late_tone(t: f64) -> (f32, f32) {
let v = if t < 0.5 { 0.0 } else { (2.0 * std::f64::consts::PI * 1000.0 * t).sin() as f32 };
(v, v)
}
let mut p = Project::default();
let comp = Comp::new(64, 64, FrameRate::new(25, 1), Tick::from_seconds_f64(2.0));
let audio = p.add_item("x.wav", effectcraft_color::Label::SeaFoam, None, ItemKind::Footage(audio_footage()));
let a = build::layer(&mut p, &comp, "a", LayerSource::Footage { item: audio }, (64, 64), None);
let sid = p.add_item("S", effectcraft_color::Label::Red, None, ItemKind::Solid(Solid { color: [0.0; 3], width: 64, height: 64, pixel_aspect: 1.0 }));
let mut s = build::layer(&mut p, &comp, "s", LayerSource::Solid { item: sid }, (64, 64), None);
let spec = effectcraft_effects::find("ec.generate.audiospectrum").unwrap();
let mut next = p.next_id;
let mut g = effectcraft_effects::instantiate(spec, &mut Ids(&mut next), spec.name, [64.0, 64.0]);
p.next_id = next;
g.prop_mut("audioLayer").unwrap().value = Value::Layer(Some(a.id.0));
s.props.sub_mut("effects").unwrap().children.push(g.into());
let mut comp = comp;
comp.layers = vec![s, a];
let cid = p.add_item("C", effectcraft_color::Label::Sandstone, None, ItemKind::Comp(comp.into()));
let src = Signal(late_tone);
let render = |t: f64, cache: Option<&crate::LayerCache>| {
let mut r = crate::Renderer::new(&p, &src, crate::RenderOpts::default());
r.cache = cache;
r.comp_frame(cid, Tick::from_seconds_f64(t))
};
let lit = |img: &Image| img.data.iter().filter(|px| px[0] + px[1] + px[2] > 0.1).count();
let cache = crate::LayerCache::default();
let silent = render(0.0, Some(&cache));
let cached = render(0.8, Some(&cache));
let fresh = render(0.8, None);
assert!(lit(&fresh) > lit(&silent), "the tone draws more than the baseline: {} vs {}", lit(&fresh), lit(&silent));
assert_eq!(cached.data, fresh.data, "cached {} px, uncached {} px", lit(&cached), lit(&fresh));
}
+162
View File
@@ -0,0 +1,162 @@
//! Backend::Auto's per-comp CPU / GPU choice (`auto.rs`), with a mock accelerator.
use std::sync::atomic::{AtomicUsize, Ordering};
use effectcraft_color::Label;
use effectcraft_project::build;
use effectcraft_project::{Comp, ItemId, ItemKind, LayerSource, Project, Solid};
use effectcraft_time::{FrameRate, Tick};
use crate::{Accelerator, AutoKey, AutoPick, Backend, NoFootage, RenderOpts, Renderer};
/// An accelerator that returns a representative pixel (or declines) and keeps Auto history.
struct MockAccel {
handle: bool,
calls: AtomicUsize,
pick: AutoPick,
}
impl MockAccel {
/// Construct a renderer stand-in without making assumptions about scheduling or speed.
fn new(handle: bool) -> MockAccel {
MockAccel { handle, calls: Default::default(), pick: Default::default() }
}
/// Number of full-frame requests dispatched to this accelerator.
fn calls(&self) -> usize {
self.calls.load(Ordering::SeqCst)
}
}
impl Accelerator for MockAccel {
fn name(&self) -> String {
"timed".into()
}
/// Count dispatched frames and return the same centre pixel as the CPU fixture.
fn comp_frame(&self, _r: &Renderer, _comp: ItemId, _t: Tick) -> Option<crate::Image> {
self.calls.fetch_add(1, Ordering::SeqCst);
// The centre of the comp, as the CPU draws it (a red solid).
self.handle.then(|| {
let mut img = crate::Image::new(200, 100);
img.set(100, 50, [1.0, 0.0, 0.0, 1.0]);
img
})
}
fn supports_effect(&self, _id: &str) -> bool {
false
}
fn effects(&self, _chain: &[crate::FxStep], _buf: &crate::Buf, _levels: Option<f32>) -> Option<crate::Buf> {
None
}
fn auto_pick(&self) -> Option<&AutoPick> {
Some(&self.pick)
}
}
/// A small red solid whose centre pixel is shared by the CPU and mock GPU fixtures.
fn project() -> (Project, ItemId) {
let mut p = Project::default();
p.settings.gpu_acceleration = true;
let mut comp = Comp::new(200, 100, FrameRate::FPS_30, Tick::from_seconds_f64(2.0));
let sid = p.add_item("Solid", Label::Red, None, ItemKind::Solid(Solid { color: [1.0, 0.0, 0.0], width: 50, height: 50, pixel_aspect: 1.0 }));
let l = build::layer(&mut p, &comp, "Solid", LayerSource::Solid { item: sid }, (50, 50), None);
comp.layers.push(l);
let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.into()));
(p, cid)
}
/// Render `n` frames with `backend`; how many the accelerator was asked for.
fn frames(p: &Project, cid: ItemId, a: &MockAccel, backend: Backend, n: usize) -> usize {
let before = a.calls();
for _ in 0..n {
let mut r = Renderer::new(p, &NoFootage, RenderOpts { backend, ..Default::default() });
r.accel = Some(a);
let img = r.comp_frame(cid, Tick::ZERO);
// Whichever compositor ran, the frame is the same.
assert!(img.get(100, 50)[0] > 0.99 && img.get(100, 50)[3] > 0.99);
}
a.calls() - before
}
/// Drive the production selector with measured costs supplied by the test, not the OS clock.
fn measured_frames(pick: &AutoPick, key: AutoKey, cpu_ms: f64, gpu_ms: f64, n: usize) -> usize {
(0..n)
.filter(|_| {
let gpu = pick.choose(key);
pick.record(key, gpu, if gpu { gpu_ms } else { cpu_ms });
gpu
})
.count()
}
/// A slower GPU is periodically re-probed; explicit GPU and Software Only ignore that history.
#[test]
fn auto_backend_moves_light_comps_to_the_cpu() {
let (p, cid) = project();
let slow = MockAccel::new(true);
let key = AutoKey::new(cid, 1.0, false);
let n = 6 + 2 * AutoPick::REPROBE as usize;
let asked = measured_frames(&slow.pick, key, 1.0, 20.0, n);
assert!((3..=5).contains(&asked), "asked the slow accelerator {asked} times in {n} frames");
let s = slow.pick.stats(key).unwrap();
assert!(s.gpu_ms.unwrap() > s.cpu_ms.unwrap(), "{s:?}");
// Check renderer wiring with known history before a real measurement can change it.
assert_eq!(frames(&p, cid, &slow, Backend::Auto, 1), 0);
assert_eq!(frames(&p, cid, &slow, Backend::Gpu, 3), 3);
let mut sw = p.clone();
sw.settings.gpu_acceleration = false;
assert_eq!(frames(&sw, cid, &slow, Backend::Auto, 3), 0);
}
/// Faster, tied and slightly noisier GPU costs stay on the GPU except during warm-up/probes.
#[test]
fn auto_backend_keeps_fast_accelerators() {
let (p, cid) = project();
for gpu_ms in [1.0, 5.0, 5.5] {
let fast = MockAccel::new(true);
let asked = measured_frames(&fast.pick, AutoKey::new(cid, 1.0, false), 5.0, gpu_ms, 40);
assert!(asked >= 36, "asked {asked} of 40 for GPU cost {gpu_ms}");
assert_eq!(frames(&p, cid, &fast, Backend::Auto, 1), 1);
}
}
/// A declining accelerator is retried only periodically while the CPU supplies frames.
#[test]
fn auto_backend_falls_back_when_the_accelerator_declines() {
let (p, cid) = project();
let declining = MockAccel::new(false);
let n = 2 * AutoPick::REPROBE as usize;
let asked = frames(&p, cid, &declining, Backend::Auto, n);
// Asked first, then only on the periodic re-probes.
assert!((1..=4).contains(&asked), "asked {asked} times in {n} frames");
}
/// Periodic probes detect a formerly slow GPU becoming faster, without mixing cache keys.
#[test]
fn auto_pick_follows_changing_costs() {
let pick = AutoPick::default();
let key = AutoKey::new(ItemId(7), 0.5, true);
let cpu = 5.0;
let mut gpu = 10.0;
let (mut early, mut late) = (0, 0);
for frame in 0..400 {
// The GPU gets faster halfway (say, its uploads became cached).
if frame == 200 {
gpu = 1.0;
}
let g = pick.choose(key);
pick.record(key, g, if g { gpu } else { cpu });
if (100..200).contains(&frame) && g {
early += 1;
}
if frame >= 300 && g {
late += 1;
}
}
assert!(early <= 3, "{early} of 100 frames on the slower GPU");
assert!(late >= 97, "{late} of 100 frames on the faster GPU");
let s = pick.stats(key).unwrap();
assert!(s.gpu_ms.unwrap() < 2.0 && s.cpu_ms.unwrap() > 4.0, "{s:?}");
// Keys are separate per comp, scale and path.
assert!(pick.stats(AutoKey::new(ItemId(7), 1.0, true)).is_none());
assert!(pick.stats(AutoKey::new(ItemId(7), 0.5, false)).is_none());
}
+98
View File
@@ -0,0 +1,98 @@
//! Auxiliary 3D channels: the comp's depth / ID / Cryptomatte pass and the 3D Channel effects on
//! a precomp of a 3D comp.
use effectcraft_color::Label;
use effectcraft_keyframe::Value;
use effectcraft_project::build::{self, Ids};
use effectcraft_project::{Comp, ItemId, ItemKind, Layer, LayerSource, Project, Solid};
use effectcraft_raster::channels3d::{BACKGROUND_DEPTH, crypto_hash};
use effectcraft_time::{FrameRate, Tick};
use crate::{NoFootage, RenderOpts, Renderer, render_frame};
const W: u32 = 200;
const H: u32 = 100;
fn zoom() -> f64 {
W as f64 * 50.0 / 36.0
}
fn solid3(p: &mut Project, comp: &Comp, name: &str, color: [f32; 3], pos: [f64; 3]) -> Layer {
let sid = p.add_item(name, Label::Red, None, ItemKind::Solid(Solid { color, width: 60, height: 60, pixel_aspect: 1.0 }));
let mut l = build::layer(p, comp, name, LayerSource::Solid { item: sid }, (60, 60), None);
l.name = name.to_string();
l.switches.three_d = true;
l.props.prop_mut("transform/position").unwrap().value = Value::Vec3(pos);
l
}
/// Inner 3D comp: "Near" (red, z 0, left) above "Far" (green, z 500, right). Outer comp: the
/// precomp layer, optionally with an effect.
fn scene(effect: Option<(&str, Vec<(&str, Value)>)>) -> (Project, ItemId, ItemId) {
let mut p = Project::default();
p.settings.bit_depth = effectcraft_project::BitDepth::Bpc32;
let inner = Comp::new(W, H, FrameRate::FPS_30, Tick::from_seconds_f64(2.0));
let iid = p.add_item("Inner", Label::Sandstone, None, ItemKind::Comp(inner.clone().into()));
let near = solid3(&mut p, &inner, "Near", [1.0, 0.0, 0.0], [60.0, 50.0, 0.0]);
let far = solid3(&mut p, &inner, "Far", [0.0, 1.0, 0.0], [140.0, 50.0, 500.0]);
p.comp_mut(iid).unwrap().layers.push(near);
p.comp_mut(iid).unwrap().layers.push(far);
let outer = Comp::new(W, H, FrameRate::FPS_30, Tick::from_seconds_f64(2.0));
let oid = p.add_item("Outer", Label::Sandstone, None, ItemKind::Comp(outer.clone().into()));
let mut pre = build::layer(&mut p, &outer, "Inner", LayerSource::Comp { item: iid }, (W, H), None);
if let Some((id, vals)) = effect {
let spec = effectcraft_effects::find(id).unwrap();
let mut next = p.next_id;
let mut g = effectcraft_effects::instantiate(spec, &mut Ids(&mut next), spec.name, [W as f64, H as f64]);
p.next_id = next;
for (k, v) in vals {
g.prop_mut(k).unwrap().value = v.clone();
}
pre.props.sub_mut("effects").unwrap().children.push(g.into());
}
p.comp_mut(oid).unwrap().layers.push(pre);
(p, iid, oid)
}
#[test]
fn comp_aux_pass_has_depth_ids_and_cryptomatte() {
let (p, iid, _) = scene(None);
let r = Renderer::new(&p, &NoFootage, RenderOpts::default());
let a = r.comp_aux(iid, Tick::ZERO).unwrap();
assert_eq!((a.width, a.height), (W, H));
let at = |name: &str, x: usize, y: usize| a.get(name).unwrap()[y * W as usize + x];
// Near plane at the camera's zoom distance, the far one 500 px behind it.
assert!((at("Z", 60, 50) as f64 - zoom()).abs() < 1e-2, "{}", at("Z", 60, 50));
assert!((at("Z", 114, 50) as f64 - (zoom() + 500.0)).abs() < 1e-2, "{}", at("Z", 114, 50));
assert_eq!(at("Z", 5, 5), BACKGROUND_DEPTH);
assert_eq!((at("ObjectID", 60, 50), at("ObjectID", 114, 50), at("ObjectID", 5, 5)), (1.0, 2.0, 0.0));
assert_eq!(at("Coverage", 60, 50), 1.0);
// Planes face the camera.
assert!((at("N.Z", 60, 50) + 1.0).abs() < 1e-5);
// Cryptomatte rank 0 = the layer under the pixel, full coverage; manifest lists names.
assert_eq!(at("CryptoObject00.R", 60, 50).to_bits(), crypto_hash("Near"));
assert_eq!(at("CryptoObject00.G", 60, 50), 1.0);
assert_eq!(a.manifest_name("CryptoObject", crypto_hash("Far")), Some("Far"));
}
#[test]
fn three_d_channel_effects_work_on_a_precomp_of_a_3d_comp() {
let near = (60, 50);
let far = (114, 50);
let alpha = |p: &Project, oid: ItemId, (x, y): (i64, i64)| render_frame(p, oid, Tick::ZERO, 1.0).get(x, y)[3];
let (p, _, oid) = scene(Some(("ec.3d.idmatte", vec![("idSelection", Value::Scalar(2.0))])));
assert_eq!(alpha(&p, oid, near), 0.0);
assert!(alpha(&p, oid, far) > 0.99);
let (p, _, oid) = scene(Some(("ec.3d.depthmatte", vec![("depth", Value::Scalar(zoom() + 250.0))])));
assert_eq!(alpha(&p, oid, near), 0.0);
assert!(alpha(&p, oid, far) > 0.99);
let (p, _, oid) = scene(Some(("ec.3d.cryptomatte", vec![("selection", Value::Str("Near".into())), ("display", Value::Enum(1))])));
assert!(alpha(&p, oid, near) > 0.99);
assert_eq!(alpha(&p, oid, far), 0.0);
// Half resolution: the pass follows the render scale.
let (p, iid, _) = scene(None);
let r = Renderer::new(&p, &NoFootage, RenderOpts { scale: 0.5, ..Default::default() });
let a = r.comp_aux(iid, Tick::ZERO).unwrap();
assert_eq!((a.width, a.height, a.scale), (W / 2, H / 2, 0.5));
assert_eq!(a.get("ObjectID").unwrap()[25 * 100 + 30], 1.0);
}
+339
View File
@@ -0,0 +1,339 @@
//! Collapse Transformations, Continuously Rasterize and the Quality switch.
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_time::{FrameRate, Tick};
use crate::{EvalCtx, Image, NoFootage, RenderOpts, Renderer, render_frame};
fn project() -> Project {
let mut p = Project::default();
p.settings.bit_depth = BitDepth::Bpc32;
p
}
fn solid(p: &mut Project, comp: &Comp, color: [f32; 3], size: (u32, u32), pos: [f64; 3]) -> Layer {
let sid = p.add_item("S", Label::Red, None, ItemKind::Solid(Solid { color, width: size.0, height: size.1, pixel_aspect: 1.0 }));
let mut l = build::layer(p, comp, "S", LayerSource::Solid { item: sid }, size, None);
l.props.prop_mut("transform/position").unwrap().value = Value::Vec3(pos);
l
}
fn set(l: &mut Layer, path: &str, v: Value) {
l.props.prop_mut(path).unwrap().value = v;
}
fn max_diff(a: &Image, b: &Image) -> f32 {
a.data.iter().zip(&b.data).flat_map(|(p, q)| (0..4).map(move |c| (p[c] - q[c]).abs())).fold(0.0, f32::max)
}
/// Outer 200×100 comp: a grey background, and either a precomp (scaled 150%, rotated) of two
/// solids — the top one in Add mode — or the same two solids parented to a null that carries the
/// precomp layer's transform (the manual flattening).
fn scene(collapse: bool, flatten: bool) -> (Project, ItemId) {
let mut p = project();
let outer = Comp::new(200, 100, FrameRate::FPS_30, Tick::from_seconds_f64(2.0));
let inner = Comp::new(100, 100, FrameRate::FPS_30, Tick::from_seconds_f64(2.0));
let mut a = solid(&mut p, &inner, [0.6, 0.1, 0.1], (40, 40), [40.0, 50.0, 0.0]);
let mut b = solid(&mut p, &inner, [0.1, 0.3, 0.6], (40, 40), [60.0, 50.0, 0.0]);
b.blend_mode = BlendMode::Add;
let bg = solid(&mut p, &outer, [0.3, 0.3, 0.3], (200, 100), [100.0, 50.0, 0.0]);
let xf = |l: &mut Layer| {
set(l, "transform/anchor", Value::Vec3([50.0, 50.0, 0.0]));
set(l, "transform/position", Value::Vec3([100.0, 50.0, 0.0]));
set(l, "transform/scale", Value::Vec3([150.0, 150.0, 100.0]));
set(l, "transform/rotation", Value::Scalar(20.0));
};
let cid = if flatten {
let mut null = build::layer(&mut p, &outer, "Null", LayerSource::Null, (100, 100), None);
xf(&mut null);
a.parent = Some(null.id);
b.parent = Some(null.id);
let mut c = outer.clone();
c.layers = vec![null, b, a, bg];
p.add_item("Outer", Label::Sandstone, None, ItemKind::Comp(c.into()))
} else {
let mut inner = inner;
inner.layers = vec![b, a];
let iid = p.add_item("Inner", Label::Sandstone, None, ItemKind::Comp(inner.into()));
let mut pre = build::layer(&mut p, &outer, "Inner", LayerSource::Comp { item: iid }, (100, 100), None);
xf(&mut pre);
pre.switches.collapse = collapse;
let mut c = outer.clone();
c.layers = vec![pre, bg];
p.add_item("Outer", Label::Sandstone, None, ItemKind::Comp(c.into()))
};
(p, cid)
}
#[test]
fn collapsed_precomp_equals_manual_flattening() {
let (p, cid) = scene(false, true);
let flat = render_frame(&p, cid, Tick::ZERO, 1.0);
let (p, cid) = scene(true, false);
let collapsed = render_frame(&p, cid, Tick::ZERO, 1.0);
assert!(max_diff(&flat, &collapsed) < 1e-4, "collapsed differs from flattening by {}", max_diff(&flat, &collapsed));
// Without collapsing, Add only sees the precomp's own layers (not the grey below), and the
// precomp is resampled twice.
let (p, cid) = scene(false, false);
let nested = render_frame(&p, cid, Tick::ZERO, 1.0);
assert!(max_diff(&flat, &nested) > 0.2);
// Opacity of the collapsed layer fades every nested layer.
let (mut p, cid) = scene(true, false);
set(&mut p.comp_mut(cid).unwrap().layers[0], "transform/opacity", Value::Scalar(50.0));
let half = render_frame(&p, cid, Tick::ZERO, 1.0);
let (x, y) = (100, 50);
let (bg, full) = (0.3, collapsed.get(x, y)[0]);
assert!((half.get(x, y)[0] - (bg + (full - bg) * 0.5)).abs() < 0.03, "{:?} vs {full}", half.get(x, y));
}
#[test]
fn masks_on_a_collapsed_layer_force_a_flattened_render() {
let (mut p, cid) = scene(true, false);
let (p2, cid2) = scene(false, false);
// Add a mask covering everything: the layer renders like an uncollapsed precomp.
let mut next = p.next_id;
let mask = build::mask(
&mut Ids(&mut next),
"Mask 1",
effectcraft_keyframe::ShapePath::rect([50.0, 50.0], 4000.0, 4000.0),
effectcraft_project::MaskMode::Add,
[255, 255, 0],
);
p.next_id = next;
p.comp_mut(cid).unwrap().layers[0].props.sub_mut("masks").unwrap().children.push(mask.into());
let a = render_frame(&p, cid, Tick::ZERO, 1.0);
let b = render_frame(&p2, cid2, Tick::ZERO, 1.0);
assert!(max_diff(&a, &b) < 1e-4, "{}", max_diff(&a, &b));
}
#[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
// blue 3D solid at z = −200 (nearer the camera). Collapsed, depth sorting puts blue in front;
// uncollapsed, the precomp is one plane at z = 0 drawn below the red.
let run = |collapse: bool| {
let mut p = project();
let outer = Comp::new(200, 200, FrameRate::FPS_30, Tick::from_seconds_f64(2.0));
let inner = Comp::new(200, 200, FrameRate::FPS_30, Tick::from_seconds_f64(2.0));
let mut blue = solid(&mut p, &inner, [0.0, 0.0, 1.0], (60, 60), [100.0, 100.0, -200.0]);
blue.switches.three_d = true;
let mut inner = inner;
inner.layers = vec![blue];
let iid = p.add_item("Inner", Label::Sandstone, None, ItemKind::Comp(inner.into()));
let mut pre = build::layer(&mut p, &outer, "Inner", LayerSource::Comp { item: iid }, (200, 200), None);
pre.switches.three_d = true;
pre.switches.collapse = collapse;
let mut red = solid(&mut p, &outer, [1.0, 0.0, 0.0], (100, 100), [100.0, 100.0, 0.0]);
red.switches.three_d = true;
let mut c = outer;
c.layers = vec![red, pre];
let cid = p.add_item("Outer", Label::Sandstone, None, ItemKind::Comp(c.into()));
render_frame(&p, cid, Tick::ZERO, 1.0).get(100, 100)
};
let c = run(true);
assert!(c[2] > 0.99 && c[0] < 0.01, "collapsed: blue in front {c:?}");
let n = run(false);
assert!(n[0] > 0.99 && n[2] < 0.01, "uncollapsed: red on top {n:?}");
}
/// Pixels along row `y` from `x0` to `x1` with partial coverage (an edge's width).
fn edge_width(img: &Image, y: i64, x0: i64, x1: i64) -> usize {
(x0..x1).filter(|&x| (0.02..0.98).contains(&img.get(x, y)[3])).count()
}
/// A 10 px white square in a 200×200 comp, scaled 800% either by its own Scale or by a parent
/// null's, with the Continuously Rasterize switch `cr`.
fn scaled_square(cr: bool, via_parent: bool) -> Image {
let mut p = project();
let comp = Comp::new(200, 200, FrameRate::FPS_30, Tick::from_seconds_f64(2.0));
let mut l = build::layer(&mut p, &comp, "Shape", LayerSource::Shape, (200, 200), None);
let mut next = p.next_id;
let mut ids = Ids(&mut next);
let rect = build::shape_rect(&mut ids, [10.0, 10.0], [0.0, 0.0], 0.0);
let fill = build::shape_fill(&mut ids, [1.0, 1.0, 1.0, 1.0]);
let g = build::shape_group(&mut ids, "Rectangle 1", vec![rect, fill]);
p.next_id = next;
l.props.sub_mut("contents").unwrap().children.push(g.into());
set(&mut l, "transform/anchor", Value::Vec3([0.0, 0.0, 0.0]));
l.switches.collapse = cr;
let mut c = comp.clone();
if via_parent {
let mut null = build::layer(&mut p, &comp, "Null", LayerSource::Null, (100, 100), None);
set(&mut null, "transform/anchor", Value::Vec3([0.0, 0.0, 0.0]));
set(&mut null, "transform/position", Value::Vec3([100.3, 100.3, 0.0]));
set(&mut null, "transform/scale", Value::Vec3([800.0, 800.0, 100.0]));
set(&mut l, "transform/position", Value::Vec3([0.0, 0.0, 0.0]));
l.parent = Some(null.id);
c.layers = vec![l, null];
} else {
set(&mut l, "transform/position", Value::Vec3([100.3, 100.3, 0.0]));
set(&mut l, "transform/scale", Value::Vec3([800.0, 800.0, 100.0]));
c.layers = vec![l];
}
let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(c.into()));
render_frame(&p, cid, Tick::ZERO, 1.0)
}
/// Shape layers are always continuously rasterised, as in After Effects (#751): a shape scaled up
/// by its own transform or a parent's is drawn at its on-screen size, with a 1 px anti-aliased
/// edge, whether or not the Continuously Rasterize switch is on.
#[test]
fn shape_scaled_up_stays_sharp_without_the_switch() {
// The 10 px square scaled 800% spans 60.3..140.3.
for cr in [false, true] {
for via_parent in [false, true] {
let img = scaled_square(cr, via_parent);
let w = edge_width(&img, 100, 50, 75);
assert!(w <= 1, "switch {cr}, scaled by parent {via_parent}: edge {w} px wide");
assert!(img.get(100, 100)[3] > 0.99 && img.get(55, 100)[3] < 0.01);
}
}
}
#[test]
fn wireframe_quality_draws_the_layer_bounds() {
let mut p = project();
let comp = Comp::new(100, 100, FrameRate::FPS_30, Tick::from_seconds_f64(2.0));
let mut l = solid(&mut p, &comp, [1.0, 0.0, 0.0], (40, 40), [50.0, 50.0, 0.0]);
l.switches.quality = Quality::Wireframe;
let mut c = comp;
c.layers = vec![l];
let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(c.into()));
let img = render_frame(&p, cid, Tick::ZERO, 1.0);
assert_eq!(img.get(50, 50)[3], 0.0, "no fill");
assert_eq!(img.get(30, 50), [1.0, 1.0, 1.0, 1.0], "outline");
// Draft quality samples nearest-neighbour: no partial pixels at a half-pixel offset.
let mut p = project();
let comp = Comp::new(100, 100, FrameRate::FPS_30, Tick::from_seconds_f64(2.0));
let mut l = solid(&mut p, &comp, [1.0, 1.0, 1.0], (40, 40), [50.5, 50.0, 0.0]);
l.switches.quality = Quality::Draft;
let mut c = comp;
c.layers = vec![l];
let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(c.into()));
let img = render_frame(&p, cid, Tick::ZERO, 1.0);
assert!(img.data.iter().all(|q| q[3] == 0.0 || q[3] == 1.0));
}
#[test]
fn collapsed_precomp_of_another_size_renders_through_the_parent_camera() {
// A 1000×600 scene comp with a blue 3D solid at its centre, collapsed into a 300×200 comp
// whose camera looks at the precomp layer (at the parent's centre). The nested layers must
// be projected with the parent comp's view (its centre and size), not the scene comp's.
let run = |three_d_precomp: bool, camera: bool| {
let mut p = project();
let outer = Comp::new(300, 200, FrameRate::FPS_30, Tick::from_seconds_f64(2.0));
let inner = Comp::new(1000, 600, FrameRate::FPS_30, Tick::from_seconds_f64(2.0));
let mut blue = solid(&mut p, &inner, [0.0, 0.0, 1.0], (40, 40), [500.0, 300.0, 0.0]);
blue.switches.three_d = true;
let mut inner = inner;
inner.layers = vec![blue];
let iid = p.add_item("Scene", Label::Sandstone, None, ItemKind::Comp(inner.into()));
let mut pre = build::layer(&mut p, &outer, "Scene", LayerSource::Comp { item: iid }, (1000, 600), None);
pre.switches.three_d = three_d_precomp;
pre.switches.collapse = true;
let mut c = outer.clone();
c.layers = vec![pre];
if camera {
let cam = build::layer(&mut p, &outer, "Camera", LayerSource::Camera, (300, 200), None);
c.layers.insert(0, cam);
}
let cid = p.add_item("Face", Label::Sandstone, None, ItemKind::Comp(c.into()));
render_frame(&p, cid, Tick::ZERO, 1.0)
};
for (three_d, camera) in [(true, true), (true, false), (false, true)] {
let img = run(three_d, camera);
let c = img.get(150, 100);
assert!(c[2] > 0.99 && c[3] > 0.99, "3D precomp {three_d}, camera {camera}: centre {c:?}");
assert!(img.get(100, 100)[3] < 0.01, "only the 40 px solid is drawn");
}
}
/// Text layers are always continuously rasterised, as text is vector in After Effects (#390, #194):
/// text scaled up with the layer's Scale is drawn at its on-screen size, as sharp as text set at
/// that font size, without the switch.
#[test]
fn text_scaled_up_stays_sharp_without_the_switch() {
let run = |size: f64, scale: f64| {
let mut p = project();
let comp = Comp::new(240, 160, FrameRate::FPS_30, Tick::from_seconds_f64(2.0));
let mut l = build::layer(&mut p, &comp, "Text", LayerSource::Text, (240, 160), None);
l.props.prop_mut("text/sourceText").unwrap().value = Value::Text(Box::new(TextDoc { text: "HE".into(), size, ..Default::default() }));
set(&mut l, "transform/anchor", Value::Vec3([0.0, 0.0, 0.0]));
set(&mut l, "transform/position", Value::Vec3([40.0, 120.0, 0.0]));
set(&mut l, "transform/scale", Value::Vec3([scale, scale, 100.0]));
assert!(!l.switches.collapse);
let mut c = comp;
c.layers = vec![l];
let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(c.into()));
render_frame(&p, cid, Tick::ZERO, 1.0)
};
let partial = |img: &Image| img.data.iter().filter(|px| (0.02..0.98).contains(&px[3])).count();
let scaled = run(10.0, 800.0);
let native = run(80.0, 100.0);
let (ps, pn) = (partial(&scaled), partial(&native));
let d = max_diff(&scaled, &native);
eprintln!("partially covered pixels: scaled {ps}, native {pn}; max difference {d}");
assert!(native.data.iter().filter(|px| px[3] > 0.98).count() > 1000, "the text renders");
assert!(ps <= pn + pn / 2, "scaled text is blurred: {ps} soft pixels against {pn}");
assert!(d < 0.05, "scaled text differs from text set at that size by {d}");
}
/// Unscaled text is drawn at the output scale wherever it sits: the lengths a position that isn't a
/// whole number maps the unit vectors to, a rounding error over 1, don't raise its raster scale a
/// quarter octave (#390: that softened it, and drew it differently at nearby times).
#[test]
fn unscaled_text_rasterises_at_the_output_scale() {
let mut p = project();
let mut comp = Comp::new(240, 160, FrameRate::FPS_30, Tick::from_seconds_f64(2.0));
let mut l = build::layer(&mut p, &comp, "Text", LayerSource::Text, (240, 160), None);
l.props.prop_mut("text/sourceText").unwrap().value = Value::Text(Box::new(TextDoc { text: "HE".into(), size: 30.0, ..Default::default() }));
comp.layers = vec![l];
let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.into()));
for i in 0..500 {
let pos = [3.1 + i as f64 * 0.37, 7.3 + i as f64 * 0.29, 0.0];
set(&mut p.comp_mut(cid).unwrap().layers[0], "transform/position", Value::Vec3(pos));
let r = Renderer::new(&p, &NoFootage, RenderOpts::default());
let c = p.comp(cid).unwrap();
let k = r.raster_scale(&EvalCtx::new(&p, cid, c, Tick::ZERO), &c.layers[0]);
assert_eq!(k, 1.0, "text at {pos:?}");
}
}
/// Effects on a shape layer run on its continuously rasterised pixels, still in layer space as
/// with the switch off in After Effects: a 10 px square scaled 800% with a 2 px blur looks like
/// an 80 px square with a 16 px blur, not a blurred bitmap enlarged afterwards (#751).
#[test]
fn effects_on_a_scaled_shape_follow_its_scale() {
let run = |size: f64, scale: f64, blur: f64| {
let mut p = project();
let comp = Comp::new(200, 200, FrameRate::FPS_30, Tick::from_seconds_f64(2.0));
let mut l = build::layer(&mut p, &comp, "Shape", LayerSource::Shape, (200, 200), None);
let mut next = p.next_id;
let mut ids = Ids(&mut next);
let rect = build::shape_rect(&mut ids, [size, size], [0.0, 0.0], 0.0);
let fill = build::shape_fill(&mut ids, [1.0, 1.0, 1.0, 1.0]);
let g = build::shape_group(&mut ids, "Rectangle 1", vec![rect, fill]);
let spec = effectcraft_effects::find("ec.blur.gaussian").unwrap();
let mut fx = effectcraft_effects::instantiate(spec, &mut ids, spec.name, [200.0, 200.0]);
p.next_id = next;
fx.prop_mut("blurriness").unwrap().value = Value::Scalar(blur);
l.props.sub_mut("contents").unwrap().children.push(g.into());
l.props.sub_mut("effects").unwrap().children.push(fx.into());
set(&mut l, "transform/anchor", Value::Vec3([0.0, 0.0, 0.0]));
set(&mut l, "transform/position", Value::Vec3([100.0, 100.0, 0.0]));
set(&mut l, "transform/scale", Value::Vec3([scale, scale, 100.0]));
let mut c = comp;
c.layers = vec![l];
let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(c.into()));
render_frame(&p, cid, Tick::ZERO, 1.0)
};
let scaled = run(10.0, 800.0, 2.0);
let native = run(80.0, 100.0, 16.0);
let d = max_diff(&scaled, &native);
assert!(scaled.get(100, 100)[3] > 0.99 && scaled.get(70, 100)[3] > 0.05, "the blur spreads the edge");
assert!(d < 0.05, "scaled blurred shape differs from the native one by {d}");
}
+341
View File
@@ -0,0 +1,341 @@
//! Bit depth, working space and linear blending.
use std::sync::Arc;
use effectcraft_color::{BlendMode, ColorSpace, Label};
use effectcraft_project::build;
use effectcraft_project::{AlphaMode, BitDepth, Comp, Footage, FootageKind, ItemId, ItemKind, LayerSource, Project, Solid};
use effectcraft_time::{FrameRate, Tick};
use crate::{FloatCurve, FootageSource, Image, LayerCache, RenderOpts, Renderer, render_frame};
fn setup(depth: BitDepth) -> (Project, ItemId, Comp) {
let mut p = Project::default();
p.settings.bit_depth = depth;
let comp = Comp::new(40, 20, 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)
}
fn add_solid(p: &mut Project, cid: ItemId, color: [f32; 3], mode: BlendMode, opacity: f64) {
let comp = p.comp(cid).unwrap().clone();
let sid = p.add_item("S", Label::Red, None, ItemKind::Solid(Solid { color, width: 40, height: 20, pixel_aspect: 1.0 }));
let mut l = build::layer(p, &comp, "S", LayerSource::Solid { item: sid }, (40, 20), None);
l.blend_mode = mode;
l.props.prop_mut("transform/opacity").unwrap().value = effectcraft_keyframe::Value::Scalar(opacity);
// New layers go on top.
p.comp_mut(cid).unwrap().layers.insert(0, l);
}
fn px(p: &Project, cid: ItemId) -> [f32; 4] {
render_frame(p, cid, Tick::ZERO, 1.0).get(10, 10)
}
#[test]
fn add_clamps_in_8_and_16_but_not_32_bpc() {
for (depth, want) in [(BitDepth::Bpc8, 1.0), (BitDepth::Bpc16, 1.0), (BitDepth::Bpc32, 1.6)] {
let (mut p, cid, _) = setup(depth);
add_solid(&mut p, cid, [0.8, 0.8, 0.8], BlendMode::Normal, 100.0);
add_solid(&mut p, cid, [0.8, 0.8, 0.8], BlendMode::Add, 100.0);
let v = px(&p, cid)[0];
assert!((v - want).abs() < 1e-5, "{depth:?}: {v}");
}
// Screen of over-range values survives in 32 bpc only: a 1.6 base screened with 0.5.
let (mut p, cid, _) = setup(BitDepth::Bpc32);
add_solid(&mut p, cid, [0.8, 0.8, 0.8], BlendMode::Normal, 100.0);
add_solid(&mut p, cid, [0.8, 0.8, 0.8], BlendMode::Add, 100.0);
add_solid(&mut p, cid, [0.5, 0.5, 0.5], BlendMode::Screen, 100.0);
let v = px(&p, cid)[0];
assert!((v - (1.6 + 0.5 - 0.8)).abs() < 1e-5, "{v}");
}
#[test]
fn quantization_steps_per_depth() {
let want = |depth| match depth {
BitDepth::Bpc8 => (0.3f32 * 255.0).round() / 255.0,
BitDepth::Bpc16 => (0.3f32 * 32768.0).round() / 32768.0,
BitDepth::Bpc32 => 0.3,
};
for depth in [BitDepth::Bpc8, BitDepth::Bpc16, BitDepth::Bpc32] {
let (mut p, cid, _) = setup(depth);
add_solid(&mut p, cid, [0.3, 0.3, 0.3], BlendMode::Normal, 100.0);
let v = px(&p, cid)[0];
assert!((v - want(depth)).abs() < 1e-7, "{depth:?}: {v} vs {}", want(depth));
}
// 8 bpc: 77/255 = 0.30196 (one step is 1/255); 16 bpc is within 1/65536.
let (mut p8, c8, _) = setup(BitDepth::Bpc8);
add_solid(&mut p8, c8, [0.3, 0.3, 0.3], BlendMode::Normal, 100.0);
assert!((px(&p8, c8)[0] - 77.0 / 255.0).abs() < 1e-7);
// A 1% opacity layer of white over black lands on a whole 8-bit step.
let (mut p, cid, _) = setup(BitDepth::Bpc8);
add_solid(&mut p, cid, [0.0, 0.0, 0.0], BlendMode::Normal, 100.0);
add_solid(&mut p, cid, [1.0, 1.0, 1.0], BlendMode::Normal, 1.0);
let v = px(&p, cid)[0] * 255.0;
assert!((v - v.round()).abs() < 1e-4 && (v - 2.55).abs() < 1.0, "{v}");
}
#[test]
fn linear_blending_of_red_over_green() {
// 50% red over green: gamma blending gives (0.5, 0.5, 0); 1.0-gamma blending mixes linear
// light, so each channel is the sRGB encoding of 0.5 linear ≈ 0.735.
let run = |linear: bool| {
let (mut p, cid, _) = setup(BitDepth::Bpc32);
p.settings.blend_linear = linear;
add_solid(&mut p, cid, [0.0, 1.0, 0.0], BlendMode::Normal, 100.0);
add_solid(&mut p, cid, [1.0, 0.0, 0.0], BlendMode::Normal, 50.0);
px(&p, cid)
};
let g = run(false);
assert!((g[0] - 0.5).abs() < 1e-5 && (g[1] - 0.5).abs() < 1e-5 && g[2].abs() < 1e-6, "{g:?}");
let l = run(true);
let e = effectcraft_color::linear_to_srgb(0.5);
assert!((l[0] - e).abs() < 1e-4 && (l[1] - e).abs() < 1e-4 && l[2].abs() < 1e-6, "{l:?}");
assert!((e - 0.7354).abs() < 1e-3);
// Unblended (opaque) layers are unchanged by linear blending.
let (mut p, cid, _) = setup(BitDepth::Bpc32);
p.settings.blend_linear = true;
add_solid(&mut p, cid, [0.25, 0.5, 0.75], BlendMode::Normal, 100.0);
let o = px(&p, cid);
assert!((o[0] - 0.25).abs() < 1e-5 && (o[1] - 0.5).abs() < 1e-5 && (o[2] - 0.75).abs() < 1e-5, "{o:?}");
}
#[test]
fn linearized_working_space_blends_linear_and_round_trips_colours() {
let (mut p, cid, _) = setup(BitDepth::Bpc32);
p.settings.working_space = Some(ColorSpace::Srgb);
p.settings.linearize = true;
add_solid(&mut p, cid, [0.0, 1.0, 0.0], BlendMode::Normal, 100.0);
add_solid(&mut p, cid, [1.0, 0.0, 0.0], BlendMode::Normal, 50.0);
let l = px(&p, cid);
let e = effectcraft_color::linear_to_srgb(0.5);
assert!((l[0] - e).abs() < 1e-4 && (l[1] - e).abs() < 1e-4, "{l:?}");
// An opaque solid shows its own colour (linearised in, encoded out).
let (mut p, cid, _) = setup(BitDepth::Bpc32);
p.settings.working_space = Some(ColorSpace::Srgb);
p.settings.linearize = true;
add_solid(&mut p, cid, [0.2, 0.5, 0.9], BlendMode::Normal, 100.0);
let o = px(&p, cid);
assert!((o[0] - 0.2).abs() < 1e-4 && (o[1] - 0.5).abs() < 1e-4 && (o[2] - 0.9).abs() < 1e-4, "{o:?}");
// Linearize needs a working space (like AE, where it is disabled without one).
let (mut p, cid, _) = setup(BitDepth::Bpc32);
p.settings.linearize = true;
add_solid(&mut p, cid, [0.0, 1.0, 0.0], BlendMode::Normal, 100.0);
add_solid(&mut p, cid, [1.0, 0.0, 0.0], BlendMode::Normal, 50.0);
assert!((px(&p, cid)[0] - 0.5).abs() < 1e-5);
}
#[test]
fn wide_working_space_colours_leave_the_display_gamut() {
// Pure Rec. 2020 red is outside sRGB: in 32 bpc the display conversion keeps the
// over-range/negative values; 8 bpc clamps them.
let (mut p, cid, _) = setup(BitDepth::Bpc32);
p.settings.working_space = Some(ColorSpace::Rec2020);
add_solid(&mut p, cid, [1.0, 0.0, 0.0], BlendMode::Normal, 100.0);
let o = px(&p, cid);
assert!(o[0] > 1.0 && o[1] < 0.0 && o[2] < 0.0, "{o:?}");
p.settings.bit_depth = BitDepth::Bpc8;
let o = px(&p, cid);
assert!((o[0] - 1.0).abs() < 1e-6 && o[1] == 0.0 && o[2] == 0.0, "{o:?}");
// sRGB working space: no conversion at all.
let (mut p, cid, _) = setup(BitDepth::Bpc32);
p.settings.working_space = Some(ColorSpace::Srgb);
add_solid(&mut p, cid, [0.3, 0.6, 0.9], BlendMode::Normal, 100.0);
let o = px(&p, cid);
assert!((o[0] - 0.3).abs() < 1e-6 && (o[2] - 0.9).abs() < 1e-6);
}
/// One flat-colour footage frame.
struct Flat([f32; 3]);
impl FootageSource for Flat {
fn frame(&self, _: ItemId, f: &Footage, _: Tick) -> Option<Arc<Image>> {
let c = self.0;
Some(Arc::new(Image::filled(f.width, f.height, [c[0], c[1], c[2], 1.0])))
}
}
fn footage(profile: Option<ColorSpace>) -> Footage {
Footage {
path: "x.png".into(),
kind: FootageKind::Still,
width: 40,
height: 20,
pixel_aspect: 1.0,
frame_rate: FrameRate::FPS_30,
native_rate: None,
duration: Tick::ZERO,
has_video: true,
has_audio: false,
alpha: AlphaMode::Ignore,
premul_color: [0.0; 3],
loop_count: 1,
codec: String::new(),
missing: false,
sequence: vec![],
color_profile: profile,
..Default::default()
}
}
#[test]
fn footage_profile_is_converted_into_the_working_space() {
let render = |ws: Option<ColorSpace>, profile: Option<ColorSpace>| {
let (mut p, cid, comp) = setup(BitDepth::Bpc32);
p.settings.working_space = ws;
let fid = p.add_item("F", Label::Aqua, None, ItemKind::Footage(footage(profile)));
let l = build::layer(&mut p, &comp, "F", LayerSource::Footage { item: fid }, (40, 20), None);
p.comp_mut(cid).unwrap().layers.push(l);
let src = Flat([1.0, 0.0, 0.0]);
Renderer::new(&p, &src, RenderOpts::default()).comp_frame(cid, Tick::ZERO).get(5, 5)
};
// Unmanaged: the file's values pass straight through.
let o = render(None, Some(ColorSpace::Rec2020));
assert_eq!(o, [1.0, 0.0, 0.0, 1.0]);
// Managed: Rec. 2020 red shown on an sRGB display is beyond sRGB red.
let o = render(Some(ColorSpace::Srgb), Some(ColorSpace::Rec2020));
assert!(o[0] > 1.0 && o[1] < 0.0, "{o:?}");
// Untagged footage is sRGB: a round trip through a Rec. 2020 working space.
let o = render(Some(ColorSpace::Rec2020), None);
assert!((o[0] - 1.0).abs() < 2e-3 && o[1].abs() < 2e-3 && o[2].abs() < 2e-3, "{o:?}");
}
/// #411: Interpret Footage ▸ Preserve RGB: the footage's values enter the composition as the file
/// stores them, whatever its colour profile, Interpret As Linear Light or the working space.
#[test]
fn preserve_rgb_footage_is_not_converted() {
let render = |linearize: bool, f: Footage| {
let (mut p, cid, comp) = setup(BitDepth::Bpc32);
p.settings.working_space = Some(ColorSpace::Rec2020);
p.settings.linearize = linearize;
let fid = p.add_item("F", Label::Aqua, None, ItemKind::Footage(f));
let l = build::layer(&mut p, &comp, "F", LayerSource::Footage { item: fid }, (40, 20), None);
p.comp_mut(cid).unwrap().layers.push(l);
let src = Flat([0.18, 0.5, 0.75]);
let out = Renderer::new(&p, &src, RenderOpts::default()).comp_frame(cid, Tick::ZERO).get(5, 5);
// Back from the output conversion (working space → sRGB) to the working space's values.
let ws = effectcraft_color::Conversion::new(ColorSpace::Srgb, false, ColorSpace::Rec2020, linearize).unwrap();
let c = ws.apply([out[0], out[1], out[2]]);
[c[0], c[1], c[2], out[3]]
};
let preserve = Footage { preserve_rgb: true, linear_light: true, ..footage(Some(ColorSpace::DisplayP3)) };
for linearize in [false, true] {
let o = render(linearize, preserve.clone());
assert!((o[0] - 0.18).abs() < 1e-3 && (o[1] - 0.5).abs() < 1e-3 && (o[2] - 0.75).abs() < 1e-3, "linearize {linearize}: {o:?}");
// Without Preserve RGB the profile and linear light convert the values.
let o = render(linearize, Footage { preserve_rgb: false, ..preserve.clone() });
assert!((o[0] - 0.18).abs() > 0.05, "linearize {linearize}: {o:?}");
}
}
#[test]
fn layer_cache_is_keyed_by_colour_settings() {
let (mut p, cid, _) = setup(BitDepth::Bpc8);
add_solid(&mut p, cid, [0.3, 0.3, 0.3], BlendMode::Normal, 100.0);
let cache = LayerCache::default();
let frame = |p: &Project| {
let mut r = Renderer::new(p, &crate::NoFootage, RenderOpts::default());
r.cache = Some(&cache);
r.comp_frame(cid, Tick::ZERO).get(10, 10)[0]
};
let a = frame(&p);
assert!((a - 77.0 / 255.0).abs() < 1e-7);
assert_eq!(cache.stats().entries, 1);
p.settings.bit_depth = BitDepth::Bpc32;
let b = frame(&p);
assert!((b - 0.3).abs() < 1e-7, "stale 8 bpc pixels: {b}");
p.settings.working_space = Some(ColorSpace::Srgb);
p.settings.linearize = true;
let _ = frame(&p);
assert_eq!(cache.stats().entries, 3, "each setting renders its own entry");
// Back to 8 bpc reuses the first entry.
p.settings.bit_depth = BitDepth::Bpc8;
p.settings.working_space = None;
p.settings.linearize = false;
let hits = cache.stats().hits;
assert!((frame(&p) - a).abs() < 1e-9);
assert_eq!(cache.stats().hits, hits + 1);
}
/// #495: with no working space, Interpret As Linear Light footage is encoded with the project's
/// Working Gamma: the sRGB curve at 2.2, a pure 2.4 power curve at 2.4 (as After Effects does);
/// Blend Colors Using 1.0 Gamma linearizes with the same curve. A working space ignores it.
#[test]
fn working_gamma_24_encodes_linear_footage_with_a_power_curve() {
let render = |gamma: f64, ws: Option<ColorSpace>| {
let (mut p, cid, comp) = setup(BitDepth::Bpc32);
p.settings.working_gamma = gamma;
p.settings.working_space = ws;
let fid = p.add_item("F", Label::Aqua, None, ItemKind::Footage(Footage { linear_light: true, ..footage(None) }));
let l = build::layer(&mut p, &comp, "F", LayerSource::Footage { item: fid }, (40, 20), None);
p.comp_mut(cid).unwrap().layers.push(l);
let src = Flat([0.051, 0.18, 0.5]);
Renderer::new(&p, &src, RenderOpts::default()).comp_frame(cid, Tick::ZERO).get(5, 5)
};
let near = |o: [f32; 4], want: [f32; 3]| (0..3).all(|i| (o[i] - want[i]).abs() < 2e-3);
let o = render(2.4, None);
assert!(near(o, [0.290, 0.490, 0.749]), "2.4: {o:?}");
let o = render(2.2, None);
assert!(near(o, [0.250, 0.461, 0.735]), "2.2: {o:?}");
// A working space ignores Working Gamma.
assert_eq!(render(2.4, Some(ColorSpace::Srgb)), render(2.2, Some(ColorSpace::Srgb)));
// Blending with 1.0 gamma: 50% white over black is 0.5 linear, encoded with the curve.
let blend = |gamma: f64| {
let (mut p, cid, _) = setup(BitDepth::Bpc32);
p.settings.working_gamma = gamma;
p.settings.blend_linear = true;
add_solid(&mut p, cid, [0.0, 0.0, 0.0], BlendMode::Normal, 100.0);
add_solid(&mut p, cid, [1.0, 1.0, 1.0], BlendMode::Normal, 50.0);
px(&p, cid)[0]
};
assert!((blend(2.4) - 0.5f32.powf(1.0 / 2.4)).abs() < 2e-3, "{}", blend(2.4));
assert!((blend(2.2) - 0.7354).abs() < 2e-3, "{}", blend(2.2));
assert_ne!(crate::color::Pipe::of(&Project::default().settings).key(), {
let mut s = Project::default().settings;
s.working_gamma = 2.4;
crate::color::Pipe::of(&s).key()
});
}
/// Float footage (an OpenEXR) the way `effectcraft-media` decodes it: linear values handed over
/// as the renderer asks ([`FloatCurve`]).
struct FloatFile([f32; 3]);
impl FootageSource for FloatFile {
fn frame(&self, item: ItemId, f: &Footage, t: Tick) -> Option<Arc<Image>> {
self.frame_with(item, f, t, None, FloatCurve::Srgb)
}
fn frame_with(&self, _: ItemId, f: &Footage, _: Tick, _: Option<(u32, u32)>, float: FloatCurve) -> Option<Arc<Image>> {
let c = self.0.map(|v| match float {
FloatCurve::Srgb => ColorSpace::Srgb.encode(v),
FloatCurve::Gamma24 => v.powf(1.0 / 2.4),
FloatCurve::Linear => v,
});
Some(Arc::new(Image::filled(f.width, f.height, [c[0], c[1], c[2], 1.0])))
}
}
/// #750: with no working space, float footage is encoded with the Working Gamma's curve (as
/// After Effects converts a linear EXR), and Interpret As Linear Light encodes it once, not on
/// top of the decode's own encoding. A working space ignores Working Gamma.
#[test]
fn float_footage_is_encoded_once_with_the_working_gamma() {
let render = |gamma: f64, ws: Option<ColorSpace>, linear_light: bool| {
let (mut p, cid, comp) = setup(BitDepth::Bpc32);
p.settings.working_gamma = gamma;
p.settings.working_space = ws;
let f = Footage { path: "x.exr".into(), linear_light, ..footage(None) };
let fid = p.add_item("F", Label::Aqua, None, ItemKind::Footage(f));
let l = build::layer(&mut p, &comp, "F", LayerSource::Footage { item: fid }, (40, 20), None);
p.comp_mut(cid).unwrap().layers.push(l);
let src = FloatFile([0.051, 0.18, 0.5]);
Renderer::new(&p, &src, RenderOpts::default()).comp_frame(cid, Tick::ZERO).get(5, 5)
};
let near = |o: [f32; 4], want: [f32; 3]| (0..3).all(|i| (o[i] - want[i]).abs() < 2e-3);
for linear_light in [false, true] {
let o = render(2.4, None, linear_light);
assert!(near(o, [0.290, 0.490, 0.749]), "2.4, linear light {linear_light}: {o:?}");
let o = render(2.2, None, linear_light);
assert!(near(o, [0.250, 0.461, 0.735]), "2.2, linear light {linear_light}: {o:?}");
let srgb = render(2.2, Some(ColorSpace::Srgb), linear_light);
assert!(near(srgb, [0.250, 0.461, 0.735]), "sRGB working space, linear light {linear_light}: {srgb:?}");
assert_eq!(render(2.4, Some(ColorSpace::Srgb), linear_light), srgb);
}
}
+108
View File
@@ -0,0 +1,108 @@
//! Footage at a reduced resolution: a source that resamples frames itself
//! ([`FootageSource::frame_at_size`], movies in `effectcraft-media`) renders what resampling the
//! full frame renders.
use std::sync::{Arc, Mutex};
use rayon::prelude::*;
use effectcraft_color::Label;
use effectcraft_project::build;
use effectcraft_project::{AlphaMode, BitDepth, Comp, Footage, FootageKind, ItemId, ItemKind, LayerSource, Project};
use effectcraft_time::{FrameRate, Tick};
use crate::{FootageSource, Image, RenderOpts, Renderer};
/// Footage with a textured frame; `sizes` (when set) resamples it itself and records the sizes
/// it was asked for.
struct Clip {
sizes: Option<Mutex<Vec<(u32, u32)>>>,
}
impl FootageSource for Clip {
fn frame(&self, _: ItemId, f: &Footage, _: Tick) -> Option<Arc<Image>> {
let mut img = Image::new(f.width, f.height);
img.rows_mut().for_each(|(y, row)| {
for (x, p) in row.iter_mut().enumerate() {
let s = (0.5 + 0.3 * (x as f32 * 0.37 + y as f32 * 0.11).sin()) * 0.9;
*p = [s, 1.0 - s, s * 0.5, 1.0];
}
});
Some(Arc::new(img))
}
fn frame_at_size(&self, item: ItemId, f: &Footage, t: Tick, w: u32, h: u32) -> Option<Arc<Image>> {
let Some(sizes) = &self.sizes else { return self.frame(item, f, t) };
sizes.lock().unwrap().push((w, h));
Some(Arc::new(effectcraft_raster::resample(&*self.frame(item, f, t)?, w, h)))
}
}
fn project(depth: BitDepth, invert_alpha: bool) -> (Project, ItemId) {
let mut p = Project::default();
p.settings.bit_depth = depth;
let comp = Comp::new(96, 54, FrameRate::FPS_30, Tick::from_seconds_f64(1.0));
let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.clone().into()));
let f = Footage {
path: "clip.mp4".into(),
kind: FootageKind::Video,
width: 96,
height: 54,
pixel_aspect: 1.0,
frame_rate: FrameRate::FPS_30,
duration: Tick::from_seconds_f64(1.0),
has_video: true,
alpha: AlphaMode::Ignore,
loop_count: 1,
invert_alpha,
..Default::default()
};
let fid = p.add_item("clip", Label::Aqua, None, ItemKind::Footage(f));
let l = build::layer(&mut p, &comp, "clip", LayerSource::Footage { item: fid }, (96, 54), None);
p.comp_mut(cid).unwrap().layers.push(l);
(p, cid)
}
#[test]
fn reduced_renders_take_footage_resampled_by_the_source() {
for depth in [BitDepth::Bpc8, BitDepth::Bpc32] {
let (p, cid) = project(depth, false);
for (scale, asked) in [(1.0, None), (0.75, None), (0.5, Some((48, 27))), (1.0 / 3.0, Some((32, 18))), (0.25, Some((24, 14)))] {
let opts = RenderOpts { scale, ..Default::default() };
let plain = Renderer::new(&p, &Clip { sizes: None }, opts).comp_frame(cid, Tick::ZERO);
let sized = Clip { sizes: Some(Mutex::default()) };
let frame = Renderer::new(&p, &sized, opts).comp_frame(cid, Tick::ZERO);
assert_eq!(frame, plain, "{depth:?} at {scale}");
assert_eq!(sized.sizes.unwrap().into_inner().unwrap(), asked.into_iter().collect::<Vec<_>>(), "{depth:?} at {scale}");
}
}
// Interpreted pixels (Invert Alpha) are interpreted at full size, then resampled.
let (p, cid) = project(BitDepth::Bpc8, true);
let sized = Clip { sizes: Some(Mutex::default()) };
Renderer::new(&p, &sized, RenderOpts { scale: 0.5, ..Default::default() }).comp_frame(cid, Tick::ZERO);
assert!(sized.sizes.unwrap().into_inner().unwrap().is_empty());
}
/// #1812: a still's layer is kept in the layer cache across times (its texture then uploads to
/// the GPU once, not every frame); a movie's is fetched again at every time.
#[test]
fn still_layers_are_cached_across_times_and_movies_are_not() {
for (kind, fetches) in [(FootageKind::Still, 1), (FootageKind::Video, 3)] {
let (mut p, cid) = project(BitDepth::Bpc8, false);
let Some(LayerSource::Footage { item }) = p.comp(cid).map(|c| c.layers[0].source.clone()) else { panic!("footage layer") };
if let Some(ItemKind::Footage(f)) = p.item_mut(item).map(|it| &mut it.kind) {
f.kind = kind;
}
let cache = crate::LayerCache::default();
let src = Clip { sizes: Some(Mutex::default()) };
let opts = RenderOpts { scale: 0.5, ..Default::default() };
for t in [0.0, 0.25, 0.5] {
let t = Tick::from_seconds_f64(t);
let mut r = Renderer::new(&p, &src, opts);
r.cache = Some(&cache);
let frame = r.comp_frame(cid, t);
assert_eq!(frame, Renderer::new(&p, &src, opts).comp_frame(cid, t), "{kind:?} at {t:?}");
}
// (each uncached render above fetches once more)
assert_eq!(src.sizes.unwrap().into_inner().unwrap().len(), fetches + 3, "{kind:?}");
}
}
+169
View File
@@ -0,0 +1,169 @@
//! Frame blending (Frame Mix and Pixel Motion) for footage and precomp layers.
use std::sync::Arc;
use rayon::prelude::*;
use effectcraft_color::Label;
use effectcraft_keyframe::{Keyframe, Value};
use effectcraft_project::build;
use effectcraft_project::{AlphaMode, BitDepth, Comp, Footage, FootageKind, FrameBlend, ItemId, ItemKind, LayerSource, Project, Solid};
use effectcraft_time::{FrameRate, Tick};
use crate::{FootageSource, Image, RenderOpts, Renderer, frame_position};
#[test]
fn frame_mix_weights_for_24_to_30_conform() {
// Comp frame k at 30 fps sits at source frame 0.8·k of 24 fps footage.
let want = [(0, 0.0), (0, 0.8), (1, 0.6), (2, 0.4), (3, 0.2), (4, 0.0), (4, 0.8)];
for (k, (i, w)) in want.into_iter().enumerate() {
let t = FrameRate::FPS_30.tick_of(k as i64);
let (fi, fw) = frame_position(t, FrameRate::FPS_24);
assert_eq!(fi, i, "comp frame {k}");
assert!((fw - w).abs() < 1e-12, "comp frame {k}: {fw} vs {w}");
}
// NTSC 29.97 footage in a 29.97 comp lines up exactly.
for k in [0, 1, 7, 1001, 30_000] {
assert_eq!(frame_position(FrameRate::FPS_29_97.tick_of(k), FrameRate::FPS_29_97), (k, 0.0));
}
}
/// Footage whose frame `i` is a flat grey of `i / 10` (24 fps), or a textured pattern moving
/// 6 px right per frame.
struct Frames {
moving: bool,
}
fn pattern(w: u32, h: u32, dx: f64) -> Image {
let mut img = Image::new(w, h);
img.rows_mut().for_each(|(y, row)| {
for (x, p) in row.iter_mut().enumerate() {
let (u, v) = (x as f64 - dx, y as f64);
let s = (0.5 + 0.2 * (u * 0.21 + v * 0.07).sin() + 0.15 * (u * 0.05 - v * 0.19).cos()) as f32;
*p = [s, s * 0.8, 1.0 - s, 1.0];
}
});
img
}
impl FootageSource for Frames {
fn frame(&self, _: ItemId, f: &Footage, t: Tick) -> Option<Arc<Image>> {
let i = f.frame_rate.frame_at(t);
if self.moving {
return Some(Arc::new(pattern(f.width, f.height, 6.0 * i as f64)));
}
let g = i as f32 / 10.0;
Some(Arc::new(Image::filled(f.width, f.height, [g, g, g, 1.0])))
}
}
fn setup(mode: FrameBlend, size: (u32, u32)) -> (Project, ItemId) {
let mut p = Project::default();
p.settings.bit_depth = BitDepth::Bpc32;
let comp = Comp::new(size.0, size.1, FrameRate::FPS_30, Tick::from_seconds_f64(2.0));
let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.clone().into()));
let f = Footage {
path: "clip.mov".into(),
kind: FootageKind::Video,
width: size.0,
height: size.1,
pixel_aspect: 1.0,
frame_rate: FrameRate::FPS_24,
native_rate: None,
duration: Tick::from_seconds_f64(2.0),
has_video: true,
has_audio: false,
alpha: AlphaMode::Ignore,
premul_color: [0.0; 3],
loop_count: 1,
codec: String::new(),
missing: false,
sequence: vec![],
color_profile: None,
..Default::default()
};
let fid = p.add_item("clip", Label::Aqua, None, ItemKind::Footage(f));
let mut l = build::layer(&mut p, &comp, "clip", LayerSource::Footage { item: fid }, size, None);
l.switches.frame_blend = mode;
p.comp_mut(cid).unwrap().layers.push(l);
(p, cid)
}
fn frame(p: &Project, cid: ItemId, k: i64, moving: bool) -> Image {
let src = Frames { moving };
Renderer::new(p, &src, RenderOpts::default()).comp_frame(cid, FrameRate::FPS_30.tick_of(k))
}
#[test]
fn frame_mix_blends_footage_frames() {
let (p, cid) = setup(FrameBlend::FrameMix, (16, 8));
// Comp frame 1 → source 0.8: 0.2·frame 0 + 0.8·frame 1 = 0.08.
let v = frame(&p, cid, 1, false).get(4, 4)[0];
assert!((v - 0.08).abs() < 1e-6, "{v}");
let v = frame(&p, cid, 2, false).get(4, 4)[0];
assert!((v - (0.4 * 0.1 + 0.6 * 0.2)).abs() < 1e-6, "{v}");
// Off (layer switch or comp switch): the frame at or before.
let (p, cid) = setup(FrameBlend::Off, (16, 8));
assert!((frame(&p, cid, 1, false).get(4, 4)[0] - 0.0).abs() < 1e-6);
let (mut p, cid) = setup(FrameBlend::FrameMix, (16, 8));
p.comp_mut(cid).unwrap().enable_frame_blending = false;
assert!((frame(&p, cid, 1, false).get(4, 4)[0] - 0.0).abs() < 1e-6);
}
fn mae(a: &Image, b: &Image, margin: u32) -> f32 {
let mut s = 0.0;
let mut n = 0;
for y in margin..a.height - margin {
for x in margin..a.width - margin {
let (p, q) = (a.get(x as i64, y as i64), b.get(x as i64, y as i64));
s += (0..3).map(|c| (p[c] - q[c]).abs()).sum::<f32>();
n += 3;
}
}
s / n as f32
}
#[test]
fn pixel_motion_beats_frame_mix_on_moving_footage() {
let size = (128, 96);
// Comp frame 2 → source frame 1.6: the pattern 9.6 px along.
let truth = pattern(size.0, size.1, 6.0 * 1.6);
let (p, cid) = setup(FrameBlend::FrameMix, size);
let mix = mae(&frame(&p, cid, 2, true), &truth, 14);
let (p, cid) = setup(FrameBlend::PixelMotion, size);
let pm = mae(&frame(&p, cid, 2, true), &truth, 14);
assert!(pm < mix * 0.3, "pixel motion {pm} vs frame mix {mix}");
}
#[test]
fn time_stretched_precomp_frame_mixes() {
// Inner comp: a white solid moving 10 px per frame from x = 10. The precomp is stretched to
// 200%, so odd outer frames fall halfway between inner frames.
let mut p = Project::default();
p.settings.bit_depth = BitDepth::Bpc32;
let mut inner = Comp::new(400, 20, FrameRate::FPS_30, Tick::from_seconds_f64(2.0));
let sid = p.add_item("S", Label::Red, None, ItemKind::Solid(Solid { color: [1.0; 3], width: 10, height: 20, pixel_aspect: 1.0 }));
let mut l = build::layer(&mut p, &inner, "S", LayerSource::Solid { item: sid }, (10, 20), None);
// Anchor at the left edge: x = 10 + 10 per frame.
l.props.prop_mut("transform/anchor").unwrap().value = Value::Vec3([0.0, 10.0, 0.0]);
l.props.prop_mut("transform/position").unwrap().keys =
vec![Keyframe::new(Tick::ZERO, Value::Vec3([10.0, 10.0, 0.0])), Keyframe::new(Tick::from_seconds_f64(1.0), Value::Vec3([310.0, 10.0, 0.0]))];
inner.layers.push(l);
let iid = p.add_item("Inner", Label::Sandstone, None, ItemKind::Comp(inner.into()));
let outer = Comp::new(400, 20, FrameRate::FPS_30, Tick::from_seconds_f64(4.0));
let cid = p.add_item("Outer", Label::Sandstone, None, ItemKind::Comp(outer.clone().into()));
let mut pre = build::layer(&mut p, &outer, "Inner", LayerSource::Comp { item: iid }, (400, 20), None);
pre.stretch = 200.0;
pre.out_point = Tick::from_seconds_f64(4.0);
pre.switches.frame_blend = FrameBlend::FrameMix;
p.comp_mut(cid).unwrap().layers.push(pre);
// Outer frame 1 → inner frame 0.5: solids at 10..20 (frame 0) and 20..30 (frame 1), half each.
let img = crate::render_frame(&p, cid, FrameRate::FPS_30.tick_of(1), 1.0);
for x in [12, 17, 22, 27] {
assert!((img.get(x, 10)[3] - 0.5).abs() < 5e-3, "x {x}: {:?}", img.get(x, 10));
}
// Without frame blending the nested comp renders at the in-between time: one solid, 15..25.
p.comp_mut(cid).unwrap().layers[0].switches.frame_blend = FrameBlend::Off;
let img = crate::render_frame(&p, cid, FrameRate::FPS_30.tick_of(1), 1.0);
assert!(img.get(12, 10)[3] < 1e-3 && (img.get(17, 10)[3] - 1.0).abs() < 1e-3 && (img.get(22, 10)[3] - 1.0).abs() < 1e-3 && img.get(27, 10)[3] < 1e-3);
}
+108
View File
@@ -0,0 +1,108 @@
//! Guide layers: drawn in their own comp's viewer (`RenderOpts::guides`), never when that comp is
//! nested in another (precomp, collapsed precomp, 2D or 3D), and never in final renders.
use effectcraft_color::Label;
use effectcraft_keyframe::Value;
use effectcraft_project::build;
use effectcraft_project::{BitDepth, Comp, ItemId, ItemKind, Layer, LayerSource, Project, Solid};
use effectcraft_time::{FrameRate, Tick};
use crate::{NoFootage, RenderOpts, Renderer};
fn solid(p: &mut Project, comp: &Comp, color: [f32; 3], size: (u32, u32), three_d: bool) -> Layer {
let sid = p.add_item("S", Label::Red, None, ItemKind::Solid(Solid { color, width: size.0, height: size.1, pixel_aspect: 1.0 }));
let mut l = build::layer(p, comp, "S", LayerSource::Solid { item: sid }, size, None);
l.props.prop_mut("transform/position").unwrap().value = Value::Vec3([50.0, 50.0, 0.0]);
l.switches.three_d = three_d;
l
}
#[derive(Clone, Copy, Debug)]
enum Nest {
/// A plain precomp layer (the nested comp rendered on its own, then composited).
Precomp,
/// Collapse Transformations on a 2D precomp layer.
Collapsed,
/// A collapsed 3D precomp in a Classic 3D comp.
Collapsed3d,
/// A collapsed 3D precomp in an Advanced 3D comp.
CollapsedAdvanced,
}
/// "Inner": a red 100×100 background with a green 40×40 guide layer in the middle; "Outer": the
/// same size, holding only Inner nested as `nest`.
fn scene(nest: Nest) -> (Project, ItemId, ItemId) {
let three_d = matches!(nest, Nest::Collapsed3d | Nest::CollapsedAdvanced);
let mut p = Project::default();
p.settings.bit_depth = BitDepth::Bpc32;
let inner = Comp::new(100, 100, FrameRate::FPS_30, Tick::from_seconds_f64(2.0));
let mut guide = solid(&mut p, &inner, [0.0, 1.0, 0.0], (40, 40), three_d);
guide.switches.guide = true;
let bg = solid(&mut p, &inner, [1.0, 0.0, 0.0], (100, 100), three_d);
let mut inner = inner;
inner.layers = vec![guide, bg];
let iid = p.add_item("Inner", Label::Sandstone, None, ItemKind::Comp(inner.into()));
let mut outer = Comp::new(100, 100, FrameRate::FPS_30, Tick::from_seconds_f64(2.0));
if matches!(nest, Nest::CollapsedAdvanced) {
outer.renderer = effectcraft_project::Renderer::Advanced3D;
}
let mut pre = build::layer(&mut p, &outer, "Inner", LayerSource::Comp { item: iid }, (100, 100), None);
pre.switches.collapse = !matches!(nest, Nest::Precomp);
pre.switches.three_d = three_d;
outer.layers = vec![pre];
let oid = p.add_item("Outer", Label::Sandstone, None, ItemKind::Comp(outer.into()));
(p, iid, oid)
}
/// The centre pixel of `comp` rendered with or without guide layers.
fn centre(p: &Project, comp: ItemId, guides: bool) -> [f32; 4] {
Renderer::new(p, &NoFootage, RenderOpts { guides, ..Default::default() }).comp_frame(comp, Tick::ZERO).get(50, 50)
}
fn is_green(c: [f32; 4]) -> bool {
c[1] > 0.9 && c[0] < 0.1
}
fn is_red(c: [f32; 4]) -> bool {
c[0] > 0.9 && c[1] < 0.1
}
#[test]
fn guide_layer_renders_in_its_own_comp_with_guides_on() {
let (p, inner, _) = scene(Nest::Precomp);
let c = centre(&p, inner, true);
assert!(is_green(c), "guide layer drawn in its own comp's viewer: {c:?}");
}
#[test]
fn final_renders_leave_guide_layers_out_by_default() {
assert!(!RenderOpts::default().guides);
let (p, inner, _) = scene(Nest::Precomp);
let c = centre(&p, inner, false);
assert!(is_red(c), "guide layer left out of final renders: {c:?}");
}
#[test]
fn guide_layers_of_a_nested_comp_never_render_in_the_parent() {
for nest in [Nest::Precomp, Nest::Collapsed, Nest::Collapsed3d, Nest::CollapsedAdvanced] {
let (p, inner, outer) = scene(nest);
// The nested comp's own viewer still shows them…
let own = centre(&p, inner, true);
assert!(is_green(own), "{nest:?}: guide layer in its own comp: {own:?}");
// …but not the parent's viewer (guides on) nor its final render.
for guides in [true, false] {
let c = centre(&p, outer, guides);
assert!(is_red(c), "{nest:?}, guides {guides}: nested guide layer drawn in the parent: {c:?}");
}
}
}
#[test]
fn a_guide_precomp_layer_of_the_top_comp_follows_the_guides_option() {
// The precomp layer itself is a guide layer in the top comp: shown in the viewer, not in
// final renders.
let (mut p, _, outer) = scene(Nest::Precomp);
p.comp_mut(outer).unwrap().layers[0].switches.guide = true;
assert!(is_red(centre(&p, outer, true)));
assert!(centre(&p, outer, false)[3] < 0.01, "nothing drawn without guides");
}
+77
View File
@@ -0,0 +1,77 @@
//! Mask motion blur follows the renderer's motion blur gate: the render options (Render
//! Settings ▸ Motion Blur, previews), the comp's switch and the precomp layers above.
use effectcraft_color::Label;
use effectcraft_keyframe::{Keyframe, ShapePath, Value};
use effectcraft_project::build::{self, Ids};
use effectcraft_project::{Comp, GroupKind, ItemId, ItemKind, LayerSource, MaskMode, MaskMotionBlur, Node, Project, Solid};
use effectcraft_time::{FrameRate, Tick};
use crate::{NoFootage, RenderOpts, Renderer};
/// A white 100×100 solid whose rect mask jumps from x 10 (frame 0) to x 50 (frame 1), Mask
/// Motion Blur On, the layer's own switch off. Rendered half way, unblurred the mask covers x
/// 30..50; blurred over the shutter it reaches down to x ≈ 20.
fn project(comp_blur: bool) -> (Project, ItemId) {
let mut p = Project::default();
let mut comp = Comp::new(100, 100, FrameRate::FPS_30, Tick::from_seconds_f64(1.0));
comp.enable_motion_blur = comp_blur;
let sid = p.add_item("S", Label::Red, None, ItemKind::Solid(Solid { color: [1.0; 3], width: 100, height: 100, pixel_aspect: 1.0 }));
let mut l = build::layer(&mut p, &comp, "S", LayerSource::Solid { item: sid }, (100, 100), None);
let mut next = p.next_id;
let mut m = build::mask(&mut Ids(&mut next), "Mask 1", ShapePath::rect([20.0, 50.0], 20.0, 100.0), MaskMode::Add, [255, 0, 0]);
p.next_id = next;
if let GroupKind::Mask { motion_blur, .. } = &mut m.kind {
*motion_blur = MaskMotionBlur::On;
}
let path = m.get_mut("path").unwrap();
path.keys = vec![
Keyframe::new(Tick::ZERO, Value::Path(ShapePath::rect([20.0, 50.0], 20.0, 100.0))),
Keyframe::new(FrameRate::FPS_30.tick_of(1), Value::Path(ShapePath::rect([60.0, 50.0], 20.0, 100.0))),
];
l.props.sub_mut("masks").unwrap().children.push(Node::Group(m));
l.switches.motion_blur = false;
let cid = p.add_item("C", Label::Sandstone, None, ItemKind::Comp(comp.into()));
p.comp_mut(cid).unwrap().layers.push(l);
(p, cid)
}
fn alpha_at_25(p: &Project, cid: ItemId, motion_blur: bool) -> f32 {
let half = Tick::from_seconds_f64(0.5 / 30.0);
Renderer::new(p, &NoFootage, RenderOpts { motion_blur, ..Default::default() }).comp_frame(cid, half).get(25, 50)[3]
}
/// A mask that holds from frame 0 to 2, then moves: frames 1 and 2 have the same mask value, but at
/// frame 2 the shutter already sees it move. The layer cache must not serve frame 1's layer there.
#[test]
fn mask_motion_blur_keys_the_layer_cache_by_time() {
let (mut p, cid) = project(true);
let rect = |x: f64| Value::Path(ShapePath::rect([x, 50.0], 20.0, 100.0));
let l = &mut p.comp_mut(cid).unwrap().layers[0];
let Some(Node::Group(m)) = l.props.sub_mut("masks").unwrap().children.first_mut() else { panic!("mask") };
m.get_mut("path").unwrap().keys = vec![
Keyframe::new(Tick::ZERO, rect(20.0)),
Keyframe::new(FrameRate::FPS_30.tick_of(2), rect(20.0)),
Keyframe::new(FrameRate::FPS_30.tick_of(3), rect(60.0)),
];
let cache = crate::LayerCache::default();
let render = |t: Tick, cached: bool| {
let mut r = Renderer::new(&p, &NoFootage, RenderOpts { motion_blur: true, ..Default::default() });
r.cache = cached.then_some(&cache);
r.comp_frame(cid, t)
};
let (f1, f2) = (FrameRate::FPS_30.tick_of(1), FrameRate::FPS_30.tick_of(2));
assert_ne!(render(f1, false), render(f2, false), "the shutter at frame 2 sees the move");
render(f1, true);
assert_eq!(render(f2, true), render(f2, false));
}
#[test]
fn mask_motion_blur_follows_the_render_and_the_comp() {
let (p, cid) = project(true);
let a = alpha_at_25(&p, cid, true);
assert!(a > 0.05 && a < 0.95, "blurred: {a}");
assert!(alpha_at_25(&p, cid, false) < 0.01, "Render Settings ▸ Motion Blur off");
let (p, cid) = project(false);
assert!(alpha_at_25(&p, cid, true) < 0.01, "the comp's switch off");
}
+82
View File
@@ -0,0 +1,82 @@
//! Mask Expansion grows or shrinks the mask outline by its distance, however large: past the
//! shape's curvature radius it must still cover everything within the distance of the path,
//! never leave a ring around an uncovered middle.
use effectcraft_color::Label;
use effectcraft_keyframe::{ShapePath, Value};
use effectcraft_project::build::{self, Ids};
use effectcraft_project::{Comp, ItemId, ItemKind, LayerSource, MaskMode, Node, Project, Solid};
use effectcraft_time::{FrameRate, Tick};
use crate::{NoFootage, RenderOpts, Renderer};
const W: u32 = 400;
const H: u32 = 300;
/// A white 400×300 solid with one mask of `shape` in `mode` expanded by `expansion` pixels.
fn project(shape: ShapePath, mode: MaskMode, expansion: f64) -> (Project, ItemId) {
let mut p = Project::default();
let comp = Comp::new(W, H, FrameRate::FPS_30, Tick::from_seconds_f64(1.0));
let sid = p.add_item("S", Label::Red, None, ItemKind::Solid(Solid { color: [1.0; 3], width: W, height: H, pixel_aspect: 1.0 }));
let mut l = build::layer(&mut p, &comp, "S", LayerSource::Solid { item: sid }, (W, H), None);
let mut next = p.next_id;
let mut m = build::mask(&mut Ids(&mut next), "Mask 1", shape, mode, [255, 0, 0]);
p.next_id = next;
m.get_mut("expansion").unwrap().value = Value::Scalar(expansion);
l.props.sub_mut("masks").unwrap().children.push(Node::Group(m));
let cid = p.add_item("C", Label::Sandstone, None, ItemKind::Comp(comp.into()));
p.comp_mut(cid).unwrap().layers.push(l);
(p, cid)
}
/// Alpha at each of `points` for a Subtract mask on a 40 px circle at the centre (the hole).
fn hole(expansion: f64, points: &[(i64, i64)]) -> Vec<f32> {
let (p, cid) = project(ShapePath::ellipse([200.0, 150.0], 40.0, 40.0), MaskMode::Subtract, expansion);
let img = Renderer::new(&p, &NoFootage, RenderOpts::default()).comp_frame(cid, Tick::ZERO);
points.iter().map(|&(x, y)| img.get(x, y)[3]).collect()
}
#[test]
fn no_expansion_cuts_the_circle() {
let a = hole(0.0, &[(200, 150), (215, 150), (225, 150)]);
assert!(a[0] < 0.01 && a[1] < 0.01, "inside the 20 px radius is cut: {a:?}");
assert!(a[2] > 0.99, "outside it is kept: {a:?}");
}
#[test]
fn expansion_past_the_radius_grows_a_solid_hole() {
// +60 on a 20 px radius: a solid hole of radius 80. The fill + stroke blend left a ring
// from 40 to 80 px with the layer showing at 21–39 px.
let a = hole(60.0, &[(200, 150), (230, 150), (200, 120), (275, 150), (285, 150)]);
assert!(a[..4].iter().all(|&v| v < 0.01), "everything within 80 px is cut: {a:?}");
assert!(a[4] > 0.99, "beyond 80 px is kept: {a:?}");
}
#[test]
fn huge_expansion_cuts_the_whole_frame() {
// +600 reaches every pixel; the blend left only the 20 px circle cut.
let a = hole(600.0, &[(200, 150), (230, 150), (0, 0), (399, 299), (399, 0)]);
assert!(a.iter().all(|&v| v < 0.01), "{a:?}");
}
#[test]
fn negative_expansion_shrinks_then_removes_the_hole() {
let a = hole(-15.0, &[(200, 150), (210, 150)]);
assert!(a[0] < 0.01, "a 5 px hole remains: {a:?}");
assert!(a[1] > 0.99, "shrunk away from 10 px out: {a:?}");
let a = hole(-25.0, &[(200, 150), (190, 150)]);
assert!(a.iter().all(|&v| v > 0.99), "shrunk past its radius, nothing is cut: {a:?}");
}
#[test]
fn expanded_rect_add_mask_has_round_corners() {
// A 100 px square mask (Add) grown by 30: the sides move out 30 px, the corners round off.
let (p, cid) = project(ShapePath::rect([200.0, 150.0], 100.0, 100.0), MaskMode::Add, 30.0);
let img = Renderer::new(&p, &NoFootage, RenderOpts::default()).comp_frame(cid, Tick::ZERO);
assert!(img.get(275, 150)[3] > 0.99, "25 px past the right side is inside");
assert!(img.get(285, 150)[3] < 0.01, "35 px past it is outside");
// 25 px diagonally out from the corner (250,100) is within 30 px: inside.
assert!(img.get(267, 82)[3] > 0.99);
// The square's bounding corner (280,70) is 42 px from the corner: a miter would cover it.
assert!(img.get(277, 73)[3] < 0.01);
}
+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);
}
}
+169
View File
@@ -0,0 +1,169 @@
//! Motion Tile's Output Width / Height on adjustment layers and on full-frame layers, with a
//! Transform after it that scales the tiled result into view (#1727, #1991).
use effectcraft_color::Label;
use effectcraft_keyframe::Value;
use effectcraft_project::build::{self, Ids};
use effectcraft_project::{Comp, ItemId, ItemKind, Layer, LayerSource, Project, Solid};
use effectcraft_time::{FrameRate, Tick};
use crate::{Image, render_frame};
const W: u32 = 200;
const H: u32 = 100;
fn solid(p: &mut Project, comp: &Comp, color: [f32; 3], w: u32, h: u32, pos: [f64; 2]) -> Layer {
let sid = p.add_item("Solid", Label::Red, None, ItemKind::Solid(Solid { color, width: w, height: h, pixel_aspect: 1.0 }));
let mut l = build::layer(p, comp, "Solid", LayerSource::Solid { item: sid }, (w, h), None);
l.props.prop_mut("transform/position").unwrap().value = Value::Vec3([pos[0], pos[1], 0.0]);
l
}
/// A white frame with a red 20×20 square in its top-left corner.
fn picture(p: &mut Project, comp: &Comp) -> Vec<Layer> {
vec![solid(p, comp, [1.0, 0.0, 0.0], 20, 20, [10.0, 10.0]), solid(p, comp, [1.0, 1.0, 1.0], W, H, [100.0, 50.0])]
}
/// Motion Tile (Output `out` %) then Transform (Scale `scale` %), on `l`.
fn tile_then_scale(p: &mut Project, l: &mut Layer, out: f64, scale: f64) {
let size = [W as f64, H as f64];
for (id, vals) in [
("ec.stylize.motiontile", vec![("outputWidth", Value::Scalar(out)), ("outputHeight", Value::Scalar(out))]),
("ec.distort.transform", vec![("scaleHeight", Value::Scalar(scale)), ("scaleWidth", Value::Scalar(scale))]),
] {
let spec = effectcraft_effects::find(id).unwrap();
let mut next = p.next_id;
let mut g = effectcraft_effects::instantiate(spec, &mut Ids(&mut next), spec.name, size);
p.next_id = next;
for (k, v) in vals {
g.prop_mut(k).unwrap().value = v;
}
l.props.sub_mut("effects").unwrap().children.push(g.into());
}
}
fn comp() -> (Project, ItemId, Comp) {
let mut p = Project::default();
p.settings.bit_depth = effectcraft_project::BitDepth::Bpc32;
let comp = Comp::new(W, H, FrameRate::FPS_30, Tick::from_seconds_f64(1.0));
let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.clone().into()));
(p, cid, comp)
}
/// The picture under an adjustment layer carrying the two effects.
fn adjustment_scene(out: f64, scale: f64) -> (Project, ItemId) {
let (mut p, cid, comp) = comp();
let mut adj = solid(&mut p, &comp, [1.0, 1.0, 1.0], W, H, [100.0, 50.0]);
adj.switches.adjustment = true;
tile_then_scale(&mut p, &mut adj, out, scale);
let mut layers = vec![adj];
layers.extend(picture(&mut p, &comp));
p.comp_mut(cid).unwrap().layers = layers;
(p, cid)
}
/// The picture as a full-frame precomp layer carrying the two effects.
fn layer_scene(out: f64, scale: f64) -> (Project, ItemId) {
let (mut p, cid, comp) = comp();
let inner = Comp::new(W, H, FrameRate::FPS_30, Tick::from_seconds_f64(1.0));
let iid = p.add_item("Picture", Label::Sandstone, None, ItemKind::Comp(inner.clone().into()));
let layers = picture(&mut p, &inner);
p.comp_mut(iid).unwrap().layers = layers;
let mut pre = build::layer(&mut p, &comp, "Picture", LayerSource::Comp { item: iid }, (W, H), None);
tile_then_scale(&mut p, &mut pre, out, scale);
p.comp_mut(cid).unwrap().layers.push(pre);
(p, cid)
}
fn is(img: &Image, x: i64, y: i64, want: [f32; 4], what: &str) {
let got = img.get(x, y);
assert!(got.iter().zip(want).all(|(a, b)| (a - b).abs() < 0.02), "{what}: ({x}, {y}) is {got:?}, want {want:?}");
}
const RED: [f32; 4] = [1.0, 0.0, 0.0, 1.0];
const WHITE: [f32; 4] = [1.0; 4];
const CLEAR: [f32; 4] = [0.0; 4];
/// Output 300 % then Scale 50 % about the frame centre: comp pixel (x, y) shows the tiled frame
/// at (100 + 2(x − 100), 50 + 2(y − 50)), so the red square repeats every 100 × 50 pixels and
/// the frame is covered edge to edge, the tiles from beyond the frame included.
fn assert_tiled(img: &Image, what: &str) {
for (x, y) in [(55, 30), (155, 30), (55, 80), (155, 80)] {
is(img, x, y, RED, what);
}
for (x, y) in [(5, 5), (195, 5), (5, 95), (195, 95), (100, 50), (100, 5)] {
is(img, x, y, WHITE, what);
}
}
#[test]
fn adjustment_motion_tile_output_spreads_tiles_past_the_frame() {
let (p, cid) = adjustment_scene(300.0, 50.0);
assert_tiled(&render_frame(&p, cid, Tick::ZERO, 1.0), "adjustment, Output 300 %");
}
#[test]
fn layer_motion_tile_output_spreads_tiles_past_the_frame() {
let (p, cid) = layer_scene(300.0, 50.0);
assert_tiled(&render_frame(&p, cid, Tick::ZERO, 1.0), "full-frame layer, Output 300 %");
}
/// Output under 100 % crops: only the centre 50 % of the frame is left, the rest is transparent
/// (After Effects shows the comp background there).
#[test]
fn adjustment_motion_tile_output_under_100_crops_the_frame() {
let (p, cid) = adjustment_scene(50.0, 100.0);
let img = render_frame(&p, cid, Tick::ZERO, 1.0);
is(&img, 100, 50, WHITE, "inside the kept centre");
is(&img, 60, 30, WHITE, "inside the kept centre");
for (x, y) in [(5, 5), (10, 10), (195, 95), (45, 50), (100, 20)] {
is(&img, x, y, CLEAR, "outside the kept centre");
}
}
/// The After Effects 2026 frames in #1727: a 1920×1080 red solid under an adjustment layer with
/// Motion Tile (Output 910 × 340 %, then 90 × 340 %) and Transform (Scale 12 %, Position 1361, 540).
/// The tiled frame, 9.1 × 3.4 comps scaled to 12 %, is a 2097 × 441 band centred on (1361, 540):
/// x 313–1920 (cut by the frame), y 320–760; at 90 % a 207-pixel column, x 1257–1465.
#[test]
fn adjustment_motion_tile_matches_the_after_effects_frames() {
for (out_w, x0, x1) in [(910.0, 313.0, 1920.0), (90.0, 1257.0, 1465.0)] {
let mut p = Project::default();
p.settings.bit_depth = effectcraft_project::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 red = solid(&mut p, &comp, [1.0, 0.0, 0.0], 1920, 1080, [960.0, 540.0]);
let mut adj = solid(&mut p, &comp, [1.0, 1.0, 1.0], 1920, 1080, [960.0, 540.0]);
adj.switches.adjustment = true;
for (id, vals) in [
("ec.stylize.motiontile", vec![("outputWidth", Value::Scalar(out_w)), ("outputHeight", Value::Scalar(340.0))]),
(
"ec.distort.transform",
vec![("scaleHeight", Value::Scalar(12.0)), ("scaleWidth", Value::Scalar(12.0)), ("position", Value::Vec2([1361.0, 540.0]))],
),
] {
let spec = effectcraft_effects::find(id).unwrap();
let mut next = p.next_id;
let mut g = effectcraft_effects::instantiate(spec, &mut Ids(&mut next), spec.name, [1920.0, 1080.0]);
p.next_id = next;
for (k, v) in vals {
g.prop_mut(k).unwrap().value = v;
}
adj.props.sub_mut("effects").unwrap().children.push(g.into());
}
p.comp_mut(cid).unwrap().layers = vec![adj, red];
// Quarter resolution: comp pixel (x, y) is output pixel (x / 4, y / 4).
let img = render_frame(&p, cid, Tick::ZERO, 0.25);
let at = |x: f64, y: f64| img.get((x / 4.0) as i64, (y / 4.0) as i64);
let what = format!("Output {out_w} %");
for (x, y) in [(x0 + 8.0, 540.0), (x1 - 8.0, 540.0), (1361.0, 328.0), (1361.0, 752.0)] {
assert_eq!(at(x, y), RED, "{what}: ({x}, {y}) is red");
}
for (x, y) in [(x0 - 8.0, 540.0), (1361.0, 312.0), (1361.0, 768.0), (100.0, 100.0)] {
assert_eq!(at(x, y), CLEAR, "{what}: ({x}, {y}) is empty");
}
if x1 < 1920.0 {
assert_eq!(at(x1 + 8.0, 540.0), CLEAR, "{what}: right of the column");
}
}
}
+90
View File
@@ -0,0 +1,90 @@
//! Nested compositions: Preserve frame rate when nested or in render queue, Preserve resolution
//! when nested.
use effectcraft_color::Label;
use effectcraft_keyframe::{Keyframe, Value};
use effectcraft_project::build;
use effectcraft_project::render_queue::RenderSettings;
use effectcraft_project::{Comp, ItemId, ItemKind, LayerSource, Project, Solid};
use effectcraft_time::{FrameRate, Tick};
use crate::render_frame;
fn s(x: f64) -> Tick {
Tick::from_seconds_f64(x)
}
/// A 30 fps outer comp nesting a 10 fps 100×100 comp whose 10×10 solid moves from x=10 (0 s)
/// to x=90 (2 s). Returns (project, outer, inner).
fn nested(preserve_rate: bool, preserve_res: bool) -> (Project, ItemId, ItemId) {
let mut p = Project::default();
let outer = Comp::new(100, 100, FrameRate::FPS_30, s(4.0));
let mut inner = Comp::new(100, 100, FrameRate::from_f64(10.0), s(4.0));
inner.preserve_frame_rate = preserve_rate;
inner.preserve_resolution = preserve_res;
let sid = p.add_item("S", Label::Red, None, ItemKind::Solid(Solid { color: [1.0, 1.0, 1.0], width: 10, height: 10, pixel_aspect: 1.0 }));
let mut l = build::layer(&mut p, &inner, "S", LayerSource::Solid { item: sid }, (10, 10), None);
l.props.prop_mut("transform/position").unwrap().keys =
vec![Keyframe::new(Tick::ZERO, Value::Vec3([10.0, 50.0, 0.0])), Keyframe::new(s(2.0), Value::Vec3([90.0, 50.0, 0.0]))];
inner.layers.push(l);
let iid = p.add_item("Inner", Label::Sandstone, None, ItemKind::Comp(inner.into()));
let cid = p.add_item("Outer", Label::Sandstone, None, ItemKind::Comp(outer.clone().into()));
let pre = build::layer(&mut p, &outer, "Inner", LayerSource::Comp { item: iid }, (100, 100), None);
p.comp_mut(cid).unwrap().layers.push(pre);
(p, cid, iid)
}
#[test]
fn a_comp_preserving_its_frame_rate_shows_only_its_own_frames_when_nested() {
// 2/30 s falls between the nested comp's frames 0 (0 s) and 1 (0.1 s).
let between = s(2.0 / 30.0);
let (p, cid, _) = nested(false, false);
assert_ne!(render_frame(&p, cid, between, 1.0).data, render_frame(&p, cid, Tick::ZERO, 1.0).data, "follows the outer comp's frames");
let (p, cid, _) = nested(true, false);
assert_eq!(render_frame(&p, cid, between, 1.0).data, render_frame(&p, cid, Tick::ZERO, 1.0).data, "holds its own frame 0");
assert_ne!(render_frame(&p, cid, s(0.1), 1.0).data, render_frame(&p, cid, Tick::ZERO, 1.0).data, "then its frame 1");
}
#[test]
fn the_render_queue_keeps_a_preserved_frame_rate() {
let (p, _, iid) = nested(true, false);
let settings = RenderSettings { frame_rate: Some(FrameRate::FPS_30), ..Default::default() };
assert_eq!(settings.rate(p.comp(iid).unwrap()), FrameRate::from_f64(10.0));
let (p, _, iid) = nested(false, false);
assert_eq!(settings.rate(p.comp(iid).unwrap()), FrameRate::FPS_30);
}
#[test]
fn a_comp_preserving_its_resolution_renders_full_size_and_lands_in_place() {
for preserve in [false, true] {
let (p, cid, _) = nested(false, preserve);
let img = render_frame(&p, cid, Tick::ZERO, 0.5);
assert_eq!((img.width, img.height), (50, 50));
// The solid covers x 5..15, y 45..55 in the 100×100 comp: 2.5..7.5, 22.5..27.5 at half size.
assert!(img.get(5, 25)[3] > 0.99, "preserve {preserve}: inside");
assert!(img.get(20, 25)[3] < 0.01 && img.get(5, 40)[3] < 0.01, "preserve {preserve}: outside");
}
}
#[test]
fn a_nested_comp_shows_nothing_past_its_end() {
for collapse in [false, true] {
let mut p = Project::default();
let outer = Comp::new(100, 100, FrameRate::FPS_30, s(4.0));
// A 1 s comp with a solid covering it for 4 s (longer than the comp itself).
let mut inner = Comp::new(100, 100, FrameRate::FPS_30, s(1.0));
let sid = p.add_item("S", Label::Red, None, ItemKind::Solid(Solid { color: [1.0, 1.0, 1.0], width: 100, height: 100, pixel_aspect: 1.0 }));
let mut l = build::layer(&mut p, &inner, "S", LayerSource::Solid { item: sid }, (100, 100), None);
l.out_point = s(4.0);
inner.layers.push(l);
let iid = p.add_item("Inner", Label::Sandstone, None, ItemKind::Comp(inner.into()));
let cid = p.add_item("Outer", Label::Sandstone, None, ItemKind::Comp(outer.clone().into()));
// The precomp layer is trimmed out to 3 s, past the nested comp's end.
let mut pre = build::layer(&mut p, &outer, "Inner", LayerSource::Comp { item: iid }, (100, 100), None);
pre.out_point = s(3.0);
pre.switches.collapse = collapse;
p.comp_mut(cid).unwrap().layers.push(pre);
assert!(render_frame(&p, cid, s(0.5), 1.0).get(50, 50)[3] > 0.99, "collapse {collapse}: inside its span");
assert!(render_frame(&p, cid, s(2.0), 1.0).get(50, 50)[3] < 0.01, "collapse {collapse}: past its end");
}
}
+113
View File
@@ -0,0 +1,113 @@
//! Paint and Puppet through the full pipeline: layer cache keys, resolution, determinism.
use effectcraft_color::Label;
use effectcraft_effects::paint::{self, StrokeSpec};
use effectcraft_effects::puppet::{self, MeshOpts, PinKind};
use effectcraft_keyframe::{Keyframe, Value};
use effectcraft_project::build::{self, Ids};
use effectcraft_project::{Comp, ItemId, ItemKind, LayerSource, Project, PropGroup, Solid};
use effectcraft_time::{FrameRate, Tick};
use crate::{LayerCache, NoFootage, RenderOpts, Renderer};
fn scene(fx: impl FnOnce(&mut Ids) -> PropGroup) -> (Project, ItemId) {
let mut p = Project::default();
let comp = Comp::new(100, 80, FrameRate::FPS_30, Tick::from_seconds_f64(3.0));
let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.clone().into()));
let sid = p.add_item("Solid", Label::Red, None, ItemKind::Solid(Solid { color: [0.0, 0.0, 1.0], width: 60, height: 40, pixel_aspect: 1.0 }));
let mut l = build::layer(&mut p, &comp, "Solid", LayerSource::Solid { item: sid }, (60, 40), None);
let mut next = p.next_id;
let g = fx(&mut Ids(&mut next));
p.next_id = next;
l.props.sub_mut("effects").unwrap().children.push(g.into());
p.comp_mut(cid).unwrap().layers.push(l);
(p, cid)
}
fn paint_fx(ids: &mut Ids, strokes: &[StrokeSpec]) -> PropGroup {
let mut g = effectcraft_effects::instantiate(effectcraft_effects::find(paint::ID).unwrap(), ids, "Paint", [60.0, 40.0]);
for (i, s) in strokes.iter().enumerate() {
g.children.push(paint::stroke_group(ids, &format!("Brush {}", i + 1), s).into());
}
g
}
fn frame(p: &Project, cid: ItemId, t: f64, scale: f64, cache: Option<&LayerCache>) -> crate::Image {
let mut r = Renderer::new(p, &NoFootage, RenderOpts { scale, ..Default::default() });
r.cache = cache;
r.comp_frame(cid, Tick::from_seconds_f64(t))
}
#[test]
fn stroke_spans_are_part_of_the_cache_key() {
// Layer at comp (20, 20)–(80, 60). A red dab at layer (10, 10) = comp (30, 30), shown 1–2 s.
let s = StrokeSpec { points: vec![[10.5, 10.5]], color: [1.0, 0.0, 0.0, 1.0], in_time: 1.0, out_time: 2.0, size_pressure: false, ..Default::default() };
let (p, cid) = scene(|ids| paint_fx(ids, &[s]));
let cache = LayerCache::default();
for t in [0.5, 1.5, 0.5, 2.5, 1.5] {
let a = frame(&p, cid, t, 1.0, Some(&cache));
let b = frame(&p, cid, t, 1.0, None);
assert_eq!(a, b, "t={t}");
let red = a.get(30, 30)[0] > 0.99;
assert_eq!(red, (1.0..2.0).contains(&t), "t={t}");
}
assert!(cache.stats().hits > 0);
}
#[test]
fn write_on_keys_animate_end() {
let s = StrokeSpec { points: vec![[5.5, 20.5], [55.5, 20.5]], color: [1.0, 0.0, 0.0, 1.0], size_pressure: false, ..Default::default() };
let (mut p, cid) = scene(|ids| paint_fx(ids, &[s]));
{
let l = &mut p.comp_mut(cid).unwrap().layers[0];
let end = l.props.prop_mut("effects/#1/brush/stroke_options/end").unwrap();
end.keys = vec![Keyframe::new(Tick::ZERO, Value::Scalar(0.0)), Keyframe::new(Tick::from_seconds_f64(1.0), Value::Scalar(100.0))];
}
let cache = LayerCache::default();
let at = |t: f64, x: u32| frame(&p, cid, t, 1.0, Some(&cache)).get(x as i64, 40)[0] > 0.99;
// Layer x 5..55 → comp x 25..75.
assert!(!at(0.0, 30));
assert!(at(0.5, 30) && at(0.5, 45) && !at(0.5, 65));
assert!(at(1.0, 74));
}
#[test]
fn paint_respects_resolution() {
let s = StrokeSpec { points: vec![[10.5, 10.5], [40.5, 30.5]], diameter: 8.0, color: [1.0, 0.0, 0.0, 1.0], ..Default::default() };
let (p, cid) = scene(|ids| paint_fx(ids, &[s]));
let full = frame(&p, cid, 0.0, 1.0, None);
let half = frame(&p, cid, 0.0, 0.5, None);
assert_eq!((half.width, half.height), (50, 40));
// Same spot, both resolutions.
assert!(full.get(45, 40)[0] > 0.9);
assert!(half.get(22, 20)[0] > 0.9);
}
#[test]
fn puppet_moves_pixels_and_caches() {
let (mut p, cid) = scene(|ids| {
let mut fx = effectcraft_effects::instantiate(effectcraft_effects::find(puppet::ID).unwrap(), ids, "Puppet", [60.0, 40.0]);
let mut m = puppet::mesh_group(ids, "Mesh 1", [30.0, 20.0], &MeshOpts::default());
let mut pin = puppet::pin_group(ids, "Puppet Pin 1", PinKind::Position, [30.0, 20.0]);
pin.get_mut("position").unwrap().value = Value::Vec2([30.0, 20.0]);
m.sub_mut("deform").unwrap().children.push(pin.into());
fx.children.push(m.into());
fx
});
let cache = LayerCache::default();
let still = frame(&p, cid, 0.0, 1.0, Some(&cache));
assert_eq!(still.get(50, 40), [0.0, 0.0, 1.0, 1.0]);
assert_eq!(still.get(85, 40)[3], 0.0);
{
let l = &mut p.comp_mut(cid).unwrap().layers[0];
let pos = l.props.prop_mut("effects/#1/mesh/deform/pin/position").unwrap();
pos.keys = vec![Keyframe::new(Tick::ZERO, Value::Vec2([30.0, 20.0])), Keyframe::new(Tick::from_seconds_f64(1.0), Value::Vec2([40.0, 20.0]))];
}
let moved = frame(&p, cid, 1.0, 1.0, Some(&cache));
assert_eq!(moved, frame(&p, cid, 1.0, 1.0, None));
// The 60 px wide layer shifted right by 10: comp x 30..90.
assert_eq!(moved.get(85, 40), [0.0, 0.0, 1.0, 1.0]);
assert_eq!(moved.get(25, 40)[3], 0.0);
let half = frame(&p, cid, 1.0, 0.5, None);
assert!(half.get(42, 20)[3] > 0.99);
}
+65
View File
@@ -0,0 +1,65 @@
//! Time remapping and separated Position dimensions.
use effectcraft_color::Label;
use effectcraft_keyframe::{Keyframe, Value};
use effectcraft_project::build::{self, Ids};
use effectcraft_project::{Comp, ItemKind, LayerSource, Node, Project, Solid};
use effectcraft_time::{FrameRate, Tick};
use crate::render_frame;
fn s(x: f64) -> Tick {
Tick::from_seconds_f64(x)
}
#[test]
fn separated_position_drives_the_transform() {
let mut p = Project::default();
let comp = Comp::new(200, 100, FrameRate::FPS_30, s(2.0));
let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.clone().into()));
let sid = p.add_item("S", Label::Red, None, ItemKind::Solid(Solid { color: [1.0, 1.0, 1.0], width: 20, height: 20, pixel_aspect: 1.0 }));
let mut l = build::layer(&mut p, &comp, "S", LayerSource::Solid { item: sid }, (20, 20), None);
let mut next = p.next_id;
let mut ids = Ids(&mut next);
let mut px = ids.prop("positionX", "X Position", Value::Scalar(20.0));
px.keys = vec![Keyframe::new(Tick::ZERO, Value::Scalar(20.0)), Keyframe::new(s(1.0), Value::Scalar(180.0))];
let py = ids.prop("positionY", "Y Position", Value::Scalar(50.0));
let pz = ids.prop("positionZ", "Z Position", Value::Scalar(0.0));
p.next_id = next;
let tr = l.props.sub_mut("transform").unwrap();
// The combined property is ignored while separated.
tr.get_mut("position").unwrap().value = Value::Vec3([100.0, 10.0, 0.0]);
tr.children.extend([Node::Prop(px), Node::Prop(py), Node::Prop(pz)]);
p.comp_mut(cid).unwrap().layers.push(l);
let a = render_frame(&p, cid, Tick::ZERO, 1.0);
assert!(a.get(20, 50)[3] > 0.99 && a.get(100, 10)[3] < 0.01);
let b = render_frame(&p, cid, s(0.5), 1.0);
assert!(b.get(100, 50)[3] > 0.99 && b.get(20, 50)[3] < 0.01);
}
#[test]
fn time_remap_picks_the_source_frame() {
let mut p = Project::default();
let outer = Comp::new(100, 100, FrameRate::FPS_30, s(4.0));
let mut inner = Comp::new(100, 100, FrameRate::FPS_30, s(4.0));
// Inner comp: a solid that moves from x=10 (t=0) to x=90 (t=2).
let sid = p.add_item("S", Label::Red, None, ItemKind::Solid(Solid { color: [1.0, 1.0, 1.0], width: 10, height: 10, pixel_aspect: 1.0 }));
let mut l = build::layer(&mut p, &inner, "S", LayerSource::Solid { item: sid }, (10, 10), None);
l.props.prop_mut("transform/position").unwrap().keys =
vec![Keyframe::new(Tick::ZERO, Value::Vec3([10.0, 50.0, 0.0])), Keyframe::new(s(2.0), Value::Vec3([90.0, 50.0, 0.0]))];
inner.layers.push(l);
let iid = p.add_item("Inner", Label::Sandstone, None, ItemKind::Comp(inner.into()));
let cid = p.add_item("Outer", Label::Sandstone, None, ItemKind::Comp(outer.clone().into()));
let mut pre = build::layer(&mut p, &outer, "Inner", LayerSource::Comp { item: iid }, (100, 100), None);
let mut next = p.next_id;
// Freeze on source time 2 s.
let tr = Ids(&mut next).prop("timeRemap", "Time Remap", Value::Scalar(2.0));
p.next_id = next;
let mut tr = tr;
tr.keys = vec![Keyframe::new(Tick::ZERO, Value::Scalar(2.0)).hold()];
pre.props.children.push(Node::Prop(tr));
p.comp_mut(cid).unwrap().layers.push(pre);
let img = render_frame(&p, cid, Tick::ZERO, 1.0);
assert!(img.get(90, 50)[3] > 0.99, "frozen at the end position");
assert!(img.get(10, 50)[3] < 0.01);
}
+103
View File
@@ -0,0 +1,103 @@
//! Region of interest: a ROI render equals the matching crop of the full render.
use effectcraft_color::{BlendMode, Label};
use effectcraft_keyframe::Value;
use effectcraft_project::build;
use effectcraft_project::{Comp, ItemKind, LayerSource, Project, Solid};
use effectcraft_time::{FrameRate, Tick};
use crate::{Image, NoFootage, RenderOpts, Renderer};
fn project(three_d: bool) -> (Project, effectcraft_project::ItemId) {
let mut p = Project::default();
let comp = Comp::new(160, 120, 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 layers = vec![];
for (i, (c, w, h, pos, rot)) in
[([1.0, 0.2, 0.1], 90, 50, [60.0, 50.0], 17.0), ([0.1, 0.4, 1.0], 70, 70, [100.0, 75.0], -30.0), ([0.9, 0.9, 0.2], 40, 30, [30.0, 95.0], 0.0)]
.into_iter()
.enumerate()
{
let sid = p.add_item("Solid", Label::Red, None, ItemKind::Solid(Solid { color: c, width: w, height: h, pixel_aspect: 1.0 }));
let mut l = build::layer(&mut p, &comp, "Solid", LayerSource::Solid { item: sid }, (w, h), None);
l.props.prop_mut("transform/position").unwrap().value = Value::Vec3([pos[0], pos[1], 0.0]);
l.props.prop_mut("transform/rotation").unwrap().value = Value::Scalar(rot);
l.props.prop_mut("transform/opacity").unwrap().value = Value::Scalar(80.0);
if i == 1 {
l.blend_mode = BlendMode::Screen;
l.switches.three_d = three_d;
}
layers.push(l);
}
p.comp_mut(cid).unwrap().layers = layers;
(p, cid)
}
fn crop(img: &Image, x: u32, y: u32, w: u32, h: u32) -> Image {
let mut out = Image::new(w, h);
for yy in 0..h {
for xx in 0..w {
out.data[(yy * w + xx) as usize] = img.data[((yy + y) * img.width + xx + x) as usize];
}
}
out
}
fn assert_same(a: &Image, b: &Image) {
assert_eq!((a.width, a.height), (b.width, b.height));
for (i, (p, q)) in a.data.iter().zip(&b.data).enumerate() {
for c in 0..4 {
assert!((p[c] - q[c]).abs() < 1e-4, "pixel {i} channel {c}: {} vs {}", p[c], q[c]);
}
}
}
#[test]
fn roi_render_equals_cropped_full_render() {
for three_d in [false, true] {
let (p, cid) = project(three_d);
let full = Renderer::new(&p, &NoFootage, RenderOpts::default()).comp_frame(cid, Tick::ZERO);
let roi = Renderer::new(&p, &NoFootage, RenderOpts { roi: Some([30.0, 20.0, 80.0, 60.0]), ..Default::default() }).comp_frame(cid, Tick::ZERO);
assert_eq!((roi.width, roi.height), (80, 60));
assert_same(&roi, &crop(&full, 30, 20, 80, 60));
}
}
#[test]
fn roi_at_half_resolution() {
let (p, cid) = project(false);
let full = Renderer::new(&p, &NoFootage, RenderOpts { scale: 0.5, ..Default::default() }).comp_frame(cid, Tick::ZERO);
let roi = Renderer::new(&p, &NoFootage, RenderOpts { scale: 0.5, roi: Some([40.0, 20.0, 60.0, 40.0]), ..Default::default() }).comp_frame(cid, Tick::ZERO);
assert_eq!((roi.width, roi.height), (30, 20));
assert_same(&roi, &crop(&full, 20, 10, 30, 20));
}
/// Extended Viewer: a region reaching past the comp frame shows the 3D layer's pixels on the
/// pasteboard, and its inner part equals the normal frame.
#[test]
fn extended_region_renders_3d_layers_beyond_the_frame() {
let (mut p, cid) = project(true);
// Push the 3D (blue) layer half out of the frame on the right.
let comp = p.comp_mut(cid).unwrap();
comp.layers[1].props.prop_mut("transform/position").unwrap().value = Value::Vec3([150.0, 60.0, 0.0]);
comp.layers[1].props.prop_mut("transform/rotation").unwrap().value = Value::Scalar(0.0);
let full = Renderer::new(&p, &NoFootage, RenderOpts::default()).comp_frame(cid, Tick::ZERO);
let region = crate::extended_region(160, 120, [-100.0, -100.0, 260.0, 220.0], 0.5).unwrap();
assert_eq!(region, [-80.0, -64.0, 320.0, 248.0]);
let ext = Renderer::new(&p, &NoFootage, RenderOpts { roi: Some(region), ..Default::default() }).comp_frame(cid, Tick::ZERO);
assert_eq!((ext.width, ext.height), (320, 248));
// Comp x 175 (past the 160 px frame) at y 60: the layer covers x 115..185.
let out = ext.get(175 + 80, 60 + 64);
assert!(out[3] > 0.5 && out[2] > 0.5, "pasteboard pixel {out:?}");
// Nothing is drawn far outside every layer.
assert_eq!(ext.get(5, 5)[3], 0.0);
// The frame part is the normal frame.
assert_same(&crop(&ext, 80, 64, 160, 120), &full);
}
#[test]
fn extended_region_is_none_inside_the_frame() {
assert_eq!(crate::extended_region(160, 120, [10.0, 10.0, 150.0, 110.0], 1.0), None);
// Clamped to the margin.
assert_eq!(crate::extended_region(100, 100, [-1000.0, 0.0, 50.0, 100.0], 1.0), Some([-112.0, 0.0, 212.0, 100.0]));
}
+509
View File
@@ -0,0 +1,509 @@
//! Layer Styles pixel tests.
use effectcraft_color::Label;
use effectcraft_keyframe::{Gradient, Keyframe, ShapePath, Value};
use effectcraft_project::build::{self, Ids};
use effectcraft_project::styles::{self as st, GROUP};
use effectcraft_project::{Comp, ItemId, ItemKind, Layer, LayerSource, MaskMode, Project, Solid};
use effectcraft_time::{FrameRate, Tick};
use crate::{Image, NoFootage, RenderOpts, Renderer, render_frame};
const NORMAL: u32 = 0;
const MULTIPLY: u32 = 3;
fn setup() -> (Project, ItemId, Comp) {
let mut p = Project::default();
let comp = Comp::new(200, 100, 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)
}
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(p: &mut Project, comp: &Comp, l: &mut Layer, style: &str) {
let mut next = p.next_id;
st::add_style(l, &mut Ids(&mut next), style, &comp.global_light, true).unwrap();
p.next_id = next;
}
fn set(l: &mut Layer, path: &str, v: Value) {
l.props.prop_mut(&format!("{GROUP}/{path}")).unwrap_or_else(|| panic!("no {path}")).value = v;
}
/// A 40×40 square of `color` centred in a 200×100 comp (x 80..120, y 30..70) over an optional
/// full-frame background.
fn scene(color: [f32; 3], bg: Option<[f32; 3]>, f: impl FnOnce(&mut Project, &Comp, &mut Layer)) -> (Project, ItemId) {
let (mut p, cid, comp) = setup();
let mut l = solid(&mut p, &comp, color, 40, 40);
f(&mut p, &comp, &mut l);
let mut layers = vec![l];
if let Some(bg) = bg {
layers.push(solid(&mut p, &comp, bg, 200, 100));
}
p.comp_mut(cid).unwrap().layers = layers;
(p, cid)
}
fn render(p: &Project, cid: ItemId) -> Image {
render_frame(p, cid, Tick::ZERO, 1.0)
}
fn near(a: f32, b: f32, tol: f32) -> bool {
(a - b).abs() <= tol
}
#[test]
fn drop_shadow_offset_colour_and_opacity() {
let (p, cid) = scene([1.0; 3], Some([0.5; 3]), |p, c, l| {
add(p, c, l, "dropShadow");
set(l, "dropShadow/size", Value::Scalar(0.0));
set(l, "dropShadow/distance", Value::Scalar(10.0));
set(l, "dropShadow/opacity", Value::Scalar(100.0));
set(l, "dropShadow/blendMode", Value::Enum(NORMAL));
});
let img = render(&p, cid);
// Global light 120° → the shadow falls down-right by (5, 8.66).
assert!(near(img.get(123, 75)[0], 0.0, 0.02), "shadow: {:?}", img.get(123, 75));
assert!(near(img.get(82, 75)[0], 0.5, 0.01), "left of the shadow stays grey");
assert!(near(img.get(100, 50)[0], 1.0, 0.01), "layer on top");
assert!(near(img.get(100, 20)[0], 0.5, 0.01), "above the layer");
// 75 % Multiply (the default mode) over grey: 0.5 × (1 − 0.75).
let (p, cid) = scene([1.0; 3], Some([0.5; 3]), |p, c, l| {
add(p, c, l, "dropShadow");
set(l, "dropShadow/size", Value::Scalar(0.0));
set(l, "dropShadow/distance", Value::Scalar(10.0));
set(l, "dropShadow/color", Value::Color([0.0, 0.0, 0.0, 1.0]));
});
let img = render(&p, cid);
assert!(near(img.get(123, 75)[0], 0.125, 0.02), "{:?}", img.get(123, 75));
// Coloured shadow on a transparent comp extends the layer's bounds.
let (p, cid) = scene([1.0; 3], None, |p, c, l| {
add(p, c, l, "dropShadow");
set(l, "dropShadow/size", Value::Scalar(0.0));
set(l, "dropShadow/distance", Value::Scalar(10.0));
set(l, "dropShadow/opacity", Value::Scalar(50.0));
set(l, "dropShadow/blendMode", Value::Enum(NORMAL));
set(l, "dropShadow/color", Value::Color([0.0, 0.0, 1.0, 1.0]));
});
let img = render(&p, cid);
let px = img.straight(123, 75);
assert!(near(px[3], 0.5, 0.02) && near(px[2], 1.0, 0.01) && px[0] < 0.01, "{px:?}");
}
#[test]
fn soft_shadow_falls_off() {
let (p, cid) = scene([1.0; 3], None, |p, c, l| {
add(p, c, l, "dropShadow");
set(l, "dropShadow/size", Value::Scalar(10.0));
set(l, "dropShadow/distance", Value::Scalar(0.0));
set(l, "dropShadow/opacity", Value::Scalar(100.0));
});
let img = render(&p, cid);
let a1 = img.get(121, 50)[3];
let a5 = img.get(125, 50)[3];
let a20 = img.get(140, 50)[3];
assert!(a1 > a5 && a5 > a20, "{a1} {a5} {a20}");
assert!(a20 < 0.01);
}
#[test]
fn stroke_positions() {
let render_stroke = |pos: u32| {
let (p, cid) = scene([1.0; 3], None, |p, c, l| {
add(p, c, l, "stroke");
set(l, "stroke/size", Value::Scalar(4.0));
set(l, "stroke/position", Value::Enum(pos));
});
render(&p, cid)
};
let red = |px: [f32; 4]| px[0] > 0.98 && px[1] < 0.02 && px[3] > 0.98;
let white = |px: [f32; 4]| px[0] > 0.98 && px[1] > 0.98 && px[3] > 0.98;
// Outside: 4 px ring beyond the right edge (x = 120).
let o = render_stroke(0);
assert!(red(o.get(122, 50)) && red(o.get(123, 50)), "{:?}", o.get(122, 50));
assert!(o.get(126, 50)[3] < 0.01);
assert!(white(o.get(118, 50)));
// Inside: the ring is within the layer.
let i = render_stroke(1);
assert!(red(i.get(118, 50)) && red(i.get(117, 50)));
assert!(white(i.get(110, 50)));
assert!(i.get(122, 50)[3] < 0.01);
// Centre: 2 px on each side.
let c = render_stroke(2);
assert!(red(c.get(119, 50)) && red(c.get(120, 50)) && red(c.get(121, 50)));
assert!(white(c.get(116, 50)));
assert!(c.get(124, 50)[3] < 0.01);
}
#[test]
fn colour_overlay_blend_modes() {
let (p, cid) = scene([1.0; 3], None, |p, c, l| {
add(p, c, l, "colorOverlay");
set(l, "colorOverlay/color", Value::Color([0.0, 0.0, 1.0, 1.0]));
set(l, "colorOverlay/opacity", Value::Scalar(50.0));
});
let px = render(&p, cid).get(100, 50);
assert!(near(px[0], 0.5, 0.01) && near(px[2], 1.0, 0.01), "{px:?}");
let (p, cid) = scene([0.5, 1.0, 1.0], None, |p, c, l| {
add(p, c, l, "colorOverlay");
set(l, "colorOverlay/color", Value::Color([1.0, 0.5, 0.0, 1.0]));
set(l, "colorOverlay/blendMode", Value::Enum(MULTIPLY));
});
let img = render(&p, cid);
let px = img.get(100, 50);
assert!(near(px[0], 0.5, 0.01) && near(px[1], 0.5, 0.01) && near(px[2], 0.0, 0.01), "{px:?}");
// Overlays stay inside the layer.
assert!(img.get(130, 50)[3] < 0.01);
}
#[test]
fn gradient_overlay_follows_angle() {
let (p, cid) = scene([1.0; 3], None, |p, c, l| {
add(p, c, l, "gradientOverlay");
});
let img = render(&p, cid);
// 90°: black at the bottom, white at the top.
let top = img.get(100, 31)[0];
let bottom = img.get(100, 68)[0];
assert!(top > 0.9 && bottom < 0.1, "{top} {bottom}");
let (p, cid) = scene([1.0; 3], None, |p, c, l| {
add(p, c, l, "gradientOverlay");
set(l, "gradientOverlay/angle", Value::Scalar(0.0));
set(l, "gradientOverlay/reverse", Value::Bool(true));
});
let img = render(&p, cid);
assert!(img.get(81, 50)[0] > 0.9 && img.get(118, 50)[0] < 0.1);
}
/// Overlays replace the layer's colour inside its alpha: an anti-aliased edge pixel mixes the
/// background with the overlay colour only, exactly as a layer of that colour would, with no
/// fringe of the layer's own colour (#416).
#[test]
fn overlays_leave_antialiased_edges_clean() {
let (blue, bg) = ([0.2, 0.533, 1.0], [0.25, 0.22, 0.45]);
// A mask half a pixel inside the square's left and right sides: in layer space, where the
// styles run, columns 80 and 119 are half covered.
let edge = |color: [f32; 3], f: &dyn Fn(&mut Project, &Comp, &mut Layer)| {
let (p, cid) = scene(color, Some(bg), |p, c, l| {
let mut next = p.next_id;
let pts = vec![[0.5, 0.0], [39.5, 0.0], [39.5, 40.0], [0.5, 40.0]];
let sp = ShapePath { in_tangents: vec![[0.0; 2]; 4], out_tangents: vec![[0.0; 2]; 4], vertices: pts, closed: true, feather: Vec::new() };
let m = build::mask(&mut Ids(&mut next), "Mask 1", sp, MaskMode::Add, [255, 255, 0]);
p.next_id = next;
l.props.group_mut("masks").unwrap().children.push(m.into());
f(p, c, l);
});
let img = render(&p, cid);
[img.get(80, 50), img.get(119, 50), img.get(100, 50)]
};
let plain = edge(blue, &|_, _, _| {});
assert!(near(plain[0][0], 0.5 * (blue[0] + bg[0]), 0.01), "half covered: {:?}", plain[0]);
let color = Value::Color([blue[0] as f64, blue[1] as f64, blue[2] as f64, 1.0]);
let flat =
Gradient { colors: vec![(0.0, [blue[0], blue[1], blue[2], 1.0]), (1.0, [blue[0], blue[1], blue[2], 1.0])], opacities: vec![(0.0, 1.0), (1.0, 1.0)] };
let styled: [(&str, Box<dyn Fn(&mut Project, &Comp, &mut Layer)>); 3] = [
(
"Color Overlay",
Box::new(|p, c, l| {
add(p, c, l, "colorOverlay");
set(l, "colorOverlay/color", color.clone());
}),
),
(
"Gradient Overlay",
Box::new(|p, c, l| {
add(p, c, l, "gradientOverlay");
set(l, "gradientOverlay/colors", Value::Gradient(flat.clone()));
}),
),
(
"Stroke (inside)",
Box::new(|p, c, l| {
add(p, c, l, "stroke");
set(l, "stroke/position", Value::Enum(1));
set(l, "stroke/size", Value::Scalar(25.0));
set(l, "stroke/color", color.clone());
}),
),
];
for (name, f) in &styled {
let px = edge([1.0; 3], f.as_ref());
for (a, b) in px.iter().zip(&plain) {
for ch in 0..4 {
assert!(near(a[ch], b[ch], 0.01), "{name}: {a:?} against the plain layer {b:?}");
}
}
}
}
fn bevel_scene(angle: f64) -> Image {
let (p, cid) = scene([0.5; 3], None, |p, c, l| {
add(p, c, l, "bevelEmboss");
set(l, "bevelEmboss/size", Value::Scalar(6.0));
set(l, "blendingOptions/globalLightAngle", Value::Scalar(angle));
});
render(&p, cid)
}
#[test]
fn bevel_lights_the_side_facing_the_light() {
// Light from the upper left (120°): left and top edges brighten, right and bottom darken.
let img = bevel_scene(120.0);
let (l, r, t, b, mid) = (img.get(82, 50)[0], img.get(117, 50)[0], img.get(100, 32)[0], img.get(100, 67)[0], img.get(100, 50)[0]);
assert!(l > 0.6 && t > 0.6, "highlights {l} {t}");
assert!(r < 0.4 && b < 0.4, "shadows {r} {b}");
assert!(near(mid, 0.5, 0.02), "flat centre {mid}");
// Light from the lower right (−60°) swaps them.
let img = bevel_scene(-60.0);
assert!(img.get(82, 50)[0] < 0.4 && img.get(117, 50)[0] > 0.6);
}
#[test]
fn global_light_drives_linked_styles() {
let shadow_at = |use_global: bool| {
let (p, cid) = scene([1.0; 3], None, |p, c, l| {
add(p, c, l, "dropShadow");
set(l, "dropShadow/size", Value::Scalar(0.0));
set(l, "dropShadow/distance", Value::Scalar(10.0));
set(l, "dropShadow/opacity", Value::Scalar(100.0));
set(l, "dropShadow/useGlobalLight", Value::Bool(use_global));
set(l, "dropShadow/angle", Value::Scalar(90.0));
set(l, "blendingOptions/globalLightAngle", Value::Scalar(0.0));
});
render(&p, cid)
};
// Global light at 0° (from the right): shadow to the left.
let g = shadow_at(true);
assert!(g.get(75, 50)[3] > 0.98 && g.get(100, 75)[3] < 0.01);
// Own angle 90° (from above): shadow below.
let o = shadow_at(false);
assert!(o.get(100, 75)[3] > 0.98 && o.get(75, 50)[3] < 0.01);
}
#[test]
fn glows_and_inner_shadow() {
let (p, cid) = scene([0.0, 0.0, 0.0], None, |p, c, l| {
add(p, c, l, "outerGlow");
add(p, c, l, "innerGlow");
set(l, "outerGlow/opacity", Value::Scalar(100.0));
set(l, "outerGlow/size", Value::Scalar(8.0));
set(l, "innerGlow/opacity", Value::Scalar(100.0));
set(l, "innerGlow/size", Value::Scalar(8.0));
});
let img = render(&p, cid);
let out_near = img.straight(121, 50);
assert!(out_near[3] > 0.6 && out_near[0] > 0.9 && out_near[2] < 0.9, "glow colour {out_near:?}");
assert!(img.get(140, 50)[3] < 0.02, "glow fades");
let edge = img.get(119, 50);
let centre = img.get(100, 50);
assert!(edge[0] > 0.6 && centre[0] < 0.05, "inner glow from the edge {edge:?} {centre:?}");
// Inner glow from the centre instead.
let (p, cid) = scene([0.0; 3], None, |p, c, l| {
add(p, c, l, "innerGlow");
set(l, "innerGlow/source", Value::Enum(0));
set(l, "innerGlow/opacity", Value::Scalar(100.0));
});
let img = render(&p, cid);
assert!(img.get(100, 50)[0] > 0.9 && img.get(119, 50)[0] < 0.6);
let (p, cid) = scene([1.0; 3], None, |p, c, l| {
add(p, c, l, "innerShadow");
set(l, "innerShadow/size", Value::Scalar(0.0));
set(l, "innerShadow/opacity", Value::Scalar(100.0));
set(l, "innerShadow/blendMode", Value::Enum(NORMAL));
});
let img = render(&p, cid);
// Light from the upper left: the shadow lines the top-left inside edges.
assert!(img.get(81, 50)[0] < 0.05 && img.get(100, 32)[0] < 0.05, "{:?}", img.get(81, 50));
assert!(img.get(117, 50)[0] > 0.95 && img.get(100, 66)[0] > 0.95);
assert!(img.get(76, 50)[3] < 0.01, "inner shadow stays inside");
}
#[test]
fn satin_shades_inside() {
let (p, cid) = scene([1.0; 3], None, |p, c, l| add(p, c, l, "satin"));
let img = render(&p, cid);
let vals: Vec<f32> = (82..118).map(|x| img.get(x, 50)[0]).collect();
let min = vals.iter().cloned().fold(1.0, f32::min);
assert!(min < 0.9, "satin darkens somewhere: {min}");
assert!(img.get(130, 50)[3] < 0.01);
}
#[test]
fn fill_opacity_knockout_and_channels() {
// Fill 0: the content disappears but the stroke stays.
let (p, cid) = scene([1.0; 3], None, |p, c, l| {
add(p, c, l, "stroke");
set(l, "blendingOptions/advancedBlending/fillOpacity", Value::Scalar(0.0));
});
let img = render(&p, cid);
assert!(img.get(100, 50)[3] < 0.01 && img.get(122, 50)[0] > 0.98);
// Interior styles ignore Fill Opacity unless blended as a group.
let overlay = |group: bool| {
let (p, cid) = scene([1.0; 3], None, |p, c, l| {
add(p, c, l, "colorOverlay");
set(l, "blendingOptions/advancedBlending/fillOpacity", Value::Scalar(0.0));
set(l, "blendingOptions/advancedBlending/blendInteriorAsGroup", Value::Bool(group));
});
render(&p, cid).get(100, 50)
};
assert!(overlay(false)[3] > 0.98 && overlay(true)[3] < 0.01);
// Knockout with Fill 0 punches through the background.
let (p, cid) = scene([1.0; 3], Some([0.5; 3]), |p, c, l| {
add(p, c, l, "stroke");
set(l, "blendingOptions/advancedBlending/fillOpacity", Value::Scalar(0.0));
set(l, "blendingOptions/advancedBlending/knockout", Value::Enum(2));
});
let img = render(&p, cid);
assert!(img.get(100, 50)[3] < 0.01 && img.get(150, 50)[3] > 0.99);
// Red channel off: the white layer leaves red from below.
let (p, cid) = scene([1.0; 3], Some([0.0; 3]), |p, c, l| {
add(p, c, l, "stroke");
set(l, "blendingOptions/advancedBlending/red", Value::Bool(false));
});
let px = render(&p, cid).get(100, 50);
assert!(px[0] < 0.01 && px[1] > 0.99 && px[2] > 0.99, "{px:?}");
}
#[test]
fn eye_switch_and_group_switch() {
let (mut p, cid) = scene([1.0; 3], None, |p, c, l| {
add(p, c, l, "stroke");
});
assert!(render(&p, cid).get(122, 50)[3] > 0.98);
p.comp_mut(cid).unwrap().layers[0].props.group_mut(&format!("{GROUP}/stroke")).unwrap().enabled = false;
assert!(render(&p, cid).get(122, 50)[3] < 0.01);
p.comp_mut(cid).unwrap().layers[0].props.group_mut(&format!("{GROUP}/stroke")).unwrap().enabled = true;
p.comp_mut(cid).unwrap().layers[0].props.group_mut(GROUP).unwrap().enabled = false;
assert!(render(&p, cid).get(122, 50)[3] < 0.01);
}
#[test]
fn styles_respect_render_scale() {
let (p, cid) = scene([1.0; 3], None, |p, c, l| {
add(p, c, l, "dropShadow");
add(p, c, l, "stroke");
set(l, "dropShadow/size", Value::Scalar(0.0));
set(l, "dropShadow/distance", Value::Scalar(20.0));
set(l, "dropShadow/opacity", Value::Scalar(100.0));
set(l, "stroke/size", Value::Scalar(4.0));
});
let full = render_frame(&p, cid, Tick::ZERO, 1.0);
let half = render_frame(&p, cid, Tick::ZERO, 0.5);
// Shadow offset (10, 17.3): at half resolution the shadow sits at half the distance.
assert!(full.get(125, 85)[3] > 0.98);
assert!(half.get(62, 42)[3] > 0.98 && half.get(55, 25)[3] > 0.98);
// Stroke 4 px → 2 px at half.
assert!(half.get(61, 25)[0] > 0.98 && half.get(61, 25)[1] < 0.05);
assert!(half.get(64, 25)[3] < 0.01 || half.get(64, 25)[1] < 0.05);
}
#[test]
fn animated_style_properties() {
let (mut p, cid) = scene([1.0; 3], None, |p, c, l| add(p, c, l, "stroke"));
let l = &mut p.comp_mut(cid).unwrap().layers[0];
l.props.prop_mut(&format!("{GROUP}/stroke/size")).unwrap().keys =
vec![Keyframe::new(Tick::ZERO, Value::Scalar(2.0)), Keyframe::new(Tick::from_seconds_f64(1.0), Value::Scalar(10.0))];
let a = render_frame(&p, cid, Tick::ZERO, 1.0);
let b = render_frame(&p, cid, Tick::from_seconds_f64(1.0), 1.0);
assert!(a.get(126, 50)[3] < 0.01 && b.get(126, 50)[3] > 0.98);
}
fn render_cached(p: &Project, cid: ItemId, cache: &crate::LayerCache) -> Image {
let mut r = Renderer::new(p, &NoFootage, RenderOpts::default());
r.cache = Some(cache);
r.comp_frame(cid, Tick::ZERO)
}
#[test]
fn style_edits_invalidate_the_cache() {
let (mut p, cid) = scene([1.0; 3], Some([0.5; 3]), |p, c, l| {
add(p, c, l, "dropShadow");
add(p, c, l, "stroke");
});
let cache = crate::LayerCache::default();
let a = render_cached(&p, cid, &cache);
assert_eq!(a.data, render(&p, cid).data);
let again = render_cached(&p, cid, &cache);
assert_eq!(a.data, again.data);
// Change a style value: the styled layer re-renders, its content (and the background) come
// from the cache.
let hits0 = cache.stats().hits;
let l = &mut p.comp_mut(cid).unwrap().layers[0];
l.props.prop_mut(&format!("{GROUP}/stroke/color")).unwrap().value = Value::Color([0.0, 1.0, 0.0, 1.0]);
let b = render_cached(&p, cid, &cache);
assert_eq!(b.data, render(&p, cid).data);
assert_ne!(a.data, b.data);
assert!(cache.stats().hits >= hits0 + 2, "content + background reused");
// Global light change (linked) also invalidates.
let l = &mut p.comp_mut(cid).unwrap().layers[0];
l.props.prop_mut(&format!("{GROUP}/blendingOptions/globalLightAngle")).unwrap().value = Value::Scalar(0.0);
let c = render_cached(&p, cid, &cache);
assert_eq!(c.data, render(&p, cid).data);
assert_ne!(b.data, c.data);
// Eye switch off.
p.comp_mut(cid).unwrap().layers[0].props.group_mut(&format!("{GROUP}/dropShadow")).unwrap().enabled = false;
let d = render_cached(&p, cid, &cache);
assert_eq!(d.data, render(&p, cid).data);
assert_ne!(c.data, d.data);
}
#[test]
fn gradient_glow_uses_gradient() {
let (p, cid) = scene([0.0; 3], None, |p, c, l| {
add(p, c, l, "outerGlow");
set(l, "outerGlow/colorType", Value::Enum(1));
set(l, "outerGlow/opacity", Value::Scalar(100.0));
set(
l,
"outerGlow/colors",
Value::Gradient(Gradient { colors: vec![(0.0, [1.0, 0.0, 0.0, 1.0]), (1.0, [0.0, 0.0, 1.0, 1.0])], opacities: vec![(0.0, 1.0), (1.0, 0.0)] }),
);
});
let img = render(&p, cid);
let px = img.straight(121, 50);
assert!(px[0] > px[2], "near the edge the glow takes the gradient's start colour {px:?}");
}
/// #1812 (review of #1928): a styled layer rendered by several workers at once shares its body and
/// style passes, as unstyled layers share their buffer.
#[test]
fn workers_styling_a_layer_at_once_share_its_buffers() {
let (p, cid) = scene([1.0; 3], None, |p, c, l| {
add(p, c, l, "dropShadow");
add(p, c, l, "stroke");
});
let cache = crate::LayerCache::default();
let start = std::sync::Barrier::new(8);
let styled: Vec<_> = std::thread::scope(|s| {
let hs: Vec<_> = (0..8)
.map(|_| {
s.spawn(|| {
let mut r = Renderer::new(&p, &NoFootage, RenderOpts::default());
r.cache = Some(&cache);
let ctx = r.eval_ctx(cid, Tick::ZERO).unwrap();
start.wait();
r.styled_layer(&ctx, &p.comp(cid).unwrap().layers[0]).unwrap()
})
})
.collect();
hs.into_iter().map(|h| h.join().unwrap()).collect()
});
let first = &styled[0];
assert!(!first.passes.is_empty(), "the drop shadow is a pass");
for st in &styled {
assert!(std::sync::Arc::ptr_eq(&st.body, &first.body), "one body");
assert!(st.passes.iter().zip(&first.passes).all(|(a, b)| std::sync::Arc::ptr_eq(&a.0, &b.0)), "one set of passes");
}
}
+149
View File
@@ -0,0 +1,149 @@
//! Time effects through the renderer: neighbouring frames via `EffectHost::self_at`, and the
//! layer cache staying correct while scrubbing.
use effectcraft_color::Label;
use effectcraft_keyframe::{Keyframe, ShapePath, Value};
use effectcraft_project::build::{self, Ids};
use effectcraft_project::{Comp, ItemId, ItemKind, LayerSource, MaskMode, Project, Solid};
use effectcraft_time::{FrameRate, Tick};
fn render(p: &Project, cid: ItemId, t: f64, cache: Option<&crate::LayerCache>) -> crate::Image {
let mut r = crate::Renderer::new(p, &crate::NoFootage, crate::RenderOpts::default());
r.cache = cache;
r.comp_frame(cid, Tick::from_seconds_f64(t))
}
/// A white 200×100 solid whose full-frame mask fades in (opacity 0 → 100 over 0..1 s), with
/// effect `fx` (and parameter overrides) applied.
fn scene(fx: Option<(&str, &[(&str, Value)])>) -> (Project, ItemId) {
let mut p = Project::default();
let comp = Comp::new(200, 100, FrameRate::FPS_30, Tick::from_seconds_f64(2.0));
let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.clone().into()));
let sid = p.add_item("Solid", Label::Red, None, ItemKind::Solid(Solid { color: [1.0, 1.0, 1.0], width: 200, height: 100, pixel_aspect: 1.0 }));
let mut l = build::layer(&mut p, &comp, "Solid", LayerSource::Solid { item: sid }, (200, 100), None);
let mut next = p.next_id;
let mut m = build::mask(&mut Ids(&mut next), "Mask 1", ShapePath::rect([100.0, 50.0], 400.0, 200.0), MaskMode::Add, [255, 255, 0]);
if let Some(o) = m.prop_mut("opacity") {
o.keys = vec![Keyframe::new(Tick::ZERO, Value::Scalar(0.0)), Keyframe::new(Tick::from_seconds_f64(1.0), Value::Scalar(100.0))];
}
l.props.sub_mut("masks").unwrap().children.push(m.into());
if let Some((id, vals)) = fx {
let spec = effectcraft_effects::find(id).unwrap();
let mut g = effectcraft_effects::instantiate(spec, &mut Ids(&mut next), spec.name, [200.0, 100.0]);
for (k, v) in vals {
g.prop_mut(k).unwrap().value = v.clone();
}
l.props.sub_mut("effects").unwrap().children.push(g.into());
}
p.next_id = next;
p.comp_mut(cid).unwrap().layers.push(l);
(p, cid)
}
fn alpha(img: &crate::Image) -> f32 {
img.get(100, 50)[3]
}
#[test]
fn posterize_time_holds_frames() {
let (p, cid) = scene(Some(("ec.time.posterizetime", &[("frameRate", Value::Scalar(4.0))])));
for (t, want) in [(0.3, 0.25), (0.45, 0.25), (0.5, 0.5), (0.74, 0.5), (0.8, 0.75)] {
let a = alpha(&render(&p, cid, t, None));
assert!((a - want).abs() < 0.01, "t={t}: {a}");
}
let (plain, cid) = scene(None);
assert!((alpha(&render(&plain, cid, 0.3, None)) - 0.3).abs() < 0.01);
}
#[test]
fn echo_blends_neighbouring_frames() {
let vals = [("echoTime", Value::Scalar(-0.1)), ("numberOfEchoes", Value::Scalar(2.0)), ("decay", Value::Scalar(0.5))];
let (p, cid) = scene(Some(("ec.time.echo", &vals)));
let a = alpha(&render(&p, cid, 0.3, None));
let want = 0.3 + 0.5 * 0.2 + 0.25 * 0.1;
assert!((a - want).abs() < 0.01, "{a} vs {want}");
}
/// Time effects see the effects applied before them, as After Effects renders them (SceneYard
/// "Task Preparation UI": Echo after CC Particle Systems II echoed the black solid under the
/// fireworks and blacked out the white page). Fill (red) then Echo: the echoes are red.
#[test]
fn time_effects_see_the_effects_before_them() {
let (mut p, cid) = scene(Some(("ec.generate.fill", &[])));
let spec = effectcraft_effects::find("ec.time.echo").unwrap();
let mut next = p.next_id;
let mut g = effectcraft_effects::instantiate(spec, &mut Ids(&mut next), spec.name, [200.0, 100.0]);
g.prop_mut("numberOfEchoes").unwrap().value = Value::Scalar(0.0);
p.next_id = next;
p.comp_mut(cid).unwrap().layers[0].props.sub_mut("effects").unwrap().children.push(g.into());
let c = render(&p, cid, 1.5, None).get(100, 50);
assert!(c[0] > 0.99 && c[1] < 0.01 && c[2] < 0.01 && c[3] > 0.99, "{c:?}");
}
#[test]
fn time_difference_of_static_layer_is_black() {
let mut p = Project::default();
let comp = Comp::new(64, 32, FrameRate::FPS_30, Tick::from_seconds_f64(2.0));
let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.clone().into()));
let sid = p.add_item("Solid", Label::Red, None, ItemKind::Solid(Solid { color: [0.8, 0.4, 0.2], width: 64, height: 32, pixel_aspect: 1.0 }));
let mut l = build::layer(&mut p, &comp, "Solid", LayerSource::Solid { item: sid }, (64, 32), None);
let spec = effectcraft_effects::find("ec.time.timedifference").unwrap();
let mut next = p.next_id;
let mut g = effectcraft_effects::instantiate(spec, &mut Ids(&mut next), spec.name, [64.0, 32.0]);
g.prop_mut("timeOffset").unwrap().value = Value::Scalar(-0.5);
p.next_id = next;
l.props.sub_mut("effects").unwrap().children.push(g.into());
p.comp_mut(cid).unwrap().layers.push(l);
let img = render(&p, cid, 1.0, None);
assert!(img.data.iter().all(|px| px[0] == 0.0 && px[1] == 0.0 && px[2] == 0.0 && px[3] == 1.0), "{:?}", img.get(10, 10));
}
/// Scrubbing back and forth with the layer cache gives exactly the uncached frames (Echo's
/// output depends on neighbouring frames, so its key must include the layer time), and an edit
/// to the animation is picked up.
#[test]
fn cache_correct_while_scrubbing() {
let vals = [("echoTime", Value::Scalar(-0.2)), ("numberOfEchoes", Value::Scalar(3.0)), ("decay", Value::Scalar(0.6)), ("echoOperator", Value::Enum(6))];
let (mut p, cid) = scene(Some(("ec.time.echo", &vals)));
let cache = crate::LayerCache::default();
for t in [0.3, 0.5, 0.3, 1.2, 0.8, 0.5, 0.3, 1.5] {
let a = render(&p, cid, t, Some(&cache));
let b = render(&p, cid, t, None);
assert_eq!(a.data, b.data, "t={t}");
}
let st = cache.stats();
assert!(st.hits > 0, "{st:?}");
// Re-key the fade (0 → 100 over 0..0.5 s): cached frames must not be served.
p.comp_mut(cid).unwrap().layers[0].props.prop_mut("masks/#1/opacity").unwrap().keys[1].time = Tick::from_seconds_f64(0.5);
for t in [0.3, 0.5, 0.8] {
assert_eq!(render(&p, cid, t, Some(&cache)).data, render(&p, cid, t, None).data, "edited t={t}");
}
}
/// A layer's input up to an effect keys only the effects before it: editing a later effect (a
/// Puppet pin being dragged, #212) keeps the input cached; editing an earlier one doesn't.
#[test]
fn input_keys_ignore_later_effects() {
let (mut p, cid) = scene(Some(("ec.blur.gaussian", &[("blurriness", Value::Scalar(4.0))])));
{
let spec = effectcraft_effects::find("ec.stylize.mosaic").unwrap();
let mut next = p.next_id;
let g = effectcraft_effects::instantiate(spec, &mut Ids(&mut next), spec.name, [200.0, 100.0]);
p.next_id = next;
p.comp_mut(cid).unwrap().layers[0].props.sub_mut("effects").unwrap().children.push(g.into());
}
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 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))
};
let (one, two) = key(&p);
assert!(one.is_some() && one != two);
let set = |p: &mut Project, path: &str, v: f64| p.comp_mut(cid).unwrap().layers[0].props.prop_mut(path).unwrap().value = Value::Scalar(v);
set(&mut p, "effects/#2/horizontal", 30.0);
assert_eq!(key(&p).0, one, "the second effect doesn't change the input to it");
assert_ne!(key(&p).1, two);
set(&mut p, "effects/#1/blurriness", 8.0);
assert_ne!(key(&p).0, one);
}