mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 06:30:03 +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:
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 50–150 red card
|
||||
|
||||
Reference in New Issue
Block a user