From 6311412fc0d014c9cd8bce6d4cdbca8b9146a18a Mon Sep 17 00:00:00 2001 From: Claudio Date: Mon, 11 May 2026 10:22:17 +0200 Subject: [PATCH] ui(preview): load 1280px thumb first, upgrade to full-res in bg The /proxy endpoint is slow on first hit, especially for RAW/HEIC where it transcodes synchronously. Preview now renders the pre-generated large thumb immediately, then preloads /proxy via Image() and swaps src when ready, so zoom (Z key / wheel) still reaches the original pixels. --- .../src/components/preview/PreviewImage.tsx | 38 +++++++++++++------ frontend/src/components/preview/previewSrc.ts | 25 +++++++----- 2 files changed, 43 insertions(+), 20 deletions(-) diff --git a/frontend/src/components/preview/PreviewImage.tsx b/frontend/src/components/preview/PreviewImage.tsx index d678fa0..09d070b 100644 --- a/frontend/src/components/preview/PreviewImage.tsx +++ b/frontend/src/components/preview/PreviewImage.tsx @@ -3,7 +3,7 @@ import { useHotkeys } from 'react-hotkeys-hook' import type { Photo } from '../../types/photo' import { getPreviewImageSrc, - getPreviewFallbackSrc, + getPreviewFullResSrc, getVideoSrc, isVideo, } from './previewSrc' @@ -44,7 +44,9 @@ function PreviewVideo({ photo }: { photo: Photo }) { function PreviewStillImage({ photo }: { photo: Photo }) { const [loaded, setLoaded] = useState(false) - const [usingFallback, setUsingFallback] = useState(false) + // Progressive enhancement: render the 1280px thumb first, then preload the + // full-res /proxy in the background and flip this to true once decoded. + const [usingFullRes, setUsingFullRes] = useState(false) // scale=1 means "fit to viewport". Anything >1 zooms in; we don't allow <1 // because the fit size already fills the viewport. @@ -53,21 +55,35 @@ function PreviewStillImage({ photo }: { photo: Photo }) { const dragStateRef = useRef<{ x: number; y: number; ox: number; oy: number } | null>(null) const imgRef = useRef(null) - // Reset everything when the photo changes. + const thumbSrc = getPreviewImageSrc(photo) + const fullResSrc = getPreviewFullResSrc(photo) + const src = usingFullRes ? fullResSrc : thumbSrc + + // Reset everything when the photo changes, then start the background + // full-res preload. Cancel the preloader's callback on unmount/change so a + // late-arriving onload from the previous photo can't flip state for the + // current one. useEffect(() => { setLoaded(false) - setUsingFallback(false) + setUsingFullRes(false) setScale(1) setOffset({ x: 0, y: 0 }) - }, [photo.id]) - const primarySrc = getPreviewImageSrc(photo) - const fallbackSrc = getPreviewFallbackSrc(photo) - const src = usingFallback ? fallbackSrc : primarySrc + const preloader = new Image() + preloader.onload = () => setUsingFullRes(true) + preloader.src = fullResSrc + return () => { + preloader.onload = null + } + }, [photo.id, fullResSrc]) + // If the thumb 404s (e.g. derivative not yet generated for a fresh import), + // jump straight to the full-res — the same element will retry against + // /proxy. If /proxy also fails, the browser shows its broken-image icon and + // we surface no further fallback. const handleError = () => { - if (!usingFallback && primarySrc !== fallbackSrc) { - setUsingFallback(true) + if (!usingFullRes) { + setUsingFullRes(true) } } @@ -150,7 +166,7 @@ function PreviewStillImage({ photo }: { photo: Photo }) { >