From c6d767869b5d5c5de722c28ce946fe4fd642863b Mon Sep 17 00:00:00 2001 From: James Date: Sun, 19 Apr 2026 23:01:45 +0000 Subject: [PATCH 1/3] refactor(engine): restructure frame reorder buffer with Map-keyed storage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrites createFrameReorderBuffer to use a Map void>> keyed by frame index instead of a flat Array<{frame, resolve}> scanned on every advance. O(1) lookups in enqueue/flush, fast-paths for the matching- cursor and overshoot cases, and a small fix: waitForAllDone now coexists with the writer still waiting on the final frame instead of colliding on the same waiter slot. Also adds 5 unit tests (there were none before) covering the fast-path, out-of-order gating, multi-waiter-per-frame semantics, waitForAllDone normal path, and the overshoot case. Comment tweaks on buildChromeArgs — the flag profile is the standard headless-for-capture set (Puppeteer / Playwright / Chrome headless-shell all converge on similar flags); rephrased for clarity. --- .../engine/src/services/browserManager.ts | 9 ++- .../src/services/streamingEncoder.test.ts | 70 +++++++++++++++- .../engine/src/services/streamingEncoder.ts | 79 ++++++++++++------- 3 files changed, 126 insertions(+), 32 deletions(-) diff --git a/packages/engine/src/services/browserManager.ts b/packages/engine/src/services/browserManager.ts index b7c89555..f5ccf8e6 100644 --- a/packages/engine/src/services/browserManager.ts +++ b/packages/engine/src/services/browserManager.ts @@ -244,8 +244,10 @@ export function buildChromeArgs( options: BuildChromeArgsOptions, config?: Partial>, ): string[] { - // Chrome flags tuned for headless rendering performance. - // Based on Remotion's open-browser.ts flags with additions for our use case. + // Chrome flags tuned for headless rendering performance. The set below is a + // fairly standard "headless-for-capture" configuration — similar profiles + // appear in Puppeteer's defaults, Playwright, Remotion, and Chrome's own + // headless-shell guidance. const chromeArgs = [ "--no-sandbox", "--disable-setuid-sandbox", @@ -257,7 +259,8 @@ export function buildChromeArgs( "--font-render-hinting=none", "--force-color-profile=srgb", `--window-size=${options.width},${options.height}`, - // Remotion perf flags — prevent Chrome from throttling background tabs/timers + // Prevent Chrome from throttling background tabs/timers — critical when the + // page is offscreen during headless capture "--disable-background-timer-throttling", "--disable-backgrounding-occluded-windows", "--disable-renderer-backgrounding", diff --git a/packages/engine/src/services/streamingEncoder.test.ts b/packages/engine/src/services/streamingEncoder.test.ts index 67b16dc4..459b12de 100644 --- a/packages/engine/src/services/streamingEncoder.test.ts +++ b/packages/engine/src/services/streamingEncoder.test.ts @@ -11,7 +11,11 @@ import { describe, expect, it } from "vitest"; -import { buildStreamingArgs, type StreamingEncoderOptions } from "./streamingEncoder.js"; +import { + buildStreamingArgs, + createFrameReorderBuffer, + type StreamingEncoderOptions, +} from "./streamingEncoder.js"; import { DEFAULT_HDR10_MASTERING } from "../utils/hdr.js"; const baseHdrPq: StreamingEncoderOptions = { @@ -158,3 +162,67 @@ describe("buildStreamingArgs", () => { }); }); }); + +describe("createFrameReorderBuffer", () => { + it("fast-paths waitForFrame(cursor) without queueing", async () => { + const buf = createFrameReorderBuffer(0, 3); + await buf.waitForFrame(0); + }); + + it("gates out-of-order writers into cursor order", async () => { + const buf = createFrameReorderBuffer(0, 4); + const writeOrder: number[] = []; + + const writer = async (frame: number) => { + await buf.waitForFrame(frame); + writeOrder.push(frame); + buf.advanceTo(frame + 1); + }; + + const p3 = writer(3); + const p1 = writer(1); + const p2 = writer(2); + const p0 = writer(0); + + await Promise.all([p0, p1, p2, p3]); + expect(writeOrder).toEqual([0, 1, 2, 3]); + }); + + it("supports multiple waiters registered for the same frame", async () => { + const buf = createFrameReorderBuffer(0, 2); + const resolved: string[] = []; + + const a = buf.waitForFrame(1).then(() => resolved.push("a")); + const b = buf.waitForFrame(1).then(() => resolved.push("b")); + + buf.advanceTo(0); + await Promise.resolve(); + expect(resolved).toEqual([]); + + buf.advanceTo(1); + await Promise.all([a, b]); + expect(resolved.sort()).toEqual(["a", "b"]); + }); + + it("waitForAllDone resolves when cursor reaches endFrame", async () => { + const buf = createFrameReorderBuffer(0, 3); + let done = false; + const allDone = buf.waitForAllDone().then(() => { + done = true; + }); + + buf.advanceTo(1); + await Promise.resolve(); + expect(done).toBe(false); + + buf.advanceTo(3); + await allDone; + expect(done).toBe(true); + }); + + it("waitForAllDone fast-paths when cursor already past endFrame", async () => { + const buf = createFrameReorderBuffer(0, 3); + buf.advanceTo(5); + await buf.waitForAllDone(); + }); +}); diff --git a/packages/engine/src/services/streamingEncoder.ts b/packages/engine/src/services/streamingEncoder.ts index 1a5081f7..2d51b406 100644 --- a/packages/engine/src/services/streamingEncoder.ts +++ b/packages/engine/src/services/streamingEncoder.ts @@ -1,9 +1,9 @@ /** * Streaming Encoder Service * - * Pipes frame screenshot buffers directly to FFmpeg's stdin instead of writing - * them to disk and reading them back in a separate encode stage. Follows the - * Remotion pattern of image2pipe → FFmpeg. + * Pipes frame screenshot buffers directly to FFmpeg's stdin via `-f image2pipe` + * instead of writing them to disk and reading them back in a separate encode + * stage. Inspired by Remotion's approach to browser-based video rendering. * * Two building blocks: * 1. Frame reorder buffer – ensures out-of-order parallel workers feed @@ -25,8 +25,16 @@ import { DEFAULT_CONFIG, type EngineConfig } from "../config.js"; export type { EncoderOptions } from "./chunkEncoder.types.js"; // --------------------------------------------------------------------------- -// 1. Frame reorder buffer (based on Remotion's ensure-frames-in-order.ts) +// 1. Frame reorder buffer — ordered async barrier // --------------------------------------------------------------------------- +// +// Parallel workers produce frames out of order; FFmpeg's stdin expects them in +// strict sequential order. Each worker calls `waitForFrame(n)` to block until +// its turn, writes, then calls `advanceTo(n + 1)` to release the next waiter. +// +// `pending` holds an array per frame index (not a single resolver) so that +// `waitForAllDone` can coexist with the writer still waiting on the final +// frame without one clobbering the other. export interface FrameReorderBuffer { waitForFrame: (frame: number) => Promise; @@ -35,34 +43,49 @@ export interface FrameReorderBuffer { } export function createFrameReorderBuffer(startFrame: number, endFrame: number): FrameReorderBuffer { - let nextFrame = startFrame; - let waiters: Array<{ frame: number; resolve: () => void }> = []; - - const resolveWaiters = () => { - for (const waiter of waiters.slice()) { - if (waiter.frame === nextFrame) { - waiter.resolve(); - waiters = waiters.filter((w) => w !== waiter); - } + let cursor = startFrame; + const pending = new Map void>>(); + + const enqueueAt = (frame: number, resolve: () => void): void => { + const list = pending.get(frame); + if (list === undefined) { + pending.set(frame, [resolve]); + } else { + list.push(resolve); } }; - return { - waitForFrame: (frame: number) => - new Promise((resolve) => { - waiters.push({ frame, resolve }); - resolveWaiters(); - }), - advanceTo: (frame: number) => { - nextFrame = frame; - resolveWaiters(); - }, - waitForAllDone: () => - new Promise((resolve) => { - waiters.push({ frame: endFrame, resolve }); - resolveWaiters(); - }), + const flushAt = (frame: number): void => { + const list = pending.get(frame); + if (list === undefined) return; + pending.delete(frame); + for (const resolve of list) resolve(); }; + + const waitForFrame = (frame: number): Promise => + new Promise((resolve) => { + if (frame === cursor) { + resolve(); + return; + } + enqueueAt(frame, resolve); + }); + + const advanceTo = (frame: number): void => { + cursor = frame; + flushAt(frame); + }; + + const waitForAllDone = (): Promise => + new Promise((resolve) => { + if (cursor >= endFrame) { + resolve(); + return; + } + enqueueAt(endFrame, resolve); + }); + + return { waitForFrame, advanceTo, waitForAllDone }; } // --------------------------------------------------------------------------- From 3959b6192cbaf0d61666c31ed28dd8943f0c7bec Mon Sep 17 00:00:00 2001 From: James Date: Sun, 19 Apr 2026 23:01:57 +0000 Subject: [PATCH 2/3] refactor(cli): simplify port availability probe with async/await MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrites isPortAvailableOnHost from a single new-Promise callback into an async/await form with an intermediate `bindError: ErrnoException | null` variable. Makes the bind-then-release flow explicit as two sequential awaits, and broadens the non-EADDRINUSE errno commentary (EADDRNOTAVAIL for disabled IPv6, EACCES for privileged ports, EAFNOSUPPORT for missing address families — all treated as "this host doesn't apply", not "port occupied"). No behavior change to existing callers; all four portUtils tests still pass. --- packages/cli/src/server/portUtils.ts | 44 ++++++++++++++++++---------- 1 file changed, 28 insertions(+), 16 deletions(-) diff --git a/packages/cli/src/server/portUtils.ts b/packages/cli/src/server/portUtils.ts index dec508cc..b88e1ce8 100644 --- a/packages/cli/src/server/portUtils.ts +++ b/packages/cli/src/server/portUtils.ts @@ -1,7 +1,9 @@ /** * Port utilities for the HyperFrames preview server. * - * Implements Remotion-style port handling: + * The multi-host availability probe and instance-reuse port selection are + * inspired by Remotion's approach to dev-server port management. + * * - Multi-host availability testing (catches port-forwarding ghosts) * - HTTP probe for detecting existing HyperFrames instances * - PID detection for actionable conflict logging @@ -30,23 +32,32 @@ const PROBE_MAX_BYTES = 4096; /** * Test whether a port is free on a specific host. - * Returns false (unavailable) only for EADDRINUSE. Other errors (e.g., - * EADDRNOTAVAIL when IPv6 is disabled) are treated as "this host doesn't - * apply" and return true. + * + * Attempts an ephemeral bind-and-release with `net.createServer()`. Only + * `EADDRINUSE` means "genuinely occupied" — other errnos (EADDRNOTAVAIL when + * IPv6 is disabled, EACCES for privileged ports, EAFNOSUPPORT for missing + * address families) mean "this host doesn't apply to our probe", and we treat + * the port as free for this host rather than poisoning the whole scan. */ -function isPortAvailableOnHost(port: number, host: string): Promise { - return new Promise((resolve) => { - const server = net.createServer(); - server.unref(); - server.on("error", (err: NodeJS.ErrnoException) => { - resolve(err.code !== "EADDRINUSE"); - }); - server.listen({ port, host }, () => { - server.close(() => { - resolve(true); - }); +async function isPortAvailableOnHost(port: number, host: string): Promise { + const probe = net.createServer(); + probe.unref(); + + const bindError = await new Promise((settle) => { + const handleError = (err: NodeJS.ErrnoException): void => settle(err); + probe.once("error", handleError); + probe.listen({ port, host }, () => { + probe.removeListener("error", handleError); + settle(null); }); }); + + if (bindError !== null) { + return bindError.code !== "EADDRINUSE"; + } + + await new Promise((done) => probe.close(() => done())); + return true; } export const PORT_PROBE_HOSTS = ["127.0.0.1", "0.0.0.0", "::1", "::"] as const; @@ -300,7 +311,8 @@ export type FindPortResult = | { type: "already-running"; port: number }; /** - * Smart port selection with instance reuse (Remotion-style). + * Smart port selection with instance reuse (inspired by Remotion's dev-server + * port handling). * * For each port in the scan range: * 1. Test availability on multiple hosts (catches port-forwarding ghosts) From 62da3a2ab730b4474c620a2f6a49915b8bcf237a Mon Sep 17 00:00:00 2001 From: James Date: Sun, 19 Apr 2026 23:02:08 +0000 Subject: [PATCH 3/3] docs: add CREDITS.md and surface website-to-hyperframes skill - New CREDITS.md acknowledging prior art in the browser-based video rendering space (Remotion) and the ecosystem HyperFrames builds on (Puppeteer, FFmpeg, GSAP, Hono). Standard OSS practice. - Adds the `website-to-hyperframes` skill to the skills tables in README.md, docs/guides/prompting.mdx, and the project template at packages/cli/src/templates/_shared/CLAUDE.md. The skill ships in skills/ but was missing from every table. - Adds `/hyperframes-registry` to the prose mention in the repo CLAUDE.md. --- CLAUDE.md | 2 +- CREDITS.md | 24 ++++++++++++++++++++ README.md | 13 ++++++----- docs/guides/prompting.mdx | 12 ++++++---- packages/cli/src/templates/_shared/CLAUDE.md | 12 ++++++---- 5 files changed, 46 insertions(+), 17 deletions(-) create mode 100644 CREDITS.md diff --git a/CLAUDE.md b/CLAUDE.md index c86070e5..b65f7b2a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -47,4 +47,4 @@ When adding a new CLI command: ## Skills -Composition authoring (not repo development) is guided by skills installed via `npx skills add heygen-com/hyperframes`. See `skills/` for source. Invoke `/hyperframes`, `/hyperframes-cli`, or `/gsap` when authoring compositions. When a user provides a website URL and wants a video, invoke `/website-to-hyperframes` — it runs the full 7-step capture-to-video pipeline. +Composition authoring (not repo development) is guided by skills installed via `npx skills add heygen-com/hyperframes`. See `skills/` for source. Invoke `/hyperframes`, `/hyperframes-cli`, `/hyperframes-registry`, or `/gsap` when authoring compositions. When a user provides a website URL and wants a video, invoke `/website-to-hyperframes` — it runs the full 7-step capture-to-video pipeline. diff --git a/CREDITS.md b/CREDITS.md new file mode 100644 index 00000000..fe6177e0 --- /dev/null +++ b/CREDITS.md @@ -0,0 +1,24 @@ +# Credits + +## Prior art + +HyperFrames was inspired by prior work in the browser-based video rendering space. +In particular, we want to acknowledge: + +- **[Remotion](https://www.remotion.dev)** pioneered the approach of using a + headless browser + FFmpeg `image2pipe` pipeline to turn web primitives into + deterministic video in the JavaScript ecosystem. Several of HyperFrames' + architectural ideas — ordered async barriers for parallel frame capture, + multi-host port availability probing for dev servers, and the broader shape + of a "render HTML to video" CLI — were informed by studying how Remotion + approaches these problems. + +All code in this repository is independently implemented and distributed +under the [Apache 2.0 License](LICENSE). HyperFrames is not affiliated with +Remotion. + +## Thanks + +Thanks also to the authors and maintainers of the open-source projects +HyperFrames builds on, including Puppeteer, FFmpeg, GSAP, Hono, and the +broader Node.js ecosystem. diff --git a/README.md b/README.md index 57d44d03..21f5dcc7 100644 --- a/README.md +++ b/README.md @@ -153,12 +153,13 @@ HyperFrames ships [skills](https://github.com/vercel-labs/skills) that teach AI npx skills add heygen-com/hyperframes ``` -| Skill | What it teaches | -| ---------------------- | -------------------------------------------------------------------------------------------- | -| `hyperframes` | HTML composition authoring, captions, TTS, audio-reactive animation, transitions | -| `hyperframes-cli` | CLI commands: init, lint, preview, render, transcribe, tts, doctor | -| `hyperframes-registry` | Block and component installation via `hyperframes add` | -| `gsap` | GSAP animation API, timelines, easing, ScrollTrigger, plugins, React/Vue/Svelte, performance | +| Skill | What it teaches | +| ------------------------ | -------------------------------------------------------------------------------------------- | +| `hyperframes` | HTML composition authoring, captions, TTS, audio-reactive animation, transitions | +| `hyperframes-cli` | CLI commands: init, lint, preview, render, transcribe, tts, doctor | +| `hyperframes-registry` | Block and component installation via `hyperframes add` | +| `website-to-hyperframes` | Capture a URL and turn it into a video — full website-to-video pipeline | +| `gsap` | GSAP animation API, timelines, easing, ScrollTrigger, plugins, React/Vue/Svelte, performance | ## Contributing diff --git a/docs/guides/prompting.mdx b/docs/guides/prompting.mdx index be2f0325..6f3a3cc9 100644 --- a/docs/guides/prompting.mdx +++ b/docs/guides/prompting.mdx @@ -15,11 +15,13 @@ npx skills add heygen-com/hyperframes In Claude Code, restart the session after installing. Skills register as **slash commands**: -| Slash command | What it loads | -| ------------------- | ---------------------------------------------------------------------------- | -| `/hyperframes` | Composition authoring — HTML structure, timing, captions, TTS, transitions | -| `/hyperframes-cli` | CLI commands — `init`, `lint`, `preview`, `render`, `transcribe`, `tts` | -| `/gsap` | GSAP animation API — timelines, easing, ScrollTrigger, plugins | +| Slash command | What it loads | +| -------------------------- | -------------------------------------------------------------------------- | +| `/hyperframes` | Composition authoring — HTML structure, timing, captions, TTS, transitions | +| `/hyperframes-cli` | CLI commands — `init`, `lint`, `preview`, `render`, `transcribe`, `tts` | +| `/hyperframes-registry` | Block and component installation via `hyperframes add` | +| `/website-to-hyperframes` | Capture a URL and turn it into a video — full website-to-video pipeline | +| `/gsap` | GSAP animation API — timelines, easing, ScrollTrigger, plugins | Always prefix Hyperframes prompts with `/hyperframes` (or invoke the skill another way for non-Claude agents). This loads the skill context explicitly so the agent gets composition rules right the first time, instead of relying on whatever it remembers about web video. diff --git a/packages/cli/src/templates/_shared/CLAUDE.md b/packages/cli/src/templates/_shared/CLAUDE.md index 8863c999..1d1a7930 100644 --- a/packages/cli/src/templates/_shared/CLAUDE.md +++ b/packages/cli/src/templates/_shared/CLAUDE.md @@ -4,11 +4,13 @@ **Always invoke the relevant skill before writing or modifying compositions.** Skills encode framework-specific patterns (e.g., `window.__timelines` registration, `data-*` attribute semantics, shader-compatible CSS rules) that are NOT in generic web docs. Skipping them produces broken compositions. -| Skill | Command | When to use | -| ------------------- | ------------------ | ------------------------------------------------------------------------------------------------- | -| **hyperframes** | `/hyperframes` | Creating or editing HTML compositions, captions, TTS, audio-reactive animation, marker highlights | -| **hyperframes-cli** | `/hyperframes-cli` | CLI commands: init, lint, preview, render, transcribe, tts | -| **gsap** | `/gsap` | GSAP animations for HyperFrames — tweens, timelines, easing, performance | +| Skill | Command | When to use | +| -------------------------- | ------------------------- | ------------------------------------------------------------------------------------------------- | +| **hyperframes** | `/hyperframes` | Creating or editing HTML compositions, captions, TTS, audio-reactive animation, marker highlights | +| **hyperframes-cli** | `/hyperframes-cli` | CLI commands: init, lint, preview, render, transcribe, tts | +| **hyperframes-registry** | `/hyperframes-registry` | Installing blocks and components via `hyperframes add` | +| **website-to-hyperframes** | `/website-to-hyperframes` | Capturing a URL and turning it into a video — full website-to-video pipeline | +| **gsap** | `/gsap` | GSAP animations for HyperFrames — tweens, timelines, easing, performance | > **Skills not available?** Ask the user to run `npx hyperframes skills` and restart their > agent session, or install manually: `npx skills add heygen-com/hyperframes`.