From 931e7c1ea9439efa21e7c53b598494a3f7a09ee9 Mon Sep 17 00:00:00 2001 From: LONTSI TIOTSOP CEDRIC Date: Fri, 3 Jul 2026 20:32:04 +0100 Subject: [PATCH] feat(browser-export): 100% client-side MP4/WebM export via WebCodecs (mediabunny) New @hyperframes/browser-export package (discussion in #1661): deterministic quantized GSAP seeks (producer parity contract), SVG foreignObject rasterization (html-to-image), OfflineAudioContext mix of data-start/data-duration audio clips, WebCodecs encoding via mediabunny (avc+aac MP4, vp9+opus WebM). Browser-only tsup build (platform: "browser" compile-time guarantee), 26 unit tests on the pure logic, docs page + build-order registration. --- bun.lock | 45 ++++-- docs/docs.json | 1 + docs/packages/browser-export.mdx | 66 ++++++++ package.json | 2 +- packages/browser-export/README.md | 54 +++++++ packages/browser-export/package.json | 56 +++++++ .../browser-export/src/audioClips.test.ts | 56 +++++++ packages/browser-export/src/audioClips.ts | 56 +++++++ packages/browser-export/src/audioMix.ts | 73 +++++++++ packages/browser-export/src/codecs.test.ts | 50 ++++++ packages/browser-export/src/codecs.ts | 17 ++ .../src/compositionMeta.test.ts | 53 +++++++ .../browser-export/src/compositionMeta.ts | 32 ++++ packages/browser-export/src/download.ts | 17 ++ packages/browser-export/src/encoder.ts | 70 +++++++++ packages/browser-export/src/exporter.ts | 145 ++++++++++++++++++ packages/browser-export/src/frameCapture.ts | 41 +++++ .../browser-export/src/frameTiming.test.ts | 30 ++++ packages/browser-export/src/frameTiming.ts | 19 +++ packages/browser-export/src/index.ts | 21 +++ packages/browser-export/src/mediaSeek.ts | 45 ++++++ .../browser-export/src/timelineSeek.test.ts | 68 ++++++++ packages/browser-export/src/timelineSeek.ts | 57 +++++++ packages/browser-export/src/types.ts | 45 ++++++ packages/browser-export/tsconfig.json | 19 +++ packages/browser-export/tsup.config.ts | 18 +++ 26 files changed, 1142 insertions(+), 14 deletions(-) create mode 100644 docs/packages/browser-export.mdx create mode 100644 packages/browser-export/README.md create mode 100644 packages/browser-export/package.json create mode 100644 packages/browser-export/src/audioClips.test.ts create mode 100644 packages/browser-export/src/audioClips.ts create mode 100644 packages/browser-export/src/audioMix.ts create mode 100644 packages/browser-export/src/codecs.test.ts create mode 100644 packages/browser-export/src/codecs.ts create mode 100644 packages/browser-export/src/compositionMeta.test.ts create mode 100644 packages/browser-export/src/compositionMeta.ts create mode 100644 packages/browser-export/src/download.ts create mode 100644 packages/browser-export/src/encoder.ts create mode 100644 packages/browser-export/src/exporter.ts create mode 100644 packages/browser-export/src/frameCapture.ts create mode 100644 packages/browser-export/src/frameTiming.test.ts create mode 100644 packages/browser-export/src/frameTiming.ts create mode 100644 packages/browser-export/src/index.ts create mode 100644 packages/browser-export/src/mediaSeek.ts create mode 100644 packages/browser-export/src/timelineSeek.test.ts create mode 100644 packages/browser-export/src/timelineSeek.ts create mode 100644 packages/browser-export/src/types.ts create mode 100644 packages/browser-export/tsconfig.json create mode 100644 packages/browser-export/tsup.config.ts diff --git a/bun.lock b/bun.lock index cac1f849d6..4a86d91418 100644 --- a/bun.lock +++ b/bun.lock @@ -22,7 +22,7 @@ }, "packages/aws-lambda": { "name": "@hyperframes/aws-lambda", - "version": "0.7.22", + "version": "0.7.26", "dependencies": { "@aws-sdk/client-s3": "^3.700.0", "@aws-sdk/client-sfn": "^3.700.0", @@ -52,9 +52,24 @@ "constructs", ], }, + "packages/browser-export": { + "name": "@hyperframes/browser-export", + "version": "0.7.26", + "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", + }, + }, "packages/cli": { "name": "@hyperframes/cli", - "version": "0.7.22", + "version": "0.7.26", "bin": { "hyperframes": "./dist/cli.js", }, @@ -103,7 +118,7 @@ }, "packages/core": { "name": "@hyperframes/core", - "version": "0.7.22", + "version": "0.7.26", "dependencies": { "@chenglou/pretext": "^0.0.5", "@hyperframes/lint": "workspace:*", @@ -128,7 +143,7 @@ }, "packages/engine": { "name": "@hyperframes/engine", - "version": "0.7.22", + "version": "0.7.26", "dependencies": { "@hono/node-server": "^1.13.0", "@hyperframes/core": "workspace:^", @@ -146,7 +161,7 @@ }, "packages/gcp-cloud-run": { "name": "@hyperframes/gcp-cloud-run", - "version": "0.7.22", + "version": "0.7.26", "dependencies": { "@google-cloud/storage": "^7.14.0", "@google-cloud/workflows": "^4.2.0", @@ -166,7 +181,7 @@ }, "packages/lint": { "name": "@hyperframes/lint", - "version": "0.7.22", + "version": "0.7.26", "dependencies": { "@hyperframes/parsers": "workspace:*", "linkedom": "^0.18.12", @@ -182,7 +197,7 @@ }, "packages/parsers": { "name": "@hyperframes/parsers", - "version": "0.7.22", + "version": "0.7.26", "dependencies": { "@babel/parser": "^7.27.0", "acorn": "^8.17.0", @@ -202,7 +217,7 @@ }, "packages/player": { "name": "@hyperframes/player", - "version": "0.7.22", + "version": "0.7.26", "dependencies": { "@hyperframes/core": "workspace:*", }, @@ -217,7 +232,7 @@ }, "packages/producer": { "name": "@hyperframes/producer", - "version": "0.7.22", + "version": "0.7.26", "dependencies": { "@fontsource/archivo-black": "^5.2.8", "@fontsource/eb-garamond": "^5.2.7", @@ -261,7 +276,7 @@ }, "packages/sdk": { "name": "@hyperframes/sdk", - "version": "0.7.22", + "version": "0.7.26", "dependencies": { "@hyperframes/core": "workspace:*", "@hyperframes/parsers": "workspace:*", @@ -288,7 +303,7 @@ }, "packages/shader-transitions": { "name": "@hyperframes/shader-transitions", - "version": "0.7.22", + "version": "0.7.26", "dependencies": { "html2canvas": "^1.4.1", }, @@ -300,7 +315,7 @@ }, "packages/studio": { "name": "@hyperframes/studio", - "version": "0.7.22", + "version": "0.7.26", "dependencies": { "@codemirror/autocomplete": "^6.20.1", "@codemirror/commands": "^6.10.3", @@ -348,7 +363,7 @@ }, "packages/studio-server": { "name": "@hyperframes/studio-server", - "version": "0.7.22", + "version": "0.7.26", "dependencies": { "@hyperframes/core": "workspace:*", "@hyperframes/parsers": "workspace:*", @@ -715,6 +730,8 @@ "@hyperframes/aws-lambda": ["@hyperframes/aws-lambda@workspace:packages/aws-lambda"], + "@hyperframes/browser-export": ["@hyperframes/browser-export@workspace:packages/browser-export"], + "@hyperframes/cli": ["@hyperframes/cli@workspace:packages/cli"], "@hyperframes/core": ["@hyperframes/core@workspace:packages/core"], @@ -1589,6 +1606,8 @@ "html-escaper": ["html-escaper@3.0.3", "", {}, "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ=="], + "html-to-image": ["html-to-image@1.11.13", "", {}, "sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg=="], + "html2canvas": ["html2canvas@1.4.1", "", { "dependencies": { "css-line-break": "^2.1.0", "text-segmentation": "^1.0.3" } }, "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA=="], "htmlparser2": ["htmlparser2@10.1.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.2.2", "entities": "^7.0.1" } }, "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ=="], diff --git a/docs/docs.json b/docs/docs.json index 9e6829eb40..708ce13b81 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -325,6 +325,7 @@ "packages/core", "packages/parsers", "packages/lint", + "packages/browser-export", "packages/studio-server", "packages/sdk", "packages/engine", diff --git a/docs/packages/browser-export.mdx b/docs/packages/browser-export.mdx new file mode 100644 index 0000000000..0612e6fe9a --- /dev/null +++ b/docs/packages/browser-export.mdx @@ -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 + + + 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. + + + + 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. + + +## 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); // ".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 `