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 type { Photo } from '../../types/photo'
import { import {
getPreviewImageSrc, getPreviewImageSrc,
getPreviewFallbackSrc, getPreviewFullResSrc,
getVideoSrc, getVideoSrc,
isVideo, isVideo,
} from './previewSrc' } from './previewSrc'
@@ -44,7 +44,9 @@ function PreviewVideo({ photo }: { photo: Photo }) {
function PreviewStillImage({ photo }: { photo: Photo }) { function PreviewStillImage({ photo }: { photo: Photo }) {
const [loaded, setLoaded] = useState(false) 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 // scale=1 means "fit to viewport". Anything >1 zooms in; we don't allow <1
// because the fit size already fills the viewport. // 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 dragStateRef = useRef<{ x: number; y: number; ox: number; oy: number } | null>(null)
const imgRef = useRef<HTMLImageElement>(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(() => { useEffect(() => {
setLoaded(false) setLoaded(false)
setUsingFallback(false) setUsingFullRes(false)
setScale(1) setScale(1)
setOffset({ x: 0, y: 0 }) setOffset({ x: 0, y: 0 })
}, [photo.id])
const primarySrc = getPreviewImageSrc(photo) const preloader = new Image()
const fallbackSrc = getPreviewFallbackSrc(photo) preloader.onload = () => setUsingFullRes(true)
const src = usingFallback ? fallbackSrc : primarySrc 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 = () => { const handleError = () => {
if (!usingFallback && primarySrc !== fallbackSrc) { if (!usingFullRes) {
setUsingFallback(true) setUsingFullRes(true)
} }
} }
@@ -150,7 +166,7 @@ function PreviewStillImage({ photo }: { photo: Photo }) {
> >
<img <img
ref={imgRef} ref={imgRef}
key={`${photo.id}-${usingFallback}`} key={photo.id}
src={src} src={src}
alt={photo.filename} alt={photo.filename}
loading="eager" 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: * Pair with `getPreviewFullResSrc` for progressive enhancement: load this
* - the original file for web-safe formats (JPEG/PNG/WebP/GIF) * first, then preload the full-res in the background and swap it in.
* - a transcoded full-res WebP for RAW/HEIC/TIFF (cached on first hit)
* *
* Videos go through `getVideoSrc` instead and use /original directly. * Videos go through `getVideoSrc` instead and use /original directly.
*/ */
export function getPreviewImageSrc(photo: Photo): string { 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. */ * Full-resolution URL fetched in the background after the thumbnail is
export function getPreviewFallbackSrc(photo: Photo): string { * already visible. The /proxy endpoint serves the original file for
return photosApi.getThumbnailUrl(photo.id, 'large') * 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 { export function getVideoSrc(photo: Photo): string {