Skip to content

Writing a plugin

sh
pnpm create @sealpixel/plugin my-plugin --id acme.thing            # JavaScript kernel
pnpm create @sealpixel/plugin my-plugin --id acme.thing --kernel wasm-rust

A plugin is one object built with definePlugin from @sealpixel/plugin-sdk:

ts
import { defineOperation, definePlugin } from '@sealpixel/plugin-sdk';
import { z } from 'zod';

interface Doc { amount: number }
interface SetOp { type: 'acme.thing.set'; id: string; at?: number; amount: number }

declare module '@sealpixel/core' {
  interface OperationRegistry { 'acme.thing.set': SetOp }
  interface DocumentRegistry { 'acme.thing': Doc }
}

const setOp = defineOperation<Doc, SetOp>({
  type: 'acme.thing.set',
  schema: z.object({ type: z.literal('acme.thing.set'), id: z.string(), at: z.number().optional(), amount: z.number().min(0).max(1) }).strict(),
  reduce: (_slice, op) => ({ amount: op.amount }),
});

export const plugin = definePlugin({
  id: 'acme.thing',
  version: 1,
  operations: [setOp],
  initialDocument: { amount: 0 },
  stage: { name: 'acme.thing', order: 150 },
  toPlan: (doc) => (doc.amount === 0 ? null : { amount: doc.amount }),
  kernel: { kind: 'js', run: (image, params) => darken(image, params) },
  tool: () => import('./tool.js').then((m) => m.tool),
});

Kernels

  • JavaScript runs between core segments on the RGBA buffer, in the worker and on the server. Integer math only, or Math.fround-disciplined floats. @sealpixel/plugin-sdk/math has deterministic helpers.
  • WebAssembly implements a tiny ABI (sealpixel_alloc, sealpixel_free, sealpixel_run, sealpixel_kernel_abi) in any language. In Rust, depend on the sealpixel-kernel crate and write one function; sealpixel_kernel!(my_fn) exports the ABI. Kernels transform the buffer in place and do not change dimensions in ABI v1.

Tools

A tool is data with template functions: panel(ctx) renders controls into the bar under the stage, overlay(ctx) draws SVG gizmos in image coordinates, ctx.hint({ filter }) drives the cheap live preview while dragging, ctx.dispatch(op) commits. Give the row that chooses what to work with class="choice" and the bar puts it on the bottom row, under the settings for that choice.

Testing

@sealpixel/plugin-testing pins render hashes in a goldens file (checkPluginParity) and fuzzes the reducer and renderer (fuzzPlugin). The scaffold wires both. Register the plugin on the client and on the server with the same list.

Publishing

Add the sealpixel-plugin npm keyword and a peerDependencies range on @sealpixel/core. Bump version whenever the op schema or kernel output changes and supply migrate.

Options

Give your plugin configurable options with the same contract the first-party plugins use:

ts
import { definePlugin } from '@sealpixel/plugin-sdk';
import { z } from 'zod';

const OptionsSchema = z.object({ maxRadius: z.number().positive(), shapes: z.array(z.enum(['circle', 'square'])).min(1) }).strict();

export const spotlightPlugin = definePlugin({
  id: 'acme.spotlight',
  // …operations, stage, toPlan, kernel…
  options: {
    schema: OptionsSchema,
    defaults: { maxRadius: 0.5, shapes: ['circle', 'square'] },
    describe: { maxRadius: 'Largest spotlight radius as a fraction of the image.', shapes: 'Shapes the tool offers.' },
  },
  // Reject an op that breaks a constraint; runs in parseEditLog, dispatch and on the server.
  validate: (op, { options }) =>
    op.type === 'acme.spotlight.set' && op.radius > options.maxRadius ? `radius exceeds ${options.maxRadius}` : null,
  // Optional: check the final state before export or a server render.
  validateDocument: (slice, { options }) => null,
  // Optional: ops that seed an empty log from defaults.
  initialOps: ({ options }) => [],
});

// Hosts: spotlightPlugin.configure({ maxRadius: 0.3 }) or pluginOptions: { 'acme.spotlight': { maxRadius: 0.3 } }

toPlan(slice, ctx) receives the resolved options as ctx.options; a UI tool reads them with ctx.options<MyOptions>('acme.spotlight'). Keep pixel-affecting data in the edit log or in assets, never in options, so the server renders the same bytes without knowing them. The docs generator lists your options when the plugin is registered in the docs build.