* perf(core): skip hidden clips' content so the browser stops decoding their images Hidden timed clips get content-visibility:hidden while their inline visibility is hidden, from script evaluation on. A clip due within the look-ahead (2 s by default) keeps its content live, so its images load before it shows. * perf(studio-server): load preview images lazily so hidden scenes fetch nothing * fix(core): stop the readiness scan waiting on and decoding later scenes' images * perf(studio-server): keep first-frame images eager so the preview opens as fast as before Only images inside a clip with a numeric start after 0 go lazy; a start the route cannot read stays eager. * perf(core): keep clips that start at 0 laid out before the first pass so the first frame is on time * perf(core): open the preview on time by hiding only lazy images before the first pass GSAP reads the computed style of the elements it animates while timelines build. Inside a content-skipped clip every read forces a style update, which cost about 65 ms at open on the bench composition. Before the first pass the rule now takes only lazy images out of layout, so hidden later scenes still do not load theirs; after it, the skip rule keeps their content skipped. * fix(core): hold a paused jump on the previous picture until the next scene's images decode * fix(studio): swap in a reloaded preview only after its restore seek has painted * fix(core): apply a held paused jump after 1 s when an image never decodes * fix(core): keep hidden-scene image skipping and held jumps to Studio's preview, never renders * fix(studio): reapply manual edits once a held paused jump lands * fix(studio): copy a clip without the preview's lazy-loading marks * fix(studio-server): mark preview images lazy without re-encoding the document's entities * fix(core): share one preview-mark constant between the runtime and the preview route * fix(studio): reapply manual edits a second time only after a seek that was held * refactor(core): share the hidden-ancestor walk between clip visibility and image holds * fix(core): import the studio preview mark from its own subpath * fix(studio-server): find preview images with the linear html scanner * chore(core): drop two doc comments the scanner's names already carry * fix(studio-server): serve the preview mark as its own meta, apart from the GSAP fallback script * fix(studio-server): capture thumbnails and frames from the preview without lazy images or the mark * fix(core): let the full readiness scan wait on every image except lazy ones off the first frame * fix(studio-server): keep an authored loading attribute in any letter case on preview images * fix(core): leave template content out of findStartTags so preview images match the DOM * fix(studio): send Ask agent the live markup without the preview loading marks * chore(core): drop the template note from findStartTags, its test carries it * perf(studio-server): splice lazy attributes into the preview in one pass * fix(studio): keep authored image loading on copy and Ask agent by marking preview-made lazy images * test(studio-server): follow the capture preview URL back to its sub-composition for every project id * refactor: name the upcoming-clip and GSAP fallback marks once * fix(studio-server): keep capture builds out of the on-disk preview store
@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.
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