Files
hyperframes/packages/engine/src/services/screenshotService.test.ts
T
Theodor Kleynhans 5dcc89c930 feat(cli): accept ffmpeg-style rational fps (NTSC, PAL, slow-mo)
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`.
2026-05-09 00:09:15 +02:00

93 lines
3.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// @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);
});
});