Skip to content
Merged
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
49 changes: 38 additions & 11 deletions packages/engine/src/services/frameCapture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -78,7 +83,11 @@ export async function createCaptureSession(
): Promise<CaptureSession> {
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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -303,6 +309,17 @@ export async function initializeSession(session: CaptureSession): Promise<void>

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;
}
Expand Down Expand Up @@ -388,6 +405,16 @@ export async function initializeSession(session: CaptureSession): Promise<void>
// 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;
}

Expand Down
14 changes: 10 additions & 4 deletions packages/engine/src/services/screenshotService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Buffer> {
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");
}
Expand Down
74 changes: 62 additions & 12 deletions packages/producer/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 (`<video>` over background); supported in Chrome, Edge, Firefox |
| `"mov"` | ProRes 4444 + yuva444p10le | **True alpha + 10-bit** | AAC | Editor ingest (Premiere, Final Cut Pro, DaVinci Resolve) |
| `"png-sequence"` | Numbered RGBA PNGs in a directory | **Lossless alpha** | Sidecar `audio.aac` | After Effects / Nuke / Fusion, or pipelines that post-process frames before encoding |

### Example

```typescript
import { createRenderJob, executeRenderJob } from "@hyperframes/producer";

const job = createRenderJob({
inputPath: "./my-composition.html",
outputPath: "./output.webm", // or a directory for "png-sequence"
width: 1080,
height: 1920,
fps: 30,
format: "webm", // "mp4" | "webm" | "mov" | "png-sequence"
});

await executeRenderJob(job);
```

### What "transparent background" means here

The producer captures Chrome screenshots with the page background forced transparent (`html, body, [data-composition-id] { background: transparent !important }`) and the CDP default background override set to RGBA 0,0,0,0. The captured PNGs carry a real alpha channel and that channel is preserved end-to-end:

- VP9 (`webm`) is encoded with `-pix_fmt yuva420p`, `-auto-alt-ref 0`, and `alpha_mode=1` metadata.
- ProRes 4444 (`mov`) is encoded with `-pix_fmt yuva444p10le`.
- PNG sequences are written without re-encoding (zero-padded `frame_NNNNNN.png`).

This is not chroma keying. There is no green/blue background to remove and no "key" tolerance to tune — pixels that were transparent in the browser are transparent in the output.

### Caveats

- **Linux + alpha forces screenshot capture.** Chrome's BeginFrame compositor (the default deterministic capture path on Linux headless-shell) does not preserve alpha; the orchestrator falls back to `Page.captureScreenshot`, which is slower per frame. macOS and Windows already use screenshot mode by default, so they are unaffected.
- **HDR + alpha is not supported.** Setting `hdr: true` together with an alpha-capable format logs a warning and falls back to SDR. Use `format: "mp4"` for HDR10 output.
- **`png-sequence` does not produce a single muxed file.** When the composition contains audio elements, an `audio.aac` sidecar is written alongside the PNGs in `outputPath`.
- **Safari + WebM alpha is incomplete.** For broad browser playback of an alpha video, ship `format: "mov"` to your editor and re-encode for the codec your distribution target supports.

### Authoring transparent compositions

Don't paint a fullscreen background in your HTML. The default body background is overridden to transparent automatically — any `body { background: ... }`, `#root { background: ... }`, or `[data-composition-id] { background: ... }` rule is force-overridden during alpha rendering. Backgrounds on inner elements (cards, scenes, components) are kept.

## How it works

1. **Serve** — spins up a local file server for the HTML composition
2. **Capture** — opens the page in headless Chrome, seeks frame-by-frame via `HeadlessExperimental.beginFrame`, captures screenshots
3. **Encode** — pipes frames through FFmpeg (with GPU encoder detection and chunked concat)
4. **Mix** — extracts `<audio>` elements and mixes them into the final video
5. **Finalize** — applies faststart for streaming-friendly MP4
2. **Capture** — opens the page in headless Chrome, seeks frame-by-frame via `HeadlessExperimental.beginFrame` (or `Page.captureScreenshot` for transparent / non-Linux renders), captures screenshots
3. **Encode** — pipes frames through FFmpeg (with GPU encoder detection and chunked concat). Skipped for `format: "png-sequence"`.
4. **Mix** — extracts `<audio>` elements and mixes them into the final video. For `png-sequence`, audio is written as an `audio.aac` sidecar.
5. **Finalize** — applies faststart for streaming-friendly MP4 (no-op for WebM, MOV, and `png-sequence`)

## Documentation

Expand Down
5 changes: 3 additions & 2 deletions packages/producer/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,9 @@
"check:runtime-conformance": "tsx src/runtime-conformance.ts",
"benchmark": "tsx src/benchmark.ts",
"bench:hdr": "tsx src/benchmark.ts --tags hdr",
"test": "tsx src/regression-harness.ts",
"test:update": "tsx src/regression-harness.ts --update",
"test": "tsx src/regression-harness.ts --exclude-tags transparency",
"test:update": "tsx src/regression-harness.ts --update --exclude-tags transparency",
"test:transparency": "tsx src/transparency-test.ts",
"docker:build:test": "docker build -f ../../Dockerfile.test -t hyperframes-producer:test ../..",
"docker:test": "docker run --rm --security-opt seccomp=unconfined --shm-size=2g -v ./tests:/app/packages/producer/tests hyperframes-producer:test",
"docker:test:update": "docker run --rm --security-opt seccomp=unconfined --shm-size=2g -v ./tests:/app/packages/producer/tests hyperframes-producer:test --update",
Expand Down
Loading
Loading