Files
hyperframes/packages/engine
Vance Ingalls 8c97113a82 fix(core,engine): give each audio bus instance its own identity in the render
Review finding 1, the last of the fifteen. A group's identity was the raw author
`id`, which is unique only per composition FILE — and the render document is the
inlined union of every file. So a sub-composition declaring a bus AND its
members, used twice, put both instances' members under one key: one sub-mix for
two independent buses, and the second bus element overwrote the first, applying
its fader, chain, label and automation to the first instance's audio. With only
the SECOND instance muted, `memberGroupHidden` dropped every member of the
merged group — both instances gone from the export.

Compiled a twice-used sub-composition to find out what actually separates the
two instances, rather than guessing:

- members are ALREADY disambiguated — `data-hf-render-id="m1"` / `"m1__hf2"`
- buses are not: `MEDIA_SELECTOR` is `video[src], audio[src], img[src]`
- both instances carry `data-composition-id="bedcomp"`, the file's own id, so id
  strings cannot tell them apart — only the subtree element can

Fixed at the boundary that already owns this collision class.
`assignMediaRenderIds` now stamps every `<hf-audio-group>` with a
document-unique `data-hf-render-id` from the SAME `taken` set (so a bus key can
never collide with a clip key either), and stamps each member with
`data-hf-group-render-id` = the render id of the bus in its OWN composition
subtree, resolved with `closest()`. A member whose bus is not in its subtree — a
hand-authored bus in the root with members in scenes — falls back to the first,
which is the pre-existing reading and the only sensible one there.

`resolveAudioGroups` and the mixer prefer the stamped key and fall back to the
author id, so the LIVE PREVIEW — which has no stamps — reads exactly as before.
`resolveGroupElement` tries the stamped instance first, since `getElementById`
can only ever find the author id.

Verified with the reviewer's own repro, end to end through the real compiler:
before, `parseAudioElements` returned both members under one `bed` group; after,
muting instance B drops only B's member and A survives at its own 0.5 fader. Two
compiler tests (instance pairing, single-instance stability, element-less group
untouched) and two mixer tests, all verified against a revert.

**Still divergent, deliberately: the live preview.** `groupInput` resolves by id
against the uncompiled document, so two instances still share one bus there. The
export was the audible bug — a muted instance silencing another's audio — and
fixing preview needs runtime subtree resolution, which is a separate change.

core 2493, engine 1617, studio 4389. fallow clean.
2026-08-20 16:41:30 -07:00
..
2026-08-20 19:03:09 -04:00

@hyperframes/engine

Seekable web-page-to-video rendering engine built on Puppeteer and FFmpeg.

Framework-agnostic: works with GSAP, Lottie, Three.js, CSS animations, or any web content that implements the window.__hf seek protocol.

Install

npm install @hyperframes/engine

Requirements: Node.js >= 22, Chrome/Chromium (auto-downloaded by Puppeteer), FFmpeg

What it does

The engine opens your HTML composition in a headless Chrome instance, seeks frame-by-frame using Chrome's HeadlessExperimental.beginFrame API, captures screenshots, and encodes them into video with FFmpeg.

Key services

Service Description
browserManager Launches and pools headless Chrome instances (chrome-headless-shell)
frameCapture Manages capture sessions — seek, screenshot, buffer lifecycle
screenshotService BeginFrame-based capture with CDP (Chrome DevTools Protocol)
chunkEncoder FFmpeg encoding with chunked concat, GPU detection, faststart
streamingEncoder Pipe frames to FFmpeg in real time (no intermediate PNGs on disk)
audioMixer Parse <audio> elements and mix audio tracks via FFmpeg
videoFrameExtractor Extract frames from <video> elements for compositing
parallelCoordinator Split frame ranges across worker processes
fileServer Serve local HTML files to the browser via Hono

Usage

import {
  acquireBrowser,
  createCaptureSession,
  initializeSession,
  captureFrame,
  closeCaptureSession,
} from "@hyperframes/engine";

// 1. Launch browser
const browserLease = await acquireBrowser({ captureMode: "beginFrame" });

// 2. Open a capture session
const session = createCaptureSession({
  browser: browserLease.browser,
  url: "http://localhost:3000/my-composition.html",
  width: 1920,
  height: 1080,
  fps: 30,
});
await initializeSession(session);

// 3. Capture frames
for (let i = 0; i < totalFrames; i++) {
  await captureFrame(session, i, `/tmp/frames/frame-${i}.png`);
}

// 4. Clean up
await closeCaptureSession(session);
await browserLease.release();

Most users should use @hyperframes/producer or the hyperframes CLI instead of calling the engine directly.

Documentation

Full documentation: hyperframes.heygen.com/packages/engine