Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
e58bff5
docs: add client-side renderer design spec
miguel-heygen Apr 9, 2026
b8c5619
docs: add client-side renderer implementation plan
miguel-heygen Apr 9, 2026
a00930d
feat(renderer): scaffold package with types and compat detection
miguel-heygen Apr 9, 2026
408d69b
feat(renderer): add frame timing and distribution utilities
miguel-heygen Apr 9, 2026
3112bc8
feat(renderer): add SnapDOM frame source implementation
miguel-heygen Apr 9, 2026
4b1e462
feat(renderer): add WebCodecs encoding worker and main-thread wrapper
miguel-heygen Apr 9, 2026
05ee085
feat(renderer): add OfflineAudioContext-based audio mixer
miguel-heygen Apr 9, 2026
fc823c5
feat(renderer): add video frame injector for <video> element capture
miguel-heygen Apr 9, 2026
5019fc0
feat(renderer): add parallel iframe pool for frame capture
miguel-heygen Apr 9, 2026
e79f6b0
feat(renderer): add main orchestrator and public API (render, createR…
miguel-heygen Apr 10, 2026
826179e
test(renderer): add end-to-end integration test with basic composition
miguel-heygen Apr 10, 2026
725d9e7
feat(renderer): integrate video frame injector into SnapDOM frame source
miguel-heygen Apr 10, 2026
18d6a3e
feat(renderer): add progress tracking and ETA estimation
miguel-heygen Apr 10, 2026
23b5d83
fix(renderer): address code review issues
miguel-heygen Apr 10, 2026
7672730
fix(renderer): fix iframe polling, add workerUrl config, add QA test …
miguel-heygen Apr 10, 2026
f10cd05
fix(renderer): fix viewport capture, empty audio, cancel propagation,…
miguel-heygen Apr 10, 2026
f9489af
feat(renderer): add drawElementImage + tab-capture frame sources, reg…
miguel-heygen Apr 10, 2026
e924c76
feat(studio): add browser-side export button using @hyperframes/renderer
miguel-heygen Apr 10, 2026
09363f1
fix(renderer): bridge __player→__hf in SnapDOM source, fix studio wor…
miguel-heygen Apr 10, 2026
7d36ab3
fix(renderer): add yields between frames to prevent main thread lockup
miguel-heygen Apr 11, 2026
0b15403
fix(studio): set concurrency=1 for browser render to prevent UI lockup
miguel-heygen Apr 11, 2026
6d4ad2e
fix(renderer): fix video frame drops, audio, animation jitter, and ba…
miguel-heygen Apr 11, 2026
83436f3
fix(renderer): fix regression harness — async wait, upload server, as…
miguel-heygen Apr 11, 2026
ef63bb9
chore(renderer): remove brainstorming docs, QA pages, and lockfile fr…
miguel-heygen Apr 11, 2026
f077c47
chore: merge main — resolve conflict in studio package.json
miguel-heygen Apr 11, 2026
e00055f
docs: add turbo render design spec
miguel-heygen Apr 11, 2026
04b4eee
feat(renderer): add turbo render mode — multi-tab parallel capture
miguel-heygen Apr 11, 2026
18a4a2d
fix(studio): read POST body as Buffer to preserve binary video uploads
miguel-heygen Apr 11, 2026
d4774be
fix(renderer): improve warmup for sub-composition fonts and GSAP settle
miguel-heygen Apr 11, 2026
4992555
fix(renderer): force reflow on sub-composition iframes to prevent pos…
miguel-heygen Apr 11, 2026
0eb4d07
fix(renderer): remove noopener from turbo popup (returns null per spec)
miguel-heygen Apr 11, 2026
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
44 changes: 44 additions & 0 deletions packages/core/src/studio-api/routes/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,50 @@ export function registerRenderRoutes(api: Hono, adapter: StudioApiAdapter): void
});
});

// Upload a browser-rendered video (client-side renderer posts the blob here)
api.post("/projects/:id/renders/upload", async (c) => {
const project = await adapter.resolveProject(c.req.param("id"));
if (!project) return c.json({ error: "not found" }, 404);

const rendersDir = adapter.rendersDir(project);
if (!existsSync(rendersDir)) mkdirSync(rendersDir, { recursive: true });

const body = await c.req.arrayBuffer();
if (!body || body.byteLength === 0) {
return c.json({ error: "empty body" }, 400);
}

const format = c.req.query("format") ?? "mp4";
const now = new Date();
const datePart = now.toISOString().slice(0, 10);
const timePart = now.toTimeString().slice(0, 8).replace(/:/g, "-");
const jobId = `${project.id}_browser_${datePart}_${timePart}`;
const ext = format === "webm" ? ".webm" : ".mp4";
const outputPath = join(rendersDir, `${jobId}${ext}`);

const { writeFileSync: writeFile } = await import("node:fs");
writeFile(outputPath, Buffer.from(body));
writeFile(
outputPath.replace(/\.(mp4|webm)$/, ".meta.json"),
JSON.stringify({
status: "complete",
source: "browser",
durationMs: Number(c.req.query("durationMs") ?? 0),
}),
);

// Register in job store so it shows in the renders list
renderJobs.set(jobId, {
id: jobId,
status: "complete",
progress: 100,
outputPath,
createdAt: Date.now(),
} as RenderJobState & { createdAt: number });

return c.json({ jobId, filename: `${jobId}${ext}`, size: body.byteLength });
});

// List renders
api.get("/projects/:id/renders", async (c) => {
const project = await adapter.resolveProject(c.req.param("id"));
Expand Down
52 changes: 52 additions & 0 deletions packages/renderer/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
{
"name": "@hyperframes/renderer",
"version": "0.0.1",
"description": "Client-side video renderer for HyperFrames compositions",
"repository": {
"type": "git",
"url": "https://github.com/heygen-com/hyperframes",
"directory": "packages/renderer"
},
"files": [
"dist",
"README.md"
],
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": {
"import": "./src/index.ts",
"types": "./src/index.ts"
}
},
"publishConfig": {
"access": "public",
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
}
},
"main": "./dist/index.js",
"types": "./dist/index.d.ts"
},
"scripts": {
"build": "tsc",
"test": "vitest run",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@zumer/snapdom": "^2.8.0",
"mediabunny": "^1.40.0"
},
"devDependencies": {
"@types/jsdom": "^28.0.1",
"jsdom": "^29.0.2",
"typescript": "^5.0.0",
"vitest": "^3.2.4"
},
"peerDependencies": {
"@hyperframes/core": "workspace:^"
}
}
10 changes: 10 additions & 0 deletions packages/renderer/public/turbo-worker.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Turbo Worker</title>
</head>
<body>
<script type="module" src="./turbo-worker.bundle.js"></script>
</body>
</html>
92 changes: 92 additions & 0 deletions packages/renderer/scripts/compile-test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
/**
* Compile a single test composition via the producer's htmlCompiler.
* Usage: npx tsx --esm packages/renderer/scripts/compile-test.ts <test-name>
*/
import { writeFileSync, mkdirSync, cpSync } from "node:fs";
import { resolve, join } from "node:path";
import { compileForRender } from "../../producer/src/services/htmlCompiler.js";
import { getVerifiedHyperframeRuntimeSource } from "../../producer/src/services/hyperframeRuntimeLoader.js";

const testName = process.argv[2];
if (!testName) {
console.error("Usage: npx tsx packages/renderer/scripts/compile-test.ts <test-name>");
process.exit(1);
}

const srcDir = resolve(`packages/producer/tests/${testName}/src`);
const htmlPath = join(srcDir, "index.html");
const outputDir = resolve(`renders/parity-regression/${testName}`);
mkdirSync(outputDir, { recursive: true });

console.log(`Compiling ${testName}...`);

// Copy all source assets to output dir so the composition can resolve relative paths
cpSync(srcDir, outputDir, { recursive: true, force: true });

const result = await compileForRender(srcDir, htmlPath, outputDir);

// Inject the HyperFrames runtime IIFE and the __player → __hf bridge.
// The producer's file server normally serves these separately,
// but for client-side rendering we need them inline.
const runtimeSource = getVerifiedHyperframeRuntimeSource();

// Bridge script: maps window.__player (set by runtime) → window.__hf (engine protocol)
const bridgeScript = `(function() {
function getDeclaredDuration() {
var root = document.querySelector('[data-composition-id]');
if (!root) return 0;
var d = Number(root.getAttribute('data-duration'));
return Number.isFinite(d) && d > 0 ? d : 0;
}
function bridge() {
var p = window.__player;
if (!p || typeof p.renderSeek !== "function" || typeof p.getDuration !== "function") {
return false;
}
window.__hf = {
get duration() {
var d = p.getDuration();
return d > 0 ? d : getDeclaredDuration();
},
seek: function(t) { p.renderSeek(t); },
};
return true;
}
if (bridge()) return;
var iv = setInterval(function() {
if (bridge()) clearInterval(iv);
}, 50);
})();`;

const runtimeTag = `<script data-hyperframes-render-runtime>${runtimeSource}</script>`;
const bridgeTag = `<script data-hyperframes-bridge>${bridgeScript}</script>`;
const htmlWithRuntime = result.html.replace("</body>", `${runtimeTag}\n${bridgeTag}\n</body>`);

writeFileSync(join(outputDir, "compiled.html"), htmlWithRuntime);
writeFileSync(
join(outputDir, "compile-meta.json"),
JSON.stringify(
{
width: result.width,
height: result.height,
staticDuration: result.staticDuration,
videoCount: result.videos.length,
audioCount: result.audios.length,
htmlLength: result.html.length,
},
null,
2,
),
);

console.log(
JSON.stringify({
ok: true,
width: result.width,
height: result.height,
staticDuration: result.staticDuration,
videos: result.videos.length,
audios: result.audios.length,
htmlLength: result.html.length,
}),
);
Loading
Loading