mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-11 02:41:57 -06:00
* Plug-ins: API 2 (PluginHost, padding, layer_span) Split out of the OpenFX work so it can be reviewed on its own. Additive; the WebAssembly ABI stays at 1. - point3 / layer / text / hidden parameters, integer sliders, parameter groups. - EffectPlugin::padding (transparent pixels around the buffer, at most 4096 per side). - EffectPlugin::render_v2 with a PluginHost: the input at other times, other layers, parameters at other times, frame rate, per-instance key, the layer's source range. - EffectEnv::layer_span from the renderer; MAX_PLUGINS 1024. - plugin_error: a plug-in effect's last render error, for the UI. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KxacwTpUwqyfpcLgKW8yjS * Plug-ins: external effect helpers (crates/helper-host) Native plug-in formats run in separate helper programs, never in EffectCraft's process (docs/adr/0001-external-effect-helpers.md). New safe-Rust crate effectcraft-helper-host (L3, native only, unsafe forbidden): it finds helper programs, starts each as a child process and registers its effects through plug-in API 2, so the UI and command surface need nothing new. - Protocol v1 (docs/external-helpers.md): JSON lines over stdin / stdout (helper replies are prefixed so plug-in chatter is ignored); pixels go through a frame exchange file (/dev/shm on Linux), never through the pipe. Render-time callbacks for the input at other times, other layers and parameters at other times, with nested renders through the same helper. - A helper that exits, hangs past its timeout or breaks the protocol is stopped. The render fails with a message, the frame is left as it was, and the helper restarts for the next render. An effect that stops its helper 3 times is turned off (helper_blocklist.txt). - Opt-in: nothing starts helpers unless the user asks (Effect > Scan for Effect Helpers), or opts in to starting them at launch with EFFECTCRAFT_HELPERS=1, an environment variable, so writing to the settings folder alone can't run code. effect.helpers.scan / load are refused for confined agent sessions (confine.rs), like file.executeFile. - Discovery: helpers/ next to the app (Contents/Helpers on macOS), helpers/ in the settings folder, EFFECTCRAFT_HELPER_PATH. - Commands effect.helpers.list / scan / load; Effect menu entries (translated); the last render error of an effect shows as a warning sign after its name in Effect Controls. - serve(): the helper side for Rust helpers. tests/it/main.rs runs the whole protocol (render, padding, callbacks, nesting, threads, crash, restart, block list, hang) against a fake helper, with nothing to install. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KxacwTpUwqyfpcLgKW8yjS * Effect helpers: address review (start failures, frame file mode, nextest listing) - A helper that can't start (not installed any more, restarting too often, no frame exchange file) is reported but no longer charged to the effect being rendered: it is Failure::Refused, not Failure::Broken, so it never counts toward turning that effect off. New check in tests/it: once the helper is out of restarts, renders of an effect fail without blocking it (fails without the fix: the effect was turned off after 3 such renders). - The frame exchange file is created owner-only (0o600) on Unix: it holds the user's frames and /dev/shm is shared. Asserted in the frames unit test. - tests/it (harness = false) speaks the libtest listing protocol cargo-nextest uses: `--list --format terse` prints `helper_protocol: test` (nothing with `--ignored`), and `helper_protocol --exact` runs the checks. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KxacwTpUwqyfpcLgKW8yjS * Effect helpers: bound writes to a helper, drain stderr, keep callbacks in step - Writes to a helper go through a writer thread with the request timeout: a helper that stops reading its stdin (hung with a large callback answer or render request pending) no longer blocks the render thread forever while holding the helper. - stderr is drained as bytes (lossy, in 64 KiB pieces) to the end: output that isn't UTF-8 stopped the reader, closing the pipe under the helper (EPIPE / SIGPIPE on its next write); lines were unbounded. - Callback images that can't be written are answered with `null` instead of failing the render while the helper waits (which left the protocol out of step); a frame that can't be written before the request is sent is not charged to the effect. - Mid-render calls use the render's own process: no respawn or heartbeat ping while the helper is waiting for an answer. - Waiting for a busy helper is bounded (load timeout) instead of forever; status() no longer blocks on a running render. - Reap the child when start-up fails after spawning; cap the block list read. - Scripts start helpers only with Allow Scripts to Write Files and Access Network on (they run native code), as for File.execute(). - helper-host declares the workspace's no-panic crate lints; the desktop main() keeps its doc comment. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Effect helpers: rustfmt the fake provider, build the test session in its initializer (clippy) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Plug-in Library: offer WebAssembly API 1 releases only; a helper already stopped mid-render isn't charged again - WasmPlugin loads only modules at the WebAssembly ABI version, which stays 1 while PLUGIN_API_VERSION is 2: share it as effects::plugin::WASM_API_VERSION and have the library's compatibility check use it, so it never installs an API 2 release that can't load. - with_running: a process that is already gone was stopped by what broke it (a nested render, charged there), so this render fails without charging its own effect. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * frames: plug-in effects count as reading other frames (plug-in API 2) With main's narrowed frame invalidation (#2795), an edit inside a precomp under an effect that reads its layer at other times must reach the layer's whole span. Plug-in API 2 gives every plug-in effect the input at other times, so any effect outside ec. counts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
328 lines
14 KiB
Rust
328 lines
14 KiB
Rust
//! The helper protocol end to end against a fake helper, written in safe Rust with
|
||
//! [`effectcraft_helper_host::serve`]. This binary is both sides: started with
|
||
//! `--effectcraft-helper` it is the helper, otherwise it runs the checks, starting itself as
|
||
//! the helper. (`harness = false`: libtest would reject the helper argument.)
|
||
|
||
use std::io::Write;
|
||
use std::sync::Arc;
|
||
use std::time::Duration;
|
||
|
||
use effectcraft_effects::plugin::{EffectPlugin, PluginFrame, PluginHost, PluginImage, PluginManifest, PluginParam, PluginParamKind, PluginParams, plugin};
|
||
use effectcraft_helper_host::protocol::{HELPER_ARG, Reply, reply_line};
|
||
use effectcraft_helper_host::serve::{Provider, serve};
|
||
|
||
fn manifest(id: &str, params: Vec<PluginParam>) -> PluginManifest {
|
||
PluginManifest {
|
||
api: 2,
|
||
id: id.into(),
|
||
name: id.rsplit('.').next().unwrap_or(id).into(),
|
||
category: "Helper Test".into(),
|
||
version: "1".into(),
|
||
author: String::new(),
|
||
description: String::new(),
|
||
params,
|
||
}
|
||
}
|
||
|
||
fn amount() -> PluginParam {
|
||
PluginParam {
|
||
id: "amount".into(),
|
||
name: "Amount".into(),
|
||
group: String::new(),
|
||
kind: PluginParamKind::Slider { default: 1.0, min: 0.0, max: 1.0, slider_min: None, slider_max: None, decimals: 2, integer: false },
|
||
}
|
||
}
|
||
|
||
/// What each fake effect does.
|
||
#[derive(Clone, Copy, PartialEq)]
|
||
enum Kind {
|
||
/// RGB → amount·(1 − RGB).
|
||
Invert,
|
||
/// Asks for 3 pixels of padding; fills the frame with its padding as red.
|
||
Pad,
|
||
/// Replaces the frame with the input one frame earlier, and puts `amount` from
|
||
/// `params_at(0)` in alpha of pixel 0.
|
||
Echo,
|
||
/// Exits the process mid-render.
|
||
Crash,
|
||
/// Asks for the parameters before 0 (an answer far larger than a pipe holds), then never
|
||
/// reads it or answers.
|
||
Hang,
|
||
/// Writes a long line of bytes that aren't UTF-8 to stderr, then more, then inverts.
|
||
Noisy,
|
||
}
|
||
|
||
struct Fake {
|
||
m: PluginManifest,
|
||
kind: Kind,
|
||
}
|
||
|
||
impl EffectPlugin for Fake {
|
||
fn manifest(&self) -> &PluginManifest {
|
||
&self.m
|
||
}
|
||
fn render(&self, _: &mut PluginFrame, _: &PluginParams, _: f64) -> Result<(), String> {
|
||
Err("needs render_v2".into())
|
||
}
|
||
fn padding(&self, _: &PluginParams, _: f64, _: f64, _: [f64; 2], _: u64) -> u32 {
|
||
if self.kind == Kind::Pad { 3 } else { 0 }
|
||
}
|
||
fn render_v2(&self, frame: &mut PluginFrame, params: &PluginParams, _time: f64, host: &dyn PluginHost) -> Result<(), String> {
|
||
match self.kind {
|
||
Kind::Invert => {
|
||
let a = params.f("amount") as f32;
|
||
for p in frame.pixels.iter_mut() {
|
||
for c in &mut p[..3] {
|
||
*c = a * (1.0 - *c);
|
||
}
|
||
}
|
||
}
|
||
Kind::Pad => frame.pixels.iter_mut().for_each(|p| *p = [3.0, 0.0, 0.0, 1.0]),
|
||
Kind::Echo => {
|
||
let prev = host.input_at(host.time() - 1.0 / host.frame_rate()).ok_or("no input at the previous frame")?;
|
||
if prev.pixels.len() != frame.pixels.len() {
|
||
return Err("previous frame has another size".into());
|
||
}
|
||
frame.pixels.copy_from_slice(&prev.pixels);
|
||
let (flat, _) = host.params_at(0.0).ok_or("no params at 0")?;
|
||
if let (Some(p), Some(a)) = (frame.pixels.first_mut(), flat.first()) {
|
||
p[3] = *a as f32;
|
||
}
|
||
}
|
||
Kind::Crash => std::process::exit(3),
|
||
Kind::Hang => {
|
||
let mut out = std::io::stdout();
|
||
out.write_all(reply_line(&Reply::ParamsAt { time: -1.0 })?.as_bytes()).and_then(|_| out.flush()).map_err(|e| e.to_string())?;
|
||
std::thread::sleep(Duration::from_secs(3600));
|
||
}
|
||
Kind::Noisy => {
|
||
let mut err = std::io::stderr();
|
||
let mut junk = vec![0xffu8; 1 << 20];
|
||
junk.push(b'\n');
|
||
err.write_all(&junk).map_err(|e| format!("stderr: {e}"))?;
|
||
// EffectCraft must still be reading.
|
||
std::thread::sleep(Duration::from_millis(300));
|
||
err.write_all(b"still talking\n").map_err(|e| format!("stderr: {e}"))?;
|
||
frame.pixels.iter_mut().for_each(|p| *p = [1.0, 0.0, 0.0, 1.0]);
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
}
|
||
|
||
struct FakeProvider;
|
||
|
||
impl Provider for FakeProvider {
|
||
fn name(&self) -> String {
|
||
"Fake".into()
|
||
}
|
||
fn effects(&mut self) -> (Vec<Arc<dyn EffectPlugin>>, Vec<String>) {
|
||
let v: Vec<Arc<dyn EffectPlugin>> =
|
||
[("invert", Kind::Invert), ("pad", Kind::Pad), ("echo", Kind::Echo), ("crash", Kind::Crash), ("hang", Kind::Hang), ("noisy", Kind::Noisy)]
|
||
.into_iter()
|
||
.map(|(n, kind)| Arc::new(Fake { m: manifest(&format!("org.test.helper.{n}"), vec![amount()]), kind }) as Arc<dyn EffectPlugin>)
|
||
.collect();
|
||
(v, vec!["fake helper: a warning".into()])
|
||
}
|
||
}
|
||
|
||
/// The host side of a render in these checks: the input at any time is a flat grey of that
|
||
/// time; with `nest`, asking for the input renders `invert` through the same helper first.
|
||
struct TestHost {
|
||
w: u32,
|
||
h: u32,
|
||
nest: bool,
|
||
}
|
||
|
||
impl PluginHost for TestHost {
|
||
fn input_at(&self, t: f64) -> Option<PluginImage> {
|
||
let mut pixels = vec![[t as f32, t as f32, t as f32, 1.0]; (self.w * self.h) as usize];
|
||
if self.nest {
|
||
let inv = plugin("org.test.helper.invert")?;
|
||
let m = inv.manifest().clone();
|
||
let mut f =
|
||
PluginFrame { width: self.w, height: self.h, pixels: &mut pixels, scale: 1.0, origin: [0.0; 2], layer_size: [self.w as f64, self.h as f64] };
|
||
inv.render_v2(&mut f, &PluginParams { manifest: &m, flat: &[1.0], texts: &[] }, t, &TestHost { nest: false, ..*self }).ok()?;
|
||
}
|
||
Some(PluginImage { width: self.w, height: self.h, pixels, origin: [0.0; 2], scale: 1.0 })
|
||
}
|
||
fn layer_input(&self, _: &str, _: f64) -> Option<PluginImage> {
|
||
None
|
||
}
|
||
fn params_at(&self, t: f64) -> Option<(Vec<f64>, Vec<String>)> {
|
||
// Before 0: megabytes, far more than a pipe holds (see `Kind::Hang`).
|
||
if t < 0.0 {
|
||
return Some((vec![0.5; 1 << 20], vec![]));
|
||
}
|
||
Some((vec![0.25], vec![]))
|
||
}
|
||
fn frame_rate(&self) -> f64 {
|
||
10.0
|
||
}
|
||
fn time(&self) -> f64 {
|
||
1.0
|
||
}
|
||
fn instance_key(&self) -> u64 {
|
||
7
|
||
}
|
||
}
|
||
|
||
fn render(id: &str, pixels: &mut Vec<[f32; 4]>, w: u32, h: u32, amount: f64, nest: bool) -> Result<(), String> {
|
||
let p = plugin(id).ok_or_else(|| format!("{id} is not registered"))?;
|
||
let m = p.manifest().clone();
|
||
let params = PluginParams { manifest: &m, flat: &[amount], texts: &[] };
|
||
let host = TestHost { w, h, nest };
|
||
let pad = p.padding(¶ms, 1.0, 1.0, [w as f64, h as f64], 7);
|
||
let (w, h) = (w + 2 * pad, h + 2 * pad);
|
||
pixels.resize((w * h) as usize, [0.0; 4]);
|
||
let mut f = PluginFrame { width: w, height: h, pixels, scale: 1.0, origin: [pad as f64; 2], layer_size: [w as f64, h as f64] };
|
||
p.render_v2(&mut f, ¶ms, 1.0, &host)
|
||
}
|
||
|
||
fn check(name: &str, ok: bool, detail: impl std::fmt::Debug) -> bool {
|
||
println!("{} {name}{}", if ok { "ok " } else { "FAIL" }, if ok { String::new() } else { format!(": {detail:?}") });
|
||
ok
|
||
}
|
||
|
||
fn run_checks() -> bool {
|
||
let exe = match std::env::current_exe() {
|
||
Ok(e) => e,
|
||
Err(e) => return check("current exe", false, e),
|
||
};
|
||
let state = std::env::temp_dir().join(format!("ec-fake-helper-state-{}", std::process::id()));
|
||
effectcraft_helper_host::blocklist::set_state_dir(state.clone());
|
||
let mut all = true;
|
||
|
||
// Discovery + describe.
|
||
let r = effectcraft_helper_host::scan_paths(std::slice::from_ref(&exe), false);
|
||
let loaded = r["loaded"].as_array().map_or(0, Vec::len);
|
||
all &= check("scan registers the helper's effects", loaded == 6, &r);
|
||
all &= check("helper warnings are reported", r["errors"].to_string().contains("a warning"), &r);
|
||
all &= check(
|
||
"a second scan doesn't start it twice",
|
||
effectcraft_helper_host::scan_paths(std::slice::from_ref(&exe), false)["helpers"].as_array().is_some_and(Vec::is_empty),
|
||
"",
|
||
);
|
||
let list = effectcraft_helper_host::list();
|
||
all &= check("list names the helper", list["helpers"][0]["name"] == "Fake" && list["helpers"][0]["running"] == true, &list);
|
||
|
||
// Plain render through the exchange file.
|
||
let mut px = vec![[0.25, 0.5, 1.0, 1.0]; 6];
|
||
let r = render("org.test.helper.invert", &mut px, 3, 2, 1.0, false);
|
||
all &= check("invert renders", r.is_ok() && px.iter().all(|p| (p[0] - 0.75).abs() < 1e-6 && p[2].abs() < 1e-6 && p[3] == 1.0), (&r, &px));
|
||
|
||
// Output on stderr that isn't text, in long lines, is drained, so the helper never blocks on it
|
||
// or loses its stderr.
|
||
let mut px = vec![[0.0; 4]; 4];
|
||
let r = render("org.test.helper.noisy", &mut px, 2, 2, 1.0, false);
|
||
all &= check("a helper's stderr is drained whatever it holds", r.is_ok() && px[0] == [1.0, 0.0, 0.0, 1.0], &r);
|
||
|
||
// Padding.
|
||
let mut px = vec![[0.0; 4]; 4];
|
||
let r = render("org.test.helper.pad", &mut px, 2, 2, 1.0, false);
|
||
all &= check("padding grows the frame", r.is_ok() && px.len() == 64 && px[0][0] == 3.0, (&r, px.len()));
|
||
|
||
// Callbacks: the input at another time and parameters at another time.
|
||
let mut px = vec![[0.0; 4]; 4];
|
||
let r = render("org.test.helper.echo", &mut px, 2, 2, 1.0, false);
|
||
all &= check("input_at / params_at round-trip", r.is_ok() && (px[1][0] - 0.9).abs() < 1e-6 && px[0][3] == 0.25, (&r, &px));
|
||
|
||
// Nested: answering input_at renders another effect of the same helper.
|
||
let mut px = vec![[0.0; 4]; 4];
|
||
let r = render("org.test.helper.echo", &mut px, 2, 2, 1.0, true);
|
||
all &= check("nested renders through one helper", r.is_ok() && (px[1][0] - 0.1).abs() < 1e-5, (&r, &px));
|
||
|
||
// Several threads at once share the helper.
|
||
let threads: Vec<_> = (0..4)
|
||
.map(|i| {
|
||
std::thread::spawn(move || {
|
||
let mut px = vec![[0.0, 0.0, 0.0, 1.0]; 16];
|
||
render("org.test.helper.invert", &mut px, 4, 4, 0.5, false).map(|_| (i, px[0][0]))
|
||
})
|
||
})
|
||
.collect();
|
||
let results: Vec<_> = threads.into_iter().map(|t| t.join().map_err(|_| "thread panicked".to_string()).and_then(|r| r)).collect();
|
||
all &= check("concurrent renders", results.iter().all(|r| matches!(r, Ok((_, v)) if (*v - 0.5).abs() < 1e-6)), &results);
|
||
|
||
// A crash mid-render is an error, the frame is untouched, and the helper comes back.
|
||
let mut px = vec![[0.5; 4]; 4];
|
||
let r = render("org.test.helper.crash", &mut px, 2, 2, 1.0, false);
|
||
all &= check("a crashing helper is an error", r.as_ref().is_err_and(|e| e.contains("helper")) && px.iter().all(|p| *p == [0.5; 4]), &r);
|
||
let mut px = vec![[0.0, 0.0, 0.0, 1.0]; 4];
|
||
let r = render("org.test.helper.invert", &mut px, 2, 2, 1.0, false);
|
||
all &= check("the helper restarts for the next render", r.is_ok() && px[0][0] == 1.0, &r);
|
||
// A render that starts the stopped helper itself (no padding request first) succeeds.
|
||
effectcraft_helper_host::stop_all();
|
||
let r = plugin("org.test.helper.invert").ok_or("invert is not registered").and_then(|p| {
|
||
let m = p.manifest().clone();
|
||
let mut px = vec![[0.0, 0.0, 0.0, 1.0]; 4];
|
||
let mut f = PluginFrame { width: 2, height: 2, pixels: &mut px, scale: 1.0, origin: [0.0; 2], layer_size: [2.0; 2] };
|
||
let host = TestHost { w: 2, h: 2, nest: false };
|
||
p.render_v2(&mut f, &PluginParams { manifest: &m, flat: &[1.0], texts: &[] }, 1.0, &host).map_err(|_| "render failed")?;
|
||
Ok(px[0][0])
|
||
});
|
||
all &= check("a stopped helper restarts for a render without padding", r == Ok(1.0), r);
|
||
|
||
// Effects that keep crashing it are turned off (and the list survives in the state dir).
|
||
for _ in 0..3 {
|
||
let _ = render("org.test.helper.crash", &mut vec![[0.0; 4]; 4], 2, 2, 1.0, false);
|
||
}
|
||
let r = render("org.test.helper.crash", &mut vec![[0.0; 4]; 4], 2, 2, 1.0, false);
|
||
all &= check("repeat offenders are blocked", r.as_ref().is_err_and(|e| e.contains("turned off")), &r);
|
||
all &= check("the block list is saved", std::fs::read_to_string(state.join("helper_blocklist.txt")).is_ok_and(|t| t.contains("org.test.helper.crash")), "");
|
||
let r = effectcraft_helper_host::scan_paths(&[], true);
|
||
all &= check("retry clears the block list", !effectcraft_helper_host::blocklist::is_blocked("org.test.helper.crash"), &r);
|
||
|
||
// A hang is cut off by the timeout, also while EffectCraft is writing to a helper that
|
||
// stopped reading.
|
||
let t0 = std::time::Instant::now();
|
||
let r = render("org.test.helper.hang", &mut vec![[0.0; 4]; 4], 2, 2, 1.0, false);
|
||
all &= check(
|
||
"a hanging helper times out",
|
||
r.as_ref().is_err_and(|e| e.contains("did not answer")) && t0.elapsed() < Duration::from_secs(30),
|
||
(&r, t0.elapsed()),
|
||
);
|
||
|
||
// The restarts above use up the helper's restarts for this minute, so it can't start again.
|
||
// That is not the fault of the effect being rendered: it fails, but is not charged and never
|
||
// turned off (it would be after CRASH_LIMIT charged failures).
|
||
let starts: Vec<_> = (0..4).map(|_| render("org.test.helper.invert", &mut vec![[0.0; 4]; 4], 2, 2, 1.0, false)).collect();
|
||
all &= check(
|
||
"a helper that can't start doesn't block the effect",
|
||
starts.iter().all(|r| r.as_ref().is_err_and(|e| e.contains("keeps stopping")))
|
||
&& !effectcraft_helper_host::blocklist::is_blocked("org.test.helper.invert"),
|
||
&starts,
|
||
);
|
||
|
||
effectcraft_helper_host::stop_all();
|
||
let _ = std::fs::remove_dir_all(&state);
|
||
all
|
||
}
|
||
|
||
/// The one test this binary reports to test runners.
|
||
const TEST_NAME: &str = "helper_protocol";
|
||
|
||
fn main() {
|
||
let args: Vec<String> = std::env::args().collect();
|
||
if args.iter().any(|a| a == HELPER_ARG) {
|
||
if let Err(e) = serve(&mut FakeProvider) {
|
||
eprintln!("fake helper: {e}");
|
||
std::process::exit(1);
|
||
}
|
||
return;
|
||
}
|
||
// The libtest listing protocol cargo-nextest uses (`--list --format terse`, with and without
|
||
// `--ignored`): one `name: test` line, then nextest runs `<binary> helper_protocol --exact`.
|
||
if args.iter().any(|a| a == "--list") {
|
||
if !args.iter().any(|a| a == "--ignored") {
|
||
println!("{TEST_NAME}: test");
|
||
}
|
||
return;
|
||
}
|
||
// Keep the hang check short.
|
||
effectcraft_helper_host::client::set_render_timeout(Duration::from_secs(3));
|
||
if !run_checks() {
|
||
std::process::exit(1);
|
||
}
|
||
}
|