Exporting a scene as a PNG sounds simple: draw it to a canvas and download the
result. But a large scene can turn that single click into a long stretch of
main-thread work—and make the rest of the page feel frozen.
We recently made Strokeline's PNG snapshot export more responsive. Here's what
the export actually does, why we changed it, and what the change does (and
doesn't) guarantee.
First, what does a PNG snapshot contain?
Strokeline's PNG export captures one scene at the current playhead time. It
is not a video render: it resolves the timeline at that instant, draws the
resulting scene to a canvas, then downloads a PNG.
The lifecycle is roughly:
- Compile and validate the current script.
- Select the active scene and clamp the playhead time to that scene's duration.
- Preload images and the handwritten font.
- Resolve the timeline into the state for that time.
- Draw the scene to a canvas.
- Encode the canvas as a PNG
Bloband download it.
The problem: one uninterrupted render
Previously, the snapshot path drew all visible scene nodes in one synchronous
pass. While that pass was running, the browser had fewer opportunities to
respond to input or paint UI updates. Dense scenes could therefore make the
export feel like it had stalled the page.
The first improvement is to render in batches of four nodes. Between batches
the renderer yields using requestAnimationFrame (or a timer fallback), so the
browser gets a chance to update the page. It also reports progress and checks
whether the user cancelled before continuing.
In simplified form, the asynchronous drawing loop is:
const batchSize = Math.max(1, Math.floor(options.batchSize ?? 12))
const totalNodes = Math.max(1, args[1].nodes.length)
const steps = drawSceneSteps(args, batchSize)
while (true) {
if (options.signal?.aborted) {
throw new DOMException("The export was cancelled.", "AbortError")
}
const step = steps.next()
if (step.done) break
options.onProgress?.(Math.min(0.98, step.value / totalNodes))
await new Promise<void>((resolve) => {
if (typeof requestAnimationFrame === "function") {
requestAnimationFrame(() => resolve())
} else {
setTimeout(resolve, 0)
}
})
}
The PNG exporter sets the batch size to four. This is cooperative scheduling,
not parallel rendering: the work still happens on the browser's main thread.
Yielding between batches gives the browser opportunities to respond, but one
especially expensive node can still take noticeable time to draw.
Put a limit on output pixels
Canvas memory use grows with the number of output pixels. Strokeline now exports
at 1× scale by default, caps the PNG at 4 million pixels, and also limits
either output dimension to 8192 pixels. It preserves the canvas aspect ratio
when it needs to scale down a large output.
For example:
| Canvas size | Pixel count | PNG output |
|---|---|---|
| 1920 × 1080 | 2.07 MP | 1920 × 1080 |
| 8000 × 5000 | 40 MP | 2529 × 1581 (about 4 MP) |
The cap is a practical limit, not a universal browser guarantee. Devices and
browsers have different memory and canvas constraints, and a scene with costly
shapes can still take time to draw.
Download a Blob instead of a base64 data URL
After drawing, the exporter uses canvas.toBlob() and downloads the resulting
PNG Blob. This avoids creating a large base64-encoded string as an intermediate
representation. The final image still has to be encoded and stored, but it
avoids that extra string allocation.
const blob = await new Promise<Blob | null>((resolve) =>
canvas.toBlob(resolve, "image/png")
)
if (!blob) throw new Error("The browser could not encode the PNG snapshot.")
download(blob, `strokeline-${timestamp()}.png`)
Progress and cancellation
The export menu reflects the drawing progress. Its cancellation signal is
checked between batches, so a cancel request takes effect at a batch boundary.
It can't interrupt an individual shape while that shape's renderer is running.
That boundary is intentional: it keeps the drawing loop straightforward while
giving long exports a way to stop without waiting for every remaining node.
What changed
- PNG snapshots render four nodes per batch and yield between batches.
- Progress is updated during rendering, and cancellation is checked at batch boundaries.
- Output is 1×, with a 4 MP pixel cap and an 8192 px maximum dimension.
- PNG data is encoded with
toBlob()rather than an intermediate base64 URL.
The implementation is in the
Strokeline repository, primarily in
web/src/export/exporters.ts, web/src/export/pngSizing.ts, and
web/src/renderer/draw.ts.
If a large export still makes your browser unresponsive, the scene, device, and
browser all matter. The batching and pixel cap reduce common sources of load;
they don't make canvas rendering free.



Top comments (0)