Added virtulization for sample images to handle huge number of samples more efficientyly.

This commit is contained in:
Jaret Burkett
2026-05-25 07:22:24 -06:00
parent fbac1cb7f5
commit 8042cbe9d2
3 changed files with 62 additions and 51 deletions

View File

@@ -115,7 +115,11 @@ const DatasetImageCard: React.FC<DatasetImageCardProps> = ({
return ( return (
<div className={`flex flex-col ${className}`}> <div className={`flex flex-col ${className}`}>
<div className="relative w-full" style={{ paddingBottom: '100%' }}> <div className="relative w-full" style={{ paddingBottom: '100%' }}>
<div className="absolute inset-0 rounded-t-lg shadow-md"> <div
className={classNames('absolute inset-0 rounded-t-lg shadow-md bg-gray-900', {
'animate-pulse': isItImage && !loaded,
})}
>
{isItAVideo && ( {isItAVideo && (
<video <video
src={`/api/img/${encodeURIComponent(imageUrl)}`} src={`/api/img/${encodeURIComponent(imageUrl)}`}

View File

@@ -58,10 +58,16 @@ const SampleImageCard: React.FC<SampleImageCardProps> = ({
const handleLoad = () => setLoaded(true); const handleLoad = () => setLoaded(true);
const isImageType = !isAudio(imageUrl) && !isVideo(imageUrl);
return ( return (
<div className={`flex flex-col ${className}`}> <div className={`flex flex-col ${className}`}>
<div ref={cardRef} className="relative w-full cursor-pointer" style={{ paddingBottom: '100%' }} onClick={onClick}> <div ref={cardRef} className="relative w-full cursor-pointer" style={{ paddingBottom: '100%' }} onClick={onClick}>
<div className="absolute inset-0 rounded-t-lg shadow-md"> <div
className={`absolute inset-0 rounded-t-lg shadow-md bg-gray-900 ${
isVisible && isImageType && !loaded ? 'animate-pulse' : ''
}`}
>
{isVisible ? ( {isVisible ? (
isAudio(imageUrl) ? ( isAudio(imageUrl) ? (
<div className="w-full h-full flex items-center justify-center bg-gray-900"> <div className="w-full h-full flex items-center justify-center bg-gray-900">

View File

@@ -1,4 +1,5 @@
import { useMemo, useState, useRef, useEffect } from 'react'; import { useMemo, useState, useRef, useCallback } from 'react';
import { Virtuoso, VirtuosoHandle } from 'react-virtuoso';
import useSampleImages from '@/hooks/useSampleImages'; import useSampleImages from '@/hooks/useSampleImages';
import SampleImageCard from './SampleImageCard'; import SampleImageCard from './SampleImageCard';
import { Job } from '@prisma/client'; import { Job } from '@prisma/client';
@@ -72,8 +73,9 @@ interface SampleImagesProps {
export default function SampleImages({ job }: SampleImagesProps) { export default function SampleImages({ job }: SampleImagesProps) {
const { sampleImages, status, refreshSampleImages } = useSampleImages(job.id, 5000); const { sampleImages, status, refreshSampleImages } = useSampleImages(job.id, 5000);
const [selectedSamplePath, setSelectedSamplePath] = useState<string | null>(null); const [selectedSamplePath, setSelectedSamplePath] = useState<string | null>(null);
const containerRef = useRef<HTMLDivElement>(null); const [scrollParent, setScrollParent] = useState<HTMLDivElement | null>(null);
const didFirstScroll = useRef(false); const scrollParentCallback = useCallback((el: HTMLDivElement | null) => setScrollParent(el), []);
const virtuosoRef = useRef<VirtuosoHandle>(null);
const numSamples = useMemo(() => { const numSamples = useMemo(() => {
if (job?.job_config) { if (job?.job_config) {
const jobConfig = JSON.parse(job.job_config) as JobConfig; const jobConfig = JSON.parse(job.job_config) as JobConfig;
@@ -85,16 +87,21 @@ export default function SampleImages({ job }: SampleImagesProps) {
return 10; return 10;
}, [job]); }, [job]);
const scrollToBottom = () => { // Group samples into rows of `numSamples` for the virtualized list — one row per sample iteration.
if (containerRef.current) { const rows = useMemo(() => {
containerRef.current.scrollTo({ top: containerRef.current.scrollHeight, behavior: 'instant' }); const out: string[][] = [];
for (let i = 0; i < sampleImages.length; i += numSamples) {
out.push(sampleImages.slice(i, i + numSamples));
} }
return out;
}, [sampleImages, numSamples]);
const scrollToBottom = () => {
virtuosoRef.current?.scrollToIndex({ index: 'LAST', align: 'end' });
}; };
const scrollToTop = () => { const scrollToTop = () => {
if (containerRef.current) { virtuosoRef.current?.scrollToIndex({ index: 0, align: 'start' });
containerRef.current.scrollTo({ top: 0, behavior: 'instant' });
}
}; };
const PageInfoContent = useMemo(() => { const PageInfoContent = useMemo(() => {
@@ -247,55 +254,49 @@ export default function SampleImages({ job }: SampleImagesProps) {
return null; return null;
}, [job]); }, [job]);
// scroll to bottom on first load of samples
useEffect(() => {
if (status === 'success' && sampleImages.length > 0 && !didFirstScroll.current) {
didFirstScroll.current = true;
setTimeout(() => {
scrollToBottom();
}, 100);
}
}, [status, sampleImages.length]);
return ( return (
<div ref={containerRef} className="absolute top-[80px] left-0 right-0 bottom-0 overflow-y-auto"> <div ref={scrollParentCallback} className="absolute top-[80px] left-0 right-0 bottom-0 overflow-y-auto">
<div className="pb-4"> <div className="pb-4">
{PageInfoContent} {PageInfoContent}
{sampleImages && ( {sampleImages && rows.length > 0 && scrollParent && (
<div className={`grid ${gridColsClass} gap-1`}> <Virtuoso
{sampleImages.map((sample: string, idx: number) => { ref={virtuosoRef}
// Compute current group (groups are size = numSamples) customScrollParent={scrollParent}
const groupIndex = Math.floor(idx / numSamples); totalCount={rows.length}
const groupStart = groupIndex * numSamples; initialTopMostItemIndex={rows.length - 1}
const groupEnd = Math.min(groupStart + numSamples, sampleImages.length); followOutput="auto"
const groupSize = groupEnd - groupStart; increaseViewportBy={400}
const isEndOfGroup = idx === groupEnd - 1; computeItemKey={index => rows[index]?.[0] ?? index}
itemContent={index => {
const row = rows[index];
if (!row) return null;
// Only enforce a MIN of 3 when the group's planned width is < 3 // Only pad the final row when numSamples < MIN_COLS and the row is short.
const MIN_COLS = 3; const MIN_COLS = 3;
const shouldPad = numSamples < MIN_COLS && groupSize < MIN_COLS; const shouldPad = numSamples < MIN_COLS && row.length < MIN_COLS;
const padsNeeded = shouldPad ? MIN_COLS - groupSize : 0; const padsNeeded = shouldPad ? MIN_COLS - row.length : 0;
return ( return (
<div key={sample} className="contents"> // pb-1 recreates the vertical gap between rows that the original single CSS grid provided via `gap-1`.
<SampleImageCard <div className={`grid ${gridColsClass} gap-1 pb-1`}>
imageUrl={sample} {row.map(sample => (
numSamples={numSamples} <SampleImageCard
sampleImages={sampleImages} key={sample}
alt="Sample Image" imageUrl={sample}
onClick={() => setSelectedSamplePath(sample)} numSamples={numSamples}
observerRoot={containerRef.current} sampleImages={sampleImages}
/> alt="Sample Image"
onClick={() => setSelectedSamplePath(sample)}
{isEndOfGroup && observerRoot={scrollParent}
padsNeeded > 0 && />
Array.from({ length: padsNeeded }).map((_, i) => ( ))}
<div key={`pad-${groupIndex}-${i}`} className="invisible" /> {Array.from({ length: padsNeeded }).map((_, i) => (
))} <div key={`pad-${index}-${i}`} className="invisible" />
))}
</div> </div>
); );
})} }}
</div> />
)} )}
</div> </div>
<SampleImageViewer <SampleImageViewer