mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-03 04:07:03 +00:00
sync/hyperframes-codegen-445592d5
7
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
46cb5ca446 |
fix(player): an embed without a height shows its film, and heavy films are measured in Safari (#5932)
* test(player): measure heavy films in real Safari and Chrome on macOS * test(player): count runtime media seeks and rate writes during the Safari bench * test(player): compare the Safari bench against native playback of the same media * test(player): split Safari control playback into per-frame media writes and per-frame transforms * perf(core): write media volume and mute only when they change * test(player): presented frame rate and compositor-animation controls in the Safari bench * perf(core): keep the media volume guard without a new comment line * test(player): derive presented frames from playback counters and snapshot video state * test(player): control page plays only listed media, and anchor the Safari process match * test(player): send the bench as code, and write only validated numbers from the browser * fix(player): perf host page gives the player a real box, so films are on screen when measured * fix(player): a player with no height set takes its film's shape instead of collapsing to nothing The player box had no intrinsic height, so an embed that set only a width (or nothing) was 0 px tall and showed no picture. An in-flow sizer now carries the composition's aspect ratio; a height the embed sets still wins, and a height-only embed keeps its full width. * test(player): label each embed on the player box page |
||
|
|
5562c280da |
fix(core): stop small previews dropping layers on heavy compositions (#5268)
* fix(player): render the preview at its shown size so heavy compositions stop dropping layers
* test(player): check a small preview rasters its will-change layers at the shown size
* fix(player): accept device-pixel snapping in the zoom probe
* fix(player): probe the initial frame and preserve editing coordinates
* fix(player): defer every initial source reload until setup
* fix(studio): convert measured frame coordinates once
* test(studio): bank reduced preview opening work
* fix(core): keep centered layers exact when the preview is zoomed
GSAP infers a -50% CSS translate from the rendered pixel matrix. At a
fractional preview zoom that matrix is snapped, so a centered layer lost
its percentage and shifted by half its height after reload, render and
redo. The runtime now restores the authored percentage before GSAP's
first transform parse. Also restores the original thumbnail ceilings.
* test(studio): bank the two resize cases that now pass edit accuracy
resize-scale-px-r30-{root,nested}-z50-after measured 0.53 px tracking on
main and 0.49 px here. Also drops a comment line the runtime name covers.
* fix(core): keep stylesheet-centered layers exact under preview zoom
The centering repair now also reads percentages from a stylesheet
transform (the common translate(-50%, -50%) pattern), runs for every
property that makes GSAP parse a transform (transformOrigin first, too),
resolves against the border box, and leaves elements without a layout
box alone instead of throwing. The browser check now runs zoomed.
* fix(core): restore only centering, the one percentage GSAP infers
GSAP folds every other CSS percentage translate into pixels, and
compositions rely on that (a translateX(-100%) title tweened to x: 0
slides in). The repair now gives back only a -50% that zoom snapping
lost, and treats a size that is not in px (a hidden element sized in
percent) as no box.
* fix(core): restore centering only where GSAP would infer it
A pixel part in the translate means GSAP never infers -50%, so the
repair leaves it alone, and a layer without a layout box is left to
GSAP whatever size its computed style reports (that differs by Chrome
build for hidden percent-sized layers).
* fix(player): fit the preview before the composition starts loading
The zoom was applied on the first resize callback, after the source was
assigned, so a fast-loading composition could run its scripts and lay
out unzoomed and then reflow. Fitting on connect, before any source,
makes the first layout use the preview zoom.
* test(player): pin the first fit before the composition source
* revert(player): drop the zoom fit so the preview keeps the export's layout
CSS zoom shaped text at the zoomed size, so paragraph heights in the
preview differed from the render by up to a line. The transform fit
stays; the centering adapter and zoom-only bench changes go with it.
* fix(core): raster a small preview at the size it is shown
Chromium never rasters a will-change: transform layer below device
scale, so a preview shown at a quarter size rastered every hinted layer
at full size and could run out of tile memory, dropping layers. While
the player shows the composition below 1x, the runtime swaps the hint
for an identity filter, which keeps its stacking context and
containing block; preserve-3d containers keep the hint.
* fix(core): keep a small preview's raster swap out of filters edits can save
The identity filter that replaced will-change: transform showed up in computed filter, so
Studio's blur slider saved it, GSAP filter tweens and clearProps picked it up, and it cut
backdrop-filter off from what is behind it. A far perspective keeps the same stacking context
and containing block, is never composited for flat content, and nothing reads or writes it.
Also: the runtime guard lint (realm-safe element checks), a class change that drops the hint
undoes the swap, and the browser check measures raster scale with the swap off and on at one
shown size and compares frosted glass and 3D pixels.
* fix(core): drop preview raster hints only where nothing depends on them
The perspective that stood in for will-change: transform could still reach a saved project:
Studio records perspective keyframes from GSAP's computed value, and copying a generated
element serialised its inline styles. It also went stale when a parent class or a later
stylesheet changed the element.
Now the runtime writes no inline style. It marks a hinted layer with a preview attribute
and one injected rule sets its will-change to auto. Only layers whose hint changes nothing
are marked: positioned, with no fixed descendants, and no z-indexed or blending descendants
unless the layer has its own z-index. Every class or tree change re-checks the whole page.
Studio strips the mark from copied markup, as it does the other preview marks.
* fix(core): keep raster hints that pseudo-elements, other hints or inline styles need
A layer's ::before or ::after can sit behind it, blend with it or be fixed to it, so those count
like children. A layer that also hints opacity or filter keeps every hint, since those make their
own boundaries. Inline style changes, which scripts make every frame, are watched cheaply: they
only give the hint back to the layers holding an element that now depends on it, or to a layer
that turned static or changed its z-index. Text changes no longer trigger a full pass; a new
stylesheet still does.
* fix(core): re-read a marked layer's own hints and follow any attribute or late sheet
A marked layer now has its mark lifted while its hints are read, so a class or inline value
that adds opacity or filter gives every hint back. Any attribute change re-checks its subtree,
so attribute selectors are followed, and a linked stylesheet re-runs the pass once it loads.
* perf(core): only lift a raster mark on the element that changed
Lifting a mark to read a layer's own hints repaints it, and a change on a scene container lifted
every marked layer under it (about 6 ms and a re-raster per event). Now only the changed element
is lifted, and a full pass clears all marks at once so the page restyles a single time. Tests
cover leaving marked layers alone, ignoring our own marks, and the linked-sheet load pass.
* fix(core): decide raster marks before writing them in a full pass
A full pass re-marked layers one at a time, so each read restyled the page again. Every mark is
now decided first and written after. The browser check holds off the player's own scale
messages while it turns the swap off and on, since a late resize tick could turn it back on.
* test(player): hold the player's own scale while the raster check turns the swap off
Turning the swap off restyles the page, and the player answers by resending its real scale,
which turned the swap back on within milliseconds, so the off half compared the swapped page
with itself. The check now holds the player's sends on the host page, asserts the swap is off
before the native shots, and removes the layers that keep their hint before either trace.
* test(player): give the raster check's 3D probe a solid fill
Stripes a few pixels wide alias differently at each raster scale, so the probe measured texture
resampling rather than the 3D shape it is there for. A solid fill keeps the shape and position.
* fix(studio): keep the preview raster mark out of stacking and saved text edits
Studio's stacking model read a marked layer's will-change as auto, so a timeline drag could
write a z-index that does nothing in the render; a marked element now still counts as a stacking
context. A text edit copied every live attribute but style, so it could save the mark; the
preview-only attributes are now one list, used by both the copy and the text-edit paths.
* fix(core): keep a marked video through a scene swap
The runtime keeps a video across a scene swap only when its live attributes match the fresh
import, and the preview raster mark made them differ, so a hinted video in a small preview was
rebuilt on every edit. The comparison now ignores every preview-only attribute, which also
fixes the same rebuild for data-hf-upcoming. The shared list is renamed PREVIEW_ONLY_ATTRS, and
the lazy-image strip in copied markup gets a test.
|
||
|
|
4bbcf650ae | fix(player): perf harness waits for assets-ready before play instead of a fixed 5s (#4137) | ||
|
|
859ac622c2 |
fix(player): report and fail closed on runtime delivery errors (#3472)
* feat(player): report runtime data application * fix(player): fail closed on runtime data delivery * fix(player): close runtime delivery and sandbox gaps * fix(player): satisfy runtime contract and CodeQL * refactor(player): simplify runtime tag scanner * style(player): apply repository formatter * fix(player): type runtime tag boundaries * fix(core): mint guest-local runtime-data ids in a separate space from host ids |
||
|
|
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`. |
||
|
|
6f05fabbf8 |
perf(player): p0-1b perf tests for fps, scrub latency, and media sync drift (#400)
## 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. |
||
|
|
10d2725b54 |
perf(player): p0-1a perf test infra + composition-load smoke test (#399)
## Summary First slice of `P0-1` from the player perf proposal: lays the foundation for a player perf gate so later PRs can plug in fps / scrub / drift / parity scenarios without rebuilding infrastructure. Ships one smoke scenario (`03-load`, cold + warm composition load) to prove the gate end-to-end on real numbers. ## Why There was no automated way to catch player perf regressions. Every perf concern in the existing proposal — composition load time, sustained FPS, scrub p95, mirror-clock drift, live-vs-seek parity — needs the same plumbing: a same-origin harness, a Puppeteer runner, a baseline file, a gate that emits structured results, and a CI workflow that runs the right scenarios on the right changes. Building that up-front in one reviewable PR lets every subsequent perf PR (`P0-1b`, `P0-1c`, and beyond) be a 100-line scenario file plus a baseline entry instead of re-litigating the framework. ## What changed ### Harness — `packages/player/tests/perf/server.ts` - `Bun.serve` on a free port, single same-origin host for the player IIFE bundle, hyperframe runtime, GSAP from `node_modules`, and fixture HTML. - Same-origin matters: cross-origin would force every probe through `postMessage`, hiding bugs and inflating numbers in ways production never sees. Tests should measure the path the studio editor actually takes. - Routes: - `/player.js` → built IIFE bundle (rebuilt on demand). - `/vendor/runtime.js`, `/vendor/gsap.min.js` → resolved from `node_modules` so fixtures don't need to ship copies. - `/fixtures/*` → fixture HTML. ### Runner — `packages/player/tests/perf/runner.ts` - `puppeteer-core` thin wrappers (`launchBrowser`, `loadHostPage`). - Uses the system Chrome detected by `setup-chrome` in CI rather than the bundled puppeteer revision — keeps the action smaller, lets us pin Chrome version policy at the workflow level, and matches what users actually run. ### Gate — `packages/player/tests/perf/perf-gate.ts` + `baseline.json` - Loads `baseline.json` (initial budgets: cold/warm comp load, fps, scrub p95 isolated/inline, drift max/p95) with a 10% `allowedRegressionRatio`. - Per-metric direction (`lower-is-better` / `higher-is-better`) so the same evaluator handles latency and throughput. - Returns a structured `GateReport` consumed by both the CLI (table output) and `metrics.json` (CI artifact). - Two modes: `measure` (log only — used during the rollout) and `enforce` (fail the build) — flip per-metric once we trust the signal, without touching the harness. ### CLI orchestrator — `packages/player/tests/perf/index.ts` - Parses `--mode` / `--scenarios` / `--runs` / `--fixture` in both space- and equals-separated form (so `--scenarios fps,scrub` and `--scenarios=fps,scrub` both work — matches what humans type and what GitHub Actions emits). - Runs scenarios, runs the gate, and **always** writes `results/metrics.json` with schema version, git SHA, metrics, and gate rows — so failed runs are still investigable from the artifact alone. ### Fixture + smoke scenario - `fixtures/gsap-heavy/index.html`: 200 stagger-animated tiles, no media. Heavy enough to make load time meaningful, light enough to be deterministic. - `scenarios/03-load.ts`: cold + warm composition load. Measures from navigation start to player `ready` event, reports p95 across runs. ### CI — `.github/workflows/player-perf.yml` - `paths-filter` on `player` / `core` / `runtime` — perf only runs when something that could move the needle actually changed. - Sets up bun + node + chrome, runs perf in `measure` mode on a shard matrix (so future scenarios shard naturally), uploads `metrics.json` artifacts, and a summary job aggregates shard results into a single PR comment. ### Wiring - `packages/player`: `puppeteer-core`, `gsap`, `@types/bun` devDeps; typecheck extended to cover the perf `tsconfig`; new `perf` script. - Root `package.json`: `player:perf` workspace script so `bun run player:perf` runs the whole suite locally with the same flags CI uses. - `.gitignore`: `packages/player/tests/perf/results/`. - Separate `tests/perf/tsconfig.json` so test code doesn't pollute the package `rootDir` while still being typechecked. ## Test plan - [x] Local: `bun run player:perf` passes — cold p95 ≈ 386 ms, warm p95 ≈ 375 ms, both well under the seeded baselines. - [x] Typecheck, lint, format pass on the perf workspace. - [x] Existing player unit tests (71/71) still green. - [ ] First CI run after merge will be the real signal: confirms `setup-chrome` works on hosted runners, the shard matrix wires up, and `metrics.json` artifacts upload. ## Stack Step `P0-1a` of the player perf proposal. The next two slices are content-only — they don't touch the harness: - `P0-1b` (#400): adds `02-fps`, `04-scrub`, `05-drift` scenarios on a 10-video-grid fixture. - `P0-1c` (#401): adds `06-parity` (live playback vs. synchronously-seeked reference, compared via SSIM). Wiring this gate up first means each follow-up is a self-contained scenario file + baseline row + workflow shard. |