Files
hyperframes/packages/player
Vance Ingalls a6e14da45c perf(player): scope MutationObserver to composition hosts (#395)
## Summary

Replace the body-wide `MutationObserver` in `<hyperframes-player>` with one scoped to top-level `[data-composition-id]` hosts. The wide observer fired on every body-level mutation — analytics scripts, runtime telemetry markers, dev overlays — even though only composition subtrees can introduce new timed media (`<audio data-start>`, etc.).

## Why

Step `P1-2` of the player perf proposal. The previous implementation observed `iframe.contentDocument.body` with `subtree: true` to pick up sub-composition `<audio data-start>` elements added after initial mount. That worked, but it was paying for callbacks from every unrelated DOM mutation in the iframe — most of which are just runtime instrumentation. Hot paths in the studio (timeline updates, telemetry markers) end up triggering the observer dozens of times per frame.

Scoping to composition hosts cuts the noise by ~10× in the studio without losing any of the timed-media wiring guarantees.

## What changed

- New `selectMediaObserverTargets(doc)` helper in `packages/player/src/mediaObserverScope.ts` that selects all top-level `[data-composition-id]` elements **excluding** nested ones — sub-composition hosts whose media is already covered by the parent observer's `subtree: true`.
- The player now attaches a single `MutationObserver` instance per top-level host (`subtree: true`), so callbacks still batch across hosts but skip out-of-host noise.
- Falls back to observing `body` when no composition hosts exist (e.g. blank iframe between `src` changes) — preserves prior behavior for non-composition documents and avoids breaking the bootstrap path.

## Test plan

- [x] 8 new unit tests in `mediaObserverScope.test.ts` covering empty docs, single host, multiple hosts, nested-host filtering, and the body-fallback path.
- [x] 2 new integration tests in `hyperframes-player.test.ts` spying on `MutationObserver.prototype.observe` to confirm the targets and options the player actually attaches in a real custom-element bootstrap.

## Stack

Step `P1-2` of the player perf proposal. Sits between `P1-1` (shared adopted stylesheets) and `P1-4` (coalescing parent media-time mirror writes) — together they target the studio multi-player render path. The perf gate scenarios in `P0-1*` will pick up the wins automatically.
2026-04-22 16:46:54 -07:00
..
2026-04-22 17:28:11 -04:00

@hyperframes/player

Embeddable web component for playing HyperFrames compositions. Zero dependencies, works with any framework.

Install

npm install @hyperframes/player

Or load directly via CDN:

<script src="https://cdn.jsdelivr.net/npm/@hyperframes/player"></script>

Usage

<hyperframes-player src="./my-composition/index.html" controls></hyperframes-player>

The player loads the composition in a sandboxed iframe, auto-detects its dimensions and duration, and scales it responsively to fit the container.

With a framework

import "@hyperframes/player";

// The custom element is now registered — use it in your markup
// React: <hyperframes-player src="..." controls />
// Vue:   <hyperframes-player :src="url" controls />

Poster image

Show a static image before playback starts:

<hyperframes-player
  src="./composition/index.html"
  poster="./thumbnail.jpg"
  controls
></hyperframes-player>

Attributes

Attribute Type Default Description
src string URL to the composition HTML file
audio-src string Audio URL for parent-frame playback (mobile)
width number 1920 Composition width in pixels (aspect ratio)
height number 1080 Composition height in pixels (aspect ratio)
controls boolean false Show play/pause, scrubber, and time display
muted boolean false Mute audio playback
poster string Image URL shown before playback starts
playback-rate number 1 Speed multiplier (0.5 = half, 2 = double)
autoplay boolean false Start playing when ready
loop boolean false Restart when the composition ends

Mobile audio

Mobile browsers block audio.play() inside iframes when the user gesture happened in the parent frame (the User Activation spec does not propagate activation across frame boundaries via postMessage).

The player handles this automatically for same-origin iframes (the default — sandbox includes allow-same-origin):

  1. When the composition is ready, the player extracts all timed media (audio[data-start], video[data-start]) from the iframe DOM and creates parent-frame copies.
  2. The iframe originals are disabled (src and data-start removed) so the runtime doesn't try to play them.
  3. When play() is called (from a user gesture), parent media .play() runs synchronously in the gesture call stack, satisfying mobile autoplay policy.
  4. Both parent media and the GSAP timeline start simultaneously and free-run — no active sync needed since both are real-time systems.

No changes are required by consumers — this works out of the box.

The optional audio-src attribute can be used to start preloading a primary audio track before the iframe loads (useful on slow connections), but is not required for mobile playback.

JavaScript API

const player = document.querySelector("hyperframes-player");

// Playback
player.play();
player.pause();
player.seek(2.5); // jump to 2.5 seconds

// Properties
player.currentTime; // number (read/write)
player.duration; // number (read-only)
player.paused; // boolean (read-only)
player.ready; // boolean (read-only)
player.playbackRate; // number (read/write)
player.muted; // boolean (read/write)
player.loop; // boolean (read/write)

// Inner iframe access (for advanced consumers — see "Advanced: iframe access" below)
player.iframeElement; // HTMLIFrameElement (read-only)

Advanced: iframe access

The composition runs inside a sandboxed <iframe> in the player's Shadow DOM. For most use cases you don't need direct access — the JavaScript API above is enough. But if you're building an editor, recorder, or custom timeline that needs to inspect the composition's DOM or read its __player / __timelines runtime objects, use the iframeElement getter:

const player = document.querySelector("hyperframes-player");
const iframe = player.iframeElement;

// Now you can reach into the composition's DOM and runtime
iframe.contentDocument.querySelectorAll("[data-composition-id]");
iframe.contentWindow.__timelines;

This is the canonical way to bridge the player into tools like @hyperframes/studio. The studio exports a resolveIframe helper that works with both iframe refs and web-component refs:

import { useTimelinePlayer, resolveIframe } from "@hyperframes/studio";

const { iframeRef } = useTimelinePlayer();
const player = document.createElement("hyperframes-player");
player.setAttribute("src", src);
container.appendChild(player);

// Forward the inner iframe so useTimelinePlayer can drive play/pause/seek.
iframeRef.current = resolveIframe(player);

React: declarative ref pattern

If you prefer JSX over imperative element creation, attach a ref directly to the web component and resolve the iframe inside an effect:

import "@hyperframes/player";
import type { HyperframesPlayer } from "@hyperframes/player";
import { useTimelinePlayer, resolveIframe } from "@hyperframes/studio";

function StudioPreview({ src }: { src: string }) {
  const { iframeRef, onIframeLoad } = useTimelinePlayer();
  const playerRef = useRef<HyperframesPlayer>(null);

  useEffect(() => {
    iframeRef.current = resolveIframe(playerRef.current);
  });

  return <hyperframes-player ref={playerRef} src={src} onLoad={onIframeLoad} />;
}

Heads up — common gotcha

If you pass the <hyperframes-player> element itself (not iframeElement) into a hook that expects an <iframe>, every .contentWindow / .contentDocument access returns null because the iframe lives inside the player's Shadow DOM. Always extract iframeElement first, or use resolveIframe from @hyperframes/studio which handles both iframe and web-component hosts transparently.

Events

Event Detail Fired when
ready { duration } Composition loaded and duration determined
play Playback started
pause Playback paused
timeupdate { currentTime } Playback position changed (~10 fps)
ended Reached the end (when not looping)
error { message } Composition failed to load
player.addEventListener("ready", (e) => {
  console.log(`Duration: ${e.detail.duration}s`);
});

player.addEventListener("ended", () => {
  console.log("Done!");
});

Sizing

The player fills its container and scales the composition to fit while preserving aspect ratio. Set a size on the element or its parent:

hyperframes-player {
  width: 100%;
  max-width: 800px;
  aspect-ratio: 16 / 9;
}

The width and height attributes define the composition's native resolution for aspect ratio calculation — they don't set the player's display size.

How it works

The player renders compositions in a sandboxed <iframe> inside a Shadow DOM. It communicates with the HyperFrames runtime via postMessage. If the composition has GSAP timelines (window.__timelines) but no runtime, the player auto-injects it from CDN.

Distribution

Format File Use case
ESM hyperframes-player.js Bundlers (Vite, webpack, etc.)
CJS hyperframes-player.cjs Node.js / require()
IIFE hyperframes-player.global.js <script> tag, CDN

All formats are minified with source maps. TypeScript definitions included.

License

MIT