mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-05 10:14:30 +00:00
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`.
This commit is contained in:
@@ -23,7 +23,7 @@ import {
|
||||
import { DEFAULT_HDR10_MASTERING } from "../utils/hdr.js";
|
||||
|
||||
const baseHdrPq: StreamingEncoderOptions = {
|
||||
fps: 30,
|
||||
fps: { num: 30, den: 1 },
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
codec: "h265",
|
||||
@@ -41,7 +41,7 @@ const baseHdrHlg: StreamingEncoderOptions = {
|
||||
};
|
||||
|
||||
const baseSdr: StreamingEncoderOptions = {
|
||||
fps: 30,
|
||||
fps: { num: 30, den: 1 },
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
codec: "h264",
|
||||
@@ -166,9 +166,52 @@ describe("buildStreamingArgs", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("fps rational forwarding", () => {
|
||||
// Regression for the fps fraction-syntax feature: both `-framerate`
|
||||
// (input timestamping) and `-r` (output framerate) must carry the
|
||||
// rational verbatim — collapsing to 29.97 decimal at this boundary
|
||||
// would defeat the whole point of supporting NTSC end-to-end.
|
||||
it("emits rational -framerate and -r for NTSC 30000/1001 (image2pipe)", () => {
|
||||
const sdrNtsc: StreamingEncoderOptions = {
|
||||
...baseSdr,
|
||||
fps: { num: 30000, den: 1001 },
|
||||
};
|
||||
const args = buildStreamingArgs(sdrNtsc, "/tmp/ntsc.mp4");
|
||||
const framerateIdx = args.indexOf("-framerate");
|
||||
expect(framerateIdx).toBeGreaterThan(-1);
|
||||
expect(args[framerateIdx + 1]).toBe("30000/1001");
|
||||
|
||||
const rIdx = args.indexOf("-r");
|
||||
expect(rIdx).toBeGreaterThan(-1);
|
||||
expect(args[rIdx + 1]).toBe("30000/1001");
|
||||
});
|
||||
|
||||
it("emits rational -framerate and -r for NTSC 30000/1001 (rawvideo HDR)", () => {
|
||||
const hdrNtsc: StreamingEncoderOptions = {
|
||||
...baseHdrPq,
|
||||
fps: { num: 30000, den: 1001 },
|
||||
};
|
||||
const args = buildStreamingArgs(hdrNtsc, "/tmp/ntsc-hdr.mp4");
|
||||
const framerateIdx = args.indexOf("-framerate");
|
||||
expect(framerateIdx).toBeGreaterThan(-1);
|
||||
expect(args[framerateIdx + 1]).toBe("30000/1001");
|
||||
|
||||
const rIdx = args.indexOf("-r");
|
||||
expect(rIdx).toBeGreaterThan(-1);
|
||||
expect(args[rIdx + 1]).toBe("30000/1001");
|
||||
});
|
||||
|
||||
it("emits bare integer -r for { num: 30, den: 1 }", () => {
|
||||
const args = buildStreamingArgs(baseSdr, "/tmp/30.mp4");
|
||||
const rIdx = args.indexOf("-r");
|
||||
expect(rIdx).toBeGreaterThan(-1);
|
||||
expect(args[rIdx + 1]).toBe("30");
|
||||
});
|
||||
});
|
||||
|
||||
describe("GPU preset mapping", () => {
|
||||
const baseGpu: StreamingEncoderOptions = {
|
||||
fps: 30,
|
||||
fps: { num: 30, den: 1 },
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
codec: "h264",
|
||||
@@ -348,7 +391,7 @@ function createSpawnSpy(): {
|
||||
}
|
||||
|
||||
const baseOptions: StreamingEncoderOptions = {
|
||||
fps: 30,
|
||||
fps: { num: 30, den: 1 },
|
||||
width: 100,
|
||||
height: 100,
|
||||
codec: "h264",
|
||||
|
||||
Reference in New Issue
Block a user