mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
fix(studio): support web-component refs in useTimelinePlayer (#245)
* fix(studio): support web-component refs in useTimelinePlayer The studio's `useTimelinePlayer` hook returns an `iframeRef` that consumers attach to an `<iframe>` element. When consumers wrap the iframe in a custom element (e.g. `<hyperframes-player>`) that puts the iframe inside its shadow DOM, every `iframeRef.current.contentWindow` access returned `null` and `getAdapter()` silently failed — meaning timeline seek, play, pause, and `refreshPlayer` all became no-ops. Changes: - Add `resolveIframe(el)` helper that returns the underlying iframe whether the host is the iframe itself, a custom element with a shadow-DOM iframe, or a wrapper with a descendant iframe. - Export `resolveIframe` from the studio so consumers can pre-resolve the iframe before assigning it to `iframeRef`. - Internal `useTimelinePlayer` keeps the strict `HTMLIFrameElement` ref type, so existing consumers attaching directly to an `<iframe>` are unaffected. Also adds: - JSDoc on the player's `iframeElement` getter. - "Advanced: iframe access" docs section in `packages/player/README.md` and `docs/packages/player.mdx`. - Type-safety lint rules in `.oxlintrc.json` and a "Type-safety conventions" section in `CONTRIBUTING.md`. Backward compatible — App.tsx and NLELayout.tsx continue to work unchanged. * chore(lint): defer no-explicit-any rule; it broke existing codebase The new rules added 37 errors across 32 existing files — mostly legitimate `window as any` casts at browser-global and test-mock boundaries. Enabling them without fixing all violations breaks CI. Revert the `.oxlintrc.json` additions and soften the CONTRIBUTING.md wording to describe the convention without claiming lint enforcement (that enforcement will come in a follow-up PR that fixes all sites).
This commit is contained in:
@@ -39,6 +39,28 @@ bun run format:check # Check formatting without writing
|
||||
|
||||
Git hooks (via [lefthook](https://github.com/evilmartians/lefthook)) run automatically after `bun install` and enforce linting + formatting on staged files before each commit.
|
||||
|
||||
#### Type-safety conventions
|
||||
|
||||
We aim for honest types — code that lies to the compiler eventually lies to users. The underlying convention is:
|
||||
|
||||
- **Avoid `any`.** Use `unknown` and narrow it where possible.
|
||||
- **Avoid `as T` type assertions.** They suppress type-checker warnings without telling the compiler anything new. Prefer:
|
||||
- Type guards (`function isFoo(x): x is Foo`)
|
||||
- `instanceof` / `typeof` narrowing
|
||||
- Centralized narrowing helpers (e.g. `resolveIframe`)
|
||||
- Properly-typed interfaces at the source
|
||||
- **Acceptable `as` use, with a comment explaining why:**
|
||||
- `as const` — literal narrowing; always safe
|
||||
- `as unknown as T` — explicit double-cast at hard type-system boundaries (e.g. parsing untrusted JSON, FFI/postMessage). Pair with a one-line justification.
|
||||
- **Avoid `!` non-null assertions** outside of post-`if`-checked code paths. Use `??` defaults or guard clauses instead.
|
||||
|
||||
If you must add a cast, add a comment:
|
||||
|
||||
```ts
|
||||
// `postMessage` data is `unknown`; the runtime guarantees this shape.
|
||||
const event = data as unknown as RuntimeEvent;
|
||||
```
|
||||
|
||||
## Pull Requests
|
||||
|
||||
- Use [conventional commit](https://www.conventionalcommits.org/) format for **all commits** (e.g., `feat: add timeline export`, `fix: resolve seek overflow`). Enforced by a git hook.
|
||||
|
||||
@@ -155,6 +155,66 @@ player.addEventListener('ready', () => player.play());
|
||||
document.getElementById('player-container').appendChild(player);
|
||||
```
|
||||
|
||||
## 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 and events above are sufficient. But if you're building an editor, recorder, or custom timeline on top of the player, you'll need to inspect the composition's DOM or read its `__player` / `__timelines` runtime objects. The `iframeElement` getter exposes the inner iframe for these consumers:
|
||||
|
||||
```js
|
||||
const player = document.querySelector('hyperframes-player');
|
||||
const iframe = player.iframeElement;
|
||||
|
||||
// Reach into the composition's DOM
|
||||
iframe.contentDocument.querySelectorAll('[data-composition-id]');
|
||||
|
||||
// Read the runtime (GSAP timelines, element registry, etc.)
|
||||
iframe.contentWindow.__timelines;
|
||||
```
|
||||
|
||||
This is the canonical way to bridge the player into editor tools like [`@hyperframes/studio`](/packages/studio). The studio exports a `resolveIframe` helper that handles both direct iframe refs and web-component refs:
|
||||
|
||||
```ts
|
||||
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 to the web component and resolve the iframe inside an effect:
|
||||
|
||||
```tsx
|
||||
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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
<Warning>
|
||||
**Common gotcha** — if you pass the `<hyperframes-player>` element itself (not `iframeElement`) into a hook or API that expects an `<iframe>`, every `.contentWindow` / `.contentDocument` access returns `null` because the iframe lives inside the player's Shadow DOM. Timeline seek, play, pause, and DOM inspection all silently no-op. **Always extract `iframeElement` first**, or use `resolveIframe` from `@hyperframes/studio` which handles both iframe and web-component hosts transparently.
|
||||
</Warning>
|
||||
|
||||
## Architecture
|
||||
|
||||
The player uses an iframe inside a Shadow DOM container. This provides:
|
||||
|
||||
@@ -76,8 +76,63 @@ 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:
|
||||
|
||||
```js
|
||||
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`](../studio). The studio exports a `resolveIframe` helper that works with both iframe refs and web-component refs:
|
||||
|
||||
```ts
|
||||
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:
|
||||
|
||||
```tsx
|
||||
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 |
|
||||
|
||||
@@ -120,7 +120,27 @@ class HyperframesPlayer extends HTMLElement {
|
||||
|
||||
// ── Public API ──
|
||||
|
||||
/** Access the inner iframe element (for advanced consumers like the studio). */
|
||||
/**
|
||||
* Access the inner `<iframe>` element rendering the composition.
|
||||
*
|
||||
* Use this when integrating the player with editors, recorders, or
|
||||
* timeline tools (e.g. `@hyperframes/studio`) that need to inspect
|
||||
* the composition's DOM or read its `__player` / `__timelines`
|
||||
* runtime objects.
|
||||
*
|
||||
* **Common pitfall:** the iframe lives inside the player's Shadow DOM.
|
||||
* Passing the `<hyperframes-player>` element itself to code that expects
|
||||
* an `<iframe>` will silently break — `.contentWindow` returns `null`.
|
||||
* Always extract `iframeElement` first:
|
||||
*
|
||||
* ```ts
|
||||
* // ❌ Wrong — element ref doesn't expose contentWindow
|
||||
* iframeRef.current = playerRef.current;
|
||||
*
|
||||
* // ✓ Right — bridge the actual iframe
|
||||
* iframeRef.current = playerRef.current.iframeElement;
|
||||
* ```
|
||||
*/
|
||||
get iframeElement(): HTMLIFrameElement {
|
||||
return this.iframe;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ export {
|
||||
VideoThumbnail,
|
||||
CompositionThumbnail,
|
||||
useTimelinePlayer,
|
||||
resolveIframe,
|
||||
usePlayerStore,
|
||||
liveTime,
|
||||
formatTime,
|
||||
|
||||
@@ -186,8 +186,34 @@ function unmutePreviewMedia(iframe: HTMLIFrameElement | null): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the underlying iframe from any host element. Supports:
|
||||
* - Direct `<iframe>` element (most common — studio's own `Player.tsx`)
|
||||
* - Custom elements (e.g. `<hyperframes-player>`) whose shadow DOM contains an iframe
|
||||
* - Wrapper elements whose light DOM contains a descendant iframe
|
||||
*
|
||||
* Exported so web-component consumers can pre-resolve the iframe before
|
||||
* assigning it to `iframeRef` returned by `useTimelinePlayer`. Returns `null`
|
||||
* when the element has no associated iframe yet.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* const { iframeRef } = useTimelinePlayer();
|
||||
* const playerElRef = useRef<HyperframesPlayer>(null);
|
||||
*
|
||||
* useEffect(() => {
|
||||
* iframeRef.current = resolveIframe(playerElRef.current);
|
||||
* }, [iframeRef]);
|
||||
* ```
|
||||
*/
|
||||
export function resolveIframe(el: Element | null): HTMLIFrameElement | null {
|
||||
if (!el) return null;
|
||||
if (el instanceof HTMLIFrameElement) return el;
|
||||
return el.shadowRoot?.querySelector("iframe") ?? el.querySelector("iframe") ?? null;
|
||||
}
|
||||
|
||||
export function useTimelinePlayer() {
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
const iframeRef = useRef<HTMLIFrameElement | null>(null);
|
||||
const rafRef = useRef<number>(0);
|
||||
const probeIntervalRef = useRef<ReturnType<typeof setInterval> | undefined>(undefined);
|
||||
const pendingSeekRef = useRef<number | null>(null);
|
||||
@@ -200,7 +226,8 @@ export function useTimelinePlayer() {
|
||||
|
||||
const getAdapter = useCallback((): PlaybackAdapter | null => {
|
||||
try {
|
||||
const win = iframeRef.current?.contentWindow as IframeWindow | null;
|
||||
const iframe = iframeRef.current;
|
||||
const win = iframe?.contentWindow as IframeWindow | null;
|
||||
if (!win) return null;
|
||||
|
||||
if (win.__player && typeof win.__player.play === "function") {
|
||||
@@ -554,8 +581,9 @@ export function useTimelinePlayer() {
|
||||
setIsPlaying(false);
|
||||
|
||||
try {
|
||||
const doc = iframeRef.current?.contentDocument;
|
||||
const iframeWin = iframeRef.current?.contentWindow as IframeWindow | null;
|
||||
const iframe = iframeRef.current;
|
||||
const doc = iframe?.contentDocument;
|
||||
const iframeWin = iframe?.contentWindow as IframeWindow | null;
|
||||
if (doc && iframeWin) {
|
||||
normalizePreviewViewport(doc, iframeWin);
|
||||
autoHealMissingCompositionIds(doc);
|
||||
@@ -591,7 +619,7 @@ export function useTimelinePlayer() {
|
||||
const rootId = rootComp.getAttribute("data-composition-id") || "composition";
|
||||
// Derive compositionSrc from the iframe URL for thumbnail rendering.
|
||||
// URL pattern: /api/projects/{id}/preview/comp/{path}
|
||||
const iframeSrc = iframeRef.current?.src || "";
|
||||
const iframeSrc = iframe?.src || "";
|
||||
const compPathMatch = iframeSrc.match(/\/preview\/comp\/(.+?)(?:\?|$)/);
|
||||
const compositionSrc = compPathMatch
|
||||
? decodeURIComponent(compPathMatch[1])
|
||||
@@ -682,7 +710,8 @@ export function useTimelinePlayer() {
|
||||
const handleMessage = (e: MessageEvent) => {
|
||||
const data = e.data;
|
||||
// Only process messages from the main preview iframe — ignore MediaPanel/ClipThumbnail iframes
|
||||
if (e.source && iframeRef.current && e.source !== iframeRef.current.contentWindow) {
|
||||
const ourIframe = iframeRef.current;
|
||||
if (e.source && ourIframe && e.source !== ourIframe.contentWindow) {
|
||||
return;
|
||||
}
|
||||
// Also handle the runtime's state message which includes timeline data
|
||||
@@ -690,8 +719,7 @@ export function useTimelinePlayer() {
|
||||
// State message means the runtime is alive — check for elements
|
||||
try {
|
||||
if (usePlayerStore.getState().elements.length === 0) {
|
||||
const iframe = iframeRef.current;
|
||||
const iframeWin = iframe?.contentWindow as IframeWindow | null;
|
||||
const iframeWin = ourIframe?.contentWindow as IframeWindow | null;
|
||||
const manifest = iframeWin?.__clipManifest;
|
||||
if (manifest && manifest.clips.length > 0) {
|
||||
processTimelineMessageRef.current(manifest);
|
||||
@@ -717,8 +745,7 @@ export function useTimelinePlayer() {
|
||||
// If manifest produced 0 elements after filtering, try DOM fallback
|
||||
if (usePlayerStore.getState().elements.length === 0) {
|
||||
try {
|
||||
const iframe = iframeRef.current;
|
||||
const doc = iframe?.contentDocument;
|
||||
const doc = ourIframe?.contentDocument;
|
||||
const adapter = getAdapter();
|
||||
if (doc && adapter) {
|
||||
const els = parseTimelineFromDOM(doc, adapter.getDuration());
|
||||
|
||||
@@ -6,7 +6,7 @@ export { VideoThumbnail } from "./components/VideoThumbnail";
|
||||
export { CompositionThumbnail } from "./components/CompositionThumbnail";
|
||||
|
||||
// Hooks
|
||||
export { useTimelinePlayer } from "./hooks/useTimelinePlayer";
|
||||
export { useTimelinePlayer, resolveIframe } from "./hooks/useTimelinePlayer";
|
||||
|
||||
// Store
|
||||
export { usePlayerStore, liveTime } from "./store/playerStore";
|
||||
|
||||
Reference in New Issue
Block a user