Capture faithful PNGs of Pulp view trees / imported UIs headlessly. Covers render_to_png backends (Skia vs CoreGraphics), the image-compositing gotcha, capture_png from a live GPU host, and the --screenshot-backend validate flag. Use whenever you render a UI to a PNG to eyeball or montage it.
Scanned 6/11/2026
Install via CLI
openskills install danielraffel/pulp---
name: screenshot
description: Capture faithful PNGs of Pulp view trees / imported UIs headlessly. Covers render_to_png backends (Skia vs CoreGraphics), the image-compositing gotcha, capture_png from a live GPU host, and the --screenshot-backend validate flag. Use whenever you render a UI to a PNG to eyeball or montage it.
---
# Screenshotting Pulp UIs
Pulp has two headless capture surfaces plus the live-host capture. Picking the
wrong one wastes time on renders that look broken but aren't.
## Default to `capture_view` / `--backend auto` — it picks the backend for you
Don't hand-pick a backend unless you have a reason. `capture_view(root, w, h,
scale)` (`screenshot.hpp`) and the CLI default `pulp-screenshot --backend auto`
inspect the view tree and do the right thing, and **never return a silent blank**:
| The view tree… | …gets | Why |
|---|---|---|
| has a `contains_native_overlay()` subtree (a WebView / native NSView) | the overlay's **in-process snapshot** (`View::capture_native_overlay_png` → e.g. WKWebView `takeSnapshot`); **refused** with a reason only if no snapshot is available | A WebKit/native overlay is composited by the OS, NOT painted into the Pulp canvas — headless raster can't see it, but a backend that exposes an in-process snapshot can still be captured |
| has a `requires_gpu_host()` view (GPU content, custom SkSL) | the **`gpu`** path (`render_to_png_gpu`, offscreen Dawn+Skia via `HeadlessSurface`) | CPU raster does NOT render GPU-required views correctly — they come out blank/wrong |
| neither | CPU raster (`skia` when Skia is built, else the platform `default_backend` / a registered provider) | fast, faithful for vector + image widgets |
`capture_view` returns `{png, ok, used, reason}`; a blank/essentially-empty frame
sets `ok=false`. The CLI exits 3 (not a saved blank) on refuse/blank. This is the
"always-capturable" contract — if a UI didn't paint, you get told, not a blank PNG.
**Wrappers must opt in.** A WebView is only captured/refused correctly when the
owning Pulp `View` actually sets `set_contains_native_overlay(true)` and overrides
`capture_native_overlay_png()` to forward `WebViewPanel::snapshot_png()`. Without
that flag, `auto` rasters the (blank) overlay area silently — set it on any
wrapper that attaches a native child via `attach_native_child_view` (see
`examples/webview-plugin` `WebViewEditorPane`).
**Build gating.** The `gpu` path and `has_gpu_capture()` are compiled in only when
Skia is present (`PULP_VIEW_HAS_GPU_CAPTURE`, gated on `PULP_HAS_SKIA` — not merely
on the `pulp-render` target, whose `HeadlessSurface::create()` is a null stub in a
no-Skia build). In a no-Skia build the auto raster fallback is `default_backend`
(so a host-registered `ScreenshotProvider` handles it), not a forced `skia` that
would return empty bytes.
**When to override:** force `--backend gpu` to capture a GPU view that isn't
flagged `requires_gpu_host()`; use `skia`/`coregraphics` for the explicit cases
below. The image-compositing rule still applies to the raster backends.
## The image rule (raster backends): use **Skia** for anything with images
`render_to_png(root, w, h, scale, backend)`
(`core/view/include/pulp/view/screenshot.hpp`) takes a `ScreenshotBackend`:
| Backend | Composites file-backed images? | Use when |
|---------|-------------------------------|----------|
| `skia` | **Yes** | Default for anything real — designs with assets (Figma/Pencil imports), icons, photos |
| `coregraphics` (macOS default of `default_backend`) | **No** | Vector-only UIs, or when you specifically want the CG path |
| `default_backend` | macOS → CoreGraphics, else provider | Avoid for asset-heavy UIs |
**Why it matters (the trap):** `ImageView::paint`
(`core/view/src/widgets/visualizers.cpp`) decodes images on-paint via the
canvas's `draw_image_from_file` / `measure_image_from_file` primitive.
`SkiaCanvas` implements it; the **CoreGraphics canvas does not**. On the CG
path every `ImageView` falls back to drawing its **filename as placeholder
text** — so an asset-rich import renders as empty boxes with `*.png` strings
scattered across it. That looks like "missing images / broken importer," but
the import is fine — it's the backend. Vector widgets (knobs, faders drawn via
canvas primitives) render on both backends, which makes the CG render
*partially* right and even more misleading.
Confirmed 2026-06-02 on the ELYSIUM Figma import: CoreGraphics → empty vessel
boxes + filename text; Skia → the gradient beakers/knobs/curves all composite
and the montage matches the Figma reference.
## Imported-design validation
`pulp import-design --validate --reference <png> --diff <png> [--render-size WxH]`
renders the generated JS and compares to a reference. As of 2026-06-02 it
defaults to `--screenshot-backend skia` (faithful). Only pass
`--screenshot-backend coregraphics` deliberately. A CoreGraphics validate of an
asset-heavy design is **not** a valid fidelity check.
```bash
pulp import-design --from figma-plugin --file scene.pulp.json --output ui.js \
--validate --reference figma-ref.png --render-size 1146x746 --diff diff.png
# (skia is the default; add --screenshot-backend coregraphics only to escape-hatch)
```
**Pixel-% is a weak gate.** `compare_screenshot_files` similarity is exact-pixel
and very sensitive to gradients, anti-aliasing, sub-pixel placement, and
background differences — a visually faithful import can report a low % on a
gradient-heavy design. Treat the % as a smoke signal; **eyeball the montage**
(reference | render) for structure + assets. Build montages with PIL.
## Capture options at a glance
- **`render_to_png` / `render_to_file`** (`screenshot.hpp`) — headless raster of
a `View` tree, no window. macOS/iOS have native backends; Linux/Windows need a
host-registered provider via `set_screenshot_provider()` (else empty/"unsupported",
not a silent blank — #299). Probe `has_screenshot_provider()`.
- **`WindowHost::capture_png()`** — reads the rendered frame from a live GPU host
(`MacGpuWindowHost`). The most faithful (real paint path, GPU). Drive it from
the design-tool example's `--no-show-window --automation-before <png>`
offscreen path, or any host that exposes capture. NOTE: the design-tool's
`--script` expects its own entry-module shape — a raw generated `ui.js` from
`import-design` does not load that way (throws). Prefer `--validate` with the
Skia backend for an imported `ui.js`.
- **`pulp::view::render_to_file`** in tests — headless view-tree PNGs in CI.
- **`pulp::view::render_to_rgba`** (`screenshot.hpp`) — raw-pixel sibling of
`render_to_png`. Returns the decoded **RGBA8** buffer (R,G,B,A byte order,
premultiplied alpha, sRGB, top-to-bottom, stride `*out_width*4`) + the pixel
dims, instead of PNG bytes — for callers that composite/upload the frame
themselves (e.g. the foreign-host embed SDK's offscreen mode) and don't want a
PNG encode+decode round-trip. **macOS-only** (forces the Skia raster path,
which is endianness-independent; the non-Apple stub returns empty — the
registered `ScreenshotProvider` is PNG-only). The internal `render_to_png_skia`
already holds these pixels before encoding; this just exposes them. Note
`AssetManager::decode_png` does NOT actually decode (it stores raw PNG bytes +
parses IHDR), so you cannot get RGBA by round-tripping a PNG through it — use
`render_to_rgba` for raw pixels.
## Render size: use the design's true root, not the source bbox
`--render-size WxH` must match the imported design's **root frame** size, not
the source tool's reported node bounding box. A Figma node's screenshot bbox
includes page margin / shadow bleed around the frame (e.g. ELYSIUM: node bbox
1146×746, but the actual root "VST Style" frame is 1000×600). Render at the
bbox and the design lays out at top-left with the host's dark background
filling the extra pixels — looks like a "wrong window size." Read the root
size from `scene.pulp.json` `root.style.width/height` (or the generated
`setSize('root', …)`), and render at that. When in doubt, render at the root
size — the result fills the canvas and matches the design's own proportions.
## Gotchas
- A fresh GPU-less build silently returns the CPU host on macOS
(`PULP_HAS_SKIA` FALSE → `MacWindowHost`, not `MacGpuWindowHost`). Verify the
binary contains `MacGpuWindowHost` before trusting a live capture (see the
`import-design` skill's GPU-host gotcha).
- Never show the user a CoreGraphics render of an asset-rich design and call it
the import result — re-render with Skia first.
- **A non-empty PNG is not a passing render.** `ScreenshotStats::passes_content_floor`
(`core/view/include/pulp/view/screenshot_compare.hpp`) is the oracle that catches
the blank/near-blank-frame bug a raw "file written" check misses: it gates on a
unique-color floor, a luminance-stddev floor, and non-background + opaque coverage
floors. Assert it (not just `!png.empty()`) when validating a GPU capture, and
pump enough settled frames first — a single unsettled frame can under-count
content. The design-import screenshot-parity test asserts
`REQUIRE_FALSE(passes_content_floor())` on a stable flat capture to prove the
oracle actually rejects empties.
No comments yet. Be the first to comment!