Files
hyperframes/packages/producer/src/benchmark.ts
T
Theodor Kleynhans 5dcc89c930 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`.
2026-05-09 00:09:15 +02:00

340 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env tsx
/**
* Render Benchmark
*
* Runs each test fixture multiple times and records per-stage timing
* plus peak heap/RSS memory. Results are saved to
* producer/tests/perf/benchmark-results.json.
*
* Usage:
* bun run benchmark # 3 runs per fixture (default)
* bun run benchmark -- --runs 5 # 5 runs per fixture
* bun run benchmark -- --only chat # single fixture
* bun run benchmark -- --exclude-tags slow
* bun run benchmark -- --tags hdr # only fixtures tagged "hdr"
* bun run bench:hdr # convenience: --tags hdr
*
* `--tags` and `--exclude-tags` may be passed together; a fixture must match
* at least one positive tag (when `--tags` is provided) AND must not match
* any excluded tag.
*/
import {
readdirSync,
readFileSync,
writeFileSync,
existsSync,
mkdirSync,
cpSync,
rmSync,
} from "node:fs";
import { join, resolve, dirname } from "node:path";
import { tmpdir } from "node:os";
import { fileURLToPath } from "node:url";
import {
createRenderJob,
executeRenderJob,
type RenderPerfSummary,
} from "./services/renderOrchestrator.js";
import { parseFps } from "@hyperframes/core";
const scriptDir = dirname(fileURLToPath(import.meta.url));
const testsDir = resolve(scriptDir, "../tests");
const perfDir = resolve(testsDir, "perf");
interface TestMeta {
name: string;
tags?: string[];
// 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 {
run: number;
perfSummary: RenderPerfSummary;
}
interface FixtureResult {
fixture: string;
name: string;
runs: BenchmarkRun[];
averages: {
totalElapsedMs: number;
captureAvgMs: number | null;
/** Average of per-run peak RSS in MiB. `null` if no run reported memory. */
peakRssMb: number | null;
/** Average of per-run peak heapUsed in MiB. `null` if no run reported memory. */
peakHeapUsedMb: number | null;
stages: Record<string, number>;
};
}
interface BenchmarkResults {
timestamp: string;
platform: string;
nodeVersion: string;
runsPerFixture: number;
fixtures: FixtureResult[];
}
interface BenchmarkArgs {
runs: number;
only: string | null;
/** Positive tag filter — fixture must include at least one. Empty = no positive filter. */
tags: string[];
/** Negative tag filter — fixture must not include any. Applied after `tags`. */
excludeTags: string[];
}
function parseArgs(): BenchmarkArgs {
let runs = 3;
let only: string | null = null;
const tags: string[] = [];
const excludeTags: string[] = [];
for (let i = 2; i < process.argv.length; i++) {
if (process.argv[i] === "--runs" && process.argv[i + 1]) {
i++;
runs = parseInt(process.argv[i] ?? "", 10);
} else if (process.argv[i] === "--only" && process.argv[i + 1]) {
i++;
only = process.argv[i] ?? null;
} else if (process.argv[i] === "--tags" && process.argv[i + 1]) {
i++;
tags.push(...(process.argv[i] ?? "").split(",").filter(Boolean));
} else if (process.argv[i] === "--exclude-tags" && process.argv[i + 1]) {
i++;
excludeTags.push(...(process.argv[i] ?? "").split(",").filter(Boolean));
}
}
return { runs, only, tags, excludeTags };
}
function discoverFixtures(
only: string | null,
tags: string[],
excludeTags: string[],
): Array<{ id: string; dir: string; meta: TestMeta }> {
const fixtures: Array<{ id: string; dir: string; meta: TestMeta }> = [];
for (const entry of readdirSync(testsDir)) {
if (entry === "perf" || entry === "parity") continue;
const dir = join(testsDir, entry);
const metaPath = join(dir, "meta.json");
const srcDir = join(dir, "src");
if (!existsSync(metaPath) || !existsSync(join(srcDir, "index.html"))) continue;
if (only && entry !== only) continue;
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;
// Negative filter (--exclude-tags): always wins.
if (excludeTags.length > 0 && fixtureTags.some((t) => excludeTags.includes(t))) continue;
fixtures.push({ id: entry, dir, meta });
}
return fixtures;
}
function avg(nums: number[]): number {
if (nums.length === 0) return 0;
return Math.round(nums.reduce((a, b) => a + b, 0) / nums.length);
}
/**
* Average a possibly-empty list of optional numbers. Returns `null` when no
* defined samples exist so the JSON output stays consistent with the
* `peakRssMb: number | null` shape the consumer (perf README, regression
* checks) expects — silently coercing missing memory data to `0` would mask
* older results regenerated against this harness.
*/
function avgOrNull(nums: Array<number | null | undefined>): number | null {
const filtered = nums.filter((n): n is number => typeof n === "number");
if (filtered.length === 0) return null;
return avg(filtered);
}
async function runBenchmark(): Promise<void> {
const { runs, only, tags, excludeTags } = parseArgs();
const fixtures = discoverFixtures(only, tags, excludeTags);
if (fixtures.length === 0) {
console.error(
`No fixtures found${tags.length ? ` matching tags=[${tags.join(",")}]` : ""}` +
`${excludeTags.length ? ` excluding=[${excludeTags.join(",")}]` : ""}`,
);
process.exit(1);
}
const filterDesc =
(tags.length ? ` tags=[${tags.join(",")}]` : "") +
(excludeTags.length ? ` exclude=[${excludeTags.join(",")}]` : "");
console.log(`\n🏁 Benchmark: ${fixtures.length} fixture(s) × ${runs} run(s)${filterDesc}\n`);
const results: FixtureResult[] = [];
for (const fixture of fixtures) {
console.log(`\n━━━ ${fixture.meta.name} (${fixture.id}) ━━━`);
const fixtureRuns: BenchmarkRun[] = [];
for (let r = 0; r < runs; r++) {
console.log(` Run ${r + 1}/${runs}...`);
// Copy src to temp dir for isolation
const tmpRoot = join(tmpdir(), `benchmark-${fixture.id}-${Date.now()}`);
mkdirSync(tmpRoot, { recursive: true });
cpSync(join(fixture.dir, "src"), join(tmpRoot, "src"), { recursive: true });
const projectDir = join(tmpRoot, "src");
const outputPath = join(tmpRoot, "output.mp4");
const job = createRenderJob({
fps: fixture.meta.renderConfig.fps,
quality: "high",
debug: false,
});
try {
await executeRenderJob(job, projectDir, outputPath);
} catch (err) {
console.error(` ❌ Run ${r + 1} failed: ${err instanceof Error ? err.message : err}`);
continue;
} finally {
try {
rmSync(tmpRoot, { recursive: true, force: true });
} catch {}
}
if (job.perfSummary) {
fixtureRuns.push({ run: r + 1, perfSummary: job.perfSummary });
const ps = job.perfSummary;
const memDesc =
ps.peakRssMb != null || ps.peakHeapUsedMb != null
? ` | peak RSS ${ps.peakRssMb ?? "?"}MiB heap ${ps.peakHeapUsedMb ?? "?"}MiB`
: "";
console.log(
` ✓ ${ps.totalElapsedMs}ms total | capture avg ${ps.captureAvgMs ?? "?"}ms/frame | ${ps.totalFrames} frames${memDesc}`,
);
}
}
if (fixtureRuns.length === 0) {
console.log(` ⚠ No successful runs`);
continue;
}
// Compute averages
const allStageKeys = new Set<string>();
for (const run of fixtureRuns) {
for (const key of Object.keys(run.perfSummary.stages)) {
allStageKeys.add(key);
}
}
const avgStages: Record<string, number> = {};
for (const key of allStageKeys) {
avgStages[key] = avg(fixtureRuns.map((r) => r.perfSummary.stages[key] ?? 0));
}
const fixtureResult: FixtureResult = {
fixture: fixture.id,
name: fixture.meta.name,
runs: fixtureRuns,
averages: {
totalElapsedMs: avg(fixtureRuns.map((r) => r.perfSummary.totalElapsedMs)),
captureAvgMs: avgOrNull(fixtureRuns.map((r) => r.perfSummary.captureAvgMs)),
peakRssMb: avgOrNull(fixtureRuns.map((r) => r.perfSummary.peakRssMb)),
peakHeapUsedMb: avgOrNull(fixtureRuns.map((r) => r.perfSummary.peakHeapUsedMb)),
stages: avgStages,
},
};
results.push(fixtureResult);
const memLine =
fixtureResult.averages.peakRssMb != null || fixtureResult.averages.peakHeapUsedMb != null
? ` | peak RSS ${fixtureResult.averages.peakRssMb ?? "?"}MiB heap ${fixtureResult.averages.peakHeapUsedMb ?? "?"}MiB`
: "";
console.log(`\n Average: ${fixtureResult.averages.totalElapsedMs}ms total${memLine}`);
for (const [stage, ms] of Object.entries(fixtureResult.averages.stages)) {
const pct = Math.round((ms / fixtureResult.averages.totalElapsedMs) * 100);
console.log(` ${stage}: ${ms}ms (${pct}%)`);
}
}
// Save results
const benchmarkResults: BenchmarkResults = {
timestamp: new Date().toISOString(),
platform: `${process.platform} ${process.arch}`,
nodeVersion: process.version,
runsPerFixture: runs,
fixtures: results,
};
if (!existsSync(perfDir)) mkdirSync(perfDir, { recursive: true });
const outputPath = join(perfDir, "benchmark-results.json");
writeFileSync(outputPath, JSON.stringify(benchmarkResults, null, 2), "utf-8");
// Print summary table
console.log("\n\n📊 BENCHMARK SUMMARY");
console.log("═".repeat(95));
console.log(
"Fixture".padEnd(25) +
"Total".padStart(10) +
"Compile".padStart(10) +
"Extract".padStart(10) +
"Audio".padStart(10) +
"Capture".padStart(10) +
"Encode".padStart(10) +
"PeakRSS".padStart(10) +
"PeakHeap".padStart(10),
);
console.log("─".repeat(95));
for (const f of results) {
const s = f.averages.stages;
console.log(
f.fixture.padEnd(25) +
`${f.averages.totalElapsedMs}ms`.padStart(10) +
`${s.compileMs ?? "-"}ms`.padStart(10) +
`${s.videoExtractMs ?? "-"}ms`.padStart(10) +
`${s.audioProcessMs ?? "-"}ms`.padStart(10) +
`${s.captureMs ?? "-"}ms`.padStart(10) +
`${s.encodeMs ?? "-"}ms`.padStart(10) +
`${f.averages.peakRssMb ?? "-"}MiB`.padStart(10) +
`${f.averages.peakHeapUsedMb ?? "-"}MiB`.padStart(10),
);
}
console.log("═".repeat(95));
console.log(`\nResults saved to: ${outputPath}`);
}
runBenchmark().catch((err) => {
console.error("Benchmark failed:", err);
process.exit(1);
});