mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-14 19:26:51 -04:00
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]>
177 lines
9.5 KiB
Markdown
177 lines
9.5 KiB
Markdown
# 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`](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.
|