Skip to content
Open
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
45 changes: 32 additions & 13 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions docs/docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,7 @@
"packages/core",
"packages/parsers",
"packages/lint",
"packages/browser-export",
"packages/studio-server",
"packages/sdk",
"packages/engine",
Expand Down
66 changes: 66 additions & 0 deletions docs/packages/browser-export.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
---
title: "@hyperframes/browser-export"
description: "Render a composition to MP4/WebM entirely in the browser — no server, no FFmpeg, no headless Chrome."
---

`@hyperframes/browser-export` renders a **live** composition to video fully client-side: deterministic GSAP seeks, SVG `foreignObject` rasterization and WebCodecs encoding via [mediabunny](https://mediabunny.dev).

```bash
npm install @hyperframes/browser-export
```

## When to Use

<Tip>
Reach for this package when running Node.js, FFmpeg or headless Chrome is not viable: browser-based editors, template SaaS frontends, quick previews and one-off exports delivered straight to the user's Downloads folder.
</Tip>

<Info>
This is a **complement** to `@hyperframes/producer`, not a replacement. The producer's headless-Chrome + FFmpeg pipeline remains the reference for deterministic, pixel-perfect production renders.
</Info>

## Quick Start

```typescript
import { exportComposition, downloadExport } from "@hyperframes/browser-export";

const result = await exportComposition(document, {
fps: 30,
format: "mp4", // or "webm"
onProgress: ({ phase, fraction }) => {
console.log(`${phase}: ${(fraction * 100).toFixed(0)}%`);
},
});

downloadExport(result); // "<composition-id>.mp4"
```

The composition must be live in the current page (or a same-origin iframe document): GSAP timelines registered in `window.__timelines`, a root element carrying `data-composition-id` / `data-width` / `data-height`.

## Pipeline

1. **Plan** — locate the composition root, read dimensions, resolve the duration from the master timeline (or `options.duration`).
2. **Audio** — parse `<audio>`/`<video>` clip metadata (`data-start`, `data-duration`/`data-end`, `data-media-start`, `data-volume`), decode and mix offline with `OfflineAudioContext`.
3. **Video** — per frame: pause + seek every registered timeline at the quantized frame time (`Math.round(t·fps)/fps`, the producer's parity contract), await `<video>` layer seeks, rasterize the root to a canvas.
4. **Encode** — WebCodecs via mediabunny (`avc`+`aac` in MP4, `vp9`+`opus` in WebM), finalized into a downloadable `Blob`.

## API

| Export | Description |
| --- | --- |
| `exportComposition(target, options?)` | Full pipeline → `ExportResult` |
| `downloadExport(result, filename?)` | Trigger a client-side download |
| `collectAudioClips(scope)`, `mixAudioClips(clips, duration)` | Audio pipeline pieces |
| `seekTimelines(registry, t, fps)`, `quantizeTimeToFrame(t, fps)` | Deterministic seek pieces |
| `findCompositionRoot(scope)`, `readCompositionMeta(root)` | Composition discovery |

**`ExportOptions`:** `fps` (default 30), `format` (`"mp4"` \| `"webm"`), `duration`, `videoBitrate`, `audioBitrate`, `includeAudio`, `pixelRatio`, `keyFrameIntervalSeconds`, `signal`, `onProgress`.

**`ExportResult`:** `blob`, `mimeType`, `width`, `height`, `fps`, `durationSeconds`, `frameCount`, `compositionId`.

## Limitations

- Requires WebCodecs (Chrome/Edge 94+, Safari 16.4+, Firefox 130+).
- SVG `foreignObject` rasterization: cross-origin images and fonts must be CORS-readable; some CSS features (e.g. `backdrop-filter`) may differ from a real Chrome screenshot.
- `<video>` layers are frame-aligned via async seeks with a 500 ms guard — long-GOP sources may land on the nearest decodable frame.
- Rendering happens on the main thread; a 30 s / 30 fps export is ~900 sequential rasterizations.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
"type": "module",
"scripts": {
"dev": "bun run studio",
"build": "bun run --filter '@hyperframes/{parsers,lint,studio-server}' build && bun run --filter @hyperframes/core build && bun run --filter '@hyperframes/{core,engine,producer,player,studio,shader-transitions,aws-lambda,gcp-cloud-run,sdk}' build && bun run --filter @hyperframes/cli build",
"build": "bun run --filter '@hyperframes/{parsers,lint,studio-server,browser-export}' build && bun run --filter @hyperframes/core build && bun run --filter '@hyperframes/{core,engine,producer,player,studio,shader-transitions,aws-lambda,gcp-cloud-run,sdk}' build && bun run --filter @hyperframes/cli build",
"build:producer": "bun run --filter @hyperframes/producer build",
"studio": "bun run --filter @hyperframes/studio dev",
"build:hyperframes-runtime": "bun run --filter @hyperframes/core build:hyperframes-runtime",
Expand Down
54 changes: 54 additions & 0 deletions packages/browser-export/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# @hyperframes/browser-export

Render a HyperFrames composition to MP4/WebM **entirely in the browser** — no server, no FFmpeg, no headless Chrome (upstream discussion: heygen-com/hyperframes#1661).

Frames are sampled with the same quantized deterministic seek the producer uses, rasterized via SVG `foreignObject` ([html-to-image](https://github.com/bubkoo/html-to-image)) and encoded with WebCodecs through [mediabunny](https://mediabunny.dev).

```bash
npm install @hyperframes/browser-export
```

## Quick start

```ts
import { exportComposition, downloadExport } from "@hyperframes/browser-export";

// The composition must be live in the page (or a same-origin iframe document):
// GSAP timelines registered in window.__timelines, root carrying
// data-composition-id / data-width / data-height.
const result = await exportComposition(document, {
fps: 30,
format: "mp4", // or "webm"
onProgress: ({ phase, fraction }) => console.log(`${phase} ${(fraction * 100).toFixed(0)}%`),
});

downloadExport(result); // "<composition-id>.mp4"
```

## How it works

1. **Plan** — finds the composition root (`[data-composition-id]` or `#root`), reads dimensions, resolves the duration from the master GSAP timeline in `window.__timelines` (or `options.duration`).
2. **Audio** — parses `<audio>`/`<video>` elements (`data-start`, `data-duration`/`data-end`, `data-media-start`, `data-volume` — the producer's contract), decodes them and mixes offline with `OfflineAudioContext` into one track.
3. **Video** — for each frame: pause + seek every registered timeline at the quantized frame time (`Math.round(t·fps)/fps`, the producer's parity contract), await `<video>` layer seeks, rasterize the root to a canvas, hand the canvas frame to mediabunny's `CanvasSource`.
4. **Encode** — WebCodecs via mediabunny (`avc`+`aac` in MP4, `vp9`+`opus` in WebM), finalized into a `Blob`.

## API

| Export | Description |
| ----------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
| `exportComposition(target, options?)` | Full pipeline → `ExportResult { blob, mimeType, width, height, fps, durationSeconds, frameCount, compositionId }` |
| `downloadExport(result, filename?)` | Trigger a client-side download |
| `collectAudioClips(scope)` / `mixAudioClips(clips, duration)` | Audio pipeline pieces |
| `seekTimelines(registry, t, fps)` / `quantizeTimeToFrame(t, fps)` | Deterministic seek pieces |
| `findCompositionRoot(scope)` / `readCompositionMeta(root)` | Composition discovery |

`ExportOptions`: `fps` (30), `format` ("mp4"), `duration`, `videoBitrate`, `audioBitrate`, `includeAudio` (true), `pixelRatio` (1), `keyFrameIntervalSeconds` (2), `signal`, `onProgress`.

## Limitations (vs the server producer)

This pipeline **complements** `@hyperframes/producer` — it does not replace it. The producer remains the reference for deterministic, pixel-perfect renders.

- Requires WebCodecs (Chrome/Edge 94+, Safari 16.4+, Firefox 130+).
- SVG `foreignObject` rasterization: cross-origin images/fonts must be CORS-readable; some exotic CSS (e.g. backdrop-filter) may differ from a real Chrome screenshot.
- `<video>` layers are frame-aligned via async seeks with a 500 ms guard — long-GOP sources may land on the nearest decodable frame.
- Rendering happens on the main thread; a 30 s / 30 fps export is ~900 sequential rasterizations.
56 changes: 56 additions & 0 deletions packages/browser-export/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
{
"name": "@hyperframes/browser-export",
"version": "0.7.26",
"repository": {
"type": "git",
"url": "https://github.com/heygen-com/hyperframes",
"directory": "packages/browser-export"
},
"files": [
"dist",
"README.md"
],
"type": "module",
"sideEffects": false,
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": {
"browser": "./src/index.ts",
"bun": "./src/index.ts",
"import": "./src/index.ts",
"types": "./src/index.ts"
},
"./package.json": "./package.json"
},
"publishConfig": {
"access": "public",
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
},
"./package.json": "./package.json"
},
"main": "./dist/index.js",
"types": "./dist/index.d.ts"
},
"scripts": {
"build": "tsup",
"test": "vitest run",
"test:watch": "vitest",
"typecheck": "tsc --noEmit",
"prepublishOnly": "echo skip"
},
"dependencies": {
"html-to-image": "^1.11.13",
"mediabunny": "^1.45.3"
},
"devDependencies": {
"@types/node": "^25.0.10",
"linkedom": "^0.18.12",
"tsup": "^8.0.0",
"typescript": "^5.0.0",
"vitest": "^3.2.4"
}
}
Loading