Generate thumbnails for dataset items so things load faster and the ui is more stable. Especially good for videos.
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import React, { useEffect, useState, ReactNode, KeyboardEvent, useRef } from 'react';
|
||||
import { FaTrashAlt } from 'react-icons/fa';
|
||||
import { FaTrashAlt, FaPlay } from 'react-icons/fa';
|
||||
import { openConfirm } from './ConfirmModal';
|
||||
import classNames from 'classnames';
|
||||
import { apiClient } from '@/utils/api';
|
||||
@@ -38,12 +38,12 @@ const DatasetImageCard: React.FC<DatasetImageCardProps> = ({
|
||||
const [showAudioPlayer, setShowAudioPlayer] = useState(true);
|
||||
const [pollTick, setPollTick] = useState(0);
|
||||
const [blobUrl, setBlobUrl] = useState<string | null>(null);
|
||||
const [streamVideo, setStreamVideo] = useState(false);
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const cardRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const isItAVideo = isVideo(imageUrl);
|
||||
const isItAudio = isAudio(imageUrl);
|
||||
const isItImage = !isItAVideo && !isItAudio;
|
||||
|
||||
// Track actual viewport visibility — Virtuoso keeps a buffer of cards mounted
|
||||
// outside the visible region, so we can't rely on mount/unmount alone.
|
||||
@@ -72,9 +72,12 @@ const DatasetImageCard: React.FC<DatasetImageCardProps> = ({
|
||||
|
||||
// Drive image loads through fetch + AbortController so scrolling past actually
|
||||
// cancels in-flight requests. Debounced 80ms so fast scroll-throughs never
|
||||
// start a request.
|
||||
// start a request. Both images and videos pull the 300x300 thumb (the server
|
||||
// generates it on a miss); ?thumb=1 falls through to the real file only when
|
||||
// a thumb can't be made, so a video/* response means "no thumb available" —
|
||||
// abort before downloading the body and stream a <video> tag instead.
|
||||
useEffect(() => {
|
||||
if (!isItImage) return;
|
||||
if (isItAudio) return;
|
||||
if (!isVisible) return;
|
||||
|
||||
const controller = new AbortController();
|
||||
@@ -82,13 +85,21 @@ const DatasetImageCard: React.FC<DatasetImageCardProps> = ({
|
||||
let objectUrl: string | null = null;
|
||||
|
||||
const timer = window.setTimeout(() => {
|
||||
fetch(`/api/img/${encodeURIComponent(imageUrl)}`, { signal: controller.signal })
|
||||
fetch(`/api/img/${encodeURIComponent(imageUrl)}?thumb=1`, { signal: controller.signal })
|
||||
.then(r => {
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||
if ((r.headers.get('content-type') || '').startsWith('video/')) {
|
||||
controller.abort();
|
||||
if (!cancelled) {
|
||||
setStreamVideo(true);
|
||||
setLoaded(true);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return r.blob();
|
||||
})
|
||||
.then(blob => {
|
||||
if (cancelled) return;
|
||||
if (cancelled || !blob) return;
|
||||
objectUrl = URL.createObjectURL(blob);
|
||||
setBlobUrl(objectUrl);
|
||||
setLoaded(true);
|
||||
@@ -104,9 +115,10 @@ const DatasetImageCard: React.FC<DatasetImageCardProps> = ({
|
||||
controller.abort();
|
||||
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
||||
setBlobUrl(null);
|
||||
setStreamVideo(false);
|
||||
setLoaded(false);
|
||||
};
|
||||
}, [imageUrl, isItImage, isVisible]);
|
||||
}, [imageUrl, isItAudio, isVisible]);
|
||||
|
||||
const combinedRefreshKey = captionRefreshKey + pollTick;
|
||||
const { caption: fetchedCaption, isLoaded: isCaptionLoaded } = useCaptionBatch(
|
||||
@@ -195,17 +207,21 @@ const DatasetImageCard: React.FC<DatasetImageCardProps> = ({
|
||||
<div className="relative w-full" style={{ paddingBottom: '100%' }}>
|
||||
<div
|
||||
className={classNames('absolute inset-0 rounded-t-lg shadow-md bg-gray-900', {
|
||||
'animate-pulse': isItImage && !loaded,
|
||||
'animate-pulse': !isItAudio && !loaded,
|
||||
})}
|
||||
>
|
||||
{isItAVideo && (
|
||||
{streamVideo && (
|
||||
<video
|
||||
src={`/api/img/${encodeURIComponent(imageUrl)}`}
|
||||
className={`w-full h-full object-contain`}
|
||||
className={classNames('w-full h-full object-contain', {
|
||||
'cursor-zoom-in': !!onImageClick,
|
||||
})}
|
||||
onClick={onImageClick}
|
||||
autoPlay={false}
|
||||
preload="metadata"
|
||||
playsInline
|
||||
loop
|
||||
muted
|
||||
controls
|
||||
/>
|
||||
)}
|
||||
{isItAudio && !showAudioPlayer && (
|
||||
@@ -226,7 +242,7 @@ const DatasetImageCard: React.FC<DatasetImageCardProps> = ({
|
||||
{isItAudio && showAudioPlayer && (
|
||||
<AudioPlayer src={`/api/img/${encodeURIComponent(imageUrl)}`} title={imageUrl.replace(/^.*[\\/]/, '')} />
|
||||
)}
|
||||
{isItImage && blobUrl && (
|
||||
{!isItAudio && blobUrl && (
|
||||
<img
|
||||
src={blobUrl}
|
||||
alt={alt}
|
||||
@@ -236,6 +252,11 @@ const DatasetImageCard: React.FC<DatasetImageCardProps> = ({
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
{isItAVideo && loaded && (
|
||||
<div className="absolute bottom-2 left-2 bg-gray-900/70 rounded-full p-2 pointer-events-none">
|
||||
<FaPlay className="w-3 h-3 text-white" />
|
||||
</div>
|
||||
)}
|
||||
{children && <div className="absolute inset-0 flex items-center justify-center">{children}</div>}
|
||||
<div className="absolute top-1 right-1 flex space-x-2 z-10">
|
||||
<button
|
||||
|
||||
Reference in New Issue
Block a user