diff --git a/packages/engine/src/services/screenshotService.test.ts b/packages/engine/src/services/screenshotService.test.ts index 4332c359d..d0d42c7bd 100644 --- a/packages/engine/src/services/screenshotService.test.ts +++ b/packages/engine/src/services/screenshotService.test.ts @@ -41,11 +41,33 @@ describe("pageScreenshotCapture supersample plumbing", () => { expect(send).toHaveBeenCalledWith( "Page.captureScreenshot", expect.objectContaining({ + captureBeyondViewport: false, clip: { x: 0, y: 0, width: 1920, height: 1080, scale: 1 }, }), ); }); + it("uses captureBeyondViewport only when callers opt in", async () => { + const send = vi.fn().mockResolvedValue({ data: ONE_PIXEL_PNG_B64 }); + const page = makeFakePageWithCdp(send); + + await pageScreenshotCapture(page, { + width: 1080, + height: 1920, + fps: { num: 30, den: 1 }, + format: "jpeg", + captureBeyondViewport: true, + }); + + expect(send).toHaveBeenCalledWith( + "Page.captureScreenshot", + expect.objectContaining({ + captureBeyondViewport: true, + clip: { x: 0, y: 0, width: 1080, height: 1920, scale: 1 }, + }), + ); + }); + it("passes `clip` with scale 1 when deviceScaleFactor is exactly 1", async () => { const send = vi.fn().mockResolvedValue({ data: ONE_PIXEL_PNG_B64 }); const page = makeFakePageWithCdp(send); diff --git a/packages/engine/src/services/screenshotService.ts b/packages/engine/src/services/screenshotService.ts index ffbe0be56..9e15c0773 100644 --- a/packages/engine/src/services/screenshotService.ts +++ b/packages/engine/src/services/screenshotService.ts @@ -135,11 +135,10 @@ export async function pageScreenshotCapture(page: Page, options: CaptureOptions) format: isPng ? "png" : "jpeg", quality: isPng ? undefined : (options.quality ?? 80), fromSurface: true, - // The explicit clip rect constrains output to exact composition - // dimensions. The viewport-boundary pre-clip from captureBeyondViewport: - // false is redundant, and Chrome's compositor rounds it inward under - // multi-tab load — clipping the bottom/right edge of tall viewports. - captureBeyondViewport: true, + // Use Chrome's faster viewport-bound screenshot path by default. Callers + // opt into the beyond-viewport path only for known compositor edge cases, + // such as native video surfaces in tall portrait renders. + captureBeyondViewport: options.captureBeyondViewport ?? false, optimizeForSpeed: !isPng, clip, }); @@ -172,7 +171,8 @@ export async function captureScreenshotWithAlpha( const result = await client.send("Page.captureScreenshot", { format: "png", fromSurface: true, - captureBeyondViewport: true, // see pageScreenshotCapture for rationale + // Preserve the #1094 tall-portrait edge-clipping guard on HDR alpha captures. + captureBeyondViewport: true, optimizeForSpeed: false, // `true` uses a zero-alpha-aware fast path that crushes real alpha values — observed empirically, CDP docs don't spell it out clip: { x: 0, y: 0, width, height, scale: 1 }, }); @@ -237,7 +237,8 @@ export async function captureAlphaPng(page: Page, width: number, height: number) const result = await client.send("Page.captureScreenshot", { format: "png", fromSurface: true, - captureBeyondViewport: true, // see pageScreenshotCapture for rationale + // Preserve the #1094 tall-portrait edge-clipping guard on HDR alpha captures. + captureBeyondViewport: true, optimizeForSpeed: false, // must be false to preserve alpha clip: { x: 0, y: 0, width, height, scale: 1 }, }); diff --git a/packages/engine/src/types.ts b/packages/engine/src/types.ts index 726b14a36..ff9caf600 100644 --- a/packages/engine/src/types.ts +++ b/packages/engine/src/types.ts @@ -92,6 +92,13 @@ export interface CaptureOptions { format?: "jpeg" | "png"; quality?: number; deviceScaleFactor?: number; + /** + * Opt into Chrome's capture-beyond-viewport screenshot path. Keep this off + * for ordinary viewport-sized captures because it is substantially slower in + * Chrome's screenshot compositor path. Enable for known compositor edge cases + * such as native video surfaces in tall portrait renders. + */ + captureBeyondViewport?: boolean; /** * FFmpeg-probed intrinsic dimensions for videos whose frames are injected * out-of-band. Applied before the readiness wait so layout that depends on diff --git a/packages/producer/src/services/distributed/renderChunk.ts b/packages/producer/src/services/distributed/renderChunk.ts index b1cc2588d..d87962175 100644 --- a/packages/producer/src/services/distributed/renderChunk.ts +++ b/packages/producer/src/services/distributed/renderChunk.ts @@ -507,6 +507,7 @@ export async function renderChunk( // declare `data-composition-variables` leave this undefined and the // engine skips the `evaluateOnNewDocument` injection. variables: encoder.variables, + captureBeyondViewport: (planVideos?.videos.length ?? 0) > 0, // 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/observability.ts b/packages/producer/src/services/render/observability.ts index ffc0cfa61..130346824 100644 --- a/packages/producer/src/services/render/observability.ts +++ b/packages/producer/src/services/render/observability.ts @@ -33,6 +33,7 @@ export interface BrowserDiagnosticSummary { export interface RenderCaptureObservability { forceScreenshot: boolean; captureMode: "screenshot" | "beginframe"; + captureBeyondViewport?: boolean; workerCount?: number; useStreamingEncode?: boolean; useLayeredComposite?: boolean; diff --git a/packages/producer/src/services/renderOrchestrator.ts b/packages/producer/src/services/renderOrchestrator.ts index 6bc15b7a8..ec33f792a 100644 --- a/packages/producer/src/services/renderOrchestrator.ts +++ b/packages/producer/src/services/renderOrchestrator.ts @@ -1213,7 +1213,11 @@ export async function executeRenderJob( quality: needsAlpha ? undefined : job.config.quality === "draft" ? 80 : 95, variables: job.config.variables, deviceScaleFactor, + captureBeyondViewport: composition.videos.length > 0, }; + updateCaptureObservability({ + captureBeyondViewport: captureOptions.captureBeyondViewport ?? false, + }); // Capture sessions do not need native browser metadata for videos whose // pixels come from out-of-band FFmpeg frame extraction. Waiting on those @@ -1438,6 +1442,7 @@ export async function executeRenderJob( observability.checkpoint("capture_strategy", "resolved", { workerCount, forceScreenshot: captureForceScreenshot, + captureBeyondViewport: captureOptions.captureBeyondViewport ?? false, useStreamingEncode, useLayeredComposite, usePageSideCompositing: usePageSideCompositingForTransitions,