Files
effectcraft/crates/effects/src/plugin.rs
T
611a282bea Plug-ins: external effect helpers (safe, out-of-process native effects) + plug-in API 2 (#889)
* 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>
2026-11-05 11:11:57 +01:00

694 lines
28 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The effect plug-in API, version [`PLUGIN_API_VERSION`].
//!
//! A plug-in is anything implementing [`EffectPlugin`]: a [`PluginManifest`] (stable id,
//! display name, Effects & Presets category, typed parameters) and a `render` that processes a
//! [`PluginFrame`] in place. [`register_plugin`] turns it into an ordinary [`EffectSpec`]:
//! instances are property groups like every built-in effect, so plug-in parameters animate,
//! take expressions, show in Effect Controls and are addressable by agents, and the effect is
//! applied by id (`effect.apply {"effect": "org.example.posterize"}`) like a built-in.
//!
//! Plug-ins come from Rust code (implement the trait and register it at start-up) or from
//! sandboxed WebAssembly modules (`effectcraft-plugin`, see `docs/plugins.md`), which implement
//! the same contract through a small C-like ABI.
//!
//! The contract (stable within an API version):
//! * pixels are premultiplied RGBA `f32` (0–1, may exceed 1 in 32 bpc projects), row-major,
//! row 0 on top, in the layer's buffer, which may be scaled (preview resolution) and padded;
//! * parameters are flattened to `f64`s in declaration order: slider / angle / checkbox (0 or 1)
//! / popup (0-based index) take one value, point two (**frame pixels**, already mapped to the
//! buffer), colour four (RGBA 0–1); API 2 adds point3 (x, y mapped like a point, z as is),
//! layer (one value: the layer id, -1 for none), and text / hidden strings, which take no
//! slot and are read with [`PluginParams::text`];
//! * `time` is layer time in seconds; `scale` is the buffer's pixels per layer pixel (scale
//! pixel distances like blur radii by it);
//! * rendering must be deterministic (same inputs, same output) and may run on several threads
//! at once.
//!
//! API 2 (native plug-ins; the WebAssembly ABI stays at 1) adds [`EffectPlugin::padding`] and
//! [`EffectPlugin::render_v2`], which gets a [`PluginHost`] to read the effect's input or other
//! layers at other times, and the extra parameter kinds. External effect helpers
//! (`effectcraft-helper-host`) are built on it.
use std::sync::{Arc, OnceLock, RwLock};
use effectcraft_keyframe::Value;
use effectcraft_project::ParamUi;
use serde::{Deserialize, Serialize};
use crate::{Buf, EffectCtx, EffectSpec, ParamSpec, Params, RenderFn};
/// The plug-in API version this build implements (manifests with API 1 or 2 are accepted).
pub const PLUGIN_API_VERSION: u32 = 2;
/// The plug-in API a WebAssembly module implements (its `ec_api_version`). API 2 is for native
/// and helper plug-ins; WebAssembly modules stay at 1.
pub const WASM_API_VERSION: u32 = 1;
/// The most plug-in effects one process can register (commercial effect suites register a few hundred each).
pub const MAX_PLUGINS: usize = 1024;
/// The most transparent pixels a plug-in may ask [`EffectPlugin::padding`] to add per side.
const MAX_PADDING: u32 = 4096;
/// A plug-in parameter's kind, default and range.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "lowercase")]
pub enum PluginParamKind {
Slider {
default: f64,
#[serde(default)]
min: f64,
#[serde(default = "hundred")]
max: f64,
/// Slider range (defaults to min..max).
#[serde(default, rename = "sliderMin")]
slider_min: Option<f64>,
#[serde(default, rename = "sliderMax")]
slider_max: Option<f64>,
#[serde(default = "two")]
decimals: u8,
/// Whole numbers (API 2; integer parameters): shown with 0 decimals.
#[serde(default)]
integer: bool,
},
Angle {
#[serde(default)]
default: f64,
},
Checkbox {
#[serde(default)]
default: bool,
},
Popup {
options: Vec<String>,
/// 0-based index.
#[serde(default)]
default: u32,
},
/// Default as fractions of the layer size (`[0.5, 0.5]` = centre).
Point {
#[serde(default = "centre")]
default: [f64; 2],
},
/// RGBA, 0–1.
Color {
#[serde(default = "white")]
default: [f64; 4],
},
/// API 2: a 3D point (x, y in frame pixels like [`PluginParamKind::Point`], z as is). The
/// default is in layer pixels (not fractions).
Point3 {
#[serde(default)]
default: [f64; 3],
},
/// API 2: another layer of the composition (flattens to its id, -1 for none).
Layer,
/// API 2: a string, shown as a text field; read with [`PluginParams::text`].
Text {
#[serde(default)]
default: String,
},
/// API 2: a string that is not shown (opaque plug-in data); read with [`PluginParams::text`].
Hidden {
#[serde(default)]
default: String,
},
}
fn hundred() -> f64 {
100.0
}
fn two() -> u8 {
2
}
fn centre() -> [f64; 2] {
[0.5, 0.5]
}
fn white() -> [f64; 4] {
[1.0; 4]
}
impl PluginParamKind {
/// How many `f64`s the parameter flattens to.
pub fn width(&self) -> usize {
match self {
PluginParamKind::Point { .. } => 2,
PluginParamKind::Point3 { .. } => 3,
PluginParamKind::Color { .. } => 4,
PluginParamKind::Text { .. } | PluginParamKind::Hidden { .. } => 0,
_ => 1,
}
}
/// Whether the parameter holds a string (it flattens to no `f64`s but one entry of
/// [`PluginParams::texts`]).
pub fn is_text(&self) -> bool {
matches!(self, PluginParamKind::Text { .. } | PluginParamKind::Hidden { .. })
}
/// Whether the kind needs plug-in API 2 (WebAssembly plug-ins can't carry it).
pub fn needs_v2(&self) -> bool {
matches!(self, PluginParamKind::Point3 { .. } | PluginParamKind::Layer | PluginParamKind::Text { .. } | PluginParamKind::Hidden { .. })
}
}
/// One parameter of a plug-in effect.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct PluginParam {
/// Stable id (the property's match name; letters, digits, `_`).
pub id: String,
pub name: String,
/// API 2: the group / page path the parameter belongs to (`Advanced/Edges`). Hosts may
/// ignore it.
#[serde(default)]
pub group: String,
#[serde(flatten)]
pub kind: PluginParamKind,
}
/// What a plug-in declares about itself.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct PluginManifest {
/// The plug-in API version it was written for (1, or 2 up to [`PLUGIN_API_VERSION`]).
pub api: u32,
/// Stable, reverse-domain effect id (`org.example.posterize`); `ec.` is reserved.
pub id: String,
pub name: String,
/// Effects & Presets category: one of [`crate::CATEGORIES`] or a new one.
pub category: String,
#[serde(default)]
pub version: String,
#[serde(default)]
pub author: String,
#[serde(default)]
pub description: String,
#[serde(default)]
pub params: Vec<PluginParam>,
}
impl PluginManifest {
/// Check the manifest against this API version.
pub fn validate(&self) -> Result<(), String> {
// API 2 effects from large third-party suites can declare far more parameters.
let max_params: usize = if self.api >= 2 { 1024 } else { 256 };
// Opaque plug-in data can be large (some effects keep look-up tables of a few MB).
const MAX_TEXT: usize = 64 << 20;
if !(1..=PLUGIN_API_VERSION).contains(&self.api) {
return Err(format!("plug-in `{}` targets plug-in API {} but this build implements API 1 to {PLUGIN_API_VERSION}", self.id, self.api));
}
let ok_id = |s: &str| !s.is_empty() && s.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'));
if !ok_id(&self.id) || self.id.starts_with("ec.") {
return Err(format!("bad plug-in id `{}` (reverse-domain, letters, digits, `.`, `_`, `-`; `ec.` is reserved)", self.id));
}
if self.name.trim().is_empty() || self.category.trim().is_empty() {
return Err(format!("plug-in `{}` needs a name and a category", self.id));
}
if self.params.len() > max_params {
return Err(format!("plug-in `{}` has {} parameters (at most {max_params})", self.id, self.params.len()));
}
let mut seen = std::collections::HashSet::new();
for p in &self.params {
if p.id.is_empty() || !p.id.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') || !seen.insert(p.id.as_str()) {
return Err(format!("plug-in `{}`: bad or duplicate parameter id `{}`", self.id, p.id));
}
if let PluginParamKind::Popup { options, default } = &p.kind
&& (options.is_empty() || *default as usize >= options.len())
{
return Err(format!("plug-in `{}`: popup `{}` needs options and a default index in range", self.id, p.id));
}
if let PluginParamKind::Point3 { default } = &p.kind
&& default.iter().any(|v| !v.is_finite())
{
return Err(format!("plug-in `{}`: point3 `{}` needs a finite default", self.id, p.id));
}
if let PluginParamKind::Text { default } | PluginParamKind::Hidden { default } = &p.kind
&& default.len() > MAX_TEXT
{
return Err(format!("plug-in `{}`: the default of `{}` is longer than {MAX_TEXT} bytes", self.id, p.id));
}
if let PluginParamKind::Slider { default, min, max, slider_min, slider_max, .. } = &p.kind {
let (lo, hi) = (slider_min.unwrap_or(*min), slider_max.unwrap_or(*max));
if [*default, *min, *max, lo, hi].iter().any(|v| !v.is_finite()) || min > max || lo > hi || default < min || default > max {
return Err(format!("plug-in `{}`: slider `{}` needs min ≤ default ≤ max (and sliderMin ≤ sliderMax)", self.id, p.id));
}
}
}
Ok(())
}
/// Total flattened parameter count.
pub fn flat_len(&self) -> usize {
self.params.iter().map(|p| p.kind.width()).sum()
}
}
/// The pixels a plug-in renders, in place.
pub struct PluginFrame<'a> {
pub width: u32,
pub height: u32,
/// Premultiplied RGBA, row-major (row 0 = top), `width * height` pixels.
pub pixels: &'a mut [[f32; 4]],
/// Buffer pixels per layer pixel (preview resolution).
pub scale: f64,
/// API 2: buffer-pixel position of layer pixel (0, 0) ([`Buf::offset`]; padding shifts it).
pub origin: [f64; 2],
/// API 2: the layer's bounds in layer pixels.
pub layer_size: [f64; 2],
}
/// An image the host hands an API 2 plug-in (the input at another time, or another layer).
/// Same layout as [`PluginFrame`]: premultiplied RGBA, row 0 = top, `origin` = buffer pixels of
/// layer pixel (0, 0) in this image.
#[derive(Clone, Debug, Default)]
pub struct PluginImage {
pub width: u32,
pub height: u32,
pub pixels: Vec<[f32; 4]>,
pub origin: [f64; 2],
pub scale: f64,
}
impl PluginImage {
fn from_buf(b: Buf) -> PluginImage {
PluginImage { width: b.img.width, height: b.img.height, pixels: b.img.data, origin: b.offset, scale: b.scale }
}
}
/// Services available during an API 2 render ([`EffectPlugin::render_v2`]).
pub trait PluginHost {
/// This effect's input (its layer with masks and the effects above this one) at another
/// **layer time** in seconds, in its own extent (see [`PluginImage::origin`]; it is not
/// padded). `None` when unavailable (adjustment layers, no host).
fn input_at(&self, layer_time: f64) -> Option<PluginImage>;
/// The layer chosen in Layer parameter `param_id` at **layer time** `layer_time` (converted
/// to composition time), effects and masks applied, resampled into this layer's buffer
/// (same size, origin and scale as the frame, centred when the layers differ in size).
/// `None` when unset or unavailable.
fn layer_input(&self, param_id: &str, layer_time: f64) -> Option<PluginImage>;
/// All flattened parameter values (and texts) at another layer time.
fn params_at(&self, layer_time: f64) -> Option<(Vec<f64>, Vec<String>)>;
/// Composition frame rate (30 when unknown).
fn frame_rate(&self) -> f64;
/// Layer time of the current render, seconds.
fn time(&self) -> f64;
/// A stable id of the applied effect instance: the same on every frame of one effect on one
/// layer, different for every layer and every application of the effect. Plug-ins that keep
/// state between frames pool their native instances by it. 0 when unknown.
fn instance_key(&self) -> u64 {
0
}
/// The layer's source time range in **layer time** seconds as (first, end): the first source
/// frame starts at `first` and the source ends at `end` (see [`crate::EffectEnv::layer_span`]).
/// `None` when the source has no duration or the host does not know it (retimers then
/// assume an unbounded range).
fn frame_range(&self) -> Option<(f64, f64)> {
None
}
}
/// The [`PluginHost::instance_key`] of the effect `ctx` renders: its instance seed (stable per
/// effect instance), offset so it is never 0.
pub fn ctx_instance_key(ctx: &EffectCtx) -> u64 {
(1u64 << 32) | u64::from(ctx.seed)
}
/// Parameter values for one render, flattened (see the module docs) with typed accessors.
pub struct PluginParams<'a> {
pub manifest: &'a PluginManifest,
pub flat: &'a [f64],
/// One string per text / hidden parameter, in declaration order.
pub texts: &'a [String],
}
impl PluginParams<'_> {
fn slot(&self, id: &str) -> Option<&[f64]> {
let mut at = 0;
for p in &self.manifest.params {
let w = p.kind.width();
if p.id == id {
return self.flat.get(at..at + w);
}
at += w;
}
None
}
/// Slider / angle / popup index / checkbox (0 or 1).
pub fn f(&self, id: &str) -> f64 {
self.slot(id).and_then(|s| s.first().copied()).unwrap_or(0.0)
}
pub fn b(&self, id: &str) -> bool {
self.f(id) != 0.0
}
/// Point in frame pixels.
pub fn point(&self, id: &str) -> [f64; 2] {
self.slot(id).and_then(|s| Some([*s.first()?, *s.get(1)?])).unwrap_or([0.0; 2])
}
/// 3D point: x, y in frame pixels, z as is.
pub fn point3(&self, id: &str) -> [f64; 3] {
self.slot(id).and_then(|s| Some([*s.first()?, *s.get(1)?, *s.get(2)?])).unwrap_or([0.0; 3])
}
pub fn color(&self, id: &str) -> [f64; 4] {
self.slot(id).and_then(|s| Some([*s.first()?, *s.get(1)?, *s.get(2)?, *s.get(3)?])).unwrap_or([1.0; 4])
}
/// The layer chosen in a layer parameter.
pub fn layer(&self, id: &str) -> Option<u64> {
self.slot(id).and_then(|s| s.first().copied()).filter(|v| *v >= 0.0).map(|v| v as u64)
}
/// A text / hidden parameter's string (empty for other ids).
pub fn text(&self, id: &str) -> &str {
let mut at = 0;
for p in &self.manifest.params {
if p.kind.is_text() {
if p.id == id {
return self.texts.get(at).map(String::as_str).unwrap_or("");
}
at += 1;
}
}
""
}
}
/// An effect plug-in.
pub trait EffectPlugin: Send + Sync {
fn manifest(&self) -> &PluginManifest;
/// Process `frame` in place. `time` is layer time in seconds. An error leaves the frame as
/// it was (the error is logged once).
fn render(&self, frame: &mut PluginFrame, params: &PluginParams, time: f64) -> Result<(), String>;
/// Where the plug-in came from (a file path, `built-in`…), for listings.
fn source(&self) -> String {
"built-in".into()
}
/// API 2: transparent pixels to add on every side of the buffer before rendering (glows,
/// blurs, regions of definition larger than the input), in buffer pixels. `layer_size` is
/// the layer's size in layer pixels (the frame's [`PluginFrame::layer_size`]; 0 when unknown)
/// and `instance_key` the [`PluginHost::instance_key`] this render will have.
fn padding(&self, _params: &PluginParams, _time: f64, _scale: f64, _layer_size: [f64; 2], _instance_key: u64) -> u32 {
0
}
/// API 2: [`EffectPlugin::render`] with host services. The default calls `render`. May be
/// called from several threads at once.
fn render_v2(&self, frame: &mut PluginFrame, params: &PluginParams, time: f64, _host: &dyn PluginHost) -> Result<(), String> {
self.render(frame, params, time)
}
}
static SLOTS: [OnceLock<Arc<dyn EffectPlugin>>; MAX_PLUGINS] = [const { OnceLock::new() }; MAX_PLUGINS];
/// Registered plug-in effects, in registration order.
fn plugin_specs() -> &'static RwLock<Vec<&'static EffectSpec>> {
static P: OnceLock<RwLock<Vec<&'static EffectSpec>>> = OnceLock::new();
P.get_or_init(|| RwLock::new(vec![]))
}
/// The registered plug-in effects.
pub fn plugins() -> Vec<&'static EffectSpec> {
plugin_specs().read().map(|v| v.clone()).unwrap_or_default()
}
/// The last render error of each plug-in effect (cleared by its next good render), so the UI can
/// show it on the effect.
fn render_errors() -> &'static RwLock<std::collections::HashMap<String, String>> {
static E: OnceLock<RwLock<std::collections::HashMap<String, String>>> = OnceLock::new();
E.get_or_init(Default::default)
}
/// Why the last render of plug-in effect `id` failed, if it did (the frame was left as it was).
pub fn plugin_error(id: &str) -> Option<String> {
render_errors().read().ok()?.get(id).cloned()
}
fn set_plugin_error(id: &str, error: Option<String>) {
let Ok(mut m) = render_errors().write() else { return };
match error {
Some(e) => {
m.insert(id.to_string(), e);
}
None => {
m.remove(id);
}
}
}
/// The plug-in behind a registered effect id.
pub fn plugin(id: &str) -> Option<Arc<dyn EffectPlugin>> {
SLOTS.iter().filter_map(|s| s.get()).find(|p| p.manifest().id == id).cloned()
}
fn leak(s: &str) -> &'static str {
Box::leak(s.to_string().into_boxed_str())
}
fn param_spec(p: &PluginParam) -> ParamSpec {
let (default, ui) = match &p.kind {
PluginParamKind::Slider { default, min, max, slider_min, slider_max, decimals, integer } => (
Value::Scalar(*default),
ParamUi::Slider {
min: *min,
max: *max,
slider_min: slider_min.unwrap_or(*min),
slider_max: slider_max.unwrap_or(*max),
decimals: if *integer { 0 } else { *decimals },
},
),
PluginParamKind::Angle { default } => (Value::Scalar(*default), ParamUi::Angle),
PluginParamKind::Checkbox { default } => (Value::Bool(*default), ParamUi::Checkbox),
PluginParamKind::Popup { options, default } => (Value::Enum(*default), ParamUi::Popup { options: options.clone() }),
PluginParamKind::Point { default } => (Value::Vec2(*default), ParamUi::Point),
PluginParamKind::Color { default } => (Value::Color(*default), ParamUi::Color),
PluginParamKind::Point3 { default } => (Value::Vec3(*default), ParamUi::Point3),
PluginParamKind::Layer => (Value::Layer(None), ParamUi::Layer),
PluginParamKind::Text { default } => (Value::Str(default.clone()), ParamUi::Text),
PluginParamKind::Hidden { default } => (Value::Str(default.clone()), ParamUi::Hidden),
};
ParamSpec { id: leak(&p.id), name: leak(&p.name), default, ui }
}
/// Register a plug-in effect: it appears in Effects & Presets under its category and is
/// applied by id like a built-in. Fails for a bad manifest, a taken id or when
/// [`MAX_PLUGINS`] are registered.
pub fn register_plugin(plugin: Arc<dyn EffectPlugin>) -> Result<&'static EffectSpec, String> {
let m = plugin.manifest().clone();
m.validate()?;
let mut specs = plugin_specs().write().map_err(|_| "plug-in registry poisoned".to_string())?;
if crate::registry().iter().any(|s| s.id == m.id) || specs.iter().any(|s| s.id == m.id) {
return Err(format!("an effect with id `{}` is already registered", m.id));
}
let slot = SLOTS
.iter()
.position(|s| s.get().is_none())
.ok_or_else(|| format!("too many plug-in effects: a session registers at most {MAX_PLUGINS}; remove some plug-ins and restart EffectCraft"))?;
SLOTS[slot].set(plugin).map_err(|_| "plug-in slot taken".to_string())?;
let spec: &'static EffectSpec = Box::leak(Box::new(EffectSpec {
id: leak(&m.id),
name: leak(&m.name),
category: leak(&m.category),
params: m.params.iter().map(param_spec).collect(),
render: TRAMPOLINES[slot],
gpu: false,
float: true,
}));
specs.push(spec);
Ok(spec)
}
/// Each slot needs its own `fn` (effect render functions are plain function pointers).
fn trampoline<const I: usize>(ctx: &EffectCtx, buf: Buf) -> Buf {
run_slot(I, ctx, buf)
}
/// Concatenate `A` runs of `B` render functions (`N` = `A * B`), for the table below.
const fn concat<const A: usize, const B: usize, const N: usize>(parts: [[RenderFn; B]; A]) -> [RenderFn; N] {
let mut out = [trampoline::<0> as RenderFn; N];
let mut i = 0;
while i < N {
out[i] = parts[i / B][i % B];
i += 1;
}
out
}
/// 16 trampolines `base ..= base + 15`.
macro_rules! t16 {
($b:expr) => {
[
trampoline::<{ $b }> as RenderFn,
trampoline::<{ $b + 1 }> as RenderFn,
trampoline::<{ $b + 2 }> as RenderFn,
trampoline::<{ $b + 3 }> as RenderFn,
trampoline::<{ $b + 4 }> as RenderFn,
trampoline::<{ $b + 5 }> as RenderFn,
trampoline::<{ $b + 6 }> as RenderFn,
trampoline::<{ $b + 7 }> as RenderFn,
trampoline::<{ $b + 8 }> as RenderFn,
trampoline::<{ $b + 9 }> as RenderFn,
trampoline::<{ $b + 10 }> as RenderFn,
trampoline::<{ $b + 11 }> as RenderFn,
trampoline::<{ $b + 12 }> as RenderFn,
trampoline::<{ $b + 13 }> as RenderFn,
trampoline::<{ $b + 14 }> as RenderFn,
trampoline::<{ $b + 15 }> as RenderFn,
]
};
}
/// Four runs of `$inner` (each `$n` long, starting `$n` apart) from `$b`: `$total` trampolines.
macro_rules! x4 {
($inner:ident, $b:expr, $n:literal, $total:literal) => {
concat::<4, $n, $total>([$inner!($b), $inner!($b + $n), $inner!($b + 2 * $n), $inner!($b + 3 * $n)])
};
}
macro_rules! t64 {
($b:expr) => {
x4!(t16, $b, 16, 64)
};
}
macro_rules! t256 {
($b:expr) => {
x4!(t64, $b, 64, 256)
};
}
macro_rules! t1024 {
($b:expr) => {
x4!(t256, $b, 256, 1024)
};
}
const TRAMPOLINES: [RenderFn; MAX_PLUGINS] = t1024!(0);
/// Flatten an instance's evaluated parameters for the plug-in (points mapped to frame pixels).
pub fn flatten(m: &PluginManifest, ctx: &EffectCtx, buf: &Buf) -> Vec<f64> {
flatten_params(m, ctx.params, buf.offset, buf.scale)
}
/// [`flatten`] for any evaluated parameters, with points mapped by buffer `offset` and `scale`.
pub fn flatten_params(m: &PluginManifest, params: &Params, offset: [f64; 2], scale: f64) -> Vec<f64> {
let to_px = |q: [f64; 2]| (q[0] * scale + offset[0], q[1] * scale + offset[1]);
let mut out = Vec::with_capacity(m.flat_len());
for p in &m.params {
let v = params.get(&p.id);
match &p.kind {
PluginParamKind::Point { .. } => {
let (x, y) = to_px(v.map(Value::as_vec2).unwrap_or([0.0; 2]));
out.extend([x, y]);
}
PluginParamKind::Point3 { default } => {
let q = v.map(Value::as_vec3).unwrap_or(*default);
let (x, y) = to_px([q[0], q[1]]);
out.extend([x, y, q[2]]);
}
PluginParamKind::Color { .. } => {
let c = v.map(Value::as_color).unwrap_or([1.0; 4]);
out.extend(c.iter().map(|x| *x as f64));
}
PluginParamKind::Checkbox { .. } => out.push(if v.map(Value::as_bool).unwrap_or(false) { 1.0 } else { 0.0 }),
PluginParamKind::Popup { .. } => out.push(v.map(Value::as_enum).unwrap_or(0) as f64),
PluginParamKind::Layer => out.push(v.and_then(Value::as_layer).map(|l| l as f64).unwrap_or(-1.0)),
PluginParamKind::Text { .. } | PluginParamKind::Hidden { .. } => {}
_ => out.push(v.map(Value::as_f64).unwrap_or(0.0)),
}
}
out
}
/// The strings of an instance's text / hidden parameters, in declaration order.
pub fn flatten_texts(m: &PluginManifest, params: &Params) -> Vec<String> {
m.params
.iter()
.filter_map(|p| match &p.kind {
PluginParamKind::Text { default } | PluginParamKind::Hidden { default } => Some(match params.get(&p.id) {
Some(Value::Str(s)) => s.clone(),
_ => default.clone(),
}),
_ => None,
})
.collect()
}
/// The host services of one render, on top of the effect context and the (padded) buffer.
struct RenderHost<'a, 'b> {
ctx: &'a EffectCtx<'b>,
manifest: &'a PluginManifest,
width: u32,
height: u32,
offset: [f64; 2],
scale: f64,
}
impl RenderHost<'_, '_> {
/// The frame's pixel grid without pixels (what [`crate::util::fit_layer`] measures).
fn grid(&self) -> Buf {
Buf { img: crate::Image { width: self.width, height: self.height, data: vec![] }, offset: self.offset, scale: self.scale }
}
}
impl PluginHost for RenderHost<'_, '_> {
fn input_at(&self, layer_time: f64) -> Option<PluginImage> {
let host = self.ctx.env.host?;
host.self_at(layer_time, self.ctx.env.effect_index).map(PluginImage::from_buf)
}
fn layer_input(&self, param_id: &str, layer_time: f64) -> Option<PluginImage> {
let ctx = self.ctx;
let lid = ctx.params.get(param_id)?.as_layer()?;
let other = if (layer_time - ctx.time).abs() < 1e-9 {
ctx.layer_param(param_id, true)?
} else {
let host = ctx.env.host?;
// Source / Masks show no effects; the default (Effects & Masks) does.
let with_effects = !matches!(ctx.params.get(&crate::layer_source_id(param_id)).map(Value::as_enum), Some(0 | 1));
host.layer_at(lid, ctx.env.comp_time + (layer_time - ctx.time), with_effects)?
};
let img = crate::util::fit_layer(ctx, &self.grid(), &other, false);
Some(PluginImage { width: img.width, height: img.height, pixels: img.data, origin: self.offset, scale: self.scale })
}
fn params_at(&self, layer_time: f64) -> Option<(Vec<f64>, Vec<String>)> {
let p = self.ctx.env.host?.params_at(layer_time)?;
Some((flatten_params(self.manifest, &p, self.offset, self.scale), flatten_texts(self.manifest, &p)))
}
fn frame_rate(&self) -> f64 {
self.ctx.fps()
}
fn time(&self) -> f64 {
self.ctx.time
}
fn instance_key(&self) -> u64 {
ctx_instance_key(self.ctx)
}
fn frame_range(&self) -> Option<(f64, f64)> {
self.ctx.env.layer_span
}
}
fn run_slot(slot: usize, ctx: &EffectCtx, mut buf: Buf) -> Buf {
let Some(plugin) = SLOTS[slot].get() else { return buf };
let m = plugin.manifest();
let texts = flatten_texts(m, ctx.params);
let flat = flatten(m, ctx, &buf);
let key = ctx_instance_key(ctx);
let pad = plugin.padding(&PluginParams { manifest: m, flat: &flat, texts: &texts }, ctx.time, buf.scale, ctx.layer_size, key).min(MAX_PADDING);
let before = buf.clone();
// Padding moves the layer origin, which points are measured from.
let flat = if pad > 0 {
buf.pad(pad);
flatten(m, ctx, &buf)
} else {
flat
};
let params = PluginParams { manifest: m, flat: &flat, texts: &texts };
let (width, height, offset, scale) = (buf.img.width, buf.img.height, buf.offset, buf.scale);
let host = RenderHost { ctx, manifest: m, width, height, offset, scale };
let mut frame = PluginFrame { width, height, pixels: &mut buf.img.data, scale, origin: offset, layer_size: ctx.layer_size };
if let Err(e) = plugin.render_v2(&mut frame, &params, ctx.time, &host) {
log::warn!("plug-in `{}`: {e}", m.id);
set_plugin_error(&m.id, Some(e));
return before;
}
if plugin_error(&m.id).is_some() {
set_plugin_error(&m.id, None);
}
buf
}