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`.
117 lines
4.5 KiB
TypeScript
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 };
|
|
}
|