From db48f4793482af15637fcf490e05d14a5934895d Mon Sep 17 00:00:00 2001 From: Manjusaka Date: Wed, 10 Jun 2026 12:49:14 +0800 Subject: [PATCH] perf(detail): render slides + thumbnail strip from sized variants, not full-res preview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The real cause of the detail-view jank (flame graph) is image decoding, not React or WebGL: on this deployment preview compression is off, so `preview_url` is the full-resolution (~30MP) image. The in-place carousel rendered the raw preview_url for up to 5 visible slides plus 21 thumbnail-strip cells, so opening an album decoded ~600 megapixels and every switch decoded a ~45MP image — device-bound jank (fast headless machines hid it; real devices stalled). Route both through the existing variant system (the same one the grid uses): - ProgressiveImage inline (non-zoom) preview now serves a ~1280 variant via makeVariantLoader instead of preview_url — a fixed width (not next/image DPR srcset) so retina screens don't get pushed to the 2560 tier. ~30MP → ~1.1MP per slide. The zoom path is untouched (still loads the original for pixel-peeping). Falls back to preview_url only when a photo has no variants, and an onError steps variant → preview_url, never up to the original. - Thumbnail strip cells (48px) serve the smallest 320 tier (requested at width 96); no variants (or onError) → decoded blurhash, never the full-res preview_url. ~30MP → ~0.07MP per thumbnail. Mirrors the variant→fallback→blurhash cascade in blur-image.tsx. No change to the zoom/WebGL path, the GL-context LRU, or the FLIP transition's target measurement. Co-Authored-By: Claude Opus 4.8 --- components/album/preview-image.tsx | 38 ++++++++++++++++++-------- components/album/progressive-image.tsx | 29 ++++++++++++++++++-- 2 files changed, 54 insertions(+), 13 deletions(-) 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 && (