Skip to content

Commit 80e7cd2

Browse files
authored
perf(player): p0-1c live-playback parity test via SSIM (heygen-com#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 heygen-com#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. heygen-com#393 `perf/x-1-emit-performance-metric` — performance.measure() emission 2. heygen-com#394 `perf/p1-1-share-player-styles-via-adopted-stylesheets` — adopted stylesheets 3. heygen-com#395 `perf/p1-2-scope-media-mutation-observer` — scoped MutationObserver 4. heygen-com#396 `perf/p1-4-coalesce-mirror-parent-media-time` — coalesce currentTime writes 5. heygen-com#397 `perf/p3-1-sync-seek-same-origin` — synchronous seek path (the path this PR pins) 6. heygen-com#398 `perf/p3-2-srcdoc-composition-switching` — srcdoc switching 7. heygen-com#399 `perf/p0-1a-perf-test-infra` — server, runner, perf-gate, CI 8. heygen-com#400 `perf/p0-1b-perf-tests-for-fps-scrub-drift` — fps / scrub / drift scenarios 9. **heygen-com#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`.
1 parent 6f05fab commit 80e7cd2

5 files changed

Lines changed: 447 additions & 2 deletions

File tree

.github/workflows/player-perf.yml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,9 @@ jobs:
5151
- shard: drift
5252
scenarios: drift
5353
runs: "3"
54+
- shard: parity
55+
scenarios: parity
56+
runs: "3"
5457
steps:
5558
- uses: actions/checkout@v4
5659

@@ -72,6 +75,18 @@ jobs:
7275
with:
7376
chrome-version: stable
7477

78+
# The parity scenario shells out to `ffmpeg -lavfi ssim` to score the
79+
# live-playback frame against the sync-seek reference frame. ffmpeg is
80+
# not on the default ubuntu-latest runner image, and a missing binary
81+
# surfaces as ENOENT inside computeSsim() — informative, but cheaper
82+
# to just install it here so the shard never trips on infra.
83+
- name: Install ffmpeg (parity shard only)
84+
if: matrix.shard == 'parity'
85+
run: |
86+
sudo apt-get update
87+
sudo apt-get install -y --no-install-recommends ffmpeg
88+
ffmpeg -version | head -n 1
89+
7590
- name: Run player perf — ${{ matrix.shard }} (measure mode)
7691
working-directory: packages/player
7792
env:

packages/player/tests/perf/baseline.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,5 +6,6 @@
66
"scrubLatencyP95InlineMs": 33,
77
"driftMaxMs": 500,
88
"driftP95Ms": 100,
9+
"paritySsimMin": 0.93,
910
"allowedRegressionRatio": 0.1
1011
}

packages/player/tests/perf/index.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import { runFps } from "./scenarios/02-fps.ts";
3333
import { runLoad } from "./scenarios/03-load.ts";
3434
import { runScrub } from "./scenarios/04-scrub.ts";
3535
import { runDrift } from "./scenarios/05-drift.ts";
36+
import { runParity } from "./scenarios/06-parity.ts";
3637
import { reportAndGate, type GateMode, type GateResult, type Metric } from "./perf-gate.ts";
3738
import { launchBrowser } from "./runner.ts";
3839
import { startServer } from "./server.ts";
@@ -41,7 +42,7 @@ const HERE = dirname(fileURLToPath(import.meta.url));
4142
const RESULTS_DIR = resolve(HERE, "results");
4243
const RESULTS_FILE = resolve(RESULTS_DIR, "metrics.json");
4344

44-
type ScenarioId = "load" | "fps" | "scrub" | "drift";
45+
type ScenarioId = "load" | "fps" | "scrub" | "drift" | "parity";
4546

4647
/**
4748
* Per-scenario default `runs` value when the caller didn't pass `--runs`.
@@ -76,6 +77,7 @@ const DEFAULT_RUNS: Record<ScenarioId, number> = {
7677
fps: 3,
7778
scrub: 3,
7879
drift: 3,
80+
parity: 3,
7981
};
8082

8183
type ResultsFile = {
@@ -126,7 +128,7 @@ function parseArgs(argv: string[]): ParsedArgs {
126128
// `mode` is consumed (measure logs regressions but never fails; enforce
127129
// exits non-zero on regression).
128130
mode: (process.env.PLAYER_PERF_MODE as GateMode) === "enforce" ? "enforce" : "measure",
129-
scenarios: ["load", "fps", "scrub", "drift"],
131+
scenarios: ["load", "fps", "scrub", "drift", "parity"],
130132
runs: null,
131133
fixture: null,
132134
headful: false,
@@ -216,6 +218,14 @@ async function main(): Promise<void> {
216218
fixture: args.fixture,
217219
});
218220
metrics.push(...m);
221+
} else if (scenario === "parity") {
222+
const m = await runParity({
223+
browser,
224+
origin: server.origin,
225+
runs: args.runs ?? DEFAULT_RUNS.parity,
226+
fixture: args.fixture,
227+
});
228+
metrics.push(...m);
219229
} else {
220230
console.warn(`[player-perf] unknown scenario: ${scenario}`);
221231
}

packages/player/tests/perf/perf-gate.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ export type PerfBaseline = {
4545
scrubLatencyP95InlineMs: number;
4646
driftMaxMs: number;
4747
driftP95Ms: number;
48+
paritySsimMin: number;
4849
allowedRegressionRatio: number;
4950
};
5051

0 commit comments

Comments
 (0)