106 lines
5.2 KiB
Rust
106 lines
5.2 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}");
|
||
}
|
||
|
||
#[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}");
|
||
}
|
||
}
|