Files
TrueGrowth/packages/drawnix/src/components/shared/VideoPosterPreview.tsx
jiam 5119ac0ef8
Some checks failed
CI / main (push) Has been cancelled
CI / release-e2e (push) Has been cancelled
Sync latest TrueGrowth updates
2026-07-08 02:03:18 +08:00

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
);
};