mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
## Summary Second slice of `P0-1` from the player perf proposal: plugs the three steady-state scenarios — sustained playback FPS, scrub latency, and media-sync drift — into the perf gate that landed in #399. Adds the multi-video fixture they all share, wires three new shards into CI, and seeds one new baseline (`droppedFramesMax`). ## Why #399 stood up the harness and proved it with a single load-time scenario. By itself that's enough to catch regressions in initial composition setup, but it can't catch the things players actually fail at in production: - **FPS regressions** — a render-loop change that drops the ticker from 60 to 45 fps still loads fast. - **Scrub latency regressions** — the inline-vs-isolated split (#397) is exactly the kind of code path where a refactor can silently push everyone back to the postMessage round trip. - **Media drift** — runtime mirror logic (#396 in this stack) and per-frame scheduling tweaks can both cause video to slip out of sync with the composition clock without producing a single console error. Each of these is a target metric in the proposal with a concrete budget. This PR turns those budgets into gated CI signals and produces continuous data for them on every player/core/runtime change. ## What changed ### Fixture — `packages/player/tests/perf/fixtures/10-video-grid/` - `index.html`: 10-second composition, 1920×1080, 30 fps, with 10 simultaneously-decoding video tiles in a 5×2 grid plus a subtle GSAP scale "breath" on each tile (so the rAF/RVFC loops have real work to do without GSAP dominating the budget the decoder needs). - `sample.mp4`: small (~190 KB) clip checked in so the fixture is hermetic — no external CDN dependency, identical bytes on every run. - Same `data-composition-id="main"` host pattern as `gsap-heavy`, so the existing harness loader works without changes. ### `02-fps.ts` — sustained playback frame rate - Loads `10-video-grid`, calls `player.play()`, samples `requestAnimationFrame` callbacks inside the iframe for 5 s. - Crucial sequencing: install the rAF sampler **before** `play()`, wait for `__player.isPlaying() === true`, **then reset the sample buffer** — otherwise the postMessage round-trip ramp-up window drags the average down by 5–10 fps. - FPS = `(samples − 1) / (lastTs − firstTs in s)`; uses rAF timestamps (the same ones the compositor saw) rather than wall-clock `setTimeout`, so we're measuring real frame production. - Dropped-frame definition matches Chrome DevTools: gap > 1.5× (1000/60 ms) ≈ 25 ms = "missed at least one vsync." - Aggregation across runs: `min(fps)` and `max(droppedFrames)` — worst case wins, since the proposal asserts a floor on fps and a ceiling on drops. - Emits `playback_fps_min` (higher-is-better, baseline `fpsMin = 55`) and `playback_dropped_frames_max` (lower-is-better, baseline `droppedFramesMax = 3`). ### `04-scrub.ts` — scrub latency, inline + isolated - Loads `10-video-grid`, pauses, then issues 10 seek calls in two batches: first the synchronous **inline** path (`<hyperframes-player>`'s default same-origin `_trySyncSeek`), then the **isolated** path (forced by replacing `_trySyncSeek` with `() => false`, which makes the player fall back to the postMessage `_sendControl("seek")` bridge that cross-origin embeds and pre-#397 builds use). - Inline runs first so the isolated mode's monkey-patch can't bleed back into the inline samples. - Detection: a rAF watcher inside the iframe polls `__player.getTime()` until it's within `MATCH_TOLERANCE_S = 0.05 s` of the requested target. Tolerance exists because the postMessage bridge converts seconds → frame number → seconds, and that round-trip can introduce sub-frame quantization drift even for targets on the canonical fps grid. - Timing: `performance.timeOrigin + performance.now()` in both contexts. `timeOrigin` is consistent across same-process frames, so `t1 − t0` is a true wall-clock latency, not a host-only or iframe-only stopwatch. - Targets alternate forward/backward (`1.0, 7.0, 2.0, 8.0, 3.0, 9.0, 4.0, 6.0, 5.0, 0.5`) so no two consecutive seeks land near each other — protects the rAF watcher from matching against a stale `getTime()` value before the seek command is processed. - Aggregation: `percentile(95)` across the pooled per-seek latencies from every run. With 10 seeks × 2 modes × 3 runs we get 30 samples per mode per CI shard, enough for a stable p95. - Emits `scrub_latency_p95_inline_ms` (lower-is-better, baseline `scrubLatencyP95InlineMs = 33`) and `scrub_latency_p95_isolated_ms` (lower-is-better, baseline `scrubLatencyP95IsolatedMs = 80`). ### `05-drift.ts` — media sync drift - Loads `10-video-grid`, plays 6 s, instruments **every** `video[data-start]` element with `requestVideoFrameCallback`. Each callback records `(compositionTime, actualMediaTime)` plus a snapshot of the clip transform (`clipStart`, `clipMediaStart`, `clipPlaybackRate`). - Drift = `|actualMediaTime − ((compTime − clipStart) × clipPlaybackRate + clipMediaStart)|` — the same transform the runtime applies in `packages/core/src/runtime/media.ts`, snapshotted once at sampler install so the per-frame work is just subtract + multiply + abs. - Sustain window is 6 s (not the proposal's 10 s) because the fixture composition is exactly 10 s long and we want headroom before the end-of-timeline pause/clamp behavior. With 10 videos × ~25 fps × 6 s we still pool ~1500 samples per run — more than enough for a stable p95. - Same "reset buffer after play confirmed" gotcha as `02-fps.ts`: frames captured during the postMessage round-trip would compare a non-zero `mediaTime` against `getTime() === 0` and inflate drift by hundreds of ms. - Aggregation: `max()` and `percentile(95)` across the pooled per-frame drifts. The proposal's max-drift ceiling of 500 ms is intentional — the runtime hard-resyncs when `|currentTime − relTime| > 0.5 s`, so a regression past 500 ms means the corrective resync kicked in and the viewer saw a jump. - Emits `media_drift_max_ms` (lower-is-better, baseline `driftMaxMs = 500`) and `media_drift_p95_ms` (lower-is-better, baseline `driftP95Ms = 100`). ### Wiring - `packages/player/tests/perf/index.ts`: add `fps`, `scrub`, `drift` to `ScenarioId`, `DEFAULT_RUNS`, the default scenario list (`--scenarios` defaults to all four), and three new dispatch branches. - `packages/player/tests/perf/perf-gate.ts`: add `droppedFramesMax: number` to `PerfBaseline`. Other baseline keys for these scenarios were already seeded in #399. - `packages/player/tests/perf/baseline.json`: add `droppedFramesMax: 3`. - `.github/workflows/player-perf.yml`: three new matrix shards (`fps` / `scrub` / `drift`) at `runs: 3`. Same `paths-filter` and same artifact-upload pattern as the `load` shard, so the summary job aggregates them automatically. ## Methodology highlights These three patterns recur in all three scenarios and are worth noting because they're load-bearing for the numbers we report: 1. **Reset buffer after play-confirmed.** The `play()` API is async (postMessage), so any samples captured before `__player.isPlaying() === true` belong to ramp-up, not steady-state. Both `02-fps` and `05-drift` clear `__perfRafSamples` / `__perfDriftSamples` *after* the wait. Without this, fps drops 5–10 and drift inflates by hundreds of ms. 2. **Iframe-side timing.** All three scenarios time inside the iframe (`performance.timeOrigin + performance.now()` for scrub, rAF/RVFC timestamps for fps/drift) rather than host-side. The iframe is what the user sees; host-side timing would conflate Puppeteer's IPC overhead with real player latency. 3. **Stop sampling before pause.** Sampler is deactivated *before* `pause()` is issued, so the pause command's postMessage round-trip can't perturb the tail of the measurement window. ## Test plan - [x] Local: `bun run player:perf` runs all four scenarios end-to-end on the 10-video-grid fixture. - [x] Each scenario produces metrics matching its declared `baselineKey` so `perf-gate.ts` can find them. - [x] Typecheck, lint, format pass on the new files. - [x] Existing player unit tests untouched (no production code changes in this PR). - [ ] First CI run will confirm the new shards complete inside the workflow timeout and that the summary job picks up their `metrics.json` artifacts. ## Stack Step `P0-1b` of the player perf proposal. Builds on: - `P0-1a` (#399): the harness, runner, gate, and CI workflow this PR plugs new scenarios into. Followed by: - `P0-1c` (#401): `06-parity` — live playback frame vs. synchronously-seeked reference frame, compared via SSIM, on the existing `gsap-heavy` fixture from #399.
308 lines
12 KiB
TypeScript
308 lines
12 KiB
TypeScript
/**
|
||
* Scenario 05: media sync drift.
|
||
*
|
||
* Loads the 10-video-grid fixture, starts playback, and uses
|
||
* `requestVideoFrameCallback` on every video element to record
|
||
* (compositionTime, actualMediaTime) pairs for each decoded frame. Drift is
|
||
* the absolute difference between the *expected* media time (derived from the
|
||
* composition time using the runtime's clip transform) and the actual media
|
||
* time the decoder presented to the compositor.
|
||
*
|
||
* Per the proposal:
|
||
* Test 4: Media sync drift (player-perf-drift)
|
||
* Load 5-video composition → play for 10 seconds → on each RVFC callback,
|
||
* record drift between expected and actual media time
|
||
* Assert: max drift < 500ms, p95 drift < 100ms
|
||
*
|
||
* Methodology details:
|
||
* - We instrument *every* `video[data-start]` element in the fixture. The
|
||
* proposal called for 5 videos; the 10-video-grid gives us 10 streams in
|
||
* the same composition, which is a more conservative regression signal.
|
||
* - The expected media time uses the same transform the runtime applies in
|
||
* packages/core/src/runtime/media.ts:
|
||
*
|
||
* expectedMediaTime = (compositionTime - clip.start) * clip.playbackRate
|
||
* + clip.mediaStart
|
||
*
|
||
* We snapshot `clip.start` / `clip.mediaStart` / `clip.playbackRate` from
|
||
* each element's dataset + `defaultPlaybackRate` once when the sampler is
|
||
* installed, so the per-frame work is just a subtract + multiply + abs.
|
||
* - The runtime's media sync runs on a 50ms `setInterval`. Between syncs the
|
||
* video element's clock free-runs. The drift we measure here is the
|
||
* residual after that 50ms loop catches up — i.e. the user-visible glitch
|
||
* budget. The runtime hard-resyncs when |currentTime - relTime| > 0.5s
|
||
* (see media.ts), which is exactly the proposal's max-drift ceiling: a
|
||
* regression past 500ms means the corrective resync kicked in and the
|
||
* viewer saw a jump.
|
||
* - We install RVFC *before* calling play(), then reset the sample buffer
|
||
* once `__player.isPlaying()` flips true. Frames captured during the
|
||
* postMessage round-trip would compare a non-zero mediaTime against
|
||
* `getTime() === 0` and inflate drift to several hundred ms — same gotcha
|
||
* as 02-fps.ts.
|
||
* - Sustain window is 6s instead of the proposal's 10s because the fixture
|
||
* composition is exactly 10s long, and we want headroom before the
|
||
* end-of-timeline pause/clamp behavior. With 10 videos × ~25fps × 6s we
|
||
* still pool ~1500 samples per run, more than enough for a stable p95.
|
||
*
|
||
* Outputs two metrics:
|
||
* - media_drift_max_ms (lower-is-better, baseline driftMaxMs)
|
||
* - media_drift_p95_ms (lower-is-better, baseline driftP95Ms)
|
||
*
|
||
* Aggregation: max() and percentile(95) across the pooled per-frame drifts
|
||
* from every video in every run.
|
||
*/
|
||
|
||
import type { Browser, Frame, Page } from "puppeteer-core";
|
||
import { loadHostPage, percentile } from "../runner.ts";
|
||
import type { Metric } from "../perf-gate.ts";
|
||
|
||
export type DriftScenarioOpts = {
|
||
browser: Browser;
|
||
origin: string;
|
||
/** Number of measurement runs. */
|
||
runs: number;
|
||
/** If null, runs the default fixture (10-video-grid). */
|
||
fixture: string | null;
|
||
};
|
||
|
||
const DEFAULT_FIXTURE = "10-video-grid";
|
||
const PLAYBACK_DURATION_MS = 6_000;
|
||
const PLAY_CONFIRM_TIMEOUT_MS = 5_000;
|
||
const FRAME_LOOKUP_TIMEOUT_MS = 5_000;
|
||
|
||
type DriftSample = {
|
||
compTime: number;
|
||
actualMediaTime: number;
|
||
clipStart: number;
|
||
clipMediaStart: number;
|
||
clipPlaybackRate: number;
|
||
};
|
||
|
||
declare global {
|
||
interface Window {
|
||
/** RVFC samples collected by the iframe-side observer. */
|
||
__perfDriftSamples?: DriftSample[];
|
||
/** Set to false to stop sampling at the end of the measurement window. */
|
||
__perfDriftActive?: boolean;
|
||
__player?: {
|
||
play: () => void;
|
||
pause: () => void;
|
||
seek: (timeSeconds: number) => void;
|
||
getTime: () => number;
|
||
getDuration: () => number;
|
||
isPlaying: () => boolean;
|
||
};
|
||
}
|
||
}
|
||
|
||
type RunResult = {
|
||
drifts: number[];
|
||
videoCount: number;
|
||
};
|
||
|
||
/**
|
||
* Find the iframe Puppeteer Frame that hosts the fixture composition. Same
|
||
* helper as the other scenarios; duplicated locally so each scenario file is
|
||
* self-contained.
|
||
*/
|
||
async function getFixtureFrame(page: Page, fixture: string): Promise<Frame> {
|
||
const expected = `/fixtures/${fixture}/`;
|
||
const deadline = Date.now() + FRAME_LOOKUP_TIMEOUT_MS;
|
||
while (Date.now() < deadline) {
|
||
const frame = page.frames().find((f) => f.url().includes(expected));
|
||
if (frame) return frame;
|
||
await new Promise((r) => setTimeout(r, 50));
|
||
}
|
||
throw new Error(`[scenario:drift] fixture frame not found for "${fixture}" within timeout`);
|
||
}
|
||
|
||
async function runOnce(
|
||
opts: DriftScenarioOpts,
|
||
fixture: string,
|
||
idx: number,
|
||
total: number,
|
||
): Promise<RunResult> {
|
||
const ctx = await opts.browser.createBrowserContext();
|
||
try {
|
||
const page = await ctx.newPage();
|
||
const { duration } = await loadHostPage(page, opts.origin, { fixture });
|
||
const requiredDurationSec = PLAYBACK_DURATION_MS / 1000;
|
||
if (duration < requiredDurationSec) {
|
||
throw new Error(
|
||
`[scenario:drift] fixture composition is ${duration.toFixed(2)}s but drift sample window needs >= ${requiredDurationSec.toFixed(0)}s`,
|
||
);
|
||
}
|
||
const frame = await getFixtureFrame(page, fixture);
|
||
|
||
// Install RVFC on every `video[data-start]` element in the iframe. Each
|
||
// callback records the wall-clock-aligned (compositionTime, mediaTime)
|
||
// pair plus a snapshot of the clip transform so we can compute drift in
|
||
// node without re-querying the dataset on every frame.
|
||
const videoCount = (await frame.evaluate(() => {
|
||
window.__perfDriftSamples = [];
|
||
window.__perfDriftActive = true;
|
||
const videos = Array.from(document.querySelectorAll<HTMLVideoElement>("video[data-start]"));
|
||
type RvfcMetadata = { mediaTime: number; presentationTime: number };
|
||
type RvfcVideo = HTMLVideoElement & {
|
||
requestVideoFrameCallback?: (
|
||
cb: (now: DOMHighResTimeStamp, metadata: RvfcMetadata) => void,
|
||
) => number;
|
||
};
|
||
let installed = 0;
|
||
for (const video of videos) {
|
||
const rvfcVideo = video as RvfcVideo;
|
||
const rvfc = rvfcVideo.requestVideoFrameCallback;
|
||
// Headless Chrome supports RVFC; bail quietly on browsers that don't.
|
||
if (!rvfc) continue;
|
||
const clipStart = Number.parseFloat(video.dataset.start ?? "0") || 0;
|
||
const clipMediaStart =
|
||
Number.parseFloat(video.dataset.playbackStart ?? video.dataset.mediaStart ?? "0") || 0;
|
||
const rawRate = video.defaultPlaybackRate;
|
||
const clipPlaybackRate =
|
||
Number.isFinite(rawRate) && rawRate > 0 ? Math.max(0.1, Math.min(5, rawRate)) : 1;
|
||
const tick = (_now: DOMHighResTimeStamp, metadata: RvfcMetadata) => {
|
||
if (!window.__perfDriftActive) return;
|
||
const compTime = window.__player?.getTime?.() ?? Number.NaN;
|
||
if (Number.isFinite(compTime)) {
|
||
window.__perfDriftSamples!.push({
|
||
compTime,
|
||
actualMediaTime: metadata.mediaTime,
|
||
clipStart,
|
||
clipMediaStart,
|
||
clipPlaybackRate,
|
||
});
|
||
}
|
||
rvfc.call(video, tick);
|
||
};
|
||
rvfc.call(video, tick);
|
||
installed++;
|
||
}
|
||
return installed;
|
||
})) as number;
|
||
|
||
if (videoCount === 0) {
|
||
throw new Error(`[scenario:drift] fixture ${fixture} contains no video[data-start] elements`);
|
||
}
|
||
|
||
// Issue play from the host page; the player posts a control message into
|
||
// the iframe and the runtime starts the 50ms media sync poll.
|
||
await page.evaluate(() => {
|
||
const el = document.getElementById("player") as (HTMLElement & { play: () => void }) | null;
|
||
if (!el) throw new Error("[scenario:drift] player element missing on host page");
|
||
el.play();
|
||
});
|
||
|
||
// Wait for the runtime to confirm playing before we trust the samples.
|
||
await frame.waitForFunction(() => window.__player?.isPlaying?.() === true, {
|
||
timeout: PLAY_CONFIRM_TIMEOUT_MS,
|
||
});
|
||
|
||
// Reset the buffer now that playback is live. Anything captured during
|
||
// the postMessage round-trip would compare a non-zero mediaTime against
|
||
// `getTime() === 0` and bias drift up by hundreds of ms.
|
||
await frame.evaluate(() => {
|
||
window.__perfDriftSamples = [];
|
||
});
|
||
|
||
await new Promise((r) => setTimeout(r, PLAYBACK_DURATION_MS));
|
||
|
||
// Stop sampling first, then pause. Same ordering as 02-fps.ts so the
|
||
// pause command can't perturb the tail of the measurement window.
|
||
const samples = (await frame.evaluate(() => {
|
||
window.__perfDriftActive = false;
|
||
return window.__perfDriftSamples ?? [];
|
||
})) as DriftSample[];
|
||
|
||
await page.evaluate(() => {
|
||
const el = document.getElementById("player") as (HTMLElement & { pause: () => void }) | null;
|
||
el?.pause();
|
||
});
|
||
|
||
if (samples.length === 0) {
|
||
throw new Error(
|
||
`[scenario:drift] run ${idx + 1}/${total}: zero RVFC samples captured (videos=${videoCount}, duration=${duration.toFixed(2)}s)`,
|
||
);
|
||
}
|
||
|
||
// Apply the runtime's transform to derive the expected media time, then
|
||
// compare against the actual media time the decoder presented. Convert
|
||
// to ms here so the gate threshold (driftMaxMs / driftP95Ms) compares
|
||
// apples-to-apples.
|
||
const drifts: number[] = [];
|
||
for (const s of samples) {
|
||
const expectedMediaTime = (s.compTime - s.clipStart) * s.clipPlaybackRate + s.clipMediaStart;
|
||
const driftMs = Math.abs(s.actualMediaTime - expectedMediaTime) * 1000;
|
||
drifts.push(driftMs);
|
||
}
|
||
|
||
const max = Math.max(...drifts);
|
||
const p95 = percentile(drifts, 95);
|
||
console.log(
|
||
`[scenario:drift] run[${idx + 1}/${total}] max=${max.toFixed(2)}ms p95=${p95.toFixed(2)}ms videos=${videoCount} samples=${samples.length}`,
|
||
);
|
||
|
||
await page.close();
|
||
return { drifts, videoCount };
|
||
} finally {
|
||
await ctx.close();
|
||
}
|
||
}
|
||
|
||
export async function runDrift(opts: DriftScenarioOpts): Promise<Metric[]> {
|
||
const fixture = opts.fixture ?? DEFAULT_FIXTURE;
|
||
const runs = Math.max(1, opts.runs);
|
||
console.log(`[scenario:drift] fixture=${fixture} runs=${runs} window=${PLAYBACK_DURATION_MS}ms`);
|
||
|
||
const allDrifts: number[] = [];
|
||
let lastVideoCount = 0;
|
||
for (let i = 0; i < runs; i++) {
|
||
const result = await runOnce(opts, fixture, i, runs);
|
||
allDrifts.push(...result.drifts);
|
||
lastVideoCount = result.videoCount;
|
||
}
|
||
|
||
// Worst case wins for max; p95 is computed across the pooled per-frame
|
||
// drifts from every video in every run. The proposal asserts max < 500ms
|
||
// and p95 < 100ms, so a single bad sample legitimately gates the build.
|
||
const maxDrift = Math.max(...allDrifts);
|
||
const p95Drift = percentile(allDrifts, 95);
|
||
// Coefficient of variation (stddev / mean) is logged here as a soft signal
|
||
// we can eyeball in CI output. We deliberately do NOT gate on it — the
|
||
// baseline asserts absolute thresholds (max, p95), and the underlying
|
||
// distribution is heavy-tailed (most frames are sub-50ms, occasional ones
|
||
// spike during the 50ms media-sync interval). But CV is a useful early
|
||
// warning: if it climbs significantly across CI runs while max + p95 stay
|
||
// green, our jitter assumptions about the runtime's resync loop have
|
||
// shifted (e.g. if media.ts changes its 50ms `setInterval` cadence) and
|
||
// we should revisit the baselines before they start producing flakes.
|
||
// TODO(player-perf): once we have ~2 weeks of CI baseline data, decide
|
||
// whether to publish CV as a tracked-but-ungated metric in baseline.json
|
||
// alongside max + p95, or wire it into the Slack regression report.
|
||
const meanDrift = allDrifts.reduce((a, b) => a + b, 0) / allDrifts.length;
|
||
const variance = allDrifts.reduce((acc, d) => acc + (d - meanDrift) ** 2, 0) / allDrifts.length;
|
||
const stddev = Math.sqrt(variance);
|
||
const cv = meanDrift > 0 ? stddev / meanDrift : 0;
|
||
console.log(
|
||
`[scenario:drift] aggregate max=${maxDrift.toFixed(2)}ms p95=${p95Drift.toFixed(2)}ms mean=${meanDrift.toFixed(2)}ms cv=${cv.toFixed(3)} videos=${lastVideoCount} samples=${allDrifts.length} runs=${runs}`,
|
||
);
|
||
|
||
return [
|
||
{
|
||
name: "media_drift_max_ms",
|
||
baselineKey: "driftMaxMs",
|
||
value: maxDrift,
|
||
unit: "ms",
|
||
direction: "lower-is-better",
|
||
samples: allDrifts,
|
||
},
|
||
{
|
||
name: "media_drift_p95_ms",
|
||
baselineKey: "driftP95Ms",
|
||
value: p95Drift,
|
||
unit: "ms",
|
||
direction: "lower-is-better",
|
||
samples: allDrifts,
|
||
},
|
||
];
|
||
}
|