Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 27 additions & 11 deletions components/album/preview-image.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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 <div className="h-full w-full bg-cover bg-center" style={{ backgroundImage: url ? `url(${url})` : undefined }} />
// 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
<img src={src} alt="" loading="lazy" draggable={false} className="h-full w-full object-cover" onError={() => setFailed(true)} />
)
}
return <div aria-hidden className="h-full w-full bg-cover bg-center" style={{ backgroundImage: blur ? `url(${blur})` : undefined }} />
}

// Bottom thumbnail strip — a horizontally scrollable row over the album window,
// the active photo ringed and auto-centered. Clicking jumps the carousel there.
// Plain <img>/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<HTMLDivElement>(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
Expand Down Expand Up @@ -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
<img src={p.preview_url} alt="" loading="lazy" draggable={false} className="h-full w-full object-cover" />
) : (
<ThumbBlur blurhash={p.blurhash} />
)}
<StripThumb photo={p} variantBaseUrl={variantBaseUrl} avifOk={avifOk} />
</button>
)
})}
Expand Down Expand Up @@ -482,6 +497,7 @@ export default function PreviewImage(props: Readonly<PreviewImageHandleProps>) {
<ThumbnailStrip
photos={photos}
activeIndex={index}
variantBaseUrl={configData?.variantBaseUrl ?? ''}
onSelect={(i) => emblaApi?.scrollTo(i)}
/>
)}
Expand Down
29 changes: 27 additions & 2 deletions components/album/progressive-image.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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-
Expand All @@ -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<WebGLImageViewerRef | null>(null)
useEffect(() => {
return () => {
Expand Down Expand Up @@ -152,15 +173,19 @@ 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}
width={props.width}
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 && (
Expand Down
Loading