perf(player): share PLAYER_STYLES via adoptedStyleSheets#394
Conversation
This stack of pull requests is managed by Graphite. Learn more about stacking. |
jrusso1020
left a comment
There was a problem hiding this comment.
Good perf win. Constructable stylesheet shared across every shadow root, memoized, with a proper <style> fallback when CSSStyleSheet is missing, replaceSync throws, or adoptedStyleSheets returns non-array. Test matrix covers each fallback path individually — exactly the right way to exercise this.
Idempotency test (applyPlayerStyles called three times → one adopted sheet, zero <style> elements) is what pinned my confidence here. The preservation of pre-existing adoptedStyleSheets is a nice touch for hosts that stack their own sheets on top.
Two non-blocking observations:
-
The test "falls back to a <style> element when adoptedStyleSheets is unsupported" uses
Object.definePropertyto make the setter throw, but the production code (if (sheet && Array.isArray(adopted))) only reads the getter. In the tested scenario the getter returnsundefined, soArray.isArray(undefined) === false, which takes the fallback path. That's fine — but an environment where the getter returns an array and the setter throws would skip the guard. Probably not real-world, just noting. -
sharedSheetmemoization key is process-scoped. In a multi-document test environment (some test runners reuse modules across multiple documents), a stale CSSStyleSheet from a prior document could be handed to a new one._resetSharedPlayerStyleSheetis the escape hatch — good that it's exported.
Approved.
— Rames Jusso
|
@jrusso1020 — thanks for the review. Two non-blocking observations to address:
You're right — the test exercises the case where the getter returns
Right call out. The escape hatch is intentionally exported for exactly this reason; the runtime path doesn't need a per-document keying because every shadow root in a single document shares the same Nothing else outstanding. |
Merge activity
|
c5deea7 to
7f09109
Compare
Replaces per-instance `<style>` injection in HyperframesPlayer with a lazily constructed CSSStyleSheet adopted into every shadow root via `shadowRoot.adoptedStyleSheets`. The studio thumbnail grid renders dozens of players concurrently — sharing one parsed stylesheet across N players (one rule tree, one parse, N adopters) cuts per-instance style work that the old <style>-per-shadow-root path imposed. Key behavior: - `getSharedPlayerStyleSheet()` is module-scoped and memoized; the sheet is built once per process and returned to every adopter. SSR- safe via a `typeof CSSStyleSheet` guard, with failures cached as `null` to avoid retrying constructor failures forever. - `applyPlayerStyles(shadow)` is the single integration point for the player. It appends (never replaces) the shared sheet so any pre- adopted sheets — host themes, scoped overrides, future caller-side injections — survive intact, and is idempotent so repeated calls don't multiply adoptions. - A defensive fallback path creates a per-instance `<style>` element when adoptedStyleSheets is unavailable (older runtimes, hostile environments, or replaceSync failure). Behavior on those paths is unchanged from the previous implementation. Tests cover sharing across instances, fallback when CSSStyleSheet is undefined or `replaceSync` throws, fallback when adoptedStyleSheets is unsupported on the shadow root, idempotency, and preservation of pre- existing adopted sheets. Includes an integration test in the player spec confirming two real `<hyperframes-player>` elements adopt the same CSSStyleSheet instance and inject no `<style>` element. No public API change. PLAYER_STYLES, PLAY_ICON, and PAUSE_ICON exports are preserved. Build size delta is negligible (utility code replaces container.appendChild calls).
7f09109 to
317424b
Compare
## 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`.

Summary
Replace per-instance
<style>injection in<hyperframes-player>with a lazily constructedCSSStyleSheetadopted viashadowRoot.adoptedStyleSheets. One parsed stylesheet, many adopters — the studio thumbnail grid renders dozens of players concurrently and was paying for N parses of the same CSS.Why
Step
P1-1of the player perf proposal. The previous implementation appended a<style>element to every shadow root, which means:<style>lives in the DOM and contributes to layout/style invalidation work when its shadow root churns.adoptedStyleSheetsflips this: parse once at module load, hand the sameCSSStyleSheetreference to every shadow root.What changed
getSharedPlayerStyleSheet()inpackages/player/src/styles.ts— module-scoped and memoized; the sheet is built once per process and returned to every adopter.applyPlayerStyles(shadow)is the single integration point. It appends (never replaces) the shared sheet so any pre-adopted sheets — host themes, scoped overrides, future caller-side injections — survive intact, and is idempotent so repeated calls don't multiply adoptions.typeof CSSStyleSheetguard. Failures (e.g.replaceSyncthrow, no constructor) are cached asnullso we don't retry constructor failures forever.<style>element whenadoptedStyleSheetsis unavailable (older runtimes, hostile environments). Behavior on those paths is unchanged from before.PLAYER_STYLES,PLAY_ICON, andPAUSE_ICONexports preserved — no public API change.Test plan
styles.test.tscover sharing across instances, fallback whenCSSStyleSheetis undefined orreplaceSyncthrows, fallback whenadoptedStyleSheetsis unsupported on the shadow root, idempotency, and preservation of pre-existing adopted sheets.hyperframes-player.test.tsconfirms two real<hyperframes-player>elements adopt the sameCSSStyleSheetinstance and inject zero<style>elements.container.appendChildcalls).Stack
Step
P1-1of the player perf proposal. Followed byP1-2(scoping the mediaMutationObserver) andP1-4(coalescing parent media-time mirror writes) — all three target the studio multi-player render path.