Drastically improve the loading speed of images in the ui by using a custom loader and abort controller to abort when images leave the view.

This commit is contained in:
Jaret Burkett
2026-05-25 10:04:25 -06:00
parent 7354def271
commit 083236a2a7
5 changed files with 203 additions and 57 deletions

View File

@@ -2,6 +2,7 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import fs from 'fs'; import fs from 'fs';
import path from 'path'; import path from 'path';
import { Readable } from 'stream';
import { getDatasetsRoot, getTrainingFolder, getDataRoot } from '@/server/settings'; import { getDatasetsRoot, getTrainingFolder, getDataRoot } from '@/server/settings';
const contentTypeMap: { [key: string]: string } = { const contentTypeMap: { [key: string]: string } = {
@@ -54,6 +55,11 @@ export async function GET(request: NextRequest, { params }: { params: { imagePat
return new NextResponse('Access denied', { status: 403 }); return new NextResponse('Access denied', { status: 403 });
} }
// Bail out early if the client already gave up
if (request.signal.aborted) {
return new NextResponse(null, { status: 499 });
}
// Stat file (async) // Stat file (async)
const stat = await fs.promises.stat(resolved).catch(() => null); const stat = await fs.promises.stat(resolved).catch(() => null);
if (!stat || !stat.isFile()) { if (!stat || !stat.isFile()) {
@@ -63,6 +69,40 @@ export async function GET(request: NextRequest, { params }: { params: { imagePat
const ext = path.extname(resolved).toLowerCase(); const ext = path.extname(resolved).toLowerCase();
const contentType = contentTypeMap[ext] || 'application/octet-stream'; const contentType = contentTypeMap[ext] || 'application/octet-stream';
// Weak ETag from inode/size/mtime — cheap and stable enough for revalidation
const etag = `W/"${stat.ino.toString(36)}-${stat.size.toString(36)}-${stat.mtimeMs.toString(36)}"`;
const cacheControl = 'public, max-age=86400, immutable';
const ifNoneMatch = request.headers.get('if-none-match');
if (ifNoneMatch && ifNoneMatch === etag) {
return new NextResponse(null, {
status: 304,
headers: {
ETag: etag,
'Cache-Control': cacheControl,
},
});
}
const buildBody = (start?: number, end?: number) => {
const nodeStream =
start !== undefined && end !== undefined
? fs.createReadStream(resolved, { start, end })
: fs.createReadStream(resolved);
// Wire client disconnect → destroy the file stream so we don't keep
// reading bytes for a request the browser has already cancelled.
const onAbort = () => nodeStream.destroy();
if (request.signal.aborted) {
nodeStream.destroy();
} else {
request.signal.addEventListener('abort', onAbort, { once: true });
}
nodeStream.once('close', () => request.signal.removeEventListener('abort', onAbort));
return Readable.toWeb(nodeStream) as unknown as ReadableStream;
};
// Support range requests for video/audio seeking // Support range requests for video/audio seeking
const rangeHeader = request.headers.get('range'); const rangeHeader = request.headers.get('range');
if (rangeHeader) { if (rangeHeader) {
@@ -71,49 +111,26 @@ export async function GET(request: NextRequest, { params }: { params: { imagePat
const end = parts[1] ? parseInt(parts[1], 10) : stat.size - 1; const end = parts[1] ? parseInt(parts[1], 10) : stat.size - 1;
const chunkSize = end - start + 1; const chunkSize = end - start + 1;
const stream = fs.createReadStream(resolved, { start, end }); return new NextResponse(buildBody(start, end) as any, {
const readable = new ReadableStream({
start(controller) {
stream.on('data', chunk => controller.enqueue(chunk));
stream.on('end', () => controller.close());
stream.on('error', err => controller.error(err));
},
cancel() {
stream.destroy();
},
});
return new NextResponse(readable as any, {
status: 206, status: 206,
headers: { headers: {
'Content-Range': `bytes ${start}-${end}/${stat.size}`, 'Content-Range': `bytes ${start}-${end}/${stat.size}`,
'Accept-Ranges': 'bytes', 'Accept-Ranges': 'bytes',
'Content-Length': String(chunkSize), 'Content-Length': String(chunkSize),
'Content-Type': contentType, 'Content-Type': contentType,
'Cache-Control': 'public, max-age=86400', 'Cache-Control': cacheControl,
ETag: etag,
}, },
}); });
} }
// Stream the file instead of buffering it entirely return new NextResponse(buildBody() as any, {
const stream = fs.createReadStream(resolved);
const readable = new ReadableStream({
start(controller) {
stream.on('data', chunk => controller.enqueue(chunk));
stream.on('end', () => controller.close());
stream.on('error', err => controller.error(err));
},
cancel() {
stream.destroy();
},
});
return new NextResponse(readable as any, {
headers: { headers: {
'Content-Type': contentType, 'Content-Type': contentType,
'Content-Length': String(stat.size), 'Content-Length': String(stat.size),
'Cache-Control': 'public, max-age=86400', 'Cache-Control': cacheControl,
'Accept-Ranges': 'bytes', 'Accept-Ranges': 'bytes',
ETag: etag,
}, },
}); });
} catch (error) { } catch (error) {

View File

@@ -150,6 +150,7 @@ export default function DatasetPage({ params }: { params: { datasetName: string
onDelete={() => refreshImageList(datasetName)} onDelete={() => refreshImageList(datasetName)}
onImageClick={() => setSelectedImgPath(img.img_path)} onImageClick={() => setSelectedImgPath(img.img_path)}
captionRefreshKey={captionRefreshKeys[img.img_path] || 0} captionRefreshKey={captionRefreshKeys[img.img_path] || 0}
observerRoot={scrollParent}
/> />
); );
}} }}

View File

@@ -16,6 +16,8 @@ interface DatasetImageCardProps {
onDelete?: () => void; onDelete?: () => void;
onImageClick?: () => void; onImageClick?: () => void;
captionRefreshKey?: number; captionRefreshKey?: number;
observerRoot?: Element | null;
rootMargin?: string;
} }
const DatasetImageCard: React.FC<DatasetImageCardProps> = ({ const DatasetImageCard: React.FC<DatasetImageCardProps> = ({
@@ -27,13 +29,88 @@ const DatasetImageCard: React.FC<DatasetImageCardProps> = ({
onDelete = () => {}, onDelete = () => {},
onImageClick, onImageClick,
captionRefreshKey = 0, captionRefreshKey = 0,
observerRoot = null,
rootMargin = '200px 0px',
}) => { }) => {
const [loaded, setLoaded] = useState<boolean>(false); const [loaded, setLoaded] = useState<boolean>(false);
const [showAudioPlayer, setShowAudioPlayer] = useState(true); const [showAudioPlayer, setShowAudioPlayer] = useState(true);
const [pollTick, setPollTick] = useState(0); const [pollTick, setPollTick] = useState(0);
const [blobUrl, setBlobUrl] = useState<string | null>(null);
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.
useEffect(() => {
const el = cardRef.current;
if (!el) return;
const observer = new IntersectionObserver(
entries => {
for (const entry of entries) {
if (entry.target === el) {
setIsVisible(entry.isIntersecting);
}
}
},
{
root: observerRoot ?? null,
threshold: 0.01,
rootMargin,
},
);
observer.observe(el);
return () => observer.disconnect();
}, [observerRoot, rootMargin]);
// Drive image loads through fetch + AbortController so scrolling past actually
// cancels in-flight requests. Debounced 80ms so fast scroll-throughs never
// start a request.
useEffect(() => {
if (!isItImage) return;
if (!isVisible) return;
const controller = new AbortController();
let cancelled = false;
let objectUrl: string | null = null;
const timer = window.setTimeout(() => {
fetch(`/api/img/${encodeURIComponent(imageUrl)}`, { signal: controller.signal })
.then(r => {
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.blob();
})
.then(blob => {
if (cancelled) return;
objectUrl = URL.createObjectURL(blob);
setBlobUrl(objectUrl);
setLoaded(true);
})
.catch(err => {
if (err?.name !== 'AbortError') console.error('Dataset image fetch failed:', err);
});
}, 80);
return () => {
cancelled = true;
clearTimeout(timer);
controller.abort();
if (objectUrl) URL.revokeObjectURL(objectUrl);
setBlobUrl(null);
setLoaded(false);
};
}, [imageUrl, isItImage, isVisible]);
const combinedRefreshKey = captionRefreshKey + pollTick; const combinedRefreshKey = captionRefreshKey + pollTick;
const { caption: fetchedCaption, isLoaded: isCaptionLoaded } = useCaptionBatch(imageUrl, combinedRefreshKey); const { caption: fetchedCaption, isLoaded: isCaptionLoaded } = useCaptionBatch(
isVisible ? imageUrl : null,
combinedRefreshKey,
);
const [caption, setCaption] = useState<string>(''); const [caption, setCaption] = useState<string>('');
const [savedCaption, setSavedCaption] = useState<string>(''); const [savedCaption, setSavedCaption] = useState<string>('');
@@ -90,10 +167,6 @@ const DatasetImageCard: React.FC<DatasetImageCardProps> = ({
}; };
}, []); }, []);
const handleLoad = (): void => {
setLoaded(true);
};
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>): void => { const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>): void => {
if (e.key === 'Enter' && !e.shiftKey) { if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault(); e.preventDefault();
@@ -108,12 +181,8 @@ const DatasetImageCard: React.FC<DatasetImageCardProps> = ({
const isCaptionCurrent = caption.trim() === savedCaption; const isCaptionCurrent = caption.trim() === savedCaption;
const isItAVideo = isVideo(imageUrl);
const isItAudio = isAudio(imageUrl);
const isItImage = !isItAVideo && !isItAudio;
return ( return (
<div className={`flex flex-col ${className}`}> <div ref={cardRef} className={`flex flex-col ${className}`}>
<div className="relative w-full" style={{ paddingBottom: '100%' }}> <div className="relative w-full" style={{ paddingBottom: '100%' }}>
<div <div
className={classNames('absolute inset-0 rounded-t-lg shadow-md bg-gray-900', { className={classNames('absolute inset-0 rounded-t-lg shadow-md bg-gray-900', {
@@ -148,11 +217,11 @@ const DatasetImageCard: React.FC<DatasetImageCardProps> = ({
{isItAudio && showAudioPlayer && ( {isItAudio && showAudioPlayer && (
<AudioPlayer src={`/api/img/${encodeURIComponent(imageUrl)}`} title={imageUrl.replace(/^.*[\\/]/, '')} /> <AudioPlayer src={`/api/img/${encodeURIComponent(imageUrl)}`} title={imageUrl.replace(/^.*[\\/]/, '')} />
)} )}
{isItImage && ( {isItImage && blobUrl && (
<img <img
src={`/api/img/${encodeURIComponent(imageUrl)}`} src={blobUrl}
alt={alt} alt={alt}
onLoad={handleLoad} decoding="async"
onClick={onImageClick} onClick={onImageClick}
className={classNames('w-full h-full object-contain transition-opacity duration-300', { className={classNames('w-full h-full object-contain transition-opacity duration-300', {
'opacity-100': loaded, 'opacity-100': loaded,

View File

@@ -31,6 +31,11 @@ const SampleImageCard: React.FC<SampleImageCardProps> = ({
const videoRef = useRef<HTMLVideoElement | null>(null); const videoRef = useRef<HTMLVideoElement | null>(null);
const [isVisible, setIsVisible] = useState(false); const [isVisible, setIsVisible] = useState(false);
const [loaded, setLoaded] = useState(false); const [loaded, setLoaded] = useState(false);
const [blobUrl, setBlobUrl] = useState<string | null>(null);
const isItAudio = isAudio(imageUrl);
const isItVideo = isVideo(imageUrl);
const isImageType = !isItAudio && !isItVideo;
// Observe both enter and exit // Observe both enter and exit
useEffect(() => { useEffect(() => {
@@ -56,9 +61,44 @@ const SampleImageCard: React.FC<SampleImageCardProps> = ({
return () => observer.disconnect(); return () => observer.disconnect();
}, [observerRoot, rootMargin]); }, [observerRoot, rootMargin]);
const handleLoad = () => setLoaded(true); // Drive image loads through fetch + AbortController so scrolling past actually
// cancels in-flight requests (browsers don't reliably cancel <img> fetches when
// the element unmounts). A short debounce skips requests entirely during fast
// scrolls where the card is only briefly visible.
useEffect(() => {
if (!isImageType) return;
if (!isVisible) return;
const isImageType = !isAudio(imageUrl) && !isVideo(imageUrl); const controller = new AbortController();
let cancelled = false;
let objectUrl: string | null = null;
const timer = window.setTimeout(() => {
fetch(`/api/img/${encodeURIComponent(imageUrl)}`, { signal: controller.signal })
.then(r => {
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.blob();
})
.then(blob => {
if (cancelled) return;
objectUrl = URL.createObjectURL(blob);
setBlobUrl(objectUrl);
setLoaded(true);
})
.catch(err => {
if (err?.name !== 'AbortError') console.error('Sample image fetch failed:', err);
});
}, 80);
return () => {
cancelled = true;
clearTimeout(timer);
controller.abort();
if (objectUrl) URL.revokeObjectURL(objectUrl);
setBlobUrl(null);
setLoaded(false);
};
}, [isVisible, isImageType, imageUrl]);
return ( return (
<div className={`flex flex-col ${className}`}> <div className={`flex flex-col ${className}`}>
@@ -69,7 +109,7 @@ const SampleImageCard: React.FC<SampleImageCardProps> = ({
}`} }`}
> >
{isVisible ? ( {isVisible ? (
isAudio(imageUrl) ? ( isItAudio ? (
<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">
<img <img
src={`/api/audio/art/${encodeURIComponent(imageUrl)}`} src={`/api/audio/art/${encodeURIComponent(imageUrl)}`}
@@ -80,31 +120,28 @@ const SampleImageCard: React.FC<SampleImageCardProps> = ({
}} }}
/> />
</div> </div>
) : isVideo(imageUrl) ? ( ) : isItVideo ? (
<video <video
ref={videoRef} ref={videoRef}
src={`/api/img/${encodeURIComponent(imageUrl)}`} src={`/api/img/${encodeURIComponent(imageUrl)}`}
className="w-full h-full object-cover" className="w-full h-full object-cover"
preload="none" preload="none"
onLoad={handleLoad}
playsInline playsInline
muted muted
loop loop
autoPlay autoPlay
controls={false} controls={false}
/> />
) : ( ) : blobUrl ? (
<img <img
src={`/api/img/${encodeURIComponent(imageUrl)}`} src={blobUrl}
alt={alt} alt={alt}
onLoad={handleLoad}
loading="lazy"
decoding="async" decoding="async"
className={`w-full h-full object-cover transition-opacity duration-300 ${ className={`w-full h-full object-cover transition-opacity duration-300 ${
loaded ? 'opacity-100' : 'opacity-0' loaded ? 'opacity-100' : 'opacity-0'
}`} }`}
/> />
) ) : null
) : null} ) : null}
{children && isVisible && <div className="absolute inset-0 flex items-center justify-center">{children}</div>} {children && isVisible && <div className="absolute inset-0 flex items-center justify-center">{children}</div>}

View File

@@ -46,13 +46,33 @@ async function flush() {
if (pending.size > 0) scheduleFlush(); if (pending.size > 0) scheduleFlush();
} }
function requestCaption(path: string): Promise<string> { function requestCaption(path: string, signal?: AbortSignal): Promise<string> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
if (signal?.aborted) {
reject(new DOMException('Aborted', 'AbortError'));
return;
}
const resolver: Resolver = { resolve, reject };
const list = pending.get(path); const list = pending.get(path);
if (list) { if (list) {
list.push({ resolve, reject }); list.push(resolver);
} else { } else {
pending.set(path, [{ resolve, reject }]); pending.set(path, [resolver]);
}
if (signal) {
const onAbort = () => {
// Remove this resolver from the pending batch. If no other card is
// still waiting on the same path, drop the path entirely so the next
// batch doesn't include it.
const arr = pending.get(path);
if (arr) {
const idx = arr.indexOf(resolver);
if (idx >= 0) arr.splice(idx, 1);
if (arr.length === 0) pending.delete(path);
}
reject(new DOMException('Aborted', 'AbortError'));
};
signal.addEventListener('abort', onAbort, { once: true });
} }
scheduleFlush(); scheduleFlush();
}); });
@@ -91,22 +111,24 @@ export default function useCaptionBatch(imgPath: string | null, refreshKey: numb
} }
let cancelled = false; let cancelled = false;
const controller = new AbortController();
lastPathRef.current = imgPath; lastPathRef.current = imgPath;
setIsLoaded(false); setIsLoaded(false);
requestCaption(imgPath) requestCaption(imgPath, controller.signal)
.then(value => { .then(value => {
if (cancelled || lastPathRef.current !== imgPath) return; if (cancelled || lastPathRef.current !== imgPath) return;
setCaption(value); setCaption(value);
setIsLoaded(true); setIsLoaded(true);
}) })
.catch(err => { .catch(err => {
if (cancelled) return; if (err?.name === 'AbortError' || cancelled) return;
console.error('Error fetching caption:', err); console.error('Error fetching caption:', err);
setIsLoaded(true); setIsLoaded(true);
}); });
return () => { return () => {
cancelled = true; cancelled = true;
controller.abort();
}; };
}, [imgPath, refreshKey]); }, [imgPath, refreshKey]);