diff --git a/components/album/preview-image.tsx b/components/album/preview-image.tsx index 624521cd..792a27e2 100644 --- a/components/album/preview-image.tsx +++ b/components/album/preview-image.tsx @@ -23,6 +23,8 @@ import { cn } from '~/lib/utils' import { useSwrHydrated } from '~/hooks/use-swr-hydrated' import { usePhotoSequence } from '~/hooks/use-photo-sequence' import { useBlurImageDataUrl } from '~/hooks/use-blurhash' +import { hasReadyVariants, makeVariantLoader } from '~/lib/image/loader' +import { useAvifSupport } from '~/hooks/use-avif-support' import useEmblaCarousel from 'embla-carousel-react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { ChevronLeftIcon } from '~/components/icons/chevron-left' @@ -90,18 +92,36 @@ function PlaceholderSlide({ blurhash, width, height }: { blurhash: string; width ) } -// Decoded-blurhash fallback for a strip thumbnail with no preview url. -function ThumbBlur({ blurhash }: { blurhash: string }) { - const url = useBlurImageDataUrl(blurhash) - return
+// One strip thumbnail. Smallest variant tier (320, via width 96) for a 48px +// cell — NOT the full-resolution preview_url (decoding ~30MP per thumbnail on +// open was the detail-view jank). Cascade follows blur-image.tsx: variant → +// decoded blurhash; onError steps down to blurhash, never up to the original. +function StripThumb({ photo, variantBaseUrl, avifOk }: { photo: ImageType; variantBaseUrl: string; avifOk: boolean }) { + const [failed, setFailed] = useState(false) + const blur = useBlurImageDataUrl(photo.blurhash) + const ready = !failed && hasReadyVariants(photo.image_key, photo.ready_max_width, variantBaseUrl) + if (ready) { + const src = makeVariantLoader({ + base: variantBaseUrl, + imageKey: photo.image_key, + readyMaxWidth: photo.ready_max_width, + format: avifOk ? 'avif' : 'webp', + })({ src: photo.image_key, width: 96 }) + return ( + // eslint-disable-next-line @next/next/no-img-element + setFailed(true)} /> + ) + } + return
} // Bottom thumbnail strip — a horizontally scrollable row over the album window, // the active photo ringed and auto-centered. Clicking jumps the carousel there. // Plain /blurhash only (no WebGL), and it reuses the same windowed `photos` // the carousel already holds, so it adds no fetches. -function ThumbnailStrip({ photos, activeIndex, onSelect }: { photos: ImageType[]; activeIndex: number; onSelect: (i: number) => void }) { +function ThumbnailStrip({ photos, activeIndex, variantBaseUrl, onSelect }: { photos: ImageType[]; activeIndex: number; variantBaseUrl: string; onSelect: (i: number) => void }) { const ref = useRef(null) + const avifOk = useAvifSupport() useEffect(() => { // Defer the scroll past the commit so reading layout doesn't force a // synchronous reflow on every switch; 'auto' (instant) keeps the active @@ -134,12 +154,7 @@ function ThumbnailStrip({ photos, activeIndex, onSelect }: { photos: ImageType[] active ? 'opacity-100 ring-2 ring-primary' : 'opacity-50 hover:opacity-90', )} > - {p.preview_url ? ( - // eslint-disable-next-line @next/next/no-img-element - - ) : ( - - )} + ) })} @@ -482,6 +497,7 @@ export default function PreviewImage(props: Readonly) { emblaApi?.scrollTo(i)} /> )} diff --git a/components/album/progressive-image.tsx b/components/album/progressive-image.tsx index 953a6238..1d54b818 100644 --- a/components/album/progressive-image.tsx +++ b/components/album/progressive-image.tsx @@ -40,6 +40,9 @@ export default function ProgressiveImage( // first mount so the engine isn't built until the user actually zooms. const [hasOpenedFullScreen, setHasOpenedFullScreen] = useState(Boolean(props.showLightbox)) const [webGLAvailable] = useState(() => isWebGLSupported()) + // If the inline preview variant fails to load, step down to preview_url — + // never escalate to the full original (blur-image.tsx policy). + const [previewVariantFailed, setPreviewVariantFailed] = useState(false) const avifOk = useAvifSupport() // High-res source for the full-screen zoom viewer: the ORIGINAL, full- @@ -60,6 +63,24 @@ export default function ProgressiveImage( : '' const highResSource = props.imageUrl || variantHighResSource + // Inline (non-zoom) preview source: a display-sized variant (~1280) rather than + // the raw preview_url. On this deployment preview compression is disabled, so + // preview_url is the full-resolution (~30MP) image — decoding one per visible + // slide/switch was the detail-view jank. 1280 is capped well below the 2560 + // zoom tier (the zoom path above still loads the original for pixel-peeping), + // and a fixed width avoids next/image DPR pushing retina screens up to 2560. + // Falls back to preview_url only when the photo has no generated variants. + const INLINE_PREVIEW_WIDTH = 1280 + const previewVariantReady = !previewVariantFailed && hasReadyVariants(props.imageKey, props.readyMaxWidth ?? 0, props.variantBaseUrl) + const previewDisplaySource = previewVariantReady + ? makeVariantLoader({ + base: props.variantBaseUrl as string, + imageKey: props.imageKey as string, + readyMaxWidth: props.readyMaxWidth as number, + format: avifOk ? 'avif' : 'webp', + })({ src: props.imageKey as string, width: INLINE_PREVIEW_WIDTH }) + : props.previewUrl + const webglViewerRef = useRef(null) useEffect(() => { return () => { @@ -152,8 +173,8 @@ export default function ProgressiveImage( animate={{ opacity: 1 }} transition={{ duration: 1 }} className="object-contain md:max-h-[90vh] cursor-pointer" - src={props.previewUrl} - overrideSrc={props.previewUrl} + src={previewDisplaySource} + overrideSrc={previewDisplaySource} placeholder="blur" unoptimized blurDataURL={dataURL} @@ -161,6 +182,10 @@ export default function ProgressiveImage( height={props.height} alt={props.alt || 'image'} onClick={openFullScreen} + onError={() => { + // Inline variant failed → drop to preview_url; never the original. + if (previewVariantReady) setPreviewVariantFailed(true) + }} /> {/* 加载进度条 */} {isLoading && (