Skip to content

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.

An example setup: editor, API, queue, Lambda, S3 and CDNThe editor in the browser saves the log and its index through your API. Your API puts a job with the original key, the render key, the log and the index on an SQS queue. The queue invokes your Lambda. The Lambda reads the original from originals/ in S3, calls renderer.render, and writes the rendered file to renders/. A CDN serves the rendered file, and your app shows it.BROWSERYOUR INFRASTRUCTURE · AWSEditorSealpixelAPIstoresQueueSQSYour LambdaNode 24 · arm64renderer.render()@sealpixel/renderbytes, hash, size1save23invoke{ originalKey, renderKey, log, index }4read theoriginal5write therenderS3 bucketoriginals/ renders/CDNrenders/…6serve7the app shows it
The tinted parts are Sealpixel: the editor and the 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:

json
{
  "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:

ts
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_VIOLATION and UNKNOWN_PLUGIN do not change on a retry. Send the job to a dead-letter queue.
  • TOO_LARGE means the original is above your limits. Raise the limits and the memory together, or refuse the job.
  • ABORTED from the signal and failures of your own code, such as an S3 error, are worth a retry.

Deploying it ​

TopicWhat to do
BundleBundle 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.
MemoryestimateMemoryMB(width, height) gives the peak for one render. For 48 MP originals, 3008 MB leaves room (Memory).
TimeoutSet the function timeout above the signal you pass to render().
ConcurrencyOne container renders one image at a time. Scale with Lambda concurrency, and cap it to cap your cost.
Cold startRender 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.
ArchitectureThe bundle is WebAssembly and data only, so arm64 and x86_64 render the same bytes. arm64 was faster and cheaper for the demo.
PermissionsThe function needs s3:GetObject on originals/* and s3:PutObject on renders/*. The browser needs no access to the bucket.
Repeated jobsThe 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.