capture-catalog-previews
You turn the react-lua UI-Labs stories of a taste (in preview-place/ or
e2e/<slug>/) into the committed preview PNGs at
design-md/<slug>/previews/*.png. The site catalog reads that folder: an entry
with previews is published (badge "✓ verified in-engine", HUD thumbnail); an
entry with none shows "preview pending" (site/src/lib/catalog.mjs,
published: previews.length > 0).
The one hard fact about capturing (do not re-derive it)
Rendered Studio pixels reach disk ONLY via macOS screencapture of the
Studio window while that window is frontmost AND actively rendering, then a
magick crop. These were all investigated and rejected — do NOT waste a
session re-trying them:
- MCP
screen_capture returns image bytes to the agent's context only; there
is no disk file.
CaptureService:CaptureScreenshot yields an in-memory rbxtemp:// id; the
raw pixels are inaccessible from Luau/disk.
- MCP
store_image/upload_image push images INTO Studio/asset-server, they
don't save renders out.
screencapture -l<windowId> of a BACKGROUNDED Studio window returns a blank
frame — Studio pauses play-mode rendering when its window is not
frontmost. So there is no "capture it while it's behind" trick.
- Orca
computer-use on this macOS provider cannot focus/raise windows and
often enumerates only one of several Studio windows.
The one lever that removes the babysitting: osascript (System Events) can
AXRaise + set a specific Studio window frontmost itself, so the agent
raises Studio right before each shot instead of asking the user to hold it in
front. If the taste's Studio window is on a different display than the user's
active work, captures don't disturb them.
Non-negotiables
- Verify EVERY captured frame by Reading the PNG before keeping it. A
focus race (another window steals front between raise and capture)
photographs the user's other windows — a privacy incident. This happened;
it was caught only by the read-back. Contaminated frames are usually much
larger/noisier on disk (a clean UI shot is ~20–110 KB; a desktop-clutter
shot was 600 KB+) — a useful smell test, but never a substitute for
Reading each one. Delete any contaminated frame on the spot; never commit it.
- Raise + capture must be ATOMIC — one shell invocation that re-raises
immediately before every shot (
references/capture.sh does this). A bare
screencapture without a fresh raise shoots whatever is in front.
- Crop offsets drift across Studio restarts / panel-layout changes —
recalibrate on the first shot of every batch (see step 3). Never assume a
remembered offset.
- The machine must be otherwise idle on that display during a batch;
agree a short window with the user (unless Studio is on a display they
aren't using).
Prerequisites (have the user confirm)
- Roblox Studio open with the taste's
src synced (argon serve) and the
UI-Labs storybook available; the Studio MCP connected (Roblox_Studio).
- Device emulation set to 1280×720 (Studio Test tab → Device). The emulated
screen renders 1:1 centered in the viewport; you crop exactly it → a clean
1280×720 with no Studio chrome. This 1280×720 + the Roblox topbar visible is
the catalog convention (match the existing
cartoon-chunky/previews/*).
Workflow
- Set the active Studio (
list_roblox_studios → set_active_studio; there
may be several — pick the one running THIS taste) and start_stop_play(true).
- Find the target window's bounds (also the title substring to match):
osascript -e 'tell application "System Events" to get {name, position, size} of every window of (every process whose name is "RobloxStudio")'
Note the region X,Y,W,H of the taste's window (title contains the .rbxl
name). Two same-named RobloxStudio processes can coexist — match by title.
- Calibrate the crop once. Mount one story (step 4a), then raise+capture the
whole window region to a RAW file, Read it downscaled, and find where the
1280×720 emulated screen sits inside it (its dusk/backdrop rectangle vs. the
near-black viewport). Probe edges with
magick raw.png -format "%[pixel:p{X,Y}]" info: and settle a
CROP=1280x720+<offsetX>+<offsetY>. (On a 1× display the emulated screen is
1280×720 px directly; on a 2× retina display it is 2560×1440 px — crop that
and -resize 1280x720.)
- Per story, loop:
a. Mount it via MCP
execute_luau (datamodel Client) — destroy any
prior StoryPreview, make a ScreenGui (ZIndexBehavior = Sibling,
IgnoreGuiInset = true), ReactRoblox.createRoot():render(story.story{ controls = story.controls }),
task.wait(0.6) (0.8–1.0 for window stories, which can render a frame
late — a mistimed shot shows only the blurred world; recapture once).
b. Capture + crop with the atomic helper:
references/capture.sh "<TITLE>" "<X,Y,W,H>" "<CROP>" design-md/<slug>/previews/<name>.png
c. Read the PNG and verify it shows only the mounted story. Re-shoot on
contamination or mistiming.
- Standard filenames (kebab-case by story; the site uses
hud.png as the
card thumbnail): hud, window, shop, settings, buttons, currency-bar, progress-bar, tokens, dialog (+ any taste-specific extras).
- Clean up: destroy the mounted
StoryPreview, start_stop_play(false),
delete raw/scratch capture files.
- Commit + push
design-md/<slug>/previews/. Confirm the loader now reports
published: true (run loadCatalog() from site/src/lib/catalog.mjs). The
deploy-site.yml workflow redeploys on the push.
References
references/capture.sh — the atomic raise + screencapture + crop helper.
tools/capture-rig/README.md (repo root) — the canonical MCP visual-QA
runbook and the "Persisting previews to disk" recipe + findings log; add new
gotchas there as you learn them.
1---2name: capture-catalog-previews3description: Capture Roblox Studio UI-Labs story renders to PNG files on disk for a taste's design-md/<slug>/previews/, which flips a catalog entry from "preview pending" to verified/published. Use when the user wants to generate or regenerate catalog preview images for a taste, asks why an entry has no thumbnail, or says previews are missing.4---56# capture-catalog-previews78You turn the react-lua UI-Labs stories of a taste (in `preview-place/` or9`e2e/<slug>/`) into the committed preview PNGs at10`design-md/<slug>/previews/*.png`. The site catalog reads that folder: an entry11with previews is `published` (badge "✓ verified in-engine", HUD thumbnail); an12entry with none shows "preview pending" (`site/src/lib/catalog.mjs`,13`published: previews.length > 0`).1415## The one hard fact about capturing (do not re-derive it)1617**Rendered Studio pixels reach disk ONLY via macOS `screencapture` of the18Studio window while that window is frontmost AND actively rendering, then a19`magick` crop.** These were all investigated and rejected — do NOT waste a20session re-trying them:2122- MCP `screen_capture` returns image bytes to the agent's context only; there23 is no disk file.24- `CaptureService:CaptureScreenshot` yields an in-memory `rbxtemp://` id; the25 raw pixels are inaccessible from Luau/disk.26- MCP `store_image`/`upload_image` push images INTO Studio/asset-server, they27 don't save renders out.28- `screencapture -l<windowId>` of a BACKGROUNDED Studio window returns a blank29 frame — **Studio pauses play-mode rendering when its window is not30 frontmost.** So there is no "capture it while it's behind" trick.31- Orca `computer-use` on this macOS provider cannot focus/raise windows and32 often enumerates only one of several Studio windows.3334The one lever that removes the babysitting: **`osascript` (System Events) can35`AXRaise` + set a specific Studio window frontmost itself**, so the agent36raises Studio right before each shot instead of asking the user to hold it in37front. If the taste's Studio window is on a *different display* than the user's38active work, captures don't disturb them.3940## Non-negotiables41421. **Verify EVERY captured frame by Reading the PNG before keeping it.** A43 focus race (another window steals front between raise and capture)44 photographs the user's other windows — a privacy incident. This happened;45 it was caught only by the read-back. Contaminated frames are usually much46 larger/noisier on disk (a clean UI shot is ~20–110 KB; a desktop-clutter47 shot was 600 KB+) — a useful smell test, but never a substitute for48 Reading each one. Delete any contaminated frame on the spot; never commit it.492. **Raise + capture must be ATOMIC** — one shell invocation that re-raises50 immediately before every shot (`references/capture.sh` does this). A bare51 `screencapture` without a fresh raise shoots whatever is in front.523. **Crop offsets drift** across Studio restarts / panel-layout changes —53 recalibrate on the first shot of every batch (see step 3). Never assume a54 remembered offset.554. **The machine must be otherwise idle on that display** during a batch;56 agree a short window with the user (unless Studio is on a display they57 aren't using).5859## Prerequisites (have the user confirm)6061- Roblox Studio open with the taste's `src` synced (argon serve) and the62 UI-Labs storybook available; the Studio MCP connected (`Roblox_Studio`).63- **Device emulation set to 1280×720** (Studio Test tab → Device). The emulated64 screen renders 1:1 centered in the viewport; you crop exactly it → a clean65 1280×720 with no Studio chrome. This 1280×720 + the Roblox topbar visible is66 the catalog convention (match the existing `cartoon-chunky/previews/*`).6768## Workflow69701. **Set the active Studio** (`list_roblox_studios` → `set_active_studio`; there71 may be several — pick the one running THIS taste) and `start_stop_play(true)`.722. **Find the target window's bounds** (also the title substring to match):73 ```sh74 osascript -e 'tell application "System Events" to get {name, position, size} of every window of (every process whose name is "RobloxStudio")'75 ```76 Note the region `X,Y,W,H` of the taste's window (title contains the .rbxl77 name). Two same-named RobloxStudio processes can coexist — match by title.783. **Calibrate the crop once.** Mount one story (step 4a), then raise+capture the79 whole window region to a RAW file, Read it downscaled, and find where the80 1280×720 emulated screen sits inside it (its dusk/backdrop rectangle vs. the81 near-black viewport). Probe edges with82 `magick raw.png -format "%[pixel:p{X,Y}]" info:` and settle a83 `CROP=1280x720+<offsetX>+<offsetY>`. (On a 1× display the emulated screen is84 1280×720 px directly; on a 2× retina display it is 2560×1440 px — crop that85 and `-resize 1280x720`.)864. **Per story, loop:**87 a. **Mount it** via MCP `execute_luau` (datamodel `Client`) — destroy any88 prior `StoryPreview`, make a `ScreenGui` (`ZIndexBehavior = Sibling`,89 `IgnoreGuiInset = true`), `ReactRoblox.createRoot():render(story.story{ controls = story.controls })`,90 `task.wait(0.6)` (0.8–1.0 for window stories, which can render a frame91 late — a mistimed shot shows only the blurred world; recapture once).92 b. **Capture + crop** with the atomic helper:93 `references/capture.sh "<TITLE>" "<X,Y,W,H>" "<CROP>" design-md/<slug>/previews/<name>.png`94 c. **Read the PNG and verify** it shows only the mounted story. Re-shoot on95 contamination or mistiming.965. **Standard filenames** (kebab-case by story; the site uses `hud.png` as the97 card thumbnail): `hud, window, shop, settings, buttons, currency-bar,98 progress-bar, tokens, dialog` (+ any taste-specific extras).996. **Clean up**: destroy the mounted `StoryPreview`, `start_stop_play(false)`,100 delete raw/scratch capture files.1017. **Commit + push** `design-md/<slug>/previews/`. Confirm the loader now reports102 `published: true` (run `loadCatalog()` from `site/src/lib/catalog.mjs`). The103 `deploy-site.yml` workflow redeploys on the push.104105## References106107- `references/capture.sh` — the atomic raise + screencapture + crop helper.108- `tools/capture-rig/README.md` (repo root) — the canonical MCP visual-QA109 runbook and the "Persisting previews to disk" recipe + findings log; add new110 gotchas there as you learn them.