diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index 20592af4e..331ba4657 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -96,6 +96,7 @@ export { syncVideoFrameVisibility, cdpSessionCache, initTransparentBackground, + initOpaqueBackgroundOverride, captureAlphaPng, applyDomLayerMask, removeDomLayerMask, diff --git a/packages/engine/src/services/browserManager.test.ts b/packages/engine/src/services/browserManager.test.ts index d5d9202ff..3a65a97e5 100644 --- a/packages/engine/src/services/browserManager.test.ts +++ b/packages/engine/src/services/browserManager.test.ts @@ -66,6 +66,28 @@ describe("buildChromeArgs browser GPU mode", () => { }); }); +describe("buildChromeArgs — window size height buffer (#1699)", () => { + const base = { width: 1920, height: 1080 }; + + it("inflates --window-size height when using system Chrome (no headless-shell)", () => { + const args = buildChromeArgs({ ...base, headlessShell: false }); + const windowSize = args.find((a) => a.startsWith("--window-size=")); + expect(windowSize).toBe("--window-size=1920,1280"); + }); + + it("uses exact --window-size height when using headless-shell", () => { + const args = buildChromeArgs({ ...base, headlessShell: true }); + const windowSize = args.find((a) => a.startsWith("--window-size=")); + expect(windowSize).toBe("--window-size=1920,1080"); + }); + + it("defaults to inflated height when headlessShell is not specified", () => { + const args = buildChromeArgs(base); + const windowSize = args.find((a) => a.startsWith("--window-size=")); + expect(windowSize).toBe("--window-size=1920,1280"); + }); +}); + describe("resolveBrowserGpuMode", () => { beforeEach(() => { _resetAutoBrowserGpuModeCacheForTests(); diff --git a/packages/engine/src/services/browserManager.ts b/packages/engine/src/services/browserManager.ts index c4004c814..b3f01e719 100644 --- a/packages/engine/src/services/browserManager.ts +++ b/packages/engine/src/services/browserManager.ts @@ -538,6 +538,8 @@ export interface BuildChromeArgsOptions { height: number; captureMode?: CaptureMode; platform?: NodeJS.Platform; + /** True when launching chrome-headless-shell (no window decorations). */ + headlessShell?: boolean; } const CANVAS_DRAW_ELEMENT_FEATURE_FLAG = "--enable-features=CanvasDrawElement"; @@ -552,6 +554,14 @@ export function buildChromeArgs( const browserGpuMode = gpuDisabled ? "software" : (config?.browserGpuMode ?? DEFAULT_CONFIG.browserGpuMode); + // chrome-headless-shell has no window decorations; system Chrome's "new + // headless" mode on macOS/Windows creates a virtual window whose outer size + // includes title bar + tab strip (~85px on macOS). --window-size sets the + // OUTER size, so the compositor surface ends up shorter than the viewport + // set by page.setViewport(). Inflate the window height so the content area + // is always >= the requested viewport, preventing the page background from + // bleeding into the bottom of captured frames (#1699). + const windowHeightBuffer = options.headlessShell ? 0 : 200; // Chrome flags tuned for headless rendering performance. The set below is a // fairly standard "headless-for-capture" configuration — similar profiles // appear in Puppeteer's defaults, Playwright, Remotion, and Chrome's own @@ -566,7 +576,7 @@ export function buildChromeArgs( ...getBrowserGpuArgs(browserGpuMode, platform), "--font-render-hinting=none", "--force-color-profile=srgb", - `--window-size=${options.width},${options.height}`, + `--window-size=${options.width},${options.height + windowHeightBuffer}`, // Prevent Chrome from throttling background tabs/timers — critical when the // page is offscreen during headless capture "--disable-background-timer-throttling", diff --git a/packages/engine/src/services/frameCapture.ts b/packages/engine/src/services/frameCapture.ts index 011660056..a2cf4b758 100644 --- a/packages/engine/src/services/frameCapture.ts +++ b/packages/engine/src/services/frameCapture.ts @@ -28,6 +28,7 @@ import { getCdpSession, pageScreenshotCapture, initTransparentBackground, + initOpaqueBackgroundOverride, } from "./screenshotService.js"; import { DEFAULT_CONFIG, type EngineConfig } from "../config.js"; import type { @@ -361,7 +362,12 @@ export async function createCaptureSession( browserTimeout: config?.browserTimeout, }); const chromeArgs = buildChromeArgs( - { width: options.width, height: options.height, captureMode: preMode }, + { + width: options.width, + height: options.height, + captureMode: preMode, + headlessShell: !!headlessShell, + }, { ...config, browserGpuMode: resolvedGpuMode }, ); @@ -1043,6 +1049,11 @@ export async function initializeSession(session: CaptureSession): Promise // `[data-composition-id]{background:transparent !important}` injection. if (session.options.format === "png") { await initTransparentBackground(session.page); + } else { + // For opaque captures, override the default canvas background to + // transparent as defense-in-depth against surface-height mismatches + // (#1699). The page's own CSS backgrounds are unaffected. + await initOpaqueBackgroundOverride(session.page); } await armStaticDedup(session, session.page, logInitPhase); diff --git a/packages/engine/src/services/screenshotService.test.ts b/packages/engine/src/services/screenshotService.test.ts index d0d42c7bd..553ff3c89 100644 --- a/packages/engine/src/services/screenshotService.test.ts +++ b/packages/engine/src/services/screenshotService.test.ts @@ -5,6 +5,7 @@ import { type Page } from "puppeteer-core"; import { pageScreenshotCapture, cdpSessionCache, + initOpaqueBackgroundOverride, injectVideoFramesBatch, syncVideoFrameVisibility, } from "./screenshotService.js"; @@ -121,6 +122,19 @@ describe("pageScreenshotCapture supersample plumbing", () => { }); }); +describe("initOpaqueBackgroundOverride (#1699)", () => { + it("sets the default background color to transparent via CDP", async () => { + const send = vi.fn().mockResolvedValue({}); + const page = makeFakePageWithCdp(send); + + await initOpaqueBackgroundOverride(page); + + expect(send).toHaveBeenCalledWith("Emulation.setDefaultBackgroundColorOverride", { + color: { r: 0, g: 0, b: 0, a: 0 }, + }); + }); +}); + describe("injectVideoFramesBatch replacement layout", () => { it("does not copy opposing inset constraints onto the injected frame image", async () => { const { window, document } = parseHTML( diff --git a/packages/engine/src/services/screenshotService.ts b/packages/engine/src/services/screenshotService.ts index 9e15c0773..545ed7651 100644 --- a/packages/engine/src/services/screenshotService.ts +++ b/packages/engine/src/services/screenshotService.ts @@ -145,6 +145,30 @@ export async function pageScreenshotCapture(page: Page, options: CaptureOptions) return Buffer.from(result.data, "base64"); } +/** + * Set the page's default background color to transparent for opaque capture + * sessions. Unlike `initTransparentBackground` (which also injects CSS to + * force composition roots transparent — needed for alpha/HDR capture), this + * only overrides the browser's *canvas* background. + * + * The page's own CSS backgrounds (`body`, `#root`, etc.) paint on top and + * are unaffected. The transparent default only matters if the compositor + * surface is shorter than the viewport clip — e.g. system Chrome on macOS + * where `--window-size` includes window decorations (#1699). Without the + * override, the gap fills with the page's canvas background color (the + * propagated `body` background), producing a visible band at the bottom of + * every frame. With the override, the gap is transparent — composited to + * white for JPEG, true transparent for PNG — a safe neutral. + * + * Call once after navigation (Chrome resets the override on `page.goto`). + */ +export async function initOpaqueBackgroundOverride(page: Page): Promise { + const client = await getCdpSession(page); + await client.send("Emulation.setDefaultBackgroundColorOverride", { + color: { r: 0, g: 0, b: 0, a: 0 }, + }); +} + /** * Capture a screenshot with transparent background (PNG + alpha channel). * diff --git a/registry/examples/surface-height-repro/hyperframes.json b/registry/examples/surface-height-repro/hyperframes.json new file mode 100644 index 000000000..366510242 --- /dev/null +++ b/registry/examples/surface-height-repro/hyperframes.json @@ -0,0 +1 @@ +{ "name": "surface-height-repro", "version": "0.0.1" } diff --git a/registry/examples/surface-height-repro/index.html b/registry/examples/surface-height-repro/index.html new file mode 100644 index 000000000..555e346bc --- /dev/null +++ b/registry/examples/surface-height-repro/index.html @@ -0,0 +1,107 @@ + + + + + + + + +