mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
## Summary Adds **scenario 06: live-playback parity** — the third and final tranche of the P0-1 perf-test buildout (`p0-1a` infra → `p0-1b` fps/scrub/drift → this). The scenario plays the `gsap-heavy` fixture, freezes it mid-animation, screenshots the live frame, then synchronously seeks the same player back to that exact timestamp and screenshots the reference. The two PNGs are diffed with `ffmpeg -lavfi ssim` and the resulting average SSIM is emitted as `parity_ssim_min`. Baseline gate: **SSIM ≥ 0.95**. This pins the player's two frame-production paths (the runtime's animation loop vs. `_trySyncSeek`) to each other visually, so any future drift between scrub and playback fails CI instead of silently shipping. ## Motivation `<hyperframes-player>` produces frames two different ways: 1. **Live playback** — the runtime's animation loop advances the GSAP timeline frame-by-frame. 2. **Synchronous seek** (`_trySyncSeek`, landed in #397) — for same-origin embeds, the player calls into the iframe runtime's `seek()` directly and asks for a specific time. These paths must agree. If they don't — different rounding, different sub-frame sampling, different state ordering — scrubbing a paused composition shows different pixels than a paused-during-playback frame at the same time. That's a class of bug that only surfaces visually, never in unit tests, and only at specific timestamps where many things are mid-flight. `gsap-heavy` is a 10s composition with 60 tiles each running a staggered 4s out-and-back tween. At t=5.0s a large fraction of those tiles are mid-flight, so the rendered frame has many distinct, position-sensitive pixels — the worst-case input for any sub-frame disagreement. If the two paths produce identical pixels here, they'll produce identical pixels everywhere that matters. ## What changed - **`packages/player/tests/perf/scenarios/06-parity.ts`** — new scenario (~340 lines). Owns capture, seek, screenshot, SSIM, artifact persistence, and aggregation. - **`packages/player/tests/perf/index.ts`** — register `parity` as a scenario id, default-runs = 3, dispatch to `runParity`, include in the default scenario list. - **`packages/player/tests/perf/perf-gate.ts`** — extend `PerfBaseline` with `paritySsimMin`. - **`packages/player/tests/perf/baseline.json`** — `paritySsimMin: 0.95`. - **`.github/workflows/player-perf.yml`** — add a `parity` shard (3 runs) to the matrix alongside `load` / `fps` / `scrub` / `drift`. ## How the scenario works The hard part is making the two captures land on the *exact same timestamp* without trusting `postMessage` round-trips or arbitrary `setTimeout` settling. 1. **Install an iframe-side rAF watcher** before issuing `play()`. The watcher polls `__player.getTime()` every animation frame and, the first time `getTime() >= 5.0`, calls `__player.pause()` *from inside the same rAF tick*. `pause()` is synchronous (it calls `timeline.pause()`), so the timeline freezes at exactly that `getTime()` value with no postMessage round-trip. The watcher's Promise resolves with that frozen value as the canonical `T_actual` for the run. 2. **Confirm `isPlaying() === true`** via `frame.waitForFunction` before awaiting the watcher. Without this, the test can hang if `play()` hasn't kicked the timeline yet. 3. **Wait for paint** — two `requestAnimationFrame` ticks on the host page. The first flushes pending style/layout, the second guarantees a painted compositor commit. Same paint-settlement pattern as `packages/producer/src/parity-harness.ts`. 4. **Screenshot the live frame** — `page.screenshot({ type: "png" })`. 5. **Synchronously seek to `T_actual`** — call `el.seek(capturedTime)` on the host page. The player's public `seek()` calls `_trySyncSeek` which (same-origin) calls `__player.seek()` synchronously, so no postMessage await is needed. The runtime's deterministic `seek()` rebuilds frame state at exactly the requested time. 6. **Wait for paint** again, screenshot the reference frame. 7. **Diff with ffmpeg** — `ffmpeg -hide_banner -i reference.png -i actual.png -lavfi ssim -f null -`. ffmpeg writes per-channel + overall SSIM to stderr; we parse the `All:` value, clamp at 1.0 (ffmpeg occasionally reports 1.000001 on identical inputs), and treat it as the run's score. 8. **Persist artifacts** under `tests/perf/results/parity/run-N/` (`actual.png`, `reference.png`, `captured-time.txt`) so CI can upload them and so a failed run is locally reproducible. Directory is already gitignored via the existing `packages/player/tests/perf/results/` rule. ### Aggregation `min()` across runs, **not** mean. We want the *worst observed* parity to pass the gate so a single bad run can't get masked by averaging. Both per-run scores and the aggregate are logged. ### Output metric | name | direction | baseline | |-------------------|------------------|----------------------| | `parity_ssim_min` | higher-is-better | `paritySsimMin: 0.95` | With deterministic rendering enabled in the runner, identical pixels produce SSIM very close to 1.0; the 0.95 threshold leaves headroom for legitimate fixture-level noise (font hinting, GPU compositor variance) while still catching any real disagreement between the two paths. ## Test plan - `bun run player:perf -- --scenarios=parity --runs=3` locally on `gsap-heavy` — passes with SSIM ≈ 0.999 across all 3 runs. - Inspected `results/parity/run-1/actual.png` and `reference.png` side-by-side — visually identical. - Inspected `captured-time.txt` to confirm `T_actual` lands just past 5.0s (within one frame). - Sanity test: temporarily forced a 1-frame offset between live and reference capture; SSIM dropped well below 0.95 as expected, confirming the threshold catches real drift. - CI: `parity` shard added alongside the existing `load` / `fps` / `scrub` / `drift` shards; same `measure`-mode / artifact-upload / aggregation flow. - `bunx oxlint` and `bunx oxfmt --check` clean on the new scenario. ## Stack This is the top of the perf stack: 1. #393 `perf/x-1-emit-performance-metric` — performance.measure() emission 2. #394 `perf/p1-1-share-player-styles-via-adopted-stylesheets` — adopted stylesheets 3. #395 `perf/p1-2-scope-media-mutation-observer` — scoped MutationObserver 4. #396 `perf/p1-4-coalesce-mirror-parent-media-time` — coalesce currentTime writes 5. #397 `perf/p3-1-sync-seek-same-origin` — synchronous seek path (the path this PR pins) 6. #398 `perf/p3-2-srcdoc-composition-switching` — srcdoc switching 7. #399 `perf/p0-1a-perf-test-infra` — server, runner, perf-gate, CI 8. #400 `perf/p0-1b-perf-tests-for-fps-scrub-drift` — fps / scrub / drift scenarios 9. **#401 `perf/p0-1c-live-playback-parity-test` ← you are here** With this PR landed the perf harness covers all five proposal scenarios: `load`, `fps`, `scrub`, `drift`, `parity`.
273 lines
9.0 KiB
TypeScript
273 lines
9.0 KiB
TypeScript
#!/usr/bin/env bun
|
||
/**
|
||
* Player Performance Test Runner
|
||
*
|
||
* Boots a static server, launches puppeteer-core against locally-served fixtures,
|
||
* runs the configured scenarios, then evaluates the collected metrics against
|
||
* baseline.json via perf-gate.
|
||
*
|
||
* Usage:
|
||
* bun run packages/player/tests/perf/index.ts
|
||
* bun run packages/player/tests/perf/index.ts --mode enforce
|
||
* bun run packages/player/tests/perf/index.ts --scenarios load
|
||
* bun run packages/player/tests/perf/index.ts --runs 5 --headful
|
||
*
|
||
* Flags:
|
||
* --mode <measure|enforce> default: PLAYER_PERF_MODE env or "measure"
|
||
* --scenarios <list> comma-separated scenario ids; default: all enabled
|
||
* --runs <n> override per-scenario run count
|
||
* --fixture <name> single fixture (default: every fixture in fixtures/)
|
||
* --headful show the browser; default: headless
|
||
*
|
||
* Exit codes:
|
||
* 0 all pass (or measure mode)
|
||
* 1 scenario crashed
|
||
* 2 perf gate failed in enforce mode
|
||
*/
|
||
|
||
import { execFileSync } from "node:child_process";
|
||
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
||
import { dirname, resolve } from "node:path";
|
||
import { fileURLToPath } from "node:url";
|
||
import { runFps } from "./scenarios/02-fps.ts";
|
||
import { runLoad } from "./scenarios/03-load.ts";
|
||
import { runScrub } from "./scenarios/04-scrub.ts";
|
||
import { runDrift } from "./scenarios/05-drift.ts";
|
||
import { runParity } from "./scenarios/06-parity.ts";
|
||
import { reportAndGate, type GateMode, type GateResult, type Metric } from "./perf-gate.ts";
|
||
import { launchBrowser } from "./runner.ts";
|
||
import { startServer } from "./server.ts";
|
||
|
||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||
const RESULTS_DIR = resolve(HERE, "results");
|
||
const RESULTS_FILE = resolve(RESULTS_DIR, "metrics.json");
|
||
|
||
type ScenarioId = "load" | "fps" | "scrub" | "drift" | "parity";
|
||
|
||
/**
|
||
* Per-scenario default `runs` value when the caller didn't pass `--runs`.
|
||
*
|
||
* Why `load` gets 5 runs and the others get 3:
|
||
*
|
||
* - `load` reports a single p95 over `runs` measurements, so each `run` is
|
||
* one sample. p95 over n=3 is mostly noise (the 95th percentile of three
|
||
* numbers is just `max`), so we bump it to 5. We considered 10 — but cold
|
||
* load is the slowest scenario in the shard (~2s × 5 runs × 2 fixtures =
|
||
* ~20s with disk cache cleared), and going to 10 would push the load shard
|
||
* past 30s of pure-measurement wall time per CI invocation.
|
||
* - `fps` aggregates as `min(ratio)` over runs — 3 runs gives us a worst-
|
||
* of-three signal, which is what we want for a floor metric. Adding more
|
||
* runs would only make the ratio strictly smaller (more chances to catch
|
||
* a stall) and shift the threshold toward false positives from runner
|
||
* contention rather than real regressions.
|
||
* - `scrub` and `drift` *pool* their per-run samples (10 seeks/run for
|
||
* scrub, ~1500 RVFC frames/run for drift) and compute the percentile over
|
||
* the pooled set. Their effective sample count for the percentile is
|
||
* `runs × samples_per_run`, not `runs`, so 3 runs already gives 30+ scrub
|
||
* samples and 4500+ drift samples per shard — well above the n≈30 rule of
|
||
* thumb for a stable p95.
|
||
*
|
||
* TODO(player-perf): revisit `fps: 3` once we have ~2 weeks of CI baseline
|
||
* data — if `min(ratio)` shows >5% inter-run variance attributable to runner
|
||
* jitter (not real player regressions), bump to 5 and tighten the
|
||
* `compositionTimeAdvancementRatioMin` baseline accordingly.
|
||
*/
|
||
const DEFAULT_RUNS: Record<ScenarioId, number> = {
|
||
load: 5,
|
||
fps: 3,
|
||
scrub: 3,
|
||
drift: 3,
|
||
parity: 3,
|
||
};
|
||
|
||
type ResultsFile = {
|
||
schemaVersion: 1;
|
||
timestamp: string;
|
||
gitSha: string | null;
|
||
mode: GateMode;
|
||
scenarios: ScenarioId[];
|
||
runs: number | null;
|
||
fixture: string | null;
|
||
crashed: boolean;
|
||
passed: boolean;
|
||
metrics: Metric[];
|
||
gate: GateResult[];
|
||
};
|
||
|
||
function readGitSha(): string | null {
|
||
try {
|
||
return execFileSync("git", ["rev-parse", "HEAD"], { encoding: "utf-8" }).trim();
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function writeResults(file: ResultsFile): void {
|
||
if (!existsSync(RESULTS_DIR)) {
|
||
mkdirSync(RESULTS_DIR, { recursive: true });
|
||
}
|
||
writeFileSync(RESULTS_FILE, JSON.stringify(file, null, 2) + "\n");
|
||
console.log(`[player-perf] wrote results to ${RESULTS_FILE}`);
|
||
}
|
||
|
||
type ParsedArgs = {
|
||
mode: GateMode;
|
||
scenarios: ScenarioId[];
|
||
runs: number | null;
|
||
fixture: string | null;
|
||
headful: boolean;
|
||
};
|
||
|
||
function parseArgs(argv: string[]): ParsedArgs {
|
||
const result: ParsedArgs = {
|
||
// TODO(player-perf): once baselines have settled on CI for ~1–2 weeks and we
|
||
// are confident there are no false positives from runner jitter, flip this
|
||
// default from "measure" to "enforce" — that single line + bumping the
|
||
// workflow's `--mode=measure` flag in .github/workflows/player-perf.yml is
|
||
// the entire opt-in. See packages/player/tests/perf/perf-gate.ts for how
|
||
// `mode` is consumed (measure logs regressions but never fails; enforce
|
||
// exits non-zero on regression).
|
||
mode: (process.env.PLAYER_PERF_MODE as GateMode) === "enforce" ? "enforce" : "measure",
|
||
scenarios: ["load", "fps", "scrub", "drift", "parity"],
|
||
runs: null,
|
||
fixture: null,
|
||
headful: false,
|
||
};
|
||
// Normalize `--key=value` into `[--key, value]` so the rest of the loop
|
||
// only has to handle the space-separated form.
|
||
const tokens: string[] = [];
|
||
for (const raw of argv.slice(2)) {
|
||
if (raw.startsWith("--") && raw.includes("=")) {
|
||
const eq = raw.indexOf("=");
|
||
tokens.push(raw.slice(0, eq), raw.slice(eq + 1));
|
||
} else {
|
||
tokens.push(raw);
|
||
}
|
||
}
|
||
for (let i = 0; i < tokens.length; i++) {
|
||
const arg = tokens[i];
|
||
const next = tokens[i + 1];
|
||
if (arg === "--mode" && next) {
|
||
if (next !== "measure" && next !== "enforce") {
|
||
throw new Error(`--mode must be measure|enforce, got ${next}`);
|
||
}
|
||
result.mode = next;
|
||
i++;
|
||
} else if (arg === "--scenarios" && next) {
|
||
result.scenarios = next.split(",").map((s) => s.trim()) as ScenarioId[];
|
||
i++;
|
||
} else if (arg === "--runs" && next) {
|
||
result.runs = parseInt(next, 10);
|
||
i++;
|
||
} else if (arg === "--fixture" && next) {
|
||
result.fixture = next;
|
||
i++;
|
||
} else if (arg === "--headful") {
|
||
result.headful = true;
|
||
}
|
||
}
|
||
return result;
|
||
}
|
||
|
||
async function main(): Promise<void> {
|
||
const args = parseArgs(process.argv);
|
||
console.log(
|
||
`[player-perf] starting: mode=${args.mode} scenarios=${args.scenarios.join(",")} runs=${args.runs ?? "default"} fixture=${args.fixture ?? "all"}`,
|
||
);
|
||
|
||
const server = startServer();
|
||
console.log(`[player-perf] server listening at ${server.origin}`);
|
||
|
||
const browser = await launchBrowser({ headless: !args.headful });
|
||
console.log("[player-perf] browser launched");
|
||
|
||
const metrics: Metric[] = [];
|
||
let crashed = false;
|
||
|
||
try {
|
||
for (const scenario of args.scenarios) {
|
||
if (scenario === "load") {
|
||
const m = await runLoad({
|
||
browser,
|
||
origin: server.origin,
|
||
runs: args.runs ?? DEFAULT_RUNS.load,
|
||
fixture: args.fixture,
|
||
});
|
||
metrics.push(...m);
|
||
} else if (scenario === "fps") {
|
||
const m = await runFps({
|
||
browser,
|
||
origin: server.origin,
|
||
runs: args.runs ?? DEFAULT_RUNS.fps,
|
||
fixture: args.fixture,
|
||
});
|
||
metrics.push(...m);
|
||
} else if (scenario === "scrub") {
|
||
const m = await runScrub({
|
||
browser,
|
||
origin: server.origin,
|
||
runs: args.runs ?? DEFAULT_RUNS.scrub,
|
||
fixture: args.fixture,
|
||
});
|
||
metrics.push(...m);
|
||
} else if (scenario === "drift") {
|
||
const m = await runDrift({
|
||
browser,
|
||
origin: server.origin,
|
||
runs: args.runs ?? DEFAULT_RUNS.drift,
|
||
fixture: args.fixture,
|
||
});
|
||
metrics.push(...m);
|
||
} else if (scenario === "parity") {
|
||
const m = await runParity({
|
||
browser,
|
||
origin: server.origin,
|
||
runs: args.runs ?? DEFAULT_RUNS.parity,
|
||
fixture: args.fixture,
|
||
});
|
||
metrics.push(...m);
|
||
} else {
|
||
console.warn(`[player-perf] unknown scenario: ${scenario}`);
|
||
}
|
||
}
|
||
} catch (err) {
|
||
crashed = true;
|
||
console.error("[player-perf] scenario crashed:", err);
|
||
} finally {
|
||
await browser.close();
|
||
await server.stop();
|
||
}
|
||
|
||
let report: { passed: boolean; rows: GateResult[] } = { passed: !crashed, rows: [] };
|
||
if (!crashed && metrics.length > 0) {
|
||
report = reportAndGate(metrics, args.mode);
|
||
}
|
||
|
||
writeResults({
|
||
schemaVersion: 1,
|
||
timestamp: new Date().toISOString(),
|
||
gitSha: readGitSha(),
|
||
mode: args.mode,
|
||
scenarios: args.scenarios,
|
||
runs: args.runs,
|
||
fixture: args.fixture,
|
||
crashed,
|
||
passed: report.passed && !crashed,
|
||
metrics,
|
||
gate: report.rows,
|
||
});
|
||
|
||
if (crashed) {
|
||
process.exit(1);
|
||
}
|
||
if (!report.passed) {
|
||
process.exit(2);
|
||
}
|
||
process.exit(0);
|
||
}
|
||
|
||
main().catch((err) => {
|
||
console.error("[player-perf] fatal:", err);
|
||
process.exit(1);
|
||
});
|