Step 2 of the DE engagement plan: multi-worker drawElement capture through the streaming encoder, with the full runtime self-verification net riding along — the confinement rule that kept the parallel clamp in place is now satisfied on this path. Opt-in via HF_DE_PARALLEL_STREAM=true; default routing (including the #2026 single-worker inversion) is unchanged. Mechanism: - distributeFramesInterleaved + WorkerTask.frameStride: worker i captures frames i, i+N, i+2N... — seek-based capture makes stride free and the ordered writer's reorder window shrinks from totalFrames/N to N (contiguous chunks serialize workers behind the writer). - Depth-2 pipelined worker-encode produce in the parallel worker loop (the same shape as the sequential loop; frame k's in-page encode overlaps k+stride's produce). HF_DE_PAR_DEBUG=1 traces the first frames per worker. - Drain guard extracted to createDrainFrameGuard (session-parameterized): every parallel frame gets the SAME blank-guard + PSNR self-verify as the sequential drain, against its owning worker's pre-injection ground truth (all sessions arm identical sample indices from CaptureOptions.compositionDurationSeconds). - FrameReorderBuffer.abort(err): a failed worker (e.g. verification error) rejects all parked and future waiters — without this, peers park forever in waitForFrame and the pool (which awaits ALL workers before surfacing errors) deadlocks. Found by the verify-trip test; unit-tested. - The typed DrawElementVerificationError is preserved past the pool's error-string flattening so the orchestrator's verify-retry recognizes it. - Static-dedup stride hazard fixed: lastEncodeResult reuse now requires EVERY frame in (lastEncodeResultFrame, i] to be predicted-static (sequential capture reduces to the old has(i) check). - Workers get separate browser PROCESSES under the flag: pages co-tenant in one browser starve non-active pages of BeginFrames on the paint-wait path (measured 86s vs 30s on a 3,245-frame rAF comp). Validation: - Happy path W3: verify samples pass across workers (4x inf on the 2,381f comp), output vs single-worker DE = 59.3dB (encode noise floor) — the interleave + dedup-stride produce identical pixels. - Verify-trip (marginal comp + HF_DE_VERIFY_MIN_DB=45): fails at frame 649 (32.2dB < 45), peers abort instead of deadlocking, whole render retries via parallel screenshot, RENDER_OK in 42.6s. - Canary suite 7/7 with the flag off (default paths untouched); producer orchestrator tests 99/99; engine suite 909 passed (14 pre-existing main failures, stash-A/B verified); reorder-buffer abort unit tests. Perf note: capture-only parallel speedup measured 1.38x (W2) / 1.52x (W3) over single-worker DE in the spike; end-to-end numbers on this machine are currently noisy (separate-browser init overhead + bench load) — clean benchmarks before any default routing change. The flag stays explicit opt-in; promoting it into the router replaces the #2026 W=1 pin for the same cohort. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@hyperframes/producer
Full HTML-to-video rendering pipeline: capture frames with Chrome's BeginFrame API, encode with FFmpeg, mix audio — all in one call.
Install
npm install @hyperframes/producer
Requirements: Node.js >= 22, Chrome/Chromium (auto-downloaded), FFmpeg
Usage
Render a video
import { createRenderJob, executeRenderJob } from "@hyperframes/producer";
const job = createRenderJob({
inputPath: "./my-composition.html",
outputPath: "./output.mp4",
width: 1920,
height: 1080,
fps: 30,
});
const result = await executeRenderJob(job, (progress) => {
console.log(`${Math.round(progress.percent * 100)}%`);
});
console.log(result.outputPath); // ./output.mp4
Run as an HTTP server
The producer can also run as a render server, accepting render requests over HTTP:
import { startServer } from "@hyperframes/producer";
await startServer({ port: 8080 });
// POST /render with a RenderConfig body
Configuration
RenderConfig controls the render pipeline:
| 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 below. |
videoFrameFormat |
"auto" |
Source video frame extraction format — "auto", "jpg", or "png". Use "png" for UI recordings, screen captures, and color-sensitive source videos. |
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
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,-cpu-used 4by default, andalpha_mode=1metadata. Tune the speed/quality tradeoff withPRODUCER_VP9_CPU_USED(-8to8) or local CLI--vp9-cpu-used. - 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: truetogether with an alpha-capable format logs a warning and falls back to SDR. Useformat: "mp4"for HDR10 output. png-sequencedoes not produce a single muxed file. When the composition contains audio elements, anaudio.aacsidecar is written alongside the PNGs inoutputPath.- 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.
Distributed rendering
For renders too large for a single machine, the producer ships a public set of distributed-render primitives. They are pure functions over local file paths — networking and orchestration live in adapter packages (Temporal, AWS Lambda + Step Functions, Cloud Run Jobs, K8s Jobs).
import { plan, renderChunk, assemble } from "@hyperframes/producer/distributed";
// Controller-side: produce a self-contained planDir + content-addressed planHash.
const planResult = await plan(
projectDir,
{ fps: 30, width: 1920, height: 1080, format: "mp4" },
"/tmp/plan",
);
// Worker-side: render one chunk. Byte-identical retries on the same
// `(planDir, chunkIndex)` — Temporal / Step Functions retry policies are safe
// to point at this.
const chunk = await renderChunk("/tmp/plan", 0, "/tmp/chunks/0.mp4");
// Controller-side: stitch chunks into the final deliverable.
await assemble(
"/tmp/plan",
["/tmp/chunks/0.mp4", "/tmp/chunks/1.mp4"],
"/tmp/plan/audio.aac",
"/tmp/output.mp4",
);
The three activity functions plus their result types are also re-exported from @hyperframes/producer so callers that pin the main package don't need a separate subpath import. Supported formats: mp4 SDR, mov ProRes 4444, and png-sequence. webm and HDR mp4 trip a typed FormatNotSupportedInDistributedError — use the in-process renderer (executeRenderJob) for those.
How it works
- Serve — spins up a local file server for the HTML composition
- Capture — opens the page in headless Chrome, seeks frame-by-frame via
HeadlessExperimental.beginFrame(orPage.captureScreenshotfor transparent / non-Linux renders), captures screenshots - Encode — pipes frames through FFmpeg (with GPU encoder detection and chunked concat). Skipped for
format: "png-sequence". - Mix — extracts
<audio>elements and mixes them into the final video. Forpng-sequence, audio is written as anaudio.aacsidecar. - Finalize — applies faststart for streaming-friendly MP4 (no-op for WebM, MOV, and
png-sequence)
Documentation
Full documentation: hyperframes.heygen.com/packages/producer
Related packages
@hyperframes/core— types, parsers, frame adapters@hyperframes/engine— lower-level capture and encode primitiveshyperframes— CLI