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 |
- No header isn't proof of a complete file: text in a workspace font can fall back to the default font unreported (§5).
- An image, illustration (SVG or edited paths) or font file counts. A font file fails like any asset (
http_404,timeout). The user API doesn't count: without the caller's session it answers 401, and the editor falls back (its font list). - A URL counts once, with its last reason; a URL a retry or a later load fetched isn't listed.
- At most 10 entries, each
srccut to 300 characters, and fewer when escaping would take the header over 4 KB;missingCountis the real total. ASCII only: other characters are escaped as JSON\uXXXX.
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.
- Stored preview:
Design.preview, a ~724 px JPEG written by editor saves, served byGET /designs/:id/preview?width=&height=and already returned asthumbnailUrlby the project list tool. It shows onlyconfig.previewBoardId, and is stale when the last change didn't come from the editor (an MCP edit through the API). - Contact sheet: ~1,400 tokens for 16 boards, against 16 × 361 at 512. Render time still grows with boards (hosting §2.5), so cap it at ~16 and say so in the tool result; that covers p90 designs (13 boards).
- Result cache (to build): key = content hash of the board's JSON and its asset URLs, plus size and format; stored in R2 or KV. An unchanged board hits; an edited board misses by design.
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 | |
|---|---|---|---|---|
| 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:
brand-kits-apply: CanvasKit, napi, and their difference 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 |
- Cost: load 2 ≈ load 1, 35–50% of render CPU (hosting §2.3).
- Origin: added with the canvas export capability (Kittl/mono#14186) by reusing
exportTargets; no documented reason for canvas #2. - Who pays: every
exportSubStatecaller: this renderer and the editor's canvas capability (apps/editor/src/extensions/EditorService/EditorService.ts). The editor's main download path (live canvas → one clean copy) loads once and is unaffected. - Fix direction, a
@repo/renderingPR:- Ask the author whether canvas #2 guards an edge case.
- Rasterize canvas #1 directly, keeping what the skipped steps compute: bounds and crop, safe dimensions, background removal, floating-board node targets, the mockup warp wait.
- Prove pixel parity in png, jpg, svg and pdf with
pnpm test:e2e: baseline before, compare after.