feat(studio): use @hyperframes/player web component for preview (#238)

## Summary

- **Replaces the studio's hand-rolled iframe + scaling in** **`Player.tsx`** with the `<hyperframes-player>` web component, eliminating duplicated ResizeObserver, dimension detection, and stage-size message handling
- **Adds a public** **`iframeElement`** **getter** to the player web component so the studio's `useTimelinePlayer` can still access the inner iframe for clip manifest parsing, timeline probing, and DOM inspection
- **Updates player package exports** to resolve from source for workspace consumers (matching `@hyperframes/core` pattern), while npm-published consumers still get built `dist/` files

### Why a separate player package?

1. **Zero dependencies, any framework** — 12KB vanilla web component vs 940KB React+Zustand+CodeMirror studio
2. **CDN-ready** — single `<script>` tag, no build pipeline needed
3. **Embeddable by third parties** — users embed compositions in their own sites without the studio
4. **Single source of truth** — studio now uses the player instead of duplicating its scaling/detection logic

## Test plan

- [x] `pnpm --filter @hyperframes/player typecheck` passes
- [x] `pnpm --filter @hyperframes/studio typecheck` passes
- [x] `pnpm --filter @hyperframes/studio build` passes
- [x] `pnpm --filter @hyperframes/studio test` passes (2 pre-existing failures, unrelated)
- [x] E2E: Standalone player loads composition, detects 4s GSAP timeline, controls work, play/pause works
- [x] E2E: Studio preview renders via `<hyperframes-player>`, `iframeElement` bridge works, playback controls sync correctly
This commit is contained in:
Miguel Ángel
2026-04-10 03:00:54 +02:00
committed by GitHub
parent 7e7d41f833
commit 3482441c9f
5 changed files with 72 additions and 104 deletions
+4 -3
View File
@@ -11,11 +11,12 @@
"dist"
],
"type": "module",
"main": "./dist/hyperframes-player.js",
"types": "./dist/hyperframes-player.d.ts",
"main": "./src/hyperframes-player.ts",
"types": "./src/hyperframes-player.ts",
"exports": {
".": {
"import": "./dist/hyperframes-player.js",
"types": "./src/hyperframes-player.ts",
"import": "./src/hyperframes-player.ts",
"require": "./dist/hyperframes-player.cjs",
"script": "./dist/hyperframes-player.global.js"
}
@@ -116,6 +116,11 @@ class HyperframesPlayer extends HTMLElement {
// ── Public API ──
/** Access the inner iframe element (for advanced consumers like the studio). */
get iframeElement(): HTMLIFrameElement {
return this.iframe;
}
play() {
this._hidePoster();
this._sendControl("play");
+1
View File
@@ -37,6 +37,7 @@
"@codemirror/theme-one-dark": "^6.1.2",
"@codemirror/view": "^6.40.0",
"@hyperframes/core": "workspace:*",
"@hyperframes/player": "workspace:*",
"@phosphor-icons/react": "^2.1.10",
"codemirror": "^6.0.1",
"motion": "^12.38.0"
@@ -1,8 +1,7 @@
import { forwardRef, useRef, useState, useCallback } from "react";
import { forwardRef, useRef } from "react";
import { useMountEffect } from "../../hooks/useMountEffect";
const NATIVE_W = 1920;
const NATIVE_H = 1080;
import type { HyperframesPlayer } from "@hyperframes/player";
import "@hyperframes/player"; // registers <hyperframes-player> custom element
interface PlayerProps {
projectId?: string;
@@ -11,118 +10,80 @@ interface PlayerProps {
portrait?: boolean;
}
/**
* Renders a composition preview using the <hyperframes-player> web component.
*
* The web component handles iframe scaling, dimension detection, and
* ResizeObserver internally. This wrapper bridges its inner iframe to the
* forwarded ref so useTimelinePlayer can access it for clip manifest parsing,
* timeline probing, and DOM inspection.
*/
export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
({ projectId, directUrl, onLoad, portrait }, ref) => {
const containerRef = useRef<HTMLDivElement>(null);
const [scale, setScale] = useState(1);
const dimsRef = useRef({
w: portrait ? NATIVE_H : NATIVE_W,
h: portrait ? NATIVE_W : NATIVE_H,
});
const [dims, setDims] = useState(dimsRef.current);
const loadCountRef = useRef(0);
const updateScale = useCallback(() => {
const el = containerRef.current;
if (!el) return;
const rect = el.getBoundingClientRect();
const d = dimsRef.current;
setScale(Math.min(rect.width / d.w, rect.height / d.h));
}, []);
useMountEffect(() => {
updateScale();
const ro = new ResizeObserver(updateScale);
if (containerRef.current) ro.observe(containerRef.current);
const container = containerRef.current;
if (!container) return;
// Listen for stage-size messages from the runtime
const handleMessage = (e: MessageEvent) => {
const data = e.data;
if (
data?.source === "hf-preview" &&
data?.type === "stage-size" &&
data.width > 0 &&
data.height > 0
) {
if (dimsRef.current.w !== data.width || dimsRef.current.h !== data.height) {
dimsRef.current = { w: data.width, h: data.height };
setDims(dimsRef.current);
updateScale();
}
// Create the web component imperatively to avoid JSX custom-element typing.
const player = document.createElement("hyperframes-player") as HyperframesPlayer;
const src = directUrl || `/api/projects/${projectId}/preview`;
player.setAttribute("src", src);
player.setAttribute("width", String(portrait ? 1080 : 1920));
player.setAttribute("height", String(portrait ? 1920 : 1080));
player.style.width = "100%";
player.style.height = "100%";
player.style.display = "block";
container.appendChild(player);
// Bridge the inner iframe to the forwarded ref for useTimelinePlayer.
const iframe = player.iframeElement;
if (typeof ref === "function") {
ref(iframe);
} else if (ref) {
(ref as React.MutableRefObject<HTMLIFrameElement | null>).current = iframe;
}
// Prevent the web component's built-in click-to-toggle behavior.
// The studio manages playback exclusively via useTimelinePlayer.
const preventToggle = (e: Event) => e.stopImmediatePropagation();
player.addEventListener("click", preventToggle, { capture: true });
// Forward the iframe's native load event to the studio's onIframeLoad.
const handleLoad = () => {
loadCountRef.current++;
// Reveal animation on reload (hot-reload, composition switch)
if (loadCountRef.current > 1) {
container.classList.remove("preview-revealing");
void container.offsetWidth;
container.classList.add("preview-revealing");
const onEnd = () => container.classList.remove("preview-revealing");
container.addEventListener("animationend", onEnd, { once: true });
}
onLoad();
};
window.addEventListener("message", handleMessage);
iframe.addEventListener("load", handleLoad);
return () => {
ro.disconnect();
window.removeEventListener("message", handleMessage);
iframe.removeEventListener("load", handleLoad);
player.removeEventListener("click", preventToggle, { capture: true });
container.removeChild(player);
// Clear the forwarded ref
if (typeof ref === "function") {
ref(null);
} else if (ref) {
(ref as React.MutableRefObject<HTMLIFrameElement | null>).current = null;
}
};
});
const handleLoad = useCallback(() => {
loadCountRef.current++;
// Auto-detect dimensions from the composition's data-width/data-height
try {
const iframeEl = typeof ref === "function" ? null : ref?.current;
const doc = iframeEl?.contentDocument;
if (doc) {
const root = doc.querySelector("[data-composition-id]");
if (root) {
const dw = parseInt(root.getAttribute("data-width") || "0", 10);
const dh = parseInt(root.getAttribute("data-height") || "0", 10);
if (dw > 0 && dh > 0 && (dw !== dimsRef.current.w || dh !== dimsRef.current.h)) {
dimsRef.current = { w: dw, h: dh };
setDims(dimsRef.current);
// Recalc scale with new dims
const el = containerRef.current;
if (el) {
const rect = el.getBoundingClientRect();
setScale(Math.min(rect.width / dw, rect.height / dh));
}
}
}
}
} catch (err) {
console.warn("[Player] Could not read iframe dimensions (cross-origin)", err);
}
if (loadCountRef.current > 1) {
const el = containerRef.current;
if (el) {
el.classList.remove("preview-revealing");
void el.offsetWidth;
el.classList.add("preview-revealing");
const onEnd = () => el.classList.remove("preview-revealing");
el.addEventListener("animationend", onEnd, { once: true });
}
}
onLoad();
}, [onLoad, ref]);
return (
<div
ref={containerRef}
className="w-full h-full max-w-full max-h-full overflow-hidden bg-black flex items-center justify-center"
>
<iframe
ref={ref}
src={directUrl || `/api/projects/${projectId}/preview`}
onLoad={handleLoad}
sandbox="allow-scripts allow-same-origin"
allow="autoplay; fullscreen"
referrerPolicy="no-referrer"
title="Project Preview"
style={{
width: dims.w,
height: dims.h,
border: "none",
transform: `scale(${scale})`,
transformOrigin: "center center",
flexShrink: 0,
}}
/>
</div>
/>
);
},
);
@@ -112,13 +112,13 @@ describe("usePlayerStore", () => {
});
});
describe("updateElementStart", () => {
describe("updateElement", () => {
it("updates the start time of a specific element", () => {
usePlayerStore.getState().setElements([
{ id: "el-1", tag: "div", start: 0, duration: 5, track: 0 },
{ id: "el-2", tag: "div", start: 5, duration: 5, track: 1 },
]);
usePlayerStore.getState().updateElementStart("el-1", 3);
usePlayerStore.getState().updateElement("el-1", { start: 3 });
const elements = usePlayerStore.getState().elements;
expect(elements[0].start).toBe(3);
expect(elements[1].start).toBe(5); // unchanged
@@ -129,7 +129,7 @@ describe("usePlayerStore", () => {
{ id: "el-1", tag: "div", start: 0, duration: 5, track: 0 },
];
usePlayerStore.getState().setElements(original);
usePlayerStore.getState().updateElementStart("nonexistent", 10);
usePlayerStore.getState().updateElement("nonexistent", { start: 10 });
expect(usePlayerStore.getState().elements[0].start).toBe(0);
});
});