Files
effectcraft/crates/render/src/tests_time.rs
T
Brandon ThomasandClaude Opus 5.5 1c0da40173 M9.5: time effects (Echo, Posterize Time, Time Difference, Time Displacement, Timewarp, CC Force Motion Blur, CC Wide Time, Pixel Motion Blur)
EffectHost gains self_at (the layer's source + masks + first N effects at
another layer time) and layer_at (another layer at another comp time);
EffectEnv carries the effect's stack index. The renderer serves these via
Renderer::layer_input, cached in the layer cache under input keys (footage
included, keyed by source time) with a depth guard against recursion.
Time effects are TIME_DEPENDENT so processed-layer keys fold in layer
time; layers with time effects and expressions are not cached.

Also registers the Effect > Audio specs (DSP in effects::audio_fx; the
mixdown integration follows in M11.2). Audio effects are skipped by the
video effect pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 23:42:11 -04:00

106 lines
5.2 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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}");
}
#[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}");
}
}