Skip to content

perf: Don't draw what's outside of the image, and render web pages in the playground - #932

Merged
shuding merged 4 commits into
mainfrom
shu/playground-screenshot
Oct 16, 2026
Merged

shuding merged 4 commits into
mainfrom
shu/playground-screenshot

Conversation

@shuding

@shuding shuding commented Oct 15, 2026 •

Copy link
Copy Markdown
Member

Two changes: Satori doesn't draw what's entirely outside of the image anymore, which makes the SVGs of long content much smaller, and a development-only playground page renders any web page with Satori, like a screenshot of its viewport.

Skip what's outside of the image

Boxes, images, outlines and lines of text entirely outside of the image aren't drawn. Rendered at 1280x800 through the playground page, main vs this PR:

Page Satori SVG resvg
nextjs.org 1.83s → 1.61s 5.10MB → 0.40MB 2.30s → 1.17s
vercel.com 5.22s → 4.15s 1.57MB → 0.42MB 9.40s → 6.21s
svelte.dev 3.10s → 2.48s 32.80MB → 1.42MB 6.74s → 0.61s
news.ycombinator.com 0.93s → 0.85s 1.67MB → 1.58MB 53ms → 53ms

Satori's times include downloading images, and resvg is the time to draw the SVG as a PNG. Rendered with resvg, all of the pixels of nextjs.org, vercel.com, svelte.dev and example.com are the same as with main, and so are all the existing snapshots and fidelity scores.

  • Box shadows, outlines, text strokes and filters are checked with how far they draw outside of boxes. Filters of colors, like grayscale(), draw in place, and blur() and drop-shadow() add to the margin of descendants.
  • Descendants of transformed elements are always drawn, and so is text with a text-shadow.
  • Boxes that define the clip paths or masks of their descendants are drawn, as the descendants use them.
  • Lines of text are skipped as a whole, as words are only drawn together on the same line. Text in inline formatting, e.g. with links, is still drawn, which is why Hacker News barely changes.
  • Elements outside of the image are still reported to onNodeDetected.

The new tests compare the image with the top of the same content drawn on a taller image, where nothing is outside.

Playground: render web pages

A /screenshot page with a URL input, through /api/screenshot, which fetches the page and returns Satori's SVG.

  • The page's HTML is parsed with parse5. Stylesheets are fetched and inlined where they're linked, with their media, and URLs in CSS and of images are made absolute.
  • Fonts of the page's @font-face rules are loaded for basic Latin, and the ones Satori can't parse, like variable fonts, are skipped. Geist is the fallback.
  • Scripts don't run, so pages are rendered as their server sends them. Images without a source, e.g. lazy ones, are empty, as Satori would throw.
  • Every request and redirect is validated with Satori's SSRF checks, as pages control the URLs of their stylesheets and fonts: they must resolve to public addresses, unless they're on the origin of the URL that was entered, e.g. a local server. With a local page linking a stylesheet and a font on another local server, and a stylesheet redirecting there, that server got no requests, and neither did a public redirect to it.
  • Stylesheets and fonts are cached while the server runs, by the origin of the URL that was entered, so a second render of a page only fetches its HTML.
  • The Server-Timing header has the time of each step, and X-Screenshot what was loaded and the style errors. The page shows them, and syncs the URL and size to its query, e.g. /screenshot?url=https://vercel.com.
  • Only in development: the API route returns 404, and the page isn't found otherwise. I checked both with NODE_ENV set to production and test.

Some results: nextjs.org and vercel.com are close to the pages, without the outlined "NEXT" graphic and the animated hero. On svelte.dev, the icons of the header are too large, and Hacker News breaks, as tables aren't supported. Satori also prints warnings for top: auto, which is valid CSS.

parse5 is added to the playground's dependencies.

A `/screenshot` page with a URL input renders the page with Satori, like
a screenshot of its viewport, through `/api/screenshot`. The page is
fetched with its stylesheets and the fonts of its `@font-face` rules that
Satori can read, and its HTML is parsed with parse5. Scripts don't run,
so pages are rendered as their server sends them.

Both are only available in development: the API route returns 404 and the
page isn't found otherwise.
@vercel

vercel Bot commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
satori-playground Ready Ready Preview, v0 Oct 16, 2026 6:10am UTC

@socket-security

socket-security Bot commented Oct 15, 2026 •

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Addednpm/​parse5@​8.0.11001008585100

View full report

@vercel-security-reviewer

Copy link
Copy Markdown

Security review details

Pages control the URLs of their stylesheets and fonts, so a page could
make the development server request private services. Like the images
that Satori fetches, every request and every redirect is now validated
with Satori's SSRF checks, and must resolve to a public address, unless
it's on the origin of the URL that was entered, e.g. a local server.
Cached stylesheets and fonts are kept by that origin too.
Boxes, images, outlines and lines of text entirely outside of the image
aren't drawn anymore, which makes the SVGs of long content much smaller,
and faster to render. Rendered at 1280x800, the SVG of nextjs.org goes
from 5.1MB to 0.4MB, and the one of svelte.dev from 32.8MB to 1.4MB.

Shadows, outlines and filters are checked with how far they draw
outside of boxes. Descendants of transformed elements are always drawn,
and so are boxes that define the clip paths or masks of descendants.
The nodes outside of the image are still reported to `onNodeDetected`.
@shuding shuding changed the title feat(playground): Render web pages with Satori in development perf: Don't draw what's outside of the image, and render web pages in the playground Oct 16, 2026
shuding added a commit that referenced this pull request Oct 16, 2026
Two fixes for values that pages ship, found rendering
[paco.me](https://paco.me) with the playground's screenshot page (#932).

- **Values of `var()` stay apart from what's next to them.** Minifiers
leave out spaces after functions, e.g. `grid-template-columns: 192px
var(--content-width)192px` or `font: 500
var(--text-xs)/var(--leading-s)var(--font-display)`. Browsers substitute
tokens, so the result is still two values, but Satori substituted text,
which made `640px192px`. On paco.me, every `font` declaration, the main
padding and the grid columns were invalid.
- **`env()` is supported,** like on desktops: the safe area and keyboard
insets are `0px`, and other variables, e.g. the title bar areas of
installed apps, use their fallbacks, or make the declaration invalid
without one. Declarations with it are valid until they're computed, like
the ones with `var()`. On paco.me, the page margins use `max(24px,
env(safe-area-inset-left))`.

Both new tests fail without the fix.
The Geist Mono variable was only defined on the wrapper of pages, where
the font of the body, which uses it, couldn't see it, so pages fell
back to a serif font. The wrapper now sets the font itself.
@shuding
shuding merged commit 7fbec2e into main Oct 16, 2026
10 checks passed
@shuding
shuding deleted the shu/playground-screenshot branch October 16, 2026 06:14
@github-actions

Copy link
Copy Markdown

🎉 This PR is included in version 0.60.2 🎉

The release is available on:

Your semantic-release bot 📦🚀

This branch was successfully deployed

1 active deployment
Preview — 9fa9de17 Deployed Oct 16, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant