Example: your own infrastructure
This page sketches one possible setup on AWS: the originals live in S3, the editor saves the edit log, and a Lambda renders the original when a job arrives. It is an example of code you write yourself. Sealpixel's part is the single renderer.render() call in the middle. Everything around it, the queue, the buckets, the permissions and the retries, is yours to shape.
The demo endpoint in the repository, apps/demo/infra, runs the same pattern behind a Function URL instead of a queue.
renderer.render() call. The dashed zone is your infrastructure. Nothing in it is required: a job worker, a container or a plain HTTP route can take the Lambda’s place.The job
The editor hands out the log and the position in it. Your API stores them and puts a job on the queue. The job only names things. It carries no image bytes:
{
"originalKey": "originals/2026/IMG_0042.jpg",
"renderKey": "renders/2026/IMG_0042.jpg",
"log": { "...": "what getEditLog() returned as log" },
"index": 3
}An SQS message holds at most 256 KB. A log with many operations or uploaded assets can be larger. Store the log in your database and send its id instead.
The handler
A sketch of the Lambda, triggered by the queue:
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { GetObjectCommand, PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
import { DEFAULT_FONT, defaultPlugins } from '@sealpixel/plugins';
import { createRenderer } from '@sealpixel/render';
import type { SQSEvent } from 'aws-lambda';
const s3 = new S3Client({});
const bucket = process.env.BUCKET!;
const beside = (file: string) => fileURLToPath(new URL(`./${file}`, import.meta.url));
// Once per container: a warm Lambda reuses the registry, the core and the font.
const renderer = createRenderer({
plugins: defaultPlugins, // the same list the editor registers
assets: [{ id: DEFAULT_FONT.id, bytes: new Uint8Array(readFileSync(beside('Roboto-Regular.ttf'))) }],
limits: { maxMegapixels: 60, maxBytes: 60e6 },
wasm: { module: beside('sealpixel_core_bg.wasm') },
});
interface RenderJob {
originalKey: string;
renderKey: string;
log: unknown;
index: number;
}
export async function handler(event: SQSEvent): Promise<void> {
for (const record of event.Records) {
const job = JSON.parse(record.body) as RenderJob;
const object = await s3.send(new GetObjectCommand({ Bucket: bucket, Key: job.originalKey }));
const original = await object.Body!.transformToByteArray();
const result = await renderer.render({
original,
editLog: job.log,
upTo: job.index,
output: { format: 'image/jpeg', quality: 0.9 },
signal: AbortSignal.timeout(45_000),
});
await s3.send(
new PutObjectCommand({
Bucket: bucket,
Key: job.renderKey,
Body: result.bytes,
ContentType: result.mimeType,
Metadata: { 'sealpixel-hash': result.hash },
}),
);
}
}render() throws a SealpixelError, and its code tells you what to do (Errors):
SOURCE_MISMATCH,INVALID_EDIT_LOG,OPTION_VIOLATIONandUNKNOWN_PLUGINdo not change on a retry. Send the job to a dead-letter queue.TOO_LARGEmeans the original is above your limits. Raise the limits and the memory together, or refuse the job.ABORTEDfrom the signal and failures of your own code, such as an S3 error, are worth a retry.
Deploying it
| Topic | What to do |
|---|---|
| Bundle | Bundle the handler with esbuild for Node 24. Put sealpixel_core_bg.wasm and the fonts your logs use next to the bundle, for example with copyFiles in SST. Runtimes has the details. |
| Memory | estimateMemoryMB(width, height) gives the peak for one render. For 48 MP originals, 3008 MB leaves room (Memory). |
| Timeout | Set the function timeout above the signal you pass to render(). |
| Concurrency | One container renders one image at a time. Scale with Lambda concurrency, and cap it to cap your cost. |
| Cold start | Render a small image at module scope, so the first job does not pay for compiling and optimising the core. The demo's src/warm-up.ts does this. |
| Architecture | The bundle is WebAssembly and data only, so arm64 and x86_64 render the same bytes. arm64 was faster and cheaper for the demo. |
| Permissions | The function needs s3:GetObject on originals/* and s3:PutObject on renders/*. The browser needs no access to the bucket. |
| Repeated jobs | The same original and log always give the same bytes and hash. If a job arrives twice, overwriting renderKey is harmless. The stored hash lets you skip the second render if you want to. |