mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-04 07:19:52 +00:00
382 lines
12 KiB
TypeScript
382 lines
12 KiB
TypeScript
import { memo, useCallback, useEffect, useRef, useState } from "react";
|
|
import { setPreviewMediaMuted } from "../../player/lib/timelineIframeHelpers";
|
|
import { buildCompositionThumbnailUrl } from "../../player/components/CompositionThumbnail";
|
|
import { TIMELINE_COMPOSITION_MIME } from "../../utils/timelineCompositionDrop";
|
|
|
|
interface CompositionsTabProps {
|
|
projectId: string;
|
|
compositions: string[];
|
|
activeComposition: string | null;
|
|
onSelect: (comp: string) => void;
|
|
onRenderComposition?: (comp: string) => void;
|
|
onAddToTimeline?: (comp: string) => void;
|
|
isRendering?: boolean;
|
|
lintFindingsByFile?: Map<string, { count: number; messages: string[] }>;
|
|
}
|
|
|
|
const DEFAULT_PREVIEW_STAGE = { width: 1920, height: 1080 };
|
|
const CARD_W = 80;
|
|
const CARD_H = 45;
|
|
const THUMBNAIL_SEEK_TIME_SECONDS = 3;
|
|
const THUMBNAIL_PLAYBACK_SYNC_ATTEMPTS = 10;
|
|
|
|
type PreviewWindow = Window & {
|
|
__player?: {
|
|
play?: () => void;
|
|
pause?: () => void;
|
|
seek?: (time: number) => void;
|
|
getDuration?: () => number;
|
|
};
|
|
};
|
|
|
|
export function resolveCompositionPreviewScale(input: {
|
|
cardWidth: number;
|
|
cardHeight: number;
|
|
stageWidth: number;
|
|
stageHeight: number;
|
|
}): number {
|
|
const safeStageWidth =
|
|
Number.isFinite(input.stageWidth) && input.stageWidth > 0
|
|
? input.stageWidth
|
|
: DEFAULT_PREVIEW_STAGE.width;
|
|
const safeStageHeight =
|
|
Number.isFinite(input.stageHeight) && input.stageHeight > 0
|
|
? input.stageHeight
|
|
: DEFAULT_PREVIEW_STAGE.height;
|
|
const scaleX = input.cardWidth / safeStageWidth;
|
|
const scaleY = input.cardHeight / safeStageHeight;
|
|
return Math.min(scaleX, scaleY);
|
|
}
|
|
|
|
export function resolveThumbnailSeekTime(durationSeconds: number | null | undefined): number {
|
|
if (
|
|
Number.isFinite(durationSeconds) &&
|
|
durationSeconds != null &&
|
|
durationSeconds > 0 &&
|
|
durationSeconds < THUMBNAIL_SEEK_TIME_SECONDS
|
|
) {
|
|
return durationSeconds / 2;
|
|
}
|
|
|
|
return THUMBNAIL_SEEK_TIME_SECONDS;
|
|
}
|
|
|
|
function parsePositiveNumber(value: string | null): number | null {
|
|
if (value == null) return null;
|
|
const parsed = Number.parseFloat(value);
|
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
|
|
}
|
|
|
|
// fallow-ignore-next-line complexity
|
|
function resolveIframeDuration(iframe: HTMLIFrameElement | null): number | null {
|
|
try {
|
|
const win = iframe?.contentWindow as PreviewWindow | null;
|
|
const playerDuration = win?.__player?.getDuration?.();
|
|
if (Number.isFinite(playerDuration) && playerDuration != null && playerDuration > 0) {
|
|
return playerDuration;
|
|
}
|
|
} catch {
|
|
/* cross-origin iframe */
|
|
}
|
|
|
|
try {
|
|
const doc = iframe?.contentDocument;
|
|
const root = doc?.querySelector("[data-composition-id]") ?? doc?.documentElement ?? null;
|
|
return (
|
|
parsePositiveNumber(root?.getAttribute("data-composition-duration") ?? null) ??
|
|
parsePositiveNumber(root?.getAttribute("data-duration") ?? null)
|
|
);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export function syncIframePlayback(iframe: HTMLIFrameElement | null, shouldPlay: boolean): boolean {
|
|
try {
|
|
const player = (iframe?.contentWindow as PreviewWindow | null)?.__player;
|
|
if (!player) return false;
|
|
|
|
if (shouldPlay) {
|
|
setPreviewMediaMuted(iframe, true);
|
|
player.play?.();
|
|
return true;
|
|
}
|
|
|
|
player.pause?.();
|
|
player.seek?.(resolveThumbnailSeekTime(resolveIframeDuration(iframe)));
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function CompCard({
|
|
projectId,
|
|
comp,
|
|
isActive,
|
|
onSelect,
|
|
onRender,
|
|
isRendering,
|
|
lintInfo,
|
|
onAddToTimeline,
|
|
}: {
|
|
projectId: string;
|
|
comp: string;
|
|
isActive: boolean;
|
|
onSelect: () => void;
|
|
onRender?: () => void;
|
|
isRendering?: boolean;
|
|
lintInfo?: { count: number; messages: string[] };
|
|
onAddToTimeline?: () => void;
|
|
}) {
|
|
const [hovered, setHovered] = useState(false);
|
|
const [stageSize, setStageSize] = useState(DEFAULT_PREVIEW_STAGE);
|
|
const [livePreviewLoaded, setLivePreviewLoaded] = useState(false);
|
|
const [thumbnailFailed, setThumbnailFailed] = useState(false);
|
|
const iframeRef = useRef<HTMLIFrameElement | null>(null);
|
|
const hoverTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
const syncTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
const draggedRef = useRef(false);
|
|
|
|
const requestIframePlaybackSync = useCallback((shouldPlay: boolean) => {
|
|
if (syncTimer.current) {
|
|
clearTimeout(syncTimer.current);
|
|
syncTimer.current = null;
|
|
}
|
|
|
|
const sync = (remainingAttempts: number) => {
|
|
if (syncIframePlayback(iframeRef.current, shouldPlay) || remainingAttempts <= 0) return;
|
|
|
|
syncTimer.current = setTimeout(() => sync(remainingAttempts - 1), 100);
|
|
};
|
|
|
|
sync(THUMBNAIL_PLAYBACK_SYNC_ATTEMPTS);
|
|
}, []);
|
|
|
|
const handleEnter = () => {
|
|
hoverTimer.current = setTimeout(() => setHovered(true), 300);
|
|
};
|
|
const handleLeave = () => {
|
|
if (hoverTimer.current) {
|
|
clearTimeout(hoverTimer.current);
|
|
hoverTimer.current = null;
|
|
}
|
|
if (syncTimer.current) {
|
|
clearTimeout(syncTimer.current);
|
|
syncTimer.current = null;
|
|
}
|
|
setHovered(false);
|
|
setLivePreviewLoaded(false);
|
|
};
|
|
const name = comp.replace(/^compositions\//, "").replace(/\.html$/, "");
|
|
const previewUrl = `/api/projects/${projectId}/preview/comp/${comp}`;
|
|
const thumbnailUrl = buildCompositionThumbnailUrl({
|
|
previewUrl,
|
|
seekTime: THUMBNAIL_SEEK_TIME_SECONDS,
|
|
duration: 0,
|
|
origin: window.location.origin,
|
|
});
|
|
const previewScale = resolveCompositionPreviewScale({
|
|
cardWidth: CARD_W,
|
|
cardHeight: CARD_H,
|
|
stageWidth: stageSize.width,
|
|
stageHeight: stageSize.height,
|
|
});
|
|
const thumbnailOffsetX = (CARD_W - stageSize.width * previewScale) / 2;
|
|
const thumbnailOffsetY = (CARD_H - stageSize.height * previewScale) / 2;
|
|
|
|
useEffect(() => {
|
|
if (hovered) requestIframePlaybackSync(true);
|
|
}, [hovered, requestIframePlaybackSync]);
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
if (hoverTimer.current) clearTimeout(hoverTimer.current);
|
|
if (syncTimer.current) clearTimeout(syncTimer.current);
|
|
};
|
|
}, []);
|
|
|
|
return (
|
|
<div
|
|
role="button"
|
|
tabIndex={0}
|
|
draggable
|
|
onDragStart={(event) => {
|
|
draggedRef.current = true;
|
|
event.dataTransfer.effectAllowed = "copy";
|
|
event.dataTransfer.setData(TIMELINE_COMPOSITION_MIME, JSON.stringify({ sourcePath: comp }));
|
|
}}
|
|
onDragEnd={() => {
|
|
window.setTimeout(() => {
|
|
draggedRef.current = false;
|
|
}, 0);
|
|
}}
|
|
onClick={() => {
|
|
if (!draggedRef.current) onSelect();
|
|
}}
|
|
onKeyDown={(event) => {
|
|
if (event.target !== event.currentTarget) return;
|
|
if (event.key === "Enter" || event.key === " ") {
|
|
event.preventDefault();
|
|
onSelect();
|
|
}
|
|
}}
|
|
onPointerEnter={handleEnter}
|
|
onPointerLeave={handleLeave}
|
|
className={`group/card w-full select-none text-left px-2 py-1.5 flex items-center gap-2.5 transition-colors cursor-grab active:cursor-grabbing ${
|
|
isActive
|
|
? "bg-studio-accent/10 border-l-2 border-studio-accent"
|
|
: "border-l-2 border-transparent hover:bg-neutral-800/50"
|
|
}`}
|
|
>
|
|
<div className="w-20 h-[45px] rounded overflow-hidden bg-neutral-900 flex-shrink-0 relative">
|
|
{thumbnailFailed ? (
|
|
<div className="absolute inset-0 flex items-center justify-center px-1 text-center text-[8px] leading-tight text-neutral-600">
|
|
Preview unavailable
|
|
</div>
|
|
) : (
|
|
<img
|
|
src={thumbnailUrl}
|
|
alt=""
|
|
draggable={false}
|
|
loading="lazy"
|
|
decoding="async"
|
|
onError={() => setThumbnailFailed(true)}
|
|
className={`absolute inset-0 h-full w-full object-contain transition-opacity ${
|
|
livePreviewLoaded ? "opacity-0" : "opacity-100"
|
|
}`}
|
|
/>
|
|
)}
|
|
{hovered && (
|
|
<iframe
|
|
ref={iframeRef}
|
|
src={previewUrl}
|
|
sandbox="allow-scripts allow-same-origin"
|
|
className="absolute border-none pointer-events-none"
|
|
style={{
|
|
transformOrigin: "0 0",
|
|
width: stageSize.width,
|
|
height: stageSize.height,
|
|
left: thumbnailOffsetX,
|
|
top: thumbnailOffsetY,
|
|
transform: `scale(${previewScale})`,
|
|
}}
|
|
onLoad={(e) => {
|
|
try {
|
|
const iframe = e.currentTarget;
|
|
const root = iframe.contentDocument?.querySelector("[data-composition-id]");
|
|
const width =
|
|
Number(root?.getAttribute("data-width")) || DEFAULT_PREVIEW_STAGE.width;
|
|
const height =
|
|
Number(root?.getAttribute("data-height")) || DEFAULT_PREVIEW_STAGE.height;
|
|
setStageSize({ width, height });
|
|
setLivePreviewLoaded(true);
|
|
requestIframePlaybackSync(true);
|
|
} catch {
|
|
setStageSize(DEFAULT_PREVIEW_STAGE);
|
|
}
|
|
}}
|
|
title={`${name} preview`}
|
|
tabIndex={-1}
|
|
/>
|
|
)}
|
|
</div>
|
|
<div
|
|
className="min-w-0 flex-1"
|
|
title={lintInfo && lintInfo.count > 0 ? lintInfo.messages.join("\n") : undefined}
|
|
>
|
|
<div className="flex items-center gap-1">
|
|
<span className="text-[11px] font-medium text-neutral-300 truncate">{name}</span>
|
|
{lintInfo && lintInfo.count > 0 && (
|
|
<span className="flex-shrink-0 w-2 h-2 rounded-full bg-amber-400" />
|
|
)}
|
|
</div>
|
|
<span className="text-[9px] text-neutral-600 truncate block">{comp}</span>
|
|
</div>
|
|
{onAddToTimeline && (
|
|
<button
|
|
type="button"
|
|
title={`Add ${name} to timeline at playhead`}
|
|
aria-label={`Add ${name} to timeline at playhead`}
|
|
onClick={(event) => {
|
|
event.stopPropagation();
|
|
onAddToTimeline();
|
|
}}
|
|
className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded text-neutral-600 opacity-0 transition-[color,background-color,opacity] hover:bg-neutral-800 hover:text-studio-accent group-hover/card:opacity-100 group-focus-within/card:opacity-100 focus:opacity-100"
|
|
>
|
|
<span aria-hidden="true">+</span>
|
|
</button>
|
|
)}
|
|
{onRender && (
|
|
<button
|
|
type="button"
|
|
title={isRendering ? "Rendering..." : `Render ${name}`}
|
|
aria-label={isRendering ? "Rendering..." : `Render ${name}`}
|
|
disabled={isRendering}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onRender();
|
|
}}
|
|
className={`flex-shrink-0 p-1 rounded transition-colors ${
|
|
isRendering
|
|
? "text-neutral-600 cursor-not-allowed"
|
|
: "text-neutral-600 hover:text-studio-accent hover:bg-neutral-800"
|
|
}`}
|
|
>
|
|
<svg
|
|
width="14"
|
|
height="14"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
>
|
|
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4" />
|
|
<polyline points="7 10 12 15 17 10" />
|
|
<line x1="12" y1="15" x2="12" y2="3" />
|
|
</svg>
|
|
</button>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export const CompositionsTab = memo(function CompositionsTab({
|
|
projectId,
|
|
compositions,
|
|
activeComposition,
|
|
onSelect,
|
|
onRenderComposition,
|
|
onAddToTimeline,
|
|
isRendering,
|
|
lintFindingsByFile,
|
|
}: CompositionsTabProps) {
|
|
if (compositions.length === 0) {
|
|
return (
|
|
<div className="flex-1 flex items-center justify-center px-4">
|
|
<p className="text-xs text-neutral-600 text-center">No compositions found</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="flex-1 overflow-y-auto">
|
|
{compositions.map((comp) => (
|
|
<CompCard
|
|
key={`${projectId}:${comp}`}
|
|
projectId={projectId}
|
|
comp={comp}
|
|
isActive={activeComposition === comp}
|
|
onSelect={() => onSelect(comp)}
|
|
onRender={onRenderComposition ? () => onRenderComposition(comp) : undefined}
|
|
onAddToTimeline={onAddToTimeline ? () => onAddToTimeline(comp) : undefined}
|
|
isRendering={isRendering}
|
|
lintInfo={lintFindingsByFile?.get(comp)}
|
|
/>
|
|
))}
|
|
</div>
|
|
);
|
|
});
|