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 && (