NC Picto · Docs

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

Picto 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 Picto 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 (picto_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},
    "shift":  {"type": "number", "min": 0, "max": 40, "default": 4, "unit": "px"},
    "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"
}

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

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 picto-engine --example bench_plugin_invert; plug-in alone: cargo run --release -p picto-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