Merge pull request #687 from heygen-com/feat/studio-shader-loading-state

feat: show composition loading in Studio
This commit is contained in:
Miguel Ángel
2026-05-09 04:51:02 +02:00
committed by GitHub
7 changed files with 177 additions and 28 deletions
@@ -0,0 +1,12 @@
import { describe, expect, it } from "vitest";
import { shouldDisableTimelineWhileCompositionLoading } from "./NLELayout";
describe("timeline loading disable state", () => {
it("disables the timeline while the composition loading overlay is visible", () => {
expect(shouldDisableTimelineWhileCompositionLoading(true)).toBe(true);
});
it("reenables the timeline after composition loading finishes", () => {
expect(shouldDisableTimelineWhileCompositionLoading(false)).toBe(false);
});
});
@@ -69,6 +69,10 @@ const MIN_TIMELINE_H = 100;
const DEFAULT_TIMELINE_H = 220;
const MIN_PREVIEW_H = 120;
export function shouldDisableTimelineWhileCompositionLoading(compositionLoading: boolean): boolean {
return compositionLoading;
}
export const NLELayout = memo(function NLELayout({
projectId,
portrait,
@@ -214,6 +218,8 @@ export const NLELayout = memo(function NLELayout({
// Resizable timeline height
const [timelineH, setTimelineH] = useState(DEFAULT_TIMELINE_H);
const [compositionLoading, setCompositionLoading] = useState(true);
const timelineDisabled = shouldDisableTimelineWhileCompositionLoading(compositionLoading);
const isTimelineVisible = timelineVisible ?? true;
const isDragging = useRef(false);
const containerRef = useRef<HTMLDivElement>(null);
@@ -327,23 +333,31 @@ export const NLELayout = memo(function NLELayout({
}, [activeCompositionPath, projectId, updateCompositionStack]);
// Resize divider handlers
const handleDividerPointerDown = useCallback((e: React.PointerEvent) => {
e.preventDefault();
isDragging.current = true;
(e.target as HTMLElement).setPointerCapture(e.pointerId);
}, []);
const handleDividerPointerDown = useCallback(
(e: React.PointerEvent) => {
if (timelineDisabled) return;
e.preventDefault();
isDragging.current = true;
(e.target as HTMLElement).setPointerCapture(e.pointerId);
},
[timelineDisabled],
);
const handleDividerPointerMove = useCallback((e: React.PointerEvent) => {
if (!isDragging.current || !containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
const mouseY = e.clientY - rect.top;
const containerH = rect.height;
const newTimelineH = Math.max(
MIN_TIMELINE_H,
Math.min(containerH - MIN_PREVIEW_H, containerH - mouseY),
);
setTimelineH(newTimelineH);
}, []);
const handleDividerPointerMove = useCallback(
(e: React.PointerEvent) => {
if (timelineDisabled) return;
if (!isDragging.current || !containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
const mouseY = e.clientY - rect.top;
const containerH = rect.height;
const newTimelineH = Math.max(
MIN_TIMELINE_H,
Math.min(containerH - MIN_PREVIEW_H, containerH - mouseY),
);
setTimelineH(newTimelineH);
},
[timelineDisabled],
);
const handleDividerPointerUp = useCallback(() => {
isDragging.current = false;
@@ -374,6 +388,7 @@ export const NLELayout = memo(function NLELayout({
projectId={projectId}
iframeRef={iframeRef}
onIframeLoad={onIframeLoad}
onCompositionLoadingChange={setCompositionLoading}
portrait={portrait}
directUrl={directUrl}
refreshKey={refreshKey}
@@ -388,7 +403,7 @@ export const NLELayout = memo(function NLELayout({
onNavigate={handleNavigateComposition}
/>
)}
<PlayerControls onTogglePlay={togglePlay} onSeek={seek} />
<PlayerControls onTogglePlay={togglePlay} onSeek={seek} disabled={timelineDisabled} />
</div>
</div>
@@ -406,13 +421,18 @@ export const NLELayout = memo(function NLELayout({
</div>
{/* Timeline section — fixed height, resizable */}
<div className="flex flex-col flex-shrink-0" style={{ height: timelineH }}>
<div
className="relative flex flex-col flex-shrink-0"
style={{ height: timelineH }}
aria-disabled={timelineDisabled || undefined}
>
{/* Timeline tracks */}
<div
// flex-col: toolbar takes natural height, Timeline fills remainder.
className="flex flex-col flex-1 min-h-0 overflow-hidden bg-neutral-950"
onDoubleClick={(e) => {
if ((e.target as HTMLElement).closest("[data-clip]")) return;
if (timelineDisabled) return;
if (compositionStack.length > 1) {
updateCompositionStack((prev) => prev.slice(0, -1));
}
@@ -435,9 +455,20 @@ export const NLELayout = memo(function NLELayout({
layerChildCounts={timelineLayerChildCounts}
thumbnailedElementIds={thumbnailedTimelineElementIds}
onToggleElementThumbnail={onToggleTimelineElementThumbnail}
disabled={timelineDisabled}
/>
</div>
{timelineFooter && <div className="flex-shrink-0">{timelineFooter}</div>}
{timelineDisabled && (
<div
className="absolute inset-0 z-30 cursor-not-allowed bg-black/18"
data-testid="timeline-loading-disabled-overlay"
aria-hidden="true"
onPointerDown={(event) => event.preventDefault()}
onDragOver={(event) => event.preventDefault()}
onDrop={(event) => event.preventDefault()}
/>
)}
</div>
</>
) : onToggleTimeline ? (
@@ -5,6 +5,7 @@ interface NLEPreviewProps {
projectId: string;
iframeRef: Ref<HTMLIFrameElement>;
onIframeLoad: () => void;
onCompositionLoadingChange?: (loading: boolean) => void;
portrait?: boolean;
directUrl?: string;
refreshKey?: number;
@@ -36,6 +37,7 @@ export const NLEPreview = memo(function NLEPreview({
projectId,
iframeRef,
onIframeLoad,
onCompositionLoadingChange,
portrait,
directUrl,
refreshKey,
@@ -88,6 +90,7 @@ export const NLEPreview = memo(function NLEPreview({
projectId={directUrl ? undefined : projectId}
directUrl={directUrl}
onLoad={retiringKey ? handleNewPlayerLoad : onIframeLoad}
onCompositionLoadingChange={onCompositionLoadingChange}
portrait={portrait}
style={retiringKey ? { position: "absolute", inset: 0, zIndex: 1 } : undefined}
/>
@@ -0,0 +1,12 @@
import { describe, expect, it } from "vitest";
import { shouldShowCompositionLoadingOverlay } from "./Player";
describe("composition loading overlay", () => {
it("shows while the composition is loading", () => {
expect(shouldShowCompositionLoadingOverlay(true)).toBe(true);
});
it("hides after the composition is ready", () => {
expect(shouldShowCompositionLoadingOverlay(false)).toBe(false);
});
});
@@ -10,6 +10,7 @@ interface PlayerProps {
projectId?: string;
directUrl?: string;
onLoad: () => void;
onCompositionLoadingChange?: (loading: boolean) => void;
portrait?: boolean;
style?: React.CSSProperties;
}
@@ -31,6 +32,10 @@ function getShaderTransitionLoading(event: Event): boolean | null {
return state.loading === true && state.ready !== true;
}
export function shouldShowCompositionLoadingOverlay(compositionLoading: boolean): boolean {
return compositionLoading;
}
function enableInteractiveIframe(player: HyperframesPlayerElement): void {
const root = player.shadowRoot;
if (!root) return;
@@ -84,7 +89,7 @@ function hasUnloadedAssets(iframe: HTMLIFrameElement, lastResult: boolean): bool
* timeline probing, and DOM inspection.
*/
export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
({ projectId, directUrl, onLoad, portrait, style }, ref) => {
({ projectId, directUrl, onLoad, onCompositionLoadingChange, portrait, style }, ref) => {
const containerRef = useRef<HTMLDivElement>(null);
const loadCountRef = useRef(0);
const assetPollRef = useRef<ReturnType<typeof setInterval> | null>(null);
@@ -93,6 +98,7 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
const [assetOverlayVisible, setAssetOverlayVisible] = useState(false);
const [assetOverlayFading, setAssetOverlayFading] = useState(false);
const [shaderTransitionLoading, setShaderTransitionLoading] = useState(false);
const [compositionLoading, setCompositionLoading] = useState(true);
useMountEffect(() => {
const container = containerRef.current;
@@ -138,10 +144,20 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
};
player.addEventListener("shadertransitionstate", handleShaderTransitionState);
const handleReady = () => {
setCompositionLoading(false);
};
const handleError = () => {
setCompositionLoading(false);
};
player.addEventListener("ready", handleReady);
player.addEventListener("error", handleError);
// Forward the iframe's native load event to the studio's onIframeLoad.
const handleLoad = () => {
loadCountRef.current++;
setShaderTransitionLoading(false);
setCompositionLoading(true);
// Reveal animation on reload (hot-reload, composition switch)
if (loadCountRef.current > 1) {
container.classList.remove("preview-revealing");
@@ -192,6 +208,8 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
iframe.removeEventListener("load", handleLoad);
player.removeEventListener("click", preventToggle, { capture: true });
player.removeEventListener("shadertransitionstate", handleShaderTransitionState);
player.removeEventListener("ready", handleReady);
player.removeEventListener("error", handleError);
if (assetPollRef.current) clearInterval(assetPollRef.current);
assetPollRef.current = null;
container.removeChild(player);
@@ -237,7 +255,13 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
};
}, [assetsLoading]);
const showAssetOverlay = assetOverlayVisible && !shaderTransitionLoading;
const showCompositionOverlay = shouldShowCompositionLoadingOverlay(compositionLoading);
const showAssetOverlay =
assetOverlayVisible && !shaderTransitionLoading && !showCompositionOverlay;
useEffect(() => {
onCompositionLoadingChange?.(showCompositionOverlay);
}, [onCompositionLoadingChange, showCompositionOverlay]);
return (
<div
@@ -245,6 +269,23 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
style={style}
>
<div ref={containerRef} className="w-full h-full" />
{showCompositionOverlay && (
<div
className="absolute inset-0 bg-black flex items-center justify-center z-30 select-none"
data-hyperframes-ignore=""
data-testid="composition-loading-overlay"
draggable={false}
onDragStart={(event) => event.preventDefault()}
onMouseDown={(event) => event.preventDefault()}
onPointerDown={(event) => event.preventDefault()}
>
<HyperframesLoader
title="Loading composition"
detail="Preparing the Studio preview."
size={56}
/>
</div>
)}
{showAssetOverlay && (
<div
className="absolute inset-0 bg-black flex items-center justify-center z-20 select-none"
@@ -26,11 +26,13 @@ export function resolveSeekPercent(clientX: number, rectLeft: number, rectWidth:
interface PlayerControlsProps {
onTogglePlay: () => void;
onSeek: (time: number) => void;
disabled?: boolean;
}
export const PlayerControls = memo(function PlayerControls({
onTogglePlay,
onSeek,
disabled = false,
}: PlayerControlsProps) {
// Subscribe to only the fields we render — each selector prevents cascading re-renders
const isPlaying = usePlayerStore((s) => s.isPlaying);
@@ -57,6 +59,7 @@ export const PlayerControls = memo(function PlayerControls({
const durationRef = useRef(duration);
durationRef.current = duration;
const controlsDisabled = disabled || !timelineReady;
useMountEffect(() => {
const updateProgress = (t: number) => {
currentTimeRef.current = t;
@@ -115,6 +118,7 @@ export const PlayerControls = memo(function PlayerControls({
const seekFromClientX = useCallback(
(clientX: number) => {
if (disabled) return;
const bar = seekBarRef.current;
if (!bar || duration <= 0) return;
const rect = bar.getBoundingClientRect();
@@ -125,7 +129,7 @@ export const PlayerControls = memo(function PlayerControls({
if (progressThumbRef.current) progressThumbRef.current.style.left = `${pct}%`;
onSeek(percent * duration);
},
[duration, onSeek],
[disabled, duration, onSeek],
);
const handlePointerDown = useCallback(
@@ -204,7 +208,7 @@ export const PlayerControls = memo(function PlayerControls({
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (!timelineReady || duration <= 0) return;
if (disabled || !timelineReady || duration <= 0) return;
const step = e.shiftKey ? 10 : 1;
if (e.key === "ArrowLeft") {
e.preventDefault();
@@ -214,14 +218,15 @@ export const PlayerControls = memo(function PlayerControls({
onSeek(Math.min(duration, stepFrameTime(currentTimeRef.current, step)));
}
},
[timelineReady, duration, onSeek],
[disabled, timelineReady, duration, onSeek],
);
const commitJumpFrame = useCallback(() => {
if (disabled) return;
const frame = Number.parseInt(jumpFrame, 10);
if (!Number.isFinite(frame) || duration <= 0) return;
onSeek(Math.min(duration, frameToSeconds(Math.max(0, frame))));
}, [duration, jumpFrame, onSeek]);
}, [disabled, duration, jumpFrame, onSeek]);
const handleJumpSubmit = useCallback(
(e: React.FormEvent) => {
@@ -243,6 +248,7 @@ export const PlayerControls = memo(function PlayerControls({
return (
<div
className="px-4 py-2 flex flex-wrap items-center gap-x-2 gap-y-1"
aria-disabled={disabled || undefined}
style={{
borderTop: "1px solid rgba(255,255,255,0.04)",
// Add iOS safe-area inset so Safari's bottom URL bar doesn't occlude
@@ -256,7 +262,7 @@ export const PlayerControls = memo(function PlayerControls({
type="button"
aria-label={isPlaying ? "Pause" : "Play"}
onClick={onTogglePlay}
disabled={!timelineReady}
disabled={controlsDisabled}
className="flex-shrink-0 w-8 h-8 flex items-center justify-center rounded-lg disabled:opacity-30 disabled:pointer-events-none transition-colors"
style={{ background: "rgba(255,255,255,0.06)" }}
>
@@ -293,12 +299,15 @@ export const PlayerControls = memo(function PlayerControls({
(sliderRef as React.MutableRefObject<HTMLDivElement | null>).current = el;
}}
role="slider"
tabIndex={0}
tabIndex={disabled ? -1 : 0}
aria-label="Seek"
aria-disabled={disabled || undefined}
aria-valuemin={0}
aria-valuemax={Math.round(duration)}
aria-valuenow={0}
className="min-w-[96px] flex-1 h-6 flex items-center cursor-pointer group"
className={`min-w-[96px] flex-1 h-6 flex items-center group ${
disabled ? "cursor-not-allowed opacity-50" : "cursor-pointer"
}`}
// `touch-action: none` tells the browser we're handling every
// pointer gesture on this element ourselves. Without it, iOS
// Safari consumes horizontal swipes for its own swipe-back-to-
@@ -334,6 +343,7 @@ export const PlayerControls = memo(function PlayerControls({
<button
type="button"
onClick={() => setShowSpeedMenu((v) => !v)}
disabled={disabled}
className="w-10 px-2 py-1 rounded-md text-[10px] font-mono tabular-nums transition-colors"
style={{ color: "#71717A", background: "rgba(255,255,255,0.04)" }}
>
@@ -374,6 +384,7 @@ export const PlayerControls = memo(function PlayerControls({
<button
type="button"
onClick={() => setLoopEnabled(!loopEnabled)}
disabled={disabled}
className={`h-7 w-14 rounded-md border px-2 text-[10px] font-medium transition-colors ${
loopEnabled
? "text-studio-accent bg-studio-accent/10 border-studio-accent/30"
@@ -389,6 +400,7 @@ export const PlayerControls = memo(function PlayerControls({
<button
type="button"
onClick={() => setTimeDisplayMode((mode) => (mode === "time" ? "frame" : "time"))}
disabled={disabled}
className="h-7 w-14 rounded-md border border-neutral-700 px-2 text-[10px] font-mono text-neutral-300 transition-colors hover:border-neutral-500 hover:bg-neutral-800"
title="Toggle time/frame display"
aria-label="Toggle time and frame display"
@@ -403,6 +415,7 @@ export const PlayerControls = memo(function PlayerControls({
<input
value={jumpFrame}
onChange={(e) => setJumpFrame(e.target.value)}
disabled={disabled}
inputMode="numeric"
pattern="[0-9]*"
aria-label="Jump to frame"
@@ -340,6 +340,7 @@ interface TimelineProps {
layerChildCounts?: ReadonlyMap<string, number>;
thumbnailedElementIds?: ReadonlySet<string>;
onToggleElementThumbnail?: (element: import("../store/playerStore").TimelineElement) => void;
disabled?: boolean;
theme?: Partial<TimelineTheme>;
}
@@ -393,6 +394,7 @@ export const Timeline = memo(function Timeline({
layerChildCounts,
thumbnailedElementIds,
onToggleElementThumbnail,
disabled = false,
theme: themeOverrides,
}: TimelineProps = {}) {
const theme = useMemo(() => ({ ...defaultTimelineTheme, ...themeOverrides }), [themeOverrides]);
@@ -412,6 +414,8 @@ export const Timeline = memo(function Timeline({
const scrollRef = useRef<HTMLDivElement>(null);
const [hoveredClip, setHoveredClip] = useState<string | null>(null);
const isDragging = useRef(false);
const disabledRef = useRef(disabled);
disabledRef.current = disabled;
const shiftClickClipRef = useRef<{
element: TimelineElement;
anchorX: number;
@@ -498,6 +502,19 @@ export const Timeline = memo(function Timeline({
if (shortcutHintRafRef.current) cancelAnimationFrame(shortcutHintRafRef.current);
});
useEffect(() => {
if (!disabled) return;
stopClipDragAutoScrollRef.current();
isDragging.current = false;
isRangeSelecting.current = false;
blockedClipRef.current = null;
setDraggedClip(null);
setResizingClip(null);
setRangeSelection(null);
setShowPopover(false);
setIsDragOver(false);
}, [disabled]);
// Effective duration: max of store duration and the furthest element end.
// processTimelineMessage updates elements but not duration, so elements can
// extend beyond the store's duration — this ensures fit mode shows everything.
@@ -724,6 +741,7 @@ export const Timeline = memo(function Timeline({
const seekFromX = useCallback(
(clientX: number) => {
if (disabledRef.current) return;
const el = scrollRef.current;
if (!el || effectiveDuration <= 0) return;
const rect = el.getBoundingClientRect();
@@ -781,6 +799,7 @@ export const Timeline = memo(function Timeline({
};
const handleWindowPointerMove = (e: PointerEvent) => {
if (disabledRef.current) return;
const drag = draggedClipRef.current;
const resize = resizingClipRef.current;
const blocked = blockedClipRef.current;
@@ -865,6 +884,7 @@ export const Timeline = memo(function Timeline({
const handleWindowPointerUp = () => {
stopClipDragAutoScrollRef.current();
if (disabledRef.current) return;
const resize = resizingClipRef.current;
if (resize) {
resizingClipRef.current = null;
@@ -958,6 +978,7 @@ export const Timeline = memo(function Timeline({
useMountEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (disabledRef.current) return;
if (!shouldHandleTimelineDeleteKey(event)) return;
const selected = selectedElementRef.current;
const onDelete = onDeleteElementRef.current;
@@ -980,6 +1001,10 @@ export const Timeline = memo(function Timeline({
const handlePointerDown = useCallback(
(e: React.PointerEvent) => {
if (disabledRef.current) {
e.preventDefault();
return;
}
if (e.button !== 0) return;
// Shift+click starts range selection — even on clips
@@ -1011,6 +1036,7 @@ export const Timeline = memo(function Timeline({
);
const handlePointerMove = useCallback(
(e: React.PointerEvent) => {
if (disabledRef.current) return;
if (isRangeSelecting.current) {
const rect = scrollRef.current?.getBoundingClientRect();
if (rect) {
@@ -1081,6 +1107,7 @@ export const Timeline = memo(function Timeline({
const [isDragOver, setIsDragOver] = useState(false);
const handleAssetDragOver = useCallback((e: React.DragEvent) => {
if (disabledRef.current) return;
const hasFiles = e.dataTransfer.files.length > 0;
const hasAsset = Array.from(e.dataTransfer.types).includes(TIMELINE_ASSET_MIME);
if (!hasFiles && !hasAsset) return;
@@ -1095,6 +1122,7 @@ export const Timeline = memo(function Timeline({
(e: React.DragEvent) => {
e.preventDefault();
setIsDragOver(false);
if (disabledRef.current) return;
if (onFileDrop && e.dataTransfer.files.length > 0) {
const scroll = scrollRef.current;
const rect = scroll?.getBoundingClientRect();
@@ -1151,6 +1179,7 @@ export const Timeline = memo(function Timeline({
const handlePinchWheel = useCallback(
(e: WheelEvent) => {
if (disabledRef.current) return;
if (!e.ctrlKey) return;
const scroll = scrollRef.current;
if (!scroll || durationRef.current <= 0 || fitPpsRef.current <= 0 || ppsRef.current <= 0) {
@@ -1206,6 +1235,7 @@ export const Timeline = memo(function Timeline({
className={`h-full border-t bg-[#0a0a0b] flex flex-col select-none transition-colors duration-150 ${
isDragOver ? "border-studio-accent/50 bg-studio-accent/[0.03]" : "border-neutral-800/50"
}`}
aria-disabled={disabled || undefined}
onDragOver={handleAssetDragOver}
onDragLeave={() => setIsDragOver(false)}
onDrop={handleAssetDrop}
@@ -1361,7 +1391,14 @@ export const Timeline = memo(function Timeline({
<div
ref={setContainerRef}
aria-label="Timeline"
className={`relative border-t select-none h-full overflow-hidden ${shiftHeld ? "cursor-crosshair" : "cursor-default"}`}
aria-disabled={disabled || undefined}
className={`relative border-t select-none h-full overflow-hidden transition-opacity ${
disabled
? "cursor-not-allowed opacity-45"
: shiftHeld
? "cursor-crosshair"
: "cursor-default"
}`}
style={{
touchAction: "pan-x pan-y",
background: theme.shellBackground,