Guide

Capture engine

Create repeatable PNG or GIF images from generative artwork in a background browser.

Install

bash
pnpm add @whitehash/capture puppeteer-core

Capture one viewport

Use the local provider in development. The final URL is intentionally caller-owned: add preview=1, fxcontext=capture, the iteration hash, minter, parameter bytes, and any other project inputs before calling the engine.

tsx
import { writeFile } from "node:fs/promises"
import {
capture,
CaptureMode,
CaptureTriggerMode,
} from "@whitehash/capture"
import { localProvider } from "@whitehash/capture/browser/local"
const result = await capture({
url: "https://art.example/token?preview=1&fxcontext=capture",
browser: localProvider({ useGl: "egl" }),
allowlist: ["https://art.example/"],
settings: {
mode: CaptureMode.VIEWPORT,
resolution: { x: 1024, y: 1024 },
triggerMode: CaptureTriggerMode.FN_TRIGGER,
},
})
await writeFile("capture.png", result.image)
console.log(result.features, result.triggeredBy, result.timing)

Choose a capture mode

ModeViewportOutputUse it when
VIEWPORTRequested resolution, 256-2048 px per axisExact viewport PNGComposition includes DOM, CSS, SVG, or WebGL
CANVAS800 × 800Canvas intrinsic resolutionOne readable canvas is the canonical artwork output
CUSTOMNot applicableRejected server-sideClient-side DOM capture needs a separate harness

Every viewport uses deviceScaleFactor: 1, so requested pixels are output pixels. Canvas captures can be much larger than their page viewport; set maxDimension and maxImageBytes for untrusted or unknown projects.

tsx
const result = await capture({
url,
browser,
maxDimension: 4096,
maxImageBytes: 20_000_000,
settings: {
mode: CaptureMode.CANVAS,
canvasSelector: "#art",
triggerMode: CaptureTriggerMode.DELAY,
delay: 1_000,
},
})

Artwork readiness contract

FN_TRIGGER waits for either a window fxhash-preview event or a console message whose text is exactly FXPREVIEW. Existing fxpreview() and $fx.preview() implementations use these conventions.

The listeners are installed before navigation, so an artwork may signal immediately while its document loads. The wait is bounded at five minutes by default. Set useFallbackCaptureOnTimeout only when a best-effort image is preferable to a hard failure.

js
// A non-fxhash page can implement the same contract:
window.dispatchEvent(new Event("fxhash-preview"))
// The v3 snippet-compatible alternative:
console.log("FXPREVIEW")

Features and GIFs

After readiness, the engine checks window.$fx._features, then legacy window.$fxhashFeatures. It returns only string, number, and boolean attributes. Invalid feature data degrades to an empty array without losing a successful image.

GIF capture requires the optional gifenc peer. FN_TRIGGER_GIF consumes one readiness signal per frame; delay captures use captureInterval.

bash
pnpm add gifenc
tsx
const animation = await capture({
url,
browser,
settings: {
mode: CaptureMode.VIEWPORT,
resolution: { x: 800, y: 800 },
triggerMode: CaptureTriggerMode.FN_TRIGGER_GIF,
gif: true,
frameCount: 24,
playbackFps: 12,
},
})

Run Chromium where your server runs

EnvironmentProviderNotes
Local Node.js@whitehash/capture/browser/localDiscovers Chrome from environment variables, PATH, and common install locations
Vercel or Lambda@whitehash/capture/browser/sparticuzUses @sparticuz/chromium-min and a hosted Chromium pack
Browserless or isolated worker@whitehash/capture/browser/remoteConnects through a browser WebSocket endpoint
tsx
import { sparticuzProvider } from "@whitehash/capture/browser/sparticuz"
const browser = sparticuzProvider({
packUrl: process.env.CHROMIUM_PACK_URL,
useGl: "egl",
})

The built-in launch arguments are container-safe and include --no-sandbox. Arbitrary generator code should run in a separately isolated remote browser, not beside credentials or sensitive workloads.

Mount an HTTP endpoint

The handler uses web-standard Request and Response. Your resolver maps a request to the final artwork URL, settings, and a versioned cache key. Store and lock modules are optional.

tsx
import { createCaptureHandler } from "@whitehash/capture"
import { memoryLock } from "@whitehash/capture/lock/memory"
import { r2Store } from "@whitehash/capture/store/r2"
const handler = createCaptureHandler({
browser,
resolve: request => {
const hash = new URL(request.url).searchParams.get("hash")
return hash ? {
key: `captures/v1/${hash}.png`,
url: artworkUrl(hash),
settings,
} : null
},
store: r2Store({ client: r2, bucket: "captures", publicBaseUrl: cdn }),
lock: memoryLock(),
headers: { "Cache-Control": "public, max-age=31536000, immutable" },
})

Cache hits redirect to a public store URL when configured, or stream stored bytes. Concurrent misses for the same key render once; waiters poll the store until the lock holder writes the result. HEAD and stable JSON error responses are built in.

Framework adapters

tsx
// Next.js route handler
import { toNextRouteHandler } from "@whitehash/capture/adapters/next"
export const runtime = "nodejs"
export const maxDuration = 300
export const { GET, HEAD } = toNextRouteHandler(handler)
// Hono
import { toHono } from "@whitehash/capture/adapters/hono"
app.get("/capture/:key", toHono(handler))
// Express
import { toExpress } from "@whitehash/capture/adapters/express"
app.use("/capture", toExpress(handler))

Post-process thumbnails

The optional Sharp entry creates a 300 × 300 inside-fit PNG. For GIFs it can also extract the middle frame as a full-resolution PNG and thumbnail.

bash
pnpm add sharp
tsx
import {
makeThumbnail,
gifMiddleFrameStill,
} from "@whitehash/capture/postprocess"
const thumbnail = await makeThumbnail(result.image)
const { image, thumbnail: gifThumbnail } =
await gifMiddleFrameStill(animation.image)

Failures and browser limits

The stable error codes are UNKNOWN, HTTP_ERROR, MISSING_PARAMETERS, INVALID_TRIGGER_PARAMETERS, INVALID_PARAMETERS, UNSUPPORTED_URL, CANVAS_CAPTURE_FAILED, TIMEOUT, and EXTRACT_FEATURES_FAILED.

The final navigation response must be exactly HTTP 200; the engine never captures an error page as artwork. A missing selector, non-canvas match, or cross-origin-tainted canvas produces CANVAS_CAPTURE_FAILED.

WebGL created with preserveDrawingBuffer: false can read back black through toDataURL(). Switch that project to VIEWPORT capture, which screenshots Chromium’s composed output instead.