Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/engine/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ export {
syncVideoFrameVisibility,
cdpSessionCache,
initTransparentBackground,
initOpaqueBackgroundOverride,
captureAlphaPng,
applyDomLayerMask,
removeDomLayerMask,
Expand Down
22 changes: 22 additions & 0 deletions packages/engine/src/services/browserManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
12 changes: 11 additions & 1 deletion packages/engine/src/services/browserManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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
Expand All @@ -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",
Expand Down
13 changes: 12 additions & 1 deletion packages/engine/src/services/frameCapture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
getCdpSession,
pageScreenshotCapture,
initTransparentBackground,
initOpaqueBackgroundOverride,
} from "./screenshotService.js";
import { DEFAULT_CONFIG, type EngineConfig } from "../config.js";
import type {
Expand Down Expand Up @@ -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 },
);

Expand Down Expand Up @@ -1043,6 +1049,11 @@ export async function initializeSession(session: CaptureSession): Promise<void>
// `[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);
Expand Down
14 changes: 14 additions & 0 deletions packages/engine/src/services/screenshotService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { type Page } from "puppeteer-core";
import {
pageScreenshotCapture,
cdpSessionCache,
initOpaqueBackgroundOverride,
injectVideoFramesBatch,
syncVideoFrameVisibility,
} from "./screenshotService.js";
Expand Down Expand Up @@ -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(
Expand Down
24 changes: 24 additions & 0 deletions packages/engine/src/services/screenshotService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
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).
*
Expand Down
1 change: 1 addition & 0 deletions registry/examples/surface-height-repro/hyperframes.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{ "name": "surface-height-repro", "version": "0.0.1" }
107 changes: 107 additions & 0 deletions registry/examples/surface-height-repro/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
<!doctype html>
<html>
<head>
<style>
html,
body {
margin: 0;
width: 1920px;
height: 1080px;
background: #faf9f5;
}
</style>
</head>
<body>
<template id="repro-template">
<style>
#root {
position: absolute;
inset: 0;
background: linear-gradient(to bottom, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
display: flex;
align-items: center;
justify-content: center;
font-family:
system-ui,
-apple-system,
sans-serif;
}
.card {
width: 860px;
padding: 60px 80px;
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 24px;
text-align: center;
color: white;
box-shadow: 0 30px 80px rgba(0, 0, 0, 0.5);
}
h1 {
font-size: 44px;
margin: 0 0 16px;
font-weight: 700;
}
.subtitle {
font-size: 22px;
opacity: 0.7;
margin: 0 0 40px;
}
.indicator {
display: flex;
gap: 24px;
justify-content: center;
margin-top: 32px;
}
.indicator div {
padding: 12px 24px;
border-radius: 12px;
font-size: 16px;
font-weight: 600;
}
.pass {
background: rgba(34, 197, 94, 0.2);
color: #4ade80;
border: 1px solid rgba(34, 197, 94, 0.3);
}
.fail {
background: rgba(239, 68, 68, 0.2);
color: #f87171;
border: 1px solid rgba(239, 68, 68, 0.3);
}
.bottom-bar {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 100px;
background: linear-gradient(to right, #e11d48, #7c3aed);
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 20px;
font-weight: 600;
letter-spacing: 1px;
}
</style>
<div
data-composition-id="surface-repro"
data-width="1920"
data-height="1080"
data-duration="3"
>
<div id="root">
<div class="card">
<h1>Surface Height Repro (#1699)</h1>
<p class="subtitle">The gradient and pink bar should extend to the very bottom edge.</p>
<div class="indicator">
<div class="pass">PASS: gradient fills full 1080px</div>
<div class="fail">FAIL: beige #FAF9F5 band at bottom</div>
</div>
</div>
<div class="bottom-bar">THIS BAR MUST BE VISIBLE AT THE BOTTOM EDGE</div>
</div>
</div>
</template>
</body>
</html>
Loading