CAPTURE.md: real screenshots and recordings for scenes
This document is the browser path, which is the path this project exists for. Everything here assumes your frontend loads and works in Chrome under Playwright. That is a hard requirement, not a preference: if a human cannot click through your app in Chrome, none of what follows applies and no setting changes it.
Binding for every scene with captureMethod: "screenshot" or
"recording" (see PLANNING.md). This protocol exists because
uncorrected captures reliably come out small, soft, or bleed in content
from a neighboring page section; here's why, and the fix. This is also
what the capture_screenshot MCP tool does internally and why: if a
capture from that tool ever comes out wrong, this is the mechanism to
debug against.
The zoom-desync bug
Playwright MCP's browser_resize sets a requested viewport, but a
browser profile can carry a per-origin zoom level (set once, persisted,
invisible in the tool output) that desyncs the requested size from the
effective CSS layout Chrome actually renders. Confirmed on a real machine:
requesting 1600x1000 rendered at window.innerWidth/innerHeight of
2000x1250 (zoom = 0.8). This is a consistent ratio, not noise, so it's
fully correctable, not something to route around by guessing crop
coordinates.
Never trust a resize at face value. Always verify.
The other three things that make a capture wrong
Zoom is the subtlest of four, not the only one. The rest are handled automatically and are documented here so that a capture that still looks wrong can be debugged against the real mechanism rather than guessed at.
Resolution. A capture is placed on a 1920x1080 stage and then a camera pushes into it,
so a capture taken at 1x is upscaled twice over and arrives soft. Captures default to
deviceScaleFactor: 2. Measured on a real page at the size the renderer shows it, 2x keeps
about 1.6x the fine detail. Pass 1 only when you want the old behaviour.
Timing. A page is not finished when load fires. Webfonts are still swapping, images
are still decoding, and any entrance transition is mid-flight. Capturing then produces a
frame no real user ever sees: fallback type, a blank hero, a panel frozen at 40% opacity
halfway through its fade. Every capture therefore settles first, waiting on
document.fonts.ready, on images to decode, and on every running animation to reach its
end.
There is one trap in that last wait and it is worth knowing about: an infinite animation
never finishes. A looping spinner, a pulsing dot, a permanently drifting gradient all
return a finished promise that never resolves. Waiting on them indiscriminately would
burn the full timeout on most real pages. They are filtered out by their computed timing,
which is also correct on the merits, because a loop has no settled state to wait for. The
result reports how many were skipped, so a page that seems to settle instantly can be
checked rather than trusted.
Use the settle interaction to force the same wait mid-script, after a click that opens a
modal or triggers a route change. Use settle: false on the tool only when catching a page
mid-transition is the actual point of the beat.
Encoding. x264's defaults are tuned for camera footage: grain, motion, no hard edges. A
screen is the opposite, and the default crf 23 puts visible ringing around text. Every
capture path in this project encodes with crf 18 and -tune stillimage instead.
Protocol
browser_resizeto your target size,browser_navigate, thenpage.evaluate(() => [window.innerWidth, window.innerHeight]). If it doesn't match what you requested, you have a desync.zoom = requestedWidth / actualInnerWidth.- Re-request a compensated viewport:
browser_resize(targetW * zoom, targetH * zoom). Verify again:innerWidth/innerHeightshould now equaltargetW/targetHexactly. This is the size you actually wanted, once compensated. - Interact with the page (click, select, scroll) to reach the exact state the beat needs, in this same compensated viewport.
- Take a full-viewport screenshot (
scale: "css", nofullPage, no element target, see below for why not element-scoped). This screenshot is physicallytargetW*zoom x targetH*zoompx and containstargetW x targetHworth of real layout, shrunk byzoom. - Measure the DOM rect of exactly the content the beat wants, with
el.getBoundingClientRect(), in the same effective CSS space as step 2. Don't eyeball pixel coordinates off the screenshot; measure the real element. - Run
python scripts/crop-shot.py <raw.png> <out.png> <x> <y> <w> <h> <zoom>(the rect from step 5, the zoom from step 1/2). It crops the physical screenshot atrect * zoom, then upscales the crop back torect's own size with Lanczos resampling, landing on a pixel-accurate, unshrunk capture with no bleed, because the crop rect came from the DOM, not a guess. Readthe output PNG before using it in a scene. Confirm by eye: no cut-off text, no chrome from an unrelated section, correct state selected.
Caveat when skipping cropSelector: capture_screenshot's output, with
no cropSelector given, is exactly the compensated viewport's own pixel
size (steps 1-2 above), not necessarily the size you originally requested.
On a machine with a real zoom desync (zoom != 1), that means the saved
image comes out smaller, and reads softer once a scene scales it back up,
than the size you asked for. Passing cropSelector sidesteps this because
the crop-and-upscale step (6 below) always lands back on the DOM rect's
own true size regardless of the measured zoom; without it, check the
tool's reported zoom before trusting the raw image at face value.
Why not page.locator(selector).screenshot() (element-scoped): it
re-measures and can auto-scroll the element into view at shot time,
independent of what you measured a moment earlier. That mismatch produces
a real bleed bug: an element-scoped shot picking up part of the next
section down. The full-viewport-then-crop approach in steps 4-6 is
deterministic: one screenshot, one measurement, one crop, no hidden
re-layout between them.
Screen recordings
For captureMethod: "recording" beats, the same zoom-desync check in step
1 still applies before you start recording: verify the effective viewport
matches what you requested before you hit record, since the desync is set
once per origin and persists for the whole session. v1 scope is fixed
full-viewport recordings only: capture at the compensated viewport size
and composite the whole frame (no post-hoc DOM-rect cropping of a moving
recording, that's future work, see PIPELINE.md).
Sizing the frame from the capture
Once you have a clean capture, size BrowserFrame to the screenshot's
native aspect so the <Img> (or OffthreadVideo, for a recording)
needs no distortion:
FRAME_W = <capture width>
FRAME_H = <capture height> + 56 // BrowserFrame's chrome bar is 56px, added on top of content
LEFT = (1920 - FRAME_W) / 2
TOP = (1080 - FRAME_H) / 2
This centers the frame on the 1920x1080 stage exactly: LEFT + FRAME_W/2
is always 960 by construction. Push the camera to 115-160% (STYLE.md's
120-250% range) with x=960 as the base; adjust y only if the beat's
important content isn't at the frame's own vertical center.
Check the bottom edge before locking a static crop. A static camera
crop that looks fine at scale 1.0 can push important content off the
bottom of frame once you zoom in on it. If a screenshot is taller than
what one crop can show at a good zoom level, animate the camera y across
the beat instead of picking one static point: pan from the top
(headline/context) to the bottom (the payoff) rather than compressing
everything into a single frame that's forced to zoom out to fit it all.