Skip to content

Commit 15aac00

Browse files
authored
Merge pull request #684 from TheodorKleynhans/feat/cli-fps-fraction-syntax
feat(cli): accept ffmpeg-style rational fps (NTSC, PAL, slow-mo)
2 parents d4ba908 + 5dcc89c commit 15aac00

27 files changed

Lines changed: 725 additions & 91 deletions

packages/cli/src/commands/benchmark.ts

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,11 @@ import { c } from "../ui/colors.js";
1414
import { formatBytes, formatDuration, errorBox } from "../ui/format.js";
1515
import * as clack from "@clack/prompts";
1616
import { withMeta } from "../utils/updateCheck.js";
17+
import { fpsToFfmpegArg } from "@hyperframes/core";
1718

1819
interface BenchmarkConfig {
1920
label: string;
20-
fps: 24 | 30 | 60;
21+
fps: import("@hyperframes/core").Fps;
2122
quality: "draft" | "standard" | "high";
2223
workers: number;
2324
}
@@ -35,12 +36,14 @@ interface ConfigResult {
3536
avgSize: number | null;
3637
}
3738

39+
const FPS_30: import("@hyperframes/core").Fps = { num: 30, den: 1 };
40+
const FPS_60: import("@hyperframes/core").Fps = { num: 60, den: 1 };
3841
const DEFAULT_CONFIGS: BenchmarkConfig[] = [
39-
{ label: "30fps \u00B7 draft \u00B7 2w", fps: 30, quality: "draft", workers: 2 },
40-
{ label: "30fps \u00B7 standard \u00B7 2w", fps: 30, quality: "standard", workers: 2 },
41-
{ label: "30fps \u00B7 high \u00B7 2w", fps: 30, quality: "high", workers: 2 },
42-
{ label: "30fps \u00B7 standard \u00B7 4w", fps: 30, quality: "standard", workers: 4 },
43-
{ label: "60fps \u00B7 standard \u00B7 4w", fps: 60, quality: "standard", workers: 4 },
42+
{ label: "30fps \u00B7 draft \u00B7 2w", fps: FPS_30, quality: "draft", workers: 2 },
43+
{ label: "30fps \u00B7 standard \u00B7 2w", fps: FPS_30, quality: "standard", workers: 2 },
44+
{ label: "30fps \u00B7 high \u00B7 2w", fps: FPS_30, quality: "high", workers: 2 },
45+
{ label: "30fps \u00B7 standard \u00B7 4w", fps: FPS_30, quality: "standard", workers: 4 },
46+
{ label: "60fps \u00B7 standard \u00B7 4w", fps: FPS_60, quality: "standard", workers: 4 },
4447
];
4548

4649
export default defineCommand({
@@ -162,7 +165,10 @@ export default defineCommand({
162165
withMeta({
163166
results: results.map((r) => ({
164167
config: r.config.label,
165-
fps: r.config.fps,
168+
// Emit fps in the on-the-wire form so the JSON payload is
169+
// round-trippable through `parseFps` (e.g. "30000/1001" stays
170+
// exact; integer fps stays integer).
171+
fps: fpsToFfmpegArg(r.config.fps),
166172
quality: r.config.quality,
167173
workers: r.config.workers,
168174
avgTimeMs: r.avgTime,

packages/cli/src/commands/render.test.ts

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ describe("renderLocal browser GPU config", () => {
6868
setEnv("PRODUCER_BROWSER_GPU_MODE", "hardware");
6969

7070
await renderLocal("/tmp/project", "/tmp/out.mp4", {
71-
fps: 30,
71+
fps: { num: 30, den: 1 },
7272
quality: "standard",
7373
format: "mp4",
7474
gpu: false,
@@ -86,7 +86,7 @@ describe("renderLocal browser GPU config", () => {
8686

8787
it("forwards browserGpuMode='auto' into producer config (probe-then-choose)", async () => {
8888
await renderLocal("/tmp/project", "/tmp/out.mp4", {
89-
fps: 30,
89+
fps: { num: 30, den: 1 },
9090
quality: "standard",
9191
format: "mp4",
9292
gpu: false,
@@ -104,7 +104,7 @@ describe("renderLocal browser GPU config", () => {
104104

105105
it("passes an explicit hardware override for default local browser GPU", async () => {
106106
await renderLocal("/tmp/project", "/tmp/out.mp4", {
107-
fps: 30,
107+
fps: { num: 30, den: 1 },
108108
quality: "standard",
109109
format: "mp4",
110110
gpu: false,
@@ -137,7 +137,7 @@ describe("renderLocal browser GPU config", () => {
137137

138138
it("forwards parsed --variables payload to createRenderJob", async () => {
139139
await renderLocal("/tmp/project", "/tmp/out.mp4", {
140-
fps: 30,
140+
fps: { num: 30, den: 1 },
141141
quality: "standard",
142142
format: "mp4",
143143
gpu: false,
@@ -152,7 +152,7 @@ describe("renderLocal browser GPU config", () => {
152152

153153
it("forwards format: png-sequence through to createRenderJob", async () => {
154154
await renderLocal("/tmp/project", "/tmp/frames", {
155-
fps: 30,
155+
fps: { num: 30, den: 1 },
156156
quality: "standard",
157157
format: "png-sequence",
158158
gpu: false,
@@ -166,7 +166,7 @@ describe("renderLocal browser GPU config", () => {
166166

167167
it("omits variables from createRenderJob when not provided", async () => {
168168
await renderLocal("/tmp/project", "/tmp/out.mp4", {
169-
fps: 30,
169+
fps: { num: 30, den: 1 },
170170
quality: "standard",
171171
format: "mp4",
172172
gpu: false,
@@ -180,7 +180,7 @@ describe("renderLocal browser GPU config", () => {
180180

181181
it("forwards entryFile to createRenderJob when --composition is set", async () => {
182182
await renderLocal("/tmp/project", "/tmp/out.mp4", {
183-
fps: 30,
183+
fps: { num: 30, den: 1 },
184184
quality: "standard",
185185
format: "mp4",
186186
gpu: false,
@@ -195,7 +195,7 @@ describe("renderLocal browser GPU config", () => {
195195

196196
it("omits entryFile from createRenderJob when --composition is not set", async () => {
197197
await renderLocal("/tmp/project", "/tmp/out.mp4", {
198-
fps: 30,
198+
fps: { num: 30, den: 1 },
199199
quality: "standard",
200200
format: "mp4",
201201
gpu: false,
@@ -209,7 +209,7 @@ describe("renderLocal browser GPU config", () => {
209209

210210
it("forwards outputResolution to createRenderJob when --resolution is set", async () => {
211211
await renderLocal("/tmp/project", "/tmp/out.mp4", {
212-
fps: 30,
212+
fps: { num: 30, den: 1 },
213213
quality: "standard",
214214
format: "mp4",
215215
gpu: false,
@@ -224,7 +224,7 @@ describe("renderLocal browser GPU config", () => {
224224

225225
it("omits outputResolution from createRenderJob by default", async () => {
226226
await renderLocal("/tmp/project", "/tmp/out.mp4", {
227-
fps: 30,
227+
fps: { num: 30, den: 1 },
228228
quality: "standard",
229229
format: "mp4",
230230
gpu: false,
@@ -245,7 +245,7 @@ describe("renderLocal browser GPU config", () => {
245245
});
246246

247247
await renderLocal("/tmp/project", "/tmp/out.mp4", {
248-
fps: 30,
248+
fps: { num: 30, den: 1 },
249249
quality: "standard",
250250
format: "mp4",
251251
gpu: false,

packages/cli/src/commands/render.ts

Lines changed: 52 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -51,12 +51,43 @@ import {
5151
validateVariables,
5252
formatVariableValidationIssue,
5353
normalizeResolutionFlag,
54+
parseFps,
55+
fpsToNumber,
56+
fpsToFfmpegArg,
5457
type VariableValidationIssue,
5558
type CanvasResolution,
59+
type Fps,
60+
type FpsParseResult,
5661
} from "@hyperframes/core";
5762

58-
const VALID_FPS = new Set([24, 30, 60]);
5963
const VALID_QUALITY = new Set(["draft", "standard", "high"]);
64+
65+
/**
66+
* Map a {@link FpsParseResult} failure reason to a human-friendly
67+
* error-box message. The empty / undefined / default-fallthrough case
68+
* shouldn't be reachable from the CLI flag (citty supplies a default of
69+
* "30") but the branch exists so this helper can be reused by other
70+
* fps-accepting CLI surfaces in the future.
71+
*/
72+
function formatFpsParseError(
73+
input: string,
74+
reason: Exclude<FpsParseResult, { ok: true }>["reason"],
75+
): string {
76+
switch (reason) {
77+
case "empty":
78+
return "Frame rate must not be empty.";
79+
case "not-a-number":
80+
return `Got "${input}". Frame rate must be an integer (e.g. 30) or a rational (e.g. 30000/1001 for NTSC).`;
81+
case "non-positive":
82+
return `Got "${input}". Frame rate must be greater than zero.`;
83+
case "out-of-range":
84+
return `Got "${input}". Frame rate must be in the range 1–240.`;
85+
case "invalid-fraction":
86+
return `Got "${input}". Rational frame rates must be two positive integers separated by '/' (e.g. 30000/1001).`;
87+
case "ambiguous-decimal":
88+
return `Got "${input}". Decimal frame rates are ambiguous — use the exact rational form instead (e.g. 30000/1001 for 29.97).`;
89+
}
90+
}
6091
const VALID_FORMAT = new Set(["mp4", "webm", "mov", "png-sequence"]);
6192
// `png-sequence` writes a directory of frames rather than a single muxed file,
6293
// so its "extension" is empty — the auto-output path becomes a directory name.
@@ -95,7 +126,10 @@ export default defineCommand({
95126
fps: {
96127
type: "string",
97128
alias: "f",
98-
description: "Frame rate: 24, 30, 60",
129+
description:
130+
"Frame rate. Accepts integer (24, 25, 30, 50, 60, 120, 240) or " +
131+
"ffmpeg-style rational (30000/1001 for NTSC 29.97, 24000/1001 for " +
132+
"23.976, 60000/1001 for 59.94). Range 1-240.",
99133
default: "30",
100134
},
101135
quality: {
@@ -194,12 +228,17 @@ export default defineCommand({
194228
const project = resolveProject(args.dir);
195229

196230
// ── Validate fps ───────────────────────────────────────────────────────
197-
const fpsRaw = parseInt(args.fps ?? "30", 10);
198-
if (!VALID_FPS.has(fpsRaw)) {
199-
errorBox("Invalid fps", `Got "${args.fps ?? "30"}". Must be 24, 30, or 60.`);
231+
// Accept either integer (`30`) or ffmpeg-style rational (`30000/1001`).
232+
// The whitelist-based validator was replaced with a sane numeric range so
233+
// legitimate framerates (NTSC trio, PAL, 120/240 slow-mo) work without
234+
// CLI gymnastics. The exact rational survives end-to-end into FFmpeg's
235+
// `-r` / `-framerate` flags via `fpsToFfmpegArg`.
236+
const fpsParse = parseFps(args.fps ?? "30");
237+
if (!fpsParse.ok) {
238+
errorBox("Invalid fps", formatFpsParseError(args.fps ?? "30", fpsParse.reason));
200239
process.exit(1);
201240
}
202-
const fps = fpsRaw as 24 | 30 | 60;
241+
const fps: Fps = fpsParse.value;
203242

204243
// ── Validate quality ───────────────────────────────────────────────────
205244
const qualityRaw = args.quality ?? "standard";
@@ -354,7 +393,9 @@ export default defineCommand({
354393
console.log(
355394
c.accent("\u25C6") + " Rendering " + c.accent(nameLabel) + c.dim(" \u2192 " + outputPath),
356395
);
357-
console.log(c.dim(" " + fps + "fps \u00B7 " + quality + " \u00B7 " + workerLabel));
396+
console.log(
397+
c.dim(" " + fpsToFfmpegArg(fps) + "fps \u00B7 " + quality + " \u00B7 " + workerLabel),
398+
);
358399
if (outputResolution) {
359400
// Don't claim "supersampled" — when the composition is already at the
360401
// target dimensions, the DPR resolves to 1 and no supersampling
@@ -521,7 +562,7 @@ export default defineCommand({
521562
});
522563

523564
interface RenderOptions {
524-
fps: 24 | 30 | 60;
565+
fps: Fps;
525566
quality: "draft" | "standard" | "high";
526567
format: "mp4" | "webm" | "mov" | "png-sequence";
527568
workers?: number;
@@ -865,7 +906,7 @@ async function renderDocker(
865906
// Track metrics (no job object available from Docker — use a minimal stub)
866907
trackRenderComplete({
867908
durationMs: elapsed,
868-
fps: options.fps,
909+
fps: fpsToNumber(options.fps),
869910
quality: options.quality,
870911
workers: options.workers,
871912
docker: true,
@@ -964,7 +1005,7 @@ function handleRenderError(
9641005
): never {
9651006
const message = error instanceof Error ? error.message : String(error);
9661007
trackRenderError({
967-
fps: options.fps,
1008+
fps: fpsToNumber(options.fps),
9681009
quality: options.quality,
9691010
docker,
9701011
workers: options.workers,
@@ -1001,7 +1042,7 @@ function trackRenderMetrics(
10011042

10021043
trackRenderComplete({
10031044
durationMs: elapsedMs,
1004-
fps: options.fps,
1045+
fps: fpsToNumber(options.fps),
10051046
quality: options.quality,
10061047
workers: options.workers ?? perf?.workers,
10071048
docker,

packages/cli/src/server/studioServer.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -213,7 +213,9 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {
213213
}
214214

215215
const job = createRenderJob({
216-
fps: opts.fps as 24 | 30 | 60,
216+
// opts.fps is already an Fps rational — see vite-config-studio
217+
// adapter for the same convention.
218+
fps: opts.fps,
217219
quality: opts.quality as "draft" | "standard" | "high",
218220
format: opts.format,
219221
outputResolution: opts.outputResolution,

packages/cli/src/utils/dockerRunArgs.test.ts

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
22
import { buildDockerRunArgs, type DockerRenderOptions } from "./dockerRunArgs.js";
33

44
const BASE: DockerRenderOptions = {
5-
fps: 30,
5+
fps: { num: 30, den: 1 },
66
quality: "standard",
77
format: "mp4",
88
gpu: false,
@@ -151,7 +151,7 @@ describe("buildDockerRunArgs", () => {
151151
const args = buildDockerRunArgs({
152152
...FIXED_INPUT,
153153
options: {
154-
fps: 60,
154+
fps: { num: 60, den: 1 },
155155
quality: "high",
156156
format: "webm",
157157
workers: 8,
@@ -240,6 +240,29 @@ describe("buildDockerRunArgs", () => {
240240
expect(args).not.toContain("--composition");
241241
});
242242

243+
it("forwards rational --fps verbatim (NTSC 30000/1001)", () => {
244+
// Regression for the fps fraction-syntax feature: the rational form must
245+
// survive the host → container hop as a single `30000/1001` argument so
246+
// the in-container CLI re-parses it as exact NTSC, not 29.97 decimal.
247+
const args = buildDockerRunArgs({
248+
...FIXED_INPUT,
249+
options: { ...BASE, fps: { num: 30000, den: 1001 } },
250+
});
251+
const fpsIdx = args.indexOf("--fps");
252+
expect(fpsIdx).toBeGreaterThanOrEqual(0);
253+
expect(args[fpsIdx + 1]).toBe("30000/1001");
254+
});
255+
256+
it("forwards integer --fps as a bare integer string", () => {
257+
const args = buildDockerRunArgs({
258+
...FIXED_INPUT,
259+
options: { ...BASE, fps: { num: 60, den: 1 } },
260+
});
261+
const fpsIdx = args.indexOf("--fps");
262+
expect(fpsIdx).toBeGreaterThanOrEqual(0);
263+
expect(args[fpsIdx + 1]).toBe("60");
264+
});
265+
243266
it("forwards --resolution to the container when outputResolution is set", () => {
244267
const args = buildDockerRunArgs({
245268
...FIXED_INPUT,

packages/cli/src/utils/dockerRunArgs.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
* a test in `dockerRunArgs.test.ts` — that combination is what catches
88
* silent-drop regressions like the one that lost `--hdr` historically.
99
*/
10+
import { fpsToFfmpegArg, type Fps } from "@hyperframes/core";
11+
1012
export interface DockerRunArgsInput {
1113
imageTag: string;
1214
/** Absolute host path to the project directory (mounted read-only at /project). */
@@ -19,7 +21,13 @@ export interface DockerRunArgsInput {
1921
}
2022

2123
export interface DockerRenderOptions {
22-
fps: 24 | 30 | 60;
24+
/**
25+
* Frame rate as an exact rational; see `Fps` in @hyperframes/core. The
26+
* docker-run arg builder serializes this back to a `--fps` string
27+
* (`"30"` or `"30000/1001"`) which the in-container CLI re-parses with
28+
* `parseFps`, so the rational survives the host → container hop.
29+
*/
30+
fps: Fps;
2331
quality: "draft" | "standard" | "high";
2432
format: "mp4" | "webm" | "mov" | "png-sequence";
2533
workers?: number;
@@ -54,7 +62,7 @@ export function buildDockerRunArgs(input: DockerRunArgsInput): string[] {
5462
"--output",
5563
`/output/${outputFilename}`,
5664
"--fps",
57-
String(options.fps),
65+
fpsToFfmpegArg(options.fps),
5866
"--quality",
5967
options.quality,
6068
"--format",

0 commit comments

Comments
 (0)