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
32 changes: 28 additions & 4 deletions components/album/histogram-chart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,14 @@ interface CompressedHistogramData {
luminance: number[]
}

// Memoize computed histograms per image url for the session. The detail view now
// switches photos in place, so without this every revisit re-fetches + re-decodes
// + re-scans pixels. Keyed by the exact url (so a different variant tier / photo
// invalidates correctly). Bounded LRU so a long browsing session can't grow it
// without limit (Map insertion order = LRU; re-set on hit keeps hot entries).
const HISTOGRAM_CACHE_LIMIT = 256
const histogramCache = new Map<string, CompressedHistogramData>()

interface HistogramData {
red: number[]
green: number[]
Expand Down Expand Up @@ -199,10 +207,21 @@ export default function HistogramChart({ imageUrl, className = '' }: Readonly<Hi
return
}

setLoading(true)
setError(false)
setNeedsCors(false)

// Already computed this image in-session → reuse, no fetch/decode/scan.
const cached = histogramCache.get(imageUrl)
if (cached) {
histogramCache.delete(imageUrl)
histogramCache.set(imageUrl, cached) // mark most-recently-used
setHistogram(cached)
setLoading(false)
return
}

setLoading(true)

// 检查是否为同源 URL
const isSameOrigin = (() => {
try {
Expand All @@ -218,9 +237,10 @@ export default function HistogramChart({ imageUrl, className = '' }: Readonly<Hi
if (!isSameOrigin) {
img.crossOrigin = 'anonymous'
}
// 添加时间戳绕过缓存,确保获取带 CORS 头的新响应
const urlWithCache = isSameOrigin ? imageUrl : `${imageUrl}${imageUrl.includes('?') ? '&' : '?'}_t=${Date.now()}`
img.src = urlWithCache
// Use the url as-is so the browser cache is reused (cross-origin CORS requests
// are cached separately from the grid's non-CORS <img>, so this still gets a
// CORS-readable response — without re-fetching the preview on every switch).
img.src = imageUrl

img.onload = () => {
const canvas = document.createElement('canvas')
Expand All @@ -244,6 +264,10 @@ export default function HistogramChart({ imageUrl, className = '' }: Readonly<Hi
try {
const imageData = ctx.getImageData(0, 0, scaledWidth, scaledHeight)
const calculatedHistogram = calculateHistogram(imageData)
histogramCache.set(imageUrl, calculatedHistogram)
if (histogramCache.size > HISTOGRAM_CACHE_LIMIT) {
histogramCache.delete(histogramCache.keys().next().value as string)
}
setHistogram(calculatedHistogram)
} catch (e) {
console.error('Error calculating histogram:', e)
Expand Down
40 changes: 33 additions & 7 deletions components/album/preview-image.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -103,8 +103,14 @@ function ThumbBlur({ blurhash }: { blurhash: string }) {
function ThumbnailStrip({ photos, activeIndex, onSelect }: { photos: ImageType[]; activeIndex: number; onSelect: (i: number) => void }) {
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
const el = ref.current?.querySelector<HTMLElement>('[data-active="true"]')
el?.scrollIntoView({ inline: 'center', block: 'nearest', behavior: 'smooth' })
// Defer the scroll past the commit so reading layout doesn't force a
// synchronous reflow on every switch; 'auto' (instant) keeps the active
// thumbnail centered without a per-switch smooth-scroll animation.
const raf = requestAnimationFrame(() => {
ref.current?.querySelector<HTMLElement>('[data-active="true"]')
?.scrollIntoView({ inline: 'center', block: 'nearest', behavior: 'auto' })
})
return () => cancelAnimationFrame(raf)
}, [activeIndex])
return (
<div className="pointer-events-none absolute inset-x-0 bottom-2 z-20 flex justify-center px-2">
Expand All @@ -122,6 +128,7 @@ function ThumbnailStrip({ photos, activeIndex, onSelect }: { photos: ImageType[]
aria-current={active}
aria-label={`View photo ${i + 1}`}
onClick={() => onSelect(i)}
style={{ contentVisibility: 'auto', containIntrinsicSize: '48px 48px' } as React.CSSProperties}
className={cn(
'relative size-12 shrink-0 overflow-hidden rounded-md transition-all',
active ? 'opacity-100 ring-2 ring-primary' : 'opacity-50 hover:opacity-90',
Expand Down Expand Up @@ -286,6 +293,18 @@ export default function PreviewImage(props: Readonly<PreviewImageHandleProps>) {
// Image URL for tone analysis and histogram
const imageUrl = current?.preview_url || current?.url || ''

// Debounce the histogram/tone source: those run an image-load + getImageData +
// full-pixel scan, wasteful to fire for every photo flashed past during fast
// switching. Initialised to the opened photo (so it shows immediately on open,
// no pop-in), then only updated once switching settles for a short idle — so a
// fast swipe through 10 photos analyses just the one you stop on.
const [deferredImageUrl, setDeferredImageUrl] = useState(imageUrl)
useEffect(() => {
if (typeof window === 'undefined') return
const id = window.setTimeout(() => setDeferredImageUrl(imageUrl), 220)
return () => window.clearTimeout(id)
}, [imageUrl])

const navigateAway = () => {
if (window != undefined) {
if (window.history.length > 1) {
Expand Down Expand Up @@ -425,7 +444,14 @@ export default function PreviewImage(props: Readonly<PreviewImageHandleProps>) {
/>
)
) : (
<PlaceholderSlide blurhash={photo.blurhash} width={photo.width} height={photo.height} />
// Only decode the real blurhash for slides near the current one;
// farther off-screen slides pass '' (→ one shared cached default
// decode) so opening a large album doesn't decode ~20 thumbhashes.
<PlaceholderSlide
blurhash={Math.abs(i - index) <= loadRadius + 2 ? photo.blurhash : ''}
width={photo.width}
height={photo.height}
/>
)}
</div>
)
Expand Down Expand Up @@ -631,18 +657,18 @@ export default function PreviewImage(props: Readonly<PreviewImageHandleProps>) {
)}

{/* Tone Analysis */}
{imageUrl && (
{deferredImageUrl && (
<div>
<SectionTitle>{t('Exif.toneAnalysis')}</SectionTitle>
<ToneAnalysis imageUrl={imageUrl} />
<ToneAnalysis imageUrl={deferredImageUrl} />
</div>
)}

{/* Histogram */}
{imageUrl && (
{deferredImageUrl && (
<div>
<SectionTitle>{t('Exif.histogram')}</SectionTitle>
<HistogramChart imageUrl={imageUrl} />
<HistogramChart imageUrl={deferredImageUrl} />
</div>
)}

Expand Down
27 changes: 23 additions & 4 deletions components/album/tone-analysis.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,12 @@ interface ToneAnalysisProps {
className?: string
}

// Memoize tone analysis per image url for the session — the in-place detail
// switch would otherwise re-fetch + re-decode + re-scan on every revisit.
// Bounded LRU (Map insertion order = LRU; re-set on hit keeps hot entries).
const TONE_CACHE_LIMIT = 256
const toneCache = new Map<string, ToneAnalysisData>()

export default function ToneAnalysis({ imageUrl, className = '' }: Readonly<ToneAnalysisProps>) {
const [toneData, setToneData] = useState<ToneAnalysisData | null>(null)
const [loading, setLoading] = useState(true)
Expand All @@ -109,10 +115,20 @@ export default function ToneAnalysis({ imageUrl, className = '' }: Readonly<Tone
return
}

setLoading(true)
setError(false)
setNeedsCors(false)

const cached = toneCache.get(imageUrl)
if (cached) {
toneCache.delete(imageUrl)
toneCache.set(imageUrl, cached) // mark most-recently-used
setToneData(cached)
setLoading(false)
return
}

setLoading(true)

// 检查是否为同源 URL
const isSameOrigin = (() => {
try {
Expand All @@ -128,9 +144,8 @@ export default function ToneAnalysis({ imageUrl, className = '' }: Readonly<Tone
if (!isSameOrigin) {
img.crossOrigin = 'anonymous'
}
// 添加时间戳绕过缓存,确保获取带 CORS 头的新响应
const urlWithCache = isSameOrigin ? imageUrl : `${imageUrl}${imageUrl.includes('?') ? '&' : '?'}_t=${Date.now()}`
img.src = urlWithCache
// Use the url as-is so the browser cache is reused (no per-switch re-fetch).
img.src = imageUrl

img.onload = () => {
const canvas = document.createElement('canvas')
Expand All @@ -154,6 +169,10 @@ export default function ToneAnalysis({ imageUrl, className = '' }: Readonly<Tone
try {
const imageData = ctx.getImageData(0, 0, scaledWidth, scaledHeight)
const analysis = analyzeTone(imageData)
toneCache.set(imageUrl, analysis)
if (toneCache.size > TONE_CACHE_LIMIT) {
toneCache.delete(toneCache.keys().next().value as string)
}
setToneData(analysis)
} catch (e) {
console.error('Error analyzing tone:', e)
Expand Down
Loading