diff --git a/packages/producer/src/services/distributed/renderChunk.ts b/packages/producer/src/services/distributed/renderChunk.ts index 373a2719c..c3127cccd 100644 --- a/packages/producer/src/services/distributed/renderChunk.ts +++ b/packages/producer/src/services/distributed/renderChunk.ts @@ -58,6 +58,7 @@ import { import { defaultLogger } from "../../logger.js"; import { runEncodeStage } from "../render/stages/encodeStage.js"; import { runCaptureStage } from "../render/stages/captureStage.js"; +import { resolveVideoCaptureBeyondViewport } from "../render/captureBeyondViewport.js"; import { type ChunkSliceJson, type LockedRenderConfig, @@ -480,6 +481,11 @@ export async function renderChunk( ) : null; + const videoCaptureBeyondViewport = resolveVideoCaptureBeyondViewport( + planVideos?.videos.length ?? 0, + "software", + ); + // ── Per-chunk work + frames directories ── // Suffix workDir with pid + random bytes so concurrent invocations on // the SAME `(planDir, chunkIndex)` (e.g. a scheduler that double-fires @@ -518,7 +524,9 @@ export async function renderChunk( // declare `data-composition-variables` leave this undefined and the // engine skips the `evaluateOnNewDocument` injection. variables: encoder.variables, - ...((planVideos?.videos.length ?? 0) > 0 ? { captureBeyondViewport: true } : {}), + ...(videoCaptureBeyondViewport !== undefined + ? { captureBeyondViewport: videoCaptureBeyondViewport } + : {}), // 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/captureBeyondViewport.test.ts b/packages/producer/src/services/render/captureBeyondViewport.test.ts new file mode 100644 index 000000000..7b43c3c14 --- /dev/null +++ b/packages/producer/src/services/render/captureBeyondViewport.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest"; +import { resolveVideoCaptureBeyondViewport } from "./captureBeyondViewport.js"; + +describe("resolveVideoCaptureBeyondViewport", () => { + it("leaves no-video renders on the engine default", () => { + expect(resolveVideoCaptureBeyondViewport(0, "software")).toBeUndefined(); + expect(resolveVideoCaptureBeyondViewport(0, "hardware")).toBeUndefined(); + }); + + it("keeps video renders on the fast viewport-bound path under software rendering", () => { + expect(resolveVideoCaptureBeyondViewport(1, "software")).toBe(false); + }); + + it("preserves the beyond-viewport video workaround under hardware rendering", () => { + expect(resolveVideoCaptureBeyondViewport(1, "hardware")).toBe(true); + }); +}); diff --git a/packages/producer/src/services/render/captureBeyondViewport.ts b/packages/producer/src/services/render/captureBeyondViewport.ts new file mode 100644 index 000000000..eaa7a3bda --- /dev/null +++ b/packages/producer/src/services/render/captureBeyondViewport.ts @@ -0,0 +1,14 @@ +export type ResolvedBrowserGpuMode = "software" | "hardware"; + +/** + * Native video surfaces can need Chrome's beyond-viewport compositor on + * hardware-accelerated captures, but that path is a full-surface software + * re-rasterization tax on SwiftShader/CPU render hosts. + */ +export function resolveVideoCaptureBeyondViewport( + videoCount: number, + browserGpuMode: ResolvedBrowserGpuMode, +): boolean | undefined { + if (videoCount <= 0) return undefined; + return browserGpuMode === "hardware"; +} diff --git a/packages/producer/src/services/renderOrchestrator.ts b/packages/producer/src/services/renderOrchestrator.ts index bb1dab9f2..3d66e53f5 100644 --- a/packages/producer/src/services/renderOrchestrator.ts +++ b/packages/producer/src/services/renderOrchestrator.ts @@ -67,6 +67,8 @@ import { LOW_MEMORY_TOTAL_MB_THRESHOLD, assertConfiguredFfmpegBinariesExist, type CapturePerfSummary, + resolveBrowserGpuMode, + resolveHeadlessShellPath, } from "@hyperframes/engine"; import { join, dirname, resolve } from "path"; import { randomUUID } from "crypto"; @@ -86,6 +88,7 @@ import { formatCaptureFrameName } from "../utils/paths.js"; import { resolveEffectiveHdrMode } from "./render/hdrMode.js"; import { buildRenderPerfSummary, pushWorkerDedupPerfs } from "./render/perfSummary.js"; import { getCaptureStageBrowserConsole } from "./render/captureStageError.js"; +import { resolveVideoCaptureBeyondViewport } from "./render/captureBeyondViewport.js"; import { type CaptureCalibrationSample, type CaptureCostEstimate, @@ -1246,6 +1249,16 @@ export async function executeRenderJob( const framesDir = join(workDir, "captured-frames"); if (!existsSync(framesDir)) mkdirSync(framesDir, { recursive: true }); + const resolvedBrowserGpuMode = await resolveBrowserGpuMode(cfg.browserGpuMode, { + chromePath: resolveHeadlessShellPath(cfg), + browserTimeout: cfg.browserTimeout, + }); + updateCaptureObservability({ browserGpuMode: resolvedBrowserGpuMode }); + const videoCaptureBeyondViewport = resolveVideoCaptureBeyondViewport( + composition.videos.length, + resolvedBrowserGpuMode, + ); + const captureOptions: CaptureOptions = { width, height, @@ -1254,7 +1267,9 @@ export async function executeRenderJob( quality: needsAlpha ? undefined : job.config.quality === "draft" ? 80 : 95, variables: job.config.variables, deviceScaleFactor, - ...(composition.videos.length > 0 ? { captureBeyondViewport: true } : {}), + ...(videoCaptureBeyondViewport !== undefined + ? { captureBeyondViewport: videoCaptureBeyondViewport } + : {}), }; resolvedCaptureBeyondViewport = captureOptions.captureBeyondViewport ?? resolvedCaptureBeyondViewport;