mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-12 21:32:52 +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>
208 lines
7.7 KiB
Rust
208 lines
7.7 KiB
Rust
//! The helper protocol, version [`PROTOCOL_VERSION`] (`docs/external-helpers.md`).
|
||
//!
|
||
//! Control messages are JSON, one per line. EffectCraft writes requests ([`Request`]) to the
|
||
//! helper's stdin; the helper writes replies ([`Reply`]) to its stdout, each line starting with
|
||
//! [`MAGIC`] so that anything else a helper (or a library it loads) prints is ignored. Pixels
|
||
//! never travel over the pipe: they live in the **frame exchange file** named in
|
||
//! [`Request::Hello`] and are addressed by [`ImageRef`]s (byte offset + size).
|
||
|
||
use effectcraft_effects::plugin::PluginManifest;
|
||
use serde::{Deserialize, Serialize};
|
||
|
||
/// The protocol version this build speaks. A helper that answers `hello` with another version
|
||
/// is not used.
|
||
pub const PROTOCOL_VERSION: u32 = 1;
|
||
|
||
/// Every reply line from a helper starts with this (anything before it on the line, and lines
|
||
/// without it, are the helper's own output and only logged).
|
||
pub const MAGIC: &str = "@ec-helper ";
|
||
|
||
/// The argument EffectCraft starts helpers with.
|
||
pub const HELPER_ARG: &str = "--effectcraft-helper";
|
||
|
||
/// The longest control line either side accepts (bytes). Hidden parameters can carry a few MB.
|
||
pub const MAX_LINE: u64 = 256 << 20;
|
||
|
||
/// The most pixels one image may have (16384 × 16384).
|
||
pub const MAX_PIXELS: u64 = 1 << 28;
|
||
|
||
/// Bytes per pixel in the exchange file: premultiplied RGBA `f32`, native byte order.
|
||
pub const PIXEL_BYTES: u64 = 16;
|
||
|
||
/// An image in the frame exchange file: `width * height` premultiplied RGBA `f32` pixels, row 0
|
||
/// on top, starting `offset` bytes into the file. `origin` and `scale` are as in
|
||
/// [`effectcraft_effects::plugin::PluginImage`].
|
||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||
pub struct ImageRef {
|
||
pub offset: u64,
|
||
pub width: u32,
|
||
pub height: u32,
|
||
#[serde(default)]
|
||
pub origin: [f64; 2],
|
||
#[serde(default = "one")]
|
||
pub scale: f64,
|
||
}
|
||
|
||
fn one() -> f64 {
|
||
1.0
|
||
}
|
||
|
||
impl ImageRef {
|
||
/// Pixels in the image, `None` when it is larger than [`MAX_PIXELS`].
|
||
pub fn pixels(&self) -> Option<usize> {
|
||
let n = u64::from(self.width).checked_mul(u64::from(self.height))?;
|
||
(n <= MAX_PIXELS).then_some(n).and_then(|n| usize::try_from(n).ok())
|
||
}
|
||
/// Bytes the image takes in the exchange file.
|
||
pub fn bytes(&self) -> Option<u64> {
|
||
u64::try_from(self.pixels()?).ok()?.checked_mul(PIXEL_BYTES)
|
||
}
|
||
}
|
||
|
||
/// One render of one effect instance.
|
||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||
pub struct RenderRequest {
|
||
/// The effect id (as described by the helper).
|
||
pub effect: String,
|
||
/// A stable id of the applied effect instance ([`effectcraft_effects::plugin::PluginHost::instance_key`]).
|
||
pub instance: u64,
|
||
/// Layer time, seconds.
|
||
pub time: f64,
|
||
/// The buffer to process in place (already padded); the result is written back to it.
|
||
pub frame: ImageRef,
|
||
/// The layer's size in layer pixels.
|
||
pub layer_size: [f64; 2],
|
||
/// Flattened parameter values (plug-in API 2 rules).
|
||
pub flat: Vec<f64>,
|
||
/// Text / hidden parameter strings.
|
||
#[serde(default)]
|
||
pub texts: Vec<String>,
|
||
pub frame_rate: f64,
|
||
/// The layer's source range in layer-time seconds, when it has one.
|
||
#[serde(default)]
|
||
pub frame_range: Option<(f64, f64)>,
|
||
}
|
||
|
||
/// EffectCraft → helper.
|
||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||
#[serde(tag = "op", rename_all = "snake_case")]
|
||
pub enum Request {
|
||
/// First message: the protocol version and the frame exchange file to open (read + write).
|
||
Hello { version: u32, frames: String },
|
||
/// List the effects the helper offers → [`Reply::Effects`].
|
||
Describe,
|
||
/// Load more effects from a file or folder the user picked → [`Reply::Effects`] (only the
|
||
/// new ones).
|
||
Load { path: String },
|
||
/// Transparent pixels to add around the buffer before [`Request::Render`] → [`Reply::Padding`].
|
||
Padding { effect: String, instance: u64, time: f64, scale: f64, layer_size: [f64; 2], flat: Vec<f64>, texts: Vec<String> },
|
||
/// Render → callbacks ([`Reply::InputAt`], ...) answered with [`Request::Image`] /
|
||
/// [`Request::Params`], then [`Reply::Done`] or [`Reply::Error`].
|
||
Render(RenderRequest),
|
||
/// The answer to [`Reply::InputAt`] / [`Reply::LayerInput`]: the image, or `None`.
|
||
Image { image: Option<ImageRef> },
|
||
/// The answer to [`Reply::ParamsAt`]: flattened values and texts, or `None`.
|
||
Params { values: Option<(Vec<f64>, Vec<String>)> },
|
||
/// The effect instance is gone; the helper may free what it keeps for it. No reply.
|
||
Destroy { instance: u64 },
|
||
/// Heartbeat → [`Reply::Pong`].
|
||
Ping,
|
||
/// Exit. No reply. (Closing stdin means the same.)
|
||
Shutdown,
|
||
}
|
||
|
||
/// One effect a helper offers.
|
||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||
pub struct RemoteEffect {
|
||
/// The plug-in API 2 manifest (id, name, category, parameters).
|
||
pub manifest: PluginManifest,
|
||
/// Whether to ask [`Request::Padding`] before every render (otherwise no padding).
|
||
#[serde(default)]
|
||
pub padding: bool,
|
||
}
|
||
|
||
/// Helper → EffectCraft.
|
||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||
#[serde(tag = "op", rename_all = "snake_case")]
|
||
pub enum Reply {
|
||
/// Answer to [`Request::Hello`]. `loads` lists the file extensions [`Request::Load`] accepts
|
||
/// (`["ofx"]`), empty when the helper loads nothing on request.
|
||
Hello {
|
||
version: u32,
|
||
name: String,
|
||
#[serde(default)]
|
||
helper_version: String,
|
||
#[serde(default)]
|
||
loads: Vec<String>,
|
||
},
|
||
Effects {
|
||
effects: Vec<RemoteEffect>,
|
||
#[serde(default)]
|
||
errors: Vec<String>,
|
||
},
|
||
Padding {
|
||
pixels: u32,
|
||
},
|
||
/// During a render: the effect's input at another layer time.
|
||
InputAt {
|
||
time: f64,
|
||
},
|
||
/// During a render: the layer chosen in a layer parameter, at a layer time.
|
||
LayerInput {
|
||
param: String,
|
||
time: f64,
|
||
},
|
||
/// During a render: the parameter values at another layer time.
|
||
ParamsAt {
|
||
time: f64,
|
||
},
|
||
/// The render finished; the result is in the request's frame.
|
||
Done,
|
||
/// The request failed (the helper keeps running).
|
||
Error {
|
||
message: String,
|
||
},
|
||
Pong,
|
||
}
|
||
|
||
/// A reply as one protocol line (with [`MAGIC`] and the newline).
|
||
pub fn reply_line(r: &Reply) -> Result<String, String> {
|
||
serde_json::to_string(r).map(|s| format!("{MAGIC}{s}\n")).map_err(|e| e.to_string())
|
||
}
|
||
|
||
/// A request as one protocol line (with the newline).
|
||
pub fn request_line(r: &Request) -> Result<String, String> {
|
||
serde_json::to_string(r).map(|s| format!("{s}\n")).map_err(|e| e.to_string())
|
||
}
|
||
|
||
/// Parse a helper output line: `Some` for a protocol line (after [`MAGIC`]), `None` for other
|
||
/// output.
|
||
pub fn parse_reply(line: &str) -> Option<Result<Reply, String>> {
|
||
let at = line.find(MAGIC)?;
|
||
let json = line.get(at + MAGIC.len()..)?.trim_end();
|
||
Some(serde_json::from_str(json).map_err(|e| format!("bad reply from helper: {e}")))
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn replies_round_trip_and_other_output_is_ignored() {
|
||
let r = Reply::InputAt { time: 1.5 };
|
||
let line = reply_line(&r).unwrap();
|
||
assert_eq!(parse_reply(&line).unwrap().unwrap(), r);
|
||
// A plug-in that printed without a newline doesn't break the protocol line.
|
||
assert_eq!(parse_reply(&format!("loading...{line}")).unwrap().unwrap(), r);
|
||
assert!(parse_reply("plug-in chatter").is_none());
|
||
assert!(parse_reply("@ec-helper {nope").unwrap().is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn image_sizes_are_checked() {
|
||
let r = ImageRef { offset: 0, width: 4, height: 2, origin: [0.0; 2], scale: 1.0 };
|
||
assert_eq!(r.bytes(), Some(128));
|
||
assert_eq!(ImageRef { width: u32::MAX, height: u32::MAX, ..r }.pixels(), None);
|
||
}
|
||
}
|