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:
Theodor Kleynhans
2026-05-09 00:09:15 +02:00
parent b58e447305
commit 5dcc89c930
27 changed files with 725 additions and 91 deletions
+11 -11
View File
@@ -68,7 +68,7 @@ describe("renderLocal browser GPU config", () => {
setEnv("PRODUCER_BROWSER_GPU_MODE", "hardware");
await renderLocal("/tmp/project", "/tmp/out.mp4", {
fps: 30,
fps: { num: 30, den: 1 },
quality: "standard",
format: "mp4",
gpu: false,
@@ -86,7 +86,7 @@ describe("renderLocal browser GPU config", () => {
it("forwards browserGpuMode='auto' into producer config (probe-then-choose)", async () => {
await renderLocal("/tmp/project", "/tmp/out.mp4", {
fps: 30,
fps: { num: 30, den: 1 },
quality: "standard",
format: "mp4",
gpu: false,
@@ -104,7 +104,7 @@ describe("renderLocal browser GPU config", () => {
it("passes an explicit hardware override for default local browser GPU", async () => {
await renderLocal("/tmp/project", "/tmp/out.mp4", {
fps: 30,
fps: { num: 30, den: 1 },
quality: "standard",
format: "mp4",
gpu: false,
@@ -137,7 +137,7 @@ describe("renderLocal browser GPU config", () => {
it("forwards parsed --variables payload to createRenderJob", async () => {
await renderLocal("/tmp/project", "/tmp/out.mp4", {
fps: 30,
fps: { num: 30, den: 1 },
quality: "standard",
format: "mp4",
gpu: false,
@@ -152,7 +152,7 @@ describe("renderLocal browser GPU config", () => {
it("forwards format: png-sequence through to createRenderJob", async () => {
await renderLocal("/tmp/project", "/tmp/frames", {
fps: 30,
fps: { num: 30, den: 1 },
quality: "standard",
format: "png-sequence",
gpu: false,
@@ -166,7 +166,7 @@ describe("renderLocal browser GPU config", () => {
it("omits variables from createRenderJob when not provided", async () => {
await renderLocal("/tmp/project", "/tmp/out.mp4", {
fps: 30,
fps: { num: 30, den: 1 },
quality: "standard",
format: "mp4",
gpu: false,
@@ -180,7 +180,7 @@ describe("renderLocal browser GPU config", () => {
it("forwards entryFile to createRenderJob when --composition is set", async () => {
await renderLocal("/tmp/project", "/tmp/out.mp4", {
fps: 30,
fps: { num: 30, den: 1 },
quality: "standard",
format: "mp4",
gpu: false,
@@ -195,7 +195,7 @@ describe("renderLocal browser GPU config", () => {
it("omits entryFile from createRenderJob when --composition is not set", async () => {
await renderLocal("/tmp/project", "/tmp/out.mp4", {
fps: 30,
fps: { num: 30, den: 1 },
quality: "standard",
format: "mp4",
gpu: false,
@@ -209,7 +209,7 @@ describe("renderLocal browser GPU config", () => {
it("forwards outputResolution to createRenderJob when --resolution is set", async () => {
await renderLocal("/tmp/project", "/tmp/out.mp4", {
fps: 30,
fps: { num: 30, den: 1 },
quality: "standard",
format: "mp4",
gpu: false,
@@ -224,7 +224,7 @@ describe("renderLocal browser GPU config", () => {
it("omits outputResolution from createRenderJob by default", async () => {
await renderLocal("/tmp/project", "/tmp/out.mp4", {
fps: 30,
fps: { num: 30, den: 1 },
quality: "standard",
format: "mp4",
gpu: false,
@@ -245,7 +245,7 @@ describe("renderLocal browser GPU config", () => {
});
await renderLocal("/tmp/project", "/tmp/out.mp4", {
fps: 30,
fps: { num: 30, den: 1 },
quality: "standard",
format: "mp4",
gpu: false,