Sync latest TrueGrowth updates
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
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';
|
||||
|
||||
@@ -8,7 +9,15 @@ 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 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) => {
|
||||
@@ -57,7 +66,9 @@ function getCachedResolvedPoster(url: string | undefined): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
return now - entry.resolvedAt <= RESOLVED_POSTER_TTL_MS ? entry.resolvedPoster : null;
|
||||
return now - entry.resolvedAt <= RESOLVED_POSTER_TTL_MS
|
||||
? entry.resolvedPoster
|
||||
: null;
|
||||
}
|
||||
|
||||
function withPosterRetryParam(url: string, attempt: number): string {
|
||||
@@ -75,15 +86,111 @@ function withPosterRetryParam(url: string, attempt: number): string {
|
||||
}
|
||||
}
|
||||
|
||||
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>,
|
||||
@@ -93,10 +200,11 @@ export interface VideoPosterPreviewProps {
|
||||
|
||||
function renderPreviewWithOverlay(
|
||||
media: React.ReactNode,
|
||||
showPlayOverlay: boolean
|
||||
showPlayOverlay: boolean,
|
||||
fit: 'cover' | 'contain'
|
||||
): React.ReactElement {
|
||||
return (
|
||||
<div className="video-poster-preview">
|
||||
<div className={`video-poster-preview video-poster-preview--fit-${fit}`}>
|
||||
{media}
|
||||
{showPlayOverlay && (
|
||||
<div className="video-poster-preview__play-overlay" aria-hidden="true">
|
||||
@@ -112,39 +220,55 @@ export const VideoPosterPreview: React.FC<VideoPosterPreviewProps> = ({
|
||||
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),
|
||||
() =>
|
||||
typeof poster === 'string' && poster.trim() ? poster.trim() : undefined,
|
||||
[poster]
|
||||
);
|
||||
const shouldIgnoreExplicitPoster = useMemo(
|
||||
() => (normalizedPoster ? hasRecentFailedPoster(normalizedPoster) : false),
|
||||
[normalizedPoster]
|
||||
);
|
||||
const generatedPoster = useThumbnailUrl(
|
||||
src,
|
||||
'video',
|
||||
thumbnailSize
|
||||
const generatedPoster = useThumbnailUrl(src, 'video', thumbnailSize);
|
||||
const [preferGeneratedPoster, setPreferGeneratedPoster] = useState(
|
||||
shouldIgnoreExplicitPoster
|
||||
);
|
||||
const [preferGeneratedPoster, setPreferGeneratedPoster] = useState(shouldIgnoreExplicitPoster);
|
||||
const posterCandidate = preferGeneratedPoster ? generatedPoster : (normalizedPoster || generatedPoster);
|
||||
const posterCandidate = preferGeneratedPoster
|
||||
? generatedPoster
|
||||
: normalizedPoster || generatedPoster;
|
||||
const hasExplicitPoster = Boolean(normalizedPoster && !preferGeneratedPoster);
|
||||
const canRetryGeneratedPoster = Boolean(
|
||||
posterCandidate && posterCandidate === generatedPoster && posterCandidate !== src
|
||||
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 [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);
|
||||
@@ -152,6 +276,7 @@ export const VideoPosterPreview: React.FC<VideoPosterPreviewProps> = ({
|
||||
|
||||
useEffect(() => {
|
||||
setResolvedPoster(getCachedResolvedPoster(posterCandidate));
|
||||
setExtractedPoster(getCachedExtractedVideoPoster(src));
|
||||
setRetryCount(0);
|
||||
setShowVideo(false);
|
||||
activatedByClickRef.current = false;
|
||||
@@ -159,7 +284,14 @@ export const VideoPosterPreview: React.FC<VideoPosterPreviewProps> = ({
|
||||
window.clearTimeout(retryTimerRef.current);
|
||||
retryTimerRef.current = null;
|
||||
}
|
||||
}, [src, posterCandidate, activateVideoOnClick, generatedPoster, normalizedPoster, thumbnailSize]);
|
||||
}, [
|
||||
src,
|
||||
posterCandidate,
|
||||
activateVideoOnClick,
|
||||
generatedPoster,
|
||||
normalizedPoster,
|
||||
thumbnailSize,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (showVideo || (resolvedPoster && resolvedPoster === posterCandidate)) {
|
||||
@@ -197,16 +329,25 @@ export const VideoPosterPreview: React.FC<VideoPosterPreviewProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
hasExplicitPoster ||
|
||||
probeImage.naturalWidth > THUMBNAIL_PLACEHOLDER_SIZE ||
|
||||
probeImage.naturalHeight > THUMBNAIL_PLACEHOLDER_SIZE
|
||||
) {
|
||||
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();
|
||||
};
|
||||
|
||||
@@ -250,15 +391,131 @@ export const VideoPosterPreview: React.FC<VideoPosterPreviewProps> = ({
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showVideo || !playOnActivate || !activatedByClickRef.current) {
|
||||
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(() => {
|
||||
// 浏览器策略可能阻止自动播放,保留 controls 让用户继续点播
|
||||
// Browsers can block programmatic playback; the card still opens preview on click.
|
||||
});
|
||||
}, [showVideo, playOnActivate]);
|
||||
}, [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 () => {
|
||||
@@ -277,7 +534,9 @@ export const VideoPosterPreview: React.FC<VideoPosterPreviewProps> = ({
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handlePosterClick: React.MouseEventHandler<HTMLImageElement> = (event) => {
|
||||
const handlePosterClick: React.MouseEventHandler<HTMLImageElement> = (
|
||||
event
|
||||
) => {
|
||||
if (activateVideoOnClick) {
|
||||
activatedByClickRef.current = true;
|
||||
setShowVideo(true);
|
||||
@@ -285,31 +544,88 @@ export const VideoPosterPreview: React.FC<VideoPosterPreviewProps> = ({
|
||||
onClick?.(event);
|
||||
};
|
||||
|
||||
const handleVideoClick: React.MouseEventHandler<HTMLVideoElement> = (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 (!showVideo && resolvedPoster) {
|
||||
if (!shouldRenderVideoElement && displayPoster) {
|
||||
return renderPreviewWithOverlay(
|
||||
<img
|
||||
src={resolvedPoster}
|
||||
src={displayPoster}
|
||||
alt={alt}
|
||||
className={`video-poster-preview__media${className ? ` ${className}` : ''}`}
|
||||
className={`video-poster-preview__media${
|
||||
className ? ` ${className}` : ''
|
||||
}`}
|
||||
loading={imageLoading}
|
||||
decoding="async"
|
||||
draggable={false}
|
||||
referrerPolicy="no-referrer"
|
||||
onClick={handlePosterClick}
|
||||
onMouseEnter={handlePreviewMouseEnter}
|
||||
onMouseLeave={handlePreviewMouseLeave}
|
||||
/>,
|
||||
showPlayOverlay
|
||||
showPlayOverlay,
|
||||
fit
|
||||
);
|
||||
}
|
||||
|
||||
if (!showVideo && !resolvedPoster) {
|
||||
if (!shouldRenderVideoElement && !resolvedPoster) {
|
||||
return renderPreviewWithOverlay(
|
||||
<div
|
||||
className={`video-poster-preview__placeholder${className ? ` ${className}` : ''}`}
|
||||
className={`video-poster-preview__placeholder${
|
||||
className ? ` ${className}` : ''
|
||||
}`}
|
||||
aria-hidden="true"
|
||||
onClick={
|
||||
activateVideoOnClick
|
||||
@@ -319,8 +635,11 @@ export const VideoPosterPreview: React.FC<VideoPosterPreviewProps> = ({
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onMouseEnter={handlePreviewMouseEnter}
|
||||
onMouseLeave={handlePreviewMouseLeave}
|
||||
/>,
|
||||
showPlayOverlay
|
||||
showPlayOverlay,
|
||||
fit
|
||||
);
|
||||
}
|
||||
|
||||
@@ -328,12 +647,19 @@ export const VideoPosterPreview: React.FC<VideoPosterPreviewProps> = ({
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={src}
|
||||
poster={resolvedPoster || normalizedPoster}
|
||||
className={`video-poster-preview__media${className ? ` ${className}` : ''}`}
|
||||
poster={videoPoster}
|
||||
className={`video-poster-preview__media${
|
||||
className ? ` ${className}` : ''
|
||||
}`}
|
||||
playsInline
|
||||
{...videoProps}
|
||||
muted={playOnHover || videoProps?.muted}
|
||||
onClick={handleVideoClick}
|
||||
onLoadedData={handleVideoLoadedData}
|
||||
onMouseEnter={handlePreviewMouseEnter}
|
||||
onMouseLeave={handlePreviewMouseLeave}
|
||||
/>,
|
||||
false
|
||||
false,
|
||||
fit
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user