Files
effectcraft/crates/host/tests/it/plugins.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

441 lines
22 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.
//! Effect plug-ins in a full session: a Rust plug-in registered through the trait and the
//! example WebAssembly plug-in (examples/plugins/posterize-bands) loaded with
//! `effect.plugins.load`, both applied by id like built-ins and rendered.
use std::sync::Arc;
use effectcraft_engine::Session;
use effectcraft_engine::effects::plugin::{EffectPlugin, PluginFrame, PluginManifest, PluginParam, PluginParamKind, PluginParams, register_plugin};
use effectcraft_engine::time::Tick;
use serde_json::json;
struct Swap {
m: PluginManifest,
}
impl EffectPlugin for Swap {
fn manifest(&self) -> &PluginManifest {
&self.m
}
fn render(&self, frame: &mut PluginFrame, params: &PluginParams, _time: f64) -> Result<(), String> {
if params.b("on") {
for p in frame.pixels.iter_mut() {
p.swap(0, 2);
}
}
Ok(())
}
}
fn setup() -> (Session, u64) {
let mut s = effectcraft_host::session();
s.execute("comp.new", json!({"name": "P", "width": 40, "height": 20, "duration": 1})).unwrap();
let l = s.execute("layer.newSolid", json!({"color": [1.0, 0.5, 0.0], "width": 40, "height": 20})).unwrap()["layer"].as_u64().unwrap();
(s, l)
}
fn px(s: &Session) -> [f32; 4] {
let cid = s.active_comp_id().unwrap();
s.render(cid, Tick::ZERO, Default::default()).get(20, 10)
}
#[test]
fn rust_plugins_apply_by_id() {
let m = PluginManifest {
api: 1,
id: "org.test.swap".into(),
name: "Swap Red Blue".into(),
category: "Channel".into(),
version: "1".into(),
author: String::new(),
description: String::new(),
params: vec![PluginParam { id: "on".into(), name: "On".into(), kind: PluginParamKind::Checkbox { default: true }, group: String::new() }],
};
register_plugin(Arc::new(Swap { m })).unwrap();
let (mut s, l) = setup();
s.execute("effect.apply", json!({"layer": l, "effect": "org.test.swap"})).unwrap();
let p = px(&s);
assert!(p[0] < 0.01 && (p[1] - 0.5).abs() < 0.01 && p[2] > 0.99, "{p:?}");
// Its parameters are ordinary properties: set (and animate) them like any effect's.
s.execute("prop.set", json!({"layer": l, "path": "effects/#1/on", "value": false})).unwrap();
let p = px(&s);
assert!(p[0] > 0.99 && p[2] < 0.01, "{p:?}");
let list = s.execute("effect.plugins.list", json!({})).unwrap();
assert!(list["plugins"].as_array().unwrap().iter().any(|p| p["id"] == "org.test.swap" && p["category"] == "Channel"));
assert_eq!(list["api"], 2);
assert!(s.execute("effect.list", json!({"filter": "swap red"})).unwrap().as_array().unwrap().len() == 1);
}
/// Build the example plug-in (skipped when the wasm32 target isn't installed).
fn build_example() -> Option<std::path::PathBuf> {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../examples/plugins/posterize-bands");
let workspace = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
let assigned = std::env::var_os("CARGO_TARGET_DIR");
let target = assigned.as_ref().map(std::path::PathBuf::from).unwrap_or_else(|| workspace.join("target"));
let target = if target.is_absolute() { target } else { workspace.join(target) };
// The plug-in is part of this test's build. Inherit the assigned target and
// compiler guards rather than creating a second target at its root.
let mut command = std::process::Command::new(env!("CARGO"));
command.current_dir(&workspace).args(["build", "--release", "--target", "wasm32-unknown-unknown", "--manifest-path"]).arg(root.join("Cargo.toml"));
if assigned.is_none() {
command.env("CARGO_TARGET_DIR", &target);
}
let st = command.status().ok()?;
if !st.success() {
eprintln!("skipping: the example plug-in didn't build (rustup target add wasm32-unknown-unknown)");
return None;
}
Some(target.join("wasm32-unknown-unknown/release/posterize_bands.wasm"))
}
/// A host session whose settings and Plug-ins folder are both in `config`: installs must never
/// reach the real settings folder the host session points its Plug-ins folder at.
fn install_session(config: &std::path::Path) -> Session {
let mut s = effectcraft_host::session();
s.config = Some(Arc::new(effectcraft_engine::config::DirConfig::new(config)));
s.plugins_dir = Some(config.join("Plug-ins"));
s
}
#[test]
fn plugin_bundle_install_copies_wasm_and_installs_panels() {
let config = tempfile::TempDir::new().unwrap();
let config = config.path();
let plug_dir = config.join("Plug-ins");
let bundle = tempfile::TempDir::new().unwrap();
let bundle = bundle.path();
std::fs::write(bundle.join("a-good.wasm"), wat_plugin("org.test.host.install", 1, "i32.const 0")).unwrap();
std::fs::write(bundle.join("My Panel.jsx"), "1".as_bytes()).unwrap();
std::fs::write(bundle.join("notes.txt"), b"not a plug-in").unwrap();
let mut s = install_session(config);
let r = s.execute("effect.plugins.install", json!({"folder": bundle.to_string_lossy()})).unwrap();
assert_eq!(r["installed"].as_array().unwrap().len(), 1, "{r}");
assert_eq!(r["errors"].as_array().unwrap().len(), 0, "{r}");
assert_eq!(r["panels"].as_array().unwrap()[0], json!("My Panel.jsx"), "{r}");
// The plug-in is on disk where the start-up scan looks, and the panel is listable like an
// installed one.
assert!(plug_dir.join("a-good.wasm").is_file(), "{r}");
assert!(s.config.as_ref().unwrap().read("Scripts/ScriptUI Panels/My Panel.jsx").as_deref() == Some("1"), "{r}");
// The effect is usable in this session already.
s.execute("comp.new", json!({"name": "I", "width": 40, "height": 20, "duration": 1})).unwrap();
let l = s.execute("layer.newSolid", json!({"color": [1.0, 0.5, 0.0], "width": 40, "height": 20})).unwrap()["layer"].as_u64().unwrap();
s.execute("effect.apply", json!({"layer": l, "effect": "Host org.test.host.install"})).unwrap();
// A load failure rolls the copy back: nothing left behind to fail every start.
let bad = tempfile::TempDir::new().unwrap();
let bad = bad.path();
std::fs::write(bad.join("b-garbage.wasm"), b"\0asm\x01\0\0\0garbage").unwrap();
let r2 = s.execute("effect.plugins.install", json!({"folder": bad.to_string_lossy()})).unwrap();
assert_eq!(r2["errors"].as_array().unwrap().len(), 1, "{r2}");
assert!(r2["installed"].as_array().unwrap().is_empty(), "{r2}");
assert!(!plug_dir.join("b-garbage.wasm").exists(), "{r2}");
// A single-file install: non-wasm paths are refused, and good files land on disk.
assert!(s.execute("effect.plugins.install", json!({"path": bundle.join("My Panel.jsx").to_string_lossy()})).is_err());
// A single-file success: the file lands in the Plug-ins folder and is reported installed.
let fresh = bundle.join("fresh.wasm");
std::fs::write(&fresh, wat_plugin("org.test.host.fresh", 1, "i32.const 0")).unwrap();
let r3 = s.execute("effect.plugins.install", json!({"path": fresh.to_string_lossy()})).unwrap();
assert_eq!(r3["installed"].as_array().unwrap()[0], json!("fresh.wasm"), "{r3}");
assert!(plug_dir.join("fresh.wasm").is_file(), "{r3}");
// Again from `config`: an already-registered (in-session) id fails as a duplicate.
let again = config.join("keep.wasm");
std::fs::copy(bundle.join("a-good.wasm"), &again).unwrap();
assert!(s.execute("effect.plugins.install", json!({"path": again.to_string_lossy()})).is_err());
}
#[test]
fn plugin_install_never_replaces_an_installed_plugin() {
let config = tempfile::TempDir::new().unwrap();
let config = config.path();
let plug_dir = config.join("Plug-ins");
let src = tempfile::TempDir::new().unwrap();
let src = src.path();
std::fs::write(src.join("a-good.wasm"), wat_plugin("org.test.host.replace", 1, "i32.const 0")).unwrap();
let mut s = install_session(config);
s.execute("effect.plugins.install", json!({"path": src.join("a-good.wasm").to_string_lossy()})).unwrap();
let installed = std::fs::read(plug_dir.join("a-good.wasm")).unwrap();
// Same file name, a different plug-in: installing over the existing one must be refused,
// not quietly swap it — and the first plug-in's bytes on disk stay exactly what they were.
let attack = tempfile::TempDir::new().unwrap();
let attack = attack.path();
std::fs::write(attack.join("a-good.wasm"), wat_plugin("org.test.host.hack", 1, "i32.const 0")).unwrap();
let r = s.execute("effect.plugins.install", json!({"folder": attack.to_string_lossy()})).unwrap();
assert!(r["installed"].as_array().unwrap().is_empty(), "{r}");
let errs: Vec<String> = r["errors"].as_array().unwrap().iter().map(|e| e["error"].as_str().unwrap().to_string()).collect();
assert!(errs.iter().any(|e| e.contains("already installed")), "{r}");
assert_eq!(std::fs::read(plug_dir.join("a-good.wasm")).unwrap(), installed);
// The same through a single-file install: a command error, disk untouched.
assert!(s.execute("effect.plugins.install", json!({"path": attack.join("a-good.wasm").to_string_lossy()})).is_err());
assert_eq!(std::fs::read(plug_dir.join("a-good.wasm")).unwrap(), installed);
// A ScriptUI panel is code too: a bundle must not replace an installed one silently either.
let p1 = tempfile::TempDir::new().unwrap();
std::fs::write(p1.path().join("My Panel.jsx"), "1").unwrap();
s.execute("effect.plugins.install", json!({"folder": p1.path().to_string_lossy()})).unwrap();
let p2 = tempfile::TempDir::new().unwrap();
std::fs::write(p2.path().join("My Panel.jsx"), "2").unwrap();
let r = s.execute("effect.plugins.install", json!({"folder": p2.path().to_string_lossy()})).unwrap();
assert!(r["panels"].as_array().unwrap().is_empty(), "{r}");
let errs: Vec<String> = r["errors"].as_array().unwrap().iter().map(|e| e["error"].as_str().unwrap().to_string()).collect();
assert!(errs.iter().any(|e| e.contains("already installed as a ScriptUI panel")), "{r}");
assert_eq!(s.config.as_ref().unwrap().read("Scripts/ScriptUI Panels/My Panel.jsx").as_deref(), Some("1"));
}
/// A single install of a hidden file is an error, not a success that installed nothing; a panel
/// whose name differs from an installed one only in case is refused (on Windows and macOS writing
/// it would replace that panel); an installed plug-in's source is its installed copy.
#[test]
fn plugin_install_refuses_hidden_files_and_case_variant_panels() {
let config = tempfile::TempDir::new().unwrap();
let config = config.path();
let src = tempfile::TempDir::new().unwrap();
let src = src.path();
let mut s = install_session(config);
let hidden = src.join(".hidden.wasm");
std::fs::write(&hidden, wat_plugin("org.test.host.hidden", 1, "i32.const 0")).unwrap();
assert!(s.execute("effect.plugins.install", json!({"path": hidden.to_string_lossy()})).is_err());
assert!(!config.join("Plug-ins").join(".hidden.wasm").exists());
let p1 = tempfile::TempDir::new().unwrap();
std::fs::write(p1.path().join("Case Panel.jsx"), "1").unwrap();
s.execute("effect.plugins.install", json!({"folder": p1.path().to_string_lossy()})).unwrap();
let p2 = tempfile::TempDir::new().unwrap();
std::fs::write(p2.path().join("case panel.jsx"), "2").unwrap();
let r = s.execute("effect.plugins.install", json!({"folder": p2.path().to_string_lossy()})).unwrap();
assert!(r["panels"].as_array().unwrap().is_empty(), "{r}");
assert_eq!(r["errors"].as_array().unwrap().len(), 1, "{r}");
assert_eq!(s.config.as_ref().unwrap().read("Scripts/ScriptUI Panels/Case Panel.jsx").as_deref(), Some("1"));
let good = src.join("sourced.wasm");
std::fs::write(&good, wat_plugin("org.test.host.sourced", 1, "i32.const 0")).unwrap();
s.execute("effect.plugins.install", json!({"path": good.to_string_lossy()})).unwrap();
let list = s.execute("effect.plugins.list", json!({})).unwrap();
let entry = list["plugins"].as_array().unwrap().iter().find(|p| p["id"] == "org.test.host.sourced").cloned().unwrap();
assert_eq!(entry["source"], json!(config.join("Plug-ins").join("sourced.wasm").to_string_lossy()), "{entry}");
}
/// A huge file is refused before it is read into memory and copied into the folder the app loads
/// at every start.
#[test]
fn plugin_install_refuses_an_oversized_file() {
let config = tempfile::TempDir::new().unwrap();
let src = tempfile::TempDir::new().unwrap();
let big = src.path().join("big.wasm");
// Sparse on every platform we build for: no 64 MiB written, just the length.
std::fs::File::create(&big).unwrap().set_len((64 << 20) + 1).unwrap();
let mut s = install_session(config.path());
let Err(e) = s.execute("effect.plugins.install", json!({"path": big.to_string_lossy()})) else { panic!("an oversized plug-in was installed") };
assert!(e.to_string().contains("byte limit"), "{e}");
assert!(!config.path().join("Plug-ins/big.wasm").exists(), "{e}");
}
#[test]
fn example_wasm_plugin_loads_and_renders() {
let Some(wasm) = build_example() else { return };
let (mut s, l) = setup();
let r = s.execute("effect.plugins.load", json!({"path": wasm.to_string_lossy()})).unwrap();
assert_eq!(r["loaded"][0]["id"], "org.effectcraft.example.posterize-bands", "{r}");
s.execute("effect.apply", json!({"layer": l, "effect": "Posterize Bands"})).unwrap();
s.execute("prop.set", json!({"layer": l, "path": "effects/#1/mix", "value": 0})).unwrap();
s.execute("prop.set", json!({"layer": l, "path": "effects/#1/levels", "value": 2})).unwrap();
// Luminance of (1, .5, 0) is ~0.57: with 2 levels it bands to 1, scaling the colour by 1/y
// (8 bpc: red clips at 1).
let p = px(&s);
let y = 0.2126 + 0.7152 * 0.5;
assert!((p[1] - 0.5 / y as f32).abs() < 0.01 && p[0] > 0.99 && p[2] < 0.01, "{p:?}");
// Invert: band 0.
s.execute("prop.set", json!({"layer": l, "path": "effects/#1/invert", "value": true})).unwrap();
assert_eq!(px(&s)[..3], [0.0, 0.0, 0.0]);
let list = s.execute("effect.plugins.list", json!({})).unwrap();
assert!(list["plugins"].as_array().unwrap().iter().any(|p| p["source"].as_str().unwrap_or("").ends_with(".wasm")));
}
/// A parameterless plug-in module (WebAssembly text): `render` is `ec_render`'s body.
pub(super) fn wat_plugin(id: &str, api: i32, render: &str) -> String {
let manifest = format!(r#"{{"api":1,"id":"{id}","name":"Host {id}","category":"Test Plug-ins","version":"1","params":[]}}"#);
let len = manifest.len();
let escaped = manifest.replace('"', "\\\"");
format!(
r#"(module
(memory (export "memory") 2)
(data (i32.const 16) "{escaped}")
(func (export "ec_api_version") (result i32) i32.const {api})
(func (export "ec_manifest_ptr") (result i32) i32.const 16)
(func (export "ec_manifest_len") (result i32) i32.const {len})
(func (export "ec_alloc") (param i32) (result i32) i32.const 1024)
(func (export "ec_render") (param i32 i32 i32 i32 i32 f64 f64) (result i32) {render}))"#
)
}
#[test]
fn plugin_loading_reports_bad_modules_and_survives_failing_renders() {
let dir = std::env::temp_dir().join(format!("effectcraft-host-plugins-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let files = [
("a-good.wasm", wat_plugin("org.test.host.good", 1, "i32.const 0").into_bytes()),
("b-garbage.wasm", b"\0asm\x01\0\0\0garbage".to_vec()),
("c-v3.wasm", wat_plugin("org.test.host.v3", 3, "i32.const 0").into_bytes()),
("d-empty.wasm", vec![]),
("e-trap.wasm", wat_plugin("org.test.host.trap", 1, "unreachable").into_bytes()),
("notes.txt", b"not a plug-in".to_vec()),
];
for (name, bytes) in &files {
std::fs::write(dir.join(name), bytes).unwrap();
}
let (mut s, l) = setup();
let r = s.execute("effect.plugins.load", json!({"folder": dir.to_string_lossy()})).unwrap();
let ids: Vec<&str> = r["loaded"].as_array().unwrap().iter().filter_map(|v| v["id"].as_str()).collect();
assert_eq!(ids, ["org.test.host.good", "org.test.host.trap"], "{r}");
let errs: Vec<String> = r["errors"].as_array().unwrap().iter().map(|e| e["path"].as_str().unwrap().to_string()).collect();
assert_eq!(errs.len(), 3, "{r}");
assert!(errs.iter().all(|p| !p.ends_with(".txt")));
assert!(r["errors"].to_string().contains("ABI 3"), "{r}");
// A single file: errors come back as command errors; loading twice is refused.
let good = dir.join("a-good.wasm").to_string_lossy().to_string();
assert!(s.execute("effect.plugins.load", json!({"path": good})).unwrap_err().to_string().contains("already registered"));
assert!(s.execute("effect.plugins.load", json!({"path": dir.join("missing.wasm").to_string_lossy()})).is_err());
assert!(s.execute("effect.plugins.load", json!({"folder": dir.join("missing").to_string_lossy()})).is_err());
assert!(s.execute("effect.plugins.load", json!({})).is_err());
// A plug-in that traps on every frame leaves the layer as it was.
let before = px(&s);
s.execute("effect.apply", json!({"layer": l, "effect": "org.test.host.trap"})).unwrap();
assert_eq!(px(&s), before);
s.execute("effect.apply", json!({"layer": l, "effect": "org.test.host.good"})).unwrap();
assert_eq!(px(&s), before);
let _ = std::fs::remove_dir_all(&dir);
}
/// An ABI 2 plug-in (WebAssembly text) that shows the neighbouring frames it got: per pixel it
/// writes |next.red − prev.red| into red, `flags / 8` into green, `prev_time` into blue and 1
/// into alpha, so a render test can read the frames and their times back from the image.
fn wat_neighbour_plugin(id: &str) -> String {
let manifest = format!(r#"{{"api":2,"id":"{id}","name":"Host {id}","category":"Test Plug-ins","version":"1","params":[]}}"#);
let len = manifest.len();
let escaped = manifest.replace('"', "\\\"");
format!(
r#"(module
(memory (export "memory") 2)
(data (i32.const 16) "{escaped}")
(func (export "ec_api_version") (result i32) i32.const 2)
(func (export "ec_manifest_ptr") (result i32) i32.const 16)
(func (export "ec_manifest_len") (result i32) i32.const {len})
(func (export "ec_alloc") (param i32) (result i32) i32.const 1024)
(func (export "ec_render2")
(param $px i32) (param $w i32) (param $h i32) (param $params i32) (param $np i32)
(param $t f64) (param $scale f64)
(param $prev i32) (param $prev_t f64) (param $next i32) (param $next_t f64) (param $flags i32)
(result i32)
(local $i i32)
(local $n i32)
local.get $w
local.get $h
i32.mul
local.set $n
block $done
loop $per_pixel
local.get $i
local.get $n
i32.ge_u
br_if $done
;; red = |next.red - prev.red|
local.get $px
local.get $i
i32.const 16
i32.mul
i32.add
local.get $next
local.get $i
i32.const 16
i32.mul
i32.add
f32.load
local.get $prev
local.get $i
i32.const 16
i32.mul
i32.add
f32.load
f32.sub
f32.abs
f32.store
;; green = flags / 8
local.get $px
local.get $i
i32.const 16
i32.mul
i32.add
local.get $flags
f32.convert_i32_s
f32.const 8
f32.div
f32.store offset=4
;; blue = prev_time
local.get $px
local.get $i
i32.const 16
i32.mul
i32.add
local.get $prev_t
f32.demote_f64
f32.store offset=8
;; alpha = 1
local.get $px
local.get $i
i32.const 16
i32.mul
i32.add
f32.const 1
f32.store offset=12
local.get $i
i32.const 1
i32.add
local.set $i
br $per_pixel
end
end
i32.const 0))"#
)
}
/// The neighbouring frames are the layer's own pixels at other times: here a precomp whose
/// nested solid fades from 0 to 100 % over one second, at 4 fps. At 0.5 s the neighbours are
/// 0.25 s and 0.75 s, so red (the current frame) was 0.5 and |next − prev| is 0.5.
#[test]
fn abi_v2_plugins_render_with_real_neighbour_frames() {
let mut s = effectcraft_host::session();
let inner = s.execute("comp.new", json!({"name": "Inner", "width": 40, "height": 20, "frameRate": 4, "duration": 1})).unwrap()["comp"].as_u64().unwrap();
let solid = s.execute("layer.newSolid", json!({"color": [1.0, 0.0, 0.0], "width": 40, "height": 20})).unwrap()["layer"].as_u64().unwrap();
s.execute("prop.addKey", json!({"layer": solid, "path": "transform/opacity", "time": 0.0, "value": 0})).unwrap();
s.execute("prop.addKey", json!({"layer": solid, "path": "transform/opacity", "time": 1.0, "value": 100})).unwrap();
let outer = s.execute("comp.new", json!({"name": "Outer", "width": 40, "height": 20, "frameRate": 4, "duration": 1})).unwrap()["comp"].as_u64().unwrap();
let layer = s.execute("layer.addItem", json!({"comp": outer, "item": inner, "time": 0})).unwrap()["layer"].as_u64().unwrap();
let outer = effectcraft_engine::project::ItemId(outer);
let dir = tempfile::TempDir::new().unwrap();
let wasm = dir.path().join("neighbours.wasm");
std::fs::write(&wasm, wat_neighbour_plugin("org.test.host.neighbours")).unwrap();
let r = s.execute("effect.plugins.load", json!({"path": wasm.to_string_lossy()})).unwrap();
assert_eq!(r["loaded"][0]["id"], "org.test.host.neighbours", "{r}");
s.execute("effect.apply", json!({"layer": layer, "effect": "org.test.host.neighbours"})).unwrap();
let p = s.render(outer, Tick::from_seconds_f64(0.5), Default::default()).get(20, 10);
assert!((p[0] - 0.5).abs() < 0.02, "precomp neighbours: {p:?}");
// Both neighbours exist, the previous one is the 0.25 s frame, and the frame is opaque.
assert!((p[1] - 3.0 / 8.0).abs() < 0.01, "flags: {p:?}");
assert!((p[2] - 0.25).abs() < 0.01, "prev time: {p:?}");
assert_eq!(p[3], 1.0, "{p:?}");
// At the very start of the layer only the next frame exists (the previous one is zeroed);
// solids are filled with a quantised colour, hence the 8-bit step.
let p = s.render(outer, Tick::ZERO, Default::default()).get(20, 10);
assert!((p[0] - 0.25).abs() < 0.01, "the next frame's red, with no previous frame: {p:?}");
assert!((p[1] - 2.0 / 8.0).abs() < 0.01, "only the next frame is valid: {p:?}");
}