Files
effectcraft/crates/render/src/tests/time.rs
T
just_anshhandjustanshh 544eebf904 WebAssembly ABI 2: neighbouring frames for effect plug-ins (#2773) (#3329)
* WebAssembly ABI 2: neighbouring frames for effect plug-ins (#2773)

A wasm plug-in can export ec_api_version 2 and ec_render2 to receive the layer's previous and next frames, their layer times and validity flags. Neighbours come from PluginHost::input_at, are resampled onto the current grid, and are zeroed when absent. ABI 1 modules (ec_render) load and render unchanged; the manifest api stays independent. A layer with an ABI 2 plug-in keys the render cache on layer time; the Plug-in Library accepts ABI 1-2 releases. Docs, help text and tests updated.

* Fix ABI 2 neighbour times on NTSC frame boundaries

	ime * fps is often a few ulps off an integer at rates such as 24000/1001. The old boundary test then took the bracket branch and passed the current frame back as prev (or
ext). Treat a near-integer as a frame boundary within 1e-6 frames, and test 2400 frames at 24000/1001: every boundary must get the frames either side. Also document the loader helpers for the docstring coverage check.

---------

Co-authored-by: justanshh <294019246+justanshh@users.noreply.github.com>
2026-11-09 10:49:53 +01:00

249 lines
11 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 std::sync::Arc;
use effectcraft_color::Label;
use effectcraft_effects::plugin::{EffectPlugin, PluginFrame, PluginManifest, PluginParams, register_plugin};
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,
property_overrides: &[],
media_overrides: &[],
reads: 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);
}
/// A plug-in that reads the layer's neighbouring frames (WebAssembly ABI 2) makes its layer's
/// cache key depend on the layer time, even when the source and every property are static. A
/// plug-in that does not keeps the old key (#1977: a static layer stays cached across a scrub).
#[test]
fn abi2_plugin_layers_key_on_layer_time() {
struct NeighbourReader {
m: PluginManifest,
}
impl EffectPlugin for NeighbourReader {
fn manifest(&self) -> &PluginManifest {
&self.m
}
fn render(&self, _frame: &mut PluginFrame, _params: &PluginParams, _time: f64) -> Result<(), String> {
Ok(())
}
fn reads_other_times(&self) -> bool {
true
}
}
struct Plain {
m: PluginManifest,
}
impl EffectPlugin for Plain {
fn manifest(&self) -> &PluginManifest {
&self.m
}
fn render(&self, _frame: &mut PluginFrame, _params: &PluginParams, _time: f64) -> Result<(), String> {
Ok(())
}
}
/// A static white solid with one plug-in effect, registered by `plugin`.
fn plugin_scene(plugin: Arc<dyn EffectPlugin>) -> (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 spec = register_plugin(plugin).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;
l.props.sub_mut("effects").unwrap().children.push(g.into());
p.comp_mut(cid).unwrap().layers.push(l);
(p, cid)
}
fn key(p: &Project, cid: ItemId, t: f64) -> Option<u64> {
let comp = p.comp(cid).unwrap();
let ctx = crate::EvalCtx {
project: p,
comp_id: cid,
comp,
time: Tick::from_seconds_f64(t),
expr: None,
footage: None,
property_overrides: &[],
media_overrides: &[],
reads: None,
};
crate::cache::layer_key(&ctx, &comp.layers[0], 1.0, false, false)
}
let manifest = |id: &str| PluginManifest {
api: 2,
id: id.into(),
name: "Key".into(),
category: "Test Plug-ins".into(),
version: "1".into(),
author: String::new(),
description: String::new(),
params: vec![],
};
let (plain, c1) = plugin_scene(Arc::new(Plain { m: manifest("org.test.render.time-key.plain") }));
assert_eq!(key(&plain, c1, 0.25), key(&plain, c1, 0.5), "a plain plug-in keeps the old key");
let (reader, c2) = plugin_scene(Arc::new(NeighbourReader { m: manifest("org.test.render.time-key.reader") }));
assert_ne!(key(&reader, c2, 0.25), key(&reader, c2, 0.5), "an ABI 2 plug-in keys on the layer time");
}