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 <[email protected]>
9.5 KiB
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 fromA-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_, notid/layer. The host validates the user's values before calling: missing values take the default, numbers are clamped tomin..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 byoverlapon 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 byoverlap;"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 alwaysf32).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.