| name | screenshot-sync |
| description | Keep a plugin/demo repo's screenshots in sync with its UX. When editor / UI / design-token code changes in an opted-in repo (one carrying .pulp/screenshots.toml), re-capture and refresh every place each shot is consumed — README images, web-demo Open Graph (og.png) images, and gallery thumbnails — consistently across the WAM and WCLAP demo sites. Two capture routes: native headless (render_to_png / pulp-screenshot, Skia) for README / native-editor shots, and live-web headless Chrome (playwright-core) for WAM/WCLAP demo OG + gallery images. Backfills demos missing OG images. This is a PORTING / consistency tool — it re-shoots a repo's OWN UI, it never clones another product's art.
|
| requires | {"tools":["python3","node"],"files":["tools/scripts/screenshot_sync_check.py","core/view/include/pulp/view/screenshot.hpp","examples/web-demos/wclap-build/cloudflare/assemble-gallery.mjs"]} |
screenshot-sync
Keep a repo's screenshots in lockstep with its UI. When the UX changes, the
README shot, the web-demo Open Graph preview (og.png), and the gallery
thumbnail all have to change with it — this skill re-captures them and the
screenshot_sync_check.py gate fails a PR that forgot to.
Read the screenshot skill first for the raw capture
mechanics (backends, the image-compositing trap, the content-floor oracle,
vision-probe). This skill is the sync/consume orchestration on top of it.
When this applies
- A repo carrying
.pulp/screenshots.toml had a UX-path change and the
screenshot-sync gate (pre-push / CI) or PostToolUse hint fired, OR
- "update the screenshots / OG images / gallery thumbs", "backfill the WCLAP OG
images", "the demo preview image is stale / links don't unfurl".
0. Confirm opt-in
Screenshots are a plugin-with-releases concern, not a core/tooling one.
Check for .pulp/screenshots.toml at the repo root. Absent → the repo is not
opted in; stop. Do not invent screenshots for a library/tool/core repo. Opt-in
is per-repo by the file's presence — the same pattern
~/.config/pulp/daw-smoke.toml and .shipyard.local/config.toml use.
Default opt-in set: the packaged-demo and packaged-examples consumers
(gpu-nam, bendr, superconvolver, spectral-lab, tempo-sampler, classic-effects,
pulp-example-plugins). Everything else — pulp core included — stays off.
The manifest: .pulp/screenshots.toml
One file per opted-in repo, at its root. It lists the UX paths that should
trigger a re-shoot and, for each screenshot target, its capture route and
every place the image is consumed — so one re-shoot fans out to README + OG
- gallery and they cannot drift.
schema_version = 1
[trigger]
paths = [
"src/**/*_editor.cpp",
"ui/**/*.js",
"assets/design-system/**/*.css",
"assets/design-system/**/*.tokens.json",
]
[[target]]
id = "mono-synth-editor"
route = "native"
tool = "pulp-mono-synth-shot"
size = "720x460"
scale = 2.0
backend = "skia"
consumes = [
{ kind = "readme", path = "docs/img/mono-synth.png" },
]
[[target]]
id = "mono-synth-web"
route = "web"
url = "example-plugins/mono-synth/index.html"
device_scale_factor = 2
consumes = [
{ kind = "og", path = "example-plugins/mono-synth/og.png" },
{ kind = "gallery", path = "docs/gallery/mono-synth.png" },
{ kind = "og_meta", html = "example-plugins/mono-synth/index.html", rel = "./og.png" },
]
[[target]]
id = "example-gallery-web"
route = "web"
url = "example-plugins/index.html"
device_scale_factor = 2
trigger = [ "ui/**/*.js", "assets/design-system/**" ]
consumes = [
{ kind = "og", path = "example-plugins/og.png" },
{ kind = "og_meta", html = "example-plugins/index.html", rel = "./og.png" },
]
Schema:
schema_version — currently 1.
[trigger].paths — global UX globs; a match invalidates every target unless a
target sets its own trigger list.
[[target]] — id (required, unique), route (native | web, required),
optional trigger (per-target override), plus route-specific keys
(tool/size/scale/backend for native; url/device_scale_factor for
web) that document how to re-capture.
consumes[] — { kind, path } or { kind, html, rel }:
readme / gallery — the PNG a markdown/gallery references.
og — the PNG the page's og:image / twitter:image point at.
og_meta — the HTML head that must declare the OG/Twitter block pointing at
that og.png (this is what backfills a demo missing OG images).
The gate verifies the readme / gallery / og PNG paths were refreshed
when a trigger fired; og_meta is documentation for the head-template edit.
1. Identify stale targets
python3 tools/scripts/screenshot_sync_check.py --base origin/main --mode=report
It lists each [[target]] a diff invalidated. Read the hook output if it already
fired. A token change lists every target.
2. Route A — native README / editor shots
For a plugin's native editor rendered to a PNG with no window. See the
screenshot skill for backend selection; the essentials:
- Prefer a per-editor dumper target (the
pulp-bitches-brew repo's
ui/brew_shots.cpp → brew-shots pattern; each plugin repo copies it as pulp-<name>-shot,
named by the manifest tool =), or pulp-screenshot --demo for SDK demos.
- Skia backend for asset-heavy UIs — the CoreGraphics canvas can't composite
file images and renders filenames as placeholder text (the classic
"broken import" that is really just the backend).
gpu backend (offscreen Dawn+Skia HeadlessSurface) for
requires_gpu_host() views and any headless/CI/SSH capture — never the live
WindowHost::capture_png() headless (it needs an interactive WindowServer
session and hangs).
- Match the native editor baseline scale 2.0 (
test_editors.cpp in the
example repos) so README shots align with the visual-regression baselines.
- Assert
ScreenshotStats::passes_content_floor — never commit a blank /
near-blank PNG (a "file written" check misses the empty-frame bug).
- Write each PNG to its
consumes.kind = readme|gallery path.
pulp-screenshot is an Apple-only target; the per-editor dumper is the portable
route and must be added to each plugin repo.
3. Route B — WAM / WCLAP web demo OG + gallery
The WAM/WCLAP plugin UI is a WASM build running in a browser, so the faithful
capture is a real browser rendering the demo page — not native
render_to_png. Pulp already ships this path (playwright-core against the system
Chrome, no browser download); the OG generators reuse it.
Convention (match WAM exactly). WAM's OG image is a device-scale-2
element screenshot of the demo page itself, saved as a colocated og.png:
- per-plugin page → screenshot the started editor panel (
#panel) — ~1280×1234
- gallery page → screenshot the card grid (
.wrap) — ~1720×2002
The reference generators:
- WAM (GitHub Pages, single gallery):
web/gen-og-images.mjs (renders each page,
presses the player's window.__start() seam, waits for window.__demo.started,
screenshots the element at deviceScaleFactor: 2 with --mute-audio --autoplay-policy=no-user-gesture-required) + web/gen-og.mjs (injects the
og:image/twitter:card=summary_large_image block only when a sibling og.png
exists) — both in pulp-example-plugins / pulp-classic-effects.
- WCLAP (Cloudflare Pages, combined
/example-plugins/ + /classic-effects/
galleries): generated by
examples/web-demos/wclap-build/cloudflare/assemble-gallery.mjs in pulp core.
Its pluginPage() / galleryPage() emit the OG head block; the sibling
gen-og-images.mjs renders each page's og.png. WCLAP pages need
cross-origin isolation (COOP/COEP) to instantiate the threaded-wasm shared
memory, so serve them with cloudflare/serve-headers.mjs (which applies the
_headers rules), never a plain static server.
Steps:
- Build/assemble the demo Pages tree (
assemble-gallery.mjs for WCLAP; the
per-plugin .wasm modules are the only heavyweight input — they can be
rebuilt with wasi-sdk or fetched from the live deploy).
- Serve it (WCLAP:
serve-headers.mjs for COOP/COEP).
- Screenshot each page at
deviceScaleFactor: 2, waiting on the ready
predicate. Do NOT resume audio for its own sake — the demos are
click-to-start; if a shot needs the started editor, drive the __start()
seam under --mute-audio (see the local-dev audio etiquette in CLAUDE.md).
- Save as colocated
og.png; assert it is non-blank before trusting it.
- Ensure the page head declares
og:image / og:url / twitter:card /
twitter:image → the colocated og.png (the og_meta consume). Edit the
shared page template once, not each page by hand.
Landmine: on a PREVIEW deploy the og:image URL points at PRODUCTION, so the unfurl is stale
assemble-gallery.mjs stamps og:image / og:url from SITE_BASE, which DEFAULTS to the
production origin (https://pulp-wclap-demos.pages.dev). Deploy to a preview alias
(wrangler pages deploy --branch <name> → <name>.pulp-wclap-demos.pages.dev) and the
page's og:image still points at PRODUCTION — so a crawler unfurling the PREVIEW link
fetches the production og.png (the OLD image; your fresh capture sits on the preview alias
and is never seen). Symptom: you regenerate og.png, confirm it live at
<preview>/…/og.png, and the link preview STILL shows the old art — because the meta URL
and the image are on different origins. Fix: rebuild the preview with the alias as base —
SITE_BASE=https://<name>.pulp-wclap-demos.pages.dev node assemble-gallery.mjs — then
redeploy; verify with curl -s <preview>/…/ | grep og:image. Production needs no override
(default base is correct once merged). Separately, social caches (iMessage, Slack) hold the
OLD unfurl until they re-crawl — changing the og:image URL forces a re-fetch, but a client
may still show its cached card until the link is re-sent or its preview cache clears.
Landmine: assemble-gallery.mjs + wrangler deploy does NOT refresh og.png
og.png is content-hash cached — gen-og-images.mjs writes an og.png.key beside each
image and SKIPS a page whose inputs (its own HTML + the assets it loads: wasm module,
player, pulp-ui.js) are unchanged, re-shooting only pages whose hash moved. The trap:
the normal deploy loop is assemble-gallery.mjs → wrangler pages deploy, and neither
runs gen-og-images.mjs. So after you change an editor and redeploy, the share card
keeps showing the OLD capture — a full-canvas editor shipped for a while with an og.png of
the pre-editor knob grid. To refresh: run gen-og-images.mjs (or delete the page's
og.png + og.png.key to force a re-shoot) BEFORE deploying. A full-canvas Skia editor
only renders under the ANGLE/GPU Chrome flags, and the shot is a DEFAULT-state capture — it
will not reflect interactive states (a knob moved, an engine toggled).
4. Consistency + backfill
- Both galleries mirror the SAME example-plugin repos, so keep the OG head block
and the og.png-generation step a shared template (WAM:
gen-og.mjs's
ogBlock(); WCLAP: assemble-gallery.mjs's page emitters) so WAM and WCLAP
can't drift.
- Backfilling a demo that lacks OG images = adding the
og:image / og:url /
twitter:card=summary_large_image / twitter:image lines to the page
template + generating the colocated og.png. Social crawlers fetch the raw
HTML and do NOT run JS, so the tags must be present at rest.
5. Verify + hand off
-
Re-run screenshot_sync_check.py --mode=report → clean.
-
Vision-probe before reading any capture for a visual judgment (screenshot skill).
-
Commit the PNGs + any head-template edit in the SAME PR as the UX change, or
add a bypass trailer on the tip commit:
Screenshot-Sync: skip target=<id|all> reason="..."
Legitimate uses: a provably-invisible refactor, a docs-only touch that trips a
glob, or a batch whose shots regenerate in a follow-up. Same philosophy as
Skill-Update: skip / Version-Bump: skip.
The gate (three layers, mirrors skill-sync)
tools/scripts/screenshot_sync_check.py reads the repo's .pulp/screenshots.toml,
diffs the change range against [trigger].paths, and checks each triggered
target's consumed PNGs were refreshed (or bypassed). It DETECTS staleness; it
does not capture (capturing is heavy + non-deterministic across machines — the
re-shoot is the explicit step above). Wired exactly like the skill-sync gate:
| Layer | Where | Mode |
|---|
| 1 hint | PostToolUse (hooks/scripts/cli-plugin-sync.sh) | --mode=hint — advisory |
| 2 report | .githooks/pre-push | --mode=report — enforcing (demote with PULP_DISABLE_PREPUSH_GATES=1) |
| 3 CI | .github/workflows/version-skill-check.yml | --mode=report — authoritative, no env demotion |
The check script ships from pulp core and is installed into consumer repos the
same way the githooks are (tools/scripts/install-githooks.sh); the trigger
map is repo-local (.pulp/screenshots.toml), because each plugin repo has its
own layout.
Gotchas
- A NEW page must be registered in
gen-og-images.mjs by hand. Only the
galleries auto-discover their demos (they read the plugins = [...] array back
out of the assembled index.html). Every non-gallery page — the standalone pages,
super-convolver, super-convolver-gpu — is a hardcoded entry. Add a page to
assemble-gallery.mjs without adding it there and assemble-gallery.mjs still
bakes an absolute og:image URL for it, pointing at a PNG that nobody ever
generated. And Cloudflare serves a missing asset as its 404 page with HTTP 200,
so this does not fail as a broken link — it fails as a blank unfurl, and a
naive curl -w %{http_code} check says 200 and confirms the lie. That is the
exact bug that shipped. check-og-images.mjs (file exists, real PNG magic bytes,
aspect ≈ 1.91:1) and check-unfurl.mjs (fetch the LIVE URL as a crawler and
follow the og:image) are the backstops — but the fix is to register the page.
Doubly easy to miss when the page is emitted conditionally, like
super-convolver-gpu, which only appears when its GPU DSP build tree exists.
- Cache-bust the main-thread player entry ONLY — never the worklet processor
or the DSP module. An AudioWorklet processor's registered name is derived
from the URL it was added from, so the main thread and the worklet must agree
on exactly one URL. A
?v= on either side forks the name and the node never
constructs — the page loads, the UI paints, and there is simply no audio. The
corollary bites in the other direction too: every module the ?v= IS stamped
on must be an input to the hash (assemble-gallery.mjs's playerVersion()
hashes the shell, widgets, both adapters, and the UI entry). Add a bustable
module without adding it to the hash and its next change ships behind a stale
cache entry — an OG shot regenerated against a page that is still serving the
old bundle is the worst version of this, because it looks correct.
- A page's UI module may be optional; a screenshot of the fallback is not a
bug. The gallery assembler skips the both-ABI SuperConvolver section entirely
when the WAM build tree (
--wam-build) is absent, and builds pages with no
custom UI when the wasm UI module (--ui-build) is absent — the player then
renders its generated parameter grid. A browser with no WebGL2 context lands on
that same grid at runtime. So before re-shooting a demo, confirm which UI the
page actually mounted; otherwise you will capture the parameter grid, commit it
as the plugin's og.png, and have "wrong" screenshots with a green build.
- Wait for the player's SETTLE signal, not for the canvas element.
mountPulpUi appends the canvas immediately and then fetches the wasm module,
so "the canvas exists" is true a full second before anything is drawn on it.
Waiting on it shoots the loading screen. The real signal is the loading class
being gone (.pw-customui-loading). gen-og-images.mjs additionally
refuses to shoot if that class is still present at timeout — a card is
worse than no card, because a blank unfurl looks like a broken product and
nothing in the build is red.
- Check the og.png's PIXELS, not its existence. A loading-screen card is a
well-formed PNG of the right size; only its content is wrong.
check-og-images.mjs
decodes it and requires ≥2% bright pixels in the control band — the live broken
card measured 0.60%, every real one 4.2–7.0%. And note Cloudflare serves a
missing asset as its 404 page with HTTP 200, so a fetch-and-check-status
probe confirms the lie; you have to look at the bytes.
- The shot is expensive; cache it on a content hash. Hash the page dir (plus
the player bundle) into
og.png.key and skip when it matches: 78 s → 2 s across
a 30-page site when nothing moved. Verify the invalidation too — a cache that
never busts silently ships yesterday's UI.
- A brotli
Content-Encoding on a plain-copied file breaks the page, not just
the byte count. The GPU page's 10.4 MB .data was copied raw while _headers
claimed brotli; the browser failed to decode it, the UI module never loaded, and
the only symptom anyone saw was an OG card reading "Loading editor…". One shared
copyUiFile() compresses every page's copy — don't hand-roll a second copy path.
- CoreGraphics can't composite file images → filename-as-text placeholders;
use the Skia backend for anything with assets.
- Headless GPU → the offscreen
gpu backend, not the live host (which hangs
headless).
- WCLAP needs cross-origin isolation (COOP/COEP) to instantiate; WAM does
not — different serve step per route. Serve WCLAP with
serve-headers.mjs.
- Social crawlers don't run JS — OG tags must be static in the head at rest.
- Demo-site HTML/OG templates for WAM live in the consumer repos
(
pulp-example-plugins / pulp-classic-effects web/gen-og*.mjs); the WCLAP
combined-site template lives in pulp core (cloudflare/assemble-gallery.mjs).
- This is a porting/consistency tool. It re-shoots the repo's own UI. Never
fabricate or copy another product's screenshots.
Landmine: the OG bake is a TWO-PASS assemble — pass BOTH passes the same build trees
gen-og-images.mjs shoots each page, then the site assembler runs again to bake the
og:image / twitter block into the HTML — a page gets its tags only when its og.png already
exists on disk, which is only true on that second pass.
Run the second pass bare (no --wam-build / --ui-build / --gpu-build) and the assembler
cannot find the build trees, so it skips every page that needs one — quietly, in a one-line
note — and therefore never rewrites their HTML, which is the only thing that pass exists to do.
/super-convolver-gpu/'s og.png was shot perfectly, then never referenced: the page shipped
with no og:image at all and unfurled bare. The image existing on disk proves nothing;
check the HTML.