diff --git a/ui/src/app/api/img/[...imagePath]/route.ts b/ui/src/app/api/img/[...imagePath]/route.ts index a3877e8a..e41362cf 100644 --- a/ui/src/app/api/img/[...imagePath]/route.ts +++ b/ui/src/app/api/img/[...imagePath]/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'; import fs from 'fs'; import path from 'path'; +import { Readable } from 'stream'; import { getDatasetsRoot, getTrainingFolder, getDataRoot } from '@/server/settings'; 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 }); } + // Bail out early if the client already gave up + if (request.signal.aborted) { + return new NextResponse(null, { status: 499 }); + } + // Stat file (async) const stat = await fs.promises.stat(resolved).catch(() => null); if (!stat || !stat.isFile()) { @@ -63,6 +69,40 @@ export async function GET(request: NextRequest, { params }: { params: { imagePat const ext = path.extname(resolved).toLowerCase(); 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 const rangeHeader = request.headers.get('range'); 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 chunkSize = end - start + 1; - const stream = fs.createReadStream(resolved, { start, end }); - 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, { + return new NextResponse(buildBody(start, end) as any, { status: 206, headers: { 'Content-Range': `bytes ${start}-${end}/${stat.size}`, 'Accept-Ranges': 'bytes', 'Content-Length': String(chunkSize), 'Content-Type': contentType, - 'Cache-Control': 'public, max-age=86400', + 'Cache-Control': cacheControl, + ETag: etag, }, }); } - // Stream the file instead of buffering it entirely - 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, { + return new NextResponse(buildBody() as any, { headers: { 'Content-Type': contentType, 'Content-Length': String(stat.size), - 'Cache-Control': 'public, max-age=86400', + 'Cache-Control': cacheControl, 'Accept-Ranges': 'bytes', + ETag: etag, }, }); } catch (error) { diff --git a/ui/src/app/datasets/[datasetName]/page.tsx b/ui/src/app/datasets/[datasetName]/page.tsx index e0b03de3..9dc7575e 100644 --- a/ui/src/app/datasets/[datasetName]/page.tsx +++ b/ui/src/app/datasets/[datasetName]/page.tsx @@ -150,6 +150,7 @@ export default function DatasetPage({ params }: { params: { datasetName: string onDelete={() => refreshImageList(datasetName)} onImageClick={() => setSelectedImgPath(img.img_path)} captionRefreshKey={captionRefreshKeys[img.img_path] || 0} + observerRoot={scrollParent} /> ); }} diff --git a/ui/src/components/DatasetImageCard.tsx b/ui/src/components/DatasetImageCard.tsx index a931c99e..678f8a78 100644 --- a/ui/src/components/DatasetImageCard.tsx +++ b/ui/src/components/DatasetImageCard.tsx @@ -16,6 +16,8 @@ interface DatasetImageCardProps { onDelete?: () => void; onImageClick?: () => void; captionRefreshKey?: number; + observerRoot?: Element | null; + rootMargin?: string; } const DatasetImageCard: React.FC = ({ @@ -27,13 +29,88 @@ const DatasetImageCard: React.FC = ({ onDelete = () => {}, onImageClick, captionRefreshKey = 0, + observerRoot = null, + rootMargin = '200px 0px', }) => { const [loaded, setLoaded] = useState(false); const [showAudioPlayer, setShowAudioPlayer] = useState(true); const [pollTick, setPollTick] = useState(0); + const [blobUrl, setBlobUrl] = useState(null); + const [isVisible, setIsVisible] = useState(false); + const cardRef = useRef(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 { caption: fetchedCaption, isLoaded: isCaptionLoaded } = useCaptionBatch(imageUrl, combinedRefreshKey); + const { caption: fetchedCaption, isLoaded: isCaptionLoaded } = useCaptionBatch( + isVisible ? imageUrl : null, + combinedRefreshKey, + ); const [caption, setCaption] = useState(''); const [savedCaption, setSavedCaption] = useState(''); @@ -90,10 +167,6 @@ const DatasetImageCard: React.FC = ({ }; }, []); - const handleLoad = (): void => { - setLoaded(true); - }; - const handleKeyDown = (e: KeyboardEvent): void => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); @@ -108,12 +181,8 @@ const DatasetImageCard: React.FC = ({ const isCaptionCurrent = caption.trim() === savedCaption; - const isItAVideo = isVideo(imageUrl); - const isItAudio = isAudio(imageUrl); - const isItImage = !isItAVideo && !isItAudio; - return ( -
+
= ({ {isItAudio && showAudioPlayer && ( )} - {isItImage && ( + {isItImage && blobUrl && ( {alt} = ({ const videoRef = useRef(null); const [isVisible, setIsVisible] = useState(false); const [loaded, setLoaded] = useState(false); + const [blobUrl, setBlobUrl] = useState(null); + + const isItAudio = isAudio(imageUrl); + const isItVideo = isVideo(imageUrl); + const isImageType = !isItAudio && !isItVideo; // Observe both enter and exit useEffect(() => { @@ -56,9 +61,44 @@ const SampleImageCard: React.FC = ({ return () => observer.disconnect(); }, [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 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 (
@@ -69,7 +109,7 @@ const SampleImageCard: React.FC = ({ }`} > {isVisible ? ( - isAudio(imageUrl) ? ( + isItAudio ? (
= ({ }} />
- ) : isVideo(imageUrl) ? ( + ) : isItVideo ? (