mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-09 14:14:18 +00:00
- engine: History keeps undone lines as branches; edit.history.list/goto; History panel tree; MCP history tool - puppet.recordPin / puppet.recordOptions; viewer Cmd-drag records; Record Options form - effects::plugin (EffectPlugin trait, register_plugin, API v1); effectcraft-plugin (wasmi, sandboxed, fuel-limited); effect.plugins.load/list - ScriptUI: engine scriptui model + layout + scriptui.* commands; script crate scriptui.js and script-host threads (dialogs block show()) - File ▸ Scripts: install script / ScriptUI panel, sample scripts, window.scriptPanel - host test: bit-identical renders across runs and thread counts Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
165 lines
6.1 KiB
Rust
165 lines
6.1 KiB
Rust
//! WebAssembly plug-ins: load, register, render through the effect registry, sandbox limits.
|
||
|
||
use effectcraft_effects::{Buf, EffectCtx, EffectEnv, Image};
|
||
use effectcraft_project::build::Ids;
|
||
|
||
use crate::load_wasm;
|
||
|
||
fn load_err(r: Result<&'static effectcraft_effects::EffectSpec, String>) -> String {
|
||
match r {
|
||
Err(e) => e,
|
||
Ok(s) => panic!("unexpectedly loaded {}", s.id),
|
||
}
|
||
}
|
||
|
||
/// A plug-in in WebAssembly text: multiplies RGB by `gain`, adds `lift` × `tint` when `on`.
|
||
fn gain_module(id: &str, render_body: &str) -> String {
|
||
let manifest = format!(
|
||
r#"{{"api":1,"id":"{id}","name":"Gain {id}","category":"Test Plug-ins","version":"1.0","params":[{{"id":"gain","name":"Gain","type":"slider","default":0.5,"min":0,"max":4}},{{"id":"on","name":"Lift","type":"checkbox","default":false}},{{"id":"tint","name":"Tint","type":"color","default":[0,0,1,1]}}]}}"#
|
||
);
|
||
let len = manifest.len();
|
||
let escaped = manifest.replace('\\', "\\\\").replace('"', "\\\"");
|
||
format!(
|
||
r#"(module
|
||
(memory (export "memory") 2)
|
||
(data (i32.const 16) "{escaped}")
|
||
(func (export "ec_api_version") (result i32) i32.const 1)
|
||
(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 $n i32) (result i32)
|
||
(local $need i32)
|
||
local.get $n
|
||
i32.const 131071
|
||
i32.add
|
||
i32.const 16
|
||
i32.shr_u
|
||
memory.size
|
||
i32.sub
|
||
local.tee $need
|
||
i32.const 0
|
||
i32.gt_s
|
||
if
|
||
local.get $need
|
||
memory.grow
|
||
drop
|
||
end
|
||
i32.const 65536)
|
||
(func (export "ec_render") (param $px i32) (param $w i32) (param $h i32) (param $params i32) (param $np i32) (param $t f64) (param $scale f64) (result i32)
|
||
(local $i i32) (local $end i32) (local $g f32) (local $on f64)
|
||
{render_body}
|
||
local.get $params
|
||
f64.load
|
||
f32.demote_f64
|
||
local.set $g
|
||
local.get $params
|
||
f64.load offset=8
|
||
local.set $on
|
||
local.get $px
|
||
local.get $w
|
||
local.get $h
|
||
i32.mul
|
||
i32.const 16
|
||
i32.mul
|
||
i32.add
|
||
local.set $end
|
||
local.get $px
|
||
local.set $i
|
||
block $done
|
||
loop $l
|
||
local.get $i
|
||
local.get $end
|
||
i32.ge_u
|
||
br_if $done
|
||
local.get $i
|
||
local.get $i
|
||
f32.load
|
||
local.get $g
|
||
f32.mul
|
||
f32.store
|
||
local.get $i
|
||
local.get $i
|
||
f32.load offset=4
|
||
local.get $g
|
||
f32.mul
|
||
f32.store offset=4
|
||
local.get $i
|
||
local.get $i
|
||
f32.load offset=8
|
||
local.get $g
|
||
f32.mul
|
||
local.get $on
|
||
local.get $params
|
||
f64.load offset=32
|
||
f64.mul
|
||
f32.demote_f64
|
||
f32.add
|
||
f32.store offset=8
|
||
local.get $i
|
||
i32.const 16
|
||
i32.add
|
||
local.set $i
|
||
br $l
|
||
end
|
||
end
|
||
i32.const 0))"#
|
||
)
|
||
}
|
||
|
||
fn render(spec: &effectcraft_effects::EffectSpec, gain: f64, on: bool) -> Image {
|
||
let mut next = 1;
|
||
let g = effectcraft_effects::instantiate(spec, &mut Ids(&mut next), spec.name, [4.0, 2.0]);
|
||
let mut params = effectcraft_effects::flatten_params(&g, &mut |p| p.value.clone());
|
||
params.values.insert("gain".into(), effectcraft_keyframe::Value::Scalar(gain));
|
||
params.values.insert("on".into(), effectcraft_keyframe::Value::Bool(on));
|
||
let ctx = EffectCtx { params: ¶ms, time: 0.0, layer_size: [4.0, 2.0], seed: 1, adjustment: false, env: EffectEnv::default() };
|
||
let buf = Buf { img: Image::filled(4, 2, [0.8, 0.6, 0.2, 1.0]), offset: [0.0; 2], scale: 1.0 };
|
||
(spec.render)(&ctx, buf).img
|
||
}
|
||
|
||
#[test]
|
||
fn wasm_plugin_registers_and_renders() {
|
||
let spec = load_wasm(gain_module("org.test.gain", "").as_bytes(), "gain.wat").unwrap();
|
||
assert_eq!(spec.id, "org.test.gain");
|
||
assert_eq!(spec.category, "Test Plug-ins");
|
||
assert_eq!(spec.params.len(), 3);
|
||
// Found like a built-in, listed under its own category.
|
||
assert!(effectcraft_effects::find("org.test.gain").is_some());
|
||
assert!(effectcraft_effects::lookup("Gain org.test.gain").is_some());
|
||
assert!(effectcraft_effects::all().iter().any(|e| e.id == "org.test.gain"));
|
||
assert!(effectcraft_effects::categories().contains(&"Test Plug-ins"));
|
||
let img = render(spec, 0.5, false);
|
||
assert_eq!(img.get(1, 1), [0.4, 0.3, 0.1, 1.0]);
|
||
// The checkbox and colour parameters arrive flattened (tint blue = 1 → +1 on blue).
|
||
let img = render(spec, 1.0, true);
|
||
assert_eq!(img.get(3, 0), [0.8, 0.6, 1.2, 1.0]);
|
||
// Deterministic, and safe to run on many threads at once (each takes an instance).
|
||
let a: Vec<Image> = (0..8).map(|_| render(spec, 0.25, false)).collect();
|
||
std::thread::scope(|sc| {
|
||
let hs: Vec<_> = (0..4).map(|_| sc.spawn(|| render(spec, 0.25, false))).collect();
|
||
for h in hs {
|
||
assert_eq!(h.join().unwrap(), a[0]);
|
||
}
|
||
});
|
||
assert!(a.iter().all(|i| *i == a[0]));
|
||
// The same id can't be registered twice.
|
||
assert!(load_err(load_wasm(gain_module("org.test.gain", "").as_bytes(), "again.wat")).contains("already registered"));
|
||
}
|
||
|
||
#[test]
|
||
fn wasm_plugins_are_sandboxed() {
|
||
// A runaway loop runs out of fuel: the frame comes back unchanged instead of hanging.
|
||
let spec = load_wasm(gain_module("org.test.spin", "(loop $spin br $spin)").as_bytes(), "spin.wat").unwrap();
|
||
let img = render(spec, 0.5, false);
|
||
assert_eq!(img.get(0, 0), [0.8, 0.6, 0.2, 1.0]);
|
||
// No imports (files, clock, network…).
|
||
let imports = r#"(module (import "env" "now" (func $now (result f64))) (memory (export "memory") 1))"#;
|
||
assert!(load_err(load_wasm(imports.as_bytes(), "imports.wat")).contains("import"));
|
||
// Garbage, wrong API version, bad manifests.
|
||
assert!(load_wasm(b"\0asm nope", "bad.wasm").is_err());
|
||
let v2 = gain_module("org.test.v2", "")
|
||
.replace("(func (export \"ec_api_version\") (result i32) i32.const 1)", "(func (export \"ec_api_version\") (result i32) i32.const 2)");
|
||
assert!(load_err(load_wasm(v2.as_bytes(), "v2.wat")).contains("API 2"));
|
||
let reserved = gain_module("ec.blur.mine", "");
|
||
assert!(load_err(load_wasm(reserved.as_bytes(), "reserved.wat")).contains("reserved"));
|
||
}
|