mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +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:
@@ -2,6 +2,131 @@
|
||||
|
||||
export type ExecutionMode = "planning" | "design" | "execution" | null;
|
||||
|
||||
// ── Frame rate ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Frame rate as an exact rational. Carrying `{num, den}` end-to-end (rather
|
||||
* than collapsing to `29.97`) lets us pass NTSC / drop-frame rates straight
|
||||
* through to FFmpeg via `-r 30000/1001` without any decimal round-trip.
|
||||
*
|
||||
* Integer fps is represented with `den: 1` (e.g. `{ num: 30, den: 1 }`).
|
||||
*
|
||||
* Use {@link fpsToNumber} when arithmetic forces a decimal (e.g. `setTimeout`
|
||||
* intervals) and {@link fpsToFfmpegArg} when emitting FFmpeg `-r` /
|
||||
* `-framerate` strings.
|
||||
*/
|
||||
export interface Fps {
|
||||
num: number;
|
||||
den: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decimal value of an {@link Fps} rational. Used at sites that need a
|
||||
* `number` for arithmetic (frame-index → time, frame intervals, telemetry
|
||||
* payloads) where the small precision loss of the decimal is acceptable.
|
||||
*/
|
||||
export function fpsToNumber(fps: Fps): number {
|
||||
return fps.num / fps.den;
|
||||
}
|
||||
|
||||
/**
|
||||
* FFmpeg-style fps argument. Returns `"30"` for integer fps and `"30000/1001"`
|
||||
* for rationals — both forms are accepted verbatim by FFmpeg's `-r` and
|
||||
* `-framerate` flags. We keep integer fps as a bare integer so existing
|
||||
* snapshot tests / log output don't churn for the common case.
|
||||
*/
|
||||
export function fpsToFfmpegArg(fps: Fps): string {
|
||||
return fps.den === 1 ? String(fps.num) : `${fps.num}/${fps.den}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Discriminated parse result for {@link parseFps}. Lets the CLI / route
|
||||
* validation own its own error UX without losing the structured failure
|
||||
* reason.
|
||||
*/
|
||||
export type FpsParseResult =
|
||||
| { ok: true; value: Fps }
|
||||
| {
|
||||
ok: false;
|
||||
reason:
|
||||
| "empty"
|
||||
| "not-a-number"
|
||||
| "non-positive"
|
||||
| "out-of-range"
|
||||
| "invalid-fraction"
|
||||
| "ambiguous-decimal";
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse a user-supplied fps spec into an {@link Fps} rational.
|
||||
*
|
||||
* Accepted forms:
|
||||
* - integer string `"30"` → `{ num: 30, den: 1 }`
|
||||
* - integer number `30` → `{ num: 30, den: 1 }`
|
||||
* - rational string `"30000/1001"` → `{ num: 30000, den: 1001 }` (exact NTSC)
|
||||
*
|
||||
* Rejected:
|
||||
* - empty / non-numeric input
|
||||
* - decimals like `"29.97"` — callers must spell rationals with `/` so the
|
||||
* exact denominator is unambiguous (FFmpeg treats `29.97` as a slightly
|
||||
* different framerate than `30000/1001`).
|
||||
* - division by zero, negative or zero numerator
|
||||
* - decimal value outside `[1, 240]` — defensive bounds for "human" fps
|
||||
* ranges (24, 25, 30, 50, 60, 120, 240, plus the NTSC trio).
|
||||
*/
|
||||
export function parseFps(input: string | number): FpsParseResult {
|
||||
if (typeof input === "number") {
|
||||
if (!Number.isFinite(input)) return { ok: false, reason: "not-a-number" };
|
||||
if (!Number.isInteger(input)) return { ok: false, reason: "ambiguous-decimal" };
|
||||
if (input <= 0) return { ok: false, reason: "non-positive" };
|
||||
if (input > 240) return { ok: false, reason: "out-of-range" };
|
||||
return { ok: true, value: { num: input, den: 1 } };
|
||||
}
|
||||
const raw = input.trim();
|
||||
if (raw === "") return { ok: false, reason: "empty" };
|
||||
|
||||
if (raw.includes("/")) {
|
||||
const parts = raw.split("/");
|
||||
if (parts.length !== 2) return { ok: false, reason: "invalid-fraction" };
|
||||
const num = Number(parts[0]);
|
||||
const den = Number(parts[1]);
|
||||
if (!Number.isFinite(num) || !Number.isFinite(den)) {
|
||||
return { ok: false, reason: "not-a-number" };
|
||||
}
|
||||
if (!Number.isInteger(num) || !Number.isInteger(den)) {
|
||||
return { ok: false, reason: "invalid-fraction" };
|
||||
}
|
||||
if (den <= 0) return { ok: false, reason: "invalid-fraction" };
|
||||
if (num <= 0) return { ok: false, reason: "non-positive" };
|
||||
const decimal = num / den;
|
||||
if (decimal < 1 || decimal > 240) return { ok: false, reason: "out-of-range" };
|
||||
return { ok: true, value: { num, den } };
|
||||
}
|
||||
|
||||
// Integer-only path — reject `"29.97"` so users are explicit about the
|
||||
// exact rational they want.
|
||||
if (!/^-?\d+$/.test(raw)) {
|
||||
// Allow caller to differentiate "29.97" from "abc" if they want; both
|
||||
// are user errors but the message can be friendlier for decimals.
|
||||
if (/^-?\d*\.\d+$/.test(raw)) return { ok: false, reason: "ambiguous-decimal" };
|
||||
return { ok: false, reason: "not-a-number" };
|
||||
}
|
||||
const n = Number(raw);
|
||||
if (n <= 0) return { ok: false, reason: "non-positive" };
|
||||
if (n > 240) return { ok: false, reason: "out-of-range" };
|
||||
return { ok: true, value: { num: n, den: 1 } };
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience wrapper around {@link parseFps} for callsites that want the
|
||||
* default-30-fps fallback when input is `undefined`. Does NOT swallow parse
|
||||
* errors — those still surface via the discriminated result.
|
||||
*/
|
||||
export function parseFpsWithDefault(input: string | number | undefined): FpsParseResult {
|
||||
if (input === undefined || input === "") return { ok: true, value: { num: 30, den: 1 } };
|
||||
return parseFps(input);
|
||||
}
|
||||
|
||||
/** Video orientation / aspect ratio. */
|
||||
export type Orientation = "16:9" | "9:16";
|
||||
|
||||
|
||||
@@ -11,6 +11,8 @@ export type {
|
||||
TimelineElementType,
|
||||
MediaElementType,
|
||||
CanvasResolution,
|
||||
Fps,
|
||||
FpsParseResult,
|
||||
MediaFile,
|
||||
CompositionAPI,
|
||||
PlayerAPI,
|
||||
@@ -38,6 +40,10 @@ export {
|
||||
CANVAS_DIMENSIONS,
|
||||
VALID_CANVAS_RESOLUTIONS,
|
||||
normalizeResolutionFlag,
|
||||
parseFps,
|
||||
parseFpsWithDefault,
|
||||
fpsToNumber,
|
||||
fpsToFfmpegArg,
|
||||
TIMELINE_COLORS,
|
||||
DEFAULT_DURATIONS,
|
||||
COMPOSITION_VARIABLE_TYPES,
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -115,3 +115,71 @@ describe("POST /projects/:id/render — outputResolution forwarding", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /projects/:id/render — fps wire format", () => {
|
||||
// The fps fraction-syntax feature accepts JSON `number` (integer fps) and
|
||||
// JSON `string` (ffmpeg-style rational) on the wire, normalizing both to
|
||||
// the structured Fps form before invoking the adapter.
|
||||
it("forwards integer fps as { num, den: 1 }", async () => {
|
||||
const spy = vi.fn();
|
||||
const { app, cleanup } = buildApp(spy);
|
||||
try {
|
||||
await app.request("http://localhost/projects/demo/render", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ fps: 60, quality: "standard", format: "mp4" }),
|
||||
});
|
||||
expect(spy.mock.calls[0][0].fps).toEqual({ num: 60, den: 1 });
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("parses '30000/1001' string body as exact NTSC", async () => {
|
||||
const spy = vi.fn();
|
||||
const { app, cleanup } = buildApp(spy);
|
||||
try {
|
||||
await app.request("http://localhost/projects/demo/render", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ fps: "30000/1001", quality: "standard", format: "mp4" }),
|
||||
});
|
||||
expect(spy.mock.calls[0][0].fps).toEqual({ num: 30000, den: 1001 });
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("falls back to 30/1 for malformed fps values", async () => {
|
||||
// Matches the lenient handling of `quality` and `resolution` in the same
|
||||
// route — the producer surfaces a clearer downstream error if the value
|
||||
// is genuinely unusable.
|
||||
const spy = vi.fn();
|
||||
const { app, cleanup } = buildApp(spy);
|
||||
try {
|
||||
await app.request("http://localhost/projects/demo/render", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ fps: "abc", quality: "standard", format: "mp4" }),
|
||||
});
|
||||
expect(spy.mock.calls[0][0].fps).toEqual({ num: 30, den: 1 });
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("falls back to 30/1 when fps is omitted", async () => {
|
||||
const spy = vi.fn();
|
||||
const { app, cleanup } = buildApp(spy);
|
||||
try {
|
||||
await app.request("http://localhost/projects/demo/render", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ quality: "standard", format: "mp4" }),
|
||||
});
|
||||
expect(spy.mock.calls[0][0].fps).toEqual({ num: 30, den: 1 });
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@ import { streamSSE } from "hono/streaming";
|
||||
import { existsSync, readFileSync, mkdirSync, unlinkSync, readdirSync, statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import type { StudioApiAdapter, RenderJobState } from "../types.js";
|
||||
import { VALID_CANVAS_RESOLUTIONS, type CanvasResolution } from "../../core.types.js";
|
||||
import { VALID_CANVAS_RESOLUTIONS, parseFps, type CanvasResolution } from "../../core.types.js";
|
||||
|
||||
const VALID_RESOLUTIONS = new Set<string>(VALID_CANVAS_RESOLUTIONS);
|
||||
|
||||
@@ -50,7 +50,12 @@ export function registerRenderRoutes(api: Hono, adapter: StudioApiAdapter): void
|
||||
if (!project) return c.json({ error: "not found" }, 404);
|
||||
|
||||
const body = (await c.req.json().catch(() => ({}))) as {
|
||||
fps?: number;
|
||||
// Polymorphic per design note in core.types.Fps:
|
||||
// number → integer fps (e.g. 30)
|
||||
// string → rational fps (e.g. "30000/1001" for NTSC 29.97)
|
||||
// Decimals are rejected on purpose so the exact denominator stays
|
||||
// unambiguous (29.97 ≠ 30000/1001 when ffmpeg consumes them).
|
||||
fps?: number | string;
|
||||
quality?: string;
|
||||
format?: string;
|
||||
resolution?: string;
|
||||
@@ -58,7 +63,13 @@ export function registerRenderRoutes(api: Hono, adapter: StudioApiAdapter): void
|
||||
const VALID_FORMATS = new Set(["mp4", "webm", "mov"]);
|
||||
const FORMAT_EXT: Record<string, string> = { mp4: ".mp4", webm: ".webm", mov: ".mov" };
|
||||
const format = VALID_FORMATS.has(body.format ?? "") ? (body.format as string) : "mp4";
|
||||
const fps: 24 | 30 | 60 = body.fps === 24 || body.fps === 60 ? body.fps : 30;
|
||||
|
||||
// Default to 30 fps when unset or unparseable. The route stays lenient on
|
||||
// invalid fps values (matching the lenient handling of `resolution` and
|
||||
// `quality` already in this file) — the producer surfaces a clearer error
|
||||
// message if the caller really did mean to fail loudly.
|
||||
const fpsParse = body.fps === undefined ? null : parseFps(body.fps);
|
||||
const fps = fpsParse && fpsParse.ok ? fpsParse.value : { num: 30, den: 1 };
|
||||
const quality = ["draft", "standard", "high"].includes(body.quality ?? "")
|
||||
? (body.quality as string)
|
||||
: "standard";
|
||||
|
||||
@@ -63,7 +63,14 @@ export interface StudioApiAdapter {
|
||||
project: ResolvedProject;
|
||||
outputPath: string;
|
||||
format: "mp4" | "webm" | "mov";
|
||||
fps: number;
|
||||
/**
|
||||
* Frame rate as an exact rational. The HTTP layer (POST
|
||||
* `/projects/:id/render`) accepts either a JSON number (integer fps,
|
||||
* `30`) or a JSON string (ffmpeg-style rational, `"30000/1001"`); the
|
||||
* route normalizes both into `Fps` before invoking the adapter, so
|
||||
* adapter implementations only ever see the rational form.
|
||||
*/
|
||||
fps: import("../core.types.js").Fps;
|
||||
quality: string;
|
||||
jobId: string;
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user