mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-13 13:52:16 +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>
122 lines
4.7 KiB
Rust
122 lines
4.7 KiB
Rust
//! The frame exchange file: pixels shared between EffectCraft and a helper without going
|
||
//! through the control pipe. EffectCraft creates it (in `/dev/shm` when there is one, so it
|
||
//! stays in memory, else the temp folder) and both sides read and write it at the offsets the
|
||
//! messages name. Plain file I/O, no `unsafe`: the OS page cache makes it shared memory in
|
||
//! practice, and a helper is free to map it.
|
||
|
||
use std::fs::{File, OpenOptions};
|
||
use std::io::{Read, Seek, SeekFrom, Write};
|
||
use std::path::{Path, PathBuf};
|
||
use std::sync::atomic::{AtomicU64, Ordering};
|
||
|
||
use crate::protocol::ImageRef;
|
||
|
||
/// An open frame exchange file.
|
||
pub struct Exchange {
|
||
file: File,
|
||
path: PathBuf,
|
||
/// Remove the file when dropped (the side that created it).
|
||
owned: bool,
|
||
}
|
||
|
||
impl Exchange {
|
||
/// Create a new, empty exchange file (EffectCraft's side).
|
||
pub fn create() -> std::io::Result<Exchange> {
|
||
static N: AtomicU64 = AtomicU64::new(0);
|
||
let shm = Path::new("/dev/shm");
|
||
let dir = if cfg!(target_os = "linux") && shm.is_dir() { shm.to_path_buf() } else { std::env::temp_dir() };
|
||
let nanos = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.subsec_nanos()).unwrap_or(0);
|
||
let path = dir.join(format!("effectcraft-helper-{}-{}-{nanos}.frames", std::process::id(), N.fetch_add(1, Ordering::Relaxed)));
|
||
let mut opts = OpenOptions::new();
|
||
opts.read(true).write(true).create_new(true);
|
||
// Frames are the user's pixels and /dev/shm is shared: only this user may read them
|
||
// (the helper runs as the same user).
|
||
#[cfg(unix)]
|
||
std::os::unix::fs::OpenOptionsExt::mode(&mut opts, 0o600);
|
||
let file = opts.open(&path)?;
|
||
Ok(Exchange { file, path, owned: true })
|
||
}
|
||
|
||
/// Open an exchange file EffectCraft created (the helper's side).
|
||
pub fn open(path: &Path) -> std::io::Result<Exchange> {
|
||
let file = OpenOptions::new().read(true).write(true).open(path)?;
|
||
Ok(Exchange { file, path: path.to_path_buf(), owned: false })
|
||
}
|
||
|
||
pub fn path(&self) -> &Path {
|
||
&self.path
|
||
}
|
||
|
||
/// Remove the file's name once both sides have it open (Unix keeps the open file usable),
|
||
/// so nothing is left behind if either side dies.
|
||
pub fn unlink(&mut self) {
|
||
if cfg!(unix) && self.owned && std::fs::remove_file(&self.path).is_ok() {
|
||
self.owned = false;
|
||
}
|
||
}
|
||
|
||
/// Write `pixels` as the image `at` describes.
|
||
pub fn write(&mut self, at: &ImageRef, pixels: &[[f32; 4]]) -> Result<(), String> {
|
||
if at.pixels() != Some(pixels.len()) {
|
||
return Err(format!("image is {}×{} but has {} pixels", at.width, at.height, pixels.len()));
|
||
}
|
||
self.file.seek(SeekFrom::Start(at.offset)).map_err(io)?;
|
||
self.file.write_all(bytemuck::cast_slice(pixels)).map_err(io)
|
||
}
|
||
|
||
/// Read the image `at` describes into `out` (which must have its size).
|
||
pub fn read_into(&mut self, at: &ImageRef, out: &mut [[f32; 4]]) -> Result<(), String> {
|
||
if at.pixels() != Some(out.len()) {
|
||
return Err(format!("image is {}×{} but the buffer has {} pixels", at.width, at.height, out.len()));
|
||
}
|
||
self.file.seek(SeekFrom::Start(at.offset)).map_err(io)?;
|
||
self.file.read_exact(bytemuck::cast_slice_mut(out)).map_err(io)
|
||
}
|
||
|
||
/// Read the image `at` describes.
|
||
pub fn read(&mut self, at: &ImageRef) -> Result<Vec<[f32; 4]>, String> {
|
||
let n = at.pixels().ok_or_else(|| format!("image {}×{} is too large", at.width, at.height))?;
|
||
let mut out = vec![[0.0f32; 4]; n];
|
||
self.read_into(at, &mut out)?;
|
||
Ok(out)
|
||
}
|
||
}
|
||
|
||
fn io(e: std::io::Error) -> String {
|
||
format!("frame exchange: {e}")
|
||
}
|
||
|
||
impl Drop for Exchange {
|
||
fn drop(&mut self) {
|
||
if self.owned {
|
||
let _ = std::fs::remove_file(&self.path);
|
||
}
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn both_sides_see_the_same_pixels() {
|
||
let mut a = Exchange::create().unwrap();
|
||
let mut b = Exchange::open(a.path()).unwrap();
|
||
// Owner-only: frames are the user's pixels and /dev/shm is shared.
|
||
#[cfg(unix)]
|
||
{
|
||
use std::os::unix::fs::PermissionsExt;
|
||
assert_eq!(std::fs::metadata(a.path()).unwrap().permissions().mode() & 0o777, 0o600);
|
||
}
|
||
let at = ImageRef { offset: 64, width: 2, height: 1, origin: [0.0; 2], scale: 1.0 };
|
||
a.write(&at, &[[0.1, 0.2, 0.3, 0.4], [1.0, 2.0, 3.0, 4.0]]).unwrap();
|
||
assert_eq!(b.read(&at).unwrap()[1], [1.0, 2.0, 3.0, 4.0]);
|
||
assert!(b.write(&at, &[[0.0; 4]]).is_err(), "size mismatch is an error");
|
||
let p = a.path().to_path_buf();
|
||
a.unlink();
|
||
drop(a);
|
||
drop(b);
|
||
assert!(!p.exists());
|
||
}
|
||
}
|