666 lines
17 KiB
TypeScript
666 lines
17 KiB
TypeScript
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
|
import { PlayCircleIcon } from 'tdesign-icons-react';
|
|
import { isCanvasFrameMostlyBlack } from '@aitu/utils';
|
|
import { useThumbnailUrl } from '../../hooks/useThumbnailUrl';
|
|
import './VideoPosterPreview.scss';
|
|
|
|
const THUMBNAIL_PLACEHOLDER_SIZE = 1;
|
|
const MAX_THUMBNAIL_RETRIES = 4;
|
|
const FAILED_POSTER_TTL_MS = 10 * 60 * 1000;
|
|
const RESOLVED_POSTER_TTL_MS = 10 * 60 * 1000;
|
|
const failedPosterCache = new Map<string, number>();
|
|
const resolvedPosterCache = new Map<
|
|
string,
|
|
{ resolvedPoster: string; resolvedAt: number }
|
|
>();
|
|
const extractedVideoPosterCache = new Map<
|
|
string,
|
|
{ poster: string; capturedAt: number }
|
|
>();
|
|
const videoPosterProbeTimes = [0.6, 1.2, 2, 0.12];
|
|
|
|
function cleanupFailedPosterCache(now: number): void {
|
|
failedPosterCache.forEach((failedAt, url) => {
|
|
if (now - failedAt > FAILED_POSTER_TTL_MS) {
|
|
failedPosterCache.delete(url);
|
|
}
|
|
});
|
|
}
|
|
|
|
function markPosterAsFailed(url: string): void {
|
|
const now = Date.now();
|
|
cleanupFailedPosterCache(now);
|
|
failedPosterCache.set(url, now);
|
|
}
|
|
|
|
function hasRecentFailedPoster(url: string): boolean {
|
|
const now = Date.now();
|
|
cleanupFailedPosterCache(now);
|
|
const failedAt = failedPosterCache.get(url);
|
|
return typeof failedAt === 'number' && now - failedAt <= FAILED_POSTER_TTL_MS;
|
|
}
|
|
|
|
function cleanupResolvedPosterCache(now: number): void {
|
|
resolvedPosterCache.forEach((entry, url) => {
|
|
if (now - entry.resolvedAt > RESOLVED_POSTER_TTL_MS) {
|
|
resolvedPosterCache.delete(url);
|
|
}
|
|
});
|
|
}
|
|
|
|
function cacheResolvedPoster(url: string, resolvedPoster: string): void {
|
|
const now = Date.now();
|
|
cleanupResolvedPosterCache(now);
|
|
resolvedPosterCache.set(url, { resolvedPoster, resolvedAt: now });
|
|
}
|
|
|
|
function getCachedResolvedPoster(url: string | undefined): string | null {
|
|
if (!url) {
|
|
return null;
|
|
}
|
|
|
|
const now = Date.now();
|
|
cleanupResolvedPosterCache(now);
|
|
const entry = resolvedPosterCache.get(url);
|
|
if (!entry) {
|
|
return null;
|
|
}
|
|
|
|
return now - entry.resolvedAt <= RESOLVED_POSTER_TTL_MS
|
|
? entry.resolvedPoster
|
|
: null;
|
|
}
|
|
|
|
function withPosterRetryParam(url: string, attempt: number): string {
|
|
if (attempt <= 0 || !url) {
|
|
return url;
|
|
}
|
|
|
|
try {
|
|
const resolved = new URL(url, window.location.origin);
|
|
resolved.searchParams.set('_poster_retry', String(attempt));
|
|
return resolved.toString();
|
|
} catch {
|
|
const separator = url.includes('?') ? '&' : '?';
|
|
return `${url}${separator}_poster_retry=${attempt}`;
|
|
}
|
|
}
|
|
|
|
function isImageMostlyBlack(image: HTMLImageElement): boolean {
|
|
const sourceWidth = image.naturalWidth;
|
|
const sourceHeight = image.naturalHeight;
|
|
if (
|
|
!sourceWidth ||
|
|
!sourceHeight ||
|
|
sourceWidth <= THUMBNAIL_PLACEHOLDER_SIZE ||
|
|
sourceHeight <= THUMBNAIL_PLACEHOLDER_SIZE
|
|
) {
|
|
return false;
|
|
}
|
|
|
|
const maxSide = 160;
|
|
const scale = Math.min(1, maxSide / Math.max(sourceWidth, sourceHeight));
|
|
const width = Math.max(1, Math.round(sourceWidth * scale));
|
|
const height = Math.max(1, Math.round(sourceHeight * scale));
|
|
|
|
try {
|
|
const canvas = document.createElement('canvas');
|
|
canvas.width = width;
|
|
canvas.height = height;
|
|
const context = canvas.getContext('2d');
|
|
if (!context) {
|
|
return false;
|
|
}
|
|
context.drawImage(image, 0, 0, width, height);
|
|
return isCanvasFrameMostlyBlack(context, width, height);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function cacheExtractedVideoPoster(src: string, poster: string): void {
|
|
const now = Date.now();
|
|
extractedVideoPosterCache.forEach((entry, url) => {
|
|
if (now - entry.capturedAt > RESOLVED_POSTER_TTL_MS) {
|
|
extractedVideoPosterCache.delete(url);
|
|
}
|
|
});
|
|
extractedVideoPosterCache.set(src, { poster, capturedAt: now });
|
|
}
|
|
|
|
function getCachedExtractedVideoPoster(src: string): string | null {
|
|
const entry = extractedVideoPosterCache.get(src);
|
|
if (!entry) {
|
|
return null;
|
|
}
|
|
|
|
if (Date.now() - entry.capturedAt > RESOLVED_POSTER_TTL_MS) {
|
|
extractedVideoPosterCache.delete(src);
|
|
return null;
|
|
}
|
|
|
|
return entry.poster;
|
|
}
|
|
|
|
function captureVideoFrame(video: HTMLVideoElement): string | null {
|
|
const sourceWidth = video.videoWidth;
|
|
const sourceHeight = video.videoHeight;
|
|
if (!sourceWidth || !sourceHeight) {
|
|
return null;
|
|
}
|
|
|
|
const maxSide = 720;
|
|
const scale = Math.min(1, maxSide / Math.max(sourceWidth, sourceHeight));
|
|
const width = Math.max(1, Math.round(sourceWidth * scale));
|
|
const height = Math.max(1, Math.round(sourceHeight * scale));
|
|
|
|
try {
|
|
const canvas = document.createElement('canvas');
|
|
canvas.width = width;
|
|
canvas.height = height;
|
|
const context = canvas.getContext('2d');
|
|
if (!context) {
|
|
return null;
|
|
}
|
|
context.drawImage(video, 0, 0, width, height);
|
|
if (isCanvasFrameMostlyBlack(context, width, height)) {
|
|
return null;
|
|
}
|
|
return canvas.toDataURL('image/jpeg', 0.78);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function shouldCaptureWithCors(src: string): boolean {
|
|
try {
|
|
return new URL(src, window.location.href).origin !== window.location.origin;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
export interface VideoPosterPreviewProps {
|
|
src: string;
|
|
poster?: string;
|
|
alt?: string;
|
|
className?: string;
|
|
fit?: 'cover' | 'contain';
|
|
thumbnailSize?: 'small' | 'large';
|
|
imageLoading?: 'lazy' | 'eager';
|
|
activateVideoOnClick?: boolean;
|
|
playOnActivate?: boolean;
|
|
playOnHover?: boolean;
|
|
onClick?: React.MouseEventHandler<HTMLImageElement | HTMLVideoElement>;
|
|
videoProps?: Omit<
|
|
React.VideoHTMLAttributes<HTMLVideoElement>,
|
|
'src' | 'poster' | 'className' | 'onClick'
|
|
>;
|
|
}
|
|
|
|
function renderPreviewWithOverlay(
|
|
media: React.ReactNode,
|
|
showPlayOverlay: boolean,
|
|
fit: 'cover' | 'contain'
|
|
): React.ReactElement {
|
|
return (
|
|
<div className={`video-poster-preview video-poster-preview--fit-${fit}`}>
|
|
{media}
|
|
{showPlayOverlay && (
|
|
<div className="video-poster-preview__play-overlay" aria-hidden="true">
|
|
<PlayCircleIcon size="32px" />
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export const VideoPosterPreview: React.FC<VideoPosterPreviewProps> = ({
|
|
src,
|
|
poster,
|
|
alt = '',
|
|
className,
|
|
fit = 'cover',
|
|
thumbnailSize = 'small',
|
|
imageLoading = 'lazy',
|
|
activateVideoOnClick = false,
|
|
playOnActivate = false,
|
|
playOnHover = false,
|
|
onClick,
|
|
videoProps,
|
|
}) => {
|
|
const normalizedPoster = useMemo(
|
|
() =>
|
|
typeof poster === 'string' && poster.trim() ? poster.trim() : undefined,
|
|
[poster]
|
|
);
|
|
const shouldIgnoreExplicitPoster = useMemo(
|
|
() => (normalizedPoster ? hasRecentFailedPoster(normalizedPoster) : false),
|
|
[normalizedPoster]
|
|
);
|
|
const generatedPoster = useThumbnailUrl(src, 'video', thumbnailSize);
|
|
const [preferGeneratedPoster, setPreferGeneratedPoster] = useState(
|
|
shouldIgnoreExplicitPoster
|
|
);
|
|
const posterCandidate = preferGeneratedPoster
|
|
? generatedPoster
|
|
: normalizedPoster || generatedPoster;
|
|
const hasExplicitPoster = Boolean(normalizedPoster && !preferGeneratedPoster);
|
|
const canRetryGeneratedPoster = Boolean(
|
|
posterCandidate &&
|
|
posterCandidate === generatedPoster &&
|
|
posterCandidate !== src
|
|
);
|
|
const videoRef = useRef<HTMLVideoElement>(null);
|
|
const activatedByClickRef = useRef(false);
|
|
const retryTimerRef = useRef<number | null>(null);
|
|
const [resolvedPoster, setResolvedPoster] = useState<string | null>(() =>
|
|
getCachedResolvedPoster(posterCandidate)
|
|
);
|
|
const [extractedPoster, setExtractedPoster] = useState<string | null>(() =>
|
|
getCachedExtractedVideoPoster(src)
|
|
);
|
|
const [retryCount, setRetryCount] = useState(0);
|
|
const [showVideo, setShowVideo] = useState(false);
|
|
const shouldRequireExplicitActivation = activateVideoOnClick;
|
|
const displayPoster =
|
|
playOnHover && extractedPoster
|
|
? extractedPoster
|
|
: resolvedPoster || extractedPoster;
|
|
const shouldRenderVideoElement = showVideo || (playOnHover && !displayPoster);
|
|
const videoPoster = displayPoster || undefined;
|
|
|
|
useEffect(() => {
|
|
setPreferGeneratedPoster(shouldIgnoreExplicitPoster);
|
|
}, [shouldIgnoreExplicitPoster]);
|
|
|
|
useEffect(() => {
|
|
setResolvedPoster(getCachedResolvedPoster(posterCandidate));
|
|
setExtractedPoster(getCachedExtractedVideoPoster(src));
|
|
setRetryCount(0);
|
|
setShowVideo(false);
|
|
activatedByClickRef.current = false;
|
|
if (retryTimerRef.current !== null) {
|
|
window.clearTimeout(retryTimerRef.current);
|
|
retryTimerRef.current = null;
|
|
}
|
|
}, [
|
|
src,
|
|
posterCandidate,
|
|
activateVideoOnClick,
|
|
generatedPoster,
|
|
normalizedPoster,
|
|
thumbnailSize,
|
|
]);
|
|
|
|
useEffect(() => {
|
|
if (showVideo || (resolvedPoster && resolvedPoster === posterCandidate)) {
|
|
return;
|
|
}
|
|
|
|
if (!posterCandidate) {
|
|
if (!shouldRequireExplicitActivation) {
|
|
setShowVideo(true);
|
|
}
|
|
return;
|
|
}
|
|
|
|
const imageUrl = hasExplicitPoster
|
|
? posterCandidate
|
|
: withPosterRetryParam(posterCandidate, retryCount);
|
|
let cancelled = false;
|
|
const probeImage = new Image();
|
|
|
|
const scheduleRetry = () => {
|
|
if (!canRetryGeneratedPoster || retryCount >= MAX_THUMBNAIL_RETRIES) {
|
|
if (!shouldRequireExplicitActivation) {
|
|
setShowVideo(true);
|
|
}
|
|
return;
|
|
}
|
|
|
|
retryTimerRef.current = window.setTimeout(() => {
|
|
setRetryCount((value) => value + 1);
|
|
}, 180 + retryCount * 220);
|
|
};
|
|
|
|
probeImage.onload = () => {
|
|
if (cancelled) {
|
|
return;
|
|
}
|
|
|
|
const isUsablePoster =
|
|
(probeImage.naturalWidth > THUMBNAIL_PLACEHOLDER_SIZE ||
|
|
probeImage.naturalHeight > THUMBNAIL_PLACEHOLDER_SIZE) &&
|
|
!isImageMostlyBlack(probeImage);
|
|
|
|
if (isUsablePoster) {
|
|
cacheResolvedPoster(posterCandidate, posterCandidate);
|
|
setResolvedPoster(posterCandidate);
|
|
return;
|
|
}
|
|
|
|
if (hasExplicitPoster && normalizedPoster) {
|
|
markPosterAsFailed(normalizedPoster);
|
|
setPreferGeneratedPoster(true);
|
|
setResolvedPoster(null);
|
|
setRetryCount(0);
|
|
return;
|
|
}
|
|
|
|
scheduleRetry();
|
|
};
|
|
|
|
probeImage.onerror = () => {
|
|
if (cancelled) {
|
|
return;
|
|
}
|
|
|
|
if (hasExplicitPoster && normalizedPoster) {
|
|
markPosterAsFailed(normalizedPoster);
|
|
setPreferGeneratedPoster(true);
|
|
setResolvedPoster(null);
|
|
setRetryCount(0);
|
|
return;
|
|
}
|
|
|
|
scheduleRetry();
|
|
};
|
|
|
|
probeImage.src = imageUrl;
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
probeImage.onload = null;
|
|
probeImage.onerror = null;
|
|
if (retryTimerRef.current !== null) {
|
|
window.clearTimeout(retryTimerRef.current);
|
|
retryTimerRef.current = null;
|
|
}
|
|
};
|
|
}, [
|
|
src,
|
|
posterCandidate,
|
|
retryCount,
|
|
hasExplicitPoster,
|
|
canRetryGeneratedPoster,
|
|
showVideo,
|
|
normalizedPoster,
|
|
resolvedPoster,
|
|
shouldRequireExplicitActivation,
|
|
]);
|
|
|
|
useEffect(() => {
|
|
if (!playOnHover || extractedPoster) {
|
|
return;
|
|
}
|
|
|
|
const cachedPoster = getCachedExtractedVideoPoster(src);
|
|
if (cachedPoster) {
|
|
setExtractedPoster(cachedPoster);
|
|
return;
|
|
}
|
|
|
|
let cancelled = false;
|
|
const probeVideo = document.createElement('video');
|
|
probeVideo.muted = true;
|
|
probeVideo.playsInline = true;
|
|
probeVideo.preload = 'auto';
|
|
if (shouldCaptureWithCors(src)) {
|
|
probeVideo.crossOrigin = 'anonymous';
|
|
}
|
|
|
|
let seekIndex = 0;
|
|
|
|
const nextProbeTime = (): number | null => {
|
|
const duration = Number.isFinite(probeVideo.duration)
|
|
? probeVideo.duration
|
|
: 0;
|
|
while (seekIndex < videoPosterProbeTimes.length) {
|
|
const nextTime = videoPosterProbeTimes[seekIndex];
|
|
seekIndex += 1;
|
|
if (duration > 0 && nextTime >= duration) {
|
|
continue;
|
|
}
|
|
return duration > 0 ? Math.min(nextTime, duration - 0.05) : nextTime;
|
|
}
|
|
return null;
|
|
};
|
|
|
|
const seekNextFrame = () => {
|
|
const targetTime = nextProbeTime();
|
|
if (targetTime === null) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
if (Math.abs(probeVideo.currentTime - targetTime) < 0.03) {
|
|
capture();
|
|
} else {
|
|
probeVideo.currentTime = targetTime;
|
|
}
|
|
} catch {
|
|
seekNextFrame();
|
|
}
|
|
};
|
|
|
|
const capture = () => {
|
|
if (cancelled) {
|
|
return;
|
|
}
|
|
|
|
const posterDataUrl = captureVideoFrame(probeVideo);
|
|
if (!posterDataUrl) {
|
|
seekNextFrame();
|
|
return;
|
|
}
|
|
|
|
cacheExtractedVideoPoster(src, posterDataUrl);
|
|
setExtractedPoster(posterDataUrl);
|
|
};
|
|
|
|
const seekFirstFrame = () => {
|
|
capture();
|
|
seekNextFrame();
|
|
};
|
|
|
|
probeVideo.addEventListener('loadedmetadata', seekFirstFrame);
|
|
probeVideo.addEventListener('loadeddata', capture);
|
|
probeVideo.addEventListener('seeked', capture);
|
|
probeVideo.src = src;
|
|
probeVideo.load();
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
probeVideo.removeEventListener('loadedmetadata', seekFirstFrame);
|
|
probeVideo.removeEventListener('loadeddata', capture);
|
|
probeVideo.removeEventListener('seeked', capture);
|
|
try {
|
|
probeVideo.removeAttribute('src');
|
|
probeVideo.load();
|
|
} catch {
|
|
// Ignore cleanup errors from partially loaded media.
|
|
}
|
|
};
|
|
}, [src, playOnHover, extractedPoster]);
|
|
|
|
useEffect(() => {
|
|
if (
|
|
!shouldRenderVideoElement ||
|
|
!playOnActivate ||
|
|
!activatedByClickRef.current
|
|
) {
|
|
return;
|
|
}
|
|
|
|
activatedByClickRef.current = false;
|
|
videoRef.current?.play().catch(() => {
|
|
// Browsers can block programmatic playback; the card still opens preview on click.
|
|
});
|
|
}, [shouldRenderVideoElement, playOnActivate]);
|
|
|
|
useEffect(() => {
|
|
if (!playOnHover || !showVideo || !shouldRenderVideoElement) {
|
|
return;
|
|
}
|
|
|
|
const video = videoRef.current;
|
|
if (!video) {
|
|
return;
|
|
}
|
|
|
|
const playPromise = video.play();
|
|
if (playPromise && typeof playPromise.catch === 'function') {
|
|
playPromise.catch(() => {
|
|
// Hover playback is a preview nicety; browsers may still block it.
|
|
});
|
|
}
|
|
}, [playOnHover, showVideo, shouldRenderVideoElement]);
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
const video = videoRef.current;
|
|
if (!video) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
video.pause();
|
|
video.removeAttribute('src');
|
|
video.load();
|
|
} catch {
|
|
// 忽略释放失败
|
|
}
|
|
};
|
|
}, []);
|
|
|
|
const handlePosterClick: React.MouseEventHandler<HTMLImageElement> = (
|
|
event
|
|
) => {
|
|
if (activateVideoOnClick) {
|
|
activatedByClickRef.current = true;
|
|
setShowVideo(true);
|
|
}
|
|
onClick?.(event);
|
|
};
|
|
|
|
const handleVideoClick: React.MouseEventHandler<HTMLVideoElement> = (
|
|
event
|
|
) => {
|
|
onClick?.(event);
|
|
};
|
|
|
|
const handleVideoLoadedData: React.ReactEventHandler<HTMLVideoElement> = (
|
|
event
|
|
) => {
|
|
videoProps?.onLoadedData?.(event);
|
|
if (!playOnHover || extractedPoster) {
|
|
return;
|
|
}
|
|
|
|
const posterDataUrl = captureVideoFrame(event.currentTarget);
|
|
if (!posterDataUrl) {
|
|
return;
|
|
}
|
|
|
|
cacheExtractedVideoPoster(src, posterDataUrl);
|
|
setExtractedPoster(posterDataUrl);
|
|
};
|
|
|
|
const handlePreviewMouseEnter: React.MouseEventHandler<
|
|
HTMLImageElement | HTMLDivElement | HTMLVideoElement
|
|
> = () => {
|
|
if (!playOnHover) {
|
|
return;
|
|
}
|
|
|
|
setShowVideo(true);
|
|
};
|
|
|
|
const handlePreviewMouseLeave: React.MouseEventHandler<
|
|
HTMLImageElement | HTMLDivElement | HTMLVideoElement
|
|
> = () => {
|
|
if (!playOnHover) {
|
|
return;
|
|
}
|
|
|
|
const video = videoRef.current;
|
|
if (video) {
|
|
try {
|
|
video.pause();
|
|
video.currentTime = 0;
|
|
} catch {
|
|
// Ignore browsers that reject seeking before metadata is ready.
|
|
}
|
|
}
|
|
|
|
setShowVideo(false);
|
|
};
|
|
|
|
const showPlayOverlay = activateVideoOnClick && !showVideo;
|
|
|
|
if (!shouldRenderVideoElement && displayPoster) {
|
|
return renderPreviewWithOverlay(
|
|
<img
|
|
src={displayPoster}
|
|
alt={alt}
|
|
className={`video-poster-preview__media${
|
|
className ? ` ${className}` : ''
|
|
}`}
|
|
loading={imageLoading}
|
|
decoding="async"
|
|
draggable={false}
|
|
referrerPolicy="no-referrer"
|
|
onClick={handlePosterClick}
|
|
onMouseEnter={handlePreviewMouseEnter}
|
|
onMouseLeave={handlePreviewMouseLeave}
|
|
/>,
|
|
showPlayOverlay,
|
|
fit
|
|
);
|
|
}
|
|
|
|
if (!shouldRenderVideoElement && !resolvedPoster) {
|
|
return renderPreviewWithOverlay(
|
|
<div
|
|
className={`video-poster-preview__placeholder${
|
|
className ? ` ${className}` : ''
|
|
}`}
|
|
aria-hidden="true"
|
|
onClick={
|
|
activateVideoOnClick
|
|
? () => {
|
|
activatedByClickRef.current = true;
|
|
setShowVideo(true);
|
|
}
|
|
: undefined
|
|
}
|
|
onMouseEnter={handlePreviewMouseEnter}
|
|
onMouseLeave={handlePreviewMouseLeave}
|
|
/>,
|
|
showPlayOverlay,
|
|
fit
|
|
);
|
|
}
|
|
|
|
return renderPreviewWithOverlay(
|
|
<video
|
|
ref={videoRef}
|
|
src={src}
|
|
poster={videoPoster}
|
|
className={`video-poster-preview__media${
|
|
className ? ` ${className}` : ''
|
|
}`}
|
|
playsInline
|
|
{...videoProps}
|
|
muted={playOnHover || videoProps?.muted}
|
|
onClick={handleVideoClick}
|
|
onLoadedData={handleVideoLoadedData}
|
|
onMouseEnter={handlePreviewMouseEnter}
|
|
onMouseLeave={handlePreviewMouseLeave}
|
|
/>,
|
|
false,
|
|
fit
|
|
);
|
|
};
|