fix(engine): pin capture surface scale to 1 device pixel per CSS pixel - #3782
fix(engine): pin capture surface scale to 1 device pixel per CSS pixel#3782veogeek-no1 wants to merge 1 commit into
Conversation
Capture reads the browser's compositor surface (`Page.captureScreenshot`
with `fromSurface: true`, and `HeadlessExperimental.beginFrame` likewise),
and that surface is rasterized at the host display's backing scale.
`page.setViewport({ deviceScaleFactor })` goes through
`Emulation.setDeviceMetricsOverride`, which only moves
`window.devicePixelRatio` on the page side — it does not resize the
surface.
On a HiDPI host every render therefore came out at twice the composition:
a 1920x1080 composition encoded to a 3840x2160 file, and no flag could
bring it down, because the pipeline's own deviceScaleFactor was already 1.
Supersampling stacked on top of it, so `--resolution landscape-4k`
produced 7680x4320 rather than 3840x2160.
Pinning the surface with `--force-device-scale-factor=1` leaves the
deliberate supersampling path (the capture clip's `scale`, from
`resolveDeviceScaleFactor`) as the only thing that multiplies output
pixels. The flag is a no-op on Linux, where the surface is already 1x, so
CI and cloud output are unchanged.
|
@miguel-heygen — flagging you as the main author of Short version: capture reads the compositor surface, which is rasterized at the host display's backing scale, while The Puppeteer probe table in the description isolates it: the emulated DSF does not change output size at all, only the launch flag does. Fix is one argument in Happy to split out the two follow-ups I noted ( |
What
Add
--force-device-scale-factor=1tobuildChromeArgs(), pinning the browser's compositor surface to one device pixel per CSS pixel.Why
On a HiDPI host every render came out at twice the composition's resolution, and no flag could bring it back down.
A composition declaring
data-width="1920" data-height="1080"encoded to a 3840×2160 file.hyperframes inforeportedResolution 1920x1080,resolveDeviceScaleFactor()returned1, andcompileStagecomputedoutputWidth/outputHeightas1920×1080— every internal value was already correct. Passing--resolution landscapeor--resolution 1080pchanged nothing, because those resolve to a scale of exactly 1 for a 1920-wide composition. There was no setting that produced 1× output.The cause is that capture reads the browser surface —
pageScreenshotCaptureusesPage.captureScreenshotwithfromSurface: true, and thebeginframepath reads the same surface — and that surface is rasterized at the host display's backing scale.page.setViewport({ deviceScaleFactor })goes throughEmulation.setDeviceMetricsOverride, which only moveswindow.devicePixelRatioon the page side; it does not resize the surface. So output pixels werecompositionWidth × clip.scale × hostSurfaceScale, with the last factor invisible to the pipeline.Deliberate supersampling stacked on top of it:
--resolution landscape-4kon a Retina host produced 7680×4320, not 3840×2160.Confirmed with a standalone Puppeteer probe against the same Chrome binary and the same launch args:
--force-device-scale-factor=1--force-device-scale-factor=2--force-device-scale-factor=1The emulated DSF does not affect output size at all; the host surface scale is the whole defect.
How
One argument in
buildChromeArgs(), which is the single arg builder for every launch path (render, snapshot, studio thumbnails, validate, layout, motionShot, distributed chunks), so one line covers them all.After the change, the capture clip's
scale— derived fromresolveDeviceScaleFactor()— is the only thing that multiplies output pixels, which is what the resolution presets were always meant to control.The flag is a no-op on Linux, where the surface is already 1×, so CI and cloud output are unchanged.
Test plan
Both binaries built from this tree (
bun run build, thennode packages/cli/dist/cli.js). "Before" is the identical build with only the new argument stripped from the bundle, so the comparison isolates exactly this change. Same project and flags in both runs; composition is 1920×1080, 10.1 s, 304 frames.Frame content verified visually at t=4 s — full frame, correct framing, overlays intact, no crop. Supersampling still works:
--resolution landscape-4kon the same project now yields exactly 3840×2160.buildChromeArgsasserts the flag ondarwin,win32andlinux; swept over every platform because the surface scale, not the platform, is the defectTargeted suites run (not the full tree):
packages/engine—vitest run src/services/browserManager.test.ts src/services/screenshotService.test.ts→ 88 passed;tsc --noEmitcleanpackages/producer—bun test .../compileStage.test.ts .../distributed/plan.test.ts→ 59 passed;vitest run .../captureStage.test.ts .../probeStage.test.ts→ passedpackages/parsers—vitest run src/outputResolutionCompatibility.test.ts→ 12 passedpackages/cli—vitest run src/capture/→ 234 passedNotes for reviewers
Two things this fix makes visible rather than causes, both left out to keep the patch minimal:
snapshot --zoomloses density on macOS.captureRegionCrop(packages/cli/src/capture/captureCompositionFrame.ts) raises the viewport DSF and callspage.screenshot({ clip })without aclip.scale. Per the probe table, viewport DSF does nothing to output size — so those crops were only high-density by accident on Retina, and are already 1× on Linux today. After this change macOS matches Linux, i.e.--zoom-scaleis a no-op everywhere. The honest repair is to passscaleinside the clip and drop the viewport dance;studioServer.ts'sthumbnailDeviceScaleFactorhas the same shape.compare/grade-comparebaselines captured on a Retina host will shift.Also unverified:
HeadlessExperimental.beginFrametakes no clip or scale, so supersampling on the Linux BeginFrame path may be separately broken. Out of scope here and not testable on the host I have (Apple Silicon Retina); Windows HiDPI is likewise reasoned about rather than measured.