# 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": ""}`, `"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`](https://github.com/wasmi-labs/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 ```json { "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: ```json {"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`](../examples/plugins/invert-rs) is a complete `no_std` plug-in (no allocator, under 1 KiB built). Build it with: ```sh 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.