Artboard renderer: how it works

Renders one artboard of a design state without a browser by running the editor's own export, so MCP tools can look at a design and iterate on it; paywall-gated editor exports are the second, not yet viable, use (hosting §1). API and commands: the app's README. Hosting: hosting. Paths are relative to apps/artboard-renderer/.

1. How it works

1.1 Layers

SelectionDownloader.exportSubState        editor     "export board X as png, jpg, svg or pdf"
Fabric, RasterizeImage, getSvgFromBoard   editor     "draw or serialize the objects"
@napi-rs/canvas (native Skia)             renderer   "make the pixels" (raster parts only)

tools/buildEngine.ts bundles the editor layers from @repo/rendering sources unchanged, except that the editor's synchronous test workers (workers-jest.ts) replace its Web Workers.

1.2 Fake DOM

The editor only calls browser globals. installFakeDom() in src/fakeDom.ts answers them with linkedom and native canvases, without changing editor code:

document.createElement('canvas') → linkedom element
canvas.getContext('2d')          → context2d() in src/canvas.ts: a Proxy over a native context
canvas.toDataURL() / toBlob()    → napi encoder
new Image(); img.src = url        → NetworkImage: fetch, decode with napi
fetch(url)                        → http(s) on allowed hosts only, no redirects, User-Agent, 10 s timeout,
                                    cached for the export; failures go to x-render-report

Fabric keeps a context across canvas resizes, and a resize replaces the native canvas, so the Proxy looks the native context up on every access:

// src/canvas.ts
get: (_target, key) => {
  const context = backing(canvas).getContext('2d'); // the native canvas at its current size
  ...

Also faked: FileReader (the export writes the DPI into PNG and JPEG files), classList tagged DOMTokenList (lodash's cloneDeep in SVG and PDF export breaks otherwise) and getElementsByTagName('*'). As in the editor's build, fs points at pdfkit's virtual fs.

1.3 One request

POST /render ─▶ supervisor (container/server.ts, container/supervisor.ts)
                  ├─ 408 past 15 s of upload; 413 when the design state body is over 10 MiB; 503 with 5 requests
                  │  held (counted before their body is read) or after 10 s in the queue
                  └─ one job at a time over IPC; 504 and SIGKILL past 25 s
                       ▼
                render process (container/renderChild.ts), engine loaded once
                  renderBoard (src/engine.ts): 400 on a bad body, format or size
                  migrateDesignState → keep one board → designStateToNormalised → exportSubState
                  x-render-report if an asset failed
                  then: cancel animation frames, clear the fetch cache and failures, shrink canvases,
                  GC if images were decoded

The supervisor replaces the render process, and the queue continues, when:

Event The request Why a process, not a worker thread
Render over 25 s 504; the process gets SIGKILL, even inside synchronous work A timer in the rendering thread can't fire while it draws, and a load can't be cancelled
Render process exits (native crash, uncaught error) 500 A crash in @napi-rs/canvas takes its whole process down
500 renders, or RSS above 1.75 GiB after a render (hosting §2.7) None: it happens between renders Exiting is the only way to free native memory V8 doesn't track
Step Why
Migration As in the editor's load: legacy objects such as mask have no Fabric class until migrated. The whole state is migrated; it costs 0.1–3 ms (hosting §2.3)
One board A substate names its export target by its only standard board
One render at a time The editor and the native canvases are process globals
Asset hosts A posted design can name any URL: only the editor's storage, image proxy and API origins (API_STORAGE_URL, CONTENT_URL, USER_API_URL) and images.unsplash.com are fetched, and a redirect fails, so it can't lead elsewhere
Asset failures The file renders without them, as in a browser, and x-render-report lists them (§1.4)
Asset fetches Kittl's WAF needs a User-Agent; caller credentials are never sent. Each URL is fetched once per export, failures included: the export loads the board twice (§6.2) and repeats images. Failed asset queries retry after 250 ms instead of 1, 2 and 4 s.
Cleanup after each export A load that throws never disposes its canvas, whose render loop would keep drawing. V8 doesn't count native pixels, so it would free them late.
Container exits SIGTERM (in-flight requests finish within 30 s), an editor URL missing at startup (one log line names it), an uncaught error in the supervisor, a render process that exits or hangs (30 s) before it is ready; the platform starts a fresh container

The editor's URLs are runtime config, not part of the bundle: one image serves every environment.

wrangler.jsonc vars (per env) ─▶ Renderer.envVars (src/index.ts) ─▶ container env
  API_STORAGE_URL ─┐
  USER_API_URL     ├─ editorEnv (src/runtimeEnv.ts) ─▶ REACT_APP_* in the render process's env,
  CONTENT_URL     ─┘    set before it imports the editor, which reads them at import time

1.4 Render report

A missing asset doesn't fail the render: the response is still 200 with the file, drawn without it, and the x-render-report header lists what's missing. The same header comes with every format, and the Worker passes it through.

complete    200, no x-render-report
missing     200, x-render-report: {"missingCount":2,"missingAssets":[
              {"src":"https://api-objects.stagingdesigner.com/users/…/paths/….json","reason":"http_403"},
              {"src":"http://169.254.169.254/meta-data.png","reason":"blocked_host"}]}
truncated   200, x-render-report: {"missingCount":14,"missingAssets":[…10 entries…],"truncated":true}
reason The asset
http_<status> Answered an error status, e.g. http_404; private assets answer http_403
timeout Took over 10 s, headers and body
blocked_host Is on a host outside the allowlist (§1.3), or isn't an http(s) URL (blob:, file:, unparsable); never requested
redirect Redirected; redirects aren't followed
network_error Failed to connect or resolve
decode_failed Loaded, but its bytes aren't an image

The MCP tool puts it next to the image as a note:

const response = await fetch(`${baseUrl}/render?board=${boardId}`, { method: 'POST', body, headers });
const image = { type: 'image', data: toBase64(await response.arrayBuffer()), mimeType: 'image/png' };
const report = response.headers.get('x-render-report');
if (!report) {
  return { content: [image] };
}
const { missingCount, missingAssets } = JSON.parse(report);
const listed = missingAssets.map(({ src, reason }) => `${src} (${reason})`).join(', ');
return { content: [image, { type: 'text', text: `Incomplete preview: ${missingCount} asset(s) didn't load: ${listed}` }] };

1.5 What the MCP tool should ask for

The model's image tokens, not render CPU, are the cost: a render costs ≈ $0.00002, its image ~60× that in input tokens (hosting §3.1). size takes any width 16–2048 and barely changes render time, since loading and decoding dominate (hosting §2.2). So pick the smallest size that answers the question:

first read      stored preview, no render ── Design.preview
overview        contact sheet, ≤ 16 boards × 256 px cells    (to build)
one board       size=384
verify an edit  size=512, the changed board only
small text      size=1024, or crop/zoom at 512               (crop: to build)
Size Vision tokens* Reads
256 (sheet cell) 100 Layout, colours, imagery, headlines; not body text
384 ~196 Default preview
512 ~361 Body text on ~1,200 px boards, 11–12 pt labels: enough to verify most edits
1024 ~1,369 Small text on boards with a long side over ~2,500 px

*A common vision-model formula, ceil(w/28) × ceil(h/28), on a square image.

2. Formats

pixelsOff is the share of pixels whose largest RGB channel difference is above 40/255, both images flattened onto white; meanDiff is the mean of that difference (0–255). References: §3.

Format Built by the editor with Against Chrome: pixelsOff / meanDiff Gaps
png RasterizeImage on napi 0.000–0.141% / ≤ 1.054
jpg RasterizeImage on napi No RGB reference
svg getSvgFromBoard 0.000–0.006% / ≤ 0.445
pdf getSvgFromBoard, then pdfkit Exact on 4 of 6; 2.2% and 7.7% with raster-image shadows Blurred shadows of raster images are missing: rasterizeShadow loads an SVG with a blur filter as an image, and Skia's SVG renderer ignores filters on <image>
CMYK Not offered: it needs the little-cms WASM and an ICC profile download, which the bundle leaves out
mp4 400: the editor's encoder needs browser video APIs
Mockup boards 422: the warp needs a Web Worker with WebGL; apps/mockup-renderer renders mockups

3. Against Chrome

References are the editor's own exports in Chrome: the export snapshots in apps/editor/e2e/__screenshots__/ (Playwright Chromium, CPU raster) and one staging PNG export. SVG and PDF are rasterized as the e2e suite does it (a Chromium screenshot; pdf.js at scale 1).

Fixture, board, width Content PNG SVG PDF
Staging draft, 512 4 rectangles 0.000% / 0.000
vector-gradients, Artboard 4, 887 Vector gradients 0.000% / 0.016 0.000% / 0.000 0.000% / 0.000
multi-board-download, Standard, 517 Logo, text 0.000% / 0.005 0.000% / 0.000 0.000% / 0.000
multi-board-download, T2fly logo, 100–300 Logo on a photo 0.02–0.03% / 0.08–0.15
multi-board-download, Smartboard, 517 Photo 0.049% / 1.054 0.000% / 0.445 0.000% / 0.445
illustration-download, Smartboard, 1200 Photo 0.000% / 0.518 0.000% / 0.000 0.000% / 0.518
illustration-download, Artboard 1, 1200 Raster illustrations, drop shadows 0.141% / 0.714 0.006% / 0.381 2.174% / 4.220
video-rendering, Artboard 1, 1200 Image with a drop shadow 0.000% / 0.065 0.000% / 0.086 7.676% / 17.469

Not covered: JPG (the only RGB JPEG reference removes the background, which /render doesn't offer) and boards wider than 2048.

napi output is deterministic per machine. amd64 and arm64 differ by meanDiff ≤ 0.105 with no pixel above 40/255 (Skia picks SIMD paths per CPU), so golden tests need a tolerance or a single architecture.

4. Why not CanvasKit

CanvasKit (Skia as WASM, with Google's JS Canvas2D emulation) is the alternative. napi is the renderer because it matches Chrome where CanvasKit doesn't:

Against the §3 references CanvasKit napi
Photos 0.24–0.65% off ≤ 0.05%
Drop shadows on cached objects 4.2–7.7% off: its emulation ignores shadows on drawImage, which Fabric uses for cached objects 0.000–0.141%
ctx.filter (blurred text shadows, neon), conic gradients, roundRect, SVG image decoding Missing Supported
Output across CPUs Bit-identical meanDiff ≤ 0.105 between amd64 and arm64
Packaging One WASM binary, patched at build time A native addon per platform

transformed-shadows, CanvasKit left, napi right:

CanvasKit drops the drop shadows that napi draws

brand-kits-apply: CanvasKit, napi, and their difference in red:

CanvasKit and napi on brand-kits-apply, with the differing pixels in red

5. Limitations

Limitation Result
Missing assets Left out and listed in x-render-report (§1.4), after the editor's own retries: about 6 s for a missing or disallowed image, plus up to 10 s for a stalled one
Private assets No caller credentials are forwarded: user uploads and edited illustrations' paths answer 401–404 and are left out (http_403 in the report), after ~6 s of idle retries per board (hosting §2.3). Fix: hosting §7, follow-ups 1 and 3.
Workspace fonts Resolved through the user API, which needs the caller's session: text in a workspace font falls back to the default font without an error
Input The whole state is posted; fetching by design id isn't wired. Validation is shallow: deeper malformed input can return 500.

6. Before running as a shared service

Priority Work Done when
1. Access Authentication and a per-user rate limit in the Worker (hosting §7); the asset-host allowlist is built Unauthorized callers are rejected
2. Input bounds Runtime state validation; caps on total output pixels and asset size (the 10 MiB body limit is built) Bad input gets a defined error, and the next render works
3. Build Move the editor's synchronous test workers (workers-jest.ts, swapped in at build time) into runtime-neutral shared code No build-time swap

6.1 Shared-package changes ship separately

The renderer relies on one @repo/rendering behavior that ships with the editor in its own PR: when toBlob returns null, alphaMask.ts rejects every waiting caller and clears its cache entry, so a later call can retry; alphaMask.test.ts covers it.

Known bugs the renderer works around (paths under packages/):

Where Bug Workaround
editor-core/rendering/rendering/src/fabric/utils/state.ts, loadStandaloneState Doesn't dispose its canvas when loadProject throws, so its render loop keeps running Cancel pending animation frames after each export
resource-manager/src/illustration/IllustrationResourceManager.ts A 401/403/404 throws a plain Error, which query-client retries 3 times with 1, 2 and 4 s backoff; a QueryError would stop it 250 ms retry delay; responses cached per export
editor-core/rendering/rendering/src/fabric/mixins/initializeSvg.ts Fetches fallbackObjectName even when it equals objectName, repeating the failed lookup Same
SelectionDownloader.exportSubState Loads the board twice (§6.2) Response cache, so each asset downloads once
rasterizeShadow (PDF export) Relies on the browser rasterizing SVG filters inside an <img> None (§2)
apps/editor/src/extensions/EditorService/getBoardSubState.ts The editor's board-to-substate step isn't in a shared package The engine narrows the state to one board itself

6.2 The double load

exportSubState loads the board into canvas #1, then hands it to exportTargets, the live editor's export path. That path serializes the board, rebuilds a cropped standalone state and loads it into canvas #2 before the only rasterize (paths under packages/editor-core/rendering/rendering/src/exporting/download/):

exportSubState(state)                                  selectionDownloader.ts:351
 ├─ loadStandaloneState(state, 'substate-export')      :367   LOAD 1 → canvas #1
 └─ exportTargets(canvas #1, { boardId | nodeIds })    :375
     ├─ waitForExportAssetsToLoad
     └─ _getAllDownloadObjectFromSelection
         ├─ mockupDesignManager.waitForPendingWarpWork
         ├─ gatherAllSelectionData(canvas #1)                 serialize objects, board, bounds
         └─ _getDownloadSelectionObject
             ├─ getSafeDimensions(bounds)
             ├─ getStandaloneState(…)                         cropped standalone state
             ├─ loadStandaloneState(…, 'selection-download')  :119  LOAD 2 → canvas #2
             └─ rasterize (png, jpg) or getSvgFromBoard (svg, pdf) on canvas #2
Load 2 repeats Load 2 doesn't repeat
CPU: Fabric objects, SVG parsing, image decoding, text shaping, effects HTTP: assets come from the per-export cache (§1.3)
Memory: canvas #1 stays alive until exportSubState returns The rasterize: there is only one