Three lanes, in order of effort. Real captures read as proof; recreations read as ads — always prefer capturing the actual site.
Every capture, whatever the lane, ends the same way: re-encode with dense
keyframes (-g 30 -keyint_min 30) so seeks don't freeze in the renderer, then
frame-extract and LOOK at every take before using it.
Lane A — Full-page screenshots (shot-cards, docs, blog "proof" beats)
A1. Playwright MCP (zero code — use it if the client has it connected)
If the Claude Code session has a Playwright (or browser) MCP available:
navigate to the URL, wait for load, take a full-page screenshot, save it into
assets/shots/. Good enough for most shot-cards. Take at the largest viewport
available and capture more height than you need — crop later.
A2. Scripted (bundled): scripts/capture-shots.mjs
Puppeteer script — edit the TARGETS list (name, url, capture height) and run
node scripts/capture-shots.mjs. What it encodes:
- Real desktop UA + 1440×900 viewport at
deviceScaleFactor: 2(crisp 2x shots). - A widget killer injected twice (cookie/consent/chat/intercom/banner/dialog
selectors hidden with
display:none !important) — once after load, once after the scroll pass, because chat widgets re-mount. - A lazy-load scroll pass (step to the bottom, settle, back to top) so images and deferred sections are actually painted before the shot.
- Clipped full-page screenshot (
clipto the min of wanted height vs body height).
Review every PNG. Common junk that survives: keychain popups (from a
Chrome-for-Testing profile — use system Chrome + a fresh userDataDir +
ignoreDefaultArgs:["--enable-automation"]), region banners, half-loaded heroes.
Lane B — Scripted scroll recordings (clean site B-roll, 3–6s clips)
The branded-ad-edit skill's references/design.md §3 carries the full recipe:
kiosk system-Chrome, scripted eased scrolling (never native jerky scroll),
recorded or screencast, cut into 3–6s clips. Key gotchas:
- Scroll the smallest matching node — a text anchor can match an ancestor div whose scrollTop never moves (the scroll silently no-ops).
- Sweep junk selectors on an interval during the take.
- Frame-extract-review EVERY take.
Lane C — Screen Studio / real-browser recording (highest fidelity, or when automation is blocked)
Some sites (Cloudflare and friends) block kiosk/fresh-profile Chrome outright. The proven fallback: record the user's REAL logged-in browser.
- Drive the real browser — a browser-extension MCP (e.g. claude-in-chrome) or the user by hand — through scripted, eased scrolls of each target page.
- Sync markers: flash the page pure white for ~0.3s before each take
(
document.body.style.filteror an injected overlay). Markers make take boundaries findable in one pass. - Record the display with Screen Studio (
screenstudio-cliif installed — checkscreenstudio --help) or any screen recorder. Screen Studio's polish (smooth cursor, auto-zoom) is why it's worth using when available. - Find the markers afterwards: invert the video and run black-frame detection —
ffmpeg -i cap.mp4 -vf "negate,blackdetect=d=0.1:pix_th=0.10" -f null -— each detected "black" run is a white flash; cut takes between markers. - Crop off the chrome: browser toolbar and the Dock/menu bar must go — e.g. a
1920×1080 display capture cropping to
1920×816aty=176keeps only the page. Compute your crop from a test frame, verify by eye. - Re-encode dense keyframes; clip into 3–6s beats.
Traps learned the hard way
- CDP on :9222 may not be the user's browser. Screen Studio runs its own
embedded Chromium that can occupy the debug port (UA
ScreenStudio/…,Target.createTargetunsupported). If an automation script suddenly sees a logged-out web, check WHOSE browser it attached to. - Inner-container scroll areas (docs sites) often defeat page-level scrolling — takes come out static. Target the container, or skip that page.
- Never enter credentials for the user; if a login wall appears, hand the browser to them and resume after.
Lane D — Public social posts and reaction GIFs without a login (2026-09-28)
- X posts render through the public embed page
https://platform.twitter.com/embed/Tweet.html?dnt=true&id=<id>&lang=en&theme=light&hideThread=true(Puppeteer, 420 px viewport, device scale 2 so the text wraps large enough for a phone). Clip from theUser-Namerow to the firsttweetText(the last one is a quoted post). Triage a post before capturing it withhttps://cdn.syndication.twimg.com/tweet-result?id=<id>&token=a: text, likes, the parent post,photos[]urls andvideo.variants[](pick the highest bitrate; portrait 1080×1920 variants exist for phone-shot posts and drop straight in as takeovers). - Reaction GIFs: GIPHY's published beta key is banned and Tenor's API is discontinued. giphy.com's
own search calls
api.giphy.com/v1/gifs/searchwith a key visible in its page requests; read it from a headless page (page.on("request")) and search from there (rating pg-13), then loop the mp4 to the beat with-stream_loop. Prefer brand-, artist- and creator-made loops over film and TV frames, and check the loop's last frame for a channel watermark before using it. - Scroll recordings: when Playwright's browser is missing, Puppeteer's
page.screencast()at a 540×960 viewport with device scale 2 gives a clean 30 fps portrait recording; re-encode with a dense GOP before the comp.
Where captures go
assets/shots/*.png for stills, media/broll/*.mp4 for clips. In compositions,
stills usually display as a sharp card floating over a blurred blow-up of
itself, or inside a browser-chrome frame (see branded-ad-edit
references/design.md).
More traps (profile captures, 2026-09)
- A TypeScript runner can poison
page.evaluate. tsx/esbuild injects a__namehelper into any function it serialises; inside the page that helper does not exist and the evaluate throws__name is not defined. Pass page scripts as strings, not functions. - Some platforms never render their grid for automation (TikTok: "Something went wrong", survives Refresh clicks and long waits). The profile header still renders — take the still, and carry the scrolling beat with another platform's grid.
- A screencast that "worked" can be static. Instagram's grid lives in an inner scroll
container; page-level
scrollTono-ops and every frame is identical. Frame-tile every take before using it — the tile is how this was caught. - Check the handle before blaming the tool. Two 404s were a handle mismatch (the creator's handle differs by a dot between platforms), not blocking.
- An authenticated dashboard capture can violate a client rule. An Ads Manager screenshot shows real account IDs and campaign names; treat it as unusable unless cropped to chrome.