mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-07 01:56:04 +00:00
Replaces the rigid `--fps 24|30|60` whitelist with a numeric range and
adds support for ffmpeg-style fractional framerates so NTSC stays exact
end-to-end.
- `--fps 30` keeps working (integer fps)
- `--fps 30000/1001` now means exact NTSC 29.97 (not the lossy decimal)
- `--fps 24000/1001`, `--fps 60000/1001`, `--fps 25/50/120/240` all work
- Decimals like `--fps 29.97` are rejected with a friendly error pointing
the user at the rational form, since `29.97` and `30000/1001` round
to different framerates inside ffmpeg
Carries an `Fps = { num: number; den: number }` rational end-to-end:
RenderConfig, EncoderOptions, StreamingEncoderOptions, CaptureOptions,
DockerRenderOptions, Studio API request body, regression-harness
meta.json. The `-r` and `-framerate` ffmpeg args emit the rational form
verbatim (`30000/1001`) so no decimal round-trip happens at the encoder
boundary. Frame-interval math uses `1000 * den / num` ms (33.366… for
NTSC, 33.333… for integer 30).
Helpers live in @hyperframes/core:
- `parseFps(input: string | number): FpsParseResult` — discriminated
parser used by both the CLI and the Studio API route
- `fpsToFfmpegArg(fps: Fps): string` — emits "30" or "30000/1001"
- `fpsToNumber(fps: Fps): number` — for arithmetic (telemetry, frame
count, frame-index → time)
Studio API wire format accepts polymorphic `fps: number | string`:
- number → integer fps (`30`)
- string → rational (`"30000/1001"`)
Decimals are rejected; matches the same rule as the CLI.
Existing meta.json fixtures with integer `"fps": 30` continue to load
unchanged — the regression-harness validator now normalizes both number
and string inputs through `parseFps`.
93 lines
3.2 KiB
TypeScript
93 lines
3.2 KiB
TypeScript
// @vitest-environment node
|
||
import { describe, it, expect, vi } from "vitest";
|
||
import { type Page } from "puppeteer-core";
|
||
import { pageScreenshotCapture, cdpSessionCache } from "./screenshotService.js";
|
||
|
||
// Stub a Page + CDPSession just enough that pageScreenshotCapture can call
|
||
// `client.send("Page.captureScreenshot", ...)` and we can inspect the args.
|
||
function makeFakePageWithCdp(send: (method: string, params: object) => Promise<{ data: string }>) {
|
||
const fakeSession = { send } as unknown as import("puppeteer-core").CDPSession;
|
||
// Stub a Page object — the WeakMap cache is the only Page-thing used in the
|
||
// path under test, so we can pre-seed it and skip page.createCDPSession().
|
||
const fakePage = {} as Page;
|
||
cdpSessionCache.set(fakePage, fakeSession);
|
||
return fakePage;
|
||
}
|
||
|
||
describe("pageScreenshotCapture supersample plumbing", () => {
|
||
// Minimal 1×1 transparent PNG, base64. The function returns Buffer.from(data, "base64")
|
||
// and we never inspect the bytes — only the params we pass to client.send.
|
||
const ONE_PIXEL_PNG_B64 =
|
||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkAAIAAAoAAv/lxKUAAAAASUVORK5CYII=";
|
||
|
||
it("omits `clip` when deviceScaleFactor is undefined (default 1)", async () => {
|
||
const send = vi.fn().mockResolvedValue({ data: ONE_PIXEL_PNG_B64 });
|
||
const page = makeFakePageWithCdp(send);
|
||
|
||
await pageScreenshotCapture(page, {
|
||
width: 1920,
|
||
height: 1080,
|
||
fps: { num: 30, den: 1 },
|
||
format: "jpeg",
|
||
quality: 80,
|
||
});
|
||
|
||
expect(send).toHaveBeenCalledWith(
|
||
"Page.captureScreenshot",
|
||
expect.not.objectContaining({ clip: expect.anything() }),
|
||
);
|
||
});
|
||
|
||
it("omits `clip` when deviceScaleFactor is exactly 1", async () => {
|
||
const send = vi.fn().mockResolvedValue({ data: ONE_PIXEL_PNG_B64 });
|
||
const page = makeFakePageWithCdp(send);
|
||
|
||
await pageScreenshotCapture(page, {
|
||
width: 1920,
|
||
height: 1080,
|
||
fps: { num: 30, den: 1 },
|
||
format: "jpeg",
|
||
deviceScaleFactor: 1,
|
||
});
|
||
|
||
const params = send.mock.calls[0]?.[1] as { clip?: unknown };
|
||
expect(params.clip).toBeUndefined();
|
||
});
|
||
|
||
it("passes `clip` with `scale = dpr` when deviceScaleFactor > 1 (the supersample contract)", async () => {
|
||
const send = vi.fn().mockResolvedValue({ data: ONE_PIXEL_PNG_B64 });
|
||
const page = makeFakePageWithCdp(send);
|
||
|
||
await pageScreenshotCapture(page, {
|
||
width: 1920,
|
||
height: 1080,
|
||
fps: { num: 30, den: 1 },
|
||
format: "jpeg",
|
||
deviceScaleFactor: 2,
|
||
});
|
||
|
||
expect(send).toHaveBeenCalledWith(
|
||
"Page.captureScreenshot",
|
||
expect.objectContaining({
|
||
clip: { x: 0, y: 0, width: 1920, height: 1080, scale: 2 },
|
||
}),
|
||
);
|
||
});
|
||
|
||
it("propagates a non-2 supersample factor (e.g. 720p → 4K = 3×)", async () => {
|
||
const send = vi.fn().mockResolvedValue({ data: ONE_PIXEL_PNG_B64 });
|
||
const page = makeFakePageWithCdp(send);
|
||
|
||
await pageScreenshotCapture(page, {
|
||
width: 1280,
|
||
height: 720,
|
||
fps: { num: 30, den: 1 },
|
||
format: "jpeg",
|
||
deviceScaleFactor: 3,
|
||
});
|
||
|
||
const params = send.mock.calls[0]?.[1] as { clip?: { scale: number } };
|
||
expect(params.clip?.scale).toBe(3);
|
||
});
|
||
});
|