mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-04 07:19:52 +00:00
feat: cache shader transition preview frames (#634)
* feat: cache shader transition preview frames * fix: move shader transition loading to player
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
export interface HyperframesLoaderProps {
|
||||
/** Status text shown below the mark. */
|
||||
title: string;
|
||||
/** Optional secondary detail line. */
|
||||
detail?: string;
|
||||
/** Optional monospace third line for IDs, counts, or percentages. */
|
||||
mono?: string;
|
||||
/** Pixel size of the mark itself; status text scales independently. */
|
||||
size?: number;
|
||||
/** Optional normalized progress value from 0 to 1. */
|
||||
progress?: number;
|
||||
}
|
||||
|
||||
export function HyperframesLoader({
|
||||
title,
|
||||
detail,
|
||||
mono,
|
||||
size = 64,
|
||||
progress,
|
||||
}: HyperframesLoaderProps) {
|
||||
const boundedProgress =
|
||||
typeof progress === "number" && Number.isFinite(progress)
|
||||
? Math.min(1, Math.max(0, progress))
|
||||
: undefined;
|
||||
const markFrameSize = Math.round(size * 1.16);
|
||||
|
||||
return (
|
||||
<div className="hf-loader" draggable={false}>
|
||||
<div
|
||||
className="hf-loader-mark-frame"
|
||||
style={{ width: markFrameSize, height: markFrameSize }}
|
||||
draggable={false}
|
||||
>
|
||||
<svg
|
||||
className="hf-loader-mark"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 100 100"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<g className="hf-loader-mark__mark" transform="translate(50 50)">
|
||||
<g className="hf-loader-mark__core" transform="scale(1)" opacity=".92">
|
||||
<g transform="translate(-50 -50)">
|
||||
<path
|
||||
d="M10.1851 57.8021L33.1145 73.8313C36.2202 75.9978 41.5173 73.5433 42.4816 69.4984L51.7611 30.4271C52.7253 26.3822 48.5802 23.9277 44.4602 26.0942L13.917 42.1235C6.96677 45.7676 4.97564 54.1579 10.1851 57.8021Z"
|
||||
fill="url(#hf-loader-grad-left)"
|
||||
/>
|
||||
<path
|
||||
d="M87.5129 57.5141L56.9696 73.5433C52.8371 75.7098 48.7046 73.2553 49.6688 69.2104L58.9483 30.1391C59.9125 26.0942 65.2097 23.6397 68.3154 25.8062L91.2447 41.8354C96.4668 45.4796 94.4631 53.8699 87.5129 57.5141Z"
|
||||
fill="url(#hf-loader-grad-right)"
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="hf-loader-grad-left"
|
||||
x1="48.5676"
|
||||
y1="25"
|
||||
x2="44.7804"
|
||||
y2="71.9384"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="#06E3FA" />
|
||||
<stop offset="1" stopColor="#4FDB5E" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="hf-loader-grad-right"
|
||||
x1="54.8282"
|
||||
y1="73.8392"
|
||||
x2="72.0989"
|
||||
y2="32.8932"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="#06E3FA" />
|
||||
<stop offset="1" stopColor="#4FDB5E" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="hf-loader-title">{title}</div>
|
||||
{detail && <div className="hf-loader-detail">{detail}</div>}
|
||||
{boundedProgress !== undefined && (
|
||||
<div className="hf-loader-progress" aria-hidden="true">
|
||||
<div
|
||||
className="hf-loader-progress__fill"
|
||||
style={{ transform: `scaleX(${boundedProgress})` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{mono && <div className="hf-loader-mono">{mono}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function StatusFrame(props: HyperframesLoaderProps) {
|
||||
return (
|
||||
<div className="hf-frame">
|
||||
<HyperframesLoader {...props} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,2 +1,4 @@
|
||||
// Minimal UI primitives for studio canvas components
|
||||
export { Button, IconButton } from "./Button";
|
||||
export { HyperframesLoader, StatusFrame } from "./HyperframesLoader";
|
||||
export type { HyperframesLoaderProps } from "./HyperframesLoader";
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { forwardRef, useRef, useState } from "react";
|
||||
import { forwardRef, useEffect, useRef, useState } from "react";
|
||||
import { isLottieAnimationLoaded } from "@hyperframes/core/runtime/lottie-readiness";
|
||||
import { useMountEffect } from "../../hooks/useMountEffect";
|
||||
import { HyperframesLoader } from "../../components/ui";
|
||||
// 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.
|
||||
@@ -16,6 +17,19 @@ interface HyperframesPlayerElement extends HTMLElement {
|
||||
iframeElement: HTMLIFrameElement;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
function getShaderTransitionLoading(event: Event): boolean | null {
|
||||
if (!(event instanceof CustomEvent)) return null;
|
||||
const detail: unknown = event.detail;
|
||||
if (!isRecord(detail)) return null;
|
||||
const state = detail.state;
|
||||
if (!isRecord(state)) return null;
|
||||
return state.loading === true && state.ready !== true;
|
||||
}
|
||||
|
||||
// Assets are considered ready when every `<video>`/`<audio>` has enough data
|
||||
// to play through without buffering, and every registered Lottie animation has
|
||||
// finished loading.
|
||||
@@ -62,7 +76,11 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const loadCountRef = useRef(0);
|
||||
const assetPollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const assetFadeRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const [assetsLoading, setAssetsLoading] = useState(false);
|
||||
const [assetOverlayVisible, setAssetOverlayVisible] = useState(false);
|
||||
const [assetOverlayFading, setAssetOverlayFading] = useState(false);
|
||||
const [shaderTransitionLoading, setShaderTransitionLoading] = useState(false);
|
||||
|
||||
useMountEffect(() => {
|
||||
const container = containerRef.current;
|
||||
@@ -78,6 +96,8 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
|
||||
// Create the web component imperatively to avoid JSX custom-element typing.
|
||||
const player = document.createElement("hyperframes-player") as HyperframesPlayerElement;
|
||||
const src = directUrl || `/api/projects/${projectId}/preview`;
|
||||
player.setAttribute("shader-capture-scale", "1");
|
||||
player.setAttribute("shader-loading", "player");
|
||||
player.setAttribute("src", src);
|
||||
player.setAttribute("width", String(portrait ? 1080 : 1920));
|
||||
player.setAttribute("height", String(portrait ? 1920 : 1080));
|
||||
@@ -99,9 +119,16 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
|
||||
const preventToggle = (e: Event) => e.stopImmediatePropagation();
|
||||
player.addEventListener("click", preventToggle, { capture: true });
|
||||
|
||||
const handleShaderTransitionState = (event: Event) => {
|
||||
const loading = getShaderTransitionLoading(event);
|
||||
if (loading !== null) setShaderTransitionLoading(loading);
|
||||
};
|
||||
player.addEventListener("shadertransitionstate", handleShaderTransitionState);
|
||||
|
||||
// Forward the iframe's native load event to the studio's onIframeLoad.
|
||||
const handleLoad = () => {
|
||||
loadCountRef.current++;
|
||||
setShaderTransitionLoading(false);
|
||||
// Reveal animation on reload (hot-reload, composition switch)
|
||||
if (loadCountRef.current > 1) {
|
||||
container.classList.remove("preview-revealing");
|
||||
@@ -151,6 +178,7 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
|
||||
cleanup = () => {
|
||||
iframe.removeEventListener("load", handleLoad);
|
||||
player.removeEventListener("click", preventToggle, { capture: true });
|
||||
player.removeEventListener("shadertransitionstate", handleShaderTransitionState);
|
||||
if (assetPollRef.current) clearInterval(assetPollRef.current);
|
||||
assetPollRef.current = null;
|
||||
container.removeChild(player);
|
||||
@@ -169,13 +197,57 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
|
||||
};
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (assetFadeRef.current) {
|
||||
clearTimeout(assetFadeRef.current);
|
||||
assetFadeRef.current = null;
|
||||
}
|
||||
|
||||
if (assetsLoading) {
|
||||
setAssetOverlayVisible(true);
|
||||
setAssetOverlayFading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setAssetOverlayFading(true);
|
||||
assetFadeRef.current = setTimeout(() => {
|
||||
setAssetOverlayVisible(false);
|
||||
setAssetOverlayFading(false);
|
||||
assetFadeRef.current = null;
|
||||
}, 240);
|
||||
|
||||
return () => {
|
||||
if (assetFadeRef.current) {
|
||||
clearTimeout(assetFadeRef.current);
|
||||
assetFadeRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [assetsLoading]);
|
||||
|
||||
const showAssetOverlay = assetOverlayVisible && !shaderTransitionLoading;
|
||||
|
||||
return (
|
||||
<div className="relative w-full h-full max-w-full max-h-full overflow-hidden bg-black flex items-center justify-center">
|
||||
<div ref={containerRef} className="w-full h-full" />
|
||||
{assetsLoading && (
|
||||
<div className="absolute inset-0 bg-black/80 flex flex-col items-center justify-center z-20 pointer-events-none">
|
||||
<div className="w-8 h-8 border-2 border-white/20 border-t-white rounded-full animate-spin" />
|
||||
<span className="text-white/60 text-xs mt-3">Loading assets…</span>
|
||||
{showAssetOverlay && (
|
||||
<div
|
||||
className="absolute inset-0 bg-black flex items-center justify-center z-20 select-none"
|
||||
data-hyperframes-ignore=""
|
||||
draggable={false}
|
||||
style={{
|
||||
opacity: assetOverlayFading ? 0 : 1,
|
||||
pointerEvents: assetOverlayFading ? "none" : "auto",
|
||||
transition: "opacity 240ms ease-out",
|
||||
}}
|
||||
onDragStart={(event) => event.preventDefault()}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onPointerDown={(event) => event.preventDefault()}
|
||||
>
|
||||
<HyperframesLoader
|
||||
title="Preparing preview assets"
|
||||
detail="Waiting for media and motion assets before playback starts."
|
||||
size={56}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -49,3 +49,115 @@ body {
|
||||
.cm-editor.cm-focused {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/*
|
||||
* HyperFrames brand loader. Shared by preview overlays that need a calm,
|
||||
* branded loading state instead of a generic spinner.
|
||||
*/
|
||||
.hf-loader {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.75rem;
|
||||
width: min(34rem, 100%);
|
||||
padding: 1.5rem;
|
||||
box-sizing: border-box;
|
||||
text-align: center;
|
||||
cursor: default;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
-webkit-user-drag: none;
|
||||
}
|
||||
|
||||
.hf-frame {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 12rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(0, 0, 0, 0.52);
|
||||
}
|
||||
|
||||
.hf-loader-mark-frame {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
overflow: visible;
|
||||
transform-origin: 50% 50%;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
-webkit-user-drag: none;
|
||||
}
|
||||
|
||||
.hf-loader-mark {
|
||||
display: block;
|
||||
overflow: visible;
|
||||
filter: drop-shadow(0 0 7px rgba(79, 219, 94, 0.2));
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
-webkit-user-drag: none;
|
||||
}
|
||||
|
||||
.hf-loader-title {
|
||||
font-family:
|
||||
Inter,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
"Segoe UI",
|
||||
sans-serif;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0;
|
||||
color: var(--hf-heading, #f4f4f5);
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.hf-loader-detail {
|
||||
max-width: 32rem;
|
||||
min-height: 2.5rem;
|
||||
overflow: hidden;
|
||||
color: var(--hf-text-secondary, rgba(244, 244, 245, 0.68));
|
||||
font-family:
|
||||
Inter,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
"Segoe UI",
|
||||
sans-serif;
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.hf-loader-mono {
|
||||
width: min(36rem, 100%);
|
||||
min-height: 1.5rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--hf-text-tertiary, rgba(244, 244, 245, 0.46));
|
||||
font-family: "IBM Plex Mono", "SF Mono", "Fira Code", monospace;
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.hf-loader-progress {
|
||||
width: min(18rem, 72vw);
|
||||
height: 0.375rem;
|
||||
overflow: hidden;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.hf-loader-progress__fill {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
transform: scaleX(0);
|
||||
transform-origin: left center;
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(90deg, #06e3fa, #4fdb5e);
|
||||
transition: transform 160ms ease;
|
||||
}
|
||||
|
||||
@@ -8,12 +8,13 @@ import {
|
||||
lstatSync,
|
||||
realpathSync,
|
||||
} from "node:fs";
|
||||
import { join, resolve } from "node:path";
|
||||
import { isAbsolute, join, relative, resolve } from "node:path";
|
||||
import type {
|
||||
StudioApiAdapter,
|
||||
ResolvedProject,
|
||||
RenderJobState,
|
||||
} from "@hyperframes/core/studio-api";
|
||||
import { createProjectSignature } from "../core/src/studio-api/helpers/projectSignature";
|
||||
import { createRetryingModuleLoader, ensureProducerDist } from "./vite.producer";
|
||||
import { readNodeRequestBody } from "./vite.request-body.js";
|
||||
import { seekThumbnailPreview } from "./vite.thumbnail";
|
||||
@@ -56,6 +57,14 @@ interface ScreenshotClip {
|
||||
height: number;
|
||||
}
|
||||
|
||||
function isPathWithin(parentDir: string, childPath: string): boolean {
|
||||
const childRelativePath = relative(resolve(parentDir), resolve(childPath));
|
||||
return (
|
||||
childRelativePath === "" ||
|
||||
(!childRelativePath.startsWith("..") && !isAbsolute(childRelativePath))
|
||||
);
|
||||
}
|
||||
|
||||
// ── Vite adapter for the shared studio API ───────────────────────────────────
|
||||
|
||||
function createViteAdapter(dataDir: string, server: ViteDevServer): StudioApiAdapter {
|
||||
@@ -76,6 +85,12 @@ function createViteAdapter(dataDir: string, server: ViteDevServer): StudioApiAda
|
||||
onProgress?: (job: { progress: number; currentStage?: string }) => void,
|
||||
) => Promise<void>;
|
||||
}> | null = null;
|
||||
const projectSignatureCache = new Map<string, string>();
|
||||
server.watcher.on("all", (_event, file) => {
|
||||
for (const projectDir of projectSignatureCache.keys()) {
|
||||
if (isPathWithin(projectDir, file)) projectSignatureCache.delete(projectDir);
|
||||
}
|
||||
});
|
||||
const getBundler = async () => {
|
||||
if (!_bundler) {
|
||||
try {
|
||||
@@ -184,6 +199,16 @@ function createViteAdapter(dataDir: string, server: ViteDevServer): StudioApiAda
|
||||
return html;
|
||||
},
|
||||
|
||||
getProjectSignature(projectDir: string): string {
|
||||
const cacheKey = resolve(projectDir);
|
||||
const cached = projectSignatureCache.get(cacheKey);
|
||||
if (cached) return cached;
|
||||
|
||||
const signature = createProjectSignature(cacheKey);
|
||||
projectSignatureCache.set(cacheKey, signature);
|
||||
return signature;
|
||||
},
|
||||
|
||||
async lint(html: string, opts?: { filePath?: string }) {
|
||||
const mod = await server.ssrLoadModule("@hyperframes/core/lint");
|
||||
return mod.lintHyperframeHtml(html, opts);
|
||||
|
||||
Reference in New Issue
Block a user