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(); 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; videoProps?: Omit< React.VideoHTMLAttributes, 'src' | 'poster' | 'className' | 'onClick' >; } function renderPreviewWithOverlay( media: React.ReactNode, showPlayOverlay: boolean, fit: 'cover' | 'contain' ): React.ReactElement { return (
{media} {showPlayOverlay && ( )}
); } export const VideoPosterPreview: React.FC = ({ 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(null); const activatedByClickRef = useRef(false); const retryTimerRef = useRef(null); const [resolvedPoster, setResolvedPoster] = useState(() => getCachedResolvedPoster(posterCandidate) ); const [extractedPoster, setExtractedPoster] = useState(() => 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 = ( event ) => { if (activateVideoOnClick) { activatedByClickRef.current = true; setShowVideo(true); } onClick?.(event); }; const handleVideoClick: React.MouseEventHandler = ( event ) => { onClick?.(event); }; const handleVideoLoadedData: React.ReactEventHandler = ( 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( {alt}, showPlayOverlay, fit ); } if (!shouldRenderVideoElement && !resolvedPoster) { return renderPreviewWithOverlay(