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:
Vance Ingalls
2026-05-06 01:25:09 -07:00
committed by GitHub
parent 64457d9fe7
commit a7b308b667
23 changed files with 3475 additions and 243 deletions
@@ -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>
+112
View File
@@ -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;
}
+26 -1
View File
@@ -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);