Files
photocraft/docs/plugins.md
T
Brandon ThomasandClaude Opus 5.5 7a5c1930b3 Sandboxed WebAssembly plug-ins: photocraft-plugins (wasmi), plugin.* commands, Filter › Plug-ins (#67)
* photocraft-plugins: sandboxed WebAssembly filter plug-ins (wasmi host, ABI v1)

A new L4 crate runs plug-ins in wasmi (pure Rust, MIT/Apache) with no host
imports, a fuel budget metered in slices with a wall-clock deadline, a memory
cap, a recursion cap and wasmi's strict module limits. Every band gets a fresh
instance; traps, budgets, bad manifests and bad pointers are all Errors.
Pixels go over as interleaved normalised f32 in tile-aligned bands, in
parallel on native, blended by the selection. Includes the example Invert
plug-in (no_std Rust, examples/plugins/invert-rs) built into a test fixture,
hostile-module tests, and docs/plugins.md.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Engine: plugin.* commands (install, list, run, remove, reload)

plugin.run filters the active pixel layer, a targeted channel or a smart
object (recorded as a smart filter) as one undo step; Preferences >
Plug-ins gains Use Additional Plug-ins Folder, loaded when prefs apply.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* UI: Filter > Plug-ins lists installed plug-ins with generated dialogs

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Plug-ins: bands never outgrow the memory cap; regenerate parity

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Bench: example plug-in Invert vs built-in Invert on 24 MP

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-04 16:08:28 -04:00

9.2 KiB
Raw 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). 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). 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.