mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
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:
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user