diff --git a/packages/engine/src/services/frameCapture.ts b/packages/engine/src/services/frameCapture.ts index 306da5414..296a2fda1 100644 --- a/packages/engine/src/services/frameCapture.ts +++ b/packages/engine/src/services/frameCapture.ts @@ -20,7 +20,12 @@ import { resolveHeadlessShellPath, type CaptureMode, } from "./browserManager.js"; -import { beginFrameCapture, getCdpSession, pageScreenshotCapture } from "./screenshotService.js"; +import { + beginFrameCapture, + getCdpSession, + pageScreenshotCapture, + initTransparentBackground, +} from "./screenshotService.js"; import { DEFAULT_CONFIG, type EngineConfig } from "../config.js"; import type { CaptureOptions, @@ -78,7 +83,11 @@ export async function createCaptureSession( ): Promise { if (!existsSync(outputDir)) mkdirSync(outputDir, { recursive: true }); - // Determine capture mode before building args — BeginFrame flags only apply on Linux + // Determine capture mode before building args — BeginFrame flags only apply on Linux. + // BeginFrame's compositor does not preserve alpha; callers that pass + // `options.format === "png"` for transparent capture should also set + // `config.forceScreenshot = true` (the producer's renderOrchestrator does this + // automatically when `RenderConfig.format` is an alpha-capable value). const headlessShell = resolveHeadlessShellPath(config); const isLinux = process.platform === "linux"; const forceScreenshot = config?.forceScreenshot ?? DEFAULT_CONFIG.forceScreenshot; @@ -144,15 +153,12 @@ export async function createCaptureSession( }; await page.setViewport(viewport); - // For PNG capture (used by WebM/transparency), make the page background transparent - // so Chrome's screenshot captures alpha channel data. Must use the same CDP session - // that the screenshot service uses (getCdpSession caches per page). - if (options.format === "png") { - const cdp = await getCdpSession(page); - await cdp.send("Emulation.setDefaultBackgroundColorOverride", { - color: { r: 0, g: 0, b: 0, a: 0 }, - }); - } + // Transparent-background setup is intentionally NOT done here. Chrome resets + // the default-background-color override on navigation, and the + // `[data-composition-id]{background:transparent}` stylesheet that + // `initTransparentBackground` injects must land in a real `document.head`. + // See `initializeSession()` below — it calls `initTransparentBackground` for + // PNG captures after `page.goto(...)` and the `window.__hf` readiness poll. return { browser, @@ -303,6 +309,17 @@ export async function initializeSession(session: CaptureSession): Promise await page.evaluate(`document.fonts?.ready`); + // For PNG captures, force the page background fully transparent so the + // captured screenshots carry a real alpha channel. Must run AFTER + // navigation (Chrome resets the override on every goto) and AFTER the + // page is loaded (the injected stylesheet needs a real document.head). + // The override is overridden by `body { background: ... }` and + // `#root { background: ... }` rules — the helper handles that with a + // `[data-composition-id]{background:transparent !important}` injection. + if (session.options.format === "png") { + await initTransparentBackground(session.page); + } + session.isInitialized = true; return; } @@ -388,6 +405,16 @@ export async function initializeSession(session: CaptureSession): Promise // Set base frame time ticks past warmup range session.beginFrameTimeTicks = (warmupTicks + 10) * session.beginFrameIntervalMs; + // For PNG captures, inject the transparent-background override + stylesheet + // (see the screenshot-mode branch above for the rationale). BeginFrame mode + // does not actually preserve alpha through its compositor — callers that + // need transparent output should set `forceScreenshot: true` so this branch + // is bypassed entirely. The call is left here as defense-in-depth for any + // future BeginFrame alpha support. + if (session.options.format === "png") { + await initTransparentBackground(session.page); + } + session.isInitialized = true; } diff --git a/packages/engine/src/services/screenshotService.ts b/packages/engine/src/services/screenshotService.ts index 79ab0117f..b506e36f7 100644 --- a/packages/engine/src/services/screenshotService.ts +++ b/packages/engine/src/services/screenshotService.ts @@ -119,16 +119,22 @@ export async function beginFrameCapture( /** * Capture a screenshot using standard Page.captureScreenshot CDP call. * Fallback for environments where BeginFrame is unavailable (macOS, Windows). + * + * For `format: "png"` captures we disable Chrome's `optimizeForSpeed` fast + * path. The fast path uses a zero-alpha-aware codec that crushes real alpha + * values to 0 or 255 (verified empirically; CDP docs don't document this) — + * exactly the same caveat called out on `captureScreenshotWithAlpha` / + * `captureAlphaPng`. Keeping the fast path for opaque jpeg captures is fine. */ export async function pageScreenshotCapture(page: Page, options: CaptureOptions): Promise { const client = await getCdpSession(page); - const format = options.format === "png" ? "png" : "jpeg"; + const isPng = options.format === "png"; const result = await client.send("Page.captureScreenshot", { - format, - quality: format === "jpeg" ? (options.quality ?? 80) : undefined, + format: isPng ? "png" : "jpeg", + quality: isPng ? undefined : (options.quality ?? 80), fromSurface: true, captureBeyondViewport: false, - optimizeForSpeed: true, + optimizeForSpeed: !isPng, }); return Buffer.from(result.data, "base64"); } diff --git a/packages/producer/README.md b/packages/producer/README.md index 7c87f0360..8e5bc6f48 100644 --- a/packages/producer/README.md +++ b/packages/producer/README.md @@ -47,22 +47,72 @@ await startServer({ port: 8080 }); `RenderConfig` controls the render pipeline: -| Option | Default | Description | -| ------------ | ------------ | -------------------------------------------------- | -| `inputPath` | — | Path to the HTML composition | -| `outputPath` | — | Output video file path | -| `width` | 1920 | Frame width in pixels | -| `height` | 1080 | Frame height in pixels | -| `fps` | 30 | Frames per second (24, 30, or 60) | -| `quality` | `"standard"` | Encoder preset (`"draft"`, `"standard"`, `"high"`) | +| Option | Default | Description | +| ------------ | ------------ | ------------------------------------------------------------------------------------------------------------------------------------ | +| `inputPath` | — | Path to the HTML composition | +| `outputPath` | — | Output video file path (or directory, for `format: "png-sequence"`) | +| `width` | 1920 | Frame width in pixels | +| `height` | 1080 | Frame height in pixels | +| `fps` | 30 | Frames per second (24, 30, or 60) | +| `quality` | `"standard"` | Encoder preset (`"draft"`, `"standard"`, `"high"`) | +| `format` | `"mp4"` | Output container — `"mp4"`, `"webm"`, `"mov"`, or `"png-sequence"`. See [Transparent Video Output](#transparent-video-output) below. | + +## Transparent Video Output + +The producer can render HTML compositions to formats that carry a **true alpha channel** — not chroma key. The same composition that renders an opaque MP4 renders a layerable overlay when you set `format`. + +| `format` | Codec / pixel format | Alpha | Audio | Use case | +| ----------------- | --------------------------------- | ----------------------- | ------------------- | --------------------------------------------------------------------------------------- | +| `"mp4"` (default) | H.264 (yuv420p) or H.265 + HDR10 | No | AAC | Streaming, social, default deliverable | +| `"webm"` | VP9 + yuva420p | **True alpha** | Opus | Web playback as overlay (`