Files
hyperframes/packages/player/tests/perf/perf-gate.ts
Vance Ingalls 80e7cd2844 perf(player): p0-1c live-playback parity test via SSIM (#401)
## 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`.
2026-04-22 18:15:46 -07:00

117 lines
4.5 KiB
TypeScript

import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
/**
* Compares measured perf metrics against baseline.json with an allowed regression ratio.
*
* Mirrors packages/producer/src/perf-gate.ts: each metric has a baseline value, the
* gate computes `max = baseline * (1 + allowedRegressionRatio)`, and any measured
* value above max counts as a regression. In "measure" mode the script logs but
* never exits non-zero — useful for the first runs while we collect realistic
* baselines on the CI runner. Flip to "enforce" once baselines are committed.
*/
const HERE = dirname(fileURLToPath(import.meta.url));
const DEFAULT_BASELINE_PATH = resolve(HERE, "baseline.json");
export type Direction = "lower-is-better" | "higher-is-better";
export type Metric = {
/** Display name, e.g. "comp_load_cold_p95_ms" */
name: string;
/** Key into baseline.json, e.g. "compLoadColdP95Ms" */
baselineKey: keyof PerfBaseline;
value: number;
unit: string;
direction: Direction;
samples?: number[];
};
export type PerfBaseline = {
compLoadColdP95Ms: number;
compLoadWarmP95Ms: number;
/**
* Floor on `(compositionTime advanced) / (wallClock elapsed)` over a sustained
* playback window — see packages/player/tests/perf/scenarios/02-fps.ts. A
* healthy player keeps up with its intended speed and reads ~1.0; values
* below 1.0 mean the composition clock fell behind real time, which is the
* actual user-visible jank we want to gate against. Refresh-rate independent
* by construction, so it does not saturate to display refresh on high-Hz
* runners the way the previous `fpsMin` did. Direction: higher-is-better.
*/
compositionTimeAdvancementRatioMin: number;
scrubLatencyP95IsolatedMs: number;
scrubLatencyP95InlineMs: number;
driftMaxMs: number;
driftP95Ms: number;
paritySsimMin: number;
allowedRegressionRatio: number;
};
export type GateMode = "measure" | "enforce";
export type GateResult = {
metric: Metric;
baseline: number;
threshold: number;
passed: boolean;
ratio: number;
};
export function loadBaseline(path?: string): PerfBaseline {
const baselinePath = path ?? process.env.PLAYER_PERF_BASELINE_PATH ?? DEFAULT_BASELINE_PATH;
const raw = readFileSync(baselinePath, "utf-8");
return JSON.parse(raw) as PerfBaseline;
}
export function evaluateMetric(metric: Metric, baseline: PerfBaseline): GateResult {
const baselineValue = baseline[metric.baselineKey];
if (typeof baselineValue !== "number") {
throw new Error(`[player-perf] baseline missing numeric key: ${String(metric.baselineKey)}`);
}
const allowed = baseline.allowedRegressionRatio;
const threshold =
metric.direction === "lower-is-better"
? baselineValue * (1 + allowed)
: baselineValue * (1 - allowed);
const passed =
metric.direction === "lower-is-better" ? metric.value <= threshold : metric.value >= threshold;
const ratio = baselineValue === 0 ? 0 : metric.value / baselineValue;
return { metric, baseline: baselineValue, threshold, passed, ratio };
}
export type GateReport = {
passed: boolean;
rows: GateResult[];
};
export function reportAndGate(
metrics: Metric[],
// `mode` is resolved upstream in packages/player/tests/perf/index.ts
// (`parseArgs`): the default comes from PLAYER_PERF_MODE env or "measure", and
// the CLI flag `--mode=measure|enforce` overrides it. The "flip to enforce"
// TODO lives at that call site so it is a one-line change.
mode: GateMode,
baselinePath?: string,
): GateReport {
const baseline = loadBaseline(baselinePath);
const rows = metrics.map((m) => evaluateMetric(m, baseline));
console.log("[PerfGate] mode=" + mode);
for (const row of rows) {
const status = row.passed ? "PASS" : "FAIL";
const dir = row.metric.direction === "lower-is-better" ? "≤" : "≥";
console.log(
`[PerfGate] ${status} ${row.metric.name} = ${row.metric.value.toFixed(2)}${row.metric.unit} (baseline=${row.baseline}${row.metric.unit}, threshold ${dir} ${row.threshold.toFixed(2)}${row.metric.unit}, ratio=${row.ratio.toFixed(3)})`,
);
}
const failed = rows.filter((r) => !r.passed);
if (failed.length === 0) return { passed: true, rows };
if (mode === "measure") {
console.log(`[PerfGate] ${failed.length} regression(s) detected — measure mode, not failing`);
return { passed: true, rows };
}
console.error(`[PerfGate] ${failed.length} regression(s) detected — enforce mode, failing`);
return { passed: false, rows };
}