* fix(core): composition scripts start after web fonts are ready A scene script that measures text (offsetHeight, getBoundingClientRect) ran while the page parsed, often before its web font loaded, so it laid the scene out with fallback metrics. Renders of the same film differed run to run, and preview could differ from export. The compilers now give every body script a type the browser does not run (text/hf-after-fonts, or +module), through one helper in scriptRuns.ts called at the end of the bundler, the producer compile and the sub-composition preview. The Studio motion script is emitted the same way so it still runs after them. The runtime entry owns the wait: on DOMContentLoaded it forces a layout, waits for document.fonts.ready or FONT_WAIT_TIMEOUT_MS (5 s), then re-creates each script in its own place in parser order (classic in order, awaiting src loads; then module and defer scripts in insertion order), boots as before, and calls the DOMContentLoaded and load listeners the scripts added. On timeout the scripts run anyway and runtime_font_wait_timeout names the families still loading. Scene swaps and the composition loader use the same wait. * fix(core): a page whose runtime predates the font gate still runs its scripts A bundle can be served with a runtime loaded from HYPERFRAME_RUNTIME_URL. If that runtime is older than the font gate, nothing would run the deferred composition scripts and the film would be blank. Whenever the marking helper defers scripts, it also puts one small inline script first in the head. At DOMContentLoaded it runs the deferred scripts in parser order itself, without waiting for fonts and with a console warning, unless a runtime has claimed them. The gate's runtime claims them when it loads, before DOMContentLoaded, so the scripts run exactly once either way. * fix(studio-server): motion paths animate in Studio preview when gsap loads in the body The MotionPathPlugin tag Studio inserts after a body gsap script now carries that script's type, so the runtime runs it right after gsap once fonts are ready, instead of before gsap exists. * fix(cli): layout, motion-shot and validate wait for the composition scripts to run The three commands waited on the timeline registry, which exists as soon as the runtime file loads, or on a flat sleep. They now wait for the runtime's render-ready flag through one shared helper, keeping each command's timeout and proceeding as before when it passes. * fix(core): a script that wraps addEventListener keeps its wrapper after its scripts run While composition scripts run, the runtime patches addEventListener to hold load events that already passed. Putting the original back now happens only if the patch is still in place, so a library wrapper installed meanwhile survives. * docs: comments on script timing match scripts that wait for web fonts The early stub's flush and the player's iframe-load handler described page scripts as done by DOMContentLoaded and the runtime as ready before load. Composition scripts now wait for web fonts, so both can come later. Comment-only. * test(core): pin script order after an external script and the scene swap's font wait An inline composition script that follows an external one sees its global, and a scene swap runs the new scene's script only once web fonts are ready. * docs(producer): the early stub comment names what drains late font-gated work * fix(studio): motion paths add without a flash when gsap loads after web fonts Studio preloads MotionPathPlugin on iframe load. A body gsap now runs after web fonts, often after that load, so the preload retries once the runtime reports ready. * test(cli): pin the runtime wait before each command first samples the page * fix(studio): the motion path preload stops waiting once the preview runtime has booted A composition with no global gsap posts ready before the preload runs; the retry listener then never detached and kept each retired preview document alive. It now waits only while the runtime has not booted, and the tests pin the cleanup and the ready filter. * fix(studio): a preview frame that never boots is not kept alive by the motion path preload Only the latest preview waits for the ready message of its runtime; a newer load drops the older wait, so a crashed or missing composition frame can be collected. * style: comments added by the font gate move into names
@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" and format: "hls") |
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", "gif", "png-sequence", or "hls". See Transparent Video Output and HLS Output below. |
hlsSegmentSeconds |
4 |
HLS target segment length in whole seconds. Ignored for every other format. |
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 |
"hls" |
H.264 (yuv420p) in MPEG-TS | No | AAC rendition | VOD streaming — see HLS Output |
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.
HLS Output
format: "hls" packages the same H.264 + AAC encode as mp4 as an HLS VOD
stream. outputPath is a directory:
master.m3u8 # references both renditions
video.m3u8 # video media playlist
video_00000.ts # MPEG-TS segments, one IDR frame at the start of each
audio.m3u8 # written only when the composition has audio
audio_00000.ts
const job = createRenderJob({
inputPath: "./my-composition.html",
outputPath: "./renders/stream",
format: "hls",
hlsSegmentSeconds: 4,
});
Segments are exactly hlsSegmentSeconds long except the last: the encoder's GOP
is locked to round(fps × hlsSegmentSeconds) frames so the packaging pass can
stream-copy (-c copy) and still cut on keyframes. There is no second encode.
Constraints:
- SDR only.
hdrMode: "force-hdr"is rejected; HDR10 would need fMP4 segments and HEVC. Useformat: "mp4"for HDR. - Software encoder only.
useGpu: trueis rejected — GPU encoders ignore the forced-keyframe lock that fixed-length segments depend on. - Not available in distributed rendering, including Lambda and Cloud Run.
- MPEG-TS carries no mov-family metadata, so the render provenance tags written into MP4 output are absent.
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).
Plan v2 is recommended for new integrations. It publishes an immutable manifest plus content-addressed artifacts and materializes only each worker's declared dependencies:
import { planV2, renderChunkV2, assembleV2 } from "@hyperframes/producer/distributed";
// Controller-side: produce a v2 manifest + local content-addressed store.
const planResult = await planV2(
projectDir,
{ fps: 30, width: 1920, height: 1080, format: "mp4" },
"/tmp/plan-v2",
);
const chunk = await renderChunkV2("/tmp/plan-v2", 0, "/tmp/chunks/0.mp4");
// Controller-side: stitch chunks into the final deliverable.
await assembleV2("/tmp/plan-v2", ["/tmp/chunks/0.mp4", "/tmp/chunks/1.mp4"], "/tmp/output.mp4");
Cloud adapters should use planV2WithPublisher() so artifacts publish
directly to object storage. The legacy plan() / renderChunk() /
assemble() v1 layout remains supported, and cloud SDKs still interpret an
omitted protocol as v1 for backwards compatibility.
The 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). Forhls, stream-copies the encode into playlists and MPEG-TS segments instead.
Where each step lives
executeRenderJob in src/services/renderOrchestrator.ts runs the stages in
src/services/render/stages/, in this order:
compileStage.ts,probeStage.ts,extractVideosStage.ts,audioStage.tssrc/services/render/captureCost.tssizes the worker count (runCaptureCalibration, thenresolveRenderWorkerCount)createCapturePlaninsrc/services/render/capturePlan.tspicks the capture kind: streaming, disk, segmented or HDR layered- capture:
captureStreamingStage.ts,captureSegmentedStage.tsandcaptureHdrStage.tsencode as they capture; disk capture (captureStage.ts) encodes afterwards inencodeStage.ts assembleStage.ts, except for PNG sequences and GIF, which are written directly
The job's producerConfig is used when present; otherwise resolveConfig in
packages/engine/src/config.ts builds it. Capture, encoding and browser
primitives are engine services (see packages/engine/README.md).
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