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.
This commit is contained in:
Claudio
2026-05-11 10:22:17 +02:00
parent 611d445d92
commit 6311412fc0
2 changed files with 43 additions and 20 deletions

View File

@@ -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<HTMLImageElement>(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 <img> 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 }) {
>
<img
ref={imgRef}
key={`${photo.id}-${usingFallback}`}
key={photo.id}
src={src}
alt={photo.filename}
loading="eager"

View File

@@ -10,22 +10,29 @@ export function isVideo(photo: Photo): boolean {
}
/**
* Pick the best display URL for a still photo in the preview view.
* Initial display URL for a still photo in the preview view: the 1280px
* `large` WebP thumbnail. Cheap and always pre-generated, so the preview
* appears instantly even for RAW/HEIC photos whose full-res /proxy would
* otherwise transcode synchronously on first hit.
*
* Always uses the /proxy endpoint, which the backend resolves to:
* - the original file for web-safe formats (JPEG/PNG/WebP/GIF)
* - a transcoded full-res WebP for RAW/HEIC/TIFF (cached on first hit)
* Pair with `getPreviewFullResSrc` for progressive enhancement: load this
* first, then preload the full-res in the background and swap it in.
*
* Videos go through `getVideoSrc` instead and use /original directly.
*/
export function getPreviewImageSrc(photo: Photo): string {
return photosApi.getProxyUrl(photo.id)
return photosApi.getThumbnailUrl(photo.id, 'large')
}
/** Fallback used when the proxy endpoint fails or 404s — shows the 1280px
* large thumbnail so the user still sees something. */
export function getPreviewFallbackSrc(photo: Photo): string {
return photosApi.getThumbnailUrl(photo.id, 'large')
/**
* Full-resolution URL fetched in the background after the thumbnail is
* already visible. The /proxy endpoint serves the original file for
* web-safe formats (JPEG/PNG/WebP/GIF) or a transcoded full-res WebP for
* RAW/HEIC/TIFF (cached on first hit). Used so Z-key / wheel zoom stays
* pixel-sharp once the upgrade lands.
*/
export function getPreviewFullResSrc(photo: Photo): string {
return photosApi.getProxyUrl(photo.id)
}
export function getVideoSrc(photo: Photo): string {