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
+23 -2
View File
@@ -36,6 +36,7 @@ import {
executeRenderJob,
type RenderPerfSummary,
} from "./services/renderOrchestrator.js";
import { parseFps } from "@hyperframes/core";
const scriptDir = dirname(fileURLToPath(import.meta.url));
const testsDir = resolve(scriptDir, "../tests");
@@ -44,7 +45,9 @@ const perfDir = resolve(testsDir, "perf");
interface TestMeta {
name: string;
tags?: string[];
renderConfig: { fps: 24 | 30 | 60 };
// Same on-disk shape as the regression harness — JSON `number` (integer
// fps) or JSON `string` ("30000/1001"). Normalized to Fps when loaded.
renderConfig: { fps: import("@hyperframes/core").Fps };
}
interface BenchmarkRun {
@@ -125,7 +128,25 @@ function discoverFixtures(
if (only && entry !== only) continue;
const meta: TestMeta = JSON.parse(readFileSync(metaPath, "utf-8"));
const rawMeta = JSON.parse(readFileSync(metaPath, "utf-8")) as {
name: string;
tags?: string[];
renderConfig: { fps: number | string };
};
// meta.json on disk uses a JSON `number` for legacy integer fps values
// and a JSON `string` for new ffmpeg-style rationals (e.g. "30000/1001").
// Normalize to the Fps rational shape so downstream code only sees the
// structured form — same convention as the regression harness.
const fpsParse = parseFps(rawMeta.renderConfig.fps);
if (!fpsParse.ok) {
throw new Error(
`Benchmark fixture ${entry}: invalid renderConfig.fps ${JSON.stringify(rawMeta.renderConfig.fps)}`,
);
}
const meta: TestMeta = {
...rawMeta,
renderConfig: { ...rawMeta.renderConfig, fps: fpsParse.value },
};
const fixtureTags = meta.tags ?? [];
// Positive filter (--tags): if provided, fixture must match at least one.
if (tags.length > 0 && !fixtureTags.some((t) => tags.includes(t))) continue;
+27 -6
View File
@@ -19,6 +19,7 @@ import { compileForRender } from "./services/htmlCompiler.js";
import { validateCompilation } from "./services/compilationTester.js";
import { extractMediaMetadata } from "./utils/ffprobe.js";
import { buildRmsEnvelope, compareAudioEnvelopes } from "./utils/audioRegression.js";
import { parseFps, fpsToNumber } from "@hyperframes/core";
// ── Types ────────────────────────────────────────────────────────────────────
@@ -31,7 +32,13 @@ type TestMetadata = {
minAudioCorrelation: number;
maxAudioLagWindows: number;
renderConfig: {
fps: 24 | 30 | 60;
/**
* Frame rate. Stored on disk as a JSON number (integer fps, e.g. `30`)
* for legacy meta.json files, or a JSON string (`"30000/1001"` for NTSC)
* for rationals. The metadata validator normalizes both into an `Fps`
* rational at load time so downstream code only sees the structured form.
*/
fps: import("@hyperframes/core").Fps;
format?: "mp4" | "webm"; // Optional: defaults to "mp4"
workers?: number; // Optional: auto-calculates if omitted
/** Force HDR in the harness; omitted/false preserves historical SDR-only test behavior. */
@@ -154,9 +161,23 @@ function validateMetadata(meta: unknown): TestMetadata {
throw new Error("meta.json: 'renderConfig' must be an object");
}
const rc = m.renderConfig as Record<string, unknown>;
if (![24, 30, 60].includes(rc.fps as number)) {
throw new Error("meta.json: 'renderConfig.fps' must be 24, 30, or 60");
// Accept either a JSON number (integer fps, e.g. 30) or a JSON string
// (ffmpeg-style rational, e.g. "30000/1001"). Normalize both into the Fps
// rational shape and write it back onto the metadata object so all
// downstream callers can assume the structured form.
const fpsRaw = rc.fps;
const fpsParse =
typeof fpsRaw === "number" || typeof fpsRaw === "string"
? parseFps(fpsRaw)
: ({ ok: false, reason: "not-a-number" } as const);
if (!fpsParse.ok) {
throw new Error(
`meta.json: 'renderConfig.fps' must be an integer (e.g. 30) or rational string (e.g. "30000/1001"); got ${JSON.stringify(
fpsRaw,
)}`,
);
}
rc.fps = fpsParse.value;
if (rc.format !== undefined && rc.format !== "mp4" && rc.format !== "webm") {
throw new Error("meta.json: 'renderConfig.format' must be 'mp4' or 'webm' (or omit for mp4)");
}
@@ -452,13 +473,13 @@ function saveFailureDetails(
renderedVideoPath,
checkpoint.time,
join(framesDir, `actual_${timeStr}s.png`),
suite.meta.renderConfig.fps,
fpsToNumber(suite.meta.renderConfig.fps),
);
extractFrameAsImage(
snapshotVideoPath,
checkpoint.time,
join(framesDir, `expected_${timeStr}s.png`),
suite.meta.renderConfig.fps,
fpsToNumber(suite.meta.renderConfig.fps),
);
} catch {
logPretty(` Warning: Could not extract frame at ${checkpoint.time}s`, "⚠️");
@@ -666,7 +687,7 @@ async function runTestSuite(
renderedOutputPath,
snapshotVideoPath,
time,
suite.meta.renderConfig.fps,
fpsToNumber(suite.meta.renderConfig.fps),
);
visualCheckpoints.push({
time,
+10 -2
View File
@@ -38,6 +38,7 @@ import { prepareHyperframeLintBody, runHyperframeLint } from "./services/hyperfr
import { resolveRenderPaths } from "./utils/paths.js";
import { defaultLogger, type ProducerLogger } from "./logger.js";
import { Semaphore } from "./utils/semaphore.js";
import { parseFps } from "@hyperframes/core";
// ---------------------------------------------------------------------------
// Types
@@ -68,7 +69,7 @@ export interface ServerOptions extends HandlerOptions {
interface RenderInput {
projectDir: string;
outputPath?: string | null;
fps: 24 | 30 | 60;
fps: import("@hyperframes/core").Fps;
quality: "draft" | "standard" | "high";
format?: "mp4" | "webm" | "mov";
workers?: number;
@@ -83,7 +84,14 @@ interface PreparedRenderInput {
}
function parseRenderOptions(body: Record<string, unknown>): Omit<RenderInput, "projectDir"> {
const fps = ([24, 30, 60].includes(body.fps as number) ? body.fps : 30) as 24 | 30 | 60;
// Accept either a JSON `number` (integer fps) or a JSON `string` (rational
// like "30000/1001"). Falls back to 30 fps on parse failure to preserve the
// forgiving behaviour the original whitelist had — the producer surfaces a
// clearer downstream error if the value is genuinely unusable.
const fpsRaw = body.fps;
const fpsParse =
typeof fpsRaw === "number" || typeof fpsRaw === "string" ? parseFps(fpsRaw) : null;
const fps = fpsParse && fpsParse.ok ? fpsParse.value : ({ num: 30, den: 1 } as const);
const quality = (
["draft", "standard", "high"].includes(body.quality as string) ? body.quality : "high"
) as "draft" | "standard" | "high";
@@ -29,7 +29,13 @@ import {
symlinkSync,
} from "fs";
import { parseHTML } from "linkedom";
import { CANVAS_DIMENSIONS, type CanvasResolution } from "@hyperframes/core";
import {
CANVAS_DIMENSIONS,
type CanvasResolution,
type Fps,
fpsToNumber,
fpsToFfmpegArg,
} from "@hyperframes/core";
import {
type EngineConfig,
resolveConfig,
@@ -214,7 +220,17 @@ export type RenderStatus =
| "cancelled";
export interface RenderConfig {
fps: 24 | 30 | 60;
/**
* Frame rate as an exact rational. Integer fps is `{ num: 30, den: 1 }`;
* NTSC is `{ num: 30000, den: 1001 }`. This shape lets the orchestrator
* pass the exact rational through to FFmpeg's `-r` / `-framerate` flags
* without a decimal round-trip — see `fpsToFfmpegArg` in @hyperframes/core.
*
* Use `fpsToNumber(config.fps)` at any site that needs a `number` for
* arithmetic (frame-index → time, telemetry, frame-interval ms). Decimal
* precision at our scales is more than sufficient.
*/
fps: Fps;
quality: "draft" | "standard" | "high";
/**
* Output container format. Defaults to `"mp4"`; existing renders are
@@ -2345,7 +2361,7 @@ export async function executeRenderJob(
perfStages.browserProbeMs = Date.now() - probeStart;
job.duration = composition.duration;
job.totalFrames = Math.ceil(composition.duration * job.config.fps);
job.totalFrames = Math.ceil(composition.duration * fpsToNumber(job.config.fps));
const totalFrames = job.totalFrames;
if (job.duration <= 0) {
@@ -2487,7 +2503,14 @@ export async function executeRenderJob(
extractionResult = await extractAllVideoFrames(
composition.videos,
projectDir,
{ fps: job.config.fps, outputDir: join(compiledDir, "__hyperframes_video_frames") },
// extractAllVideoFrames takes fps as a number (decimal). Frames sampled
// from a video at 29.97 vs 30 differ by ~1 frame in 1000 — not enough
// to break visual parity, and the encoder-side rational keeps the
// output framerate exact.
{
fps: fpsToNumber(job.config.fps),
outputDir: join(compiledDir, "__hyperframes_video_frames"),
},
abortSignal,
{ extractCacheDir: cfg.extractCacheDir },
compiledDir,
@@ -2697,7 +2720,7 @@ export async function executeRenderJob(
captureCalibration = await measureCaptureCostFromSession(
calibrationSession,
totalFrames,
job.config.fps,
fpsToNumber(job.config.fps),
);
logCaptureCalibrationResult(captureCalibration, log);
} catch (error) {
@@ -2741,7 +2764,7 @@ export async function executeRenderJob(
captureCalibration = await measureCaptureCostFromSession(
calibrationSession,
totalFrames,
job.config.fps,
fpsToNumber(job.config.fps),
);
logCaptureCalibrationResult(captureCalibration, log);
} catch (fallbackError) {
@@ -2967,10 +2990,11 @@ export async function executeRenderJob(
return map;
});
const fpsDecimal = fpsToNumber(job.config.fps);
const transitionRanges: TransitionRange[] = transitionMeta.map((t) => ({
...t,
startFrame: Math.floor(t.time * job.config.fps),
endFrame: Math.ceil((t.time + t.duration) * job.config.fps),
startFrame: Math.floor(t.time * fpsDecimal),
endFrame: Math.ceil((t.time + t.duration) * fpsDecimal),
}));
if (transitionRanges.length > 0) {
@@ -3118,7 +3142,8 @@ export async function executeRenderJob(
"-t",
String(duration),
"-r",
String(job.config.fps),
// Pass the rational form to FFmpeg so NTSC stays exact end-to-end.
fpsToFfmpegArg(job.config.fps),
"-vf",
`scale=${dims.width}:${dims.height}:force_original_aspect_ratio=increase,crop=${dims.width}:${dims.height}`,
"-pix_fmt",
@@ -3267,7 +3292,7 @@ export async function executeRenderJob(
beforeCaptureHook,
width,
height,
fps: job.config.fps,
fps: fpsToNumber(job.config.fps),
compositeTransfer,
nativeHdrImageIds,
hdrImageBuffers,
@@ -3295,7 +3320,7 @@ export async function executeRenderJob(
for (let i = 0; i < totalFrames; i++) {
assertNotAborted();
const time = i / job.config.fps;
const time = (i * job.config.fps.den) / job.config.fps.num;
if (hdrPerf) hdrPerf.frames += 1;
// Seek timeline
@@ -3420,7 +3445,7 @@ export async function executeRenderJob(
sceneBuf as Buffer,
el,
time,
job.config.fps,
fpsToNumber(job.config.fps),
hdrVideoFrameSources,
hdrVideoStartTimes,
width,
@@ -3733,7 +3758,7 @@ export async function executeRenderJob(
for (let i = 0; i < totalFrames; i++) {
assertNotAborted();
const time = i / job.config.fps;
const time = (i * job.config.fps.den) / job.config.fps.num;
const { buffer } = await captureFrameToBuffer(session, i, time);
await reorderBuffer.waitForFrame(i);
currentEncoder.writeFrame(buffer);
@@ -3841,7 +3866,7 @@ export async function executeRenderJob(
for (let i = 0; i < job.totalFrames; i++) {
assertNotAborted();
const time = i / job.config.fps;
const time = (i * job.config.fps.den) / job.config.fps.num;
await captureFrame(session, i, time);
job.framesRendered = i + 1;
@@ -4011,7 +4036,11 @@ export async function executeRenderJob(
const perfSummary: RenderPerfSummary = {
renderId: job.id,
totalElapsedMs: totalElapsed,
fps: job.config.fps,
// RenderPerfSummary surfaces fps as a decimal because it lands in JSON
// payloads (CLI telemetry, regression-harness reports) where a single
// number is friendlier than `{num,den}`. Callers needing the rational
// back can read `job.config.fps`.
fps: fpsToNumber(job.config.fps),
quality: job.config.quality,
workers: workerCount,
chunkedEncode: enableChunkedEncode,
+1 -1
View File
@@ -31,7 +31,7 @@ const FIXTURE_SRC = join(FIXTURE_DIR, "src");
const WIDTH = 200;
const HEIGHT = 200;
const FPS = 30;
const FPS: import("@hyperframes/core").Fps = { num: 30, den: 1 };
const TRANSPARENT_X = 10; // expected fully transparent
const TRANSPARENT_Y = 10;
const OPAQUE_X = 100; // inside the 50150 red card