refactor: remove blurhash replace to thumbhash

- Added Thumbhash component to replace Blurhash in PhotoViewer and PhotoMasonryItem for enhanced image loading performance.
- Updated the manifest version from 'v4' to 'v5' across multiple files to reflect the new data structure.
- Introduced thumbHash generation in the image processing pipeline, ensuring compatibility with existing thumbnail logic.
- Updated package dependencies to include thumbhash version 0.1.1.

Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
Innei
2025-06-29 15:46:46 +08:00
parent e2a3c55910
commit 27ea01fbf1
20 changed files with 119 additions and 150 deletions

View File

@@ -69,6 +69,7 @@
"swiper": "11.2.8",
"swr": "2.3.3",
"tailwind-merge": "3.3.1",
"thumbhash": "0.1.1",
"tiff": "^7.0.0",
"usehooks-ts": "3.1.1",
"vaul": "1.1.2",

View File

@@ -13,7 +13,6 @@ import {
useRef,
useState,
} from 'react'
import { Blurhash } from 'react-blurhash'
import { ErrorBoundary } from 'react-error-boundary'
import { useTranslation } from 'react-i18next'
import type { Swiper as SwiperType } from 'swiper'
@@ -26,6 +25,7 @@ import { useMobile } from '~/hooks/useMobile'
import { Spring } from '~/lib/spring'
import type { PhotoManifest } from '~/types/photo'
import { Thumbhash } from '../thumbhash'
import { ExifPanel } from './ExifPanel'
import { GalleryThumbnail } from './GalleryThumbnail'
import { ProgressiveImage } from './ProgressiveImage'
@@ -147,7 +147,7 @@ export const PhotoViewer = ({
{/* 固定背景层防止透出 */}
{/* 交叉溶解的 Blurhash 背景 */}
<AnimatePresence mode="popLayout">
{isOpen && currentPhoto.blurhash && (
{isOpen && currentPhoto.thumbHash && (
<ErrorBoundary fallback={null}>
<PassiveFragment>
<m.div
@@ -158,20 +158,15 @@ export const PhotoViewer = ({
className="bg-material-opaque fixed inset-0"
/>
<m.div
key={currentPhoto.blurhash}
key={currentPhoto.id}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={Spring.presets.smooth}
className="fixed inset-0"
>
<Blurhash
hash={currentPhoto.blurhash}
width="100%"
height="100%"
resolutionX={32}
resolutionY={32}
punch={1}
<Thumbhash
thumbHash={currentPhoto.thumbHash}
className="size-fill"
/>
</m.div>

View File

@@ -0,0 +1,22 @@
import { decompressUint8Array } from '@afilmory/builder'
import { useMemo } from 'react'
import { thumbHashToDataURL } from 'thumbhash'
import { clsxm } from '~/lib/cn'
export const Thumbhash = ({
thumbHash,
className,
}: {
thumbHash: ArrayLike<number> | string
className?: string
}) => {
const dataURL = useMemo(() => {
if (typeof thumbHash === 'string') {
return thumbHashToDataURL(decompressUint8Array(thumbHash))
}
return thumbHashToDataURL(thumbHash)
}, [thumbHash])
return <img src={dataURL} className={clsxm('h-full w-full', className)} />
}

View File

@@ -1,10 +1,9 @@
import clsx from 'clsx'
import { m } from 'motion/react'
import { Fragment, useCallback, useEffect, useRef, useState } from 'react'
import { Blurhash } from 'react-blurhash'
import { ErrorBoundary } from 'react-error-boundary'
import { useTranslation } from 'react-i18next'
import { Thumbhash } from '~/components/ui/thumbhash'
import {
CarbonIsoOutline,
MaterialSymbolsShutterSpeed,
@@ -229,18 +228,8 @@ export const PhotoMasonryItem = ({
onMouseLeave={handleMouseLeave}
>
{/* Blurhash 占位符 */}
{data.blurhash && (
<ErrorBoundary fallback={null}>
<Blurhash
hash={data.blurhash}
width="100%"
height="100%"
resolutionX={32}
resolutionY={32}
punch={1}
className="absolute inset-0"
/>
</ErrorBoundary>
{data.thumbHash && (
<Thumbhash thumbHash={data.thumbHash} className="absolute inset-0" />
)}
{!imageError && (

View File

@@ -192,7 +192,7 @@ export const Component = () => {
const photos = photoLoader.getPhotos()
const manifestData = {
version: 'v4',
version: 'v5',
data: photos,
}
@@ -355,7 +355,7 @@ export const Component = () => {
<JsonHighlight
data={
searchTerm
? { version: 'v4', data: filteredPhotos }
? { version: 'v5', data: filteredPhotos }
: manifestData
}
/>

View File

@@ -1,43 +1,37 @@
import { photoLoader } from '@afilmory/data'
import { Blurhash } from 'react-blurhash'
import { ErrorBoundary } from 'react-error-boundary'
import { ScrollArea } from '~/components/ui/scroll-areas/ScrollArea'
import { Thumbhash } from '~/components/ui/thumbhash'
export const Component = () => {
const photos = photoLoader.getPhotos()
return (
<div className="columns-4 gap-4">
{photos.map((photo) => (
<div
key={photo.id}
className="group relative"
style={{
paddingBottom: `${(photo.height / photo.width) * 100}%`,
}}
>
<img
src={photo.thumbnailUrl}
alt={photo.title}
height={photo.height}
width={photo.width}
className="absolute inset-0"
/>
{photo.blurhash && (
<ErrorBoundary fallback={null}>
<ScrollArea rootClassName="h-screen">
<div className="columns-4 gap-0">
{photos.map((photo) => (
<div
key={photo.id}
className="group relative m-2"
style={{
paddingBottom: `${(photo.height / photo.width) * 100}%`,
}}
>
<img
src={photo.thumbnailUrl}
alt={photo.title}
height={photo.height}
width={photo.width}
className="absolute inset-0"
/>
{photo.thumbHash && (
<div className="absolute inset-0 opacity-0 group-hover:opacity-100">
<Blurhash
hash={photo.blurhash}
width="100%"
height="100%"
resolutionX={32}
resolutionY={32}
punch={1}
/>
<Thumbhash thumbHash={photo.thumbHash} />
</div>
</ErrorBoundary>
)}
</div>
))}
</div>
)}
</div>
))}
</div>
</ScrollArea>
)
}

View File

@@ -20,6 +20,7 @@
"exiftool-vendored": "30.2.0",
"heic-convert": "2.1.0",
"heic-to": "1.1.14",
"sharp": "0.34.2"
"sharp": "0.34.2",
"thumbhash": "0.1.1"
}
}

View File

@@ -312,7 +312,7 @@ class PhotoGalleryBuilder {
): Promise<AfilmoryManifest> {
return options.isForceMode || options.isForceManifest
? {
version: 'v4',
version: 'v5',
data: [],
}
: await loadExistingManifest()

View File

@@ -1,47 +1,25 @@
import { encode, isBlurhashValid } from 'blurhash'
import sharp from 'sharp'
import { rgbaToThumbHash } from 'thumbhash'
import { logger } from '../logger/index.js'
// 生成 blurhash基于缩略图数据保持长宽比
export async function generateBlurhash(
thumbnailBuffer: Buffer,
originalWidth: number,
originalHeight: number,
): Promise<string | null> {
): Promise<Uint8Array | null> {
try {
// 复用缩略图的 Sharp 实例来生成 blurhash
// 确保转换为 raw RGBA 格式
const { data, info } = await sharp(thumbnailBuffer)
.resize(100, 100, { fit: 'inside' })
.raw()
.ensureAlpha()
.toBuffer({
resolveWithObject: true,
})
const xComponents = 4
const yComponents = 4
logger.blurhash.info(
`生成参数:原始 ${originalWidth}x${originalHeight}, 实际 ${info.width}x${info.height}, 组件 ${xComponents}x${yComponents}`,
)
// 生成 blurhash
const blurhash = encode(
new Uint8ClampedArray(data),
info.width,
info.height,
xComponents,
yComponents,
)
if (!isBlurhashValid(blurhash)) {
logger.blurhash.error('生成失败blurhash 无效', blurhash)
return null
}
logger.blurhash.success(`生成成功:${blurhash}`)
return blurhash
const thumbHash = rgbaToThumbHash(info.width, info.height, data)
return thumbHash
} catch (error) {
logger.blurhash.error('生成失败:', error)
return null

View File

@@ -27,7 +27,7 @@ function createFailureResult(): ThumbnailResult {
return {
thumbnailUrl: null,
thumbnailBuffer: null,
blurhash: null,
thumbHash: null,
}
}
@@ -35,12 +35,12 @@ function createFailureResult(): ThumbnailResult {
function createSuccessResult(
thumbnailUrl: string,
thumbnailBuffer: Buffer,
blurhash: string | null,
thumbHash: Uint8Array | null,
): ThumbnailResult {
return {
thumbnailUrl,
thumbnailBuffer,
blurhash,
thumbHash,
}
}
@@ -63,8 +63,6 @@ export async function thumbnailExists(photoId: string): Promise<boolean> {
// 读取现有缩略图并生成 blurhash
async function processExistingThumbnail(
photoId: string,
originalWidth: number,
originalHeight: number,
): Promise<ThumbnailResult | null> {
const { thumbnailPath, thumbnailUrl } = getThumbnailPaths(photoId)
@@ -73,13 +71,9 @@ async function processExistingThumbnail(
try {
const existingBuffer = await fs.readFile(thumbnailPath)
const blurhash = await generateBlurhash(
existingBuffer,
originalWidth,
originalHeight,
)
const thumbHash = await generateBlurhash(existingBuffer)
return createSuccessResult(thumbnailUrl, existingBuffer, blurhash)
return createSuccessResult(thumbnailUrl, existingBuffer, thumbHash)
} catch (error) {
thumbnailLog?.warn(`读取现有缩略图失败,重新生成:${photoId}`, error)
return null
@@ -90,8 +84,6 @@ async function processExistingThumbnail(
async function generateNewThumbnail(
imageBuffer: Buffer,
photoId: string,
originalWidth: number,
originalHeight: number,
): Promise<ThumbnailResult> {
const { thumbnailPath, thumbnailUrl } = getThumbnailPaths(photoId)
@@ -123,13 +115,9 @@ async function generateNewThumbnail(
log.success(`生成完成:${photoId} (${sizeKB}KB, ${duration}ms)`)
// 基于生成的缩略图生成 blurhash
const blurhash = await generateBlurhash(
thumbnailBuffer,
originalWidth,
originalHeight,
)
const thumbHash = await generateBlurhash(thumbnailBuffer)
return createSuccessResult(thumbnailUrl, thumbnailBuffer, blurhash)
return createSuccessResult(thumbnailUrl, thumbnailBuffer, thumbHash)
} catch (error) {
log.error(`生成失败:${photoId}`, error)
return createFailureResult()
@@ -140,8 +128,6 @@ async function generateNewThumbnail(
export async function generateThumbnailAndBlurhash(
imageBuffer: Buffer,
photoId: string,
originalWidth: number,
originalHeight: number,
forceRegenerate = false,
): Promise<ThumbnailResult> {
const thumbnailLog = getGlobalLoggers().thumbnail
@@ -151,11 +137,7 @@ export async function generateThumbnailAndBlurhash(
// 如果不是强制模式且缩略图已存在,尝试复用现有文件
if (!forceRegenerate && (await thumbnailExists(photoId))) {
const existingResult = await processExistingThumbnail(
photoId,
originalWidth,
originalHeight,
)
const existingResult = await processExistingThumbnail(photoId)
if (existingResult) {
return existingResult
@@ -164,12 +146,7 @@ export async function generateThumbnailAndBlurhash(
}
// 生成新的缩略图
return await generateNewThumbnail(
imageBuffer,
photoId,
originalWidth,
originalHeight,
)
return await generateNewThumbnail(imageBuffer, photoId)
} catch (error) {
thumbnailLog.error(`处理失败:${photoId}`, error)
return createFailureResult()

View File

@@ -1,3 +1,5 @@
export * from './lib/u8array.js'
export type { StorageConfig } from './storage/interfaces.js'
export type {
FujiRecipe,
PhotoManifestItem,

View File

@@ -0,0 +1,11 @@
export const compressUint8Array = (uint8Array: Uint8Array) => {
return Array.from(uint8Array)
.map((byte) => byte.toString(16).padStart(2, '0'))
.join('')
}
export const decompressUint8Array = (compressed: string) => {
return Uint8Array.from(
compressed.match(/.{1,2}/g)!.map((byte) => Number.parseInt(byte, 16)),
)
}

View File

@@ -20,15 +20,15 @@ export async function loadExistingManifest(): Promise<AfilmoryManifest> {
'🔍 未找到 manifest 文件/解析失败,创建新的 manifest 文件...',
)
return {
version: 'v4',
version: 'v5',
data: [],
}
}
if (manifest.version !== 'v4') {
if (manifest.version !== 'v5') {
logger.fs.error('🔍 无效的 manifest 版本,创建新的 manifest 文件...')
return {
version: 'v4',
version: 'v5',
data: [],
}
}
@@ -61,7 +61,7 @@ export async function saveManifest(items: PhotoManifestItem[]): Promise<void> {
manifestPath,
JSON.stringify(
{
version: 'v4',
version: 'v5',
data: sortedManifest,
} as AfilmoryManifest,
null,

View File

@@ -82,7 +82,7 @@ export function validateCacheData(
needsThumbnail:
options.isForceMode ||
options.isForceThumbnails ||
!existingItem.blurhash,
!existingItem.thumbHash,
needsExif:
options.isForceMode || options.isForceManifest || !existingItem.exif,
needsToneAnalysis:

View File

@@ -10,6 +10,7 @@ import {
generateThumbnailAndBlurhash,
thumbnailExists,
} from '../image/thumbnail.js'
import { decompressUint8Array } from '../lib/u8array.js'
import { workdir } from '../path.js'
import type {
PhotoManifestItem,
@@ -22,7 +23,7 @@ import type { PhotoProcessorOptions } from './processor.js'
export interface ThumbnailResult {
thumbnailUrl: string
thumbnailBuffer: Buffer
blurhash: string
thumbHash: Uint8Array | null
}
/**
@@ -32,8 +33,6 @@ export interface ThumbnailResult {
export async function processThumbnailAndBlurhash(
imageBuffer: Buffer,
photoId: string,
width: number,
height: number,
existingItem: PhotoManifestItem | undefined,
options: PhotoProcessorOptions,
): Promise<ThumbnailResult> {
@@ -43,7 +42,7 @@ export async function processThumbnailAndBlurhash(
if (
!options.isForceMode &&
!options.isForceThumbnails &&
existingItem?.blurhash &&
existingItem?.thumbHash &&
(await thumbnailExists(photoId))
) {
try {
@@ -61,7 +60,7 @@ export async function processThumbnailAndBlurhash(
return {
thumbnailUrl,
thumbnailBuffer,
blurhash: existingItem.blurhash,
thumbHash: decompressUint8Array(existingItem.thumbHash),
}
} catch (error) {
loggers.thumbnail.warn(`读取现有缩略图失败,重新生成:${photoId}`, error)
@@ -73,15 +72,13 @@ export async function processThumbnailAndBlurhash(
const result = await generateThumbnailAndBlurhash(
imageBuffer,
photoId,
width,
height,
options.isForceMode || options.isForceThumbnails,
)
return {
thumbnailUrl: result.thumbnailUrl!,
thumbnailBuffer: result.thumbnailBuffer!,
blurhash: result.blurhash!,
thumbHash: result.thumbHash!,
}
}

View File

@@ -8,6 +8,7 @@ import {
isBitmap,
preprocessImageBuffer,
} from '../image/processor.js'
import { compressUint8Array } from '../lib/u8array.js'
import type { PhotoManifestItem } from '../types/photo.js'
import { shouldProcessPhoto } from './cache-manager.js'
import {
@@ -154,8 +155,6 @@ export async function executePhotoProcessingPipeline(
const thumbnailResult = await processThumbnailAndBlurhash(
imageBuffer,
photoId,
metadata.width,
metadata.height,
existingItem,
options,
)
@@ -196,7 +195,9 @@ export async function executePhotoProcessingPipeline(
.getStorageManager()
.generatePublicUrl(photoKey),
thumbnailUrl: thumbnailResult.thumbnailUrl,
blurhash: thumbnailResult.blurhash,
thumbHash: thumbnailResult.thumbHash
? compressUint8Array(thumbnailResult.thumbHash)
: null,
width: metadata.width,
height: metadata.height,
aspectRatio,

View File

@@ -1,10 +0,0 @@
// 重新导出适配器中的函数以保持 API 兼容性
// 推荐使用新的 StorageManager API这些函数将在未来版本中被弃用
export {
detectLivePhotos,
generateS3Url,
getImageFromS3,
listAllFilesFromS3,
listImagesFromS3,
} from '../storage/adapters.js'

View File

@@ -1,6 +1,6 @@
import type { PhotoManifestItem } from './photo'
export type AfilmoryManifest = {
version: 'v4'
version: 'v5'
data: PhotoManifestItem[]
}

View File

@@ -45,7 +45,7 @@ export interface PhotoManifestItem extends PhotoInfo {
id: string
originalUrl: string
thumbnailUrl: string
blurhash: string | null
thumbHash: string | null
width: number
height: number
aspectRatio: number
@@ -151,7 +151,7 @@ export interface PickedExif {
export interface ThumbnailResult {
thumbnailUrl: string | null
thumbnailBuffer: Buffer | null
blurhash: string | null
thumbHash: Uint8Array | null
}
export type FujiRecipe = {

13
pnpm-lock.yaml generated
View File

@@ -323,6 +323,9 @@ importers:
tailwind-merge:
specifier: 3.3.1
version: 3.3.1
thumbhash:
specifier: 0.1.1
version: 0.1.1
tiff:
specifier: ^7.0.0
version: 7.0.0
@@ -456,6 +459,9 @@ importers:
sharp:
specifier: 0.34.2
version: 0.34.2
thumbhash:
specifier: 0.1.1
version: 0.1.1
packages/data:
dependencies:
@@ -1963,7 +1969,7 @@ packages:
resolution: {integrity: sha512-fyjAACV62oPV925xFCrH8DR5xWhg9KYtJT4s3u54jxp+L/hbpTY2kIeEFFbFe+a/HCE94zGQMZLIpVTPVZDhaA==}
peerDependencies:
'@types/react': '*'
react: 18.3.1
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
@@ -7355,6 +7361,9 @@ packages:
resolution: {integrity: sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A==}
engines: {node: '>=12.22'}
thumbhash@0.1.1:
resolution: {integrity: sha512-kH5pKeIIBPQXAOni2AiY/Cu/NKdkFREdpH+TLdM0g6WA7RriCv0kPLgP731ady67MhTAqrVG/4mnEeibVuCJcg==}
tiff@7.0.0:
resolution: {integrity: sha512-NhAYu+1cYzWpJ64bxaAC/b1fy6ZwkllMxYUziVZAISZa2TKu8H7AsC7Kpz4epZoMb0SNS4fOA6Ql1gZCZ27UXQ==}
@@ -16229,6 +16238,8 @@ snapshots:
throttle-debounce@5.0.2: {}
thumbhash@0.1.1: {}
tiff@7.0.0:
dependencies:
iobuffer: 6.0.0