mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 23:03:09 +00:00
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`.
340 lines
11 KiB
TypeScript
340 lines
11 KiB
TypeScript
#!/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);
|
||
});
|