Sync latest TrueGrowth updates
Some checks failed
CI / main (push) Has been cancelled
CI / release-e2e (push) Has been cancelled

This commit is contained in:
jiam
2026-07-08 02:03:18 +08:00
parent 52636c91ae
commit 5119ac0ef8
102 changed files with 20985 additions and 1760 deletions

View File

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