fix: reorganize photo processing logic and enhance thumbnail generation, fixed #27 closed #28

- Consolidated thumbnail and blurhash processing into a dedicated data-processing module for better code organization.
- Improved the logic for reusing existing thumbnails and blurhashes, ensuring efficient handling of image processing.
- Updated the cache manager to validate cache data integrity and streamline the overall photo processing workflow.
- Enhanced logging for better traceability during thumbnail generation and processing.

Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
Innei
2025-06-29 10:49:16 +08:00
parent 708f300433
commit 48aec690da
7 changed files with 350 additions and 273 deletions

View File

@@ -11,9 +11,13 @@ export async function generateBlurhash(
): Promise<string | null> {
try {
// 复用缩略图的 Sharp 实例来生成 blurhash
const { data, info } = await sharp(thumbnailBuffer).toBuffer({
resolveWithObject: true,
})
// 确保转换为 raw RGBA 格式
const { data, info } = await sharp(thumbnailBuffer)
.raw()
.ensureAlpha()
.toBuffer({
resolveWithObject: true,
})
const xComponents = Math.min(Math.max(Math.round(info.width / 16), 3), 9)
const yComponents = Math.min(Math.max(Math.round(info.height / 16), 3), 9)
@@ -22,6 +26,15 @@ export async function generateBlurhash(
`生成参数:原始 ${originalWidth}x${originalHeight}, 实际 ${info.width}x${info.height}, 组件 ${xComponents}x${yComponents}`,
)
// 验证数据长度是否匹配
const expectedLength = info.width * info.height * info.channels
if (data.length !== expectedLength) {
logger.blurhash.error(
`数据长度不匹配:期望 ${expectedLength},实际 ${data.length}`,
)
return null
}
// 生成 blurhash
const blurhash = encode(
new Uint8ClampedArray(data),

View File

@@ -4,18 +4,55 @@ import path from 'node:path'
import { workdir } from '@afilmory/builder/path.js'
import sharp from 'sharp'
import type { Logger } from '../logger/index.js'
import { getGlobalLoggers } from '../photo/logger-adapter.js'
import type { ThumbnailResult } from '../types/photo.js'
import { generateBlurhash } from './blurhash.js'
// 常量定义
const THUMBNAIL_DIR = path.join(workdir, 'public/thumbnails')
const THUMBNAIL_QUALITY = 100
const THUMBNAIL_WIDTH = 600
// 获取缩略图路径信息
function getThumbnailPaths(photoId: string) {
const filename = `${photoId}.webp`
const thumbnailPath = path.join(THUMBNAIL_DIR, filename)
const thumbnailUrl = `/thumbnails/${filename}`
return { thumbnailPath, thumbnailUrl }
}
// 创建失败结果
function createFailureResult(): ThumbnailResult {
return {
thumbnailUrl: null,
thumbnailBuffer: null,
blurhash: null,
}
}
// 创建成功结果
function createSuccessResult(
thumbnailUrl: string,
thumbnailBuffer: Buffer,
blurhash: string | null,
): ThumbnailResult {
return {
thumbnailUrl,
thumbnailBuffer,
blurhash,
}
}
// 确保缩略图目录存在
async function ensureThumbnailDir(): Promise<void> {
await fs.mkdir(THUMBNAIL_DIR, { recursive: true })
}
// 检查缩略图是否存在
export async function thumbnailExists(photoId: string): Promise<boolean> {
try {
const thumbnailPath = path.join(
workdir,
'public/thumbnails',
`${photoId}.webp`,
)
const { thumbnailPath } = getThumbnailPaths(photoId)
await fs.access(thumbnailPath)
return true
} catch {
@@ -23,6 +60,82 @@ 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)
const thumbnailLog = getGlobalLoggers().thumbnail
thumbnailLog.info(`复用现有缩略图:${photoId}`)
try {
const existingBuffer = await fs.readFile(thumbnailPath)
const blurhash = await generateBlurhash(
existingBuffer,
originalWidth,
originalHeight,
)
return createSuccessResult(thumbnailUrl, existingBuffer, blurhash)
} catch (error) {
thumbnailLog?.warn(`读取现有缩略图失败,重新生成:${photoId}`, error)
return null
}
}
// 生成新的缩略图
async function generateNewThumbnail(
imageBuffer: Buffer,
photoId: string,
originalWidth: number,
originalHeight: number,
): Promise<ThumbnailResult> {
const { thumbnailPath, thumbnailUrl } = getThumbnailPaths(photoId)
const log = getGlobalLoggers().thumbnail
log.info(`生成缩略图:${photoId}`)
const startTime = Date.now()
try {
// 创建 Sharp 实例,复用于缩略图和 blurhash 生成
const sharpInstance = sharp(imageBuffer).rotate() // 自动根据 EXIF 旋转
// 生成缩略图
const thumbnailBuffer = await sharpInstance
.clone() // 克隆实例用于缩略图生成
.resize(THUMBNAIL_WIDTH, null, {
withoutEnlargement: true,
})
.webp({
quality: THUMBNAIL_QUALITY,
})
.toBuffer()
// 保存到文件
await fs.writeFile(thumbnailPath, thumbnailBuffer)
// 记录生成信息
const duration = Date.now() - startTime
const sizeKB = Math.round(thumbnailBuffer.length / 1024)
log.success(`生成完成:${photoId} (${sizeKB}KB, ${duration}ms)`)
// 基于生成的缩略图生成 blurhash
const blurhash = await generateBlurhash(
thumbnailBuffer,
originalWidth,
originalHeight,
)
return createSuccessResult(thumbnailUrl, thumbnailBuffer, blurhash)
} catch (error) {
log.error(`生成失败:${photoId}`, error)
return createFailureResult()
}
}
// 生成缩略图和 blurhash复用 Sharp 实例)
export async function generateThumbnailAndBlurhash(
imageBuffer: Buffer,
@@ -30,86 +143,35 @@ export async function generateThumbnailAndBlurhash(
originalWidth: number,
originalHeight: number,
forceRegenerate = false,
workerLogger?: {
thumbnail: Logger['thumbnail']
blurhash: Logger['blurhash']
},
): Promise<ThumbnailResult> {
const thumbnailLog = workerLogger?.thumbnail
const thumbnailLog = getGlobalLoggers().thumbnail
try {
const thumbnailDir = path.join(workdir, 'public/thumbnails')
await fs.mkdir(thumbnailDir, { recursive: true })
await ensureThumbnailDir()
const thumbnailPath = path.join(thumbnailDir, `${photoId}.webp`)
const thumbnailUrl = `/thumbnails/${photoId}.webp`
// 如果不是强制模式且缩略图已存在,读取现有文件
// 如果不是强制模式且缩略图已存在,尝试复用现有文件
if (!forceRegenerate && (await thumbnailExists(photoId))) {
thumbnailLog?.info(`复用现有缩略图:${photoId}`)
try {
const existingBuffer = await fs.readFile(thumbnailPath)
const existingResult = await processExistingThumbnail(
photoId,
originalWidth,
originalHeight,
)
// 基于现有缩略图生成 blurhash
const blurhash = await generateBlurhash(
existingBuffer,
originalWidth,
originalHeight,
)
return {
thumbnailUrl,
thumbnailBuffer: existingBuffer,
blurhash,
}
} catch (error) {
thumbnailLog?.warn(`读取现有缩略图失败,重新生成:${photoId}`, error)
// 继续执行生成逻辑
if (existingResult) {
return existingResult
}
// 如果处理现有缩略图失败,继续生成新的
}
thumbnailLog?.info(`生成缩略图:${photoId}`)
const startTime = Date.now()
// 创建 Sharp 实例,复用于缩略图和 blurhash 生成
const sharpInstance = sharp(imageBuffer).rotate() // 自动根据 EXIF 旋转
// 生成缩略图
const thumbnailBuffer = await sharpInstance
.clone() // 克隆实例用于缩略图生成
.resize(600, null, {
withoutEnlargement: true,
})
.webp({
quality: 100,
})
.toBuffer()
// 保存到文件
await fs.writeFile(thumbnailPath, thumbnailBuffer)
const duration = Date.now() - startTime
const sizeKB = Math.round(thumbnailBuffer.length / 1024)
thumbnailLog?.success(`生成完成:${photoId} (${sizeKB}KB, ${duration}ms)`)
// 基于生成的缩略图生成 blurhash
const blurhash = await generateBlurhash(
thumbnailBuffer,
// 生成新的缩略图
return await generateNewThumbnail(
imageBuffer,
photoId,
originalWidth,
originalHeight,
)
return {
thumbnailUrl,
thumbnailBuffer,
blurhash,
}
} catch (error) {
thumbnailLog?.error(`生成失败:${photoId}`, error)
return {
thumbnailUrl: null,
thumbnailBuffer: null,
blurhash: null,
}
thumbnailLog.error(`处理失败:${photoId}`, error)
return createFailureResult()
}
}

View File

@@ -1,49 +1,6 @@
// 主要构建器
export { type BuilderOptions, defaultBuilder } from './builder/index.js'
export type { StorageConfig } from './storage/interfaces.js'
// 日志系统
export { type Logger, logger, type WorkerLogger } from './logger/index.js'
// 类型定义
export type {
CompressedHistogramData,
HistogramData,
ImageMetadata,
PhotoInfo,
FujiRecipe,
PhotoManifestItem,
ProcessPhotoResult,
ThumbnailResult,
PickedExif,
ToneAnalysis,
ToneType,
} from './types/photo.js'
// S3 操作
export { generateBlurhash } from './image/blurhash.js'
export {
getImageMetadataWithSharp,
preprocessImageBuffer,
} from './image/processor.js'
export {
generateThumbnailAndBlurhash,
thumbnailExists,
} from './image/thumbnail.js'
export { s3Client } from './s3/client.js'
// 照片处理
export { extractPhotoInfo } from './photo/info-extractor.js'
export { type PhotoProcessorOptions, processPhoto } from './photo/processor.js'
// Manifest 管理
export {
handleDeletedPhotos,
loadExistingManifest,
needsUpdate,
saveManifest,
} from './manifest/manager.js'
export type { FujiRecipe, PickedExif } from './types/photo.js'
// Worker 池
export {
type TaskFunction,
WorkerPool,
type WorkerPoolOptions,
} from './worker/pool.js'

View File

@@ -1,155 +1,17 @@
import path from 'node:path'
import { workdir } from '@afilmory/builder/path.js'
import type sharp from 'sharp'
import { HEIC_FORMATS } from '../constants/index.js'
import { extractExifData } from '../image/exif.js'
import { calculateHistogramAndAnalyzeTone } from '../image/histogram.js'
import {
generateThumbnailAndBlurhash,
thumbnailExists,
} from '../image/thumbnail.js'
import type {
PhotoManifestItem,
PickedExif,
ToneAnalysis,
} from '../types/photo.js'
import { getGlobalLoggers } from './logger-adapter.js'
import { thumbnailExists } from '../image/thumbnail.js'
import type { PhotoManifestItem } from '../types/photo.js'
import type { PhotoProcessorOptions } from './processor.js'
export interface ThumbnailResult {
thumbnailUrl: string
thumbnailBuffer: Buffer
blurhash: string
}
export interface CacheableData {
thumbnail?: ThumbnailResult
exif?: PickedExif
toneAnalysis?: ToneAnalysis
}
/**
* 处理缩略图和 blurhash
* 优先复用现有数据,如果不存在或需要强制更新则重新生成
*/
export async function processThumbnailAndBlurhash(
imageBuffer: Buffer,
photoId: string,
width: number,
height: number,
existingItem: PhotoManifestItem | undefined,
options: PhotoProcessorOptions,
): Promise<ThumbnailResult> {
const loggers = getGlobalLoggers()
// 检查是否可以复用现有数据
if (
!options.isForceMode &&
!options.isForceThumbnails &&
existingItem?.blurhash &&
(await thumbnailExists(photoId))
) {
try {
const fs = await import('node:fs/promises')
const thumbnailPath = path.join(
workdir,
'public/thumbnails',
`${photoId}.webp`,
)
const thumbnailBuffer = await fs.readFile(thumbnailPath)
const thumbnailUrl = `/thumbnails/${photoId}.webp`
loggers.blurhash.info(`复用现有 blurhash: ${photoId}`)
loggers.thumbnail.info(`复用现有缩略图:${photoId}`)
return {
thumbnailUrl,
thumbnailBuffer,
blurhash: existingItem.blurhash,
}
} catch (error) {
loggers.thumbnail.warn(`读取现有缩略图失败,重新生成:${photoId}`, error)
// 继续执行生成逻辑
}
thumbnail?: {
thumbnailUrl: string
thumbnailBuffer: Buffer
blurhash: string
}
// 生成新的缩略图和 blurhash
const result = await generateThumbnailAndBlurhash(
imageBuffer,
photoId,
width,
height,
options.isForceMode || options.isForceThumbnails,
{
thumbnail: loggers.thumbnail.originalLogger,
blurhash: loggers.blurhash.originalLogger,
},
)
return {
thumbnailUrl: result.thumbnailUrl!,
thumbnailBuffer: result.thumbnailBuffer!,
blurhash: result.blurhash!,
}
}
/**
* 处理 EXIF 数据
* 优先复用现有数据,如果不存在或需要强制更新则重新提取
*/
export async function processExifData(
imageBuffer: Buffer,
rawImageBuffer: Buffer | undefined,
photoKey: string,
existingItem: PhotoManifestItem | undefined,
options: PhotoProcessorOptions,
): Promise<PickedExif | null> {
const loggers = getGlobalLoggers()
// 检查是否可以复用现有数据
if (!options.isForceMode && !options.isForceManifest && existingItem?.exif) {
const photoId = path.basename(photoKey, path.extname(photoKey))
loggers.exif.info(`复用现有 EXIF 数据:${photoId}`)
return existingItem.exif
}
// 提取新的 EXIF 数据
const ext = path.extname(photoKey).toLowerCase()
const originalBuffer = HEIC_FORMATS.has(ext) ? rawImageBuffer : undefined
return await extractExifData(imageBuffer, originalBuffer)
}
/**
* 处理影调分析
* 优先复用现有数据,如果不存在或需要强制更新则重新计算
*/
export async function processToneAnalysis(
sharpInstance: sharp.Sharp,
photoKey: string,
existingItem: PhotoManifestItem | undefined,
options: PhotoProcessorOptions,
): Promise<ToneAnalysis | null> {
const loggers = getGlobalLoggers()
// 检查是否可以复用现有数据
if (
!options.isForceMode &&
!options.isForceManifest &&
existingItem?.toneAnalysis
) {
const photoId = path.basename(photoKey, path.extname(photoKey))
loggers.tone.info(`复用现有影调分析:${photoId}`)
return existingItem.toneAnalysis
}
// 计算新的影调分析
return await calculateHistogramAndAnalyzeTone(
sharpInstance,
loggers.tone.originalLogger,
)
exif?: any
toneAnalysis?: any
}
/**
@@ -196,3 +58,36 @@ export async function shouldProcessPhoto(
return { shouldProcess: false, reason: '无需处理' }
}
/**
* 检查缓存数据的完整性
*/
export function validateCacheData(
existingItem: PhotoManifestItem | undefined,
options: PhotoProcessorOptions,
): {
needsThumbnail: boolean
needsExif: boolean
needsToneAnalysis: boolean
} {
if (!existingItem) {
return {
needsThumbnail: true,
needsExif: true,
needsToneAnalysis: true,
}
}
return {
needsThumbnail:
options.isForceMode ||
options.isForceThumbnails ||
!existingItem.blurhash,
needsExif:
options.isForceMode || options.isForceManifest || !existingItem.exif,
needsToneAnalysis:
options.isForceMode ||
options.isForceManifest ||
!existingItem.toneAnalysis,
}
}

View File

@@ -0,0 +1,147 @@
import fs from 'node:fs/promises'
import path from 'node:path'
import type sharp from 'sharp'
import { HEIC_FORMATS } from '../constants/index.js'
import { extractExifData } from '../image/exif.js'
import { calculateHistogramAndAnalyzeTone } from '../image/histogram.js'
import {
generateThumbnailAndBlurhash,
thumbnailExists,
} from '../image/thumbnail.js'
import { workdir } from '../path.js'
import type {
PhotoManifestItem,
PickedExif,
ToneAnalysis,
} from '../types/photo.js'
import { getGlobalLoggers } from './logger-adapter.js'
import type { PhotoProcessorOptions } from './processor.js'
export interface ThumbnailResult {
thumbnailUrl: string
thumbnailBuffer: Buffer
blurhash: string
}
/**
* 处理缩略图和 blurhash
* 优先复用现有数据,如果不存在或需要强制更新则重新生成
*/
export async function processThumbnailAndBlurhash(
imageBuffer: Buffer,
photoId: string,
width: number,
height: number,
existingItem: PhotoManifestItem | undefined,
options: PhotoProcessorOptions,
): Promise<ThumbnailResult> {
const loggers = getGlobalLoggers()
// 检查是否可以复用现有数据
if (
!options.isForceMode &&
!options.isForceThumbnails &&
existingItem?.blurhash &&
(await thumbnailExists(photoId))
) {
try {
const thumbnailPath = path.join(
workdir,
'public/thumbnails',
`${photoId}.webp`,
)
const thumbnailBuffer = await fs.readFile(thumbnailPath)
const thumbnailUrl = `/thumbnails/${photoId}.webp`
loggers.blurhash.info(`复用现有 blurhash: ${photoId}`)
loggers.thumbnail.info(`复用现有缩略图:${photoId}`)
return {
thumbnailUrl,
thumbnailBuffer,
blurhash: existingItem.blurhash,
}
} catch (error) {
loggers.thumbnail.warn(`读取现有缩略图失败,重新生成:${photoId}`, error)
// 继续执行生成逻辑
}
}
// 生成新的缩略图和 blurhash
const result = await generateThumbnailAndBlurhash(
imageBuffer,
photoId,
width,
height,
options.isForceMode || options.isForceThumbnails,
{
thumbnail: loggers.thumbnail.originalLogger,
blurhash: loggers.blurhash.originalLogger,
},
)
return {
thumbnailUrl: result.thumbnailUrl!,
thumbnailBuffer: result.thumbnailBuffer!,
blurhash: result.blurhash!,
}
}
/**
* 处理 EXIF 数据
* 优先复用现有数据,如果不存在或需要强制更新则重新提取
*/
export async function processExifData(
imageBuffer: Buffer,
rawImageBuffer: Buffer | undefined,
photoKey: string,
existingItem: PhotoManifestItem | undefined,
options: PhotoProcessorOptions,
): Promise<PickedExif | null> {
const loggers = getGlobalLoggers()
// 检查是否可以复用现有数据
if (!options.isForceMode && !options.isForceManifest && existingItem?.exif) {
const photoId = path.basename(photoKey, path.extname(photoKey))
loggers.exif.info(`复用现有 EXIF 数据:${photoId}`)
return existingItem.exif
}
// 提取新的 EXIF 数据
const ext = path.extname(photoKey).toLowerCase()
const originalBuffer = HEIC_FORMATS.has(ext) ? rawImageBuffer : undefined
return await extractExifData(imageBuffer, originalBuffer)
}
/**
* 处理影调分析
* 优先复用现有数据,如果不存在或需要强制更新则重新计算
*/
export async function processToneAnalysis(
sharpInstance: sharp.Sharp,
photoKey: string,
existingItem: PhotoManifestItem | undefined,
options: PhotoProcessorOptions,
): Promise<ToneAnalysis | null> {
const loggers = getGlobalLoggers()
// 检查是否可以复用现有数据
if (
!options.isForceMode &&
!options.isForceManifest &&
existingItem?.toneAnalysis
) {
const photoId = path.basename(photoKey, path.extname(photoKey))
loggers.tone.info(`复用现有影调分析:${photoId}`)
return existingItem.toneAnalysis
}
// 计算新的影调分析
return await calculateHistogramAndAnalyzeTone(
sharpInstance,
loggers.tone.originalLogger,
)
}

View File

@@ -9,12 +9,12 @@ import {
preprocessImageBuffer,
} from '../image/processor.js'
import type { PhotoManifestItem } from '../types/photo.js'
import { shouldProcessPhoto } from './cache-manager.js'
import {
processExifData,
processThumbnailAndBlurhash,
processToneAnalysis,
shouldProcessPhoto,
} from './cache-manager.js'
} from './data-processors.js'
import { extractPhotoInfo } from './info-extractor.js'
import { processLivePhoto } from './live-photo-handler.js'
import { getGlobalLoggers } from './logger-adapter.js'

View File

@@ -3,13 +3,16 @@ export type { PhotoProcessorOptions } from './processor.js'
export { processPhoto } from './processor.js'
// 缓存管理
export type { CacheableData, ThumbnailResult } from './cache-manager.js'
export type { CacheableData } from './cache-manager.js'
export { shouldProcessPhoto } from './cache-manager.js'
// 数据处理器
export type { ThumbnailResult } from './data-processors.js'
export {
processExifData,
processThumbnailAndBlurhash,
processToneAnalysis,
shouldProcessPhoto,
} from './cache-manager.js'
} from './data-processors.js'
// Live Photo 处理
export type { LivePhotoResult } from './live-photo-handler.js'