mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-02 14:38:21 -06:00
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:
@@ -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));
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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:?}");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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]));
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user