mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-14 06:12:13 +00: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>
259 lines
9.9 KiB
Rust
259 lines
9.9 KiB
Rust
//! # effectcraft-helper-host (L3)
|
|
//!
|
|
//! **External effect helpers**: separate programs that offer effects to EffectCraft over a small,
|
|
//! versioned protocol ([`protocol`], `docs/external-helpers.md`, ADR
|
|
//! `docs/adr/0001-external-effect-helpers.md`). EffectCraft finds installed helpers
|
|
//! ([`find_helpers`]), starts each as a child process, lists its effects and registers every one
|
|
//! as an ordinary plug-in effect (plug-in API 2), so the UI, the property tree, keyframes,
|
|
//! scripting and the command surface need nothing new. Rendering sends the frame to the helper
|
|
//! through a shared exchange file ([`frames`]) and answers its questions (the input at another
|
|
//! time, another layer, parameters at another time) while it works.
|
|
//!
|
|
//! The point is containment: whatever native code a helper runs (an OpenFX plug-in host, for
|
|
//! example) runs in **its** process. If it crashes, hangs or talks nonsense, EffectCraft stops
|
|
//! it, shows the error on the effect, leaves the frame as it was and starts the helper again
|
|
//! for the next render; an effect that keeps taking its helper down is turned off
|
|
//! ([`blocklist`]). This crate is safe Rust (`unsafe_code = "forbid"`, like the rest of the
|
|
//! workspace), loads no native code and opens no network connection.
|
|
//!
|
|
//! [`serve`] is the helper side, for helper programs written in Rust.
|
|
//!
|
|
//! Native only: on wasm32 the crate is empty (there are no processes to start).
|
|
|
|
#![cfg(not(target_arch = "wasm32"))]
|
|
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
|
|
|
pub mod blocklist;
|
|
pub mod client;
|
|
pub mod frames;
|
|
pub mod protocol;
|
|
pub mod serve;
|
|
|
|
use std::path::{Path, PathBuf};
|
|
use std::sync::{Arc, Mutex, OnceLock};
|
|
|
|
use effectcraft_effects::plugin::{plugin, register_plugin};
|
|
use serde_json::{Value, json};
|
|
|
|
use crate::client::{Helper, HelperEffect, lock};
|
|
use crate::protocol::RemoteEffect;
|
|
|
|
/// Helper program names start with this (`effectcraft-helper-ofx`, plus `.exe` on Windows).
|
|
pub const PROGRAM_PREFIX: &str = "effectcraft-helper-";
|
|
|
|
/// The running helpers.
|
|
fn helpers() -> &'static Mutex<Vec<Arc<Helper>>> {
|
|
static H: OnceLock<Mutex<Vec<Arc<Helper>>>> = OnceLock::new();
|
|
H.get_or_init(Default::default)
|
|
}
|
|
|
|
/// Where helpers are looked for: `helpers/` next to the executable, `helpers/` in the settings
|
|
/// folder (`config_dir`), and every entry of `EFFECTCRAFT_HELPER_PATH` (a path list of helper
|
|
/// programs or folders).
|
|
pub fn default_search_paths(config_dir: Option<&Path>) -> Vec<PathBuf> {
|
|
let mut v = vec![];
|
|
if let Some(dir) = std::env::current_exe().ok().and_then(|e| e.parent().map(Path::to_path_buf)) {
|
|
v.push(dir.join("helpers"));
|
|
// macOS app bundles: Contents/MacOS/effectcraft → Contents/Helpers.
|
|
if cfg!(target_os = "macos")
|
|
&& let Some(contents) = dir.parent()
|
|
{
|
|
v.push(contents.join("Helpers"));
|
|
}
|
|
}
|
|
if let Some(c) = config_dir {
|
|
v.push(c.join("helpers"));
|
|
}
|
|
if let Some(p) = std::env::var_os("EFFECTCRAFT_HELPER_PATH") {
|
|
v.extend(std::env::split_paths(&p).filter(|p| !p.as_os_str().is_empty()));
|
|
}
|
|
v
|
|
}
|
|
|
|
/// Whether a file name is a helper program's.
|
|
pub fn is_helper_name(name: &str) -> bool {
|
|
let stem = if cfg!(windows) {
|
|
match name.len().checked_sub(4).and_then(|i| name.get(i..).map(|ext| (i, ext))) {
|
|
Some((i, ext)) if ext.eq_ignore_ascii_case(".exe") => name.get(..i).unwrap_or(""),
|
|
_ => return false,
|
|
}
|
|
} else {
|
|
name
|
|
};
|
|
stem.len() > PROGRAM_PREFIX.len() && stem.starts_with(PROGRAM_PREFIX) && !stem.contains('.')
|
|
}
|
|
|
|
/// The helper programs in `paths`: files named `effectcraft-helper-*` in each folder and its
|
|
/// direct subfolders, and paths that are themselves such files (any name is accepted for an
|
|
/// explicit file). Sorted, without duplicates.
|
|
pub fn find_helpers(paths: &[PathBuf]) -> Vec<PathBuf> {
|
|
fn named(p: &Path) -> bool {
|
|
p.is_file() && p.file_name().and_then(|n| n.to_str()).is_some_and(is_helper_name)
|
|
}
|
|
let mut out = vec![];
|
|
for p in paths {
|
|
if p.is_file() {
|
|
out.push(p.clone());
|
|
continue;
|
|
}
|
|
let Ok(rd) = std::fs::read_dir(p) else { continue };
|
|
for e in rd.flatten() {
|
|
let path = e.path();
|
|
if named(&path) {
|
|
out.push(path);
|
|
} else if path.is_dir()
|
|
&& let Ok(sub) = std::fs::read_dir(&path)
|
|
{
|
|
out.extend(sub.flatten().map(|e| e.path()).filter(|p| named(p)));
|
|
}
|
|
}
|
|
}
|
|
out.sort();
|
|
out.dedup();
|
|
out
|
|
}
|
|
|
|
/// Register a helper's effects. Effects already registered (an earlier scan) are kept as they
|
|
/// are; blocked effects are reported, not registered.
|
|
fn register(helper: &Arc<Helper>, effects: Vec<RemoteEffect>, errors: &mut Vec<String>) -> Vec<Value> {
|
|
let mut loaded = vec![];
|
|
let mut added = 0;
|
|
for e in effects {
|
|
let id = e.manifest.id.clone();
|
|
if blocklist::is_blocked(&id) {
|
|
errors.push(format!("{id}: turned off because it kept stopping its helper (scan with retry to try again)"));
|
|
continue;
|
|
}
|
|
if plugin(&id).is_some() {
|
|
continue;
|
|
}
|
|
let (name, category) = (e.manifest.name.clone(), e.manifest.category.clone());
|
|
match register_plugin(Arc::new(HelperEffect::new(helper.clone(), e))) {
|
|
Ok(_) => {
|
|
added += 1;
|
|
loaded.push(json!({"id": id, "name": name, "category": category, "helper": helper.program().to_string_lossy()}));
|
|
}
|
|
Err(err) => errors.push(format!("{id}: {err}")),
|
|
}
|
|
}
|
|
helper.add_effects(added);
|
|
loaded
|
|
}
|
|
|
|
/// Start every helper found in `paths` that isn't running yet and register its effects.
|
|
/// `retry` forgets the block list first. Returns `{paths, helpers, loaded, errors}`.
|
|
pub fn scan_paths(paths: &[PathBuf], retry: bool) -> Value {
|
|
if retry {
|
|
blocklist::clear();
|
|
}
|
|
let (mut started, mut loaded, mut errors) = (vec![], vec![], vec![]);
|
|
for program in find_helpers(paths) {
|
|
if lock(helpers()).iter().any(|h| h.program() == program) {
|
|
continue;
|
|
}
|
|
match Helper::start(&program) {
|
|
Ok((h, effects, errs)) => {
|
|
errors.extend(errs.into_iter().map(|e| format!("{}: {e}", program.display())));
|
|
loaded.extend(register(&h, effects, &mut errors));
|
|
started.push(json!(program.to_string_lossy()));
|
|
lock(helpers()).push(h);
|
|
}
|
|
Err(e) => errors.push(e),
|
|
}
|
|
}
|
|
json!({
|
|
"paths": paths.iter().map(|p| p.to_string_lossy()).collect::<Vec<_>>(),
|
|
"helpers": started,
|
|
"loaded": loaded,
|
|
"errors": errors,
|
|
})
|
|
}
|
|
|
|
/// Load a file or folder through the helpers that accept its extension (a folder goes to every
|
|
/// helper that loads anything). Returns `{loaded, errors}`.
|
|
pub fn load_path(path: &str) -> Result<Value, String> {
|
|
let p = Path::new(path);
|
|
let ext = p.extension().and_then(|e| e.to_str()).unwrap_or("").to_string();
|
|
// `.ofx.bundle` folders and the like: match on any dotted part of the name.
|
|
let parts: Vec<String> = p.file_name().and_then(|n| n.to_str()).map(|n| n.split('.').skip(1).map(str::to_string).collect()).unwrap_or_default();
|
|
let list: Vec<Arc<Helper>> = lock(helpers()).clone();
|
|
let takers: Vec<Arc<Helper>> = list
|
|
.into_iter()
|
|
.filter(|h| {
|
|
let s = h.status();
|
|
if p.is_dir() && parts.is_empty() { !s.loads.is_empty() } else { h.loads(&ext) || parts.iter().any(|x| h.loads(x)) }
|
|
})
|
|
.collect();
|
|
if takers.is_empty() {
|
|
return Err(format!("no effect helper loads `{path}` (installed helpers: Effect ▸ Scan for Effect Helpers)"));
|
|
}
|
|
let (mut loaded, mut errors) = (vec![], vec![]);
|
|
for h in takers {
|
|
match h.load(path) {
|
|
Ok((effects, errs)) => {
|
|
errors.extend(errs);
|
|
loaded.extend(register(&h, effects, &mut errors));
|
|
}
|
|
Err(e) => errors.push(e),
|
|
}
|
|
}
|
|
if loaded.is_empty() && !errors.is_empty() {
|
|
return Err(errors.join("; "));
|
|
}
|
|
Ok(json!({"loaded": loaded, "errors": errors}))
|
|
}
|
|
|
|
/// The helpers and their state: `{helpers: [{program, name, version, loads, running, effects,
|
|
/// restarts, error}], blocked}`.
|
|
pub fn list() -> Value {
|
|
let helpers: Vec<Value> = lock(helpers())
|
|
.iter()
|
|
.map(|h| {
|
|
let s = h.status();
|
|
json!({
|
|
"program": s.program.to_string_lossy(),
|
|
"name": s.name,
|
|
"version": s.version,
|
|
"loads": s.loads,
|
|
"running": s.running,
|
|
"effects": s.effects,
|
|
"restarts": s.restarts,
|
|
"error": s.last_error,
|
|
})
|
|
})
|
|
.collect();
|
|
json!({"helpers": helpers, "blocked": blocklist::blocked()})
|
|
}
|
|
|
|
/// Stop every helper process (they start again when needed). Called when the app quits;
|
|
/// helpers also exit on their own when EffectCraft's end of the pipe closes.
|
|
pub fn stop_all() {
|
|
for h in lock(helpers()).iter() {
|
|
h.stop();
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn helper_programs_are_recognised_by_name() {
|
|
if cfg!(windows) {
|
|
assert!(is_helper_name("effectcraft-helper-ofx.exe"));
|
|
assert!(!is_helper_name("effectcraft-helper-ofx"));
|
|
} else {
|
|
assert!(is_helper_name("effectcraft-helper-ofx"));
|
|
assert!(!is_helper_name("effectcraft-helper-ofx.pdb"));
|
|
}
|
|
assert!(!is_helper_name("effectcraft-helper-"));
|
|
assert!(!is_helper_name("effectcraft"));
|
|
}
|
|
|
|
#[test]
|
|
fn missing_folders_find_nothing_and_loading_without_helpers_is_an_error() {
|
|
assert!(find_helpers(&[PathBuf::from("/no/such/folder")]).is_empty());
|
|
assert!(load_path("/no/such/thing.xyz").is_err());
|
|
}
|
|
}
|