mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 12:54:29 +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`.
159 lines
5.7 KiB
TypeScript
159 lines
5.7 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import { parseFps, fpsToNumber, fpsToFfmpegArg } from "./core.types";
|
|
|
|
describe("parseFps — integer forms", () => {
|
|
it("parses integer string '30' as { num: 30, den: 1 }", () => {
|
|
const result = parseFps("30");
|
|
expect(result.ok).toBe(true);
|
|
if (result.ok) expect(result.value).toEqual({ num: 30, den: 1 });
|
|
});
|
|
|
|
it("parses integer number 30 as { num: 30, den: 1 }", () => {
|
|
const result = parseFps(30);
|
|
expect(result.ok).toBe(true);
|
|
if (result.ok) expect(result.value).toEqual({ num: 30, den: 1 });
|
|
});
|
|
|
|
it("parses '24' / '60' / '120' / '240' as integer fps with den=1", () => {
|
|
for (const n of [24, 60, 120, 240]) {
|
|
const result = parseFps(String(n));
|
|
expect(result.ok).toBe(true);
|
|
if (result.ok) expect(result.value).toEqual({ num: n, den: 1 });
|
|
}
|
|
});
|
|
|
|
it("trims surrounding whitespace on integer strings", () => {
|
|
const result = parseFps(" 30 ");
|
|
expect(result.ok).toBe(true);
|
|
if (result.ok) expect(result.value).toEqual({ num: 30, den: 1 });
|
|
});
|
|
});
|
|
|
|
describe("parseFps — rational forms", () => {
|
|
it("parses '30000/1001' as exact NTSC", () => {
|
|
const result = parseFps("30000/1001");
|
|
expect(result.ok).toBe(true);
|
|
if (result.ok) expect(result.value).toEqual({ num: 30000, den: 1001 });
|
|
});
|
|
|
|
it("parses '24000/1001' as 23.976", () => {
|
|
const result = parseFps("24000/1001");
|
|
expect(result.ok).toBe(true);
|
|
if (result.ok) expect(result.value).toEqual({ num: 24000, den: 1001 });
|
|
});
|
|
|
|
it("parses '60000/1001' as 59.94", () => {
|
|
const result = parseFps("60000/1001");
|
|
expect(result.ok).toBe(true);
|
|
if (result.ok) expect(result.value).toEqual({ num: 60000, den: 1001 });
|
|
});
|
|
|
|
it("parses '60/2' as { num: 60, den: 2 } (no auto-simplification)", () => {
|
|
// Preserving the literal numerator/denominator keeps `fpsToFfmpegArg`
|
|
// round-trippable — if the user typed the rational form, we forward it
|
|
// verbatim to ffmpeg.
|
|
const result = parseFps("60/2");
|
|
expect(result.ok).toBe(true);
|
|
if (result.ok) expect(result.value).toEqual({ num: 60, den: 2 });
|
|
});
|
|
});
|
|
|
|
describe("parseFps — rejected inputs", () => {
|
|
it("rejects 'abc'", () => {
|
|
const result = parseFps("abc");
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok) expect(result.reason).toBe("not-a-number");
|
|
});
|
|
|
|
it("rejects '30/0' (zero denominator)", () => {
|
|
const result = parseFps("30/0");
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok) expect(result.reason).toBe("invalid-fraction");
|
|
});
|
|
|
|
it("rejects '30/-1' (negative denominator)", () => {
|
|
const result = parseFps("30/-1");
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok) expect(result.reason).toBe("invalid-fraction");
|
|
});
|
|
|
|
it("rejects '0' (non-positive)", () => {
|
|
const result = parseFps("0");
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok) expect(result.reason).toBe("non-positive");
|
|
});
|
|
|
|
it("rejects '241' (out of range)", () => {
|
|
const result = parseFps("241");
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok) expect(result.reason).toBe("out-of-range");
|
|
});
|
|
|
|
it("rejects '29.97' (ambiguous decimal — must spell rational)", () => {
|
|
// The whole point of taking rationals is precision; accepting the
|
|
// decimal form silently would invert that guarantee.
|
|
const result = parseFps("29.97");
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok) expect(result.reason).toBe("ambiguous-decimal");
|
|
});
|
|
|
|
it("rejects empty string", () => {
|
|
const result = parseFps("");
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok) expect(result.reason).toBe("empty");
|
|
});
|
|
|
|
it("rejects '30000/1001/extra' (too many slashes)", () => {
|
|
const result = parseFps("30000/1001/extra");
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok) expect(result.reason).toBe("invalid-fraction");
|
|
});
|
|
|
|
it("rejects rational with decimal numerator '30.5/1'", () => {
|
|
const result = parseFps("30.5/1");
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok) expect(result.reason).toBe("invalid-fraction");
|
|
});
|
|
|
|
it("rejects rational > 240 by decimal value", () => {
|
|
// 1000/3 ≈ 333.33 — even though numerator and denominator are integers,
|
|
// the decimal value is out of the supported range.
|
|
const result = parseFps("1000/3");
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok) expect(result.reason).toBe("out-of-range");
|
|
});
|
|
});
|
|
|
|
describe("fpsToNumber + fpsToFfmpegArg + frame interval math", () => {
|
|
it("integer fps 30/1 → 33.333…ms frame interval", () => {
|
|
const fps = { num: 30, den: 1 };
|
|
const intervalMs = (1000 * fps.den) / fps.num;
|
|
expect(intervalMs).toBeCloseTo(33.333, 3);
|
|
});
|
|
|
|
it("NTSC 30000/1001 → 33.366…ms frame interval", () => {
|
|
const fps = { num: 30000, den: 1001 };
|
|
const intervalMs = (1000 * fps.den) / fps.num;
|
|
// 1001/30 = 33.36666…ms — the canonical NTSC interval. Differs from
|
|
// the integer-30 interval by ~0.033 ms (1 ULP at our scales).
|
|
expect(intervalMs).toBeCloseTo(33.36666, 3);
|
|
expect(intervalMs).not.toBeCloseTo(33.333, 3);
|
|
});
|
|
|
|
it("fpsToNumber collapses rational to decimal", () => {
|
|
expect(fpsToNumber({ num: 30, den: 1 })).toBe(30);
|
|
expect(fpsToNumber({ num: 30000, den: 1001 })).toBeCloseTo(29.97003, 5);
|
|
});
|
|
|
|
it("fpsToFfmpegArg emits bare integer for den=1", () => {
|
|
expect(fpsToFfmpegArg({ num: 30, den: 1 })).toBe("30");
|
|
expect(fpsToFfmpegArg({ num: 60, den: 1 })).toBe("60");
|
|
});
|
|
|
|
it("fpsToFfmpegArg emits 'num/den' for rationals", () => {
|
|
expect(fpsToFfmpegArg({ num: 30000, den: 1001 })).toBe("30000/1001");
|
|
expect(fpsToFfmpegArg({ num: 24000, den: 1001 })).toBe("24000/1001");
|
|
expect(fpsToFfmpegArg({ num: 60000, den: 1001 })).toBe("60000/1001");
|
|
});
|
|
});
|