mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-05 10:14:30 +00:00
* 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.
229 lines
8.0 KiB
TypeScript
229 lines
8.0 KiB
TypeScript
/**
|
|
* buildStreamingArgs unit tests.
|
|
*
|
|
* These tests focus on the FFmpeg CLI shape rather than spawning the encoder
|
|
* — they're the cheap regression net for the HDR static-metadata bug
|
|
* (side_data=[none] in the encoded MP4) reproduced by
|
|
* packages/producer/scripts/hdr-smoke.ts. Without these assertions, future
|
|
* refactors of the x265-params string can silently strip
|
|
* master-display / max-cll and ship as SDR BT.2020 again.
|
|
*/
|
|
|
|
import { describe, expect, it } from "vitest";
|
|
|
|
import {
|
|
buildStreamingArgs,
|
|
createFrameReorderBuffer,
|
|
type StreamingEncoderOptions,
|
|
} from "./streamingEncoder.js";
|
|
import { DEFAULT_HDR10_MASTERING } from "../utils/hdr.js";
|
|
|
|
const baseHdrPq: StreamingEncoderOptions = {
|
|
fps: 30,
|
|
width: 1920,
|
|
height: 1080,
|
|
codec: "h265",
|
|
preset: "medium",
|
|
quality: 23,
|
|
pixelFormat: "yuv420p10le",
|
|
useGpu: false,
|
|
rawInputFormat: "rgb48le",
|
|
hdr: { transfer: "pq" },
|
|
};
|
|
|
|
const baseHdrHlg: StreamingEncoderOptions = {
|
|
...baseHdrPq,
|
|
hdr: { transfer: "hlg" },
|
|
};
|
|
|
|
const baseSdr: StreamingEncoderOptions = {
|
|
fps: 30,
|
|
width: 1920,
|
|
height: 1080,
|
|
codec: "h264",
|
|
preset: "medium",
|
|
quality: 23,
|
|
useGpu: false,
|
|
};
|
|
|
|
function getX265ParamsValue(args: string[]): string | undefined {
|
|
const idx = args.indexOf("-x265-params");
|
|
return idx === -1 ? undefined : args[idx + 1];
|
|
}
|
|
|
|
describe("buildStreamingArgs", () => {
|
|
describe("HDR PQ (libx265)", () => {
|
|
it("emits master-display and max-cll in -x265-params", () => {
|
|
const args = buildStreamingArgs(baseHdrPq, "/tmp/out.mp4");
|
|
const x265 = getX265ParamsValue(args);
|
|
expect(x265).toBeDefined();
|
|
expect(x265).toContain(`master-display=${DEFAULT_HDR10_MASTERING.masterDisplay}`);
|
|
expect(x265).toContain(`max-cll=${DEFAULT_HDR10_MASTERING.maxCll}`);
|
|
expect(x265).toContain("colorprim=bt2020");
|
|
expect(x265).toContain("transfer=smpte2084");
|
|
expect(x265).toContain("colormatrix=bt2020nc");
|
|
});
|
|
|
|
it("tags the output stream with bt2020 / smpte2084 / tv range", () => {
|
|
const args = buildStreamingArgs(baseHdrPq, "/tmp/out.mp4");
|
|
expect(args).toContain("-colorspace:v");
|
|
expect(args[args.indexOf("-colorspace:v") + 1]).toBe("bt2020nc");
|
|
expect(args[args.indexOf("-color_primaries:v") + 1]).toBe("bt2020");
|
|
expect(args[args.indexOf("-color_trc:v") + 1]).toBe("smpte2084");
|
|
expect(args[args.indexOf("-color_range") + 1]).toBe("tv");
|
|
});
|
|
|
|
it("uses libx265 with -tag:v hvc1 for QuickTime compatibility", () => {
|
|
const args = buildStreamingArgs(baseHdrPq, "/tmp/out.mp4");
|
|
const cvIdx = args.indexOf("-c:v");
|
|
expect(cvIdx).toBeGreaterThan(-1);
|
|
expect(args[cvIdx + 1]).toBe("libx265");
|
|
expect(args).toContain("-tag:v");
|
|
expect(args[args.indexOf("-tag:v") + 1]).toBe("hvc1");
|
|
});
|
|
|
|
it("keeps the aq-mode prefix even with master-display present", () => {
|
|
const args = buildStreamingArgs(baseHdrPq, "/tmp/out.mp4");
|
|
const x265 = getX265ParamsValue(args);
|
|
expect(x265?.startsWith("aq-mode=3")).toBe(true);
|
|
});
|
|
|
|
it("uses the simpler aq-mode prefix on ultrafast preset", () => {
|
|
const args = buildStreamingArgs({ ...baseHdrPq, preset: "ultrafast" }, "/tmp/out.mp4");
|
|
const x265 = getX265ParamsValue(args);
|
|
expect(x265?.startsWith("aq-mode=3:")).toBe(true);
|
|
expect(x265).not.toContain("aq-strength");
|
|
expect(x265).toContain(`master-display=${DEFAULT_HDR10_MASTERING.masterDisplay}`);
|
|
});
|
|
});
|
|
|
|
describe("HDR HLG (libx265)", () => {
|
|
it("emits master-display, max-cll, and the HLG transfer", () => {
|
|
const args = buildStreamingArgs(baseHdrHlg, "/tmp/out.mp4");
|
|
const x265 = getX265ParamsValue(args);
|
|
expect(x265).toContain("transfer=arib-std-b67");
|
|
expect(x265).toContain(`master-display=${DEFAULT_HDR10_MASTERING.masterDisplay}`);
|
|
expect(x265).toContain(`max-cll=${DEFAULT_HDR10_MASTERING.maxCll}`);
|
|
});
|
|
|
|
it("tags the output stream with arib-std-b67", () => {
|
|
const args = buildStreamingArgs(baseHdrHlg, "/tmp/out.mp4");
|
|
expect(args[args.indexOf("-color_trc:v") + 1]).toBe("arib-std-b67");
|
|
});
|
|
});
|
|
|
|
describe("HDR raw input tagging", () => {
|
|
it("tags the rawvideo input with the matching color metadata", () => {
|
|
const args = buildStreamingArgs(baseHdrPq, "/tmp/out.mp4");
|
|
const inputColorTrcIdx = args.indexOf("-color_trc");
|
|
expect(inputColorTrcIdx).toBeGreaterThan(-1);
|
|
expect(args[inputColorTrcIdx + 1]).toBe("smpte2084");
|
|
const inputPrimariesIdx = args.indexOf("-color_primaries");
|
|
expect(inputPrimariesIdx).toBeGreaterThan(-1);
|
|
expect(args[inputPrimariesIdx + 1]).toBe("bt2020");
|
|
// Pix_fmt of the raw input must match the buffer we hand FFmpeg.
|
|
expect(args.indexOf("rgb48le")).toBeGreaterThan(-1);
|
|
});
|
|
|
|
it("does not strip the input color tags when bitrate is set instead of CRF", () => {
|
|
const args = buildStreamingArgs({ ...baseHdrPq, bitrate: "20M" }, "/tmp/out.mp4");
|
|
const x265 = getX265ParamsValue(args);
|
|
expect(x265).toContain(`master-display=${DEFAULT_HDR10_MASTERING.masterDisplay}`);
|
|
expect(args).toContain("-b:v");
|
|
expect(args[args.indexOf("-b:v") + 1]).toBe("20M");
|
|
});
|
|
});
|
|
|
|
describe("SDR fallback", () => {
|
|
it("does NOT emit HDR mastering metadata for SDR encodes", () => {
|
|
const args = buildStreamingArgs(baseSdr, "/tmp/out.mp4");
|
|
const x264 = args[args.indexOf("-x264-params") + 1];
|
|
expect(x264).toContain("colorprim=bt709");
|
|
expect(x264).toContain("transfer=bt709");
|
|
expect(x264).toContain("colormatrix=bt709");
|
|
expect(x264).not.toContain("master-display");
|
|
expect(x264).not.toContain("max-cll");
|
|
});
|
|
|
|
it("tags SDR output with bt709 and tv range", () => {
|
|
const args = buildStreamingArgs(baseSdr, "/tmp/out.mp4");
|
|
expect(args[args.indexOf("-color_trc:v") + 1]).toBe("bt709");
|
|
expect(args[args.indexOf("-color_primaries:v") + 1]).toBe("bt709");
|
|
expect(args[args.indexOf("-colorspace:v") + 1]).toBe("bt709");
|
|
expect(args[args.indexOf("-color_range") + 1]).toBe("tv");
|
|
});
|
|
});
|
|
|
|
describe("output path", () => {
|
|
it("places the output path last after -y", () => {
|
|
const args = buildStreamingArgs(baseHdrPq, "/tmp/some-output.mp4");
|
|
expect(args[args.length - 2]).toBe("-y");
|
|
expect(args[args.length - 1]).toBe("/tmp/some-output.mp4");
|
|
});
|
|
});
|
|
});
|
|
|
|
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();
|
|
});
|
|
});
|