Render on your server
@sealpixel/render renders an edit log on the original. It uses the same WebAssembly core as the browser, so the bytes match a browser export. The contract is small: the original's bytes and the edit log go in, and the rendered file comes out.
The package does no routing, no CORS, no storage and no retries. It maps no HTTP status codes. Put it inside your own route, function or job worker.
import { createRenderer } from '@sealpixel/render';
import { defaultPlugins } from '@sealpixel/plugins';
// Once per process: builds the plugin registry. The first render loads the core.
const renderer = createRenderer({
plugins: defaultPlugins, // the same list the editor registers
limits: { maxMegapixels: 80, maxBytes: 100e6, maxLogBytes: 2e6 },
});
const { log, index } = saved; // what the editor's getEditLog() returned
const result = await renderer.render({
original, // Uint8Array: the original, byte for byte
editLog: log,
upTo: index,
output: { format: 'image/jpeg', quality: 0.9 },
});
// result: { bytes, stream(), hash, width, height, mimeType, timings }@sealpixel/render. The dashed zones are your infrastructure: where the inputs come from and where the file goes. The numbers are the renderer’s steps, in order.Edit in the browser, render on your server shows where the log and the original come from. Example: your own infrastructure sketches one complete setup around it.
createRenderer(options)
| Option | Default | What it does |
|---|---|---|
plugins | required | The plugins that interpret the log. Register the same list as the editor. |
pluginOptions | none | { pluginId: partialOptions }, applied with configurePlugins. Use the same map as the editor, so that the renderer enforces the same constraints. An id that is not in plugins throws INVALID_REQUEST here. |
assets | none | [{ id, bytes }]: fonts and images that every render can use, for example the plugins' fonts. See Assets. |
limits.maxBytes | 100e6 | The largest original, in bytes. |
limits.maxMegapixels | 80 | The largest original, in megapixels, read from the file header. |
limits.maxLogBytes | 2e6 | The largest edit log, in UTF-8 bytes of its canonical JSON. |
verifySourceHash | true | Compare the sha256 of the original with log.source.hash, when the log has one. |
wasm | from node_modules | { module }: a path, the bytes or a compiled WebAssembly.Module of sealpixel_core_bg.wasm. A bundle needs it, see Runtimes. |
Create the renderer once and keep it. A warm process then reuses the registry, the loaded core and the sha256 of the assets you passed here.
renderer.render(input)
| Field | Default | What it is |
|---|---|---|
original | required | A Uint8Array with the file the editor loaded, byte for byte. A scaled or re-encoded copy has another hash and fails. |
editLog | required | log from getEditLog(). The renderer treats it as untrusted and parses it. |
upTo | every op | index from getEditLog(). The render uses ops[0..upTo). Without it, a step that the user took back reaches the render. |
output | { format: 'image/png' } | The format and the encoder options, the same as for a browser export. |
assets | none | [{ id, bytes }]: fonts and images of this log only, for example a sticker that the user uploaded. An id here wins over the same id in createRenderer. |
embedEditLog | false | Write { log, index } into the file's metadata, as download({ embedEditLog: true }) does. |
manifest | false | Also return the render manifest, for Verify a render. |
signal | none | An AbortSignal. The render stops between steps with ABORTED. |
The result:
| Field | What it is |
|---|---|
bytes | The encoded file, a Uint8Array. |
stream() | A ReadableStream<Uint8Array> over bytes, for a Response or a stream API. The file is complete before the stream starts, so the stream saves no memory. |
hash | The sha256 of bytes, computed in the core. A browser export of the same log at the same index has the same hash. |
width, height | The size of the render. |
mimeType | output.format. |
timings | Milliseconds for check, decode, render, encode and total. |
manifest | The render manifest, when manifest: true was passed. See Verify a render. |
The checks
render() runs these steps in this order. The first step that fails throws, and nothing after it runs.
- The size of the original against
limits.maxBytes. - The width and the height from the file header against
limits.maxMegapixels. No pixel is decoded yet. - The size of the edit log against
limits.maxLogBytes. - The parse of the log: its schema, its plugins, and every op against the plugin options.
- The sha256 of the original against
log.source.hash, when the log has one andverifySourceHashis on. - The decode, once. The decoded size must equal
log.source. The decoded size includes the EXIF orientation, so a portrait photo stored as landscape passes. The render reuses this decode. - The final state against the plugin options, for example a locked watermark that must be present.
- The render and the encode. Each font or image that the render uses must be in
assets, with the sha256 of its declaration. The output carries the ICC profile of the original and nothing else of its metadata. Metadata has the table.
Errors
Every failure is a SealpixelError from @sealpixel/core. Its name is the code. Map the codes to your own responses.
| Code | Cause |
|---|---|
TOO_LARGE | The original, its pixel count or the log is above a limit. The core also throws it for a plan whose work or output size is above its own limits (Security). |
UNSUPPORTED_FORMAT | The original is not a PNG, a JPEG or a WebP. |
DECODE_FAILED | The original has a valid header but does not decode. |
INVALID_REQUEST | editLog is missing, upTo is not an integer from 0 to the number of ops, or output.format is unknown. |
INVALID_EDIT_LOG | The log does not match the schema. |
UNKNOWN_PLUGIN | The log uses an op of a plugin that is not in plugins. |
OPTION_VIOLATION | An op, or the final state, breaks a plugin option. details lists the violations. |
SOURCE_MISMATCH | The hash or the size of the original is not the one in the log. Or the bytes of an asset do not have the sha256 of its declaration: then details.assetId names the asset. |
MISSING_ASSET | The render needs a font or an image that is not in assets. details.assetId names it. |
ABORTED | The signal was aborted. |
Assets
Text, stickers and watermarks need fonts and images. The ops name them by id. The renderer loads no asset itself: it fetches no URL and reads no file. Pass the bytes of every asset that a log can use:
- to
createRenderer, for the assets that every render uses, such as the fonts of the plugins, - to
render(), for the assets of one log, such as an uploaded sticker.
assetDeclarations(plugins, editLog?) lists the assets that a render can need: [{ id, sha256, url? }]. It reads the declarations of the plugins and the entries of log.assets, and it does no I/O. url is where the declaration says the bytes are. Use it to find the bytes, or ignore it.
import { assetDeclarations, createRenderer } from '@sealpixel/render';
import { defaultPlugins } from '@sealpixel/plugins';
assetDeclarations(defaultPlugins);
// [{ id: 'font.roboto', sha256: '56a4…', url: 'file:///…/Roboto-Regular.ttf' }]
const renderer = createRenderer({
plugins: defaultPlugins,
assets: [{ id: 'font.roboto', bytes: fontBytes }],
});
await renderer.render({
original,
editLog: log,
upTo: index,
assets: [{ id: 'asset.3f2a9c1d0b7e4a51', bytes: uploadedStickerBytes }],
});When a declaration carries a sha256, the renderer compares it with the sha256 of the bytes. A plugin declaration wins over an entry in log.assets with the same id. An asset that the render needs and that is not in assets is MISSING_ASSET. The renderer does not look for it anywhere else.
Every render starts with an empty asset table in the core. A render therefore uses only the bytes passed for it, even when an earlier render used the same id with other bytes. The renders of one process share one core instance, so they take turns in it: one render at a time decodes, renders and encodes (Memory).