Capture engine
Install
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.
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
| Mode | Viewport | Output | Use it when |
|---|---|---|---|
VIEWPORT | Requested resolution, 256-2048 px per axis | Exact viewport PNG | Composition includes DOM, CSS, SVG, or WebGL |
CANVAS | 800 × 800 | Canvas intrinsic resolution | One readable canvas is the canonical artwork output |
CUSTOM | Not applicable | Rejected server-side | Client-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.
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.
// 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.
pnpm add gifenc
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
| Environment | Provider | Notes |
|---|---|---|
| Local Node.js | @whitehash/capture/browser/local | Discovers Chrome from environment variables, PATH, and common install locations |
| Vercel or Lambda | @whitehash/capture/browser/sparticuz | Uses @sparticuz/chromium-min and a hosted Chromium pack |
| Browserless or isolated worker | @whitehash/capture/browser/remote | Connects through a browser WebSocket endpoint |
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.
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
// Next.js route handlerimport { toNextRouteHandler } from "@whitehash/capture/adapters/next"export const runtime = "nodejs"export const maxDuration = 300export const { GET, HEAD } = toNextRouteHandler(handler)// Honoimport { toHono } from "@whitehash/capture/adapters/hono"app.get("/capture/:key", toHono(handler))// Expressimport { 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.
pnpm add sharp
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.