refactor: frame reorder buffer + port probe cleanup; add CREDITS.md and missing skill (#341)

* refactor(engine): restructure frame reorder buffer with Map-keyed storage

Rewrites createFrameReorderBuffer to use a Map<number, Array<() => 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.

* refactor(cli): simplify port availability probe with async/await

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.

* 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.
This commit is contained in:
James Russo
2026-04-19 16:33:42 -07:00
committed by GitHub
parent 0cc79a35b0
commit 7b0c7e73b2
9 changed files with 199 additions and 64 deletions
+1 -1
View File
@@ -47,4 +47,4 @@ When adding a new CLI command:
## Skills ## 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.
+24
View File
@@ -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.
+7 -6
View File
@@ -153,12 +153,13 @@ HyperFrames ships [skills](https://github.com/vercel-labs/skills) that teach AI
npx skills add heygen-com/hyperframes npx skills add heygen-com/hyperframes
``` ```
| Skill | What it teaches | | Skill | What it teaches |
| ---------------------- | -------------------------------------------------------------------------------------------- | | ------------------------ | -------------------------------------------------------------------------------------------- |
| `hyperframes` | HTML composition authoring, captions, TTS, audio-reactive animation, transitions | | `hyperframes` | HTML composition authoring, captions, TTS, audio-reactive animation, transitions |
| `hyperframes-cli` | CLI commands: init, lint, preview, render, transcribe, tts, doctor | | `hyperframes-cli` | CLI commands: init, lint, preview, render, transcribe, tts, doctor |
| `hyperframes-registry` | Block and component installation via `hyperframes add` | | `hyperframes-registry` | Block and component installation via `hyperframes add` |
| `gsap` | GSAP animation API, timelines, easing, ScrollTrigger, plugins, React/Vue/Svelte, performance | | `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 ## Contributing
+7 -5
View File
@@ -15,11 +15,13 @@ npx skills add heygen-com/hyperframes
In Claude Code, restart the session after installing. Skills register as **slash commands**: In Claude Code, restart the session after installing. Skills register as **slash commands**:
| Slash command | What it loads | | Slash command | What it loads |
| ------------------- | ---------------------------------------------------------------------------- | | -------------------------- | -------------------------------------------------------------------------- |
| `/hyperframes` | Composition authoring — HTML structure, timing, captions, TTS, transitions | | `/hyperframes` | Composition authoring — HTML structure, timing, captions, TTS, transitions |
| `/hyperframes-cli` | CLI commands — `init`, `lint`, `preview`, `render`, `transcribe`, `tts` | | `/hyperframes-cli` | CLI commands — `init`, `lint`, `preview`, `render`, `transcribe`, `tts` |
| `/gsap` | GSAP animation API — timelines, easing, ScrollTrigger, plugins | | `/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 |
<Tip> <Tip>
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. 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.
+28 -16
View File
@@ -1,7 +1,9 @@
/** /**
* Port utilities for the HyperFrames preview server. * 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) * - Multi-host availability testing (catches port-forwarding ghosts)
* - HTTP probe for detecting existing HyperFrames instances * - HTTP probe for detecting existing HyperFrames instances
* - PID detection for actionable conflict logging * - 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. * 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 * Attempts an ephemeral bind-and-release with `net.createServer()`. Only
* apply" and return true. * `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<boolean> { async function isPortAvailableOnHost(port: number, host: string): Promise<boolean> {
return new Promise<boolean>((resolve) => { const probe = net.createServer();
const server = net.createServer(); probe.unref();
server.unref();
server.on("error", (err: NodeJS.ErrnoException) => { const bindError = await new Promise<NodeJS.ErrnoException | null>((settle) => {
resolve(err.code !== "EADDRINUSE"); const handleError = (err: NodeJS.ErrnoException): void => settle(err);
}); probe.once("error", handleError);
server.listen({ port, host }, () => { probe.listen({ port, host }, () => {
server.close(() => { probe.removeListener("error", handleError);
resolve(true); settle(null);
});
}); });
}); });
if (bindError !== null) {
return bindError.code !== "EADDRINUSE";
}
await new Promise<void>((done) => probe.close(() => done()));
return true;
} }
export const PORT_PROBE_HOSTS = ["127.0.0.1", "0.0.0.0", "::1", "::"] as const; 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 }; | { 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: * For each port in the scan range:
* 1. Test availability on multiple hosts (catches port-forwarding ghosts) * 1. Test availability on multiple hosts (catches port-forwarding ghosts)
+7 -5
View File
@@ -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. **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 | | Skill | Command | When to use |
| ------------------- | ------------------ | ------------------------------------------------------------------------------------------------- | | -------------------------- | ------------------------- | ------------------------------------------------------------------------------------------------- |
| **hyperframes** | `/hyperframes` | Creating or editing HTML compositions, captions, TTS, audio-reactive animation, marker highlights | | **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-cli** | `/hyperframes-cli` | CLI commands: init, lint, preview, render, transcribe, tts |
| **gsap** | `/gsap` | GSAP animations for HyperFrames — tweens, timelines, easing, performance | | **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 > **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`. > agent session, or install manually: `npx skills add heygen-com/hyperframes`.
@@ -244,8 +244,10 @@ export function buildChromeArgs(
options: BuildChromeArgsOptions, options: BuildChromeArgsOptions,
config?: Partial<Pick<EngineConfig, "disableGpu" | "chromePath">>, config?: Partial<Pick<EngineConfig, "disableGpu" | "chromePath">>,
): string[] { ): string[] {
// Chrome flags tuned for headless rendering performance. // Chrome flags tuned for headless rendering performance. The set below is a
// Based on Remotion's open-browser.ts flags with additions for our use case. // fairly standard "headless-for-capture" configuration — similar profiles
// appear in Puppeteer's defaults, Playwright, Remotion, and Chrome's own
// headless-shell guidance.
const chromeArgs = [ const chromeArgs = [
"--no-sandbox", "--no-sandbox",
"--disable-setuid-sandbox", "--disable-setuid-sandbox",
@@ -257,7 +259,8 @@ export function buildChromeArgs(
"--font-render-hinting=none", "--font-render-hinting=none",
"--force-color-profile=srgb", "--force-color-profile=srgb",
`--window-size=${options.width},${options.height}`, `--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-background-timer-throttling",
"--disable-backgrounding-occluded-windows", "--disable-backgrounding-occluded-windows",
"--disable-renderer-backgrounding", "--disable-renderer-backgrounding",
@@ -11,7 +11,11 @@
import { describe, expect, it } from "vitest"; 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"; import { DEFAULT_HDR10_MASTERING } from "../utils/hdr.js";
const baseHdrPq: StreamingEncoderOptions = { 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();
});
});
@@ -1,9 +1,9 @@
/** /**
* Streaming Encoder Service * Streaming Encoder Service
* *
* Pipes frame screenshot buffers directly to FFmpeg's stdin instead of writing * Pipes frame screenshot buffers directly to FFmpeg's stdin via `-f image2pipe`
* them to disk and reading them back in a separate encode stage. Follows the * instead of writing them to disk and reading them back in a separate encode
* Remotion pattern of image2pipe → FFmpeg. * stage. Inspired by Remotion's approach to browser-based video rendering.
* *
* Two building blocks: * Two building blocks:
* 1. Frame reorder buffer ensures out-of-order parallel workers feed * 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"; 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 { export interface FrameReorderBuffer {
waitForFrame: (frame: number) => Promise<void>; waitForFrame: (frame: number) => Promise<void>;
@@ -35,34 +43,49 @@ export interface FrameReorderBuffer {
} }
export function createFrameReorderBuffer(startFrame: number, endFrame: number): FrameReorderBuffer { export function createFrameReorderBuffer(startFrame: number, endFrame: number): FrameReorderBuffer {
let nextFrame = startFrame; let cursor = startFrame;
let waiters: Array<{ frame: number; resolve: () => void }> = []; const pending = new Map<number, Array<() => void>>();
const resolveWaiters = () => { const enqueueAt = (frame: number, resolve: () => void): void => {
for (const waiter of waiters.slice()) { const list = pending.get(frame);
if (waiter.frame === nextFrame) { if (list === undefined) {
waiter.resolve(); pending.set(frame, [resolve]);
waiters = waiters.filter((w) => w !== waiter); } else {
} list.push(resolve);
} }
}; };
return { const flushAt = (frame: number): void => {
waitForFrame: (frame: number) => const list = pending.get(frame);
new Promise<void>((resolve) => { if (list === undefined) return;
waiters.push({ frame, resolve }); pending.delete(frame);
resolveWaiters(); for (const resolve of list) resolve();
}),
advanceTo: (frame: number) => {
nextFrame = frame;
resolveWaiters();
},
waitForAllDone: () =>
new Promise<void>((resolve) => {
waiters.push({ frame: endFrame, resolve });
resolveWaiters();
}),
}; };
const waitForFrame = (frame: number): Promise<void> =>
new Promise<void>((resolve) => {
if (frame === cursor) {
resolve();
return;
}
enqueueAt(frame, resolve);
});
const advanceTo = (frame: number): void => {
cursor = frame;
flushAt(frame);
};
const waitForAllDone = (): Promise<void> =>
new Promise<void>((resolve) => {
if (cursor >= endFrame) {
resolve();
return;
}
enqueueAt(endFrame, resolve);
});
return { waitForFrame, advanceTo, waitForAllDone };
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------