mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
fix(studio): SSR-safe player load, captions import cleanup (#248)
* fix(studio): load @hyperframes/player lazily to support SSR Player.tsx had a bare `import "@hyperframes/player"` at module scope. The player package registers a class that extends HTMLElement as a side effect, and HTMLElement doesn't exist in a Node server runtime. Any consumer that imported from @hyperframes/studio during server-side rendering (e.g. the Next.js App Router evaluating a client component for SSR) threw `HTMLElement is not defined`. Move the import inside the mount effect via dynamic `import(...)` so it only runs in the browser, and wire up a cancellation flag and deferred cleanup so a fast unmount doesn't leak listeners or DOM nodes. * fix(studio): remove .js extensions from captions-internal imports The captions module imported sibling files as `./types.js` and `./parser.js`. That's legal ESM TypeScript, but Turbopack (and other bundlers) refuse to resolve those specifiers against .ts files when the package is consumed from node_modules — the rest of @hyperframes/studio uses extensionless imports for that reason. Align captions with the rest of the codebase so the package builds without bundler-specific configuration in consumers. * chore: release @hyperframes/player@0.2.7 and @hyperframes/studio@0.2.9 Ships the root-timeline resolution fix (#247), the SSR-safe player load, and the captions import cleanup.
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hyperframes/player",
|
||||
"version": "0.2.6",
|
||||
"version": "0.2.7",
|
||||
"description": "Embeddable web component for HyperFrames compositions",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hyperframes/studio",
|
||||
"version": "0.2.5",
|
||||
"version": "0.2.9",
|
||||
"description": "",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// @vitest-environment node
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { generateCaptionHtml } from "./generator.js";
|
||||
import { buildCaptionModel, TranscriptWord } from "./parser.js";
|
||||
import { generateCaptionHtml } from "./generator";
|
||||
import { buildCaptionModel, TranscriptWord } from "./parser";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fixtures
|
||||
|
||||
@@ -8,7 +8,7 @@ import type {
|
||||
CaptionContainerStyle,
|
||||
CaptionShadow,
|
||||
CaptionGlow,
|
||||
} from "./types.js";
|
||||
} from "./types";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// @vitest-environment node
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { extractTranscript, buildCaptionModel, TranscriptWord } from "./parser.js";
|
||||
import { DEFAULT_STYLE, DEFAULT_CONTAINER, DEFAULT_ANIMATION_SET } from "./types.js";
|
||||
import { extractTranscript, buildCaptionModel, TranscriptWord } from "./parser";
|
||||
import { DEFAULT_STYLE, DEFAULT_CONTAINER, DEFAULT_ANIMATION_SET } from "./types";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fixtures
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
DEFAULT_STYLE,
|
||||
DEFAULT_CONTAINER,
|
||||
DEFAULT_ANIMATION_SET,
|
||||
} from "./types.js";
|
||||
} from "./types";
|
||||
|
||||
export interface TranscriptWord {
|
||||
id?: string;
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { forwardRef, useRef } from "react";
|
||||
import { useMountEffect } from "../../hooks/useMountEffect";
|
||||
import type { HyperframesPlayer } from "@hyperframes/player";
|
||||
import "@hyperframes/player"; // registers <hyperframes-player> custom element
|
||||
// NOTE: importing "@hyperframes/player" registers a class extending HTMLElement
|
||||
// at module load, which throws under SSR. Defer the import to the mount effect
|
||||
// so it only runs in the browser.
|
||||
|
||||
interface PlayerProps {
|
||||
projectId?: string;
|
||||
@@ -27,55 +29,68 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
// 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);
|
||||
let canceled = false;
|
||||
let cleanup: (() => void) | undefined;
|
||||
|
||||
// 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;
|
||||
}
|
||||
// Dynamic import registers the custom element in the browser only.
|
||||
import("@hyperframes/player").then(() => {
|
||||
if (canceled) return;
|
||||
|
||||
// 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 });
|
||||
// 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);
|
||||
|
||||
// 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 });
|
||||
// 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;
|
||||
}
|
||||
onLoad();
|
||||
};
|
||||
iframe.addEventListener("load", handleLoad);
|
||||
|
||||
// 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();
|
||||
};
|
||||
iframe.addEventListener("load", handleLoad);
|
||||
|
||||
cleanup = () => {
|
||||
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;
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
return () => {
|
||||
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;
|
||||
}
|
||||
canceled = true;
|
||||
cleanup?.();
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user