diff --git a/packages/engine/src/services/frameCapture.captureOptions.test.ts b/packages/engine/src/services/frameCapture.captureOptions.test.ts new file mode 100644 index 0000000000..13c273544b --- /dev/null +++ b/packages/engine/src/services/frameCapture.captureOptions.test.ts @@ -0,0 +1,36 @@ +// @vitest-environment node +import { describe, expect, it } from "vitest"; +import { resolveCaptureSessionOptions } from "./frameCapture.js"; + +describe("createCaptureSession captureBeyondViewport defaults", () => { + it("plumbs the macOS regular-Chrome default into returned session options", () => { + const options = resolveCaptureSessionOptions( + { + width: 1920, + height: 1080, + fps: { num: 30, den: 1 }, + format: "jpeg", + }, + "Chrome/149.0.7827.155", + "darwin", + ); + + expect(options.captureBeyondViewport).toBe(true); + }); + + it("preserves explicit caller overrides", () => { + const options = resolveCaptureSessionOptions( + { + width: 1920, + height: 1080, + fps: { num: 30, den: 1 }, + format: "jpeg", + captureBeyondViewport: false, + }, + "Chrome/149.0.7827.155", + "darwin", + ); + + expect(options.captureBeyondViewport).toBe(false); + }); +}); diff --git a/packages/engine/src/services/frameCapture.ts b/packages/engine/src/services/frameCapture.ts index 011660056f..4599a01307 100644 --- a/packages/engine/src/services/frameCapture.ts +++ b/packages/engine/src/services/frameCapture.ts @@ -28,6 +28,7 @@ import { getCdpSession, pageScreenshotCapture, initTransparentBackground, + shouldDefaultCaptureBeyondViewport, } from "./screenshotService.js"; import { DEFAULT_CONFIG, type EngineConfig } from "../config.js"; import type { @@ -310,6 +311,18 @@ export async function driveWarmupTicks( } } +export function resolveCaptureSessionOptions( + options: CaptureOptions, + browserVersion: string, + platform: NodeJS.Platform = process.platform, +): CaptureOptions { + return { + ...options, + captureBeyondViewport: + options.captureBeyondViewport ?? shouldDefaultCaptureBeyondViewport(browserVersion, platform), + }; +} + async function waitForCloseWithTimeout(promise: Promise): Promise { let timedOut = false; let timer: ReturnType | undefined; @@ -417,6 +430,7 @@ export async function createCaptureSession( }, variablesJson); } const browserVersion = await browser.version(); + const sessionOptions = resolveCaptureSessionOptions(options, browserVersion); const expectedMajor = config?.expectedChromiumMajor; if (Number.isFinite(expectedMajor)) { const actualChromiumMajor = Number.parseInt( @@ -430,9 +444,9 @@ export async function createCaptureSession( } } const viewport: Viewport = { - width: options.width, - height: options.height, - deviceScaleFactor: options.deviceScaleFactor || 1, + width: sessionOptions.width, + height: sessionOptions.height, + deviceScaleFactor: sessionOptions.deviceScaleFactor || 1, }; await page.setViewport(viewport); @@ -446,7 +460,7 @@ export async function createCaptureSession( return { browser, page, - options, + options: sessionOptions, serverUrl, outputDir, onBeforeCapture, diff --git a/packages/engine/src/services/screenshotService.test.ts b/packages/engine/src/services/screenshotService.test.ts index d0d42c7bdb..b60b5c54cf 100644 --- a/packages/engine/src/services/screenshotService.test.ts +++ b/packages/engine/src/services/screenshotService.test.ts @@ -7,6 +7,7 @@ import { cdpSessionCache, injectVideoFramesBatch, syncVideoFrameVisibility, + shouldDefaultCaptureBeyondViewport, } from "./screenshotService.js"; // Stub a Page + CDPSession just enough that pageScreenshotCapture can call @@ -121,6 +122,22 @@ describe("pageScreenshotCapture supersample plumbing", () => { }); }); +describe("shouldDefaultCaptureBeyondViewport", () => { + it("guards regular Chrome on macOS", () => { + expect(shouldDefaultCaptureBeyondViewport("Chrome/149.0.7827.155", "darwin")).toBe(true); + }); + + it("keeps chrome-headless-shell on the faster viewport-bound path", () => { + expect(shouldDefaultCaptureBeyondViewport("HeadlessChrome/148.0.7778.97", "darwin")).toBe( + false, + ); + }); + + it("does not change regular Chrome defaults on non-macOS platforms", () => { + expect(shouldDefaultCaptureBeyondViewport("Chrome/149.0.7827.155", "linux")).toBe(false); + }); +}); + 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 9e15c07734..5957b60484 100644 --- a/packages/engine/src/services/screenshotService.ts +++ b/packages/engine/src/services/screenshotService.ts @@ -19,6 +19,17 @@ export async function getCdpSession(page: Page): Promise 0, + ...((planVideos?.videos.length ?? 0) > 0 ? { captureBeyondViewport: true } : {}), // lock the BeginFrame warmup loop to a fixed iteration count so // `beginFrameTimeTicks` is host-independent. Only chunks ever set this. lockWarmupTicks: true, diff --git a/packages/producer/src/services/render/stages/captureStage.ts b/packages/producer/src/services/render/stages/captureStage.ts index 12efa349bd..33d1c6762c 100644 --- a/packages/producer/src/services/render/stages/captureStage.ts +++ b/packages/producer/src/services/render/stages/captureStage.ts @@ -126,6 +126,8 @@ export interface CaptureStageResult { probeSession: CaptureSession | null; /** Browser console buffer from whichever session was active last. */ lastBrowserConsole: string[]; + /** Engine-resolved screenshot flag from the consumed sequential/probe session, when observed. */ + captureBeyondViewport?: boolean; } export async function runCaptureStage(input: CaptureStageInput): Promise { @@ -150,6 +152,7 @@ export async function runCaptureStage(input: CaptureStageInput): Promise 0, + ...(composition.videos.length > 0 ? { captureBeyondViewport: true } : {}), }; - updateCaptureObservability({ - captureBeyondViewport: captureOptions.captureBeyondViewport ?? false, - }); + resolvedCaptureBeyondViewport = + captureOptions.captureBeyondViewport ?? resolvedCaptureBeyondViewport; + if (resolvedCaptureBeyondViewport !== undefined) { + updateCaptureObservability({ captureBeyondViewport: resolvedCaptureBeyondViewport }); + } // Capture sessions do not need native browser metadata for videos whose // pixels come from out-of-band FFmpeg frame extraction. Waiting on those @@ -1442,7 +1448,7 @@ export async function executeRenderJob( observability.checkpoint("capture_strategy", "resolved", { workerCount, forceScreenshot: captureForceScreenshot, - captureBeyondViewport: captureOptions.captureBeyondViewport ?? false, + captureBeyondViewport: resolvedCaptureBeyondViewport ?? null, useStreamingEncode, useLayeredComposite, usePageSideCompositing: usePageSideCompositingForTransitions, @@ -1592,6 +1598,11 @@ export async function executeRenderJob( streamingHandled = true; workerCount = streamingRes.workerCount; updateCaptureObservability({ workerCount }); + if (streamingRes.captureBeyondViewport !== undefined) { + updateCaptureObservability({ + captureBeyondViewport: streamingRes.captureBeyondViewport, + }); + } probeSession = streamingRes.probeSession; lastBrowserConsole = streamingRes.lastBrowserConsole; perfStages.captureMs = Date.now() - stage4Start; @@ -1637,6 +1648,11 @@ export async function executeRenderJob( const captureFrameMs = Date.now() - captureFrameStart; workerCount = captureRes.workerCount; updateCaptureObservability({ workerCount }); + if (captureRes.captureBeyondViewport !== undefined) { + updateCaptureObservability({ + captureBeyondViewport: captureRes.captureBeyondViewport, + }); + } probeSession = captureRes.probeSession; lastBrowserConsole = captureRes.lastBrowserConsole; diff --git a/packages/producer/tests/chrome-screenshot-bottom-edge/meta.json b/packages/producer/tests/chrome-screenshot-bottom-edge/meta.json new file mode 100644 index 0000000000..87d3db0fea --- /dev/null +++ b/packages/producer/tests/chrome-screenshot-bottom-edge/meta.json @@ -0,0 +1,13 @@ +{ + "name": "chrome-screenshot-bottom-edge", + "description": "Regression guard for screenshot capture leaking page background into the bottom of a viewport-sized MP4. The composition paints a red page background below full-frame content so any bottom-edge capture gap collapses PSNR.", + "tags": ["regression", "screenshot", "edge"], + "minPsnr": 30, + "maxFrameFailures": 0, + "minAudioCorrelation": 0, + "maxAudioLagWindows": 1, + "renderConfig": { + "fps": 12, + "workers": 1 + } +} diff --git a/packages/producer/tests/chrome-screenshot-bottom-edge/output/compiled.html b/packages/producer/tests/chrome-screenshot-bottom-edge/output/compiled.html new file mode 100644 index 0000000000..2f1e54cad9 --- /dev/null +++ b/packages/producer/tests/chrome-screenshot-bottom-edge/output/compiled.html @@ -0,0 +1,606 @@ + + + + + + Chrome Screenshot Bottom Edge + + + + +
+
+
bottom edge must stay inside the composition
+
+
+
+ + + diff --git a/packages/producer/tests/chrome-screenshot-bottom-edge/output/output.mp4 b/packages/producer/tests/chrome-screenshot-bottom-edge/output/output.mp4 new file mode 100644 index 0000000000..ce358816a7 --- /dev/null +++ b/packages/producer/tests/chrome-screenshot-bottom-edge/output/output.mp4 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ffec9cda98f24e06e74f65440ef13a3889ce110bc2b04394fd93f8afb61c8893 +size 43189 diff --git a/packages/producer/tests/chrome-screenshot-bottom-edge/src/index.html b/packages/producer/tests/chrome-screenshot-bottom-edge/src/index.html new file mode 100644 index 0000000000..fbcbcde23f --- /dev/null +++ b/packages/producer/tests/chrome-screenshot-bottom-edge/src/index.html @@ -0,0 +1,76 @@ + + + + + + Chrome Screenshot Bottom Edge + + + + +
+
+
bottom edge must stay inside the composition
+
+
+
+ + +