Files
midasdfandClaude Opus 5.5 40f14d25e8 Plug-ins: keep the originating error across parallel bands (#1654)
When one band failed, its siblings stopped with a generic "another band failed"
error, which could win the collection order and hide the real cause. The abort
flag now carries the first error, so every band reports it; tests cover traps,
error codes and the instruction budget across parallel bands.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:11:00 -04:00

9.5 KiB
Raw Permalink Blame History

Plug-ins: the WebAssembly plug-in API (ABI v1)

PhotoCraft does not host native Photoshop plug-ins (.8BF, CEP, UXP): that needs unsafe FFI into arbitrary machine code and cannot work in the browser build. Instead, plug-ins are WebAssembly modules run in a sandbox. One .wasm file works on macOS, Windows, Linux and the web.

This page is the contract for plug-in authors and the reference for the host (crates/plugins, the plugin.* commands in crates/engine/src/plugin_cmds.rs).

Using plug-ins

Command Params What it does
plugin.install {"path": "/x/y.wasm"} (native) or {"data": "<base64>"}, "replace": bool = true Validates and installs a module; returns its description.
plugin.list {} {"plugins": [{id, name, version, kind, author, description, params, paramsSchema, overlap, area, size, source}], "folder": report}
plugin.run {"id": "…", "params": {…}} (plug-in params may also be top-level keys) Runs a filter plug-in on the active pixel layer, targeted alpha channel / Quick Mask, or smart object. One undo step.
plugin.remove {"id": "…"} Uninstalls.
plugin.reload {"path": "folder"} (default: the preference folder) Loads every *.wasm in a folder; a bad module is reported and skipped.

In the app, installed filters appear under Filter › Plug-ins (with a generated dialog and live preview when they have parameters), next to Install Plug-in….

Preference folder. Edit › Preferences › Plug-ins › Use Additional Plug-ins Folder plus Additional Plug-ins Folder: when both are set, every *.wasm in that folder (not recursive) is installed at startup and whenever the preference changes (native builds only; the web build installs from bytes).

Plug-ins are installed per process, not per document. A smart filter recorded from a plug-in ({"command": "plugin.run", "params": {"id", "params"}}) re-runs on re-render while that plug-in is installed; if it is missing, the filter is skipped (the cached pixels stay as they were), like other filters PhotoCraft does not implement.

The sandbox

The host is wasmi, a pure-Rust WebAssembly interpreter (MIT OR Apache-2.0). It builds for wasm32-unknown-unknown too, so plug-ins also run in the web build. Limits (photocraft_plugins::Limits, defaults):

Limit Default On violation
Host imports none: no WASI, file system, network, clock or randomness module rejected at install
Module size 32 MiB rejected
Linear memory 512 MiB per instance memory.grow returns -1; oversized initial memory fails
Instructions (fuel) 50 M per call + 4,000 per sample for pc_filter Limit("instruction budget")
Wall clock 60 s per run (native; checked every 20 M instructions) Limit("time budget")
Call depth 1,024 frames trap
Module shape wasmi's strict limits (≤ 10,000 functions, 1 memory, …) rejected

Every band runs in a fresh instance, so no state survives between calls, and parallel bands never share memory. Traps, exhausted budgets, malformed modules, bad manifests, out-of-range pointers and non-zero return codes all become errors; the document is left untouched (the edit is transactional). When a failure stops sibling bands, those bands retain the originating error instead of replacing it with a generic cancellation message. Output samples that are NaN or infinite keep their original value; integer-depth documents and alpha are clamped to 0–1.

wasmi is built with its portable (loop) dispatcher: the default tail-call dispatcher relies on LLVM sibling-call optimisation, and when that doesn't happen a long-running module overflows the host stack, which aborts the process instead of returning an error.

ABI v1

A plug-in is a core WebAssembly module (MVP + the usual post-MVP features: bulk memory, sign extension, multi-value, saturating conversions; no SIMD, no memory64) with no imports and these exports. All integers are i32 unless noted; pointers are offsets into the module's memory.

Export Signature Meaning
memory memory The module's linear memory.
pc_abi_version () -> i32 Must return 1.
pc_manifest () -> i64 (len << 32) | ptr of the UTF-8 manifest JSON (≤ 64 KiB).
pc_alloc (size) -> ptr A block of size bytes the host may write, or 0 on failure. Never freed: each band gets a fresh instance.
pc_filter (buf, buf_len, width, height, channels, format, params, params_len) -> i32 Filters the pixels in place; 0 = success, anything else is an error code.

Manifest

{
  "id": "org.example.tone",
  "name": "Tone…",
  "version": "1.0.0",
  "kind": "filter",
  "author": "Example",
  "description": "What it does.",
  "params": {
    "amount": {"type": "number", "min": 0, "max": 100, "default": 50},
    "steps":  {"type": "int", "min": 1, "max": 16, "default": 4},
    "mono":   {"type": "bool", "default": false},
    "mode":   {"type": "choice", "options": ["soft", "hard"], "default": "soft"}
  },
  "overlap": 0,
  "area": "content"
}
  • id: 1–64 characters from A-Z a-z 0-9 . _ - (reverse-DNS recommended). Installing a module with the same id replaces the old one.
  • name: the menu label (1–64 printable characters). Any script works, but control characters and Unicode format characters (bidi overrides and marks, zero-width spaces, soft hyphens) are rejected, except the zero-width joiner and non-joiner, and at least one character must be visible. kind: "filter" (the only kind in v1).
  • params: in dialog order. Names are [A-Za-z0-9_], not starting with _, not id/layer. The host validates the user's values before calling: missing values take the default, numbers are clamped to min..max (ints rounded), a wrong type or an unknown choice is an error.
  • overlap (0–256): pixels of context the filter needs around each output pixel (a blur radius). The host pads each band by overlap on every side, repeating the edge pixels of the canvas (or of off-canvas layer content), and keeps only the inner rectangle of the result.
  • area: "content" (default) filters the layer's non-transparent bounds grown by overlap; "canvas" filters the whole canvas, including transparent pixels (for generators).

Pixels

buf holds width × height × channels little-endian f32 samples, interleaved, row-major, normalised to 0–1 at every bit depth (32-bit documents may hold values above 1). The buffer is width × height including the overlap margins. Channels are the document's own colour model, with no conversion:

format = depth | alpha << 8 | mode << 16

  • depth (bits 0–7): the document's bit depth, 8, 16 or 32 (informational: samples are always f32).
  • alpha (bit 8): 1 when the last channel is (straight, not premultiplied) alpha.
  • mode (bits 16–23): the colour mode, numbered as in the PSD file format: 0 Bitmap, 1 Grayscale, 2 Indexed (stored as RGB), 3 RGB, 4 CMYK, 7 Multichannel, 8 Duotone (stored as gray), 9 Lab.

Alpha channels and Quick Mask targets arrive as one grayscale channel.

The host converts the layer into bands (tile-aligned blocks of at most ~4 MiB of f32), runs them in parallel on native builds, writes the results back in the layer's format, then blends with the selection: out = original + (filtered − original) × coverage, exactly as built-in filters do. A plug-in never sees the selection and needs nothing special to support it.

Parameters

params points at params_len bytes of UTF-8 JSON: the validated parameters, plus _image with the band's geometry:

{"amount": 50, "mode": "soft",
 "_image": {"x": 0, "y": 256, "width": 1024, "height": 256, "overlap": 0,
            "canvasWidth": 6000, "canvasHeight": 4000,
            "mode": "rgb", "depth": 8, "alpha": true, "channels": 4}}

x, y is the document position of the buffer's top-left sample (including the margins), so position-dependent filters (gradients, vignettes) are seamless across bands.

Writing a plug-in in Rust

examples/plugins/invert-rs is a complete no_std plug-in (no allocator, under 1 KiB built). Build it with:

rustup target add wasm32-unknown-unknown
examples/plugins/invert-rs/build.sh      # cargo build --release --target wasm32-unknown-unknown

The script also refreshes crates/plugins/tests/fixtures/invert.wasm, the test fixture built from that source. Any language that targets core WebAssembly works (C, Zig, AssemblyScript, WAT…), as long as the module has no imports: in Rust, that means no_std or a std build that never touches WASI.

Performance

The interpreter costs roughly 10–100× native speed per instruction, so plug-ins suit per-pixel and small-neighbourhood filters. Because bands run in parallel, the example Invert on a 6000 × 4000 RGBA 8-bit layer (24 MP, release, 14-core Apple Silicon under heavy load) took 1.1–1.6 s through plugin.run, versus 3.6–5.1 s for the (single-threaded) built-in Image › Adjustments › Invert (cargo run --release -p photocraft-engine --example bench_plugin_invert; plug-in alone: cargo run --release -p photocraft-plugins --example bench_invert). Peak extra memory is about three bands per worker thread (input, original for the selection blend, and the instance's memory), i.e. ~12 MiB per thread.

Not in v1

  • Panel / UI plug-ins, plug-ins that read other layers or documents, host callbacks (progress, cancel), plug-in-provided file formats.
  • Filter › Last Filter does not repeat plug-in runs yet.