150 lines
7.6 KiB
Rust
150 lines
7.6 KiB
Rust
//! 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);
|
||
}
|